Skip to content

QVAC-21921 tts-cpp: native ACE-Step music generation on CPU#98

Open
freddy311082 wants to merge 3 commits into
masterfrom
feat/QVAC-21921-acestep-cpu
Open

QVAC-21921 tts-cpp: native ACE-Step music generation on CPU#98
freddy311082 wants to merge 3 commits into
masterfrom
feat/QVAC-21921-acestep-cpu

Conversation

@freddy311082

Copy link
Copy Markdown

Summary

Adds a native C++ ACE-Step 1.5 music-generation pipeline to tts-cpp
(tts_cpp::acestep::Engine), running fully on CPU via ggml. No Python, no
subprocess — the whole pipeline is portable C++ for cross-platform builds.

Pipeline: Qwen3-Embedding text encoder → ACE-LM (metadata Phase 1 + audio
codes Phase 2) → FSQ detokenizer → cond/text encoders → DiT (flow-matching)
→ Oobleck VAE → stereo 48 kHz PCM.

Merge order (important)

This PR depends on custom ggml ops that live in a separate PR:

  1. First merge qvac-ext-ggml (adds ggml_snake + ggml_col2im_1d,
    used by the Oobleck VAE decoder) and bump ggml.
  2. Then this PR. Without the ops PR + bump it will not build/run.

What's included

  • tts-cpp/src/acestep/ — full pipeline: LM (lm_ggml, lm_pipeline),
    BPE tokenizer, metadata FSM, FSQ detok, text/cond encoders, DiT graph +
    flow-matching sampler, Oobleck VAE.
  • Philox4x32-10 + Box-Muller noise for torch.randn (CUDA) parity.
  • music-cli end-to-end driver + per-stage smoke tools.

CPU-parity fixes (validated vs acestep.cpp --dump, fixed seed)

  • Seed: widened to int64 end-to-end; the DiT noise uses the full seed
    via Philox (LM sampler keeps a uint32 mt19937 seed by design). Fixes
    overflow that decorrelated the noise.
  • Timbre token: text2music now feeds one frame of the silence latent to
    the timbre encoder, so enc_hidden carries the timbre token
    ([lyric|timbre|text]), matching upstream (194 → 195 tokens). use_timbre_cls
    is detected from acestep.encoder_hidden_size > 0.
  • Result: noise corr 1.0, enc_hidden corr ~0.999, DiT latent corr ~0.999;
    the high-frequency "robotic" artifact is gone.

Review follow-ups (already applied)

  • Removed the obsolete "not wired yet / needs parity pass" NOTE in dit_ggml.
  • Documented the turbo-only assumptions (t_r == t, single conditional pass /
    no CFG); base/sft CFG parity is flagged as not yet verified.
  • Synth-parity debug hooks (ACESTEP_DUMP_DIR / ACESTEP_INJECT_*) compiled
    out by default, behind -DACESTEP_PARITY_DEBUG.
  • LM logs (incl. the audio-code CSV dump) gated behind verbose; the addon
    runs with verbose=false, so production stderr stays quiet.

@freddy311082
freddy311082 requested review from a team as code owners July 20, 2026 16:08
@github-actions

Copy link
Copy Markdown

Review Status

Current Status: ❌ PENDING
Approvals so far: none

Pending reviews: Needs 1 Management or Team Lead, and 1 more from Management, Team Lead, or Member.

Comment thread tts-cpp/src/acestep/dit_ggml.cpp Fixed
Comment thread tts-cpp/src/acestep/dit_ggml.cpp Fixed
Comment thread tts-cpp/src/acestep/dit_ggml.cpp Fixed
Comment thread tts-cpp/src/acestep/dit_ggml.cpp Fixed
Comment thread tts-cpp/src/acestep/dit_ggml.cpp Fixed
Comment thread tts-cpp/src/acestep/dit_ggml.cpp Fixed
Comment thread tts-cpp/src/acestep/lm_ggml.cpp Fixed
Comment thread tts-cpp/src/acestep/qwen3_block.h Fixed
Comment thread tts-cpp/src/acestep/vae_ggml.cpp Fixed
Comment thread tts-cpp/docs/QVAC-21921-acestep-recon.md Outdated
Comment thread tts-cpp/CMakeLists.txt Outdated
Comment thread tts-cpp/src/acestep/dit_gguf.cpp Outdated
Comment thread tts-cpp/src/acestep/vae_gguf.cpp Outdated
Comment thread tts-cpp/CMakeLists.txt Outdated
Comment thread tts-cpp/src/acestep/engine.cpp Outdated
Comment thread engines/audiogen/src/acestep/music_cli.cpp
Comment thread engines/audiogen/src/acestep/engine.cpp
Comment thread engines/audiogen/src/acestep/bpe_tokenizer.cpp
Comment thread engines/audiogen/src/acestep/engine.cpp
freddy311082 added a commit that referenced this pull request Jul 20, 2026
Review fixes from CodeQL + Gustavo on the native ACE-Step CPU port:

