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()` 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 |
|----------|-------|---------|
@ -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` |
| 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
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.

View File

@ -131,11 +131,14 @@ mcp = FastMCP(
"tts-mcp",
instructions=(
"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 "
"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. "
"through the host speakers.\n\n"
"IMPORTANT — speak() plays through physical speakers, not into your context. "
"Always call it IN PARALLEL with other tools in the same message rather than "
"blocking on it alone. The return value is informational (speech_id, duration) "
"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), "
"orpheus (LLM via llama-server, supports <laugh> etc.). "
"Always pass project= with the current project directory name (last path component) "
@ -449,10 +452,20 @@ async def speak(
) -> dict:
"""Synthesize text and play it through the host speakers.
Audio plays through physical speakers you don't need to wait for the
result to continue your work. Feel free to call this alongside other tools
in the same message. Progress notifications report playback status every
second.
CALL THIS IN PARALLEL with other tools don't block on it alone.
Audio plays through physical speakers, not into your context. The return
value (speech_id, duration) is informational only and never needed for
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
first sentence plays within ~1-2s while remaining sentences synthesize