Skip to content

feat(editor): Map Health — per-bar grid-vs-onset drift review lens - #249

Merged
byrongamatos merged 3 commits into
mainfrom
feat/editor-map-health
Jul 13, 2026
Merged

feat(editor): Map Health — per-bar grid-vs-onset drift review lens#249
byrongamatos merged 3 commits into
mainfrom
feat/editor-map-health

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

The 2nd-pass thesis is "review an automatic map, don't hand-enter it" — but there was no review surface until now (P2-4). Map Health scores, per measure, how well the beat grid agrees with the detected onsets and paints a thin three-state wash under the ruler, so drift is visible wherever you chart. A DAW-plus — no mainstream DAW shows per-bar drift heat.

The metric (pure src/map-health.js)

_mapHealthPure(beats, onsets, opts) → {measures:[{driftFrac, coverage, band}], overall}.

  • Per beat: resid = |beat.time − nearestOnset|, reported as a fraction of the local beat interval — 25 ms is inaudible at 60 bpm but half a subdivision on 200-bpm 16ths, so it colours by fraction of a beat, never raw ms (reads correctly across tempo ramps + meter changes).
  • Per measure: the median driftFrac over evidenced beats (median so one expressive off-beat note can't drag a bar red), plus coverage = evidenced/total.

Three states — the third is non-negotiable

  • 🟢 green — grid agrees with present onsets (< 5%).
  • 🟠 amber — grid is drifting (5–12%).
  • 🔴 red — grid disagrees with present corroborating onsets (> 12%).
  • greyno onsets to judge (silence / sustained / held / pedaled) → neutral, never red. Colouring an unmeasurable held bar red is crying wolf; the author learns to ignore red, which is fatal. Absence of evidence is carried, not drift.

Wiring

A ~5 px wash under the beat ticks (ruler.js), memoized on editGen + the onset-cache identity (no per-frame recompute), off by default, a view flag with no history. Toggle in the Tempo/Grid menu (audio-only). Reads only S.beats + _ensureOnsets(), so it rides #248's banded onsets once they land and degrades gracefully to the RMS detector meanwhile (worse onsets → more grey, never wrong).

Tests

  • tests/map_health.test.mjs (10) — aligned→green; offset 8% → amber (the signature fail-on-main case); onsets removed → grey, NOT red; a held bar → grey; median robustness (one expressive onset does not flag the timekeeper bar); >12% → red; band thresholds; tempo/meter independence (8% reads the same at 120 and 240 bpm); degenerate input.
  • Full JS suite 135, ESLint 0 errors, routes.py untouched.
  • Live-verified on AC/DC: 105 measures, overall green @ 88.6 % coverage, 68 green / 26 amber / 2 red / 9 grey (not crying wolf — grey for the silent intro bar), toggle + persistence + stable memo, and the wash renders on the ruler.

Follow-up (noted, not in this PR)

Click a hot bar → jump into Tempo Map + auto-offer the G suggest for that span; an LCD pill; distinct-signature reporting (lay-back vs nudge vs ramp vs isolated fill); the octave "reads 2×, halve?" one-click. The core review surface ships here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an optional Map Health toggle to the Tempo/Grid menu.
    • Renders a review-only thin per-measure color strip under the ruler (green/amber/red/grey) to show grid-versus-recording drift and alignment.
    • Uses coverage-based banding (grey when insufficient evidence) and defaults off.
    • Automatically hides when no audio is available.
  • Bug Fixes

    • Improved behavior for missing or insufficient timing evidence to avoid misleading results.
  • Tests

    • Added expanded regression/integration coverage for scoring and ruler/chart time handling.

The whole "review an automatic map" 2nd pass had no review surface until now
(P2-4). Map Health scores, per measure, how well the grid agrees with the
detected onsets and paints a thin three-state wash under the ruler, so drift is
visible wherever you chart.

New pure src/map-health.js: _mapHealthPure(beats, onsets, opts) → per-measure
{driftFrac, coverage, band} + overall. Per beat resid = |beat.time −
nearestOnset|, reported as a FRACTION of the local beat interval (25ms is
inaudible at 60bpm but half a subdivision on 200-bpm 16ths — colour by fraction,
never raw ms). Per-measure = the MEDIAN driftFrac over EVIDENCED beats (median so
one expressive off-beat note can't drag a bar red) + coverage.

THREE states, the third non-negotiable: green (agrees, <5%), amber (drifting,
5-12%), red (disagrees with PRESENT onsets, >12%), and GREY when there are no
onsets to judge (silence / sustained / held / pedaled) — NEUTRAL, never red.
Colouring an unmeasurable held bar red is crying wolf; the author learns to
ignore red, which is fatal.

Wiring (ruler.js): a ~5px wash under the beat ticks, memoized on editGen + the
onset-cache identity (no per-frame recompute), off by default, a view flag (no
history). Toggle in the Tempo/Grid menu (audio-only). Reads only S.beats +
_ensureOnsets() — so it rides #248's banded onsets once they land, and degrades
gracefully to the RMS detector meanwhile (worse onsets → more grey, never wrong).

Tests: tests/map_health.test.mjs (10) — aligned→green, offset 8%→amber (the
signature fail-on-main case), onsets-removed→grey NOT red, held-bar→grey, median
robustness (one expressive onset doesn't flag the timekeeper bar), >12%→red,
band thresholds, tempo/meter independence, degenerate input. 135 JS green, lint
0-err, routes.py untouched. Live-verified on AC/DC: 105 measures, overall green
@ 88.6% coverage, 68 green / 26 amber / 2 red / 9 grey (not crying wolf), toggle
+ persistence + stable memo, wash renders on the ruler.

FOLLOW-UP (noted): click a hot bar → jump to Tempo Map + offer the G suggest;
LCD pill; distinct-signature reporting (lay-back vs nudge vs ramp); octave
"reads 2×, halve?" one-click. Core review surface ships here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@byrongamatos, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 95efdf0b-7efd-4625-9b0d-35ed3c8f32fd

📥 Commits

Reviewing files that changed from the base of the PR and between a1070ff and 6cae2ca.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/main.js
  • src/menu-bar.js
📝 Walkthrough

Walkthrough

Adds an optional Map Health feature that scores beat-grid alignment against recording onsets, renders per-measure status colors under the ruler, and exposes the feature through the Tempo/Grid menu.

Changes

Map Health

Layer / File(s) Summary
Map Health scoring and tests
src/map-health.js, tests/map_health.test.mjs
Computes median beat drift, onset coverage, and green/amber/red/grey bands, with tests covering alignment, thresholds, missing evidence, outliers, invalid inputs, final-measure spans, audio shift, and memoization.
Ruler overlay state and rendering
src/ruler.js
Adds persisted toggle state, memoized audio-shift-aware results, status updates, guarded redraws, and a five-pixel per-measure color band beneath the ruler.
Menu and window integration
src/menu-bar.js, src/main.js, CHANGELOG.md
Adds an audio-gated Tempo/Grid entry, exposes the window toggle handler, and documents the feature as disabled by default.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Editor
  participant MenuBar
  participant Main
  participant Ruler
  participant MapHealth
  Editor->>MenuBar: select Map Health
  MenuBar->>Main: call editorToggleMapHealth
  Main->>Ruler: forward toggle
  Ruler->>MapHealth: score beats against onsets
  MapHealth-->>Ruler: return measure bands
  Ruler-->>Editor: redraw ruler overlay
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the editor Map Health feature and its per-bar grid-vs-onset drift review purpose.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-map-health

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/map_health.test.mjs (1)

103-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the exact threshold boundaries.

Add assertions for 0.05 and 0.12; the current representative values do not verify the documented inclusive/exclusive behavior.

Proposed additions
     assert.strictEqual(band(0.03), 'green');
+    assert.strictEqual(band(0.05), 'amber');
     assert.strictEqual(band(0.08), 'amber');
+    assert.strictEqual(band(0.12), 'amber');
     assert.strictEqual(band(0.15), 'red');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/map_health.test.mjs` around lines 103 - 109, Extend the “band
thresholds” test around _mapHealthPure to assert the exact boundary behavior at
0.05 and 0.12, verifying the documented inclusive/exclusive classification while
preserving the existing green, amber, and red representative cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/map-health.js`:
- Around line 54-58: Update _bandFor in src/map-health.js: classify driftFrac
equal to greenMax as amber while keeping red classification only for values
strictly above redMin. Add boundary assertions in tests/map_health.test.mjs for
the exact 0.05 and 0.12 thresholds, covering the documented classifications.
- Around line 79-115: The measure construction in src/map-health.js lines 79-115
must treat a terminal closing downbeat as a boundary, not a measure start:
exclude it from downs, use the next downbeat as the endpoint when present, and
extrapolate the final measure endpoint only when no boundary exists. Update
tests/map_health.test.mjs lines 26-35 to include a closing downbeat and assert
exactly bars measures with complete spans.

In `@src/ruler.js`:
- Around line 88-91: Update _drawMapHealthBand to apply the same
audio-availability gate as the menu before checking _mapHealthEnabled(), so
audio-less charts do not render inherited map-health state. Reuse the existing
audio/onset availability condition or helper used by the menu, while preserving
the current result and measures checks for eligible charts.

---

Nitpick comments:
In `@tests/map_health.test.mjs`:
- Around line 103-109: Extend the “band thresholds” test around _mapHealthPure
to assert the exact boundary behavior at 0.05 and 0.12, verifying the documented
inclusive/exclusive classification while preserving the existing green, amber,
and red representative cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 75cad3db-0b80-472d-a712-ccbdad468d61

📥 Commits

Reviewing files that changed from the base of the PR and between 4968f8b and fa57e0c.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • src/main.js
  • src/map-health.js
  • src/menu-bar.js
  • src/ruler.js
  • tests/map_health.test.mjs

Comment thread src/map-health.js
Comment thread src/map-health.js
Comment on lines +79 to +115
const downs = [];
for (let i = 0; i < beats.length; i++) if (beats[i] && beats[i].measure > 0) downs.push(i);
if (!downs.length) return empty;

const intervalAt = (j) => {
if (j + 1 < beats.length) return beats[j + 1].time - beats[j].time;
if (j - 1 >= 0) return beats[j].time - beats[j - 1].time;
return 0;
};

const measures = [];
let cursor = 0;
for (let d = 0; d < downs.length; d++) {
const start = downs[d];
const end = d + 1 < downs.length ? downs[d + 1] : beats.length; // exclusive
const drifts = [];
let total = 0;
for (let j = start; j < end; j++) {
if (!beats[j] || !Number.isFinite(beats[j].time)) continue;
total++;
const interval = intervalAt(j);
if (!(interval > 0)) continue;
const found = _nearestOnsetFrom(onsetTimes, beats[j].time, interval * evidenceWin, cursor);
cursor = found.cursor;
if (found.time !== null) drifts.push(Math.abs(found.time - beats[j].time) / interval);
}
const coverage = total > 0 ? drifts.length / total : 0;
const driftFrac = _median(drifts);
measures.push({
i: measures.length,
measure: beats[start].measure,
startTime: beats[start].time,
endTime: (end < beats.length ? beats[end].time : beats[beats.length - 1].time),
driftFrac,
coverage,
band: _bandFor(driftFrac, coverage, greenMax, redMin, minCoverage),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align measure construction with the closing-downbeat contract. The implementation treats the final boundary as a measure, while the test fixture omits that production shape entirely.

  • src/map-health.js#L79-L115: exclude a terminal closing downbeat from measure starts and extrapolate the last measure endpoint only when no boundary exists.
  • tests/map_health.test.mjs#L26-L35: include a closing downbeat and assert the result still contains exactly bars measures with complete spans.
📍 Affects 2 files
  • src/map-health.js#L79-L115 (this comment)
  • tests/map_health.test.mjs#L26-L35
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/map-health.js` around lines 79 - 115, The measure construction in
src/map-health.js lines 79-115 must treat a terminal closing downbeat as a
boundary, not a measure start: exclude it from downs, use the next downbeat as
the endpoint when present, and extrapolate the final measure endpoint only when
no boundary exists. Update tests/map_health.test.mjs lines 26-35 to include a
closing downbeat and assert exactly bars measures with complete spans.

Comment thread src/ruler.js
…inal bar

Review fixes on the Map Health lens:

- HIGH: the lens compared chart-time beats against BUFFER-time onsets
  (_ensureOnsets). Any non-zero S.audioShift made the whole map read red.
  Consume _ensureOnsetsShifted() — the chart-time view, which is what every
  other beat-vs-onset consumer uses.

- HIGH: _ensureOnsetsShifted() reallocates on every call when the shift is
  non-zero, so keying the memo on it would miss every frame and put the
  O(bars x beats) scan back on the draw path. Key on the RAW cache identity
  plus the shift scalar instead (and invalidate on a shift change, which the
  old key could not see at all).

- MED: the unclosed final measure ended AT the last beat, clipping the last
  bar's wash a beat short and collapsing a grid that ends on a closing
  downbeat to zero width. Extrapolate one beat past the last beat, keeping
  the canonical _tempoMeasures rule that every downbeat starts a measure.

- LOW: the persisted flag let an audio-less chart paint an all-grey strip
  with no menu row to switch it off. Gate the draw on S.audioBuffer, the
  same gate as the audioOnly menu row (CodeRabbit).

- LOW: _bandFor made exactly greenMax green, contradicting its own header
  (green is strictly under greenMax) (CodeRabbit).

- Toggling on now reports through setStatus like every sibling toggle, and
  says so explicitly when there are no transients to judge against — an
  all-grey strip is honest but looks broken when it is silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/map_health.test.mjs (1)

181-192: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Memo test implicitly depends on S.beats left over from the previous test.

Unlike the preceding seam test (lines 173-174), this test never assigns S.beats before its first _mapHealthResults() call — it relies on the 8-element array set by the prior test still being in module state. If this test is run in isolation or reordered, S.beats won't be that populated array, and S.beats.map(...) at Line 187 will throw if S.beats is null/undefined by default (or silently exercise a degenerate empty-grid path instead of the intended populated-grid identity check).

♻️ Proposed fix: make the grid setup explicit
 t('the memo survives a non-zero audio shift (no per-frame recompute on the draw path)', () => {
     seedPeaks();
     S.audioShift = 0.2;
+    S.beats = [];
+    for (let i = 0; i < 8; i++) S.beats.push({ time: 0.4 + i * 0.5, measure: i % 4 === 0 ? (i / 4) + 1 : 0 });
     const a = _mapHealthResults();
     const b = _mapHealthResults();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/map_health.test.mjs` around lines 181 - 192, Initialize S.beats
explicitly with the populated test grid before the first _mapHealthResults()
call in the memo test. Keep the existing audio-shift and grid-rebuild assertions
unchanged so the test remains isolated and verifies identity changes on a valid
grid.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/ruler.js`:
- Around line 92-101: Update the status-message logic in the ruler update block
around _mapHealthResults and setStatus to distinguish missing audio, missing or
insufficient beat-grid data in S.beats, and genuinely zero detected transients.
Preserve the existing coverage-based message for positive coverage, but only
report “no transients detected” when an audio buffer and usable beat grid exist;
otherwise use status text that accurately identifies the unavailable input.

---

Nitpick comments:
In `@tests/map_health.test.mjs`:
- Around line 181-192: Initialize S.beats explicitly with the populated test
grid before the first _mapHealthResults() call in the memo test. Keep the
existing audio-shift and grid-rebuild assertions unchanged so the test remains
isolated and verifies identity changes on a valid grid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5b7d73b2-27c1-41ec-aa55-1b162b1fda49

📥 Commits

Reviewing files that changed from the base of the PR and between fa57e0c and a1070ff.

📒 Files selected for processing (3)
  • src/map-health.js
  • src/ruler.js
  • tests/map_health.test.mjs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/map-health.js

Comment thread src/ruler.js
Comment on lines +92 to +101
// An all-grey strip is the honest render of "no transients to judge against",
// but silently. Say so — otherwise the lens looks broken rather than blank.
if (next) {
const cov = _mapHealthResults().overall.coverage;
setStatus(cov > 0
? 'Map Health on — per-bar grid-vs-recording drift under the ruler (green agrees, red disagrees, grey = nothing to judge; review only)'
: 'Map Health on — no transients detected in the recording yet, so every bar reads grey (nothing to judge)');
} else {
setStatus('Map Health off');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Status message conflates "no audio" / "no beat grid" / "no transients found" into a single condition.

cov > 0 is the only branch condition, but coverage is also 0 when there's no audio buffer (_ensureOnsets() returns null) or no beat grid (S.beats is null/too short) — not just when onsets genuinely weren't detected. The draw path already treats !S.audioBuffer as a distinct case (Line 112); the status text should do the same instead of always claiming "no transients detected in the recording yet."

Proposed fix
     if (next) {
-        const cov = _mapHealthResults().overall.coverage;
-        setStatus(cov > 0
-            ? 'Map Health on — per-bar grid-vs-recording drift under the ruler (green agrees, red disagrees, grey = nothing to judge; review only)'
-            : 'Map Health on — no transients detected in the recording yet, so every bar reads grey (nothing to judge)');
+        const cov = S.audioBuffer ? _mapHealthResults().overall.coverage : 0;
+        setStatus(cov > 0
+            ? 'Map Health on — per-bar grid-vs-recording drift under the ruler (green agrees, red disagrees, grey = nothing to judge; review only)'
+            : S.audioBuffer
+                ? 'Map Health on — no transients detected in the recording yet, so every bar reads grey (nothing to judge)'
+                : 'Map Health on — no recording loaded, so every bar reads grey (nothing to judge)');
     } else {
🧰 Tools
🪛 ast-grep (0.44.1)

[error] 95-97: React's useState should not be directly called
Context: setStatus(cov > 0
? 'Map Health on — per-bar grid-vs-recording drift under the ruler (green agrees, red disagrees, grey = nothing to judge; review only)'
: 'Map Health on — no transients detected in the recording yet, so every bar reads grey (nothing to judge)')
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)


[error] 99-99: React's useState should not be directly called
Context: setStatus('Map Health off')
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ruler.js` around lines 92 - 101, Update the status-message logic in the
ruler update block around _mapHealthResults and setStatus to distinguish missing
audio, missing or insufficient beat-grid data in S.beats, and genuinely zero
detected transients. Preserve the existing coverage-based message for positive
coverage, but only report “no transients detected” when an audio buffer and
usable beat grid exist; otherwise use status text that accurately identifies the
unavailable input.

@byrongamatos
byrongamatos merged commit 024339a into main Jul 13, 2026
3 of 4 checks passed
byrongamatos added a commit that referenced this pull request Jul 13, 2026
This branch predates the fixes that landed with its parent, and git would
merge the two cleanly while silently reverting them. Reconciled by hand:

  · ruler.js keeps main's _ensureOnsetsShifted() + the audio-shift memo key
    (taking this branch's side would restore the time-base bug that paints
    the whole map red on any slid recording, and re-arm the per-frame scan)
  · map-health.js keeps main's extrapolated final-measure endTime, and
    regains this branch's beatIdx field (the click-through's Suggest anchor)
  · MAP_HEALTH_BAND_H stays the single constant shared by the paint and the
    hit-test, so the click target cannot drift from the painted wash

Kept main's test file (a superset) and re-added the beatIdx test. All four of
the parent's guard tests pass against the click-through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos added a commit that referenced this pull request Jul 13, 2026
…ix (#251)

* feat(editor): Map Health — per-bar grid-vs-onset drift review lens

The whole "review an automatic map" 2nd pass had no review surface until now
(P2-4). Map Health scores, per measure, how well the grid agrees with the
detected onsets and paints a thin three-state wash under the ruler, so drift is
visible wherever you chart.

New pure src/map-health.js: _mapHealthPure(beats, onsets, opts) → per-measure
{driftFrac, coverage, band} + overall. Per beat resid = |beat.time −
nearestOnset|, reported as a FRACTION of the local beat interval (25ms is
inaudible at 60bpm but half a subdivision on 200-bpm 16ths — colour by fraction,
never raw ms). Per-measure = the MEDIAN driftFrac over EVIDENCED beats (median so
one expressive off-beat note can't drag a bar red) + coverage.

THREE states, the third non-negotiable: green (agrees, <5%), amber (drifting,
5-12%), red (disagrees with PRESENT onsets, >12%), and GREY when there are no
onsets to judge (silence / sustained / held / pedaled) — NEUTRAL, never red.
Colouring an unmeasurable held bar red is crying wolf; the author learns to
ignore red, which is fatal.

Wiring (ruler.js): a ~5px wash under the beat ticks, memoized on editGen + the
onset-cache identity (no per-frame recompute), off by default, a view flag (no
history). Toggle in the Tempo/Grid menu (audio-only). Reads only S.beats +
_ensureOnsets() — so it rides #248's banded onsets once they land, and degrades
gracefully to the RMS detector meanwhile (worse onsets → more grey, never wrong).

Tests: tests/map_health.test.mjs (10) — aligned→green, offset 8%→amber (the
signature fail-on-main case), onsets-removed→grey NOT red, held-bar→grey, median
robustness (one expressive onset doesn't flag the timekeeper bar), >12%→red,
band thresholds, tempo/meter independence, degenerate input. 135 JS green, lint
0-err, routes.py untouched. Live-verified on AC/DC: 105 measures, overall green
@ 88.6% coverage, 68 green / 26 amber / 2 red / 9 grey (not crying wolf), toggle
+ persistence + stable memo, wash renders on the ruler.

FOLLOW-UP (noted): click a hot bar → jump to Tempo Map + offer the G suggest;
LCD pill; distinct-signature reporting (lay-back vs nudge vs ramp); octave
"reads 2×, halve?" one-click. Core review surface ships here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

* feat(editor): Map Health click-through — a hot bar takes you to the fix

Follow-up to #249. "Tempo is wrong here" → "click the red bar, the fix is
waiting." Clicking a DRIFTING (amber/red) bar in the Map Health wash now enters
Tempo Map mode, scrolls the bar into view, anchors Suggest on that bar's
downbeat (S.tempoSel), and tells the user G is waiting — so the fix is one
keypress away. Green/grey bars aren't actionable and fall through to the normal
ruler scrub.

map-health.js: each measure now carries beatIdx (the S.beats downbeat index) so
the click-through can anchor Suggest exactly. ruler.js: _mapHealthBarAt(t) +
_mapHealthClickThrough(t) (enter Tempo Map first — it clears the selection —
THEN set tempoSel), wired into rulerOnMouseDown for clicks on the thin wash at
the ruler's bottom edge.

Tests: map_health.test.mjs gains a beatIdx case (downbeat indices 0/4/8 across
3 bars). 135 JS green, lint 0-err, routes.py untouched. Live-verified on AC/DC:
clicking amber bar 6 enters Tempo Map, sets tempoSel to its downbeat (idx 20),
and posts "Bar 6 drifts 7% … press G"; green + grey bars return false (scrub).

Stacks on #249 (feat/editor-map-health); rebase onto main when it merges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

* fix(editor): Map Health click-through — hit-test the painted wash, not a guess

Review fixes on the click-through:

· The click target now IS the painted rect. MAP_HEALTH_BAND_H is one constant
  shared by _drawMapHealthBand and the mousedown gate (was: paints 5px, clicks
  6px), and the gate demands x >= LABEL_W — the wash clamps its spans to the
  label gutter's right, so a click on the bare gutter was claiming whatever bar
  xToTime() happened to land in and teleporting you there.

· A click-through arriving while ALREADY in Tempo Map now clears tempoSelMulti.
  _editorTempoSuggestFit re-anchors on a live multi-range's first downbeat, which
  outranks tempoSel — so with a range selected, Suggest silently fit from the
  wrong bar while the status line promised the bar you clicked.

· The lead-in scroll is bounded by the viewport. Zoom caps at 2000 px/s, so a
  viewport can be shorter than 0.5s: the flat 0.5s lead pushed the very downbeat
  you clicked off the RIGHT edge.

· The status copy resolves the Suggest key from the command registry instead of
  hardcoding "G" — two shortcut profiles exist. Copy also no longer claims G
  "fits" the barlines; it proposes a fit (a ghost-handle click accepts).

Six regression tests in tests/ruler.test.mjs drive the real onset detector; five
fail on the pre-fix code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
@byrongamatos
byrongamatos deleted the feat/editor-map-health branch July 13, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants