From 5ca20279663cad15c27af55416f6f2af0b072705 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Mon, 24 Aug 2026 11:19:56 +0700 Subject: [PATCH] fix(fxrt): underrun xu ly o worklet (hold+fade), bo fade engine, fix wet bus - sf-fx-realtime.js: underrun -> hold block cuoi (HOLD) roi fade-out 4 block ~21ms gain 1->0 truoc khi im lang (het click cat cung khi main-thread stall chan ca 2 huong - engine khong the gui fade toi kip); resume giua fade -> fade-in nhanh - plugins.py: bo fade-out/fade-in engine (den tre vo dung + fade-in sau stall tao dip dinh ky = vibrato/wow&flutter), giu to_thread(time.sleep) poll 2ms - app.jsx/precompiled/index.html: fix master wet bus (no dry lai) + latency const FX_RT_ROUNDTRIP_SAMPLES + bump worklet cache-buster v=202608241800 - fxrt_ws_probe.py: consumer stall (mo hinh dung app that), WorkletSim holdfade mode Probe consumer-stall (app that): light 5/5 hard click 0.245-0.285 -> hard=0; heavy: recv=sent (het fade thua), hard=0 --- app/api/v1/plugins.py | 70 ++---------------- app/static/js/app.jsx | 14 ++-- app/static/js/app.precompiled.js | 15 ++-- app/static/js/sf-fx-realtime.js | 122 +++++++++++++++++++++---------- app/templates/index.html | 2 +- fxrt_ws_probe.py | 98 +++++++++++++++---------- 6 files changed, 170 insertions(+), 151 deletions(-) diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index 2ffae96..e9dcbd4 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -1097,38 +1097,16 @@ async def ws_fx_realtime(websocket: WebSocket, session_id: str): # 4 → gửi hết ngay → CLIENT tích lũy buffer (worklet FILL) hấp thụ # jitter bridge/WS. Pace đúng realtime trước đây làm client queue ≈1 → # bất kỳ jitter (bridge process VST biến thiên, GC, WS) → queue cạn → - # zero-fill crackle. Worklet cap 32 block chặn overflow khi bridge + # zero-fill crackle. Worklet cap 48 block chặn overflow khi bridge # nhanh hơn. - # StALL-FADE (fix crackle nặng khi load VST FX): transport stall (browser - # main-thread block GUI/rAF/React) làm bridge ngừng sản xuất output → - # worklet hết buffer (FILL=24 ~128ms) → HOLD 2 block → CẮT cứng xuống - # silence → click. Khi nhận ra output ngừng > ngưỡng → gửi fade-out: - # lặp block real cuối với gain 1→0 qua 8 block (~42ms, pace realtime). - # Worklet hết buffer nhưng nội dung đã về ~0 → hold/silence không click. - # Output trở lại → fade-in 8 block đầu 0→1 (hết click resume). - import struct as _struct - block_sec = (sess.block_size or 256) / max(int(sess.sample_rate or 44100), 1) - # Ngưỡng stall: > batch period (4*block_sec) + jitter; 5.5*block_sec ≈ 29.3ms - # (batch gap thường ~21.3ms + jitter bridge ~2ms sau fix timer 1ms + wakeup - # pump hiếm ~8ms). 6x (32ms) bỏ sót case queue worklet thấp lúc recovery; - # 5x false-fade khi gap 29ms không phải stall thật. 5.5x dung hòa. - _stall_s = 5.5 * block_sec - _fade_blocks = 8 - _fade_fmt = '<%df' % ((sess.block_size or 256) * 2) - def _gain(blk, g): - if g >= 1.0: return blk - if g <= 0.0: return b'\x00' * len(blk) - vals = _struct.unpack(_fade_fmt, blk) - return _struct.pack(_fade_fmt, *[v * g for v in vals]) + # UNDERRUN do stall (main-thread browser block GUI/rAF/React) xử lí Ở + # CLIENT: worklet lặp block cuối (HOLD) rồi fade-out ~21ms + # (sf-fx-realtime.js). Engine KHÔNG fade — fade gửi từ engine không thể + # tới worklet kịp khi main-thread stall chặn cả 2 hướng (fade nằm trong + # browser queue, tới sau khi worklet đã cạn buffer → click vẫn còn), và + # fade-in sau stall tạo dip âm lượng định kỳ = vibrato/wow&flutter. _last_lat = None - _last_blk = None - _last_out = 0.0 - _had_out = False - _fading = False - _fade_i = 0 - _fade_in = 0 try: - _loop = asyncio.get_event_loop() while True: try: sess.flush_input_stale() # input lẻ kẹt trong pending → publish @@ -1145,47 +1123,13 @@ async def ws_fx_realtime(websocket: WebSocket, session_id: str): pass blks = sess.read_output() if not blks: - _now = _loop.time() - _gap = _now - _last_out if _had_out else 0.0 - if (_had_out and not _fading and _fade_in <= 0 and - _last_blk is not None and _fade_i == 0 and - _gap > _stall_s): - _fading = True - _fade_i = 0 - if _fading and _last_blk is not None and _fade_i < _fade_blocks: - # Gửi BURST toàn bộ fade-out (không pace realtime): worklet - # queue (FILL 24) hấp thụ 8 block; fade frames xếp sau buffer - # real → worklet phát dốc 1→0 khi hết buffer → silence ~0. - # Burst cũng phủ case queue worklet THẤP lúc recovery (fade - # đến trước khi queue cạn, thay vì chậm 42ms như pace thật). - while _fade_i < _fade_blocks: - # gain (8-i-1)/7: block cuối đúng 0.0 → hold/silence ~0 - await websocket.send_bytes(_gain( - _last_blk, - (_fade_blocks - 1 - _fade_i) / (_fade_blocks - 1))) - _fade_i += 1 - _last_out = _loop.time() - _fading = False # đã về ~0 → worklet tự silence - continue # to_thread(time.sleep): asyncio.sleep trên Windows bị cap # 15.6ms (GQCS/IOCP, Win11 cap nền) dù timeBeginPeriod; # time.sleep dùng high-res waitable timer -> poll đúng 2ms. await asyncio.to_thread(_time.sleep, 0.002) continue for _blk in blks: - if _fading or _fade_i >= _fade_blocks: - # resume (giữa fade hoặc sau fade xong) → reset + fade-in - _fading = False - _fade_i = 0 - _fade_in = _fade_blocks - if _fade_in > 0: - # gain (8-i-1)/7 dốc 0→1: block cuối full - _blk = _gain(_blk, 1.0 - (_fade_in - 1) / _fade_blocks) - _fade_in -= 1 await websocket.send_bytes(_blk) - _last_blk = _blk - _last_out = _loop.time() - _had_out = True except Exception: pass pump_task = asyncio.create_task(_pump_output()) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 5622db2..c5d61d4 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -1165,7 +1165,8 @@ function initMasterBus(ctx) { masterBus.inputAnalyser.connect(masterBus.outputAnalyser); masterBus.outputAnalyser.connect(masterBus.masterFxDryGain); masterBus.masterFxDryGain.connect(masterBus.pdcDelay); - masterBus.outputAnalyser.connect(masterBus.masterFxGain); + // Wet bus: CHI nhan node fxRt (fxRtStart noi) — khong noi dry truc tiep vao + // masterFxGain (no = dry + processed = play double khi PWR bat). masterBus.masterFxGain.connect(masterBus.output); masterBus.muteGain.connect(masterBus.analyser); masterBus.muteGain.connect(masterBus.splitter); @@ -1766,7 +1767,7 @@ async function fxRtStart() { fxRt.sessionId = st.session_id; fxRtGuiPushShm(); try { - await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241200`); + await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241800`); if (token !== fxRt.startToken) { fxRt.starting = false; return; } fxRt.node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] }); const u = new URL(API_BASE_URL); @@ -1941,9 +1942,10 @@ function fxRtStop(silent) { // qua worklet → lệch pha với mastered. fxRtApplyPdc trễ dry path đúng lượng // để align. FX_RT_ROUNDTRIP_SAMPLES là ước lượng — ponytail: đo RTT thật // (tag seq trên block) khi cần sample-accurate. -// RTT = batching 4 block (~7 block @48k ≈ 1792) + FILL buffer 8 block (2048) -// — worklet chỉ phát khi queue ≥ FILL → latency tĩnh thêm FILL → 15 block. -const FX_RT_ROUNDTRIP_SAMPLES = 3840; +// RTT = batching 4 block (~7 block @48k ≈ 1792) + FILL buffer 24 block +// (6144) — worklet chỉ phát khi queue ≥ FILL → latency tĩnh thêm FILL → 31 +// block ≈ 7936. (FILL 16 trước đây = 5888; giữ đồng bộ với sf-fx-realtime.js) +const FX_RT_ROUNDTRIP_SAMPLES = 7936; function fxRtApplyPdc() { const ctx = getAudioContext(); const bus = masterBus; @@ -23837,7 +23839,7 @@ const App = () => { fxRtTracks[key] = entry; fxRtGuiPushShm(); try { - await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241200`); + await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241800`); const node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] }); entry.node = node; const u = new URL(API_BASE_URL); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 35e7345..ace5c01 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -214,7 +214,9 @@ eqLowFilter.connect(eqMid1Filter);eqMid1Filter.connect(eqMid2Filter);eqMid2Filte // Compressor mặc định (ratio 12, threshold -24 — LUÔN-ON) vừa (a) pump-down // tín hiệu → âm nhỏ/méo, vừa (b) phát NaN khi gặp bass transient → 11 biquad // "state is bad" → CÂM + stuck. Mastering chain có comp/lim module riêng khi bật. -masterBus.input.connect(masterBus.inputAnalyser);masterBus.inputAnalyser.connect(masterBus.outputAnalyser);masterBus.outputAnalyser.connect(masterBus.masterFxDryGain);masterBus.masterFxDryGain.connect(masterBus.pdcDelay);masterBus.outputAnalyser.connect(masterBus.masterFxGain);masterBus.masterFxGain.connect(masterBus.output);masterBus.muteGain.connect(masterBus.analyser);masterBus.muteGain.connect(masterBus.splitter);masterBus.analyser.connect(ctx.destination);window.masterBus=masterBus;// Apply mastering once when the chain is first created (and on the +masterBus.input.connect(masterBus.inputAnalyser);masterBus.inputAnalyser.connect(masterBus.outputAnalyser);masterBus.outputAnalyser.connect(masterBus.masterFxDryGain);masterBus.masterFxDryGain.connect(masterBus.pdcDelay);// Wet bus: CHI nhan node fxRt (fxRtStart noi) — khong noi dry truc tiep vao +// masterFxGain (no = dry + processed = play double khi PWR bat). +masterBus.masterFxGain.connect(masterBus.output);masterBus.muteGain.connect(masterBus.analyser);masterBus.muteGain.connect(masterBus.splitter);masterBus.analyser.connect(ctx.destination);window.masterBus=masterBus;// Apply mastering once when the chain is first created (and on the // masteringSettings effect for subsequent changes — see useEffect). if(window.currentMasteringSettings){try{// Reset sig-cache: chain MỚI (biquad/maximizer node mới) giữ giá trị // INIT (gain 0, width 100…) — applyMasteringSettings early-return vì @@ -302,7 +304,7 @@ else if(fxRt.chainKey!==ck){fxRtSetChain(chain);fxRt.chainKey=ck;return;}else re // node → bypass vô hiệu. Mỗi fxRtStop() bump startToken → mọi checkpoint // dưới đây thấy mismatch → hủy session vừa tạo, không nối node. const token=++fxRt.startToken;fxRt.starting=true;const ctx=getAudioContext();if(!ctx||!ctx.audioWorklet||!masterBus||!masterBus.output||!masterBus.analyser||!masterBus.masterFxGain||!masterBus.masterFxDryGain)return;const chainPayload=chain.map(s=>s.type==='vst3'?{type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:false}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:false});let st;try{st=await window.SonicAPI.fxRealtimeStart({fx_chain:chainPayload,sample_rate:ctx.sampleRate||44100});}catch(e){console.warn('[FX RT] start fail:',e);showToast('Không khởi động VST FX realtime: '+(e.message||e),'warning');fxRt.starting=false;return;}if(token!==fxRt.startToken){// Bypass/stop giữa lúc engine đang load chain — hủy session vừa tạo. -console.warn('[FX RT] start cancelled mid-flight — session stopped');try{window.SonicAPI.fxRealtimeStop({session_id:st.session_id}).catch(()=>{});}catch(e2){}fxRt.starting=false;return;}fxRt.sessionId=st.session_id;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608240831`);if(token!==fxRt.startToken){fxRt.starting=false;return;}fxRt.node=new AudioWorkletNode(ctx,'sf-fx-realtime',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2]});const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';const tok=localStorage.getItem('sonic_token')||'';const wsUrl=`${wsProto}${u.host}${st.ws_url}${tok?'?token='+encodeURIComponent(tok):''}`;const ws=new WebSocket(wsUrl);fxRt.ws=ws;ws.binaryType='arraybuffer';ws.onopen=()=>{if(token!==fxRt.startToken){try{ws.close();}catch(e){}fxRt.starting=false;return;}fxRt.active=true;fxRt.starting=false;fxRt.inQueue=[];// bỏ audio stale sản xuất trước khi WS open (~4s load VST) — hết lag khởi đầu +console.warn('[FX RT] start cancelled mid-flight — session stopped');try{window.SonicAPI.fxRealtimeStop({session_id:st.session_id}).catch(()=>{});}catch(e2){}fxRt.starting=false;return;}fxRt.sessionId=st.session_id;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241800`);if(token!==fxRt.startToken){fxRt.starting=false;return;}fxRt.node=new AudioWorkletNode(ctx,'sf-fx-realtime',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2]});const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';const tok=localStorage.getItem('sonic_token')||'';const wsUrl=`${wsProto}${u.host}${st.ws_url}${tok?'?token='+encodeURIComponent(tok):''}`;const ws=new WebSocket(wsUrl);fxRt.ws=ws;ws.binaryType='arraybuffer';ws.onopen=()=>{if(token!==fxRt.startToken){try{ws.close();}catch(e){}fxRt.starting=false;return;}fxRt.active=true;fxRt.starting=false;fxRt.inQueue=[];// bỏ audio stale sản xuất trước khi WS open (~4s load VST) — hết lag khởi đầu try{fxRtPushAllParams();}catch(e){}};ws.onmessage=ev=>{// Gap 8 (PDC realtime): text frame {cmd:'latency', total} tu server -> // cap nhat delay dry-bypass path (bu latency chain VST3 realtime). if(typeof ev.data==='string'){try{const d=JSON.parse(ev.data);if(d&&d.cmd==='latency'&&typeof d.total==='number'){fxRt.latencyTotal=d.total;// Chi delay dry path khi crossfade da xong (dry silent) — ap som @@ -342,9 +344,10 @@ if(!_fxStopNode)fxRtApplyPdc();if(sid){try{window.SonicAPI.fxRealtimeStop({sessi // qua worklet → lệch pha với mastered. fxRtApplyPdc trễ dry path đúng lượng // để align. FX_RT_ROUNDTRIP_SAMPLES là ước lượng — ponytail: đo RTT thật // (tag seq trên block) khi cần sample-accurate. -// RTT = batching 4 block (~7 block @48k ≈ 1792) + FILL buffer 8 block (2048) -// — worklet chỉ phát khi queue ≥ FILL → latency tĩnh thêm FILL → 15 block. -const FX_RT_ROUNDTRIP_SAMPLES=3840;function fxRtApplyPdc(){const ctx=getAudioContext();const bus=masterBus;if(!ctx||!bus||!bus.pdcDelay)return;const l=fxRt&&fxRt.active?FX_RT_ROUNDTRIP_SAMPLES+(fxRt.latencyTotal||0):0;try{bus.pdcDelay.delayTime.setTargetAtTime(l/(ctx.sampleRate||44100),ctx.currentTime,0.05);}catch(e){}}// Crossfade master-FX switch: ramp masterFxGain/masterFxDryGain thay vi +// RTT = batching 4 block (~7 block @48k ≈ 1792) + FILL buffer 24 block +// (6144) — worklet chỉ phát khi queue ≥ FILL → latency tĩnh thêm FILL → 31 +// block ≈ 7936. (FILL 16 trước đây = 5888; giữ đồng bộ với sf-fx-realtime.js) +const FX_RT_ROUNDTRIP_SAMPLES=7936;function fxRtApplyPdc(){const ctx=getAudioContext();const bus=masterBus;if(!ctx||!bus||!bus.pdcDelay)return;const l=fxRt&&fxRt.active?FX_RT_ROUNDTRIP_SAMPLES+(fxRt.latencyTotal||0):0;try{bus.pdcDelay.delayTime.setTargetAtTime(l/(ctx.sampleRate||44100),ctx.currentTime,0.05);}catch(e){}}// Crossfade master-FX switch: ramp masterFxGain/masterFxDryGain thay vi // hard-rewire (click). Hai gain nay duoc tao trong initMasterBus. function fxRtCrossfade(toFx,token){const ctx=getAudioContext();const bus=masterBus;if(!ctx||!bus||!bus.masterFxGain||!bus.masterFxDryGain)return;if(token!==undefined&&token!==fxRt.startToken)return;const tc=0.025;try{bus.masterFxGain.gain.setTargetAtTime(toFx?1:0,ctx.currentTime,tc);bus.masterFxDryGain.gain.setTargetAtTime(toFx?0:1,ctx.currentTime,tc);}catch(e){}}// ── Realtime VST FX per-track (SHM bridge) — mọi luồng track qua bridge ───── // Mỗi track: session riêng (1 WS/SHM) cho TỪNG đường chain có âm — audio-clips @@ -1696,7 +1699,7 @@ fxRtTrackReconcile(trackId,node,track);}catch(e){console.warn('rebuildTrackFxGra // Cần activeTrackNodesRef/isPlayingRef. Session giống master (fxRtStart): // fetch start → AudioWorkletNode 'sf-fx-realtime' → WS; WS open → splice // worklet vào chain hiện tại (đọc mods MỚI NHẤT — an toàn với rebuild). -const fxRtTrackStart=async(trackId,track,path)=>{const key=trackId+':'+path;const chain=fxRtTrackChain(track);if(!chain.length)return;if(fxRtTracks[key]){if(!fxRtTracks[key].node||fxRtTracks[key].node.context!==getAudioContext())fxRtTrackStop(trackId,path,true);else return;}const ctx=getAudioContext();if(!ctx||!ctx.audioWorklet)return;const chainPayload=chain.map(s=>s.type==='vst3'?{type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:false}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:false});let st;try{st=await window.SonicAPI.fxRealtimeStart({fx_chain:chainPayload,sample_rate:ctx.sampleRate||44100});}catch(e){console.warn('[FX RT track] start fail',trackId,path,e);return;}const entry={sessionId:st.session_id,ws:null,node:null,ready:false,path,spliced:null,chainKey:fxRtTrackChainKey(chain),paramsBySlot:chain.map(s=>fxRtEntryParams(s,null)),inQueue:[],pumpTimer:null,lastInSent:0};fxRtTracks[key]=entry;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608240831`);const node=new AudioWorkletNode(ctx,'sf-fx-realtime',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2]});entry.node=node;const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';const tok=localStorage.getItem('sonic_token')||'';const ws=new WebSocket(`${wsProto}${u.host}${st.ws_url}${tok?'?token='+encodeURIComponent(tok):''}`);entry.ws=ws;ws.binaryType='arraybuffer';ws.onopen=()=>{entry.ready=true;entry.inQueue=[];// bỏ audio stale trước khi WS open — hết lag khởi đầu +const fxRtTrackStart=async(trackId,track,path)=>{const key=trackId+':'+path;const chain=fxRtTrackChain(track);if(!chain.length)return;if(fxRtTracks[key]){if(!fxRtTracks[key].node||fxRtTracks[key].node.context!==getAudioContext())fxRtTrackStop(trackId,path,true);else return;}const ctx=getAudioContext();if(!ctx||!ctx.audioWorklet)return;const chainPayload=chain.map(s=>s.type==='vst3'?{type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:false}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:false});let st;try{st=await window.SonicAPI.fxRealtimeStart({fx_chain:chainPayload,sample_rate:ctx.sampleRate||44100});}catch(e){console.warn('[FX RT track] start fail',trackId,path,e);return;}const entry={sessionId:st.session_id,ws:null,node:null,ready:false,path,spliced:null,chainKey:fxRtTrackChainKey(chain),paramsBySlot:chain.map(s=>fxRtEntryParams(s,null)),inQueue:[],pumpTimer:null,lastInSent:0};fxRtTracks[key]=entry;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241800`);const node=new AudioWorkletNode(ctx,'sf-fx-realtime',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2]});entry.node=node;const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';const tok=localStorage.getItem('sonic_token')||'';const ws=new WebSocket(`${wsProto}${u.host}${st.ws_url}${tok?'?token='+encodeURIComponent(tok):''}`);entry.ws=ws;ws.binaryType='arraybuffer';ws.onopen=()=>{entry.ready=true;entry.inQueue=[];// bỏ audio stale trước khi WS open — hết lag khởi đầu fxRtTrackSplice(trackId,path);try{(entry.paramsBySlot||[]).forEach((pl,slot)=>pl.forEach(p=>ws.send(JSON.stringify({cmd:'set_param',slot,key:p.key,value:p.value}))));}catch(e){}};ws.onmessage=ev=>{if(!(ev.data instanceof ArrayBuffer)||!entry.node)return;const f=new Float32Array(ev.data);const half=f.length/2;const l=new Float32Array(half);const r=new Float32Array(half);for(let i=0;ifxRtTrackStop(trackId,path,true);ws.onerror=()=>{try{ws.close();}catch(e){}};entry.node.port.onmessage=ev=>{const d=ev.data;if(!d||!entry.ws)return;if(d.type==='set_param'){try{entry.ws.send(JSON.stringify({cmd:'set_param',slot:d.slot,key:d.key,value:d.value}));}catch(e){}return;}if(d.type!=='in')return;// Burst 4 block (1024 mẫu) → tách 4 WS frame 512 floats → bridge batch 4. // Pace 1 burst/21ms (giống master) — tránh dồn frame sau stall làm tràn ring. entry.inQueue.push(d);const pump=()=>{if(!entry.inQueue.length)return;const now=Date.now();while(entry.inQueue.length&&now-entry.lastInSent>=FXRT_BURST_MS){entry.lastInSent=now;const dd=entry.inQueue.shift();if(entry.ws&&entry.ws.readyState===1){try{const f=new Float32Array(dd.data);if(f.length>512){for(let i=0;i{const entry=fxRtTracks[trackId+':'+path];if(!entry||!entry.spliced||!entry.node)return;try{entry.node.disconnect();try{entry.spliced.tail.connect(entry.spliced.dest);}catch(e){}entry.spliced=null;}catch(e){console.warn('[FX RT track] unsplice fail',trackId,path,e);}};// Chain đổi trên session ĐANG CHẠY — set_chain KHÔNG restart (bridge swap diff --git a/app/static/js/sf-fx-realtime.js b/app/static/js/sf-fx-realtime.js index 0fdcf24..38864d2 100644 --- a/app/static/js/sf-fx-realtime.js +++ b/app/static/js/sf-fx-realtime.js @@ -12,21 +12,32 @@ // 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 +// ≥ 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 → zero-fill. Underrun (queue cạn khi đang phát) → re-buffer: im lặng -// chờ đủ FILL rồi phát tiếp (1 lần gián đoạn thay vì zero-fill liên tục). -// Cap 32 block (~170ms) chặn overflow khi bridge nhanh hơn realtime. -// ponytail: thay drop/rebuffer bằng adaptive stretch khi cần chất lượng cao. +// → cạn → ngắt quãng. +// UNDERRUN (queue cạn khi đang phát): KHÔNG zero-fill/re-buffer im lặng — lặp +// block cuối tối đa HOLD block (~11ms) giữ âm liên tục (hết click/ngắt), rồi +// FADE-OUT HOLD_FADE block (~21ms) gain 1→0 trước khi im lặng nếu stall kéo +// dài (hết click cắt cứng — engine không thể gửi fade tới kịp khi main-thread +// stall chặn cả 2 hướng); resume NGAY khi có block mới (không đợi đủ FILL), +// nếu đang giữa fade-out → fade-in nhanh (hết jump ~0→1) + không re-fade sau +// lần đầu (tránh tụt volume). +// Cap 48 block (~256ms) chặn overflow khi bridge nhanh hơn realtime. +// ponytail: thay hold bằng adaptive stretch khi cần chất lượng cao. class SfFxRealtimeProcessor extends AudioWorkletProcessor { constructor() { super(); this.BLOCK = 256; // FXRT_BLOCK this.BURST = 4; // số block gom 1 frame (bridge batch) - this.FILL = 16; // 16 block ~86ms: buffer khởi đầu — hấp thụ jitter main-thread (GUI decode, rAF, solo restart) - this.REBUFFER = 2; // 2 block ~11ms: sau underrun chỉ cần bấy nhiêu để phát lại — gap ngắn, không còn 86ms im lặng - this.CAP = 32; // cap queue chặn overflow + this.FILL = 24; // 24 block ~128ms: buffer khởi đầu — hấp thụ jitter main-thread (GUI decode, rAF, solo restart) + this.CAP = 48; // cap queue chặn overflow (~256ms) + this.HOLD = 2; // 2 block ~11ms: lặp block cuối khi underrun (hết click) trước khi im lặng + this.HOLD_FADE = 4; // 4 block ~21ms: fade 1→0 sau HOLD hết (hết click cắt cứng) + this.lastBlock = null; // block cuối đã phát xong (hold khi underrun) + this.holdPos = 0; + this.holdLeft = 0; + this.holdFadeLeft = 0; // mẫu còn lại của fade-out sau HOLD this.bufLen = this.BLOCK * this.BURST; this.inL = new Float32Array(this.bufLen); this.inR = new Float32Array(this.bufLen); @@ -34,7 +45,7 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { 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 — phân biệt FILL khởi đầu vs REBUFFER sau underrun + this.hasStarted = false; // đã phát block đầu chưa — FILL chỉ chờ ở lần khởi đầu 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 @@ -76,40 +87,77 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { } } - // Phát output: im lặng tới khi queue ≥ FILL; queue cạn khi đang phát - // (underrun) → im lặng chờ đủ FILL (re-buffer) thay vì zero-fill liên tục. + // 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 → lặp block cuối tối đa HOLD + // block (không zero-fill → hết click/ngắt) rồi mới im lặng; resume NGAY + // khi có block mới (không đợi re-buffer FILL) + không re-fade sau lần đầu. for (let i = 0; i < n; i++) { - if (this.playing && this.outQueue.length === 0) this.playing = false; - if (!this.playing) { - // Khởi đầu: chờ đủ FILL (hấp thụ jitter). Sau underrun: chỉ cần - // REBUFFER (2 block ~11ms) — gap ngắn thay vì 86ms im lặng. - const need = this.hasStarted ? this.REBUFFER : this.FILL; - if (this.outQueue.length >= need) { - this.playing = true; - this.hasStarted = true; - // Fade-in mượt (hết click lúc FILL xong / resume sau underrun); báo - // main thread để crossfade dry→fx đúng thời điểm. - this.outGain = 0; - this.fadeLeft = this.FADE; - this.port.postMessage({ type: 'started' }); - } else { - outL[i] = 0; - if (outR) outR[i] = 0; + 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--; + } + outL[i] = blk.l[this.outPos] * this.outGain; + if (outR) outR[i] = blk.r[this.outPos] * this.outGain; + this.outPos++; + if (this.outPos >= this.BLOCK) { + this.lastBlock = blk; + this.outQueue.shift(); + this.outPos = 0; + } continue; } + // Underrun giữa stream — chuyển hold (không cắt zero ngay → hết click). + this.playing = false; + this.holdPos = 0; + this.holdLeft = this.HOLD * this.BLOCK; + this.holdFadeLeft = this.HOLD_FADE * this.BLOCK; } - const blk = this.outQueue[0]; - if (this.fadeLeft > 0) { - this.outGain = Math.min(1, this.outGain + 1 / this.FADE); - this.fadeLeft--; - } - outL[i] = blk.l[this.outPos] * this.outGain; - if (outR) outR[i] = blk.r[this.outPos] * this.outGain; - this.outPos++; - if (this.outPos >= this.BLOCK) { - this.outQueue.shift(); + // Không phát: resume khi đủ block (lần đầu chờ FILL; sau đó chỉ cần 1 + // block — hold che khoảng chờ). + if (this.outQueue.length >= (this.hasStarted ? 1 : this.FILL)) { + this.playing = true; this.outPos = 0; + const wasFading = this.holdFadeLeft > 0 && this.holdLeft <= 0; + this.holdLeft = 0; + this.holdFadeLeft = 0; + if (wasFading && this.hasStarted) { + // Resume giữa fade-out: gain đang <1 → fade-in nhanh tránh jump ~0→1 + this.outGain = 0; + this.fadeLeft = this.FADE; + } + 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.holdLeft > 0 && this.lastBlock) { + outL[i] = this.lastBlock.l[this.holdPos] * this.outGain; + if (outR) outR[i] = this.lastBlock.r[this.holdPos] * this.outGain; + this.holdPos++; + if (this.holdPos >= this.BLOCK) this.holdPos = 0; + this.holdLeft--; + continue; + } + if (this.holdFadeLeft > 0 && this.lastBlock) { + // Fade-out 1→0 sau HOLD: im lặng êm, hết click cắt cứng. + const g = this.outGain * (this.holdFadeLeft / (this.HOLD_FADE * this.BLOCK)); + outL[i] = this.lastBlock.l[this.holdPos] * g; + if (outR) outR[i] = this.lastBlock.r[this.holdPos] * g; + this.holdPos++; + if (this.holdPos >= this.BLOCK) this.holdPos = 0; + this.holdFadeLeft--; + continue; + } + outL[i] = 0; + if (outR) outR[i] = 0; } return true; } diff --git a/app/templates/index.html b/app/templates/index.html index 862e190..25b583b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -51,7 +51,7 @@ - +