#!/usr/bin/env python3 """ Hermes Voice Client Deux modes : VEILLE (wake word "Ok Hermes") → CONVERSATION (parole libre 60s) Usage: 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) """ import argparse import queue import subprocess import tempfile import time import unicodedata from collections import deque from pathlib import Path import numpy as np import requests import sounddevice as sd # ── Configuration ────────────────────────────────────────────────────────────── 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 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" KEYWORD = "hermes" CHAT_TIMEOUT = 60 # secondes avant retour en veille (sans interaction) CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"} PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper" PIPER_VOICE = Path.home() / ".local/share/piper/fr_FR-upmc-medium.onnx" MAX_HISTORY = 10 HERMES_MODE = "ssh" HERMES_SSH = "s01" VOICE_INJECT = "[Réponse vocale : courte, 1-2 phrases max, sans markdown ni listes] " # ────────────────────────────────────────────────────────────────────────────── _history: list[dict] = [] def beep(freq: int = 880, duration: float = 0.12): t = np.linspace(0, duration, int(SAMPLE_RATE * duration), endpoint=False) wave = (0.25 * np.sin(2 * np.pi * freq * t)).astype(np.float32) sd.play(wave, samplerate=SAMPLE_RATE) sd.wait() def _deaccent(s: str) -> str: return unicodedata.normalize("NFD", s).encode("ascii", "ignore").decode("ascii").lower() def detect_keyword(text: str) -> tuple[bool, str]: words = text.strip().split() low = [_deaccent(w).strip(",.!?«»") for w in words] try: idx = next(i for i, w in enumerate(low) if KEYWORD in w) return True, " ".join(words[idx + 1:]).strip() except StopIteration: return False, "" def transcribe(whisper_model, audio: np.ndarray) -> str: audio_f32 = audio.astype(np.float32) / 32768.0 segments, _ = whisper_model.transcribe( audio_f32, language="fr", task="transcribe", beam_size=3, vad_filter=True, initial_prompt="Transcription en français.", ) return " ".join(seg.text for seg in segments).strip() def ask_hermes(text: str) -> str: if HERMES_MODE == "ssh": return _ask_via_ssh(text) return _ask_via_litellm(text) def _ask_via_ssh(text: str) -> str: query = VOICE_INJECT + text safe = query.replace("'", "'\\''") cmd = [ "ssh", HERMES_SSH, f"sudo -i -u hermes bash -c 'HERMES_HOME=/srv/data/hermes hermes --profile funk-ai -z \"{safe}\"'" ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) output = result.stdout.strip() if not output and result.returncode != 0: raise RuntimeError(result.stderr.strip() or "hermes -z a échoué") return output def _ask_via_litellm(text: str) -> str: global _history lower = _deaccent(text) if any(w in lower for w in ("reset", "nouveau contexte", "oublie tout", "efface")): _history.clear() 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": 200, "temperature": 0.7, }, headers={"Authorization": f"Bearer {LITELLM_KEY}"}, timeout=30, ) resp.raise_for_status() response = resp.json()["choices"][0]["message"]["content"].strip() _history.append({"role": "assistant", "content": response}) return response def speak(text: str): voice = Path(PIPER_VOICE).expanduser() bin_path = Path(PIPER_BIN).expanduser() if not voice.exists() or not bin_path.exists(): return with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: out = Path(f.name) try: subprocess.run( [str(bin_path), "--model", str(voice), "--output_file", str(out)], input=text.encode(), capture_output=True, check=True, env={"LD_LIBRARY_PATH": str(bin_path.parent)}, ) subprocess.run(["aplay", "-q", str(out)], check=False) except FileNotFoundError: pass finally: out.unlink(missing_ok=True) 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 ❌ {e}") return print(f"\n 🤖 Hermes : {response}\n") if not no_tts: speak(response) 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) 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=FRAME_SAMPLES, callback=callback): while True: # 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") frame = audio_q.get() pre_roll.append(frame) try: is_speech = vad.is_speech(frame.tobytes(), SAMPLE_RATE) except Exception: continue if not is_speech: continue # Parole détectée — vider le pre-roll dans captured et continuer captured = list(pre_roll) # 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]) text = transcribe(whisper_model, captured_audio) if not text: continue 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_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() def callback(indata, frames, time_info, status): chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy() audio_q.put(chunk) print(" 🔴 Enregistrement... (Entrée pour terminer)") with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16", blocksize=512, callback=callback): input() chunks = [] while not audio_q.empty(): chunks.append(audio_q.get_nowait()) if not chunks: continue beep(440) 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(): parser = argparse.ArgumentParser(description="Hermes Voice Client") parser.add_argument("--ptt", action="store_true", help="Mode push-to-talk") parser.add_argument("--no-tts", action="store_true", help="Réponses texte seulement") args = parser.parse_args() 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") 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__": main()