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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@ 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, 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
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
Expand Down
3 changes: 2 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
_tempoMeasureDenominator, _tempoMeasures, _tempoNormalizeDenominatorPure,
_tempoSetBeatsPerMeasure, _tempoSetDenominatorOnBeatsPure,
_tempoSetMeasureBpmPure, editorScanTempoZones, editorApplyTempoZones,
editorConfirmTempoZones, editorZonesSingleTempo
editorConfirmTempoZones, editorZonesSingleTempo, editorHealGrid
} from './tempo.js';
import { initTempoZones } from './tempo-zones.js';
import {
Expand Down Expand Up @@ -561,6 +561,7 @@
window.editorSyncTempo = editorSyncTempo;
window.editorScanTempoZones = () => editorScanTempoZones();
window.editorApplyTempoZones = () => editorApplyTempoZones();
window.editorHealGrid = () => editorHealGrid();
window.editorToggleMapHealth = (force) => editorToggleMapHealth(force);
window.editorSyncUpdateFactor = editorSyncUpdateFactor;
window.editorHideSyncDialog = editorHideSyncDialog;
Expand Down Expand Up @@ -1711,7 +1712,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 1715 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
1 change: 1 addition & 0 deletions src/menu-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
99 changes: 99 additions & 0 deletions src/tempo.js
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,105 @@ 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;
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) {
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() {
// 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;
}
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 healed = _gridHealPure(S.beats);
S.history.exec(new TempoGridCmd(S.beats, healed, 'heal uneven beats', S.tempoSel, S.tempoSel));
host.draw();
host.updateStatus();
// 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;
}

// 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
Expand Down
117 changes: 117 additions & 0 deletions tests/grid_heal.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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('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
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);
Loading