VOL. I  ·  NO. 
SUB/WAVE
ON AIR
No skips · No shuffle · Just radio

MANUAL · 12

Voices & TTS.

The DJ's words are written by the language model, but turning them into speech is a separate job. Six text-to-speech engines render the voice: four built in, one hosted, and one a TTS server you run yourself. You can mix them per segment, clone a voice, point at your own server, or hand the heavy one to a GPU.

THE ENGINES

Local voices, or the cloud.

You pick the engine under Admin → TTS voice. Four are built in, one is hosted, and one points at a server you run yourself.

  • Piper is the default. It's compact, runs on practically any hardware, and renders speech faster than real time. The voice is clear but a little synthetic. It also doubles as the station's safety net (see below).
  • Kokoro is a local neural model that sounds markedly more natural, closer to a real broadcaster. It's heavier: it loads a model into memory and takes longer per line, so it wants a bit of CPU and RAM headroom. It ships a range of voices, with a British selection surfaced in the console.
  • Chatterbox clones a voice from a short reference clip, so each persona can have its own distinct sound, and it voices paralinguistic cues like [laugh] and [sigh] as real sounds. It's the most capable local engine and the heaviest: comfortable on a GPU, slow on CPU. It lives in the optional tts-heavy sidecar.
  • PocketTTS is a small, multilingual model from kyutai-labs that runs about six times faster than real time on CPU, with built-in voices in English, French, German, Italian, Spanish and Portuguese. It sits between Piper (fast, robotic) and Chatterbox (heavy, expressive), in the same tts-heavy sidecar.
  • Cloud is hosted text-to-speech through OpenAI or ElevenLabs, using an API key. It's the most lifelike of the six, but it costs per use and depends on the network being up. The Cloud engine also speaks OpenAI-compatible, so it can point at any self-hosted speech server, including a Chatterbox box on your own GPU (see below).
  • Remote is a TTS server you run yourself — a LAN box, a Tailscale host, a spare GPU — that speaks a tiny Subwave-native HTTP contract. It's the clean way to self-host an engine like Qwen3-TTS, F5-TTS or CosyVoice without dressing it up as another provider (see below).

You don't have to commit to one. The operator can assign a different engine per kind of segment (a rich cloud voice for station IDs, say, but a fast local voice for routine time checks), with everything else falling through to a default engine.

THE DJ NEVER GOES SILENT

