speak() now blocks until playback finishes, reporting progress via SSE (5% entry tone → 30% synthesis → 35-99% playing → 100% done). Entry tone fires immediately on call to cover synthesis latency. Queue shutdown waits for current speech to finish (configurable timeout, default 30s) before draining pending items — no more mid-sentence cutoffs on container restart. Cancellation via cancel_speech() tool or MCP notifications/cancelled kills pw-play and plays a vinyl scratch tone. Consumer continues to next item after cancel. Progress tracking uses a background ticker task instead of asyncio.wait_for polling — the latter causes stale CancelledError propagation to the consumer under Python 3.13.
117 lines
3.3 KiB
Python
117 lines
3.3 KiB
Python
"""WAV writing and audio playback utilities."""
|
|
|
|
import asyncio
|
|
import itertools
|
|
import time
|
|
import wave
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from .settings import settings
|
|
|
|
_counter = itertools.count(1)
|
|
|
|
|
|
def write_wav(
|
|
samples: np.ndarray,
|
|
sample_rate: int,
|
|
path: Path | None = None,
|
|
prefix: str = "tts-",
|
|
) -> Path:
|
|
"""Write float32 or int16 samples to a WAV file.
|
|
|
|
If path is None, generates a timestamped filename in the output directory.
|
|
Returns the path to the written file.
|
|
"""
|
|
if path is None:
|
|
out_dir = settings.output_dir
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
ts = f"{time.strftime('%Y%m%d-%H%M%S')}-{next(_counter):04d}"
|
|
path = out_dir / f"{prefix}{ts}.wav"
|
|
|
|
# Normalize float samples to 16-bit PCM
|
|
if samples.dtype in (np.float32, np.float64):
|
|
peak = max(abs(samples.max()), abs(samples.min()), 1e-8)
|
|
samples = (samples / peak * 32767).astype(np.int16)
|
|
|
|
with wave.open(str(path), "wb") as wf:
|
|
wf.setnchannels(1)
|
|
wf.setsampwidth(2)
|
|
wf.setframerate(sample_rate)
|
|
wf.writeframes(samples.tobytes())
|
|
|
|
return path
|
|
|
|
|
|
def write_wav_from_pcm(
|
|
pcm_bytes: bytes,
|
|
sample_rate: int,
|
|
sample_width: int,
|
|
channels: int,
|
|
path: Path | None = None,
|
|
prefix: str = "tts-",
|
|
) -> Path:
|
|
"""Write raw PCM bytes to a WAV file."""
|
|
if path is None:
|
|
out_dir = settings.output_dir
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
ts = f"{time.strftime('%Y%m%d-%H%M%S')}-{next(_counter):04d}"
|
|
path = out_dir / f"{prefix}{ts}.wav"
|
|
|
|
with wave.open(str(path), "wb") as wf:
|
|
wf.setnchannels(channels)
|
|
wf.setsampwidth(sample_width)
|
|
wf.setframerate(sample_rate)
|
|
wf.writeframes(pcm_bytes)
|
|
|
|
return path
|
|
|
|
|
|
def wav_duration(path: Path) -> float:
|
|
"""Get duration of a WAV file in seconds."""
|
|
with wave.open(str(path), "rb") as wf:
|
|
return wf.getnframes() / wf.getframerate()
|
|
|
|
|
|
class PlaybackError(RuntimeError):
|
|
"""Raised when audio playback fails or times out."""
|
|
|
|
|
|
async def play_audio(path: Path, expected_seconds: float = 0) -> None:
|
|
"""Play a WAV file through PipeWire (pw-play). Async wrapper.
|
|
|
|
Args:
|
|
path: Path to the WAV file.
|
|
expected_seconds: Expected duration for timeout calculation.
|
|
If 0, reads duration from the WAV file header.
|
|
"""
|
|
if expected_seconds <= 0:
|
|
expected_seconds = wav_duration(path)
|
|
# Generous margin: 2x duration + 10s for PipeWire startup overhead
|
|
timeout = max(expected_seconds * 2, 10) + 10
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"pw-play", str(path),
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.wait()
|
|
raise PlaybackError(
|
|
f"pw-play timed out after {timeout:.0f}s "
|
|
f"(expected {expected_seconds:.1f}s audio)"
|
|
)
|
|
except asyncio.CancelledError:
|
|
# Consumer was cancelled (shutdown or cancel) — don't orphan the subprocess
|
|
proc.kill()
|
|
await proc.wait()
|
|
raise
|
|
if proc.returncode != 0:
|
|
raise PlaybackError(
|
|
f"pw-play failed ({proc.returncode}): {stderr.decode().strip()}"
|
|
)
|