V45.45: fix GUI embed VST den tren box (Nectar/iZotope) - first-paint kick real click + BitBlt black-frame fallback + on-screen parking MAC DINH (SF_FXGUI_EMBED_ONSCREEN=0 de tat) + plugins.py log TAT CA stdout bridge (khong nuot dong truoc SF_FXGUI_PORT=) + auto-open GUI khi add VST FX vao Mastering slot. Rebuild daw_vst_bridge+fx_vst_bridge+installer NSIS.

This commit is contained in:
2026-09-03 07:13:21 +07:00
parent f958348a20
commit db9123438d
10 changed files with 137 additions and 63 deletions
+24 -20
View File
@@ -1,4 +1,4 @@
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio, logging
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio, logging, queue
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from pydantic import BaseModel
@@ -744,15 +744,31 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren
close_fds=os.name != "nt",
env=dict(os.environ, SF_PARENT_PID=str(os.getpid())),
creationflags=_gui_creationflags)
# Bridge prints SF_FXGUI_PORT=<port> after binding its HTTP server.
# Drain stdout NGAY TU DAU (daemon thread) và log TẤT CẢ các dòng
# bridge in — gồm cả các dòng in TRƯỚC SF_FXGUI_PORT= ("[FxGui]
# env ..." của RenderFxJob.cpp) mà cách đọc-tới-port cũ nuốt mất,
# làm log engine không chứng minh được binary/env nào đang chạy.
# Port được chuyển về qua queue (thread này không đọc stdout).
port_q = queue.Queue(maxsize=1)
def _drain_fx_gui_stdout(pp):
try:
for ln in pp.stdout:
ln = (ln or "").strip()
if ln:
logger.info("[fx-gui] %s", ln)
if ln.startswith("SF_FXGUI_PORT="):
try:
port_q.put_nowait(int(ln.split("=", 1)[1]))
except Exception:
pass
except Exception:
pass
threading.Thread(target=_drain_fx_gui_stdout, args=(proc,), daemon=True).start()
embed_url = None
try:
for line in proc.stdout:
line = (line or "").strip()
if line.startswith("SF_FXGUI_PORT="):
port = int(line.split("=", 1)[1])
embed_url = f"http://127.0.0.1:{port}"
break
port = port_q.get(timeout=60)
embed_url = f"http://127.0.0.1:{port}"
except Exception:
pass
if not embed_url:
@@ -762,18 +778,6 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren
except Exception:
proc.kill()
raise HTTPException(status_code=500, detail="Bridge không báo port fx-gui (mở GUI thất bại).")
# Keep draining stdout in a daemon thread: the bridge logs to
# stdout/stderr while running; if the pipe fills (64KB) the bridge
# blocks on any later write -> GUI freezes/breaks.
def _drain_fx_gui_stdout(pp):
try:
for ln in pp.stdout:
ln = (ln or "").strip()
if ln:
logger.info("[fx-gui] %s", ln)
except Exception:
pass
threading.Thread(target=_drain_fx_gui_stdout, args=(proc,), daemon=True).start()
proc._sf_plugin_path = plugin_path
proc._sf_embed_url = embed_url
_register_fx_gui_process(proc)
+3
View File
@@ -14929,6 +14929,9 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
vstFxChain: [...(prev.vstFxChain || []), { type: 'vst3', path: fx.path, name: fx.name || fx.id, preset_b64: '', bypass: false }]
}));
setAddModuleOpen(false);
// Tự mở GUI embed ngay khi add VST FX vào slot — user không cần bấm vào
// slot trong chain để hiển thị nữa (yêu cầu 2026-09-03).
openMasterVstGui({ path: fx.path, name: fx.name || fx.id });
};
const updateChainEntryParams = (modId, patch) => {
setOzState(prev => ({
+5 -3
View File
@@ -348,7 +348,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:!!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=202609022050`);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://';// WS to 'localhost' bi tre ~300ms: Chromium thu ::1 truoc (Windows khong
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=202609030646`);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://';// WS to 'localhost' bi tre ~300ms: Chromium thu ::1 truoc (Windows khong
// co listener IPv6 -> SYN hang ~2s) roi moi fallback 127.0.0.1. Dung
// thang 127.0.0.1 (loopback) -> WS open ~5ms. Server chap nhan loopback.
const wsHost=u.hostname==='localhost'||u.hostname==='::1'?'127.0.0.1'+(u.port?':'+u.port:''):u.host;const tok=localStorage.getItem('sonic_token')||'';const wsUrl=`${wsProto}${wsHost}${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
@@ -1115,7 +1115,9 @@ if(woChannelRef.current==='stereo'||woChannelRef.current==='left'){woCtx.strokeS
if(woChannelRef.current==='stereo'||woChannelRef.current==='right'){woCtx.strokeStyle='#0d9488';woCtx.lineWidth=1.2;woCtx.beginPath();for(let i=0;i<rHistory.length;i++){const x=i/(rHistory.length-1)*w;const val=rHistory[i]*zoomGain;const y=centerY-val*centerY;if(i===0)woCtx.moveTo(x,y);else woCtx.lineTo(x,y);}woCtx.stroke();if(woModeRef.current==='envelope'){woCtx.beginPath();for(let i=0;i<rHistory.length;i++){const x=i/(rHistory.length-1)*w;const val=rHistory[i]*zoomGain;const y=centerY+val*centerY;if(i===0)woCtx.moveTo(x,y);else woCtx.lineTo(x,y);}woCtx.stroke();}}}}renderFrame();return()=>{window.removeEventListener('resize',resizeAll);if(animFrameRef.current)cancelAnimationFrame(animFrameRef.current);};},[isOpen]);React.useEffect(()=>{if(!isOpen){stopAudioDemo();knobsInitializedRef.current=false;}else{setTimeout(()=>{try{window.lucide.createIcons();}catch(e){}},100);}},[isOpen]);// Hooks MUST be declared before the early return below — React requires a
// stable hook count across renders (error #310 otherwise).
const dragChainIndexRef=React.useRef(null);const[addModuleOpen,setAddModuleOpen]=React.useState(false);if(!isOpen)return null;const switchModule=name=>setOzState(prev=>({...prev,activeModule:name}));// ── Mastering expand: dynamic module chain (mastering_expand.md §II.3) ──
const MODULE_META={eq:{name:'Dynamic EQ',sub:'4-Band Peak',icon:'activity',color:'#22d3ee'},eqpro:{name:'Parametric / Graphic EQ PRO',sub:'Pro-Q style · 8 bands · interactive',icon:'chart-area',color:'#2dd4bf'},imager:{name:'Imager',sub:'4-Band Width',icon:'radio',color:'#a855f7'},maximizer:{name:'Maximizer',sub:'IRC IV True Peak',icon:'gauge',color:'#34d399'},compressor:{name:'Bus Compressor',sub:'Glue & Punch',icon:'compress',color:'#fbbf24'},limiter:{name:'Brickwall Limiter',sub:'True-Peak 20:1',icon:'shield-half',color:'#f43f5e'},multiband:{name:'Multiband Compressor',sub:'3-Band LR4',icon:'layers',color:'#a3e635'},deesser:{name:'De-Esser',sub:'Sibilance tamer',icon:'mic-2',color:'#fb923c'},exciter:{name:'Harmonic Exciter',sub:'Saturation & Air',icon:'wand-2',color:'#c084fc'},rebalance:{name:'Master Rebalance',sub:'M/S Balance',icon:'sliders-horizontal',color:'#38bdf8'},vst3:{name:'VST FX',sub:'VST3 plugin',icon:'plug',color:'#2dd4bf'}};const chainFlag=type=>type==='eq'?'eqActive':type==='eqpro'?'eqproActive':type==='imager'?'imagerActive':type==='maximizer'?'maximizerActive':type==='compressor'?'compActive':type==='limiter'?'limActive':type==='exciter'?'excActive':type==='vst3'?'vstFxActive':type==='multiband'?'mbActive':type==='deesser'?'deessActive':'rebalActive';const chainActive=type=>!!ozState[chainFlag(type)];const mb=(ozState.chain||[]).find(m=>m.type==='multiband');const de=(ozState.chain||[]).find(m=>m.type==='deesser');const toggleChainModule=modId=>{setOzState(prev=>{const chain=prev.chain.map(m=>{if(m.id!==modId)return m;const next=!m.active;return{...m,active:next};});const flags={};chain.forEach(m=>{flags[chainFlag(m.type)]=!!m.active;});const target=chain.find(m=>m.id===modId);let vstFxChain=prev.vstFxChain||[];if(target&&target.type==='vst3'){const vi=vstFxChain.findIndex(s=>s.path===target.path);if(vi>=0)vstFxChain=vstFxChain.map((s,si)=>si===vi?{...s,bypass:!target.active}:s);}return{...prev,chain,...flags,vstFxChain};});};const removeChainModule=modId=>{const removedMod=(ozState.chain||[]).find(m=>m.id===modId);if(removedMod&&removedMod.type==='vst3'&&masterGuiOpen&&masterGuiOpen.path===removedMod.path)closeMasterGui();setOzState(prev=>{const removed=prev.chain.find(m=>m.id===modId);const chain=prev.chain.filter(m=>m.id!==modId);const flags={};chain.forEach(m=>{flags[chainFlag(m.type)]=!!m.active;});const activeModule=prev.activeModule;let vstFxChain=prev.vstFxChain||[];if(removed&&removed.type==='vst3'){const vi=vstFxChain.findIndex(s=>s.path===removed.path);if(vi>=0)vstFxChain=[...vstFxChain.slice(0,vi),...vstFxChain.slice(vi+1)];}return{...prev,chain,...flags,vstFxChain,activeModule:chain.length?chain.some(m=>m.type===activeModule)?activeModule:chain[chain.length-1].type:null};});};const reorderChain=(fromIdx,toIdx)=>{if(fromIdx===toIdx)return;setOzState(prev=>{const chain=[...prev.chain];const moved=chain.splice(fromIdx,1)[0];chain.splice(toIdx,0,moved);const vstFxChain=chain.filter(m=>m&&m.type==='vst3').map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass}));return{...prev,chain,vstFxChain};});};const addModuleToChain=type=>{const meta=MODULE_META[type];const id='mod_'+type+'_'+Date.now();const entry={id,type,name:meta.name,active:true};if(type==='eqpro')entry.params={amount:100,bands:JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS))};if(type==='multiband')entry.params={lf_cross:200,hf_cross:4000,low_thr:-24,low_ratio:3,low_makeup:0,mid_thr:-24,mid_ratio:3,mid_makeup:0,high_thr:-24,high_ratio:3,high_makeup:0};if(type==='deesser')entry.params={threshold:-30,ratio:40,freq:6000,makeup:0};setOzState(prev=>({...prev,chain:[...(prev.chain||[]),entry],[chainFlag(type)]:true,activeModule:type}));setAddModuleOpen(false);};const addVstToChain=path=>{const fx=(masterFxList||[]).find(f=>f.path===path);if(!fx)return;const id='mod_vst_'+Date.now();const entry={id,type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false,active:true};setOzState(prev=>({...prev,chain:[...(prev.chain||[]),entry],vstFxChain:[...(prev.vstFxChain||[]),{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}]}));setAddModuleOpen(false);};const updateChainEntryParams=(modId,patch)=>{setOzState(prev=>({...prev,chain:(prev.chain||[]).map(m=>m.id===modId?{...m,params:{...(m.params||{}),...patch}}:m)}));};// ── EQ Preset Library (mastering_expand.md §II.1) ──
const MODULE_META={eq:{name:'Dynamic EQ',sub:'4-Band Peak',icon:'activity',color:'#22d3ee'},eqpro:{name:'Parametric / Graphic EQ PRO',sub:'Pro-Q style · 8 bands · interactive',icon:'chart-area',color:'#2dd4bf'},imager:{name:'Imager',sub:'4-Band Width',icon:'radio',color:'#a855f7'},maximizer:{name:'Maximizer',sub:'IRC IV True Peak',icon:'gauge',color:'#34d399'},compressor:{name:'Bus Compressor',sub:'Glue & Punch',icon:'compress',color:'#fbbf24'},limiter:{name:'Brickwall Limiter',sub:'True-Peak 20:1',icon:'shield-half',color:'#f43f5e'},multiband:{name:'Multiband Compressor',sub:'3-Band LR4',icon:'layers',color:'#a3e635'},deesser:{name:'De-Esser',sub:'Sibilance tamer',icon:'mic-2',color:'#fb923c'},exciter:{name:'Harmonic Exciter',sub:'Saturation & Air',icon:'wand-2',color:'#c084fc'},rebalance:{name:'Master Rebalance',sub:'M/S Balance',icon:'sliders-horizontal',color:'#38bdf8'},vst3:{name:'VST FX',sub:'VST3 plugin',icon:'plug',color:'#2dd4bf'}};const chainFlag=type=>type==='eq'?'eqActive':type==='eqpro'?'eqproActive':type==='imager'?'imagerActive':type==='maximizer'?'maximizerActive':type==='compressor'?'compActive':type==='limiter'?'limActive':type==='exciter'?'excActive':type==='vst3'?'vstFxActive':type==='multiband'?'mbActive':type==='deesser'?'deessActive':'rebalActive';const chainActive=type=>!!ozState[chainFlag(type)];const mb=(ozState.chain||[]).find(m=>m.type==='multiband');const de=(ozState.chain||[]).find(m=>m.type==='deesser');const toggleChainModule=modId=>{setOzState(prev=>{const chain=prev.chain.map(m=>{if(m.id!==modId)return m;const next=!m.active;return{...m,active:next};});const flags={};chain.forEach(m=>{flags[chainFlag(m.type)]=!!m.active;});const target=chain.find(m=>m.id===modId);let vstFxChain=prev.vstFxChain||[];if(target&&target.type==='vst3'){const vi=vstFxChain.findIndex(s=>s.path===target.path);if(vi>=0)vstFxChain=vstFxChain.map((s,si)=>si===vi?{...s,bypass:!target.active}:s);}return{...prev,chain,...flags,vstFxChain};});};const removeChainModule=modId=>{const removedMod=(ozState.chain||[]).find(m=>m.id===modId);if(removedMod&&removedMod.type==='vst3'&&masterGuiOpen&&masterGuiOpen.path===removedMod.path)closeMasterGui();setOzState(prev=>{const removed=prev.chain.find(m=>m.id===modId);const chain=prev.chain.filter(m=>m.id!==modId);const flags={};chain.forEach(m=>{flags[chainFlag(m.type)]=!!m.active;});const activeModule=prev.activeModule;let vstFxChain=prev.vstFxChain||[];if(removed&&removed.type==='vst3'){const vi=vstFxChain.findIndex(s=>s.path===removed.path);if(vi>=0)vstFxChain=[...vstFxChain.slice(0,vi),...vstFxChain.slice(vi+1)];}return{...prev,chain,...flags,vstFxChain,activeModule:chain.length?chain.some(m=>m.type===activeModule)?activeModule:chain[chain.length-1].type:null};});};const reorderChain=(fromIdx,toIdx)=>{if(fromIdx===toIdx)return;setOzState(prev=>{const chain=[...prev.chain];const moved=chain.splice(fromIdx,1)[0];chain.splice(toIdx,0,moved);const vstFxChain=chain.filter(m=>m&&m.type==='vst3').map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass}));return{...prev,chain,vstFxChain};});};const addModuleToChain=type=>{const meta=MODULE_META[type];const id='mod_'+type+'_'+Date.now();const entry={id,type,name:meta.name,active:true};if(type==='eqpro')entry.params={amount:100,bands:JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS))};if(type==='multiband')entry.params={lf_cross:200,hf_cross:4000,low_thr:-24,low_ratio:3,low_makeup:0,mid_thr:-24,mid_ratio:3,mid_makeup:0,high_thr:-24,high_ratio:3,high_makeup:0};if(type==='deesser')entry.params={threshold:-30,ratio:40,freq:6000,makeup:0};setOzState(prev=>({...prev,chain:[...(prev.chain||[]),entry],[chainFlag(type)]:true,activeModule:type}));setAddModuleOpen(false);};const addVstToChain=path=>{const fx=(masterFxList||[]).find(f=>f.path===path);if(!fx)return;const id='mod_vst_'+Date.now();const entry={id,type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false,active:true};setOzState(prev=>({...prev,chain:[...(prev.chain||[]),entry],vstFxChain:[...(prev.vstFxChain||[]),{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}]}));setAddModuleOpen(false);// Tự mở GUI embed ngay khi add VST FX vào slot — user không cần bấm vào
// slot trong chain để hiển thị nữa (yêu cầu 2026-09-03).
openMasterVstGui({path:fx.path,name:fx.name||fx.id});};const updateChainEntryParams=(modId,patch)=>{setOzState(prev=>({...prev,chain:(prev.chain||[]).map(m=>m.id===modId?{...m,params:{...(m.params||{}),...patch}}:m)}));};// ── EQ Preset Library (mastering_expand.md §II.1) ──
const EQ_PRESET_LIBRARY={flat:{name:'Flat / Reset',bands:[{id:1,type:'lowshelf',freq:100,gain:0.0,q:0.7},{id:2,type:'peaking',freq:800,gain:0.0,q:0.7},{id:3,type:'peaking',freq:3200,gain:0.0,q:1.2},{id:4,type:'highshelf',freq:10000,gain:0.0,q:0.7}]},vocal_clarity:{name:'Vocal Unmask & Clarity',bands:[{id:1,type:'lowshelf',freq:90,gain:-2.5,q:0.7},{id:2,type:'peaking',freq:500,gain:-1.8,q:1.0},{id:3,type:'peaking',freq:2800,gain:3.2,q:1.2},{id:4,type:'highshelf',freq:12000,gain:2.0,q:0.7}]},bass_punch:{name:'EDM Low-End Punch',bands:[{id:1,type:'lowshelf',freq:80,gain:4.0,q:0.8},{id:2,type:'peaking',freq:300,gain:-3.0,q:1.4},{id:3,type:'peaking',freq:4000,gain:1.5,q:1.0},{id:4,type:'highshelf',freq:10000,gain:1.0,q:0.7}]},warm_tape:{name:'Warm Vintage Analog',bands:[{id:1,type:'lowshelf',freq:120,gain:2.0,q:0.6},{id:2,type:'peaking',freq:1500,gain:1.0,q:0.5},{id:3,type:'peaking',freq:5000,gain:-2.0,q:1.0},{id:4,type:'highshelf',freq:8000,gain:-3.0,q:0.7}]}};const applyEQPreset=presetKey=>{const preset=EQ_PRESET_LIBRARY[presetKey];if(!preset)return;// Phase 4.5: EQ DSP chạy trong bridge — preset chỉ sync state; engine
// nhận qua fxRtPushAllParams() ở applyMasteringSettings.
// Sync UI knobs + canvas: [eqLowGain, eqMid1Gain, eqMid2Gain, eqHighGain]
@@ -2006,7 +2008,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:!!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=202609022050`);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://';// WS to 'localhost' bi tre ~300ms (Chromium thu ::1 truoc roi fallback) - dung 127.0.0.1
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=202609030646`);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://';// WS to 'localhost' bi tre ~300ms (Chromium thu ::1 truoc roi fallback) - dung 127.0.0.1
const wsHost=u.hostname==='localhost'||u.hostname==='::1'?'127.0.0.1'+(u.port?':'+u.port:''):u.host;const tok=localStorage.getItem('sonic_token')||'';const ws=new WebSocket(`${wsProto}${wsHost}${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
+1 -1
View File
@@ -51,7 +51,7 @@
<script src="/static/js/services/promptTemplateManager.js?v=202608290945"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202608290945"></script>
<script src="/static/js/services/chordTheory.js?v=202608290945"></script>
<script src="/static/js/app.precompiled.js?v=202609022050" defer></script>
<script src="/static/js/app.precompiled.js?v=202609030646" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202608290945">
<style>
:root {
+100 -38
View File
@@ -172,6 +172,14 @@ void sendOptions(SOCKET c) {
// frame, while the GDI BitBlt view is a stale first paint for GPU-rendered
// editors (JUCE/FAST Compressor) and never updates. BitBlt stays as fallback.
// Returns JPEG.
// Mean of the raw BGRA frame (0..255) — black-frame guard below.
static double frameMean(const unsigned char* px, int w, int h) {
const size_t n = (size_t)w * h;
unsigned long long s = 0;
for (size_t i = 0; i < n; ++i)
s += px[i * 4 + 2] + px[i * 4 + 1] + px[i * 4 + 0];
return (double)s / (3.0 * (double)n);
}
bool captureHwndToJpeg(HWND hwnd, std::vector<unsigned char>& out, int quality,
float* outAvg = nullptr, bool* outOkB = nullptr,
bool* outOkP = nullptr) {
@@ -207,7 +215,18 @@ bool captureHwndToJpeg(HWND hwnd, std::vector<unsigned char>& out, int quality,
// for GPU-rendered editors (JUCE/FAST Compressor) and never updates, which
// made the panel look frozen. BitBlt stays only as a fallback.
unsigned char* px = nullptr;
if (okP) px = pxP.data();
if (okP && okB) {
// Black-frame guard: PrintWindow can return TRUE with an ALL-BLACK
// frame while the editor has not painted yet (iZotope-class, e.g.
// Nectar — those editors paint only after their window was activated;
// the /show first-paint kick fixes that, but until it fires BitBlt is
// the only source of real pixels). If PrintWindow is pure black and
// the GDI view has content, send the GDI view instead of a black panel.
const double mP = frameMean(pxP.data(), w, h);
const double mB = frameMean(pxB.data(), w, h);
if (mP < 6.0 && mB > 6.0) px = pxB.data();
else px = pxP.data();
} else if (okP) px = pxP.data();
else if (okB) px = pxB.data();
if (outOkB) *outOkB = okB;
if (outOkP) *outOkP = okP;
@@ -277,15 +296,26 @@ POINT g_savedCursor = {0, 0}; // cursor to restore after the dance
// rect = legacy cursor-relative mode.
RECT g_embedRect = {0, 0, 0, 0};
bool g_anchored = false; // window anchored over panel (stays until /close)
// iZotope-class editors (Nectar/Ozone) paint their surface ONLY after the
// window was actually visible AND activated at least once; parked HWND_BOTTOM
// under the DAW they never paint -> black forever. /show performs a one-time
// kick (raise above the DAW + invalidate + real click), then demotes the
// window back under the DAW — the painted surface survives the demote.
// Reset on /hide so each reopen re-kicks.
std::atomic<bool> g_paintKicked{false};
// SF_FXGUI_EMBED_ONSCREEN=1: park the host window ON a monitor at the embed
// panel rect (HWND_BOTTOM, under the DAW -> user never sees it) instead of
// off-screen at -20000. DWM does not redirect windows parked fully off the
// desktop on some drivers (Intel UHD 630 + iZotope GL editor) -> PrintWindow
// captures only black. On a monitor (even occluded by the DAW above) DWM
// keeps a redirection surface -> /frame gets real pixels. Real-input dance
// still lifts the window to HWND_TOP for actual clicks (g_anchored guard).
static const bool g_embedOnscreen = std::getenv("SF_FXGUI_EMBED_ONSCREEN") != nullptr;
// On-screen embed parking (DEFAULT since V45.45): park the host window ON a
// monitor at the embed panel rect (HWND_BOTTOM, under the DAW -> user never
// sees it) instead of off-screen at -20000. DWM does not redirect windows
// parked fully off the desktop on some drivers (Intel UHD 630 + iZotope GL
// editor) -> capture sees only black. On a monitor (even occluded by the DAW
// above) DWM keeps a redirection surface -> /frame gets real pixels. Real-input
// dance still lifts the window to HWND_TOP for actual clicks (g_anchored
// guard). SF_FXGUI_EMBED_ONSCREEN=0 disables (env kept for debugging).
static const bool g_embedOnscreen = [] {
const char* v = std::getenv("SF_FXGUI_EMBED_ONSCREEN");
return v == nullptr || v[0] != '0';
}();
// SF_FXGUI_DEBUG=1: rate-limited /frame capture ground truth ([FxGuiDbg] lines).
static const bool g_fxGuiDbg = std::getenv("SF_FXGUI_DEBUG") != nullptr;
@@ -522,8 +552,8 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
// to show that blank frame as a white flash on open.
sendResponse(c, 503, "application/json", "{\"error\":\"loading\"}", 19);
} else {
// EMBED_ONSCREEN: keep the host window ON a monitor at the
// embed panel rect (HWND_BOTTOM, under the DAW) so DWM
// On-screen embed parking: keep the host window ON a monitor
// at the embed panel rect (HWND_BOTTOM, under the DAW) so DWM
// redirects the editor's GPU surface and capture sees real
// pixels — the one-shot rect /show may have run pre-attach
// (deduped), so reconcile here on every poll until it fits.
@@ -609,6 +639,7 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
g_anchored = false;
g_dance = false;
g_parkUntil = 0;
g_paintKicked = false; // next /show re-kicks the first paint
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/show") {
// Optional body {rect:{x,y,w,h}} (screen px) — anchored placement
@@ -635,43 +666,74 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
}
}
if (sroot) std::free(sroot);
// Embedded mode: the native window NEVER comes on-screen — it stays
// parked off-screen at -20000,-20000 (GDI capture + synthetic input
// work regardless of position). The panel rect sizes the host
// window + plugin view so the capture frame matches the embed panel
// (layout bug); /hide hid the window, so re-show it off-screen so
// /frame keeps capturing.
if (rectOk) {
// Embedded mode: park the host window ON a monitor at the embed
// panel rect (HWND_BOTTOM, under the DAW -> user never sees it)
// instead of off-screen at -20000. iZotope-class editors never
// paint while fully off-screen, and DWM does not redirect windows
// parked off the desktop on some drivers (Intel UHD 630) — on a
// monitor (even occluded by the DAW above) capture sees real
// pixels. The panel rect also sizes the host window + plugin view
// so the capture frame matches the embed panel (layout bug);
// /hide hid the window, so re-show it here.
// Skip while the editor is still attaching (pump thread busy;
// SetWindowPos from this worker would block until load/attach
// finishes) and while anchored/dancing (real-input owns the
// window position during clicks).
if (g_embedOnscreen && rectOk && g_fxGuiEditorReady.load() &&
!g_anchored && !g_dance) {
const int cw = g_embedRect.right - g_embedRect.left;
const int ch = g_embedRect.bottom - g_embedRect.top;
RECT cur = {};
GetWindowRect(hwnd, &cur);
// Skip when already the right size: frontend re-POSTs /show
const bool samePlace =
cur.left == g_embedRect.left && cur.top == g_embedRect.top &&
cur.right - cur.left == cw && cur.bottom - cur.top == ch;
// Skip when already parked right: frontend re-POSTs /show
// every ~500ms; re-laying-out the plugin view every tick is
// wasteful (WM_FXGUI_RESIZE -> checkSizeConstraint/onSize).
if (cur.right - cur.left != cw || cur.bottom - cur.top != ch)
fxGuiRequestResize(hwnd, cw, ch);
}
// EMBED_ONSCREEN: park the window ON the monitor at the embed
// panel rect, HWND_BOTTOM (below the DAW -> user never sees it)
// so DWM redirects the editor's GPU surface and /frame can
// capture it. Skip while the editor is still attaching (the
// pump thread is busy; SetWindowPos from here would block this
// worker until load/attach finishes) and while anchored/dancing
// (real-input keeps the window on top over the panel for clicks).
if (g_embedOnscreen && rectOk && !g_anchored && !g_dance &&
g_fxGuiEditorReady.load()) {
const int cw = g_embedRect.right - g_embedRect.left;
const int ch = g_embedRect.bottom - g_embedRect.top;
RECT cur = {};
GetWindowRect(hwnd, &cur);
if (!IsWindowVisible(hwnd) || cur.left != g_embedRect.left ||
cur.top != g_embedRect.top || cur.right - cur.left != cw ||
cur.bottom - cur.top != ch)
if (!samePlace || !IsWindowVisible(hwnd))
SetWindowPos(hwnd, HWND_BOTTOM, g_embedRect.left,
g_embedRect.top, cw, ch,
SWP_NOACTIVATE | SWP_SHOWWINDOW);
if (cur.right - cur.left != cw || cur.bottom - cur.top != ch)
fxGuiRequestResize(hwnd, cw, ch);
// First-paint kick (one-time per /show cycle, only after the
// editor attached). iZotope-class editors (Nectar/Ozone) paint
// ONLY when their window is actually visible AND activated:
// parked HWND_BOTTOM under the DAW they never paint -> black
// forever, and SetForegroundWindow from this background process
// is blocked by the Windows foreground lock. So: raise the
// window ABOVE the DAW at the panel rect, paint it there, then
// demote back under the DAW — the painted surface survives the
// demote (capture stays non-black at HWND_BOTTOM).
if (!g_paintKicked) {
g_paintKicked = true;
SetWindowPos(hwnd, HWND_TOP, g_embedRect.left, g_embedRect.top,
cw, ch, SWP_SHOWWINDOW | SWP_NOACTIVATE);
ShowWindow(hwnd, SW_SHOW);
InvalidateRect(hwnd, nullptr, TRUE);
RedrawWindow(hwnd, nullptr, nullptr,
RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
// Foreground-lock bypass: a real synthesized click (system
// input, not subject to the SetForegroundWindow lock)
// activates the window the same way a user click would.
Sleep(200);
RECT wr = {};
GetWindowRect(hwnd, &wr);
const int cx = wr.left + (wr.right - wr.left) / 2;
const int cy = wr.top + 8; // title-bar strip, avoid knobs
SetCursorPos(cx, cy);
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
Sleep(500); // give the editor a beat to paint while on top
SetWindowPos(hwnd, HWND_BOTTOM, g_embedRect.left, g_embedRect.top,
cw, ch, SWP_NOACTIVATE);
std::cerr << "[FxGui] first-paint kick fg=click @" << cx
<< "," << cy << std::endl;
}
} else if (!IsWindowVisible(hwnd)) {
// No usable rect yet (pre-layout / still attaching): keep the
// window somewhere harmless until the frontend supplies it.
SetWindowPos(hwnd, HWND_NOTOPMOST, -20000, -20000, 0, 0,
SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
}
+4 -1
View File
@@ -1910,7 +1910,10 @@ int run_fx_gui_server(const std::string& jobPath) {
// crash fix (attach on a real monitor) and the black-capture fix (stay
// on a monitor afterwards, parked under the DAW at the panel rect) go
// together — setting only EMBED_ONSCREEN must not regress the crash.
const bool embedOnscreen = std::getenv("SF_FXGUI_EMBED_ONSCREEN") != nullptr;
// V45.45: on-screen embed parking la MAC DINH (DWM Intel UHD 630 khong
// redirect window parked -20000). Env chi con de TAT: SF_FXGUI_EMBED_ONSCREEN=0.
const char* eosEnv = std::getenv("SF_FXGUI_EMBED_ONSCREEN");
const bool embedOnscreen = eosEnv == nullptr || eosEnv[0] != '0';
const bool attachOnscreen = embedOnscreen || std::getenv("SF_FXGUI_ATTACH_ONSCREEN") != nullptr;
std::cout << "[FxGui] env SF_FXGUI_ATTACH_ONSCREEN=" << (attachOnscreen ? 1 : 0)
<< " SF_FXGUI_EMBED_ONSCREEN=" << (embedOnscreen ? 1 : 0)
Binary file not shown.
Binary file not shown.