Skip to main content
Glama
mcp-agent-chatterbox

mcp-agent-chatterbox

mcp-agent-chatterbox

MCP server for local, GPU-accelerated text-to-speech with Chatterbox (Resemble AI, MIT). Text goes in, spoken audio comes out — no cloud service, no API key, no per-character cost.

Ships three things:

  • A resident synthesis worker that holds the model, renders sentence by sentence and plays as it goes. Start it once and leave it running.

  • An MCP tool surface (speak, tts_status, tts_unload, list_voices, stop_speech) that any MCP client can call. It forwards speak to the worker.

  • An opencode plugin (.opencode/plugins/tts.js) that adds a speak tool and a /speak command to opencode itself, and installs itself globally so it works in every project, not just this repo.

Contents

To…

Go to

install it and get it speaking

Quick start

use the control panel

The panel

call speak and friends from an agent

Tools

run it from a shell

CLI

register the MCP server in a harness

Registering the MCP server

change a setting

Configuration

fix "no audio" / worker_not_running

Troubleshooting

Reference: How the pieces fit · The four models · Requirements · Installation · Model weights · Skill · opencode plugin · Voice cloning · Web surface · Design notes · Dependency advisories · Development

Related MCP server: muse-tts-embed

Quick start

Four steps from a fresh checkout to audible output.

# 1. environment (CUDA torch comes from the lockfile)
uv sync --extra dev

# 2. settings: copy the reference list, then edit .env
cp .env.example .env

# 3. the resident worker: start once, leave running
uv run mcp-agent-chatterbox worker
uv run mcp-agent-chatterbox worker-status    # is it up? what is it doing?
uv run mcp-agent-chatterbox worker-stop      # finish the queue, then exit

# 4. prove the whole chain, with no MCP involved
uv run mcp-agent-chatterbox speak "Build finished."

The first speak downloads the model (minutes) and loads it; later calls reuse it. uv run mcp-agent-chatterbox doctor prints the device, per-GPU VRAM, the models and the voices found — the first thing to run when a call misbehaves.

I want to…

Do this

speak from an agent

register the server (here), then call speak

stop, replay or speak by hand

uv run mcp-agent-chatterbox panel → http://127.0.0.1:8766/

free the VRAM, keep the worker

tts_unload, or Unload model in the panel

stop the worker at the end of the day

uv run mcp-agent-chatterbox worker-stop (then panel-stop if the panel is open)

serve over HTTP instead of stdio

uv run mcp-agent-chatterbox serve --http --port 8123

The short version of everything below: the MCP server never synthesizes. It hands text to the worker over loopback, so the worker has to be running before anything can speak — and that is why step 3 exists at all.

How the pieces fit

LLM / agent                              browser
   │  MCP tool call: speak(text=...)        │  buttons: stop / replay / speak / worker
   ▼                                        ▼
MCP server (server.py, cli: `serve`)     panel (panel.py, cli: `panel`)
   └──────────────────┬─────────────────────┘
                      │  one JSON line per request over TCP 127.0.0.1:8765
                      ▼
worker (worker.py, cli: `worker`)
                      │  Speaker → ChatterboxEngine → model on the GPU
                      │  render-ahead buffer, then playback in order
                      ▼
