From cead8c8c711c663f30ae1f72b448131ebbc86285 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Sun, 16 Aug 2026 20:43:50 +0700 Subject: [PATCH] =?UTF-8?q?fix(vsti):=20crackle=20fix=20P1-P3=20=E2=80=94?= =?UTF-8?q?=20SHM=208-slot=20ring=20+=20drain-delta=20pump=20+=20AudioWork?= =?UTF-8?q?let=20sink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/static/js/services/bridgeAudioNode.js | 90 +++++++++++++++++--- app/static/js/services/bridgeAudioWorklet.js | 85 ++++++++++++++++++ app/templates/index.html | 2 +- native_bridge/include/SharedMemoryIPC.h | 11 +++ native_bridge/src/main.cpp | 7 +- native_bridge/tests/shm_selfcheck.cpp | 13 ++- src-tauri/src/lib.rs | 33 ++++--- src-tauri/src/shm.rs | 23 ++++- 8 files changed, 235 insertions(+), 29 deletions(-) create mode 100644 app/static/js/services/bridgeAudioWorklet.js 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); } }