Files
Alfredv2/alfred/app.backup.weather_outfits/backup_audio_20261016_133326/audio.py
T
2026-10-26 00:06:24 +01:00

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 []