V1
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user