diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9c8a56c..c7b04598 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -114,6 +114,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
never crosses a neighbouring barline. **Locked barlines never snap**, and with
Snap = Grid the drag stays a plain continuous move. A status line confirms
when a barline lands on an attack.
+- **Shift Audio — slide the recording in time, keeping the chart fixed.** A
+ **Shift Audio…** button (next to Replace Audio) slides the whole recording
+ earlier or later against the chart — the inverse of the chart-side Offset. Use
+ it when a recording starts late, has leading silence, or you swapped it via
+ Replace Audio and it no longer lines up with the chart you already built:
+ move the *audio* instead of re-timing every note. It's **non-destructive**
+ (the samples are never stretched — playback just reads the buffer from a
+ shifted position) and **undoable**; the waveform and onset strip slide with it
+ so what you see matches what you hear, and onset snap / Suggest / Sync follow
+ the shifted audio. One shift applies to the whole audio group, so stems (when
+ they arrive in the editor) will move together. *(Persisting the shift into the
+ built pack is a follow-up — the value is wired onto the save/load path and
+ honored on load, pending the pack field.)*
- **Song Fit — one place to line the chart up with the recording.** A **Song
Fit…** button in the Tempo Map inspector opens a small menu with the three
ways to fit a chart to audio, each labelled with what it does to your notes:
diff --git a/screen.html b/screen.html
index 1ad2ca54..562c2527 100644
--- a/screen.html
+++ b/screen.html
@@ -41,6 +41,7 @@
+
diff --git a/src/audio.js b/src/audio.js
index b2841478..d59ed690 100644
--- a/src/audio.js
+++ b/src/audio.js
@@ -227,6 +227,20 @@ export function _ensureOnsets() {
return _onsetCache;
}
+// Onsets in CHART/timeline time — buffer-time onsets plus the audio placement
+// shift — for everything that relates a detected attack to a musical position
+// (Suggest-fit, onset snap, Sync phase). Returns the raw cached array UNCHANGED
+// when there is no shift (the common case → zero allocation on hot paths); only
+// maps when the recording has been slid. The buffer-time `_ensureOnsets()` cache
+// stays the source of truth (memoized on the peaks); the shift is applied on read
+// so it always tracks the live S.audioShift.
+export function _ensureOnsetsShifted() {
+ const raw = _ensureOnsets();
+ const sh = Number(S.audioShift) || 0;
+ if (!raw || !sh) return raw;
+ return raw.map(o => ({ t: o.t + sh, s: o.s }));
+}
+
export function _refreshOnsetBtn() {
const btn = document.getElementById('editor-onset-btn');
if (!btn) return;
@@ -283,21 +297,96 @@ export function _editorToggleSnapMode() {
// window.editorToggleSnapMode re-attached in main.js
+/* @pure:audio-shift:start */
+// Where to start the buffer given the playhead chart-time, the audio placement
+// shift, and the buffer length. The audio plays buffer-time (cursorTime -
+// audioShift): a positive shift slides the recording LATER, so the chart runs
+// ahead of the audio and the source start is delayed; a negative shift skips
+// into the buffer. Returns { play, offset, delay } — `play:false` when the
+// (shifted) audio has already ended at this chart position, so no source is
+// created and only the transport clock runs.
+export function _audioBufferStartPure(cursorTime, audioShift, bufferDuration) {
+ const bufOff = (Number(cursorTime) || 0) - (Number(audioShift) || 0);
+ const dur = Number(bufferDuration) || 0;
+ if (dur > 0 && bufOff >= dur) return { play: false, offset: 0, delay: 0 };
+ if (bufOff < 0) return { play: true, offset: 0, delay: -bufOff };
+ return { play: true, offset: bufOff, delay: 0 };
+}
+
+// Effective timeline length for shifted audio. A positive shift delays the
+// recording, so its tail ends after the raw buffer duration; negative shifts
+// crop the front but do not shrink the chart the user already has.
+export function _audioTimelineDurationPure(timelineDuration, audioShift, bufferDuration) {
+ const base = Math.max(0, Number(timelineDuration) || 0);
+ const dur = Math.max(0, Number(bufferDuration) || 0);
+ const sh = Number(audioShift) || 0;
+ const shiftedEnd = dur > 0 ? dur + Math.max(0, sh) : 0;
+ return Math.max(base, shiftedEnd);
+}
+/* @pure:audio-shift:end */
+
+function _audioTimelineDuration() {
+ return _audioTimelineDurationPure(S.duration, S.audioShift, S.audioBuffer && S.audioBuffer.duration);
+}
+
export function _startAudioSourceAtCursor(preRoll = 0) {
- S.audioSource = S.audioCtx.createBufferSource();
- S.audioSource.buffer = S.audioBuffer;
- // Reference recording stays on a transparent path to destination — its
- // mixer fader is a plain gain (unity by default): the guide-clap limiter
- // must never color the recording, even when claps are off. Only the
- // guide/click voices sum through the limiter (see _ensureMasterBus).
- const refGain = _ensureRefGain();
- if (refGain) S.audioSource.connect(refGain);
- else S.audioSource.connect(S.audioCtx.destination);
- _mixApplyFirstPlayFade();
- S.audioSource.start(preRoll > 0 ? S.audioCtx.currentTime + preRoll : 0, S.cursorTime);
+ // Slide the recording by S.audioShift (the chart clock, anchored below, is
+ // untouched — only the buffer read position moves). A positive shift can
+ // push the audio start into the future (delay) or, near the end, past the
+ // buffer entirely (no source; the transport still runs so the cursor and
+ // guide advance over the trailing silence).
+ const st = _audioBufferStartPure(S.cursorTime, S.audioShift, S.audioBuffer && S.audioBuffer.duration);
+ if (st.play) {
+ S.audioSource = S.audioCtx.createBufferSource();
+ S.audioSource.buffer = S.audioBuffer;
+ // Reference recording stays on a transparent path to destination — its
+ // mixer fader is a plain gain (unity by default): the guide-clap limiter
+ // must never color the recording, even when claps are off. Only the
+ // guide/click voices sum through the limiter (see _ensureMasterBus).
+ const refGain = _ensureRefGain();
+ if (refGain) S.audioSource.connect(refGain);
+ else S.audioSource.connect(S.audioCtx.destination);
+ _mixApplyFirstPlayFade();
+ const when = (preRoll > 0 || st.delay > 0) ? S.audioCtx.currentTime + preRoll + st.delay : 0;
+ S.audioSource.start(when, st.offset);
+ } else {
+ S.audioSource = null;
+ }
_anchorTransportAtCursor(preRoll);
}
+// Undoable audio placement shift. Song-scoped (it's not tied to one arrangement)
+// and a pure scalar move — no beats/notes change. Re-seats a live audio source so
+// the new placement is heard immediately, and redraws the (shifted) waveform.
+export class AudioShiftCmd {
+ constructor(oldShift, newShift) {
+ this.oldShift = Number(oldShift) || 0;
+ this.newShift = Number(newShift) || 0;
+ this.songScope = true;
+ }
+ exec() { S.audioShift = this.newShift; _afterAudioShiftChange(); }
+ rollback() { S.audioShift = this.oldShift; _afterAudioShiftChange(); }
+}
+function _afterAudioShiftChange() {
+ // If playing, restart the source at the cursor so the buffer offset updates
+ // mid-playback (the transport clock/cursor are untouched — only the audio moves).
+ if (S.playing) _restartPlaybackAt(S.cursorTime);
+ if (host && typeof host.editorApplyScrollBounds === 'function') host.editorApplyScrollBounds();
+ if (host && typeof host.draw === 'function') host.draw();
+}
+
+// Verb: set the absolute audio shift (seconds, 1ms resolution), undoably.
+export function editorSetAudioShift(val) {
+ const next = Math.round((parseFloat(val) || 0) * 1000) / 1000;
+ const cur = Number(S.audioShift) || 0;
+ if (Math.abs(next - cur) < 1e-4) return;
+ S.history.exec(new AudioShiftCmd(cur, next));
+ setStatus(`Audio shifted ${next >= 0 ? '+' : ''}${(next * 1000).toFixed(0)}ms — recording moved, chart unchanged.`);
+}
+export function editorNudgeAudioShift(delta) {
+ editorSetAudioShift((Number(S.audioShift) || 0) + (Number(delta) || 0));
+}
+
// Anchor the transport clock at the current cursor: pin wall-time to the
// AudioContext clock and chart-time to cursorTime, so playbackTick can derive
// the cursor from the ctx clock. In buffered mode this rides alongside the
@@ -335,7 +424,7 @@ export function _restartPlaybackAt(t) {
try { S.audioSource.stop(); } catch (_) {}
S.audioSource = null;
}
- S.cursorTime = Math.max(0, Math.min(S.duration || Infinity, t));
+ S.cursorTime = Math.max(0, Math.min(_audioTimelineDuration() || Infinity, t));
// Compose mode re-anchors the clock without a BufferSource — the guide/
// click scheduler is the only sound (charrette §1.7).
if (S.audioBuffer) _startAudioSourceAtCursor();
@@ -421,9 +510,10 @@ export function playbackTick() {
// anchor sits in the future, so the raw chart time would read negative).
S.cursorTime = Math.max(S.playStartTime,
_transportChartTimePure(S.playStartTime, S.playStartWall, S.audioCtx.currentTime));
+ const timelineEnd = _audioTimelineDuration();
const loopRestart = _recState === 'recording'
? null
- : _loopPlaybackRestartTimePure(S.cursorTime, S.barSel, S.loopEnabled, S.duration);
+ : _loopPlaybackRestartTimePure(S.cursorTime, S.barSel, S.loopEnabled, timelineEnd);
if (loopRestart !== null) {
// A/B compare flips its pass BEFORE the restart so the ramped
// reference mute/unmute lands with the wrap, not a frame late.
@@ -436,7 +526,7 @@ export function playbackTick() {
rafId = requestAnimationFrame(playbackTick);
return;
}
- if (S.cursorTime >= S.duration) {
+ if (S.cursorTime >= timelineEnd) {
// If a live MIDI recording is active, finalize it at the song end
// before resetting the cursor — otherwise chartTimeNow() keeps
// advancing past S.duration and emits notes beyond the chart.
diff --git a/src/create.js b/src/create.js
index edfa6b34..ccc61a40 100644
--- a/src/create.js
+++ b/src/create.js
@@ -30,7 +30,7 @@ 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 { _ensureOnsets } from './audio.js';
+import { _ensureOnsetsShifted } from './audio.js';
import { _firstDownbeatTimePure, _importBar1NudgePure, _liftAllBeats, _restoreBeatLocks, _syncAppliedMessagePure } from './tempo.js';
import { seedSurfacePreset, surfacePersistFor } from './toolbars.js';
import { _editorMaybeStartTour } from './tour.js';
@@ -2058,7 +2058,7 @@ export async function editorDoCreate() {
// 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 (_) {}
+ try { const _on = _ensureOnsetsShifted(); if (_on && _on.length) _firstOnset = _on[0].t; } catch (_) {}
const _nudge = _importBar1NudgePure(_firstDownbeatTimePure(S.beats), _firstOnset);
if (_nudge) _msg = _msg ? (_msg + ' ' + _nudge) : _nudge;
}
@@ -2192,6 +2192,7 @@ export async function editorApplyCreateResult(data) {
S.sections = data.sections || [];
S.duration = data.duration || 0;
S.offset = data.offset || 0;
+ S.audioShift = data.audio_shift || 0;
S.currentArr = 0;
S.sel.clear();
S.toneSel = null;
@@ -2243,6 +2244,7 @@ export async function editorApplyCreateResult(data) {
document.getElementById('editor-play-btn').disabled = !data.audio_url;
document.getElementById('editor-sync-btn').classList.toggle('hidden', !data.audio_url);
document.getElementById('editor-replace-audio-btn').classList.remove('hidden');
+ document.getElementById('editor-shift-audio-btn')?.classList.remove('hidden');
_updateTonesButtonVisibility();
host.updateArrangementSelector();
host.updateStatus();
diff --git a/src/file-ops.js b/src/file-ops.js
index a6017651..4f781b25 100644
--- a/src/file-ops.js
+++ b/src/file-ops.js
@@ -158,6 +158,7 @@ export async function loadCDLC(filename, options = {}) {
S.sections = data.sections || [];
S.duration = data.duration || 0;
S.offset = data.offset || 0;
+ S.audioShift = data.audio_shift || 0;
// Drum tab is loaded server-side when the manifest carries a
// `drum_tab:` key and the file passes schema validation. Treat
// a missing/falsey value as "no drums" so the +Drums modal can
@@ -240,6 +241,7 @@ export async function loadCDLC(filename, options = {}) {
document.getElementById('editor-play-btn').disabled = !data.audio_url;
document.getElementById('editor-sync-btn').classList.toggle('hidden', !data.audio_url);
document.getElementById('editor-replace-audio-btn').classList.remove('hidden');
+ document.getElementById('editor-shift-audio-btn')?.classList.toggle('hidden', !data.audio_url);
_updateTonesButtonVisibility();
host.updateArrangementSelector();
host.updateStatus();
@@ -531,6 +533,10 @@ function _buildSaveBody(forceFullSnapshot) {
chord_templates: arr.chord_templates,
beats: S.beats,
sections: S.sections,
+ // Audio placement shift (recording slid vs. a fixed chart). Sent so the
+ // backend can persist it into the pack manifest as `audio_shift` (read
+ // back on load via data.audio_shift). Harmless if the backend ignores it.
+ audio_shift: Number(S.audioShift) || 0,
// Always ship title/artist so archive saves persist in-session
// metadata edits too. Backend merges with session metadata
// (album/year captured at load time) so all four fields
diff --git a/src/input.js b/src/input.js
index 4bae248d..c46bc91d 100644
--- a/src/input.js
+++ b/src/input.js
@@ -5,7 +5,7 @@
// the commands refresh in the composition root goes through host.
import { AddAnchorCmd, AddHandshapeCmd, AddToneChangeCmd, RemoveAnchorCmd, RemoveHandshapeCmd, RemoveToneChangeCmd, _anchorLaneTopY, _currentAnchorArr, _currentToneArr, _ensureTones, _handshapeLaneTopY, _readAnchorSnapshot, onAnchorLaneContextMenu, onHandshapeLaneContextMenu, onToneLaneContextMenu } from './annotation-lanes.js';
-import { _editBlipAt, _editorToggleFollow, _editorToggleGuideClap, _editorToggleLoopAB, _editorToggleMetronome, _editorToggleOnsetStrip, _editorToggleSnapMode, _ensureOnsets, startPlayback, stopPlayback } from './audio.js';
+import { _editBlipAt, _editorToggleFollow, _editorToggleGuideClap, _editorToggleLoopAB, _editorToggleMetronome, _editorToggleOnsetStrip, _editorToggleSnapMode, _ensureOnsetsShifted, startPlayback, stopPlayback } from './audio.js';
import { _suggestActive, _suggestCompute, _suggestDismiss } from './tempo-suggest.js';
import { editorToggleMixerPanel } from './mixer-panel.js';
import { canvas } from './canvas.js';
@@ -625,7 +625,7 @@ function _editorTempoSuggestFit() {
setStatus('Enter Tempo Map (T) first — Suggest fits the barlines to the recording.');
return true;
}
- const onsets = _ensureOnsets();
+ const onsets = _ensureOnsetsShifted();
if (!onsets || !onsets.length) {
setStatus('Suggest needs the recording’s onset analysis — load audio first.');
return true;
diff --git a/src/loop.js b/src/loop.js
index 9e691b61..484f832b 100644
--- a/src/loop.js
+++ b/src/loop.js
@@ -19,7 +19,8 @@
// Browser surface: the loop strip and its controls.
// ════════════════════════════════════════════════════════════════════
import {
- _abApplyRefGain, _abDisarm, _abOn, _ensureOnsets, _nearestOnsetTimePure, _refreshLoopABBtn,
+ _abApplyRefGain, _abDisarm, _abOn, _audioTimelineDurationPure,
+ _ensureOnsetsShifted, _nearestOnsetTimePure, _refreshLoopABBtn,
} from './audio.js';
import { beatOf, timeOf } from './beats.js';
import { DPR, canvas } from './canvas.js';
@@ -38,7 +39,8 @@ export function _editorViewportDuration() {
}
export function _editorClampScrollX(scrollX) {
- return _editorClampScrollXPure(scrollX, S.duration, _editorViewportDuration(), EDITOR_SCROLL_TAIL_SECONDS);
+ const duration = _audioTimelineDurationPure(S.duration, S.audioShift, S.audioBuffer && S.audioBuffer.duration);
+ return _editorClampScrollXPure(scrollX, duration, _editorViewportDuration(), EDITOR_SCROLL_TAIL_SECONDS);
}
export function _editorApplyScrollBounds() {
@@ -387,7 +389,7 @@ export function snapTime(t) {
// (no warp; just snap placement to the onset time). Falls back to grid snap
// when no onset is near, or none is computed, so placement stays sensible.
if (S.snapEnabled && S.snapMode === 'onset') {
- const onsets = (typeof _ensureOnsets === 'function') ? _ensureOnsets() : null;
+ const onsets = (typeof _ensureOnsetsShifted === 'function') ? _ensureOnsetsShifted() : null;
const near = _nearestOnsetTimePure(onsets, t, ONSET_SNAP_TOL);
if (near !== null) return near;
}
diff --git a/src/main.js b/src/main.js
index 672f846e..24782d18 100644
--- a/src/main.js
+++ b/src/main.js
@@ -10,7 +10,7 @@ import { DPR, canvas, ctx, setCanvas } from './canvas.js';
import {
} from './position.js';
-import { _editorPromptChoice, _installModalKeyboard, setStatus } from './ui.js';
+import { _editorPromptChoice, _editorPromptText, _installModalKeyboard, setStatus } from './ui.js';
import { EditHistory } from './history.js';
import {
_ROLL_REFUSE_REASONS,
@@ -44,7 +44,7 @@ import {
_editBlipAt, _editorToggleGuideClap,
_editorToggleLoopAB, _editorToggleMetronome, _editorToggleOnsetStrip,
_editorToggleSnapMode, _mixLoadPct, cancelAudioLoad, editorEditBlipEnabled,
- editorSetEditBlip, editorSetMixLevel, initAudio, loadAudio,
+ editorSetEditBlip, editorSetMixLevel, editorSetAudioShift, editorNudgeAudioShift, initAudio, loadAudio,
startPlayback, stopPlayback, teardownAudio, editorSetCountIn,
} from './audio.js';
import { _mixerClapState, _mixerPanelRefresh, editorToggleMixerPanel, initMixerPanel } from './mixer-panel.js';
@@ -531,6 +531,21 @@ window.editorSaveAsSloppakConfirm = editorSaveAsSloppakConfirm;
window.editorSaveAs = editorSaveAs;
// Replace-audio modal (replace-audio.js owns the logic; HTML calls these by name).
+window.editorSetAudioShift = editorSetAudioShift;
+window.editorNudgeAudioShift = editorNudgeAudioShift;
+// Slide the recording in time to line it up with the chart (audio moves, chart
+// stays). Prompt is prefilled with the current shift in seconds; +ve = later.
+window.editorPromptAudioShift = async () => {
+ const cur = Number(S.audioShift) || 0;
+ const raw = await _editorPromptText({
+ title: 'Shift audio',
+ label: 'Slide the recording in time (seconds; + = later, − = earlier). The chart stays put.',
+ value: cur ? String(cur) : '',
+ placeholder: 'e.g. 0.20 or -0.05',
+ });
+ if (raw === null) return;
+ editorSetAudioShift(raw);
+};
window.editorShowReplaceAudioModal = editorShowReplaceAudioModal;
window.editorHideReplaceAudioModal = editorHideReplaceAudioModal;
window.editorSetReplaceAudioMode = editorSetReplaceAudioMode;
diff --git a/src/state.js b/src/state.js
index 7dc2d2ef..79c9805f 100644
--- a/src/state.js
+++ b/src/state.js
@@ -18,6 +18,15 @@ export const S = {
// already realigned. TempoOffsetCmd is the only writer (so undo restores it);
// _resetOffsetUI clears it on load. Never written to the pack.
appliedOffset: 0,
+ // Audio placement shift (seconds): slides the RECORDING in time while the
+ // chart/grid/notes stay fixed — the inverse of the chart-side `offset`
+ // above, and non-destructive (the samples are never stretched). At playhead
+ // chart-time T the audio plays buffer-time (T - audioShift); the waveform
+ // and onset strip render shifted to match. One value for the whole audio
+ // group (stems, when added, ride it together). AudioShiftCmd is the writer;
+ // persisted to the pack as `audio_shift` so a Replace-Audio realignment
+ // survives reload.
+ audioShift: 0,
// Selected tone-change marker — stored as a direct ref into the
// active arrangement's `arr.tones.changes` array (not an index)
// so commands that sort/splice that array don't invalidate the
diff --git a/src/sync-tempo.js b/src/sync-tempo.js
index dfe5f91a..28279b75 100644
--- a/src/sync-tempo.js
+++ b/src/sync-tempo.js
@@ -3,7 +3,7 @@
// locked sync points). The window.editor* entry points are re-attached by
// main.js; repaint goes through host.
-import { _ensureOnsets } from './audio.js';
+import { _ensureOnsetsShifted } from './audio.js';
import { S } from './state.js';
import { TempoMapCmd, _respaceWithLocksPure, _tempoPivotTimePure } from './tempo.js';
import { setStatus } from './ui.js';
@@ -203,7 +203,7 @@ export function editorSyncTempo() {
// votes with strengths, and proposes the downbeat PHASE. The raw-buffer
// autocorrelation stays as the fallback for songs with no strip yet.
const onsetGuess = _detectTempoFromOnsetsPure(
- typeof _ensureOnsets === 'function' ? _ensureOnsets() : null);
+ typeof _ensureOnsetsShifted === 'function' ? _ensureOnsetsShifted() : null);
let hint = '';
if (onsetGuess && onsetGuess.confidence >= 0.15) {
syncState.audioBPM = onsetGuess.bpm;
diff --git a/src/waveform.js b/src/waveform.js
index 57369505..44e1d080 100644
--- a/src/waveform.js
+++ b/src/waveform.js
@@ -31,17 +31,20 @@ export function drawWaveform(w) {
const N = pk.bins;
const mid = TIMELINE_TOP + WAVEFORM_H / 2;
const amp = WAVEFORM_H / 2 - 4;
- // Visible pixel span of the audio (clamped to the waveform lane).
- const xLo = Math.max(LABEL_W, Math.floor(timeToX(0)));
- const xHi = Math.min(w, Math.ceil(timeToX(dur)));
+ // Audio placement shift: buffer-time B renders at timeToX(B + sh), so the
+ // waveform slides with the recording while the grid/notes stay put.
+ const sh = Number(S.audioShift) || 0;
+ // Visible pixel span of the (shifted) audio, clamped to the waveform lane.
+ const xLo = Math.max(LABEL_W, Math.floor(timeToX(sh)));
+ const xHi = Math.min(w, Math.ceil(timeToX(dur + sh)));
if (xHi <= xLo) return;
- // Per-column bin range for the pixel [px, px+1). Each column aggregates
- // every bin it spans, so the shape stays correct from full-song zoom-out
- // down to a single bin per pixel.
+ // Per-column bin range for the pixel [px, px+1). Buffer-time at a pixel is
+ // (xToTime(px) - sh). Each column aggregates every bin it spans, so the
+ // shape stays correct from full-song zoom-out down to one bin per pixel.
const binRange = (px) => {
- let i0 = Math.floor(xToTime(px) / dur * N);
- let i1 = Math.floor(xToTime(px + 1) / dur * N);
+ let i0 = Math.floor((xToTime(px) - sh) / dur * N);
+ let i1 = Math.floor((xToTime(px + 1) - sh) / dur * N);
if (i0 < 0) i0 = 0;
if (i1 >= N) i1 = N - 1;
if (i1 < i0) i1 = i0;
@@ -92,20 +95,22 @@ function _drawOnsetStrip(w) {
if (!onsets || !onsets.length) return;
const dur = S.duration || 0;
if (dur <= 0) return;
- const xLo = Math.max(LABEL_W, Math.floor(timeToX(0)));
- const xHi = Math.min(w, Math.ceil(timeToX(dur)));
+ // Onsets are buffer-time; they render shifted with the audio (timeToX(t+sh)).
+ const sh = Number(S.audioShift) || 0;
+ const xLo = Math.max(LABEL_W, Math.floor(timeToX(sh)));
+ const xHi = Math.min(w, Math.ceil(timeToX(dur + sh)));
// onsets are time-sorted and timeToX is monotonic, so the on-screen pixel
// is non-decreasing across the array. Binary-search the first visible
// onset (px >= xLo) and stop at the first past xHi — no full-array scan.
let lo = 0, hi = onsets.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
- if (Math.round(timeToX(onsets[mid].t)) < xLo) lo = mid + 1;
+ if (Math.round(timeToX(onsets[mid].t + sh)) < xLo) lo = mid + 1;
else hi = mid;
}
for (let i = lo; i < onsets.length; i++) {
const o = onsets[i];
- const px = Math.round(timeToX(o.t));
+ const px = Math.round(timeToX(o.t + sh));
if (px > xHi) break;
// Stronger attacks read brighter and taller — quiet ghost hits stay
// visible but understated.
diff --git a/tests/audio_shift.test.mjs b/tests/audio_shift.test.mjs
new file mode 100644
index 00000000..a4fa1a37
--- /dev/null
+++ b/tests/audio_shift.test.mjs
@@ -0,0 +1,92 @@
+/*
+ * Audio placement shift — slide the recording in time while the chart stays fixed.
+ *
+ * Non-destructive: the samples are never stretched; only the buffer read position
+ * (playback) and the waveform/onset rendering move. This suite proves:
+ * 1. _audioBufferStartPure — where/when to start the buffer given the playhead
+ * chart-time, the shift, and the buffer length (incl. the pre-audio delay and
+ * the past-the-end no-source case).
+ * 2. AudioShiftCmd / editorSetAudioShift — the undoable scalar move (1ms
+ * resolution, no-op when unchanged, exec→rollback→redo).
+ *
+ * Run: node tests/audio_shift.test.mjs
+ */
+import assert from 'node:assert';
+import { S } from '../src/state.js';
+import { EditHistory } from '../src/history.js';
+import { _audioBufferStartPure, _audioTimelineDurationPure, AudioShiftCmd, editorSetAudioShift } from '../src/audio.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;
+
+// ── 1. _audioBufferStartPure ─────────────────────────────────────────────────
+t('no shift → play from the cursor, no delay', () => {
+ assert.deepStrictEqual(_audioBufferStartPure(3, 0, 100), { play: true, offset: 3, delay: 0 });
+});
+t('positive shift, cursor past the shift → skip into the buffer', () => {
+ // audio slid +2s later; at chart-time 5 the buffer is at 5-2 = 3.
+ assert.deepStrictEqual(_audioBufferStartPure(5, 2, 100), { play: true, offset: 3, delay: 0 });
+});
+t('positive shift, cursor before the shift → delay the start, buffer 0', () => {
+ // at chart-time 0.5 the audio (slid +2s) has not begun; it begins in 1.5s.
+ const r = _audioBufferStartPure(0.5, 2, 100);
+ assert.ok(r.play && near(r.offset, 0) && near(r.delay, 1.5));
+});
+t('negative shift → play deeper into the buffer immediately', () => {
+ // audio slid 1s EARLIER; at chart-time 3 the buffer is at 3-(-1) = 4.
+ assert.deepStrictEqual(_audioBufferStartPure(3, -1, 100), { play: true, offset: 4, delay: 0 });
+});
+t('past the (shifted) end → no source, transport still runs', () => {
+ // buffer is 10s; at chart-time 12 with no shift the audio has ended.
+ assert.deepStrictEqual(_audioBufferStartPure(12, 0, 10), { play: false, offset: 0, delay: 0 });
+ // a +5s shift pushes the end to chart-time 15, so 12 still plays.
+ assert.deepStrictEqual(_audioBufferStartPure(12, 5, 10), { play: true, offset: 7, delay: 0 });
+});
+t('unknown/zero buffer duration never reports past-the-end', () => {
+ assert.strictEqual(_audioBufferStartPure(999, 0, 0).play, true);
+});
+
+t('_audioTimelineDurationPure extends positive shifts so delayed tails are reachable', () => {
+ assert.strictEqual(_audioTimelineDurationPure(10, 2, 10), 12, 'positive shift extends the timeline');
+ assert.strictEqual(_audioTimelineDurationPure(15, 2, 10), 15, 'existing longer chart still wins');
+ assert.strictEqual(_audioTimelineDurationPure(10, -2, 10), 10, 'negative shift never shrinks the chart');
+ assert.strictEqual(_audioTimelineDurationPure(8, 2, 0), 8, 'no buffer falls back to chart duration');
+});
+
+// ── 2. AudioShiftCmd / editorSetAudioShift ───────────────────────────────────
+function seed() {
+ trackHooks();
+ seedState({ arrangements: [{ name: 'G', notes: [], chords: [] }], currentArr: 0,
+ audioShift: 0, playing: false, history: new EditHistory() });
+}
+t('AudioShiftCmd sets S.audioShift and round-trips exec→undo→redo', () => {
+ seed();
+ S.history.exec(new AudioShiftCmd(0, 0.25));
+ assert.ok(near(S.audioShift, 0.25), 'exec applied the shift');
+ S.history.doUndo();
+ assert.ok(near(S.audioShift, 0), 'undo restored');
+ S.history.doRedo();
+ assert.ok(near(S.audioShift, 0.25), 'redo re-applied');
+});
+t('editorSetAudioShift rounds to 1ms, execs a command, and names the move', () => {
+ seed();
+ editorSetAudioShift('0.2004');
+ assert.ok(near(S.audioShift, 0.2), 'rounded to the millisecond');
+ assert.strictEqual(S.history.undo.length, 1, 'one undoable command');
+ assert.ok(/Audio shifted \+200ms/.test(lastStatus()), 'status names the shift + that the chart is unchanged');
+ assert.ok(/chart unchanged/.test(lastStatus()));
+});
+t('editorSetAudioShift is a no-op when the value is unchanged', () => {
+ seed();
+ S.audioShift = 0.1;
+ editorSetAudioShift('0.1');
+ assert.strictEqual(S.history.undo.length, 0, 'no command pushed for a no-op');
+});
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
diff --git a/tests/onset_snap.test.js b/tests/onset_snap.test.js
index 84c63ac9..d2abadce 100644
--- a/tests/onset_snap.test.js
+++ b/tests/onset_snap.test.js
@@ -53,7 +53,7 @@ const S = { snapEnabled: true, snapMode: 'grid', snapIdx: 0, beats: [{ time: 0 }
let onsets = [];
const snapTime = new Function(
'S', '_editorEffectiveSnapValuePure', 'SNAP_VALUES', '_editorSnapSubdivisionsPure',
- 'timeOf', 'beatOf', '_ensureOnsets', '_nearestOnsetTimePure', 'ONSET_SNAP_TOL',
+ 'timeOf', 'beatOf', '_ensureOnsetsShifted', '_nearestOnsetTimePure', 'ONSET_SNAP_TOL',
'_swingQuantizeBeatPure',
'"use strict";' + extractFn('snapTime') + '\nreturn snapTime;'
)(