Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
./build/ace-synth --help 2>&1 | head -5
./build/quantize --help 2>&1 | head -3

- name: Unit tests
run: ctest --test-dir build --output-on-failure

lint:
name: Lint & Static Analysis
runs-on: ubuntu-latest
Expand Down
81 changes: 81 additions & 0 deletions .github/workflows/scoring-drift-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Check if the upstream Python scoring reference has drifted from the
# commit pinned in src/dtw-score.h. The C++ port is a snapshot of:
# ace-step/ACE-Step-1.5@82252c24
# acestep/core/scoring/_dtw.py
# acestep/core/scoring/dit_score.py
# acestep/core/generation/handler/lyric_score.py
# acestep/core/generation/handler/lyric_alignment_common.py
#
# If the upstream files change, this workflow fails and the C++ port
# needs re-evaluation. The expected SHA256 hashes are embedded below
# so there's no separate file to maintain — update all four hashes and
# the pin comment in dtw-score.h when re-syncing.
name: Scoring Drift Check

on:
schedule:
- cron: '0 8 * * 1' # weekly: Monday 08:00 UTC
workflow_dispatch:

permissions: {}

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Fetch upstream Python scoring files
run: |
BASE="https://raw.githubusercontent.com/ace-step/ACE-Step-1.5/main"
CURL=(curl -fsSL --max-time 30 --retry 2 --retry-max-time 60)
"${CURL[@]}" "$BASE/acestep/core/scoring/_dtw.py" -o _dtw.py
"${CURL[@]}" "$BASE/acestep/core/scoring/dit_score.py" -o dit_score.py
"${CURL[@]}" "$BASE/acestep/core/generation/handler/lyric_score.py" -o lyric_score.py
"${CURL[@]}" "$BASE/acestep/core/generation/handler/lyric_alignment_common.py" -o lyric_alignment_common.py
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Verify hashes match pinned snapshot
run: |
# Expected SHA256 of the Python files at commit 82252c24.
# Update these (and the pin comment in src/dtw-score.h) when
# re-syncing the C++ port after an upstream algorithm change.
EXPECTED_DTW="2d2252e7108f296cd26722c7b622e1c1a1ed47c71459a8ff873164c4c1ada962"
EXPECTED_SCORE="73700ff976549339dad3d6c500f8cbe9a8cce08d1979bbb85e3e3ed53e0605a1"
EXPECTED_HANDLER="92ccffbe079482b9e0e7a7fc6304a408fa7d996a7f9e88c7b3f819ee5d463510"
EXPECTED_COMMON="ec35e392a2354696e9def4f98db27c415f09763e839609dd368532971ec0eb08"

ACTUAL_DTW=$(sha256sum _dtw.py | cut -d' ' -f1)
ACTUAL_SCORE=$(sha256sum dit_score.py | cut -d' ' -f1)
ACTUAL_HANDLER=$(sha256sum lyric_score.py | cut -d' ' -f1)
ACTUAL_COMMON=$(sha256sum lyric_alignment_common.py | cut -d' ' -f1)

echo "_dtw.py: expected=$EXPECTED_DTW actual=$ACTUAL_DTW"
echo "dit_score.py: expected=$EXPECTED_SCORE actual=$ACTUAL_SCORE"
echo "lyric_score.py: expected=$EXPECTED_HANDLER actual=$ACTUAL_HANDLER"
echo "lyric_alignment_common.py: expected=$EXPECTED_COMMON actual=$ACTUAL_COMMON"

if [ "$ACTUAL_DTW" != "$EXPECTED_DTW" ]; then
echo "::error::_dtw.py has drifted from the pinned commit (82252c24)."
echo "::error::The C++ port in src/dtw-score.h may need updating."
exit 1
fi

if [ "$ACTUAL_SCORE" != "$EXPECTED_SCORE" ]; then
echo "::error::dit_score.py has drifted from the pinned commit (82252c24)."
echo "::error::The C++ port in src/dtw-score.h may need updating."
exit 1
fi

if [ "$ACTUAL_HANDLER" != "$EXPECTED_HANDLER" ]; then
echo "::error::lyric_score.py has drifted from the pinned commit (82252c24)."
echo "::error::The C++ scoring forward path may need updating."
exit 1
fi

if [ "$ACTUAL_COMMON" != "$EXPECTED_COMMON" ]; then
echo "::error::lyric_alignment_common.py has drifted from the pinned commit (82252c24)."
echo "::error::Lyric slicing or model-specific head configuration may need updating."
exit 1
fi

echo "No upstream source drift detected against the pinned snapshot."
echo "The C++ implementation may contain documented correctness fixes."
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
cmake_minimum_required(VERSION 3.14)
project(acestep-ggml LANGUAGES C CXX)

