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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Metronome guide + whole-song tempo fits.** A click or reference stem can
be locked as the session's **tempo guide** (the ♩ button in the Audio
tracks manager): assisted mapping (`G`) then analyzes that track instead
of the main mix, treating each click as one beat — so tempo changes in
the click are followed directly, and the guide role persists with the
song. `G` on ordinary audio now proposes all the way to the final
authored barline too: the onset-supported prefix keeps its measured
confidence, and everything past a confidence break continues as visibly
low-confidence, editable estimates that are never committed on their own.
A new **Accept Whole Fit** button (and command) takes the entire proposal
— including the open final measure, whose interior beats now ride the
accepted tempo instead of staying on the old grid — as ONE undoable edit.
The fit's anchor is always the focused barline, locked or not; a stale
multi-selection no longer resets analysis toward the beginning or caps
the march. In the click-track engine, a locked barline keeps its authored
time without disturbing the pulse walk, so one stale lock can't
phase-shift every later suggestion.

- **Tracks are now first-class, persistent objects.** A song's tracks — the
master recording, studio stems, and every transcription part, optionally
grouped into folders — form one ordered tree the editor remembers across
Expand Down
36 changes: 32 additions & 4 deletions docs/TEMPO-MAPPING-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,46 @@ and tempo drift does not imply a new meter.
The normal workflow is seed, suggest, correct:

1. Mark two or more reliable downbeats.
2. Suggest the next short range, a selection, or the song tail.
2. Suggest from the chosen anchor through the whole authored song. Keep the
onset-supported prefix at its measured confidence; after a confidence break,
continue as visibly low-confidence editable estimates instead of stopping.
3. Show proposed barlines with confidence; do not commit them silently.
4. Accept a range or correct the first wrong proposal.
5. Recalculate only the unconfirmed future from that correction.
6. Stop at low confidence, silence, phase breaks, or likely tempo/meter changes
and request another anchor.
5. Recalculate only the unconfirmed future from that correction. The active
barline is sufficient as the analysis anchor; locking is optional and means
only that a later fit must preserve that barline's authored time.
6. At low confidence, silence, phase breaks, or likely tempo/meter changes,
stop trusting onset snaps but continue the proposal from the most recent
fitted interval. Never commit that inferred tail without explicit acceptance.

Manual anchors are authoritative. Onset detection may offer a visible soft
snap but must never silently move a mark. Long gaps must not silently infer a
measure count. Odd-meter and complex songs are mapped by bounded phrases with
explicit meter/grouping markers.

An audio source explicitly declared as a **metronome guide** is the deliberate
exception to bounded performance-onset marching. Its consolidated transients
are treated as authored beat pulses, so the mapper may propose the whole chart,
including click-track tempo changes. Missing detections are extrapolated from
the recent pulse interval and must carry lower confidence. The result remains a
proposal: **Accept Whole Fit** commits it as one undoable command, and individual
accept-through remains available. Merely naming a file “click” never enables
this policy; the user must opt in on the audio track. A barline multi-selection
does not bound this mode: it chooses the starting anchor and the proposal runs
through the final authored barline. Because the canonical grid's final measure
is open, accepting that last proposal carries the most recent fitted interval
through its remaining interior beats rather than leaving the last bar at the
old tempo.
Locked barlines preserve their own authored source time but do not alter the
metronome pulse cursor. A lock is not evidence of a missing or extra beat; the
authored beat count continues across it so one stale lock cannot phase-shift
every later proposal.
The guide identity, not the currently focused audio reference, owns analysis:
every **G** action under a locked guide must analyze — and await — that guide
source's audio before reading onsets, revalidating its preconditions after the
wait. Playback and the visible waveform keep following the session recording;
declaring a guide redirects tempo truth only.

## Pitch-Preserving Slow Playback

