"""Détection permanente du mot d'activation d'Alfred. Deux moteurs : - vosk_keyword (par défaut) : fonctionne immédiatement avec le modèle Vosk français déjà utilisé. - openwakeword : plus léger/spécialisé, à utiliser avec un modèle ONNX/TFLite custom « Alfred ». Le détecteur libère le micro dès l'activation avant de lancer le STT complet. """ from __future__ import annotations from services.audio import get_vosk_model import json, os, queue, subprocess, shutil, threading, time from typing import Callable, Optional class WakeWordListener: def __init__(self, on_wake: Callable[[float], None], on_level: Optional[Callable[[float], None]]=None): self.on_wake=on_wake; self.on_level=on_level self.stop_event=threading.Event(); self.pause_event=threading.Event(); self.thread=None self.last_error=None; self.last_score=0.0 def start(self): if self.thread and self.thread.is_alive(): return self.stop_event.clear(); self.thread=threading.Thread(target=self._run, daemon=True); self.thread.start() def stop(self): self.stop_event.set() def pause(self): self.pause_event.set() def resume(self): self.pause_event.clear() def status(self): return {"running": bool(self.thread and self.thread.is_alive()), "paused": self.pause_event.is_set(), "engine": os.getenv("WAKE_ENGINE","vosk_keyword"), "word": os.getenv("WAKE_WORD","alfred"), "last_score": self.last_score, "error": self.last_error} def _run(self): engine=os.getenv("WAKE_ENGINE","vosk_keyword").lower() while not self.stop_event.is_set(): if self.pause_event.is_set(): time.sleep(.1); continue try: if engine == "openwakeword": self._openwakeword_session() else: self._vosk_session() except Exception as e: self.last_error=str(e); time.sleep(1) def _level(self, data): if not self.on_level: return try: import numpy as np a=np.frombuffer(data,dtype=np.int16).astype(np.float32) rms=float(np.sqrt(np.mean(a*a))) if a.size else 0.0 self.on_level(min(1.0,rms/6000.0)) except Exception: pass def _audio_chunks(self, blocksize=1280): rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")); q=queue.Queue() try: import sounddevice as sd def cb(indata, frames, ti, status): q.put(bytes(indata)) dev=os.getenv("AUDIO_INPUT_DEVICE") or None with sd.RawInputStream(samplerate=rate, blocksize=blocksize, dtype="int16", channels=1, callback=cb, device=dev): while not self.stop_event.is_set() and not self.pause_event.is_set(): yield q.get() return except Exception: pass if not shutil.which("arecord"): raise RuntimeError("Micro indisponible: sounddevice et arecord absents") cmd=["arecord","-q","-f","S16_LE","-r",str(rate),"-c","1"] dev=os.getenv("ALSA_CAPTURE_DEVICE","").strip() if dev: cmd += ["-D",dev] p=subprocess.Popen(cmd,stdout=subprocess.PIPE) try: while not self.stop_event.is_set() and not self.pause_event.is_set(): data=p.stdout.read(blocksize*2) if data: yield data finally: if p.poll() is None: p.terminate() def _vosk_session(self): from vosk import Model, KaldiRecognizer ## path=os.getenv("VOSK_MODEL_PATH",""); word=os.getenv("WAKE_WORD","alfred").strip().lower() ## path = os.getenv( "WAKE_VOSK_MODEL_PATH", os.getenv("VOSK_MODEL_PATH", "") ); word=os.getenv("WAKE_WORD","alfred").strip().lower() if not os.path.exists(path): raise RuntimeError(f"Modèle Vosk introuvable: {path}") rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")) # Grammaire réduite = détection peu coûteuse et évite de transcrire les conversations. ## rec=KaldiRecognizer(Model(path), rate, json.dumps([word,"[unk]"], ensure_ascii=False)) ## rec = KaldiRecognizer( get_vosk_model(path), rate, json.dumps([word, "[unk]"], ensure_ascii=False) ) for data in self._audio_chunks(4000): self._level(data) if rec.AcceptWaveform(data): txt=(json.loads(rec.Result() or "{}").get("text") or "").lower() else: txt=(json.loads(rec.PartialResult() or "{}").get("partial") or "").lower() if word in txt.split(): self.last_score=1.0; self.pause_event.set(); self.on_wake(1.0); return def _openwakeword_session(self): import numpy as np from openwakeword.model import Model model_path=os.getenv("WAKE_MODEL_PATH","").strip() if not model_path or not os.path.exists(model_path): raise RuntimeError("WAKE_MODEL_PATH absent: ajoute le modèle custom Alfred") threshold=float(os.getenv("WAKE_THRESHOLD","0.5")) model=Model(wakeword_models=[model_path], inference_framework=os.getenv("WAKE_INFERENCE","onnx")) for data in self._audio_chunks(1280): self._level(data); audio=np.frombuffer(data,dtype=np.int16) scores=model.predict(audio) score=max((float(v) for v in scores.values()), default=0.0); self.last_score=score if score >= threshold: self.pause_event.set(); self.on_wake(score); return