diff --git a/CLAUDE.md b/CLAUDE.md index cb272f6..1f897b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/tts_mcp/server.py b/src/tts_mcp/server.py index 5bfb454..6951016 100644 --- a/src/tts_mcp/server.py +++ b/src/tts_mcp/server.py @@ -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 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