include(CTest)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Debug:
<details>
<summary>API endpoints</summary>

The server exposes four POST endpoints and two GET endpoints:
The server exposes five POST endpoints and two GET endpoints:

**POST /lm** - Generate lyrics and audio codes from a caption. Returns JSON.

Expand All @@ -126,6 +126,13 @@ win over audio when both are sent on the same side).
**POST /understand** - Reverse pipeline: audio in, metadata + lyrics + codes out.
Multipart only (source audio or pre-encoded latents required, optional request JSON for params).

**POST /score** - Score lyric alignment for generated DiT latents. Multipart
only: send `request` as one AceRequest or an array and `pred_latents` as raw
f32 `[batch, T, 64]` data (`latent` is accepted as an alias). A single request
can reuse the latent part returned by `/synth`; batched latents are concatenated
in request order. Results contain separate pure-noise (`lm`) and regressed
generated-latent (`dit`) metrics.

**POST /vae** - Standalone VAE entrypoint: send `audio` to encode (latents
out), send `src_latents` to decode (audio out). Multipart only, the two
inputs are mutually exclusive. Lets the webui cache a latent on an
Expand Down
26 changes: 19 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -821,10 +821,10 @@ in one GPU pass.
HTTP server exposing the same pipelines as `ace-lm`, `ace-synth`, and
`ace-understand`. One binary, one port.

POST /lm, POST /synth, POST /understand and POST /vae are all **asynchronous**: they
return a job ID immediately, push the request to a FIFO queue, and the single
worker thread processes jobs in order. Clients poll GET /job?id=N for status
and fetch results with GET /job?id=N&result=1.
POST /lm, POST /synth, POST /understand, POST /score and POST /vae are all
**asynchronous**: they return a job ID immediately, push the request to a FIFO
queue, and the single worker thread processes jobs in order. Clients poll
GET /job?id=N for status and fetch results with GET /job?id=N&result=1.
Cancel: POST /job?id=N&cancel=1 stops a specific job.

`--models` scans a directory for GGUF files and classifies each by its
Expand All @@ -846,6 +846,7 @@ quantisation raise both columns.
| LM | Qwen3 LM + KV cache | ~2-3 GB | ~2-3 GB |
| Synth | Qwen3 text-enc, cond-enc, DiT, VAE enc, VAE dec, FSQ tok/detok | ~2-3 GB (DiT or VAE tiles) | ~3-4 GB + tiles |
| Understand | Qwen3 LM, VAE enc, FSQ tok | ~2-3 GB (LM or VAE tiles) | ~2-3 GB + tiles |
| Score | Qwen3 text-enc, cond-enc, DiT | ~2-3 GB (DiT) | ~2-3 GB |

VAE tile activations scale with `--vae-chunk` and `--vae-overlap`. Bigger
tiles process audio faster with fewer seams but cost more transient VRAM
Expand Down Expand Up @@ -913,11 +914,18 @@ POST /understand Submit understand, returns job ID
body: multipart/form-data (audio or src_latents required, optional request JSON)
response: {"id":"3"}

POST /score Submit generated-latent lyric scoring, returns job ID
body: multipart/form-data
'request': AceRequest or [AceRequest, ...]
'pred_latents': raw f32 [batch, T, 64] ('latent' alias accepted)
batched requests must share duration, inference steps, model and adapter
response: {"id":"4"}

POST /vae Submit VAE encode or decode, returns job ID
body: multipart/form-data (exactly one of 'audio' or 'src_latents')
'audio' -> encode path (latents out)
'src_latents' -> decode path (audio out)
response: {"id":"4"}
response: {"id":"5"}

GET /job?id=N Poll job status
response: {"status":"running|done|failed|cancelled"}
Expand All @@ -926,6 +934,7 @@ GET /job?id=N&result=1 Fetch job result
lm: application/json [AceRequest, ...]
synth: multipart/mixed (one audio part + one latent part per track, paired)
understand: multipart/mixed (one json part + one latent part for the source)
score: application/json [{"lm_score":...,"dit_score":...,"lm":{...},"dit":{...}}, ...]
vae encode: application/octet-stream (raw .vae bytes, no audio echo: client already has it)
vae decode: audio/mpeg or audio/wav (raw, no latent echo: client already has it)

Expand All @@ -944,10 +953,13 @@ GET /logs SSE stream of server stderr
GET / Embedded WebUI (gzipped HTML)
```

Latent payload format (src_latents, ref_latents, synth/understand response latent parts, /vae encode response body):
Latent payload format (src_latents, ref_latents, pred_latents, synth/understand
response latent parts, /vae encode response body):
raw f32 little-endian, flat [T, 64], no header. T = size / 256. Same byte
layout neural-codec writes as `.vae` files. Hard cap T <= 15000 frames
(matches the silence_latent buffer baked into the DiT GGUF), 413 over.
(matches the silence_latent buffer baked into the DiT GGUF), 413 over. For a
batched score request, concatenate one equal-length `[T, 64]` tensor per JSON
request in the same order.

`lm_model`, `synth_model`, `adapter`, `adapter_scale` fields in the JSON body
select which model and adapter to load. `lm_mode` picks the LM instruction
Expand Down
80 changes: 63 additions & 17 deletions src/dit-graph.h
Original file line number Diff line number Diff line change
Expand Up @@ -120,16 +120,26 @@ static struct ggml_tensor * dit_ggml_build_temb(struct ggml_context * ctx,
// Q: [D, S, Nh], K: [D, S_kv, Nkv], V: [D, S_kv, Nkv]
// mask: [S_kv, S] F16 or NULL, scale: 1/sqrt(D)
// Returns: [D, Nh, S] (same layout as flash_attn_ext output)
//
// When capture_scores is non-NULL, the softmax scores tensor (attention weights)
// is marked as a graph output and stored at *capture_scores. Shape: [S_kv, S, Nh, N].
// This is used by the /score endpoint to extract cross-attention matrices for
// lyric alignment scoring. Ported from Python ACE-Step output_attentions=True.
static struct ggml_tensor * dit_attn_f32(struct ggml_context * ctx,
struct ggml_tensor * q,
struct ggml_tensor * k,
struct ggml_tensor * v,
struct ggml_tensor * mask,
float scale) {
float scale,
struct ggml_tensor ** capture_scores = nullptr) {
struct ggml_tensor * scores = ggml_mul_mat(ctx, k, q);
scores = ggml_soft_max_ext(ctx, scores, mask, scale, 0.0f);
struct ggml_tensor * vt = ggml_cont(ctx, ggml_transpose(ctx, v));
struct ggml_tensor * out = ggml_mul_mat(ctx, vt, scores);
if (capture_scores) {
*capture_scores = scores;
ggml_set_output(scores);
}
struct ggml_tensor * vt = ggml_cont(ctx, ggml_transpose(ctx, v));
struct ggml_tensor * out = ggml_mul_mat(ctx, vt, scores);
return ggml_cont(ctx, ggml_permute(ctx, out, 0, 2, 1, 3));
}

Expand Down Expand Up @@ -271,6 +281,9 @@ static struct ggml_tensor * dit_ggml_build_mlp(struct ggml_context * ctx,
// Build cross-attention sub-graph for a single layer.
// norm_ca: [H, S, N] pre-normalized hidden state (Q source)
// enc: [H, enc_S, N] condition-embedded encoder states (K/V source)
// When capture_scores is non-NULL, forces the f32 attention path and stores
// the softmax attention weights tensor at *capture_scores (marked as graph
// output). Shape: [enc_S, S, Nh, N]. Used by /score for lyric alignment.
// Returns: output [H, S, N] (NOT added to residual yet)
static struct ggml_tensor * dit_ggml_build_cross_attn(struct ggml_context * ctx,
DiTGGML * m,
Expand All @@ -281,7 +294,8 @@ static struct ggml_tensor * dit_ggml_build_cross_attn(struct ggml_context * ctx,
struct ggml_tensor * mask, // [enc_S, S, 1, N] F16 or NULL
int S,
int enc_S,
int N) {
int N,
struct ggml_tensor ** capture_scores = nullptr) {
DiTGGMLConfig & c = m->cfg;
int D = c.head_dim;
int Nh = c.n_heads;
Expand Down Expand Up @@ -334,9 +348,13 @@ static struct ggml_tensor * dit_ggml_build_cross_attn(struct ggml_context * ctx,
// mask blocks padding positions in encoder hidden states
float scale = 1.0f / sqrtf((float) D);

// When capturing scores, force the f32 path (flash_attn_ext does not
// materialize the attention weights as a separate tensor).
bool use_fa = m->use_flash_attn && !capture_scores;

// K/V come in F32 from mul_mat (no KV cache here). Cast to F16 before FA,
// mirroring llama.cpp build_attn_mha for graphs without a KV cache.
if (m->use_flash_attn) {
if (use_fa) {
if (k->type == GGML_TYPE_F32) {
k = ggml_cast(ctx, k, GGML_TYPE_F16);
}
Expand All @@ -345,9 +363,9 @@ static struct ggml_tensor * dit_ggml_build_cross_attn(struct ggml_context * ctx,
}
}

struct ggml_tensor * attn = m->use_flash_attn ? ggml_flash_attn_ext(ctx, q, k, v, mask, scale, 0.0f, 0.0f) :
dit_attn_f32(ctx, q, k, v, mask, scale);
if (m->use_flash_attn) {
struct ggml_tensor * attn = use_fa ? ggml_flash_attn_ext(ctx, q, k, v, mask, scale, 0.0f, 0.0f) :
dit_attn_f32(ctx, q, k, v, mask, scale, capture_scores);
if (use_fa) {
ggml_flash_attn_ext_set_prec(attn, GGML_PREC_F32);
}

Expand Down Expand Up @@ -375,7 +393,8 @@ static struct ggml_tensor * dit_ggml_build_layer(struct ggml_context * ctx,
struct ggml_tensor * ca_mask, // [enc_S, S, 1, N] or NULL
int S,
int enc_S,
int N) {
int N,
struct ggml_tensor ** capture_scores = nullptr) {
DiTGGMLConfig & c = m->cfg;
DiTGGMLLayer * ly = &m->layers[layer_idx];
int H = c.hidden_size;
Expand Down Expand Up @@ -428,7 +447,7 @@ static struct ggml_tensor * dit_ggml_build_layer(struct ggml_context * ctx,
if (enc) {
struct ggml_tensor * norm_ca = dit_ggml_rms_norm_weighted(ctx, hidden, ly->cross_attn_norm, c.rms_norm_eps);
struct ggml_tensor * ca_out =
dit_ggml_build_cross_attn(ctx, m, ly, norm_ca, enc, positions, ca_mask, S, enc_S, N);
dit_ggml_build_cross_attn(ctx, m, ly, norm_ca, enc, positions, ca_mask, S, enc_S, N, capture_scores);
hidden = ggml_add(ctx, hidden, ca_out);
}

Expand Down Expand Up @@ -462,14 +481,20 @@ static struct ggml_tensor * dit_ggml_build_layer(struct ggml_context * ctx,
//
// Graph outputs:
// "velocity" [out_channels, T, N] predicted flow velocity
//
// When score_layers is non-NULL, cross-attention scores are captured for the
// specified layers (forced f32 path). Each captured layer produces a named
// output tensor "cross_attn_scores_L{layer}" of shape [enc_S, S, Nh, N].
// These are retrieved after graph compute for lyric alignment scoring.
static struct ggml_cgraph * dit_ggml_build_graph(DiTGGML * m,
struct ggml_context * ctx,
int T, // temporal length (before patching)
int enc_S, // encoder sequence length
int N, // batch size
struct ggml_tensor ** p_input, // [out] input tensor to fill
struct ggml_tensor ** p_output) { // [out] output tensor to read

int T, // temporal length (before patching)
int enc_S, // encoder sequence length
int N, // batch size
struct ggml_tensor ** p_input, // [out] input tensor to fill
struct ggml_tensor ** p_output, // [out] output tensor to read
const int * score_layers = nullptr, // layers to capture, or NULL
int n_score_layers = 0) {
DiTGGMLConfig & c = m->cfg;
int S = T / c.patch_size; // sequence length after patching
int H = c.hidden_size;
Expand Down Expand Up @@ -573,7 +598,28 @@ static struct ggml_cgraph * dit_ggml_build_graph(DiTGGML * m,
for (int i = 0; i < c.n_layers; i++) {
// layer_type=0 (sliding window): sa_mask_sw, layer_type=1 (full): unmasked
struct ggml_tensor * sa_mask = (m->layers[i].layer_type == 0) ? sa_mask_sw : nullptr;
hidden = dit_ggml_build_layer(ctx, m, i, hidden, tproj, enc, positions, sa_mask, ca_mask, S, enc_S, N);

// Check if this layer should capture cross-attention scores
bool capture_this_layer = false;
struct ggml_tensor * capture = nullptr;
if (score_layers) {
for (int sl = 0; sl < n_score_layers; sl++) {
if (score_layers[sl] == i) {
capture_this_layer = true;
break;
}
}
}

hidden = dit_ggml_build_layer(ctx, m, i, hidden, tproj, enc, positions, sa_mask, ca_mask, S, enc_S, N,
capture_this_layer ? &capture : nullptr);

// Name captured cross-attention scores for later retrieval
if (capture) {
char score_name[64];
snprintf(score_name, sizeof(score_name), "cross_attn_scores_L%d", i);
ggml_set_name(capture, score_name);
}
// Debug dumps at key layers: 0, 6, 12, 18, last
if (i == 0 || i == 6 || i == 12 || i == 18 || i == c.n_layers - 1) {
char lname[64];
Expand Down
Loading