diff --git a/app/static/js/services/bridgeAudioNode.js b/app/static/js/services/bridgeAudioNode.js
index d388969..af6fa6b 100644
--- a/app/static/js/services/bridgeAudioNode.js
+++ b/app/static/js/services/bridgeAudioNode.js
@@ -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;
diff --git a/app/static/js/services/bridgeAudioWorklet.js b/app/static/js/services/bridgeAudioWorklet.js
new file mode 100644
index 0000000..35e3104
--- /dev/null
+++ b/app/static/js/services/bridgeAudioWorklet.js
@@ -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);
diff --git a/app/templates/index.html b/app/templates/index.html
index aef2571..7c37333 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -40,7 +40,7 @@
-
+
diff --git a/native_bridge/include/SharedMemoryIPC.h b/native_bridge/include/SharedMemoryIPC.h
index 11ca5ab..2874621 100644
--- a/native_bridge/include/SharedMemoryIPC.h
+++ b/native_bridge/include/SharedMemoryIPC.h
@@ -62,6 +62,17 @@ struct SharedAudioBufferIPC {
// liveness on this, so a slow (but alive) audio loop no longer looks dead.
// Append-only field: existing offsets stay stable.
volatile uint32_t heartbeat;
+
+ // VSTi crackle fix: multi-slot audio ring (appended after heartbeat — all
+ // offsets above stay stable). The bridge renders block N into slot
+ // (bridgeWriteIndex & ringMask), then increments bridgeWriteIndex (publish).
+ // The reader drains EVERY slot between its last index and writeIndex, in
+ // order, so a delayed reader no longer loses a whole block to single-slot
+ // overwrite (the old 1-slot gap). ringMask = slot count - 1 (power of 2);
+ // 8 slots ~= 42ms of audio @ 48k — covers a pump/WebView jank window.
+ volatile uint32_t ringMask;
+ float ringLeft[8][AUDIO_BLOCK_SIZE];
+ float ringRight[8][AUDIO_BLOCK_SIZE];
};
// G4.3: per-plugin sandbox SHM — double-buffered audio handoff.
diff --git a/native_bridge/src/main.cpp b/native_bridge/src/main.cpp
index 90cc25a..94f295c 100644
--- a/native_bridge/src/main.cpp
+++ b/native_bridge/src/main.cpp
@@ -654,6 +654,8 @@ int main(int argc, char* argv[]) {
#else
(void)shmName; // POSIX shm mapping (shm_open) added when porting off Windows
auto* shmIPC = (SharedAudioBufferIPC*)std::calloc(1, sizeof(SharedAudioBufferIPC));
+ // VSTi crackle fix: 8-slot audio ring (see SharedMemoryIPC.h).
+ shmIPC->ringMask = 7;
if (!shmIPC) return 1;
#endif
@@ -754,9 +756,12 @@ int main(int argc, char* argv[]) {
};
// Render only [from, to) of the block — used by sample-accurate splitting.
+ // VSTi crackle fix: target the multi-slot ring slot for the block being
+ // built, so old blocks stay readable until the reader drains them.
auto renderSegment = [&](uint32_t from, uint32_t to) {
if (to <= from) return;
- instruments.renderAll(shmIPC->masterLeft + from, shmIPC->masterRight + from, to - from);
+ uint32_t slot = shmIPC->bridgeWriteIndex & shmIPC->ringMask;
+ instruments.renderAll(shmIPC->ringLeft[slot] + from, shmIPC->ringRight[slot] + from, to - from);
};
double blockDurationMs = (double)block / sampleRate * 1000.0;
diff --git a/native_bridge/tests/shm_selfcheck.cpp b/native_bridge/tests/shm_selfcheck.cpp
index 1a2aade..bf43ec2 100644
--- a/native_bridge/tests/shm_selfcheck.cpp
+++ b/native_bridge/tests/shm_selfcheck.cpp
@@ -65,7 +65,18 @@ int main() {
// 5. Layout stability — MUST match Rust src-tauri/src/shm.rs
assert(sizeof(SharedAudioBufferIPC::MidiEventIPC) == 12);
assert(sizeof(SharedAudioBufferIPC::ControlEventIPC) == 1040);
- assert(sizeof(SharedAudioBufferIPC) == 11168); // G3.1: +4 heartbeat
+ // G4.5 ring: 11168 (G3.1 +heartbeat) + ringMask(4) + ringLeft(8192) + ringRight(8192)
+ // = 27556, rounded up to 8-byte struct alignment (u64 blockTimestamp) => 27560.
+ // Matches Rust size_of (src-tauri/src/shm.rs layout test).
+ assert(sizeof(SharedAudioBufferIPC) == 27560);
+
+ // 6. Audio ring round-trip (multi-slot, VSTi crackle fix)
+ ipc->ringMask = 7;
+ for (uint32_t i = 0; i < AUDIO_BLOCK_SIZE; ++i) ipc->ringLeft[3][i] = (float)i * 2.0f;
+ ipc->ringRight[3][0] = 42.0f;
+ assert(ipc->ringLeft[3][255] == 510.0f);
+ assert(ipc->ringRight[3][0] == 42.0f);
+ assert((ipc->bridgeWriteIndex & ipc->ringMask) < 8);
std::printf("SHM self-check OK (sizeof struct = %zu bytes)\n", sizeof(SharedAudioBufferIPC));
shm_close(h);
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 8edbe7d..c57b70f 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -451,23 +451,34 @@ pub fn run() {
restart_attempts = 0;
}
// Audio emission = writeIndex advancing (audio loop alive).
- let audio_advanced = guard.as_ref().map(|shm| {
- let idx = shm.write_index();
+ // VSTi crackle fix: drain the FULL delta from the multi-slot
+ // ring (8 slots) — a delayed pump no longer drops whole
+ // blocks that a single-slot SHM would have overwritten.
+ let mut audio_advanced = None;
+ if let Some(s) = guard.as_ref() {
+ let idx = s.write_index();
if idx != last_index {
+ let delta = idx.wrapping_sub(last_index);
+ // Only the last 8 blocks are still intact in the ring;
+ // if the pump stalled longer, older blocks are lost.
+ let start = if delta as usize > 8 { idx - 8 } else { last_index };
+ for k in start..idx {
+ let (l, r) = s.read_ring_audio(k);
+ let _ = pump_handle.emit(
+ "bridge-audio",
+ AudioFrame { l: l.to_vec(), r: r.to_vec() },
+ );
+ }
+ if delta as usize > 8 {
+ eprintln!("[tauri] audio pump lost {} blocks (stalled > ring)", delta - 8);
+ }
last_index = idx;
- true
- } else {
- false
+ audio_advanced = Some(true);
}
- });
+ }
if audio_advanced == Some(true) {
last_audio_change = std::time::Instant::now();
stall_logged = false;
- let (l, r) = guard.as_ref().map(|s| s.read_audio()).unwrap_or(([0f32; shm::AUDIO_BLOCK_SIZE], [0f32; shm::AUDIO_BLOCK_SIZE]));
- let _ = pump_handle.emit(
- "bridge-audio",
- AudioFrame { l: l.to_vec(), r: r.to_vec() },
- );
}
// G3.1 FIX: nhả guard TRƯỚC stall log / watchdog kill+spawn.
// kill_bridge -> flush_bridge_state khóa chính ShmState mutex
diff --git a/src-tauri/src/shm.rs b/src-tauri/src/shm.rs
index 7bcf6a4..ce9e6ce 100644
--- a/src-tauri/src/shm.rs
+++ b/src-tauri/src/shm.rs
@@ -67,6 +67,13 @@ pub struct SharedAudioBufferIPC {
// independent of the audio loop. Watchdog liveness keys on this.
// Append-only field: offsets above are unchanged.
pub heartbeat: u32,
+ // VSTi crackle fix: multi-slot audio ring (appended after heartbeat —
+ // offsets above are unchanged). Bridge renders block N into slot
+ // (bridge_write_index & ring_mask), then increments bridge_write_index.
+ // Pump drains EVERY slot between its last index and write_index, in order.
+ pub ring_mask: u32,
+ pub ring_left: [[f32; AUDIO_BLOCK_SIZE]; 8],
+ pub ring_right: [[f32; AUDIO_BLOCK_SIZE]; 8],
}
pub struct Shm {
@@ -159,6 +166,17 @@ impl Shm {
(ipc.master_left, ipc.master_right)
}
+ /// Read ring slot for block index `idx` (slot = idx & ring_mask).
+ pub fn read_ring_audio(&self, idx: u32) -> ([f32; AUDIO_BLOCK_SIZE], [f32; AUDIO_BLOCK_SIZE]) {
+ let ipc = self.ipc();
+ let slot = (idx & ipc.ring_mask) as usize;
+ (ipc.ring_left[slot], ipc.ring_right[slot])
+ }
+
+ pub fn ring_mask(&self) -> u32 {
+ self.ipc().ring_mask
+ }
+
pub fn write_index(&self) -> u32 {
self.ipc().bridge_write_index
}
@@ -196,6 +214,9 @@ mod tests {
// native_bridge/tests/shm_selfcheck.cpp asserts the same sizes.
assert_eq!(std::mem::size_of::(), 12);
assert_eq!(std::mem::size_of::(), 1040);
- assert_eq!(std::mem::size_of::(), 11168);
+ // 11168 + ring_mask(4) + ring_left(8*256*4) + ring_right(8*256*4)
+ // = 27556, rounded up to 8-byte struct alignment (u64 block_timestamp)
+ // => 27560. Matches MSVC sizeof (verified against the C header).
+ assert_eq!(std::mem::size_of::(), 27560);
}
}