sentence-batched chunks → tts_output/*.wav → speakers

Three consequences, each of which explains a behaviour you will otherwise trip over:

  • The speak tool is still the way an agent speaks. The worker does not implement MCP and never sees a JSON-RPC frame; it only understands small newline-delimited JSON requests (speak, status, history, replay, unload, stop, shutdown). The MCP server is the adapter that turns a tool call into that request, and the panel is a second adapter that turns a button into the same request. So an agent needs the server and the worker; the server alone writes no audio, and neither does the panel.

  • The server holds no model. It boots in milliseconds and stays responsive because there is nothing in it to load. That is deliberate: tts_status and list_voices must answer while a model is still downloading.

  • The CLI is a second front door, not a third layer. mac speak "..." runs the same Speaker directly, without the MCP server; mac worker* commands use the same TCP socket the server uses. Nothing in the CLI is a parallel implementation of synthesis.

Why a separate process. Synthesis used to happen inside the MCP server, and inside that process it reliably stopped after two sentences and never resumed. The same code outside a server finished the whole reply every time. It failed identically over stdio and over HTTP, so it was not the transport; and it failed with the client's pipe held open and closed early, so it was not the shutdown path. The measurement, for the record, on the same six sentences:

where the synthesis ran

result

inside the MCP server process (stdio)

2 of 6 sentences, then nothing for 300s+

inside the MCP server process (HTTP)

2 of 6 sentences, then nothing

in a plain process, same code

6 of 6, ~32s

server forwarding to the worker

6 of 6, ~34s

The second problem was lifetime. The server dies when its client's turn ends, so a reply longer than the turn was killed mid-sentence no matter how the queue was drained. A resident worker outlives any turn, and keeps the model loaded, so successive replies pay nothing for the load.

speak accepts a reply and answers immediately; rendering and playback keep running in the worker afterwards. A blocking answer was the earlier design — the tool result was a record of what was actually written and played, taken at the moment the reply finished — but it held the harness's tool call open for the entire spoken duration, and LM Studio's tool-call timeout aborts such a call long before a long reply finishes, cutting the turn off mid-playback. Returning on acceptance makes speak immune to any client timeout. The audio is played by the worker, so it keeps playing regardless of what the harness does with the server process, and the record of the utterance (written files, played counts, timings, errors) is served afterwards by tts_status under worker.last.

The four models

Key

Params

Languages

Reference clip

Notes

nano

110M

English

not needed

Default. Turbo's small sibling: same architecture, single-step decoder, tags and built-in voice, tightest latency/memory budget (also runs on CPU).

turbo

350M

English

not needed

Nano's bigger sibling: same architecture, tags and built-in voice, more VRAM headroom. Understands paralinguistic tokens inline: [laugh], [chuckle], [whispering], [clear throat], [shush], and 14 more (exact set: TURBO_TAGS in registry.py).

multilingual

500M

23 (incl. German)

required

language="de", en, fr, … t3_model="v2" (default) or "v3".

original

500M

English

required

The CFG / exaggeration-tuning model, for delivery style control.

The full tag set is per model and read straight from the pulled vocabularies (ground truth on 2026-10-22, an earlier list contained invented tokens): nano/turbo know exactly 19 (TURBO_TAGS in registry.py, including the two easy-to-miss spellings [clear throat] with a space and [whispering] with -ing); the 500M models know a broader 28-token set (ORIGINAL_TAGS, which does contain [whisper] and [music]). Anything else in brackets is dropped before rendering — an invented tag such as [soft, contented hum] would otherwise be spoken literally, word for word. The count of dropped tokens is logged, never their text.

Only turbo and nano ship a stock voice. The other two raise AssertionError: Please prepare_conditionals first or specify audio_prompt_path without a reference clip, so the tool checks this up front and returns a reference_clip_required payload naming the fix instead of a stack trace.

Requirements

  • Python 3.13 (requires-python = ">=3.13,<3.14" in pyproject.toml)

  • An NVIDIA GPU with a driver of 525+ for the CUDA 12.4 wheels. CPU works but is slow.

  • ~6 GB of free VRAM for a comfortable margin (turbo needs ~2-3 GB, the 500M models ~3-4 GB)

  • Windows for audio playback. On other platforms the chunk files are still written; the play field comes back false and tts_status records the playback error in speech.last.errors.

Installation

uv sync --extra dev

uv sync installs the CUDA build of PyTorch automatically. pyproject.toml pins torch to PyTorch's official CUDA index via [tool.uv.sources], so GPU acceleration is a property of the environment rather than an accident of the platform.

Verify

uv run mcp-agent-chatterbox doctor

Prints the resolved device, per-GPU free/total VRAM, the models, and any reference clips found. On a multi-GPU box this is also how you find out which card is free.

Quick CUDA check:

uv run python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

Expect 2.14.0+cu132 True.

Why setuptools is floored, not capped

pyproject.toml requires setuptools>=83.0.0. That floor exists to clear GHSA-h35f-9h28-mq5c, which is fixed in 83.0.0. It is a floor, never a cap, so uv picks the patched release and cannot regress below it.

An earlier revision carried a hard setuptools<81 cap here, justified by a story that turned out to be wrong. It is recorded because the symptom is still real and the wrong fix is a trap:

The turbo model embeds Perth, Resemble AI's audio watermarking library, and calls it at load time. When Perth fails to import, perth/__init__.py swallows the ImportError and sets PerthImplicitWatermarker = None — the failure is silent by design upstream — and ChatterboxTurboTTS.__init__ then calls that None:

TypeError: 'NoneType' object is not callable

The cap was added because the visible ImportError was believed to be from pkg_resources import resource_filename, which setuptools 81 dropped. It was not. Two independent findings:

  • perth 1.1.0 never imports pkg_resources at all. The resolved git dependency carries a real package with real metadata and uses importlib, not the loose vendored copy described in the old note.

  • The actual ImportError was a librosa one: cannot import name 'resample' from 'librosa'. It surfaced through perth_watermarker.py's from librosa import resample, which is a red herring — librosa itself was broken. librosa lazily loads resample from librosa.core.spectrum, which imports numba; numba imports numba.misc.coverage_support, which subclasses coverage.types.Tracer; and a half-deleted coverage install made that fail with AttributeError: module 'coverage' has no attribute 'types'. lazy_loader swallowed the resulting chain, leaving librosa.resample simply unbound — so import librosa still succeeded, and the breakage only appeared later, inside an unrelated package's from librosa import resample.

The coverage install was damaged by a uv sync that was killed midway because the running MCP server held coverage/tracer.pyd open, leaving coverage/__init__.py deleted while types.py survived. So the cap was fixing nothing: it merely rode along with the resolution that removed the corruption. Verified on setuptools 84.0.0 with pkg_resources gone entirely — perth.PerthImplicitWatermarker imports as a real class and turbo renders.

Consequences worth knowing:

  • A clean coverage install is what actually matters. The tell is coverage.__file__ being None (namespace-package shell). If turbo ever throws that TypeError again, run uv sync to completion and check coverage/__init__.py exists before suspecting a dependency.

  • Only turbo is affected. multilingual, original, and nano never touch Perth.

  • Never restore a setuptools<81 cap. It cannot fix this failure, and it reintroduces a known advisory.

Why chatterbox-tts comes from a git snapshot

We deliberately do not install the PyPI release. chatterbox-tts has shipped nothing since 0.1.7 (2026-03-26), but upstream master carries what the wheel lacks:

  • real v2/v3 multilingual checkpoint selection. The 0.1.7 wheel's mtl_tts.from_pretrained() takes no t3_model argument, so our t3_model knob would TypeError the moment it was used. Master maps v2/v3 to t3_mtl23ls_v2/v3.safetensors.

  • the 500M EOS-noise trim — the final speech token decodes to ~40 ms of noise before EOS and is dropped from the wav.

  • the HF Xet-download fallback — the Xet storage backend crashes on some environments; master retries over the HTTP/LFS path automatically.

pyproject.toml pins the snapshot to an exact commit (rev = 5de7a54a…) under [tool.uv.sources], so the environment is reproducible. To pick up newer upstream work, bump the rev deliberately and re-run the four models — then re-examine the dtype-shim reasoning, because upstream still carries the norm_loudness float64 upcast that engine._harden_reference_dtype() exists to contain. The snapshot also builds resemble-perth from git (the watermarker behind turbo), replacing the loose vendored copy the 0.1.7 wheel shipped.

Consequences worth knowing:

  • Master keeps version 0.1.7 — upstream never bumps it, so the git rev is the package's only reliable identity. Do not "upgrade" back to a PyPI chatterbox-tts>=0.1.7; you would silently lose v3, the trim, and the Xet fallback.

  • The t3_model knob is now real: v2 (default) or v3. v3 is a separate multigigabyte checkpoint downloaded on first use.

  • Master retuned the multilingual repetition_penalty default from 2.0 to 1.2; the registry and its test mirror that.

  • The dtype shim is independent of this snapshot — the bug exists in both the wheel and master, so the shim stays either way.

Model weights

Weights are not bundled. They download from Hugging Face on the first speak() call and are cached in the standard location (%USERPROFILE%\.cache\huggingface\hub on Windows, override with HF_HOME). Subsequent calls reuse the cache.

  • turbo → ResembleAI/chatterbox-turbo

  • nano → ResembleAI/chatterbox-nano (a separate repo, not a subset of turbo)

  • multilingual / original → ResembleAI/chatterbox

Nothing is downloaded by doctor or tts_status, so those stay fast and work offline.

Putting the cache where you want it

HF_HOME is the official Hugging Face switch: every HF tool (huggingface-cli, transformers, huggingface_hub, datasets) resolves its cache root from it. Setting it system-wide is the right thing to do, and it is what you want. This server adds one variable of its own, CHATTERBOX_HF_HOME, and the two are not alternatives to each other — CHATTERBOX_HF_HOME simply sets HF_HOME (see engine.py:_apply_hf_home). If the two are set to the same path, nothing is in conflict and nothing is duplicated in effect.

So why is a second spelling needed at all? Because a system-wide HF_HOME can fail to reach the server, and that failure is silent. Two things are involved.

1. Not every harness forwards your environment to MCP subprocesses. This is the mechanism worth being precise about, because it is easy to misdiagnose. Environment variables are not global to the machine at runtime — each process receives an environment block from its parent, and a parent may spawn a child with a subset of its own. When a harness builds the child environment from only the env: block in its own config, your user-level HF_HOME is simply absent from that child. The server then has no HF_HOME, and huggingface_hub falls back to the default under C:\ — no error, just a download to the wrong drive.

Worse, the env: block itself turned out not to be a dependable carrier. LM Studio's mcp.json and Hermes both accept one, both are spec-correct, and neither was observed forwarding it to the server subprocess in practice — every CHATTERBOX_* setting quietly fell back to its compiled default. This is why the server now reads a project .env file it opens itself; see Configuration. That file is the source of truth, and it makes CHATTERBOX_HF_HOME work regardless of what the harness chooses to pass.

This is not about PATH. PATH is the list of directories searched for executables and has nothing to do with where a cache is written. The variable that matters is HF_HOME, and the thing that failed was process environment inheritance, not a path lookup.

2. HF_HOME must be set before huggingface_hub is imported. huggingface_hub reads and seals HF_HOME into its constants module on first import, which happens the moment a Chatterbox model class is imported. Setting the variable later in the process has no effect at all. This server therefore applies CHATTERBOX_HF_HOME to HF_HOME inside engine.load() — immediately before load_model_class(), and before anything has touched huggingface_hub — so the cache root is fixed at the only moment that matters.

Together these are why setting the cache path in the project .env is the robust move:

CHATTERBOX_HF_HOME=/path/to/hf-cache   # sets HF_HOME before the model import

Setting it here and in your system environment to the same path is fine — the server detects the match and does nothing. Forward slashes work on Windows; left unset, the variable leaves HF's own default untouched rather than pointing it somewhere arbitrary.

The symptom, if you get it wrong: the first speak() after a config change takes minutes again, and a second copy of the model appears under C:\Users\<you>\.cache\huggingface\hub\models--ResembleAI--chatterbox-<model>. HF_HOME is sealed per process, so a harness restart re-reads config while a long-running server does not — check .env, and the server's startup log line, when hunting this. The ready line records which config file was read.

Corollary for nano. Because nano is a separate HF repo, switching CHATTERBOX_MODEL between nano and turbo (either direction) is a fresh download, not a reuse of an already-cached sibling. A machine with turbo cached still pays a full nano fetch the first time — into whichever cache root is actually in effect, so get the cache path right before that first nano call.

Registering the MCP server

Settings go in .env, not here. The server reads .env from the repo root at startup, so registration only needs to say how to launch it:

{
  "mcp": {
    "mcp-agent-chatterbox": {
      "type": "local",
      "command": [
        "uv", "run", "--directory", "<absolute path to this repo>",
        "mcp-agent-chatterbox", "serve"
      ],
      "enabled": true
    }
  }
}

Copy .env.example to .env and edit that — see Configuration for the full key list and the reason this indirection exists. Nothing is loaded at startup, so an enabled: true server stays idle until a tool call arrives.

The environment block, and why it is not the source of truth

Most harnesses accept an env / environment block and some do honour it. It is kept here as a per-harness override — useful for pointing one client at a different GPU or model than the rest — but it must not be the only place a setting lives:

      "environment": {
        // Overrides for THIS client only. The durable value belongs in .env.
        "CHATTERBOX_MODEL": "turbo",
        "CHATTERBOX_GPU_INDEX": "0"
      }

Precedence is environment over .env: a variable already present in the process environment is never overwritten by the file. That is what makes the override above work, and it is also why a one-off CHATTERBOX_MODEL=turbo in a shell, or a test's monkeypatch, always wins.

The trap this arrangement exists to close: LM Studio's mcp.json and Hermes both accept an env block, both are spec-correct (env is the key — there is nothing to rename), and neither forwarded it to the server subprocess in practice. With .env in play that failure is inert, because the file is read by the server itself and no harness has to pass anything.

YAML-based harnesses (clients that configure MCP servers in a .yaml file rather than JSON) take the same command, with command/args split:

  chatterbox:
    command: uv
    args:
      - run
      - --directory
      - <absolute path to this repo>
      - mcp-agent-chatterbox
      - serve
    env:                     # optional overrides; .env holds the durable values
      CHATTERBOX_MODEL: turbo
      CHATTERBOX_GPU_INDEX: '1'

Quote the numeric-looking values ('1') — YAML would otherwise read them as integers, and a non-string env value is a harness-specific coin flip.

LM Studio / Claude Desktop / other strict-JSON clients: the same command, command split from args, and the environment named env:

{
  "mcpServers": {
    "mcp-agent-chatterbox": {
      "command": "uv",
      "args": [
        "run", "--directory", "<absolute path to this repo>",
        "mcp-agent-chatterbox", "serve"
      ],
      "env": {
        "CHATTERBOX_MODEL": "turbo",
        "CHATTERBOX_GPU_INDEX": "1"
      }
    }
  }
}

Restart the client. The server speaks stdio, loads no weights at startup, and stays responsive while the model is idle. Its first log line records the config it resolved, so a wrong setting is visible without a tool call:

[server] mcp-agent-chatterbox v0.1.0 ready (output=…, voices=…, model=nano, voice=seven, config=I:\…\.env)

Skill: teaching an agent to answer by voice

The MCP server's instructions field and the speak tool description carry the trigger rule ("when the user asks for a spoken reply, call speak()"), so any client sees it in the tool schema. Some harnesses additionally load Agent Skills — a SKILL.md file with workflow instructions — which gives a small local model much more to hold on to: what counts as a voice request, that one instruction persists for the whole exchange, that the spoken text is the model's own answer (never a repetition of the user's message), spoken register (no markdown/lists/paths), which model and voice to pick (prefer nano for English; ask the user which voice once, then remember it, unless the server configures a default), how to summarize a long answer for the ear, and the failure modes to avoid retrying.

This repo ships one at skills/voice-output/SKILL.md.

Install in LM Studio Bionic: Settings → Skills → add skills/voice-output/SKILL.md, or ask Bionic to install the skill from this repo's URL. Trigger it explicitly with @voice-output in the composer, or invoke it with a phrase like "reply by voice from now on".

Install in opencode: copy the folder into the global skills directory:

Copy-Item -Recurse skills\voice-output $env:USERPROFILE\.config\opencode\skills\

Any other harness that speaks the Agent Skills format (Codex, Claude Code, and compatible tools) can consume the same file.

The opencode plugin

.opencode/plugins/tts.js installs the repo's commands — /speak, /speak-stop and /tts-status — into opencode. It self-replicates into ~/.config/opencode/plugins/ the first time opencode loads it from this repo, and copies every *.md in .opencode/commands/ into the global commands directory, so the commands are available in every project rather than only sessions started inside this folder. The repo copy is the source of truth; edit there, never the global replica. Removing a command from the repo does not delete the installed copy — install is not reaping.

The plugin deliberately does not auto-speak assistant replies: loading a model costs seconds and VRAM, and unsolicited audio on every turn would be awful. Speaking stays an explicit choice, by tool call or by /speak.

The panel

A page with the controls that have no home in a chat window: Stop playback, Replay last, a play button on every message still on disk, Unload model, a Start worker / Stop worker toggle in the header, and a text box that speaks what you type. Started on demand, nothing autostarts:

uv run mcp-agent-chatterbox panel            # serves http://127.0.0.1:8766/ and opens it
uv run mcp-agent-chatterbox panel --no-open  # just serve it
uv run mcp-agent-chatterbox panel --port 9000
uv run mcp-agent-chatterbox panel-stop       # ask a running panel to shut down
uv run mcp-agent-chatterbox panel-stop --port 9000

It is one HTML file and the standard library's http.server — no toolkit to install, no build step. It plays no audio itself and holds no state: every voice button is one op forwarded to the resident worker, which owns the queue, the model and the output directory. The two lifecycle buttons are the exception — they call worker.spawn() and send the shutdown op — but they still own no model, so the split holds. That split is not tidiness, it is what makes Stop honest — playback is winsound, whose sound belongs to the thread that started it, so only the worker's own playback thread can cut a chunk short. A panel that played the WAVs itself would be uncancellable from the worker, and pressing Stop playback would only land between chunks.

Three different things can be stopped, so the panel names them separately: Stop playback cuts the sound, Unload model releases the model's VRAM but keeps the worker running (the next utterance reloads from disk in seconds), and Stop worker drains the queue and exits the process. The header toggle reads Start worker whenever no worker answers, so the page can bring one back after the GPU has been lent to ComfyUI or a game. Starting a worker loads no model — it is an idle listener until something speaks — so borrowing the toggle costs no VRAM, and the worker stays detached: quitting the panel does not stop it.

A cold start says so. The first utterance after a worker start or an unload has to read the weights before it can make a sound, which used to look like a queued job that never spoke. The pill reads loading model…, the detail line names the model being loaded, and the queue line still shows the job waiting — so the wait is explained rather than merely endured. Measured on this machine a full cold start (torch import included) is around 45 s; a reload after an unload in a worker that already imported torch is much quicker, so the page promises no number.

Unloading is reported, not confirmed. The header pill reads unloading model… while the request is out, and once the weights are gone the detail line keeps the price paid — model not loaded · last unload freed 2396 MB · the next utterance loads it first — until they are resident again. No action in this page opens a modal: every one of them is cheap and reversible, so a dialog would only interrupt the page that describes the state. Judge a model load by consequence, not by ceremony.

Replay is free. Every message that was ever spoken left its chunks in tts_output/ as <message_id>_<n>.wav, so repeating one is a file read, not a render: no GPU time, no model load, and it works even after tts_unload. A replay job goes through the same queue as live speech, so it never talks over an utterance that is still rendering, and Stop playback cuts it off like anything else. The files are the history, which also means the list survives a worker restart — and that deleting old tts_output/*.wav files is how you shorten it.

Each row shows what it says. A message id and a byte count do not tell you which utterance a play button belongs to, so every row carries the message text in the space between its play button and the file data. Reading it means reading the files, which the panel does anyway: the chunk ICMT tags are concatenated in playback order, cached by path, modification time and size so a two-second poll over a directory of old chunks costs stat calls rather than file reads. Idle, the text is one clipped line with an ellipsis; hovering the row scrolls it like a news ticker at 60 px/s — about eleven characters a second, followable without crawling — and leaving resets it to the start. The scroll distance is measured from the row's real overflow, so a short message does not scroll at all and a long one takes as long as it needs instead of running faster. Files rendered before the tags existed show no transcript recorded. A poll that reports an unchanged history does not rebuild the rows, because replacing the node under the cursor would stop the ticker every two seconds. With prefers-reduced-motion the text wraps in place on hover instead of moving.

Two deliberate limits: the panel is bound to 127.0.0.1 (not reachable from another machine), and every action requires a custom header that a foreign web page cannot set without a CORS preflight this server never approves, plus a loopback Origin when one is sent. Any page you visit can otherwise POST to a localhost port and unload your model.

The page carries no external assets, so it renders the same in a normal browser and inside an embedded browser pane. There is no desktop app integration to build: it is a URL.

Stopping it. Ctrl+C works, but it is no longer required: the header has a Stop panel button and there is a panel-stop command (--port to target a non-default port). Both post to /api/panel/stop, which shuts the server down from inside; the page replaces itself with a "Panel stopped" message instead of polling a dead server. The resident worker is untouched — stopping the panel does not stop speech.

Stop playback cuts the sound, not just the queue. A blocking play starts the WAV asynchronously and waits in its own thread, checking a stop flag every 20 ms, so pressing Stop playback silences the current chunk instead of waiting for it to end. A stopped chunk counts as played rather than as a playback error.

Further improvements to this web-based panel are envisioned — per-message deletion, a progress bar for the chunk being spoken, a compact layout for narrow panes. None of them changes the design above: a page, a queue owner, and one op per button.

Tools

speak

The main call. Accepts text, hands it to the resident worker, and returns as soon as the worker has accepted it — synthesis and playback then run to completion in the worker, regardless of what the caller does with the result. The worker renders it chunk by chunk into <message_id>_<n>.wav and plays the chunks in order. Rendering runs on its own thread, a few chunks ahead of the player, so a boundary costs the configured pause, not a full render's worth of silence.

If no worker is running it returns worker_not_running with the command to start one. It never falls back to synthesizing in the server process.

Parameter

Default

Meaning

text

—

What to say. Required.

model

nano

nano | turbo | multilingual | original

voice

server setting (CHATTERBOX_VOICE)

Reference clip name in the voices dir (filename without extension). Required for multilingual/original

reference_clip

—

Path to a wav/mp3/flac, as an alternative to voice

language

—

ISO 639-1 code, multilingual only

t3_model

v2

v2 | v3, multilingual only

exaggeration

per model

Emotional range. 500M models only — turbo ignores it

cfg_weight

per model

Guidance strength. 500M models only, turbo 0.0 / 500M 0.5

temperature

per model

Sampling temperature (0.05–5.0)

top_p

per model

Nucleus-sampling cutoff (0.0–1.0)

top_k

per model

Top-k sampling size (0–1000). Turbo only — the 500M models have no such parameter

repetition_penalty

per model

Penalise repeated tokens (1.0–2.0)

norm_loudness

per model

Normalize to −27 LUFS. Turbo only — the 500M models have no such parameter

seed

random

Reseed torch for a reproducible re-render within the resident session; 0 means random. Not guaranteed byte-identical across a restart

play_audio

server setting

false writes the chunk files only

filename

generated

Base name for the chunk files; they are written as <filename>_1.wav, <filename>_2.wav, ... A millisecond-timestamped message id is used when omitted

Style parameters left unset keep each model's own tuned values rather than a single global default — turbo is tuned for latency at cfg_weight=0.0, the 500M models for fidelity at 0.5, and forcing one number onto all three audibly degrades two of them. A knob a model does not support (turbo ignores exaggeration/cfg_weight; the 500M models take no top_k/norm_loudness) is dropped with a warning rather than crashing.

Text is split into chunks of whole sentences — CHATTERBOX_SENTENCES_PER_CHUNK of them per chunk (3 by default), because Chatterbox's own generate() truncates long inputs (turbo degrades past roughly 600 characters and comes back shorter than a shorter prompt), so a single oversized render would be garbled. Every chunk is a separate generate() call, and each boundary costs a pause plus a fresh prosody start, which is why short sentences are batched rather than spoken one at a time — with one sentence per chunk a quick, clipped reply sounds staccato. The first sentence always stays alone so audio starts as soon as it is rendered, and a batch never exceeds CHATTERBOX_MAX_CHUNK_CHARS characters; a single sentence longer than that is split further on word boundaries. The chunks share the same voice, style knobs and reference clip, and are played with CHATTERBOX_CHUNK_PAUSE_MS of silence between them.

A batch is also capped by speaking time, not just by sentence count. Measured on this stack, speech runs at about 17.5 characters per second and rendering costs about 0.4× the audio it produces — so a chunk may be at most ~2.2× as long as the one before it and still be ready before that one finishes playing. Chunks therefore ramp up from the short opening sentence instead of jumping straight to a full batch. Without that rule the second chunk could take longer to render than the first takes to play, and the player would sit silent with text still waiting.

Playback waits until roughly two seconds of audio are already on disk, which covers an opening sentence too short to hide the next render, then keeps up to three rendered chunks banked ahead. Nothing is timed at runtime: the queue paces the renderer, so a machine that renders faster simply stays further ahead.

On acceptance speak reports the message id, the chunk count, the file names the chunks will be written to and how many utterances are queued ahead — it does not wait for the utterance to play. Once it has finished, tts_status's worker.last carries the record of what was written and played: the measured synthesis and audio durations, first_audio_s (submission to the first audible chunk) and max_gap_s (the worst silence the player could not avoid), so dryness is measurable instead of something you have to hear. Playback is strictly in order while rendering runs ahead, so the first sentence is audible within seconds and the rest follow without a render gap between them. Files stay on disk and replay in filename order; there is no stitched final file. stop_speech cuts the current sentence and cancels everything still queued. Because the audio belongs to the worker, it keeps playing even if the MCP server process goes away when the turn ends.

// a quick aside in the stock voice
{ "text": "Build finished. All tests pass.", "model": "turbo" }

// German, cloned voice
{ "text": "Der Build ist fertig.", "model": "multilingual", "language": "de" }

// expressive narration — exaggeration tunes the 500M models; turbo ignores it
{ "text": "And then [chuckle] it compiled on the first try.", "model": "original",
  "exaggeration": 0.4 }

What is inside the files

Each chunk file carries a standard RIFF LIST/INFO tag block describing the audio it contains, written by audio.write_wav:

Tag

Value

INAM (title)

<message_id> #<chunk>/<total>

ICMT (comment)

the text of that chunk — concat the chunks in order for the whole message

ICRD (date)

the day it was rendered

ISFT (software)

mcp-agent-chatterbox <version>

The transcript is per chunk, not per message, so a 4000-character reply is not copied into every file. A 500-character chunk adds about 650 bytes against roughly a megabyte of audio — under 0.1%, and ffprobe, foobar2000, VLC and Windows read the tags while any reader that does not know the chunk skips it, so playback is unchanged. The panel is the first consumer: it reads ICMT back out of the files to label each history row, which is why a row can show its text without the worker remembering anything.

ffprobe -v error -show_entries format_tags -of default output/1770000000123_2.wav
# TAG:title=1770000000123 #2/3
# TAG:comment=Grüße aus München. Das ist die zweite Gruppe von Sätzen.

What this means for privacy. The text is now on disk in a searchable form, where before it was only audible. The project still never logs spoken words — the log is a different medium and stays size-only — but the WAVs (and anything that indexes or backs them up) now contain the transcript verbatim. Delete output/*.wav to remove it, the same way you shorten the panel's history.

What it actually costs

Measured 2026-10-21 on the development machine (Ryzen 5 5600, RTX 3060 12 GB, nano, voice seven, cuda:1), same 1494-character, 21-sentence text twice — once on a freshly started worker, once warm. Nine chunks both times.

Run

Audio

Synthesis

First audio

Worst gap

Wall clock

Cold worker

84.0 s

36.9 s

47.1 s

0.0 s

134.4 s

Warm worker

81.9 s

34.7 s

2.1 s

0.0 s

87.3 s

Read the warm row as the shape of the pipeline: first_audio_s plus the audio duration plus the pause budget accounts for essentially the whole wall clock (2.1 + 81.9 + 2.0 = 86.0 s against 87.3 s measured), and max_gap_s is zero, so every boundary was covered by a chunk that had finished rendering while the previous one played. Cold and warm wall clock here is the worker pipeline (submission to the last chunk spoken), not the tool call — since speak returns on acceptance, the call itself lasts milliseconds. The cold row is the same pipeline behind a model load — the first generate() pays for torch, the weights and CUDA init, and there is nothing to play until it returns. That cost belongs to the first call of a worker session, not to the streaming design.

Chunk lengths in that run, in seconds of audio: 4.4 → 8.1 → 11.8 → 15.1 → 16.3 → 4.8 → 5.8 → 9.0 → 8.8. The ramp at the start is the duration cap at work. The short 4.8 s chunk after the 16.3 s one is what the buffer is for: by the time the long chunk finished, the following ones were already on disk, so the short one never had to be waited for. The dangerous direction is the reverse — a short chunk followed by a much longer one — and that is exactly what the cap keeps out of the list. Absolute numbers are machine-specific; first_audio_s and max_gap_s in the speak response are the two figures to compare on your own hardware.

tts_status

Readiness without loading anything: whether the worker is running, the models available, the configured default voice (CHATTERBOX_VOICE), the multilingual language list, and the tags turbo understands. When the worker is up it also reports its resolved device, free VRAM per GPU, the speech queue, and the model's load state — model.loaded, model.name, and model.loading, which names the model being read from disk right now. That last field is what makes a cold start visible: the first utterance after a start or an unload spends its opening seconds loading weights, and without it the only symptom is silence. When it is down, worker_running is false and worker_note says what to do — the rest of the response is still accurate, which is what you want when the missing piece is the worker itself.

tts_unload

Asks the worker to release the resident model and return its VRAM. Needed when another process wants the card. Only one model is resident at a time — asking for a different one evicts the previous automatically — but this frees it entirely. With no worker running there is nothing to release, and the reply says so rather than pretending it worked.

list_voices

The reference clips available for cloning, with names to pass as voice=.

{
  "status": "ok",
  "voices_dir": "<absolute path to the voices directory>",
  "count": 3,
  "voices": ["<clip name>", "<clip name>", "<clip name>"],
  "accepted_formats": [".wav", ".mp3", ".flac", ".ogg", ".m4a"],
  "requires_reference_clip": ["multilingual", "original"],
  "note": "Pass the name as voice= to speak()."
}

requires_reference_clip lists the models that cannot speak without a clip (multilingual, original — the two 500M models, which ship no built-in voice). turbo and nano are absent because they have a stock voice and accept an optional clip on top of it.

Renamed from usable_with. The old field name read as "the models that accept a clip", which is every model — cloning works on turbo and nano too, they just fall back to the built-in voice when none is given. So the name described the opposite of what the list contained, and only happened to look correct because the models that require a clip are a subset of those that accept one. Any client keying off usable_with now reads null and silently gets an empty list — if you have a saved prompt, script or test matching that key, update it to requires_reference_clip.

stop_speech

Asks the worker to cut off the sentence that is playing and cancel every utterance still queued.

Voice cloning

Put reference clips in voices/ (create it; it is not tracked by git). Then refer to a clip by its filename stem:

voices/<your-clip-name>.wav   →   speak(text, voice="<your-clip-name>")

Names resolve case-insensitively, and a unique prefix works (voice="sev"). An ambiguous prefix returns the candidates rather than picking one.

How much reference audio to use

Give it as much clean single-speaker speech as you have. Do not trim a good recording down to a short excerpt.

This tool's own documentation used to say "5-15 seconds". That is wrong, and it was wrong in the direction that costs quality, so it was replaced with what an A/B test actually found. Comparing a 40.1 s reference against an 11.8 s excerpt of that same recording — five texts per arm, plus a same-reference re-render arm as the noise floor — gave this for impulsive discontinuities per second (adaptive Laplacian impulse detector):

Threshold

40.1 s reference

11.8 s excerpt

noise floor

k=6 fine

283.6

459.0

5.1

k=10 moderate

108.8

173.6

7.7

k=20 large

72.5

80.1

5.2

The short reference produced ~36 % more moderate and ~44 % more fine impulses, several times the model's own sampling variance, and the effect survived controlling for gain. Worst-spike magnitude, HF energy and zero-crossing irregularity were indistinguishable, so what degrades is fine crackle rather than loud pops. The failure mode is too little audio, not too much.

We did not test 20 s, 60 s or longer, so this does not establish an optimum — only that truncating a good recording to "5-15 s" measurably degrades it. Under about 5 s is genuinely too little.

Three more things that measurement contradicted:

  • A processed or mastered source is fine. The 40 s reference above was an Audacity-mastered file and was the better of the two. Compression is not the problem; shortness is.

  • Do not normalise, gain-match or limit the reference. Applying +23 dB of peak normalisation to the excerpt moved rendered output level by 12.1 dB (RMS −23.1 vs −35.2 dBFS, against a 0.35 dB level noise floor), and the hotter output was measurably grainier. Leave the source level alone.

  • Sample rate and channel count need not match anything. The 40 s reference was 48 kHz stereo and needed no preparation; Chatterbox resamples and downmixes internally.

What you still control: no music, no background noise, no second speaker, and prefer continuous speech over silence-padded audio.

Caveat on all of the above: n=5 per arm on a single speaker, and the detector measures discontinuities, not timbre, prosody or speaker similarity. It quantifies one artefact class, not overall quality — your own listening is still the arbiter.

list_voices reports each clip's duration, sample rate and channel count, and flags clips short enough to be worth warning about, precisely because duration is what predicted quality here.

CLI

# the synthesis worker: start this once, leave it running
uv run mcp-agent-chatterbox worker
uv run mcp-agent-chatterbox worker --port 8765
uv run mcp-agent-chatterbox worker-status
uv run mcp-agent-chatterbox worker-stop

# diagnostics: device, per-GPU VRAM, models, voices
uv run mcp-agent-chatterbox doctor

# synthesize without going through MCP
uv run mcp-agent-chatterbox speak "Build finished." --model turbo
uv run mcp-agent-chatterbox speak "Guten Morgen." --model multilingual --voice <your-clip-name> --language de
uv run mcp-agent-chatterbox speak "..." --no-play
uv run mcp-agent-chatterbox speak "..." --wait   # block until rendering and playback finish

# the control panel (stop playback, replay, speak by hand, start/stop the worker)
# on http://127.0.0.1:8766/
uv run mcp-agent-chatterbox panel

# run the server directly
uv run mcp-agent-chatterbox serve

The console script is only on PATH inside the project (or via uv run). To call it from anywhere, install a tiny shim — see Install the mac shim.

worker runs the resident process in the foreground; it loads the model on the first utterance and keeps it loaded. speak shares the same code path; by default it accepts the job and returns immediately, rendering and playing in a detached background process so the shell stays free for the next command (output goes to logs/speak_background.log), and --wait blocks until the queue is drained. It is the fastest way to check a fresh install without involving MCP at all. panel is the only command that needs no GPU: it renders nothing and forwards every action to the worker, so it is also safe to leave running while another job owns the VRAM.

Install the mac shim (to run it from anywhere)

pip/uv installs the console script inside this project's environment only, so from any other directory mcp-agent-chatterbox is not a command. That is the one thing that bites in practice: you start a resident worker once, and you want to do it from wherever you happen to be, not from the repo. A one-line .cmd shim on PATH closes that gap.

Nothing extra is shipped for this — the shim is three branches and belongs in your bin directory, not in the repo (it embeds an absolute path that differs per machine). Create the file yourself:

1. Pick a directory that is already on PATH. %USERPROFILE%\bin is the convention this stack uses; confirm it resolves:

$env:PATH -split ';' | Select-String -SimpleMatch "$env:USERPROFILE\bin"

If that prints nothing, either use a directory that is on PATH, or add this one permanently (then open a new terminal):

[Environment]::SetEnvironmentVariable(
  'Path',
  [Environment]::GetEnvironmentVariable('Path', 'User') + ";$env:USERPROFILE\bin",
  'User'
)

2. Create %USERPROFILE%\bin\mac.cmd with your own repo path in REPO:

@echo off
REM mac - run mcp-agent-chatterbox from any directory.
setlocal
set "REPO=<absolute path to this repo>"

REM Preferred: uv resolves the project environment itself.
where uv >nul 2>&1
if %ERRORLEVEL% equ 0 goto :use_uv

REM Fallback: the project venv, for machines without uv.
if exist "%REPO%\.venv\Scripts\python.exe" goto :use_venv

echo mac: mcp-agent-chatterbox not found.
echo   tried: uv run --directory "%REPO%" mcp-agent-chatterbox
echo   tried: "%REPO%\.venv\Scripts\python.exe" -m mcp_agent_chatterbox
echo Is the repo path right, and has the environment been synced?
exit /b 1

:use_uv
uv run --directory "%REPO%" mcp-agent-chatterbox %*
exit /b %ERRORLEVEL%

:use_venv
"%REPO%\.venv\Scripts\python.exe" -m mcp_agent_chatterbox %*
exit /b %ERRORLEVEL%

3. Verify from a directory that is not the repo — this is the whole point:

cd C:\
mac worker-status

Expect either worker on 127.0.0.1:8765: idle or a no worker on ... line. Both prove the shim reached the package; only the first proves a worker is running.

Why two branches: uv run is the fast path and cannot drift from the lockfile, but it is useless on a machine without uv. The venv fallback needs no tooling beyond the synced environment. %* forwards every argument and exit /b %ERRORLEVEL% keeps the exit code, so mac worker-status still exits non-zero when no worker is up — which is what makes it usable in a script.

Do not wrap the two commands in if (...) blocks. In batch, %ERRORLEVEL% inside a parenthesised block is substituted when the block is parsed, not when each line runs, so exit /b %ERRORLEVEL% would return the status from before the command — 0 every time. That silently turns a failing mac speak into a successful one. Labelled goto branches keep each %ERRORLEVEL% on a line of its own, read after the command it belongs to.

Then from anywhere: mac worker, mac worker-status, mac worker-stop, mac doctor, mac speak "...".

Web surface (HTTP transport)

Yes, there is one, and it is the least-comfortable part of this server. Read this before binding it to anything but loopback.

# streamable-http on loopback (the default host)
uv run mcp-agent-chatterbox serve --http --port 8123
# endpoint: http://127.0.0.1:8123/mcp

serve takes --http (a flag), not --transport <name>. The underlying server.run() also accepts "sse", but no CLI path reaches it — it is library-only and deprecated in the MCP spec. Prefer streamable-http.

What was verified. A real MCP client over real uvicorn: 5 tools advertised, tts_status, speak and tts_unload all correct, plus a 4-client concurrent burst on a cold engine that produced exactly one weight load. That last one is the load-bearing test — see the concurrency note below.

No authentication. None. There is no token, no TLS, no per-client identity. --host defaults to 127.0.0.1, which is the only thing keeping this off your network. --host 0.0.0.0 publishes an unauthenticated text-to-speech endpoint to every host that can route to you. Do not do that on an untrusted network.

One process, one engine, all clients share it. The HTTP server builds a single AppContext, so:

  • stop_speech() stops playback for every connected client, not just yours.

  • tts_unload() frees the GPU out from under any in-flight speak.

  • There is no per-client output directory; all sessions write to the same output_dir.

Concurrent clients are a queue, not a parallel server. Measured with 4 simultaneous clients: per-call wall times of 1.63s / 3.34s / 4.72s / 6.22s — a staircase, because synthesize() holds an engine-wide lock (see below). Four clients took 6.4s where a parallel server would take ~1.6s. That is the correct trade for one GPU and one resident model: correctness over throughput. tts_status deliberately takes no lock and stays instant even mid-queue.

The concurrency bug this surface would have had. The engine was written for stdio, where the transport serialises one client's calls, so load() was an unlocked check-then-act over a process-global model cache. Under HTTP that is a live race: two threads both miss the cache check and both call from_pretrained() — 4 copies of turbo's weights on a 12 GB card that the VRAM preflight had already cleared — and a request for a different model could gc.collect() + torch.cuda.empty_cache() while another request was still generating. load(), unload() and synthesize() are now serialised behind a re-entrant lock, and tests/test_concurrency.py fails if that lock is removed.

Treat it as single-operator. It is fine for a local dashboard, a second harness on the same box, or testing. It is not an authenticated service.

Configuration

Settings live in a .env file at the repo root, read once at startup. Copy .env.example and edit it:

cp .env.example .env

The file documents every key inline. .env is git-ignored — it holds machine-specific values (paths, GPU choice, the model you have cached) — while .env.example is tracked and is the reference list.

Why a file and not just the harness's env block? Because env blocks are not reliably delivered. LM Studio's mcp.json and Hermes both accept one, both are spec-correct, and neither was observed forwarding it to the server subprocess — the server came up on its compiled defaults while the config asked for something else, silently. A file the server opens itself has no such dependency: the same settings apply under LM Studio, Hermes and opencode, and there is one place to edit. See The environment block for using env as a per-harness override on top of this.

Precedence is environment-over-file. A variable already present in the process environment is never overwritten by .env. So CHATTERBOX_MODEL=turbo uv run …, a CI job, or a test's monkeypatch all win over the file; the file holds the durable value.

No secrets are involved.

Variable

Default

Meaning

CHATTERBOX_MODEL

nano

Model used when speak names none

CHATTERBOX_VOICE

unset

Default reference clip (name in the voices dir) used when a speak call names neither voice= nor reference_clip=. Skips the tool-level reference_clip_required for multilingual/original, and applies to turbo/nano too. A name that does not exist fails fast with voice_not_found — never a silent substitution

CHATTERBOX_DEVICE

auto

auto | cpu | cuda | cuda:N

CHATTERBOX_GPU_INDEX

unset

Pin a CUDA ordinal. Overrides the auto heuristic

CHATTERBOX_OUTPUT_DIR

tts_output

Where WAVs are written

CHATTERBOX_VOICES_DIR

voices

Where reference clips live

CHATTERBOX_LOGS_DIR

logs

Rotating log file location

CHATTERBOX_AUTOPLAY

1

Play each chunk as it renders

CHATTERBOX_MAX_CHARS

4000

Reject longer single requests

CHATTERBOX_MAX_CHUNK_CHARS

500

Hard cap for one rendered chunk — one sentence, or a whole batch. A single sentence longer than this is split on word boundaries

CHATTERBOX_SENTENCES_PER_CHUNK

3

Sentences batched into one chunk. Every chunk is a separate generate() call costing a pause and a fresh prosody start, so 1 makes short-sentence speech staccato. The first sentence always stays alone (fast start); a batch never exceeds CHATTERBOX_MAX_CHUNK_CHARS, and it is capped by speaking time too, so it can never grow past ~2.2× the playback length of the chunk before it

CHATTERBOX_CHUNK_PAUSE_MS

250

Silence between played chunks — between sentence groups when batching, not between every sentence

CHATTERBOX_WORKER_PORT

8765

Loopback port the worker listens on. The server and the worker read this same value; point them at different ones and speak reports worker_not_running while a healthy worker sits on the other port

CHATTERBOX_WORKER_CONNECT_TIMEOUT

5

Seconds to wait for the connection to the worker. Bounds the connect only, never the read — the dispatch loop answers one caller at a time, so a request may wait behind another connection, and cutting the read off would turn that wait into a false failure

CHATTERBOX_VRAM_MB

4096

Free-VRAM floor for a load attempt

CHATTERBOX_STRICT_VRAM

0

1 refuses a load below the floor instead of warning

CHATTERBOX_HF_HOME

unset

Where the Hugging Face cache lives. Does not compete with HF_HOME — it sets it, at load time, before huggingface_hub seals the value on import. Set it in .env so the cache root cannot be lost to a harness that does not forward your environment. See Model weights

Relative paths resolve against the project root, never the process CWD, since MCP harnesses spawn servers with an unpredictable working directory.

Windows note: use forward slashes in path values. Backslashes are not reliably handled by python-dotenv.

The server's first log line states exactly which config was resolved, so a setting that failed to arrive is visible without issuing a tool call:

[server] mcp-agent-chatterbox v0.1.0 ready (output=…, voices=…, model=nano, voice=seven, config=I:\…\.env)

Choosing a GPU

auto picks the CUDA device with the most free memory, which matters when one card is occupied by something else — a loaded LLM, a rendering job. On a box where LM Studio holds both GPUs, doctor shows the headroom per card and CHATTERBOX_GPU_INDEX pins a specific one.

Design notes

The worker owns the model; the server owns the protocol. The measurement behind that split, and why in-process synthesis stalled, is in How the pieces fit. What follows from it here: requests are one JSON line each way, one request per connection, and the worker is strictly single-threaded — one GPU, one resident model, and a Chatterbox model instance carries mutable state. Nothing may end that loop: a handler exception is logged and returned as worker_error rather than allowed to kill a resident process that the next utterance also depends on.

A card that cannot be queried is skipped, not fatal. If torch.cuda.mem_get_info() throws for every ordinal — a driver that has not finished initialising, a card held by another process — auto falls back to CPU instead of raising. A gpu_index pin outside the visible range is the one case that errors, because there the user asked for a specific card and silently using another would be worse than failing.

One resident model. MAX_RESIDENT = 1. A 350M turbo model and a 500M multilingual model do not both fit on a 12 GB card that already has an LLM resident. Asking for a different model evicts the previous one and empties the CUDA allocator cache.

The engine is locked; the status reads are not. Inside the worker, load(), unload() and synthesize() serialise behind one re-entrant lock, because the model cache is process-global and a single model instance is not safe to call generate() on from two threads. The worker's accept loop is single-threaded on top of that, so only one speak is ever in flight. tts_status, gpu_report and free_vram_mb() deliberately take no lock, so a ten-second cold weight load never stalls a diagnostic call — which is the call you need while staring at a slow first request. The lock matters for HTTP, where the engine would otherwise be reached concurrently; it was the difference between working and OOM-ing there. The reasoning and the measured numbers are in the web-surface section.

The device string goes straight to from_pretrained(). Chatterbox's own .to(device) is incomplete upstream: in ChatterboxTTS and ChatterboxMultilingualTTS it moves only t3 and gen, leaving ve, s3gen and conds on the old device and never updating self.device — so generate() would then move tensors to the wrong place. In ChatterboxTurboTTS it iterates a gen attribute that __init__ never sets, so it raises AttributeError. There is therefore no load-on-CPU-then-move path, which is why free VRAM is checked before a load instead of after a failure.

VRAM is checked before, not during. Because weights land directly on the device, a load started with too little headroom dies part way through reading the checkpoint with a bare torch.cuda.OutOfMemoryError, after the download. The engine queries free VRAM first, warns (or refuses under CHATTERBOX_STRICT_VRAM), and maps a genuine OOM onto an actionable cuda_out_of_memory payload.

Weights are never loaded at startup. The server builds no engine at all, and a status or voice-listing call must not pay the multi-second torch import plus a multi-gigabyte fetch. The worker, which is started deliberately and lives for hours, loads the model on its first utterance and keeps it resident.

Dependency advisories

Dependabot reports against everything in uv.lock, including packages nobody imports. All currently open alerts are closed; this section records how, so nobody "fixes" one of them the wrong way.

Pillow — overridden to 13.0.0. The lock used to hold 12.3.0, which carries 41 advisories (5 critical, 23 high, 13 moderate), every one of them first patched in 13.0.0. Nothing here depends on Pillow directly: it is in the tree only because chatterbox-tts pulls in gradio and diffusers, and gradio declares pillow<13.0,>=8.0 — exactly the vulnerable range. No gradio release lifts that cap (6.31.0, the newest on PyPI, still says <13.0), so [tool.uv] override-dependencies raises it the same way the torch and gradio pins are raised. Safe by construction rather than by assumption: importing speak, engine or panel leaves PIL unloaded, gradio is never launched, the only consumer is diffusers (which imports PIL during the model load), and every advisory needs attacker-supplied image bytes reaching a decode or encode path — this package's inputs are text and reference-clip audio.

certifi — bumped. 2026.7.22 bundled emSign root CAs with compromised intermediate keys; the lock now carries 2026.10.19. This one mattered because it is the CA bundle used for HTTPS when model weights are fetched from Hugging Face.

setuptools, starlette, and the rest. setuptools is floored at 83.0.0 so it cannot resolve below the patched release; the reasoning (and the misdiagnosis that once turned it into a cap) is in Why setuptools is floored, not capped. starlette could only leave 0.52.x together with gradio>=6.15.1, because gradio 6.8.0 pins starlette<1.0; both are overridden. The remainder — brotli, filelock, msgpack, opentelemetry-api, virtualenv — is bumped past its first patched version and pinned by the lock.

Re-check the Pillow cap when gradio lifts it:

uv lock --upgrade-package pillow --dry-run

Troubleshooting

cuda_out_of_memory / "not enough free VRAM". Something else is holding the card. Check doctor for per-GPU free memory, unload the model in LM Studio, or set CHATTERBOX_GPU_INDEX to a freer card.

reference_clip_required. multilingual and original have no stock voice. Add a clip to voices/ and pass voice=, or use model="turbo".

voice_not_found. Run list_voices for the exact names.

unsupported_language. language takes an ISO 639-1 code and only applies to multilingual. tts_status lists all 23.

First call is very slow. That is the Hugging Face download. Later calls reuse the cache — unless the cache root moved. If the first call is slow again after you changed a config or restarted a harness, the server is probably re-downloading to a different location: check the model's registration for CHATTERBOX_HF_HOME and look for a second copy under C:\Users\<you>\.cache\huggingface\hub. See Model weights for why HF_HOME in your shell is not always enough.

Audio does not come out. First check the worker is up: tts_status -> worker_running, or mcp-agent-chatterbox worker-status. If it is down, start it (mcp-agent-chatterbox worker) and try again — the server never synthesizes on its own. With a worker running, tts_status -> worker shows the queue: pending above zero means it is still rendering, and last carries written, played_chunks, errors, first_audio_s and max_gap_s for the most recent utterance — a max_gap_s that keeps coming back well above the configured chunk pause means the renderer is falling behind the player on this machine. Playback needs Windows; on other platforms the chunk files are still written. stop_speech clears anything queued.

worker_not_running. Nothing is listening on CHATTERBOX_WORKER_PORT. The MCP server does not synthesize; it only forwards. Start the worker once and leave it running. Double-check both sides read the same port value.

turbo and the watermarker. Turbo's output carries Resemble AI's watermarking mark, added by the vendored Perth library. Turning it off would be a licence question, not a technical one, so it stays on.

Logs. logs/mcp_agent_chatterbox.log (rotating, 5 MB × 5). Unhandled tracebacks go there, never into the model's context. The console handler writes to stderr with errors="replace", so a character the Windows console code page cannot represent degrades to ? instead of raising or garbling the line — Chatterbox logs a ✅ of its own, so this is not hypothetical.

uv sync fails with file-lock/Access denied errors (Windows). The running MCP server holds Scripts/mcp-agent-chatterbox.exe and the site-packages files it imported, so a mid-session sync cannot uninstall them. Worse, a sync that starts anyway and dies halfway can leave a husked package — the metadata looks installed but the package directory lost its __init__.py and most files (a real incident gutted coverage this way, and every model then failed to load with AttributeError: module 'coverage' has no attribute 'types', because librosa → numba imports it for tracing). Restart opencode so the server process exits, then redo uv sync. If the husk is already there, repair it in place: uv pip install --force-reinstall coverage==7.16.1 (or the husked package's own version) before relying on the environment. import librosa succeeding is the cheapest canary for this whole chain.

Development

uv sync --extra dev
uv run pytest              # unit tests, no model download
uv run ruff check .
uv run ruff format .

Tests stub torch and the Chatterbox classes, so the suite runs in seconds and does not touch the network or the GPU. For a real end-to-end check use mcp-agent-chatterbox speak.

Dependency pins and the state of the Dependabot alerts are in Dependency advisories.

License

MIT — see LICENSE. Chatterbox itself is MIT, © Resemble AI.

Available Tools

5 tools
list_voicesA

List the reference clips available for voice cloning — the files in the voices directory. Returns each clip's name (what you pass as voice=), filename, size, duration, sample rate and channel count, plus which models can use it. The multilingual and original models require one of these; turbo does not.

For good cloning use as much clean single-speaker audio as you have, not a short excerpt — clipping a good recording down measurably adds fine clicks and crackle. A mastered or compressed source is fine; do not normalise it. Sample rate and channel count need not match anything. Clips too short to be safe carry a quality_note.

NameRequiredDescriptionDefault

No parameters

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.3/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the behavioral burden and does well: it describes the returned fields (name, filename, size, duration, sample rate, channel count, model compatibility) and notes that too-short clips carry a quality_note. It does not explicitly state that the operation is read-only or mention any auth/rate-limit behavior, but those are largely implied by 'List' and not critical here.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The first paragraph is front-loaded and efficient. The second paragraph, while relevant to voice cloning, contains detailed audio-quality advice ('clipping... adds fine clicks and crackle', 'do not normalise it') that is not necessary for selecting or invoking list_voices and adds tangential length.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a zero-parameter listing tool with an output schema, the description is complete enough: it explains what is returned, notes the quality_note field, and clarifies which models depend on these clips. No critical information for correct invocation is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool takes zero parameters, so the baseline is 4. The description mentions voice= only as context for other tools, not as a parameter of this one, and the schema is empty.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description uses a specific verb and resource: 'List the reference clips available for voice cloning — the files in the voices directory.' It is immediately distinguishable from siblings like speak, stop_speech, tts_status, and tts_unload, all of which perform different actions.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It provides clear context for when the listed clips are needed: 'The multilingual and original models require one of these; turbo does not.' There are no alternative listing tools to compare against, but the description does not explicitly state exclusions or prerequisites beyond model compatibility.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

speakA

Speak text aloud with Chatterbox on the local GPU, save it as a WAV, and by default play it through the system audio device.

model: "turbo" (default, 350M, English, no reference clip needed, supports inline [laugh]/[chuckle] tags), "multilingual" (500M, 23 languages, requires a reference clip) or "original" (500M, English, requires a reference clip). voice: name of a reference clip in the voices directory (its filename without extension). Required for multilingual/original. reference_clip: path to a wav/mp3/flac clip, as an alternative to voice=. Supply as much clean continuous speech as you have. Short references measurably increase fine clicks and crackle: a 40 s reference beat an 11.8 s excerpt of the same recording by a wide margin, several times the model's own run-to-run variance. Under ~5 s is genuinely too little. Do not normalise or limit the clip -- that shifts output level and adds artefacts. A mastered or compressed recording is fine. Sample rate and channel count need not match anything. language: ISO 639-1 code, multilingual only (e.g. "de", "en", "fr"). t3_model: "v2" (default) or "v3" — multilingual checkpoint. temperature: sampling temperature (0.05-5.0). Honored by all models. top_p: nucleus-sampling cutoff (0.0-1.0). Honored by all models. top_k: top-k sampling size (0-1000). Honored by turbo only — the 500M models have no such parameter. repetition_penalty: penalise repeated tokens (1.0-2.0). Honored by all models. norm_loudness: normalize output to -27 LUFS. Honored by turbo only — the 500M models have no such parameter. exaggeration/cfg_weight: style controls (0.0-2.0 / 0.0-1.0). Honored ONLY by the 500M models (multilingual/original); turbo ignores both, so setting them with model="turbo" is dropped with a warning, not an error. cfg_weight>0 doubles the text tokens for CFG guidance. seed: reseed torch (CPU + CUDA) so re-renders are reproducible within the resident session; 0 (the upstream convention) or unset keeps random sampling. Byte-identical output across a server restart is not guaranteed — Chatterbox is nondeterministic across CUDA kernel choices. Left unset, every knob keeps the model's own tuned default (e.g. turbo runs cfg_weight 0.0 with top_k 1000; the 500M models run cfg_weight 0.5). play_audio: set false to only write the file. Defaults to the server setting (on). wait: block until playback finishes instead of returning immediately. filename: output basename; a safe name is generated when omitted.

The first call downloads the model from Hugging Face (hundreds of MB to a few GB) and can take a while; later calls reuse the cached weights. Returns JSON with the output filename, duration, sample rate, model, device and whether it played.

NameRequiredDescriptionDefault
seedNo
textYes
waitNo
modelNo
top_kNo
top_pNo
voiceNo
filenameNo
languageNo
t3_modelNo
cfg_weightNo
play_audioNo
temperatureNo
exaggerationNo
norm_loudnessNo
reference_clipNo
repetition_penaltyNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.3/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With zero annotations, the description carries the full burden and does so well: it discloses the first-call model download (hundreds of MB to a few GB, slow) and subsequent cache reuse, the file-write and audio-play side effects, that play_audio defaults to a server setting, that wait blocks, and that a safe filename is auto-generated. It also surfaces edge behavior an agent could not guess — ignored parameters produce a warning not an error, cfg_weight doubles token count, and byte-identical output is not guaranteed across a restart due to CUDA nondeterminism.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The one-line purpose is front-loaded, followed by a consistent per-parameter reference block, which is an efficient structure for 17 parameters. It runs long and the reference-clip anecdote (40 s vs 11.8 s excerpt) is more detailed than strictly necessary, but overall each entry earns roughly its place given the parameter count.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a 17-parameter, annotation-free synthesis tool, the description is complete: it covers defaults, model/param interactions, side effects, download latency, and even summarizes the JSON return. With an output schema present it correctly does not need to detail return structure, so nothing an agent needs to invoke this correctly is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must supply all parameter meaning, and it does: every knob (model, voice, reference_clip, language, t3_model, temperature, top_p, top_k, repetition_penalty, norm_loudness, exaggeration/cfg_weight, seed, play_audio, wait, filename) gets a definition, valid range, and the model-compatibility rule (turbo-only vs 500M-only params). It even adds non-obvious practical guidance on reference-clip length and loudness normalization that the schema could never convey.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The opening sentence states a specific verb and resource: synthesize text with a named engine, write a WAV, and by default play it. It clearly reads as the 'do the speech synthesis' action versus the lifecycle siblings (tts_status, tts_unload, stop_speech, list_voices). However, it never names or contrasts those siblings explicitly, so distinctness is inferred rather than stated.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It explains the conditions that select a model (turbo needs no reference clip; multilingual/original require one) and the meaning of defaults, which is useful context. But there is no explicit when-to-use/when-not guidance and no routing to siblings — e.g. it never says to use list_voices to discover a valid voice name or stop_speech to interrupt playback.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

stop_speechA

Stop audio that is currently playing. Useful for cutting off a long utterance started with wait=false.

NameRequiredDescriptionDefault

No parameters

Output Schema

NameRequiredDescription
resultYes

TDQS

A3.9/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the full behavioral burden. It conveys the core effect (stopping playing audio) and a usage trigger, but omits details such as whether it stops all playback or only the current utterance, and what happens when nothing is playing.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two short sentences, front-loaded with the core action and followed by a relevant usage note. Every sentence earns its place with no wasted wording.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's simplicity (no parameters, output schema present), the description covers the essential purpose and one usage context. It falls short only by not distinguishing this tool from related siblings like tts_unload, which an agent may need to choose correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool has zero parameters, so there is no parameter semantics to explain. The empty input schema is self-explanatory, and the description appropriately does not add unnecessary parameter details.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb and resource: 'Stop audio that is currently playing.' This clearly identifies the operation, but it does not differentiate the tool from siblings such as tts_unload, which may also stop audio, so the sibling-routing aspect is missing.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It provides a clear usage scenario: 'Useful for cutting off a long utterance started with wait=false.' However, it does not name alternative tools or state when not to use this tool, leaving the agent to infer the boundary with tts_unload.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

tts_statusA

Report Chatterbox TTS readiness: installed packages, resolved device, per-GPU free/total VRAM, the resident model, the models available, the languages the multilingual model speaks, and the paralinguistic tags turbo understands. Loads no weights — safe as a preflight before a speak call. On a machine where a large model already occupies a GPU, check free_mb here first: a card with less than ~4096 MB free will fail to load.

NameRequiredDescriptionDefault

No parameters

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.4/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the full safety burden and does so: 'Loads no weights — safe as a preflight' discloses the non-mutating behavior, and it warns that a card with under ~4096 MB free will fail to load. It omits any mention of latency or whether the check itself touches the GPU.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Three sentences, front-loaded with the primary purpose and then the safety note and the failure heuristic. The opening enumeration is long but each item is genuinely informative, so it earns its length without much waste.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a zero-parameter status tool with an output schema, the description need not explain return values, and it covers the remaining critical context: that the call is weight-free and what VRAM threshold causes downstream load failures. Nothing an agent needs before calling is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool takes zero parameters, so there is nothing for the description to disambiguate; the baseline for a parameterless tool is 4. The description adds no misleading parameter-like details.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb ('Report') and resource ('Chatterbox TTS readiness'), then enumerates exactly what is reported: packages, device, VRAM, resident/available models, languages, and tags. It also positions itself against siblings by naming its role as a preflight before a speak call.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicitly frames the tool as a preflight before a speak call and tells the agent to check free_mb here first when a GPU is occupied. It does not, however, state when not to use it or contrast it with list_voices or other siblings, so guidance is clear but incomplete.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

tts_unloadA

Release the currently loaded Chatterbox model and return its VRAM to the GPU. Use this when another process needs the card, or to drop a 500M model before a lighter turbo load. Safe to call when nothing is loaded.

NameRequiredDescriptionDefault

No parameters

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.1/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the full burden and does reasonably well: it discloses the side effect (model unloaded, VRAM freed) and idempotency ('safe to call when nothing is loaded'). It stops short of saying whether in-flight speech is interrupted or whether a reload is required afterward.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Three short sentences, front-loaded with the action, then the trigger conditions, then the safety reassurance. Every sentence earns its place with no filler.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

An output schema exists, so return-value explanation is unnecessary, and a zero-parameter unload tool needs little else. The only meaningful gap is the absence of explicit routing versus the sibling stop_speech.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool takes zero parameters, so per the baseline there is nothing for the description to document. No parameter-level gaps exist.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource: release the loaded Chatterbox model and return its VRAM to the GPU. An agent can tell this is an unload/teardown operation. It does not, however, explicitly differentiate itself from the sibling stop_speech, which arguably also halts model activity, so it falls short of the 5 bar.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Gives concrete triggering scenarios: another process needs the card, or dropping a 500M model before a lighter turbo load. It also preempts the caller's main worry with 'Safe to call when nothing is loaded.' No explicit when-not-to-use or naming of stop_speech as the lighter-weight alternative, so not a 5.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 5 tool updatesv0.1.0
    • First observedlist_voices
    • First observedspeak
    • First observedstop_speech
    • First observedtts_status
    • First observedtts_unload

TDQS

A4.2/5.0

Scored across 5 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: tts_status reports readiness, tts_unload releases VRAM, speak synthesizes audio, list_voices enumerates reference clips, and stop_speech halts playback. There is no overlap or boundary confusion between any pair.

Naming Consistency3/5

Naming is mixed: two tools use a 'tts_' prefix (tts_status, tts_unload) while the others do not, and 'speak' is a bare verb while list_voices/stop_speech follow verb_noun. It is still readable snake_case, but the conventions are not predictable as a set.

Tool Count5/5

Five tools is well-scoped for a local TTS server: status, unload, speak, list_voices, and stop_speech each cover a necessary lifecycle operation without redundancy. Nothing feels thin or bloated.

Completeness4/5

The surface covers the core TTS lifecycle (preflight, synthesize/play, list voices, stop, unload) and the speak tool exposes rich model controls. Minor gaps exist, such as no explicit preload of a model or enumeration of previously generated output files, but agents can work around these.

Maintenance

ActivityActive
ResponsivenessNo recent community issues

Related MCP Connectors

Related MCP Servers