31 Commits

Author SHA1 Message Date
f13278da64 Merge voice-multiproject-ux: streaming utterances, secretary, tone kit
Multi-project voice UX: whole streaming utterances (no interleave) + WAV
leak fix, secretary announcements with a reserved voice, call-waiting,
conversation-first listen (VAD/warmup), status-aware progress, and a
telephone-themed tone kit tuned live.
2026-07-03 20:21:30 -06:00
fc5057366c docs: utterance queue, secretary, tone kit, listen
Update CLAUDE.md for the streaming-utterance model (drop the stale
_WorkItem/suppress_exit_tone references), the secretary announcements +
reserved voice, call-waiting, the telephone tone kit, and the
conversation-first listen() defaults.
2026-07-03 20:08:20 -06:00
3c4e06aa64 Add Pink Floyd tone kit and conversation-first listen
A telephone-themed bookend set, tuned by ear:

- speak: heartbeat entry ("Speak to Me" woven with warm MF tones), soft-chord
  exit (pure sines, easy on the ears on repetition)
- listen: mf-dial start (the genuine Young Lust R1 operator routing sequence,
  KP 0-4-4-1-8-3-1 ST) and machine end (Welcome to the Machine throb)
- mf-listen/mf-done gentler alternatives, and a call-waiting blip

listen() is now conversation-first: wait_for_silence defaults on with a 30s
cap, and the "go" tone plays only after the mic is live (warmup) so the first
word isn't clipped. VAD defaults tuned (aggressiveness 3, 2200ms silence,
400ms min-speech) after live testing showed the old values cut replies off on
brief background transients. Adds webrtcvad-wheels for the VAD path.
2026-07-03 20:08:14 -06:00
5db7876dac Stream utterances through the speech queue
Replace the per-chunk queue model with whole streaming utterances so
concurrent projects never interleave. Each speak() reserves one ordering
slot up front and streams its synthesized chunks through an internal
channel; the consumer stays locked to that utterance from entry tone to
exit tone, preserving synth/playback pipelining without letting another
project's audio wedge between sentences.

Also folded in:

- Reap synthesized WAVs after playback (and on cancel/shutdown/queue-full),
  fixing an unbounded /tmp/mcspeak leak — one file per spoken sentence was
  never deleted.
- Secretary announcements: announce a project by name on speaker-change or
  after a lull, synthesized in a reserved voice (Kokoro) that is excluded
  from the project auto-assignment pool. Decision made at play time so
  urgent reordering is respected.
- Call-waiting: a brief blip mixed over the current message when a different
  project queues up, gated to once per playing turn so it never spams.
- Unify _speak_single/_speak_chunked into one _speak path.
- Status-aware progress: a queued item now reports "waiting in line" instead
  of racing to a false "playing 99%".
- Synchronous abort flag closes a cancel-race where a chunk synthesized
  during the cancel-tone window could leak past the reap.
2026-07-03 20:08:04 -06:00
d73bb49fd4 Add listen() tool — capture mic via pw-record, transcribe via Parakeet
mcspeak gains a `listen` MCP tool that records from the host's default
PipeWire source for N seconds and pipes the WAV through the existing
transcribe_audio() pipeline. The container's PipeWire socket bind
mount (already in place for pw-play output) gives pw-record the same
bidirectional access — no new container capabilities needed.

Recording format: 16 kHz mono s16, which is Parakeet's preferred
input, so no resample step. Default source is whatever the host
considers the default mic (`pactl get-default-source`); switching
mics on the host is picked up at next call without restart. Override
per-call via the `source` param if needed.

Optional save_path persists the recording under /output/ (same
scoping discipline as generate_audio). Response carries recorded
(in-container path) and saved_to (host-visible path) so callers can
re-transcribe with different params or play back later.

Forward-compat params (timestamp_granularities, diarize, num_speakers,
punctuation, min_confidence) thread straight through to transcribe_audio
so listen() inherits whatever the gpu-stack Parakeet endpoint gains
later, no client change.

Implementation gotcha: pw-record's exit code on SIGTERM is
non-deterministic (observed 1 on this version, not 0 or -15). The
original allowlist check would have rejected every successful
recording. The fix trusts the WAV file's existence + size instead;
if pw-record genuinely fails the file is missing or empty, which is
the reliable signal.
2026-06-27 21:39:13 -06:00
549deb877d Gitignore orpheus-data/ for the local GGUF cache
Mirrors the piper-data/ entry — Orpheus runs against a 2 GB GGUF
fetched once and bind-mounted into llama-server. Repo stays
binary-free.
2026-06-27 21:28:02 -06:00
f00d0e27a2 Add transcribe tool — Parakeet STT via gpu.supported.systems
mcspeak gains a `transcribe` MCP tool that accepts an audio file path
and returns text. Hits the Whisper-API-shaped Parakeet endpoint at
mcspeak.gpu.supported.systems/v1/audio/transcriptions with the shared
bearer key from TTS_PARAKEET_KEY. The mcspeak.* subdomain flows
through to Langfuse as user=mcspeak for tenant attribution.

