From 82364488596a63b09c1d60e80f3b70e299caf73a Mon Sep 17 00:00:00 2001 From: alkatrazz Date: Tue, 2 Jun 2026 19:23:26 +0200 Subject: [PATCH] feat(voice): mode VEILLE/CONVERSATION + webrtcvad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réécriture complète pour une expérience chat naturelle : - Mode VEILLE : écoute "Ok Hermès" → active la conversation - Mode CONVERSATION : 60s de parole libre sans répéter le wake word - webrtcvad mode=3 (remplace détection énergie) — fini les faux déclenchements - Single large-v3 (supprime la détection 2 niveaux peu fiable) - VOICE_INJECT prefix pour forcer des réponses courtes - Mots de sortie : "au revoir", "bonne nuit", "stop hermes", "fin" Co-Authored-By: Claude Sonnet 4.6 --- tools/hermes-voice/hermes-voice.py | 315 ++++++++++++++-------------- tools/hermes-voice/requirements.txt | 1 + 2 files changed, 156 insertions(+), 160 deletions(-) diff --git a/tools/hermes-voice/hermes-voice.py b/tools/hermes-voice/hermes-voice.py index a977e60..c91cb6e 100644 --- a/tools/hermes-voice/hermes-voice.py +++ b/tools/hermes-voice/hermes-voice.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ Hermes Voice Client -VAD → Whisper STT (2 niveaux) → keyword "hermes" → hermes -z SSH → piper TTS +Deux modes : VEILLE (wake word "Ok Hermes") → CONVERSATION (parole libre 60s) Usage: - python hermes-voice.py # dites "Ok Hermes, ..." — écoute en permanence - python hermes-voice.py --ptt # Entrée pour commencer/terminer + python hermes-voice.py # écoute en permanence + python hermes-voice.py --ptt # Entrée pour parler python hermes-voice.py --no-tts # réponses texte seulement (debug) """ @@ -13,6 +13,8 @@ import argparse import queue import subprocess import tempfile +import time +import unicodedata from collections import deque from pathlib import Path @@ -21,44 +23,36 @@ import requests import sounddevice as sd # ── Configuration ────────────────────────────────────────────────────────────── -# Si port 4000 inaccessible : ouvrir un tunnel SSH avant de lancer : -# ssh -L 4000:localhost:4000 user@192.168.1.200 -# puis changer LITELLM_URL → http://localhost:4000/... -LITELLM_URL = "http://192.168.1.200:4000/v1/chat/completions" -LITELLM_KEY = "lm-studio" -HERMES_MODEL = "hermes-default" -SYSTEM_PROMPT = ( +LITELLM_URL = "http://192.168.1.200:4000/v1/chat/completions" +LITELLM_KEY = "lm-studio" +HERMES_MODEL = "hermes-default" +SYSTEM_PROMPT = ( "Tu es Hermes, l'assistant vocal du homelab Funk. " "Réponds toujours en français, de façon concise (2-3 phrases maximum)." ) -SAMPLE_RATE = 16000 -BLOCK_SIZE = 512 # 32ms par bloc -PRE_ROLL = 10 # blocs à conserver avant la détection (début de phrase) -VOICE_THRESH = 0.012 # seuil RMS normalisé pour détecter la parole -SILENCE_SEC = 1.5 # silence pour terminer l'enregistrement -MIN_SPEECH = 0.8 # durée minimale traitée (filtre les bruits courts) +SAMPLE_RATE = 16000 +SILENCE_SEC = 1.2 # secondes de silence pour terminer l'enregistrement +MIN_SPEECH = 0.6 # durée minimale (filtre les bruits courts) +PRE_ROLL = 15 # frames à conserver avant la détection -WHISPER_SIZE = "large-v3" # large-v3 : meilleure reconnaissance FR (distil-large-v3 dérive vers l'anglais) -KEYWORD = "hermes" -KEYWORD_POS = 5 # le mot-clé doit être dans les N premiers mots +WHISPER_SIZE = "large-v3" +KEYWORD = "hermes" +CHAT_TIMEOUT = 60 # secondes avant retour en veille (sans interaction) +CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"} -# Piper TTS — binaire : https://github.com/rhasspy/piper/releases -# Voix FR : wget .../fr_FR-upmc-medium.onnx → ~/.local/share/piper/ -PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper" -PIPER_VOICE = Path.home() / ".local/share/piper/fr_FR-upmc-medium.onnx" +PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper" +PIPER_VOICE = Path.home() / ".local/share/piper/fr_FR-upmc-medium.onnx" -MAX_HISTORY = 10 # nombre d'échanges (user+assistant) conservés en mémoire +MAX_HISTORY = 10 +HERMES_MODE = "ssh" +HERMES_SSH = "s01" -# Mode "full Hermes" via SSH (soul + skills + RAG + mémoire long-terme) -# Passer HERMES_MODE = "ssh" pour router par `hermes -z` sur storage-01 -# Passer HERMES_MODE = "litellm" pour appel direct LiteLLM (session history uniquement) -HERMES_MODE = "ssh" # "litellm" | "ssh" -HERMES_SSH = "s01" # alias SSH défini dans ~/.ssh/config +VOICE_INJECT = "[Réponse vocale : courte, 1-2 phrases max, sans markdown ni listes] " # ────────────────────────────────────────────────────────────────────────────── -_history: list[dict] = [] # historique session (mode litellm uniquement) +_history: list[dict] = [] def beep(freq: int = 880, duration: float = 0.12): @@ -68,23 +62,18 @@ def beep(freq: int = 880, duration: float = 0.12): sd.wait() -def rms(chunk: np.ndarray) -> float: - return float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2)) / 32768.0) +def _deaccent(s: str) -> str: + return unicodedata.normalize("NFD", s).encode("ascii", "ignore").decode("ascii").lower() def detect_keyword(text: str) -> tuple[bool, str]: - """Vérifie si KEYWORD est dans les premiers mots et retourne la commande sans le mot-clé.""" - words = text.strip().split() - stripped = [w.lower().strip(",.!?«»") for w in words] - found = any(KEYWORD in w for w in stripped[:KEYWORD_POS]) - if not found: - return False, "" + words = text.strip().split() + low = [_deaccent(w).strip(",.!?«»") for w in words] try: - idx = next(i for i, w in enumerate(stripped) if KEYWORD in w) - command = " ".join(words[idx + 1:]).strip() + idx = next(i for i, w in enumerate(low) if KEYWORD in w) + return True, " ".join(words[idx + 1:]).strip() except StopIteration: - command = text - return True, command or text + return False, "" def transcribe(whisper_model, audio: np.ndarray) -> str: @@ -93,7 +82,7 @@ def transcribe(whisper_model, audio: np.ndarray) -> str: audio_f32, language="fr", task="transcribe", - beam_size=5, + beam_size=3, vad_filter=True, initial_prompt="Transcription en français.", ) @@ -107,12 +96,9 @@ def ask_hermes(text: str) -> str: def _ask_via_ssh(text: str) -> str: - """Route par le vrai Hermes sur storage-01 (soul + skills + RAG + mémoire).""" - import subprocess - # Forcer le français + échapper les guillemets simples pour bash - query = f"Réponds en français. {text}" + query = VOICE_INJECT + text safe = query.replace("'", "'\\''") - cmd = [ + cmd = [ "ssh", HERMES_SSH, f"sudo -i -u hermes bash -c 'HERMES_HOME=/srv/data/hermes hermes --profile funk-ai -z \"{safe}\"'" ] @@ -124,23 +110,19 @@ def _ask_via_ssh(text: str) -> str: def _ask_via_litellm(text: str) -> str: - """Appel direct LiteLLM avec historique de session.""" global _history - - lower = text.lower().strip() + lower = _deaccent(text) if any(w in lower for w in ("reset", "nouveau contexte", "oublie tout", "efface")): _history.clear() - return "Contexte effacé, on repart de zéro." - + return "Contexte effacé." _history.append({"role": "user", "content": text}) window = _history[-(MAX_HISTORY * 2):] - resp = requests.post( LITELLM_URL, json={ "model": HERMES_MODEL, "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + window, - "max_tokens": 300, + "max_tokens": 200, "temperature": 0.7, }, headers={"Authorization": f"Bearer {LITELLM_KEY}"}, @@ -153,134 +135,149 @@ def _ask_via_litellm(text: str) -> str: def speak(text: str): - voice = Path(PIPER_VOICE).expanduser() + voice = Path(PIPER_VOICE).expanduser() bin_path = Path(PIPER_BIN).expanduser() - if not voice.exists(): - print(f" [TTS] voix introuvable : {voice}") - return - if not bin_path.exists(): - print(f" [TTS] piper introuvable : {bin_path}") + if not voice.exists() or not bin_path.exists(): return with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: out = Path(f.name) - # Les .so de piper doivent être dans le même répertoire que le binaire - lib_dir = str(bin_path.parent) - env = {"LD_LIBRARY_PATH": lib_dir} try: subprocess.run( [str(bin_path), "--model", str(voice), "--output_file", str(out)], input=text.encode(), capture_output=True, check=True, - env=env, + env={"LD_LIBRARY_PATH": str(bin_path.parent)}, ) subprocess.run(["aplay", "-q", str(out)], check=False) except FileNotFoundError: - print(" [TTS] piper non trouvé — voir https://github.com/rhasspy/piper/releases") + pass finally: out.unlink(missing_ok=True) -def handle_vad(whisper_fast, whisper_full, audio: np.ndarray, no_tts: bool): - """VAD : détection rapide (small) puis transcription précise (large-v3).""" - if len(audio) / SAMPLE_RATE < MIN_SPEECH: - return - - # Étape 1 — vérification rapide du mot-clé avec le petit modèle - quick = transcribe(whisper_fast, audio) - found, _ = detect_keyword(quick) - if not found: - print(f"(ignoré : \"{quick[:60]}\")") - return - - # Étape 2 — transcription précise uniquement si mot-clé trouvé - print(" ⏳ Transcription...", end=" ", flush=True) - text = transcribe(whisper_full, audio) - if not text: - print("(rien compris)") - return - _, command = detect_keyword(text) - query = command or text - - print(f"\n 🗣️ Vous : {text}") - _respond(query, no_tts) - - -def handle_ptt(whisper_full, audio: np.ndarray, no_tts: bool): - """PTT : transcription directe sans filtre mot-clé.""" - if len(audio) / SAMPLE_RATE < MIN_SPEECH: - return - - print(" ⏳ Transcription...", end=" ", flush=True) - text = transcribe(whisper_full, audio) - if not text: - print("(rien compris)") - return - print(f"\n 🗣️ Vous : {text}") - _respond(text, no_tts) - - def _respond(query: str, no_tts: bool): print(" ⏳ Hermes...", end=" ", flush=True) try: response = ask_hermes(query) except (requests.RequestException, RuntimeError) as e: - print(f"\n ❌ Erreur : {e}") + print(f"\n ❌ {e}") return print(f"\n 🤖 Hermes : {response}\n") if not no_tts: speak(response) -def run_vad(whisper_fast, whisper_full, no_tts: bool): - """Écoute continue — dites 'Ok Hermes, ...' pour déclencher.""" - print(f"🎙️ En écoute — dites \"Ok {KEYWORD.capitalize()}, ...\" (Ctrl+C pour quitter)\n") +def record_until_silence(audio_q: queue.Queue, vad, frame_samples: int, frame_ms: int) -> np.ndarray | None: + """Enregistre depuis la queue jusqu'à SILENCE_SEC de silence webrtcvad.""" + required_silent = int(SILENCE_SEC * 1000 / frame_ms) + captured = [] + silent_frames = 0 + + while True: + try: + frame = audio_q.get(timeout=3.0) + except queue.Empty: + break + captured.append(frame) + try: + is_speech = vad.is_speech(frame.tobytes(), SAMPLE_RATE) + except Exception: + is_speech = True + if not is_speech: + silent_frames += 1 + else: + silent_frames = 0 + if silent_frames >= required_silent: + break + + if not captured: + return None + audio = np.concatenate(captured) + return audio if len(audio) / SAMPLE_RATE >= MIN_SPEECH else None + + +def run_vad(whisper_model, no_tts: bool): + import webrtcvad + + vad = webrtcvad.Vad(mode=3) + FRAME_MS = 20 + FRAME_SAMPLES = int(SAMPLE_RATE * FRAME_MS / 1000) # 320 samples + + chat_mode = False + last_activity = 0.0 + + print("💤 Veille — dites \"Ok Hermès\" pour commencer (Ctrl+C pour quitter)\n") audio_q: queue.Queue = queue.Queue() - pre_roll: deque = deque(maxlen=PRE_ROLL) + pre_roll: deque = deque(maxlen=PRE_ROLL) def callback(indata, frames, time_info, status): chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy() audio_q.put(chunk) with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16", - blocksize=BLOCK_SIZE, callback=callback): + blocksize=FRAME_SAMPLES, callback=callback): while True: - chunk = audio_q.get() - pre_roll.append(chunk) + # Timeout conversation + if chat_mode and time.time() - last_activity > CHAT_TIMEOUT: + chat_mode = False + print("💤 Retour en veille — dites \"Ok Hermès\" pour reprendre\n") - if rms(chunk) < VOICE_THRESH: + frame = audio_q.get() + pre_roll.append(frame) + + try: + is_speech = vad.is_speech(frame.tobytes(), SAMPLE_RATE) + except Exception: continue - # Voix détectée — enregistrer jusqu'au silence - print(" 🔴 ", end="", flush=True) + if not is_speech: + continue + + # Parole détectée — vider le pre-roll dans captured et continuer captured = list(pre_roll) - silent_blocks = 0 - required_silent = int(SILENCE_SEC * SAMPLE_RATE / BLOCK_SIZE) + # Drainer la queue le temps de silence + audio = record_until_silence(audio_q, vad, FRAME_SAMPLES, FRAME_MS) + if audio is None: + continue + captured_audio = np.concatenate([np.concatenate(captured), audio]) - while True: - try: - chunk = audio_q.get(timeout=3.0) - except queue.Empty: - break - captured.append(chunk) - if rms(chunk) < VOICE_THRESH: - silent_blocks += 1 - else: - silent_blocks = 0 - print(".", end="", flush=True) - if silent_blocks >= required_silent: - break + text = transcribe(whisper_model, captured_audio) + if not text: + continue - print() - beep(440) - handle_vad(whisper_fast, whisper_full, np.concatenate(captured), no_tts) + if chat_mode: + # En conversation — mots de fin ? + if any(w in _deaccent(text) for w in CHAT_EXIT): + chat_mode = False + print(f"\n 🗣️ Vous : {text}") + beep(440) + speak("À bientôt !") + print("💤 Retour en veille\n") + continue + # Envoyer directement + print(f"\n 🗣️ Vous : {text}") + last_activity = time.time() + _respond(text, no_tts) + + else: + # En veille — chercher le wake word + found, command = detect_keyword(text) + if not found: + continue + # Wake word confirmé + chat_mode = True + last_activity = time.time() + beep(880) + print(f"\n 🗣️ Vous : {text}") + print(f"💬 Mode conversation ({CHAT_TIMEOUT}s) — parlez librement\n") + if command: + _respond(command, no_tts) -def run_ptt(whisper_full, no_tts: bool): - """Push-to-talk : Entrée pour commencer, Entrée pour terminer.""" +def run_ptt(whisper_model, no_tts: bool): print("🎙️ Mode push-to-talk — Entrée pour parler, Entrée pour terminer (Ctrl+C pour quitter)\n") - while True: input("[ ↵ Entrée pour parler ]") audio_q: queue.Queue = queue.Queue() @@ -291,7 +288,7 @@ def run_ptt(whisper_full, no_tts: bool): print(" 🔴 Enregistrement... (Entrée pour terminer)") with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16", - blocksize=BLOCK_SIZE, callback=callback): + blocksize=512, callback=callback): input() chunks = [] while not audio_q.empty(): @@ -300,7 +297,16 @@ def run_ptt(whisper_full, no_tts: bool): if not chunks: continue beep(440) - handle_ptt(whisper_full, np.concatenate(chunks), no_tts) + audio = np.concatenate(chunks) + if len(audio) / SAMPLE_RATE < MIN_SPEECH: + continue + print(" ⏳ Transcription...", end=" ", flush=True) + text = transcribe(whisper_model, audio) + if not text: + print("(rien compris)") + continue + print(f"\n 🗣️ Vous : {text}") + _respond(text, no_tts) def main(): @@ -311,29 +317,18 @@ def main(): print("Hermes Voice Client") print("=" * 40) - + print(f" ⏳ Chargement Whisper ({WHISPER_SIZE})...", end=" ", flush=True) from faster_whisper import WhisperModel + whisper = WhisperModel(WHISPER_SIZE, device="cpu", compute_type="int8") + print("✓\n") - if args.ptt: - print(f" ⏳ Chargement Whisper ({WHISPER_SIZE})...", end=" ", flush=True) - whisper_full = WhisperModel(WHISPER_SIZE, device="cpu", compute_type="int8") - print("✓\n") - try: - run_ptt(whisper_full, args.no_tts) - except KeyboardInterrupt: - print("\n👋 Au revoir") - else: - # Mode VAD : deux modèles — small pour détection rapide, large pour transcription - print(" ⏳ Chargement Whisper rapide (small)...", end=" ", flush=True) - whisper_fast = WhisperModel("small", device="cpu", compute_type="int8") - print("✓") - print(f" ⏳ Chargement Whisper précis ({WHISPER_SIZE})...", end=" ", flush=True) - whisper_full = WhisperModel(WHISPER_SIZE, device="cpu", compute_type="int8") - print("✓\n") - try: - run_vad(whisper_fast, whisper_full, args.no_tts) - except KeyboardInterrupt: - print("\n👋 Au revoir") + try: + if args.ptt: + run_ptt(whisper, args.no_tts) + else: + run_vad(whisper, args.no_tts) + except KeyboardInterrupt: + print("\n👋 Au revoir") if __name__ == "__main__": diff --git a/tools/hermes-voice/requirements.txt b/tools/hermes-voice/requirements.txt index 61ed827..ab2c097 100644 --- a/tools/hermes-voice/requirements.txt +++ b/tools/hermes-voice/requirements.txt @@ -2,3 +2,4 @@ faster-whisper>=1.0.0 sounddevice>=0.4.6 numpy>=1.24.0 requests>=2.31.0 +webrtcvad-wheels>=2.0.10