"""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.""" from services.audio_settings import speaker_muted if speaker_muted(): return 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")) # ALFRED_WYOMING_WHISPER_STT_V1: Vosk segments the utterance and # provides UI partials. Wyoming Whisper replaces only its final transcript. stt_engine = os.getenv("STT_ENGINE", "vosk").strip().lower() wyoming_enabled = stt_engine in ("wyoming", "wyoming_whisper", "whisper") wyoming_audio = bytearray() wyoming_max_bytes = max(16000, int(os.getenv("STT_MAX_AUDIO_SECONDS", "30")) * sample_rate * 2) 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]: # ALFRED_WYOMING_WHISPER_STT_V1: retain the exact PCM heard by Vosk. if wyoming_enabled and len(wyoming_audio) < wyoming_max_bytes: wyoming_audio.extend(data[:wyoming_max_bytes - len(wyoming_audio)]) 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: if wyoming_enabled and wyoming_audio: # ALFRED_WYOMING_WHISPER_STT_V1: remote final STT, local fallback. try: from services.wyoming_stt import transcribe_pcm whisper_text = transcribe_pcm( bytes(wyoming_audio), host=os.getenv("WYOMING_WHISPER_HOST", "192.168.1.13"), port=int(os.getenv("WYOMING_WHISPER_PORT", "10300")), language=os.getenv("WYOMING_WHISPER_LANGUAGE", "fr"), sample_rate=sample_rate, connect_timeout=float(os.getenv("WYOMING_WHISPER_CONNECT_TIMEOUT", "3")), response_timeout=float(os.getenv("WYOMING_WHISPER_TIMEOUT", "20")), ) if whisper_text: print(f"[ALFRED STT] Whisper final : {whisper_text!r} (Vosk : {text!r})", flush=True) return whisper_text print("[ALFRED STT] Whisper réponse vide, secours Vosk", flush=True) except Exception as exc: print(f"[ALFRED STT] Whisper indisponible, secours Vosk : {exc}", flush=True) 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(maxsize=32) def callback(indata, frames, time_info, status): try: q.put_nowait(bytes(indata)) except queue.Full: pass 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()): try: chunk = q.get(timeout=0.25) except queue.Empty: continue text = process(chunk) if text: return STTResult(text=text) except Exception as exc: print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True) # except Exception: # pass if stop_event and stop_event.is_set(): return STTResult() 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 []