- CodeQL (9 sites): widen int*int to int64_t/size_t before the ggml
  dimension / index conversion (dit_ggml, lm_ggml, qwen3_block, vae_ggml).
- Windows portability: new header-only gguf_mmap.h (POSIX mmap /
  Win32 CreateFileMapping+MapViewOfFile); dit_gguf/vae_gguf now use it
  instead of raw POSIX open/mmap/munmap/close.
- Tests: new test/test_acestep_units.cpp wired into ctest (LABEL "unit",
  no GGUF fixture) covering dit_build_schedule, philox_randn (torch.randn
  parity golden), fsq_decode_index and sample_top_k_p.
- Metadata: engine now returns the Phase-1-enriched prompt.* (bpm,
  keyscale, vocal_language, caption) and parses timesignature to the int
  field, instead of echoing the raw params.*.
- Denoising tail: pad frames [T_25Hz, T) are filled with the silence
  latent and keep chunk mask = 1.0, matching acestep.cpp (not zeroed).
- MetadataFSM::init log gated behind a verbose flag threaded from the LM
  params (production addon path runs quiet).
- resolve_paths: anchor on ACE-Step filename stems instead of the bare
  "lm" substring.
- bpe_decode: cache the byte->char reverse map on the tokenizer at load
  instead of rebuilding it per call.
- Remove QVAC ticket references from source comments; drop the plan docs.
freddy311082 added a commit that referenced this pull request Jul 20, 2026
Move the ACE-Step music-generation engine out of tts-cpp into a new
standalone audiogen-cpp/ engine folder (sibling of tts-cpp / parakeet-cpp),
addressing the PR #98 review. tts-cpp no longer builds any acestep sources.