If a voice ever fails (a cloud outage, a model that isn't installed), the station drops to a local engine automatically. Piper is always there as the last resort, so a spoken segment is never lost to a missing voice.

CHATTERBOX & POCKETTTS

Enabling the tts-heavy sidecar.

Piper and Kokoro ship inside the controller image, and the cloud engine just needs an API key. Chatterbox and PocketTTS are the exceptions: they drag in a few GB of PyTorch and model weights between them, so they live in a separate, opt-in tts-heavy container rather than being bundled into every install.

To enable, set COMPOSE_PROFILES=tts-heavy in your .env and bring the stack up:

echo COMPOSE_PROFILES=tts-heavy >> .env
docker compose up -d

For a one-off start without persisting the choice, run docker compose --profile tts-heavy up -d instead. The setup wizard at /onboarding also writes the env var for you if you tick “Enable Chatterbox + PocketTTS”.

Once the sidecar is up, both engines show as available under Admin → TTS voice. For voice cloning (Chatterbox or PocketTTS), drop a short reference WAV into state/voices/ (legacy state/chatterbox-voices/ is still read) and pick it on the Personas page. Without one, both engines use their built-in default voice. PocketTTS also exposes a curated set of built-in voice ids (alba, anna, charles, …) alongside any cloned voices. Until the sidecar is started, selecting either engine silently falls back to Piper.

For backwards compatibility, the older --build-arg WITH_CHATTERBOX=1 / WITH_POCKETTTS=1 paths in docker/Dockerfile.controller still work. They bundle the engines inside the controller image instead, but the sidecar is the recommended path for fresh installs.

REMOTE

Your own TTS server.

The Remote engine points SUB/WAVE at a TTS server you run yourself, anywhere the controller can reach over the network — a LAN box, a Tailscale host, a spare GPU machine. Unlike the OpenAI-compatible Cloud route (which speaks OpenAI's /v1/audio/speech), Remote speaks a tiny Subwave-native contract, so wrapping a model like Qwen3-TTS, F5-TTS or CosyVoice in a server is only a few lines.

Configure it under Admin → TTS voice: pick Remote and set Server URL to the endpoint (e.g. http://192.168.1.101:5001, a LAN or Tailscale IP the controller container can reach, not 127.0.0.1). The console shows ready once the health check passes, and the station falls back to Piper whenever the URL is blank or the server is down. A persona's Remote voice is free text forwarded straight to your server — a voice id, a reference-WAV filename, a VoiceDesign prompt, whatever it understands.

THE CONTRACT

Your server needs two routes. GET /health returns { "ok": true } when it's ready. POST /speak takes JSON { "text": "…", "voice": "…" } and returns the rendered audio (WAV) in the response body. The audio travels over the wire, so no shared filesystem or volume is needed — that's the difference from the bundled tts-heavy sidecar, which hands back a path on a shared volume.

That's the whole server — for example, in Flask:

@app.get("/health")
def health():
    return {"ok": True}

@app.post("/speak")
def speak():
    body  = request.get_json()
    text  = body["text"]
    voice = body.get("voice", "")
    wav   = my_model.render(text, voice)        # -> WAV bytes
    return Response(wav, mimetype="audio/wav")

Optional: if your server substitutes a different voice than the one requested, set the X-TTS-Fell-Back response header (plus X-TTS-Voice-Used and X-TTS-Fell-Back-Reason) and SUB/WAVE logs the substitution instead of leaving you to guess why the voice changed.

CHATTERBOX ON A GPU

When CPU isn't enough.

Chatterbox is the most expressive local engine and the most demanding. On a CPU it pegs every core and still runs slower than real time. If you have a GPU there are two ways to put it to work, and the easy one needs no rebuild at all.

The easy route: your own server over the OpenAI layer. Run a Chatterbox server that exposes an OpenAI-compatible /v1/audio/speech endpoint on your GPU machine (the community Chatterbox TTS API project does exactly this), then point SUB/WAVE's Cloud engine at it. Under Admin → TTS voice, set the Cloud engine's Provider to OpenAI-compatible, put the box's address in Server base URL (e.g. http://192.168.1.101:5000/v1, including the /v1, on a LAN or Tailscale IP the controller container can reach rather than 127.0.0.1), and set Model to the id the server reports at /v1/models. The DJ now speaks through your GPU with no image rebuild.

CLONING STILL WORKS, SERVER-SIDE

The OpenAI speech API has no field for a per-request reference clip, but SUB/WAVE still forwards the persona's voice name. So you register your reference clip as a named voice on the Chatterbox server (an optimus, say) and select it by name, and the clone renders on your GPU. What you give up versus the native route is the in-app state/voices/ per-persona workflow and daypart speed shaping.

The native route: GPU-enable the bundled sidecar. The shipped tts-heavy image installs CPU-only PyTorch wheels, so setting TTS_HEAVY_DEVICE=cuda on its own silently falls back to CPU. To drive the card natively (and keep reference-WAV cloning), point the CHATTERBOX_TORCH_INDEX_URL build arg at a CUDA wheel index and bring the sidecar up with the docker-compose.tts-heavy-gpu.yml overlay, which carries the GPU reservation for you. No Dockerfile editing, just a local build on a host with the NVIDIA Container Toolkit.

The full step-by-step for both routes lives in docs/gpu-tts.md in the repo, including the exact build args and the compose GPU reservation. The DJ itself is unchanged either way; this only swaps where the Chatterbox voice is rendered. For the model that writes the show rather than speaks it, see Models & Tokens.