208 lines
6.5 KiB
Python
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)
|