111 lines
5.3 KiB
Python
111 lines
5.3 KiB
Python
"""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
|