From c5a271082bc9bf0958a599c92794d78d41b4cf0e Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 14 Jul 2026 06:56:05 -0500 Subject: [PATCH 1/3] Add Tempo/Grid heal for degenerate interior beat spacing Field projects carry measures whose sub-beats pile milliseconds apart beside a seconds-wide hole (hand re-syncs / old imports). New pures scan (gap <30% or >300% of even spacing) and re-space sick measures' interiors evenly; downbeats never move; one TempoGridCmd so notes keep seconds. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q --- CHANGELOG.md | 20 +++++++++ src/main.js | 3 +- src/menu-bar.js | 1 + src/tempo.js | 87 ++++++++++++++++++++++++++++++++++++ tests/grid_heal.test.mjs | 96 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/grid_heal.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 503111cc..ca193510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Ctrl+Z restores the previous grid. **Single tempo instead** is the escape hatch when a steady song over-segments — one uniform grid at the zones' duration-weighted tempo. +- **Heal uneven beat spacing.** Hand re-syncs and old imports can leave a + measure's *interior* beats in a pathological shape — sub-beats piled a few + milliseconds apart next to a seconds-wide hole — which garbles the metronome, + snapping, and every per-beat view, even though the barlines themselves are + right. A new **Tempo/Grid ▸ Heal uneven beat spacing** action finds those + measures (any beat gap under 30% or over 300% of the measure's even spacing) + and re-spaces their interior beats evenly between the barlines. **Barlines + never move** — they're your authored truth; the beats between them are + bookkeeping — and **notes keep their exact timing** against the recording. + One undoable step; if the grid is healthy it says so and touches nothing. +- **Scan for tempo zones (preview).** A new **Tempo/Grid ▸ Scan for tempo zones** + action reads the recording and reports the handful of *tempo intents* it finds + — e.g. "3 tempo zones detected: 120 bpm · 140 bpm · rit 140→90". It's the first + step of segment-first mapping: instead of guessing one tempo for the whole song + or laying a shaky barline on every beat, it proposes a few constant/ramp zones + the way a musician would describe the arrangement. This preview only *reports* + what it finds — turning the zones into a grid (Confirm & Apply) is coming next. + Under the hood it locates the pulse by autocorrelating the detected onsets with + an octave guard + tempo prior (so it doesn't read double-time or half-time), + and it gets sharper once the banded onset detection lands. - **Apply a rough map from the detected tempo zones.** After Scan shows the zones, **Tempo/Grid ▸ Apply rough map** turns them into an actual beat grid — a barline grid at each zone's tempo, with its downbeat phase seeded from the diff --git a/src/main.js b/src/main.js index 27669df3..75273471 100644 --- a/src/main.js +++ b/src/main.js @@ -130,7 +130,7 @@ import { _tempoMeasureDenominator, _tempoMeasures, _tempoNormalizeDenominatorPure, _tempoSetBeatsPerMeasure, _tempoSetDenominatorOnBeatsPure, _tempoSetMeasureBpmPure, editorScanTempoZones, editorApplyTempoZones, - editorConfirmTempoZones, editorZonesSingleTempo + editorConfirmTempoZones, editorZonesSingleTempo, editorHealGrid } from './tempo.js'; import { initTempoZones } from './tempo-zones.js'; import { @@ -561,6 +561,7 @@ window.editorApplyReplaceAudio = editorApplyReplaceAudio; window.editorSyncTempo = editorSyncTempo; window.editorScanTempoZones = () => editorScanTempoZones(); window.editorApplyTempoZones = () => editorApplyTempoZones(); +window.editorHealGrid = () => editorHealGrid(); window.editorToggleMapHealth = (force) => editorToggleMapHealth(force); window.editorSyncUpdateFactor = editorSyncUpdateFactor; window.editorHideSyncDialog = editorHideSyncDialog; diff --git a/src/menu-bar.js b/src/menu-bar.js index 5b22a5d9..deea404c 100644 --- a/src/menu-bar.js +++ b/src/menu-bar.js @@ -232,6 +232,7 @@ export const EDITOR_MENUS = Object.freeze([ { label: 'Scan for tempo zones…', fn: 'editorScanTempoZones', audioOnly: true }, { label: 'Apply rough map from tempo zones', fn: 'editorApplyTempoZones', audioOnly: true }, { label: 'Map Health (grid-vs-recording drift)', fn: 'editorToggleMapHealth', audioOnly: true }, + { label: 'Heal uneven beat spacing', fn: 'editorHealGrid' }, { sep: true }, { hdr: 'Snap' }, { cmd: 'toggleSnap' }, diff --git a/src/tempo.js b/src/tempo.js index 72dce6d2..aca484db 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -1010,6 +1010,93 @@ export function _tempoBeatDragBoundsPure(beats, d, minGap, duration) { } /* @pure:tempo-beat-drag:end */ +/* @pure:grid-heal:start */ +// ── Heal uneven beat spacing (the degenerate-bar repair) ───────────────────── +// Real projects accumulate pathological INTERIOR beat spacing — hand re-syncs +// and old imports leave sub-beats piled milliseconds apart next to a +// seconds-wide hole inside one measure (seen in the field: 5 ms gaps beside a +// 2 s gap), which garbles the click, snapping, and every per-beat view. The +// heal re-spaces a sick measure's interior beats EVENLY between its two +// downbeats. Downbeats are NEVER moved — barlines are the charter's authored +// truth; the beats between them are bookkeeping. + +// A measure is sick when any interior gap is under `minFrac` (default 30%) or +// over `maxFrac` (default 300%) of its even spacing. Returns the measure +// numbers that need healing (empty = grid is fine). +export function _gridHealScanPure(beats, opts) { + const o = opts || {}; + const minFrac = Number.isFinite(o.minFrac) ? o.minFrac : 0.3; + const maxFrac = Number.isFinite(o.maxFrac) ? o.maxFrac : 3; + const out = []; + if (!Array.isArray(beats) || beats.length < 2) return out; + const dbs = []; + for (let i = 0; i < beats.length; i++) if (beats[i].measure > 0) dbs.push(i); + for (let s = 0; s + 1 < dbs.length; s++) { + const a = dbs[s], b = dbs[s + 1]; + if (b - a < 2) continue; // no interior beats to judge + const span = beats[b].time - beats[a].time; + if (!(span > 0)) continue; + const even = span / (b - a); + for (let k = a; k < b; k++) { + const gap = beats[k + 1].time - beats[k].time; + if (gap < even * minFrac || gap > even * maxFrac) { + out.push(beats[a].measure); + break; + } + } + } + return out; +} + +// Re-space every sick measure's interior beats evenly between its (unmoved) +// downbeats. Equal-length output, strictly increasing, downbeat times and all +// non-time fields untouched — shaped for a TempoGridCmd, so notes keep their +// SECONDS and re-lift their beat positions from the healed grid (the notes +// were synced to the audio; the grid was the sick part). +export function _gridHealPure(beats, opts) { + const sick = new Set(_gridHealScanPure(beats, opts)); + if (!sick.size) return null; + const out = beats.map(b => ({ ...b })); + const dbs = []; + for (let i = 0; i < out.length; i++) if (out[i].measure > 0) dbs.push(i); + for (let s = 0; s + 1 < dbs.length; s++) { + const a = dbs[s], b = dbs[s + 1]; + if (!sick.has(out[a].measure) || b - a < 2) continue; + const span = out[b].time - out[a].time; + for (let k = a + 1; k < b; k++) { + out[k].time = _r3(out[a].time + (span * (k - a)) / (b - a)); + } + } + return out; +} +/* @pure:grid-heal:end */ + +// The menu verb: scan, heal, commit as ONE undoable TempoGridCmd. Notes keep +// their exact seconds (they were synced to the recording — the grid was the +// sick part); Undo restores the old spacing bit-exact. +export function editorHealGrid() { + if (!S.beats || S.beats.length < 2) { + setStatus('No beat grid on this song — nothing to heal.'); + return true; + } + if (!S.sessionId || !S.history) { + setStatus('Healing the grid needs a song open — create or open one first.'); + return true; + } + const healed = _gridHealPure(S.beats); + if (!healed) { + setStatus('Beat spacing looks healthy — nothing to heal.'); + return true; + } + const n = _gridHealScanPure(S.beats).length; + S.history.exec(new TempoGridCmd(S.beats, healed, 'heal uneven beats', S.tempoSel, S.tempoSel)); + host.draw(); + host.updateStatus(); + setStatus(`Healed uneven beat spacing in ${n} measure${n === 1 ? '' : 's'} — barlines untouched, ` + + 'notes kept their timing; Undo restores.'); + return true; +} + // Per-beat rubato drag — the intra-bar counterpart of the pole drag. // Rebuilds from the original grid each move (no compounding) and lets // _tempoApplyDrag re-space the neighbours proportionally around the diff --git a/tests/grid_heal.test.mjs b/tests/grid_heal.test.mjs new file mode 100644 index 00000000..ff325668 --- /dev/null +++ b/tests/grid_heal.test.mjs @@ -0,0 +1,96 @@ +/* + * Heal uneven beat spacing (the degenerate-bar repair). Motivated by a field + * project whose grid carried interior sub-beats piled 5 ms apart next to a 2 s + * hole inside single measures — hand re-syncs and old imports both leave this + * shape, and it garbles the click, snapping, and every per-beat view. + * + * Pinned: the scan flags a measure when any interior gap falls under 30% or + * over 300% of its even spacing; the heal re-spaces ONLY sick measures' + * interiors evenly between their downbeats; downbeats never move; non-time + * fields survive; equal-length, strictly-increasing output (the TempoGridCmd + * contract — notes keep their seconds and re-lift from the healed grid). + * + * Every case fails on pre-fix main (the pures don't exist). + * Run: node tests/grid_heal.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 { _gridHealScanPure, _gridHealPure } = await import('../src/tempo.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 D = (time, measure) => ({ time, measure, den: 4 }); +const I = (time) => ({ time, measure: -1 }); + +// The field shape: measure 49's interiors pile at 5 ms right before the next +// downbeat, leaving a 2 s hole after the downbeat (real numbers from the +// reporting project). Measures 48 and 50 are healthy 4/4 at ~117 BPM. +function fieldGrid() { + return [ + D(96.0, 48), I(96.51), I(97.02), I(97.53), + D(98.04, 49), I(98.364), I(98.369), I(98.374), // pile-up: 5 ms gaps + D(100.467, 50), I(100.98), I(101.49), I(102.0), + D(102.51, 51), + ]; +} + +t('the scan flags the pile-up measure and only it', () => { + assert.deepStrictEqual(_gridHealScanPure(fieldGrid()), [49]); +}); + +t('a healthy grid scans clean and heals to null', () => { + const g = [D(0, 1), I(0.5), I(1.0), I(1.5), D(2.0, 2), I(2.5), I(3.0), I(3.5), D(4.0, 3)]; + assert.deepStrictEqual(_gridHealScanPure(g), []); + assert.strictEqual(_gridHealPure(g), null); +}); + +t('healing re-spaces ONLY the sick measure, evenly, downbeats untouched', () => { + const g = fieldGrid(); + const healed = _gridHealPure(g); + assert.strictEqual(healed.length, g.length, 'equal count — the command contract'); + // Downbeats exactly where they were. + for (let i = 0; i < g.length; i++) { + if (g[i].measure > 0) assert.strictEqual(healed[i].time, g[i].time, `downbeat m${g[i].measure} unmoved`); + } + // The sick measure's interiors now sit at even quarters of its span. + const span = 100.467 - 98.04; + assert.strictEqual(healed[5].time, Math.round((98.04 + span / 4) * 1000) / 1000); + assert.strictEqual(healed[6].time, Math.round((98.04 + span / 2) * 1000) / 1000); + assert.strictEqual(healed[7].time, Math.round((98.04 + (3 * span) / 4) * 1000) / 1000); + // Healthy neighbours untouched. + assert.strictEqual(healed[1].time, 96.51); + assert.strictEqual(healed[9].time, 100.98); + // Strictly increasing throughout. + for (let i = 1; i < healed.length; i++) assert.ok(healed[i].time > healed[i - 1].time); +}); + +t('a lone oversized hole flags too, even with no tiny gap beside it', () => { + // Interiors bunched early: gaps of 0.32 (above the 30% floor, so they do + // not trip the minimum) then a 3.04 s hole — 3.04× the even 1.0 spacing, + // over the 300% ceiling. The HOLE alone flags the measure. + const g = [D(0, 1), I(0.32), I(0.64), I(0.96), D(4.0, 2), I(5.0), I(6.0), I(7.0), D(8.0, 3)]; + assert.deepStrictEqual(_gridHealScanPure(g), [1]); +}); + +t('non-time fields ride through the heal untouched', () => { + const g = fieldGrid(); + g[5].locked = true; // even a (nonsensical) interior flag survives + const healed = _gridHealPure(g); + assert.strictEqual(healed[5].locked, true); + assert.strictEqual(healed[4].den, 4); + assert.strictEqual(healed[4].measure, 49); + assert.deepStrictEqual(g[5], { time: 98.364, measure: -1, locked: true }, 'input not mutated'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); From e4d1fb890005c1a93910984efa239702244fe669 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 20:02:58 +0200 Subject: [PATCH 2/3] Fix grid heal emitting duplicate beat times; name the healed measures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes in the heal, both on the data-loss side: - A measure whose even spacing lands under the grid's millisecond resolution was re-spaced with _r3, collapsing two beats onto the SAME time — a duplicate-time grid is worse than the sickness it replaced (beatOf/timeOf stop being inverses across a zero-width gap). Reachable in exactly the pathological grids this action targets. The scan now skips those measures: corruption past what an even re-space can fix. - The scan cannot tell a corrupt pile-up from a deliberate grand pause held ~9x its neighbours — both are a wildly uneven interior gap, and the heal flattens either. Undo restores, but only if the charter can SEE which bars moved, so the status line now names them instead of only counting them. Also: check the session before the grid (the row is ungated, so "no song open" must say so, not blame a grid that cannot exist yet) and scan once instead of twice. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +++- node_modules | 1 + src/tempo.js | 32 ++++++++++++++++++++++---------- tests/grid_heal.test.mjs | 21 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) create mode 120000 node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index ca193510..51b96177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,7 +106,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and re-spaces their interior beats evenly between the barlines. **Barlines never move** — they're your authored truth; the beats between them are bookkeeping — and **notes keep their exact timing** against the recording. - One undoable step; if the grid is healthy it says so and touches nothing. + One undoable step, and it **names the measures it healed** so a bar you meant + to be wildly uneven (a held grand pause reads the same as a corrupt gap) is + one Ctrl+Z away; if the grid is healthy it says so and touches nothing. - **Scan for tempo zones (preview).** A new **Tempo/Grid ▸ Scan for tempo zones** action reads the recording and reports the handful of *tempo intents* it finds — e.g. "3 tempo zones detected: 120 bpm · 140 bpm · rit 140→90". It's the first 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/tempo.js b/src/tempo.js index aca484db..db33d6f9 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -1035,8 +1035,13 @@ export function _gridHealScanPure(beats, opts) { const a = dbs[s], b = dbs[s + 1]; if (b - a < 2) continue; // no interior beats to judge const span = beats[b].time - beats[a].time; - if (!(span > 0)) continue; const even = span / (b - a); + // Below the grid's millisecond resolution a re-space cannot land its + // beats on DISTINCT times (_r3 collapses them), so healing such a + // measure would emit a duplicate-time grid — worse than the sickness. + // That is corruption past what an even re-space can fix: leave it. + // NaN/zero/negative spans fall out here too. + if (!(even > 0.001)) continue; for (let k = a; k < b; k++) { const gap = beats[k + 1].time - beats[k].time; if (gap < even * minFrac || gap > even * maxFrac) { @@ -1075,25 +1080,32 @@ export function _gridHealPure(beats, opts) { // their exact seconds (they were synced to the recording — the grid was the // sick part); Undo restores the old spacing bit-exact. export function editorHealGrid() { - if (!S.beats || S.beats.length < 2) { - setStatus('No beat grid on this song — nothing to heal.'); - return true; - } + // Session before grid — the menu row is ungated (healing needs no recording), + // so "no song open" must say so rather than blame a grid that cannot exist yet. if (!S.sessionId || !S.history) { setStatus('Healing the grid needs a song open — create or open one first.'); return true; } - const healed = _gridHealPure(S.beats); - if (!healed) { + if (!S.beats || S.beats.length < 2) { + setStatus('No beat grid on this song — nothing to heal.'); + return true; + } + const sick = _gridHealScanPure(S.beats); + if (!sick.length) { setStatus('Beat spacing looks healthy — nothing to heal.'); return true; } - const n = _gridHealScanPure(S.beats).length; + const healed = _gridHealPure(S.beats); S.history.exec(new TempoGridCmd(S.beats, healed, 'heal uneven beats', S.tempoSel, S.tempoSel)); host.draw(); host.updateStatus(); - setStatus(`Healed uneven beat spacing in ${n} measure${n === 1 ? '' : 's'} — barlines untouched, ` - + 'notes kept their timing; Undo restores.'); + // NAME the measures, don't just count them. The scan cannot tell a corrupt + // pile-up from a deliberate grand pause held ~9× its neighbours (both are a + // wildly uneven interior gap), and this flattens either. Undo restores, but + // only if the charter can SEE which bars moved — so list them. + const shown = sick.slice(0, 8).join(', ') + (sick.length > 8 ? `, +${sick.length - 8} more` : ''); + setStatus(`Healed uneven beat spacing in ${sick.length} measure${sick.length === 1 ? '' : 's'} ` + + `(${shown}) — barlines untouched, notes kept their timing; Undo restores.`); return true; } diff --git a/tests/grid_heal.test.mjs b/tests/grid_heal.test.mjs index ff325668..756db677 100644 --- a/tests/grid_heal.test.mjs +++ b/tests/grid_heal.test.mjs @@ -82,6 +82,27 @@ t('a lone oversized hole flags too, even with no tiny gap beside it', () => { assert.deepStrictEqual(_gridHealScanPure(g), [1]); }); +t('a sub-millisecond measure is left alone, never healed into duplicate times', () => { + // Downbeats 2 ms apart with 3 interior beats: the even spacing is 0.5 ms, so a + // re-space rounded to the grid's millisecond resolution would land two beats on + // the SAME time — a duplicate-time grid is worse than the sickness it replaces + // (beatOf/timeOf stop being inverses across a zero-width gap). Corruption past + // what an even re-space can fix: the scan must skip it. + const g = [ + D(10.0, 1), I(10.0001), I(10.0002), I(10.0019), + D(10.002, 2), I(10.5), I(11.0), I(11.5), D(12.0, 3), + ]; + assert.deepStrictEqual(_gridHealScanPure(g), [], 'sub-ms measure is not healable'); + assert.strictEqual(_gridHealPure(g), null); +}); + +t('the healed grid is always strictly increasing', () => { + const healed = _gridHealPure(fieldGrid()); + for (let i = 1; i < healed.length; i++) { + assert.ok(healed[i].time > healed[i - 1].time, `beat ${i} must advance`); + } +}); + t('non-time fields ride through the heal untouched', () => { const g = fieldGrid(); g[5].locked = true; // even a (nonsensical) interior flag survives From 49e9be6de4c7e613c5a1c3f534f798bb023d9225 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 20:34:41 +0200 Subject: [PATCH 3/3] chore: untrack the node_modules symlink An agent worktree symlinked node_modules; .gitignore lists only node_modules/ (trailing slash), which matches a directory but not a symlink, so git add -A tracked it. The symlink points 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