From 20ec5caeb45f3f3962c979e4b607fec000b0491d Mon Sep 17 00:00:00 2001 From: Ramaz Tskhadadze Date: Thu, 5 Nov 2026 22:41:28 +0400 Subject: [PATCH 1/2] QVAC-27588 fix: return raw ACE-Step output when normalizeLoudness is off and keep lego stem gain The addon rescaled every non-edit result to a 0.9 peak before the int16 quantisation, whatever normalizeLoudness said, and did the same to lego stems. The engine normalizes neither, so the flag only switched off its percentile pass and a stem lost its gain relative to the source. The gain decision moves to OutputGain.hpp, where it is unit-tested. --- packages/audiogen-ggml/CHANGELOG.md | 9 ++++ packages/audiogen-ggml/CMakeLists.txt | 5 +- packages/audiogen-ggml/README.md | 2 +- .../model-interface/acestep/AcestepModel.cpp | 33 ++++-------- .../model-interface/acestep/OutputGain.hpp | 52 +++++++++++++++++++ .../addon/tests/test_output_gain.cpp | 42 +++++++++++++++ packages/audiogen-ggml/index.d.ts | 7 ++- packages/audiogen-ggml/src/index.ts | 7 ++- 8 files changed, 129 insertions(+), 28 deletions(-) create mode 100644 packages/audiogen-ggml/addon/src/model-interface/acestep/OutputGain.hpp create mode 100644 packages/audiogen-ggml/addon/tests/test_output_gain.cpp diff --git a/packages/audiogen-ggml/CHANGELOG.md b/packages/audiogen-ggml/CHANGELOG.md index ef64913b8ce..61b5f60ca85 100644 --- a/packages/audiogen-ggml/CHANGELOG.md +++ b/packages/audiogen-ggml/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- ACE-Step `normalizeLoudness: false` returns the engine's raw output. The + addon rescaled every non-edit result to a 0.9 peak whatever the option said, + so the flag only switched off the engine's percentile pass. Samples the VAE + leaves outside `[-1, 1]` now saturate at int16 full scale on that path. +- ACE-Step lego stems keep their mix gain relative to the source; the same + rescale raised or lowered each stem to a 0.9 peak. + ## [0.10.0] - 2026-11-05 ### Breaking diff --git a/packages/audiogen-ggml/CMakeLists.txt b/packages/audiogen-ggml/CMakeLists.txt index 87b492630ab..b8fe1bb4357 100644 --- a/packages/audiogen-ggml/CMakeLists.txt +++ b/packages/audiogen-ggml/CMakeLists.txt @@ -207,7 +207,10 @@ if(BUILD_TESTING) find_package(GTest CONFIG REQUIRED) enable_testing() - add_executable(audiogen_ggml_tests addon/tests/test_app_memory_budget.cpp) + add_executable(audiogen_ggml_tests + addon/tests/test_app_memory_budget.cpp + addon/tests/test_output_gain.cpp + ) target_include_directories(audiogen_ggml_tests PRIVATE ${PROJECT_SOURCE_DIR}/addon/src ) diff --git a/packages/audiogen-ggml/README.md b/packages/audiogen-ggml/README.md index 101655c1aca..f32d30dcbfa 100644 --- a/packages/audiogen-ggml/README.md +++ b/packages/audiogen-ggml/README.md @@ -689,7 +689,7 @@ wrapped by a level-gated `QvacLogger`. | `lmPhase1` | Allow the LM to infer missing metadata before generating semantic codes. | | `simpleMode` | Expand the caption query into a full request (caption, lyrics, unset metadata) before synthesis. | | `rewriteQuery` | LM FORMAT pass: rewrite the caption into a detailed description, preserving the lyric content. Requires real `lyrics` and `taskType: 'text2music'`; mutually exclusive with `simpleMode`. | -| `normalizeLoudness` | Percentile loudness normalization of generated audio (default `true`); edits are never normalized. | +| `normalizeLoudness` | Percentile loudness normalization of generated audio, then a 0.9 peak (default `true`); `false` returns the raw engine output. Edits and lego stems are never normalized. | | `generateLrc` | Synchronized lyric timestamps: `stats.lrc` (LRC text) + `stats.lyricsScore`. Requires lyrics and `taskType: 'text2music'`. | | `computeQualityScore` | Teacher-forced LM quality score of the generated codes; `stats.qualityScore` in `[0, 1]`. Requires `taskType: 'text2music'`. | | `dcwEnabled` / `dcwScaler` / `dcwHighScaler` | Haar DCW correction controls. | diff --git a/packages/audiogen-ggml/addon/src/model-interface/acestep/AcestepModel.cpp b/packages/audiogen-ggml/addon/src/model-interface/acestep/AcestepModel.cpp index b3f5667ae89..41181de98b6 100644 --- a/packages/audiogen-ggml/addon/src/model-interface/acestep/AcestepModel.cpp +++ b/packages/audiogen-ggml/addon/src/model-interface/acestep/AcestepModel.cpp @@ -11,6 +11,7 @@ #include "audiogen-cpp/acestep/engine.h" #include "model-interface/BackendUtils.hpp" +#include "model-interface/acestep/OutputGain.hpp" namespace qvac::audiogenggml::acestep { @@ -353,28 +354,16 @@ AcestepModel::Output AcestepModel::generate(const AnyInput& in) { out.metadata = std::move(metadata); } - // Peak-normalise before the int16 quantisation, exactly like the music CLI's - // wav_write (gain = 0.9 / peak). The Oobleck VAE routinely outputs float - // samples slightly outside [-1, 1]; converting those straight to int16 hard- - // clips at full scale and produces the harsh, distorted "horrible" audio the - // addon path had (vs. the clean CLI output). Normalising to -0.9 dBFS keeps - // headroom and removes the clipping. Single-shot output, so a 2-pass over the - // full track is trivial. - // - // Always normalise non-silent output to a fixed -0.9 dBFS peak - // (gain = 0.9 / peak whenever peak > kMinNormPeak): this removes clipping and - // gives every track consistent headroom vs. the raw engine output. A - // silent/near-silent result (peak below the threshold) is left untouched: - // seeding the peak at ~0 and dividing would turn low-level noise into a - // ~-0.9 dBFS blast. kMinNormPeak ~= -60 dBFS. - constexpr float kMinNormPeak = 1e-3F; - float peak = 0.0F; - for (float s : result.pcm) - peak = std::fmax(peak, std::fabs(s)); - // Edit plans promise exact preservation outside Repaint regions. Applying - // whole-track peak normalization here would modify every preserved sample. - const float gain = - in.editOperations.empty() && peak > kMinNormPeak ? 0.9F / peak : 1.0F; + // Edit plans promise exact preservation outside Repaint regions, a lego stem + // keeps its mix gain relative to the source, and `normalizeLoudness: false` + // asks for the engine's raw output, so only a normalized generation is + // rescaled here (see OutputGain.hpp). + const float gain = outputGain( + result.pcm, + OutputGainRequest{ + .normalizeLoudness = in.normalizeLoudness, + .isEdit = !in.editOperations.empty(), + .isLegoStem = in.taskType == "lego"}); out.pcm.reserve(result.pcm.size()); for (float s : result.pcm) diff --git a/packages/audiogen-ggml/addon/src/model-interface/acestep/OutputGain.hpp b/packages/audiogen-ggml/addon/src/model-interface/acestep/OutputGain.hpp new file mode 100644 index 00000000000..c60d21d0b8c --- /dev/null +++ b/packages/audiogen-ggml/addon/src/model-interface/acestep/OutputGain.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +namespace qvac::audiogenggml::acestep { + +// Peak the addon scales a normalized generation to before the int16 +// quantisation, and the peak below which a result counts as silence. +// MIN_NORMALIZATION_PEAK ~= -60 dBFS. +constexpr float TARGET_OUTPUT_PEAK = 0.9F; +constexpr float MIN_NORMALIZATION_PEAK = 1e-3F; + +// What the addon knows about a run when it decides the output gain. +struct OutputGainRequest { + // `normalizeLoudness` on the job (default true). + bool normalizeLoudness = true; + // The run executed an edit plan (FlowEdit / Repaint). + bool isEdit = false; + // The run generated a lego stem. + bool isLegoStem = false; +}; + +// The engine only normalizes loudness when the caller asked for it, and never +// for an edit (Repaint preserves untouched source regions bit-for-bit) or a +// lego stem (it keeps its mix gain relative to the source). The addon follows +// the same rule, so those three outputs reach the caller at the engine's gain. +inline bool normalizesOutput(const OutputGainRequest& request) { + return request.normalizeLoudness && !request.isEdit && !request.isLegoStem; +} + +// Gain applied to the engine's float PCM before the int16 quantisation. +// +// A normalized generation is scaled to a fixed TARGET_OUTPUT_PEAK +// (gain = 0.9 / peak): the engine's percentile normalization leaves the peak +// at full scale, and the headroom keeps the int16 conversion from clipping. A +// silent or near-silent result is left untouched, since dividing by a ~0 peak +// would turn low-level noise into a full-scale blast. +// +// Every other output keeps gain 1.0; samples the VAE left outside [-1, 1] then +// saturate at int16 full scale. +inline float +outputGain(const std::vector& pcm, const OutputGainRequest& request) { + if (!normalizesOutput(request)) + return 1.0F; + float peak = 0.0F; + for (const float sample : pcm) + peak = std::fmax(peak, std::fabs(sample)); + return peak > MIN_NORMALIZATION_PEAK ? TARGET_OUTPUT_PEAK / peak : 1.0F; +} + +} // namespace qvac::audiogenggml::acestep diff --git a/packages/audiogen-ggml/addon/tests/test_output_gain.cpp b/packages/audiogen-ggml/addon/tests/test_output_gain.cpp new file mode 100644 index 00000000000..51beddc6667 --- /dev/null +++ b/packages/audiogen-ggml/addon/tests/test_output_gain.cpp @@ -0,0 +1,42 @@ +#include + +#include + +#include "model-interface/acestep/OutputGain.hpp" + +using qvac::audiogenggml::acestep::outputGain; +using qvac::audiogenggml::acestep::OutputGainRequest; + +namespace { +// Peaks at 1.25: outside [-1, 1], as the VAE's raw output routinely is. +const std::vector LOUD_PCM{0.5F, -1.25F, 0.25F, 1.0F}; +} // namespace + +TEST(OutputGainTest, ScalesANormalizedGenerationToTheTargetPeak) { + EXPECT_FLOAT_EQ(outputGain(LOUD_PCM, OutputGainRequest{}), 0.9F / 1.25F); +} + +TEST(OutputGainTest, LeavesRawOutputAloneWhenNormalizationIsOff) { + EXPECT_EQ( + outputGain(LOUD_PCM, OutputGainRequest{.normalizeLoudness = false}), + 1.0F); +} + +TEST(OutputGainTest, KeepsALegoStemAtItsMixGain) { + EXPECT_EQ(outputGain(LOUD_PCM, OutputGainRequest{.isLegoStem = true}), 1.0F); +} + +TEST(OutputGainTest, PreservesEditOutput) { + EXPECT_EQ(outputGain(LOUD_PCM, OutputGainRequest{.isEdit = true}), 1.0F); +} + +TEST(OutputGainTest, DoesNotAmplifySilence) { + const std::vector nearSilent{0.0F, 5e-4F, -5e-4F}; + EXPECT_EQ(outputGain(nearSilent, OutputGainRequest{}), 1.0F); + EXPECT_EQ(outputGain({}, OutputGainRequest{}), 1.0F); +} + +TEST(OutputGainTest, AttenuatesAQuietGenerationUpToTheTargetPeak) { + const std::vector quiet{0.1F, -0.3F}; + EXPECT_FLOAT_EQ(outputGain(quiet, OutputGainRequest{}), 0.9F / 0.3F); +} diff --git a/packages/audiogen-ggml/index.d.ts b/packages/audiogen-ggml/index.d.ts index dd8b9a8fc2e..6a46eb31262 100644 --- a/packages/audiogen-ggml/index.d.ts +++ b/packages/audiogen-ggml/index.d.ts @@ -136,8 +136,11 @@ export interface GenerateOptions { /** * Percentile loudness normalization on the generated audio (default true): * the 99.999th-percentile sample scales to full scale and the tiny tail - * above it clips, matching the reference loudness. Set false for the raw - * engine output. Audio edits are never normalized. + * above it clips, matching the reference loudness; the result is then + * scaled to a 0.9 peak for int16 headroom. Set false for the raw engine + * output at its own gain: samples the VAE leaves outside [-1, 1] saturate + * at int16 full scale. Audio edits and lego stems are never normalized, so + * a stem keeps its mix gain relative to the source. */ normalizeLoudness?: boolean; /** diff --git a/packages/audiogen-ggml/src/index.ts b/packages/audiogen-ggml/src/index.ts index 831c904f2a7..9548ee7e18b 100644 --- a/packages/audiogen-ggml/src/index.ts +++ b/packages/audiogen-ggml/src/index.ts @@ -194,8 +194,11 @@ export interface GenerateOptions { /** * Percentile loudness normalization on the generated audio (default true): * the 99.999th-percentile sample scales to full scale and the tiny tail - * above it clips, matching the reference loudness. Set false for the raw - * engine output. Audio edits are never normalized. + * above it clips, matching the reference loudness; the result is then + * scaled to a 0.9 peak for int16 headroom. Set false for the raw engine + * output at its own gain: samples the VAE leaves outside [-1, 1] saturate + * at int16 full scale. Audio edits and lego stems are never normalized, so + * a stem keeps its mix gain relative to the source. */ normalizeLoudness?: boolean /** From c744f647f9dd3e11f22360a56e880ffd5e6478e3 Mon Sep 17 00:00:00 2001 From: Ramaz Tskhadadze Date: Fri, 6 Nov 2026 15:45:34 +0400 Subject: [PATCH 2/2] QVAC-27588 ci: run the audiogen-ggml C++ unit tests in the cpp-tests lane The lane built the addon, printed a stub notice and ran only the fuzz target, so audiogen_ggml_tests never ran in CI. It now runs test:cpp after the native build's caches are saved and before the fuzz build reconfigures the tree. --- .../cpp-test-coverage-audiogen-ggml.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cpp-test-coverage-audiogen-ggml.yml b/.github/workflows/cpp-test-coverage-audiogen-ggml.yml index effc71147f4..747009c07b0 100644 --- a/.github/workflows/cpp-test-coverage-audiogen-ggml.yml +++ b/.github/workflows/cpp-test-coverage-audiogen-ggml.yml @@ -1,7 +1,7 @@ name: CPP Tests (AudioGen GGML) -# Cache the native build. Addon-level C++ unit tests are still a stub. -# After those caches are saved, this lane runs the bounded config-parse fuzz target. +# Cache the native build. After those caches are saved, this lane runs the +# addon-level C++ unit tests and then the bounded config-parse fuzz target. on: push: branches: [main] @@ -220,16 +220,18 @@ jobs: key: ${{ steps.ccache-cache.outputs.cache-primary-key }} path: ${{ env.CCACHE_DIR }} - - name: No addon-level C++ tests yet - run: | - echo 'AudioGen native build completed; no addon-level C++ unit tests yet.' >> "$GITHUB_STEP_SUMMARY" - echo 'ACE-Step engine unit tests run in qvac-fabric-speech.cpp audiogen CI.' >> "$GITHUB_STEP_SUMMARY" - - name: Show ccache statistics if: always() && !cancelled() && steps.cpp-build.outcome == 'success' run: ccache -s - # fuzz:build reconfigures the same tree, so it runs after the native + # test:cpp reconfigures the tree with BUILD_TESTING=ON, so it runs after + # the native build's caches are saved. These are the addon's own unit + # tests; the ACE-Step engine's run in qvac-fabric-speech.cpp audiogen CI. + - name: Run addon C++ unit tests + working-directory: ${{ matrix.workdir }} + run: npm run test:cpp + + # fuzz:build reconfigures the same tree again, so it runs after the native # build's caches are saved. Bounded unit-test mode. A failure fails the # job that publishes qvac/cpp-tests-audiogen-ggml. - name: Build fuzz targets