diff --git a/benchmarks/annotations/somebody-to-love-extended-mix.json b/benchmarks/annotations/somebody-to-love-extended-mix.json index ceb4ae2..a5e0249 100644 --- a/benchmarks/annotations/somebody-to-love-extended-mix.json +++ b/benchmarks/annotations/somebody-to-love-extended-mix.json @@ -979,6 +979,8 @@ "fitted_phase_secs": 0.0035, "generator": "annotate_rigid_grid (rigid grid at manifest BPM, kick-band phase fit)", "manifest_bpm": 128.0, + "note": "2026-08-07: genuinely phase-indecisive. The rigid fit lands on the exact manifest BPM but phase_lock measures 0.181 (below the 0.3 adoption gate) AND tracked-beat corroboration is only 0.28 (below CORROBORATION_MIN_AGREEMENT = 0.6, src/analysis/rigid_grid.rs) - the two independent estimators (kick-band phase circle vs full-band DP tracker) genuinely disagree, so the rigid grid is correctly not adopted. See LEARNINGS.md 'Stage 10 - Tracked-Beat Corroboration for Rigid Grids'.", + "phase_indecisive": true, "phase_lock": 0.165, "verified_by_ear": false } diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index caed2b7..ae259ab 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,3 +1,10 @@ +# Standalone crate: explicitly not part of any enclosing workspace. The +# repo root excludes "desktop", but that path-based exclude fails to match +# when this checkout is nested inside another (e.g. a git worktree under +# .claude/worktrees/), so cargo would otherwise walk up and claim the +# outer repo's workspace. +[workspace] + [package] name = "timestretch-desktop" version = "0.3.0" diff --git a/desktop/src/waveform/counter.rs b/desktop/src/waveform/counter.rs index f59dfb9..8c8ca9f 100644 --- a/desktop/src/waveform/counter.rs +++ b/desktop/src/waveform/counter.rs @@ -46,4 +46,14 @@ pub fn paint_beat_counter(ui: &mut egui::Ui, marks: &GridMarks, position_frames: ); } } + + // Honest-display hint: the waveform ticks are drawn dimmed for this + // grid, and this says why. + if marks.low_confidence() { + ui.label( + egui::RichText::new("grid: low confidence") + .small() + .color(palette::TEXT_DIM), + ); + } } diff --git a/desktop/src/waveform/mod.rs b/desktop/src/waveform/mod.rs index 9aa3a79..30de95f 100644 --- a/desktop/src/waveform/mod.rs +++ b/desktop/src/waveform/mod.rs @@ -52,6 +52,38 @@ pub(crate) mod palette { pub const PLAYED_TINT: Color32 = Color32::from_rgb(110, 110, 118); } +/// Grid-confidence threshold below which the deck presents the grid as +/// tentative: beat/downbeat ticks draw dimmed and the counter row shows a +/// "grid: low confidence" hint. +/// +/// What `timestretch::BeatGrid::confidence` means: +/// - Tracked (DP) grids score 0.4 * tempogram path salience + 0.3 * +/// beat-level onset support + 0.3 * interval regularity +/// (`grid_confidence`, src/analysis/beat.rs:600-631). +/// - Rigid-adopted grids keep at least that: confidence = +/// `grid.confidence.max(fit.phase_lock)` (src/analysis/rigid_grid.rs:298), +/// so rigid adoption never lowers the reading. +/// +/// Corpus evidence (benchmarks/baselines/bpm_accuracy_baseline_latest.json): +/// every real-music grid across the 16-track corpus — rigid-adopted or +/// tracked — reports confidence 0.79-0.94. 0.6 sits with clear margin below +/// that cluster, so a healthy grid never dims, while grids whose own +/// evidence collapses (weak periodicity or poor beat-level onset support: +/// ambient, rubato, speech-heavy material) fall through the salience and +/// support terms of the formula and flag. +/// +/// Honest limit: this flag cannot catch a wandering DP grid on quantized +/// material — somebody-to-love-extended-mix measures confidence 0.845 with +/// beat F 0.31 in the same baseline, because the metric scores internal +/// consistency, not ground truth. That failure class is handled upstream by +/// rigid-grid adoption and tracked-beat corroboration +/// (src/analysis/rigid_grid.rs:45-61), not by this display threshold. +pub(crate) const LOW_CONFIDENCE_THRESHOLD: f32 = 0.6; + +/// Gamma-space multiplier applied to tick colors on a low-confidence grid +/// (~40% alpha versions of the palette colors). +pub(crate) const LOW_CONFIDENCE_TICK_DIM: f32 = 0.4; + /// Beats in a bar for the counter/phrase math. The Stage 10 grid carries a /// 4/4 prior; bars with other beat counts wrap modulo 4 for display. const BEATS_PER_BAR: usize = 4; @@ -73,6 +105,10 @@ pub struct GridMarks { beat_in_bar: Vec, /// Median beat interval in frames (0.0 when fewer than 2 beats). median_beat_frames: f64, + /// Whether the detector's grid confidence fell below + /// [`LOW_CONFIDENCE_THRESHOLD`]; painters dim their ticks and the + /// counter row shows a hint. + low_confidence: bool, } impl GridMarks { @@ -83,6 +119,7 @@ impl GridMarks { bar_of: Vec::new(), beat_in_bar: Vec::new(), median_beat_frames: 0.0, + low_confidence: false, } } @@ -133,6 +170,7 @@ impl GridMarks { bar_of, beat_in_bar, median_beat_frames, + low_confidence: grid.confidence < LOW_CONFIDENCE_THRESHOLD, } } @@ -145,6 +183,12 @@ impl GridMarks { self.frames.len() >= 2 } + /// Whether the detector reported this grid below + /// [`LOW_CONFIDENCE_THRESHOLD`] (ticks dim, counter hints). + pub fn low_confidence(&self) -> bool { + self.low_confidence + } + pub fn frame(&self, i: usize) -> f64 { self.frames[i] } @@ -412,6 +456,31 @@ mod tests { assert_eq!(phrase_beats, vec![0, 64]); } + #[test] + fn low_confidence_flag_tracks_grid_confidence() { + let mut grid = timestretch::BeatGrid::empty(100); + grid.beats = (0..16).map(|i| i as f64 * 100.0).collect(); + grid.downbeats = vec![0, 4, 8, 12]; + grid.bpm = 60.0; + + // Corpus-healthy reading (tracked or rigid-adopted): not flagged. + grid.confidence = 0.85; + assert!(!GridMarks::from_grid(&grid).low_confidence()); + + // Collapsed evidence (weak periodicity / onset support): flagged. + grid.confidence = 0.3; + assert!(GridMarks::from_grid(&grid).low_confidence()); + + // Exactly at the threshold: not low — the gate is strict-less-than. + grid.confidence = LOW_CONFIDENCE_THRESHOLD; + assert!(!GridMarks::from_grid(&grid).low_confidence()); + } + + #[test] + fn empty_marks_are_not_flagged_low_confidence() { + assert!(!GridMarks::empty().low_confidence()); + } + #[test] fn median_interval_ignores_outliers() { let mut grid = timestretch::BeatGrid::empty(100); diff --git a/desktop/src/waveform/overview.rs b/desktop/src/waveform/overview.rs index 0a9e552..4de9db2 100644 --- a/desktop/src/waveform/overview.rs +++ b/desktop/src/waveform/overview.rs @@ -107,7 +107,16 @@ pub fn paint_overview(ui: &mut egui::Ui, params: OverviewParams<'_>) -> Option 0 { + let (bar_color, phrase_color) = if params.marks.low_confidence() { + ( + palette::TICK_BEAT.gamma_multiply(super::LOW_CONFIDENCE_TICK_DIM), + palette::TICK_PHRASE.gamma_multiply(super::LOW_CONFIDENCE_TICK_DIM), + ) + } else { + (palette::TICK_BEAT, palette::TICK_PHRASE) + }; let plan = overlay_plan( rect.width(), params.marks.len(), @@ -128,12 +137,9 @@ pub fn paint_overview(ui: &mut egui::Ui, params: OverviewParams<'_>) -> Option, + /// True when two independent estimators (kick-band rigid fit and the + /// DP tracker) genuinely disagreed about beat phase on plausibly + /// quantized material — the grid's phase should not be trusted for + /// display or quantization even if it looks internally consistent. + /// [`confidence`](Self::confidence) is capped alongside this flag. + pub phase_untrusted: bool, } impl BeatGrid { @@ -98,6 +104,7 @@ impl BeatGrid { downbeat_confidence: 0.0, sample_rate, tempo_candidates: Vec::new(), + phase_untrusted: false, } } @@ -276,6 +283,7 @@ pub(crate) fn detect_beats_from_transients_with_options( downbeat_confidence, sample_rate, tempo_candidates, + phase_untrusted: false, } } @@ -749,6 +757,7 @@ mod tests { downbeat_confidence: 1.0, sample_rate, tempo_candidates: Vec::new(), + phase_untrusted: false, } } @@ -1159,6 +1168,7 @@ mod tests { downbeat_confidence: 1.0, sample_rate: 44100, tempo_candidates: Vec::new(), + phase_untrusted: false, }; assert!((grid.bpm_at(10000.0) - 120.0).abs() < 1e-9); assert!((grid.bpm_at(70000.0) - 132.0).abs() < 1e-9); diff --git a/src/analysis/preanalysis.rs b/src/analysis/preanalysis.rs index 4804bf0..775eb0e 100644 --- a/src/analysis/preanalysis.rs +++ b/src/analysis/preanalysis.rs @@ -113,8 +113,10 @@ pub fn analyze_for_dj_with_report( 0 }; - let confidence = - estimate_confidence(&beat_positions, &transients.onsets, sample_rate).max(grid.confidence); + let confidence = artifact_confidence( + estimate_confidence(&beat_positions, &transients.onsets, sample_rate), + &grid, + ); let transient_strengths = if transients.strengths.len() == transients.onsets.len() { transients.strengths.clone() @@ -194,6 +196,22 @@ fn mad_of(values: &[f32]) -> f32 { median_of(&deviations) } +/// Combines the interval-regularity estimate with the grid's own +/// confidence into the value stored in the artifact — the number hosts +/// display. Normally the max of the two, but a phase-untrusted grid +/// (both rigid adoption gates declined on plausibly quantized material) +/// keeps its cap: interval regularity scores internal consistency, not +/// ground truth, and must not reinstate confidence the estimator +/// disagreement just revoked. +fn artifact_confidence(estimated: f32, grid: &crate::BeatGrid) -> f32 { + let combined = estimated.max(grid.confidence); + if grid.phase_untrusted { + combined.min(crate::analysis::rigid_grid::PHASE_UNTRUSTED_CONFIDENCE_CAP) + } else { + combined + } +} + /// Estimates confidence from beat regularity and onset support. fn estimate_confidence(beats: &[usize], onsets: &[usize], sample_rate: u32) -> f32 { if beats.len() < 3 { @@ -242,6 +260,24 @@ fn estimate_confidence(beats: &[usize], onsets: &[usize], sample_rate: u32) -> f mod tests { use super::*; + #[test] + fn phase_untrusted_grid_caps_artifact_confidence() { + // The value hosts display is the ARTIFACT confidence. Interval + // regularity must not reinstate confidence over the cap on a + // phase-untrusted grid (Somebody To Love: regularity estimates + // ~0.85 while the grid's phase is wrong). + let mut grid = crate::BeatGrid::empty(44100); + grid.confidence = 0.5; + grid.phase_untrusted = true; + assert_eq!(artifact_confidence(0.85, &grid), 0.5); + // A trusted grid keeps the max-of-both behavior in both + // directions. + grid.phase_untrusted = false; + assert_eq!(artifact_confidence(0.85, &grid), 0.85); + grid.confidence = 0.9; + assert_eq!(artifact_confidence(0.3, &grid), 0.9); + } + #[test] fn test_analyze_for_dj_click_train_has_confidence() { let sample_rate = 44100u32; diff --git a/src/analysis/rigid_grid.rs b/src/analysis/rigid_grid.rs index 3fd922c..d03f81a 100644 --- a/src/analysis/rigid_grid.rs +++ b/src/analysis/rigid_grid.rs @@ -62,6 +62,12 @@ const CORROBORATION_MIN_AGREEMENT: f64 = 0.6; /// Tolerance for a tracked beat to count as landing on the rigid grid. /// Same figure as the smear radius: one vinyl-tight beat placement. const CORROBORATION_TOL_SECS: f64 = SMEAR_RADIUS_SECS; +/// Confidence ceiling reported when BOTH adoption gates decline a fit +/// (indecisive phase and no tracked-beat corroboration): estimator +/// disagreement on quantized material means the tracked grid's phase is +/// suspect no matter how internally consistent it looks. Below the +/// desktop's low-confidence display threshold (0.6) by design. +pub(crate) const PHASE_UNTRUSTED_CONFIDENCE_CAP: f32 = 0.5; /// Sanity floor: under a timing-tolerant (smeared) objective the rigid /// grid must reach at least this fraction of the tracked beats' score, /// so a decisive-but-wrong fit (e.g. seeded off an octave-wrong tempo on @@ -232,6 +238,27 @@ pub fn refine_grid_rigid(samples: &[f32], sample_rate: u32, grid: BeatGrid) -> ( .count(); let agreement = hits as f64 / grid.beats.len() as f64; if agreement < CORROBORATION_MIN_AGREEMENT { + // Both adoption gates failed: the kick-band fit found the + // exact tempo but its phase is indecisive AND the tracked + // beats do not corroborate it — the two independent + // estimators genuinely disagree, which is positive evidence + // the surviving tracked grid's PHASE is untrustworthy on + // quantized material (corpus: Somebody To Love, beat F 0.31 + // yet raw confidence 0.845 — the confidence metric scores + // internal consistency, not ground truth). Cap the reported + // confidence so hosts can show an honest low-confidence + // grid — but only when the rigid fit also explains the kicks + // about as well as the tracked beats (the material is + // plausibly quantized). A tempo ramp or live take also fails + // both gates, but its constant-grid fit scores far below its + // tracked beats under the smeared objective, and its honestly + // wandering grid must keep its honest tracked confidence. + let (tracked_score, rigid_score, _) = smeared_scores(samples, sr, &grid, &fit); + let mut grid = grid; + if rigid_score >= tracked_score * ADOPT_MIN_SMEARED_RATIO { + grid.confidence = grid.confidence.min(PHASE_UNTRUSTED_CONFIDENCE_CAP); + grid.phase_untrusted = true; + } return (grid, false); } } @@ -240,17 +267,13 @@ pub fn refine_grid_rigid(samples: &[f32], sample_rate: u32, grid: BeatGrid) -> ( // envelope so honest ±few-ms placements score alike, then require the // rigid grid to reach a fraction of the tracked beats' score. let sr = sample_rate as f64; - let KickEnvelope { - onset, frame_secs, .. - } = kick_onset_envelope(samples, sr); - let radius = (SMEAR_RADIUS_SECS / frame_secs).round().max(1.0) as usize; - let smeared = triangular_smear(&onset, radius); - let tracked_secs: Vec = grid.beats.iter().map(|&b| b / sr).collect(); - let tracked_score = mean_env_at(&smeared, frame_secs, &tracked_secs); - let rigid_score = mean_env_at(&smeared, frame_secs, &fit.beats_secs); + let (tracked_score, rigid_score, env) = smeared_scores(samples, sr, &grid, &fit); if rigid_score < tracked_score * ADOPT_MIN_SMEARED_RATIO { return (grid, false); } + let KickEnvelope { + onset, frame_secs, .. + } = env; // Downbeat rotation by kick-band accent (mod 4), as in the annotator. let mut rotation_scores = [0.0f64; 4]; @@ -299,11 +322,32 @@ pub fn refine_grid_rigid(samples: &[f32], sample_rate: u32, grid: BeatGrid) -> ( downbeat_confidence, sample_rate, tempo_candidates, + phase_untrusted: false, }, true, ) } +/// Mean smeared-onset score of the tracked beats vs the rigid fit's +/// beats, plus the kick envelope for reuse: how well each grid explains +/// the kicks under a timing-tolerant (±[`SMEAR_RADIUS_SECS`]) objective. +/// Shared by the adoption sanity floor and the phase-untrusted gate so +/// "the material is plausibly quantized" means the same thing in both. +fn smeared_scores( + samples: &[f32], + sr: f64, + grid: &BeatGrid, + fit: &RigidGridFit, +) -> (f64, f64, KickEnvelope) { + let env = kick_onset_envelope(samples, sr); + let radius = (SMEAR_RADIUS_SECS / env.frame_secs).round().max(1.0) as usize; + let smeared = triangular_smear(&env.onset, radius); + let tracked_secs: Vec = grid.beats.iter().map(|&b| b / sr).collect(); + let tracked_score = mean_env_at(&smeared, env.frame_secs, &tracked_secs); + let rigid_score = mean_env_at(&smeared, env.frame_secs, &fit.beats_secs); + (tracked_score, rigid_score, env) +} + /// Kick-band envelopes at the analysis hop. pub(crate) struct KickEnvelope { /// Per-hop RMS energy of the low-passed kick band. @@ -593,11 +637,20 @@ mod tests { // the tracked beats visit every phase of the rigid grid. *b += period * i as f64 / n; } - let (_grid, adopted) = refine_grid_rigid(&samples, SR, drifting); + let (grid, adopted) = refine_grid_rigid(&samples, SR, drifting); assert!( !adopted, "drifting tracked beats must not corroborate a rigid fit" ); + assert!( + grid.confidence <= PHASE_UNTRUSTED_CONFIDENCE_CAP, + "estimator disagreement must cap reported confidence, got {}", + grid.confidence + ); + assert!( + grid.phase_untrusted, + "estimator disagreement on quantized material must flag the grid" + ); } #[test] @@ -622,9 +675,21 @@ mod tests { let tracked = detect_beats(&samples, SR); assert!(tracked.bpm > 0.0); let tracked_beats = tracked.beats.clone(); + let tracked_confidence = tracked.confidence; let (grid, adopted) = refine_grid_rigid(&samples, SR, tracked); assert!(!adopted, "a tempo ramp must not adopt a rigid grid"); assert_eq!(grid.beats, tracked_beats); + // A ramp fails both adoption gates too, but its constant-grid fit + // does not explain the kicks — the tracked grid is honestly right, + // so its confidence must survive uncapped and unflagged. + assert!( + !grid.phase_untrusted, + "a well-tracked ramp must not be flagged phase-untrusted" + ); + assert_eq!( + grid.confidence, tracked_confidence, + "a well-tracked ramp must keep its tracked confidence" + ); } #[test] diff --git a/src/core/preanalysis.rs b/src/core/preanalysis.rs index bbcc5ed..1e538c6 100644 --- a/src/core/preanalysis.rs +++ b/src/core/preanalysis.rs @@ -31,17 +31,26 @@ use std::path::Path; /// (rigid grids now ship where wandering DP grids did), so v8 artifacts /// for that class are stale. /// +/// v10: no schema change — estimator-disagreement confidence capping +/// (ROADMAP Stage 10 honest low-confidence display): when both rigid +/// adoption gates decline a fit on plausibly quantized material, the +/// stored artifact confidence is capped at 0.5 so hosts can flag the +/// grid. v9 artifacts for that class (e.g. Somebody To Love) carry the +/// old wrongly-confident values and would never trigger the display. +/// /// The bump-when policy for these two constants lives in CLAUDE.md /// ("Analysis Version Policy") and is checked at release time via /// RELEASE_CHECKLIST.md. -pub const PREANALYSIS_VERSION: u32 = 9; +pub const PREANALYSIS_VERSION: u32 = 10; /// Oldest schema version whose *analysis results* match the current /// detector. Artifacts below this fail /// [`PreAnalysisArtifact::matches_source`], so cached sidecars regenerate: /// pre-v4 carried the window-start bias; v4–v7 predate (or are ambiguous -/// about) the rigid-grid beat fit; v8 predates corroborated adoption. -const MIN_COMPATIBLE_VERSION: u32 = 9; +/// about) the rigid-grid beat fit; v8 predates corroborated adoption; +/// v9 predates estimator-disagreement confidence capping, so cached +/// confidence for phase-indecisive tracks is wrongly high. +const MIN_COMPATIBLE_VERSION: u32 = 10; fn default_artifact_version() -> u32 { 1 diff --git a/tests/algorithm_edge_cases.rs b/tests/algorithm_edge_cases.rs index 9d45a89..03a3549 100644 --- a/tests/algorithm_edge_cases.rs +++ b/tests/algorithm_edge_cases.rs @@ -361,6 +361,7 @@ fn test_beat_grid_interval_samples() { downbeat_confidence: 1.0, sample_rate: 44100, tempo_candidates: Vec::new(), + phase_untrusted: false, }; let interval = grid.beat_interval_samples(); // 120 BPM at 44100 Hz = 22050 samples per beat