- git mv src/acestep/* + include/tts-cpp/acestep/{engine,vae}.h + the unit
  test into audiogen-cpp/ (history preserved as renames)
- new standalone CMake project: find_package(ggml) via the ggml-speech fork
  (needs the custom snake / col2im_1d ops), own export.h (AUDIOGEN_API) +
  audiogen-cppConfig; builds the lib, the CLIs/smoke harnesses and the
  CPU-only unit test (ctest -L unit)
- drop the tts-cpp backend_selection dependency (CPU-only, uses
  ggml_backend_cpu_init directly)
- remove all acestep sources / executables / test from tts-cpp/CMakeLists.txt

C++ namespace stays tts_cpp::acestep (minimal churn). Verified: standalone
audiogen-cpp build is green and the unit test passes; tts-cpp reconfigures
clean without acestep.
Add optional on_step hooks so the engine can surface real generation
progress to its ProgressFn, driving a determinate UI progress bar
instead of an indeterminate spinner.

- dit_ggml: DitSampleParams.on_step(step, num_steps), fired at the start
  of each Euler step; returning false requests cooperative cancellation.
- lm_pipeline: LmSampleParams.on_step(cur, max_tokens), fired throttled
  (every 8 tokens) in the Phase-2 code loop. This is the longest stage on
  CPU, so it drives most of the bar. Informational only (no cancel).
- engine: wire both hooks to the existing ProgressFn as report("lm", ...)
  and report("dit", ...).

No public API change: only the internal DitSampleParams/LmSampleParams
gain a default-empty callback; engine.h's ProgressFn is reused as-is.
@freddy311082
freddy311082 force-pushed the feat/QVAC-21921-acestep-cpu branch from b61530e to d27c3eb Compare July 21, 2026 19:33
The VAE decode was a single opaque ggml_backend_graph_compute call, so the
progress bar froze on the last DiT step until the whole render finished. Run
the decode through a ggml_backend_sched with an eval callback that fires once
per computed node, turning the VAE into fine-grained (done/total) progress.

- vae.h / vae_api.cpp: Vae::decode takes an optional ProgressCb(done,total);
  returning false cancels the decode (empty result). Throttled to ~1% steps.
- vae_ggml.cpp: replace gallocr + graph_compute with ggml_backend_sched. The
  scheduler requires a CPU backend as the mandatory fallback, so pass [GPU, CPU]
  when running on a GPU and [CPU] when already on CPU; with op_offload=false and
  every VAE op supported on the GPU (snake / col2im_1d Metal kernels) nothing
  actually falls back, so results are numerically identical.
- engine.cpp: wire the VAE callback into the engine ProgressFn as the "vae"
  stage and honor mid-decode cancellation.
sp.on_step = [&](int step, int total) { return report("dit", step, total); };

std::vector<float> latent;
if (!dit_sample(m->dit, sp, latent)) throw std::runtime_error("acestep engine: DiT sample failed");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new per-step cancellation added in this PR is unsafe here. dit_sample returns false both on a real failure and when on_step/report("dit", ...) returns false for a cooperative cancel. In dit_sample, if (p.on_step && !p.on_step(step, p.num_steps)) return false; is indistinguishable from a compute failure, so cancelling during the DiT stage (the long pole, where a user is most likely to cancel) throws runtime_error instead of returning empty PCM. This contradicts the engine contract in engine.h ("Generate music from a text prompt. Empty pcm on cancellation.") and is inconsistent with the LM and VAE stages, which cancel gracefully. If the addon ever passes a cancelling ProgressFn, the exception crosses the addon boundary. Distinguish cancel from failure (e.g. return the cancel state via an out-param or check cancel_flag after dit_sample returns false and return result; instead of throwing).

sp.real_enc_S = &enc_S;
// Surface per-step diffusion progress (the long pole) to the caller's
// ProgressFn; returning false here also honours cooperative cancellation.
sp.on_step = [&](int step, int total) { return report("dit", step, total); };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The progress/cancellation plumbing added by the two newest commits (report, dit_sample on_step cancel, lm_pipeline throttled on_step, vae_eval_cb node-count throttling + mid-decode cancel) is behavior that ships with no test. Per coding-standards.mdc ("Test every behavior change") and qvac-whisper-cpp.mdc ("Add tests for every ... change"), add a unit test that exercises cancellation (a ProgressFn returning false at each stage yields empty PCM, no throw) and the vae_eval_cb throttle logic. The existing test_acestep_units.cpp covers only the weight-free math (schedule/philox/fsq/sampler), not the new callback logic.


// VAE tiling for bounded memory on long tracks (see vae.h decode path).
int vae_chunk = 1024;
int vae_overlap = 64;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vae_chunk and vae_overlap are never read: Engine::generate calls m->vae->decode(latent, T, ...) in one shot and Vae::decode has no tiling. The comment points at a "vae.h decode path" that doesn't tile, so the fields are dead config and the comment is misleading. Either wire the tiling (the vae.h docs warn memory scales with decoded length, so long durations will allocate the full graph) or drop the two fields and the comment.

auto * p = static_cast<VaeNodeProg *>(ud);
p->done++;
if (p->cb && *p->cb) {
int pct = p->total > 0 ? (int) ((long long) p->done * 100 / p->total) : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total is ggml_graph_n_nodes(gf), but the eval callback fires per node the scheduler actually computes; with a GPU+CPU backend list the scheduler can insert copy/split nodes, so done can exceed total and the reported percentage can go past 100. Cosmetic only (progress bar overshoot), but clamp done/pct to total/100 to keep the reported progress monotone and bounded.

VAE: decode long latents in overlapping time windows (core 256 / overlap 48
latent frames) and stitch the audio. The Oobleck decoder allocates a single
arena that grows linearly with T_latent; for a full song it exceeds Metal's
max buffer length on iOS and ggml_gallocr returns a null buffer -> segfault.
Windowing keeps the peak arena ~1.2 GB while staying numerically identical to
the non-chunked path for short latents. Per-node progress is mapped onto a
single 0..100% VAE bar across chunks.

engine: when a GPU is active, default the autoregressive LM to the CPU backend.
On iOS A-series Metal the LM yields empty/garbage logits ("LM produced no audio
codes") while the same weights decode correctly on CPU; the one-shot text/cond
encoders + DiT + VAE stay on the GPU. Env escape hatches (no rebuild):
ACESTEP_LM_GPU=1 forces the LM back to GPU (desktop bench), ACESTEP_ENCODERS_CPU=1
moves the encoders to CPU. Also free textenc/LM/cond after their stage in
low-mem mode and lazily reload next generation to cut peak memory on device.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants