Refactor speech queue: producer-consumer with priority and timeouts

Replace asyncio.Lock with bounded PriorityQueue + dedicated consumer
coroutine. Addresses Hamilton review findings:

- Playback timeout: pw-play subprocess killed if it exceeds 2x
  expected duration + margin (prevents deadlock on PipeWire hang)
- Bounded queue: max 20 items with backpressure rejection
- Priority tiers: urgent messages jump ahead of normal items
- Graceful shutdown: consumer cancelled, pending items drained,
  in-flight subprocess killed on lifespan teardown
- Structured errors: all failure modes return dicts, not raw exceptions
This commit is contained in:
Ryan Malloy 2026-02-21 15:56:01 -07:00
parent 4698d8b0d2
commit 48b518771d
3 changed files with 234 additions and 74 deletions

View File

@ -74,13 +74,43 @@ def wav_duration(path: Path) -> float:
return wf.getnframes() / wf.getframerate() return wf.getnframes() / wf.getframerate()
async def play_audio(path: Path) -> None: class PlaybackError(RuntimeError):
"""Play a WAV file through PipeWire (pw-play). Async wrapper.""" """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( proc = await asyncio.create_subprocess_exec(
"pw-play", str(path), "pw-play", str(path),
stdout=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
_, stderr = await proc.communicate() 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) — don't orphan the subprocess
proc.kill()
await proc.wait()
raise
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"pw-play failed ({proc.returncode}): {stderr.decode().strip()}") raise PlaybackError(
f"pw-play failed ({proc.returncode}): {stderr.decode().strip()}"
)

View File

@ -1,43 +1,70 @@
"""Speech queue — serializes playback so agents don't talk over each other.""" """Speech queue — producer-consumer pattern with priority and cancellation.
Replaces the original asyncio.Lock approach. A dedicated consumer coroutine
pulls work items from a bounded PriorityQueue and plays them one at a time.
Producers enqueue items and await their Future to get the result.
Priority tiers:
0 = urgent (preempts normal items in the queue)
1 = normal (default)
"""
import asyncio import asyncio
from collections import deque import sys
from contextlib import asynccontextmanager import time
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import AsyncIterator from enum import IntEnum
from typing import Callable, Coroutine
from .audio import play_audio from .audio import PlaybackError, play_audio
from .engines.base import TTSResult from .engines.base import TTSResult
@dataclass class Priority(IntEnum):
class _Waiter: URGENT = 0
caller_id: str NORMAL = 1
text_preview: str
MAX_QUEUE_DEPTH = 20
@dataclass(order=True)
class _WorkItem:
"""A prioritized playback request."""
priority: int
sequence: int # tiebreaker for same-priority items (FIFO)
result: TTSResult = field(compare=False)
future: asyncio.Future = field(compare=False)
info_callback: Callable[..., Coroutine] | None = field(
default=None, compare=False
)
caller_id: str = field(default="", compare=False)
enqueued_at: float = field(default_factory=time.time, compare=False)
class SpeechQueue: class SpeechQueue:
"""FIFO queue for audio playback. """Bounded priority queue with a dedicated playback consumer.
Only one audio file plays at a time. Callers that arrive while Call start() to launch the consumer, stop() to cancel it and
someone is speaking wait in line and get progress updates via drain pending items.
their MCP context.
""" """
def __init__(self) -> None: def __init__(self, max_depth: int = MAX_QUEUE_DEPTH) -> None:
self._lock = asyncio.Lock() self._queue: asyncio.PriorityQueue[_WorkItem] = asyncio.PriorityQueue(
self._current: _Waiter | None = None maxsize=max_depth
self._waiters: deque[_Waiter] = deque() )
self._consumer_task: asyncio.Task | None = None
self._current: _WorkItem | None = None
self._current_proc: asyncio.subprocess.Process | None = None
self._counter = 0 self._counter = 0
self._max_depth = max_depth
self._stopped = False
def _next_id(self) -> str: def _next_id(self) -> str:
self._counter += 1 self._counter += 1
return f"speaker-{self._counter}" return f"speaker-{self._counter}"
@property
def depth(self) -> int:
return len(self._waiters)
@property @property
def current_speaker(self) -> str | None: def current_speaker(self) -> str | None:
return self._current.caller_id if self._current else None return self._current.caller_id if self._current else None
@ -45,66 +72,163 @@ class SpeechQueue:
def status(self) -> dict: def status(self) -> dict:
return { return {
"current_speaker": self.current_speaker, "current_speaker": self.current_speaker,
"queue_depth": self.depth, "queue_depth": self._queue.qsize(),
"waiting": [w.caller_id for w in self._waiters], "max_depth": self._max_depth,
} }
@asynccontextmanager def start(self) -> None:
async def acquire( """Launch the consumer coroutine."""
self, if self._consumer_task is None or self._consumer_task.done():
caller_id: str | None = None, self._stopped = False
text_preview: str = "", self._consumer_task = asyncio.create_task(
) -> AsyncIterator[str]: self._consumer(), name="speech-queue-consumer"
"""Context manager that waits for the speaker's turn. )
Yields the caller_id once it's this caller's turn to play audio.
"""
cid = caller_id or self._next_id()
waiter = _Waiter(caller_id=cid, text_preview=text_preview[:60])
self._waiters.append(waiter)
async def stop(self) -> None:
"""Cancel the consumer and reject all pending items."""
self._stopped = True
if self._consumer_task and not self._consumer_task.done():
self._consumer_task.cancel()
try: try:
async with self._lock: await self._consumer_task
# We're up — remove ourselves from the waiting list except asyncio.CancelledError:
if waiter in self._waiters: pass
self._waiters.remove(waiter)
self._current = waiter # Drain remaining items
yield cid while not self._queue.empty():
try:
item = self._queue.get_nowait()
if not item.future.done():
item.future.set_result({
"played": False,
"error": "Queue shut down",
"engine": item.result.engine,
"voice": item.result.voice,
})
except asyncio.QueueEmpty:
break
# Kill in-flight playback
if self._current_proc and self._current_proc.returncode is None:
self._current_proc.kill()
print("Killed in-flight pw-play on shutdown", file=sys.stderr)
async def _consumer(self) -> None:
"""Pull work items and play them sequentially."""
while not self._stopped:
try:
item = await self._queue.get()
except asyncio.CancelledError:
break
if item.future.cancelled():
self._queue.task_done()
continue
self._current = item
try:
if item.info_callback:
try:
await item.info_callback(
f"Now playing: {item.result.engine}/{item.result.voice}"
)
except Exception:
pass # Context may have expired — non-fatal
await play_audio(
item.result.audio_path,
expected_seconds=item.result.duration_seconds,
)
if not item.future.done():
item.future.set_result({
"played": True,
"file": str(item.result.audio_path),
"duration_seconds": item.result.duration_seconds,
"engine": item.result.engine,
"voice": item.result.voice,
})
except PlaybackError as e:
print(f" Playback error: {e}", file=sys.stderr)
if not item.future.done():
item.future.set_result({
"played": False,
"error": str(e),
"file": str(item.result.audio_path),
"engine": item.result.engine,
"voice": item.result.voice,
})
except asyncio.CancelledError:
if not item.future.done():
item.future.set_result({
"played": False,
"error": "Playback cancelled",
"engine": item.result.engine,
"voice": item.result.voice,
})
break
except Exception as e:
print(f" Unexpected playback error: {e}", file=sys.stderr)
if not item.future.done():
item.future.set_result({
"played": False,
"error": f"Unexpected error: {e}",
"engine": item.result.engine,
"voice": item.result.voice,
})
finally: finally:
if self._current is waiter:
self._current = None self._current = None
self._queue.task_done()
async def speak( async def speak(
self, self,
result: TTSResult, result: TTSResult,
caller_id: str | None = None, caller_id: str | None = None,
info_callback=None, priority: Priority = Priority.NORMAL,
info_callback: Callable[..., Coroutine] | None = None,
) -> dict: ) -> dict:
"""Queue and play a TTSResult. Returns status dict when done. """Enqueue a TTSResult for playback. Returns result dict when done.
info_callback: async callable(message) for progress updates (e.g. ctx.info). Raises QueueFull if the queue is at max capacity (backpressure).
""" """
cid = caller_id or self._next_id() if self._stopped:
preview = f"{result.engine}/{result.voice}"
# Show queue position before acquiring
if self._lock.locked():
pos = self.depth + 1
msg = f"Queued at position {pos}"
if self._current:
msg += f" (currently playing: {self._current.caller_id})"
if info_callback:
await info_callback(msg)
async with self.acquire(cid, preview):
if info_callback:
await info_callback(f"Now playing: {result.engine}/{result.voice}")
await play_audio(result.audio_path)
return { return {
"played": True, "played": False,
"file": str(result.audio_path), "error": "Queue is shut down",
"duration_seconds": result.duration_seconds,
"engine": result.engine, "engine": result.engine,
"voice": result.voice, "voice": result.voice,
} }
cid = caller_id or self._next_id()
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
item = _WorkItem(
priority=priority,
sequence=self._counter,
result=result,
future=future,
info_callback=info_callback,
caller_id=cid,
)
try:
self._queue.put_nowait(item)
except asyncio.QueueFull:
return {
"played": False,
"error": f"Queue full ({self._max_depth} items). Try again later.",
"engine": result.engine,
"voice": result.voice,
}
# Notify caller of queue position
depth = self._queue.qsize()
if depth > 1 and info_callback:
msg = f"Queued at position {depth}"
if self._current:
msg += f" (currently playing: {self._current.caller_id})"
try:
await info_callback(msg)
except Exception:
pass # Context may not be ready yet — non-fatal
return await future

View File

@ -13,7 +13,7 @@ from .engines.base import TTSEngine
from .engines.kokoro import KokoroEngine from .engines.kokoro import KokoroEngine
from .engines.orpheus import OrpheusEngine from .engines.orpheus import OrpheusEngine
from .engines.piper import PiperEngine from .engines.piper import PiperEngine
from .queue import SpeechQueue from .queue import Priority, SpeechQueue
from .settings import settings from .settings import settings
ENGINE_NAMES = Literal["piper", "kokoro", "orpheus"] ENGINE_NAMES = Literal["piper", "kokoro", "orpheus"]
@ -53,6 +53,7 @@ async def app_lifespan(server: FastMCP):
print(f" {name}: {health['status']}", file=sys.stderr) print(f" {name}: {health['status']}", file=sys.stderr)
queue = SpeechQueue() queue = SpeechQueue()
queue.start()
print( print(
f"TTS MCP server ready on {settings.host}:{settings.port} " f"TTS MCP server ready on {settings.host}:{settings.port} "
@ -64,6 +65,7 @@ async def app_lifespan(server: FastMCP):
yield {"engines": engines, "queue": queue} yield {"engines": engines, "queue": queue}
finally: finally:
print("TTS MCP server shutting down", file=sys.stderr) print("TTS MCP server shutting down", file=sys.stderr)
await queue.stop()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -98,17 +100,20 @@ async def speak(
text: str, text: str,
engine: ENGINE_NAMES = "kokoro", engine: ENGINE_NAMES = "kokoro",
voice: str | None = None, voice: str | None = None,
urgent: bool = False,
ctx: Context = CurrentContext(), ctx: Context = CurrentContext(),
) -> dict: ) -> dict:
"""Synthesize text and play it through the host speakers. """Synthesize text and play it through the host speakers.
Audio is queued if another agent is currently speaking, you'll wait Audio is queued if another agent is currently speaking, you'll wait
your turn and get notified when playback starts. your turn and get notified when playback starts. Urgent messages
jump ahead of normal-priority items in the queue.
Args: Args:
text: Text to speak. Orpheus supports emotion tags like <laugh>, <sigh>, etc. text: Text to speak. Orpheus supports emotion tags like <laugh>, <sigh>, etc.
engine: TTS engine to use. kokoro is fastest, orpheus is most expressive. engine: TTS engine to use. kokoro is fastest, orpheus is most expressive.
voice: Voice name (use list_voices to see options). None = engine default. voice: Voice name (use list_voices to see options). None = engine default.
urgent: If True, this message jumps ahead of normal-priority items.
""" """
engines, queue = _get_state(ctx) engines, queue = _get_state(ctx)
@ -121,9 +126,10 @@ async def speak(
await ctx.info(f"Synthesizing with {engine}...") await ctx.info(f"Synthesizing with {engine}...")
result = await eng.synthesize(text, voice) result = await eng.synthesize(text, voice)
# Queue for playback (serialized) # Queue for playback (serialized, priority-ordered)
return await queue.speak( return await queue.speak(
result, result,
priority=Priority.URGENT if urgent else Priority.NORMAL,
info_callback=ctx.info, info_callback=ctx.info,
) )