8f10353f0c
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
443 lines
15 KiB
JavaScript
443 lines
15 KiB
JavaScript
// Chunked audio engine for the mobile player.
|
|
//
|
|
// Fetches WAV stems in fixed-size windows via HTTP Range requests and chains
|
|
// AudioBufferSourceNodes back-to-back for gapless playback. Compared to the
|
|
// full-decode engine (audioEngine.js):
|
|
// - First audio after ~7 MB download (one 10-second chunk per 4 stems on WiFi)
|
|
// instead of waiting for the complete file
|
|
// - Peak RAM ~28 MB vs ~420 MB for a 5-minute 4-stem track
|
|
// - No track-length cap
|
|
// - Same glitch-free behavior on Safari/WKWebView: AudioBufferSourceNode,
|
|
// no streaming elements, no HTTP/1.1 connection-cap underruns
|
|
//
|
|
// 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 -> SoundTouchNode -> destination
|
|
|
|
const CHUNK_SEC = 5; // seconds of audio per chunk
|
|
const LOOKAHEAD_SEC = 12; // schedule next chunk this far ahead of playhead
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WAV parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function _parseWavHeader(buf) {
|
|
const view = new DataView(buf);
|
|
const tag = (off) => String.fromCharCode(...new Uint8Array(buf, off, 4));
|
|
|
|
if (tag(0) !== "RIFF" || tag(8) !== "WAVE") return null;
|
|
|
|
let audioFormat = 1, channels = 2, sampleRate = 44100, bitsPerSample = 16;
|
|
let dataOffset = -1, dataSize = 0;
|
|
|
|
let off = 12;
|
|
while (off + 8 <= buf.byteLength) {
|
|
const id = tag(off);
|
|
const size = view.getUint32(off + 4, true);
|
|
if (id === "fmt ") {
|
|
audioFormat = view.getUint16(off + 8, true);
|
|
channels = view.getUint16(off + 10, true);
|
|
sampleRate = view.getUint32(off + 12, true);
|
|
bitsPerSample = view.getUint16(off + 22, true);
|
|
} else if (id === "data") {
|
|
dataOffset = off + 8;
|
|
dataSize = size;
|
|
break;
|
|
}
|
|
off += 8 + size + (size & 1); // chunks are word-aligned
|
|
}
|
|
|
|
if (dataOffset < 0) return null;
|
|
|
|
const bytesPerFrame = channels * (bitsPerSample >> 3);
|
|
return {
|
|
audioFormat, channels, sampleRate, bitsPerSample,
|
|
dataOffset, dataSize, bytesPerFrame,
|
|
duration: dataSize / (bytesPerFrame * sampleRate),
|
|
};
|
|
}
|
|
|
|
// Convert raw interleaved PCM bytes to an AudioBuffer.
|
|
// Fast paths for the common cases (stereo 16-bit, stereo float32).
|
|
function _pcmToAudioBuffer(ctx, pcmData, header) {
|
|
const { channels, sampleRate, bitsPerSample, audioFormat } = header;
|
|
const totalSamples = Math.floor(pcmData.byteLength / (channels * (bitsPerSample >> 3)));
|
|
if (totalSamples === 0) return null;
|
|
|
|
const ab = ctx.createBuffer(channels, totalSamples, sampleRate);
|
|
|
|
if (bitsPerSample === 16) {
|
|
const src = new Int16Array(pcmData);
|
|
const scale = 1 / 32768;
|
|
if (channels === 2) {
|
|
const ch0 = ab.getChannelData(0);
|
|
const ch1 = ab.getChannelData(1);
|
|
for (let i = 0, j = 0; i < totalSamples; i++, j += 2) {
|
|
ch0[i] = src[j] * scale;
|
|
ch1[i] = src[j + 1] * scale;
|
|
}
|
|
} else {
|
|
for (let ch = 0; ch < channels; ch++) {
|
|
const out = ab.getChannelData(ch);
|
|
for (let i = 0; i < totalSamples; i++) out[i] = src[i * channels + ch] * scale;
|
|
}
|
|
}
|
|
} else if (audioFormat === 3 && bitsPerSample === 32) {
|
|
const src = new Float32Array(pcmData);
|
|
if (channels === 2) {
|
|
const ch0 = ab.getChannelData(0);
|
|
const ch1 = ab.getChannelData(1);
|
|
for (let i = 0, j = 0; i < totalSamples; i++, j += 2) {
|
|
ch0[i] = src[j];
|
|
ch1[i] = src[j + 1];
|
|
}
|
|
} else {
|
|
for (let ch = 0; ch < channels; ch++) {
|
|
const out = ab.getChannelData(ch);
|
|
for (let i = 0; i < totalSamples; i++) out[i] = src[i * channels + ch];
|
|
}
|
|
}
|
|
} else {
|
|
return null; // unsupported format
|
|
}
|
|
|
|
return ab;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Engine factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {{name:string,url:string}[]} stems Active stems (WAV URLs).
|
|
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void, context?:AudioContext}} opts
|
|
*/
|
|
export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {}) {
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
const ctx = context || new AC();
|
|
const ownsCtx = !context;
|
|
const master = ctx.createGain();
|
|
|
|
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();
|
|
for (const s of stems) {
|
|
if (!s?.url) continue;
|
|
const gain = ctx.createGain();
|
|
gain.connect(master);
|
|
stemMap.set(s.name, { url: s.url, header: null, gain, activeNodes: [] });
|
|
}
|
|
|
|
let _duration = 0;
|
|
let playing = false;
|
|
let destroyed = false;
|
|
let rafId = null;
|
|
|
|
// Playback clock: getCurrentTime = ctx.currentTime - _startCtxTime + _startOffset
|
|
let _startCtxTime = 0;
|
|
let _startOffset = 0;
|
|
// _scheduledTo: track position (seconds) up to which AudioBufferSourceNodes
|
|
// have already been scheduled. Always sits at a chunk boundary after play().
|
|
let _scheduledTo = 0;
|
|
// True once the first AudioBufferSourceNode is actually queued; guards
|
|
// getCurrentTime() from advancing during an async chunk fetch.
|
|
let _audioStarted = false;
|
|
let _filling = false; // prevents concurrent _scheduleNext() calls
|
|
|
|
// Chunk cache: chunkIdx -> { promise: Promise<Map>, result: Map|null }
|
|
// result is set synchronously once the promise resolves so play() can
|
|
// schedule chunk 0 without an async await after ready() completes.
|
|
const _cache = new Map();
|
|
|
|
function _getCurrentTime() {
|
|
if (!playing || !_audioStarted) return _startOffset;
|
|
return Math.min((ctx.currentTime - _startCtxTime) * _playbackRate + _startOffset, _duration);
|
|
}
|
|
|
|
// --- fetch helpers ---
|
|
|
|
async function _fetchHeader(url) {
|
|
const res = await fetch(url, { headers: { Range: "bytes=0-1023" } });
|
|
const buf = await res.arrayBuffer();
|
|
return _parseWavHeader(buf);
|
|
}
|
|
|
|
async function _fetchPcm(stem, chunkIdx) {
|
|
const { url, header } = stem;
|
|
const { dataOffset, dataSize, bytesPerFrame, sampleRate } = header;
|
|
const chunkBytes = Math.floor(CHUNK_SEC * sampleRate) * bytesPerFrame;
|
|
const byteStart = dataOffset + chunkIdx * chunkBytes;
|
|
if (byteStart >= dataOffset + dataSize) return null; // past end of file
|
|
const byteEnd = Math.min(byteStart + chunkBytes, dataOffset + dataSize) - 1;
|
|
const res = await fetch(url, { headers: { Range: `bytes=${byteStart}-${byteEnd}` } });
|
|
if (!res.ok && res.status !== 206) throw new Error(`Range fetch ${res.status}`);
|
|
return res.arrayBuffer();
|
|
}
|
|
|
|
// Returns a Promise<Map<name, AudioBuffer>>. Deduplicates: if a fetch for
|
|
// chunkIdx is already in flight, returns the same promise.
|
|
function _fetchChunk(chunkIdx) {
|
|
const hit = _cache.get(chunkIdx);
|
|
if (hit) return hit.promise;
|
|
|
|
const entry = { promise: null, result: null };
|
|
entry.promise = (async () => {
|
|
const pairs = await Promise.all(
|
|
[...stemMap.entries()]
|
|
.filter(([, s]) => s.header)
|
|
.map(async ([name, stem]) => {
|
|
try {
|
|
const pcm = await _fetchPcm(stem, chunkIdx);
|
|
if (!pcm) return [name, null];
|
|
return [name, _pcmToAudioBuffer(ctx, pcm, stem.header)];
|
|
} catch (e) {
|
|
console.warn(`[chunked] chunk ${chunkIdx} stem ${name}:`, e);
|
|
return [name, null];
|
|
}
|
|
})
|
|
);
|
|
const map = new Map(pairs.filter(([, b]) => b));
|
|
entry.result = map;
|
|
// Keep at most the previous chunk + current in cache to bound memory.
|
|
for (const k of _cache.keys()) {
|
|
if (k < chunkIdx - 1) _cache.delete(k);
|
|
}
|
|
return map;
|
|
})();
|
|
|
|
_cache.set(chunkIdx, entry);
|
|
return entry.promise;
|
|
}
|
|
|
|
// --- node lifecycle ---
|
|
|
|
function _stopNodes() {
|
|
for (const stem of stemMap.values()) {
|
|
for (const node of stem.activeNodes) {
|
|
try { node.stop(); } catch { /* already stopped */ }
|
|
try { node.disconnect(); } catch { /* noop */ }
|
|
}
|
|
stem.activeNodes = [];
|
|
}
|
|
}
|
|
|
|
// Schedule all stems' AudioBufferSourceNodes to start at `when` (AudioContext
|
|
// time), beginning `startSecs` into each buffer. Returns the duration of audio
|
|
// that will play (max over stems of buffer.duration - startSecs).
|
|
function _scheduleChunk(buffers, when, startSecs) {
|
|
let playDur = 0;
|
|
for (const [name, stem] of stemMap) {
|
|
const buf = buffers.get(name);
|
|
if (!buf) continue;
|
|
const node = ctx.createBufferSource();
|
|
node.buffer = buf;
|
|
if (!stNode) node.playbackRate.value = _playbackRate; // tape-effect fallback
|
|
node.connect(stem.gain);
|
|
const offset = Math.max(0, Math.min(startSecs, buf.duration - 0.001));
|
|
node.start(when, offset);
|
|
stem.activeNodes.push(node);
|
|
playDur = Math.max(playDur, buf.duration - offset);
|
|
}
|
|
return playDur;
|
|
}
|
|
|
|
// --- lookahead scheduler ---
|
|
|
|
async function _scheduleNext() {
|
|
const chunkIdx = Math.floor(_scheduledTo / CHUNK_SEC);
|
|
_fetchChunk(chunkIdx + 1); // fire-and-forget pre-fetch of the chunk after
|
|
|
|
// Use the synchronous result if already decoded, otherwise await.
|
|
const hit = _cache.get(chunkIdx);
|
|
const buffers = hit?.result ?? await _fetchChunk(chunkIdx);
|
|
if (!playing || destroyed) return;
|
|
if (!buffers || buffers.size === 0) return; // past end; tick() handles onEnded
|
|
|
|
// With SoundTouch, sources play at 1.0x so scheduled wall-clock time equals
|
|
// audio time. With tape-effect (_playbackRate != 1, no stNode), sources play
|
|
// faster/slower so wall-clock time = audio seconds / _playbackRate.
|
|
const playFactor = stNode ? 1.0 : _playbackRate;
|
|
const idealWhen = _startCtxTime + (_scheduledTo - _startOffset) / playFactor;
|
|
const when = Math.max(idealWhen, ctx.currentTime + 0.01);
|
|
// If we're late (slow network), skip the audio portion that already "passed".
|
|
const firstBuf = buffers.values().next().value;
|
|
const maxSkip = firstBuf ? Math.max(0, firstBuf.duration - 0.001) : 0;
|
|
const bufOffset = Math.min(Math.max(0, when - idealWhen) * playFactor, maxSkip);
|
|
|
|
const dur = _scheduleChunk(buffers, when, bufOffset);
|
|
_scheduledTo += bufOffset + dur; // always advances by ~CHUNK_SEC
|
|
}
|
|
|
|
function _maybeSchedule() {
|
|
if (_filling || !playing || destroyed) return;
|
|
if (_scheduledTo >= _duration) return;
|
|
if (_scheduledTo - _getCurrentTime() < LOOKAHEAD_SEC) {
|
|
_filling = true;
|
|
_scheduleNext().finally(() => { _filling = false; });
|
|
}
|
|
}
|
|
|
|
function _tick() {
|
|
if (!playing) return;
|
|
const t = _getCurrentTime();
|
|
if (t >= _duration) {
|
|
playing = false;
|
|
_audioStarted = false;
|
|
_startOffset = _duration;
|
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
onTime?.(_duration);
|
|
onEnded?.();
|
|
return;
|
|
}
|
|
_maybeSchedule();
|
|
onTime?.(t);
|
|
rafId = requestAnimationFrame(_tick);
|
|
}
|
|
|
|
// --- public API ---
|
|
|
|
function play() {
|
|
if (playing || destroyed) return;
|
|
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
|
playing = true;
|
|
|
|
const chunkIdx = Math.floor(_startOffset / CHUNK_SEC);
|
|
const offsetWithin = _startOffset - chunkIdx * CHUNK_SEC;
|
|
|
|
const startWith = (buffers) => {
|
|
if (!playing || destroyed) return;
|
|
const when = ctx.currentTime + 0.05;
|
|
_startCtxTime = when;
|
|
const dur = _scheduleChunk(buffers, when, offsetWithin);
|
|
_scheduledTo = _startOffset + dur;
|
|
_audioStarted = true;
|
|
_fetchChunk(chunkIdx + 1); // pre-fetch next chunk
|
|
rafId = requestAnimationFrame(_tick);
|
|
};
|
|
|
|
// chunk 0 is pre-decoded during ready(), so the sync path is the hot path.
|
|
const hit = _cache.get(chunkIdx);
|
|
if (hit?.result) {
|
|
startWith(hit.result);
|
|
} else {
|
|
_fetchChunk(chunkIdx)
|
|
.then(startWith)
|
|
.catch((e) => {
|
|
console.warn("[chunked] play fetch failed:", e);
|
|
playing = false;
|
|
_audioStarted = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
function pause() {
|
|
if (!playing) return;
|
|
_startOffset = _getCurrentTime();
|
|
_stopNodes();
|
|
playing = false;
|
|
_audioStarted = false;
|
|
_scheduledTo = _startOffset;
|
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
}
|
|
|
|
function seek(t) {
|
|
const clamped = Math.max(0, Math.min(t, _duration || 0));
|
|
const wasPlaying = playing;
|
|
if (wasPlaying) {
|
|
_stopNodes();
|
|
playing = false;
|
|
_audioStarted = false;
|
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
}
|
|
_startOffset = clamped;
|
|
_scheduledTo = clamped;
|
|
// Evict cache for chunks before the new position.
|
|
const newIdx = Math.floor(clamped / CHUNK_SEC);
|
|
for (const k of _cache.keys()) {
|
|
if (k < newIdx) _cache.delete(k);
|
|
}
|
|
onTime?.(clamped);
|
|
if (wasPlaying) play();
|
|
}
|
|
|
|
// 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([
|
|
_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;
|
|
|
|
// Kick off chunk 0 and 1 in the background; play() picks up the cached result.
|
|
_fetchChunk(0);
|
|
_fetchChunk(1);
|
|
return true;
|
|
})();
|
|
|
|
return {
|
|
ready,
|
|
play,
|
|
pause,
|
|
seek,
|
|
setTime: seek,
|
|
isPlaying: () => playing,
|
|
getCurrentTime: _getCurrentTime,
|
|
getDuration: () => _duration,
|
|
setLoop: () => {},
|
|
setGain(name, v) {
|
|
const stem = stemMap.get(name);
|
|
if (stem) stem.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
|
|
},
|
|
setMasterGain(v) {
|
|
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
|
|
},
|
|
setPlaybackRate(rate) {
|
|
const t = _getCurrentTime(); // capture before updating rate
|
|
_playbackRate = rate;
|
|
if (stNode) {
|
|
stNode.parameters.get('tempo').value = rate;
|
|
} else if (playing) {
|
|
// Tape-effect fallback: seek to current position so new source nodes
|
|
// are created with the updated playbackRate and scheduling math resets.
|
|
seek(t);
|
|
}
|
|
},
|
|
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(() => {});
|
|
},
|
|
audioContext: ctx,
|
|
};
|
|
}
|