From 6fc5b5984ececca2a2c118c5af1acf40d5b351ab Mon Sep 17 00:00:00 2001 From: alkatrazz Date: Tue, 2 Jun 2026 15:12:44 +0200 Subject: [PATCH] feat(voice): ajouter client vocal Hermes (hermes-voice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/hermes-voice/ : pipeline VAD → faster-whisper → LiteLLM → piper TTS - mot-clé "hermes" détecté dans la transcription Whisper (pas de wake word model) - modes : VAD continu + push-to-talk + --no-tts pour debug - nftables : ouvrir port 4000 LiteLLM vers LAN domestique (192.168.1.0/24) - admin/ia/hermes-voice.md : documentation installation et utilisation Co-Authored-By: Claude Sonnet 4.6 --- admin/ia/hermes-voice.md | 119 ++++++++ .../roles/gateway/templates/nftables.conf.j2 | 4 +- tools/hermes-voice/hermes-voice.py | 262 ++++++++++++++++++ tools/hermes-voice/requirements.txt | 4 + 4 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 admin/ia/hermes-voice.md create mode 100644 tools/hermes-voice/hermes-voice.py create mode 100644 tools/hermes-voice/requirements.txt diff --git a/admin/ia/hermes-voice.md b/admin/ia/hermes-voice.md new file mode 100644 index 0000000..0173f76 --- /dev/null +++ b/admin/ia/hermes-voice.md @@ -0,0 +1,119 @@ +# Hermes Voice Client + +Client vocal local pour interagir avec Hermes via la voix depuis le poste perso. + +## Architecture + +``` +Micro → VAD (énergie) → faster-whisper (STT) → LiteLLM :4000 → piper (TTS) → Haut-parleur +``` + +- **STT** : faster-whisper `small` sur CPU (9950X3D) — ~0.3s de latence +- **Détection** : mot-clé "hermes" dans la transcription Whisper (pas de wake word model) +- **LLM** : modèle `hermes-default` via LiteLLM sur storage-01 +- **TTS** : piper avec voix `fr_FR-upmc-medium` + +## Installation + +### Dépendances Python + +```bash +cd tools/hermes-voice/ +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### Piper TTS + +```bash +# Télécharger la release piper depuis https://github.com/rhasspy/piper/releases +# ex : piper_linux_x86_64.tar.gz + +mkdir -p ~/.local/share/piper-runtime ~/.local/share/piper +tar xf piper_linux_x86_64.tar.gz -C /tmp +cp -r /tmp/piper/* ~/.local/share/piper-runtime/ + +# Voix française +wget -O ~/.local/share/piper/fr_FR-upmc-medium.onnx \ + "https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx" +wget -O ~/.local/share/piper/fr_FR-upmc-medium.onnx.json \ + "https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx.json" +``` + +### Test piper + +```bash +echo "Bonjour, je suis Hermes." | \ + LD_LIBRARY_PATH=~/.local/share/piper-runtime \ + ~/.local/share/piper-runtime/piper \ + --model ~/.local/share/piper/fr_FR-upmc-medium.onnx \ + --output_file /tmp/test.wav && aplay /tmp/test.wav +``` + +Si `aplay` manque : `sudo dnf install alsa-utils` + +### Réseau + +Le port 4000 (LiteLLM) doit être accessible depuis le LAN domestique (`192.168.1.0/24`). +Déjà ouvert dans `ansible/roles/gateway/templates/nftables.conf.j2` depuis le 2026-06-02. + +Vérification : +```bash +curl -s http://192.168.1.200:4000/v1/models -H "Authorization: Bearer lm-studio" +``` + +## Utilisation + +```bash +cd tools/hermes-voice/ +source .venv/bin/activate + +# Mode VAD — parler naturellement, commencer par "Hermes, ..." +python hermes-voice.py + +# Mode push-to-talk — Entrée pour démarrer/terminer +python hermes-voice.py --ptt + +# Sans TTS (réponses texte uniquement — pour déboguer) +python hermes-voice.py --ptt --no-tts +``` + +## Mode VAD + +Le client écoute en continu. Quand de l'énergie vocale est détectée : +1. Enregistrement jusqu'à 1.5s de silence +2. Whisper transcrit +3. Si "hermes" apparaît dans les 5 premiers mots → la commande est envoyée à LiteLLM +4. Sinon → ignoré (conversations ambiantes non captées) + +Exemple : *"Hermes, quel est l'état du cluster ?"* + +## Configuration (`hermes-voice.py`) + +| Variable | Défaut | Description | +|---|---|---| +| `LITELLM_URL` | `http://192.168.1.200:4000/...` | Endpoint LiteLLM | +| `HERMES_MODEL` | `hermes-default` | Modèle LiteLLM | +| `WHISPER_SIZE` | `small` | Taille modèle Whisper | +| `VOICE_THRESH` | `0.012` | Seuil RMS détection voix | +| `SILENCE_SEC` | `1.5` | Délai silence pour terminer | +| `KEYWORD` | `hermes` | Mot-clé déclencheur | +| `KEYWORD_POS` | `5` | Position max du mot-clé | +| `PIPER_VOICE` | `~/.local/share/piper/fr_FR-upmc-medium.onnx` | Voix TTS | + +## Dépannage + +**Port 4000 inaccessible** : ouvrir un tunnel SSH temporaire +```bash +ssh -L 4000:localhost:4000 user@192.168.1.200 +# puis changer LITELLM_URL → http://localhost:4000/... +``` + +**libpiper_phonemize.so introuvable** : tous les `.so` de piper doivent être dans +`~/.local/share/piper-runtime/` (le script gère `LD_LIBRARY_PATH` automatiquement). + +**Whisper ne comprend pas** : parler plus lentement, augmenter `WHISPER_SIZE` → `medium`. +Ou baisser `VOICE_THRESH` si le micro est peu sensible. + +**Faux positifs VAD** (bruit ambiant déclenche) : augmenter `VOICE_THRESH` (ex: `0.02`). diff --git a/ansible/roles/gateway/templates/nftables.conf.j2 b/ansible/roles/gateway/templates/nftables.conf.j2 index 1862309..93f04af 100644 --- a/ansible/roles/gateway/templates/nftables.conf.j2 +++ b/ansible/roles/gateway/templates/nftables.conf.j2 @@ -44,8 +44,8 @@ table inet filter { # Hermes dashboard (poste admin uniquement) tcp dport 9119 ip saddr {{ hermes_dashboard_allowed_ip }} accept - # LiteLLM proxy (pods k8s 10.42/16 + nœuds cluster) - tcp dport 4000 ip saddr { 192.168.10.0/24, 10.42.0.0/16 } accept + # LiteLLM proxy (pods k8s 10.42/16 + nœuds cluster + LAN domestique pour client vocal) + tcp dport 4000 ip saddr { 192.168.1.0/24, 192.168.10.0/24, 10.42.0.0/16 } accept # Postfix relay SMTP (pods k8s 10.42/16 + nœuds cluster) tcp dport 25 ip saddr { 192.168.10.0/24, 10.42.0.0/16 } accept diff --git a/tools/hermes-voice/hermes-voice.py b/tools/hermes-voice/hermes-voice.py new file mode 100644 index 0000000..5237c7f --- /dev/null +++ b/tools/hermes-voice/hermes-voice.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Hermes Voice Client +VAD → Whisper STT → keyword "hermes" → LiteLLM → piper TTS + +Usage: + python hermes-voice.py # parlez naturellement, commencez par "Hermes, ..." + python hermes-voice.py --ptt # Entrée pour commencer/terminer (plus simple) + python hermes-voice.py --no-tts # réponses texte seulement (pour tester) +""" + +import argparse +import queue +import subprocess +import tempfile +from collections import deque +from pathlib import Path + +import numpy as np +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 = ( + "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) + +WHISPER_SIZE = "small" # base=rapide, small=équilibré, medium=précis +KEYWORD = "hermes" +KEYWORD_POS = 5 # le mot-clé doit être dans les N premiers mots + +# 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" + +# ────────────────────────────────────────────────────────────────────────────── + + +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 rms(chunk: np.ndarray) -> float: + return float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2)) / 32768.0) + + +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, "" + try: + idx = next(i for i, w in enumerate(stripped) if KEYWORD in w) + command = " ".join(words[idx + 1:]).strip() + except StopIteration: + command = text + return True, command or text + + +def transcribe(whisper_model, audio: np.ndarray) -> str: + audio_f32 = audio.astype(np.float32) / 32768.0 + segments, _ = whisper_model.transcribe( + audio_f32, language="fr", beam_size=3, vad_filter=True + ) + return " ".join(seg.text for seg in segments).strip() + + +def ask_hermes(text: str) -> str: + resp = requests.post( + LITELLM_URL, + json={ + "model": HERMES_MODEL, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": text}, + ], + "max_tokens": 300, + "temperature": 0.7, + }, + headers={"Authorization": f"Bearer {LITELLM_KEY}"}, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"].strip() + + +def speak(text: str): + 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}") + 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, + ) + subprocess.run(["aplay", "-q", str(out)], check=False) + except FileNotFoundError: + print(" [TTS] piper non trouvé — voir https://github.com/rhasspy/piper/releases") + finally: + out.unlink(missing_ok=True) + + +def handle(whisper_model, audio: np.ndarray, no_tts: bool, require_keyword: bool = True): + if len(audio) / SAMPLE_RATE < MIN_SPEECH: + return + + print(" ⏳ Transcription...", end=" ", flush=True) + text = transcribe(whisper_model, audio) + if not text: + print("(rien compris)") + return + + if require_keyword: + found, command = detect_keyword(text) + if not found: + print(f"(ignoré : \"{text[:60]}\")") + return + query = command + else: + query = text + + print(f"\n 🗣️ Vous : {text}") + print(" ⏳ Hermes...", end=" ", flush=True) + try: + response = ask_hermes(query) + except requests.RequestException as e: + print(f"\n ❌ Réseau : {e}") + return + print(f"\n 🤖 Hermes : {response}\n") + + if not no_tts: + speak(response) + + +def run_vad(whisper_model, no_tts: bool): + """Écoute continue par VAD — commencer par 'Hermes, ...' pour déclencher.""" + print(f"🎙️ En écoute — commencez par \"{KEYWORD.capitalize()}, ...\" (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=BLOCK_SIZE, callback=callback): + while True: + chunk = audio_q.get() + pre_roll.append(chunk) + + if rms(chunk) < VOICE_THRESH: + continue + + # Voix détectée — enregistrer jusqu'au silence + print(" 🔴 ", end="", flush=True) + captured = list(pre_roll) + silent_blocks = 0 + required_silent = int(SILENCE_SEC * SAMPLE_RATE / BLOCK_SIZE) + + 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 + + print() + beep(440) + handle(whisper_model, np.concatenate(captured), no_tts, require_keyword=True) + + +def run_ptt(whisper_model, no_tts: bool): + """Push-to-talk : Entrée pour commencer, Entrée pour terminer.""" + 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=BLOCK_SIZE, callback=callback): + input() + chunks = [] + while not audio_q.empty(): + chunks.append(audio_q.get_nowait()) + + if not chunks: + continue + beep(440) + handle(whisper_model, np.concatenate(chunks), no_tts, require_keyword=False) + + +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() diff --git a/tools/hermes-voice/requirements.txt b/tools/hermes-voice/requirements.txt new file mode 100644 index 0000000..61ed827 --- /dev/null +++ b/tools/hermes-voice/requirements.txt @@ -0,0 +1,4 @@ +faster-whisper>=1.0.0 +sounddevice>=0.4.6 +numpy>=1.24.0 +requests>=2.31.0