// 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 32 block hấp thụ. // // FILL (hết crackle do jitter): không phát ngay khi có block đầu — chờ queue // ≥ FILL (8 block ~43ms @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; // V44: 24 block ~128ms @48k: buffer khởi đầu — hấp thụ jitter main-thread + stall deschedule bridge 68-138ms. PDC 7936 = (7+24)*256 (fxRtApplyPdc). V43 16 -> cushion ~9 (q 3-9) chua du: stall 68-82ms van gay underrun. V44 len 24 -> cushion ~17 (~90ms). this.RESUME_FILL = 24; // V26: re-buffer khi resume SAU STOP (flush_for_stop) — 16 block ~85ms (V43: = FILL) → play kế tiếp bắt đầu nhanh; underrun-stall vẫn dùng FILL (crackle/wobble). this.resumeFill = this.FILL; // mức re-buffer khi resume sau flush — stop: RESUME_FILL, underrun-stall: FILL this.CAP = 48; // V47: 32->48 (~256ms) — hết click cap-drop sau stall (cap=13/session cũ); KHÔNG đổi latency khởi đầu/PDC (FILL giữ 24) 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.stopped = false; // Bug1 echo: sau flush_for_stop dung het output — chi resume khi main gui clear_queue moi 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 đang chạy sau jump (cap-drop/resume/wrap) — hết click this.skipFadeTotal = 64; // V42: độ dài crossfade hiện tại (64 mẫu thường; WRAP_FADE 256 tại seam wrap) 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.wrapFadeBlocks = -1; // V42: số block tail pass cũ còn lại sau clear_queue('loop') — đếm ngược để crossfade đúng seam tail→head this.latencySamples = 0; // G3: latency chain thật (engine qua WS) — align wrap fade theo processed tail this.wrapMode = false; // V45.3: dang trong wrap (clear_queue loop) — resume underrun bo flush + re-buffer nho this.WRAP_FADE = 2048; // V46.2: 256->2048 (~43ms @48k) — che hole engine restart (~10-16ms) + jump noi dung end→start tai seam (256 qua ngan = giät nhẹ) 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 === 'latency') { this.latencySamples = (d.samples || 0); return; } if (d && d.type === 'flush_for_stop') { // V26: user stop — main thread báo xóa audio tail trong queue (audio đã // xử lí còn phát ~0.3-0.5s sau stop = tiếng "ding"). Fade-out 21ms từ // mẫu cuối (hết click) → im lặng; báo main flush engine ring (audio // stale trong WS/SHM không chơi tiếp). Resume lần sau: RESUME_FILL // (8 block ~43ms, V40: = FILL) — play kế tiếp không chờ trễ. this.outQueue.length = 0; this.outPos = 0; this.playing = false; this.wrapFadeBlocks = -1; // V42: stop — hủy wrap crossfade chờ this.flushPending = true; this.resumeFadeIn = true; this.resumeFill = this.RESUME_FILL; this.silenced = false; this.wrapMode = false; // V30: fade-out tu mau output CUOI (lastOut) — truoc day dung fadeFrom // cu (mac dinh 0) -> jump mau -> click/crackle ngay khi stop. this.fadeFromL = this.lastOutL; this.fadeFromR = this.lastOutR; this.holdFadeLeft = this.HOLD_FADE * this.BLOCK; this.stopped = true; // Bug1: drop moi 'out' sau stop — engine van render input dang fade + plugin tail; neu khong drop, queue day RESUME_FILL -> worklet tu resume phat tail = echo sau stop. this.port.postMessage({ type: 'flush' }); return; } if (d && d.type === 'clear_queue') { this.clearMode = d.mode; // V45.19: nho mode de tag fx_seek_first tai dbgPlayFirst this.stopped = false; // Bug1: play/seek/wrap moi — cho phep nhan out tro lai // V34: backlog guard o DAU handler (V33 dat sau mutation -> outQueue.length=0 // chay truoc -> guard luon return, clear van drop queue + fade + re-buffer moi // lan = V31). Queue nho (< FILL 8 block) -> bo qua hoan toan (khong mutation, // khong re-buffer) -> het ARM crackle (queue ARM chi 0-8 block, engine ~realtime). // Queue day (idle lau, engine chay truoc ~32 block) -> clear dung (fix lag 170ms). // V35: mode:'play' (start/section/pianoroll) LUON clear - queue < FILL luc // play-start con stale block idle/ARM (~16) -> bo qua => phat audio cu roi // can queue ~500ms (cold start). mode:'live' (ARM) giu guard het crackle. // V37 ARM probe: reset dbg flags BEFORE guard so live notes (small queue) also log fx_hop this.dbgClearAt = currentTime * 1000; this.dbgInFirst = true; this.dbgOutFirst = true; this.dbgPlayFirst = true; // V39: loop wrap — KHONG wipe: giu queue chay lien tuc (tail pass cu // pre-roll pass moi qua VST lookahead) -> het wobble dau bai. Chi reset // dbg flags. mode 'play' van wipe (bao ve audio cu khi seek/cold start). // V42: dem so block tail pass cu con lai trong queue -> wrap crossfade // trigger dung luc block dau pass moi len front (seam tail->head). if (d.mode === 'loop') { // G3: align processed-tail — tail pass cũ còn in-flight trong bridge // (FX latency thật từ engine) chưa về queue; đếm đủ block tới seam // (block đầu pass mới lên front) → crossfade đúng chỗ jump end→start. // V45.2 (option B): cắt bớt tail cũ giữ KEEP block thay vì giữ nguyên // FILL 24 (~128ms) → downbeat pass mới không trễ ~110ms. skipFade // crossfade từ lastOut che jump cắt front; wrapFadeBlocks đếm lại từ // KEEP để seam crossfade vẫn đúng lúc. // V45.3: KEEP 4 -> 16 (~85ms): KEEP 4 (~21ms) engine (stall bridge // 68-138ms sau transport play) khong refill kip -> queue can -> underrun // -> flush + re-buffer FILL 24 = gap ~110-235ms moi wrap (WORSE hon // truoc V45.2: downbeat tre 106-128ms nhung khong gap). KEEP 16 che // stall dien hinh 68-82ms lien tuc (khong gap, downbeat ~85ms); stall // dai hon roi vao nhanh resume wrap (bo flush + resumeFill 8). const KEEP = 16; this.wrapMode = true; const _preQ = this.outQueue.length; // V46.1 instrument seam if (_preQ > KEEP) { const head = _preQ - KEEP; this.outQueue.splice(0, head); this.outPos = 0; if (this.playing) { this.skipFade = this.WRAP_FADE; this.skipFadeTotal = this.WRAP_FADE; this.skipFromL = this.lastOutL; this.skipFromR = this.lastOutR; } } // V46.2: countdown = DUNG so block tail (sau splice) — truoc day cong // ceil(latency/BLOCK)~137 (estimate 365ms) => crossfade ban tre ~380ms // (giua bar1) = vo hieu, seam that di qua khong crossfade. Pipeline lag // THUC ~25ms (fx_out_first +8ms sau clear) chu khong phai VST latency. this.wrapFadeBlocks = this.outQueue.length; // V46.1 instrument seam: pre=queue truoc splice, keep=sau splice, lat=latency est, wfb=countdown toi seam this.port.postMessage({ type: 'dbg', tag: 'fx_loop_clear', t: Math.round(currentTime * 1000), pre: _preQ, keep: this.outQueue.length, lat: this.latencySamples || 0, wfb: this.wrapFadeBlocks, playing: this.playing ? 1 : 0 }); return; } if (d.mode === 'seek') { // V45.18: seek khi play master FX active — wipe queue cu (audio cu // choi tiep 0-50ms sau click) + resume nhanh RESUME_FILL 8 (~43ms) // thay vi FILL 24 (~128ms) — het "am tre 1 beat" (hit dau roi vao // vung cam 128ms). Giong resume wrap V45.4 (proven khong gap). this.outQueue.length = 0; this.outPos = 0; this.playing = false; this.wrapFadeBlocks = -1; this.wrapMode = false; this.resumeFadeIn = true; this.resumeFill = 8; this.silenced = false; this.fadeFromL = this.lastOutL; this.fadeFromR = this.lastOutR; this.holdFadeLeft = this.HOLD_FADE * this.BLOCK; this.port.postMessage({ type: 'dbg', tag: 'fx_seek_clear', t: Math.round(currentTime * 1000), q: 0 }); return; } if (d.mode !== 'play' && this.outQueue.length < this.FILL) return; // V31: play/ARM live note — main thread bao xoa backlog block idle // (engine render lien tuc khi STOP -> outQueue day ~32 block ~170ms // audio cu; play moi phai cho phia sau -> lag 0.3-0.5s). KHONG post // 'flush' (khong chaym engine WS — note-on play vua day xuong se bi // drop). Resume sau 8 block (~43ms) + fade-out/fade-in tu mau cuoi. this.outQueue.length = 0; this.outPos = 0; this.playing = false; this.wrapFadeBlocks = -1; // V42: wipe — hủy wrap crossfade chờ (không có tail cũ) this.wrapMode = false; // V45.3: play/seek — het context wrap this.resumeFadeIn = true; // V34: mode phan biet — play re-buffer FILL 8 (~43ms, cushion du hap thu // engine ramp/jitter dau play); ARM live note giu RESUME_FILL 8 (~43ms) — preview // nhanh, khong cho FILL 8. this.resumeFill = (d.mode === 'play') ? this.FILL : this.RESUME_FILL; this.silenced = false; this.fadeFromL = this.lastOutL; this.fadeFromR = this.lastOutR; this.holdFadeLeft = this.HOLD_FADE * this.BLOCK; 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.stopped || this.flushPending) return; this.outQueue.push({ l: d.l, r: d.r }); if (this.dbgOutFirst) { this.dbgOutFirst = false; this.port.postMessage({ type: 'dbg', tag: 'fx_out_first', t: Math.round(currentTime * 1000), q: this.outQueue.length }); } 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.skipFadeTotal = 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; // V32: do latency master FX path — danh dau clear_queue, bao main thread hop dau // sau clear (in_first / out_first / play_first) → dbg.log delta vs play_start. this.dbgClearAt = 0; this.dbgInFirst = false; this.dbgOutFirst = false; this.dbgPlayFirst = false; } 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; if (this.dbgInFirst) { this.dbgInFirst = false; this.port.postMessage({ type: 'dbg', tag: 'fx_in_first', t: Math.round(currentTime * 1000) }); } } } } // 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 / this.skipFadeTotal; 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; // V42: seam tail→head — sau khi shift hết N block tail pass cũ, block // đầu pass mới lên front → crossfade WRAP_FADE từ mẫu cuối (che click // jump nội dung end→start, FX path). Trigger 1 lần mỗi wrap. if (this.wrapFadeBlocks > 0) { this.wrapFadeBlocks--; if (this.wrapFadeBlocks === 0) { this.skipFade = this.WRAP_FADE; this.skipFadeTotal = this.WRAP_FADE; this.skipFromL = this.lastOutL; this.skipFromR = this.lastOutR; this.port.postMessage({ type: 'dbg', tag: 'fx_wrap_xfade', t: Math.round(currentTime * 1000), q: this.outQueue.length }); } } } 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.wrapFadeBlocks = -1; // V42: underrun — hủy wrap crossfade chờ (resume qua fade-in/crossfade thường) 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 ? this.resumeFill : 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) { // V45.3: wrap (loop restart) — engine da transport('play') tu main // (D17): block moi = audio pass moi, KHONG phai backlog stale (boi // worklet flush gui 'flush' xuong engine ring = lam mat tac dung D17 // skip-flush o stopAllPlayback -> gap giua seam). Bo flush (round-trip) // + re-buffer nho hon FILL 24 (~128ms) -> resume sau stall som hon // (~43ms) -> gap wrap giam tu ~110-235ms xuong con vai chuc ms. // V46: non-wrap underrun-stall (producer stall giua play, transport // khong doi) — sau flush da bo backlog stale, cushion 8 block (~43ms, // proven V45.4 seek/wrap) la du; FILL 24 (~128ms) lam moi underrun // them ~60-130ms silence (recovery ~150-400ms -> ~90-130ms). const _wrap = this.wrapMode; this.outQueue.length = 0; this.outPos = 0; this.resumeFadeIn = true; // V46.3: wrap-context underrun — resume chi can 4 block (~10.7ms) khong // phai 8 (~21ms): queue pass moi la audio LIEN TUC (bar1 da bat dau), // khong con backlog stale de phong — cho 8 block them ~10ms gap seam // (log 08:49 wrap2: q=4 tai wrap -> tail 10.7ms can, bar1 toi +26ms, // resume 8-block -> gap ~35ms = giat lag). Non-wrap giu 8 (V46). this.resumeFill = _wrap ? 4 : 8; this.flushPending = _wrap ? false : true; if (!_wrap) this.port.postMessage({ type: 'flush' }); i--; continue; } this.playing = true; this.outPos = 0; this.holdFadeLeft = 0; this.wrapMode = false; // V45.3: resume xong — ve che do thuong (underrun sau nay flush nhu cu) if (this.dbgPlayFirst) { this.dbgPlayFirst = false; // V45.19 Bug 1b: seek -> tag fx_seek_first (main thread snap khong can // guard 50ms — seek resume nhanh ~43ms < 50ms). Wrap/play -> fx_play_first. this.port.postMessage({ type: 'dbg', tag: (this.clearMode === 'seek' ? 'fx_seek_first' : 'fx_play_first'), t: Math.round(currentTime * 1000), q: this.outQueue.length, m: this.clearMode || '' }); } 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.skipFadeTotal = 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);