Skip to content

Add VoxCPM2 as a TTS engine, in pure Rust - #75

Draft
JRufer wants to merge 2 commits into
masterfrom
claude/voxcpm2-tts-integration-2y9f7p
Draft

Add VoxCPM2 as a TTS engine, in pure Rust#75
JRufer wants to merge 2 commits into
masterfrom
claude/voxcpm2-tts-integration-2y9f7p

Conversation

@JRufer

@JRufer JRufer commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Adds VoxCPM2 as a sixth TTS engine, with voice design and voice cloning behind a settings section that mirrors the Breeze-TTS-2 one.

No Python

The upstream VoxCPM2 reference is Python + PyTorch. This does not use it. Synthesis runs in process, in pure Rust through the voxcpm-rs crate on Burn — the same shape of dependency as the pocket-tts crate already behind Pocket-TTS and Breeze-TTS-2. No Python interpreter, no subprocess, no ONNX Runtime anywhere in this path.

VoxCPM2 is Apache-2.0 and the weights are ungated, so unlike Breeze-TTS-2 and Pocket-TTS there is no access token and no non-commercial restriction.

Voice selection

  • Voice design — describe the speaker in natural language and the model invents a matching voice. VoxCPM2 takes this as a (description) prefix on the text, which the engine composes.
  • Voice cloning — a reference .wav from the voice folder already shared with Pocket-TTS and Breeze-TTS-2, so a clip dropped in once works with every engine. An optional style instruction shapes delivery without changing the identity. A sibling <name>.txt transcript upgrades cloning from imitation to continuation, which tracks the reference speaker more closely.

Latency

The target was first audio under a second. In descending order of impact:

  • The model stays resident for the TTS worker thread's lifetime, so the 20-25 s checkpoint load is paid at most once. Prewarm defaults to on and moves it to startup. The prewarm pass runs a real short generation rather than only loading weights, so the first spoken utterance does not pay for GPU shader compilation either.
  • Generation is streamed into the rodio sink chunk by chunk, so the user waits for the first chunk instead of the whole utterance.
  • chunk_patches defaults to 2, not the crate's 5 — patches generated before playback starts, each roughly 80 ms of audio and one autoregressive step. This is the direct time-to-first-sound knob.
  • inference_timesteps defaults to 6, not upstream's 10 — the floor of the range that keeps quality intact, and linear in generation cost.
  • Reference clips are decoded and resampled once and cached as raw PCM, so cloning does not re-read a file per utterance.

Stopping is prompt: the generator gets a cancel token that the model polls between diffusion steps, so the stop key abandons the current step rather than finishing the chunk. First-chunk latency is logged per utterance and surfaces in Settings as Run speed.

Build flags

The GPU backend (wgpu: Vulkan / Metal / DX12, no vendor SDK) ships in the default build, because a 2B model on CPU is far slower than realtime and cannot meet the target. voxcpm2-cpu selects the portable ndarray backend. A build with neither still downloads the model and saves its settings; only synthesis is unavailable, and Settings says so up front rather than failing on the first utterance.

Two defects found and fixed during self-review

  • The configured max_len cap was never passed to the generator, so a pathological input could generate unbounded speech.
  • Playback-end fired when generation finished rather than when audio finished. Since the GPU backend generates faster than realtime, that cleared "Speaking…" early and left the stop key with nothing to stop.

Testing

Suite Result
cargo test -p voxctrl-config 10 passed
cargo test -p voxctrl-tts --features voxcpm2-cpu 150 passed
cargo test -p voxctrl-app --features voxcpm2 79 passed
npx vitest run 69 passed
npx svelte-check 0 errors
cargo clippy on the new code clean

New coverage: 15 Rust tests over checkpoint readiness (including that an in-flight .part file never counts as ready), prompt composition and voice-mode selection; 12 Svelte tests over the settings section, covering both voice modes, missing-file reporting, and every reason Test TTS is blocked.

The full-workspace build could not be verified here: ort-sys downloads ONNX Runtime at build time and that host is blocked by this environment's egress proxy, a pre-existing constraint the repo documents. The Tauri app was verified to compile with --no-default-features --features custom-protocol,voxcpm2.

Docs

