// sf-fx-realtime.js — AudioWorkletProcessor nối WebAudio → SHM FX engine. // process() gom 128-sample quantum thành block 256 stereo float32 (khớp // FXRT_BLOCK trong FxRealtimeIPC.h) → postMessage cho main thread gửi WS; // nhận block đã xử lý từ main thread → queue → phát ra output. Queue rỗng → // zero fill (WS chưa kịp round-trip). // // BURST (fix lag/giật/crackle): gom 4 block (1024 mẫu) vào 1 frame rồi post. // Bridge VST3 (fx_vst_bridge) chạy chậm hơn realtime khi take=1 (256 mẫu: // ~5.5ms/call > budget 5.33ms → 179/s < 187.5/s → engine drop input → // crackle). Gửi burst 4 block → bridge take=4 → 1 call process 1024 mẫu // (amortize overhead: 14-19ms/4 block ≈ 3.5-4.75ms/block) → đủ 187.5/s. // Latency thêm ~4 block (~21ms) — worklet outQueue 64 block hấp thụ. // // FILL (hết crackle do jitter): không phát ngay khi có block đầu — chờ queue // ≥ FILL (24 block ~128ms @48k) rồi mới phát. Engine gửi ASAP (không pace // realtime) → client tích lũy buffer an toàn; mọi jitter (bridge process VST // biến thiên, GC, WS, main thread rAF) bị hấp thụ bởi FILL thay vì queue ≈1 // → cạn → ngắt quãng. // UNDERRUN (queue cạn khi đang phát): V23 — KHÔNG lặp block cuối (lặp tạo // jump pha ở điểm vào hold + mỗi wrap → click), KHÔNG zero-fill: fade-out // tuyến tính từ mẫu output cuối (HOLD_FADE block ~21ms) tới im lặng; resume // NGAY khi có block mới (không đợi đủ FILL): đã im lặng → fade-in từ 0, đang // giữa fade → crossfade 64 mẫu từ mẫu hiện tại (hết jump). Cap-drop (queue // tràn sau stall) crossfade 64 mẫu (hết click) — giống bridgeAudioWorklet. // Cap 64 block (~341ms) chặn overflow khi bridge nhanh hơn realtime. // ponytail: thay fade-out bằng adaptive stretch khi cần chất lượng cao hơn. class SfFxRealtimeProcessor extends AudioWorkletProcessor { constructor() { super(); this.BLOCK = 256; // FXRT_BLOCK this.BURST = 4; // số block gom 1 frame (bridge batch) this.FILL = 24; // 24 block ~128ms @48k: buffer khởi đầu — hấp thụ jitter main-thread (GUI decode, rAF, VST panel). PDC 7936 = (7+24)*256 (fxRtApplyPdc). this.CAP = 64; // cap queue chặn overflow (~341ms) this.HOLD_FADE = 4; // 4 block ~21ms: fade 1→0 sau HOLD hết (hết click cắt cứng) this.holdFadeLeft = 0; // mẫu còn lại của fade-out khi underrun this.silenced = false; // đã fade về 0 → im lặng; resume phải fade-in, không hold lại this.bufLen = this.BLOCK * this.BURST; this.inL = new Float32Array(this.bufLen); this.inR = new Float32Array(this.bufLen); this.inCount = 0; this.outQueue = []; // [{l: Float32Array, r: Float32Array}] this.outPos = 0; this.playing = false; // false → im lặng (đang chờ đủ FILL) this.hasStarted = false; // đã phát block đầu chưa — FILL chỉ chờ ở lần khởi đầu this.flushPending = false; // V24: đang chờ engine flush (resume sau underrun) — drop block stale this.resumeFadeIn = false; // V24: interruption đã xử lí (clear+flush) — resume sẽ fade-in từ 0 this.FADE = 512; // fade-in 512 mẫu (~11ms @48k) khi bắt đầu phát/resume — hết click this.outGain = 0; // gain phát hiện tại (0→1 trong fade) this.fadeLeft = 0; // số mẫu còn lại của fade this.skipFade = 0; // >0: crossfade 64 mẫu đang chạy sau jump (cap-drop/resume) — hết click this.skipFromL = 0.0; // mẫu output tại điểm jump — điểm bắt đầu crossfade this.skipFromR = 0.0; this.lastOutL = 0.0; // mẫu output cuối — nguồn crossfade + điểm bắt đầu fade-out this.lastOutR = 0.0; this.fadeFromL = 0.0; // mẫu cố định khi fade-out underrun (fade tuyến tính tới 0) this.fadeFromR = 0.0; this.port.onmessage = (e) => { const d = e.data; if (d && d.type === 'set_param') { this.port.postMessage(d); return; } if (d && d.type === 'flush_done') { this.flushPending = false; return; } if (!d || d.type !== 'out' || !d.l || !d.r) return; // V24: đang chờ flush (resume sau underrun/stall) → drop block stale. // Backlog engine gửi khi worklet im là audio CŨ (chơi lại → lag cộng dồn // ~1s) — chỉ nhận block MỚI từ sau flush_done. if (this.flushPending) return; this.outQueue.push({ l: d.l, r: d.r }); while (this.outQueue.length > this.CAP) { this.outQueue.shift(); this.statCapDrop++; // V23: drop cứng = jump mẫu → click. Crossfade 64 mẫu từ mẫu output // cuối sang block mới; chỉ đặt 1 lần cho đợt drop liên tiếp. outPos=0: // block mới (front) phải phát từ đầu, không tiếp tục vị trí block cũ. if (this.skipFade <= 0) { this.skipFade = 64; this.skipFromL = this.lastOutL; this.skipFromR = this.lastOutR; } this.outPos = 0; } }; // Telemetry (chẩn đoán crackle trên session thật): đếm sự kiện + báo main // thread mỗi ~2s. Nếu user bật master FX nghe crackle mà stats cho thấy // underruns>0 → delivery bị stall (main-thread busy) → nguồn crackle thật. this.statUnderrun = 0; this.statHold = 0; this.statFade = 0; this.statSilence = 0; this.statCapDrop = 0; this.statFadeIn = 0; this.statCalls = 0; } process(inputs, outputs) { const input = inputs[0]; const output = outputs[0]; const n = output[0] ? output[0].length : 128; const outL = output[0]; const outR = output[1]; // Góp input thành burst 4 block (1024 mẫu) → gửi main thread // (transferable buffer). Main thread tách 4 WS frame → bridge batch 4. if (input && input[0]) { const inL = input[0]; const inR = input[1] || input[0]; for (let i = 0; i < n; i++) { if (this.inCount < this.bufLen) { this.inL[this.inCount] = inL[i]; this.inR[this.inCount] = inR[i]; } this.inCount++; if (this.inCount >= this.bufLen) { const inter = new Float32Array(this.bufLen * 2); for (let j = 0; j < this.bufLen; j++) { inter[j * 2] = this.inL[j]; inter[j * 2 + 1] = this.inR[j]; } this.port.postMessage({ type: 'in', data: inter.buffer }, [inter.buffer]); this.inCount = 0; } } } // Phát output: chờ queue ≥ FILL trước khi phát lần đầu (hấp thụ jitter // engine/bridge/WS); underrun GIỮA stream → fade-out 21ms từ mẫu cuối rồi // im lặng (V23); resume NGAY khi có block mới (không đợi re-buffer FILL), // đã im lặng → fade-in, giữa fade → crossfade 64 mẫu (hết jump). for (let i = 0; i < n; i++) { this.statCalls++; if (this.statCalls >= 1500) { // ~2s @48k/128 this.statCalls = 0; this.port.postMessage({ type: 'stats', underruns: this.statUnderrun, holds: this.statHold, fades: this.statFade, silences: this.statSilence, capDrops: this.statCapDrop, fadeIns: this.statFadeIn, queueLen: this.outQueue.length }); } if (this.playing) { const blk = this.outQueue[0]; if (blk) { if (this.fadeLeft > 0) { this.outGain = Math.min(1, this.outGain + 1 / this.FADE); this.fadeLeft--; } if (this.skipFade > 0) { const t = 1 - this.skipFade / 64; outL[i] = (this.skipFromL * (1 - t) + blk.l[this.outPos] * t) * this.outGain; if (outR) outR[i] = (this.skipFromR * (1 - t) + blk.r[this.outPos] * t) * this.outGain; this.skipFade--; } else { outL[i] = blk.l[this.outPos] * this.outGain; if (outR) outR[i] = blk.r[this.outPos] * this.outGain; } this.lastOutL = outL[i]; this.lastOutR = outR ? outR[i] : outL[i]; this.outPos++; if (this.outPos >= this.BLOCK) { this.outQueue.shift(); this.outPos = 0; } continue; } // Underrun giữa stream — V23: fade-out NGAY (không lặp block cuối: // lặp tạo jump pha ở điểm vào + mỗi wrap → click). Fade tuyến tính từ // mẫu output cuối tới 0 (21ms) — vào liền mạch (fadeFrom = lastOut), // hết click cắt cứng. Resume → crossfade/fade-in (xem dưới). this.playing = false; this.statUnderrun++; if (!this.silenced) { // đã im lặng (fade xong) → giữ im lặng this.fadeFromL = this.lastOutL; this.fadeFromR = this.lastOutR; this.holdFadeLeft = this.HOLD_FADE * this.BLOCK; this.statHold++; } } // Không phát: resume khi đủ block (lần đầu chờ FILL; sau đó chỉ cần 1 // block — hold che khoảng chờ). V25: sau flush (resumeFadeIn=true) chờ // re-buffer đủ FILL mới resume — chặn loop underrun→flush→resume-1-block // →underrun (wobble): 1 block phát hết ~5ms → cạn → flush lại. if (this.outQueue.length >= (this.hasStarted && !this.resumeFadeIn ? 1 : this.FILL)) { // V24: interruption (underrun sau stall) — backlog trong queue là audio // LẠC HẬU (chơi lại → lag cộng dồn ~1s). KHÔNG chơi backlog: clear // queue + báo main thread flush (engine xóa input/output ring, main // drop output stale in-flight) → chờ flush_done → resume chỉ với block // MỚI. flushPending → onmessage drop block stale trong lúc chờ (hết // race ack). Fade-out đang chạy (nếu có) tiếp tục tới im lặng — hết // click. resumeFadeIn guard chống re-trigger flush khi ack tới mà fade // chưa xong. resume → fade-in từ 0 (đã im) hoặc crossfade từ mẫu hiện // tại (giữa fade) — hết jump. const wasInterrupted = !this.resumeFadeIn && (this.holdFadeLeft > 0 || this.silenced) && this.hasStarted; if (wasInterrupted) { this.outQueue.length = 0; this.outPos = 0; this.flushPending = true; this.resumeFadeIn = true; this.port.postMessage({ type: 'flush' }); i--; continue; } this.playing = true; this.outPos = 0; this.holdFadeLeft = 0; if (this.resumeFadeIn) { this.resumeFadeIn = false; if (this.silenced) { // Đã im lặng (fade xong / sau flush) → fade-in từ 0 (hết jump ~0→1). this.outGain = 0; this.fadeLeft = this.FADE; this.statFadeIn++; } else { // Giữa fade-out → crossfade ngắn từ mẫu hiện tại (không jump về 0). this.skipFade = 64; this.skipFromL = this.lastOutL; this.skipFromR = this.lastOutR; } } this.silenced = false; if (!this.hasStarted) { this.hasStarted = true; this.outGain = 0; this.fadeLeft = this.FADE; // Báo main thread 1 lần để crossfade dry→fx đúng thời điểm. this.port.postMessage({ type: 'started' }); } i--; continue; } if (this.holdFadeLeft > 0) { // Fade-out 1→0 (underrun): fade tuyến tính từ mẫu cuối, im lặng êm. const g = this.outGain * (this.holdFadeLeft / (this.HOLD_FADE * this.BLOCK)); outL[i] = this.fadeFromL * g; if (outR) outR[i] = this.fadeFromR * g; this.holdFadeLeft--; if (this.holdFadeLeft <= 0) { this.silenced = true; this.statFade++; this.statSilence++; } this.lastOutL = outL[i]; this.lastOutR = outR ? outR[i] : outL[i]; continue; } outL[i] = 0; if (outR) outR[i] = 0; this.lastOutL = 0; this.lastOutR = 0; } return true; } } registerProcessor('sf-fx-realtime', SfFxRealtimeProcessor);