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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 14 additions & 2 deletions src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
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';

Expand Down Expand Up @@ -1582,7 +1583,7 @@
// half-wired Create-New redesign (977ec65, #45).
function _populateCreateArrButtons() {
const wrap = document.getElementById('editor-create-arr-buttons');
if (!wrap) return;

Check warning on line 1586 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'_populateCreateArrButtons' is defined but never used
wrap.replaceChildren();
// Functional roster + Vocals shown-but-disabled: the editor has no vocals
// edit mode yet, so offering it would create a pack you can't edit — an
Expand Down Expand Up @@ -1755,7 +1756,7 @@
return data;
} catch (e) {
return null;
}

Check warning on line 1759 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}

// Download a YouTube URL as the auto-sync audio source. Same downstream
Expand Down Expand Up @@ -2048,7 +2049,18 @@
// 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;
Expand Down
94 changes: 92 additions & 2 deletions src/tempo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 : [];
Expand Down Expand Up @@ -502,12 +563,15 @@ function _ensureTempoSyncInspector() {
+ '<span id="editor-tempo-sync-label" class="text-gray-200 font-medium min-w-[5.5rem]">No selection</span>'
+ '<span id="editor-tempo-sync-hint" class="text-gray-500"></span>'
+ '<button type="button" id="editor-tempo-sync-insert" class="px-2 py-0.5 rounded bg-dark-600 text-gray-300 hover:bg-dark-500 disabled:opacity-50 disabled:cursor-not-allowed" title="Mark a barline at the playhead">Mark</button>'
+ '<button type="button" id="editor-tempo-sync-bar1" class="px-2 py-0.5 rounded bg-dark-600 text-gray-300 hover:bg-dark-500 disabled:opacity-50 disabled:cursor-not-allowed" title="Shift the grid, notes and sections so bar 1 lands at the playhead (the audio does not move)">Bar 1 here</button>'
+ '<button type="button" id="editor-tempo-sync-delete" class="px-2 py-0.5 rounded bg-dark-600 text-gray-300 hover:bg-dark-500 disabled:opacity-50 disabled:cursor-not-allowed" title="Delete selected barline">Delete</button>'
+ '<button type="button" id="editor-tempo-sync-modulate" class="px-2 py-0.5 rounded bg-dark-600 text-gray-300 hover:bg-dark-500 disabled:opacity-50 disabled:cursor-not-allowed" title="Metric modulation: new tempo = current × ratio, from this measure to the next tempo change (M)">Modulate…</button>';
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);
Expand Down Expand Up @@ -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 += '<div class="border-t border-gray-700 my-1"></div>';
html += mkBtn('togglelock',
(S.beats[onPole] && S.beats[onPole].locked) ? 'Unlock barline' : 'Lock barline',
Expand All @@ -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);
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading