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;' )(