diff --git a/client/hermes-voice/ai_client.py b/client/hermes-voice/ai_client.py index ddbe48f..55df3f7 100644 --- a/client/hermes-voice/ai_client.py +++ b/client/hermes-voice/ai_client.py @@ -1,15 +1,20 @@ import httpx -from config import ( - MC2_BASE_URL, CHAT_MODEL, VISION_MODEL, - MAX_RESPONSE_TOKENS, REQUEST_TIMEOUT, SYSTEM_PROMPT, - SCREENSHOT_ON_QUERY, + +SYSTEM_PROMPT = ( + "Du bist Hermes, ein hilfreicher KI-Assistent. " + "Antworte kurz und präzise, da deine Antwort vorgelesen wird. " + "Maximal 3-4 Sätze, kein Markdown." ) +REQUEST_TIMEOUT = 30 -def ask(text: str, screenshot_b64: str | None = None) -> str: +def ask(text: str, screenshot_b64: str | None, settings: dict) -> str: """Schickt Text (+ optionalen Screenshot) an MC2 und gibt die Antwort zurück.""" - use_vision = screenshot_b64 is not None and SCREENSHOT_ON_QUERY - model = VISION_MODEL if use_vision else CHAT_MODEL + use_vision = screenshot_b64 is not None + model = settings.get("vision_model", "Qwen3-VL-2B-Instruct") if use_vision \ + else settings.get("chat_model", "Hermes-4-14B") + base_url = settings.get("mc2_url", "http://192.168.178.151:9001/v1") + max_tokens = int(settings.get("max_response_tokens", 200)) if use_vision: user_content = [ @@ -27,16 +32,13 @@ def ask(text: str, screenshot_b64: str | None = None) -> str: {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_content}, ], - "max_tokens": MAX_RESPONSE_TOKENS, + "max_tokens": max_tokens, "stream": False, } try: with httpx.Client(timeout=REQUEST_TIMEOUT) as client: - response = client.post( - f"{MC2_BASE_URL}/chat/completions", - json=payload, - ) + response = client.post(f"{base_url}/chat/completions", json=payload) response.raise_for_status() return response.json()["choices"][0]["message"]["content"].strip() except httpx.TimeoutException: diff --git a/client/hermes-voice/audio_input.py b/client/hermes-voice/audio_input.py index 21844ac..8d500b9 100644 --- a/client/hermes-voice/audio_input.py +++ b/client/hermes-voice/audio_input.py @@ -1,154 +1,91 @@ """ -Audio-Input: Energy VAD → Whisper Wake Detection → Whisper STT - -Ablauf: - 1. Energie-VAD erkennt Sprache im Mikrofon - 2. Whisper tiny transkribiert den Clip (schnell) - 3. Enthält das Transcript ein Wake Word? → Ding + Befehl aufnehmen - 4. Enthält es Wake Word + Befehl in einem Atemzug? → direkt zurückgeben +Audio-Input: Aufnahme + Whisper STT. +Kein Wake-Word-Loop — Aufnahme startet direkt auf Hotkey-Signal. """ - import os -import threading import tempfile +import threading import numpy as np import sounddevice as sd import scipy.io.wavfile as wav from faster_whisper import WhisperModel -from config import ( - WAKE_WORDS, WAKE_WHISPER_MODEL, - WHISPER_MODEL_SIZE, WHISPER_LANGUAGE, - SILENCE_TIMEOUT, MAX_RECORD_SECONDS, -) +from pathlib import Path SAMPLE_RATE = 16000 CHUNK = 1024 - -# Energie-Schwelle: unter diesem RMS = Stille -# Ggf. anpassen wenn zu sensitiv (höher) oder zu träge (niedriger) ENERGY_THRESHOLD = 0.008 -_whisper_tiny: WhisperModel | None = None -_whisper_main: WhisperModel | None = None +_model: WhisperModel | None = None +_model_size: str = "" +_recording = False +_frames: list[np.ndarray] = [] +_stream: sd.InputStream | None = None +_lock = threading.Lock() -def _get_whisper(size: str) -> WhisperModel: - global _whisper_tiny, _whisper_main - if size == WAKE_WHISPER_MODEL: - if _whisper_tiny is None: - print(f"[STT] Lade Whisper {size} (Wake Detection)…") - _whisper_tiny = WhisperModel(size, device="cpu", compute_type="int8") - return _whisper_tiny - else: - if _whisper_main is None: - print(f"[STT] Lade Whisper {size} (Befehl-Transkription)…") - _whisper_main = WhisperModel(size, device="cpu", compute_type="int8") - return _whisper_main +def load_model(model_size: str): + global _model, _model_size + if _model is None or _model_size != model_size: + model_dir = Path.home() / ".hermes-voice" / "whisper-models" + model_dir.mkdir(parents=True, exist_ok=True) + _model = WhisperModel( + model_size, + device="cpu", + compute_type="int8", + download_root=str(model_dir), + ) + _model_size = model_size -def _record_until_silence( - stop_event: threading.Event, - max_seconds: float = 6.0, - silence_timeout: float = 1.5, -) -> np.ndarray | None: - """Nimmt Audio auf bis zur Stille oder Timeout. Gibt None zurück wenn gestoppt.""" - frames: list[np.ndarray] = [] - speech_chunks = 0 - silence_chunks = 0 - silence_limit = int(silence_timeout * SAMPLE_RATE / CHUNK) - max_chunks = int(max_seconds * SAMPLE_RATE / CHUNK) +def start_recording(): + global _recording, _frames, _stream + with _lock: + _recording = True + _frames = [] - with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, - dtype="float32", blocksize=CHUNK) as stream: - for _ in range(max_chunks): - if stop_event.is_set(): - return None - data, _ = stream.read(CHUNK) - chunk = data[:, 0] - frames.append(chunk.copy()) - energy = float(np.sqrt(np.mean(chunk ** 2))) + def callback(indata, frames, time, status): + if _recording: + _frames.append(indata[:, 0].copy()) - if energy > ENERGY_THRESHOLD: - speech_chunks += 1 - silence_chunks = 0 - else: - silence_chunks += 1 - - if speech_chunks > 2 and silence_chunks >= silence_limit: - break - - if speech_chunks < 2: - return None # nur Rauschen, kein echter Sprachinhalt - - return np.concatenate(frames) + _stream = sd.InputStream( + samplerate=SAMPLE_RATE, + channels=1, + dtype="float32", + blocksize=CHUNK, + callback=callback, + ) + _stream.start() -def _transcribe(audio: np.ndarray, model_size: str) -> str: - """Schreibt Audio-Array als WAV, transkribiert mit Whisper.""" +def stop_recording() -> np.ndarray: + global _recording, _stream + with _lock: + _recording = False + if _stream: + _stream.stop() + _stream.close() + _stream = None + if not _frames: + return np.array([], dtype=np.float32) + return np.concatenate(_frames) + + +def transcribe(audio: np.ndarray, model_size: str, language: str) -> str: + if len(audio) < SAMPLE_RATE * 0.3: + return "" + load_model(model_size) + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: tmp = f.name try: wav.write(tmp, SAMPLE_RATE, (audio * 32767).astype(np.int16)) - model = _get_whisper(model_size) - segments, _ = model.transcribe( + lang = language if language != "auto" else None + segments, _ = _model.transcribe( tmp, - language=WHISPER_LANGUAGE, + language=lang, beam_size=3, vad_filter=True, ) return " ".join(s.text for s in segments).strip() finally: os.unlink(tmp) - - -def _strip_wake_word(text: str) -> str: - """Entfernt das Wake Word vom Anfang des Textes.""" - lower = text.lower() - for ww in sorted(WAKE_WORDS, key=len, reverse=True): - idx = lower.find(ww) - if idx != -1: - rest = text[idx + len(ww):].lstrip(" ,.") - return rest - return text - - -def wait_for_wake_word(stop_event: threading.Event) -> bool: - """ - Lauscht kontinuierlich. Gibt True zurück wenn Wake Word erkannt, False wenn gestoppt. - Initialisiert Whisper-Modelle beim ersten Aufruf. - """ - _get_whisper(WAKE_WHISPER_MODEL) # Modell vorladen - print(f"[Wake] Höre auf: {WAKE_WORDS}") - - while not stop_event.is_set(): - audio = _record_until_silence(stop_event, max_seconds=6.0, silence_timeout=1.0) - if audio is None: - continue - - text = _transcribe(audio, WAKE_WHISPER_MODEL) - if not text: - continue - - lower = text.lower() - if any(ww in lower for ww in WAKE_WORDS): - return True - - return False - - -def record_speech() -> np.ndarray: - """Nimmt den eigentlichen Befehl nach dem Wake Word auf.""" - stop = threading.Event() # separater Event, läuft immer durch - audio = _record_until_silence( - stop, - max_seconds=MAX_RECORD_SECONDS, - silence_timeout=SILENCE_TIMEOUT, - ) - return audio if audio is not None else np.array([], dtype=np.float32) - - -def transcribe(audio: np.ndarray) -> str: - """Transkribiert Befehl-Audio mit dem größeren Hauptmodell.""" - if len(audio) < SAMPLE_RATE * 0.3: - return "" - return _transcribe(audio, WHISPER_MODEL_SIZE) diff --git a/client/hermes-voice/build.bat b/client/hermes-voice/build.bat new file mode 100644 index 0000000..a4de412 --- /dev/null +++ b/client/hermes-voice/build.bat @@ -0,0 +1,46 @@ +@echo off +cd /d "%~dp0" + +echo ======================================== +echo Hermes Voice Client -- Build (.exe) +echo ======================================== +echo. + +:: Venv pruefen +if not exist ".venv\Scripts\activate.bat" ( + echo [FEHLER] Bitte erst setup.bat ausfuehren. + pause + exit /b 1 +) + +:: PyInstaller installieren falls noetig +.venv\Scripts\pip install pyinstaller -q + +echo [1/2] Baue HermesVoice.exe ... +.venv\Scripts\pyinstaller ^ + --onefile ^ + --windowed ^ + --name HermesVoice ^ + --add-data "assets;assets" ^ + --hidden-import "customtkinter" ^ + --hidden-import "pynput.keyboard._win32" ^ + --hidden-import "pynput.mouse._win32" ^ + --collect-all "customtkinter" ^ + main.py + +echo. +if exist "dist\HermesVoice.exe" ( + echo [2/2] Fertig! + echo. + echo dist\HermesVoice.exe ^(%.0f MB^) + echo. + echo Die .exe benoetigt beim ersten Start Internet fuer: + echo - Whisper-Modell-Download ^(~150MB fuer "small"^) + echo - Edge TTS ^(online^) + echo. + for %%I in ("dist\HermesVoice.exe") do echo Groesse: %%~zI Bytes +) else ( + echo [FEHLER] Build fehlgeschlagen. Siehe build-Log oben. +) +echo ======================================== +pause diff --git a/client/hermes-voice/config_manager.py b/client/hermes-voice/config_manager.py new file mode 100644 index 0000000..b908eaf --- /dev/null +++ b/client/hermes-voice/config_manager.py @@ -0,0 +1,39 @@ +import json +from pathlib import Path + +CONFIG_DIR = Path.home() / ".hermes-voice" +SETTINGS_FILE = CONFIG_DIR / "settings.json" + +DEFAULTS = { + "mc2_url": "http://192.168.178.151:9001/v1", + "chat_model": "Hermes-4-14B", + "vision_model": "Qwen3-VL-2B-Instruct", + "hotkey": "ctrl_r", + "tts_voice": "de-DE-KillianNeural", + "tts_rate": "+0%", + "whisper_model": "small", + "language": "de", + "screenshot_enabled": True, + "screenshot_monitor": 1, + "max_response_tokens": 200, + "silence_timeout": 1.5, +} + + +def load() -> dict: + CONFIG_DIR.mkdir(exist_ok=True) + if SETTINGS_FILE.exists(): + try: + saved = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + return {**DEFAULTS, **saved} + except Exception: + pass + return dict(DEFAULTS) + + +def save(settings: dict): + CONFIG_DIR.mkdir(exist_ok=True) + SETTINGS_FILE.write_text( + json.dumps(settings, indent=2, ensure_ascii=False), + encoding="utf-8", + ) diff --git a/client/hermes-voice/hotkey_listener.py b/client/hermes-voice/hotkey_listener.py new file mode 100644 index 0000000..e148b00 --- /dev/null +++ b/client/hermes-voice/hotkey_listener.py @@ -0,0 +1,103 @@ +""" +Globaler Push-to-Talk Hotkey via pynput. +Funktioniert auch wenn das Fenster minimiert/im Hintergrund ist. +""" +import threading +from pynput import keyboard + +# Mapping: config-String → pynput Key/KeyCode +_SPECIAL = { + "ctrl_r": keyboard.Key.ctrl_r, + "ctrl_l": keyboard.Key.ctrl_l, + "alt_r": keyboard.Key.alt_r, + "alt_l": keyboard.Key.alt_l, + "shift_r": keyboard.Key.shift_r, + "shift_l": keyboard.Key.shift_l, + "f13": keyboard.Key.f13, + "f14": keyboard.Key.f14, + "f15": keyboard.Key.f15, + "f16": keyboard.Key.f16, + "caps_lock": keyboard.Key.caps_lock, + "scroll_lock": keyboard.Key.scroll_lock, + "pause": keyboard.Key.pause, +} + +DISPLAY_NAMES = { + "ctrl_r": "Rechte Strg", + "ctrl_l": "Linke Strg", + "alt_r": "Rechte Alt", + "alt_l": "Linke Alt", + "shift_r": "Rechte Shift", + "shift_l": "Linke Shift", + "f13": "F13", "f14": "F14", "f15": "F15", "f16": "F16", + "caps_lock": "Caps Lock", + "scroll_lock": "Scroll Lock", + "pause": "Pause", +} + + +def key_to_str(key) -> str: + """Konvertiert pynput Key → config-String.""" + for name, k in _SPECIAL.items(): + if key == k: + return name + if hasattr(key, "char") and key.char: + return key.char.lower() + return str(key).replace("Key.", "") + + +def str_to_display(key_str: str) -> str: + return DISPLAY_NAMES.get(key_str, key_str.upper()) + + +class HotkeyListener: + def __init__(self, key_str: str, on_press_cb, on_release_cb): + self._key_str = key_str + self._on_press = on_press_cb + self._on_release = on_release_cb + self._pressed = False + self._listener: keyboard.Listener | None = None + + def _target_key(self): + return _SPECIAL.get(self._key_str) or keyboard.KeyCode.from_char(self._key_str) + + def _on_press_raw(self, key): + if not self._pressed and key == self._target_key(): + self._pressed = True + self._on_press() + + def _on_release_raw(self, key): + if self._pressed and key == self._target_key(): + self._pressed = False + self._on_release() + + def start(self): + self._listener = keyboard.Listener( + on_press=self._on_press_raw, + on_release=self._on_release_raw, + ) + self._listener.start() + + def stop(self): + if self._listener: + self._listener.stop() + + def update_key(self, key_str: str): + self._key_str = key_str + self._pressed = False + + +class KeyCapturer: + """Einmalig den nächsten Tastendruck abfangen für Hotkey-Konfiguration.""" + + def __init__(self, callback): + self._callback = callback + self._listener: keyboard.Listener | None = None + + def start(self): + def on_press(key): + key_str = key_to_str(key) + self._listener.stop() + self._callback(key_str) + self._listener = keyboard.Listener(on_press=on_press) + self._listener.start() diff --git a/client/hermes-voice/main.py b/client/hermes-voice/main.py index addd836..275b8ed 100644 --- a/client/hermes-voice/main.py +++ b/client/hermes-voice/main.py @@ -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() diff --git a/client/hermes-voice/requirements.txt b/client/hermes-voice/requirements.txt index 848190e..f5b143b 100644 --- a/client/hermes-voice/requirements.txt +++ b/client/hermes-voice/requirements.txt @@ -1,9 +1,7 @@ # Hermes Voice Client — Dependencies # Installation: pip install -r requirements.txt -# Wake Word Detection: Silero VAD + Whisper (kein Account, beliebiges Wort) - -# Speech-to-Text (läuft lokal auf CPU) +# Speech-to-Text (lokal auf CPU) faster-whisper>=1.0.0 # Audio I/O @@ -18,11 +16,14 @@ edge-tts>=6.1.9 mss>=9.0.1 Pillow>=10.0.0 -# HTTP Client (async) +# HTTP Client httpx>=0.27.0 -# System Tray -pystray>=0.19.5 - # Audio Playback pygame>=2.5.0 + +# GUI +customtkinter>=5.2.0 + +# Globaler Hotkey +pynput>=1.7.6 diff --git a/client/hermes-voice/setup.bat b/client/hermes-voice/setup.bat index a6bfabf..fe6a861 100644 --- a/client/hermes-voice/setup.bat +++ b/client/hermes-voice/setup.bat @@ -28,15 +28,17 @@ echo [2/3] Installiere Dependencies... :: Kurz-Check echo [3/3] Pruefe Konfiguration... -.venv\Scripts\python -c "from config import WAKE_WORDS; print('Wake Words:', WAKE_WORDS)" +.venv\Scripts\python -c "import customtkinter; import pynput; print('GUI + Hotkey OK')" echo. echo ======================================== echo Setup abgeschlossen! echo. echo Naechste Schritte: -echo 1. Wake Word in config.py pruefen (WAKE_WORDS) -echo 2. MC2-URL in config.py pruefen (MC2_BASE_URL) -echo 3. start.bat ausfuehren +echo 1. start.bat ausfuehren +echo 2. Einstellungen (Zahnrad) pruefen: +echo - MC2 URL (Standard: 192.168.178.151:9001) +echo - Hotkey (Standard: Rechte Strg) +echo Optional: build.bat fuer .exe-Kompilierung echo ======================================== pause