Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
25 changes: 22 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,28 @@ 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.
> **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

Expand Down
82 changes: 82 additions & 0 deletions src/engine/stages/keylock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<f64>() / 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
Expand Down
24 changes: 24 additions & 0 deletions src/engine/stages/sola.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Loading