Encourage parallel speak() calls, document progress monitoring

Update server instructions and tool docstring to explicitly tell clients
to call speak() in parallel with other tools rather than blocking on it
alone. Expand CLAUDE.md concurrency section with progress notification
details (ticker interval, message fields, annotation semantics).
This commit is contained in:
Ryan Malloy 2026-03-04 17:19:06 -07:00
parent 70511916b9
commit ab9b695f21
2 changed files with 33 additions and 13 deletions

View File

@ -141,7 +141,11 @@ All pactl errors are caught and logged. If PulseAudio is unavailable (no socket,
## `speak()` Progress Lifecycle ## `speak()` Progress Lifecycle
`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. `speak()` blocks until playback finishes, reporting progress throughout. **Clients should call it in parallel with other tools** rather than blocking on it alone — the return value is informational (speech_id, duration) and never needed for subsequent reasoning.
### Progress notifications
A background ticker emits MCP progress notifications every ~0.5 seconds during playback. Each notification includes `progress` (0-100), `total` (100), and a `message` string. MCP-aware clients render this as a live progress bar.
| Progress | Phase | Message | | Progress | Phase | Message |
|----------|-------|---------| |----------|-------|---------|
@ -151,13 +155,16 @@ All pactl errors are caught and logged. If PulseAudio is unavailable (no socket,
| 35-99% | Playing — progress tracks elapsed time vs expected duration | `Playing audio — you can continue working` | | 35-99% | Playing — progress tracks elapsed time vs expected duration | `Playing audio — you can continue working` |
| 100% | Playback finished | `Playback complete` | | 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. The entry tone fires before synthesis, covering the 1-3s latency gap. The progress message intentionally says "you can continue working" to signal to LLM clients that they don't need to wait.
### Concurrency guidance ### 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. `speak()` blocks for 10-60s for typical text, but audio plays through physical speakers — the return value doesn't feed into subsequent reasoning. The server instructions and tool docstring explicitly tell clients to **call speak in parallel with other tools** in the same message. Tool annotations provide structured signals:
`speech_status(speech_id)` is still available for checking outcomes after the fact. Possible statuses: `completed`, `playing`, `queued`, `unknown`. - `openWorldHint=True` — this tool interacts with the physical world (speakers)
- `idempotentHint=True` — safe to retry without side effects beyond replaying audio
`speech_status(speech_id)` is 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.

View File

@ -131,11 +131,14 @@ mcp = FastMCP(
"tts-mcp", "tts-mcp",
instructions=( instructions=(
"Multi-engine text-to-speech server. Use 'speak' to synthesize and play audio " "Multi-engine text-to-speech server. Use 'speak' to synthesize and play audio "
"through the host speakers. The call completes when playback finishes, but you " "through the host speakers.\n\n"
"can safely call speak alongside other tools — audio plays through physical " "IMPORTANT — speak() plays through physical speakers, not into your context. "
"speakers and doesn't need your attention. Progress updates arrive every second " "Always call it IN PARALLEL with other tools in the same message rather than "
"during playback. Use 'speech_status' to check outcomes after the fact. " "blocking on it alone. The return value is informational (speech_id, duration) "
"Use 'generate_audio' to synthesize without playing. " "and never needed for subsequent reasoning. Progress notifications arrive every "
"~0.5s during playback so clients can show a progress indicator.\n\n"
"If you need to check whether speech finished, use speech_status(speech_id) "
"after the fact. Use generate_audio to synthesize without playing.\n\n"
"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.). "
"Always pass project= with the current project directory name (last path component) " "Always pass project= with the current project directory name (last path component) "
@ -449,10 +452,20 @@ async def speak(
) -> dict: ) -> dict:
"""Synthesize text and play it through the host speakers. """Synthesize text and play it through the host speakers.
Audio plays through physical speakers you don't need to wait for the CALL THIS IN PARALLEL with other tools don't block on it alone.
result to continue your work. Feel free to call this alongside other tools Audio plays through physical speakers, not into your context. The return
in the same message. Progress notifications report playback status every value (speech_id, duration) is informational only and never needed for
second. subsequent reasoning. Call speak alongside your next tool in the same
message and let progress notifications track playback.
Progress notifications arrive every ~0.5s during playback:
- 5%: Entry tone played (audible acknowledgement)
- 30%: Synthesis complete
- 35-99%: Playing progress tracks elapsed time vs duration
- 100%: Playback complete
External media (YouTube, Spotify) is automatically ducked during speech
and fades back in after the exit tone.
Long texts are automatically split into sentences and pipelined the Long texts are automatically split into sentences and pipelined the
first sentence plays within ~1-2s while remaining sentences synthesize first sentence plays within ~1-2s while remaining sentences synthesize