From da89e9da2af35d7873668c6ec34385c5a7404509 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Thu, 6 Aug 2026 18:28:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20mild-motion=20bounded=20recenter=20?= =?UTF-8?q?=E2=80=94=20seam=20survives=20sustained=20rides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP Stage 15 core item. Characterization (engine-level probe): sustained mild rides (0.5-1% deviation, the mix-in gesture) let SOLA's drift sawtooth to the full 192-frame trigger, holding the 120 Hz crossover seam comb-filtered at -7 dB for as long as the ride lasts - rest recovery requires ~150 ms of stillness a moving fader never provides. Fix: when the transposition deviation is mild (< 1.2%) and settled drift exceeds 96 frames, fire the BOUNDED recenter splice (the rest- splice mechanism, without the dwell). The landing bound is load-bearing: a first attempt that simply tightened the general trigger made the seam WORSE (-5.5 dB persistent) because unbounded early splices park drift on the dominant-period grid on periodic content (the autoresearch #62 pathology the bound exists to prevent). Measured on the ride-profile matrix: worst comb -7.1 -> -4.4 dB, riding steady-state -2.5 -> -1.2 dB, creep/park profiles bit-identical, ~5 bounded splices/s (vs ~18/s the shipped +/-8% path already carries). Gated by seam_survives_a_sustained_mild_ride (pre-fix: -7.14 dB, 17 splices, fails). Cents-wobble and A/B-matrix gates unchanged. Also promotes qa/engine_keylock and qa/engine_transients into the CI quality-gates job (Stage 15 exit criterion). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 +++ ROADMAP.md | 20 +++++++-- src/engine/stages/keylock.rs | 82 ++++++++++++++++++++++++++++++++++++ src/engine/stages/sola.rs | 24 +++++++++++ 4 files changed, 129 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76fa563..309a40b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,12 @@ jobs: env: TIMESTRETCH_STRICT_CALLBACK_BUDGET: "1" TIMESTRETCH_CALLBACK_BUDGET_MULTIPLIER: "2.0" + # Ride-quality gates (ROADMAP Stage 15 promotion): cents wobble on + # sustained rides and transient sharpness — the two harnesses most + # directly tied to how the deck sounds under the fader; previously + # local-only. + - run: cargo test --features qa-harnesses --release --test engine_keylock -- --nocapture + - run: cargo test --features qa-harnesses --release --test engine_transients -- --nocapture - uses: actions/upload-artifact@v4 with: name: quality-dashboard-${{ github.run_id }} diff --git a/ROADMAP.md b/ROADMAP.md index 1f4ee14..fa43902 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -285,9 +285,23 @@ Automation: auto > wide stage is its sole consumer and WHY SOLA deliberately does not read > it (suppressing opportunistic splices during rides would push drift into > forced onset-unprotected splices) — wiring it remains the evidence-gated -> experiment below. Remaining: seam recovery under continuous rides, the -> optional correlation-reference/strength items, and promoting the two QA -> harnesses into CI. +> experiment below. +> +> **Seam recovery under motion landed 2026-08-06** (branch +> `feat/ride-seam-recovery`): characterization showed sustained mild rides +> (0.5–1% deviation — the mix-in gesture) comb the seam at −7 dB for the +> whole ride, because drift sawtooths to the full 192-frame trigger and +> rest recovery needs stillness. Fix: a mild-motion BOUNDED recenter +> (rest-splice mechanism, no dwell) at drift > 96 when deviation < 1.2%. +> Measured: worst comb −7.1 → −4.4 dB, riding steady-state −2.5 → −1.2 dB, +> safe profiles bit-identical, ~5 bounded splices/s. A first attempt that +> tightened the general trigger made things WORSE (−5.5 dB persistent) — +> unbounded early splices park on the dominant-period grid; the landing +> bound is the load-bearing part. Gated by +> `seam_survives_a_sustained_mild_ride` (fails pre-fix at −7.14). QA +> harnesses `engine_keylock` + `engine_transients` promoted into CI. +> Remaining: the optional correlation-reference/strength items (evidence- +> gated), the modulation_hold experiment, and the owner mix-in listen. ### Why diff --git a/src/engine/stages/keylock.rs b/src/engine/stages/keylock.rs index c265265..62557b1 100644 --- a/src/engine/stages/keylock.rs +++ b/src/engine/stages/keylock.rs @@ -226,6 +226,88 @@ mod tests { .collect() } + #[test] + fn seam_survives_a_sustained_mild_ride() { + // ROADMAP Stage 15: a DJ riding the fader gently (±0.6% here) used + // to let SOLA's drift sawtooth to the full 192-frame trigger, + // comb-filtering the crossover seam at −7 dB for as long as the + // ride lasted (rest recovery needs ~150 ms of stillness a moving + // fader never provides). The mild-motion bounded recenter caps the + // sawtooth at ~96 frames: measured worst −4.4 dB / riding mean + // −1.5 dB on this fixture. Gates pin those with margin, plus a + // splice-count bound so the fix cannot degenerate into churn. + let seam_hz = 135.0; + let srf = SR as f64; + let mut stage = KeylockStage::new(SR, 1); + let mut block = BlockBuf::new(1); + let mut collected = Vec::new(); + let (mut ps, mut ph) = (0.0f64, 0.0f64); + let total_blocks = (30.0 * srf / BLOCK_FRAMES as f64) as usize; + for bi in 0..total_blocks { + let t = (bi * BLOCK_FRAMES) as f64 / srf; + let rate = if t < 2.0 { + 1.0 + } else { + 1.0 + 0.006 * (2.0 * std::f64::consts::PI * 0.3 * (t - 2.0)).sin() + }; + for s in block.channel_mut(0).iter_mut() { + ps += 2.0 * std::f64::consts::PI * seam_hz * rate / srf; + ph += 2.0 * std::f64::consts::PI * 880.0 * rate / srf; + *s = 0.15 * ps.sin() as f32 + 0.5 * ph.sin() as f32; + } + let ctx = StageCtx { + embedded_rate: rate, + embedded_rate_slope: 0.0, + onsets: &[], + modulation_hold: false, + has_artifact: false, + keylock: 1.0, + }; + stage.process(&mut block, &ctx); + collected.extend_from_slice(block.channel(0)); + } + let goertzel = |lo: usize, hi: usize| -> f64 { + let w = 2.0 * std::f64::consts::PI * seam_hz / srf; + let coeff = 2.0 * w.cos(); + let (mut s1, mut s2) = (0.0f64, 0.0f64); + for &x in &collected[lo..hi] { + let s0 = x as f64 + coeff * s1 - s2; + s2 = s1; + s1 = s0; + } + (s1 * s1 + s2 * s2 - coeff * s1 * s2).sqrt() / ((hi - lo) as f64 / 2.0) + }; + let win = (0.25 * srf) as usize; + let baseline = (goertzel(2 * win, 3 * win) + goertzel(3 * win, 4 * win)) / 2.0; + let mut worst = 0.0f64; + let mut tail = Vec::new(); + for k in 8..collected.len() / win { + let db = 20.0 * (goertzel(k * win, (k + 1) * win) / baseline).log10(); + worst = worst.min(db); + if k * win > collected.len() - 12 * win { + tail.push(db); + } + } + let tail_mean = tail.iter().sum::() / tail.len() as f64; + let splices = stage.sola.splice_count(); + println!( + "mild ride seam: worst {worst:+.2} dB, tail {tail_mean:+.2} dB, {splices} splices" + ); + assert!( + worst > -6.0, + "seam comb too deep during mild ride: worst {worst:+.2} dB (pre-Stage-15: −7)" + ); + assert!( + tail_mean > -3.0, + "seam still de-phased while riding: tail mean {tail_mean:+.2} dB (pre-Stage-15: −2.5 \ + and not recovering)" + ); + assert!( + splices < 400, + "mild-motion recentering degenerated into splice churn: {splices} over 30 s" + ); + } + #[test] fn fade_band_rate_steps_are_click_free() { // The extreme-rate fade crossfades the corrected and raw high diff --git a/src/engine/stages/sola.rs b/src/engine/stages/sola.rs index 099f4d1..112131e 100644 --- a/src/engine/stages/sola.rs +++ b/src/engine/stages/sola.rs @@ -203,6 +203,22 @@ const RING_MASK: usize = RING_LEN - 1; /// Drift from the nominal lag that triggers a splice, in frames. const DRIFT_TRIGGER: f64 = 192.0; +/// Under MILD transposition deviations (below [`MILD_DEV`]) drift beyond +/// this fires a BOUNDED recenter splice (the rest-splice mechanism, no +/// dwell). Rationale (ROADMAP Stage 15): a sustained 0.5–1% ride lets +/// drift sawtooth to the full [`DRIFT_TRIGGER`] — a ~2–4 ms high-band +/// shift against the low band's rigid delay, comb-filtering the +/// crossover seam at −7 dB for as long as the ride lasts. Recentring at +/// 96 frames caps the shift at ~2 ms for ~3–5 splices/s — well under the +/// ~18/s the shipped ±8% path already carries. The landing bound matters: +/// an UNbounded splice on periodic content parks on the dominant-period +/// grid (or zero-jump limit-cycles, autoresearch #62) and makes the seam +/// WORSE — measured −5.5 dB persistent vs −2 dB with the bound. At +/// strong deviations nothing changes: combing there is brief, and +/// tighter triggers would multiply splice granulation. +const MILD_DRIFT_TRIGGER: f64 = 96.0; +/// Deviation ceiling for the mild-motion bounded recenter. +const MILD_DEV: f64 = 0.012; /// Drift at which a splice is forced even through a transient. const HARD_TRIGGER: f64 = 320.0; /// Correlation search half-range around the nominal jump, in frames. @@ -462,6 +478,14 @@ impl SolaCorrector { // gate is simpler and at least as good; see autoresearch // log #43/#51.) self.try_splice(drift, onsets); + } else if deviation < MILD_DEV && settled_drift.abs() > MILD_DRIFT_TRIGGER { + // Mild-motion bounded recenter (ROADMAP Stage 15): during + // sustained gentle rides the seam combs for the whole ride + // unless drift is kept small; the bounded landing (must + // actually recenter, or skip and let drift ride to the + // normal trigger) avoids the periodic-content parking that + // an unbounded early splice causes. + self.try_rest_splice(drift, onsets); } else if at_rest && settled_drift.abs() > REST_SPLICE_DRIFT { // At sustained rest a parked drift comb-filters the // crossover overlap against the low band's fixed delay; From 629d6d6b82dd8e3ad15e9a7105c147fc5aeee4bc Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Thu, 6 Aug 2026 18:37:33 +0800 Subject: [PATCH 2/2] docs: record Stage 15 owner mix-in listen verdict Bass body stable through a sustained gentle ride on the live desktop deck; mandatory exit criteria met on merge, optional items stay evidence-gated. Co-Authored-By: Claude Fable 5 --- ROADMAP.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fa43902..e9519cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -300,8 +300,13 @@ Automation: auto > bound is the load-bearing part. Gated by > `seam_survives_a_sustained_mild_ride` (fails pre-fix at −7.14). QA > harnesses `engine_keylock` + `engine_transients` promoted into CI. -> Remaining: the optional correlation-reference/strength items (evidence- -> gated), the modulation_hold experiment, and the owner mix-in listen. +> **Owner mix-in listen passed (2026-08-06, live desktop deck, this +> branch): "definitely an improvement" — bass body stable through a +> sustained gentle ride.** With the fade-band click gate (D6, PR #37) and +> the QA harnesses in CI, the stage's mandatory exit criteria are met on +> merge. The correlation-reference/strength items and the +> modulation_hold wiring experiment stay explicitly optional — each +> lands only if it moves a gate, and the stage does not wait for them. ### Why