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