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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<button id="editor-create-btn" onclick="editorShowCreateModal()" class="px-3 py-1 bg-green-800 hover:bg-green-700 rounded text-xs font-medium" title="Start a new song">New…</button>
<button id="editor-save-btn" onclick="editorSave()" class="px-3 py-1 bg-accent hover:bg-accent-light rounded text-xs font-medium" disabled>Save</button>
<button id="editor-replace-audio-btn" onclick="editorShowReplaceAudioModal()" class="px-3 py-1 bg-dark-600 hover:bg-dark-500 rounded text-xs font-medium hidden" title="Replace the audio track">Replace Audio</button>
<button id="editor-shift-audio-btn" onclick="editorPromptAudioShift()" class="px-3 py-1 bg-dark-600 hover:bg-dark-500 rounded text-xs font-medium hidden" title="Slide the recording in time to line it up with the chart — the audio moves, the chart stays put (non-destructive, undoable)">Shift Audio…</button>
<button id="editor-build-btn" onclick="editorBuild()" class="px-3 py-1 bg-purple-800 hover:bg-purple-700 rounded text-xs font-medium hidden" title="Assemble the finished .feedpak package">Build feedpak</button>
</div>
<!-- Parts -->
Expand Down
118 changes: 104 additions & 14 deletions src/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
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';
Expand Down Expand Up @@ -1583,7 +1583,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 1586 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 @@ -1756,7 +1756,7 @@
createState.lastSync = { ...createState.lastSync, ...data };
}
return data;
} catch (e) {

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
return null;
}
}
Expand Down Expand Up @@ -2058,7 +2058,7 @@
// 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;
}
Expand Down Expand Up @@ -2192,6 +2192,7 @@
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;
Expand Down Expand Up @@ -2243,6 +2244,7 @@
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();
Expand Down
6 changes: 6 additions & 0 deletions src/file-ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions src/loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
}
Expand Down
19 changes: 17 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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,
Expand Down Expand Up @@ -44,7 +44,7 @@
_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';
Expand Down Expand Up @@ -531,6 +531,21 @@
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;
Expand Down Expand Up @@ -1683,7 +1698,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 1701 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
Loading
Loading