import asyncio import os import subprocess import tempfile import time import threading # ALFRED_DUPLEX_BARGE_IN_V1 import wave from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncTcpClient from wyoming.tts import Synthesize, SynthesizeVoice PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1") PIPER_PORT = int(os.getenv("PIPER_PORT", "10200")) AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0") async def synthesize(text, wav_path): client = AsyncTcpClient(PIPER_HOST, PIPER_PORT) await client.connect() try: await client.write_event( Synthesize( text=text, voice=SynthesizeVoice( name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium") )).event() ) wav_file = None try: while True: event = await asyncio.wait_for( client.read_event(), timeout=30 ) if event is None: raise RuntimeError("Connexion Piper interrompue") if AudioStart.is_type(event.type): start = AudioStart.from_event(event) wav_file = wave.open(wav_path, "wb") wav_file.setnchannels(start.channels) wav_file.setsampwidth(start.width) wav_file.setframerate(start.rate) elif AudioChunk.is_type(event.type): chunk = AudioChunk.from_event(event) if wav_file is None: wav_file = wave.open(wav_path, "wb") wav_file.setnchannels(chunk.channels) wav_file.setsampwidth(chunk.width) wav_file.setframerate(chunk.rate) wav_file.writeframes(chunk.audio) elif AudioStop.is_type(event.type): break finally: if wav_file is not None: wav_file.close() finally: await client.disconnect() # ALFRED_DUPLEX_BARGE_IN_V1: control only this module's aplay process. _playback_lock = threading.RLock() _playback_cancel = threading.Event() _playback_proc = None def stop_playback(): """Interrupts Piper playback or marks pending synth for cancellation.""" global _playback_proc _playback_cancel.set() with _playback_lock: process = _playback_proc if process is not None and process.poll() is None: try: process.terminate() except OSError: pass # DONA_WAVES_V1 — volume réel du WAV Wyoming Piper (50 ms, hors micro). from array import array as _dona_array import sys as _dona_sys _dona_level_callback = None def dona_set_level_callback(callback): global _dona_level_callback _dona_level_callback = callback def _dona_emit_level(value): if _dona_level_callback is not None: try: _dona_level_callback(max(0.0, min(1.0, float(value)))) except Exception: pass # Une erreur côté interface ne doit jamais interrompre Piper. def _dona_waves_stream_envelope(wav_path, proc): """Échantillonne le PCM pendant aplay, sans enregistrer ni exposer le son.""" try: with wave.open(wav_path, 'rb') as stream: if stream.getsampwidth() != 2: return # Autre format : état « speaking » en secours côté navigateur. rate=stream.getframerate() if rate <= 0: return frames=max(1,int(rate * .05)) started=time.monotonic() index=0 while proc.poll() is None: raw=stream.readframes(frames) if not raw: break samples=_dona_array('h') samples.frombytes(raw) if _dona_sys.byteorder=='big': samples.byteswap() stride=max(1,len(samples)//420) window=samples[::stride] rms=(sum(s*s for s in window)/max(1,len(window)))**.5 desired=started+index*frames/rate remaining=desired-time.monotonic() if remaining>0: time.sleep(min(remaining,.07)) if proc.poll() is not None: break _dona_emit_level(min(1.0,rms/6500)) index+=1 except Exception as exc: print('[DONA WAVES] analyse PCM impossible :',type(exc).__name__,flush=True) finally: _dona_emit_level(0) def speak(text): if not text or not text.strip(): return _playback_cancel.clear() # ALFRED_DUPLEX_BARGE_IN_V1 fd, wav_path = tempfile.mkstemp( prefix="alfred_piper_", suffix=".wav" ) os.close(fd) start = time.monotonic() try: print("[ALFRED PIPER] Synthèse...", flush=True) asyncio.run(synthesize(text, wav_path)) print("[ALFRED PIPER] Lecture...", flush=True) # ALFRED_DUPLEX_BARGE_IN_V1: allow a concurrent microphone detector # to terminate playback, without killing Hermes or the entire service. global _playback_proc if _playback_cancel.is_set(): print("[ALFRED PIPER] Annulé avant lecture", flush=True) return proc = subprocess.Popen(["aplay", "-q", "-D", AUDIO_DEVICE, wav_path]) with _playback_lock: _playback_proc = proc if _playback_cancel.is_set() and proc.poll() is None: proc.terminate() # DONA_WAVES_V1 : animation par l'amplitude du vrai son joué. threading.Thread(target=_dona_waves_stream_envelope, args=(wav_path,proc),daemon=True).start() try: result = proc.wait(timeout=120) except subprocess.TimeoutExpired: proc.kill() proc.wait() raise finally: with _playback_lock: if _playback_proc is proc: _playback_proc = None if _playback_cancel.is_set(): print("[ALFRED PIPER] Lecture interrompue par la voix", flush=True) return if result: raise subprocess.CalledProcessError(result, proc.args) print( f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s", flush=True ) except Exception as e: print(f"[ALFRED PIPER] Erreur : {e}", flush=True) raise finally: if os.path.exists(wav_path): os.remove(wav_path)