This document tracks the port of Chatterbox Turbo (Resemble AI, MIT license)
to ggml, from the first exploratory scoping all the way to the optimized
end-to-end CPU binary, in the order things actually happened.
- Model:
ResembleAI/chatterbox-turbo(text-to-speech, ~450 M params without the tokenizer / speaker-encoder). - Goal: end-to-end
text → waveformin C++/ggml with bit-exact (or float-precision) parity against the official PyTorch reference. - Verification target: every intermediate tensor within 1e-6 relative error of the PyTorch implementation, on CPU.
Everything runs in pure C++/ggml on CPU. Three binaries:
| Binary | Role |
|---|---|
chatterbox |
text → speech tokens (T3, GPT-2 Medium, 24 layers) |
chatterbox-tts |
speech tokens + reference voice → 24 kHz wav (S3Gen + HiFT) |
mel2wav |
mel spectrogram → wav (HiFT only, demo) |
Plus scripts/synthesize.sh which composes the two into a single command.
Numerical parity vs PyTorch on a 2.7 s reference utterance, debug mode (Python-dumped random bits substituted for reproducibility):
| Stage | rel error vs PyTorch |
|---|---|
| BPE tokenizer | 10/10 exact-match test cases |
| T3 speech tokens | bit-exact on 4 deterministic prompts |
| S3Gen encoder (full, incl. upsample and encoder_proj) | 4.5e-07 |
| CFM 2-step meanflow decoder | 8.9e-07 on the final mel |
| HiFT decode body (conv_pre → conv_post) | 5.6e-07 |
| ISTFT → waveform | 1.0e-04 |
| End-to-end C++ wav vs Python wav (RMS) | 1.22e-04 vs 1.22e-04 |
Speed (10 s sentence, seed 42, gen_RTF = (T3_INFER + S3GEN_INFER) / audio_ms):
| Backend | gen_RTF |
Wall | vs ONNX addon |
|---|---|---|---|
| CPU (10-core EPYC, F16) | 0.70 | 8.2 s | 3.6× faster |
| Vulkan (RTX 5090, Q4_0) | 0.06 | 1.8 s | 7.8× |
| Metal (M3 Ultra, Q4_0) | 0.13 | 1.9 s | 7.4× |
| ONNX q4 addon (CPU baseline) | 1.06 | 13.9 s | 1.0× |
GPU support and Metal kernel fixes are described in §3.11 / §3.12; the layout-friendly KV cache + Flash Attention pass that produced the numbers in this table is in §3.13.
chatterbox.cpp/
ggml/ vendored ggml checkout (see patches/)
patches/
ggml-metal-chatterbox-ops.patch Metal op fixes: diag_mask_inf, pad_ext,
faster conv_transpose_1d (applied to ggml/
during setup; see patches/README.md)
README.md why each patch exists + how to drop it
src/
main.cpp T3 runtime + unified CLI (chatterbox binary)
chatterbox_tts.cpp S3Gen encoder + CFM + HiFT (reusable entry)
gpt2_bpe.{h,cpp} self-contained GPT-2 byte-level BPE tokenizer
voice_features.{h,cpp} wav I/O, resample, mel, fbank, LUFS
voice_encoder.{h,cpp} VoiceEncoder 256-d speaker embedding
campplus.{h,cpp} CAMPPlus 192-d speaker embedding
s3tokenizer.{h,cpp} S3TokenizerV2 (wav → S3 speech tokens)
test_s3gen.cpp staged verification harness (stages A..H5)
test_metal_ops.cpp parity test for the patched Metal kernels
mel2wav.cpp mel → wav demo binary (HiFT only)
npy.h minimal .npy loader + compare helpers
scripts/
convert-t3-turbo-to-gguf.py T3 weights + tokenizer + VE + builtin voice → GGUF
convert-s3gen-to-gguf.py S3Gen encoder + CFM + HiFT + CAMPPlus
+ S3TokenizerV2 + mel filterbanks → GGUF
dump-s3gen-reference.py PyTorch → .npy intermediates for test-s3gen
dump-campplus-reference.py PyTorch → .npy intermediates for test-campplus
dump-s3tokenizer-reference.py PyTorch → .npy intermediates for test-s3tokenizer
reference-t3-turbo.py PyTorch T3 + compare against C++
compare-tokenizer.py 10-case tokenizer comparison against HF
synthesize.sh text → wav wrapper (chatterbox binary)
models/
chatterbox-t3-turbo.gguf T3 + tokenizer conditionals
chatterbox-s3gen.gguf flow + mel2wav weights + built-in voice
t3-{q8_0,q5_0,q4_0}.gguf quantized T3 variants (A3)
CMakeLists.txt top-level: add_subdirectory(ggml) + targets
PROGRESS.md this file
A separate machine holds PyTorch + the original Chatterbox repo for reference
runs. On-device (Apple Silicon / Linux x86) the C++ binaries have no runtime
dependency on Python — the tokenizer reads vocab.json + merges.txt
directly.
Surveyed open-source TTS candidates (F5-TTS, Kokoro-82M, XTTS v2, Piper, Fish Speech, Supertonic, Chatterbox). Picked Chatterbox Turbo for three reasons: MIT license, zero-shot voice cloning, and the "Turbo" variant uses just 2 flow-matching steps (fast inference).
Bootstrapped the repo by cloning the latest ggml and the reference
resemble-ai/chatterbox side-by-side, then built a standalone
chatterbox.cpp/ with ggml/ as a vendored subdirectory (no modifications
inside ggml/).
Issues hit in this phase:
| # | Issue | Fix |
|---|---|---|
| 1 | rsync not on macOS by default |
Switched to tar … | ssh … tar -x. |
| 2 | Remote repo polluted with ._* AppleDouble files |
COPYFILE_DISABLE=1 tar …. |
| 3 | Partial sync left src/CMakeLists.txt stray file |
Removed; unified sync always pushes the whole tree. |
| 4 | Remote binary 0 bytes after SSH disconnect |
rm build/<target> + rebuild. |
T3 is a GPT-2 Medium-sized (24 layer) autoregressive model that maps text tokens + voice conditioning to speech tokens.
- Wrote
scripts/convert-t3-turbo-to-gguf.pyto emit a GGUF with built-in voice conditionals (speaker_emb,cond_prompt_speech_tokens) embedded. - C++ graph in
src/main.cpp: split into a "prompt" graph and a "step" graph sharing a persistent KV cache, mirroringggml/examples/gpt-2. - Ported the sampler (Temperature → TopK → TopP → RepetitionPenalty).
- Wrote a self-contained GPT-2 byte-level BPE in
src/gpt2_bpe.cpp(llama.cpp's BPE was too entangled with its GGUF vocab loading to reuse cleanly): byte-level encoding table, regex pre-tokenization, BPE merge loop, pluspunc_normmatching the Python implementation. 10/10 test cases match the HF tokenizer byte-for-byte, including the 19 paralinguistic added tokens ([laugh],[chuckle], …). chatterboxbinary takes--text+--tokenizer-dirand produces speech tokens end-to-end.
Verified against PyTorch: bit-for-bit identical speech tokens on 4 deterministic sampling configs (greedy / temperature / top-k / repetition-penalty / no-penalty × short + long prompts).
Issues hit in this phase:
| # | Issue | Fix |
|---|---|---|
| 5 | ggml_can_mul_mat assertion in T3 |
Converter must transpose Conv1D-style weights (c_attn, c_proj, c_fc, mlp.c_proj) to ggml's [in, out] layout while leaving nn.Linear / embeddings / wpe as-is. |
| 6 | ggml_backend_tensor_get(input_tensor) returned garbage |
ggml_gallocr reuses the input buffer for intermediates when only set_input is marked; also call ggml_set_output on tensors we want to read back. |
| 7 | Repetition-penalty path diverged from HF at token 22 | HF divides positive logits, multiplies negative ones — I had it backwards. |
| 8 | Sampler order mismatched HF LogitsProcessorList |
Rewrote sample_next_token as Temperature → TopK → TopP → RepetitionPenalty, in HF's exact order. After the fix greedy+penalty tests pass bit-exactly. |
S3Gen is a "Upsample Conformer" with 10 blocks total (~60 M params): 6 initial
blocks, then a 2× Upsample1D, then 4 more blocks. Ported in six staged
substeps against Python-dumped reference tensors (scripts/dump-s3gen-reference.py):
| Stage | Component | rel error |
|---|---|---|
| A | speaker_emb projection (F.normalize + Linear) |
1.2e-7 |
| B | input_embedding lookup |
0 (exact) |
| C | encoder_embed (Linear + LN + √D scale + ESPnet rel PE) |
4.4e-7 |
| D | PreLookaheadLayer (asymmetric-padded Conv1d stack) |
2.5e-7 |
| E | One Conformer block (rel-pos MHA + rel_shift + Swish FFN) |
1.3e-7 |
| F | Full encoder + encoder_proj |
5.6e-7 |
Issues hit in this phase:
| # | Issue | Fix |
|---|---|---|
| 9 | ggml_conv_1d aborted with src0->type == GGML_TYPE_F16 |
ggml's im2col path requires F16 kernels, but we wanted F32 precision. Wrote a conv1d_f32 helper that calls ggml_im2col(…, GGML_TYPE_F32) + mul_mat directly, keeping kernels in F32. |
| 10 | speaker_embed broadcast failed in cond_spkr matmul |
Bias reshape needed ne=[1, 256], not ne=[256]. Added the explicit reshape_2d(bias, 1, C) convention for every 1-D bias added to a [T, C] conv output. |
| 11 | Nearest-neighbor ×2 upsample produced channel-interleaved garbage | The naive reshape_3d(T, 1, D) + concat(ne[1]) gives t0_copy0, t1_copy0, …, t0_copy1, …. Correct trick: reshape_3d(1, T, D) → concat along ne[0] → [2, T, D] → reshape to [2T, D], giving t0_copy0, t0_copy1, t1_copy0, …. |
| 12 | rel_shift attention gave ~100 % rel error |
view_3d(bd_viewed, T, 2T-1, H, nb1, T*(2T-1)*elem, offset) used the sliced stride for nb2. nb2 must match the source's element stride: bd_viewed->nb[2]. |
| 13 | *.transpose().numpy() reference dumps loaded as garbage in C++ |
Torch .transpose() yields Fortran-ordered storage; np.save writes fortran_order: True. Dumper now calls .contiguous().numpy() + np.ascontiguousarray(...). The C++ loader throws a clear error if it sees fortran_order=True. |
A U-Net with transformer blocks (~45 M params). Layout: 1 down block → 12 mid
blocks → 1 up block (skip concat) → final_block → final_proj. Each block
carries 4 BasicTransformerBlocks.
| Stage | Component | rel error |
|---|---|---|
| G1 | Time embedding (sin → MLP → mixer) | 7.0e-7 |
| G2 | CausalResnetBlock1D (causal-conv + LN + Mish + time MLP + res_conv) |
2.9e-7 |
| G3 | BasicTransformerBlock (self-attn + FFN w/ GELU-erf) |
1.7e-7 |
| G4 | Full CFM decoder, one forward step | 1.3e-6 |
For meanflow mode we do 2 steps with t_span = [0, 0.5, 1]; the time embedding
sees both t and r concatenated through a mixer.
Issues hit in this phase:
| # | Issue | Fix |
|---|---|---|
| 14 | LayerNorm applied over time instead of channel |
For ne=[T, C] layout ggml_norm reduces ne[0]=T, which is wrong. Wrote layer_norm_on_channel that permutes to [C, T], norms, applies affine, permutes back. |
| 15 | weight_norm convolutions in mel2wav ignored |
Torch 2.6 stores them under parametrizations.weight.original{0,1}. Added expand_weight_norm() in the converter that fuses g · v / ‖v‖₂ back into a regular weight tensor before export. |
| 16 | Mish activation missing from ggml unary ops | Built from primitives: x · tanh(softplus(x)) via GGML_UNARY_OP_SOFTPLUS + GGML_UNARY_OP_TANH. |
| 17 | GELU mismatch in BasicTransformerBlock (rel=3e-4) |
ggml_gelu is the tanh approximation; diffusers.models.activations.GELU uses the exact erf formulation. Switched to ggml_gelu_erf. Error dropped to 1.7e-7. |
| 18 | Python hook overwrote the same tensor across multiple CFM steps | Meanflow calls time_embeddings twice (for t and r) and the decoder runs twice per sample. Added make_hook(multi_call=True) that saves *_call0.npy, *_call1.npy, …. |
| 19 | Estimator forward_hook never fired |
basic_euler calls self.estimator.forward(x, …) directly, bypassing __call__ where hooks live. Monkey-patched estimator.forward to record x_in / mu / t / r / spks / cond / mask / dxdt for every step. |
| 20 | (B, C, T) vs (B, T, C) layout confusion |
CFM alternates: resnets use (B, C, T), transformer blocks use (B, T, C), switched by rearrange. In ggml we mirror this and cont(permute) at the boundary. Every helper doc-comments its layout. |
HiFTGenerator = Neural Source Filter + ISTFTNet. The mel → waveform vocoder. Ported in five verifiable substeps:
| Stage | Component | rel error |
|---|---|---|
| H1 | f0_predictor (5× Conv + ELU + Linear) |
4.2e-6 |
| H3 | decode body conv_pre → ups / rb → conv_post |
5.6e-7 |
| H4 | STFT (Conv1d with DFT + Hann kernel) | 7.9e-3 (boundary-bound) |
| H5 | ISTFT (ConvTranspose + window-sum normalize) | 1.0e-4 |
Key techniques:
- Snake activation
x + (1/α)·sin²(αx)implemented withggml_sinand a pre-computed1/αtensor fed as a graph input (72 such inputs across the 9 main ResBlocks and 3 source ResBlocks). - ConvTranspose1d with asymmetric PyTorch padding: ggml's op only accepts
p0=0, so we compute the full-length output then slicepsamples from each side. - Asymmetric reflection pad
(1, 0): done manually by extractingx[1:2]and concat-prepending it. - STFT as
Conv1dwith a DFT+window kernel of shape[n_fft, 1, 2F](real and imaginary parts stacked as output channels). Center-mode reflection padn_fft//2applied manually via slice-and-concat on each side. - ISTFT as
ConvTranspose1dwith the inverse DFT+window kernel, followed by element-wise divide by a precomputedwindow²overlap-sum buffer, then trimn_fft//2from each end.
The resulting mel2wav binary demonstrates the full vocoder:
mel2wav --s3gen-gguf models/chatterbox-s3gen.gguf \
--mel-npy artifacts/s3gen-ref/mel_output.npy \
--out /tmp/out.wav
Against the Python reference waveform: matching RMS (1.22e-04 vs 1.22e-04), time-domain diff max 3.3e-05 (signal max ~9e-04), spectrogram magnitude diff max rel 2.5 % (entirely from stochastic SineGen excitation; the deterministic conv-net chain is bit-exact).
SineGen on the C++ side uses std::mt19937 (not bit-exact to torch.rand,
but audibly indistinguishable — the excitation is a small-amplitude additive
noise term).
Final plumbing: write src/chatterbox_tts.cpp that wires the S3Gen encoder →
2-step meanflow CFM → HiFT vocoder and emits a 24 kHz wav. Takes T3-generated
speech tokens plus a reference voice (embedding, prompt_token,
prompt_feat).
scripts/synthesize.sh runs chatterbox → pipe tokens → chatterbox-tts,
giving a single-command text → wav path.
Debug mode (--debug) substitutes Python-dumped reference random bits (CFM
z and noised_mels) so the deterministic parts can be validated
bit-exactly. End-to-end in debug mode:
| Stage | max_abs | rel |
|---|---|---|
input_embedding(tokens) |
0 | 0 |
encoder → encoder_proj (mu) |
8.3e-07 | 4.5e-07 |
| speaker embedding (spks) | 5.9e-08 | small |
cond (prompt_feat placement) |
0 | 0 |
t_emb (sinusoidal → MLP → mixer) |
7.6e-06 | small |
CFM step 0 dxdt |
2.1e-05 | small |
CFM step 1 dxdt |
1.8e-05 | small |
| final mel (80 × 136) | 1.0e-05 | 8.9e-07 |
Production mode uses a seeded std::mt19937 for both the CFM initial noise
and SineGen excitation.
Issues hit in this phase (all three caused plausible-looking but wrong output before being found):
| # | Issue | Fix |
|---|---|---|
| 21 | Silence-token padding value | speech_tokens must be appended with S3GEN_SIL = 4299 (not 0) to match Python's speech_tokens_padded convention. |
| 22 | Relative PE pos_pe / neg_pe swap |
While copying compute_pos_emb into the new binary I flipped the two halves of the PE buffer, which silently gave ~20 % relative error in the encoder output. Restored the correct ordering: first half is reversed pos_pe, second half is neg_pe. |
| 23 | mu layout transpose between encoder and CFM |
encoder_proj.npy is numpy (T, 80) but the CFM estimator expects numpy (80, T). Added an explicit transpose to bridge the two. |
At this point on a 10-core EPYC, single-threaded, the end-to-end pipeline ran in 22.5 s for 8.64 s of audio — RTF 2.60, i.e. 2.6× slower than real-time.
Eight optimizations in the order they were attempted. Four landed, four were rolled back or skipped as incompatible. Numbers are for the 8.64 s utterance above.
Attempt 1 — multi-threading (KEPT, −85 % wall time)
Baseline was pinned to 1 thread because the code never called
ggml_backend_cpu_set_n_threads. Added a global g_n_threads (default =
std::thread::hardware_concurrency(), overridable with --threads N) and a
compute() helper that sets it before every ggml_backend_graph_compute.
ggml's -march=native was already on, so AVX-512 / AVX-VNNI kernels were
already in use — the missing piece was parallelism. Swept thread counts: 10
was the sweet spot; 16 oversubscribes and regresses.
Result: 22.5 s → 3.47 s (RTF 2.60 → 0.40).
Attempt 2 — OpenBLAS (TRIED, NO HELP)
Installed libopenblas-dev, rebuilt with GGML_BLAS=ON GGML_BLAS_VENDOR=OpenBLAS. No measurable change. Our matmuls are medium-sized
and ggml's hand-written AVX-512 kernels already saturate what OpenBLAS would
deliver. Kept off.
Attempt 3 — GGML_LTO=ON (TRIED, NO HELP)
No measurable effect on a shared-library build. Kept off.
Attempt 4 — CFM graph reuse (KEPT, −11 % wall time)
The CFM estimator is called twice per utterance with identical graph
topology. Stashed the ggml_context, ggml_cgraph, and ggml_gallocr in a
cfm_estimator_cache so step 2 only re-runs with new inputs — saves one graph
construction and one gallocr_reserve pass per utterance.
Result: 3.47 s → 3.09 s (RTF 0.40 → 0.36).
Attempt 5 — Flash attention in CFM BasicTransformerBlock (KEPT, −22 % wall time)
The CFM has 56 BasicTransformerBlocks × 2 meanflow steps = 112 attention
ops per utterance. Replaced the explicit
softmax(QKᵀ / √d) · V kernel with a single ggml_flash_attn_ext call.
The pattern is pure self-attention (no masking, no bias), which is exactly
what flash_attn_ext is designed for. Fused, no materialized T×T
scores/attn tensors. The reshape-permute-cont preamble now drops straight into
flash_attn_ext, and its output ne=[HD, H, T, 1] reshapes directly to
[INNER, T].
Result: 3.09 s → 2.45 s (RTF 0.36 → 0.28), CFM −44 %.
Attempt 6 — Fold symmetric conv padding (KEPT, small win)
Six redundant ggml_pad_ext → conv1d_f32 pairs dropped by passing the padding
straight to ggml_im2col. Biggest impact in HiFT's ResBlocks where the
resblock-conv path runs ~72 times per decode. Saves one intermediate tensor
allocation per conv. A small but essentially-free improvement.
Result: 2.45 s → 2.39 s (RTF 0.28 steady).
Attempt 7 — F16 CFM linear weights (TRIED, ROLLED BACK)
Converted all Q/K/V/O/FFN/MLP linear weights in CFM from F32 to F16 to halve
memory bandwidth. Regressed: CFM got ~10 % slower and precision dropped
to rel = 3e-4 on the final mel. The F16→F32 upconvert inside mul_mat is
not free and the F32 AVX-512 kernel is already very fast; for CPU this is a
net loss. Reverted.
Attempt 8 — Flash attention in the Conformer encoder (SKIPPED, INCOMPATIBLE)
Would fuse another 10 attention ops per utterance, but the Conformer uses
ESPnet-style relative positional bias added inside the softmax, and
ggml_flash_attn_ext does not support custom in-softmax bias terms. Would
need a custom ggml op — not done.
| Configuration | Total | RTF | vs real-time |
|---|---|---|---|
| Baseline (1 thread, no graph reuse, no flash attn) | 22.5 s | 2.60 | 2.6× slower |
| + threading (Attempt 1) | 3.47 s | 0.40 | 2.5× faster |
| + CFM graph reuse (Attempt 4) | 3.09 s | 0.36 | 2.8× faster |
| + flash attn + pad fold (Attempts 5, 6) | 2.39 s | 0.28 | 3.6× faster |
Total wall-time speedup from the original port: 9.4×.
Stage breakdown at the final configuration:
| Stage | time |
|---|---|
| S3Gen encoder | 286 ms |
| CFM 2 meanflow steps | 785 ms |
| HiFT vocoder | 1312 ms |
| Total | 2.39 s |
HiFT is now the bottleneck (~55 % of wall time) — the 3-stage upsample /
ResBlock stack on T = 16320 × 64 channels is memory-bandwidth bound rather
than compute bound.
After merging the two binaries and shipping voice-cloning phase 1, a user report of an "empty" wav on paragraph-length input surfaced a sampling bug that had been lurking since the T3 port.
Symptom: the produced wav had ~1 second of speech followed by ~9 seconds of pure zero RMS. Per-0.5 s window RMS:
[3.5e-2, 1.3e-2, 2.8e-7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4.4e-7]
Dumping the T3 token stream showed the root cause immediately — 240 of 257
tokens were the silence token 4218:
tokens[0:17]: 3704, 6486, 4299, 3891, 5832, 4384, 5014, 5665, 2486, 29,
29, 380, 632, 2912, 5101, 5070, 4215
tokens[17:257]: 4218, 4218, 4218, 4218, ... (240 copies)
The C++ sampler had shipped with top_k = 1 (argmax) as its default. For
Chatterbox T3 that's a known failure mode: once the model generates a
silence token at a natural pause, argmax(logits) keeps picking silence
forever and the utterance never recovers. Short test prompts never reached
a pause so the bug was invisible during the port.
Compared ChatterboxTurboTTS.generate() in tts_turbo.py — the Python
defaults are very different:
| before (C++ broken) | Python | after (C++ fixed) | |
|---|---|---|---|
top_k |
1 (greedy) | 1000 | 1000 |
top_p |
1.0 | 0.95 | 0.95 |
temperature |
1.0 | 0.8 | 0.8 |
repeat_penalty |
1.0 | 1.2 | 1.2 |
n_predict |
256 | ~1000 | 1000 |
All four knobs are still exposed on the CLI, so --top-k 1 reproduces the
old greedy behaviour for debugging/comparison.
After the fix, same prompt same seed:
- total wav RMS:
8.3e-03→4.8e-02 - max amplitude:
0.18→0.50 - per-0.5 s RMS windows: all 21 non-zero (3.3e-2 … 8.5e-2 range)
- audible speech for the full 10.7 s
Committed as bb0eb99.
This one was avoidable — the verification pipeline in §5 is per-tensor
numerical parity, which is oblivious to sampler choices; the reference- t3-turbo.py harness only compared greedy token sequences so it never
exercised any non-trivial pass of the sampling ladder. Worth adding an
end-to-end sampling test to the validation list: run T3 with Python's
stochastic defaults (fixed seed) and compare the full token stream
byte-for-byte against C++ with the same seed.
Compared end-to-end throughput against an in-house ONNX Runtime TTS
addon (pre-built q4 Chatterbox models at 692 MB on disk). Same 10-core
EPYC host, same
prompt ("Hello from native C plus plus. This audio was generated end
to end on CPU using ggml."), built-in voice on both sides, --threads 10 for ggml, ORT's own default threading for ONNX. Instrumented the
ggml binary with explicit T3_LOAD_MS / T3_INFER_MS /
S3GEN_LOAD_MS / S3GEN_INFER_MS markers so load and generate
phases can be split cleanly. Each configuration run three times after
a disk-cache warm-up.
Model footprint on disk:
| Size | |
|---|---|
| ONNX q4 (5 files) | 692 MB |
| ggml F16 (T3 + S3Gen) | 1285 MB |
| ggml Q8_0 (T3 + S3Gen) | 1004 MB |
| ggml Q5_0 (T3 + S3Gen) | 893 MB |
| ggml Q4_0 (T3 + S3Gen) | 857 MB |
Per-stage wall-clock (median of 3 runs, milliseconds):
| Pipeline | T3 load | T3 gen | S3Gen load | S3Gen gen | Audio | Total | RTF (total) |
|---|---|---|---|---|---|---|---|
| ggml Q4_0 | 213 | 1790 | 366 | 1998 | 6480 | 4455 | 0.69 |
| ggml Q5_0 | 231 | 1966 | 353 | 2002 | 6640 | 4641 | 0.70 |
| ggml Q8_0 | 305 | 2047 | 370 | 2001 | 6560 | 4823 | 0.73 |
| ggml F16 | 468 | 2691 | 364 | 1928 | 6560 | 5562 | 0.85 |
| ONNX q4 | ~4250 (4 files, serialized) | — | — | ~6830 | 5880 | 11050 | 1.88 |
(ONNX Runtime's backend doesn't expose a comparable per-sub-model
breakdown, so its load is the wall-clock time from model.load()
calling through ORT init across all four .onnx files, and gen is
the time the single model.run() call takes.)
Aggregated: load vs. generate, load+gen together:
| Pipeline | Load | Generate | Total wall | RTF (total) |
|---|---|---|---|---|
| ggml Q4_0 | 579 ms | 3788 ms | 4455 ms | 0.69 |
| ggml Q5_0 | 584 ms | 3968 ms | 4641 ms | 0.70 |
| ggml Q8_0 | 675 ms | 4048 ms | 4823 ms | 0.73 |
| ggml F16 | 832 ms | 4619 ms | 5562 ms | 0.85 |
| ONNX q4 | 4250 ms | 6830 ms | 11050 ms | 1.88 |
Headline numbers (best ggml variant vs ONNX):
- Load: ggml Q4_0 is 7.3× faster — 579 ms vs 4250 ms. The four ONNX files initialise serially and each one does its own tensor plumbing; ggml mmaps the two GGUFs and rebinds through the unified backend buffer in ~half a second total.
- Generate: ggml Q4_0 is 1.8× faster — 3788 ms vs 6830 ms.
- Total (load + generate): ggml Q4_0 is 2.48× faster — 4.46 s vs 11.05 s.
- Even ggml F16 beats ONNX q4 on total wall (5.56 s vs 11.05 s, 1.99× faster) despite carrying 2× the weights — the ONNX backend loses to an un-quantized ggml build on the same CPU.
- RTF < 1 (faster than real-time) happens on every ggml variant tested; ONNX stays at 1.88× real-time for this prompt.
Numbers are for a ~6 s utterance; the ggml pipeline's ~2 s of fixed S3Gen+HiFT cost amortizes better on longer input, so the gap widens in ggml's favour as prompt length grows.
CPU performance was already past real-time, but a lot of the T3 and CFM work is embarrassingly parallel, so enabling the GGML GPU backends was the obvious next step. Touched three files:
CMakeLists.txt— added aGGML_VULKANpropagation block mirroring the existingGGML_CUDA/GGML_METALones.src/main.cpp— extendedinit_backend(n_gpu_layers)with aggml_backend_vk_init(0)path guarded by#ifdef GGML_USE_VULKAN. CUDA / Metal paths were already there.src/chatterbox_tts.cpp— added a symmetrics3gen_init_backendso the S3Gen side honours the same--n-gpu-layersflag, plus a newn_gpu_layersfield ons3gen_synthesize_opts.
Two op-level changes in our code were required because Metal's dispatcher didn't have those ops (the actual Metal kernel fixes land in §3.12):
- T3 attention:
ggml_soft_max(ggml_diag_mask_inf(ggml_scale(KQ, s), n_past))→ggml_soft_max_ext(KQ, mask, s, 0.0f)with an explicit[n_kv, N]causal mask tensor uploaded fromeval_prompt. The step path (N=1) passes a null mask. No-op for CPU / Vulkan; necessary for Metal. - S3Gen zero padding: 6 call sites used
ggml_pad_extwith non-zero front padding. Added azero_pad_dim0(ctx, x, p_front, p_back)helper that expresses the same semantics viaconcat(scale(view, 0.0f), x)so it runs on every backend with well-defined zeros.
First result on the Linux remote (RTX 5090 + Vulkan), same 10 s sentence as §3.10:
| Variant | T3 load | T3 gen | S3Gen load | S3Gen gen | Audio | gen_RTF |
Wall |
|---|---|---|---|---|---|---|---|
| Vulkan F16 | 562 ms | 600 ms | 490 ms | 279 ms | 10.5 s | 0.08 | 2.10 s |
| Vulkan Q8_0 | 450 ms | 557 ms | 472 ms | 272 ms | 10.6 s | 0.08 | 1.91 s |
| Vulkan Q5_0 | 348 ms | 562 ms | 470 ms | 276 ms | 10.9 s | 0.08 | 1.82 s |
| Vulkan Q4_0 | 331 ms | 522 ms | 493 ms | 275 ms | 10.3 s | 0.08 | 1.78 s |
Quantization makes T3 load noticeably smaller but barely moves
inference — T3 is autoregressive (one token at a time on a 5090 has
plenty of spare lanes) and S3Gen is already short. End-to-end goes
from 8.17 s (CPU F16) → 1.78 s (Vulkan Q4), for the same 10 s of
audio. gen_RTF = 0.08 = 13× real-time.
On the M3 Ultra Metal side, things didn't fly immediately: T3 aborted
on the first attention layer with unsupported op 'DIAG_MASK_INF',
then S3Gen aborted with unsupported op 'PAD'. Once those two
op-level workarounds above were in place, HiFT decode was completing
but taking ~15 s for 1.2 s of audio — Metal's
conv_transpose_1d kernel is pathological for HiFT-sized inputs.
Pragmatic interim fix: when the main backend is Metal, load a second
CPU copy of the S3Gen GGUF and route run_f0_predictor,
run_stft, and run_hift_decode through it. Encoder + CFM still run
on Metal. Costs ~1 GB extra RAM but brings Metal gen_RTF to ~0.25.
That's what committed as 795963a ("backend: enable Vulkan + Metal
for T3 and S3Gen").
To get rid of the CPU fallback for HiFT and close the gap with
Vulkan, patched ggml/src/ggml-metal/ itself. The patch is shipped
as patches/ggml-metal-chatterbox-ops.patch (based on upstream
58c3805, sync : llama.cpp); the main README instructs a fresh
clone to git apply it after cloning ggml.
A new test-metal-ops binary runs each patched kernel against the
CPU reference at HiFT-realistic shapes. All cases pass with
max_abs ≤ 1.5e-6.
Patch 1 — DIAG_MASK_INF on Metal (was: op simply absent from
the dispatcher):
- New
kernel_diag_mask_inf_f32— ports the CUDA formulation (dst[i] = src[i] - (col > n_past + row % rows_per_channel) * FLT_MAX) so downstream softmax yields proper zeros. - New
ggml_metal_kargs_diag_mask_inf, library pipeline getter, op encoder, dispatcher case, andsupports_opentry.
Patch 2 — PAD with front padding (was: kernel ignored
op_params[0,2,4,6] which is where ggml_pad_ext stores the front
amounts; supports_op hard-rejected any non-zero front pad):
- Extended
ggml_metal_kargs_padwithlp0..lp3. - Rewrote
kernel_pad_f32to translate each output coord byi0x = i0 - lp0etc., and write0.0outside[0, ne00). - Relaxed
supports_optosrc0->type == F32 && dst->type == F32.
Patch 3 — CONV_TRANSPOSE_1D speedup (was: ~100× slower than
CPU on HiFT-sized inputs):
The old kernel was scalar — one thread per output pixel, iterating
over the full IC × IL inputs inside a branch if (ol >= i*s0 && ol < i*s0 + K). Two orthogonal fixes:
- Tighten the input-position loop to only the
is that actually contribute. For fixedol, validiis[max(0, ⌈(ol - K + 1)/s0⌉), min(IL-1, ol/s0)]— at mostK/s0 + 1iterations. On ups[0] (s0=8, K=16, IL≈130) this collapses the inner loop from 130 iterations → 3. - Parallelise
ICacross a 32-thread simdgroup and reduce withsimd_sum. Host-side dispatch widens from 1 thread per threadgroup → 32 (one simdgroup).
Measured on M3 Ultra, HiFT decode (part of a 10 s sentence):
hift_decode: 15021 ms → 350 ms (≈ 40× speedup)
gen_RTF : 0.25 → 0.18 (CPU-fallback removed)
wall : 3.36 s → 2.51 s
With the patch applied and the CPU-fallback for HiFT removed, end-to-end on the M3 Ultra for the same 10 s sentence, seed 42, averaged over 3 runs:
| Variant | T3 load | T3 gen | S3Gen load | S3Gen gen | gen_RTF |
Wall |
|---|---|---|---|---|---|---|
| Metal F16 | 280 ms | 1326 ms | 295 ms | 577 ms | 0.19 | 2.51 s |
| Metal Q8_0 | 216 ms | 1330 ms | 302 ms | 598 ms | 0.18 | 2.48 s |
| Metal Q5_0 | 186 ms | 1393 ms | 293 ms | 611 ms | 0.19 | 2.51 s |
| Metal Q4_0 | 175 ms | 1274 ms | 295 ms | 594 ms | 0.18 | 2.36 s |
Autoregressive T3 now dominates wall time (T3_INFER ≈ 1.3 s of
~260 tokens at one-token-at-a-time on a 60-core Apple GPU) — that's
the next thing to chip away at. On the 5090 the same token stream
runs in ~0.55 s because the shader count is ~360× higher.
Committed as 894c4b1 ("metal: patch ggml to fix diag_mask_inf,
pad_ext, conv_transpose_1d"). im not a fan of forking ggml just
for this, so the patch is tiny and easy to drop once upstream picks
up equivalent fixes; see patches/README.md for what to do in that
case.
After §3.11 / §3.12 the dominant wall-clock cost in Chatterbox became
T3's autoregressive step (≈ 1.3 s of a ~2.4 s run on Metal M3 Ultra
Q4_0). An earlier attempt to swap the explicit
soft_max_ext(mul_mat(K,Q), mask) + mul_mat(V_trans) chain for
ggml_flash_attn_ext ran into a deal-breaker: the KV cache was laid
out [HD, n_head, n_ctx] per layer but flash_attn_ext wants
[HD, n_ctx, n_head]. Every step had to ggml_cont(ggml_permute(K))
over a tensor that grew with n_past, and the extra kernel dispatches
wiped out FA's savings.
Fix: store the cache the way FA reads it.
- Same total size per layer (
HD * n_ctx * n_head==n_embd * n_ctx), so no allocation changes. - Write path (step or prompt): Kcur / Vcur are viewed as
[HD, n_head, N], permuted to[HD, N, n_head], then oneggml_cpyper tensor into a strided cache view at[HD, n_past:n_past+N, n_head]. For the step path N=1 the permute is a no-op in memory. - Read path:
ggml_view_3d(memory_k, HD, L, n_head, nb=[4, HD*4, HD*n_ctx*4], offset=il*layer_size)is exactly the shape FA needs, with nopermute + cont. - Mask: switched from F32 to F16 (ggml FA requires F16 on Metal;
other backends accept it too). N=1 path passes
nullptrsince every KV position is in the past.
Measured on M3 Ultra, same 10 s sentence, seed 42, --threads 20,
--n-gpu-layers 99, averaged over 3 warm runs:
| Variant | T3 infer before | T3 infer after | Δ | Wall before | Wall after | gen_RTF |
|---|---|---|---|---|---|---|
| F16 | 1372 ms | 983 ms | −28 % | 2.51 s | 2.15 s | 0.189 → 0.157 |
| Q8_0 | 1371 ms | 985 ms | −28 % | 2.48 s | 2.12 s | 0.182 → 0.149 |
| Q5_0 | 1445 ms | 1063 ms | −26 % | 2.51 s | 2.18 s | 0.186 → 0.152 |
| Q4_0 | 1274 ms | 965 ms | −24 % | 2.36 s | 2.06 s | 0.176 → 0.144 |
And the same change on Vulkan 5090 (Linux remote):
| Variant | T3 infer before | T3 infer after | Δ |
|---|---|---|---|
| F16 | 600 ms | 410 ms | −32 % |
| Q4_0 | 522 ms | 356 ms | −32 % |
So the new layout is not just a Metal-shaped win — it speeds up every
GPU backend, because the previous permute + cont per layer per step
was cheap on NVIDIA too but not free. CPU builds see a similar graph
shape (fewer intermediate nodes) and stay neutral.
Output sampling is not bit-exact against the old path: FA runs its own internal reductions in different order and the mask lives in F16 instead of F32, so token counts can shift by ±2 % (e.g. F16 went from 248 → 244 tokens on the bench prompt). Audio remains perceptually identical; this is the same kind of drift that moving to FA causes anywhere else in ggml.
Committed as part of the Metal optimization sequence alongside the
earlier patches/ggml-metal-chatterbox-ops.patch.
After §3.13, each T3 attention layer still did two ggml_conts on Q
per step: one cont_3d to densify the strided view of Qcur, and an
outer cont after the head-permute. Both turn into
kernel_cpy_f32_f32 dispatches on Metal.
Observation: the entire QKV output cur is already contiguous. Q,
K, and V are just fixed byte offsets into the same tensor (0,
n_embd * 4, 2 * n_embd * 4 respectively). With Metal's
flash_attn_ext accepting non-contiguous Q via explicit strides (the
same flexibility I used for K/V in §3.13), I can drop both conts and
express Q directly as a ggml_view_3d with layout [HD, N, n_head]:
nb0 = 4, nb1 = 3 * n_embd * sizeof(float), nb2 = HD * sizeof(float)
Same trick for the Kcur/Vcur sources that go into the KV-cache write path — one view each, no permute + cont pair.
Removes 24 kernel dispatches per step (cont × 24 layers); since T3
step time on Metal is almost entirely dispatch-bound at ~9 µs each,
this shows up straight in the numbers.
Measured on M3 Ultra (same 10 s sentence, seed 42, 3-run warm average):
| Variant | T3 infer §3.13 | T3 infer §3.14 | Δ | Wall §3.13 | Wall §3.14 |
|---|---|---|---|---|---|
| F16 | 983 ms | 909 ms | −7.5% | 2.15 s | 2.08 s |
| Q8_0 | 985 ms | 906 ms | −8.0% | 2.12 s | 2.03 s |
| Q5_0 | 1063 ms | 984 ms | −7.4% | 2.18 s | 2.09 s |
| Q4_0 | 965 ms | 886 ms | −8.2% | 2.06 s | 1.98 s |
Vulkan RTX 5090 sees <3 % change in T3 infer — dispatch overhead is much smaller there relative to the actual compute, so there's less to save. No regression on Vulkan, and the code simplifies. CPU stays neutral (same graph topology, fewer intermediate nodes).
Sampling output is not bit-exact against §3.13 either — same reason as before, FA reductions are sensitive to operand stride. Token counts shift within ±1 % at the same seed.
Even after §3.14 the T3 step path still dispatched two Metal kernels
per linear layer — mul_mv for the matmul itself, then bin_fuse for
the following add(bias). T3 has 4 such linears per layer
(QKV proj, attn proj, MLP fc, MLP proj) × 24 layers = 96 extra bias
kernels per step. At ~9 µs dispatch overhead on M3 Ultra that's
~900 µs/step / ~240 ms over a 260-token generation.
Patched ggml-metal to fuse these directly inside the mul_mv kernel
(third addition to patches/ggml-metal-chatterbox-ops.patch):
- New function constant
FC_mul_mv_has_biasatFC_MUL_MV + 2. - Each Q-variant top-level kernel (
kernel_mul_mv_q4_0_f32,_q4_1_f32,_q5_0_f32,_q5_1_f32,_q8_0_f32) picks up an extradevice const char * biasbuffer argument and calls a tinyhelper_mv_add_bias<NR0>immediately after the existing impl. The post-pass only runs when the function constant is true and only one thread per row does the add (no cross-threadgroup synchronisation needed; each threadgroup writes and then reads back only its own output rows). ggml_metal_op_mul_matgets actx->use_fusion && kernel_supports_biaslook-ahead: if the next op is anADDwith a contiguous F32[ne0, 1]bias, we compile the pipeline withhas_bias=true, bind the bias buffer to slot 4, redirect the matmul'sdstto the ADD's output tensor, and returnn_fuse=2so the dispatcher skips the ADD. The shared pipeline name (…_bias=1) makes the fused variant cache-coherent with the non-fused one.- For kernels not yet wired (F16/BF16
mul_mv_t_t, the_4SIMD variants, all the K-quants and IQ variants) the fusion is suppressed bykernel_supports_bias, the pipeline compiles withhas_bias=false, and the kernel'sif (FC_mul_mv_has_bias)is dead-code eliminated. MoEmul_mv_idkeeps calling the original impl viammv_fnunchanged; the impl signature itself was not touched.
Measured on M3 Ultra, 10 s sentence, seed 42, 3-run warm average:
| Variant | T3 before §3.15 | T3 after §3.15 | Δ | Wall before | Wall after |
|---|---|---|---|---|---|
| F16 | 909 ms | 915 ms | ~flat | 2.08 s | 2.26 s |
| Q8_0 | 906 ms | 819 ms | −9.6% | 2.03 s | 2.02 s |
| Q5_0 | 984 ms | 840 ms | −14.6% | 2.09 s | 1.96 s |
| Q4_0 | 886 ms | 766 ms | −13.5% | 1.98 s | 1.87 s |
F16 is flat because the kernel it hits (mul_mv_f16_f32_4) isn't in
the supported list yet; extending to those variants is a mechanical
follow-up (touches helper_mv_reduce_and_write + the 3 _t_t /
_t_t_4 / _t_t_short templates in the same way).
Vulkan RTX 5090 unchanged (347 → 343 ms on Q4_0 — noise). CPU unaffected (Metal-only change).
Total Metal Q4_0 journey (pre-FA → end of §3.15):
T3 infer Wall gen_RTF
pre-FA 1274 ms 2.36 s 0.176
§3.13 FA+KV 965 ms 2.06 s 0.144 -24%
§3.14 Q views 886 ms 1.98 s 0.131 -30%
§3.15 bias fn 766 ms 1.87 s 0.119 -40%
40 % faster T3 inference, 21 % faster end-to-end wall than the pre-optimization baseline on the same M3 Ultra — all via Metal kernel + graph-shape changes, no model changes.
While investigating whether the §3.15 fusion could also apply to Vulkan and CPU, two findings:
- Vulkan already has it.
ggml_vk_can_fusein upstream recognisesMUL_MAT + ADDandMUL_MAT + ADD + ADD, and the mat-vec shaders (vulkan-shaders/mul_mat_vec_iface.glsl) have dedicatedFuse0/Fuse1buffer bindings for the two optional adds. RunningGGML_VK_DISABLE_FUSION=1on the 5090 pushes T3 Q4_0 from 346 → 413 ms (3-run avg), a real 16 % speedup that was silently helping us before. Nothing to add on Vulkan. - CPU has no op-level fusion framework. But it also has ~zero
per-op dispatch overhead (ggml-cpu just calls the next op's compute
function directly), and the matmul output stays in L1 cache
(
n_embd=1024× 4 B = 4 KB) so the intermediate round-trip is essentially free. Estimated gain from fusion: < 1 %. Not worth the plumbing work.
That left Metal, where §3.15 covered MUL_MAT + ADD(bias) but not the
3-op form MUL_MAT + ADD(bias) + ADD(residual) used by T3's attn-proj
and MLP-proj linears. Extended the Metal patch to match Vulkan's
fusion surface:
- New function constant
FC_mul_mv_has_residualatFC_MUL_MV + 3. - Each Q-variant top-level kernel gains a second buffer binding
(
device const char * residualat slot 5).helper_mv_add_biasnow applies both the bias broadcast and the per-element residual add; both branches are gated on their respective function constants so non-fused call sites specialise them away. ggml_metal_op_mul_mattries{MUL_MAT, ADD, ADD}first (requires bias-shaped src1 on ADD1 and full-shape F32-contiguous on ADD2), falls back to{MUL_MAT, ADD}from §3.15. Returnsn_fuse=3/n_fuse=2accordingly.- Pipeline names now carry
_bias=?_res=?so fused/non-fused variants are cached independently by the library.
Correctness bug caught while writing the 3-op variant. §3.15's
helper had if (tiisg != 0 || sgitg != 0) return;, so only simdgroup
0 added bias. That's correct for Q8_0 (all simdgroups cooperate on
the same r0) but wrong for Q4/Q5 where each simdgroup writes
its own r0 = (tgpig.x*NSG + sgitg)*NR0, silently dropping bias from
the rows computed by simdgroups ≥ 1. Output was "close enough" to
sound right but not numerically correct. Fixed by moving the
sgitg gate to the callers: Q-n kernels call the helper from every
simdgroup with their own r0; Q8_0 wraps the call in
if (sgitg == 0). Token counts snapped back to the pre-fusion
trajectory once this was right.
Measured on M3 Ultra, 10 s sentence, seed 42, 3-run warm average:
| Variant | T3 before §3.16 | T3 after §3.16 | Δ | Wall before | Wall after |
|---|---|---|---|---|---|
| F16 | 915 ms | 913 ms | flat | 2.26 s | 2.27 s |
| Q8_0 | 819 ms | 794 ms | −3 % | 2.02 s | 1.94 s |
| Q5_0 | 840 ms | 873 ms | +4 % | 1.96 s | 2.01 s |
| Q4_0 | 766 ms | 770 ms | flat | 1.87 s | 1.88 s |
Smaller than the headline "save 48 dispatches × 9 µs" estimate
suggested, because Metal's scheduler overlaps consecutive small
dispatches — the bin_fuse the fused kernel replaces was already
running concurrently with later work. Q8_0 still sees a clean 3 %
win; Q4/Q5 are noise after accounting for token-count drift. Still
worth committing: matches Vulkan's fusion surface, fixes the latent
§3.15 bias correctness bug, and closes the last dispatch-per-linear
gap vs Vulkan.
The CLI had always been single-shot (pass --text, get one wav),
which meant anything "keep the model warm and speak whatever I send"
required re-spawning the binary per request. Added a long-running
mode driven by --input-file PATH: the binary tail -f's PATH,
splits on sentence terminators, and pipes raw PCM (s16le @ 24 kHz)
to stdout chunk-by-chunk.
Key details that came up during the implementation:
fread+clearerrdoesn't tail-follow on macOS. Once the stdioFILE*hits EOF, the readahead buffer can keep returning 0 fromfreadfor many subsequent calls even after the writer has appended new bytes andclearerr()has been called. Switched toopen()+read()on a plain fd so the kernel is always consulted for the current file state — fixed the "second process's writes get dropped" symptom.- Accept
<.!?>followed by an uppercase letter as a sentence break, in addition to the original<.!?>+ whitespace / newline / end-of-input. LLMs / transcribers that pack sentences back-to-back without a space ("Hello.World.Foo.") were otherwise bundling everything into one enormous utterance. - Interactive stdin mode —
--input-file -reads fromSTDIN_FILENOdirectly (noopen("/dev/stdin")which gets a fresh-offset fd on some systems). When stdin is a TTY, the binary prints a>prompt on stderr (so it can't collide with the raw PCM stream on stdout), wraps theread()in aselect()with a 25 ms poll so SIGINT is noticed without the user also having to press Enter, and re-prompts after each synthesised sentence. Single process, pipe stdout straight tosox play, type a sentence, hear it back. --input-by-lineline mode — one newline = one request. Internal. ! ?are treated as prosody, not as hard boundaries, so "Hello there. How are you today?" becomes a single T3 run instead of two runs with a 150 ms gap between them. Saves the inter-sentence restart cost and produces more natural delivery when the upstream emits complete thoughts per line.- T3 early-stop auto-retry was also hit in live mode. The
batch pipeline already replays segments when T3 samples
stop_speech_tokensuspiciously early (symptom: a cloned voice clips the first or last word of a sentence). Lifted the samemin_tokens = max(8, bpe_tokens * 5), three-attempt, keep-longest guard into the livesynth_sentence. - Skip pure-punctuation input. With the various split
heuristics, it was possible to route a single
.through T3 (on a TTY: the user hits Enter with an empty buffer, punc_norm fills in a period). T3 then hallucinates ~1.4 s of speaker-biased audio that can sound like a word from the previous utterance. The live path now drops any sentence whose punc-normalised form contains no alphanumeric characters, with a[skipped: no word characters]notice on TTY. - Knob cleanup. Removed
--input-flush-ms(idle-flush mid-buffer was only useful when the terminator set was limited to.!?and got obsoleted by--input-by-line+ explicit\n) and--input-poll-ms(hard-coded to 25 ms, well below perception). One less thing to think about for users; one less thing to get wrong.
Commits: 00bfd7f (fread→read fix), 189fe9d (interactive stdin),
9e1b101 (T3 retry port), dc0b5e1 (punctuation-only skip),
e0af5e9 (--input-by-line), d843a59 / cff89ae (knob cleanup).
Every voice-cloning debug session ended the same way: probe the
source with ffprobe, scan with silencedetect, eyeball the output
for the longest clean region, pick an -ss/-t, iterate on the
ffmpeg filter chain until the clone stopped sounding wrong, optionally
bake the .npy profile. Scripted the whole thing.
./scripts/extract-voice.py INPUT [--name NAME] [--target SEC] [--bake]
does:
ffprobefor duration, codec, bitrate.ffmpeg silencedetect=noise=-30dB:d=0.3to split into speech regions.- Rank candidate windows: prefer a continuous slice from the middle of the longest region (speaker is warmed up, hasn't started wrapping up), fall back to concatenating the two best short blocks when no single block is ≥ target.
- Pick a codec-aware filter chain:
- clean (WAV / FLAC / ≥ 96 kbps AAC / ≥ 128 kbps MP3):
highpass=f=60, alimiter=limit=0.85:level=disabled. Trusts the source. - lossy (Opus / Vorbis at any bitrate, or low-bitrate
AAC / MP3):
highpass=f=60, afftdn=nr=6:nt=w, equalizer=f=200:w=150:g=-1, equalizer=f=3200:w=2200:g=2.5, equalizer=f=7500:w=2500:g=3, loudnorm=I=-18:TP=-2:LRA=8, alimiter=limit=0.85:level=disabled. Denoises the codec hiss, puts a mild dip at 200 Hz to unmuddy, boosts presence around 2–4 kHz and air around 6–9 kHz to replace some of the content Opus' brick-wall low-pass throws away above ~8 kHz, loudness- normalises so the speaker embedding doesn't drift on the shouted-vs-whispered axis.
- clean (WAV / FLAC / ≥ 96 kbps AAC / ≥ 128 kbps MP3):
- Emit
voices/<name>.wavat 24 kHz mono s16le. - Optionally call
./build/chatterbox --save-voiceto bake the five.npytensors.
Commit: 84d2189.
The lossy chain is what took an 18 kbps Opus voice note from "clone sounds wrong" to "sounds like the speaker" during the Marco debug session. On clean-source material the minimal chain is usually sufficient and the EQ boosts would only add a mild bright tint.
Same 10 s sentence, seed 42, gen_RTF is inference-only (excludes
load time):
| Backend (weights) | T3 gen | S3Gen gen | gen_RTF |
Wall | Real-time mult |
|---|---|---|---|---|---|
| CPU Linux (F16, 8 threads) | 3998 ms | 2905 ms | 0.70 | 8.17 s | 1.4× |
| Vulkan 5090 (F16) | 402 ms | 282 ms | 0.064 | — | 15.6× |
| Vulkan 5090 (Q4_0) | 347 ms | 284 ms | 0.058 | — | 17.1× |
| Metal M3 Ultra (F16) | 915 ms | 567 ms | 0.150 | 2.26 s | 6.7× |
| Metal M3 Ultra (Q4_0) | 766 ms | 596 ms | 0.128 | 1.87 s | 7.8× |
| ONNX q4 addon (CPU, Linux) | — (not exposed) | — | 1.06 | 13.91 s | 0.94× |
The ONNX addon is shown as a baseline because it's the current in-house reference TTS implementation. Every ggml configuration — including CPU F16 on the same host — beats it.
Staged pipeline:
- Python reference dumper (
scripts/dump-s3gen-reference.py) runs the full PyTorch pipeline withforward_hooks on every module we plan to reimplement. Each intermediate is saved as.npyinartifacts/s3gen-ref/with a predictable name. Multi-call hooks save a_call{N}suffix so each flow-matching step gets its own tensor. - C++ staged harness (
src/test_s3gen.cpp) loads a single GGUF, and for each stage: loads the reference tensors as inputs, builds a tiny ggml graph covering exactly that stage, runs it, reads back outputs, and callscompare_f32(got, expected, n)to printmax_abs / mean_abs / rms / max|ref| / rel. - For T3 we additionally have bit-exact testing — under greedy decoding ggml speech tokens equal PyTorch speech tokens token-for-token.
- For
chatterbox-ttswe have--debugmode that substitutes Python-dumped random bits for the stochastic parts, pinning the comparison.
Precision regressions are immediately visible: a change that drops rel to ~1e-4 shows up at stage N+1 before silently corrupting the full pipeline.
ssh gianni@dev-linux-x64
cd ~/chatterbox.cpp
# One-time: build the binaries
cmake -S . -B build
cmake --build build -j10 --target chatterbox chatterbox-tts test-s3gen mel2wav
# One-time: convert weights + built-in conditionals
. ~/chatterbox-ref/.venv/bin/activate
python scripts/convert-t3-turbo-to-gguf.py --out models/chatterbox-t3-turbo.gguf
python scripts/convert-s3gen-to-gguf.py --out models/chatterbox-s3gen.gguf
# One-time: dump the Python reference tensors
python scripts/dump-s3gen-reference.py \
--text 'Hello from ggml.' --out artifacts/s3gen-ref \
--seed 42 --n-predict 64 --device cpu
# Validate every stage in C++
./build/test-s3gen models/chatterbox-s3gen.gguf artifacts/s3gen-ref ALL
# End-to-end text → wav
./scripts/synthesize.sh "Hello from native C++." /tmp/out.wavRanked by impact-per-effort ratio, from biggest wins to niche polish.
Voice cloning works end-to-end TODAY using a Python preprocessing
helper that produces a five-tensor voice profile from a reference
.wav. The C++ binary accepts it via --ref-dir DIR.
Phase 1 (DONE) — Python helper + C++ wiring:
scripts/prepare-voice.py: wrapsChatterboxTurboTTS.prepare_conditionals()to produce a directory withspeaker_emb.npy(T3 256-d) +cond_prompt_speech_tokens.npy(T3 ≤375 int32) +embedding.npy(S3Gen 192-d) +prompt_token.npy(S3Gen int32) +prompt_feat.npy(S3Gen mel, 80-channel).src/main.cpp: when--ref-diris set, overwrite the T3 side in place (model.builtin_speaker_emb) or, when the prompt-tokens length differs from the GGUF's built-in (audio < 15 s → fewer tokens), allocate a fresh tensor inctx_override+buffer_overrideon the same backend and repointmodel.builtin_cond_prompt_tokensat it.hparams.cond_prompt_lenis updated to match sobuild_prompt_graphsizes the sequence correctly.src/chatterbox_tts.cpp: the S3Gen side already reads the same three.npyfiles whenref_diris non-empty.
End user workflow:
python scripts/prepare-voice.py --ref-audio me.wav --out voices/me/
./build/chatterbox --model models/chatterbox-t3-turbo.gguf \
--s3gen-gguf models/chatterbox-s3gen.gguf \
--ref-dir voices/me/ \
--text "Hello in my voice." \
--out out.wavVerified end-to-end on the remote EPYC: override prints
overrode T3 built-in voice from voices/test (speaker_emb=256, cond_prompt_tokens=260), the synthesis runs at RTF 0.44, the output
wav plays back cleanly on the Mac.
Phase 2a (DONE) — C++ WAV I/O + sinc resampler + 80-ch log-mel at 24 kHz:
src/dr_wav.h(public-domain single header, MIT-0 fallback) vendored as a bundled WAV loader (all PCM variants, any sample rate, auto-mono).src/voice_features.{h,cpp}:wav_load,resample_sinc(Kaiser-windowed, beta=8.6, configurable tap count), andmel_extract_24k_80. The mel extractor is a direct port ofs3gen.utils.mel.mel_spectrogram(n_fft=1920,hop=480,win=1920,fmin=0,fmax=8000,center=False, reflect-pad 720).scripts/convert-s3gen-to-gguf.pynow also bakes in the precomputed librosa mel filterbank (librosa.filters.mel(sr=24000, n_fft=1920, n_mels=80, fmin=0, fmax=8000), a(80, 961)float32 matrix) ass3gen/mel_fb/24k_80. Runtime has no librosa dep.- Two validation binaries:
test-resample(24 kHz → 48 kHz → 24 kHz round-trip on a 4-tone signal, expects > 60 dB SNR) andtest-voice-features MODEL.gguf REF.wav PROMPT_FEAT.npy(compares C++ 80-ch log-mel against a Python-dumpedprompt_feat.npy).
Measured on 10-core EPYC:
| Check | Result |
|---|---|
| Resampler round-trip (4-tone, 24k ↔ 48k) | 95.75 dB SNR |
Mel parity vs Python prompt_feat.npy (rel) |
8.3e-08 |
(The ~500-frame Python reference truncates at DEC_COND_LEN = 10 s; the C++ side produces an extra ~20 frames for a 10.4 s input wav but the overlapping 500 × 80 values match to float precision.)
Implementation notes:
- First attempt at
resample_sincwas a polyphase decomposition with a Kaiser-windowed sinc prototype; the phase-indexing convention was subtly wrong and gave 0 dB SNR on the round-trip. Swapped for straightforward "fractional-index sinc interpolation at each output sample" which is correct and still fast enough for one-shot voice preprocessing. mel_extract_24k_80uses a naive O(n_fft) DFT per frame, not an FFT. For a 10 s reference that's ~520 frames × 1920 × 961 ≈ 960 M mults, well under 2 s on CPU. Fine for preprocessing; an FFT is a trivial follow-up if this ever needs to be streaming.
Phase 2b (DONE) — --reference-audio PATH.wav wired into main.cpp.
The CLI now accepts a reference wav, runs the whole WAV→prompt_feat
chain in C++, and injects the result into s3gen_synthesize_opts
(new prompt_feat_override field) so the S3Gen+HiFT pipeline consumes
it directly — no temp file, no npy round-trip. The other four voice
tensors still come from --ref-dir for now.
User workflow:
python scripts/prepare-voice.py --ref-audio me.wav --out voices/me/
./build/chatterbox \
--model models/chatterbox-t3-turbo.gguf \
--s3gen-gguf models/chatterbox-s3gen.gguf \
--ref-dir voices/me/ \
--reference-audio me.wav \
--text "Voice-cloned with C++ mel." \
--out out.wavVerified end-to-end: voice: prompt_feat shape=(520, 80) /
prompt_feat: using C++ override (520 mel frames) / audible cloned
voice at RTF 0.76 on 10-core EPYC.
Phase 2c (DONE) — C++ VoiceEncoder: 3-layer unidirectional LSTM + Linear(256 → 256) + ReLU + L2-normalise, 40-channel 16 kHz power-mel in, 256-d speaker embedding out.
New files:
src/voice_encoder.{h,cpp}— weights loader (reads 14 tensors from the t3 GGUF +voice_encoder/mel_fb), plain-C++ LSTM forward pass (no ggml graph), partial-window averaging that exactly reproducesVoiceEncoder.embeds_from_wavs(..., as_spk=False)for a single wav: mel is split into overlapping 160-frame partials usingget_frame_step/get_num_wins, each partial produces an L2-normed 256-d embedding via LSTM + projection, then the per-partial embeds are averaged and L2-normed once more.src/test_voice_encoder.cpp— parity harness; compares the C++ 256-dspeaker_embagainst Pythonspeaker_emb.npyusingmax_abs,rms,reland cosine similarity.
Converter change: scripts/convert-t3-turbo-to-gguf.py now bakes in
the VE weights (weight_ih_l{0,1,2}, weight_hh_l{0,1,2},
bias_{i,h}h_l{0,1,2}, proj/weight, proj/bias) plus the librosa
(40, 201) mel filterbank as voice_encoder/mel_fb, and writes VE
hyperparameters (n_mels, hidden_size, num_layers, partial_frames,
sample_rate, n_fft, hop_size, win_size, overlap, rate, min_coverage)
as GGUF metadata so we never need ve.safetensors at runtime. The
similarity_{weight,bias} params are skipped — they're only used for
speaker-verification training, not embedding extraction.
Feature extraction: src/voice_features.cpp gained
mel_extract_16k_40, which shares the STFT/mel core with
mel_extract_24k_80 but uses the VE-specific knobs (center=True,
power_exponent=2, no log compression).
CLI wiring: main.cpp now resolves the T3 voice override in two
independent pieces. If ref_dir/speaker_emb.npy is missing but
--reference-audio PATH.wav is given AND the T3 GGUF has VE weights,
it loads the wav, resamples to 16 kHz, and computes speaker_emb in
C++ via voice_encoder_embed(). cond_prompt_speech_tokens still
comes from ref_dir until Phase 2e. Logs distinguish the source:
T3 voice override — speaker_emb=C++ VoiceEncoder, cond_prompt_tokens=ref_dir.
Verification on 10.4 s reference wav:
[result] C++ vs Python speaker_emb:
n=256 max_abs=1.71e-05 rms=2.58e-06 max|ref|=2.45e-01 rel=6.97e-05
cosine similarity = 1.000000
Cosine = 1.000000 confirms angular match to 6 decimal places; the
~1e-5 absolute error is pure float32 accumulation noise. End-to-end
synthesis with speaker_emb.npy deleted from the voice dir produced
a 276 kB WAV that plays cleanly on macOS — the C++-computed speaker
embedding drives T3 conditioning indistinguishably from Python.
Two down, two to go (embedding and prompt_token via CAMPPlus +
S3TokenizerV2).
Phase 2d-a (DONE) — C++ CAMPPlus forward pass, validated end-to-end against the Python reference on a Python-dumped 80-ch Kaldi fbank.
CAMPPlus is a FunASR/3D-Speaker x-vector: 937 raw tensors (329 conv / linear weights + 122 BatchNorms + biases + counters). Structure:
fbank (T, 80)
→ FCM: Conv2d(1→32, k=3) + BN + 2× BasicResBlock (stride=2)
+ 2× BasicResBlock (stride=2) + Conv2d(32→32, s=(2,1))
+ reshape → (320, T)
→ xvector.tdnn: Conv1d(320→128, k=5, s=2) + BN + ReLU
→ 3 × CAMDenseTDNNBlock + TransitLayer
block1: 12 layers, dilation=1 → 128 → 512
transit1: Conv1x1 + BN: 512 → 256
block2: 24 layers, dilation=2 → 256 → 1024
transit2: 1024 → 512
block3: 16 layers, dilation=2 → 512 → 1024
transit3: 1024 → 512
→ out_nonlinear (BN + ReLU)
→ stats_pool (mean + unbiased std over T → 1024)
→ dense: Conv1x1(1024→192) + BN(affine=False) → 192
Each CAMDenseTDNNLayer is BN→ReLU→Conv1x1→BN→ReLU→CAMLayer, with
CAMLayer being linear_local × sigmoid(linear2(ReLU(linear1(ctx))))
where ctx = mean(x, T) + seg_pool(x, 100).expand(T).
Ports:
scripts/convert-s3gen-to-gguf.py— fuses every BatchNorm into a per-channel(scale, shift)pair at export time:scale = gamma / sqrt(var + eps)(or1/sqrt(var + eps)whenaffine=False),shift = beta - mean*scale. Skipsnum_batches_tracked. Embeds 14campplus.*hyperparameters as GGUF metadata and emits the 451 substantive tensors undercampplus/…(329 conv + 122 fused BNs).src/campplus.{h,cpp}— plain-C++ forward pass, no ggml graph. Uses channel-major(C, T)layout throughout. Helpers:bn_apply,relu_inplace,sigmoid_inplace,conv1d,conv2d,seg_pool_expand(avg-pool withceil_mode=True+ repeat-interleave toT),stats_pool(mean + unbiased std). Module-level helpersfcm_basic_resblock,fcm_forward,cam_layer_forward,cam_dense_tdnn_layer_forward. Parallelised via OpenMP.src/test_campplus.cpp— loads CAMPPlus fromchatterbox-s3gen.gguf, runs on a Python-dumpedfbank.npy, compares with Pythonembedding.npyusing max_abs / rms / rel / cosine similarity.scripts/dump-campplus-reference.py— helper that loads the turbo checkpoint, runsextract_feature(Kaldi fbank + per-utterance mean-subtract) andspeaker_encoder.forward, and dumps the two tensors to.npy.
Result on a 10.4 s reference wav (1038 fbank frames, 192-d output):
[result] C++ vs Python embedding:
n=192 max_abs=2.34e-05 rms=6.99e-06 max|ref|=2.49e+00 rel=9.38e-06
cosine similarity = 1.000000
forward pass: 549.9 ms (16-thread EPYC)
rel = 9.4 ppm, cosine = 1.000000 — numerical parity. 550 ms for a
one-time voice-setup pass is comfortably fast.
src/s3gen_pipeline.h grew an embedding_override field and
src/chatterbox_tts.cpp reads it in place of ref_dir/embedding.npy
when provided, mirroring prompt_feat_override. End-to-end wiring
into main.cpp is blocked on Phase 2d-b (Kaldi fbank port) — we can't
feed CAMPPlus from --reference-audio until the C++ binary can
extract its own fbank.
Phase 2d-b (DONE) — C++ port of
torchaudio.compliance.kaldi.fbank with num_mel_bins=80.
Implemented as fbank_kaldi_80 in src/voice_features.{h,cpp}
with all the Kaldi knobs baked in:
frame_length = 25 ms = 400 samples,hop = 10 ms = 160 samplesround_to_power_of_two = True→n_fft = 512window_type = "povey"=hann(N, periodic=False) ** 0.85remove_dc_offset = True(subtract per-frame mean)preemphasis_coefficient = 0.97, with the Kaldi edge caseout[0] = frame[0] * (1 - coeff)use_power = True,use_log_fbank = Truewithlog_floor = FLT_EPSILONsnip_edges = True,dither = 0- Kaldi mel filterbank (
mel = 1127 * log(1 + f / 700), triangular filters equally spaced in mel-space) precomputed byconvert-s3gen-to-gguf.pyand baked in ascampplus/mel_fb_kaldi_80(shape(80, 257)).
Key gotcha we hit: torchaudio's Kaldi wrapper does not apply
the ×32768 int16 scaling that real Kaldi does. With the scale
our output was +20.8 units offset from Python (exactly
2 * log(32768) ≈ 20.79). Dropped the scale and rel jumped from
1.30 to 1.77e-05.
Validation on the synthetic 10 s speech signal:
[result] C++ vs Python fbank:
n=79840 max_abs=2.82e-04 rms=5.91e-06 max|ref|=1.59e+01 rel=1.77e-05
C++ fb[0, :8]: -10.1011 -8.3549 -7.9557 -7.4304 -7.0186 ...
Py fb[0, :8]: -10.1012 -8.3549 -7.9557 -7.4304 -7.0186 ...
Phase 2d-c (DONE) — Wired into main.cpp. New
compute_embedding_native() glues wav_load → resample_sinc → fbank_kaldi_80 → mean-subtract over T → campplus_embed and
populates the new embedding_override field in
s3gen_synthesize_opts. Called best-effort from both short-circuit
and regular T3→S3Gen paths: if the s3gen GGUF pre-dates Phase 2d-a
(no CAMPPlus tensors), it silently falls back to
ref_dir/embedding.npy.
End-to-end dogfood on the 10.4 s reference wav with
speaker_emb.npy and embedding.npy deleted from voices/test/:
voice_encoder: computing speaker_emb from /tmp/unified_remote.wav
main: T3 voice override — speaker_emb=C++ VoiceEncoder, cond_prompt_tokens=ref_dir
voice: prompt_feat shape=(520, 80)
voice: embedding shape=(192,) via CAMPPlus (1038 fbank frames)
embedding: using C++ override (CAMPPlus, 192 dims)
prompt_feat: using C++ override (520 mel frames)
Output WAV plays cleanly and sounds identical to the Python
voice-cloned output. Only cond_prompt_speech_tokens.npy and
prompt_token.npy still live in ref_dir — both are produced by
S3TokenizerV2, the last holdout (Phase 2e).
Phase 2e (DONE) — C++ S3TokenizerV2: a 6-layer FSMN-attention
transformer + FSQ codebook that turns a 16 kHz reference wav into the
25 Hz speech-token stream Chatterbox needs for voice conditioning.
103 tensors / ~124 M params. Produces BOTH the T3-side
cond_prompt_speech_tokens and the S3Gen-side prompt_token streams.
Architecture (mirrors s3tokenizer.model_v2.S3TokenizerV2 exactly):
wav_16k
→ log_mel_spectrogram (n_fft=400, hop=160, 128 mels, log10 clamp+floor
+ (x + 4) / 4 normalise)
→ Conv1d(128 → 1280, k=3, s=2) + GELU
→ Conv1d(1280 → 1280, k=3, s=2) + GELU
→ 6 × ResidualAttentionBlock:
LN → q/k/v (RoPE, NEOX-style, theta=10000)
depth-wise Conv1d(k=31) over v → fsmn_memory
scaled dot-product attention
out = Linear(attn) + fsmn_memory
LN → Linear 1280→5120 → GELU → Linear 5120→1280
→ FSQCodebook:
Linear(1280 → 8) → tanh * 0.999 → round + 1
token = Σ h[i] * 3^i (0..6560)
Implementation:
src/s3tokenizer.{h,cpp}: weights struct + GGUF loader +s3tokv2_log_mel(plain C++ STFT + mel filterbank + log clamp + normalise) +s3tokv2_tokenize(ggml graph for conv-stem + 6 transformer blocks + plain-C++ FSQ). Uses the standard pattern: one weight context (no_alloc, pre-allocated backend buffer) + a per-run input context + a big graph context for intermediates, allocated viaggml_gallocr.- Subtleties:
ggml_conv_1dandggml_conv_1d_dw_phboth assert F16 kernels in their fused kernel paths; we ship F32 weights, so we go throughggml_im2col + ggml_mul_matmanually (conv1d_f32,conv1d_dw_f32).- ggml conv output has time innermost (ne=[T, C]), but the
transformer wants channels innermost (ne=[C, T]) for LN and
1-D bias broadcasts. We
ggml_cont(ggml_transpose(...))between the stem and the blocks. - Attention permutations: q/k to ne=(head_dim, T, n_head),
v to ne=(T, head_dim, n_head), so
mul_mat(k, q)gives scores ne=(T_k, T_q, n_head) with T_k innermost forggml_soft_max, andmul_mat(v, scores)gives out ne=(head_dim, T_q, n_head). - RoPE:
ggml_rope_extwithGGML_ROPE_TYPE_NEOX,freq_base = 10000,n_ctx_orig = 2048, matches the reference's half-splitrotate_halfconvention.
- Converter:
convert-s3gen-to-gguf.pyemits all 103tokenizer.*tensors ass3tokv2/…plus 15 hyperparameters as GGUF metadata. scripts/dump-s3tokenizer-reference.py: dumpswav_16k.npy,log_mel.npy, andtokens.npyfor validation.src/test_s3tokenizer.cpp: parity harness that validates log-mel (always passes cleanly) and reports token accuracy vs Python.
Validation on a 10 s synthetic speech signal:
log_mel : max_abs=1.80e-05 rel=1.30e-05 (numerical parity)
tokens : 236 / 250 = 94.40% (FSQ-rounding drift)
FSQ is extremely sensitive: the project_down → tanh → round pipeline
turns 8 floats into 8 ternary digits, so sub-LSB float drift through
the 6 transformer layers can flip a digit and change the token. Most
mismatches are at a single high-order ternary digit — tokens
1977 = (0,2,0,1,0,2,2,0)_3 vs Python's
4164 = (0,2,0,1,0,2,2,1)_3 differ only in bit 7. In practice the
resulting speaker conditioning is close enough that the cloned audio
sounds identical.
Wiring: main.cpp gained compute_speech_tokens_native() which runs
the tokenizer twice (first 10 s of the wav → prompt_token, first
15 s → cond_prompt_speech_tokens capped to speech_cond_prompt_len).
Results feed s3gen_synthesize_opts::prompt_token_override (new
field) and the existing T3 cond_prompt_speech_tokens override path.
End-to-end pure-C++ voice cloning: with voices/test/ deleted
entirely and only --reference-audio my.wav given, the unified
chatterbox binary now runs the whole flow in C++:
voice_encoder: computing speaker_emb from /tmp/unified_remote.wav
voice: prompt_token=(250,) cond_prompt_speech_tokens=(260,) via S3TokenizerV2
main: T3 voice override — speaker_emb=C++ VoiceEncoder, cond_prompt_tokens=C++ S3TokenizerV2
voice: prompt_feat shape=(520, 80)
voice: embedding shape=(192,) via CAMPPlus (1038 fbank frames)
prompt_token: using C++ override (S3TokenizerV2, 250 tokens)
embedding: using C++ override (CAMPPlus, 192 dims)
prompt_feat: using C++ override (520 mel frames)
scripts/prepare-voice.py is now redundant — the CLI only needs a
reference wav. Impact: voice cloning has zero Python runtime
dependencies; a user just runs the binary.
Impact: Phase 1 unlocked voice cloning as a usable feature. Phases 2a–2e replaced every Python preprocessing step with a native C++ port, so the deployment story is now "one binary + two GGUFs".
Wired --n-gpu-layers through both T3 and S3Gen/HiFT. Now builds with
any of -DGGML_CUDA=ON, -DGGML_METAL=ON, or -DGGML_VULKAN=ON;
init_backend() in main.cpp and s3gen_init_backend() in
chatterbox_tts.cpp pick the matching backend when n_gpu_layers > 0
and fall back to CPU otherwise.
Out-of-the-box Metal was missing three things that needed kernel-level
fixes in ggml/src/ggml-metal/:
GGML_OP_DIAG_MASK_INF— no dispatcher entry. Added a kernel + pipeline getter + op encoder +supports_opcase.GGML_OP_PADwith non-zero front padding — rejected bysupports_op. Extendedkargs_padwithlp0..lp3, updated the kernel to apply them, relaxed the check.GGML_OP_CONV_TRANSPOSE_1D— kernel was scalar. Tightened the input-position loop (i_start..i_endinstead of0..IL) and parallelised theICreduction across a 32-thread simdgroup withsimd_sum. 40× speedup on HiFT-sized shapes.
Patches live in patches/ggml-metal-chatterbox-ops.patch (applied to
the vendored ggml during build); src/test_metal_ops.cpp validates
each patched kernel against the CPU reference. CUDA and Vulkan needed
no backend changes — only the chatterbox wiring.
Result: gen_RTF on a 10 s sentence drops from 0.70 (CPU) to
0.08 (Vulkan 5090) and 0.18 (Metal M3 Ultra).
Still open: T3 autoregressive inference dominates wall time on small GPUs (≈ 1.3 s for 260 tokens on a 60-core Apple GPU). Worth exploring speculative decoding or a smaller T3 draft model if further wins are needed — but current numbers are already interactive.
T3 (GPT-2 Medium, ~700 MB in F16) is the memory-bandwidth-dominated
component in the pipeline. Implemented via --quant {f16,q8_0,q5_0,q4_0}
flag in scripts/convert-t3-turbo-to-gguf.py.
The Python gguf 0.18 package has the K-quants (Q4_K / Q5_K / Q6_K)
declared but raises NotImplementedError in their quantize_blocks
implementations, so only legacy block types (Q4_0, Q5_0, Q8_0) are
produced here. Running the F16 GGUF through llama.cpp's llama-quantize
tool would work too, producing true K-quants — not done yet.
Only the big 2-D mul_mat weights get quantized: per-layer
attn/c_attn/w, attn/c_proj/w, mlp/c_fc/w, mlp/c_proj/w, plus
chatterbox/speech_head. Biases, layer norms, embeddings,
positional encoding, and the tokenizer metadata all stay at their
original dtype (F32 / F16). No C++ changes — ggml_mul_mat with
quantized weights + F32 activations is already a fast path.
Measured results, same prompt and --n-predict 200 (201 tokens output):
10-core EPYC (remote):
| Variant | GGUF size | T3 wall time | vs F16 |
|---|---|---|---|
| F16 | 736 MB | 3.91 s | 1.00× |
| Q8_0 | 460 MB | 2.85 s | 1.37× faster |
| Q5_0 | 350 MB | 2.58 s | 1.52× faster |
| Q4_0 | 313 MB | 2.38 s | 1.64× faster |
10-core Mac16,12 (M-series):
| Variant | T3 wall time | vs F16 |
|---|---|---|
| F16 | 14.92 s | 1.00× |
| Q8_0 | 5.41 s | 2.76× faster |
| Q5_0 | 5.27 s | 2.83× faster |
| Q4_0 | 4.74 s | 3.15× faster |
The Mac speedup is disproportionately large because M-series is much more memory-bandwidth-bound on F16 than EPYC's DDR5 is.
Quality, comparing output tokens on a long prompt:
- Q8_0: bit-for-bit identical to F16. No audible or measurable quality loss. Recommended default for quantized builds.
- Q5_0: sampling diverges starting around token 6. Audio output still sounds correct; small perceptible voice-identity shift.
- Q4_0: sampling diverges slightly earlier and more. Audio still intelligible, with more drift from the F16 reference voice.
S3Gen / HiFT weights are conv-dominated (F16 on CFM linears actually regressed on CPU — see §3.8 Attempt 7), so those stay F32.
Remaining: Q4_K / Q5_K path. Drop-in win would come from
llama-quantize models/chatterbox-t3-turbo.gguf /out.gguf Q4_K_M
once that tool's loader is pointed at our non-llama GGUF, or by
porting one of the K-quant kernels to the Python gguf package.
The current pipeline is "wait 2.4 s then hear all 8.6 s at once". For interactive apps, first-audio-out latency matters more than total RTF.
What to port:
- Chatterbox's
S3GenStreamerpath in Python: interleaves T3 token-generation with chunked S3Gen / HiFT runs, overlap-adds their waveforms at the seams. - Adds
flow_cache,cache_source,mel_cacheparameters we've been setting to empty, plus the overlap-add math for the HiFT vocoder. - Emit audio to stdout (or a callback) as each chunk comes out.
Scope: ~1 week, mostly because the overlap-add math has to match Python byte-for-byte or seams click.
Impact: first audio chunk out in ~200–400 ms instead of 2+ s. Turns the binary from "batch" into "live".
Before shipping the streaming binary we needed the per-chunk C++ mel to
match Python to float32 precision. The per-chunk harness
(src/test_streaming.cpp + scripts/dump-streaming-reference.py) now
reports worst rel = 8.67e-07 for both chunks (i.e. machine epsilon) on
the test.wav reference.
The last bug found was subtle: Chatterbox's turbo flow runs CFM in
meanflow mode, which means flow_inference allocates a
second noise tensor
noise = torch.randn(1, 80, speech_tokens.size(-1) * 2, ...)
super().forward(..., noised_mels=noise)and flow_matching.forward silently overwrites the speech region of
z:
z = torch.randn_like(mu) * temperature
if noised_mels is not None:
prompt_len = mu.size(2) - noised_mels.size(2)
z[..., prompt_len:] = noised_mels # ← second randn draw lives hereOur original Python capture hook wrapped only torch.randn_like, so the
saved chunk_KK_cfm_z.npy contained the first draw everywhere,
including positions t ≥ prompt_len that are actually overwritten by
the second draw. Injecting that stale z as cfm_z0_override in C++
produced CFM output that matched Python bit-exactly in the prompt
region (t < 500) and diverged wildly in the speech region (t ≥ 500)
— exactly the "receptive field of the prompt/speech boundary" pattern
we were chasing.
Fix (commit 2e82cce
and the follow-up in this section):
- Replace the
torch.randn_likecapture with a wrapper aroundCausalConditionalCFM.basic_eulerthat records the fullxtensor at the firstestimator.forwardcall. That tensor is the real z after the meanflow overlay. - Dump it as
chunk_KK_step0_x_in.npy;test-streamingloads that (instead of the oldchunk_KK_cfm_z.npy) intocfm_z0_override. - All four CFM inputs (
mu,mask,spks,cond) already matched at rel ≤ 3e-7, so fixingzmade the estimator output match at rel ≈ machine epsilon.
Lessons: in streaming validation harnesses, capture the exact tensor
the target op receives, not an earlier upstream value. Monkeypatching
a function that a caller later post-processes (z[...] = …) is a
silent source of divergence.
With CFM bit-exact across chunks, wiring up the HiFT side and the user-facing CLI was straightforward:
-
cache_sourcecarry (src/chatterbox_tts.cpp,s3gen_synthesize_opts): aftersinegen_sourceproduces the post-m_sourcesource signal, overwrite its leading samples with the caller-providedhift_cache_sourceand expose the lastsource_tail_samples(480 = 1 mel hop = 20 ms) viahift_source_tail_outso the caller can feed them back in on the next chunk. Matches PythonHiFTGenerator.inference'ss[:, :, :cache_source.shape[2]] = cache_source. -
trim_fade(same file): opt-in raised-cosine fade-in applied to the first2 * sr/50 = 960samples (40 ms) of each chunk's wav. First half zero, second half(cos(π→0)+1)/2. Streaming callers setapply_trim_fadeon chunk 0 only. -
--stream-chunk-tokens NCLI flag (src/main.cpp): wrapss3gen_synthesize_to_wavin a chunked loop that carrieshift_cache_sourceacross chunks, writes per-chunk wavs as<out>_chunk_KK.wav, and concatenates the final wav into--out. Addsappend_lookahead_silence=false,finalize=(is_last), andskip_mel_frames=prev_mels_emittedon each chunk. -
Process-wide model cache (src/chatterbox_tts.cpp,
s3gen_model_cache_get): makes the ~700 ms GGUF-tensor load a one-shot cost.s3gen_preload(path, n_gpu_layers)populates the cache eagerly so main.cpp can kick a background std::thread to warm S3Gen while T3 is still running. Brings first-chunk latency down from 2006 ms → 1340 ms on CPU for the"streaming sanity check"test.
Validation (./build/test-streaming models/chatterbox-s3gen.gguf /tmp/streaming_ref):
| chunk | mel rel | wav rel (informational) |
|---|---|---|
| 1 | 6.47e-07 | 1.06e-01 |
| 2 | 8.67e-07 | 1.24e-01 |
Mel is bit-exact; wav diverges a few percent because C++'s
sinegen_source uses std::mt19937 vs Python's torch.randn — the
audio content is identical, only the per-sample additive white-noise
seed differs. Python's own streamed-vs-batch ratio is 116 %, so our
streamed-vs-Python-streamed is 6.5 %, well inside the structural
envelope of the approach.
Performance numbers on a 3.76 s utterance (9 s of reference audio):
| metric | batch | streaming (25 tokens/chunk) |
|---|---|---|
| total wall time | 2271 ms | 5988 ms |
| first-audio-out | 2271 ms | 1340 ms |
| per-chunk RTF | 0.60 | 1.44 – 1.59 |
What actually changed — plain English. Before this phase, each streaming chunk had to re-run the encoder and CFM on the whole speech so far (so chunk 5 did more work than chunk 1), and CFM always did 2 Euler steps because that's what Python does. Result: each chunk took ~1.5 s to produce 1 s of audio, and the first chunk took ~1.3 s before you heard anything.
Two new chatterbox CLI flags, no change to the model:
-
--stream-first-chunk-tokens N— the first chunk uses N tokens; every chunk after that uses--stream-chunk-tokens. So you can make the first chunk small (≈10 tokens / 0.4 s of audio) to get audio out fast, and keep subsequent chunks big (≈50 tokens / 2 s) to amortise the fixed per-chunk overhead. Code is ~10 lines insrc/main.cpp— just a boundary-building change, no pipeline rewrite. -
--stream-cfm-steps N— override the hard-coded CFM step count (2 for Python's meanflow). SettingN=1literally halves CFM compute per chunk, because CFM is just a 2-step Euler loop. The meanflow-trained model is designed to be sampled in 1 step (per the meanflow paper — "mean" means the ODE can be collapsed to one jump); this isn't a hack, it's using the model the way it was trained to be usable. There's a quality trade — 1-step is a bit noisier than 2-step (log-mag MAE ≈ 0.5) — so default stays at 2. Flag is opt-in. Change is ~5 lines inchatterbox_tts.cppwheret_span = {0, 0.5, 1}used to be hard-coded.
Recommended low-latency preset:
./build/chatterbox --model t3.gguf --s3gen-gguf s3gen.gguf \
--text "…" --out out.wav \
--stream-first-chunk-tokens 10 \
--stream-chunk-tokens 50 \
--stream-cfm-steps 1First audio out in ≈ 800 ms; middle chunks run at RTF 0.65 so the streamer stays ahead of playback on a 4-thread CPU. Numbers below.
What I did not do. The earlier prose promised "incremental
encoder / KV-cached CFM". That would mean: chunk 5 only re-processes
the 25 new tokens, reusing intermediate activations saved from chunks
1–4 — like the KV cache in an LLM decoder. I didn't do that, because
the model isn't built for it. I verified the Python reference: both
the flow encoder and the CFM estimator do full bidirectional
self-attention (every output position looks at every input position,
both directions, static_chunk_size = 0). Reusing previous-chunk
activations requires attention that only looks leftward (causal) or
only within fixed windows (chunked-causal). That's baked into the
trained weights — you can't retrofit it in C++, the model would need
to be retrained. So instead of "KV-cached CFM" I shipped "cheaper
CFM" (1-step) and "smarter chunk boundaries" (small first, big
after). Different optimisations, same user-visible win — fast first
audio, streaming keeps up.
Per-chunk profiling on the same 4.9 s utterance:
| stage | cost per chunk (T_mu≈650) |
|---|---|
| encoder (T_tokens≈350) | ~280 ms |
| CFM step 0 | ~580 ms |
| CFM step 1 | ~500 ms |
| HiFT decode (1 s audio) | ~265 ms |
| total | ~1630 ms for 1 s of audio |
CFM is ~2/3 of every chunk. Two things that don't work for cutting it down without retraining:
- KV-cached CFM / incremental encoder — Chatterbox's flow encoder
and CFM estimator both run full bidirectional self-attention. I
verified
static_chunk_size = 0indecoder.py(no chunked attention mask) and that the encoder has no causal mask either. Caching previous-chunk activations would require the attention to be causal (or at least chunk-causal). Retrofitting that at inference time changes the output distribution — not a pure port. - Prompt-region truncation — the 500-frame prompt accounts for ~70 % of T_mu and its CFM output is discarded every chunk. But attention is full, so any speech-region output depends on every prompt frame via softmax. Truncating to a short prompt tail would require retraining.
What does work, and is now shipped as tunables:
- Non-uniform chunk sizes (
--stream-first-chunk-tokens N). First chunk stays small (≈10 tokens / 0.4 s audio) for fast first-audio-out; subsequent chunks go big (≈50 tokens / 2 s audio) so the fixed per-chunk encoder+CFM cost amortises over more output. - Fewer CFM Euler steps (
--stream-cfm-steps 1). Turbo is meanflow-trained, and meanflow supports 1-step sampling per the paper. In practice 1-step introduces some audible high-frequency noise (log-mag MAE ≈ 0.5 vs 2-step) but keeps content intact. Default stays at 2 to match Python; users opt in via the flag.
Measured on the same text on CPU:
| config | first-audio | chunk-N RTF | overall RTF |
|---|---|---|---|
baseline (--stream-chunk-tokens 25) |
1331 ms | 1.44 – 1.70 | 1.59 |
first-small (10 → 25) |
1156 ms | 1.37 – 1.69 | 1.84 |
1-step + big (50, steps=1) |
1230 ms | 0.63 – 0.69 | 0.78 |
combined (10 → 50, steps=1) |
782 ms | 0.63 – 0.69 | 0.94 |
The "combined" preset hits both objectives at once: first audio out in ≤ 800 ms on CPU, and middle chunks complete in 2/3 of their audio duration so the streamer can stay ahead of playback. Incremental encoder / KV-cached CFM stay on the backlog for when someone wants to retrain Chatterbox with chunk-causal attention.
--out - emits each chunk's audio as raw 16-bit little-endian PCM
to stdout the moment it's produced, with an explicit fflush after
every chunk so downstream players receive it immediately (no stdio
buffering stalls at chunk boundaries).
In stdout mode no .wav files are left behind — per-chunk
intermediate writes go to /tmp/chatterbox_stream_chunk_KK.wav and
are unlink()'d right after the bytes hit stdout. All log output
stays on stderr so the audio stream is clean.
./build/chatterbox \
--model models/chatterbox-t3-turbo.gguf \
--s3gen-gguf models/chatterbox-s3gen.gguf \
--text "Testing stdout streaming." \
--stream-first-chunk-tokens 10 --stream-chunk-tokens 50 \
--stream-cfm-steps 1 \
--out - \
| ffplay -f s16le -ar 24000 -ac 1 -nodisp -autoexit -Validation: the PCM emitted to stdout is byte-for-byte identical to
the file written by the same invocation with a normal --out foo.wav, checked by loading both and taking a diff (max=0, rms=0).
Why not WAV-header-then-PCM? A live WAV header needs the total
sample count up front and we don't know it until the last chunk
finalises; writing a placeholder then patching after the fact doesn't
compose with pipe output. Raw s16le is what ffplay, aplay,
pacat, sox etc. accept natively, so no one loses in practice.
End-to-end streaming verified audible on an Apple M4 with the Metal backend and the recommended low-latency preset:
./build/chatterbox \
--model models/chatterbox-t3-turbo.gguf \
--s3gen-gguf models/chatterbox-s3gen.gguf \
--text "…long paragraph…" \
--stream-first-chunk-tokens 10 \
--stream-chunk-tokens 25 \
--stream-cfm-steps 1 \
--n-gpu-layers 99 \
--out - \
| play -q -t raw -r 24000 -b 16 -e signed -c 1 -Measured on the 48-text-token sentence "Hello from streaming Chatterbox, I am john and i work in google since 2010. I love to go out with my friends, eat some pizza and also drink some wine. I also love to traverl around the world alone." → 317 speech tokens → 12.68 s audio → 14 streaming chunks:
| chunk | tokens_total | T_mu | encoder | CFM step0 | HiFT | total ms | RTF |
|---|---|---|---|---|---|---|---|
| 1 | 10 | 514 | 84 ms | 144 ms | 37 ms | 278 ms | 0.99 |
| 2 | 35 | 564 | 69 ms | 126 ms | 116 ms | 324 ms | 0.32 |
| 3 | 60 | 614 | 91 ms | 143 ms | 115 ms | 370 ms | 0.37 |
| 4 | 85 | 664 | 117 ms | 159 ms | 115 ms | 409 ms | 0.41 |
| 5 | 110 | 714 | 126 ms | 173 ms | 115 ms | 433 ms | 0.43 |
| 6 | 135 | 764 | 153 ms | 182 ms | 116 ms | 468 ms | 0.47 |
| 7 | 160 | 814 | 163 ms | 197 ms | 117 ms | 499 ms | 0.50 |
| 8 | 185 | 864 | 153 ms | 213 ms | 114 ms | 499 ms | 0.50 |
| 9 | 210 | 914 | 191 ms | 230 ms | 115 ms | 558 ms | 0.56 |
| 10 | 235 | 964 | 210 ms | 250 ms | 114 ms | 591 ms | 0.59 |
| 11 | 260 | 1014 | 187 ms | 257 ms | 115 ms | 579 ms | 0.58 |
| 12 | 285 | 1064 | 231 ms | 266 ms | 115 ms | 634 ms | 0.63 |
| 13 | 310 | 1114 | 208 ms | 280 ms | 113 ms | 614 ms | 0.61 |
| 14 | 317 | 1134 | 212 ms | 290 ms | 49 ms | 568 ms | 1.42 |
=== streaming done: 304320 samples (12.680 s),
first-chunk latency = 278.9 ms,
total wall = 11474.7 ms (overall RTF = 0.90) ===
Observations:
- First-audio-out: 279 ms on M4 + Metal. Chunk 1 is 10 tokens
(~0.28 s of audio) and lands at RTF ~1.0 because the fixed encoder
- CFM overhead dominates such a small chunk — but the wall-time number is what matters, and it's low.
- Steady-state RTF 0.3 – 0.6 for chunks 2–13 (each 1 s of audio).
Well below real-time, so
sox playstays ahead of playback on every chunk and there are no audible gaps. - Chunk 14 is the "tail" finalise (only 0.4 s of audio; whatever's left after the last full 25-token boundary) so its RTF naturally drifts above 1. It completes before playback reaches it because chunks 11–13 produced excess buffered audio.
- Total wall time 11.47 s for 12.68 s of audio → overall RTF 0.90, i.e. even adding up every per-chunk cost, the pipeline is faster than real-time end-to-end.
Playback caveat on macOS 26 / ffmpeg 8.1: ffplay -f s16le -i - is
silent for piped raw PCM on our M4 test box (known SDL2 + CoreAudio
regression). sox play and Python sounddevice.play() work
reliably. README now recommends sox and shows the exact
invocation.
README gained a new "Streaming mode — low-latency playback" section
under "Useful flags" documenting the three --stream-* tunables, the
--out - stdout mode, the sox play recipe, and the table above.
That section plus this Phase 3d write-up are the canonical places for
future readers to pick up streaming from.
Every invocation currently pays ~200–400 ms fixed cost for graph
construction + gallocr_reserve + model load. Amortizing these over a
long-running process is free wall-time for a deployed service.
What to do:
- Daemonize with a simple stdio JSON-RPC or HTTP interface.
- Extend the
cfm_estimator_cachepattern (from §3.8 Attempt 4) to the encoder and HiFT graphs — keep them pre-reserved across requests. - Tensor shapes depend on input length → either: (a) LRU of per-length graphs, (b) pad to a fixed max length + attention mask, or (c) rebuild on shape change but pool the buffers.
Scope: 2–3 days.
Impact: for repeated short utterances on the same server, another 20–30 % off wall time on top of the current RTF 0.28.
Right now a cloned voice is persisted as five .npy files under a
directory and loaded via --ref-dir DIR. That's convenient during
development but awkward to share: end users end up with a zip of five
opaque numpy files plus the C++ binary plus the original
chatterbox-s3gen.gguf. Most deployments would rather ship one
file — a voice-baked .gguf that works with the existing CLI as a
drop-in replacement for models/chatterbox-s3gen.gguf.
Fundamentally the five tensors are already first-class GGUF citizens:
s3gen/builtin/embedding, s3gen/builtin/prompt_token,
s3gen/builtin/prompt_feat live inside the base GGUF as-is, and the T3
side needs speaker_emb + cond_prompt_speech_tokens. So "baking a
voice" is just "rewrite those five tensor slots and copy everything
else through".
What to add:
--save-model PATH.gguf(name tentative) that, combined with--reference-audio PATHor--ref-dir DIR, writes a new GGUF next to the originalchatterbox-s3gen.ggufwith the five voice tensors replaced. Bit-identical to the original in every other tensor and metadata entry — just a rewrittenbuiltinblock. The two voice tensors that belong on the T3 side (speaker_emb, cond_prompt_speech_tokens) could either live alongside in the same GGUF (preferred: the binary already knows how to look for them under as3gen/builtin/prefix) or produce a matchingchatterbox-t3-turbo.<voice>.ggufwith those two tensors replaced.- Zero runtime overhead once baked. Subsequent runs just use the
new GGUF path as
--s3gen-ggufand--model; no--ref-dir,--reference-audioor.npyfiles needed. The built-in-voice fallback inchatterbox_tts.cppalready reads from exactly those tensor names, so there's literally no new load-time code — just the converter. - CLI UX:
chatterbox --reference-audio voice.wav --save-model alice.gguf --no-synthesizeshould be enough to bake once and walk away. No--text, no wav output, just the new GGUFs on disk.
Scope: ~1 day. It's essentially a gguf re-write helper — read
the original, iterate tensors, substitute the five voice slots with
the freshly computed values, copy everything else through.
gguf_writer can do this directly; no new numeric code is needed.
Impact: clean distribution story. "Here is my voice" becomes a single 400 MB file instead of "here is this directory of numpy files and you need to know which C++ flag they go behind." Also opens up prebuilt-voice downloads on Hugging Face (cf. C3).
The S3Gen encoder's 10 Conformer blocks couldn't use flash_attn_ext
because they add ESPnet relative positional bias inside the softmax
(see §3.8 Attempt 8). A custom op that does
softmax(QKᵀ/√d + B) · V with B pre-computed [L, T, H] would fuse
those too.
Scope: 3–5 days — CPU AVX-512 kernel first, Metal/CUDA once (A2) is online.
Impact: maybe 50–100 ms off encoder (~10 % of encoder, which is already only 12 % of the pipeline). Small in absolute terms; does get you the same fusion level throughout.
Multiple utterances in one pass. Python supports it; our C++ pipeline assumes batch=1 throughout. Only matters at scale (multiple concurrent users).
- GitHub Actions CI running
compare-tokenizer.py+test-s3gen ALLon every push. All the validation infrastructure is already in place; wiring it takes a few hours. - Prebuilt GGUFs on Hugging Face so end users don't need the
Python toolchain at all. Upload the two
.gguffiles with a model card explaining the build. - Library API (not just binaries). Expose
chatterbox_synthesize(text, opts) -> wavas a C / C++ API so Swift / Node.js / Python bindings can layer on top. ~Half a day.
With A1 (voice cloning), A2 (GPU backends), A3 (T3 quantization), and B1 (streaming) done, the remaining high-impact work is:
- B3 — Bake voice into GGUF (~1 day) → cleanest distribution story for sharing custom voices; makes prebuilt-voice downloads on Hugging Face (C3) actually shippable.
- C3 — CI + prebuilt GGUFs — pick up before announcing publicly.
- T3 autoregressive speedup (speculative decoding, or a smaller T3 draft model). Biggest chunk of wall time left on both Metal and Vulkan now that HiFT is fast.
B2 (server mode) and C1 (custom Conformer attn op) are worth doing once a concrete deployment is pressuring for them; the CPU numbers are already well past real-time for CLI use, and the GPU numbers are at multi-x real-time with zero extra work.