Input audio_path validates against /output/ and /tmp/mcspeak/ —
symmetric with generate_audio's _resolve_output_path discipline. That
covers the primary round-trip use case (transcribing audio mcspeak
just generated) without extra mounts. Path canonicalization rejects
both absolute paths outside the allowlist and ../-traversal escapes.

The tool exposes forward-compat params (timestamp_granularities,
diarize, num_speakers, vad, punctuation, min_confidence) that the
current phonescribe gateway silently ignores. When the gpu-stack
agent lands word-level alignment or speaker diarization, the same
calls start producing richer responses with no client change. The
shaped-now-instead-of-later approach saves a breaking change later.

response_format dispatch: json/verbose_json parse as JSON dict
directly; text/srt/vtt wrap as {"text": <body>, "format": <fmt>}.
The gateway today rejects srt/vtt with HTTP 400 "use json | text |
verbose_json" — that's a clean error users see, not silent garbage.

transcribe.py: shared httpx.AsyncClient mirrored from orpheus.py's
pattern (explicit timeouts, ConnectError/ReadError/TimeoutException
catches). Closed via lifespan finalizer.

httpx 0.28 gotcha: passing `data=` as list-of-tuples silently routes
the value to `content=`, which wraps as SyncByteStream and crashes
the AsyncClient with "Attempted to send a sync request." `data` must
be a Mapping; list values inside the dict become repeated form
fields automatically.
2026-06-16 07:18:13 -06:00
27b4c4ae29 Add generate_audio file/format params; bring Piper TTS up with es_MX default
generate_audio gains output_path + format params (wav, mp3, ogg, flac,
m4a). Non-wav formats route through ffmpeg (added to the Dockerfile).
Files land under /output/ inside the container, bind-mounted from the
host's TTS_OUTPUT_HOST_DIR (defaults to ~/mcspeak-out). Subdirectories
are auto-created. Path validation rejects writes outside /output/ —
both absolute escapes like /etc/passwd and traversal forms like
../../etc/passwd are canonicalized and checked against the mount root.
Extension auto-corrects to match the requested format.

piper-tts service added to compose using rhasspy/wyoming-piper. Default
voice es_MX-ald-medium is pre-warmed at container start and persists in
./piper-data/ across recreates (~60 MB download on first run). Port
10200 published to localhost so host-side scripts can also reach
Wyoming directly; mcspeak inside the stack uses container DNS. Voice
configurable via TTS_PIPER_VOICE — the same env var threads through to
both wyoming-piper's --voice flag and mcspeak's settings.piper_voice,
so they stay in sync.

PiperEngine.__init__ accepts a default_voice override; settings.piper_voice
threads it from the env. list_engines now reports the configured default
rather than the hardcoded en_US-lessac-medium. speak() / generate_audio()
calls with engine=piper and no explicit voice use the configured one.

mcspeak-internal network changed from internal:true to default-bridge.
internal:true was overkill — the goal was per-stack DNS scoping (which
compose provides automatically via the project-prefixed network name),
not internet isolation. The latter broke piper-tts's HuggingFace voice
download with "Name resolution failure" on first start.

.gitignore: add piper-data/ so the downloaded voice models stay out of
the repo.
2026-06-13 17:35:36 -06:00
ec4e1b6093 Fix stream-restore mute + entry tone overlap, make Orpheus opt-in
Stream-restore mute: pw-play streams were being silently restored to
0% volume by PulseAudio's stream-restore module matching the music
role key, producing audible-but-clean-exit playback. audio.py now
bumps each new pw-play sink-input to 100% as a background task; the
new value re-stamps stream-restore on stream end.

Entry tone overlap: when speak() arrived during prior playback, its
entry tone played immediately from the handler and overlapped the
ongoing audio. queue.is_idle() now gates this — tone plays now when
idle (preserves latency-hiding intent), else defers to the consumer
via _WorkItem.entry_tone, which plays it right before the item's
audio. Chunked path defers only on chunk 0.

