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:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user