diff --git a/acestep/engine/sa3_context.py b/acestep/engine/sa3_context.py index 26fe8b4..f8820a7 100644 --- a/acestep/engine/sa3_context.py +++ b/acestep/engine/sa3_context.py @@ -180,9 +180,11 @@ def make_schedule_builder( def _builder(denoise: float) -> torch.Tensor: import stable_audio_3.inference.sampling as sampling + from .sa3_denoise_mapping import map_denoise_to_entry_sigma + schedule = sampling.build_schedule( steps=int(steps), - sigma_max=float(denoise), + sigma_max=map_denoise_to_entry_sigma(float(denoise)), dist_shift=prepared["dist_shift"], effective_seq_len=prepared["effective_seq_len"], fallback_seq_len=prepared["fallback_seq_len"], diff --git a/acestep/engine/sa3_denoise_mapping.py b/acestep/engine/sa3_denoise_mapping.py new file mode 100644 index 0000000..c0e8cbd --- /dev/null +++ b/acestep/engine/sa3_denoise_mapping.py @@ -0,0 +1,77 @@ +"""Optional product mapping for the Stable Audio 3 denoise control. + +The corrected upstream schedule makes ``sigma_max`` mathematically monotonic, +but measured audio change is still concentrated near the top of its range. In +a 41-point sweep over nine clips, less than one fifth of the available change +occurred below sigma 0.70. The knot table below inverts that measured curve so +equal control movements target roughly equal movements in the referee score. + +The referee combines harmonic and rhythmic change and reproduced two separate +listening-order judgments that a loudness-based measure did not. It validates +the ordering and the location of the dead region, not a claim that every step +is equally perceptible. This is also one global mapping: sparse acoustic input +can move faster than dense electronic material. + +Set ``DEMON_SA3_DENOISE_MAPPING=identity`` to bypass the product mapping while +retaining the upstream monotonic-schedule bugfix. +""" + +from __future__ import annotations + +import os +import typing as tp + +__all__ = [ + "denoise_mapping_mode", + "dial_to_entry_sigma", + "map_denoise_to_entry_sigma", +] + + +# Dial position -> entry sigma, obtained by inverting the measured change curve. +# Both endpoints remain exact: zero preserves the source and one starts from +# pure noise. Values between measured knots interpolate linearly. +_CALIBRATION: tp.Sequence[tuple[float, float]] = ( + (0.00, 0.0000), + (0.10, 0.3786), + (0.20, 0.5762), + (0.30, 0.6853), + (0.40, 0.7321), + (0.50, 0.7683), + (0.65, 0.8376), + (0.80, 0.8843), + (1.00, 1.0000), +) + + +def denoise_mapping_mode() -> str: + """Return ``calibrated`` (default) or the rollback mode ``identity``.""" + mode = os.environ.get("DEMON_SA3_DENOISE_MAPPING", "calibrated").strip().lower() + if mode not in ("calibrated", "identity"): + raise ValueError( + "DEMON_SA3_DENOISE_MAPPING must be calibrated|identity, " + f"got {mode!r}" + ) + return mode + + +def dial_to_entry_sigma(dial: float) -> float: + """Interpolate the measured monotonic mapping, clamped to ``[0, 1]``.""" + position = min(max(float(dial), 0.0), 1.0) + if position <= _CALIBRATION[0][0]: + return _CALIBRATION[0][1] + + for (p0, s0), (p1, s1) in zip(_CALIBRATION, _CALIBRATION[1:]): + if position <= p1: + fraction = (position - p0) / (p1 - p0) + return s0 + (s1 - s0) * fraction + + return _CALIBRATION[-1][1] + + +def map_denoise_to_entry_sigma(dial: float) -> float: + """Apply the selected product mapping without changing endpoint semantics.""" + clamped = min(max(float(dial), 0.0), 1.0) + if denoise_mapping_mode() == "identity": + return clamped + return dial_to_entry_sigma(clamped) diff --git a/acestep/streaming/sa3_backend.py b/acestep/streaming/sa3_backend.py index 6452e12..0e8bdca 100644 --- a/acestep/streaming/sa3_backend.py +++ b/acestep/streaming/sa3_backend.py @@ -103,10 +103,11 @@ def sa3_knob_specs() -> list: KnobSpec( "sa3_denoise", default=1.0, max_val=1.0, group="sa3", description=( - "SA3 init_noise_level: fresh-noise vs source-anchor mix " - "at slot init (1.0 = generate from pure noise, lower = " - "closer cover of the source). Distinct from ACE's " - "'denoise' (k1 strength), hence the prefix." + "Measured SA3 audio-change amount: 1.0 generates from pure " + "noise, while lower values stay progressively closer to the " + "source. Mapped onto init_noise_level so useful change is " + "spread across the dial. Distinct from ACE's 'denoise' " + "(k1 strength), hence the prefix." ), ), KnobSpec( diff --git a/tests/unit/test_sa3_denoise_mapping.py b/tests/unit/test_sa3_denoise_mapping.py new file mode 100644 index 0000000..7e0db2f --- /dev/null +++ b/tests/unit/test_sa3_denoise_mapping.py @@ -0,0 +1,53 @@ +import pytest + +from acestep.engine.sa3_denoise_mapping import ( + denoise_mapping_mode, + dial_to_entry_sigma, + map_denoise_to_entry_sigma, +) + + +def test_mapping_is_monotonic_and_preserves_endpoints(): + values = [dial_to_entry_sigma(i / 100) for i in range(101)] + + assert values[0] == 0.0 + assert values[-1] == 1.0 + assert all(a <= b for a, b in zip(values, values[1:])) + + +def test_mapping_matches_measured_knots(): + assert dial_to_entry_sigma(0.1) == pytest.approx(0.3786) + assert dial_to_entry_sigma(0.5) == pytest.approx(0.7683) + assert dial_to_entry_sigma(0.8) == pytest.approx(0.8843) + + +def test_mapping_interpolates_between_knots(): + midpoint = (0.7683 + 0.8376) / 2 + assert dial_to_entry_sigma(0.575) == pytest.approx(midpoint) + + +def test_mapping_clamps_inputs(): + assert dial_to_entry_sigma(-1) == 0.0 + assert dial_to_entry_sigma(2) == 1.0 + + +def test_default_mapping_lifts_the_measured_dead_region(monkeypatch): + monkeypatch.delenv("DEMON_SA3_DENOISE_MAPPING", raising=False) + + assert denoise_mapping_mode() == "calibrated" + assert map_denoise_to_entry_sigma(0.2) > 0.5 + assert map_denoise_to_entry_sigma(0.5) > 0.7 + + +def test_identity_mode_is_a_bugfix_preserving_rollback(monkeypatch): + monkeypatch.setenv("DEMON_SA3_DENOISE_MAPPING", "identity") + + assert map_denoise_to_entry_sigma(0.2) == 0.2 + assert map_denoise_to_entry_sigma(0.5) == 0.5 + + +def test_invalid_mode_fails_loudly(monkeypatch): + monkeypatch.setenv("DEMON_SA3_DENOISE_MAPPING", "mystery") + + with pytest.raises(ValueError, match=r"calibrated\|identity"): + map_denoise_to_entry_sigma(0.5)