Docker: llama-server gated behind a `with-orpheus` compose profile;
mcspeak.depends_on uses required:false. `make up` defaults to
kokoro-only with no GPU dependency; `make up-with-orpheus` runs the
full stack. Rename dootie-internal network → mcspeak-internal
(auto-created per-stack via internal:true). Add 127.0.0.1:8371 port
mapping for local MCP clients.

README: fix claude mcp add command (was stdio, must be --transport
http); document kokoro-only default and the with-orpheus path; add
Kokoro model download step. .env.example added so `cp .env.example
.env` matches the documented setup.
2026-05-28 14:14:38 -06:00
44fec2ec32 Bump version to 2026.03.04.1 for PyPI re-publish with renamed module 2026-03-04 20:11:52 -07:00
402a877726 Rename module src/tts_mcp → src/mcspeak
Standard src-layout: package name matches import name. All internal
imports are relative so no code changes needed beyond pyproject.toml.
Also fixes 4 pre-existing ruff line-length violations.
2026-03-04 20:11:23 -07:00
d054caa43c 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.
2026-03-04 19:46:25 -07:00
d3aaac2042 Rename PyPI package to mcspeak, add mcspeak script entry point 2026-03-04 19:42:17 -07:00
359536199b Bump version to 2026.03.04 for PyPI release
Media ducking, parallel speak() guidance, and pactl-based volume control.
2026-03-04 19:16:31 -07:00
ab9b695f21 Encourage parallel speak() calls, document progress monitoring
Update server instructions and tool docstring to explicitly tell clients
to call speak() in parallel with other tools rather than blocking on it
alone. Expand CLAUDE.md concurrency section with progress notification
details (ticker interval, message fields, annotation semantics).
2026-03-04 17:19:06 -07:00
70511916b9 Add media ducking: fade external audio during TTS playback
Duck all PulseAudio sink-inputs (Firefox, Spotify, etc.) before speech,
crossfade the entry tone over the fading media, then restore volumes
after the exit tone. Uses pactl subprocesses for reliable PipeWire
compatibility — pulsectl-asyncio's native protocol writes are silently
dropped by PipeWire's PA compat layer.

New module media_duck.py with MediaDucker class. Unduck is consolidated
into the consumer's finally block to guarantee restoration on all exit
paths (normal, error, cancel, shutdown).
2026-03-03 21:38:56 -07:00
7959efbd51 Add tool annotations, progress messages, and chunked synthesis pipeline
Chunked synthesis splits long texts into sentences and pipelines
synthesis with playback — first audio plays within ~1-2s instead of
waiting for full synthesis. Queue supports suppress_exit_tone between
chunks for seamless playback.

Tool annotations (ToolAnnotations) signal behavioral hints to MCP
clients: speak() is openWorldHint=True (physical speakers),
cancel_speech() is destructiveHint=True, read-only tools marked
accordingly.

Progress notifications now include message= strings that tell LLM
clients they can continue working during playback. Server instructions
corrected from "returns immediately" to accurately describe blocking
behavior with parallel-safe guidance. speak() docstring updated to
encourage concurrent tool calls.
2026-03-03 18:00:11 -07:00
79bfe2a89b Fix sequential timeout stacking in shutdown budget
The same shutdown_timeout was used for both uvicorn handler drain AND
queue.stop() consumer drain — sequential phases sharing Docker's
wall-clock budget. With 30s each, worst case was 60s, exceeding
the 35s stop_grace_period and causing SIGKILL.

Fix: uvicorn gets a fixed 3s drain (handlers just re-raise), queue
gets the full shutdown_timeout. Docker grace = 3 + timeout + 5s safety.

Also adds shutdown observability: startup logs the timing chain,
queue.stop() logs remaining audio vs available budget.
2026-03-02 18:28:04 -07:00
d10eb9ab57 Fix graceful shutdown: let consumer finish audio on SIGTERM
speak()'s CancelledError handler was calling queue.cancel() which killed
pw-play mid-sentence during container restarts. Now it just re-raises,
letting the consumer finish naturally. Explicit cancel_speech() still
kills playback immediately.

Also override FastMCP's timeout_graceful_shutdown=0 in uvicorn config
so request handlers get shutdown_timeout seconds before cancellation.
2026-03-02 18:12:31 -07:00
7cae72b936 Graceful shutdown, blocking speak() with progress, and cancel support
speak() now blocks until playback finishes, reporting progress via SSE
(5% entry tone → 30% synthesis → 35-99% playing → 100% done). Entry
tone fires immediately on call to cover synthesis latency.

Queue shutdown waits for current speech to finish (configurable timeout,
default 30s) before draining pending items — no more mid-sentence
cutoffs on container restart.

