diff --git a/CHANGELOG.md b/CHANGELOG.md index d550d1ea..ee2493ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **"Bar 1 here" — re-anchor the whole song to the playhead.** In Tempo Map + mode, an inspector button and a right-click item on the bar-1 pole shift the + grid, every part's notes, and the sections so bar 1's downbeat lands at the + playhead — the recording never moves. It rides the undoable offset command, so + Ctrl+Z restores the previous placement exactly. The space before bar 1 now + draws as a labelled **Lead-in** region (a hatched wash mirroring the Unmapped + tail), and the pickup right-click item is relabelled "(partial first bar — for + music that starts before beat 1)" so the two are easy to tell apart. On import, + when the grid puts bar 1 at 0:00 but the recording clearly starts later, the + status line **suggests** opening Tempo Map and using "Bar 1 here" — it never + auto-shifts. - **User Guide** — a task-oriented, end-user guide to charting in the editor (start a project, the workspace, play/navigate, edit notes & techniques, parts, tempo mapping, drums, structure, save/build, shortcut essentials). diff --git a/src/create.js b/src/create.js index e5042040..43aa18ef 100644 --- a/src/create.js +++ b/src/create.js @@ -30,7 +30,8 @@ import { KEYS_PATTERN, isKeysMode, updatePianoRange } from './keys.js'; import { _seedExtendedStringsFromTuning } from './lanes.js'; import { S, markSessionDirty } from './state.js'; import { disposeBackendSession, stopSessionProcesses } from './session-lifecycle.js'; -import { _liftAllBeats, _restoreBeatLocks, _syncAppliedMessagePure } from './tempo.js'; +import { _ensureOnsets } from './audio.js'; +import { _firstDownbeatTimePure, _importBar1NudgePure, _liftAllBeats, _restoreBeatLocks, _syncAppliedMessagePure } from './tempo.js'; import { seedSurfacePreset, surfacePersistFor } from './toolbars.js'; import { _editorEscHtml, _installModalKeyboard, setStatus } from './ui.js'; @@ -2048,7 +2049,18 @@ export async function editorDoCreate() { // for either — point the user at the Tempo Map editor to fine-tune any // residual drift by hand. See _syncAppliedMessagePure. const _syncMsg = _syncAppliedMessagePure(data.sync_applied, data.sync_reason); - if (_syncMsg && typeof setStatus === 'function') setStatus(_syncMsg); + let _msg = _syncMsg; + // Import nudge (SUGGEST only — never auto-shift): the grid landed bar 1 + // at ~0 but the recording clearly starts later. Skip for 'warp' imports + // (already bar-by-bar aligned). Onsets are ready here — the awaited + // editorApplyCreateResult above decoded the audio into S.waveformPeaks. + if (data.sync_applied !== 'warp') { + let _firstOnset = null; + try { const _on = _ensureOnsets(); if (_on && _on.length) _firstOnset = _on[0].t; } catch (_) {} + const _nudge = _importBar1NudgePure(_firstDownbeatTimePure(S.beats), _firstOnset); + if (_nudge) _msg = _msg ? (_msg + ' ' + _nudge) : _nudge; + } + if (typeof setStatus === 'function') setStatus(_msg); } catch (e) { status.textContent = 'Import failed: ' + e.message; btn.disabled = false; diff --git a/src/tempo.js b/src/tempo.js index f01642b7..d2849377 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -269,6 +269,29 @@ export function _tempoMapDraw(w, h) { } } + // Lead-in region (mirror of the Unmapped tail): the space BEFORE bar 1's + // downbeat — pickup / count-in time where no bar has started. Same hatched + // wash + label so the two "no mapped bar here" regions read alike; drawn + // under the notes/poles. Runs from the timeline start (0) to bar 1. + const _bar1T = _firstDownbeatTimePure(S.beats); + if (_bar1T !== null && _bar1T > 1e-6) { + const lx0 = Math.max(LABEL_W, timeToX(0)); + const lx1 = Math.min(w, timeToX(_bar1T)); + const ltop = (TIMELINE_TOP + WAVEFORM_H); + if (lx1 > lx0 + 2) { + ctx.fillStyle = 'rgba(100,116,139,0.06)'; + ctx.fillRect(lx0, ltop, lx1 - lx0, gridBottom - ltop); + _tempoHatchRect(lx0, ltop, lx1 - lx0, gridBottom - ltop, '#64748b', 8, 0.10); + if (lx1 - lx0 > 56) { + ctx.fillStyle = '#64748b'; + ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText('Lead-in', lx0 + 6, ltop + 6); + } + } + } + // Dimmed reference layer — the current arrangement's notes + drum // hits, fixed at their absolute times so the user can drag the grid // to line up with them (and the waveform). @@ -442,6 +465,44 @@ export function _syncAppliedMessagePure(syncApplied, syncReason) { } /* @pure:tempo-map-guidance:end */ +/* @pure:tempo-bar1:start */ +// The seconds of bar 1's downbeat — the first beat carrying a real measure +// number (measure > 0). Lead-in / pickup beats (measure <= 0) sit before it. +// null when there is no downbeat to anchor to. Shared by the Bar-1-here verb, +// the Lead-in region wash, and the import nudge. +export function _firstDownbeatTimePure(beats) { + if (!Array.isArray(beats)) return null; + for (let i = 0; i < beats.length; i++) { + if (beats[i] && beats[i].measure > 0) return beats[i].time; + } + return null; +} + +// The rigid grid shift that lands bar 1's downbeat at targetTime, plus the +// shifted grid (beats moved by the same delta — a pure +delta, exactly what +// TempoOffsetCmd extrapolates over every part). null when there is no downbeat. +export function _tempoBar1ShiftPure(beats, targetTime) { + const t0 = _firstDownbeatTimePure(beats); + if (t0 === null) return null; + const delta = (Number(targetTime) || 0) - t0; + return { delta, newBeats: beats.map(b => ({ ...b, time: b.time + delta })) }; +} + +// Import nudge (SUGGEST only): the grid put bar 1 at ~0 but the recording +// clearly starts later — return the copy that points the user at 'Bar 1 here'. +// Empty string (no nudge) unless bar 1 is essentially at 0 AND the first onset +// is both clearly past 0 and a meaningful gap beyond bar 1. Never auto-shifts — +// the design non-negotiable is that imports only ever suggest a re-anchor. +export function _importBar1NudgePure(bar1Time, firstOnsetTime) { + if (bar1Time === null || bar1Time === undefined) return ''; + if (firstOnsetTime === null || firstOnsetTime === undefined) return ''; + if (!(bar1Time <= 0.15)) return ''; // bar 1 must already sit at ~0 + if (!(firstOnsetTime >= 0.4)) return ''; // recording must clearly start later + if (firstOnsetTime - bar1Time < 0.4) return ''; // and by a musically meaningful gap + return `The recording seems to start around ${firstOnsetTime.toFixed(1)}s but the chart starts at 0:00 — open Tempo Map and use ‘Bar 1 here’.`; +} +/* @pure:tempo-bar1:end */ + /* @pure:tempo-sync-inspector:start */ export function _tempoSyncInspectorStatePure(measures, selectedIndex) { const rows = Array.isArray(measures) ? measures : []; @@ -502,12 +563,15 @@ function _ensureTempoSyncInspector() { + 'No selection' + '' + '' + + '' + '' + ''; const insertBtn = el.querySelector('#editor-tempo-sync-insert'); + const bar1Btn = el.querySelector('#editor-tempo-sync-bar1'); const deleteBtn = el.querySelector('#editor-tempo-sync-delete'); const modulateBtn = el.querySelector('#editor-tempo-sync-modulate'); if (insertBtn) insertBtn.onclick = () => _tempoInsertSyncPoint(S.cursorTime); + if (bar1Btn) bar1Btn.onclick = () => _tempoSetBar1Here(); if (deleteBtn) deleteBtn.onclick = () => { if (S.tempoSel >= 0) _tempoDeleteSyncPoint(S.tempoSel); }; if (modulateBtn) modulateBtn.onclick = () => _editorModulateTempoAtSelection(); bpm.parentNode.insertBefore(el, bpm.previousElementSibling || bpm); @@ -922,9 +986,14 @@ export function _tempoMapOnContextMenu(e) { html += mkBtn('tsedit', 'Set time signature…'); if (cur < 16) html += mkBtn('tsplus', 'Add a beat (time signature +)'); if (cur > 1) html += mkBtn('tsminus', 'Remove a beat (time signature −)'); - // Pickup lives on the FIRST measure only (D3): a partial first bar. + // Bar-1 re-anchor + pickup live on the FIRST measure only (D3). "Bar 1 + // here" is listed above the pickup: re-anchoring the whole song is the + // coarser, more common first move; the partial-bar pickup is the refinement. const _firstPole = _tempoMeasures()[0]; - if (_firstPole && onPole === _firstPole.i && cur > 1) html += mkBtn('pickup', 'Set pickup (partial first bar)…'); + if (_firstPole && onPole === _firstPole.i) { + html += mkBtn('bar1here', 'Bar 1 here (move bar 1 to the playhead)'); + if (cur > 1) html += mkBtn('pickup', 'Set pickup (partial first bar — for music that starts before beat 1)…'); + } html += '
'; html += mkBtn('togglelock', (S.beats[onPole] && S.beats[onPole].locked) ? 'Unlock barline' : 'Lock barline', @@ -942,6 +1011,7 @@ export function _tempoMapOnContextMenu(e) { btn.onclick = () => { host.hideContextMenu(); const a = btn.dataset.action; + if (a === 'bar1here') { _tempoSetBar1Here(); return; } if (a === 'pickup') { _tempoPromptPickup(); return; } if (a === 'delete-multi') _tempoDeleteSelection(); else if (a === 'delete') _tempoDeleteSyncPoint(onPole); @@ -1303,6 +1373,26 @@ export function _pickupBarShiftPure(beats) { } /* @pure:tempo-pickup:end */ +// The verb: shift the whole grid (and, via TempoOffsetCmd's total reproject, +// every part's notes/chords/anchors/drums AND the sections) so bar 1's downbeat +// lands at the playhead. The audio never moves — this is a chart re-anchor, so +// it rides the SAME offset command as a manual nudge (S.appliedOffset accrues, +// undoable). Reachable from the inspector "Bar 1 here" button and the bar-1 +// pole's right-click item. +export function _tempoSetBar1Here() { + const target = Number(S.cursorTime) || 0; + const res = _tempoBar1ShiftPure(S.beats, target); + if (!res) { setStatus('No measure grid to place bar 1 on.'); return; } + if (Math.abs(res.delta) < 1e-4) { setStatus('Bar 1 is already at the playhead.'); return; } + const prevApplied = Number(S.appliedOffset) || 0; + const oldBeats = S.beats.map(b => ({ ...b })); + S.history.exec(new TempoOffsetCmd(oldBeats, res.newBeats, prevApplied, prevApplied + res.delta)); + const el = (typeof document !== 'undefined') ? document.getElementById('editor-offset') : null; + if (el) el.value = String(prevApplied + res.delta); + host.draw(); + setStatus(`Bar 1 → ${target.toFixed(2)}s — chart and notes shifted; audio unchanged.`); +} + // The verb: prompt for the pickup beat count and apply as one undoable // grid command. Reachable from the Tempo Map context menu (first measure) // and the command registry (the B4 menu lists it once both land). diff --git a/tests/bar1_here.test.mjs b/tests/bar1_here.test.mjs new file mode 100644 index 00000000..f073bb98 --- /dev/null +++ b/tests/bar1_here.test.mjs @@ -0,0 +1,175 @@ +/* + * "Bar 1 here" re-anchor + Lead-in + import nudge (tempo track PR 6). + * + * "Bar 1 here" shifts the whole grid — and, through TempoOffsetCmd's total + * reproject, EVERY part's notes/chords/anchors/drums plus the sections — so + * bar 1's downbeat lands at the playhead. The audio never moves: it is a chart + * re-anchor riding the same offset command as a manual nudge (S.appliedOffset + * accrues, undoable). This suite proves: + * 1. _firstDownbeatTimePure — bar 1's seconds (first measure > 0), null-safe. + * 2. _tempoBar1ShiftPure — the rigid delta + shifted grid, null with no bar. + * 3. _importBar1NudgePure — SUGGEST copy only when bar 1 ≈ 0 AND the first + * onset is clearly + meaningfully later (never otherwise). + * 4. _tempoSetBar1Here — lands bar 1 at the playhead across every part + + * sections, accrues S.appliedOffset, and exec→undo→redo round-trips exactly. + * + * Run: node tests/bar1_here.test.mjs + */ +import assert from 'node:assert'; +import { S } from '../src/state.js'; +import { EditHistory } from '../src/history.js'; +import { + _firstDownbeatTimePure, _importBar1NudgePure, + _tempoBar1ShiftPure, _tempoSetBar1Here, +} from '../src/tempo.js'; +import { seedState, trackHooks, lastStatus } from './_history_env.mjs'; + +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 near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps; + +// A uniform 1s beat grid; first beat is the downbeat unless lead-in is prepended. +const grid = times => times.map((time, i) => ({ time, measure: i === 0 ? 1 : -1 })); + +// ── 1. _firstDownbeatTimePure ──────────────────────────────────────────────── +t('_firstDownbeatTimePure returns the first measure>0 time', () => { + assert.strictEqual(_firstDownbeatTimePure(grid([2, 3, 4])), 2); +}); +t('_firstDownbeatTimePure skips lead-in beats (measure <= 0)', () => { + const beats = [{ time: 0, measure: 0 }, { time: 1, measure: 0 }, { time: 2, measure: 1 }, { time: 3, measure: -1 }]; + assert.strictEqual(_firstDownbeatTimePure(beats), 2); +}); +t('_firstDownbeatTimePure is null with no downbeat / bad input', () => { + assert.strictEqual(_firstDownbeatTimePure([{ time: 0, measure: 0 }]), null); + assert.strictEqual(_firstDownbeatTimePure([]), null); + assert.strictEqual(_firstDownbeatTimePure(null), null); +}); + +// ── 2. _tempoBar1ShiftPure ─────────────────────────────────────────────────── +t('_tempoBar1ShiftPure lands bar 1 at the target and shifts the grid rigidly', () => { + const res = _tempoBar1ShiftPure(grid([1, 2, 3]), 4); + assert.ok(near(res.delta, 3), 'delta = target - bar1'); + assert.deepStrictEqual(res.newBeats.map(b => b.time), [4, 5, 6], 'whole grid +delta'); +}); +t('_tempoBar1ShiftPure with a lead-in shifts the lead-in with the grid', () => { + const beats = [{ time: 0, measure: 0 }, { time: 2, measure: 1 }]; // pickup at 0, bar1 at 2 + const res = _tempoBar1ShiftPure(beats, 5); + assert.ok(near(res.delta, 3), 'delta anchors on bar 1, not the lead-in'); + assert.deepStrictEqual(res.newBeats.map(b => b.time), [3, 5], 'lead-in rides'); +}); +t('_tempoBar1ShiftPure is null with no downbeat', () => { + assert.strictEqual(_tempoBar1ShiftPure([{ time: 0, measure: 0 }], 5), null); +}); +t('_tempoBar1ShiftPure does not mutate its input', () => { + const beats = grid([1, 2, 3]); + _tempoBar1ShiftPure(beats, 9); + assert.deepStrictEqual(beats.map(b => b.time), [1, 2, 3]); +}); + +// ── 3. _importBar1NudgePure ────────────────────────────────────────────────── +t('_importBar1NudgePure suggests when bar 1 ≈ 0 but the recording starts later', () => { + const msg = _importBar1NudgePure(0, 7.02); + assert.ok(/7\.0s/.test(msg), 'names the onset time'); + assert.ok(/Bar 1 here/.test(msg), 'points at the verb'); +}); +t('_importBar1NudgePure stays silent when bar 1 is already placed later', () => { + assert.strictEqual(_importBar1NudgePure(7.0, 7.02), '', 'bar 1 not at ~0 → no nudge'); +}); +t('_importBar1NudgePure stays silent when the recording starts at 0 too', () => { + assert.strictEqual(_importBar1NudgePure(0, 0.1), '', 'onset early → no nudge'); +}); +t('_importBar1NudgePure stays silent on a tiny gap or missing onset', () => { + assert.strictEqual(_importBar1NudgePure(0.1, 0.45), '', 'gap under 0.4s → no nudge'); + assert.strictEqual(_importBar1NudgePure(0, null), '', 'no onset → no nudge'); + assert.strictEqual(_importBar1NudgePure(null, 7), '', 'no bar 1 → no nudge'); +}); + +// ── Shared multi-part fixture (mirrors tempo_op_commands) ───────────────────── +function seedMultiPart() { + trackHooks(); + const mkArr = (base) => ({ + name: base === 0 ? 'Guitar' : 'Bass', + notes: [{ string: 0, fret: 0, time: 1.0, sustain: 0.5 }, { string: 1, fret: 2, time: 3.0 }], + chords: [{ time: 2.0, notes: [{ string: 0, time: 2.0 }, { string: 1, time: 2.0, sustain: 0.5 }] }], + anchors: [{ time: 1.0, fret: 1, width: 4 }], + handshapes: [{ chord_id: 0, start_time: 2.0, end_time: 2.5 }], + }); + seedState({ + arrangements: [mkArr(0), mkArr(1)], + currentArr: 0, + sessionId: 'sess-1', + beats: grid([0, 1, 2, 3, 4]), // bar 1 downbeat at t=0 + sections: [{ name: 'Verse', start_time: 1.0 }], + drumTab: { version: 1, name: 'kit', kit: 'std', hits: [{ p: 'kick', t: 2.0 }, { p: 'snare', t: 3.0 }] }, + appliedOffset: 0, + cursorTime: 0, + history: new EditHistory(), + }); +} +function timesSnapshot() { + return JSON.stringify({ + arr: S.arrangements.map(a => ({ + notes: a.notes.map(n => [n.time, n.sustain ?? null]), + chords: a.chords.map(c => [c.time, c.notes.map(cn => cn.time)]), + anchors: a.anchors.map(x => x.time), + handshapes: a.handshapes.map(h => [h.start_time, h.end_time]), + })), + drums: S.drumTab.hits.map(h => h.t), + sections: S.sections.map(s => s.start_time), + }); +} + +// ── 4. _tempoSetBar1Here command round-trip ────────────────────────────────── +t('_tempoSetBar1Here lands bar 1 at the playhead across EVERY part + sections', () => { + seedMultiPart(); + const before = timesSnapshot(); + S.cursorTime = 2.0; // move bar 1 from 0 → 2.0s + _tempoSetBar1Here(); + + assert.ok(near(S.beats[0].time, 2.0), 'bar 1 downbeat landed at the playhead'); + // Rigid +2.0 over every part (the old direct path left other parts behind). + assert.ok(near(S.arrangements[0].notes[0].time, 3.0), 'current-arr note +delta'); + assert.ok(near(S.arrangements[1].notes[1].time, 5.0), 'other-arr note +delta'); + assert.ok(near(S.arrangements[1].chords[0].time, 4.0), 'other-arr chord +delta'); + assert.ok(near(S.arrangements[1].anchors[0].time, 3.0), 'other-arr anchor +delta'); + assert.ok(near(S.arrangements[1].handshapes[0].end_time, 4.5), 'other-arr handshape span +delta'); + assert.ok(near(S.drumTab.hits[0].t, 4.0), 'drum hit +delta'); + assert.ok(near(S.sections[0].start_time, 3.0), 'section +delta'); + // Sustains are durations — they hold. + assert.ok(near(S.arrangements[0].notes[0].sustain, 0.5), 'sustain preserved'); + assert.strictEqual(S.appliedOffset, 2.0, 'the shift accrues on S.appliedOffset'); + assert.strictEqual(document.getElementById('editor-offset').value, '2', 'offset input synced'); + assert.ok(/Bar 1 → 2\.00s/.test(lastStatus()), 'status names the new bar-1 time'); + + S.history.doUndo(); + assert.strictEqual(timesSnapshot(), before, 'undo restored the exact pre-edit seconds'); + assert.strictEqual(S.appliedOffset, 0, 'undo restored appliedOffset'); + + S.history.doRedo(); + assert.ok(near(S.beats[0].time, 2.0), 'redo re-applied the shift'); + assert.strictEqual(S.appliedOffset, 2.0, 'redo restored appliedOffset'); +}); + +t('_tempoSetBar1Here is a no-op with an honest status when bar 1 is already at the playhead', () => { + seedMultiPart(); + S.cursorTime = 0; // bar 1 already at 0 + const before = timesSnapshot(); + _tempoSetBar1Here(); + assert.strictEqual(timesSnapshot(), before, 'nothing moved'); + assert.strictEqual(S.history.undo.length, 0, 'no command pushed'); + assert.ok(/already at the playhead/.test(lastStatus())); +}); + +t('_tempoSetBar1Here refuses gracefully with no measure grid', () => { + seedMultiPart(); + S.beats = [{ time: 0, measure: 0 }, { time: 1, measure: 0 }]; // lead-in only, no downbeat + _tempoSetBar1Here(); + assert.ok(/No measure grid/.test(lastStatus())); + assert.strictEqual(S.history.undo.length, 0, 'no command pushed'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);