docs/tts.md (engine notes and the latency tuning guide), docs/configuration.md (the voxcpm2 sub-object), docs/api.md (voxcpm2_status, download_voxcpm2), docs/architecture.md, and the README feature list plus a new build-flags section.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QactcDkKLabdkXqys9uyMm


Generated by Claude Code

VoxCPM2 (OpenBMB, Apache-2.0) is a 2B-parameter multilingual speech model
offering the same two ways to pick a voice that Breeze-TTS-2 does — natural
language voice design and cloning from a reference clip — plus style control
layered on a cloned voice. The settings UI mirrors the Breeze-TTS-2 layout.

The upstream reference implementation is Python and PyTorch, which has no place
in this app. Synthesis instead runs in process through the pure-Rust `voxcpm-rs`
crate on Burn, the same shape of dependency as the `pocket-tts` crate already
behind Pocket-TTS and Breeze-TTS-2. No Python, no subprocess, no ONNX Runtime.

Latency, targeting first audio under a second:

- The loaded model is cached for the TTS worker thread's lifetime, so the
  20-25 s checkpoint load is paid at most once. Prewarm defaults to on and moves
  it to startup, and warms with a real short generation so the first spoken
  utterance does not pay for GPU shader compilation either.
- Generation is streamed into the rodio sink chunk by chunk, so the wait is the
  first chunk rather than the whole utterance.
- chunk_patches defaults to 2 rather than the crate's 5, halving the patches
  generated before playback starts (~80 ms of audio each).
- inference_timesteps defaults to 6 rather than upstream's 10, the floor of the
  range that keeps quality intact.
- Reference clips are decoded and resampled once, then cached as raw PCM.
- Stopping hands the generator a cancel token polled between diffusion steps, so
  the stop key abandons the current step instead of finishing the chunk.

The GPU (wgpu) backend is compiled by default; voxcpm2-cpu selects the portable
ndarray backend. A build with neither still downloads the model and saves its
settings, and Settings says so rather than failing on the first utterance.

Covered by 15 Rust tests over readiness, prompt composition and voice-mode
selection, and 12 Svelte tests over the settings section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QactcDkKLabdkXqys9uyMm
Playback appended one rodio SamplesBuffer per generated chunk and started on
the first one. Two things follow from that, and both were audible.

The sink's queue runs dry whenever the next chunk is not finished when the
previous one stops playing, and rodio does not wait for it: SourcesQueueOutput
splices in a filler block of silence and carries on. Playback consumes audio in
real time while generation does not produce it in real time, so on any machine
where the model is not comfortably faster than playback, speech stalled and
resumed over and over. A bigger chunk_patches only bought a longer run between
gaps, which is exactly the symptom reported.

Every chunk was also its own entry in the sink's queue, making each chunk
boundary a seam in the audio pipeline rather than a continuous stream.

Both are fixed:

- Audio now reaches the sink as a single continuous StreamingPcm source per
  utterance, fed over a channel. There is no per-chunk queue boundary left, and
  a starved sink degrades to brief silence inside one stream instead of a queue
  transition. It never blocks the audio thread, and dropping the sender is what
  ends the source, so sleep_until_end still returns.

- Playback waits for a lead buffer instead of starting on the first chunk. The
  engine measures the real-time factor of generation from the second chunk
  onward, excluding the one-off prefill so the figure is the steady-state rate.
  Under RTF 0.8 the buffer grows by itself once playback starts, so the
  configured 400 ms lead is already safe. At or above it, the lead is extended
  to cover (RTF - 1) * remaining with a jitter margin. If generation finishes
  before the lead is reached, the utterance is played complete in one buffer.

The lead is exposed as a Lead Buffer slider, documented as the control to raise
when speech breaks up. chunk_patches is no longer a latency control at all — it
governs throughput, since AudioVAE decode work scales as O(N^2/chunk_patches) —
so its default rises from 2 to 4, which makes generation faster and the lead
easier to hold.

Adds 7 tests over lead sizing: faster-than-realtime keeps the minimum, slower
demands proportional cover, the lead is monotonic in both slowness and remaining
audio, never drops below the configured floor, and survives a degenerate rate
measurement rather than never playing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QactcDkKLabdkXqys9uyMm
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants