// app/static/js/services/bridgeAudioNode.js // Audio sink: consumes 'bridge-audio' PCM frames (native bridge) and plays them // into the WebAudio graph. Primary path: AudioWorklet (audio thread, no main // thread jank). Fallback: ScriptProcessorNode when worklet unavailable. // VSTi crackle fix (FIX_VSTI_AUDIO_CRACKLE.md P1/P2): worklet ring absorbs pump // jitter, underrun fades to 0 (no click), overrun drops oldest + crossfade. (function () { var RING_DEPTH = 24; // fallback ring (blocks of 256 samples) var WORKLET_MAX = 64; // blocks buffered while worklet loads (~371ms @48k) var SP_BUFFER = 4096; var _chunks = []; // fallback: [{l: Float32Array(256), r: Float32Array(256)}] var _preBuffer = []; // pending blocks while worklet module loads var _spn = null; var _gainNode = null; var _workletNode = null; var _ctx = null; var _initialized = false; var _useWorklet = false; var _dest = null; // destination gain node da noi (chong connect trung) function _getCtx() { if (_ctx) return _ctx; if (typeof getAudioContext === 'function') _ctx = getAudioContext(); else if (window.__sharedAudioCtx) _ctx = window.__sharedAudioCtx; else _ctx = new (window.AudioContext || window.webkitAudioContext)(); return _ctx; } function _setupWorklet() { if (!_ctx.audioWorklet || !_ctx.audioWorklet.addModule) { console.warn('[BridgeAudioNode] AudioWorklet unavailable — ScriptProcessor fallback'); return false; } var url = '/static/js/services/bridgeAudioWorklet.js?v=202608291200'; _ctx.audioWorklet.addModule(url).then(function () { if (!_initialized) return; // disconnect() happened while loading if (_useWorklet) return; // already set up (init retry) try { _workletNode = new AudioWorkletNode(_ctx, 'bridge-audio-sink', { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [2] }); _workletNode.connect(_gainNode); _useWorklet = true; // drain pre-buffer into worklet (order preserved) while (_preBuffer.length) { var b = _preBuffer.shift(); _workletNode.port.postMessage({ type: 'audio', l: b.l, r: b.r }); } console.log('[BridgeAudioNode] AudioWorklet active'); try { if (window.__dbgLog) window.__dbgLog('worklet', 'active pre=' + _preBuffer.length); } catch (e) {} } catch (err) { console.warn('[BridgeAudioNode] AudioWorkletNode failed — ScriptProcessor fallback', err); _useWorklet = false; try { if (window.__dbgLog) window.__dbgLog('worklet', 'NODE_FAIL'); } catch (e) {} } }).catch(function (err) { console.warn('[BridgeAudioNode] addModule failed — ScriptProcessor fallback', err); try { if (window.__dbgLog) window.__dbgLog('worklet', 'MODULE_FAIL'); } catch (e) {} }); return true; // pending — worklet may become active later } function init(audioCtx) { if (_initialized) return; _ctx = audioCtx || _getCtx(); _gainNode = _ctx.createGain(); _gainNode.gain.value = 1.0; // ScriptProcessor created but NOT connected until we know worklet failed. _spn = _ctx.createScriptProcessor(SP_BUFFER, 0, 2); _spn.onaudioprocess = function (e) { var L = e.outputBuffer.getChannelData(0); var R = e.outputBuffer.getChannelData(1); var outLen = L.length; L.fill(0); R.fill(0); if (!_chunks.length) return; var written = 0; while (written < outLen && _chunks.length) { var blk = _chunks[0]; var n = Math.min(blk.l.length, outLen - written); L.set(blk.l.subarray(0, n), written); R.set(blk.r.subarray(0, n), written); written += n; if (n >= blk.l.length) { _chunks.shift(); } else { blk.l = blk.l.subarray(n); blk.r = blk.r.subarray(n); } } }; if (!_setupWorklet()) { // No worklet at all — go straight to ScriptProcessor. _spn.connect(_gainNode); _useWorklet = false; while (_preBuffer.length) _chunks.push(_preBuffer.shift()); while (_chunks.length > RING_DEPTH) _chunks.shift(); } _initialized = true; console.log('[BridgeAudioNode] initialized (ring depth ' + RING_DEPTH + ')'); } function onAudio(l, r) { if (!_initialized) init(); var blk = { l: new Float32Array(l), r: new Float32Array(r) }; if (_useWorklet && _workletNode) { _workletNode.port.postMessage({ type: 'audio', l: blk.l, r: blk.r }); return; } if (!_useWorklet && _ctx && _ctx.audioWorklet && _ctx.audioWorklet.addModule) { // Worklet loading — buffer temporarily (bounded), avoid fallback churn. _preBuffer.push(blk); while (_preBuffer.length > WORKLET_MAX) _preBuffer.shift(); return; } _chunks.push(blk); while (_chunks.length > RING_DEPTH) _chunks.shift(); } function flush() { _preBuffer = []; _chunks = []; if (_useWorklet && _workletNode) { try { _workletNode.port.postMessage({ type: 'flush' }); } catch (e) {} } } function getOutputNode() { return _gainNode; } function disconnect() { flush(); if (_workletNode) { try { _workletNode.disconnect(); } catch (e) {} try { _workletNode.port.close(); } catch (e) {} } if (_spn && _gainNode) { try { _spn.disconnect(); } catch (e) {} try { _gainNode.disconnect(); } catch (e) {} } _initialized = false; _useWorklet = false; _workletNode = null; _spn = null; _gainNode = null; _ctx = null; _dest = null; } window.BridgeAudioNode = { init: init, onAudio: onAudio, flush: flush, getOutputNode: getOutputNode, connect: function (dest) { if (!_gainNode) return; if (_dest === dest) return; // da noi — khong noi trung (double-sum) if (_dest) { try { _gainNode.disconnect(_dest); } catch (e) {} } _gainNode.connect(dest); _dest = dest; }, disconnect: disconnect, isReady: function () { return _initialized; }, getDiag: function () { return { initialized: _initialized, useWorklet: _useWorklet, hasWorkletNode: !!_workletNode, preBufferLen: _preBuffer.length, chunksLen: _chunks.length, gain: _gainNode ? _gainNode.gain.value : -1 }; } }; })();