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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/map-health.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
8 changes: 7 additions & 1 deletion src/ruler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
54 changes: 52 additions & 2 deletions src/transport-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -307,6 +309,11 @@ function buildLcd(mode) {
`<select id="editor-lcd-countin" class="editor-lcd-select" aria-label="Count-in bars">${copts}</select>`,
'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',
`<button id="editor-lcd-grid" class="editor-lcd-badge" style="cursor:pointer"`
+ ` title="How much of the beat grid agrees with the recording (Map Health). Click to jump to the worst bar with the fix armed.">—</button>`));
}
if (c.sel) parts.push(lcdCell('sel', 'Sel', `<span id="editor-lcd-sel"></span>`, 'Selected notes'));
if (c.mode) parts.push(lcdCell('mode', 'Mode',
`<span id="editor-lcd-mode" class="editor-lcd-badge"></span>`, mode.title));
Expand Down Expand Up @@ -361,7 +368,7 @@ function buildMenu() {
if (!menu) return;
const row = (kind, key, label, checked) =>
`<label class="editor-transport-menu-row"><input type="checkbox" data-kind="${kind}" data-key="${key}"${checked ? ' checked' : ''}> ${esc(label)}</label>`;
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 = `<div class="editor-transport-menu-head">Customize Control Bar</div>`
+ row('group', 'util', 'Tracks / Mix / Follow group', prefs.groups.util)
+ row('group', 'modes', 'Click / Clap / A/B / Count / Snap group', prefs.groups.modes)
Expand Down Expand Up @@ -465,6 +472,20 @@ 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 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.');
});
Comment thread
byrongamatos marked this conversation as resolved.
on('editor-tp-count', () => {
const cur = editorCountInBars();
let last = null;
Expand Down Expand Up @@ -580,6 +601,35 @@ 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;
// 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] : '';
}
}
}

const modeEl = document.getElementById('editor-lcd-mode');
if (modeEl) { _set(modeEl, mode.short); if (modeEl.title !== mode.title) modeEl.title = mode.title; }

Expand Down
6 changes: 4 additions & 2 deletions tests/count_lcd.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
70 changes: 70 additions & 0 deletions tests/map_health_pill.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading