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
This commit is contained in:
2026-08-24 11:19:56 +07:00
parent 3c9f1c5809
commit 5ca2027966
6 changed files with 170 additions and 151 deletions
+7 -63
View File
@@ -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())
+8 -6
View File
@@ -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);
+9 -6
View File
@@ -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;i<half;i++){l[i]=f[i*2];r[i]=f[i*2+1];}entry.node.port.postMessage({type:'out',l,r});};ws.onclose=()=>fxRtTrackStop(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<f.length;i+=512)entry.ws.send(f.slice(i,i+512).buffer);}else{entry.ws.send(dd.data);}}catch(e){}}}if(entry.inQueue.length){if(entry.pumpTimer)clearTimeout(entry.pumpTimer);entry.pumpTimer=setTimeout(pump,Math.max(1,FXRT_BURST_MS-(now-entry.lastInSent)));}else if(entry.pumpTimer){clearTimeout(entry.pumpTimer);entry.pumpTimer=null;}};pump();};}catch(e){console.warn('[FX RT track] setup fail',trackId,path,e);fxRtTrackStop(trackId,path,true);}};const fxRtTrackUnsplice=(trackId,path)=>{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
+85 -37
View File
@@ -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;
}
+1 -1
View File
@@ -51,7 +51,7 @@
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/services/chordTheory.js?v=202608211300"></script>
<script src="/static/js/app.precompiled.js?v=202608240831" defer></script>
<script src="/static/js/app.precompiled.js?v=202608241041" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+60 -38
View File
@@ -54,14 +54,25 @@ def stall_model(name):
class WorkletSim:
"""Mô phỏng chính xác SfFxRealtimeProcessor outQueue từ arrival times."""
"""Mô phỏng chính xác SfFxRealtimeProcessor outQueue từ arrival times.
def __init__(self):
mode='cut' — hiện tại: underrun → hold HOLD block rồi CẮT cứng silence
(click nếu nội dung khác 0).
mode='holdfade' — đề xuất (fix worklet): underrun → giữ block cuối + fade
gain 1→0 qua FADE_HOLD block (~21ms) rồi silence ~0
(hết click); giữa hold, block mới tới → resume ngay.
"""
FADE_HOLD = 4 # block fade khi underrun (proposal)
def __init__(self, mode="cut"):
self.mode = mode
self.arrivals = [] # (block_seq, time, peak)
self.underruns = 0
self.silences = 0
self.silent_ms = 0.0
self.hold_events = 0
self.hold_fades = 0
self.silence_peaks = [] # peak block cuối trước mỗi silence (fade -> ~0)
def run(self, duration):
@@ -70,6 +81,7 @@ class WorkletSim:
started = False
idx = 0
last_peak = 1.0
hold_fade = 0 # còn N block fade đang phát (mode holdfade)
for item in self.arrivals:
bi, t = item[0], item[1]
peak = item[2] if len(item) > 2 else 1.0
@@ -85,29 +97,50 @@ class WorkletSim:
# consume các block đến hạn trước arrival này
while play is not None and play <= t:
if queue:
if hold_fade > 0 and self.mode == "holdfade":
# có block mới giữa hold-fade → resume ngay (không đợi
# hết fade, không cắt): fade chỉ còn là chỗi ~0.
hold_fade = 0
last_peak = queue.pop(0)[1]
play += BLOCK_PERIOD
else:
# underrun tại play — hold HOLD block rồi silence
# underrun tại play
self.underruns += 1
hold_end = play + HOLD * BLOCK_PERIOD
if t >= hold_end:
# silence từ hold_end tới khi có block mới (t)
self.silences += 1
self.silent_ms += (t - hold_end) * 1e3
self.silence_peaks.append(last_peak)
if self.mode == "holdfade":
# giữ block cuối + fade 1→0 qua FADE_HOLD block
hold_fade = self.FADE_HOLD
self.hold_fades += 1
while play <= t and hold_fade > 0:
last_peak *= (hold_fade - 1) / max(self.FADE_HOLD - 1, 1)
hold_fade -= 1
play += BLOCK_PERIOD
if hold_fade == 0 and play <= t:
# đã fade về ~0 → silence êm (không click)
self.silences += 1
self.silent_ms += (t - play) * 1e3
self.silence_peaks.append(last_peak)
play = None
idx += 1
else:
self.hold_events += 1
play = None
idx += 1
hold_end = play + HOLD * BLOCK_PERIOD
if t >= hold_end:
# silence từ hold_end tới khi có block mới (t)
self.silences += 1
self.silent_ms += (t - hold_end) * 1e3
self.silence_peaks.append(last_peak)
else:
self.hold_events += 1
play = None
idx += 1
if play is None and queue:
play = t + BLOCK_PERIOD
last_peak = queue.pop(0)[1]
return self
def report(self):
r = ("worklet sim: underruns=%d hold_only=%d silences=%d silent_ms=%.1f"
% (self.underruns, self.hold_events, self.silences, self.silent_ms))
r = ("worklet sim[%s]: underruns=%d hold_only=%d hold_fades=%d silences=%d silent_ms=%.1f"
% (self.mode, self.underruns, self.hold_events, self.hold_fades,
self.silences, self.silent_ms))
if self.silence_peaks:
arr = np.array(self.silence_peaks)
hard = int((arr > 0.05).sum())
@@ -179,12 +212,13 @@ async def run_ws_probe(port, chain, secs, stall):
produce_count = n
async def stall_scheduler():
# main-thread busy: báo stall_until cho pump qua queue
# main-thread busy: set stall_until — pump VÀ consumer đều đọc (cùng
# main thread bị block → cả 2 hướng đều đứng; app thật đúng vậy).
while not stop.is_set():
if s_prob:
await asyncio.sleep(random.expovariate(s_prob))
dur = random.uniform(s_min, s_max) / 1000.0
await stall_q.put(time.perf_counter() + dur)
stall_state["until"] = time.perf_counter() + dur
else:
await asyncio.sleep(60)
@@ -192,13 +226,12 @@ async def run_ws_probe(port, chain, secs, stall):
# fxRtInPump JS: tối đa 1 burst/21ms; stall → không gửi
nonlocal backlog
last_sent = 0.0
stall_until = 0.0
send_times = []
iter_times = []
while not stop.is_set():
now = time.perf_counter()
iter_times.append(now)
if now < stall_until:
if now < stall_state["until"]:
await asyncio.to_thread(time.sleep, 0.002)
continue
while not produced.empty():
@@ -212,33 +245,22 @@ async def run_ws_probe(port, chain, secs, stall):
send_times.append(time.perf_counter() - t0)
last_sent = now
await asyncio.to_thread(time.sleep, 0.001)
# cập nhật stall_until từ scheduler
if not stall_q.empty():
stall_until = stall_q.get_nowait()
nonlocal send_time_stats, pump_iter_stats
send_time_stats = send_times
pump_iter_stats = iter_times
stall_q = asyncio.Queue()
stall_state = {"until": 0.0}
async def consumer():
if os.environ.get("FXRT_PROBE_PLAIN_RECV"):
while not stop.is_set():
try:
msg = await ws.recv()
except Exception:
break
if isinstance(msg, bytes) and len(msg) == BLOCK * 2 * 4:
now = time.perf_counter()
pk = float(np.frombuffer(msg, dtype=np.float32)[::2].max())
async with recv_lock:
recv_times.append((len(recv_times), now, pk))
return
while not stop.is_set():
try:
msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
except asyncio.TimeoutError:
if os.environ.get("FXRT_PROBE_CONSUMER_STALL") and \
time.perf_counter() < stall_state["until"]:
# main-thread busy: WS message nằm trong browser queue, main
# không decode/postMessage → worklet không nhận gì.
await asyncio.to_thread(time.sleep, 0.002)
continue
try:
msg = await ws.recv()
except Exception:
break
if isinstance(msg, bytes) and len(msg) == BLOCK * 2 * 4:
@@ -308,7 +330,7 @@ async def run_ws_probe(port, chain, secs, stall):
over = g[g > 1.35 * BLOCK_PERIOD * 1e3]
print(" gaps>%.1fms: %d (%.2f%%)" % (1.35 * BLOCK_PERIOD * 1e3, len(over),
100.0 * len(over) / len(g) if len(g) else 0))
sim = WorkletSim()
sim = WorkletSim(mode=os.environ.get("FXRT_PROBE_WORKLET", "cut"))
sim.arrivals = times
sim.run(dur)
print(" " + sim.report())