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:
Ryan Malloy 2026-03-04 19:46:25 -07:00
parent d3aaac2042
commit d054caa43c
9 changed files with 200 additions and 42 deletions

View File

@ -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

View File

@ -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
View 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.
[![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 `<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

View File

@ -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.

View File

@ -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"]

View File

@ -1,4 +1,4 @@
"""Entry point for tts-mcp server."""
"""Entry point for mcspeak server."""
from .server import mcp
from .settings import settings

View File

@ -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"

View File

@ -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
View File

@ -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"