""" 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 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 hotkey_listener import HotkeyListener, KeyCapturer, str_to_display ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") # ── UI-Event-Queue (thread-safe) ───────────────────────────────────────── _ui_queue: queue.Queue = queue.Queue() def ui_event(event: str, data=None): _ui_queue.put((event, data)) # ── 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 self.title("Hermes Voice") self.geometry("400x520") self.resizable(False, False) self.protocol("WM_DELETE_WINDOW", self._on_close) 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) # 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) # 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) 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() start_recording() 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: ui_event("status", ("idle", "Bereit", "#22c55e")) return ui_event("log_user", text) screenshot = (capture_screen() if self.settings["screenshot_enabled"] else None) response = ask(text, screenshot, self.settings) 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")) # ── 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) 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() # ── 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 _build(self): self.grid_columnconfigure(1, weight=1) row = 0 def label(text): nonlocal row ctk.CTkLabel(self, text=text, anchor="w").grid( row=row, column=0, padx=16, pady=6, sticky="w") 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 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 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__": app = HermesApp() app.mainloop()