diff --git a/CLAUDE.md b/CLAUDE.md index 1f897b3..1aca469 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# TTS MCP Server +# McSpeak -Multi-engine text-to-speech server exposed via FastMCP 3.0 Streamable HTTP. Engines: Kokoro (ONNX), Piper (Wyoming/Docker), Orpheus (llama-server + SNAC). +Multi-engine text-to-speech MCP server exposed via FastMCP 3.0 Streamable HTTP. Engines: Kokoro (ONNX), Piper (Wyoming/Docker), Orpheus (llama-server + SNAC). ## Build & Run diff --git a/Dockerfile b/Dockerfile index 358424c..daa285d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,4 +40,4 @@ EXPOSE 8371 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ CMD python -c "import socket; s=socket.create_connection(('127.0.0.1',8371),2); s.close()" || exit 1 -CMD ["tts-mcp"] +CMD ["mcspeak"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..67afd68 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# McSpeak + +Multi-engine text-to-speech server exposed as MCP tools via [FastMCP 3.0](https://gofastmcp.com/) Streamable HTTP. Plays audio through the host's speakers using PipeWire. Built for giving AI agents a voice -- literally. + +[![PyPI](https://img.shields.io/pypi/v/mcspeak)](https://pypi.org/project/mcspeak/) +[![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://python.org) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) + +## Quick Start + +```bash +# Run directly from PyPI (no install needed) +uvx mcspeak + +# Or install and run +pip install mcspeak +mcspeak +``` + +The server starts on `http://0.0.0.0:8371` and exposes MCP tools over Streamable HTTP. Point any MCP client at it. + +**Add to Claude Code:** + +```bash +claude mcp add mcspeak -- uvx mcspeak +``` + +For Docker deployment (recommended -- handles PipeWire, Piper, and Orpheus GPU inference), see [Docker Setup](#docker-setup) below. + +## MCP Tools + +| Tool | What it does | +|------|-------------| +| `speak` | Synthesize text and play through speakers. Blocks with live progress. | +| `generate_audio` | Synthesize to WAV file without playing. Bypasses the queue. | +| `list_voices` | List available voices for an engine. | +| `list_engines` | Show all engines and their health status. | +| `speech_status` | Check status of a queued/playing speech item. | +| `cancel_speech` | Kill playback immediately. Plays a cancel tone. | + +There's also an `audio://recent` resource listing the last 20 generated WAV files. + +## Engines + +| Engine | Backend | Speed | Voices | Notes | +|--------|---------|-------|--------|-------| +| **kokoro** | ONNX (local CPU) | ~4x realtime | ~50 | Default. Fast, good quality. | +| **piper** | Wyoming protocol (Docker) | ~2x realtime | Depends on model | Connects to any Wyoming-compatible TTS server. | +| **orpheus** | llama-server + SNAC | ~1x realtime | 6 | LLM-based. Supports ``, ``, emotion tags. Needs GPU. | + +## Features + +### Radio-Style Alert Tones + +Speech is bookended by short tones inspired by two-way radio systems. An entry tone fires immediately when `speak()` is called (before synthesis starts), acting as an audible "I heard you" that covers the 1-3s synthesis latency. An exit tone plays when the queue empties. + +Available tones: `chirp` (Nextel iDEN), `apollo` (NASA quindar key-up), `roger` (CB radio descending beep), `quindar-out` (NASA unkey), `scratch` (vinyl record scratch for cancellation), and more. All generated programmatically at startup -- no bundled audio files. + +### Voice Identity + +When multiple projects connect simultaneously, each one automatically gets a distinct voice via round-robin assignment from a curated pool. Voices are interleaved across gender and accent (American, British, European) for maximum contrast. Assignments persist across restarts. + +Pass `project="my-project"` in `speak()` calls, or let it auto-detect from MCP Roots. + +### Media Ducking + +External audio (YouTube, Spotify, etc.) automatically fades down before speech and fades back in after the exit tone, like a radio broadcast interruption. Uses `pactl` under the hood. Fails silently if PulseAudio isn't available. + +### Chunked Synthesis Pipeline + +Long texts (20+ words with sentence boundaries) are split into chunks and pipelined -- synthesis of chunk N+1 overlaps playback of chunk N. First audio plays within ~1-2s instead of waiting for the full text to synthesize. + +### Graceful Shutdown + +`docker compose down` lets the current speech finish instead of cutting mid-sentence. Two-phase: 3s handler drain, then up to 30s for the consumer to finish. Configurable via `TTS_SHUTDOWN_TIMEOUT`. + +## Configuration + +All settings use the `TTS_` prefix and can be set via environment variables or `.env` file. + +| Variable | Default | Description | +|----------|---------|-------------| +| `TTS_HOST` | `0.0.0.0` | Server bind address | +| `TTS_PORT` | `8371` | Server port | +| `TTS_ENTRY_TONE` | `chirp` | Tone before speech (`chirp`, `apollo`, `none`, or WAV path) | +| `TTS_EXIT_TONE` | `roger` | Tone after speech (`roger`, `quindar-out`, `none`, or WAV path) | +| `TTS_CANCEL_TONE` | `scratch` | Tone on cancel (`scratch`, `reverse-roger`, `none`, or WAV path) | +| `TTS_DUCK_MEDIA` | `true` | Fade external audio during speech | +| `TTS_DUCK_FADE_OUT_MS` | `500` | Media fade-out duration (ms) | +| `TTS_DUCK_FADE_IN_MS` | `1000` | Media fade-in duration (ms) | +| `TTS_VOICE_IDENTITY` | `true` | Auto-assign distinct voices per project | +| `TTS_VOICE_IDENTITY_FILE` | (empty) | JSON file to persist voice assignments | +| `TTS_VOICE_IDENTITY_EXCLUDE` | `af_nicole` | Voices excluded from auto-assign pool | +| `TTS_ANNOUNCE_PROJECT` | `false` | Prefix speech with project name | +| `TTS_VOICE_BLACKLIST` | `am_adam,af_jessica` | Voices hidden from listings | +| `TTS_SHUTDOWN_TIMEOUT` | `30` | Max seconds to wait for speech on shutdown | +| `TTS_KOKORO_MODEL` | `models/kokoro/kokoro-v1.0.onnx` | Path to Kokoro ONNX model | +| `TTS_KOKORO_VOICES` | `models/kokoro/voices-v1.0.bin` | Path to Kokoro voice pack | +| `TTS_PIPER_HOST` | `172.26.0.3` | Piper Wyoming server host | +| `TTS_PIPER_PORT` | `10200` | Piper Wyoming server port | +| `TTS_ORPHEUS_URL` | `http://127.0.0.1:8081` | Orpheus llama-server URL | + +## Docker Setup + +Docker is the recommended deployment. The compose file runs McSpeak alongside a GPU-accelerated llama-server for Orpheus. + +**Prerequisites:** +- PipeWire running on the host (for `pw-play` audio output) +- NVIDIA GPU + nvidia-container-toolkit (for Orpheus only) +- Kokoro ONNX model files in `./models/kokoro/` + +```bash +# Copy and edit .env +cp .env.example .env +# Set ORPHEUS_GGUF_PATH to your Orpheus GGUF model location + +# Build and start +make up + +# Follow logs +make logs + +# Check health +make status +``` + +The container mounts the host PipeWire socket (`/run/user/1000/pipewire-0`) for audio playback and the PulseAudio compat socket (`/run/user/1000/pulse`) for media ducking. + +`stop_grace_period` in the compose file is set to 38s (3s drain + 30s shutdown timeout + 5s safety). Adjust if you change `TTS_SHUTDOWN_TIMEOUT`. + +## Architecture + +``` +src/tts_mcp/ + server.py FastMCP lifespan, tool definitions, chunked synthesis + queue.py Producer-consumer speech queue with priority + outcome tracking + tones.py Procedural tone WAV generation (numpy, 48kHz 16-bit PCM) + audio.py WAV writing and async pw-play wrapper + media_duck.py Async pactl volume control for media ducking + voice_identity.py Project-to-voice mapping with round-robin assignment + settings.py Pydantic settings from env vars + engines/ + base.py TTSEngine abstract base + kokoro.py Kokoro ONNX engine + piper.py Wyoming protocol client + orpheus.py llama-server + SNAC decoder +``` + +See [CLAUDE.md](CLAUDE.md) for the full technical reference -- design decisions, progress lifecycle, cancellation semantics, and the Python 3.13 `asyncio.wait_for` pitfall that cost a few hours of debugging. + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml index 9b31f03..23a0f70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: - tts-mcp: + mcspeak: build: . - container_name: tts-mcp + container_name: mcspeak restart: unless-stopped # Must exceed: 3s handler drain + TTS_SHUTDOWN_TIMEOUT + 5s safety margin. # Default: 3 + 30 + 5 = 38s. Increase if TTS_SHUTDOWN_TIMEOUT > 30. diff --git a/pyproject.toml b/pyproject.toml index 9ae86f9..4a611df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ dependencies = [ [project.scripts] mcspeak = "tts_mcp.__main__:main" -tts-mcp = "tts_mcp.__main__:main" [tool.hatch.build.targets.wheel] packages = ["src/tts_mcp"] diff --git a/src/tts_mcp/__main__.py b/src/tts_mcp/__main__.py index 4b03dba..d43a2b3 100644 --- a/src/tts_mcp/__main__.py +++ b/src/tts_mcp/__main__.py @@ -1,4 +1,4 @@ -"""Entry point for tts-mcp server.""" +"""Entry point for mcspeak server.""" from .server import mcp from .settings import settings diff --git a/src/tts_mcp/server.py b/src/tts_mcp/server.py index 6951016..654dd7e 100644 --- a/src/tts_mcp/server.py +++ b/src/tts_mcp/server.py @@ -88,7 +88,7 @@ async def app_lifespan(server: FastMCP): cancel_label = settings.cancel_tone if cancel_tone else "none" duck_label = "on" if ducker else "off" print( - f"TTS MCP server ready on {settings.host}:{settings.port} " + f"McSpeak ready on {settings.host}:{settings.port} " f"with {len(engines)} engines, tones={entry_label}/{exit_label}/{cancel_label}, " f"duck={duck_label}", file=sys.stderr, @@ -116,7 +116,7 @@ async def app_lifespan(server: FastMCP): "ducker": ducker, } finally: - print("TTS MCP server shutting down", file=sys.stderr) + print("McSpeak shutting down", file=sys.stderr) await queue.stop() for eng in engines.values(): if hasattr(eng, "close"): @@ -128,7 +128,7 @@ async def app_lifespan(server: FastMCP): # --------------------------------------------------------------------------- mcp = FastMCP( - "tts-mcp", + "mcspeak", instructions=( "Multi-engine text-to-speech server. Use 'speak' to synthesize and play audio " "through the host speakers.\n\n" diff --git a/src/tts_mcp/settings.py b/src/tts_mcp/settings.py index 3a46141..7cb1861 100644 --- a/src/tts_mcp/settings.py +++ b/src/tts_mcp/settings.py @@ -67,7 +67,7 @@ class Settings(BaseSettings): p = Path(self.audio_dir) p.mkdir(parents=True, exist_ok=True) return p - return Path("/tmp/tts-mcp") + return Path("/tmp/mcspeak") settings = Settings() diff --git a/uv.lock b/uv.lock index 74f72b5..618aecc 100644 --- a/uv.lock +++ b/uv.lock @@ -101,10 +101,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -885,6 +891,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] +[[package]] +name = "mcspeak" +version = "2026.3.4" +source = { editable = "." } +dependencies = [ + { name = "fastmcp" }, + { name = "httpx" }, + { name = "kokoro-onnx" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pydantic-settings" }, + { name = "snac" }, + { name = "soundfile" }, + { name = "torch" }, + { name = "wyoming" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastmcp", specifier = ">=3.0.0" }, + { name = "httpx" }, + { name = "kokoro-onnx", specifier = ">=0.5.0" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pydantic-settings" }, + { name = "snac", specifier = ">=1.2.1" }, + { name = "soundfile" }, + { name = "torch" }, + { name = "wyoming", specifier = ">=1.8.0" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1971,37 +2008,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, ] -[[package]] -name = "tts-mcp" -version = "2026.2.20" -source = { editable = "." } -dependencies = [ - { name = "fastmcp" }, - { name = "httpx" }, - { name = "kokoro-onnx" }, - { name = "numpy" }, - { name = "onnxruntime" }, - { name = "pydantic-settings" }, - { name = "snac" }, - { name = "soundfile" }, - { name = "torch" }, - { name = "wyoming" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastmcp", specifier = ">=3.0.0" }, - { name = "httpx" }, - { name = "kokoro-onnx", specifier = ">=0.5.0" }, - { name = "numpy" }, - { name = "onnxruntime" }, - { name = "pydantic-settings" }, - { name = "snac", specifier = ">=1.2.1" }, - { name = "soundfile" }, - { name = "torch" }, - { name = "wyoming", specifier = ">=1.8.0" }, -] - [[package]] name = "typer" version = "0.24.0"