Rename project to mcspeak, add README
Rename all user-facing references from tts-mcp to mcspeak: service name, container, compose project, Dockerfile CMD, FastMCP instance, MCP config, startup logs, temp dir, and CLAUDE.md header. Python module stays tts_mcp (import name != package name is standard practice). README covers quick start, MCP tools, engines, features, config, Docker setup, and architecture.
This commit is contained in:
parent
d3aaac2042
commit
d054caa43c
@ -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
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
153
README.md
Normal file
153
README.md
Normal file
@ -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.
|
||||
|
||||
[](https://pypi.org/project/mcspeak/)
|
||||
[](https://python.org)
|
||||
[](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 `<laugh>`, `<sigh>`, 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
|
||||
@ -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.
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Entry point for tts-mcp server."""
|
||||
"""Entry point for mcspeak server."""
|
||||
|
||||
from .server import mcp
|
||||
from .settings import settings
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
68
uv.lock
generated
68
uv.lock
generated
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user