feat(voice): ajouter client vocal Hermes (hermes-voice)

- 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 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-02 15:12:44 +02:00
parent eca28a8dc1
commit 6fc5b5984e
4 changed files with 387 additions and 2 deletions

119
admin/ia/hermes-voice.md Normal file
View file

@ -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`).

View file

@ -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

View file

@ -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()

View file

@ -0,0 +1,4 @@
faster-whisper>=1.0.0
sounddevice>=0.4.6
numpy>=1.24.0
requests>=2.31.0