Feat: Hermes Voice Client — CustomTkinter GUI + Push-to-Talk + .exe Build
- main.py: CustomTkinter GUI (400x520) mit Status-Indikator, Conversation-Log, Screenshot-Toggle und Settings-Dialog. Thread-sicherer UI-Queue für Hotkey-Callbacks. - hotkey_listener.py: pynput globaler Hotkey (press/release) + KeyCapturer für interaktive Hotkey-Konfiguration im Settings-Dialog. - config_manager.py: JSON-Settings in ~/.hermes-voice/settings.json mit DEFAULTS, load() merged gespeicherte mit Default-Werten. - audio_input.py: Vereinfacht auf start_recording/stop_recording/transcribe (kein Wake-Word-Loop mehr, direkt hotkey-gesteuert). - ai_client.py: Settings-dict statt config.py-Imports, MC2-URL/Modelle konfigurierbar. - requirements.txt: customtkinter>=5.2.0 + pynput>=1.7.6 hinzugefügt, pystray entfernt. - build.bat: PyInstaller --onefile --windowed → dist/HermesVoice.exe. - setup.bat: Veraltete Wake-Word-Hinweise entfernt, GUI-Check angepasst. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+280
-94
@@ -1,129 +1,315 @@
|
||||
"""
|
||||
Hermes Voice Client
|
||||
Wake Word → STT → Screenshot → Hermes (MC2) → TTS
|
||||
|
||||
Läuft als System-Tray-App im Hintergrund.
|
||||
Aktivierung: "Hey Jarvis" (konfigurierbar in config.py)
|
||||
Hermes Voice Client — GUI
|
||||
Push-to-Talk: Hotkey halten → sprechen → loslassen → Hermes antwortet
|
||||
"""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import sys
|
||||
import customtkinter as ctk
|
||||
from PIL import Image, ImageDraw
|
||||
import pystray
|
||||
|
||||
from audio_input import wait_for_wake_word, record_speech, transcribe
|
||||
from audio_output import speak, play_ding, stop_speaking
|
||||
import config_manager as cfg
|
||||
from audio_input import start_recording, stop_recording, transcribe
|
||||
from audio_output import speak, stop_speaking, play_ding
|
||||
from screen_capture import capture_screen
|
||||
from ai_client import ask
|
||||
from config import SCREENSHOT_ON_QUERY
|
||||
from hotkey_listener import HotkeyListener, KeyCapturer, str_to_display
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────────────
|
||||
class State:
|
||||
IDLE = "idle" # wartet auf Wake Word
|
||||
LISTENING = "listen" # nimmt Sprache auf
|
||||
THINKING = "think" # wartet auf MC2-Antwort
|
||||
SPEAKING = "speak" # gibt Antwort aus
|
||||
ctk.set_appearance_mode("dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
_state = State.IDLE
|
||||
_stop_event = threading.Event()
|
||||
_tray_icon: pystray.Icon | None = None
|
||||
# ── UI-Event-Queue (thread-safe) ─────────────────────────────────────────
|
||||
_ui_queue: queue.Queue = queue.Queue()
|
||||
|
||||
def ui_event(event: str, data=None):
|
||||
_ui_queue.put((event, data))
|
||||
|
||||
|
||||
# ── Tray Icon ────────────────────────────────────────────────────────────
|
||||
COLORS = {
|
||||
State.IDLE: "#22c55e", # grün
|
||||
State.LISTENING: "#eab308", # gelb
|
||||
State.THINKING: "#3b82f6", # blau
|
||||
State.SPEAKING: "#a855f7", # lila
|
||||
}
|
||||
# ── Hauptfenster ─────────────────────────────────────────────────────────
|
||||
class HermesApp(ctk.CTk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.settings = cfg.load()
|
||||
self._hotkey: HotkeyListener | None = None
|
||||
self._worker: threading.Thread | None = None
|
||||
|
||||
def _make_icon(color: str) -> Image.Image:
|
||||
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.ellipse([4, 4, 60, 60], fill=color)
|
||||
return img
|
||||
self.title("Hermes Voice")
|
||||
self.geometry("400x520")
|
||||
self.resizable(False, False)
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _set_state(state: str):
|
||||
global _state
|
||||
_state = state
|
||||
if _tray_icon:
|
||||
_tray_icon.icon = _make_icon(COLORS[state])
|
||||
labels = {
|
||||
State.IDLE: "Hermes — bereit (Hey Jarvis)",
|
||||
State.LISTENING: "Hermes — hört zu…",
|
||||
State.THINKING: "Hermes — denkt…",
|
||||
State.SPEAKING: "Hermes — spricht",
|
||||
}
|
||||
_tray_icon.title = labels[state]
|
||||
self._build_ui()
|
||||
self._start_hotkey()
|
||||
self.after(100, self._poll_queue)
|
||||
|
||||
# ── Layout ───────────────────────────────────────────────────────────
|
||||
def _build_ui(self):
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_rowconfigure(2, weight=1)
|
||||
|
||||
# ── Hauptloop ────────────────────────────────────────────────────────────
|
||||
def _voice_loop():
|
||||
print("[Hermes] Bereit. Sage 'Hey Jarvis' zum Aktivieren.")
|
||||
while not _stop_event.is_set():
|
||||
_set_state(State.IDLE)
|
||||
# Header
|
||||
header = ctk.CTkFrame(self, height=50, corner_radius=0)
|
||||
header.grid(row=0, column=0, sticky="ew")
|
||||
header.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkLabel(header, text="⚡ Hermes Voice",
|
||||
font=ctk.CTkFont(size=16, weight="bold")).grid(
|
||||
row=0, column=0, padx=16, pady=12, sticky="w")
|
||||
ctk.CTkButton(header, text="⚙", width=36, height=36,
|
||||
command=self._open_settings).grid(
|
||||
row=0, column=1, padx=8, pady=8)
|
||||
|
||||
# 1. Auf Wake Word warten
|
||||
detected = wait_for_wake_word(_stop_event)
|
||||
if not detected:
|
||||
break
|
||||
# Status
|
||||
status_frame = ctk.CTkFrame(self, corner_radius=12)
|
||||
status_frame.grid(row=1, column=0, padx=16, pady=(16, 8), sticky="ew")
|
||||
status_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# 2. Aktivierungs-Ding + Aufnahme starten
|
||||
self._status_dot = ctk.CTkLabel(status_frame, text="●",
|
||||
font=ctk.CTkFont(size=32),
|
||||
text_color="#22c55e")
|
||||
self._status_dot.grid(row=0, column=0, pady=(12, 4))
|
||||
|
||||
self._status_label = ctk.CTkLabel(status_frame, text="Bereit",
|
||||
font=ctk.CTkFont(size=14))
|
||||
self._status_label.grid(row=1, column=0, pady=(0, 4))
|
||||
|
||||
self._hotkey_label = ctk.CTkLabel(
|
||||
status_frame,
|
||||
text=f"[ {str_to_display(self.settings['hotkey'])} ] gedrückt halten",
|
||||
font=ctk.CTkFont(size=11),
|
||||
text_color="gray",
|
||||
)
|
||||
self._hotkey_label.grid(row=2, column=0, pady=(0, 12))
|
||||
|
||||
# Conversation log
|
||||
log_frame = ctk.CTkFrame(self, corner_radius=12)
|
||||
log_frame.grid(row=2, column=0, padx=16, pady=8, sticky="nsew")
|
||||
log_frame.grid_columnconfigure(0, weight=1)
|
||||
log_frame.grid_rowconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(log_frame, text="Letzte Konversation",
|
||||
font=ctk.CTkFont(size=11), text_color="gray").grid(
|
||||
row=0, column=0, padx=12, pady=(8, 0), sticky="w")
|
||||
|
||||
self._log = ctk.CTkTextbox(log_frame, font=ctk.CTkFont(size=12),
|
||||
state="disabled", wrap="word")
|
||||
self._log.grid(row=1, column=0, padx=8, pady=(4, 8), sticky="nsew")
|
||||
|
||||
# Footer
|
||||
footer = ctk.CTkFrame(self, height=40, corner_radius=0)
|
||||
footer.grid(row=3, column=0, sticky="ew")
|
||||
footer.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._screenshot_var = ctk.BooleanVar(
|
||||
value=self.settings["screenshot_enabled"])
|
||||
ctk.CTkCheckBox(footer, text="Screenshot mitsenden",
|
||||
variable=self._screenshot_var,
|
||||
command=self._toggle_screenshot).grid(
|
||||
row=0, column=0, padx=16, pady=8, sticky="w")
|
||||
|
||||
ctk.CTkButton(footer, text="Stop", width=60, height=28,
|
||||
fg_color="#dc2626", hover_color="#b91c1c",
|
||||
command=stop_speaking).grid(
|
||||
row=0, column=1, padx=8, pady=8)
|
||||
|
||||
# ── Hotkey ───────────────────────────────────────────────────────────
|
||||
def _start_hotkey(self):
|
||||
if self._hotkey:
|
||||
self._hotkey.stop()
|
||||
self._hotkey = HotkeyListener(
|
||||
self.settings["hotkey"],
|
||||
on_press_cb=self._hotkey_pressed,
|
||||
on_release_cb=self._hotkey_released,
|
||||
)
|
||||
self._hotkey.start()
|
||||
|
||||
def _hotkey_pressed(self):
|
||||
ui_event("status", ("recording", "🔴 Ich höre…", "#ef4444"))
|
||||
play_ding()
|
||||
_set_state(State.LISTENING)
|
||||
audio = record_speech()
|
||||
start_recording()
|
||||
|
||||
# 3. Transkribieren
|
||||
text = transcribe(audio)
|
||||
def _hotkey_released(self):
|
||||
audio = stop_recording()
|
||||
ui_event("status", ("thinking", "💭 Denke…", "#3b82f6"))
|
||||
self._worker = threading.Thread(
|
||||
target=self._process, args=(audio,), daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
def _process(self, audio):
|
||||
text = transcribe(audio,
|
||||
self.settings["whisper_model"],
|
||||
self.settings["language"])
|
||||
if not text:
|
||||
continue
|
||||
print(f"[STT] {text!r}")
|
||||
ui_event("status", ("idle", "Bereit", "#22c55e"))
|
||||
return
|
||||
|
||||
# 4. Screenshot machen
|
||||
screenshot = capture_screen() if SCREENSHOT_ON_QUERY else None
|
||||
ui_event("log_user", text)
|
||||
screenshot = (capture_screen()
|
||||
if self.settings["screenshot_enabled"] else None)
|
||||
response = ask(text, screenshot, self.settings)
|
||||
|
||||
# 5. MC2 fragen
|
||||
_set_state(State.THINKING)
|
||||
response = ask(text, screenshot)
|
||||
print(f"[Hermes] {response!r}")
|
||||
ui_event("log_hermes", response)
|
||||
ui_event("status", ("speaking", "🔊 Spricht…", "#a855f7"))
|
||||
speak(response, self.settings["tts_voice"], self.settings["tts_rate"])
|
||||
ui_event("status", ("idle", "Bereit", "#22c55e"))
|
||||
|
||||
# 6. Antwort sprechen
|
||||
_set_state(State.SPEAKING)
|
||||
speak(response)
|
||||
# ── Queue-Polling ────────────────────────────────────────────────────
|
||||
def _poll_queue(self):
|
||||
while not _ui_queue.empty():
|
||||
event, data = _ui_queue.get_nowait()
|
||||
if event == "status":
|
||||
_, label, color = data
|
||||
self._status_label.configure(text=label)
|
||||
self._status_dot.configure(text_color=color)
|
||||
elif event == "log_user":
|
||||
self._append_log(f"Du: {data}")
|
||||
elif event == "log_hermes":
|
||||
self._append_log(f"Hermes: {data}\n")
|
||||
self.after(80, self._poll_queue)
|
||||
|
||||
print("[Hermes] Beendet.")
|
||||
def _append_log(self, text: str):
|
||||
self._log.configure(state="normal")
|
||||
self._log.insert("end", text + "\n")
|
||||
self._log.see("end")
|
||||
self._log.configure(state="disabled")
|
||||
|
||||
# ── Settings Dialog ──────────────────────────────────────────────────
|
||||
def _open_settings(self):
|
||||
SettingsWindow(self)
|
||||
|
||||
def _toggle_screenshot(self):
|
||||
self.settings["screenshot_enabled"] = self._screenshot_var.get()
|
||||
cfg.save(self.settings)
|
||||
|
||||
def apply_settings(self, new_settings: dict):
|
||||
self.settings = new_settings
|
||||
cfg.save(new_settings)
|
||||
self._hotkey.update_key(new_settings["hotkey"])
|
||||
self._hotkey_label.configure(
|
||||
text=f"[ {str_to_display(new_settings['hotkey'])} ] gedrückt halten")
|
||||
self._screenshot_var.set(new_settings["screenshot_enabled"])
|
||||
|
||||
def _on_close(self):
|
||||
if self._hotkey:
|
||||
self._hotkey.stop()
|
||||
stop_speaking()
|
||||
self.destroy()
|
||||
|
||||
|
||||
# ── System Tray ──────────────────────────────────────────────────────────
|
||||
def _on_quit(icon, item):
|
||||
_stop_event.set()
|
||||
stop_speaking()
|
||||
icon.stop()
|
||||
# ── Settings Window ───────────────────────────────────────────────────────
|
||||
class SettingsWindow(ctk.CTkToplevel):
|
||||
def __init__(self, parent: HermesApp):
|
||||
super().__init__(parent)
|
||||
self._parent = parent
|
||||
self.title("Einstellungen")
|
||||
self.geometry("420x520")
|
||||
self.resizable(False, False)
|
||||
self.grab_set()
|
||||
self._s = dict(parent.settings)
|
||||
self._capturing = False
|
||||
self._build()
|
||||
|
||||
def _on_mute(icon, item):
|
||||
stop_speaking()
|
||||
def _build(self):
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
row = 0
|
||||
|
||||
def main():
|
||||
global _tray_icon
|
||||
def label(text):
|
||||
nonlocal row
|
||||
ctk.CTkLabel(self, text=text, anchor="w").grid(
|
||||
row=row, column=0, padx=16, pady=6, sticky="w")
|
||||
|
||||
loop_thread = threading.Thread(target=_voice_loop, daemon=True)
|
||||
loop_thread.start()
|
||||
def entry(key, width=240):
|
||||
nonlocal row
|
||||
var = ctk.StringVar(value=str(self._s.get(key, "")))
|
||||
e = ctk.CTkEntry(self, textvariable=var, width=width)
|
||||
e.grid(row=row, column=1, padx=16, pady=6, sticky="ew")
|
||||
row += 1
|
||||
return var
|
||||
|
||||
menu = pystray.Menu(
|
||||
pystray.MenuItem("Hermes Voice", None, enabled=False),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Sprechen stoppen", _on_mute),
|
||||
pystray.MenuItem("Beenden", _on_quit),
|
||||
)
|
||||
def dropdown(key, options, width=200):
|
||||
nonlocal row
|
||||
var = ctk.StringVar(value=str(self._s.get(key, options[0])))
|
||||
dd = ctk.CTkOptionMenu(self, values=options, variable=var, width=width)
|
||||
dd.grid(row=row, column=1, padx=16, pady=6, sticky="w")
|
||||
row += 1
|
||||
return var
|
||||
|
||||
_tray_icon = pystray.Icon(
|
||||
"hermes-voice",
|
||||
icon=_make_icon(COLORS[State.IDLE]),
|
||||
title="Hermes — bereit (Hey Jarvis)",
|
||||
menu=menu,
|
||||
)
|
||||
_tray_icon.run()
|
||||
def toggle(key):
|
||||
nonlocal row
|
||||
var = ctk.BooleanVar(value=bool(self._s.get(key, False)))
|
||||
sw = ctk.CTkSwitch(self, text="", variable=var)
|
||||
sw.grid(row=row, column=1, padx=16, pady=6, sticky="w")
|
||||
row += 1
|
||||
return var
|
||||
|
||||
label("MC2 URL")
|
||||
self._url = entry("mc2_url")
|
||||
|
||||
label("Hotkey")
|
||||
self._hotkey_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self._hotkey_frame.grid(row=row, column=1, padx=16, pady=6, sticky="w")
|
||||
self._hotkey_display = ctk.CTkLabel(
|
||||
self._hotkey_frame,
|
||||
text=str_to_display(self._s.get("hotkey", "ctrl_r")),
|
||||
width=100,
|
||||
)
|
||||
self._hotkey_display.grid(row=0, column=0, padx=(0, 8))
|
||||
ctk.CTkButton(self._hotkey_frame, text="Aufnehmen", width=90,
|
||||
command=self._capture_hotkey).grid(row=0, column=1)
|
||||
row += 1
|
||||
|
||||
label("TTS Stimme")
|
||||
self._voice = dropdown("tts_voice", [
|
||||
"de-DE-KillianNeural", "de-DE-SeraphinaMultilingualNeural",
|
||||
"de-DE-ConradNeural", "en-US-AndrewNeural", "en-US-AriaNeural",
|
||||
])
|
||||
|
||||
label("Whisper Modell")
|
||||
self._whisper = dropdown("whisper_model",
|
||||
["tiny", "base", "small", "medium"])
|
||||
|
||||
label("Sprache")
|
||||
self._lang = dropdown("language",
|
||||
["de", "en", "auto"])
|
||||
|
||||
label("Screenshot")
|
||||
self._screenshot = toggle("screenshot_enabled")
|
||||
|
||||
label("Monitor")
|
||||
self._monitor = dropdown("screenshot_monitor", ["1", "2", "3"])
|
||||
|
||||
label("Max. Tokens")
|
||||
self._tokens = entry("max_response_tokens", width=100)
|
||||
|
||||
row += 1
|
||||
ctk.CTkButton(self, text="Speichern", command=self._save).grid(
|
||||
row=row, column=0, columnspan=2, pady=16, padx=16, sticky="ew")
|
||||
|
||||
def _capture_hotkey(self):
|
||||
self._hotkey_display.configure(text="Taste drücken…")
|
||||
|
||||
def on_key(key_str):
|
||||
self._s["hotkey"] = key_str
|
||||
self.after(0, lambda: self._hotkey_display.configure(
|
||||
text=str_to_display(key_str)))
|
||||
|
||||
KeyCapturer(on_key).start()
|
||||
|
||||
def _save(self):
|
||||
self._s["mc2_url"] = self._url.get().strip()
|
||||
self._s["tts_voice"] = self._voice.get()
|
||||
self._s["whisper_model"] = self._whisper.get()
|
||||
self._s["language"] = self._lang.get()
|
||||
self._s["screenshot_enabled"] = self._screenshot.get()
|
||||
self._s["screenshot_monitor"] = int(self._monitor.get())
|
||||
try:
|
||||
self._s["max_response_tokens"] = int(self._tokens.get())
|
||||
except ValueError:
|
||||
pass
|
||||
self._parent.apply_settings(self._s)
|
||||
self.destroy()
|
||||
|
||||
|
||||
# ── Entry Point ───────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
app = HermesApp()
|
||||
app.mainloop()
|
||||
|
||||
Reference in New Issue
Block a user