mcspeak/src/tts_mcp/queue.py
Ryan Malloy 7959efbd51 Add tool annotations, progress messages, and chunked synthesis pipeline
Chunked synthesis splits long texts into sentences and pipelines
synthesis with playback — first audio plays within ~1-2s instead of
waiting for full synthesis. Queue supports suppress_exit_tone between
chunks for seamless playback.

Tool annotations (ToolAnnotations) signal behavioral hints to MCP
clients: speak() is openWorldHint=True (physical speakers),
cancel_speech() is destructiveHint=True, read-only tools marked
accordingly.

Progress notifications now include message= strings that tell LLM
clients they can continue working during playback. Server instructions
corrected from "returns immediately" to accurately describe blocking
behavior with parallel-safe guidance. speak() docstring updated to
encourage concurrent tool calls.
2026-03-03 18:00:11 -07:00

420 lines
16 KiB
Python

"""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 call enqueue() which returns immediately with a speech_id.
Use get_status() to check playback outcomes asynchronously.
Priority tiers:
0 = urgent (preempts normal items in the queue)
1 = normal (default)
"""
import asyncio
import sys
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path
from .audio import PlaybackError, play_audio
from .engines.base import TTSResult
class Priority(IntEnum):
URGENT = 0
NORMAL = 1
MAX_QUEUE_DEPTH = 20
MAX_OUTCOMES = 100
@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)
speech_id: str = field(default="", compare=False)
enqueued_at: float = field(default_factory=time.time, compare=False)
suppress_exit_tone: bool = field(default=False, compare=False)
class SpeechQueue:
"""Bounded priority queue with a dedicated playback consumer.
Call start() to launch the consumer, stop() to cancel it and
drain pending items.
"""
def __init__(
self,
max_depth: int = MAX_QUEUE_DEPTH,
exit_tone: Path | None = None,
standby_tone: Path | None = None,
cancel_tone: Path | None = None,
shutdown_timeout: float = 30.0,
) -> None:
self._queue: asyncio.PriorityQueue[_WorkItem] = asyncio.PriorityQueue(
maxsize=max_depth
)
self._consumer_task: asyncio.Task | None = None
self._current: _WorkItem | None = None
self._item_cancelled = False
self._counter = 0
self._max_depth = max_depth
self._stopped = False
self._exit_tone = exit_tone
self._standby_tone = standby_tone
self._cancel_tone = cancel_tone
self._shutdown_timeout = shutdown_timeout
self._outcomes: OrderedDict[str, dict] = OrderedDict()
self._futures: dict[str, asyncio.Future] = {}
def _next_speech_id(self) -> str:
self._counter += 1
return f"speech-{self._counter}"
def _record_outcome(self, speech_id: str, result_dict: dict) -> None:
"""Store a completed outcome, evicting the oldest if over limit."""
result_dict["speech_id"] = speech_id
result_dict["completed_at"] = time.time()
self._outcomes[speech_id] = result_dict
self._futures.pop(speech_id, None)
while len(self._outcomes) > MAX_OUTCOMES:
self._outcomes.popitem(last=False)
@property
def current_speaker(self) -> str | None:
return self._current.speech_id if self._current else None
def status(self) -> dict:
return {
"current_speaker": self.current_speaker,
"queue_depth": self._queue.qsize(),
"max_depth": self._max_depth,
}
def start(self) -> None:
"""Launch the consumer coroutine."""
if self._consumer_task is None or self._consumer_task.done():
self._stopped = False
self._consumer_task = asyncio.create_task(
self._consumer(), name="speech-queue-consumer"
)
def _drain_remaining(self) -> None:
"""Drain pending items, recording shutdown outcomes. Sync — safe for finally."""
while not self._queue.empty():
try:
item = self._queue.get_nowait()
outcome = {
"played": False,
"error": "Queue shut down",
"engine": item.result.engine,
"voice": item.result.voice,
}
if not item.future.done():
item.future.set_result(outcome)
self._record_outcome(item.speech_id, outcome)
except asyncio.QueueEmpty:
break
def _force_cancel_consumer(self) -> None:
"""Cancel consumer task if still running."""
if self._consumer_task and not self._consumer_task.done():
self._consumer_task.cancel()
async def stop(self) -> None:
"""Gracefully stop the consumer — let current speech finish, then drain.
Waits up to shutdown_timeout seconds for the currently-playing item to
complete. If it doesn't finish in time, falls back to hard cancel.
Drain always runs, even if stop() itself is cancelled.
"""
self._stopped = True
try:
if self._consumer_task and not self._consumer_task.done():
# Log what we're waiting for
if self._current:
elapsed = time.time() - self._current.enqueued_at
remaining = max(0, self._current.result.duration_seconds - elapsed)
print(
f"Speech queue: waiting for current audio "
f"(~{remaining:.0f}s remaining, budget {self._shutdown_timeout:.0f}s)",
file=sys.stderr,
)
else:
print("Speech queue: consumer idle, shutting down", file=sys.stderr)
# Consumer polls _stopped every 0.5s between items, so it will
# exit the while loop after finishing the current item.
try:
await asyncio.wait_for(
asyncio.shield(self._consumer_task),
timeout=self._shutdown_timeout,
)
print(
"Speech queue: graceful shutdown complete",
file=sys.stderr,
)
except asyncio.TimeoutError:
print(
f"Speech queue: shutdown timeout ({self._shutdown_timeout:.0f}s) "
f"expired, force-cancelling consumer",
file=sys.stderr,
)
self._consumer_task.cancel()
try:
await self._consumer_task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
# stop() itself was cancelled — force-kill consumer before draining
self._force_cancel_consumer()
raise
finally:
self._drain_remaining()
def _resolve_outcome(self, item: _WorkItem, outcome: dict) -> None:
"""Set the future result and record the outcome."""
if not item.future.done():
item.future.set_result(outcome)
self._record_outcome(item.speech_id, outcome)
async def _consumer(self) -> None:
"""Pull work items and play them sequentially.
The _item_cancelled flag distinguishes cancel(speech_id) (item-level,
continue loop) from shutdown cancellation (exit loop).
"""
while not self._stopped:
try:
item = await asyncio.wait_for(self._queue.get(), timeout=0.5)
except asyncio.TimeoutError:
continue # Re-check _stopped flag
except asyncio.CancelledError:
break
if item.future.cancelled():
self._queue.task_done()
continue
self._current = item
self._item_cancelled = False
try:
await play_audio(
item.result.audio_path,
expected_seconds=item.result.duration_seconds,
)
# Play exit tone — "standby" if more queued, "roger" if done
# suppress_exit_tone skips tones between chunks of the same message
if not item.suppress_exit_tone:
exit_t = (
self._standby_tone if self._queue.qsize() > 0
else self._exit_tone
)
if exit_t:
try:
await play_audio(exit_t, expected_seconds=0.3)
except PlaybackError:
pass # Non-fatal
self._resolve_outcome(item, {
"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:
self._resolve_outcome(item, {
"played": False,
"error": str(e),
"file": str(item.result.audio_path),
"engine": item.result.engine,
"voice": item.result.voice,
})
except asyncio.CancelledError:
if self._item_cancelled:
# Item-level cancel via cancel() — play cancel tone, continue
self._item_cancelled = False
if self._cancel_tone:
try:
await play_audio(self._cancel_tone, expected_seconds=0.3)
except (PlaybackError, asyncio.CancelledError):
pass
self._resolve_outcome(item, {
"played": False,
"error": "Cancelled by client",
"engine": item.result.engine,
"voice": item.result.voice,
})
# Don't break — continue to next item
else:
# Shutdown — record and exit loop
self._resolve_outcome(item, {
"played": False,
"error": "Playback cancelled",
"engine": item.result.engine,
"voice": item.result.voice,
})
break
except Exception as e:
self._resolve_outcome(item, {
"played": False,
"error": f"Unexpected error: {e}",
"engine": item.result.engine,
"voice": item.result.voice,
})
finally:
self._current = None
self._queue.task_done()
async def enqueue(
self,
result: TTSResult,
priority: Priority = Priority.NORMAL,
suppress_exit_tone: bool = False,
) -> dict:
"""Enqueue a TTSResult for playback. Returns immediately with enqueue metadata.
Does NOT block until playback finishes — use get_status(speech_id) to
check the outcome later.
Raises QueueFull if the queue is at max capacity (backpressure).
"""
if self._stopped:
return {
"queued": False,
"error": "Queue is shut down",
"engine": result.engine,
"voice": result.voice,
}
speech_id = self._next_speech_id()
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
item = _WorkItem(
priority=priority,
sequence=self._counter,
result=result,
future=future,
speech_id=speech_id,
suppress_exit_tone=suppress_exit_tone,
)
try:
self._queue.put_nowait(item)
except asyncio.QueueFull:
return {
"queued": False,
"error": f"Queue full ({self._max_depth} items). Try again later.",
"speech_id": speech_id,
"engine": result.engine,
"voice": result.voice,
}
self._futures[speech_id] = future
return {
"speech_id": speech_id,
"queued": True,
"position": self._queue.qsize(),
"file": str(result.audio_path),
"duration_seconds": result.duration_seconds,
"engine": result.engine,
"voice": result.voice,
}
async def wait_for_completion(self, speech_id: str) -> dict:
"""Await playback completion for a specific speech_id.
Returns the outcome dict when playback finishes (or fails/is cancelled).
If the speech_id is unknown or already completed, returns immediately.
"""
future = self._futures.get(speech_id)
if future is None:
# Already completed and cleaned up, or unknown
return self._outcomes.get(speech_id, {"status": "unknown", "speech_id": speech_id})
return await future
def cancel(self, speech_id: str) -> dict:
"""Cancel a queued or playing speech item.
If the item is currently playing, kills pw-play and the consumer plays
the cancel tone. If queued, marks the future as cancelled so the
consumer skips it. Returns cancellation status.
"""
# Currently playing — cancel via the consumer task
if self._current and self._current.speech_id == speech_id:
if self._consumer_task and not self._consumer_task.done():
self._item_cancelled = True
self._consumer_task.cancel()
return {"cancelled": True, "was": "playing", "speech_id": speech_id}
# Queued — cancel the future (consumer skips cancelled futures)
future = self._futures.get(speech_id)
if future and not future.done():
future.cancel()
self._futures.pop(speech_id, None)
self._record_outcome(speech_id, {
"played": False,
"error": "Cancelled by client",
})
return {"cancelled": True, "was": "queued", "speech_id": speech_id}
return {
"cancelled": False,
"speech_id": speech_id,
"reason": "Not found or already completed",
}
def get_status(self, speech_id: str) -> dict:
"""Check the status of a speech item by its ID.
Returns a dict with 'status' key: 'completed', 'playing', 'queued', or 'unknown'.
"""
# Check completed outcomes
if speech_id in self._outcomes:
outcome = self._outcomes[speech_id]
return {"status": "completed", **outcome}
# Check currently playing
if self._current and self._current.speech_id == speech_id:
return {
"status": "playing",
"speech_id": speech_id,
"engine": self._current.result.engine,
"voice": self._current.result.voice,
"file": str(self._current.result.audio_path),
"duration_seconds": self._current.result.duration_seconds,
}
# Scan queue (PriorityQueue._queue is the underlying heap list)
try:
for pos, item in enumerate(self._queue._queue, start=1):
if item.speech_id == speech_id:
return {
"status": "queued",
"speech_id": speech_id,
"position": pos,
"engine": item.result.engine,
"voice": item.result.voice,
}
except AttributeError:
pass # Internal API — degrade gracefully
return {"status": "unknown", "speech_id": speech_id}