Cancellation via cancel_speech() tool or MCP notifications/cancelled
kills pw-play and plays a vinyl scratch tone. Consumer continues to
next item after cancel.

Progress tracking uses a background ticker task instead of
asyncio.wait_for polling — the latter causes stale CancelledError
propagation to the consumer under Python 3.13.
2026-03-02 17:45:48 -07:00
44d4f5a3d6 Rename Caddy domain to mctalkbox.l.supported.systems 2026-03-02 15:30:57 -07:00
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
6a81e0760a Add exit tone (roger beep) and standby tone after speech
Queue-aware exit tones: descending roger beep when queue empties
("over"), ascending standby blip when more items are queued ("standby,
more coming"). Includes quindar-out (2475 Hz) as Apollo-themed
alternative. Configurable via TTS_EXIT_TONE env var.

Also adds CLAUDE.md documenting the full tone system.
2026-02-23 14:44:27 -07:00
25de529bf4 Add entry tone (Nextel chirp) before queued speech playback
Generate 48kHz WAV tones at startup (chirp and apollo/quindar) using
numpy. The queue consumer plays the selected tone before each speech
item via pw-play. Configurable via TTS_ENTRY_TONE env var: chirp
(default), apollo, none, or path to a custom WAV file.
2026-02-23 14:40:36 -07:00
bf0dfa7a5e Decode SNAC in single pass to fix audio artifacts
SNAC's convolutional decoder has a receptive field spanning multiple
frames. Batched decode (28 tokens at a time) created boundary
discontinuities that produced muddled audio. Since pw-play waits for
the full WAV anyway, single-pass decode costs only ~2s extra and
produces clean audio.
2026-02-21 21:51:42 -07:00
538b8a513e Replace Ollama with llama-server for 15x Orpheus throughput
Build llama.cpp from source with SM 120 CUDA kernels and FORCE_CUBLAS
for RTX 5070 Blackwell. Rewrite OrpheusEngine to stream tokens via SSE
and decode SNAC in overlapping 28-token batches (4 frames), replacing
the blocking requests+stream:false approach.

Performance: 13.5 → 170-213 tok/s. 100s audio generates in ~48s (2x
faster than realtime). Replaces requests with httpx async client.

Also switch MCP transport to stateless_http mode so container restarts
don't invalidate client sessions.
2026-02-21 21:33:23 -07:00
7f9557d93b Increase Orpheus Ollama timeout from 120s to 600s
Long emotional monologues with multiple <sigh>/<laugh>/<gasp> tags
generate ~4000 tokens at ~12 tok/s, easily exceeding 2 minutes.
2026-02-21 17:09:13 -07:00
48b518771d Refactor speech queue: producer-consumer with priority and timeouts
Replace asyncio.Lock with bounded PriorityQueue + dedicated consumer
coroutine. Addresses Hamilton review findings:

- Playback timeout: pw-play subprocess killed if it exceeds 2x
  expected duration + margin (prevents deadlock on PipeWire hang)
- Bounded queue: max 20 items with backpressure rejection
- Priority tiers: urgent messages jump ahead of normal items
- Graceful shutdown: consumer cancelled, pending items drained,
  in-flight subprocess killed on lifespan teardown
- Structured errors: all failure modes return dicts, not raw exceptions
2026-02-21 15:56:01 -07:00
4698d8b0d2 Lazy-load SNAC decoder to reduce idle memory
SNAC + torch no longer load at startup — deferred to first Orpheus
call via double-checked locking. Startup drops from ~13s to 0.5s,
idle RAM reduced by ~200MB. OrpheusEngine constructor no longer
takes snac_model; it self-loads on demand.
2026-02-21 13:04:35 -07:00
c53db4b251 Add Docker deployment with Caddy HTTPS reverse proxy
CPU-only torch image (~180MB vs 873MB CUDA), PipeWire socket
passthrough for audio playback, SNAC HuggingFace cache volume.
Served at voice.l.supported.systems via caddy-docker-proxy.
2026-02-20 20:47:22 -07:00
2fd84f0df7 Initial TTS MCP server with 3 engines
FastMCP 3.0 Streamable HTTP server exposing Piper (Wyoming/Docker),
Kokoro (ONNX), and Orpheus (Ollama+SNAC) as MCP tools. Includes a
FIFO speech queue so concurrent agents don't talk over each other —
waiting callers get queue position updates via ctx.info().

Tools: speak, generate_audio, list_voices, list_engines
Resource: audio://recent
2026-02-20 18:10:15 -07:00