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.
This commit is contained in:
parent
79bfe2a89b
commit
7959efbd51
63
CLAUDE.md
63
CLAUDE.md
@ -93,22 +93,69 @@ Voices are interleaved for perceptual diversity: American female → British mal
|
|||||||
|
|
||||||
## `speak()` Progress Lifecycle
|
## `speak()` Progress Lifecycle
|
||||||
|
|
||||||
`speak()` blocks until playback finishes, reporting progress throughout:
|
`speak()` blocks until playback finishes, reporting progress throughout. Each progress notification includes a `message` field that LLM clients can read to understand the current phase.
|
||||||
|
|
||||||
| Progress | Phase |
|
| Progress | Phase | Message |
|
||||||
|----------|-------|
|
|----------|-------|---------|
|
||||||
| 5% | Entry tone played — audible "I heard you" |
|
| 5% | Entry tone played — audible "I heard you" | `Entry tone` |
|
||||||
| 30% | Synthesis complete |
|
| 30% | Synthesis complete | `Synthesizing...` |
|
||||||
| 35% | Enqueued for playback |
|
| 35% | Enqueued for playback | `Playing audio — you can continue working` |
|
||||||
| 35-99% | Playing — progress tracks elapsed time vs expected duration |
|
| 35-99% | Playing — progress tracks elapsed time vs expected duration | `Playing audio — you can continue working` |
|
||||||
| 100% | Playback finished |
|
| 100% | Playback finished | `Playback complete` |
|
||||||
|
|
||||||
MCP-aware clients see a live progress bar. The entry tone fires before synthesis, covering the 1-3s latency gap.
|
MCP-aware clients see a live progress bar. The entry tone fires before synthesis, covering the 1-3s latency gap.
|
||||||
|
|
||||||
|
### Concurrency guidance
|
||||||
|
|
||||||
|
The `speak()` call completes when playback finishes (10-60s for typical text), but the audio plays through physical speakers — the return value doesn't feed into subsequent reasoning. LLM clients like Claude Code can call `speak()` alongside other tools in the same message. The server instructions and tool docstring explicitly encourage this. Tool annotations (`openWorldHint=True`, `idempotentHint=True`) provide structured signals to clients that support them.
|
||||||
|
|
||||||
`speech_status(speech_id)` is still available for checking outcomes after the fact. Possible statuses: `completed`, `playing`, `queued`, `unknown`.
|
`speech_status(speech_id)` is still available for checking outcomes after the fact. Possible statuses: `completed`, `playing`, `queued`, `unknown`.
|
||||||
|
|
||||||
Outcomes are stored in a bounded ring (last 100 items) — old entries are evicted automatically.
|
Outcomes are stored in a bounded ring (last 100 items) — old entries are evicted automatically.
|
||||||
|
|
||||||
|
## Chunked Synthesis (Pipelined Playback)
|
||||||
|
|
||||||
|
Long texts (>= 20 words with sentence boundaries) are automatically split into sentence-sized chunks and pipelined — synthesis of chunk N+1 overlaps playback of chunk N.
|
||||||
|
|
||||||
|
```
|
||||||
|
Single-shot: [---12s synthesis---][----------48s playback----------] First audio at T+12s
|
||||||
|
Chunked: [~1s synth][play 1][~1s synth][play 2][~1s synth][play 3]... First audio at T+1s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why no gaps
|
||||||
|
|
||||||
|
Kokoro synthesizes ~4x realtime on CPU, so synthesis always outpaces playback. `pw-play` adds ~10ms startup per chunk — imperceptible. Slower engines (Orpheus) may have brief gaps but still beat the full-synthesis-first approach.
|
||||||
|
|
||||||
|
### Short text bypass
|
||||||
|
|
||||||
|
Texts under 20 words or without sentence boundaries (`.!?` followed by whitespace) take the single-shot path — zero overhead, identical to pre-chunking behavior.
|
||||||
|
|
||||||
|
### Tone behavior
|
||||||
|
|
||||||
|
- Entry tone plays once at the start (before first chunk synthesis)
|
||||||
|
- Exit/standby tones are suppressed between chunks (`suppress_exit_tone` flag on `_WorkItem`)
|
||||||
|
- Final chunk plays the normal exit tone (roger) or standby tone
|
||||||
|
|
||||||
|
### Cancellation in chunked mode
|
||||||
|
|
||||||
|
- **Explicit `cancel_speech(speech_id)`** — cancels the returned speech_id (the final chunk). Already-playing earlier chunks finish naturally.
|
||||||
|
- **MCP disconnect** — the synthesis loop stops (remaining chunks aren't synthesized). Already-enqueued chunks play through.
|
||||||
|
|
||||||
|
### Progress lifecycle (chunked)
|
||||||
|
|
||||||
|
| Progress | Phase | Message |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| 5% | Entry tone played | `Entry tone` |
|
||||||
|
| 5-30% | Synthesis progress across all chunks | `Synthesizing chunk N/M...` |
|
||||||
|
| 35% | All chunks enqueued | `Playing audio — you can continue working` |
|
||||||
|
| 35-99% | Playing — tracks elapsed time vs total duration | `Playing audio — you can continue working` |
|
||||||
|
| 100% | Final chunk playback finished | `Playback complete` |
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- `server.py` — `split_text()`, `_speak_single()`, `_speak_chunked()`, `_await_with_progress()`
|
||||||
|
- `queue.py` — `suppress_exit_tone` field on `_WorkItem`
|
||||||
|
|
||||||
## Cancellation
|
## Cancellation
|
||||||
|
|
||||||
Speech can be cancelled two ways:
|
Speech can be cancelled two ways:
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class _WorkItem:
|
|||||||
future: asyncio.Future = field(compare=False)
|
future: asyncio.Future = field(compare=False)
|
||||||
speech_id: str = field(default="", compare=False)
|
speech_id: str = field(default="", compare=False)
|
||||||
enqueued_at: float = field(default_factory=time.time, compare=False)
|
enqueued_at: float = field(default_factory=time.time, compare=False)
|
||||||
|
suppress_exit_tone: bool = field(default=False, compare=False)
|
||||||
|
|
||||||
|
|
||||||
class SpeechQueue:
|
class SpeechQueue:
|
||||||
@ -213,6 +214,8 @@ class SpeechQueue:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Play exit tone — "standby" if more queued, "roger" if done
|
# 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 = (
|
exit_t = (
|
||||||
self._standby_tone if self._queue.qsize() > 0
|
self._standby_tone if self._queue.qsize() > 0
|
||||||
else self._exit_tone
|
else self._exit_tone
|
||||||
@ -282,6 +285,7 @@ class SpeechQueue:
|
|||||||
self,
|
self,
|
||||||
result: TTSResult,
|
result: TTSResult,
|
||||||
priority: Priority = Priority.NORMAL,
|
priority: Priority = Priority.NORMAL,
|
||||||
|
suppress_exit_tone: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Enqueue a TTSResult for playback. Returns immediately with enqueue metadata.
|
"""Enqueue a TTSResult for playback. Returns immediately with enqueue metadata.
|
||||||
|
|
||||||
@ -308,6 +312,7 @@ class SpeechQueue:
|
|||||||
result=result,
|
result=result,
|
||||||
future=future,
|
future=future,
|
||||||
speech_id=speech_id,
|
speech_id=speech_id,
|
||||||
|
suppress_exit_tone=suppress_exit_tone,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""FastMCP 3.0 server — tools, lifespan, and resource definitions."""
|
"""FastMCP 3.0 server — tools, lifespan, and resource definitions."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -9,6 +10,7 @@ from typing import Literal
|
|||||||
|
|
||||||
from fastmcp import Context, FastMCP
|
from fastmcp import Context, FastMCP
|
||||||
from fastmcp.server.dependencies import CurrentContext
|
from fastmcp.server.dependencies import CurrentContext
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from .audio import play_audio
|
from .audio import play_audio
|
||||||
from .engines.base import TTSEngine
|
from .engines.base import TTSEngine
|
||||||
@ -115,9 +117,11 @@ async def app_lifespan(server: FastMCP):
|
|||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
"tts-mcp",
|
"tts-mcp",
|
||||||
instructions=(
|
instructions=(
|
||||||
"Multi-engine text-to-speech server. Use 'speak' to synthesize and enqueue audio "
|
"Multi-engine text-to-speech server. Use 'speak' to synthesize and play audio "
|
||||||
"for playback through the host speakers — returns immediately with a speech_id. "
|
"through the host speakers. The call completes when playback finishes, but you "
|
||||||
"Use 'speech_status' to check if playback completed, is in progress, or still queued. "
|
"can safely call speak alongside other tools — audio plays through physical "
|
||||||
|
"speakers and doesn't need your attention. Progress updates arrive every second "
|
||||||
|
"during playback. Use 'speech_status' to check outcomes after the fact. "
|
||||||
"Use 'generate_audio' to synthesize without playing. "
|
"Use 'generate_audio' to synthesize without playing. "
|
||||||
"Engines: kokoro (fast ONNX, ~50 voices), piper (Wyoming/Docker), "
|
"Engines: kokoro (fast ONNX, ~50 voices), piper (Wyoming/Docker), "
|
||||||
"orpheus (LLM via llama-server, supports <laugh> etc.). "
|
"orpheus (LLM via llama-server, supports <laugh> etc.). "
|
||||||
@ -142,6 +146,43 @@ async def _play_tone(tone_path: Path) -> None:
|
|||||||
print(f" Entry tone failed (non-fatal): {e}", file=sys.stderr)
|
print(f" Entry tone failed (non-fatal): {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
_CHUNK_WORD_THRESHOLD = 20 # texts shorter than this bypass chunking
|
||||||
|
|
||||||
|
|
||||||
|
def split_text(text: str) -> list[str]:
|
||||||
|
"""Split text into sentence-sized chunks for pipelined synthesis.
|
||||||
|
|
||||||
|
Splits on sentence-ending punctuation (.!?) followed by whitespace.
|
||||||
|
Merges short fragments (< 5 words) with their neighbor.
|
||||||
|
Returns [text] unchanged if under the word threshold.
|
||||||
|
"""
|
||||||
|
words = text.split()
|
||||||
|
if len(words) < _CHUNK_WORD_THRESHOLD:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
# Split on sentence boundaries — require uppercase after split to avoid
|
||||||
|
# false positives on abbreviations (Dr.), decimals (3.0), URLs, etc.
|
||||||
|
raw = re.split(r"(?<=[.!?])\s+(?=[A-Z])", text.strip())
|
||||||
|
if len(raw) <= 1:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
# Merge short fragments (< 5 words) with next chunk.
|
||||||
|
# First chunk is allowed to be short — minimizes time-to-first-audio.
|
||||||
|
chunks: list[str] = [raw[0]]
|
||||||
|
for fragment in raw[1:]:
|
||||||
|
if len(chunks[-1].split()) < 5:
|
||||||
|
chunks[-1] = chunks[-1] + " " + fragment
|
||||||
|
else:
|
||||||
|
chunks.append(fragment)
|
||||||
|
|
||||||
|
# If the last chunk is very short, merge it into its predecessor
|
||||||
|
if len(chunks) > 1 and len(chunks[-1].split()) < 5:
|
||||||
|
tail = chunks.pop()
|
||||||
|
chunks[-1] = chunks[-1] + " " + tail
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_project_voice(
|
async def _resolve_project_voice(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
engine_name: str,
|
engine_name: str,
|
||||||
@ -171,7 +212,196 @@ async def _resolve_project_voice(
|
|||||||
# Tools
|
# Tools
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@mcp.tool
|
async def _speak_single(
|
||||||
|
eng: TTSEngine,
|
||||||
|
engine: str,
|
||||||
|
text: str,
|
||||||
|
voice: str | None,
|
||||||
|
queue: SpeechQueue,
|
||||||
|
entry_tone: Path | None,
|
||||||
|
priority: Priority,
|
||||||
|
ctx: Context,
|
||||||
|
) -> dict:
|
||||||
|
"""Single-shot speak path — synthesize full text, then enqueue.
|
||||||
|
|
||||||
|
Used for short texts (< 20 words) and as the fallback when text has
|
||||||
|
no sentence boundaries.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if entry_tone:
|
||||||
|
await _play_tone(entry_tone)
|
||||||
|
await ctx.report_progress(progress=5, total=100, message="Entry tone")
|
||||||
|
|
||||||
|
await ctx.info(f"Synthesizing with {engine}...")
|
||||||
|
result = await eng.synthesize(text, voice)
|
||||||
|
await ctx.report_progress(progress=30, total=100, message="Synthesizing...")
|
||||||
|
|
||||||
|
await ctx.info(f"Synthesized {result.duration_seconds:.1f}s audio, enqueueing...")
|
||||||
|
enqueue_result = await queue.enqueue(result, priority=priority)
|
||||||
|
|
||||||
|
if not enqueue_result.get("queued"):
|
||||||
|
return enqueue_result
|
||||||
|
|
||||||
|
speech_id = enqueue_result["speech_id"]
|
||||||
|
duration = result.duration_seconds
|
||||||
|
await ctx.report_progress(
|
||||||
|
progress=35, total=100,
|
||||||
|
message="Playing audio \u2014 you can continue working",
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.info("Playing...")
|
||||||
|
# NOTE: Do NOT use asyncio.wait_for() — see CLAUDE.md Python 3.13 pitfall
|
||||||
|
outcome = await _await_with_progress(queue, speech_id, duration, ctx, 35)
|
||||||
|
|
||||||
|
await ctx.report_progress(progress=100, total=100, message="Playback complete")
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# speak() was cancelled (server shutdown or MCP client disconnect).
|
||||||
|
# Do NOT cancel the consumer — let it finish the current audio.
|
||||||
|
# Shutdown: queue.stop() in the lifespan finalizer handles graceful drain.
|
||||||
|
# Client cancel: use cancel_speech(speech_id) for explicit mid-playback stop.
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _speak_chunked(
|
||||||
|
eng: TTSEngine,
|
||||||
|
engine: str,
|
||||||
|
chunks: list[str],
|
||||||
|
voice: str | None,
|
||||||
|
queue: SpeechQueue,
|
||||||
|
entry_tone: Path | None,
|
||||||
|
priority: Priority,
|
||||||
|
ctx: Context,
|
||||||
|
) -> dict:
|
||||||
|
"""Chunked speak path — pipeline synthesis with playback.
|
||||||
|
|
||||||
|
Synthesizes sentence chunks one at a time and enqueues each immediately.
|
||||||
|
Playback of chunk N overlaps synthesis of chunk N+1. Non-final chunks
|
||||||
|
suppress exit/standby tones for seamless playback.
|
||||||
|
"""
|
||||||
|
n_chunks = len(chunks)
|
||||||
|
total_duration = 0.0
|
||||||
|
all_speech_ids: list[str] = []
|
||||||
|
first_enqueue_time: float | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if entry_tone:
|
||||||
|
await _play_tone(entry_tone)
|
||||||
|
await ctx.report_progress(progress=5, total=100, message="Entry tone")
|
||||||
|
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
is_last = i == n_chunks - 1
|
||||||
|
pct_synth = 5 + int(25 * (i + 1) / n_chunks) # 5-30% across all chunks
|
||||||
|
|
||||||
|
await ctx.info(f"Synthesizing chunk {i + 1}/{n_chunks} with {engine}...")
|
||||||
|
result = await eng.synthesize(chunk, voice)
|
||||||
|
total_duration += result.duration_seconds
|
||||||
|
await ctx.report_progress(
|
||||||
|
progress=pct_synth, total=100,
|
||||||
|
message=f"Synthesizing chunk {i + 1}/{n_chunks}...",
|
||||||
|
)
|
||||||
|
|
||||||
|
enqueue_result = await queue.enqueue(
|
||||||
|
result,
|
||||||
|
priority=priority,
|
||||||
|
suppress_exit_tone=not is_last,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not enqueue_result.get("queued"):
|
||||||
|
# Queue full mid-message — already-enqueued chunks play through
|
||||||
|
if all_speech_ids:
|
||||||
|
await ctx.info(f"Queue full at chunk {i + 1}/{n_chunks}, waiting for enqueued chunks...")
|
||||||
|
elapsed = time.time() - first_enqueue_time if first_enqueue_time else 0
|
||||||
|
remaining = max(total_duration - elapsed, 0.1)
|
||||||
|
outcome = await _await_with_progress(queue, all_speech_ids[-1], remaining, ctx, 35)
|
||||||
|
outcome["chunks_enqueued"] = len(all_speech_ids)
|
||||||
|
outcome["chunks_total"] = n_chunks
|
||||||
|
return outcome
|
||||||
|
return enqueue_result
|
||||||
|
|
||||||
|
all_speech_ids.append(enqueue_result["speech_id"])
|
||||||
|
if first_enqueue_time is None:
|
||||||
|
first_enqueue_time = time.time()
|
||||||
|
|
||||||
|
# All chunks enqueued — wait for final chunk to finish playing
|
||||||
|
if not all_speech_ids:
|
||||||
|
return {"error": "No chunks were successfully enqueued", "chunks_total": n_chunks}
|
||||||
|
|
||||||
|
await ctx.report_progress(
|
||||||
|
progress=35, total=100,
|
||||||
|
message="Playing audio \u2014 you can continue working",
|
||||||
|
)
|
||||||
|
await ctx.info(f"Playing {n_chunks} chunks ({total_duration:.1f}s total)...")
|
||||||
|
|
||||||
|
# Subtract time already elapsed since first chunk started playing
|
||||||
|
elapsed = time.time() - first_enqueue_time if first_enqueue_time else 0
|
||||||
|
remaining = max(total_duration - elapsed, 0.1)
|
||||||
|
outcome = await _await_with_progress(queue, all_speech_ids[-1], remaining, ctx, 35)
|
||||||
|
await ctx.report_progress(progress=100, total=100, message="Playback complete")
|
||||||
|
|
||||||
|
# Check for partial failures in earlier chunks
|
||||||
|
failed_chunks = []
|
||||||
|
for idx, sid in enumerate(all_speech_ids[:-1]):
|
||||||
|
chunk_status = queue.get_status(sid)
|
||||||
|
if chunk_status.get("status") == "completed" and not chunk_status.get("played", True):
|
||||||
|
failed_chunks.append(idx + 1)
|
||||||
|
|
||||||
|
outcome["chunks"] = n_chunks
|
||||||
|
outcome["total_duration_seconds"] = total_duration
|
||||||
|
if failed_chunks:
|
||||||
|
outcome["partial_failure"] = True
|
||||||
|
outcome["failed_chunks"] = failed_chunks
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# speak() was cancelled (server shutdown or MCP client disconnect).
|
||||||
|
# Do NOT cancel the consumer — let already-enqueued chunks play through.
|
||||||
|
# Shutdown: queue.stop() in the lifespan finalizer handles graceful drain.
|
||||||
|
# Client cancel: use cancel_speech(speech_id) for explicit mid-playback stop.
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _await_with_progress(
|
||||||
|
queue: SpeechQueue,
|
||||||
|
speech_id: str,
|
||||||
|
duration: float,
|
||||||
|
ctx: Context,
|
||||||
|
base_pct: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Await playback completion with a progress ticker.
|
||||||
|
|
||||||
|
Uses a background task for progress — NOT asyncio.wait_for polling
|
||||||
|
(see CLAUDE.md for the Python 3.13 pitfall).
|
||||||
|
"""
|
||||||
|
async def _progress_ticker():
|
||||||
|
t0 = time.time()
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
pct = min(base_pct + int((99 - base_pct) * elapsed / max(duration, 0.1)), 99)
|
||||||
|
await ctx.report_progress(
|
||||||
|
progress=pct, total=100,
|
||||||
|
message="Playing audio \u2014 you can continue working",
|
||||||
|
)
|
||||||
|
|
||||||
|
ticker = asyncio.create_task(_progress_ticker())
|
||||||
|
try:
|
||||||
|
return await queue.wait_for_completion(speech_id)
|
||||||
|
finally:
|
||||||
|
ticker.cancel()
|
||||||
|
try:
|
||||||
|
await ticker
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
idempotentHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
))
|
||||||
async def speak(
|
async def speak(
|
||||||
text: str,
|
text: str,
|
||||||
engine: ENGINE_NAMES = "kokoro",
|
engine: ENGINE_NAMES = "kokoro",
|
||||||
@ -182,8 +412,14 @@ async def speak(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Synthesize text and play it through the host speakers.
|
"""Synthesize text and play it through the host speakers.
|
||||||
|
|
||||||
Blocks until playback finishes, reporting progress throughout:
|
Audio plays through physical speakers — you don't need to wait for the
|
||||||
entry tone → synthesis → queued → playing → done.
|
result to continue your work. Feel free to call this alongside other tools
|
||||||
|
in the same message. Progress notifications report playback status every
|
||||||
|
second.
|
||||||
|
|
||||||
|
Long texts are automatically split into sentences and pipelined — the
|
||||||
|
first sentence plays within ~1-2s while remaining sentences synthesize
|
||||||
|
in the background.
|
||||||
|
|
||||||
Audio is queued so agents don't talk over each other. Urgent messages
|
Audio is queued so agents don't talk over each other. Urgent messages
|
||||||
jump ahead of normal-priority items.
|
jump ahead of normal-priority items.
|
||||||
@ -205,70 +441,15 @@ async def speak(
|
|||||||
ctx, engine, eng, voice, text, voice_cache, project,
|
ctx, engine, eng, voice, text, voice_cache, project,
|
||||||
)
|
)
|
||||||
|
|
||||||
speech_id = None
|
priority = Priority.URGENT if urgent else Priority.NORMAL
|
||||||
try:
|
|
||||||
# Entry tone — audible "I heard you" before synthesis begins
|
|
||||||
if entry_tone:
|
|
||||||
await _play_tone(entry_tone)
|
|
||||||
await ctx.report_progress(progress=5, total=100)
|
|
||||||
|
|
||||||
# Synthesize audio (multiple agents can synthesize simultaneously)
|
chunks = split_text(text)
|
||||||
await ctx.info(f"Synthesizing with {engine}...")
|
if len(chunks) <= 1:
|
||||||
result = await eng.synthesize(text, voice)
|
return await _speak_single(eng, engine, text, voice, queue, entry_tone, priority, ctx)
|
||||||
await ctx.report_progress(progress=30, total=100)
|
return await _speak_chunked(eng, engine, chunks, voice, queue, entry_tone, priority, ctx)
|
||||||
|
|
||||||
# Enqueue for serialized playback
|
|
||||||
await ctx.info(f"Synthesized {result.duration_seconds:.1f}s audio, enqueueing...")
|
|
||||||
enqueue_result = await queue.enqueue(
|
|
||||||
result,
|
|
||||||
priority=Priority.URGENT if urgent else Priority.NORMAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not enqueue_result.get("queued"):
|
|
||||||
return enqueue_result
|
|
||||||
|
|
||||||
speech_id = enqueue_result["speech_id"]
|
|
||||||
duration = result.duration_seconds
|
|
||||||
await ctx.report_progress(progress=35, total=100)
|
|
||||||
|
|
||||||
# Wait for playback with progress via a background ticker.
|
|
||||||
# NOTE: Do NOT use asyncio.wait_for() with the future — Python 3.13's
|
|
||||||
# wait_for cancels its inner task on timeout, and repeated cancel/re-await
|
|
||||||
# on the same Future causes a stale CancelledError to propagate to the
|
|
||||||
# consumer task, killing pw-play mid-sentence.
|
|
||||||
await ctx.info("Playing...")
|
|
||||||
|
|
||||||
async def _progress_ticker():
|
|
||||||
"""Send progress updates while playback runs."""
|
|
||||||
t0 = time.time()
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
elapsed = time.time() - t0
|
|
||||||
pct = min(35 + int(64 * elapsed / max(duration, 0.1)), 99)
|
|
||||||
await ctx.report_progress(progress=pct, total=100)
|
|
||||||
|
|
||||||
ticker = asyncio.create_task(_progress_ticker())
|
|
||||||
try:
|
|
||||||
outcome = await queue.wait_for_completion(speech_id)
|
|
||||||
finally:
|
|
||||||
ticker.cancel()
|
|
||||||
try:
|
|
||||||
await ticker
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await ctx.report_progress(progress=100, total=100)
|
|
||||||
return outcome
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# speak() was cancelled (server shutdown or MCP client disconnect).
|
|
||||||
# Do NOT cancel the consumer — let it finish the current audio.
|
|
||||||
# Shutdown: queue.stop() in the lifespan finalizer handles graceful drain.
|
|
||||||
# Client cancel: use cancel_speech(speech_id) to explicitly stop playback.
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
||||||
async def speech_status(
|
async def speech_status(
|
||||||
speech_id: str,
|
speech_id: str,
|
||||||
ctx: Context = CurrentContext(),
|
ctx: Context = CurrentContext(),
|
||||||
@ -285,7 +466,11 @@ async def speech_status(
|
|||||||
return queue.get_status(speech_id)
|
return queue.get_status(speech_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool(annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=True,
|
||||||
|
idempotentHint=True,
|
||||||
|
))
|
||||||
async def cancel_speech(
|
async def cancel_speech(
|
||||||
speech_id: str,
|
speech_id: str,
|
||||||
ctx: Context = CurrentContext(),
|
ctx: Context = CurrentContext(),
|
||||||
@ -302,7 +487,11 @@ async def cancel_speech(
|
|||||||
return queue.cancel(speech_id)
|
return queue.cancel(speech_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool(annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
idempotentHint=True,
|
||||||
|
))
|
||||||
async def generate_audio(
|
async def generate_audio(
|
||||||
text: str,
|
text: str,
|
||||||
engine: ENGINE_NAMES = "kokoro",
|
engine: ENGINE_NAMES = "kokoro",
|
||||||
@ -343,7 +532,7 @@ async def generate_audio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
||||||
async def list_voices(
|
async def list_voices(
|
||||||
engine: ENGINE_NAMES,
|
engine: ENGINE_NAMES,
|
||||||
ctx: Context = CurrentContext(),
|
ctx: Context = CurrentContext(),
|
||||||
@ -364,7 +553,7 @@ async def list_voices(
|
|||||||
return await engines[engine].list_voices()
|
return await engines[engine].list_voices()
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True))
|
||||||
async def list_engines(
|
async def list_engines(
|
||||||
ctx: Context = CurrentContext(),
|
ctx: Context = CurrentContext(),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user