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:
Hitonabi
2026-06-26 22:23:36 +02:00
parent b51fc899ca
commit 037c4b11df
8 changed files with 555 additions and 239 deletions
+14 -12
View File
@@ -1,15 +1,20 @@
import httpx import httpx
from config import (
MC2_BASE_URL, CHAT_MODEL, VISION_MODEL, SYSTEM_PROMPT = (
MAX_RESPONSE_TOKENS, REQUEST_TIMEOUT, SYSTEM_PROMPT, "Du bist Hermes, ein hilfreicher KI-Assistent. "
SCREENSHOT_ON_QUERY, "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.""" """Schickt Text (+ optionalen Screenshot) an MC2 und gibt die Antwort zurück."""
use_vision = screenshot_b64 is not None and SCREENSHOT_ON_QUERY use_vision = screenshot_b64 is not None
model = VISION_MODEL if use_vision else CHAT_MODEL 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: if use_vision:
user_content = [ user_content = [
@@ -27,16 +32,13 @@ def ask(text: str, screenshot_b64: str | None = None) -> str:
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content}, {"role": "user", "content": user_content},
], ],
"max_tokens": MAX_RESPONSE_TOKENS, "max_tokens": max_tokens,
"stream": False, "stream": False,
} }
try: try:
with httpx.Client(timeout=REQUEST_TIMEOUT) as client: with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
response = client.post( response = client.post(f"{base_url}/chat/completions", json=payload)
f"{MC2_BASE_URL}/chat/completions",
json=payload,
)
response.raise_for_status() response.raise_for_status()
return response.json()["choices"][0]["message"]["content"].strip() return response.json()["choices"][0]["message"]["content"].strip()
except httpx.TimeoutException: except httpx.TimeoutException:
+59 -122
View File
@@ -1,154 +1,91 @@
""" """
Audio-Input: Energy VAD → Whisper Wake Detection → Whisper STT Audio-Input: Aufnahme + Whisper STT.
Kein Wake-Word-Loop — Aufnahme startet direkt auf Hotkey-Signal.
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
""" """
import os import os
import threading
import tempfile import tempfile
import threading
import numpy as np import numpy as np
import sounddevice as sd import sounddevice as sd
import scipy.io.wavfile as wav import scipy.io.wavfile as wav
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
from config import ( from pathlib import Path
WAKE_WORDS, WAKE_WHISPER_MODEL,
WHISPER_MODEL_SIZE, WHISPER_LANGUAGE,
SILENCE_TIMEOUT, MAX_RECORD_SECONDS,
)
SAMPLE_RATE = 16000 SAMPLE_RATE = 16000
CHUNK = 1024 CHUNK = 1024
# Energie-Schwelle: unter diesem RMS = Stille
# Ggf. anpassen wenn zu sensitiv (höher) oder zu träge (niedriger)
ENERGY_THRESHOLD = 0.008 ENERGY_THRESHOLD = 0.008
_whisper_tiny: WhisperModel | None = None _model: WhisperModel | None = None
_whisper_main: 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: def load_model(model_size: str):
global _whisper_tiny, _whisper_main global _model, _model_size
if size == WAKE_WHISPER_MODEL: if _model is None or _model_size != model_size:
if _whisper_tiny is None: model_dir = Path.home() / ".hermes-voice" / "whisper-models"
print(f"[STT] Lade Whisper {size} (Wake Detection)…") model_dir.mkdir(parents=True, exist_ok=True)
_whisper_tiny = WhisperModel(size, device="cpu", compute_type="int8") _model = WhisperModel(
return _whisper_tiny model_size,
else: device="cpu",
if _whisper_main is None: compute_type="int8",
print(f"[STT] Lade Whisper {size} (Befehl-Transkription)…") download_root=str(model_dir),
_whisper_main = WhisperModel(size, device="cpu", compute_type="int8") )
return _whisper_main _model_size = model_size
def _record_until_silence( def start_recording():
stop_event: threading.Event, global _recording, _frames, _stream
max_seconds: float = 6.0, with _lock:
silence_timeout: float = 1.5, _recording = True
) -> np.ndarray | None: _frames = []
"""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)
with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, def callback(indata, frames, time, status):
dtype="float32", blocksize=CHUNK) as stream: if _recording:
for _ in range(max_chunks): _frames.append(indata[:, 0].copy())
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)))
if energy > ENERGY_THRESHOLD: _stream = sd.InputStream(
speech_chunks += 1 samplerate=SAMPLE_RATE,
silence_chunks = 0 channels=1,
else: dtype="float32",
silence_chunks += 1 blocksize=CHUNK,
callback=callback,
if speech_chunks > 2 and silence_chunks >= silence_limit: )
break _stream.start()
if speech_chunks < 2:
return None # nur Rauschen, kein echter Sprachinhalt
return np.concatenate(frames)
def _transcribe(audio: np.ndarray, model_size: str) -> str: def stop_recording() -> np.ndarray:
"""Schreibt Audio-Array als WAV, transkribiert mit Whisper.""" 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: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
tmp = f.name tmp = f.name
try: try:
wav.write(tmp, SAMPLE_RATE, (audio * 32767).astype(np.int16)) wav.write(tmp, SAMPLE_RATE, (audio * 32767).astype(np.int16))
model = _get_whisper(model_size) lang = language if language != "auto" else None
segments, _ = model.transcribe( segments, _ = _model.transcribe(
tmp, tmp,
language=WHISPER_LANGUAGE, language=lang,
beam_size=3, beam_size=3,
vad_filter=True, vad_filter=True,
) )
return " ".join(s.text for s in segments).strip() return " ".join(s.text for s in segments).strip()
finally: finally:
os.unlink(tmp) 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)
+46
View File
@@ -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
+39
View File
@@ -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",
)
+103
View File
@@ -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()
+278 -92
View File
@@ -1,129 +1,315 @@
""" """
Hermes Voice Client Hermes Voice Client — GUI
Wake Word → STT → Screenshot → Hermes (MC2) → TTS Push-to-Talk: Hotkey halten → sprechen → loslassen → Hermes antwortet
Läuft als System-Tray-App im Hintergrund.
Aktivierung: "Hey Jarvis" (konfigurierbar in config.py)
""" """
import queue
import threading import threading
import sys import sys
import customtkinter as ctk
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
import pystray
from audio_input import wait_for_wake_word, record_speech, transcribe import config_manager as cfg
from audio_output import speak, play_ding, stop_speaking 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 screen_capture import capture_screen
from ai_client import ask from ai_client import ask
from config import SCREENSHOT_ON_QUERY from hotkey_listener import HotkeyListener, KeyCapturer, str_to_display
# ── Status ─────────────────────────────────────────────────────────────── ctk.set_appearance_mode("dark")
class State: ctk.set_default_color_theme("blue")
IDLE = "idle" # wartet auf Wake Word
LISTENING = "listen" # nimmt Sprache auf
THINKING = "think" # wartet auf MC2-Antwort
SPEAKING = "speak" # gibt Antwort aus
_state = State.IDLE # ── UI-Event-Queue (thread-safe) ─────────────────────────────────────────
_stop_event = threading.Event() _ui_queue: queue.Queue = queue.Queue()
_tray_icon: pystray.Icon | None = None
def ui_event(event: str, data=None):
_ui_queue.put((event, data))
# ── Tray Icon ──────────────────────────────────────────────────────────── # ── Hauptfenster ─────────────────────────────────────────────────────────
COLORS = { class HermesApp(ctk.CTk):
State.IDLE: "#22c55e", # grün def __init__(self):
State.LISTENING: "#eab308", # gelb super().__init__()
State.THINKING: "#3b82f6", # blau self.settings = cfg.load()
State.SPEAKING: "#a855f7", # lila self._hotkey: HotkeyListener | None = None
} self._worker: threading.Thread | None = None
def _make_icon(color: str) -> Image.Image: self.title("Hermes Voice")
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) self.geometry("400x520")
draw = ImageDraw.Draw(img) self.resizable(False, False)
draw.ellipse([4, 4, 60, 60], fill=color) self.protocol("WM_DELETE_WINDOW", self._on_close)
return img
def _set_state(state: str): self._build_ui()
global _state self._start_hotkey()
_state = state self.after(100, self._poll_queue)
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]
# ── Layout ───────────────────────────────────────────────────────────
def _build_ui(self):
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)
# ── Hauptloop ──────────────────────────────────────────────────────────── # Header
def _voice_loop(): header = ctk.CTkFrame(self, height=50, corner_radius=0)
print("[Hermes] Bereit. Sage 'Hey Jarvis' zum Aktivieren.") header.grid(row=0, column=0, sticky="ew")
while not _stop_event.is_set(): header.grid_columnconfigure(0, weight=1)
_set_state(State.IDLE) 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 # Status
detected = wait_for_wake_word(_stop_event) status_frame = ctk.CTkFrame(self, corner_radius=12)
if not detected: status_frame.grid(row=1, column=0, padx=16, pady=(16, 8), sticky="ew")
break 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() play_ding()
_set_state(State.LISTENING) start_recording()
audio = record_speech()
# 3. Transkribieren def _hotkey_released(self):
text = transcribe(audio) 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: if not text:
continue ui_event("status", ("idle", "Bereit", "#22c55e"))
print(f"[STT] {text!r}") return
# 4. Screenshot machen ui_event("log_user", text)
screenshot = capture_screen() if SCREENSHOT_ON_QUERY else None screenshot = (capture_screen()
if self.settings["screenshot_enabled"] else None)
response = ask(text, screenshot, self.settings)
# 5. MC2 fragen ui_event("log_hermes", response)
_set_state(State.THINKING) ui_event("status", ("speaking", "🔊 Spricht…", "#a855f7"))
response = ask(text, screenshot) speak(response, self.settings["tts_voice"], self.settings["tts_rate"])
print(f"[Hermes] {response!r}") ui_event("status", ("idle", "Bereit", "#22c55e"))
# 6. Antwort sprechen # ── Queue-Polling ────────────────────────────────────────────────────
_set_state(State.SPEAKING) def _poll_queue(self):
speak(response) 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)
# ── System Tray ────────────────────────────────────────────────────────── def _toggle_screenshot(self):
def _on_quit(icon, item): self.settings["screenshot_enabled"] = self._screenshot_var.get()
_stop_event.set() 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() stop_speaking()
icon.stop() self.destroy()
def _on_mute(icon, item):
stop_speaking()
def main(): # ── Settings Window ───────────────────────────────────────────────────────
global _tray_icon 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()
loop_thread = threading.Thread(target=_voice_loop, daemon=True) def _build(self):
loop_thread.start() self.grid_columnconfigure(1, weight=1)
row = 0
menu = pystray.Menu( def label(text):
pystray.MenuItem("Hermes Voice", None, enabled=False), nonlocal row
pystray.Menu.SEPARATOR, ctk.CTkLabel(self, text=text, anchor="w").grid(
pystray.MenuItem("Sprechen stoppen", _on_mute), row=row, column=0, padx=16, pady=6, sticky="w")
pystray.MenuItem("Beenden", _on_quit),
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
_tray_icon = pystray.Icon( label("TTS Stimme")
"hermes-voice", self._voice = dropdown("tts_voice", [
icon=_make_icon(COLORS[State.IDLE]), "de-DE-KillianNeural", "de-DE-SeraphinaMultilingualNeural",
title="Hermes — bereit (Hey Jarvis)", "de-DE-ConradNeural", "en-US-AndrewNeural", "en-US-AriaNeural",
menu=menu, ])
)
_tray_icon.run() 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__": if __name__ == "__main__":
main() app = HermesApp()
app.mainloop()
+8 -7
View File
@@ -1,9 +1,7 @@
# Hermes Voice Client — Dependencies # Hermes Voice Client — Dependencies
# Installation: pip install -r requirements.txt # Installation: pip install -r requirements.txt
# Wake Word Detection: Silero VAD + Whisper (kein Account, beliebiges Wort) # Speech-to-Text (lokal auf CPU)
# Speech-to-Text (läuft lokal auf CPU)
faster-whisper>=1.0.0 faster-whisper>=1.0.0
# Audio I/O # Audio I/O
@@ -18,11 +16,14 @@ edge-tts>=6.1.9
mss>=9.0.1 mss>=9.0.1
Pillow>=10.0.0 Pillow>=10.0.0
# HTTP Client (async) # HTTP Client
httpx>=0.27.0 httpx>=0.27.0
# System Tray
pystray>=0.19.5
# Audio Playback # Audio Playback
pygame>=2.5.0 pygame>=2.5.0
# GUI
customtkinter>=5.2.0
# Globaler Hotkey
pynput>=1.7.6
+6 -4
View File
@@ -28,15 +28,17 @@ echo [2/3] Installiere Dependencies...
:: Kurz-Check :: Kurz-Check
echo [3/3] Pruefe Konfiguration... 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 ======================================== echo ========================================
echo Setup abgeschlossen! echo Setup abgeschlossen!
echo. echo.
echo Naechste Schritte: echo Naechste Schritte:
echo 1. Wake Word in config.py pruefen (WAKE_WORDS) echo 1. start.bat ausfuehren
echo 2. MC2-URL in config.py pruefen (MC2_BASE_URL) echo 2. Einstellungen (Zahnrad) pruefen:
echo 3. start.bat ausfuehren echo - MC2 URL (Standard: 192.168.178.151:9001)
echo - Hotkey (Standard: Rechte Strg)
echo Optional: build.bat fuer .exe-Kompilierung
echo ======================================== echo ========================================
pause pause