380 lines
12 KiB
Python
380 lines
12 KiB
Python
"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre.
|
|
|
|
STT: Vosk + sounddevice, avec fallback arecord/ALSA.
|
|
TTS: Piper local, avec fallback espeak puis spd-say.
|
|
Sortie: haut-parleur local via paplay/aplay.
|
|
|
|
Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu
|
|
à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique.
|
|
"""
|
|
from __future__ import annotations
|
|
from functools import lru_cache
|
|
|
|
import time
|
|
import json
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from typing import Callable, Optional
|
|
|
|
|
|
def _cmd_ok(cmd, **kwargs):
|
|
try:
|
|
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
check=True, text=True, **kwargs)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def audio_backend_detect() -> str:
|
|
configured = os.getenv("AUDIO_BACKEND", "auto").lower()
|
|
if configured in ("pactl", "alsa"):
|
|
return configured
|
|
if shutil.which("pactl"):
|
|
info = _cmd_ok(["pactl", "info"])
|
|
if info and "Server Name" in info.stdout:
|
|
return "pactl"
|
|
if shutil.which("amixer"):
|
|
return "alsa"
|
|
return "none"
|
|
|
|
|
|
def _which(cmd):
|
|
return shutil.which(cmd)
|
|
|
|
|
|
def _is_piper_tts(binary: Optional[str]) -> bool:
|
|
if not binary:
|
|
return False
|
|
try:
|
|
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, text=True, timeout=3)
|
|
out = (r.stdout or "").lower()
|
|
if "gtk" in out or "ratbag" in out:
|
|
return False
|
|
return (("--model" in out or " -m " in out) and
|
|
("--output_file" in out or " -o " in out))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _find_piper_binary() -> Optional[str]:
|
|
candidates = [
|
|
os.getenv("PIPER_BIN"),
|
|
"/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"),
|
|
"/usr/local/bin/piper", "/usr/bin/piper", _which("piper"),
|
|
]
|
|
for candidate in candidates:
|
|
if candidate and _is_piper_tts(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _piper_supports_long_flag(binary: str) -> bool:
|
|
try:
|
|
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, text=True, timeout=3)
|
|
out = (r.stdout or "").lower()
|
|
return "--model" in out and "--output_file" in out
|
|
except Exception:
|
|
return True
|
|
|
|
###
|
|
def tts_backend_detect() -> str:
|
|
"""Retourne le moteur TTS configuré pour Alfred."""
|
|
|
|
if os.getenv("PIPER_HOST"):
|
|
return "wyoming-piper"
|
|
|
|
if _which("espeak-ng"):
|
|
return "espeak-ng"
|
|
|
|
return "none"
|
|
|
|
|
|
'''
|
|
def tts_backend_detect() -> str:
|
|
piper = _find_piper_binary()
|
|
model = os.getenv("PIPER_MODEL", "")
|
|
if piper and model and os.path.exists(model):
|
|
return "piper"
|
|
if _which("espeak"):
|
|
return "espeak"
|
|
if _which("spd-say"):
|
|
return "spd-say"
|
|
return "none"
|
|
'''
|
|
####
|
|
|
|
def speak_text(text: str):
|
|
"""Synthèse vocale via Wyoming Piper, avec secours eSpeak."""
|
|
if not text or not text.strip():
|
|
return
|
|
|
|
text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))]
|
|
|
|
try:
|
|
from services.piper_tts import speak as piper_speak
|
|
|
|
print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True)
|
|
piper_speak(text)
|
|
return
|
|
|
|
except Exception as exc:
|
|
print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True)
|
|
|
|
# Secours eSpeak
|
|
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
|
|
os.close(fd)
|
|
|
|
try:
|
|
print("[ALFRED TTS] Secours eSpeak", flush=True)
|
|
|
|
subprocess.run(
|
|
["espeak-ng", "-v", "fr", "-w", wav_path, text],
|
|
check=True,
|
|
timeout=30
|
|
)
|
|
|
|
subprocess.run(
|
|
["aplay", "-q", "-D", "plughw:1,0", wav_path],
|
|
check=True,
|
|
timeout=120
|
|
)
|
|
|
|
except Exception as exc:
|
|
print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True)
|
|
|
|
finally:
|
|
if os.path.exists(wav_path):
|
|
os.remove(wav_path)
|
|
|
|
'''
|
|
def speak_text(text: str):
|
|
if not text or not text.strip():
|
|
return
|
|
|
|
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
|
|
os.close(fd)
|
|
|
|
try:
|
|
start = time.monotonic()
|
|
print("[ALFRED TTS] Génération audio...", flush=True)
|
|
|
|
subprocess.run(
|
|
["espeak-ng", "-v", "fr", "-w", wav_path, text],
|
|
check=True,
|
|
timeout=30
|
|
)
|
|
|
|
print("[ALFRED TTS] Lecture audio...", flush=True)
|
|
|
|
subprocess.run(
|
|
["aplay", "-q", "-D", "plughw:1,0", wav_path],
|
|
check=True,
|
|
timeout=120
|
|
)
|
|
|
|
print(
|
|
f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s",
|
|
flush=True
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"[ALFRED TTS] Erreur : {e}", flush=True)
|
|
|
|
finally:
|
|
if os.path.exists(wav_path):
|
|
os.remove(wav_path)
|
|
|
|
|
|
|
|
|
|
def speak_text(text: str):
|
|
"""Synthétise et joue localement le texte. Retourne (ok, backend, erreur)."""
|
|
text = (text or "").strip()
|
|
if not text:
|
|
return False, "none", "texte vide"
|
|
text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))]
|
|
|
|
backend = audio_backend_detect()
|
|
piper = _find_piper_binary()
|
|
model = os.getenv("PIPER_MODEL", "")
|
|
|
|
if piper and model and os.path.exists(model):
|
|
wav_path = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
|
wav_path = tmp.name
|
|
cmd = [piper]
|
|
if _piper_supports_long_flag(piper):
|
|
cmd += ["--model", model, "--output_file", wav_path]
|
|
else:
|
|
cmd += ["-m", model, "-o", wav_path]
|
|
p = subprocess.run(cmd, input=text, text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
|
if p.returncode != 0:
|
|
return False, "piper", (p.stderr or p.stdout or "")[:2000]
|
|
if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0:
|
|
return False, "piper", "Piper n'a pas produit de WAV"
|
|
if backend == "pactl" and shutil.which("paplay"):
|
|
play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
elif shutil.which("aplay"):
|
|
play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
else:
|
|
return False, "piper", "paplay/aplay introuvable"
|
|
return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée"
|
|
except Exception as exc:
|
|
return False, "piper", str(exc)
|
|
finally:
|
|
if wav_path:
|
|
try: os.unlink(wav_path)
|
|
except OSError: pass
|
|
|
|
if _which("espeak"):
|
|
args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text]
|
|
if backend == "pactl" and shutil.which("padsp"):
|
|
args = ["padsp"] + args
|
|
ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
|
|
return ok, "espeak", None if ok else "espeak a échoué"
|
|
|
|
if _which("spd-say"):
|
|
ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE).returncode == 0
|
|
return ok, "spd-say", None if ok else "spd-say a échoué"
|
|
return False, "none", "aucun backend TTS disponible"
|
|
'''
|
|
|
|
_tts_lock = threading.Lock()
|
|
def speak_async(text: str, on_start: Optional[Callable] = None,
|
|
on_end: Optional[Callable] = None) -> bool:
|
|
"""Lecture non bloquante; refuse d'empiler deux réponses vocales."""
|
|
if _tts_lock.locked():
|
|
return False
|
|
def worker():
|
|
with _tts_lock:
|
|
if on_start: on_start()
|
|
try: speak_text(text)
|
|
finally:
|
|
if on_end: on_end()
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
return True
|
|
|
|
|
|
@dataclass
|
|
class STTResult:
|
|
text: str = ""
|
|
error: Optional[str] = None
|
|
|
|
|
|
|
|
@lru_cache(maxsize=2)
|
|
def get_vosk_model(model_path):
|
|
from vosk import Model
|
|
|
|
print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True)
|
|
model = Model(model_path)
|
|
print("[ALFRED] Modèle Vosk prêt", flush=True)
|
|
|
|
return model
|
|
|
|
|
|
def listen_once(stop_event: Optional[threading.Event] = None,
|
|
on_partial: Optional[Callable[[str], None]] = None,
|
|
on_level: Optional[Callable[[float], None]] = None) -> STTResult:
|
|
"""Attend une phrase finale Vosk et la retourne.
|
|
|
|
Reprend le comportement du prototype: sounddevice en priorité, arecord sinon.
|
|
Le niveau audio transmis à l'UI est une approximation RMS sur int16.
|
|
"""
|
|
try:
|
|
import numpy as np
|
|
from vosk import Model, KaldiRecognizer
|
|
except Exception as exc:
|
|
return STTResult(error=f"Dépendance STT absente: {exc}")
|
|
|
|
model_path = os.getenv("VOSK_MODEL_PATH", "")
|
|
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
|
|
if not model_path or not os.path.exists(model_path):
|
|
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
|
|
try:
|
|
# rec = KaldiRecognizer(Model(model_path), sample_rate)
|
|
rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate)
|
|
except Exception as exc:
|
|
return STTResult(error=f"Init Vosk: {exc}")
|
|
|
|
def process(data: bytes) -> Optional[str]:
|
|
if on_level and data:
|
|
try:
|
|
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
|
|
rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0
|
|
on_level(min(1.0, rms / 6000.0))
|
|
except Exception:
|
|
pass
|
|
if rec.AcceptWaveform(data):
|
|
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
|
|
if text:
|
|
return text
|
|
elif on_partial:
|
|
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
|
|
print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True)
|
|
on_partial(partial)
|
|
return None
|
|
|
|
try:
|
|
import sounddevice as sd
|
|
q = queue.Queue()
|
|
def callback(indata, frames, time_info, status):
|
|
q.put(bytes(indata))
|
|
device = os.getenv("AUDIO_INPUT_DEVICE") or None
|
|
with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16",
|
|
channels=1, callback=callback, device=device):
|
|
while not (stop_event and stop_event.is_set()):
|
|
text = process(q.get())
|
|
if text:
|
|
return STTResult(text=text)
|
|
except Exception as exc:
|
|
print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True)
|
|
|
|
# except Exception:
|
|
# pass
|
|
|
|
if not shutil.which("arecord"):
|
|
return STTResult(error="sounddevice indisponible et arecord introuvable")
|
|
cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"]
|
|
alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip()
|
|
if alsa_device:
|
|
cmd += ["-D", alsa_device]
|
|
proc = None
|
|
try:
|
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
|
|
while not (stop_event and stop_event.is_set()):
|
|
chunk = proc.stdout.read(8000)
|
|
if not chunk:
|
|
continue
|
|
text = process(chunk)
|
|
if text:
|
|
return STTResult(text=text)
|
|
return STTResult()
|
|
except Exception as exc:
|
|
return STTResult(error=f"arecord: {exc}")
|
|
finally:
|
|
if proc and proc.poll() is None:
|
|
proc.terminate()
|
|
|
|
|
|
def devices():
|
|
try:
|
|
# import sounddevice as sd
|
|
# return sd.query_devices()
|
|
print("[ALFRED STT] Périphériques audio :", flush=True)
|
|
print(sd.query_devices(), flush=True)
|
|
print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True)
|
|
return sd.query_devices()
|
|
except Exception:
|
|
return []
|