feat(voice): mode VEILLE/CONVERSATION + webrtcvad

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 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-02 19:23:26 +02:00
parent 69ea82c0e7
commit 8236448859
2 changed files with 156 additions and 160 deletions

View file

@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Hermes Voice Client 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: Usage:
python hermes-voice.py # dites "Ok Hermes, ..." — écoute en permanence python hermes-voice.py # écoute en permanence
python hermes-voice.py --ptt # Entrée pour commencer/terminer python hermes-voice.py --ptt # Entrée pour parler
python hermes-voice.py --no-tts # réponses texte seulement (debug) python hermes-voice.py --no-tts # réponses texte seulement (debug)
""" """
@ -13,6 +13,8 @@ import argparse
import queue import queue
import subprocess import subprocess
import tempfile import tempfile
import time
import unicodedata
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
@ -21,44 +23,36 @@ import requests
import sounddevice as sd import sounddevice as sd
# ── Configuration ────────────────────────────────────────────────────────────── # ── Configuration ──────────────────────────────────────────────────────────────
# Si port 4000 inaccessible : ouvrir un tunnel SSH avant de lancer : LITELLM_URL = "http://192.168.1.200:4000/v1/chat/completions"
# ssh -L 4000:localhost:4000 user@192.168.1.200 LITELLM_KEY = "lm-studio"
# puis changer LITELLM_URL → http://localhost:4000/... HERMES_MODEL = "hermes-default"
LITELLM_URL = "http://192.168.1.200:4000/v1/chat/completions" SYSTEM_PROMPT = (
LITELLM_KEY = "lm-studio"
HERMES_MODEL = "hermes-default"
SYSTEM_PROMPT = (
"Tu es Hermes, l'assistant vocal du homelab Funk. " "Tu es Hermes, l'assistant vocal du homelab Funk. "
"Réponds toujours en français, de façon concise (2-3 phrases maximum)." "Réponds toujours en français, de façon concise (2-3 phrases maximum)."
) )
SAMPLE_RATE = 16000 SAMPLE_RATE = 16000
BLOCK_SIZE = 512 # 32ms par bloc SILENCE_SEC = 1.2 # secondes de silence pour terminer l'enregistrement
PRE_ROLL = 10 # blocs à conserver avant la détection (début de phrase) MIN_SPEECH = 0.6 # durée minimale (filtre les bruits courts)
VOICE_THRESH = 0.012 # seuil RMS normalisé pour détecter la parole PRE_ROLL = 15 # frames à conserver avant la détection
SILENCE_SEC = 1.5 # silence pour terminer l'enregistrement
MIN_SPEECH = 0.8 # durée minimale traitée (filtre les bruits courts)
WHISPER_SIZE = "large-v3" # large-v3 : meilleure reconnaissance FR (distil-large-v3 dérive vers l'anglais) WHISPER_SIZE = "large-v3"
KEYWORD = "hermes" KEYWORD = "hermes"
KEYWORD_POS = 5 # le mot-clé doit être dans les N premiers mots 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 PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper"
# Voix FR : wget .../fr_FR-upmc-medium.onnx → ~/.local/share/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) VOICE_INJECT = "[Réponse vocale : courte, 1-2 phrases max, sans markdown ni listes] "
# 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) _history: list[dict] = []
def beep(freq: int = 880, duration: float = 0.12): def beep(freq: int = 880, duration: float = 0.12):
@ -68,23 +62,18 @@ def beep(freq: int = 880, duration: float = 0.12):
sd.wait() sd.wait()
def rms(chunk: np.ndarray) -> float: def _deaccent(s: str) -> str:
return float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2)) / 32768.0) return unicodedata.normalize("NFD", s).encode("ascii", "ignore").decode("ascii").lower()
def detect_keyword(text: str) -> tuple[bool, str]: 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()
words = text.strip().split() low = [_deaccent(w).strip(",.!?«»") for w in words]
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: try:
idx = next(i for i, w in enumerate(stripped) if KEYWORD in w) idx = next(i for i, w in enumerate(low) if KEYWORD in w)
command = " ".join(words[idx + 1:]).strip() return True, " ".join(words[idx + 1:]).strip()
except StopIteration: except StopIteration:
command = text return False, ""
return True, command or text
def transcribe(whisper_model, audio: np.ndarray) -> str: def transcribe(whisper_model, audio: np.ndarray) -> str:
@ -93,7 +82,7 @@ def transcribe(whisper_model, audio: np.ndarray) -> str:
audio_f32, audio_f32,
language="fr", language="fr",
task="transcribe", task="transcribe",
beam_size=5, beam_size=3,
vad_filter=True, vad_filter=True,
initial_prompt="Transcription en français.", initial_prompt="Transcription en français.",
) )
@ -107,12 +96,9 @@ def ask_hermes(text: str) -> str:
def _ask_via_ssh(text: str) -> str: def _ask_via_ssh(text: str) -> str:
"""Route par le vrai Hermes sur storage-01 (soul + skills + RAG + mémoire).""" query = VOICE_INJECT + text
import subprocess
# Forcer le français + échapper les guillemets simples pour bash
query = f"Réponds en français. {text}"
safe = query.replace("'", "'\\''") safe = query.replace("'", "'\\''")
cmd = [ cmd = [
"ssh", HERMES_SSH, "ssh", HERMES_SSH,
f"sudo -i -u hermes bash -c 'HERMES_HOME=/srv/data/hermes hermes --profile funk-ai -z \"{safe}\"'" 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: def _ask_via_litellm(text: str) -> str:
"""Appel direct LiteLLM avec historique de session."""
global _history global _history
lower = _deaccent(text)
lower = text.lower().strip()
if any(w in lower for w in ("reset", "nouveau contexte", "oublie tout", "efface")): if any(w in lower for w in ("reset", "nouveau contexte", "oublie tout", "efface")):
_history.clear() _history.clear()
return "Contexte effacé, on repart de zéro." return "Contexte effacé."
_history.append({"role": "user", "content": text}) _history.append({"role": "user", "content": text})
window = _history[-(MAX_HISTORY * 2):] window = _history[-(MAX_HISTORY * 2):]
resp = requests.post( resp = requests.post(
LITELLM_URL, LITELLM_URL,
json={ json={
"model": HERMES_MODEL, "model": HERMES_MODEL,
"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + window, "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + window,
"max_tokens": 300, "max_tokens": 200,
"temperature": 0.7, "temperature": 0.7,
}, },
headers={"Authorization": f"Bearer {LITELLM_KEY}"}, headers={"Authorization": f"Bearer {LITELLM_KEY}"},
@ -153,134 +135,149 @@ def _ask_via_litellm(text: str) -> str:
def speak(text: str): def speak(text: str):
voice = Path(PIPER_VOICE).expanduser() voice = Path(PIPER_VOICE).expanduser()
bin_path = Path(PIPER_BIN).expanduser() bin_path = Path(PIPER_BIN).expanduser()
if not voice.exists(): if not voice.exists() or not bin_path.exists():
print(f" [TTS] voix introuvable : {voice}")
return
if not bin_path.exists():
print(f" [TTS] piper introuvable : {bin_path}")
return return
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
out = Path(f.name) 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: try:
subprocess.run( subprocess.run(
[str(bin_path), "--model", str(voice), "--output_file", str(out)], [str(bin_path), "--model", str(voice), "--output_file", str(out)],
input=text.encode(), input=text.encode(),
capture_output=True, capture_output=True,
check=True, check=True,
env=env, env={"LD_LIBRARY_PATH": str(bin_path.parent)},
) )
subprocess.run(["aplay", "-q", str(out)], check=False) subprocess.run(["aplay", "-q", str(out)], check=False)
except FileNotFoundError: except FileNotFoundError:
print(" [TTS] piper non trouvé — voir https://github.com/rhasspy/piper/releases") pass
finally: finally:
out.unlink(missing_ok=True) 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): def _respond(query: str, no_tts: bool):
print(" ⏳ Hermes...", end=" ", flush=True) print(" ⏳ Hermes...", end=" ", flush=True)
try: try:
response = ask_hermes(query) response = ask_hermes(query)
except (requests.RequestException, RuntimeError) as e: except (requests.RequestException, RuntimeError) as e:
print(f"\n Erreur : {e}") print(f"\n{e}")
return return
print(f"\n 🤖 Hermes : {response}\n") print(f"\n 🤖 Hermes : {response}\n")
if not no_tts: if not no_tts:
speak(response) speak(response)
def run_vad(whisper_fast, whisper_full, no_tts: bool): def record_until_silence(audio_q: queue.Queue, vad, frame_samples: int, frame_ms: int) -> np.ndarray | None:
"""Écoute continue — dites 'Ok Hermes, ...' pour déclencher.""" """Enregistre depuis la queue jusqu'à SILENCE_SEC de silence webrtcvad."""
print(f"🎙️ En écoute — dites \"Ok {KEYWORD.capitalize()}, ...\" (Ctrl+C pour quitter)\n") 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() 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): def callback(indata, frames, time_info, status):
chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy() chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy()
audio_q.put(chunk) audio_q.put(chunk)
with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16", with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16",
blocksize=BLOCK_SIZE, callback=callback): blocksize=FRAME_SAMPLES, callback=callback):
while True: while True:
chunk = audio_q.get() # Timeout conversation
pre_roll.append(chunk) 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 continue
# Voix détectée — enregistrer jusqu'au silence if not is_speech:
print(" 🔴 ", end="", flush=True) continue
# Parole détectée — vider le pre-roll dans captured et continuer
captured = list(pre_roll) captured = list(pre_roll)
silent_blocks = 0 # Drainer la queue le temps de silence
required_silent = int(SILENCE_SEC * SAMPLE_RATE / BLOCK_SIZE) 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: text = transcribe(whisper_model, captured_audio)
try: if not text:
chunk = audio_q.get(timeout=3.0) continue
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() if chat_mode:
beep(440) # En conversation — mots de fin ?
handle_vad(whisper_fast, whisper_full, np.concatenate(captured), no_tts) 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): 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") print("🎙️ Mode push-to-talk — Entrée pour parler, Entrée pour terminer (Ctrl+C pour quitter)\n")
while True: while True:
input("[ ↵ Entrée pour parler ]") input("[ ↵ Entrée pour parler ]")
audio_q: queue.Queue = queue.Queue() audio_q: queue.Queue = queue.Queue()
@ -291,7 +288,7 @@ def run_ptt(whisper_full, no_tts: bool):
print(" 🔴 Enregistrement... (Entrée pour terminer)") print(" 🔴 Enregistrement... (Entrée pour terminer)")
with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16", with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16",
blocksize=BLOCK_SIZE, callback=callback): blocksize=512, callback=callback):
input() input()
chunks = [] chunks = []
while not audio_q.empty(): while not audio_q.empty():
@ -300,7 +297,16 @@ def run_ptt(whisper_full, no_tts: bool):
if not chunks: if not chunks:
continue continue
beep(440) 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(): def main():
@ -311,29 +317,18 @@ def main():
print("Hermes Voice Client") print("Hermes Voice Client")
print("=" * 40) print("=" * 40)
print(f" ⏳ Chargement Whisper ({WHISPER_SIZE})...", end=" ", flush=True)
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
whisper = WhisperModel(WHISPER_SIZE, device="cpu", compute_type="int8")
print("\n")
if args.ptt: try:
print(f" ⏳ Chargement Whisper ({WHISPER_SIZE})...", end=" ", flush=True) if args.ptt:
whisper_full = WhisperModel(WHISPER_SIZE, device="cpu", compute_type="int8") run_ptt(whisper, args.no_tts)
print("\n") else:
try: run_vad(whisper, args.no_tts)
run_ptt(whisper_full, args.no_tts) except KeyboardInterrupt:
except KeyboardInterrupt: print("\n👋 Au revoir")
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")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -2,3 +2,4 @@ faster-whisper>=1.0.0
sounddevice>=0.4.6 sounddevice>=0.4.6
numpy>=1.24.0 numpy>=1.24.0
requests>=2.31.0 requests>=2.31.0
webrtcvad-wheels>=2.0.10