(stems, { onTime, onEnded, context } = {})
| 114 | * @param {{onTime?:(t:number)=>void, onEnded?:()=>void, context?:AudioContext}} opts |
| 115 | */ |
| 116 | export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {}) { |
| 117 | const AC = window.AudioContext || window.webkitAudioContext; |
| 118 | const ctx = context || new AC(); |
| 119 | const ownsCtx = !context; |
| 120 | const master = ctx.createGain(); |
| 121 | |
| 122 | let stNode = null; |
| 123 | let _playbackRate = 1.0; |
| 124 | const _workletReady = (ctx.audioWorklet |
| 125 | ? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => { |
| 126 | stNode = new AudioWorkletNode(ctx, 'soundtouch-processor'); |
| 127 | master.connect(stNode); |
| 128 | stNode.connect(ctx.destination); |
| 129 | }).catch((err) => { |
| 130 | console.warn('[chunkedEngine] SoundTouch worklet failed, tape-effect fallback:', err); |
| 131 | master.connect(ctx.destination); |
| 132 | }) |
| 133 | : Promise.resolve().then(() => { master.connect(ctx.destination); })); |
| 134 | |
| 135 | // Per-stem state: url, parsed WAV header, gain node, currently playing nodes |
| 136 | const stemMap = new Map(); |
| 137 | for (const s of stems) { |
| 138 | if (!s?.url) continue; |
| 139 | const gain = ctx.createGain(); |
| 140 | gain.connect(master); |
| 141 | stemMap.set(s.name, { url: s.url, header: null, gain, activeNodes: [] }); |
| 142 | } |
| 143 | |
| 144 | let _duration = 0; |
| 145 | let playing = false; |
| 146 | let destroyed = false; |
| 147 | let rafId = null; |
| 148 | |
| 149 | // Playback clock: getCurrentTime = ctx.currentTime - _startCtxTime + _startOffset |
| 150 | let _startCtxTime = 0; |
| 151 | let _startOffset = 0; |
| 152 | // _scheduledTo: track position (seconds) up to which AudioBufferSourceNodes |
| 153 | // have already been scheduled. Always sits at a chunk boundary after play(). |
| 154 | let _scheduledTo = 0; |
| 155 | // True once the first AudioBufferSourceNode is actually queued; guards |
| 156 | // getCurrentTime() from advancing during an async chunk fetch. |
| 157 | let _audioStarted = false; |
| 158 | let _filling = false; // prevents concurrent _scheduleNext() calls |
| 159 | |
| 160 | // Chunk cache: chunkIdx -> { promise: Promise<Map>, result: Map|null } |
| 161 | // result is set synchronously once the promise resolves so play() can |
| 162 | // schedule chunk 0 without an async await after ready() completes. |
| 163 | const _cache = new Map(); |
| 164 | |
| 165 | function _getCurrentTime() { |
| 166 | if (!playing || !_audioStarted) return _startOffset; |
| 167 | return Math.min((ctx.currentTime - _startCtxTime) * _playbackRate + _startOffset, _duration); |
| 168 | } |
| 169 | |
| 170 | // --- fetch helpers --- |
| 171 | |
| 172 | async function _fetchHeader(url) { |
| 173 | const res = await fetch(url, { headers: { Range: "bytes=0-1023" } }); |
no test coverage detected