feat(voice): améliorer hermes-voice + fix hermes-agent

hermes-voice :
- mode SSH : appel `hermes -z` via storage-01 (soul + skills + RAG + mémoire)
- mode litellm : fallback avec historique de session (10 échanges)
- Whisper large-v3 : meilleure reconnaissance FR (distil-large-v3 dérivait vers l'anglais)
- forcer français dans les requêtes SSH
- historique conversationnel + reset vocal ("oublie tout")

hermes-agent :
- fix boucle de redémarrage (510 restarts) : ajouter --replace à ExecStart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-02 16:24:21 +02:00
parent 6fc5b5984e
commit a3ba3089e2
3 changed files with 119 additions and 45 deletions

View file

@ -1,36 +1,44 @@
# Hermes Voice Client
Client vocal local pour interagir avec Hermes via la voix depuis le poste perso.
Client vocal local pour interagir avec Hermes depuis le poste perso.
## Architecture
```
Micro → VAD (énergie) → faster-whisper (STT) → LiteLLM :4000 → piper (TTS) → Haut-parleur
Micro → VAD (énergie) → Whisper large-v3 (STT) → hermes -z via SSH → 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`
- **STT** : Whisper `large-v3` sur CPU (9950X3D) — ~2-3s, excellent en français
- **Détection** : mot-clé "hermes" détecté dans la transcription (pas de wake word model)
- **Backend** : `hermes --profile funk-ai -z` sur storage-01 via SSH — soul + skills + RAG + mémoire long-terme
- **TTS** : piper `fr_FR-upmc-medium`
## Installation
### Dépendances Python
### Dépendances système
```bash
sudo dnf install alsa-utils # AlmaLinux / Fedora
# ou : sudo apt install alsa-utils
```
### Environnement Python
```bash
cd tools/hermes-voice/
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Whisper large-v3 (~3GB) téléchargé automatiquement au premier lancement
```
### 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
# Binaire piper — télécharger depuis https://github.com/rhasspy/piper/releases
# ex : piper_linux_x86_64.tar.gz
tar xf piper_linux_x86_64.tar.gz -C /tmp
cp -r /tmp/piper/* ~/.local/share/piper-runtime/
@ -41,8 +49,7 @@ 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
Test piper :
```bash
echo "Bonjour, je suis Hermes." | \
LD_LIBRARY_PATH=~/.local/share/piper-runtime \
@ -51,18 +58,17 @@ echo "Bonjour, je suis Hermes." | \
--output_file /tmp/test.wav && aplay /tmp/test.wav
```
Si `aplay` manque : `sudo dnf install alsa-utils`
### Accès réseau
### 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.
Port 4000 (LiteLLM) ouvert depuis `192.168.1.0/24` dans nftables depuis le 2026-06-02.
Vérification :
```bash
curl -s http://192.168.1.200:4000/v1/models -H "Authorization: Bearer lm-studio"
```
SSH vers storage-01 doit fonctionner sans mot de passe (clé dans `~/.ssh/config`, alias `s01`).
## Utilisation
```bash
@ -72,48 +78,68 @@ 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
# Mode push-to-talk — Entrée pour démarrer/terminer (plus simple)
python hermes-voice.py --ptt
# Sans TTS (réponses texte uniquement — pour déboguer)
# Sans TTS — réponses texte uniquement (debug réseau/Hermes)
python hermes-voice.py --ptt --no-tts
```
## Mode VAD
## Modes de fonctionnement
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)
### Mode VAD (défaut)
Écoute continue. Quand de l'énergie vocale est détectée :
1. Enregistre jusqu'à 1.5s de silence
2. Whisper transcrit en français
3. Si "hermes" apparaît dans les 5 premiers mots → envoyé à Hermes
4. Sinon → ignoré
Exemple : *"Hermes, quel est l'état du cluster ?"*
### Mode SSH (HERMES_MODE = "ssh", défaut)
La requête passe par `hermes --profile funk-ai -z` sur storage-01. Hermes a accès à :
- Son **soul** (`funk-ai`) — connaissance complète du homelab
- Ses **skills** — agent-delegation, RAG docs
- Sa **mémoire long-terme** (`/srv/data/hermes/profiles/funk-ai/memories/`)
### Mode LiteLLM (HERMES_MODE = "litellm")
Appel direct LiteLLM :4000, historique de session conservé en RAM (10 échanges). Pas de soul ni de mémoire persistante — utile si SSH trop lent ou Hermes indisponible.
## 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é |
| `HERMES_MODE` | `ssh` | `ssh` = vrai Hermes / `litellm` = LLM direct |
| `HERMES_SSH` | `s01` | Alias SSH storage-01 |
| `WHISPER_SIZE` | `large-v3` | Modèle Whisper (large-v3 recommandé pour le FR) |
| `VOICE_THRESH` | `0.012` | Seuil RMS détection voix (augmenter si bruit ambiant) |
| `SILENCE_SEC` | `1.5` | Délai silence pour terminer l'enregistrement |
| `KEYWORD` | `hermes` | Mot-clé déclencheur (mode VAD) |
| `PIPER_VOICE` | `~/.local/share/piper/fr_FR-upmc-medium.onnx` | Voix TTS |
## Dépannage
**Port 4000 inaccessible** : ouvrir un tunnel SSH temporaire
**Whisper transcrit en anglais** : utiliser `large-v3` (pas `distil-large-v3` qui dérive vers l'anglais).
**Modèle bloqué au chargement** : il télécharge depuis HuggingFace. Surveiller :
```bash
ssh -L 4000:localhost:4000 user@192.168.1.200
watch -n2 'du -sh ~/.cache/huggingface/hub/'
```
`large-v3` = ~3GB, `small` = ~250MB.
**Port 4000 inaccessible** : ouvrir un tunnel SSH temporaire :
```bash
ssh -L 4000:localhost:4000 s01
# puis changer LITELLM_URL → http://localhost:4000/...
```
**libpiper_phonemize.so introuvable** : tous les `.so` de piper doivent être dans
**libpiper_phonemize.so introuvable** : tous les fichiers 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.
**Hermes via SSH lent** : basculer `HERMES_MODE = "litellm"` pour bypasser SSH.
**Faux positifs VAD** (bruit ambiant déclenche) : augmenter `VOICE_THRESH` (ex: `0.02`).
**Faux positifs VAD** : augmenter `VOICE_THRESH` (ex: `0.02`).

View file

@ -14,7 +14,7 @@ Environment=LM_BASE_URL={{ hermes_lm_base_url }}
Environment=LM_API_KEY={{ hermes_lm_api_key }}
Environment=PATH={{ hermes_data_dir }}/hermes-agent/venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart={{ hermes_home }}/.local/bin/hermes gateway run
ExecStart={{ hermes_home }}/.local/bin/hermes gateway run --replace
Restart=on-failure
RestartSec=10s

View file

@ -39,7 +39,7 @@ 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
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
@ -48,8 +48,18 @@ KEYWORD_POS = 5 # le mot-clé doit être dans les N premiers mots
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
# 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
# ──────────────────────────────────────────────────────────────────────────────
_history: list[dict] = [] # historique session (mode litellm uniquement)
def beep(freq: int = 880, duration: float = 0.12):
t = np.linspace(0, duration, int(SAMPLE_RATE * duration), endpoint=False)
@ -80,20 +90,56 @@ def detect_keyword(text: str) -> tuple[bool, str]:
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
audio_f32,
language="fr",
task="transcribe",
beam_size=5,
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:
"""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}"
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:
"""Appel direct LiteLLM avec historique de session."""
global _history
lower = text.lower().strip()
if any(w in lower for w in ("reset", "nouveau contexte", "oublie tout", "efface")):
_history.clear()
return "Contexte effacé, on repart de zéro."
_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},
{"role": "user", "content": text},
],
"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + window,
"max_tokens": 300,
"temperature": 0.7,
},
@ -101,7 +147,9 @@ def ask_hermes(text: str) -> str:
timeout=30,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"].strip()
response = resp.json()["choices"][0]["message"]["content"].strip()
_history.append({"role": "assistant", "content": response})
return response
def speak(text: str):