fea4fcf145
* feat(playback): count-in before playback and exports, redesign transport footer Count-in (#269): one bar of click count-in leads into playback and into audio exports, independent of the running click track (a clean backing track can still get a count-in). The lead-in math is defined once and mirrored between metronome.js and click_render.py, pinned by parity tests on both sides. - Playback: audioEngine schedules stem playback on a future ctx-time start so the count-in clicks land in the silent gap before the song begins; the metronome schedules them through the same clock mapping the running click already uses. - Export: stems are delayed via ffmpeg's adelay and the click WAV is rendered in output coordinates when a count-in is requested, so it isn't re-trimmed by the region -ss like a plain click. Also rebuilds the transport footer around labelled control groups (Transport, Position, Speed, Click Track) instead of a right-click popover: playback speed collapses to three practice presets (0.25x / 0.5x / 1x), the click track gets an on/off toggle and a count-in switch, and the track-info block collapses from four stacked detail rows to one compact line. * fix(ui): hide click-track panel by default before any track is loaded The panel lost its default "hidden" class when it changed from a right-click popover to always-inline (#269 follow-up) -- on a fresh page load, before any track was ever picked, nothing forced it hidden, so "Ready to import a track" showed a full set of live- looking click controls for a track that didn't exist. * polish(ui): footer wave time labels, orphan dividers, visible click-volume readout - Time labels above the footer's mini waveform, matching the main ruler. - Divider marks between control clusters in the footer's controls row, hidden via ResizeObserver when wrapping strands one at the end of a line with nothing after it to separate. - Click volume percentage shown next to the slider again instead of screen-reader-only -- a level you can only learn by hovering isn't one you can reliably match between sessions. - Count-in switched from a checkbox to a press-to-toggle button, matching the click on/off control beside it (both answer "is this on for the next play?", so they read as the same kind of control now). * fix(playback): count-in never armed on the chunked audio engine The chunked engine is the default playback path (engineMode() falls back to "chunked" unless a debug localStorage flag forces "fulldecode") -- but count-in support (play(leadIn), supportsCountIn, a clamped getCurrentTime during the lead-in) was only ever added to audioEngine.js, the full-decode path. Since _armCountIn() bails out whenever eng.supportsCountIn is falsy, count-in silently never armed for any track played through the engine essentially everyone actually uses, and playback started immediately regardless of the toggle. Mirrors the same fix in chunkedAudioEngine.js: play() accepts a leadIn and schedules the first chunk that far in the future (falling back to the existing 10ms/50ms margins when there is no count-in), and getCurrentTime() clamps to the start offset during that gap instead of reading negative. Verified directly against the running engine clock (not just DOM text, which rounds to whole seconds): the position holds at the start offset for the full lead-in and then advances normally, pausing mid-count-in stops cleanly with no phantom scheduled audio, and replaying re-arms a fresh count-in. * polish(ui): align the footer with the lane column, move track info into it The footer's waveform strip ran the full width of the window while the lane waveforms above it start after the 300px stems/mixer panel, so the same position sat at two different x positions in the two strips and neither ruler's ticks lined up with the other's. The footer is now two columns on the studio's own grid. Everything time-related -- the control clusters, the waveform, its ruler and the detection note -- sits in the right column and starts exactly where the lane waveforms start, running flush to the window edge like they do. The track identity (art, title, meta, favourite, Export Mix) moves into the left column under the mixer panel and shares its width and 14px padding, so titles, stem names and the "Mixer" heading share one left edge down the page. That also drops a whole row from the footer: 255px tall where the three stacked tiers were 318px. - The 300px is now --daw-col-w, read by the stems panel, the label cell above it and the footer, instead of being hardcoded in each. - The waveform strip is full-bleed with top/bottom rules rather than a rounded inset panel: a side border would have offset the canvas by its own width, which is exactly the misalignment being fixed. - Both rulers share tickStep(), so a time is labelled at the same x in each. - The export menu opens up and to the right; right-aligned from the left column it would have hung over the sidebar. Grid becomes a press-to-toggle button matching the click and count-in buttons beside it -- click opens the editor and lights it, click again closes it. Its lit state is synced inside toggleBeatGridEditor, the one place every open and close runs through, so Done, Escape and losing the beat grid all leave the button correct. The G shortcut is gone: the button says what it does now, and a single letter bound to a modal editor is easy to hit by accident. * polish(ui): close the footer waveform strip's open left edge The strip carries only top and bottom rules -- side borders were dropped so the canvas would land exactly on the lane waveforms' left edge -- which left its left end open, the two rules stopping in mid-air. Drawn as an outset box-shadow rather than a border-left: a border sits inside the box and would push the canvas a pixel off the alignment it exists to keep. The line falls on the same x as the stems panel's right border, so that seam now runs unbroken from the top of the mixer to the bottom of the strip. * fix(ui): ticking an export option no longer closes the export menu Every interactive element in the export menu called stopPropagation so the document-level dismiss handler would not fire, but the two option checkboxes had no click handler at all -- so ticking one bubbled out and closed the menu under the pointer. That was survivable with one checkbox. This branch adds a second ("Add count-in"), and wanting both is the normal case for practising to a click: the first tick closed the menu, and the second needed it reopened. Guard the panel itself rather than adding a third per-element stopPropagation that the next option added would forget: a click inside a menu is not a click away from it. Nothing depended on the bubble to close the menu -- the export actions close it themselves through enterBusy() -> closePanel(). --------- Co-authored-by: Thales <>
316 lines
13 KiB
JavaScript
316 lines
13 KiB
JavaScript
// Web Audio decode-and-mix playback engine.
|
|
//
|
|
// Safari/WKWebView goes choppy when playing N streaming <audio> elements (one per
|
|
// stem) over HTTP/1.1: the 6-connection-per-origin cap + small media buffers + the
|
|
// multitrack's per-element currentTime nudging cause underruns. This engine instead
|
|
// decodes each active stem once into an AudioBuffer and plays them all from a single
|
|
// AudioContext clock — sample-accurate, zero streaming connections during playback,
|
|
// no drift. Works identically on WKWebView, Safari, and Chrome.
|
|
//
|
|
// Graph: per-stem AudioBufferSourceNode -> GainNode (vol/mute/solo) -> AnalyserNode (VU)
|
|
// -> masterGain -> SoundTouchNode -> destination
|
|
//
|
|
// Used behind a feature flag (see player.js) so it can be A/B'd against the legacy
|
|
// streaming path before cutover.
|
|
|
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
|
|
/**
|
|
* @param {{name:string,url:string}[]} stems Active stems only (caller filters).
|
|
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void}} cbs
|
|
*/
|
|
export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
|
|
// Mobile/iOS only starts audio from a context resumed inside a user gesture.
|
|
// Callers can pass a shared, gesture-unlocked `context` (the mobile UI does);
|
|
// desktop passes none and we own a fresh one. We only close contexts we own.
|
|
const ctx = context || new AudioCtx();
|
|
const ownsCtx = !context;
|
|
const master = ctx.createGain();
|
|
|
|
// SoundTouch pitch-preserving time-stretch on the master bus.
|
|
// Falls back to tape-effect (playbackRate) if AudioWorklet is unavailable.
|
|
let stNode = null;
|
|
const _workletReady = (ctx.audioWorklet
|
|
? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
|
|
stNode = new AudioWorkletNode(ctx, 'soundtouch-processor');
|
|
master.connect(stNode);
|
|
stNode.connect(ctx.destination);
|
|
}).catch((err) => {
|
|
console.warn('[audioEngine] SoundTouch worklet load failed, using tape-effect fallback:', err);
|
|
master.connect(ctx.destination);
|
|
})
|
|
: Promise.resolve().then(() => { master.connect(ctx.destination); }));
|
|
|
|
/** @type {Map<string,{buffer:AudioBuffer,gain:GainNode,analyser:AnalyserNode,source:AudioBufferSourceNode|null}>} */
|
|
const tracks = new Map();
|
|
let duration = 0;
|
|
let playing = false;
|
|
let startCtxTime = 0; // ctx.currentTime at playback start
|
|
let startOffset = 0; // media offset at that moment
|
|
let rafId = null;
|
|
let destroyed = false;
|
|
let loop = { enabled: false, start: 0, end: 0 };
|
|
let _playbackRate = 1.0;
|
|
// Bumped whenever the media-time -> ctx-time mapping below changes (start,
|
|
// seek, loop jump, rate change, pause). The metronome watches this to know
|
|
// when its already-scheduled clicks are stale and must be torn down.
|
|
let _epoch = 0;
|
|
// Why ready() resolved false, in words fit to show a user. Mirrors the same
|
|
// accessor on the chunked engine so callers need not know which one they hold.
|
|
let _loadError = null;
|
|
|
|
// Decode all stems up front AND load the SoundTouch worklet in parallel.
|
|
// Resolves true once at least one stem is ready (worklet load is best-effort).
|
|
const ready = (async () => {
|
|
// Counted so the failure can name a cause rather than arriving as a silent
|
|
// console warning (#359). A fetch that never landed and a file the decoder
|
|
// rejected are different problems for the user.
|
|
let unreachable = 0;
|
|
let undecodable = 0;
|
|
|
|
await Promise.all([
|
|
_workletReady,
|
|
...stems.map(async (s) => {
|
|
if (!s?.url) return;
|
|
let bytes;
|
|
try {
|
|
const res = await fetch(s.url);
|
|
if (!res.ok) throw new Error(`fetch ${res.status}`);
|
|
bytes = await res.arrayBuffer();
|
|
} catch (e) {
|
|
unreachable++;
|
|
console.warn(`[audioEngine] fetch failed for ${s.name}:`, e);
|
|
return;
|
|
}
|
|
try {
|
|
const buffer = await ctx.decodeAudioData(bytes);
|
|
if (destroyed) return;
|
|
const gain = ctx.createGain();
|
|
const analyser = ctx.createAnalyser();
|
|
analyser.fftSize = 1024;
|
|
gain.connect(analyser);
|
|
analyser.connect(master);
|
|
tracks.set(s.name, { buffer, gain, analyser, source: null });
|
|
duration = Math.max(duration, buffer.duration);
|
|
} catch (e) {
|
|
undecodable++;
|
|
console.warn(`[audioEngine] decode failed for ${s.name}:`, e);
|
|
}
|
|
}),
|
|
]);
|
|
|
|
if (tracks.size === 0) {
|
|
_loadError = undecodable
|
|
? "This track's audio files are in a format StemDeck could not read."
|
|
: unreachable
|
|
? "Could not load this track's audio files."
|
|
: "This track has no stem files to play.";
|
|
}
|
|
return tracks.size > 0;
|
|
})();
|
|
|
|
// Clamped so the reported playhead never reads before the start position.
|
|
// During a count-in the sources are scheduled to begin in the future
|
|
// (startCtxTime > ctx.currentTime), which would otherwise make this go
|
|
// negative -- the playhead must sit still at the start until the audio enters.
|
|
// A no-op for a normal start, where startCtxTime == the moment play() ran.
|
|
const now = () =>
|
|
playing ? Math.max(startOffset, (ctx.currentTime - startCtxTime) * _playbackRate + startOffset) : startOffset;
|
|
|
|
// Extra headroom folded into a count-in's lead so every count click lands
|
|
// safely in the future even after the small gap between scheduling the
|
|
// sources and handing the clicks to the audio clock.
|
|
const COUNT_IN_MARGIN = 0.06;
|
|
|
|
function stopSources() {
|
|
for (const t of tracks.values()) {
|
|
if (t.source) {
|
|
try { t.source.stop(); } catch { /* already stopped */ }
|
|
try { t.source.disconnect(); } catch { /* noop */ }
|
|
t.source = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function startSources(offset, when = ctx.currentTime) {
|
|
for (const t of tracks.values()) {
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = t.buffer;
|
|
// SoundTouch handles time-stretch; playbackRate stays 1.0.
|
|
// Falls back to tape-effect only when the worklet is unavailable.
|
|
if (!stNode) src.playbackRate.value = _playbackRate;
|
|
src.connect(t.gain);
|
|
src.start(when, Math.max(0, Math.min(offset, t.buffer.duration)));
|
|
t.source = src;
|
|
}
|
|
startCtxTime = when;
|
|
startOffset = offset;
|
|
_epoch++;
|
|
}
|
|
|
|
// Rate at which the source nodes consume their buffers. With SoundTouch
|
|
// mounted they always run at 1.0 and the worklet does the stretching; the
|
|
// tape-effect fallback resamples the sources themselves.
|
|
const srcRate = () => (stNode ? 1 : _playbackRate);
|
|
|
|
// Inverse of the scheduling in startSources: the AudioContext time at which
|
|
// media time `t` is fed into the graph. Scheduling a click here puts it in
|
|
// the same sample frame as the stems, which is what keeps the two locked
|
|
// together -- and because it describes the *input* to SoundTouch, it holds
|
|
// whatever the worklet does downstream, since the click goes through it too.
|
|
const sourceTimeToCtxTime = (t) => startCtxTime + (t - startOffset) / srcRate();
|
|
|
|
// True inverse of the above. The metronome re-anchors with this rather than
|
|
// getCurrentTime(): that reports the *output* playhead for the UI, which with
|
|
// SoundTouch mounted is a different quantity from where the sources have
|
|
// actually been read to. Anchoring the click cursor in the source domain
|
|
// keeps it consistent with the times it schedules against.
|
|
const ctxTimeToSourceTime = (c) => startOffset + (c - startCtxTime) * srcRate();
|
|
|
|
function tick() {
|
|
if (!playing) return;
|
|
let t = now();
|
|
if (loop.enabled && loop.end > loop.start && t >= loop.end) {
|
|
seek(loop.start);
|
|
t = loop.start;
|
|
} else if (t >= duration) {
|
|
pause();
|
|
startOffset = duration;
|
|
onTime?.(duration);
|
|
onEnded?.();
|
|
return;
|
|
}
|
|
onTime?.(t);
|
|
rafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
// `leadIn` (source seconds, default 0) delays the moment the stems begin so a
|
|
// count-in can sound in the gap first. The sources are scheduled at a future
|
|
// ctx time; the count-in clicks (negative source time) map into `[now, when]`
|
|
// through the same sourceTimeToCtxTime the metronome uses, so they stay locked
|
|
// to the audio. See transport.togglePlayPause + metronome.playCountIn.
|
|
function play(leadIn = 0) {
|
|
if (playing || destroyed || !tracks.size) return;
|
|
// Safari: resume the context fire-and-forget within the user-gesture tick.
|
|
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
|
let off = startOffset;
|
|
if (off >= duration) off = 0;
|
|
const lead = Math.max(0, leadIn);
|
|
const when = ctx.currentTime + (lead > 0 ? (lead + COUNT_IN_MARGIN) / srcRate() : 0);
|
|
startSources(off, when);
|
|
playing = true;
|
|
rafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
function pause() {
|
|
if (!playing) return;
|
|
const t = now();
|
|
stopSources();
|
|
playing = false;
|
|
startOffset = Math.max(0, Math.min(t, duration));
|
|
_epoch++;
|
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
}
|
|
|
|
function seek(t) {
|
|
const clamped = Math.max(0, Math.min(t, duration || 0));
|
|
if (playing) {
|
|
stopSources();
|
|
startSources(clamped); // bumps _epoch
|
|
} else {
|
|
startOffset = clamped;
|
|
_epoch++;
|
|
}
|
|
onTime?.(clamped);
|
|
}
|
|
|
|
function setGain(name, v) {
|
|
const t = tracks.get(name);
|
|
if (t) t.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
|
|
}
|
|
|
|
function setMasterGain(v) {
|
|
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
|
|
}
|
|
|
|
function destroy() {
|
|
destroyed = true;
|
|
stopSources();
|
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
tracks.clear();
|
|
if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
|
|
if (ownsCtx) ctx.close().catch(() => {});
|
|
}
|
|
|
|
return {
|
|
ready,
|
|
getLoadError: () => _loadError,
|
|
play,
|
|
pause,
|
|
seek,
|
|
setTime: seek, // alias to match the multitrack interface used by transport.js
|
|
isPlaying: () => playing,
|
|
// This engine honours play(leadIn) for a count-in; the streaming/chunked
|
|
// paths do not, so the transport checks this before scheduling one.
|
|
supportsCountIn: true,
|
|
getCurrentTime: now,
|
|
getDuration: () => duration,
|
|
setLoop: (enabled, start, end) => { loop = { enabled, start, end }; },
|
|
// Metronome support. sourceTimeToCtxTime is the contract that keeps the
|
|
// click locked to the stems; the epoch tells the scheduler when to discard
|
|
// clicks it already queued, and isClockReady guards the window where
|
|
// `playing` is set but the mapping is not yet valid.
|
|
sourceTimeToCtxTime,
|
|
ctxTimeToSourceTime,
|
|
getScheduleEpoch: () => _epoch,
|
|
isClockReady: () => playing,
|
|
// The click connects here, not to ctx.destination: same bus as the stems,
|
|
// so it inherits master gain and the identical SoundTouch path.
|
|
getMasterNode: () => master,
|
|
setPlaybackRate(rate) {
|
|
if (stNode) {
|
|
// Pitch-preserving: update SoundTouch tempo parameter. The sources keep
|
|
// running at 1.0x, so the source-domain clock anchor is untouched.
|
|
_playbackRate = rate;
|
|
_epoch++;
|
|
stNode.parameters.get('tempo').value = rate;
|
|
return;
|
|
}
|
|
// Tape-effect fallback: the sources themselves resample, so the new rate
|
|
// only applies from this instant -- but startCtxTime/startOffset still
|
|
// describe the old one. Re-anchoring at the current position keeps
|
|
// sourceTimeToCtxTime a true inverse of the running sources; without it
|
|
// every click scheduled after a speed change drifts. chunkedAudioEngine
|
|
// re-seeks here for exactly the same reason.
|
|
const t = now();
|
|
_playbackRate = rate;
|
|
if (playing) {
|
|
stopSources();
|
|
startSources(Math.max(0, Math.min(t, duration))); // bumps _epoch
|
|
} else {
|
|
startOffset = t;
|
|
_epoch++;
|
|
}
|
|
},
|
|
setGain,
|
|
setMasterGain,
|
|
getAnalyser: (name) => tracks.get(name)?.analyser ?? null,
|
|
// Decoded AudioBuffers keyed by stem name — reused by the visuals (overview
|
|
// waveforms, mini-waves, VU envelopes, energy bars) so they don't need the
|
|
// multitrack to also decode the audio. Map<name, AudioBuffer>.
|
|
getBuffers: () => {
|
|
const m = new Map();
|
|
for (const [name, t] of tracks) m.set(name, t.buffer);
|
|
return m;
|
|
},
|
|
destroy,
|
|
audioContext: ctx,
|
|
};
|
|
}
|
|
|
|
// Rough decoded-PCM memory estimate (Float32 = 4 bytes/sample/channel) used by the
|
|
// caller's guard to fall back to streaming for very long / many-stem tracks.
|
|
export function estimateDecodedBytes(durationSec, stemCount, channels = 2, sampleRate = 44100) {
|
|
return Math.round(durationSec * stemCount * channels * sampleRate * 4);
|
|
}
|