mcspeak/src/tts_mcp/audio.py
Ryan Malloy 7cae72b936 Graceful shutdown, blocking speak() with progress, and cancel support
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.
2026-03-02 17:45:48 -07:00

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()}"
)