diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index 310acab..10a0cc0 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -1167,6 +1167,10 @@ async def ws_fx_realtime(websocket: WebSocket, session_id: str): sess.set_param(cmd.get("slot"), cmd.get("key"), cmd.get("value")) elif cmd.get("cmd") == "latency": sess.report_latency(cmd.get("slot"), cmd.get("samples")) + elif cmd.get("cmd") == "flush": + # V24: client resume sau stall — xóa backlog cũ (lag ~1s). + sess.flush_all() + await websocket.send_text(json.dumps({"cmd": "flush_done"})) continue data = msg.get("bytes") if data is None: diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py index 9903293..9c50666 100644 --- a/app/core/fx_realtime.py +++ b/app/core/fx_realtime.py @@ -130,7 +130,8 @@ class FxRealtimeSession: self.latencies = {} # {slot_idx: samples} # Diagnostics (FXRT crackle hunt): drop/publish counters. self.stats = {'publish': 0, 'drop_batch': 0, 'drop_blocks': 0, - 'stale_flush': 0, 'read_blocks': 0, 'read_empty': 0} + 'stale_flush': 0, 'read_blocks': 0, 'read_empty': 0, + 'flush': 0} self._spawn() # ── process ───────────────────────────────────────────────────────────── @@ -264,26 +265,34 @@ class FxRealtimeSession: def _flush_input_locked(self): """Publish pending vào input ring. Ghi data trước, in_write += n sau → - bridge đọc in_write 1 lần → thấy nguyên batch (take=4).""" + bridge đọc in_write 1 lần → thấy nguyên batch (take=4). V24 NO-DROP: + ring đầy → GIỮ pending (pump flush_input_stale 2ms retry) — không drop + → hết content gap + hết race pointer (drop cũ cập in_read vượt in_write + → avail > in_slots → ghi đè slot bridge đang đọc → torn frame). Cap + 2048 block (~11s) chống tích lũy vô hạn khi client dồn liên tục.""" if not self._in_pending: return h = self.h + if len(self._in_pending) > 2048: + drop = len(self._in_pending) - 2048 + del self._in_pending[:drop] # drop block CŨ NHẤT (front) + self.stats['drop_blocks'] += drop frames = self._in_pending - self._in_pending = [] - self._in_pending_t0 = None avail = h.in_slots - (h.in_write - h.in_read) self.stats['publish'] += len(frames) if avail <= 0: - h.in_read += len(frames) # ring full → drop cả batch - self.stats['drop_batch'] += len(frames) + # ring đầy → giữ nguyên pending; pump retry khi bridge drain return n = min(len(frames), avail) - drop = len(frames) - n - if drop > 0: - h.in_read += drop # drop frame cũ nhất - self.stats['drop_blocks'] += drop + publish = frames[:n] + if n < len(frames): + del frames[:n] # publish n block CŨ NHẤT, giữ phần thừa làm pending + self._in_pending_t0 = time.time() if frames else None + else: + self._in_pending = [] + self._in_pending_t0 = None for i in range(n): - L, R = frames[i + drop] + L, R = publish[i] slot = (h.in_write + i) & (h.in_slots - 1) bl = IN_L_OFF // 4 + slot * self.block_size br = IN_R_OFF // 4 + slot * self.block_size @@ -299,6 +308,17 @@ class FxRealtimeSession: self.stats['stale_flush'] += 1 self._flush_input_locked() + def flush_all(self): + """V24: reset toàn bộ pipeline khi client resume sau stall — xóa + input/output ring + pending → bridge xử lí audio MỚI, không backlog cũ + (lag cộng dồn ~1s sau stall). Gọi khi nhận {cmd:'flush'} qua WS.""" + with self._lock: + self._in_pending = [] + self._in_pending_t0 = None + self.h.in_read = self.h.in_write + self.h.out_read = self.h.out_write + self.stats['flush'] += 1 + def read_output(self): """Drain output ring → list bytes (mỗi block 2048 bytes interleaved).""" out = [] diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 96f77e1..3ab5741 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -1848,7 +1848,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=202608312300`); + await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202609011200`); 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); @@ -1861,6 +1861,7 @@ async function fxRtStart() { ws.onopen = () => { if (token !== fxRt.startToken) { try { ws.close(); } catch (e) {} fxRt.starting = false; return; } fxRt.active = true; + fxRt.flushing = false; 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) {} @@ -1876,11 +1877,18 @@ async function fxRtStart() { // Chi delay dry path khi crossfade da xong (dry silent) — ap som // trong luc dry con la path nghe duoc se tao gap ~80ms. if (fxRt.fadedIn) fxRtApplyPdc(); + } else if (d && d.cmd === 'flush_done') { + // V24: engine da flush ring xong — mo lai nhan output + bao worklet + // (worklet drop block cho ack nay; mat ack -> fallback timer nha). + fxRt.flushing = false; + if (fxRt.flushTimer) { clearTimeout(fxRt.flushTimer); fxRt.flushTimer = null; } + if (fxRt.node) fxRt.node.port.postMessage({ type: 'flush_done' }); } } catch (e) {} return; } if (!(ev.data instanceof ArrayBuffer) || !fxRt.node) return; + if (fxRt.flushing) return; // V24: output stale trong luc flush — drop const f = new Float32Array(ev.data); const half = f.length / 2; const l = new Float32Array(half); @@ -1940,6 +1948,22 @@ async function fxRtStart() { }, 30); return; } + if (d.type === 'flush') { + // V24: worklet underrun-resume — yeu cau engine flush ring (xoa backlog + // stale) → het lag ~1s sau stall. Fallback timer phong ack mat (WS/ + // engine loi) → tu nha sau 500ms, worklet nhan block moi binh thuong. + if (fxRt.ws && fxRt.ws.readyState === 1) { + try { fxRt.ws.send(JSON.stringify({ cmd: 'flush' })); } catch (e) {} + } + fxRt.flushing = true; + if (fxRt.flushTimer) clearTimeout(fxRt.flushTimer); + fxRt.flushTimer = setTimeout(() => { + fxRt.flushTimer = null; + fxRt.flushing = false; + if (fxRt.node) fxRt.node.port.postMessage({ type: 'flush_done' }); + }, 500); + return; + } if (d.type === 'set_param') { try { fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot: d.slot, key: d.key, value: d.value })); } catch (e) {} return; @@ -1985,7 +2009,9 @@ function fxRtStop(silent) { fxRt.startToken++; fxRt.starting = false; if (fxRt.pumpTimer) { clearInterval(fxRt.pumpTimer); fxRt.pumpTimer = null; } + if (fxRt.flushTimer) { clearTimeout(fxRt.flushTimer); fxRt.flushTimer = null; } fxRt.inQueue = []; + fxRt.flushing = false; if (!fxRt.active && !fxRt.ws && !fxRt.node && !fxRt.sessionId) return; let _fxStopNode = null; try { @@ -24466,11 +24492,11 @@ const App = () => { 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) + '|vb:' + (track && track.vstFxBypass ? '1' : '0'), paramsBySlot: chain.map(s => fxRtEntryParams(s, null)), inQueue: [], pumpTimer: null, lastInSent: 0 }; + const entry = { sessionId: st.session_id, ws: null, node: null, ready: false, path, spliced: null, chainKey: fxRtTrackChainKey(chain) + '|vb:' + (track && track.vstFxBypass ? '1' : '0'), paramsBySlot: chain.map(s => fxRtEntryParams(s, null)), inQueue: [], pumpTimer: null, lastInSent: 0, flushing: false, flushTimer: null }; fxRtTracks[key] = entry; fxRtGuiPushShm(); try { - await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608312300`); + await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202609011200`); const node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] }); entry.node = node; const u = new URL(API_BASE_URL); @@ -24488,7 +24514,19 @@ const App = () => { } catch (e) {} }; ws.onmessage = (ev) => { + if (typeof ev.data === 'string') { + try { + const d = JSON.parse(ev.data); + if (d && d.cmd === 'flush_done') { + entry.flushing = false; + if (entry.flushTimer) { clearTimeout(entry.flushTimer); entry.flushTimer = null; } + if (entry.node) entry.node.port.postMessage({ type: 'flush_done' }); + } + } catch (e) {} + return; + } if (!(ev.data instanceof ArrayBuffer) || !entry.node) return; + if (entry.flushing) return; // V24: output stale trong luc flush — drop const f = new Float32Array(ev.data); const half = f.length / 2; const l = new Float32Array(half); @@ -24501,6 +24539,21 @@ const App = () => { entry.node.port.onmessage = (ev) => { const d = ev.data; if (!d || !entry.ws) return; + if (d.type === 'flush') { + // V24: nhu master — worklet underrun-resume → flush engine ring + // (xoa backlog stale) → het lag ~1s sau stall. Fallback phong ack mat. + if (entry.ws && entry.ws.readyState === 1) { + try { entry.ws.send(JSON.stringify({ cmd: 'flush' })); } catch (e) {} + } + entry.flushing = true; + if (entry.flushTimer) clearTimeout(entry.flushTimer); + entry.flushTimer = setTimeout(() => { + entry.flushTimer = null; + entry.flushing = false; + if (entry.node) entry.node.port.postMessage({ type: 'flush_done' }); + }, 500); + 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; @@ -24588,7 +24641,9 @@ const App = () => { const entry = fxRtTracks[key]; if (!entry) return; if (entry.pumpTimer) { clearInterval(entry.pumpTimer); entry.pumpTimer = null; } + if (entry.flushTimer) { clearTimeout(entry.flushTimer); entry.flushTimer = null; } entry.inQueue = []; + entry.flushing = false; try { fxRtTrackUnsplice(trackId, path); if (entry.node) { entry.node.port.onmessage = null; try { entry.node.disconnect(); } catch (e) {} entry.node = null; } @@ -25388,6 +25443,9 @@ const App = () => { // Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2) const startLocalTrackPlayback = (trackId, offsetTime) => { const context = getAudioContext(); + // V24: solo/local path — gọi fxRtStart (idempotent) để âm solo qua master + // chain (trước đây path này bỏ qua → solo không qua master FX). + try { fxRtStart(); } catch (e) { console.warn('[FX RT] start error:', e); } // V12: parity với startTrackPlayback — loop restart (solo/local) sau // stopAllPlayback(): hủy STOP#2 + báo PLAY (nếu không C++ transportStopped // giữ true → mọi note-on bị drop → solo/local loop pass 2+ câm) + set diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index c7d5274..c56a53f 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -328,12 +328,15 @@ 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:!!s.bypass||!!(window.currentMasteringSettings||{}).vstBypass}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:!!s.bypass});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=202608312300`);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=202609011200`);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.flushing=false;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 // trong luc dry con la path nghe duoc se tao gap ~80ms. -if(fxRt.fadedIn)fxRtApplyPdc();}}catch(e){}return;}if(!(ev.data instanceof ArrayBuffer)||!fxRt.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;ifxRtStop(true);ws.onerror=()=>{try{ws.close();}catch(e){}};// Worklet gom 4 block (1024 mẫu) vào 1 frame → tách 4 WS frame 512 floats +if(fxRt.fadedIn)fxRtApplyPdc();}else if(d&&d.cmd==='flush_done'){// V24: engine da flush ring xong — mo lai nhan output + bao worklet +// (worklet drop block cho ack nay; mat ack -> fallback timer nha). +fxRt.flushing=false;if(fxRt.flushTimer){clearTimeout(fxRt.flushTimer);fxRt.flushTimer=null;}if(fxRt.node)fxRt.node.port.postMessage({type:'flush_done'});}}catch(e){}return;}if(!(ev.data instanceof ArrayBuffer)||!fxRt.node)return;if(fxRt.flushing)return;// V24: output stale trong luc flush — drop +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;ifxRtStop(true);ws.onerror=()=>{try{ws.close();}catch(e){}};// Worklet gom 4 block (1024 mẫu) vào 1 frame → tách 4 WS frame 512 floats // ở đây. Bridge VST3 nhận input dồn → take=4 → batch 1 call process 1024 // mẫu (~3.5-4.75ms/block) → đủ 187.5/s (take=1: 5.5ms > budget 5.33ms → // drop → crackle). KHÔNG dùng f.subarray().buffer — nó gửi cả backing @@ -344,7 +347,10 @@ const now=Date.now();while(fxRt.inQueue.length&&now-fxRt.lastInSent>=FXRT_BURST_ // chong race: neu stop/restart xay ra trong luc cho, bo qua. if(fxRt.fadedIn)return;setTimeout(()=>{if(token!==fxRt.startToken)return;fxRt.fadedIn=true;fxRtCrossfade(true,token);// Dry gan silent (tc 0.025) roi moi tre pdcDelay — tranh gap khi delay // dang lon dan. -setTimeout(()=>fxRtApplyPdc(),250);},30);return;}if(d.type==='set_param'){try{fxRt.ws.send(JSON.stringify({cmd:'set_param',slot:d.slot,key:d.key,value:d.value}));}catch(e){}return;}if(d.type==='stats'){// Telemetry worklet (mỗi ~2s): chẩn đoán crackle session thật. +setTimeout(()=>fxRtApplyPdc(),250);},30);return;}if(d.type==='flush'){// V24: worklet underrun-resume — yeu cau engine flush ring (xoa backlog +// stale) → het lag ~1s sau stall. Fallback timer phong ack mat (WS/ +// engine loi) → tu nha sau 500ms, worklet nhan block moi binh thuong. +if(fxRt.ws&&fxRt.ws.readyState===1){try{fxRt.ws.send(JSON.stringify({cmd:'flush'}));}catch(e){}}fxRt.flushing=true;if(fxRt.flushTimer)clearTimeout(fxRt.flushTimer);fxRt.flushTimer=setTimeout(()=>{fxRt.flushTimer=null;fxRt.flushing=false;if(fxRt.node)fxRt.node.port.postMessage({type:'flush_done'});},500);return;}if(d.type==='set_param'){try{fxRt.ws.send(JSON.stringify({cmd:'set_param',slot:d.slot,key:d.key,value:d.value}));}catch(e){}return;}if(d.type==='stats'){// Telemetry worklet (mỗi ~2s): chẩn đoán crackle session thật. fxRt.stats=d;console.debug('[FX RT stats]',d);return;}if(d.type!=='in')return;// Gửi NGAY, không pace setTimeout: input pacing = đồng hồ audio worklet // (1 frame/21.33ms), không phải timer main-thread. Timer bận/trễ → input // gửi trễ → engine/bridge chỉ sản xuất output khi có input → cushion cạn @@ -363,7 +369,7 @@ fxRtSendFrames(d);};// Pace input: tối đa 1 block/kỳ (~5.8ms). Gửi dồn try{masterBus.outputAnalyser.disconnect(masterBus.output);}catch(e){}masterBus.outputAnalyser.connect(fxRt.node);fxRt.node.connect(masterBus.masterFxGain);fxRt.chainKey=ck;}catch(e){console.warn('[FX RT] setup fail:',e);fxRtStop(true);}}function fxRtStop(silent){if(fxRtWarmIdleTimer){clearTimeout(fxRtWarmIdleTimer);fxRtWarmIdleTimer=null;}// Phải bump token TRƯỚC guard early-return: lúc fxRtStart đang in-flight thì // active/ws/node/sessionId đều chưa có → guard sẽ return, nhưng start vẫn // phải bị hủy (token mismatch ở checkpoint). -fxRt.startToken++;fxRt.starting=false;if(fxRt.pumpTimer){clearInterval(fxRt.pumpTimer);fxRt.pumpTimer=null;}fxRt.inQueue=[];if(!fxRt.active&&!fxRt.ws&&!fxRt.node&&!fxRt.sessionId)return;let _fxStopNode=null;try{if(fxRt.node){_fxStopNode=fxRt.node;if(masterBus&&masterBus.masterFxGain&&masterBus.masterFxDryGain){// Crossfade deactivation: ramp dry 0->1 / fx 1->0 roi MOI disconnect +fxRt.startToken++;fxRt.starting=false;if(fxRt.pumpTimer){clearInterval(fxRt.pumpTimer);fxRt.pumpTimer=null;}if(fxRt.flushTimer){clearTimeout(fxRt.flushTimer);fxRt.flushTimer=null;}fxRt.inQueue=[];fxRt.flushing=false;if(!fxRt.active&&!fxRt.ws&&!fxRt.node&&!fxRt.sessionId)return;let _fxStopNode=null;try{if(fxRt.node){_fxStopNode=fxRt.node;if(masterBus&&masterBus.masterFxGain&&masterBus.masterFxDryGain){// Crossfade deactivation: ramp dry 0->1 / fx 1->0 roi MOI disconnect // (tranh click hard-switch nguoc). Dry path da noi san qua // masterFxDryGain — khong can reconnect outputAnalyser->output. PDC giam // ve 0 sau khi dry chiem cho (fx silent). @@ -1826,13 +1832,16 @@ 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:!!s.bypass||!!(track&&track.vstFxBypass)}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:!!s.bypass});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)+'|vb:'+(track&&track.vstFxBypass?'1':'0'),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=202608312300`);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. +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:!!s.bypass||!!(track&&track.vstFxBypass)}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:!!s.bypass});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)+'|vb:'+(track&&track.vstFxBypass?'1':'0'),paramsBySlot:chain.map(s=>fxRtEntryParams(s,null)),inQueue:[],pumpTimer:null,lastInSent:0,flushing:false,flushTimer:null};fxRtTracks[key]=entry;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202609011200`);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(typeof ev.data==='string'){try{const d=JSON.parse(ev.data);if(d&&d.cmd==='flush_done'){entry.flushing=false;if(entry.flushTimer){clearTimeout(entry.flushTimer);entry.flushTimer=null;}if(entry.node)entry.node.port.postMessage({type:'flush_done'});}}catch(e){}return;}if(!(ev.data instanceof ArrayBuffer)||!entry.node)return;if(entry.flushing)return;// V24: output stale trong luc flush — drop +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==='flush'){// V24: nhu master — worklet underrun-resume → flush engine ring +// (xoa backlog stale) → het lag ~1s sau stall. Fallback phong ack mat. +if(entry.ws&&entry.ws.readyState===1){try{entry.ws.send(JSON.stringify({cmd:'flush'}));}catch(e){}}entry.flushing=true;if(entry.flushTimer)clearTimeout(entry.flushTimer);entry.flushTimer=setTimeout(()=>{entry.flushTimer=null;entry.flushing=false;if(entry.node)entry.node.port.postMessage({type:'flush_done'});},500);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 // atomic khi worker load xong) → load VST FX / đổi preset GUI không ngắt // âm/crackle. entry.chainKey cập nhật ngay — reconcile sau đó thấy khớp. -const fxRtTrackSetChain=(trackId,path,track,chainKey)=>{const key=trackId+':'+path;const entry=fxRtTracks[key];if(!entry||!entry.ws||entry.ws.readyState!==1)return;const chain=fxRtTrackChain(track);if(!chain.length)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:!!s.bypass||!!(track&&track.vstFxBypass)}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:!!s.bypass});entry.chainKey=chainKey;window.SonicAPI.fxRealtimeSetChain({session_id:entry.sessionId,fx_chain:chainPayload}).then(()=>{try{(entry.paramsBySlot||[]).forEach((pl,slot)=>pl.forEach(p=>{if(entry.ws&&entry.ws.readyState===1)entry.ws.send(JSON.stringify({cmd:'set_param',slot,key:p.key,value:p.value}));}));}catch(e){}}).catch(e=>console.warn('[FX RT track] set-chain fail',trackId,path,e));};const fxRtTrackSplice=(trackId,path)=>{const key=trackId+':'+path;const entry=fxRtTracks[key];const node=activeTrackNodesRef.current[trackId];if(!entry||!entry.ready||!entry.node||!node||entry.spliced)return;const tail=path==='audio'?node.fxEntry:node.sfEntry;const dest=path==='audio'?node.fxLegacyIn:node.sfOut;if(!tail||!dest)return;try{tail.disconnect(dest);entry.node.connect(dest);tail.connect(entry.node);entry.spliced={tail,dest};console.log('[FX RT track] spliced',trackId,path);}catch(e){console.warn('[FX RT track] splice fail',trackId,path,e);}};const fxRtTrackStop=(trackId,path,silent)=>{const key=trackId+':'+path;const entry=fxRtTracks[key];if(!entry)return;if(entry.pumpTimer){clearInterval(entry.pumpTimer);entry.pumpTimer=null;}entry.inQueue=[];try{fxRtTrackUnsplice(trackId,path);if(entry.node){entry.node.port.onmessage=null;try{entry.node.disconnect();}catch(e){}entry.node=null;}}catch(e){}try{if(entry.ws){entry.ws.onclose=null;entry.ws.onmessage=null;try{entry.ws.close();}catch(e){}entry.ws=null;}}catch(e){}const sid=entry.sessionId;delete fxRtTracks[key];fxRtGuiPushShm();if(sid){try{window.SonicAPI.fxRealtimeStop({session_id:sid}).catch(()=>{});}catch(e){}}};const fxRtTrackReconcile=(trackId,node,track)=>{try{const chain=fxRtTrackChain(track);const chainKey=fxRtTrackChainKey(chain)+'|vb:'+(track&&track.vstFxBypass?'1':'0');const wantPaths=chain.length?fxRtTrackPaths(track):[];const prefix=trackId+':';Object.keys(fxRtTracks).forEach(k=>{if(!k.startsWith(prefix))return;const p=k.slice(prefix.length);if(wantPaths.indexOf(p)<0)fxRtTrackStop(trackId,p,true);});if(!isPlayingRef.current)return;wantPaths.forEach(p=>{const key=trackId+':'+p;const entry=fxRtTracks[key];if(!entry)fxRtTrackStart(trackId,track,p);else if(entry.chainKey!==chainKey){// Chain đổi (load/đổi VST FX, preset GUI, bypass slot) — session +const fxRtTrackSetChain=(trackId,path,track,chainKey)=>{const key=trackId+':'+path;const entry=fxRtTracks[key];if(!entry||!entry.ws||entry.ws.readyState!==1)return;const chain=fxRtTrackChain(track);if(!chain.length)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:!!s.bypass||!!(track&&track.vstFxBypass)}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:!!s.bypass});entry.chainKey=chainKey;window.SonicAPI.fxRealtimeSetChain({session_id:entry.sessionId,fx_chain:chainPayload}).then(()=>{try{(entry.paramsBySlot||[]).forEach((pl,slot)=>pl.forEach(p=>{if(entry.ws&&entry.ws.readyState===1)entry.ws.send(JSON.stringify({cmd:'set_param',slot,key:p.key,value:p.value}));}));}catch(e){}}).catch(e=>console.warn('[FX RT track] set-chain fail',trackId,path,e));};const fxRtTrackSplice=(trackId,path)=>{const key=trackId+':'+path;const entry=fxRtTracks[key];const node=activeTrackNodesRef.current[trackId];if(!entry||!entry.ready||!entry.node||!node||entry.spliced)return;const tail=path==='audio'?node.fxEntry:node.sfEntry;const dest=path==='audio'?node.fxLegacyIn:node.sfOut;if(!tail||!dest)return;try{tail.disconnect(dest);entry.node.connect(dest);tail.connect(entry.node);entry.spliced={tail,dest};console.log('[FX RT track] spliced',trackId,path);}catch(e){console.warn('[FX RT track] splice fail',trackId,path,e);}};const fxRtTrackStop=(trackId,path,silent)=>{const key=trackId+':'+path;const entry=fxRtTracks[key];if(!entry)return;if(entry.pumpTimer){clearInterval(entry.pumpTimer);entry.pumpTimer=null;}if(entry.flushTimer){clearTimeout(entry.flushTimer);entry.flushTimer=null;}entry.inQueue=[];entry.flushing=false;try{fxRtTrackUnsplice(trackId,path);if(entry.node){entry.node.port.onmessage=null;try{entry.node.disconnect();}catch(e){}entry.node=null;}}catch(e){}try{if(entry.ws){entry.ws.onclose=null;entry.ws.onmessage=null;try{entry.ws.close();}catch(e){}entry.ws=null;}}catch(e){}const sid=entry.sessionId;delete fxRtTracks[key];fxRtGuiPushShm();if(sid){try{window.SonicAPI.fxRealtimeStop({session_id:sid}).catch(()=>{});}catch(e){}}};const fxRtTrackReconcile=(trackId,node,track)=>{try{const chain=fxRtTrackChain(track);const chainKey=fxRtTrackChainKey(chain)+'|vb:'+(track&&track.vstFxBypass?'1':'0');const wantPaths=chain.length?fxRtTrackPaths(track):[];const prefix=trackId+':';Object.keys(fxRtTracks).forEach(k=>{if(!k.startsWith(prefix))return;const p=k.slice(prefix.length);if(wantPaths.indexOf(p)<0)fxRtTrackStop(trackId,p,true);});if(!isPlayingRef.current)return;wantPaths.forEach(p=>{const key=trackId+':'+p;const entry=fxRtTracks[key];if(!entry)fxRtTrackStart(trackId,track,p);else if(entry.chainKey!==chainKey){// Chain đổi (load/đổi VST FX, preset GUI, bypass slot) — session // đang chạy → set_chain KHÔNG restart (bridge swap atomic khi load // xong) → hết crackle khi load VST / chỉnh thông số. Session chưa // ready (start in-flight) → stop+start lại cho đúng chain. @@ -2002,7 +2011,9 @@ setTimeout(()=>{// ⚠️ Guard: stop → setTimeout sót không được fire ( if(!isPlayingRef.current)return;if(window.triggerMidiVuActivity){// VU key theo CONTEXT (tách MAIN vs SECTION — 07:20) window.triggerMidiVuActivity((activeTab&&activeTab.startsWith('session_')?'_sess_':'')+subTrack.id,note.velocity||0.8);window.triggerMidiVuActivity((activeTab&&activeTab.startsWith('session_')?'_sess_':'')+track.id,note.velocity||0.8);}},delay*1000);}else{const remainingDurMs=(notePlayEndMain-offsetTime)*1000;if(window.SonicSF){scheduleMidiNoteDispatch(subTrack,note.pitch||60,note.velocity||0.8,remainingDurMs,context.currentTime,program,subNode,subCh,subTrack.synth_engine);scheduleNoteExpressionCC11(subTrack,note,subCh,context.currentTime,0,remainingDurMs/1000);}if(window.triggerMidiVuActivity){// VU key theo CONTEXT (tách MAIN vs SECTION — 07:20) window.triggerMidiVuActivity((activeTab&&activeTab.startsWith('session_')?'_sess_':'')+subTrack.id,note.velocity||0.8);window.triggerMidiVuActivity((activeTab&&activeTab.startsWith('session_')?'_sess_':'')+track.id,note.velocity||0.8);}}}});});}});});}});updateSfRouting();};// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2) -const startLocalTrackPlayback=(trackId,offsetTime)=>{const context=getAudioContext();// V12: parity với startTrackPlayback — loop restart (solo/local) sau +const startLocalTrackPlayback=(trackId,offsetTime)=>{const context=getAudioContext();// V24: solo/local path — gọi fxRtStart (idempotent) để âm solo qua master +// chain (trước đây path này bỏ qua → solo không qua master FX). +try{fxRtStart();}catch(e){console.warn('[FX RT] start error:',e);}// V12: parity với startTrackPlayback — loop restart (solo/local) sau // stopAllPlayback(): hủy STOP#2 + báo PLAY (nếu không C++ transportStopped // giữ true → mọi note-on bị drop → solo/local loop pass 2+ câm) + set // isPlayingRef NGAY (guardPlay race — xem startTrackPlayback). diff --git a/app/static/js/sf-fx-realtime.js b/app/static/js/sf-fx-realtime.js index 8780f45..a772e2c 100644 --- a/app/static/js/sf-fx-realtime.js +++ b/app/static/js/sf-fx-realtime.js @@ -9,7 +9,7 @@ // ~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ụ. +// 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 @@ -22,14 +22,14 @@ // 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 48 block (~256ms) chặn overflow khi bridge nhanh hơn realtime. +// 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 = 48; // 48 block ~256ms: buffer khởi đầu — hấp thụ jitter main-thread (GUI decode, rAF, VST panel) + 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 @@ -42,6 +42,8 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { 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 @@ -55,7 +57,12 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { 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(); @@ -162,15 +169,32 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { // 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)) { + // 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; - const wasInterrupted = (this.holdFadeLeft > 0 || this.silenced) && this.hasStarted; this.holdFadeLeft = 0; - if (wasInterrupted) { + if (this.resumeFadeIn) { + this.resumeFadeIn = false; if (this.silenced) { - // Đã im lặng (fade xong) → fade-in từ 0 (hết jump ~0→1). - // Dùng flag silenced (không đo lastOut: mẫu cuối fade ≈ fadeFrom/1024 - // ≈ 1e-3, ngưỡng số dễ sai). + // Đã 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++; diff --git a/app/templates/index.html b/app/templates/index.html index 9e40880..2a5f077 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -51,7 +51,7 @@ - +