Add VoxCPM v1 — lightweight VoxCPM TTS support (0.5B) - #256
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`.
…den impl Restore working voice cloning by fixing the reference-audio conditioning and AudioVAE encoder alignment against the golden VoxCPM.cpp port: - generator: only set the CFM `prefix_cond` from prefill rows carrying audio (audio_mask). Previously the trailing text row's zero feature overwrote the reference patch, feeding the DiT a zero acoustic anchor for voice cloning (matches torch feat[:, -1] semantics) - audiovae: re-enable VAD silence trimming for prompt/reference audio (matches golden server_common.cpp:842/878), then pad to patch alignment before VAE encoding (left for prompt, right for reference) - audiovae: drop the `stride % 2` output_padding on the encoder downsample conv so causal padding matches the reference encoder - assets: declare base_lm.embed_tokens.weight as [vocab, hidden] so V1 GGUFs storing the embedding transposed ([hidden, vocab]) load correctly - audiovae: add VOXCPM_DUMP_REF_MONO / REF_FEAT / ENC_STAGE debug dumps Validation (sensevoice-small STT, continuation-mode clone with the Anna reference): 6/6 target sentences transcribe exactly; text-only TTS unchanged. Reference-only cloning (ref_start/ref_end tokens) still fails identically in the golden VoxCPM.cpp - a model-level limitation.
VoxCPM1 voice cloning via `--voice-ref <wav>` produced non-cloned
speech, while `--audio <wav>` (plus `--reference-text`) cloned
correctly. Both flags carried the same user intent, but the CLI mapped
them to different request fields that the session treated as two
distinct audio roles.
**Root cause:** `--voice-ref` set `request.voice->speaker->audio`, which
the session consumed as *reference audio*. For VoxCPM1 the reference
path is wrong in two ways:
- `encode_prompt_audio()` only copies `prompt_text` inside the
`prompt_audio` branch, so a reference-only request dropped the
reference transcript entirely (the LM never saw it).
- The reference role right-pads the audio and prepends it wrapped in
the `<audio_prompt_start/end>` tokens 103/104. Those belong to
VoxCPM2's "reference-mode plumbing"; the V1 LM was only trained for
prompt-continuation cloning (golden VoxCPM.cpp uses
`--prompt-audio` + `--prompt-text`, and its V1 server never calls
`encode_reference_audio`).
**Fix:** in `VoxCPM2SessionBase::encoded_prompt_for_request()`, when the
model is V1 and only a reference audio is supplied (no `--audio`),
route it through the prompt path — the audio becomes `prompt_audio`
(left-padded, after `<audio_start>`) and `--reference-text` becomes
`prompt_text` (concatenated with the target text). V2 keeps the
reference-mode path untouched. Applies to both offline and streaming
runs (single shared function). Without `--reference-text` the request
now fails with the golden's exact rule ("prompt audio requires
prompt_text or reference_text").
**Changes:** 1 file, +24/−12 lines
- `src/models/voxcpm2/session.cpp` — V1 reference→prompt routing with
cache key/lookup/encode all using the effective audio roles
**Verified (CPU, 0.5B Q8_0):**
| Test | Result |
|------|--------|
| V1 `--voice-ref` + ref-text | byte-identical WAV to `--audio` + ref-text (same clone) |
| V1 `--voice-ref` without ref-text | clean error (matches golden iff rule) |
| V1 `--audio` regression | byte-identical output |
| V2 `--voice-ref` regression | 48kHz, byte-identical to pre-fix (reference mode preserved) |
| 5-voice clone batch (ana/eric/andrew/jenny/nicole) | 16kHz speech, RMS 0.06–0.08 ✓ |
**Note:** V1.5 (44.1kHz) fails at load with "encoder sample capacity
must be divisible by encoder stride" — pre-existing config gap (stride
1764 ∤ default capacity 240000), identical on `--audio` before this fix.
…main For fixing VocCPM v1 --voice-ref clone issue
Move VoxCPM GGUF tokenizer/config metadata reading out of the framework TensorSource interface into a new voxcpm2 GgufMetadataReader. Revert the validate_expected_shape relaxed_rank parameter and redundant <memory> include; tensor_source.h/.cpp now differ from upstream/main by a single line (is_synthesized).
Only the cloned voice is cached across requests; prompt-prefill and AudioVAE encoder/decoder graphs are freed at request end and rebuilt fresh on the next request. Idle VRAM drops to ~1.4GB after generation; very long text may require up to ~3.5GB VRAM during generation.
|
Removed any changes to the framework. |
# Conflicts: # webui/native/dist/index.html
# Conflicts: # webui/native/dist/index.html
|
@jasonchen31 Thanks for continuing to iterate on this! The PR is closer, but I still see several issues that should be addressed before merge. Main blockers:
Overall, we’re close! Don’t feel discouraged. My first attempts were much worse :) |
|
Thanks for your comments. They are good practices. I'll be off for 1-2 weeks travelling. To be fixed later or please feel free to fix them if urgent. |
Enjoy your travels! No worries at all. I’ll take care of it when I get a chance. |
- Fixed stateful convolution view strides in causal_conv1d_stateful, causal_conv1d_dw_stateful, and causal_conv_transpose1d_stateful - Used source tensor's actual column-major strides (nb[1], nb[2]) instead of assuming contiguous layout - Non-streaming and streaming now produce identical audio statistics - All tests pass: voxcpm1_tts, voxcpm1_streaming_tts, voice cloning streaming - Long streaming verified (174 chunks, 13.92s) with minimal boundary diffs
- Removed VOXCPM_DUMP_* environment variable debug dumping code from audiovae.cpp, generator.cpp, minicpm_blocks.h - Removed VOXCPM1_LOG_STOP debug logging - Removed unused encoder_stages_ member variable from AudioVAE decoder - All tests pass: voxcpm1_tts, voxcpm1_streaming_tts, voice cloning streaming
- Add tokenizer_common.h/cpp with shared BPE/UTF-8/CJK tokenization logic - Refactor tokenizer_text to load JSON then delegate to common - Refactor tokenizer_gguf to load GGUF metadata then delegate to common - Eliminates ~500 lines of duplicated code
|
@0xShug0 Fixed item 4, 5 and streaming glitch issue solved. |
The voxcpm1 family sources only serve V1 (VoxCPM-0.5B); V2 now lives in its own registry entry. Remove every V2 conditional from the V1 code path: - assets.cpp: parse_config now hard-rejects architecture != "voxcpm". - session.cpp: drop the legacy voxcpm2.* -> voxcpm1.* option alias in normalize_v1_session_options. - generator.cpp: collapse the six has_fusion_proj sites to the V1 path (no fusion_concat_proj, mu_tokens=1, add_dit_mu). Delete the concat_dit_mu helper, the VOXCPM_TEST_BATCH_MU test hook, and the V2-related comments. V1 tensor transforms (embed [hidden,vocab] -> [vocab,hidden] transpose, weight_v/weight_g decomposition, AudioVAE folded-conv handling) are preserved unchanged. Verified by regenerating 9 voice samples (3 tts, 3 clone, 3 stream+clone) and transcribing all 9 with sensevoice-small: every output matches the input text.
# Conflicts: # webui/native/dist/index.html
Resync the committed static webui with the freshly-merged SvelteKit
sources. Build output is functionally equivalent to the upstream's
hand-shipped index.html (which was taken via 'git checkout --theirs'
during the upstream merge); only embedded asset hashes and the
SvelteKit version stamp differ.
Verified: cmake build is green and VoxCPM1 smoke test transcribes
correctly ('Post merge smoke test.').
The V1 family only ever takes the ADD residual path; the V2 fusion_concat_proj linear projection was already unreachable in generator.cpp and minicpm.cpp after the V2 branch cleanup. Remove the leftover dead code: - minicpm.h: delete VoxCPM1ProjectionWeights::fusion_concat_proj field - minicpm.cpp: drop the config.v1 ? Add : Linear(fusion_concat_proj) branch (keep Add) and the load of fusion_concat_proj weights - assets.cpp: drop the V1->V2 rename_map entries for proj.fusion_concat.* / fusion_concat_proj.*, the synthesized fusion_concat_proj.weight/bias tensors, their Xavier init, and the require_tensor_shape anchor for fusion_concat_proj.weight No V1 tensor transforms touched. Verified by regenerating 9 voice samples (3 tts, 3 clone, 3 stream+clone): all have real speech waveforms (RMS 0.056-0.158, max 0.34-1.0, active zero-crossing rates). STT host was down at commit time; signal stats confirm valid output.
# Conflicts: # webui/configs/models_catalog.json # webui/native/dist/index.html
Replace TransformingTensorSource (V1->V2 name routing, reshape maps, weight-norm decomposition) with a thin VoxCPM1TensorSource that reads the V1 GGUF names as-is. GgufTensorSource already reverses ggml's ne order, so the shapes it reports match the logical shapes the modules request and no name mapping, reshaping or transposition is needed. Changes: - src/community_models/voxcpm1/assets.cpp: drop the V1->V2 rename map, the reshape/weight-norm maps and the synthesized feat_encoder tensors; keep only the decoder sr_cond_model placeholders (absent from V1 GGUFs) plus the relaxed-rank fallback for tensors stored with a different rank but equal element count; switch the weight anchors to V1 names. - src/community_models/voxcpm1/audiovae.cpp: read the pre-folded audio_vae.*.weight tensors directly instead of refolding synthesized weight_v/weight_g (which was an exact identity); remove fold_weight_norm and squeeze_weight_g. - src/community_models/voxcpm1/minicpm.cpp: take explicit embedding, layer and norm tensor names (token_embd.weight, blk.N.attn_*/ffn_*, output_norm.weight) instead of deriving V2 prefixes. token_embd.weight is deliberately not transposed: its ne [1024, 73448] is reported as [73448, 1024] = [vocab, hidden], which is exactly the layout ggml_get_rows consumes. Transposing it scrambles every embedding row and degrades synthesis to near-silent low-frequency rumble. Reading it as-is also keeps the Q8_0 blocks intact instead of dequantizing 75M parameters to F32 and requantizing them. Verified against voxcpm-0.5b-q8_0-audiovae-f16.gguf with 9 samples: 3 offline TTS, 3 voice clones and 3 streaming clones.
|
@0xShug0 All done. Could you have a check.? And the download link is provided already in the PR text. |
|
@jasonchen31 Thanks! I will make some changes directly to the PR. |
|
@jasonchen31 Thanks! PR merged. I added a conversion script to convert the model to audio.cpp-native GGUF and removed the duplicated local logic. |
|
Great! Thank you! |
1. Overview
Adds support for the OpenBMB VoxCPM-0.5B lightweight TTS model to audio.cpp (ported from VoxCPM.cpp), reusing the existing and already-released
voxcpm2model tree:voxcpm-0.5b-q8_0-audiovae-f16.ggufYou may find the converting tools in VoxCPM.cpp project
GGUF files are here.
Architecture (0.5B): VAE encoder 128 / decoder 1536, encoder_rates
[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.Since the v1 GGUF stores 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).All work is on
main, 21 commits ahead ofupstream/main(merge-base4e973b1), consisting of 12 porting commits plus merges.tensor_source.hdiffers from upstream by a single line (is_synthesized) andtensor_source.cppis identical — the port is structured to be upstreamable.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 GGUF carries noaudiocpp.tensor_shapesoverride metadata (v2 does), so the adapter must present shapes itself.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/model directory with a config regenerated from its own GGUF metadata + tokenizer sidecars, and updatedmodel_specs/voxcpm1.jsonpackage targets accordingly.is_synthesized(), embedding transpose[hidden, vocab]→[vocab, hidden], and tensor synthesis only for tensors actually missing from the GGUF — turning pure noise into intelligible speech.audiocpp.vocab_*/ config keys) with a GGUF-native tokenizer, removing external sidecar dependence.retry_badcase=false).TensorSourceintovoxcpm2GgufMetadataReader, revertedvalidate_expected_shaperelaxed-rank param. Framework net delta: +1 lineis_synthesized.mem_saver+ unconditional end-of-request release; only the cloned voice is cached across requests).3. Changes per file (full diff vs upstream/main
4e973b1)CMakeLists.txtaudiocpp_add_model(voxcpm1 ...)reusing the 7 voxcpm2 sources; registersengine::models::voxcpm2::make_voxcpm1_loader.include/engine/framework/assets/tensor_source.hvirtual bool is_synthesized(...) { return false; }— the only framework change that survives; needed to distinguish real vs fabricated weights at the abstractTensorSourcelevel.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. Tasks:ttswith{offline, streaming}modes,supports_speaker_reference = true. 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.*,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 GGUF carries no SR-conditioning tensors• Synthesized missing v1 tensors, only when absent from the GGUF (
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)•
is_synthesized()override (map membership onsynthesized_tensors_)• Rank-tolerant
require_f32(accept element-count-equal, shape-different fetches) + relaxed-rank VAE weight_v anchors• Embedding transpose in
set_backend_tensor(): V1 GGUF storestoken_embd.weightas[hidden, vocab]but gglm expects[vocab, hidden]; transpose applied when shapes match the swap•
has_tensor/require_metadata/require_tensor_datafolded + synthesized lookups• Anchor fix:
encoder.fc_mu.weight_vuses computed encoder-in (encoder_dim << #rates= 2048), notdecoder_dim(1536)src/models/voxcpm2/generator.cpphas_fusion_proj:tensor != nullptr && !is_synthesized(...)(5 call sites — build/run/generate paths); residual input =AddModuleinstead of concat+linear when false (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(earlierfsq_lm_dit_hiddenshortcut removed)src/models/voxcpm2/minicpm.cppresidual_input=AddModule(lm_hidden, masked_current)instead of concat+linear; residual_lm always runs. RoPE longrope factors loaded from GGUF config for v1 (prefill lm_hidden l2 within ~2% of reference).src/models/voxcpm2/minicpm_blocks.hsrc/models/voxcpm2/session.cppmin_tokensfloor (avoids premature stop at ~2 tokens); stop-progress handling; voice-clone:--voice-refrouted through the prompt path (reference audio + transcript as reference_text), reference-onlyref_start/ref_endbranch (fails identically in golden impl — model limitation); streaming support; per-request VRAM release (unconditional end-of-request release of prefill + decoder graphs;mem_saveradditionally releases all generator graphs; only the cloned voice is cached).src/models/voxcpm2/audiovae.cppstride % 2output_padding on downsample conv so causal padding matches the reference encoder;VOXCPM_DUMP_REF_MONO/REF_FEAT/ENC_STAGEdebug dumps;release_encoder_graph()so encoder VRAM frees right after encode.src/models/voxcpm2/config_gguf.cpp/.hvoxcpm.*keys: architecture, dims, layer counts, VAE dims/rates, max_len, RoPE factors, stop floor).src/models/voxcpm2/gguf_metadata.cpp/.hGgufMetadataReader: framework-independent GGUF metadata accessor so the frameworkTensorSourcestays upstream-shaped.src/models/voxcpm2/tokenizer_gguf.cpp/.haudiocpp.vocab_*metadata.src/models/voxcpm2/tokenizer_wrapper.hsrc/models/voxcpm2/tokenizer_text.cpp/.htokenizepath chosen by tokenizer type.include/engine/models/voxcpm2/audiovae.hrelease_encoder_graph().include/engine/models/voxcpm2/generator.hrelease_runtime_memory().include/engine/models/voxcpm2/minicpm.hrelease_runtime_memory()on prefill/text-embedding runtimes.model_specs/voxcpm1.jsonvoxcpm1_0.5b_q8_0→VoxCPM1-GGUF(default).tools/audiocpp_cli/audiocpp_cli_path_cases.jsonvoxcpm1_tts,voxcpm1_voice_clone,voxcpm1_streaming_tts.webui/configs/models_catalog.jsonvoxcpm1catalog entry.webui/configs/model_params.jsonnum_inference_steps,guidance_scale,min_tokens, ...).webui/native/dist/index.htmldocs/tts.mdmodels/VoxCPM1-GGUF/config.json4. 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.5. Usage
Build
Run — VoxCPM-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.wavVoice clone
Streaming
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 --mode streaming --text "Hello from VoxCPM1." \ --request-option retry_badcase=false --out out.wavOptions
--tasktts--familyvoxcpm1--backendcpu,cuda,vulkan,metal,hip,bestbest--modeoffline,streamingofflineretry_badcase=false.--voice-ref--reference-text.--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 backend.speech.audio.deltaSSE chunks flow at the model native 16 kHz; validated via the voxcpm1 streaming WebUI script (webui/voxcpm1_stream_webui.py).lm_hiddenl2 within ~2% ofVoxCPM.cpp; stop predictor fires at pos=19; duration 1.60 s vs reference 1.68 s.config.v1, v2 defaultfalse); VoxCPM2 still generates 48 kHz speech with byte-identical output versus pre-change baseline.7. Supported modes
retry_badcase=false(same as v2).ref_start/ref_end) cloning fails identically in the goldenVoxCPM.cpp— a model-level limitation.8. Fixed issues (was: "Known issue: noisy output")
The "pure noise" blocker from the initial port is resolved. Root causes found and fixed:
fusion_concat_projwas Xavier-synthesized on every load and treated as a learned tensor. Fixed by addingis_synthesized()to theTensorSourceinterface (tensor_source.h+1 line) and overriding it inTransformingTensorSource; the 5has_fusion_projguards now exclude synthesized weights (falsefor true V1 models).token_embd.weightas[hidden, vocab]=[1024, 73448]; audio.cpp/ggml needs[vocab, hidden]. Fixed with a transpose inset_backend_tensor().feat_encoder.special_tokenshape (1D vs 4D).min_tokensfloor; durations scale 1.76 s→4.32 s with text length.stride % 2output_padding so causal padding matches the reference encoder (clone conditioning).9. Remaining tasks
models_catalog.json,model_params.json,native/dist/index.html)voxcpm1_tts,voxcpm1_voice_clone,voxcpm1_streaming_tts)min_tokensfloor parity vs reference (prefill l2 ~2%)mem_saver(idle ~1.4 GB)