mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 13:34:43 +02:00
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>
335 lines
11 KiB
Python
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()
|