Skip to content
Open
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
2 changes: 2 additions & 0 deletions benchmarks/annotations/somebody-to-love-extended-mix.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 7 additions & 0 deletions desktop/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/waveform/counter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
}
69 changes: 69 additions & 0 deletions desktop/src/waveform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -73,6 +105,10 @@ pub struct GridMarks {
beat_in_bar: Vec<u8>,
/// 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 {
Expand All @@ -83,6 +119,7 @@ impl GridMarks {
bar_of: Vec::new(),
beat_in_bar: Vec::new(),
median_beat_frames: 0.0,
low_confidence: false,
}
}

Expand Down Expand Up @@ -133,6 +170,7 @@ impl GridMarks {
bar_of,
beat_in_bar,
median_beat_frames,
low_confidence: grid.confidence < LOW_CONFIDENCE_THRESHOLD,
}
}

Expand All @@ -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]
}
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 11 additions & 5 deletions desktop/src/waveform/overview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,16 @@ pub fn paint_overview(ui: &mut egui::Ui, params: OverviewParams<'_>) -> Option<f

// Bar ticks along the bottom edge, phrase starts emphasized. Density
// thins by bar-number stride so surviving ticks stay phrase-aligned.
// Low-confidence grids draw dimmed, matching the zoomed view.
if params.marks.is_usable() && params.total_frames > 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(),
Expand All @@ -128,12 +137,9 @@ pub fn paint_overview(ui: &mut egui::Ui, params: OverviewParams<'_>) -> Option<f
let x = rect.left()
+ rect.width() * ((params.marks.frame(i) * inv_total) as f32).clamp(0.0, 1.0);
let (height, stroke) = if phrase {
(
TICK_PHRASE_PX,
egui::Stroke::new(2.0_f32, palette::TICK_PHRASE),
)
(TICK_PHRASE_PX, egui::Stroke::new(2.0_f32, phrase_color))
} else {
(TICK_BAR_PX, egui::Stroke::new(1.0_f32, palette::TICK_BEAT))
(TICK_BAR_PX, egui::Stroke::new(1.0_f32, bar_color))
};
painter.line_segment(
[
Expand Down
19 changes: 13 additions & 6 deletions desktop/src/waveform/zoomed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,18 @@ pub fn paint_zoomed(
}
}

// Beat/downbeat edge ticks (top and bottom), density-adaptive.
// Beat/downbeat edge ticks (top and bottom), density-adaptive. On a
// low-confidence grid the ticks draw dimmed — the grid is a hint, not
// an assertion.
if params.marks.is_usable() {
let (beat_color, downbeat_color) = if params.marks.low_confidence() {
(
palette::TICK_BEAT.gamma_multiply(super::LOW_CONFIDENCE_TICK_DIM),
palette::TICK_DOWNBEAT.gamma_multiply(super::LOW_CONFIDENCE_TICK_DIM),
)
} else {
(palette::TICK_BEAT, palette::TICK_DOWNBEAT)
};
let visible = params.marks.visible_range(start_frame, end_frame);
let downbeats = visible
.clone()
Expand All @@ -291,15 +301,12 @@ pub fn paint_zoomed(
if bar == 0 || !(bar - 1).is_multiple_of(stride) {
continue;
}
(
TICK_DOWNBEAT_PX,
egui::Stroke::new(2.0_f32, palette::TICK_DOWNBEAT),
)
(TICK_DOWNBEAT_PX, egui::Stroke::new(2.0_f32, downbeat_color))
} else {
if !plan.draw_beats {
continue;
}
(TICK_BEAT_PX, egui::Stroke::new(1.0_f32, palette::TICK_BEAT))
(TICK_BEAT_PX, egui::Stroke::new(1.0_f32, beat_color))
};
let x = frame_to_x(params.marks.frame(i));
painter.line_segment(
Expand Down
26 changes: 25 additions & 1 deletion src/analysis/rigid_grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
Expand Down Expand Up @@ -232,6 +238,19 @@ 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; ramps and live material are unaffected (their fits
// are declined by the sanity floor or never reach here).
let mut grid = grid;
grid.confidence = grid.confidence.min(PHASE_UNTRUSTED_CONFIDENCE_CAP);
return (grid, false);
}
}
Expand Down Expand Up @@ -593,11 +612,16 @@ 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
);
}

#[test]
Expand Down
Loading