Files
Alfredv2/alfred/app/services/piper_tts.py
T
2026-11-01 15:01:22 +01:00

208 lines
6.5 KiB
Python

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)