Funk-lab/stt/client/stt/voice/engine.py
ALI YESILKAYA ae4d5bd20e
fix(stt-client): restaure les intents vocaux (#37) + l'affichage version perdus de main (#40)
Deux features mergées n'avaient pas atteint main (effet de bord des PR empilées :
#37 a été mergé dans sa branche de base supprimée, jamais propagé). Recollées proprement
sur main à jour, en fusionnant engine._respond / app.py avec le travail contextes (#39).

Intents vocaux (lecture seule, court-circuitent le LLM) :
- portal/intents.py + ghostfolio.py (récupérés) + exports __init__.
- engine.py : intent_router AVANT le LLM ; cohabite avec context_provider
  (pas de contexte émis sur un intent local).
- app.py : construit le routeur (services, ouverture, santé, Ghostfolio).
- config : section [ghostfolio] + doc example.toml.

Affichage version :
- app.py : meta_msg poussé à la connexion.
- hud : case 'meta' + setVersion + ligne « Version installée » (section Service).

- bump 0.11.0 → 0.12.0.

Validé : compile, routage intents, coexistence intent/visualiseur dans _respond
(intent → mode portail sans contexte ; LLM → contexte émis), HUD (meta/version).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:05:03 +02:00

308 lines
13 KiB
Python

"""Moteur vocal STT.
Refactor de tools/hermes-voice.py en classe avec callbacks d'état (`emit`), pour que
le serveur websocket puisse pousser l'état au HUD. Boucle :
veille → wake word "Ok Hermès" → conversation (parole libre) → retour veille.
Dépendances lourdes (sounddevice, faster_whisper, webrtcvad) importées à la volée :
`stt --help` et la config fonctionnent sans micro ni modèle installé.
"""
from __future__ import annotations
import queue
import subprocess
import tempfile
import threading
import time
import unicodedata
from collections import deque
from pathlib import Path
from typing import Any, Callable
from .asr import make_backend
SAMPLE_RATE = 16000
PRE_ROLL = 15
CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"}
PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper"
PIPER_VOICE_DIR = Path.home() / ".local/share/piper"
def _deaccent(s: str) -> str:
return (
unicodedata.normalize("NFD", s)
.encode("ascii", "ignore")
.decode("ascii")
.lower()
)
class VoiceEngine:
"""Écoute le micro, transcrit, interroge le STT-server, synthétise la réponse.
`responder(text) -> str` envoie le texte au serveur et renvoie la réponse.
`emit(event: dict)` est appelé à chaque changement d'état / message, pour le HUD.
Événements : {"type": "state", "state": idle|listening|thinking|speaking},
{"type": "user", "text": …}, {"type": "assistant", "text": …},
{"type": "error", "text": …}.
"""
def __init__(
self,
cfg: dict[str, Any],
responder: Callable[[str], str],
emit: Callable[[dict], None],
memory=None,
no_tts: bool = False,
model_label: Callable[[], str] | None = None,
):
self.cfg = cfg
self.responder = responder
self.emit = emit
self.memory = memory
self.no_tts = no_tts
# callable renvoyant l'alias modèle courant → affiché en tag dans le HUD
self.model_label = model_label
# callable renvoyant le dernier contexte assemblé (serveur) → visualiseur HUD.
# Renseigné après coup par le serveur (app.py) ; None tant qu'absent.
self.context_provider: Callable[[], dict | None] | None = None
# routeur d'intentions locales (portail) : (text) -> réponse | None.
# Renseigné après coup par le serveur (app.py). Si une intention matche, on
# répond SANS appeler le LLM.
self.intent_router: Callable[[str], str | None] | None = None
# backend ASR enfichable (whisper par défaut, onnx/parakeet en option)
self._asr = make_backend(cfg)
self._stop = False
# sérialise un tour (voix ou texte) : pas deux réponses concurrentes
self._respond_lock = threading.Lock()
# micro coupé : la boucle audio ignore les trames (le HUD pilote via set_muted)
self._muted = False
# interruption d'un tour en cours (bouton « stop » du HUD)
self._interrupt = threading.Event()
# process de lecture TTS courant (aplay) → tuable pour couper la parole
self._tts_proc: subprocess.Popen | None = None
@property
def muted(self) -> bool:
return self._muted
# ── chargement modèle ────────────────────────────────────────────────
def load(self) -> None:
self._asr.load()
# ── STT ──────────────────────────────────────────────────────────────
def _transcribe(self, audio) -> str:
return self._asr.transcribe(audio)
def _detect_wake(self, text: str) -> tuple[bool, str]:
wake = self.cfg.get("wake_word", "hermes")
words = text.strip().split()
low = [_deaccent(w).strip(",.!?«»") for w in words]
try:
idx = next(i for i, w in enumerate(low) if wake in w)
return True, " ".join(words[idx + 1:]).strip()
except StopIteration:
return False, ""
# ── TTS ──────────────────────────────────────────────────────────────
def _speak(self, text: str) -> None:
if self.no_tts or self._interrupt.is_set():
return
voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx"
if not voice.exists() or not PIPER_BIN.exists():
return
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
out = Path(f.name)
try:
subprocess.run(
[str(PIPER_BIN), "--model", str(voice), "--output_file", str(out)],
input=text.encode(),
capture_output=True,
check=True,
env={"LD_LIBRARY_PATH": str(PIPER_BIN.parent)},
)
if self._interrupt.is_set(): # « stop » pressé pendant la synthèse
return
# Popen (pas run) → on garde la main pour tuer la lecture sur « stop »
self._tts_proc = subprocess.Popen(["aplay", "-q", str(out)])
self._tts_proc.wait()
except FileNotFoundError:
pass
finally:
self._tts_proc = None
out.unlink(missing_ok=True)
def stop_response(self) -> None:
"""Coupe le tour en cours : stoppe la lecture TTS et saute la suite.
Appelé depuis le serveur websocket (bouton « stop » du HUD), hors du thread
qui tient `_respond_lock` → ne bloque pas, agit sur le tour actif.
"""
self._interrupt.set()
proc = self._tts_proc
if proc is not None and proc.poll() is None:
proc.terminate()
self.emit({"type": "state", "state": "idle"})
def set_muted(self, muted: bool) -> None:
"""Coupe / réactive le micro (la boucle audio ignore les trames si coupé)."""
self._muted = bool(muted)
self.emit({"type": "mic", "muted": self._muted})
if self._muted:
self.emit({"type": "state", "state": "idle"})
# ── interrogation du serveur ─────────────────────────────────────────
def _respond(self, text: str) -> None:
with self._respond_lock: # un seul tour à la fois (voix ⟂ texte)
self._interrupt.clear() # nouveau tour → on repart d'une ardoise propre
self.emit({"type": "user", "text": text})
if self.memory:
self.memory.log("user", text)
# 1) intention locale (portail) : réponse instantanée, sans LLM
resp = None
mode = None
if self.intent_router is not None:
try:
resp = self.intent_router(text)
except Exception: # noqa: BLE001 — un intent cassé ne bloque pas le tour
resp = None
if resp is not None:
mode = "portail"
else:
# 2) sinon : on interroge le LLM via le STT-server
self.emit({"type": "state", "state": "thinking"})
try:
resp = self.responder(text)
except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas
self.emit({"type": "error", "text": str(e)})
self.emit({"type": "state", "state": "idle"})
return
mode = self.model_label() if self.model_label else None
if self._interrupt.is_set(): # « stop » pressé pendant la génération
self.emit({"type": "state", "state": "idle"})
return
self.emit({"type": "assistant", "text": resp, "mode": mode})
# contexte assemblé (serveur) → visualiseur HUD ; pas sur un intent local
if mode != "portail" and self.context_provider is not None:
try:
ctx = self.context_provider()
except Exception: # noqa: BLE001
ctx = None
if ctx:
self.emit({"type": "context", "context": ctx})
if self.memory:
self.memory.log("assistant", resp)
self.emit({"type": "state", "state": "speaking"})
self._speak(resp)
def respond_text(self, text: str) -> None:
"""Traite une entrée texte du HUD comme un tour de conversation (sans micro).
Appelé hors boucle audio (depuis le serveur websocket, via un executor) : le
moteur n'a pas besoin d'être chargé/démarré pour répondre à du texte.
"""
text = (text or "").strip()
if not text:
return
self._respond(text)
self.emit({"type": "state", "state": "idle"})
# ── boucle principale (VAD + wake word) ──────────────────────────────
def run(self) -> None:
import numpy as np
import sounddevice as sd
import webrtcvad
vad = webrtcvad.Vad(mode=3)
frame_ms = 20
frame_samples = int(SAMPLE_RATE * frame_ms / 1000)
silence_sec = self.cfg.get("silence_sec", 1.2)
min_speech = self.cfg.get("min_speech_sec", 0.6)
chat_timeout = self.cfg.get("chat_timeout_sec", 60)
required_silent = int(silence_sec * 1000 / frame_ms)
audio_q: queue.Queue = queue.Queue()
pre_roll: deque = deque(maxlen=PRE_ROLL)
chat_mode = False
last_activity = 0.0
def callback(indata, frames, time_info, status):
chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy()
audio_q.put(chunk)
def record_until_silence() -> "np.ndarray | None":
captured, silent = [], 0
while not self._stop:
try:
frame = audio_q.get(timeout=3.0)
except queue.Empty:
break
captured.append(frame)
try:
speech = vad.is_speech(frame.tobytes(), SAMPLE_RATE)
except Exception:
speech = True
silent = 0 if speech else silent + 1
if silent >= required_silent:
break
if not captured:
return None
audio = np.concatenate(captured)
return audio if len(audio) / SAMPLE_RATE >= min_speech else None
self.emit({"type": "state", "state": "idle"})
with sd.InputStream(
samplerate=SAMPLE_RATE, channels=1, dtype="int16",
blocksize=frame_samples, callback=callback,
):
while not self._stop:
if chat_mode and time.time() - last_activity > chat_timeout:
chat_mode = False
self.emit({"type": "state", "state": "idle"})
frame = audio_q.get()
if self._muted: # micro coupé : on vide la trame et on ignore
continue
pre_roll.append(frame)
try:
if not vad.is_speech(frame.tobytes(), SAMPLE_RATE):
continue
except Exception:
continue
self.emit({"type": "state", "state": "listening"})
captured = list(pre_roll)
tail = record_until_silence()
if tail is None:
self.emit({"type": "state", "state": "idle" if not chat_mode else "listening"})
continue
audio = np.concatenate([np.concatenate(captured), tail])
text = self._transcribe(audio)
if not text:
continue
if chat_mode:
if any(w in _deaccent(text) for w in CHAT_EXIT):
chat_mode = False
self.emit({"type": "user", "text": text})
self._speak("À bientôt !")
self.emit({"type": "state", "state": "idle"})
continue
last_activity = time.time()
self._respond(text)
self.emit({"type": "state", "state": "listening"})
else:
found, command = self._detect_wake(text)
if not found:
self.emit({"type": "state", "state": "idle"})
continue
chat_mode = True
last_activity = time.time()
if command:
self._respond(command)
self.emit({"type": "state", "state": "listening"})
def stop(self) -> None:
self._stop = True