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
"""
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:
python hermes-voice.py # dites "Ok Hermes, ..." — écoute en permanence
python hermes-voice.py --ptt # Entrée pour commencer/terminer
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)
"""
@ -13,6 +13,8 @@ import argparse
import queue
import subprocess
import tempfile
import time
import unicodedata
from collections import deque
from pathlib import Path
@ -21,44 +23,36 @@ 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 = (
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)
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" # 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
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 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"
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)
# 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
VOICE_INJECT = "[Réponse vocale : courte, 1-2 phrases max, sans markdown ni listes] "
# ──────────────────────────────────────────────────────────────────────────────
_history: list[dict] = [] # historique session (mode litellm uniquement)
_history: list[dict] = []
def beep(freq: int = 880, duration: float = 0.12):
@ -68,23 +62,18 @@ def beep(freq: int = 880, duration: float = 0.12):
sd.wait()
def rms(chunk: np.ndarray) -> float:
return float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2)) / 32768.0)
def _deaccent(s: str) -> str:
return unicodedata.normalize("NFD", s).encode("ascii", "ignore").decode("ascii").lower()
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, ""
words = text.strip().split()
low = [_deaccent(w).strip(",.!?«»") for w in words]
try:
idx = next(i for i, w in enumerate(stripped) if KEYWORD in w)
command = " ".join(words[idx + 1:]).strip()
idx = next(i for i, w in enumerate(low) if KEYWORD in w)
return True, " ".join(words[idx + 1:]).strip()
except StopIteration:
command = text
return True, command or text
return False, ""
def transcribe(whisper_model, audio: np.ndarray) -> str:
@ -93,7 +82,7 @@ def transcribe(whisper_model, audio: np.ndarray) -> str:
audio_f32,
language="fr",
task="transcribe",
beam_size=5,
beam_size=3,
vad_filter=True,
initial_prompt="Transcription en français.",
)
@ -107,12 +96,9 @@ def ask_hermes(text: str) -> str:
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}"
query = VOICE_INJECT + text
safe = query.replace("'", "'\\''")
cmd = [
cmd = [
"ssh", HERMES_SSH,
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:
"""Appel direct LiteLLM avec historique de session."""
global _history
lower = text.lower().strip()
lower = _deaccent(text)
if any(w in lower for w in ("reset", "nouveau contexte", "oublie tout", "efface")):
_history.clear()
return "Contexte effacé, on repart de zéro."
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": 300,
"max_tokens": 200,
"temperature": 0.7,
},
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
@ -153,134 +135,149 @@ def _ask_via_litellm(text: str) -> str:
def speak(text: str):
voice = Path(PIPER_VOICE).expanduser()
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}")
if not voice.exists() or not bin_path.exists():
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,
env={"LD_LIBRARY_PATH": str(bin_path.parent)},
)
subprocess.run(["aplay", "-q", str(out)], check=False)
except FileNotFoundError:
print(" [TTS] piper non trouvé — voir https://github.com/rhasspy/piper/releases")
pass
finally:
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):
print(" ⏳ Hermes...", end=" ", flush=True)
try:
response = ask_hermes(query)
except (requests.RequestException, RuntimeError) as e:
print(f"\n Erreur : {e}")
print(f"\n{e}")
return
print(f"\n 🤖 Hermes : {response}\n")
if not no_tts:
speak(response)
def run_vad(whisper_fast, whisper_full, no_tts: bool):
"""Écoute continue — dites 'Ok Hermes, ...' pour déclencher."""
print(f"🎙️ En écoute — dites \"Ok {KEYWORD.capitalize()}, ...\" (Ctrl+C pour quitter)\n")
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)
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):
blocksize=FRAME_SAMPLES, callback=callback):
while True:
chunk = audio_q.get()
pre_roll.append(chunk)
# 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")
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
# Voix détectée — enregistrer jusqu'au silence
print(" 🔴 ", end="", flush=True)
if not is_speech:
continue
# Parole détectée — vider le pre-roll dans captured et continuer
captured = list(pre_roll)
silent_blocks = 0
required_silent = int(SILENCE_SEC * SAMPLE_RATE / BLOCK_SIZE)
# 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])
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
text = transcribe(whisper_model, captured_audio)
if not text:
continue
print()
beep(440)
handle_vad(whisper_fast, whisper_full, np.concatenate(captured), no_tts)
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_full, no_tts: bool):
"""Push-to-talk : Entrée pour commencer, Entrée pour terminer."""
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()
@ -291,7 +288,7 @@ def run_ptt(whisper_full, no_tts: bool):
print(" 🔴 Enregistrement... (Entrée pour terminer)")
with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16",
blocksize=BLOCK_SIZE, callback=callback):
blocksize=512, callback=callback):
input()
chunks = []
while not audio_q.empty():
@ -300,7 +297,16 @@ def run_ptt(whisper_full, no_tts: bool):
if not chunks:
continue
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():
@ -311,29 +317,18 @@ def main():
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")
if args.ptt:
print(f" ⏳ Chargement Whisper ({WHISPER_SIZE})...", end=" ", flush=True)
whisper_full = WhisperModel(WHISPER_SIZE, device="cpu", compute_type="int8")
print("\n")
try:
run_ptt(whisper_full, args.no_tts)
except KeyboardInterrupt:
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")
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__":

View file

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