From 727fff497a8d40af1132be4e9705278571837da4 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 14 Jul 2026 11:56:58 -0500 Subject: [PATCH 1/4] Add the grid-health pill to the transport LCD Map Health polish: while the Tempo/Grid Map Health toggle is on, a new Grid LCD cell shows the percent of judgeable bars agreeing with the recording (grey never counts either way; no verdict = dash, never a fake 100%), coloured by the worst band present. Clicking it jumps to the worst drifting bar with the fix armed via the factored _mapHealthGotoMeasure (shared with the wash click-through). Cell hides with the lens off; Customize row can hide it independently. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q --- CHANGELOG.md | 12 ++++++ src/map-health.js | 40 +++++++++++++++++++ src/ruler.js | 8 +++- src/transport-bar.js | 43 ++++++++++++++++++++- tests/count_lcd.test.mjs | 6 ++- tests/map_health_pill.test.mjs | 70 ++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 tests/map_health_pill.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7c4acc..67d88cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Map opens, the bar scrolls into view, and Suggest is anchored on it so pressing **G** proposes a barline fit to the recording from that bar on. (Green and grey bars aren't actionable, so clicking them just scrubs as usual.) + glance whether the automatic tempo map can be trusted. +- **Grid-health readout in the transport display.** While Map Health is on + (the same Tempo/Grid toggle), the transport LCD gains a small **Grid** cell + showing what percent of the song's judgeable bars agree with the recording — + green when everything lines up, amber or red when bars are drifting, and a + neutral dash when there's nothing to judge yet (it never fakes a 100%). + Bars with nothing to measure — silent or sustained — don't count for or + against the score, same as the strip. **Click the percent** and the editor + jumps straight to the worst drifting bar with the fix armed, exactly like + clicking that bar in the strip — handy when the strip itself is scrolled out + of view. Turn Map Health off and the cell tucks itself away; the transport's + Customize row can also hide it independently. - **Audition speed — slow the recording down for practice, pitch preserved.** A new speed control in the transport bar (**100% / 75% / 50%**) plays the reference slower without dropping its pitch, so you can hear a fast run or a diff --git a/src/map-health.js b/src/map-health.js index 21d47232..3ac9f44d 100644 --- a/src/map-health.js +++ b/src/map-health.js @@ -147,4 +147,44 @@ export const MAP_HEALTH_COLORS = { red: '#ef4444', grey: '#64748b', }; + +// The LCD pill verdict: what fraction of the JUDGEABLE measures agree with +// the recording, coloured by the worst state present. Grey (nothing to judge) +// measures don't count either way — a sustained bridge can't lower the score, +// same no-crying-wolf rule as the wash. Null = no verdict (no judgeable bars +// at all), so the pill shows a neutral dash instead of a fake 100%. +export function _mapHealthPillPure(result) { + const ms = result && Array.isArray(result.measures) ? result.measures : []; + let green = 0, amber = 0, red = 0; + for (const m of ms) { + if (m.band === 'green') green++; + else if (m.band === 'amber') amber++; + else if (m.band === 'red') red++; + } + const judged = green + amber + red; + if (!judged) return null; + return { + pct: Math.round((100 * green) / judged), + band: red ? 'red' : amber ? 'amber' : 'green', + judged, + }; +} + +// The measure a "take me to the worst spot" click should land on: any red +// beats any amber; within a band, the largest drift wins. Null when nothing +// is drifting (the pill click then has nothing to fix — say so, don't jump). +export function _mapHealthWorstPure(result) { + const ms = result && Array.isArray(result.measures) ? result.measures : []; + const rank = (b) => (b === 'red' ? 2 : b === 'amber' ? 1 : 0); + let worst = null; + for (const m of ms) { + if (!rank(m.band)) continue; + if (!worst + || rank(m.band) > rank(worst.band) + || (rank(m.band) === rank(worst.band) && (m.driftFrac || 0) > (worst.driftFrac || 0))) { + worst = m; + } + } + return worst; +} /* @pure:map-health:end */ diff --git a/src/ruler.js b/src/ruler.js index 2c211306..bf3c405e 100644 --- a/src/ruler.js +++ b/src/ruler.js @@ -441,6 +441,13 @@ export function _mapHealthBarAt(t) { export function _mapHealthClickThrough(t) { const m = _mapHealthBarAt(t); if (!m || (m.band !== 'red' && m.band !== 'amber')) return false; + _mapHealthGotoMeasure(m); + return true; +} + +// The shared "take me to the fix" motion — used by the wash click-through +// above and the transport LCD grid pill (which works even with the wash off). +export function _mapHealthGotoMeasure(m) { // Enter Tempo Map FIRST (it clears the selection), THEN anchor Suggest on // this bar's downbeat so the fit marches from exactly the bar that's drifting. if (!S.tempoMapMode) _editorToggleTempoMapMode(); @@ -463,7 +470,6 @@ export function _mapHealthClickThrough(t) { const cmd = _editorCommandById('tempoSuggestFit'); const key = ((cmd && cmd.keys && cmd.keys[editorShortcutProfile]) || 'G').split(' (')[0]; setStatus(`Bar ${m.measure} drifts ${pct}% from the recording — Tempo Map opened; press ${key} to fit the barlines from here.`); - return true; } export function rulerOnMouseDown(e, x, y, w) { diff --git a/src/transport-bar.js b/src/transport-bar.js index e532056d..14d815d0 100644 --- a/src/transport-bar.js +++ b/src/transport-bar.js @@ -43,6 +43,8 @@ */ import { beatOf, timeOf } from './beats.js'; +import { MAP_HEALTH_COLORS, _mapHealthPillPure, _mapHealthWorstPure } from './map-health.js'; +import { _mapHealthEnabled, _mapHealthGotoMeasure, _mapHealthResults } from './ruler.js'; import { S } from './state.js'; import { host } from './host.js'; import { setStatus } from './ui.js'; @@ -209,7 +211,7 @@ export function _countRememberedPure(next, current) { // garbage into the DOM builders. Unknown keys are dropped. A pref blob saved // before a cell existed (e.g. `countin`) leaves that cell at its default — // visible — rather than dropping it. -export const TRANSPORT_LCD_CELLS = ['position', 'time', 'tempo', 'meter', 'key', 'countin', 'sel', 'mode']; +export const TRANSPORT_LCD_CELLS = ['position', 'time', 'tempo', 'meter', 'key', 'countin', 'grid', 'sel', 'mode']; export function _transportPrefsPure(raw) { const p = { primary: 'position', @@ -307,6 +309,11 @@ function buildLcd(mode) { ``, 'Count-in: bars of metronome clicks before playback (and recording) starts — writes through to the toolbar Count control')); } + if (c.grid) { + parts.push(lcdCell('grid', 'Grid', + ``)); + } if (c.sel) parts.push(lcdCell('sel', 'Sel', ``, 'Selected notes')); if (c.mode) parts.push(lcdCell('mode', 'Mode', ``, mode.title)); @@ -361,7 +368,7 @@ function buildMenu() { if (!menu) return; const row = (kind, key, label, checked) => ``; - const cellLabels = { position: 'Position', time: 'Time', tempo: 'Tempo', meter: 'Meter', key: 'Key', countin: 'Count-in', sel: 'Selection', mode: 'Mode badge' }; + const cellLabels = { position: 'Position', time: 'Time', tempo: 'Tempo', meter: 'Meter', key: 'Key', countin: 'Count-in', grid: 'Grid health', sel: 'Selection', mode: 'Mode badge' }; menu.innerHTML = `
Customize Control Bar
` + row('group', 'util', 'Tracks / Mix / Follow group', prefs.groups.util) + row('group', 'modes', 'Click / Clap / A/B / Count / Snap group', prefs.groups.modes) @@ -465,6 +472,16 @@ function wireBar(bar) { on('editor-tp-clap', () => { if (typeof window.editorToggleGuideClap === 'function') window.editorToggleGuideClap(); _transportBarTick(true); }); on('editor-tp-ab', () => { if (typeof window.editorToggleLoopAB === 'function') window.editorToggleLoopAB(); _transportBarTick(true); }); on('editor-tp-trainer', () => { if (typeof window.editorToggleAuditionTrainer === 'function') window.editorToggleAuditionTrainer(); _transportBarTick(true); }); + // Grid-health pill: jump to the worst drifting bar with the fix armed + // (the same motion as clicking a hot bar in the Map Health wash — works + // even while the wash itself is toggled off). Nothing drifting? Say so. + on('editor-lcd-grid', () => { + if (!_mapHealthEnabled()) return; // cell is hidden with the lens off; belt and braces + if (!S.audioBuffer) { setStatus('Grid health needs a recording to judge against.'); return; } + const worst = _mapHealthWorstPure(_mapHealthResults()); + if (worst) _mapHealthGotoMeasure(worst); + else setStatus('Grid health: every judgeable bar agrees with the recording — nothing to fix.'); + }); on('editor-tp-count', () => { const cur = editorCountInBars(); let last = null; @@ -580,6 +597,28 @@ export function _transportBarTick(force) { const selN = S.drumEditMode ? S.drumSel.size : S.sel.size; _set(document.getElementById('editor-lcd-sel'), String(selN)); + // Grid-health pill: percent of judgeable bars that agree with the + // recording, coloured by the worst state present. The pill RIDES the + // Tempo/Grid ▸ Map Health toggle (one switch = the whole lens: ruler wash + // + this pill) — its LCD cell hides entirely while the lens is off. + // _mapHealthResults() is memoized on editGen + the onset cache, so a + // visible pill costs a map lookup per tick — the O(bars × beats) scan + // only reruns after an actual edit. No audio (or nothing judgeable yet) + // shows a neutral dash, never a fake 100%. + const gridEl = document.getElementById('editor-lcd-grid'); + if (gridEl) { + const lensOn = _mapHealthEnabled(); + const cell = gridEl.closest('.editor-lcd-cell'); + if (cell) cell.classList.toggle('hidden', !lensOn); + if (lensOn) { + const pill = S.audioBuffer ? _mapHealthPillPure(_mapHealthResults()) : null; + const txt = pill ? `${pill.pct}%` : '—'; + if (gridEl.textContent !== txt) gridEl.textContent = txt; + const color = pill ? MAP_HEALTH_COLORS[pill.band] : ''; + if (gridEl.style.color !== color) gridEl.style.color = color; + } + } + const modeEl = document.getElementById('editor-lcd-mode'); if (modeEl) { _set(modeEl, mode.short); if (modeEl.title !== mode.title) modeEl.title = mode.title; } diff --git a/tests/count_lcd.test.mjs b/tests/count_lcd.test.mjs index 124dc542..2e0f57e5 100644 --- a/tests/count_lcd.test.mjs +++ b/tests/count_lcd.test.mjs @@ -28,9 +28,11 @@ function t(name, fn) { catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } } -t('countin is an LCD cell, in the charrette order (… Key · Count-in · Sel · Mode)', () => { +t('countin is an LCD cell, in the charrette order (… Key · Count-in · Grid · Sel · Mode)', () => { + // Deliberate pin update: the Map Health grid pill joined the LCD between + // Count-in and Sel. assert.deepStrictEqual(TRANSPORT_LCD_CELLS, - ['position', 'time', 'tempo', 'meter', 'key', 'countin', 'sel', 'mode']); + ['position', 'time', 'tempo', 'meter', 'key', 'countin', 'grid', 'sel', 'mode']); }); t('countin defaults visible, including under a pref blob saved before it existed', () => { diff --git a/tests/map_health_pill.test.mjs b/tests/map_health_pill.test.mjs new file mode 100644 index 00000000..b3fbf7b7 --- /dev/null +++ b/tests/map_health_pill.test.mjs @@ -0,0 +1,70 @@ +/* + * The transport-LCD grid-health pill (Map Health follow-up): a glanceable + * "how much of the grid agrees with the recording" percent, coloured by the + * worst state present, with click-to-fix jumping to the worst drifting bar. + * + * Pinned here: the pill percent counts only JUDGEABLE bars (grey never dilutes + * or inflates the score — the wash's no-crying-wolf rule), the colour is the + * worst band present, an unjudgeable song yields NO verdict (dash, never a + * fake 100%), and the worst-bar picker ranks any red over any amber with + * drift as the tiebreak. Fails on main (the pures don't exist there). + * + * Run: node tests/map_health_pill.test.mjs + */ +import assert from 'node:assert'; + +globalThis.document = globalThis.document || { + getElementById: () => null, addEventListener: () => {}, activeElement: null, +}; +globalThis.localStorage = globalThis.localStorage || { getItem: () => null, setItem: () => {} }; +globalThis.window = globalThis.window || globalThis; + +const { _mapHealthPillPure, _mapHealthWorstPure } = await import('../src/map-health.js'); + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +const M = (band, driftFrac = 0, measure = 1) => ({ band, driftFrac, measure, startTime: measure * 2 }); + +t('the percent counts judgeable bars only — grey neither dilutes nor inflates', () => { + // 8 green + 2 amber + 10 grey: 80% of the JUDGEABLE bars agree. + const measures = [ + ...Array.from({ length: 8 }, () => M('green')), + M('amber', 0.08), M('amber', 0.06), + ...Array.from({ length: 10 }, () => M('grey')), + ]; + const pill = _mapHealthPillPure({ measures }); + assert.strictEqual(pill.pct, 80); + assert.strictEqual(pill.judged, 10); +}); + +t('the colour is the worst state present', () => { + assert.strictEqual(_mapHealthPillPure({ measures: [M('green'), M('green')] }).band, 'green'); + assert.strictEqual(_mapHealthPillPure({ measures: [M('green'), M('amber')] }).band, 'amber'); + assert.strictEqual(_mapHealthPillPure({ measures: [M('green'), M('amber'), M('red', 0.2)] }).band, 'red'); +}); + +t('nothing judgeable = no verdict, never a fake 100%', () => { + assert.strictEqual(_mapHealthPillPure({ measures: [M('grey'), M('grey')] }), null); + assert.strictEqual(_mapHealthPillPure({ measures: [] }), null); + assert.strictEqual(_mapHealthPillPure(null), null); +}); + +t('the worst bar: any red beats any amber; within a band the biggest drift wins', () => { + const measures = [ + M('green', 0, 1), M('amber', 0.11, 2), M('amber', 0.08, 3), + M('red', 0.13, 4), M('red', 0.30, 5), M('grey', 0, 6), + ]; + const worst = _mapHealthWorstPure({ measures }); + assert.strictEqual(worst.measure, 5, 'the reddest red'); + const amberOnly = _mapHealthWorstPure({ measures: measures.slice(0, 3) }); + assert.strictEqual(amberOnly.measure, 2, 'no red → the worst amber'); + assert.strictEqual(_mapHealthWorstPure({ measures: [M('green'), M('grey')] }), null, + 'nothing drifting → null (the click says so instead of jumping)'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); From b3cbb5d077bfe9b453d2e20f96ad700b84c74a9a Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 20:02:33 +0200 Subject: [PATCH 2/4] Guard the pill's colour write on the band, not on style.color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gridEl.style.color !== color` never matched: the DOM normalizes an assigned '#ef4444' back to 'rgb(239, 68, 68)', so the skip-if-unchanged guard was always true and the inline style got re-written on every transport tick — the exact per-frame churn the _set() guards elsewhere in the tick exist to avoid. Compare the band string instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- node_modules | 1 + src/transport-bar.js | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..c5cf027d --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/byron/Repositories/feedback-plugin-editor/node_modules \ No newline at end of file diff --git a/src/transport-bar.js b/src/transport-bar.js index 14d815d0..17bb1d17 100644 --- a/src/transport-bar.js +++ b/src/transport-bar.js @@ -614,8 +614,15 @@ export function _transportBarTick(force) { const pill = S.audioBuffer ? _mapHealthPillPure(_mapHealthResults()) : null; const txt = pill ? `${pill.pct}%` : '—'; if (gridEl.textContent !== txt) gridEl.textContent = txt; - const color = pill ? MAP_HEALTH_COLORS[pill.band] : ''; - if (gridEl.style.color !== color) gridEl.style.color = color; + // Guard the colour write on the BAND, not on style.color: the DOM + // normalizes an assigned '#ef4444' back to 'rgb(239, 68, 68)', so a + // hex compare NEVER matches and would re-write the inline style on + // every tick — exactly the per-frame churn the _set() guards avoid. + const band = pill ? pill.band : ''; + if (gridEl.dataset.band !== band) { + gridEl.dataset.band = band; + gridEl.style.color = band ? MAP_HEALTH_COLORS[band] : ''; + } } } From 3afbf742be472c5ea7b70c95af5123fd2c79cb3a Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 20:03:11 +0200 Subject: [PATCH 3/4] Grid pill click: don't claim the grid "agrees" when there is no verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _mapHealthWorstPure() returns null for two different reasons — nothing is drifting, or nothing is judgeable at all. The pill already tells them apart (a dash, never a fake 100%); the click's status line did not, and told a nothing-to-judge song that every bar agrees with the recording. Split the two on the pill verdict. (CodeRabbit) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/transport-bar.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/transport-bar.js b/src/transport-bar.js index 17bb1d17..354137ee 100644 --- a/src/transport-bar.js +++ b/src/transport-bar.js @@ -478,8 +478,12 @@ function wireBar(bar) { on('editor-lcd-grid', () => { if (!_mapHealthEnabled()) return; // cell is hidden with the lens off; belt and braces if (!S.audioBuffer) { setStatus('Grid health needs a recording to judge against.'); return; } - const worst = _mapHealthWorstPure(_mapHealthResults()); + const res = _mapHealthResults(); + const worst = _mapHealthWorstPure(res); if (worst) _mapHealthGotoMeasure(worst); + // No worst bar has TWO causes, and the pill already tells them apart: + // a dash (no judgeable bar at all) must not claim the grid "agrees". + else if (!_mapHealthPillPure(res)) setStatus('Grid health: nothing judgeable in this recording yet — no bars to fix.'); else setStatus('Grid health: every judgeable bar agrees with the recording — nothing to fix.'); }); on('editor-tp-count', () => { From f3338332f19fa2cc8724d2c7ed889df235f5c5f9 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 20:21:59 +0200 Subject: [PATCH 4/4] chore: untrack the node_modules symlink An agent worktree symlinked node_modules; .gitignore only lists node_modules/ (trailing slash), which matches a directory but not a symlink, so git add -A tracked it. The symlink pointed at a local absolute path and would break any other checkout. --- node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index c5cf027d..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/byron/Repositories/feedback-plugin-editor/node_modules \ No newline at end of file