mcspeak/CLAUDE.md
Ryan Malloy eaff3e8861 Add project-aware voice identity with round-robin assignment
Each project gets a distinct voice from a curated English pool,
assigned via round-robin with gender/accent interleaving for
maximum perceptual contrast between consecutive projects.

- New voice_identity.py: pool filtering, interleaving, persistence
- Round-robin replaces SHA-256 hashing (no collisions until pool
  exhaustion at 22 voices)
- Assignments persist to /data/voice-assignments.json across restarts
- speak() and generate_audio() accept optional project= parameter
- MCP roots fallback with 2s timeout for future bidirectional clients
- English-only pool (af_/am_/bf_/bm_/ef_/em_ prefixes)
- af_nicole excluded from auto-assign (whispery), still explicit-ok
- Fix voice blacklist to use full identifiers (am_adam, af_jessica)
2026-02-24 11:58:10 -07:00

4.3 KiB

TTS MCP Server

Multi-engine text-to-speech server exposed via FastMCP 3.0 Streamable HTTP. Engines: Kokoro (ONNX), Piper (Wyoming/Docker), Orpheus (llama-server + SNAC).

Build & Run

make up        # build + start (docker compose)
make logs      # follow logs
make restart   # restart containers
make status    # show running containers + health

Entry & Exit Tones (Beep System)

Queued speech playback (speak()) is bookended by short alert tones. generate_audio() is unaffected (file-only, no playback).

Tone Positions

Position When Purpose
Entry tone Before speech starts "Incoming transmission" alert
Exit tone After speech, queue empty "Over and out" — channel clear
Standby tone After speech, more queued "Standby" — more messages coming

Available Tones

Name Frequency Duration Inspired by
chirp 1800 Hz ~144 ms Nextel iDEN Talk Permit Tone (TPT) — the 24/24/24/24/48 ms on/off pattern
apollo 2525 Hz 250 ms NASA quindar intro (key-up) tone used during Apollo missions
roger 1400-1000 Hz ~100 ms Classic CB radio descending two-tone roger beep
quindar-out 2475 Hz 250 ms NASA quindar unkey tone (distinct frequency from intro)
standby 1000-1400 Hz ~60 ms Ascending blip — inverse of roger, signals "more coming"

Configuration

TTS_ENTRY_TONE=chirp        # before speech (chirp, apollo, none, or /path/to/custom.wav)
TTS_EXIT_TONE=roger          # after speech, queue empty (roger, quindar-out, none, or path)

The standby tone is always the built-in ascending blip. It plays instead of the exit tone when more items are queued.

Tones are generated programmatically at startup (48kHz, 16-bit PCM, -3 dB headroom) in tones.py using numpy. No bundled audio assets.

Voice Identity (Project-Aware Voices)

When multiple Claude Code sessions connect simultaneously, voice identity gives each project a distinct voice via round-robin assignment from a curated English voice pool.

How It Works

  1. Client calls speak() or generate_audio() without specifying voice=
  2. Project is identified via the project tool parameter, or falls back to MCP Roots (list_roots() with 2s timeout)
  3. The next unused voice is assigned from the interleaved pool (alternating gender and accent for maximum contrast)
  4. Assignment is persisted to /data/voice-assignments.json — survives server restarts

Explicit voice= parameter always overrides auto-assignment. Voice pools are cached for 5 minutes (picks up blacklist/engine changes).

Note: MCP Roots require stateful Streamable HTTP. With stateless_http=True (current default), roots will timeout — the project parameter is the primary identification method.

Configuration

TTS_VOICE_IDENTITY=true                          # Enable project-aware voice assignment
TTS_VOICE_IDENTITY_PREFIXES=af_,am_,bf_,bm_,ef_,em_  # English voice prefixes
TTS_VOICE_IDENTITY_EXCLUDE=af_nicole              # Available explicitly, excluded from auto-assign (whispery)
TTS_VOICE_IDENTITY_FILE=/data/voice-assignments.json  # Persist across restarts
TTS_ANNOUNCE_PROJECT=false                        # Prefix speech with project name

Pool Interleave Order

Voices are interleaved for perceptual diversity: American female → British male → European female → American male → British female → European male. First 6 projects get maximally distinct voices.

Files

  • voice_identity.py — Pool filtering, interleaving, round-robin assignment, JSON persistence

Architecture

  • server.py — FastMCP lifespan, tool definitions, engine setup
  • queue.py — Producer-consumer speech queue with priority tiers
  • tones.py — Tone WAV generator (entry/exit/standby)
  • audio.py — WAV writing and pw-play async wrapper
  • settings.py — Pydantic settings from env vars (prefix: TTS_)
  • engines/ — TTSEngine implementations (kokoro, piper, orpheus)

Key Design Decisions

  • Speech queue is serialized (one playback at a time) but synthesis is parallel
  • Tones are non-fatal: if pw-play fails on a tone, speech still plays
  • Orpheus uses llama-server (not Ollama) for 15x throughput via continuous batching
  • SNAC decoder is lazy-loaded on first Orpheus call to reduce idle memory