Audition speed is a transport transform applied after source-to-musical
Expand Down
14 changes: 11 additions & 3 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,17 @@ Three ways to set the tempo, from coarse to fine:
3. **Tempo Map mode** (`T`) — the precise tool. The bottom strip shows every
**barline**; drag one onto its downbeat in the waveform and the surrounding
bars re-space to fit. In this mode:
- **`G` — Suggest fit**: from the selected barline, the editor proposes the
next downbeats from the audio's onsets. Click a ghost handle to accept
through it.
- **`G` — Suggest fit**: from the selected barline, the editor proposes
downbeats from the audio's onsets — all the way to the end of the song.
Where the audio stops corroborating, the remaining barlines continue as
visibly low-confidence estimates (never committed on their own). Click a
ghost handle to accept through it, or press **Accept Whole Fit** in the
tempo toolbar to take the entire proposal as one undoable edit.
- **Metronome guide**: if your session includes a click/reference stem,
open **Audio tracks** (the stem manager) and click the **♩** on that row
to lock it as the tempo guide. `G` then analyzes the click instead of
the mix — each click is one beat, so tempo changes in the click are
followed directly. Click ♩ again to unlock.
- **`Shift+B` — Tap tempo**: tap along and the selected barline takes your
tempo.
- **`B` — Set BPM** for the selected barline; **`M` — metric modulation**
Expand Down
84 changes: 84 additions & 0 deletions src/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,90 @@ export function _ensureOnsetsShifted() {
return raw.map(o => ({ ...o, t: o.t + sh })); // carry s + per-band strengths
}

// ── Metronome-guide analysis (analysis-only; never touches playback) ──
// The tempo guide can be a STEM, and the transport still owns exactly one
// decoded buffer (the session recording). Guide analysis therefore decodes
// the guide source into a LOCAL buffer and runs the same pure spectral-flux
// pipeline against it — S.audioBuffer / S.waveformPeaks / playback are never
// touched, so locking a click stem as the guide can't reroute what the user
// hears or sees. One-slot cache keyed by (sourceId, url); a generation token
// guards the async fetch+decode+STFT against song switches and re-requests.
let _guideOnsetCache = null; // { sourceId, url, onsets } (buffer-time)
let _guideGeneration = 0;
let _guideInflight = null; // { sourceId, url, promise } — coalesce concurrent same-guide requests

// New song boundary: drop the cache and orphan any in-flight guide job so a
// previous song's decode can never land on the current one.
export function _guideAnalysisReset() {
_guideOnsetCache = null;
_guideInflight = null;
_guideGeneration++;
}

// Coalesce concurrent requests for the SAME (sourceId, url): a second G press
// before the first decode lands must reuse the in-flight promise, not start a
// rival generation that supersedes — and null out — the first. Different guides
// (or a song switch, which resets _guideInflight) still supersede via the token.
export function ensureGuideOnsets(sourceId, url) {
if (!sourceId || !url) return Promise.resolve(null);
if (_guideOnsetCache && _guideOnsetCache.sourceId === sourceId
&& _guideOnsetCache.url === url) {
return Promise.resolve(_guideOnsetCache.onsets);
}
if (_guideInflight && _guideInflight.sourceId === sourceId && _guideInflight.url === url) {
return _guideInflight.promise;
}
const promise = _computeGuideOnsets(sourceId, url);
_guideInflight = { sourceId, url, promise };
const clear = () => { if (_guideInflight && _guideInflight.promise === promise) _guideInflight = null; };
promise.then(clear, clear);
return promise;
}

async function _computeGuideOnsets(sourceId, url) {
const generation = ++_guideGeneration;
let decoded;
try {
_ensureAudioCtx();
const resp = await fetch(url);
if (!resp.ok) return null;
const raw = await resp.arrayBuffer();
decoded = await S.audioCtx.decodeAudioData(raw);
} catch (_) { return null; }
if (generation !== _guideGeneration) return null; // superseded mid-decode
// Same chunked STFT the session buffer gets (setTimeout ticks, never
// freeze a frame) — awaitable here because the G handler shows progress
// and revalidates its own preconditions after the await.
let plan;
try { plan = _spectralFluxOnsetsPlan(decoded.getChannelData(0), decoded.sampleRate); }
catch (_) { return null; }
const FRAMES_PER_TICK = 1500;
const onsets = await new Promise((resolve) => {
const step = () => {
if (generation !== _guideGeneration) { resolve(null); return; }
let done = false;
try { done = _spectralFluxStep(plan, FRAMES_PER_TICK); }
catch (_) { resolve(null); return; }
if (!done) { setTimeout(step, 0); return; }
try { resolve(_pickOnsetsPure(plan.res, {})); } catch (_) { resolve(null); }
};
setTimeout(step, 0);
});
if (!onsets || !onsets.length || generation !== _guideGeneration) return null;
_guideOnsetCache = { sourceId, url, onsets };
return onsets;
}

