Funk-lab/tools/hermes-voice/hermes-voice.py
alkatrazz 8236448859 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>
2026-06-02 19:23:26 +02:00

335 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Hermes Voice Client
Deux modes : VEILLE (wake word "Ok Hermes") → CONVERSATION (parole libre 60s)
Usage:
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)
"""
import argparse
import queue
import subprocess
import tempfile
import time
import unicodedata
from collections import deque
from pathlib import Path
import numpy as np
import requests
import sounddevice as sd
# ── Configuration ──────────────────────────────────────────────────────────────
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
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"
KEYWORD = "hermes"
CHAT_TIMEOUT = 60 # secondes avant retour en veille (sans interaction)
CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"}
PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper"
PIPER_VOICE = Path.home() / ".local/share/piper/fr_FR-upmc-medium.onnx"
MAX_HISTORY = 10
HERMES_MODE = "ssh"
HERMES_SSH = "s01"
VOICE_INJECT = "[Réponse vocale : courte, 1-2 phrases max, sans markdown ni listes] "
# ──────────────────────────────────────────────────────────────────────────────
_history: list[dict] = []
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 _deaccent(s: str) -> str:
return unicodedata.normalize("NFD", s).encode("ascii", "ignore").decode("ascii").lower()
def detect_keyword(text: str) -> tuple[bool, str]:
words = text.strip().split()
low = [_deaccent(w).strip(",.!?«»") for w in words]
try:
idx = next(i for i, w in enumerate(low) if KEYWORD in w)
return True, " ".join(words[idx + 1:]).strip()
except StopIteration:
return False, ""
def transcribe(whisper_model, audio: np.ndarray) -> str:
audio_f32 = audio.astype(np.float32) / 32768.0
segments, _ = whisper_model.transcribe(
audio_f32,
language="fr",
task="transcribe",
beam_size=3,
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:
query = VOICE_INJECT + 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:
global _history
lower = _deaccent(text)
if any(w in lower for w in ("reset", "nouveau contexte", "oublie tout", "efface")):
_history.clear()
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": 200,
"temperature": 0.7,
},
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
timeout=30,
)
resp.raise_for_status()
response = resp.json()["choices"][0]["message"]["content"].strip()
_history.append({"role": "assistant", "content": response})
return response
def speak(text: str):
voice = Path(PIPER_VOICE).expanduser()
bin_path = Path(PIPER_BIN).expanduser()
if not voice.exists() or not bin_path.exists():
return
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
out = Path(f.name)
try:
subprocess.run(
[str(bin_path), "--model", str(voice), "--output_file", str(out)],
input=text.encode(),
capture_output=True,
check=True,
env={"LD_LIBRARY_PATH": str(bin_path.parent)},
)
subprocess.run(["aplay", "-q", str(out)], check=False)
except FileNotFoundError:
pass
finally:
out.unlink(missing_ok=True)
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{e}")
return
print(f"\n 🤖 Hermes : {response}\n")
if not no_tts:
speak(response)
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)
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=FRAME_SAMPLES, callback=callback):
while True:
# 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")
frame = audio_q.get()
pre_roll.append(frame)
try:
is_speech = vad.is_speech(frame.tobytes(), SAMPLE_RATE)
except Exception:
continue
if not is_speech:
continue
# Parole détectée — vider le pre-roll dans captured et continuer
captured = list(pre_roll)
# 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])
text = transcribe(whisper_model, captured_audio)
if not text:
continue
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_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()
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=512, callback=callback):
input()
chunks = []
while not audio_q.empty():
chunks.append(audio_q.get_nowait())
if not chunks:
continue
beep(440)
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():
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()