Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:09 +08:00

219 lines
7.6 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;
// 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 () => {
await Promise.all([
_workletReady,
...stems.map(async (s) => {
if (!s?.url) return;
try {
const res = await fetch(s.url);
if (!res.ok) throw new Error(`fetch ${res.status}`);
const buffer = await ctx.decodeAudioData(await res.arrayBuffer());
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) {
console.warn(`[audioEngine] decode failed for ${s.name}:`, e);
}
}),
]);
return tracks.size > 0;
})();
const now = () => (playing ? (ctx.currentTime - startCtxTime) * _playbackRate + startOffset : startOffset);
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) {
const 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;
}
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);
}
function play() {
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;
startSources(off);
playing = true;
rafId = requestAnimationFrame(tick);
}
function pause() {
if (!playing) return;
const t = now();
stopSources();
playing = false;
startOffset = Math.max(0, Math.min(t, duration));
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
function seek(t) {
const clamped = Math.max(0, Math.min(t, duration || 0));
if (playing) {
stopSources();
startSources(clamped);
} else {
startOffset = clamped;
}
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,
play,
pause,
seek,
setTime: seek, // alias to match the multitrack interface used by transport.js
isPlaying: () => playing,
getCurrentTime: now,
getDuration: () => duration,
setLoop: (enabled, start, end) => { loop = { enabled, start, end }; },
setPlaybackRate(rate) {
_playbackRate = rate;
if (stNode) {
// Pitch-preserving: update SoundTouch tempo parameter
stNode.parameters.get('tempo').value = rate;
} else {
// Tape-effect fallback
for (const t of tracks.values()) {
if (t.source) t.source.playbackRate.value = rate;
}
}
},
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);
}