// Guide onsets in CHART time — the guide rides the same session timeline as
// the recording, so the same placement shift applies on read (mirror of
// _ensureOnsetsShifted).
export async function ensureGuideOnsetsShifted(sourceId, url, sourceOffset = 0) {
const raw = await ensureGuideOnsets(sourceId, url);
const sh = (Number(S.audioShift) || 0) + (Number(sourceOffset) || 0);
if (!raw || !sh) return raw;
return raw.map(o => ({ ...o, t: o.t + sh }));
}

export function _refreshOnsetBtn() {
const btn = document.getElementById('editor-onset-btn');
if (!btn) return;
Expand Down
3 changes: 2 additions & 1 deletion src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
import { S, markSessionDirty } from './state.js';
import { _marksSanitizePure } from './tempo-marks.js';
import { disposeBackendSession, stopSessionProcesses } from './session-lifecycle.js';
import { _ensureOnsetsShifted } from './audio.js';
import { _ensureOnsetsShifted, _guideAnalysisReset } from './audio.js';
import { _firstDownbeatTimePure, _importBar1NudgePure, _liftAllBeats, _restoreBeatLocks, _syncAppliedMessagePure } from './tempo.js';
import { seedSurfacePreset, surfacePersistFor } from './toolbars.js';
import { trackSessionSavePayload } from './track-session.js';
Expand Down Expand Up @@ -1605,7 +1605,7 @@
// Left in place rather than deleted, because deleting them is a separate change
// from the bug fix that made them redundant. They arrived with the same
// half-wired Create-New redesign (977ec65, #45).
function _populateCreateArrButtons() {

Check warning on line 1608 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'_populateCreateArrButtons' is defined but never used
const wrap = document.getElementById('editor-create-arr-buttons');
if (!wrap) return;
wrap.replaceChildren();
Expand Down Expand Up @@ -1806,7 +1806,7 @@
createState.lastSync = { ...createState.lastSync, ...data };
}
return data;
} catch (e) {

Check warning on line 1809 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
return null;
}
}
Expand Down Expand Up @@ -2325,6 +2325,7 @@
// inert on this branch, but when present it receives every create-time
// source immediately so stems do not appear only after a save/reopen.
host.installCreatedTrackSession(data.track_session, data.audio_sources || []);
_guideAnalysisReset(); // fresh import — no stale guide onsets may survive
const _importHasDrums = !!(S.drumTab && (S.drumTab.hits || []).length);
const _importHasKeys = (S.arrangements || []).some(
a => KEYS_PATTERN.test(a.name || ''));
Expand Down
5 changes: 4 additions & 1 deletion src/file-ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// triggers stay in main.js and are reached through host.

import { _anchorsAreDirty, _stripToneInternals, _tonesAreDirty, _updateTonesButtonVisibility } from './annotation-lanes.js';
import { _abDisarm, _resetAuditionForNewSong, loadAudio } from './audio.js';
import { _abDisarm, _guideAnalysisReset, _resetAuditionForNewSong, loadAudio } from './audio.js';
import { _handshapesAreDirty, _normalizeHandshape, flattenChords, reconstructChords } from './chords.js';
import { _normalizeTuningToLanes } from './commands.js';
import { EditHistory } from './history.js';
Expand Down Expand Up @@ -187,6 +187,9 @@ export async function loadCDLC(filename, options = {}) {
// explicitly: S.audioUrl still points at the PREVIOUS song here
// (loadAudio runs later), and the master row must not depend on it.
installTrackSession(data.track_session, data.audio_url || '');
// New song ⇒ the previous song's guide analysis (a decoded stem +
// its onsets) is stale; also orphans any in-flight guide decode.
_guideAnalysisReset();
// Exit drum-edit mode on song change so we don't carry a stale
// selection into a sloppak whose hits[] is different.
S.drumEditMode = false;
Expand Down
Loading
Loading