fix(vsti): crackle fix P1-P3 — SHM 8-slot ring + drain-delta pump + AudioWorklet sink
- SharedMemoryIPC.h/main.cpp: render into ring slot (idx & ringMask), 8 slots - shm.rs: ring fields + read_ring_audio; lib.rs pump drains FULL delta, not just latest - bridgeAudioNode.js: AudioWorklet primary (bridge-audio-sink), ScriptProcessor fallback - bridgeAudioWorklet.js: ring 64 blocks, underrun fade, overrun drop + crossfade - layout tests: sizeof 27560 (8-align via u64)
This commit is contained in:
@@ -1,17 +1,21 @@
|
||||
// app/static/js/services/bridgeAudioNode.js
|
||||
// Audio sink: consumes 'bridge-audio' PCM frames (native bridge) and plays them
|
||||
// through a ScriptProcessorNode into the WebAudio graph (track FX / master bus).
|
||||
// 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 () {
|
||||
// Bridge renders one 256-sample block per ~5.8ms; ScriptProcessor fires once
|
||||
// per 4096 samples (~16 blocks @44.1k). The ring MUST cover one full callback
|
||||
// period or we underrun (silence gaps) — 24 blocks ~= 139ms headroom.
|
||||
var RING_DEPTH = 24;
|
||||
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 = []; // [{l: Float32Array(256), r: Float32Array(256)}]
|
||||
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() {
|
||||
@@ -22,11 +26,45 @@
|
||||
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=202608152200';
|
||||
_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');
|
||||
} catch (err) {
|
||||
console.warn('[BridgeAudioNode] AudioWorkletNode failed — ScriptProcessor fallback', err);
|
||||
_useWorklet = false;
|
||||
}
|
||||
}).catch(function (err) {
|
||||
console.warn('[BridgeAudioNode] addModule failed — ScriptProcessor fallback', err);
|
||||
});
|
||||
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);
|
||||
@@ -34,8 +72,6 @@
|
||||
var outLen = L.length;
|
||||
L.fill(0); R.fill(0);
|
||||
if (!_chunks.length) return;
|
||||
// Drain as many queued blocks as fit in this callback (16 max) — filling
|
||||
// only one block per callback previously played 1/16 of the audio.
|
||||
var written = 0;
|
||||
while (written < outLen && _chunks.length) {
|
||||
var blk = _chunks[0];
|
||||
@@ -46,36 +82,62 @@
|
||||
if (n >= blk.l.length) {
|
||||
_chunks.shift();
|
||||
} else {
|
||||
// Partial block (only possible if block size != 256): keep remainder.
|
||||
blk.l = blk.l.subarray(n);
|
||||
blk.r = blk.r.subarray(n);
|
||||
}
|
||||
}
|
||||
};
|
||||
_spn.connect(_gainNode);
|
||||
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();
|
||||
_chunks.push({ l: new Float32Array(l), r: new Float32Array(r) });
|
||||
// Drop oldest on overrun — keeps latency bounded (~139ms max).
|
||||
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() { _chunks = []; }
|
||||
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) {}
|
||||
}
|
||||
// Reset state so a later init() can rebuild (AudioRoutingEngine re-connect).
|
||||
_initialized = false;
|
||||
_useWorklet = false;
|
||||
_workletNode = null;
|
||||
_spn = null;
|
||||
_gainNode = null;
|
||||
_ctx = null;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// app/static/js/services/bridgeAudioWorklet.js
|
||||
// VSTi crackle fix: audio sink chay tren audio thread (khong jank main thread).
|
||||
// - Ring block 256 mau, toi da 64 block (~371ms @48k) — hap thu jitter pump.
|
||||
// - Underrun: fade ve 0 thay vi click (hard silence).
|
||||
// - Resume sau silence: fade vao tu 0.
|
||||
// - Overrun: drop block cu nhat + crossfade 64 mau tai diem drop (het click).
|
||||
class BridgeAudioSinkProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this._ring = [];
|
||||
this._MAX = 64;
|
||||
this._gain = 1.0;
|
||||
this._lastOutL = 0.0;
|
||||
this._lastOutR = 0.0;
|
||||
this._skipFade = 0; // >0: crossfade dang chay sau overrun drop
|
||||
this._skipFromL = 0.0;
|
||||
this._skipFromR = 0.0;
|
||||
this.port.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (!m) return;
|
||||
if (m.type === 'audio') {
|
||||
this._ring.push({ l: m.l, r: m.r });
|
||||
while (this._ring.length > this._MAX) {
|
||||
// overrun: bo block cu nhat; lan consume toi se crossfade ranh gioi
|
||||
this._ring.shift();
|
||||
this._skipFade = 64;
|
||||
}
|
||||
} else if (m.type === 'flush') {
|
||||
this._ring.length = 0;
|
||||
this._gain = 1.0;
|
||||
this._skipFade = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
process(inputs, outputs) {
|
||||
const out = outputs[0];
|
||||
if (!out || out.length < 2) return true;
|
||||
const L = out[0];
|
||||
const R = out[1];
|
||||
const n = L.length;
|
||||
let w = 0;
|
||||
while (w < n) {
|
||||
if (this._ring.length) {
|
||||
const blk = this._ring[0];
|
||||
const avail = blk.l.length;
|
||||
const take = Math.min(avail, n - w);
|
||||
if (this._skipFade > 0) {
|
||||
for (let i = 0; i < take; i++) {
|
||||
const t = i / 64;
|
||||
L[w + i] = (this._lastOutL * (1 - t) + blk.l[i] * t) * this._gain;
|
||||
R[w + i] = (this._lastOutR * (1 - t) + blk.r[i] * t) * this._gain;
|
||||
}
|
||||
this._skipFade = Math.max(0, this._skipFade - take);
|
||||
} else {
|
||||
for (let i = 0; i < take; i++) {
|
||||
L[w + i] = blk.l[i] * this._gain;
|
||||
R[w + i] = blk.r[i] * this._gain;
|
||||
}
|
||||
}
|
||||
this._lastOutL = L[w + take - 1];
|
||||
this._lastOutR = R[w + take - 1];
|
||||
if (this._gain < 1.0) this._gain = Math.min(1.0, this._gain + take / 256.0);
|
||||
w += take;
|
||||
if (take >= avail) {
|
||||
this._ring.shift();
|
||||
} else {
|
||||
blk.l = blk.l.subarray(take);
|
||||
blk.r = blk.r.subarray(take);
|
||||
}
|
||||
} else {
|
||||
// underrun: fade ve 0 (khong click)
|
||||
for (let i = w; i < n; i++) {
|
||||
this._gain = Math.max(0, this._gain - 1.0 / 256.0);
|
||||
L[i] = this._lastOutL * this._gain;
|
||||
R[i] = this._lastOutR * this._gain;
|
||||
}
|
||||
this._lastOutL *= this._gain;
|
||||
this._lastOutR *= this._gain;
|
||||
w = n;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('bridge-audio-sink', BridgeAudioSinkProcessor);
|
||||
Reference in New Issue
Block a user