V24: fix master-chain crackle, 1s lag, solo bypass

- worklet: FILL 48->24 (PDC 7936 dung), flush protocol khi resume sau interruption
  (clear queue + fade-in/crossfade chi voi block moi), drop block stale
- engine fx_realtime: _flush_input_locked NO-DROP (ring day -> giu pending, cap 2048),
  flush_all() dong bo in/out khi worklet resume
- app.jsx: WS flush_done + flushing guard, fallback timer 500ms
- plugins.py: xu ly {cmd:flush} -> flush_done
- index.html: bump v param
This commit is contained in:
2026-08-26 20:15:13 +07:00
parent 28a06134f9
commit f38e500326
6 changed files with 148 additions and 31 deletions
+4
View File
@@ -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:
+31 -11
View File
@@ -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 = []
+61 -3
View File
@@ -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
+19 -8
View File
@@ -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;i<half;i++){l[i]=f[i*2];r[i]=f[i*2+1];}fxRt.node.port.postMessage({type:'out',l,r});};ws.onclose=()=>fxRtStop(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;i<half;i++){l[i]=f[i*2];r[i]=f[i*2+1];}fxRt.node.port.postMessage({type:'out',l,r});};ws.onclose=()=>fxRtStop(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;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.
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;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==='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<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
// 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).
+32 -8
View File
@@ -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++;
+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=202608311300" defer></script>
<script src="/static/js/app.precompiled.js?v=202608311400" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {