From 7b21def5a4e5125f562e2b903772cd1cf6162d42 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:50:39 +0100 Subject: [PATCH] fix(mobile): fast track load + pitch-preserving speed on chunked engine Three changes to chunkedAudioEngine.js: - CHUNK_SEC 10 -> 5: halves the initial chunk download (~10 MB -> ~5 MB for 6 stems), reducing the first-play buffering time. - ready() no longer blocks on chunk 0 download. It resolves after the parallel WAV header fetches (~6 x 1 KB) and kicks chunk 0/1 off in the background. play() already handled the not-yet-cached case, so first play is gapless once the background fetch finishes. Track appears ready in the UI in ~100 ms instead of several seconds. - Add SoundTouch WSOLA AudioWorklet on the master bus (same pattern as audioEngine.js) and expose setPlaybackRate(). The mobile speed slider was already wired to call this method, but it was missing from the API so the control silently did nothing. Pitch is now preserved at all speeds on mobile. --- static/js/chunkedAudioEngine.js | 50 +++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js index 025e108..c6c5679 100644 --- a/static/js/chunkedAudioEngine.js +++ b/static/js/chunkedAudioEngine.js @@ -13,9 +13,9 @@ // The backend's FileResponse already handles Range requests natively (Starlette // 1.3.x), so no server-side changes are needed. // -// Graph: per-stem AudioBufferSourceNode -> GainNode -> masterGain -> destination +// Graph: per-stem AudioBufferSourceNode -> GainNode -> masterGain -> SoundTouchNode -> destination -const CHUNK_SEC = 10; // seconds of audio per chunk +const CHUNK_SEC = 5; // seconds of audio per chunk const LOOKAHEAD_SEC = 12; // schedule next chunk this far ahead of playhead // --------------------------------------------------------------------------- @@ -118,7 +118,19 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { const ctx = context || new AC(); const ownsCtx = !context; const master = ctx.createGain(); - master.connect(ctx.destination); + + let stNode = null; + let _playbackRate = 1.0; + 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('[chunkedEngine] SoundTouch worklet failed, tape-effect fallback:', err); + master.connect(ctx.destination); + }) + : Promise.resolve().then(() => { master.connect(ctx.destination); })); // Per-stem state: url, parsed WAV header, gain node, currently playing nodes const stemMap = new Map(); @@ -152,7 +164,7 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { function _getCurrentTime() { if (!playing || !_audioStarted) return _startOffset; - return Math.min(ctx.currentTime - _startCtxTime + _startOffset, _duration); + return Math.min((ctx.currentTime - _startCtxTime) * _playbackRate + _startOffset, _duration); } // --- fetch helpers --- @@ -356,28 +368,29 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { if (wasPlaying) play(); } - // Initialize: fetch all WAV headers in parallel, decode chunk 0, pre-fetch - // chunk 1. Resolves true once at least one stem is ready. + // Initialize: fetch all WAV headers in parallel and load the SoundTouch worklet. + // Chunk 0 is kicked off in the background so ready() resolves quickly (headers + // only, ~6 x 1 KB) instead of blocking on the full first-chunk download (~5 MB). + // play() handles the case where chunk 0 is not yet cached. const ready = (async () => { if (!stemMap.size) return false; - await Promise.all( - [...stemMap.values()].map(async (stem) => { + await Promise.all([ + _workletReady, + ...[...stemMap.values()].map(async (stem) => { try { stem.header = await _fetchHeader(stem.url); } catch (e) { console.warn("[chunked] header fetch failed:", e); } - }) - ); + }), + ]); for (const stem of stemMap.values()) { if (stem.header) _duration = Math.max(_duration, stem.header.duration); } if (!_duration) return false; - // Pre-decode chunk 0 so play() can schedule it without awaiting. - const chunk0 = await _fetchChunk(0); - if (!chunk0.size) return false; - - _fetchChunk(1); // pre-fetch chunk 1 in background + // Kick off chunk 0 and 1 in the background; play() picks up the cached result. + _fetchChunk(0); + _fetchChunk(1); return true; })(); @@ -398,11 +411,18 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { setMasterGain(v) { master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01); }, + setPlaybackRate(rate) { + _playbackRate = rate; + if (stNode) { + stNode.parameters.get('tempo').value = rate; + } + }, getAnalyser: () => null, getBuffers: () => new Map(), destroy() { destroyed = true; if (playing) pause(); + if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } } _cache.clear(); stemMap.clear(); if (ownsCtx) ctx.close().catch(() => {});