diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py index f6eaaf3..83b2db2 100644 --- a/app/core/fx_realtime.py +++ b/app/core/fx_realtime.py @@ -26,7 +26,7 @@ import uuid import numpy as np from app.config import settings -from app.core.native_render import find_fx_bridge_exe +from app.core.native_render import find_fx_bridge_exe, find_juce_fx_bridge_exe FXRT_MAGIC = 0x46585254 FXRT_BLOCK = 256 @@ -164,9 +164,11 @@ class FxRealtimeSession: def _spawn(self): juce_exe = os.getenv("SF_JUCE_FX_BRIDGE_PATH", "").strip() - exe = juce_exe or find_fx_bridge_exe() + # G3: mặc định dùng JUCE bridge (latency graph thật + processed samples); + # fallback bridge cũ khi không có exe JUCE (regression an toàn). + exe = juce_exe or find_juce_fx_bridge_exe() or find_fx_bridge_exe() if not exe: - raise RuntimeError("Không tìm thấy fx_vst_bridge.exe") + raise RuntimeError("Không tìm thấy bridge exe (juce_fx_bridge / fx_vst_bridge)") job = { "sample_rate": self.sample_rate, "block_size": self.block_size, @@ -176,7 +178,7 @@ class FxRealtimeSession: fd, self._job_path = tempfile.mkstemp(suffix=".json", prefix="fxrt_") with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(job, f) - if juce_exe: + if os.path.basename(exe).lower().startswith("juce_fx_bridge"): cmd = [exe, "--juce-fx", self._job_path, "--shm", self.name, "--parent", str(os.getpid())] else: diff --git a/app/core/native_render.py b/app/core/native_render.py index 188857a..901e5f8 100644 --- a/app/core/native_render.py +++ b/app/core/native_render.py @@ -20,6 +20,7 @@ from app.config import settings BRIDGE_EXE = "daw_vst_bridge.exe" if os.name == "nt" else "daw_vst_bridge" FX_BRIDGE_EXE = "fx_vst_bridge.exe" if os.name == "nt" else "fx_vst_bridge" +JUCE_FX_BRIDGE_EXE = "juce_fx_bridge.exe" if os.name == "nt" else "juce_fx_bridge" def find_bridge_exe(exe_name=BRIDGE_EXE, env_key="SF_BRIDGE_PATH") -> str: @@ -60,6 +61,10 @@ def find_bridge_exe(exe_name=BRIDGE_EXE, env_key="SF_BRIDGE_PATH") -> str: def find_fx_bridge_exe() -> str: """Đường dẫn FX bridge (fx_vst_bridge.exe): SF_FX_BRIDGE_PATH → bundle → install/.""" return find_bridge_exe(FX_BRIDGE_EXE, "SF_FX_BRIDGE_PATH") + +def find_juce_fx_bridge_exe() -> str: + """Đường dẫn FX bridge JUCE (juce_fx_bridge.exe): SF_JUCE_FX_BRIDGE_PATH → bundle → install/.""" + return find_bridge_exe(JUCE_FX_BRIDGE_EXE, "SF_JUCE_FX_BRIDGE_PATH") def normalize_audio_peak(audio, target_db=0.0): """Scale audio (numpy array, peak) về target_db nếu vượt; giữ nguyên nếu dưới. diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 0746018..2287eda 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -301,7 +301,7 @@ const len=Math.floor(pcm.length/channels);const bps=4;const dataBytes=len*bps*ch // worklet → analyser (sau master fader, trước metering/destination — khớp thứ // tự offline export). Track: mỗi track 1 session/worklet splice vào CẢ 2 đường // chain (audio-clips + MIDI/SF) — mọi luồng track qua bridge trước main out. -const fxRt={active:false,starting:false,startToken:0,sessionId:null,ws:null,node:null,inQueue:[],pumpTimer:null,lastInSent:0,chainKey:null,latencyTotal:0,fadedIn:false,pdcCalibMs:0};// Loop restart/seek/reschedule: stopAllPlayback giữ session FX realtime (không +const fxRt={active:false,starting:false,startToken:0,sessionId:null,ws:null,node:null,inQueue:[],pumpTimer:null,lastInSent:0,chainKey:null,latencyTotal:0,fadedIn:false,pdcCalibMs:0,processedSamples:null,processedAnchor:null};// Loop restart/seek/reschedule: stopAllPlayback giữ session FX realtime (không // teardown) → fxRtStart early-return → KHÔNG reload VST ~4s mỗi pass loop // (hết bypass rồi mới load lại). Chỉ set TRƯỚC nhánh restart/resume; user // stop thật (fade=true) giữ master FX warm + idle timeout (V21); bridge-down / @@ -339,13 +339,17 @@ 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=202608272300`);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=202608281559`);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 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 +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;// G3: processed samples (playhead engine-derived) — anchor lan dau +// khi active (cold start: VST load xong -> message latency dau tien). +if(typeof d.processed==='number'){fxRt.processedSamples=d.processed;if(fxRt.active&&fxRt.processedAnchor==null){fxRt.processedAnchor=d.processed;}}// G3: bao latency that cho worklet — align wrap crossfade theo +// processed tail (wrapFadeBlocks = queue + ceil(latency/BLOCK)). +try{if(fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'latency',samples:fxRt.latencyTotal||0});}catch(e){}// 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). @@ -412,7 +416,12 @@ if(!_fxStopNode)fxRtApplyPdc();if(sid){try{window.SonicAPI.fxRealtimeStop({sessi // ≈ 1792) + FILL buffer 8 block (2048) = 15 block ≈ 3840 (~80ms @48k). Giữ // đồng bộ với sf-fx-realtime.js. FX_RT_START_EST_MS: ước lượng khởi đầu khi // chưa có phép đo (fx_play_first) — dùng cho freeze playhead thay V39 0.31s. -const FX_RT_ROUNDTRIP_SAMPLES=3840;const FX_RT_START_EST_MS=450;function fxRtApplyPdc(){const ctx=getAudioContext();const bus=masterBus;if(!ctx||!bus||!bus.pdcDelay)return;const l=fxRt&&fxRt.active?FX_RT_ROUNDTRIP_SAMPLES+(fxRt.latencyTotal||0):0;try{bus.pdcDelay.delayTime.setTargetAtTime(l/(ctx.sampleRate||44100),ctx.currentTime,0.05);}catch(e){}}// Crossfade master-FX switch: ramp masterFxGain/masterFxDryGain thay vi +const FX_RT_ROUNDTRIP_SAMPLES=3840;const FX_RT_START_EST_MS=450;// G3: anchor processed — playhead = startOffset + (processed - anchor - latency)/sr +// (da tru latency -> bam vi tri mau NGHE DUOC; pre-roll tu giu tai startOffset — +// thay freeze/snap V40). Anchor moi play/wrap restart qua __fxRtPlayheadFreeze +// (moi play/resume path deu goi); cold start (chua active) -> null -> WS message +// dau tien anchor. +function fxRtAnchorProcessed(){if(fxRt&&fxRt.active&&typeof fxRt.processedSamples==='number'){fxRt.processedAnchor=fxRt.processedSamples;}else{fxRt.processedAnchor=null;}}function fxRtApplyPdc(){const ctx=getAudioContext();const bus=masterBus;if(!ctx||!bus||!bus.pdcDelay)return;const l=fxRt&&fxRt.active?FX_RT_ROUNDTRIP_SAMPLES+(fxRt.latencyTotal||0):0;try{bus.pdcDelay.delayTime.setTargetAtTime(l/(ctx.sampleRate||44100),ctx.currentTime,0.05);}catch(e){}}// Crossfade master-FX switch: ramp masterFxGain/masterFxDryGain thay vi // hard-rewire (click). Hai gain nay duoc tao trong initMasterBus. function fxRtCrossfade(toFx,token){const ctx=getAudioContext();const bus=masterBus;if(!ctx||!bus||!bus.masterFxGain||!bus.masterFxDryGain)return;if(token!==undefined&&token!==fxRt.startToken)return;const tc=0.025;try{bus.masterFxGain.gain.setTargetAtTime(toFx?1:0,ctx.currentTime,tc);bus.masterFxDryGain.gain.setTargetAtTime(toFx?0:1,ctx.currentTime,tc);}catch(e){}}// ── Realtime VST FX per-track (SHM bridge) — mọi luồng track qua bridge ───── // Mỗi track: session riêng (1 WS/SHM) cho TỪNG đường chain có âm — audio-clips @@ -1513,7 +1522,9 @@ const startOffsetTimeRef=useRef(0);const startBufferOffsetRef=useRef(0);const st // pre-roll; section play không khởi động FX session → false). __fxRtSnapPlayhead: // worklet fx_play_first → bỏ freeze đúng mẫu đầu tiên nghe được. Fail-safe: // session không active trong 1500ms → hủy freeze (đừng đóng băng playhead vô hạn). -window.__fxRtPlayheadFreeze=function(allowCold){try{if(_fxRtFreezeTimer){clearTimeout(_fxRtFreezeTimer);_fxRtFreezeTimer=null;}}catch(e3){}// V41: gate theo CHAIN (không chỉ active) — session FX warm (stop giữ warm, +window.__fxRtPlayheadFreeze=function(allowCold){try{if(_fxRtFreezeTimer){clearTimeout(_fxRtFreezeTimer);_fxRtFreezeTimer=null;}}catch(e3){}// G3: FX active -> playhead engine-derived (processed) — KHONG freeze/snap. +// Anchor processed tai day (moi play/resume path deu goi ham nay). +if(fxRt&&fxRt.active){_fxRtPlayheadSnapPending=false;fxRtAnchorProcessed();return;}// V41: gate theo CHAIN (không chỉ active) — session FX warm (stop giữ warm, // bypass toggle gọi rebuildMasteringGraph KHÔNG gọi fxRtStop) → fxRt.active vẫn // true khi bypass → trước đây freeze nhầm → playhead chạy trước → wrap sớm → vấp. const _fxWillRun=!!(fxRt&&fxRtChain().length>0&&(fxRt.active||allowCold));if(!_fxWillRun)return;const _estMs=fxRt.pdcCalibMs||FX_RT_START_EST_MS;const _wasActiveAtFreeze=!!(fxRt&&fxRt.active);startAudioTimeRef.current=getAudioContext().currentTime+_estMs/1000;_fxRtPlayheadSnapPending=true;_fxRtFreezeTries=0;// V41: snap xác định bằng timer tại est — loop wrap warm không post fx_play_first @@ -1751,7 +1762,9 @@ const lastMasterRebuildTimeRef=React.useRef(0);const lastRescheduleTimeRef=React // updatePlayhead (~100ms) re-schedule luôn. const triggerRescheduleNow=()=>{try{lastRescheduleTimeRef.current=0;}catch(e){}};const updatePlayhead=()=>{// D9: playhead sample counter cho bridge — C++ A11/A13 dùng để đồng bộ // timeline sample-accurate (scheduling theo sampleOffset). -if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected&&(isPlaying||isPlayingRef.current)){try{const _pctx=getAudioContext();const _elapsed=_pctx.currentTime-(startAudioTimeRef.current||_pctx.currentTime);bridgePlayheadSampleRef.current=Math.max(0,Math.floor((startOffsetTimeRef.current+_elapsed)*(_pctx.sampleRate||44100)));// set_position ~mỗi giây: C++ A13 cập nhật timeline anchor, tối ưu cho +if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected&&(isPlaying||isPlayingRef.current)){try{const _pctx=getAudioContext();// G3: FX active -> position tu processed engine (da tru latency) — bam +// vi tri mau NGHE duoc, khong freeze/snap; fallback wall-clock. +let _posSamples;if(fxRt&&fxRt.active&&typeof fxRt.processedSamples==='number'&&fxRt.processedAnchor!=null){_posSamples=Math.max(Math.floor(startOffsetTimeRef.current*(_pctx.sampleRate||44100)),Math.floor(startOffsetTimeRef.current*(_pctx.sampleRate||44100)+(fxRt.processedSamples-fxRt.processedAnchor-(fxRt.latencyTotal||0))));}else{const _elapsed=_pctx.currentTime-(startAudioTimeRef.current||_pctx.currentTime);_posSamples=Math.max(0,Math.floor((startOffsetTimeRef.current+_elapsed)*(_pctx.sampleRate||44100)));}bridgePlayheadSampleRef.current=_posSamples;// set_position ~mỗi giây: C++ A13 cập nhật timeline anchor, tối ưu cho // seek/loop (Rust pump + C++ không tự biết vị trí; JS là nguồn duy nhất). try{const _sr=_pctx.sampleRate||44100;if(!window.__bridgeLastPosSent)window.__bridgeLastPosSent=0;if(bridgePlayheadSampleRef.current-window.__bridgeLastPosSent>_sr){window.__bridgeLastPosSent=bridgePlayheadSampleRef.current;window.NativeBridgeService.transport('set_position',bridgePlayheadSampleRef.current);}}catch(e2){}}catch(e){}}// Realtime re-schedule: khi đang play (loop play) mà items (vị trí/speed/ // duration — kể cả nội dung section tab) thay đổi → dừng + schedule lại từ @@ -1820,10 +1833,10 @@ if(!st.buffer&&st.type!=='PIANO_ROLL')return;if(!st.isPlaying){// PIANO ROLL / s // item (st.currentTime = playPos - item.startTime); playhead vượt ra // ngoài duration item → bắt đầu từ đầu (0). Không có play nào → đứng // yên (chế độ edit). Loop luôn tiếp tục (rAF). -if(isPlaying){const context=getAudioContext();const _ph=startOffsetTimeRef.current+(context.currentTime-startAudioTimeRef.current);let _local=_ph;const _itemInfo=getPianoRollItemInfo(st);if(_itemInfo){if(_ph<_itemInfo.startTime)_local=0;else if(_ph>=_itemInfo.startTime+_itemInfo.duration)_local=0;// vượt duration item → đầu +if(isPlaying){const context=getAudioContext();let _ph=startOffsetTimeRef.current+(context.currentTime-startAudioTimeRef.current);if(fxRt&&fxRt.active&&typeof fxRt.processedSamples==='number'&&fxRt.processedAnchor!=null){_ph=startOffsetTimeRef.current+(fxRt.processedSamples-fxRt.processedAnchor-(fxRt.latencyTotal||0))/(context.sampleRate||44100);}let _local=_ph;const _itemInfo=getPianoRollItemInfo(st);if(_itemInfo){if(_ph<_itemInfo.startTime)_local=0;else if(_ph>=_itemInfo.startTime+_itemInfo.duration)_local=0;// vượt duration item → đầu else _local=_ph-_itemInfo.startTime;}// Log chẩn đoán (rate-limited ~2 lần/giây): xác nhận đồng bộ MAIN/ // SECTION ↔ PIANO ROLL — ph (vị trí play) → local (trong item). -try{const _sf=window.__syncFrame=(window.__syncFrame||0)+1;if(_sf%45===0){console.log('[Sync] PR',st.id,'ph=',_ph.toFixed(2),'item.start=',_itemInfo?_itemInfo.startTime.toFixed(2):'-','dur=',_itemInfo?_itemInfo.duration.toFixed(2):'-','→ local=',_local.toFixed(2),'tab=',activeTabRef.current);}}catch(e){}setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:_local}:s));}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}const context=getAudioContext();const speedFactor=st.speed||1.0;const elapsed=context.currentTime-startAudioTimeRef.current;const wallTime=startOffsetTimeRef.current+elapsed;const bufferPos=startBufferOffsetRef.current+elapsed*speedFactor;// Loop sub-tab selection (bufferPos is buffer-time) +try{const _sf=window.__syncFrame=(window.__syncFrame||0)+1;if(_sf%45===0){console.log('[Sync] PR',st.id,'ph=',_ph.toFixed(2),'item.start=',_itemInfo?_itemInfo.startTime.toFixed(2):'-','dur=',_itemInfo?_itemInfo.duration.toFixed(2):'-','→ local=',_local.toFixed(2),'tab=',activeTabRef.current);}}catch(e){}setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:_local}:s));}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}const context=getAudioContext();const speedFactor=st.speed||1.0;const elapsed=context.currentTime-startAudioTimeRef.current;let wallTime=startOffsetTimeRef.current+elapsed;const bufferPos=startBufferOffsetRef.current+elapsed*speedFactor;if(fxRt&&fxRt.active&&typeof fxRt.processedSamples==='number'&&fxRt.processedAnchor!=null){wallTime=startOffsetTimeRef.current+(fxRt.processedSamples-fxRt.processedAnchor-(fxRt.latencyTotal||0))/(context.sampleRate||44100);}// Loop sub-tab selection (bufferPos is buffer-time) if(st.selectionStart!==null&&st.selectionEnd!==null&&st.selectionStart!==st.selectionEnd&&st.isLooping){const start=Math.min(st.selectionStart,st.selectionEnd);const end=Math.max(st.selectionStart,st.selectionEnd);if(bufferPos>=end){fxRtWrapRestart=true;// V38: loop wrap restart fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime stopAllPlayback();setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,buffer:s.buffer||getAudioContext().createBuffer(1,128,getAudioContext().sampleRate),currentTime:start,isPlaying:true}:s));schedulePianoRollMidi(st,start);startSubTabPlayback(st,start);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}}const effectiveDuration=st.type==='PIANO_ROLL'?(()=>{const notes=st.notes||[];const bpmVal=parseInt(bpmRef?.current||bpm)||120;const beatSec=60.0/bpmVal;if(recordingStateRef.current==='RECORDING')return 600.0;// 10 min during recording @@ -1831,7 +1844,9 @@ let maxEnd=0;notes.forEach(n=>{const end=(n.start_beat||0)+(n.duration_beats||1) fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime stopAllPlayback();if(st.isLooping){setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,buffer:s.buffer||getAudioContext().createBuffer(1,128,getAudioContext().sampleRate),currentTime:0,isPlaying:true}:s));// Loop lại: phải schedule notes MỚI (âm piano roll mất ở lần loop 2 // nếu chỉ chạy silent buffer). -if(st.type==='PIANO_ROLL')schedulePianoRollMidi(st,0);startSubTabPlayback(st,0);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:0,isPlaying:false}:s));}return;}setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:wallTime}:s));animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}if(!isPlaying)return;const context=getAudioContext();const elapsed=context.currentTime-startAudioTimeRef.current;const updatedTime=startOffsetTimeRef.current+elapsed;// Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 +if(st.type==='PIANO_ROLL')schedulePianoRollMidi(st,0);startSubTabPlayback(st,0);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:0,isPlaying:false}:s));}return;}setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:wallTime}:s));animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}if(!isPlaying)return;const context=getAudioContext();const elapsed=context.currentTime-startAudioTimeRef.current;const updatedTime=startOffsetTimeRef.current+elapsed;// G3: playhead display engine-derived khi FX active (wrap/loop trigger giu +// wall-clock — transport truth). +const _playheadSec=fxRt&&fxRt.active&&typeof fxRt.processedSamples==='number'&&fxRt.processedAnchor!=null?Math.max(startOffsetTimeRef.current,startOffsetTimeRef.current+(fxRt.processedSamples-fxRt.processedAnchor-(fxRt.latencyTotal||0))/(context.sampleRate||44100)):updatedTime;// Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 // If selection cleared by user, play linearly (don't loop) if(!selectionCleared&&isLoopingSelection&&selLeft!==null&&selRight!==null){if(selRight>selLeft&&updatedTime>=selRight){if(hasAnySolo||selectionMode==='local'){fxRtWrapRestart=true;// V38: loop wrap restart fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime @@ -1841,7 +1856,7 @@ stopAllPlayback();startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=c // buffer 12 bars như maxDuration dùng cho scroll/zoom). if(updatedTime>=projectEndRef.current){console.log('[Stop] projectEnd reached — updatedTime=',updatedTime.toFixed(2),'projectEnd=',projectEndRef.current.toFixed(2),'isLoopingSelection=',isLoopingSelection);if(recordingStateRef.current==='RECORDING'){setCurrentTime(updatedTime);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}if(isLoopingSelection){fxRtWrapRestart=true;// V38: loop wrap restart fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();startOffsetTimeRef.current=0;startAudioTimeRef.current=context.currentTime;startTrackPlayback(0);setCurrentTime(0);setIsPlaying(true);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}stopAllPlayback();setCurrentTime(0);return;}setCurrentTime(updatedTime);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);};useEffect(()=>{if(isPlaying||subTabs.some(s=>s.isPlaying)){animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{cancelAnimationFrame(animationFrameIdRef.current);}return()=>cancelAnimationFrame(animationFrameIdRef.current);},[isPlaying,subTabs,isLoopingSelection,selLeft,selRight,selectionMode,localSelectionTrackId,selectionCleared,activeTab]);// ── FX Nodes ── +stopAllPlayback();startOffsetTimeRef.current=0;startAudioTimeRef.current=context.currentTime;startTrackPlayback(0);setCurrentTime(0);setIsPlaying(true);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}stopAllPlayback();setCurrentTime(0);return;}setCurrentTime(_playheadSec);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);};useEffect(()=>{if(isPlaying||subTabs.some(s=>s.isPlaying)){animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{cancelAnimationFrame(animationFrameIdRef.current);}return()=>cancelAnimationFrame(animationFrameIdRef.current);},[isPlaying,subTabs,isLoopingSelection,selLeft,selRight,selectionMode,localSelectionTrackId,selectionCleared,activeTab]);// ── FX Nodes ── const createChorusNode=(context,inputNode,outputNode)=>{const dryGain=context.createGain();dryGain.gain.value=0.6;const wetGain=context.createGain();wetGain.gain.value=0.5;const delayNode=context.createDelay();delayNode.delayTime.value=0.02;const lfo=context.createOscillator();lfo.type='sine';lfo.frequency.value=1.5;const lfoGain=context.createGain();lfoGain.gain.value=0.002;lfo.connect(lfoGain);lfoGain.connect(delayNode.delayTime);lfo.start();inputNode.connect(dryGain);inputNode.connect(delayNode);delayNode.connect(wetGain);dryGain.connect(outputNode);wetGain.connect(outputNode);return{stop:()=>{try{lfo.stop();}catch(e){}}};};const createReverbNode=(context,inputNode,outputNode)=>{const dryGain=context.createGain();dryGain.gain.value=0.6;const wetGain=context.createGain();wetGain.gain.value=0.4;const convolver=context.createConvolver();const rate=context.sampleRate;const len=rate*2.0;const impulse=context.createBuffer(2,len,rate);const left=impulse.getChannelData(0);const right=impulse.getChannelData(1);for(let i=0;i{if(!track)return null;let node=activeTrackNodesRef.current[track.id];if(!node){const gainNode=context.createGain();const volDb=track.volumeDb??0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);gainNode.gain.setValueAtTime(volLinear,context.currentTime);const pannerNode=context.createStereoPanner();pannerNode.pan.setValueAtTime((track.pan??0)/100,context.currentTime);// Ensure master bus is initialized for MAIN OUT routing if(!masterBus)initMasterBus(context);const analyserNode=context.createAnalyser();analyserNode.fftSize=2048;pannerNode.connect(analyserNode);// Wave Observer scope analysers (unified_fx_rack_panel_update.md §III.3): @@ -1889,7 +1904,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=202608272300`);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=202608281559`);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;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 diff --git a/app/templates/index.html b/app/templates/index.html index afe9fdf..9205ae6 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -51,7 +51,7 @@ - +