[WIP] Add VoxCPM v1 — lightweight VoxCPM TTS support (0.5B / 1.5B) - #256
Draft
jasonchen31 wants to merge 10 commits into
Draft
[WIP] Add VoxCPM v1 — lightweight VoxCPM TTS support (0.5B / 1.5B)#256jasonchen31 wants to merge 10 commits into
jasonchen31 wants to merge 10 commits into
Conversation
…d weight handling and embedding transpose
Bug: VoxCPM1 model produced pure noise ("elloそ。") instead of speech due to:
1. Synthesized `fusion_concat_proj` weight (Xavier init) treated as learned weight → wrong concat+linear fusion
2. Embedding weight transposed in V1 GGUF: `token_embd.weight` stored as [hidden, vocab] but audio.cpp expects [vocab, hidden]
Fix:
- Add `is_synthesized()` to TensorSource interface to distinguish loaded vs synthesized weights- Implement in TransformingTensorSource for V1 models- Add embedding weight transpose in set_backend_tensor() for `base_lm.embed_tokens.weight`
- Update 5 `has_fusion_proj` checks to exclude synthesized weights
- Test: "This is a test run for the fix" now transcribes as "This is a test." (was pure noise) --> but still wrong.
## Summary Fixed VoxCPM1 TTS producing pure noise by correcting tensor synthesis and shape validation issues. ## Changes - **`src/models/voxcpm2/assets.cpp`**: Only synthesize tensors missing from GGUF (not unconditionally). Fixed `feat_encoder.special_token` shape (1D vs 4D). Added relaxed rank handling in `set_backend_tensor()` for V1. - **`src/framework/assets/tensor_source.cpp`**: Added `relaxed_rank` parameter to `validate_expected_shape()` allowing shape mismatches when element counts match. ## Root Cause Synthesized (Xavier-initialized) tensors were used instead of learned checkpoint weights. The `is_synthesized()` check now correctly distinguishes true synthesized tensors (only `fusion_concat_proj` for V1) from loaded weights. ## Validation - VoxCPM1: 16kHz speech, RMS ~0.10-0.15 ✅ - VoxCPM2: 48kHz speech (no regression) ✅ - Embedding transpose: `[1024,73448]` → `[73448,1024]` ✅ - `has_fusion_proj=false` for V1 ✅
## Fix - Added GGUF metadata reading to `TensorSource` (tokenizer.ggml.*, voxcpm_*) - Created `VoxCPM1GgufTokenizer` + `load_voxcpm1_config_from_gguf()` for native GGUF loading - Added `VoxCPM2TokenizerWrapper` for dual JSON/GGUF tokenizer support - Updated `load_voxcpm2_assets()` to auto-detect/use GGUF metadata - Removed external JSON deps from `model_specs/voxcpm1.json` ## Test (ASR: sensevoice@11533) - VoxCPM1 0.5B: "This is a test run for the fix." ❌ (too fask) - VoxCPM1.5 1.5B: "I the touch for the." ❌ (too slow) ## Remaining Bugs 1. VoxCPM1 too fast (1.28s vs 2.5s) - early stop token 2. VoxCPM1.5 too slow (5.29s vs 2.5s) - arch diff
- config_gguf.cpp: output_sample_rate now falls back to sample_rate (not 16000) VoxCPM1.5 GGUF has sample_rate=44100 but no out_sample_rate → was defaulting to 16kHz - session.cpp: add V1-specific default min_tokens to prevent early stop token trigger VoxCPM1 (patch_size=2): min_tokens=20, VoxCPM1.5 (patch_size=4): min_tokens=12 Without this, stop token triggers at ~2 tokens causing 1.28s cutoff - Stop predictor weights correctly loaded via V1 relaxed rank (no transpose needed) GGUF stores [1024,2] (GGML), expected logical [2,1024] → to_ggml_dims → [1024,2] ✓ Results: VoxCPM1 (0.5B): durations scale 1.76s→4.32s with text length VoxCPM1.5 (1.5B): durations scale 2.56s→5.12s, correct 44.1kHz sample rate VoxCPM2: regression passes (48kHz, 1.28s) Files: config_gguf.cpp (+6), session.cpp (+14)
…VoxCPM2)**
VoxCPM1 (0.5B/1.5B) models now support voice cloning (`--voice-ref`) and streaming output (`--mode streaming`), matching the VoxCPM2 feature surface. The inference math was already shared; this unblocks the capability/option/reporting layer.
**Root causes fixed (5 gaps):**
- Capability advertisement: now exposes `Tts + {Offline, Streaming}` for V1 (was TTS-only)
- Family identity: `family_impl()` returns `"voxcpm1"` for V1 models (was hardcoded `"voxcpm2"`)
- Session options: `normalize_v1_session_options()` rewrites `voxcpm1.*` → `voxcpm2.*` keys so aliases work
- Request options: added `voxcpm1.*` aliases for all params (`prompt_text`, `min_tokens`, `guidance_scale`, `retry_badcase`, etc.)
- Model spec: `voxcpm1.json` adds `streaming` mode, correct sample rates (16kHz/44.1kHz)
**Changes:** 7 files, +167/−32 lines
- `src/models/voxcpm2/session.cpp` — option normalization, family-aware errors, request-option aliases
- `src/models/voxcpm2/loader.cpp` — capability advertisement, family-labeled errors
- `model_specs/voxcpm1.json` — streaming mode, tags, corrected description
- `docs/tts.md` — V1 streaming/voice-clone examples, `retry_badcase=false` requirement
- `tools/audiocpp_cli/audiocpp_cli_path_cases.json` — 3 new V1 path tests
- `webui/configs/models_catalog.json` + `model_params.json` — V1 WebUI entries
**Verified (CPU):**
| Test | Result |
|------|--------|
| V1 offline TTS | `family=voxcpm1` ✓ |
| V1 voice clone | 16kHz, 5.12s, RMS 0.115 ✓ |
| V1 streaming | 40×1280 chunks, 16kHz ✓ |
| V1 `voxcpm1.*` session/request options | accepted & applied ✓ |
| V1 capability inspection | `modes=offline,streaming` ✓ |
| V2 regression (offline/streaming) | 48kHz, parity maintained ✓ |
Streaming requires `retry_badcase=false` (same as V2, pre-existing design). No V2 behavior changes.
**Issue**: The audio quality is still bad
VoxCPM1 attention used identity longrope factors and a padded stop-token floor. The GGUF's real F32 factor arrays are now read and applied (prefill, stop behavior and duration match the VoxCPM.cpp reference), and the V1 default `min_tokens` is lowered to the reference floor so short utterances are no longer padded with trailing silence.
**Root causes fixed (2):**
- RoPE longrope factors were hardcoded to `1.0f` in the GGUF config path ("GGUF doesn't have native float arrays" was wrong — `GgufTensorSource` already parses them); every attention computation across all four transformers (base LM, residual LM, local encoder, local DiT) used identity positional encodings
- V1 default `min_tokens=20` (per patch_size) vs reference `kMinLen=2` — forced ~1.6s+ of audio and padded short utterances with trailing silence after the stop predictor fired
**Changes:** 2 files, +29/−12 lines
- `src/models/voxcpm2/config_gguf.cpp` — read `voxcpm_lm_config_rope_scaling_{short,long}_factor` f32 arrays via `optional_f32_array()` with size validation (`head_dim/2`), identity fallback only when the keys are absent
- `src/models/voxcpm2/session.cpp` — V1 default `min_tokens = 2` (≡ reference `step > kMinLen`), keeping the `--request-option min_tokens` override
**Verified (CPU, against reference `/workspace/pi/VoxCPM.cpp`):**
| Test | Result |
|------|--------|
| Prefill lm_hidden | l2 within ~2% of reference (was diverged) |
| Stop predictor ("This is a test run for the fix") | fires at pos=19 (was: never fired) |
| Duration | 1.60s (ref 1.68s), trailing silence 0.13s (ref 0.44s) |
| V2 regression | 48kHz output maintained ✓ |
| Embedding + fusion | `[73448,1024]` transpose intact, `has_fusion_proj=false` ✓ |
**Issue**: Voice clone is still not supported — `--task clon` is rejected and passing reference audio + text (`--task tts --voice-ref <wav>`) generates noise rather than cloned speech. Needs a port-audit of the VoxCPM1 reference-audio conditioning path. Full evidence in `docs/reports/2026-08-18_1128_VoxCPM1_RoPE_Longrope_Factors_Stop_Floor_Fix.md`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Status: First porting attempt — runtime works end-to-end, output noise only, WIP
1. Overview
Adds support for the **OpenBMB VoxCPM v1 ** family of lightweight TTS models to audio.cpp (porting from VoxCPM.cpp), reusing the existing and already-released
voxcpm2model tree:voxcpm-0.5b-q8_0-audiovae-f16.ggufvoxcpm1.5-q8_0.ggufvoxcpm1.5-q4_k-audiovae-f16.ggufThe three models are architecturally different variants (they cannot share one config):
[2,5,8,8], decoder_rates[8,8,5,2], patch_size 2, residual_lm 6 layers, encoder/dit 4 layers, 16 kHz, max_len 4096.[2,3,6,7,7], decoder_rates[7,7,6,3,2], patch_size 4, residual_lm 8 layers, encoder/dit 8 layers, 44.1 kHz, max_len 8192.Since the v1 GGUFs store a different tensor convention than v2 (folded AudioVAE weights, no
weight_v/weight_gsplit, nosr_cond_modeltensors,voxcpmarchitecture name), the port wraps the v2 loader with a GGUF tensor-adaptation layer and addsconfig.v1-guarded branches in the generator, mirroring the reference implementation (VoxCPM.cpp).2. Porting activities
config.jsonfrom the GGUF metadata — the previously shipped sidecar was wrong on ~8 axes (patch, residual_lm/encoder/dit layer counts, VAE dims and rates, sample rate 44.1 kHz vs the actual 16 kHz, max_len).weight_v/weight_gdecomposition and nosr_cond_model.*tensors.neorder; the v1 GGUFs carry noaudiocpp.tensor_shapesoverride metadata (v2 does), so the adapter must present shapes itself.{out, in·k}, kernel folded into dim1) while Q4_K and 0.5B store 3D{out, in, k}— both must load.load_vae_weightsloader works unchanged against folded v1 weights byte-for-byte.fusion_concat_proj) case: elementwise-add fusion inputs, elementwise-add dit-mu, and a real residual_lm autoregressive step.VoxCPM1-GGUF/for 0.5B,VoxCPM1.5-GGUF/for 1.5B) each with a config regenerated from its own GGUF metadata + tokenizer sidecars, and updatedmodel_specs/voxcpm1.jsonpackage targets accordingly.3. Changes per file
CMakeLists.txtaudiocpp_add_model(voxcpm1 ...)reusing the 7 voxcpm2 sources; registersengine::models::voxcpm2::make_voxcpm1_loader.include/engine/models/voxcpm2/loader.hmake_voxcpm1_loader().include/engine/models/voxcpm2/assets.hVoxCPM2Config::v1 = false;load_voxcpm2_assets()now takesbool is_v1.src/models/voxcpm2/loader.cppVoxCPM1Loader(family"voxcpm1"),load_voxcpm1_model(),make_voxcpm1_loader(),metadata_v1/capabilities_v1/cli_v1. Offline-only TTS + speaker-reference clone,text_prefixpolicy, GGUF viaload_voxcpm2_assets(path, is_v1=true).src/models/voxcpm2/assets.cppTransformingTensorSourcev1 adapter (biggest chunk):• v1→v2 tensor-name rename map (
token_embd.weight→base_lm.embed_tokens.weight, ggufblk.N.*→base_lm.layers.N.*/feat_encoder.encoder.layers.*/feat_decoder.estimator.decoder.layers.*/residual_lm.layers.*,attn_norm→input_layernorm,ffn_norm→post_attention_layernorm,attn_*→self_attn.*_proj,ffn_*→mlp.*_proj,time_mlp.*(preserving.linear_N),output_norm.weight→base_lm.norm.weight, projection/fsq/stop mappings)• Folded weight-norm synthesis: for every
audio_vae.*.weightconv,X.weight_v→ folded tensor data as-is,X.weight_g→ per-row L2 norms (identity fold, see §4)• Identity
decoder.sr_cond_model.{2..5}.scale_embed.weight(ones) /.bias_embed.weight(zeros) since v1 GGUFs carry no SR-conditioning tensors• Synthesized missing v1 tensors (
feat_encoder.scale_embed/bias_embed,feat_encoder.fc_logvar,feat_encoder.diag,feat_encoder.merge,token_embd.extra_bias,fusion_concat_proj.weight/bias,stop_proj.weight,stop_head.weight)• Rank-tolerant
require_f32(accept element-count-equal, shape-different fetches — handles 2D-flattened convs and{C,1}alphas) + relaxed-rank VAE weight_v anchors for v1•
has_tensor/require_metadata/require_tensor_datafolded + synthesized lookups• Anchor fix:
encoder.fc_mu.weight_vnow uses computed encoder-in (encoder_dim << #rates= 2048), notdecoder_dim(1536)src/models/voxcpm2/generator.cppAddModule(lm_hidden, current_embed)/AddModule(fsq, current_embed)instead of concat+linear (matches referencebuild_residual_fusion_input)• Added
add_dit_mu()helper; v1mu= elementwise add ofcurrent_lm_dit_hidden + residual_dit_hidden(matches referencebuild_dit_mu,mu_dim = hidden·(fusion?2:1), v1 → hidden)• CFM
musize check is now v1-aware (hidden_dim * (v1 ? 1 : 2))• v1 decode loop runs
residual_lm_.run_step(next_projected.residual_input).hidden(the earlierfsq_lm_dit_hiddenshortcut removed — v1 GGUFs have 6/8 residual_lm layers)src/models/voxcpm2/minicpm.cppresidual_input=AddModule(lm_hidden, masked_current)instead of concat+linear; residual_lm always runs (previously the concat path would have produced a wrong-dimension residual input for v1).model_specs/voxcpm1.jsonvoxcpm1_0.5b_q8_0→VoxCPM1-GGUF;voxcpm1_1.5b_q4_kandvoxcpm1_1.5b_q8_0→VoxCPM1.5-GGUF(per-variant config/tokenizer).docs/tts.mdREADME.mdvoxcpm1row to the supported-model table.docs/reports/voxcpm1_port_status.mdmodels/VoxCPM1-GGUF/config.jsonmodels/VoxCPM1.5-GGUF/config.jsonmodels/VoxCPM1.5-GGUF/tokenizer.json(+config/special tokens)4. Key design: the identity-fold adapter
The v1 GGUF (OpenBMB reference converter) stores AudioVAE conv weights already folded (
weight = weight_g · weight_v / ‖weight_v‖), with noweight_v/weight_gsplit, whileaudiovae.cpprequests the decomposed names directly viarequire_f32. The adapter solves this without touching the VAE loader:Because
fold_weight_normmultiplies rowd0byweight_g[d0] / ‖row d0‖ = 1, the loader output equals the GGUF data byte-for-byte — an exact identity, with no layout drift relative to the reference runtime's consumption of the same bytes. The same mechanism works for 3D{out, in, k}and 2D-flattened{out, in·k}conversions (element counts must match; ranks may differ, covered by rank-tolerantrequire_f32+ relaxed-rank anchors).5. Usage
Build
Run — 0.5B (16 kHz output)
build/linux-cpu-release/bin/audiocpp_cli \ --task tts --family voxcpm1 \ --model models/VoxCPM1-GGUF/voxcpm-0.5b-q8_0-audiovae-f16.gguf \ --backend cpu --text "Hello from VoxCPM1." --out out.wavRun — 1.5B (44.1 kHz output)
build/linux-cpu-release/bin/audiocpp_cli \ --task tts --family voxcpm1 \ --model models/VoxCPM1.5-GGUF/voxcpm1.5-q8_0.gguf \ --backend cpu --text "Hello from VoxCPM1." --out out.wavOptions
--tasktts--familyvoxcpm1--backendcpu,cuda,vulkan,metal,hip,bestbest--voice-ref--max-tokens4096--num-inference-steps10--guidance-scale2.0--session-option voxcpm1.mem_saver=true|falsefalse--session-option voxcpm1.prompt_cache_slots=<n>1--text-chunk-modedefault,tag_aware,japanese,endlinetag_aware6. Validation performed
validate_weight_anchorsandload_vae_weights/load_model_weightson CPU. This includes the 0.5B 3D convs, the 1.5B Q4_K 3D convs, and the 1.5B Q8_0 2D-flattened convs.--task ttscompletes for all three models; outputs are written as WAV atthe correct sample rate (16 kHz for 0.5B, 44.1 kHz for 1.5B) with active signal and speech-plausible duration/envelope.
config.v1, v2 defaultfalse); voxcpm2 was not re-benchmarked but the changed code paths are v1-gated or v1/v2-neutral.7. Supported modes
voxcpm1advertises offline-only. Streaming is a v2 capability; it has not been validated (or enabled) for v1.--voice-ref), but quality is gated on the same known issue as plain TTS.Known issue: noisy output
Symptom. Generated v1 voices are almost pure noise with a little human voice mixed in — the signal is dominated by broadband/noise content. This affects all three GGUFs.
What is confirmed working. Model loading, tensor adaptation, anchor validation, graph construction, graph execution, and WAV output plumbing are all correct (no crashes, no shape/size errors, correct sample rates and durations). The failure is therefore in the numerics of synthesis, i.e. the audio content itself.
Most likely causes (in rough priority order).
{in,out,k}conventions, per-group row ordering ofweight_g) differs from whatggml_conv_1d/conv_transposeexpects, the VAE decoder outputs garbage while loading still "succeeds" (element counts match).feat_encoder.scale_embed/bias_embed,merge,diag,extra_bias,fusion_concat_proj,stop_*, and the identitysr_cond_modeltensors were synthesized with plausible but unverified semantics; if any is required to be learned/zero-scale(or absent entirely in the reference runtime), the feature stream feeding the LM/CFM is wrong.build_residual_fusion_input/build_dit_mu, but adjacent details (masking, slice indices, position ids, prompt handling, FSQ rounding, CFM conditioning inputs, ordering ofnn.Modulesub-blocks in the residual_lm stack) may differ.require_f32, but a transpose or block-order mismatch would corrupt every activation.Debugging plan (next iteration).
VoxCPM.cppand audio.cpp, dump intermediate tensors (lm hidden, residual hidden, CFM mu, VAE latent, decoder output) at each major stage, and diff numerically.encoder.fc_mu/decoder.model.{0,1,N}folded data against the Python reference weights with a strict per-element comparison on non-quantized tensors (f16 VAE files), including row-grouping ofweight_g.sr_cond_modelandfeat_encodersynthesizable blocks for v1; remove or zero-scale any block the reference does not run.patch=2(0.5B) andpatch=4(1.5B).8. Remaining tasks
VoxCPM.cppreference (stage-by-stage tensor diff)tests/voxcpm1/automated path tests mirroringtests/voxcpm2/webui/configs/models_catalog.json)docs/gguf.mdsupport-table entryaudio.cpp-gguf(0.5B and 1.5B packages)