3028 lines
1.5 MiB
Plaintext
3028 lines
1.5 MiB
Plaintext
const{useState,useRef,useEffect,useMemo,useCallback}=React;// ── FastAPI Backend Configuration ──
|
||
const API_BASE_URL=(window.API_BASE_URL||window.location.origin).replace(/\/+$/,'');const API_AUDIO=`${API_BASE_URL}/api/v1/audio`;const API_MULTITRACK=`${API_BASE_URL}/api/v1/multitrack`;const API_TASKS=`${API_BASE_URL}/api/v1/audio/tasks`;// ── Dedicated per-track MIDI channel allocation ──
|
||
// FluidSynth has 16 channels; if two tracks share a channel, arming one track
|
||
// re-selects the other track's program and its instrument changes. Every track
|
||
// gets its own stable, unique channel (0-15, skipping 9 which is the classic
|
||
// percussion slot) so multi-track ARM / playback never cross-contaminates
|
||
// instruments. Module-level so both App and the piano-roll sub-components
|
||
// (PianoRollTabEditor etc.) allocate the SAME channel for a track.
|
||
const trackMidiChannelsRef={current:{}};const ensureTrackMidiChannel=(track,tracks)=>{if(!track)return 0;const trackList=tracks||[];const inUse=new Set();trackList.forEach(tr=>{if(tr&&tr.id!==track.id&&tr.midiChannel!==undefined)inUse.add(tr.midiChannel);});const cached=trackMidiChannelsRef.current[track.id];if(cached!==undefined&&!inUse.has(cached))return cached;const preferred=track.midiChannel!==undefined&&!inUse.has(track.midiChannel)?track.midiChannel:null;if(preferred!==null){trackMidiChannelsRef.current[track.id]=preferred;return preferred;}for(let c=0;c<16;c++){if(c===9)continue;if(!inUse.has(c)){trackMidiChannelsRef.current[track.id]=c;return c;}}trackMidiChannelsRef.current[track.id]=0;return 0;};const assignTrackMidiChannel=(track,tracks)=>{const ch=ensureTrackMidiChannel(track,tracks);if(track&&track.midiChannel!==ch)track.midiChannel=ch;return ch;};// ── Instrument context (PIANO ROLL play — nguồn duy nhất, mọi nơi dùng) ──
|
||
// Track "đã loaded instrument" = track.instrumentProgram (GM preset) HOẶC
|
||
// track.synth_engine (soundfont: soundfont_id/bank/program). Resolve thành
|
||
// context thống nhất cho schedulePianoRollMidi + preview (wheel/click/keybed)
|
||
// để note PHẢI chơi đúng instrument của track — không phụ thuộc st snapshot.
|
||
const resolveTrackInstrumentCtx=(track,tracks)=>{const all=tracks||[];const ch=track?assignTrackMidiChannel(track,all):0;const se=track?track.synth_engine:undefined;const isSf=!!(se&&(se.type==='soundfont'||se.soundfont_id));if(isSf){return{ch,program:undefined,// SF path — synthEngine được ưu tiên trong _playNoteFluid
|
||
synthEngine:se,sfId:se.soundfont_id,bank:se.soundfont_bank||0,prog:se.soundfont_program||0};}if(track&&track.instrumentProgram!==undefined){return{ch,program:track.instrumentProgram,synthEngine:undefined,sfId:undefined,bank:0,prog:track.instrumentProgram};}return{ch,program:undefined,synthEngine:undefined,sfId:undefined,bank:0,prog:0};};// ── Bridge program-change helper (D8/A12): gửi CC0/CC32 (bank THẬT) + PROGRAM
|
||
// qua bridge trước note-on — dedupe theo track+bank+program (không spam mỗi
|
||
// note; reset khi load instrument mới). Dùng chung mọi đường: timeline,
|
||
// preview, keybed. Trước đây bank hardcode 0 + preview/keybed KHÔNG gửi
|
||
// program change → SF2 preset ở bank≠0 ra piano sai.
|
||
window.__ensureBridgeProgram=(trkId,program,synthEngine,force)=>{try{if(!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return;const _bank=synthEngine&&synthEngine.soundfont_bank!==undefined?synthEngine.soundfont_bank:0;const _prog=program!==undefined&&program!==null?program:synthEngine&&synthEngine.soundfont_program!==undefined?synthEngine.soundfont_program:undefined;if(_prog===undefined||_prog===null)return;const _key=trkId+':'+_bank+':'+_prog;const _lp=window.__bridgeLastProgram||(window.__bridgeLastProgram={});if(!force&&_lp[_key])return;_lp[_key]=true;const _isPerc=_bank===128;window.SonicMidiRouter.pushEvent({cmd:'CC',channel:trkId,pitch:0,velocity:0,data2:_bank>>7&0x7F,percussion:_isPerc});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:trkId,pitch:32,velocity:0,data2:_bank&0x7F,percussion:_isPerc});window.SonicMidiRouter.pushEvent({cmd:'PROGRAM',channel:trkId,pitch:0,velocity:0,data2:_prog,percussion:_isPerc});}catch(e){console.warn('[Bridge] ensureBridgeProgram error:',e);}};// Đảm bảo FluidSynth channel của track đã select ĐÚNG instrument trước khi
|
||
// notes bắn. Fire-and-forget: playNote tự load + retry nếu SF chưa load xong
|
||
// (dedup sẵn trong loadSoundFont) — không chặn, không gây stall khi mở tab.
|
||
const ensureSonicInstrument=ctx=>{try{if(!window.SonicSF||!window.SonicSF.selectInstrument)return;if(ctx.sfId){window.SonicSF.selectInstrument(ctx.ch,ctx.bank,ctx.prog,ctx.sfId);}else if(ctx.program!==undefined){window.SonicSF.selectInstrument(ctx.ch,0,ctx.program,null);}}catch(e){console.warn('[Instrument] ensureSonicInstrument error:',e);}};// ── MIDI note-off scheduling helper (pending map chống note chồng) ─────────
|
||
// FIX (Bug 2): NOTE_OFF trễ (dur+30ms) của note cùng pitch bắn SAU note-on kế
|
||
// tiếp (bar liền nhau cùng hợp âm, piano roll note chồng) -> cắt note mới →
|
||
// câm. Map pending theo key channel:pitch — schedule NOTE_OFF mới cùng key →
|
||
// hủy NOTE_OFF cũ (retrigger đúng, note mới ngân đủ duration).
|
||
// D18e: seam ring-out - note cuoi loop dang ngan qua seam co NOTE_OFF tu nhien
|
||
// trong cua so nay (KEEP ~85ms + WRAP_FADE 2048 ~43ms - sf-fx-realtime.js
|
||
// V45.2/46.2) KHONG bi panic: ring qua KEEP/crossfade tail roi NOTE_OFF. Ngan
|
||
// dai qua seam van panic (D18d) - tranh chong am lap sau.
|
||
const RING_GRACE_MS=150;const pendingNoteOffMap={};// V18: note-off cũ cùng key KHÔNG bị hủy mù — hủy mù làm note cùng pitch lặp
|
||
// (bass: root note chạy liên tục) không bao giờ nhận note-off -> kêu dài chồng
|
||
// âm echo, không decay. So sánh thời điểm: note-off cũ bắn TRƯỚC note-on mới →
|
||
// giữ nguyên (decay đúng hạn). Note-off cũ bắn SAU note-on mới (overlap — Bug 2
|
||
// cũ) → dời về NGAY TRƯỚC note-on mới: note cũ decay sạch, note mới ngân đủ.
|
||
const scheduleNoteOff=(key,fn,noteOnDelayMs,durMs)=>{const offDelay=noteOnDelayMs+(durMs||1000)+30;const prev=pendingNoteOffMap[key];if(prev&&prev.offDelay>noteOnDelayMs){try{clearTimeout(prev.timer);}catch(e){}const t=setTimeout(function(){if(pendingNoteOffMap[key]&&pendingNoteOffMap[key].timer===t)delete pendingNoteOffMap[key];prev.fn();},Math.max(0,noteOnDelayMs-5));}const timer=setTimeout(function(){if(pendingNoteOffMap[key]&&pendingNoteOffMap[key].timer===timer)delete pendingNoteOffMap[key];fn();},offDelay);// D18e: offAt (moc tuyet doi luc NOTE_OFF ban) - seam-ring capture o
|
||
// stopAllPlayback doc entry con pending de biet pitch nao sap NOTE_OFF tu nhien.
|
||
pendingNoteOffMap[key]={timer:timer,fn:fn,offDelay:offDelay,offAt:performance.now()+offDelay};return timer;};const clearAllPendingNoteOffs=()=>{Object.keys(pendingNoteOffMap).forEach(function(k){const entry=pendingNoteOffMap[k];if(entry){try{clearTimeout(entry.timer);}catch(e){}}delete pendingNoteOffMap[k];});};// ── Môi trường chạy: docker vs standalone ─────────────────────────────────
|
||
// environment: "docker" → âm instrument qua FluidSynthWASM (client); "standalone"
|
||
// → xử lí trực tiếp trên OS (backend native pyfluidsynth). Quy tắc:
|
||
// KHÔNG phải docker = standalone (Windows/Linux/macOS chạy trực tiếp).
|
||
const sfEnv=()=>{try{const r=window.SonicRuntime;if(r&&r.environment)return r.environment;const c=r&&r.capabilities;return c&&c.docker?'docker':'standalone';}catch(e){return'docker';}};const isDockerSf=()=>sfEnv()==='docker';const isStandaloneSf=()=>sfEnv()==='standalone';// Track dùng âm soundfont (không phải VSTi) — route native khi standalone.
|
||
const isSfTrackEngine=se=>{if(!se)return false;return!!(se.soundfont_id||String(se.type||'').indexOf('soundfont')!==-1||String(se.type||'').indexOf('sf3')!==-1);};const isVstTrackEngine=se=>!!se&&String(se.type||'').indexOf('vst')!==-1;// ── Native soundfont preview (standalone) ─────────────────────────────────
|
||
// Render 1 note bằng backend native FluidSynth (pyfluidsynth) → play WAV.
|
||
// Mỗi key (thường = track.id) một Audio element — note mới stop note cũ;
|
||
// token chống stale (response cũ không đè response mới).
|
||
const _nativeSfPreviews={};const playNativeSfNote=(track,pitch,velocity,durationMs,startTime,key)=>{try{const eng=track&&track.synth_engine;const sfId=eng&&eng.soundfont_id||(track&&track.instrumentId&&String(track.instrumentId).startsWith('sf_')?track.instrumentId:null);if(!sfId)return;const bank=eng&&eng.soundfont_bank||0;const program=eng&&eng.soundfont_program||(track&&track.instrumentProgram!==undefined?track.instrumentProgram:0);const k=key||(track?track.id:'global');const prev=_nativeSfPreviews[k];const token=(prev?prev.token:0)+1;const durationSec=Math.max(0.2,(durationMs||500)/1000);window.SonicAPI.soundfontRender({soundfont_id:sfId,bank:bank,program:program,bpm:120,notes:[{pitch:pitch,start_beat:0,duration_beats:durationSec*2,velocity:velocity!=null?velocity:0.8}]}).then(function(res){if(!res||!res.success||!res.url)return;if(_nativeSfPreviews[k]&&_nativeSfPreviews[k].token!==token)return;// stale
|
||
const audio=new Audio(API_BASE_URL+res.url);_nativeSfPreviews[k]={audio:audio,token:token};const ctx=getAudioContext();const delay=startTime?Math.max(0,(startTime-ctx.currentTime)*1000):0;setTimeout(function(){if(!_nativeSfPreviews[k]||_nativeSfPreviews[k].audio!==audio)return;audio.play().catch(function(){});audio._sfStopTimer&&clearTimeout(audio._sfStopTimer);audio._sfStopTimer=setTimeout(function(){try{audio.pause();}catch(e){}},durationSec*1000+400);},delay);}).catch(function(){});}catch(e){console.warn('[NativeSF] playNativeSfNote error:',e);}};const stopNativeSfNote=key=>{try{const k=key||'global';const prev=_nativeSfPreviews[k];if(!prev)return;prev.token++;try{if(prev.audio){prev.audio.pause();prev.audio.currentTime=0;}}catch(e){}}catch(e){}};const stopAllNativeSfNotes=()=>{try{Object.keys(_nativeSfPreviews).forEach(function(k){stopNativeSfNote(k);});}catch(e){}};// ── Native track render (standalone, transport) ──────────────────────────────────────────────────────
|
||
// Render TOÀN BỘ MIDI items của track bằng 1 request (native FluidSynth cho
|
||
// soundfont, native_bridge --render cho VSTi) → decode → 1 BufferSource duy
|
||
// nhất cho cả track (như audio clip). Thay thế render per-item: RENDER_LOCK
|
||
// server serialize từng item → project nhiều item = delay vài chục giây.
|
||
// opts:
|
||
// baseOffsetSec: offset thêm (section: secStart) | limitSec: chặn tại secEnd
|
||
// isActive(): guard stop giữa chừng | sources: mảng nguồn để stop
|
||
// playheadNow(): playhead THẬT tại thời điểm resolve — render trễ vài chục
|
||
// giây → item vẫn schedule đúng vị trí playhead hiện tại (không chồng
|
||
// âm/wall-of-sound như dùng offsetTime cũ).
|
||
// notesOverride: dùng thay track.midiItems (piano roll tab).
|
||
// Epoch guard: stop/rebuild tăng _nativePlayEpoch → mọi render pending của
|
||
// lần play cũ resolve nhưng bị skip (không duplicate schedule).
|
||
let _nativePlayEpoch=0;// CC11 timers epoch rieng: chi tang o stopAllPlayback (cung pattern bridgeNoteEpochRef
|
||
// cho note-on). schedulePianoRollMidi + startSubTabPlayback moi lan play deu tang
|
||
// _nativePlayEpoch -> CC11 timers capture epoch giua 2 lan ++ bi stale -> piano roll
|
||
// KHONG co CC11 realtime (main session co vi startTrackPlayback chi ++ 1 lan).
|
||
let _cc11Epoch=0;// V17: fade session dang treo (gain ramp FADE_MS) — restore khi play moi /
|
||
// stop moi / doHardStop de gain KHONG bi ket ~0.0001 (root cause "am bi fade out").
|
||
let _pendingFadeSession=null;const restorePendingFadeSession=()=>{if(!_pendingFadeSession)return;let _bgBefore=-1;try{if(_pendingFadeSession.bridgeGainNode)_bgBefore=_pendingFadeSession.bridgeGainNode.gain.value;}catch(e){}try{const t=getAudioContext().currentTime;_pendingFadeSession.fadedGains.forEach(g=>{try{g.node.gain.cancelScheduledValues(t);g.node.gain.setValueAtTime(g.prev,t);}catch(e){}});if(_pendingFadeSession.bridgeGainNode){try{_pendingFadeSession.bridgeGainNode.gain.cancelScheduledValues(t);_pendingFadeSession.bridgeGainNode.gain.setValueAtTime(1.0,t);}catch(e){}}}catch(e){}_dbgLog('restore','gains='+_pendingFadeSession.fadedGains.length+' bg='+_bgBefore.toFixed(4)+'->1.0');_pendingFadeSession=null;};// V28: timing diagnostics -> %APPDATA%/SonicForgeDAW/logs/dbg.log (Rust dbg_log).
|
||
// Fire-and-forget invoke — khong chan main thread; tag+extra ghi kem unix_ms.
|
||
const _dbgLog=(tag,extra)=>{try{if(window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke){window.__TAURI__.core.invoke('dbg_log',{tag:tag,extra:extra===undefined?'':String(extra)}).catch(function(){});}}catch(e){}};// V29: expose dbg hook cho BridgeAudioNode (worklet state) + helpers.
|
||
window.__dbgLog=_dbgLog;// M20 Bug B diag: log dB meter master OUT (masterBus.analyser — cùng nguồn
|
||
// outPeakText MASTERING PANEL, SAU chain FX) + fader gain state. Hist vòng ~8s
|
||
// @250ms (chỉ giữ RAM, không log cho tới khi có event); marker: load (initMasterBus),
|
||
// fx_click (nút FX master), panel_open + post 1/2/4/8s (MASTERING PANEL mở) →
|
||
// so dB trước/sau để tìm cơ chế Bug B (âm lượng giảm khi mở panel).
|
||
let _mdiagHist=[];let _mdiagTimer=null;let _mdiagBuf=null;function _mdiagDb(){try{const b=window.masterBus;if(!b||!b.analyser)return null;const an=b.analyser;const len=an.fftSize||2048;if(!_mdiagBuf||_mdiagBuf.length<len)_mdiagBuf=new Float32Array(len);an.getFloatTimeDomainData(_mdiagBuf);let pk=0;for(let i=0;i<len;i++){const v=_mdiagBuf[i];const a=v<0?-v:v;if(a>pk)pk=a;}return pk<0.00003?-120:20*Math.log10(pk);}catch(e){return null;}}function _mdiagFader(){try{const b=window.masterBus;if(!b)return'na';const f=x=>{try{const n=x&&x.gain?x.gain.value:null;return n===null?'na':n.toFixed(4);}catch(e){return'na';}};return'out='+f(b.output)+' mute='+f(b.muteGain)+' volL='+f(b.volLGain)+' volR='+f(b.volRGain);}catch(e){return'na';}}function _mdiagSample(tag){const db=_mdiagDb();try{_dbgLog('mdiag_'+tag,'db='+(db===null?'na':db.toFixed(1))+' '+_mdiagFader());}catch(e){}}function _mdiagStart(){if(_mdiagTimer)return;_mdiagTimer=setInterval(()=>{try{if(!window.masterBus)return;_mdiagHist.push(_mdiagDb());if(_mdiagHist.length>32)_mdiagHist.shift();// ~8s @250ms
|
||
}catch(e){}},250);}function _mdiagDump(){try{for(let i=0;i<_mdiagHist.length;i++){const v=_mdiagHist[i];_dbgLog('mdiag_hist','db='+(v===null?'na':v.toFixed(1)));}}catch(e){}}let _mdiagPostTimer=null;function _mdiagSchedulePost(){if(_mdiagPostTimer)clearTimeout(_mdiagPostTimer);const del=[1000,2000,4000,8000];let k=0;const tick=()=>{if(k>=del.length)return;_mdiagPostTimer=setTimeout(()=>{_mdiagSample('post'+del[k]);k++;tick();},del[k]);};tick();}if(window.PerformanceObserver){try{new PerformanceObserver(list=>{for(const e of list.getEntries())_dbgLog('longtask','d='+Math.round(e.duration)+' s='+Math.round(e.startTime));}).observe({entryTypes:['longtask']});}catch(e){}}const _dbgBridgeGain=()=>{try{if(window.BridgeAudioNode&&window.BridgeAudioNode.getOutputNode){const _g=window.BridgeAudioNode.getOutputNode();if(_g&&_g.gain)return'bg='+_g.gain.value.toFixed(4);return'bg=none';}}catch(e){}return'bg=n/a';};const _dbgWorkletState=()=>{try{if(window.BridgeAudioNode&&window.BridgeAudioNode.getDiag){const _d=window.BridgeAudioNode.getDiag();const _ws=_d.ws?' ov='+_d.ws.overruns+' un='+_d.ws.underruns+' ring='+_d.ws.ring:'';return'wl='+(_d.useWorklet?'y':'n')+' pre='+_d.preBufferLen+' ch='+_d.chunksLen+_ws;}}catch(e){}return'wl=n/a';};window.__dbgBridgeGain=_dbgBridgeGain;window.__dbgWorkletState=_dbgWorkletState;// V28: ding fix — midi bat ky (keyboard/pad/timeline) restore bridge gain sau
|
||
// stop (gain giu ~0.0001 — V27 restore 800ms khong du cho VSTi release tail dai).
|
||
window.__restorePendingFadeSession=restorePendingFadeSession;// V31: live note (ARM keyboard) — xoa backlog outQueue master FX truoc khi
|
||
// note preview render (idle block ~341ms lam ARM lag ~0.3s).
|
||
// V45.32: preview latency — flush ring bridge sink (audio cu render truoc
|
||
// ~170ms) tai idle->active note-on de note moi nghe ngay, khong phai cho
|
||
// backlog cu chay het. Guard: khong flush khi timeline dang play (tranh gap
|
||
// audio playback dang phat).
|
||
let _timelinePlaying=false;window.__clearMasterFxQueue=function(){_dbgLastClearMs=performance.now();try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:'live'});}catch(e){}try{if(!_timelinePlaying&&window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}};// V32: queueLen master FX worklet hien tai (midi_push log — ARM latency).
|
||
window.__fxRtQueueLen=function(){try{return fxRt&&fxRt.stats?fxRt.stats.queueLen:'n/a';}catch(e){return'n/a';}};// V28: do jank — play_start timestamp; play_rAF / play_first_audio log delta.
|
||
let _dbgPlayStartMs=0;let _dbgLastClearMs=0;let _dbgFirstAudioPending=false;let _dbgFirstAudiblePending=false;// V40: real PDC — freeze playhead theo latency đo được (fx_play_first self-calib).
|
||
let _fxRtPlayheadSnapPending=false;let _fxRtFreezeTimer=null;let _fxRtFreezeTries=0;let _fxRtSeekFreeze=false;let _fxRtSeekFreezeTimer=null;// M21 ph-diag (Bug C): playhead nhay khi PWR master FX toggle. _phDiagArmedUntil
|
||
// (ms epoch) mo cua so log khi __phArmPwr duoc goi; _phArmed kiem tra hieu luc.
|
||
let _phDiagArmedUntil=0;const _phArmed=()=>Date.now()<_phDiagArmedUntil;const _nativeTrackUrls={};const _nativeTrackPending={};// ── MIDI overlap cleanup + polyphony gain (spec midi_polyphony_loudness) ─
|
||
// Dedup: note cùng pitch + cùng start_beat → gộp 1, giữ velocity cao hơn
|
||
// (tránh synth instantiate nhiều voice cùng pitch — spec §II.5).
|
||
// Legato cleanup: note trước cùng pitch kéo dài qua start_beat note sau → cắt
|
||
// 0.01 beats trước Note-On mới (tránh duplicate voice allocation).
|
||
const _cleanNativeNotes=notes=>{const out=[];notes.forEach(function(n){const pitch=n.pitch||60;const start=n.start_beat||0;const dup=out.find(function(o){return o.pitch===pitch&&o.start_beat===start;});if(dup){dup.velocity=Math.max(dup.velocity,n.velocity!=null?n.velocity:0.8);dup.duration_beats=Math.max(dup.duration_beats,n.duration_beats||1);return;}out.push({pitch:pitch,start_beat:start,duration_beats:n.duration_beats||1,velocity:n.velocity!=null?n.velocity:0.8,...(Array.isArray(n.expression_curve)&&n.expression_curve.length?{expression_curve:n.expression_curve}:{})});});out.forEach(function(n){let nextStart=Infinity;out.forEach(function(m){if(m===n||m.pitch!==n.pitch||m.start_beat<=n.start_beat)return;if(m.start_beat<nextStart)nextStart=m.start_beat;});if(nextStart<n.start_beat+n.duration_beats){n.duration_beats=Math.max(0.01,nextStart-n.start_beat-0.01);}});return out;};// Polyphony gain compensation (spec §II.1): scale velocity theo số voice đồng
|
||
// thời tại thời điểm note bắt đầu — Equal Power G = 1/sqrt(N). Solo N=1 → giữ
|
||
// nguyên; chord 8 note → -9dB mỗi voice, tổng peak không vượt trần.
|
||
const _scaleNativePolyphony=notes=>{notes.forEach(function(n){const nStart=n.start_beat||0;let active=0;notes.forEach(function(m){const mEnd=(m.start_beat||0)+(m.duration_beats||1);if((m.start_beat||0)<=nStart&&nStart<mEnd)active++;});if(active>1){const g=1/Math.sqrt(active);n.velocity=(n.velocity!=null?n.velocity:0.8)*g;}});return notes;};const _buildNativeTrackNotes=(track,bpm,baseOffsetSec,notesOverride)=>{const secPerBeat=60.0/(parseInt(bpm)||120);const items=notesOverride?[{startTime:0,notes:notesOverride}]:track&&track.midiItems||[];let trackStartAbs=Infinity;const absNotes=[];items.forEach(function(item){const itemStartAbs=(baseOffsetSec||0)+(item.startTime||0);if(itemStartAbs<trackStartAbs)trackStartAbs=itemStartAbs;(item.notes||[]).forEach(function(n){absNotes.push({pitch:n.pitch||60,start_beat:itemStartAbs/secPerBeat+(n.start_beat||0),duration_beats:n.duration_beats||1,velocity:n.velocity!=null?n.velocity:0.8,...(Array.isArray(n.expression_curve)&&n.expression_curve.length?{expression_curve:n.expression_curve}:{})});});});return{absNotes:_scaleNativePolyphony(_cleanNativeNotes(absNotes)),trackStartAbs:trackStartAbs};};const _nativeTrackKey=(track,bpm,baseOffsetSec,notesOverride)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId||'';const sfId=eng&&eng.soundfont_id||'';const data=_buildNativeTrackNotes(track,bpm,baseOffsetSec,notesOverride);const nk=data.absNotes.map(function(n){return n.pitch+':'+n.start_beat+':'+n.duration_beats+':'+n.velocity+(Array.isArray(n.expression_curve)&&n.expression_curve.length?':'+n.expression_curve.map(function(p){return p.position_ratio+'='+p.value;}).join('~'):'');}).join(',');return[track&&track.id?track.id:'',instrumentId,sfId,eng&&eng.preset_id||'',eng&&eng.preset_path||'',parseFloat(bpm)||120,baseOffsetSec||0,nk].join('|');}catch(e){return'';}};const _renderNativeTrack=(track,bpm,baseOffsetSec,notesOverride)=>{try{const eng=track&&track.synth_engine;const data=_buildNativeTrackNotes(track,bpm,baseOffsetSec,notesOverride);if(!data.absNotes.length)return Promise.resolve(null);if(isSfTrackEngine(eng)){const sfId=eng&&eng.soundfont_id;if(!sfId)return Promise.resolve(null);return window.SonicAPI.soundfontRender({soundfont_id:sfId,bank:eng&&eng.soundfont_bank||0,program:eng&&eng.soundfont_program||0,bpm:parseFloat(bpm)||120,notes:data.absNotes}).then(function(res){if(!res||!res.success||!res.url)return null;return API_BASE_URL+res.url;}).catch(function(){return null;});}const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return Promise.resolve(null);return window.SonicAPI.midiRender({instrument_id:instrumentId,notes:data.absNotes,bpm:parseFloat(bpm)||120,preset_id:eng&&eng.preset_id||undefined,preset_path:eng&&eng.preset_path||undefined,preset_data:eng&&eng.preset_data||undefined}).then(function(res){if(!res||!res.success||!res.url)return null;return API_BASE_URL+res.url;}).catch(function(){return null;});}catch(e){return Promise.resolve(null);}};// Dedupe in-flight render + cache WAV: play lặp/re-schedule trong lúc render
|
||
// đang chạy → chờ cùng promise, không bắn duplicate request (tránh queue sau
|
||
// RENDER_LOCK + render 2 lần cùng nội dung).
|
||
const ensureRenderNativeTrack=(track,bpm,baseOffsetSec,notesOverride)=>{const key=_nativeTrackKey(track,bpm,baseOffsetSec,notesOverride);if(!key)return _renderNativeTrack(track,bpm,baseOffsetSec,notesOverride);if(_nativeTrackUrls[key])return Promise.resolve(_nativeTrackUrls[key]);if(_nativeTrackPending[key])return _nativeTrackPending[key];const p=_renderNativeTrack(track,bpm,baseOffsetSec,notesOverride).then(function(url){if(url)_nativeTrackUrls[key]=url;return url;}).then(function(url){delete _nativeTrackPending[key];return url;});_nativeTrackPending[key]=p;return p;};// Giới hạn decodeAudioData song song (max 3) — nhiều WAV fetch về cùng lúc →
|
||
// main thread nghẽn → UI đơ, Stop không phản hồi.
|
||
let _nativeDecodeActive=0;const _nativeDecodeQueue=[];const _pumpNativeDecodes=()=>{while(_nativeDecodeActive<3&&_nativeDecodeQueue.length){const job=_nativeDecodeQueue.shift();_nativeDecodeActive++;try{job.ctx.decodeAudioData(job.buf,function(ab){_nativeDecodeActive--;job.cb(ab);_pumpNativeDecodes();},function(){_nativeDecodeActive--;job.cb(null);_pumpNativeDecodes();});}catch(e){_nativeDecodeActive--;job.cb(null);_pumpNativeDecodes();}}};const _decodeNativeQueue=(ctx,buf,cb)=>{_nativeDecodeQueue.push({ctx:ctx,buf:buf,cb:cb});_pumpNativeDecodes();};const scheduleNativeTrack=(track,offsetTime,context,destNode,bpm,opts)=>{try{const eng=track&&track.synth_engine;if(!isSfTrackEngine(eng)&&!isVstTrackEngine(eng))return;const baseOffsetSec=opts&&opts.baseOffsetSec||0;const notesOverride=opts&&opts.notesOverride||null;const data=_buildNativeTrackNotes(track,bpm,baseOffsetSec,notesOverride);if(!data.absNotes.length)return;const key=_nativeTrackKey(track,bpm,baseOffsetSec,notesOverride);const epoch=_nativePlayEpoch;const scheduleUrl=function(url){if(!url)return;fetch(url).then(function(r){return r.arrayBuffer();}).then(function(buf){_decodeNativeQueue(context,buf,function(audioBuf){try{if(!audioBuf)return;if(epoch!==_nativePlayEpoch)return;// stale play (stop/rebuild)
|
||
if(opts&&typeof opts.isActive==='function'&&!opts.isActive())return;const ph=opts&&typeof opts.playheadNow==='function'?opts.playheadNow():offsetTime;const src=context.createBufferSource();src.buffer=audioBuf;src.connect(destNode);const trackEndAbs=data.trackStartAbs+audioBuf.duration;if(ph<data.trackStartAbs){src.start(context.currentTime+(data.trackStartAbs-ph),0);}else if(ph<trackEndAbs){src.start(context.currentTime,Math.min(ph-data.trackStartAbs,Math.max(0,audioBuf.duration-0.05)));}else{return;}if(opts&&opts.limitSec&&opts.limitSec<trackEndAbs){src.stop(context.currentTime+Math.max(0.02,opts.limitSec-Math.max(ph,data.trackStartAbs)));}if(opts&&opts.sources&&opts.sources.push)opts.sources.push(src);}catch(e){console.warn('[NativeTrack] schedule decode error:',e);}});}).catch(function(){});};if(key&&_nativeTrackUrls[key]){scheduleUrl(_nativeTrackUrls[key]);return;}ensureRenderNativeTrack(track,bpm,baseOffsetSec,notesOverride).then(function(url){if(!url)return;scheduleUrl(url);});}catch(e){console.warn('[NativeTrack] scheduleNativeTrack error:',e);}};// ── Native VSTi preview (standalone, bridge inactive) ─────────────────────
|
||
// Render 1 note bằng backend pedalboard (VST3 đúng instrument_id/preset) →
|
||
// play WAV — giống hệt nativeSf nhưng qua midiRender; map token riêng.
|
||
const _nativeVstPreviews={};const playNativeVstNote=(track,pitch,velocity,durationMs,startTime,key)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return;const k=key||(track?track.id:'global');const prev=_nativeVstPreviews[k];const token=(prev?prev.token:0)+1;const durationSec=Math.max(0.2,(durationMs||500)/1000);window.SonicAPI.midiRender({instrument_id:instrumentId,notes:[{pitch:pitch,start_beat:0,duration_beats:durationSec*2,velocity:velocity!=null?velocity:0.8}],bpm:120,preset_id:eng&&eng.preset_id||undefined,preset_path:eng&&eng.preset_path||undefined,preset_data:eng&&eng.preset_data||undefined}).then(function(res){if(!res||!res.success||!res.url)return;if(_nativeVstPreviews[k]&&_nativeVstPreviews[k].token!==token)return;// stale
|
||
const audio=new Audio(API_BASE_URL+res.url);_nativeVstPreviews[k]={audio:audio,token:token};const ctx=getAudioContext();const delay=startTime?Math.max(0,(startTime-ctx.currentTime)*1000):0;setTimeout(function(){if(!_nativeVstPreviews[k]||_nativeVstPreviews[k].audio!==audio)return;audio.play().catch(function(){});audio._vstStopTimer&&clearTimeout(audio._vstStopTimer);audio._vstStopTimer=setTimeout(function(){try{audio.pause();}catch(e){}},durationSec*1000+400);},delay);}).catch(function(){});}catch(e){console.warn('[NativeVST] playNativeVstNote error:',e);}};const stopNativeVstNote=key=>{try{const k=key||'global';const prev=_nativeVstPreviews[k];if(!prev)return;prev.token++;try{if(prev.audio){prev.audio.pause();prev.audio.currentTime=0;}}catch(e){}}catch(e){}};const stopAllNativeVstNotes=()=>{try{Object.keys(_nativeVstPreviews).forEach(function(k){stopNativeVstNote(k);});}catch(e){}};// ── Native track pre-render (standalone) ──────────────────────────────────────────────────────────────
|
||
// Render CẢ TRACK 1 request khi project load → play lần đầu không chờ render.
|
||
let _prerenderNativeTracksTimer=null;const prerenderNativeTracks=(tracks,bpm)=>{try{if(!isStandaloneSf())return;if(_prerenderNativeTracksTimer){clearTimeout(_prerenderNativeTracksTimer);_prerenderNativeTracksTimer=null;}_prerenderNativeTracksTimer=setTimeout(function(){_prerenderNativeTracksTimer=null;const seen={};(tracks||[]).forEach(function(track){const eng=track&&track.synth_engine;const native=isStandaloneSf()&&(isVstTrackEngine(eng)||isSfTrackEngine(eng))&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if(!native)return;const key=_nativeTrackKey(track,bpm,0,null);if(!key||seen[key]||_nativeTrackUrls[key])return;seen[key]=true;ensureRenderNativeTrack(track,bpm,0,null).then(function(){});});},800);}catch(e){}};// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
|
||
(function handleSfsDeepLink(){try{const params=new URLSearchParams(window.location.search);const sfsParam=params.get('sfs');if(!sfsParam)return;const decoded=JSON.parse(decodeURIComponent(sfsParam));window.__pendingSfsProject=decoded;// consumed after auth in App
|
||
if(window.history.replaceState){window.history.replaceState({},document.title,window.location.pathname);}}catch(e){window.__pendingSfsProject=null;}})();// Storage for server-side file IDs mapped to track IDs
|
||
let serverFileIdMap={};let audioCtx;let masterBus=null;// { input, compressor, analyser, output, masteringActive, dryInput, dryOutput }
|
||
// Gap 11: last offline master render stats (LUFS/dBTP) cho A/B reference compare
|
||
let _lastMasterStats=null;window.__getLastMasterStats=()=>_lastMasterStats;// Per-track mastering-bypass state (trackId -> bool), kept in sync with the
|
||
// tracks state so ANY audio path can route without holding the track object.
|
||
const trackMasteringBypassMap={};const trackAudioBypassMap={};const trackMidiBypassMap={};// Mastering chain ON? (masterConnected && !isBypassed)
|
||
const masteringChainOn=()=>!!(window.currentMasteringSettings&&window.currentMasteringSettings.masterConnected&&!window.currentMasteringSettings.isBypassed);// ♪ bypass hiệu lực CHỈ khi mastering chain TẮT — khi chain ON, MỌI track
|
||
// (solo/preview/play) PHẢI đi qua mastering chain (user requirement: âm phải
|
||
// qua chain để đủ lớn). Chain OFF → theo ♪ maps như cũ.
|
||
const effMidiBypass=track=>{// PWR master ON → MỌI track PHẢI qua mastering chain (fx_chain_architecture.md
|
||
// §1: track → summing → MASTER FX CHAIN → main out). Nút ♪ chỉ có hiệu lực
|
||
// khi chain TẮT (masterConnected=false) — khi bật, âm MIDI/SF bắt buộc qua
|
||
// chain (user requirement: âm phải qua chain để đủ lớn).
|
||
if(masteringChainOn())return false;// ƯU TIÊN track object (field midiBypass): track object phân biệt đúng
|
||
// context (main track vs section clone — id TRÙNG nhau); map keyed theo id
|
||
// gây collision (clone section ghi đè map của main track cùng id → MIDI
|
||
// section bị route theo ♪ main = bypass sai). Map chỉ là fallback realtime.
|
||
if(track&&track.midiBypass!==undefined)return!!track.midiBypass;if(track&&track.id&&trackMidiBypassMap[track.id]!==undefined)return!!trackMidiBypassMap[track.id];return false;};const effAudioBypass=track=>{// PWR master ON → MỌI track PHẢI qua mastering chain (fx_chain_architecture.md
|
||
// §1). Nút A chỉ có hiệu lực khi chain TẮT — khi bật, audio-clip bắt buộc
|
||
// qua chain.
|
||
if(masteringChainOn())return false;if(track&&track.audioBypass!==undefined)return!!track.audioBypass;if(track&&track.id&&trackAudioBypassMap[track.id]!==undefined)return!!trackAudioBypassMap[track.id];return false;};// Build the dual routing for one track: routeGain -> mastering chain (normal),
|
||
// dryGain -> dry bus (bypass). Gains start at complementary 1/0 values.
|
||
function createMasteringRoute(ctx,track,bus){// Prefer the live bypass map (synced from the tracks state on every render),
|
||
// falling back to the track object — this guarantees the A-button toggle is
|
||
// picked up even if a stale track object is passed in.
|
||
let bypass=false;// Bypass theo LOẠI nội dung track (rules nút A/♪): track MIDI-only → nút ♪
|
||
// quyết định — KHÔNG dùng audioBypass (section track default audioBypass=true
|
||
// → route dry → midi section bị bypass dù ♪ tắt — log: SF → track node
|
||
// (sfEntry) + noteon chạy nhưng âm dry).
|
||
const hasClips=track&&(track.clips&&track.clips.length>0||!!track.buffer);const hasMidi=track&&track.midiItems&&track.midiItems.length>0;if(hasMidi&&!hasClips){// MIDI-only → ♪; ƯU TIÊN field track object (context đúng — id section
|
||
// clone TRÙNG main track; map keyed theo id bị ghi đè chéo → bypass sai).
|
||
bypass=track.midiBypass!==undefined?!!track.midiBypass:track.id&&trackMidiBypassMap[track.id]!==undefined?!!trackMidiBypassMap[track.id]:false;}else if(track){bypass=track.audioBypass!==undefined?!!track.audioBypass:track.id&&trackAudioBypassMap[track.id]!==undefined?!!trackAudioBypassMap[track.id]:false;}// Mastering chain ON → MỌI track qua chain (♪ bị override — user requirement)
|
||
// ⚠️ GỠ 17:00 — override nuốt nút A/♪ (log: audioBypass=true nhưng
|
||
// routeGain=1) — rules hiện tại: NÚT A/♪ quyết định bypass, không phụ thuộc
|
||
// mastering ON.
|
||
// ✅ KHÔI PHỤC override (fx_chain_architecture.md §1): khi PWR master bật,
|
||
// mọi luồng track (audio-clip + MIDI/SF) BẮT BUỘC qua MASTER FX CHAIN —
|
||
// nút A/♪ track chỉ có hiệu lực khi chain TẮT (bypass khi tắt).
|
||
if(masteringChainOn())bypass=false;const routeGain=ctx.createGain();const dryGain=ctx.createGain();const masterDest=bus?bus.input:ctx.destination;const dryDest=bus&&bus.dryInput?bus.dryInput:ctx.destination;routeGain.gain.value=bypass?0:1;dryGain.gain.value=bypass?1:0;routeGain.connect(masterDest);dryGain.connect(dryDest);const routeObj={routeGain,dryGain};routeObj._trackId=track&&track.id;routeObj._bypass=bypass;return routeObj;}// Bypass theo LOẠI nội dung track (dùng chung: createMasteringRoute, sync
|
||
// effect, startTrackPlayback re-apply) — MIDI-only track → nút ♪; có clips →
|
||
// nút A. Section track default audioBypass=true — nếu route đọc audioBypass
|
||
// cho track MIDI → midi bị kéo vào dry (bị bypass) dù ♪ tắt.
|
||
function routeBypassFor(track){// PWR master ON → mọi track qua mastering chain (fx_chain_architecture.md §1);
|
||
// nút A/♪ chỉ có hiệu lực khi chain TẮT.
|
||
if(masteringChainOn()){routeBypassFor._lastType='master-on';return false;}const hasC=track&&(track.clips&&track.clips.length>0||!!track.buffer);const hasM=track&&track.midiItems&&track.midiItems.length>0;if(hasM&&!hasC){// MIDI-only → ♪; ƯU TIÊN field track object (context đúng — id section
|
||
// clone TRÙNG main track; map keyed theo id bị ghi đè chéo → bypass sai).
|
||
routeBypassFor._lastType='midi';return track.midiBypass!==undefined?!!track.midiBypass:track.id&&trackMidiBypassMap[track.id]!==undefined?!!trackMidiBypassMap[track.id]:false;}routeBypassFor._lastType='audio';return track.audioBypass!==undefined?!!track.audioBypass:track.id&&trackAudioBypassMap[track.id]!==undefined?!!trackAudioBypassMap[track.id]:false;}// Live-toggle a route. HARD switch: cancel any pending automation and assign
|
||
// .value directly (instant, cannot be delayed by the automation queue).
|
||
function setMasteringRoute(route,bypass){if(!route)return;const on=!!bypass;try{const ctx=typeof getAudioContext==='function'?getAudioContext():null;if(!ctx)return;const t=ctx.currentTime;route.routeGain.gain.cancelScheduledValues(t);route.dryGain.gain.cancelScheduledValues(t);route.routeGain.gain.value=on?0:1;route.dryGain.gain.value=on?1:0;route._bypass=on;console.log('[Bypass] track',route._trackId,'audioBypass='+on,'→',on?'DRY BUS (bỏ mastering + bỏ track FX)':'MASTERING CHAIN (qua FX + mastering)');}catch(e){console.warn('setMasteringRoute error:',e);}}// Realtime mute/solo: audible linear gain for a track given the full track list
|
||
// of the CURRENT context. Solo semantics: if ANY track is soloed, only soloed
|
||
// tracks are audible; muted tracks are always silent.
|
||
function computeTrackAudibleGain(trackList,track){if(!track)return 0;if(track.muted)return 0;const hasSolo=(trackList||[]).some(t=>t.solo);if(hasSolo&&!track.solo)return 0;const volDb=Number(track.volumeDb);if(isNaN(volDb)||!isFinite(volDb))return 1.0;return volDb<=-50?0:Math.pow(10,volDb/20);}// Apply a gain to a track node's gain with a short crossfade (click-free).
|
||
function setTrackNodeGain(node,gainLinear){if(!node||!node.gainNode||!audioCtx)return;const t=audioCtx.currentTime;const g=typeof gainLinear==='number'&&isFinite(gainLinear)&&!isNaN(gainLinear)?gainLinear:1.0;node.gainNode.gain.cancelScheduledValues(t);node.gainNode.gain.setTargetAtTime(g,t,0.02);}// Track thực sự phát âm MIDI/bridge (cần CC7 để đổi volume/mute/solo): có MIDI
|
||
// items, type MIDI, hoặc instrument (VST3/soundfont qua bridge).
|
||
function isMidiSoundTrack(t){return!!(t&&(t.midiItems&&t.midiItems.length>0||t.type==='MIDI'||t.instrumentId||t.synth_engine&&(t.synth_engine.soundfont_id||t.synth_engine.plugin_path)));}// MIDI volume realtime: CC7 là đường DUY NHẤT đổi âm bridge — bridge trộn MỌI
|
||
// channel vào MỘT shared audio sink (bridgeAudioNode), per-track WebAudio
|
||
// gainNode KHÔNG ảnh hưởng âm bridge. Bridge active → gửi qua
|
||
// SonicMidiRouter.pushEvent (đúng channel allocation của note-on, trackId làm
|
||
// channel); bridge down → SonicSF (WASM local) fallback.
|
||
function sendMidiVolumeCC7(t,list,audible){const volDb=typeof t.volumeDb==='number'&&isFinite(t.volumeDb)?t.volumeDb:0;const volLin=volDb<=-50?0:Math.pow(10,volDb/20);const cc7=audible?Math.max(1,Math.min(127,Math.round(volLin*100))):0;try{if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive&&window.SonicMidiRouter.isBridgeActive()){const isPerc=!!(t.synth_engine&&t.synth_engine.soundfont_bank===128)||!!t.is_percussion;window.SonicMidiRouter.pushEvent({cmd:'CC',channel:t.id,pitch:7,velocity:0,data2:cc7,percussion:isPerc});return;}}catch(e){}try{if(window.SonicSF&&typeof window.SonicSF.controllerChange==='function'){const ch=ensureTrackMidiChannel(t,list);window.SonicSF.controllerChange(ch,7,cc7);}}catch(e){}}// V45.24: PR tab audio DOC LAP voi mute/solo cua MAIN/SECTION — khoi phuc
|
||
// volume track (theo volumeDb, bo qua mute/solo) truoc khi note PR phat.
|
||
// sendMidiVolumeCC7(t, list, true) gui CC7=vol (channel khong con bi mute/solo
|
||
// tu toggle MAIN); setTrackNodeGain phuc hoi gainNode (SonicSF fallback dest).
|
||
function ensureTrackPrAudible(t,list,node){if(!t)return;try{const volDb=Number(t.volumeDb);const volLin=isNaN(volDb)||!isFinite(volDb)?1.0:volDb<=-50?0:Math.pow(10,volDb/20);if(node)setTrackNodeGain(node,volLin);sendMidiVolumeCC7(t,list,true);}catch(e){}}// ── CC11 expression (realtime) ──────────────────────────────────────────
|
||
// Mỗi note có expression_curve → chuỗi CC11 theo grid 16ms. Timer đăng ký
|
||
// vào _cc11Timers để stopAllPlayback hủy sạch; epoch guard (_nativePlayEpoch)
|
||
// chống timer stale sau stop/loop restart (pattern scheduleMidiNoteDispatch).
|
||
let _cc11Timers=new Set();const clearAllCC11Timers=()=>{_cc11Timers.forEach(t=>{try{clearTimeout(t);}catch(e){}});_cc11Timers.clear();};const _cc11ValueAt=(curve,ratio)=>{if(!curve||!curve.length)return 127;if(curve.length===1)return curve[0].value;const pts=curve.slice().sort((a,b)=>a.position_ratio-b.position_ratio);if(ratio<=pts[0].position_ratio)return pts[0].value;if(ratio>=pts[pts.length-1].position_ratio)return pts[pts.length-1].value;for(let i=1;i<pts.length;i++){if(ratio<=pts[i].position_ratio){const a=pts[i-1],b=pts[i];const t=(ratio-a.position_ratio)/(b.position_ratio-a.position_ratio||1);return Math.round(a.value+(b.value-a.value)*t);}}return pts[pts.length-1].value;};// ponytail: duplicate interp cc11ValueAt (component) — consolidate khi curve
|
||
// thành field track-level.
|
||
const EXPRESSION_CC=11;// CC11 expression — TEST 7 da xong, revert 11
|
||
const scheduleNoteExpressionCC11=(track,note,ch,startWallTime,startSec,durSec)=>{try{const curve=Array.isArray(note&¬e.expression_curve)?note.expression_curve:[];if(!curve||!curve.length)return;const epoch=_cc11Epoch;const isPerc=!!(track&&track.synth_engine&&track.synth_engine.soundfont_bank===128)||!!(track&&track.is_percussion);const ctx=getAudioContext();const GRID_MS=16;const n=Math.max(1,Math.ceil(durSec*1000/GRID_MS));const stepMs=durSec*1000/n;for(let i=0;i<=n;i++){const ratio=Math.min(1,i/n);const wallMs=(startWallTime+startSec)*1000+i*stepMs;const delayMs=Math.max(0,wallMs-ctx.currentTime*1000);const val=_cc11ValueAt(curve,ratio);const timer=setTimeout(()=>{if(epoch!==_cc11Epoch)return;if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive&&window.SonicMidiRouter.isBridgeActive()){window.SonicMidiRouter.pushEvent({cmd:'CC',channel:track?track.id:0,pitch:EXPRESSION_CC,velocity:0,data2:val,percussion:isPerc});return;}if(window.SonicSF&&typeof window.SonicSF.controllerChange==='function'){window.SonicSF.controllerChange(ch,EXPRESSION_CC,val);}},delayMs);_cc11Timers.add(timer);}}catch(e){console.warn('[CC11] schedule error:',e);}};// MAIN SESSION end-time (seconds): endtime of the items ON the session's own
|
||
// tracks only — audio clips, MIDI items, and section-item bounds. Section-TAB
|
||
// content is deliberately IGNORED here: when played inside the main session the
|
||
// sub-track items are clamped to their section bounds, so a long SECTION-TAB
|
||
// must NOT stretch the main project. The SECTION-TAB duration is computed
|
||
// separately from ITS OWN tracks (see maxDuration useMemo).
|
||
function computeMainSessionEndTime(tracksList){let max=0;const midiEnd=m=>{if(m&&typeof m.endTime==='number')return m.endTime;return(m&&m.startTime||0)+(m&&typeof m.duration==='number'?m.duration:4);};(tracksList||[]).forEach(t=>{const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default',buffer:t.buffer,startTime:t.startTime||0,speed:t.speed||1.0}]:[];clips.forEach(c=>{if(c.buffer)max=Math.max(max,(c.startTime||0)+c.buffer.duration/(c.speed||1.0));});(t.midiItems||[]).forEach(m=>{max=Math.max(max,midiEnd(m));});(t.sections||[]).forEach(s=>{max=Math.max(max,(s.start||0)+(s.duration||0));});});return max;}// Signature of all item positions/speeds/durations on the given tracks (+ the
|
||
// content inside section tabs). Compared against the signature captured at
|
||
// schedule time: when they differ mid-playback the loop re-schedules so moved
|
||
// items play at their NEW position instead of the stale one.
|
||
function buildItemsSignature(tracksList,tabsList){const tSig=t=>{const clips=(t.clips||[]).map(c=>c.id+':'+Math.round((c.startTime||0)*100)+':'+Math.round((c.speed||1)*100)).join(',');const midi=(t.midiItems||[]).map(m=>m.id+':'+Math.round((m.startTime||0)*100)).join(',');const secs=(t.sections||[]).map(s=>s.id+':'+Math.round((s.start||0)*100)+':'+Math.round((s.duration||0)*100)).join(',');// Track-level default clip (track.buffer): vị trí lưu ở track.startTime —
|
||
// KHÔNG nằm trong t.clips, phải đưa vào signature nếu không kéo default
|
||
// clip sẽ không kích hoạt re-schedule (vẫn phát nội dung cũ).
|
||
const defClip=t.buffer?'def:'+Math.round((t.startTime||0)*100)+':'+Math.round((t.speed||1)*100):'';return clips+'|'+midi+'|'+secs+'|'+defClip;};let s=(tracksList||[]).map(tSig).join(';');s+='##'+(tabsList||[]).map(st=>(st.tracks||[]).map(tSig).join(';')).join('|');return s;}// Reusable time-domain buffers for the imager vectorscope/correlation meter
|
||
// (leftAnalyser/rightAnalyser are fixed at fftSize 2048) — allocated once so
|
||
// the 60fps render loop does not churn the GC.
|
||
const _imagerBufL=new Float32Array(2048);const _imagerBufR=new Float32Array(2048);// Real-time stereo correlation (−1.0 … +1.0) from the master output L/R
|
||
// analysers. +0.5…+1.0 safe, 0…+0.5 caution, <0 phase cancellation (spec §III).
|
||
function computeStereoCorrelation(){if(!masterBus||!masterBus.leftAnalyser||!masterBus.rightAnalyser)return 1.0;try{masterBus.leftAnalyser.getFloatTimeDomainData(_imagerBufL);masterBus.rightAnalyser.getFloatTimeDomainData(_imagerBufR);let sumLR=0,sumL2=0,sumR2=0;for(let i=0;i<2048;i++){sumLR+=_imagerBufL[i]*_imagerBufR[i];sumL2+=_imagerBufL[i]*_imagerBufL[i];sumR2+=_imagerBufR[i]*_imagerBufR[i];}if(sumL2<1e-9||sumR2<1e-9)return 1.0;// silence → neutral
|
||
return Math.max(-1,Math.min(1,sumLR/Math.sqrt(sumL2*sumR2)));}catch(e){return 1.0;}}function makeDistortionCurve(k){const n_samples=44100;const curve=new Float32Array(n_samples);for(let i=0;i<n_samples;++i){const x=i*2/n_samples-1;curve[i]=Math.atan(x*k)/(Math.atan(k)||1);}return curve;}function makeSoftClipCurve(percent){const n=4096;const curve=new Float32Array(n);const p=Math.max(0,Math.min(100,percent));const t=1-p/100*0.8;const inv=1-t;for(let i=0;i<n;++i){const x=i*2/(n-1)-1;const ax=Math.abs(x);if(ax<t)curve[i]=x;else curve[i]=Math.sign(x)*(t+inv*Math.tanh((ax-t)/inv));}return curve;}function applyMasteringSettings(s){console.log('[Mastering] applyMasteringSettings active=',!!(s&&s.masterConnected),'bypass=',!!(s&&s.isBypassed),'eqActive=',!!(s&&s.eqActive),'masterBus=',!!masterBus);if(!masterBus||!audioCtx||!s)return;// getAudioContext() invokes this on EVERY call (stopAll, play, VU, …).
|
||
// Re-applying identical values in rapid bursts is "fast parameter automation"
|
||
// and makes Chromium flag the biquad EQ filters as unstable ("state is bad").
|
||
// Only touch the graph when a value actually changed.
|
||
const sig=[s.masterConnected,s.isBypassed,s.eqActive,s.eqLowGain,s.eqMid1Gain,s.eqMid2Gain,s.eqHighGain,s.imagerActive,s.w1,s.w2,s.w3,s.w4,s.maximizerActive,s.maxGain,s.maxSoftClip,s.maxUpward,s.ceiling,s.compActive,s.compThreshold,s.compRatio,s.compMakeup,s.limActive,s.limThreshold,s.limMode,s.limLookahead,s.excActive,s.excDrive,s.rebalActive,s.rebalMid,s.rebalSide].join('|');if(sig===_lastMasteringSig)return;_lastMasteringSig=sig;// Phase 4.5: master chain DSP chạy trong fx_vst_bridge — WebAudio chỉ
|
||
// I/O. Param đổi → đẩy TOÀN chain về engine qua SET_PARAM.
|
||
if(fxRt&&fxRt.ws&&fxRt.ws.readyState===1){try{fxRtPushAllParams();}catch(e){}}}function initMasterBus(ctx){if(masterBus)return masterBus;// Clamp every filter frequency below Nyquist (0.45 * sampleRate). A biquad
|
||
// with frequency ≥ Nyquist gets NaN coefficients → "BiquadFilterNode: state
|
||
// is bad" → the whole mastering chain outputs silence (IN peak yes, OUT no).
|
||
// Low sample-rate devices (8/11/16 kHz audio drivers) would otherwise break
|
||
// the 10 kHz highshelf / 6 kHz imager crossover filters.
|
||
const maxFilterFreq=(ctx.sampleRate||44100)*0.45;const clampF=v=>Math.max(20,Math.min(v,maxFilterFreq));// Create EQ filters
|
||
const eqLowFilter=ctx.createBiquadFilter();eqLowFilter.type='lowshelf';eqLowFilter.frequency.value=clampF(100);const eqMid1Filter=ctx.createBiquadFilter();eqMid1Filter.type='peaking';eqMid1Filter.frequency.value=clampF(822);eqMid1Filter.Q.value=0.7;const eqMid2Filter=ctx.createBiquadFilter();eqMid2Filter.type='peaking';eqMid2Filter.frequency.value=clampF(3200);eqMid2Filter.Q.value=1.2;const eqHighFilter=ctx.createBiquadFilter();eqHighFilter.type='highshelf';eqHighFilter.frequency.value=clampF(10000);// Create Stereo Imager nodes
|
||
const imagerInput=ctx.createGain();const imagerOutput=ctx.createGain();// Imager Crossover Filters
|
||
const f1_lp=ctx.createBiquadFilter();f1_lp.type='lowpass';f1_lp.frequency.value=clampF(100);const f2_hp=ctx.createBiquadFilter();f2_hp.type='highpass';f2_hp.frequency.value=clampF(100);const f2_lp=ctx.createBiquadFilter();f2_lp.type='lowpass';f2_lp.frequency.value=clampF(1000);const f3_hp=ctx.createBiquadFilter();f3_hp.type='highpass';f3_hp.frequency.value=clampF(1000);const f3_lp=ctx.createBiquadFilter();f3_lp.type='lowpass';f3_lp.frequency.value=clampF(6000);const f4_hp=ctx.createBiquadFilter();f4_hp.type='highpass';f4_hp.frequency.value=clampF(6000);const split1=ctx.createChannelSplitter(2);const split2=ctx.createChannelSplitter(2);const split3=ctx.createChannelSplitter(2);const split4=ctx.createChannelSplitter(2);const merge1=ctx.createChannelMerger(2);const merge2=ctx.createChannelMerger(2);const merge3=ctx.createChannelMerger(2);const merge4=ctx.createChannelMerger(2);const gainLL1=ctx.createGain();const gainRL1=ctx.createGain();const gainLR1=ctx.createGain();const gainRR1=ctx.createGain();const gainLL2=ctx.createGain();const gainRL2=ctx.createGain();const gainLR2=ctx.createGain();const gainRR2=ctx.createGain();const gainLL3=ctx.createGain();const gainRL3=ctx.createGain();const gainLR3=ctx.createGain();const gainRR3=ctx.createGain();const gainLL4=ctx.createGain();const gainRL4=ctx.createGain();const gainLR4=ctx.createGain();const gainRR4=ctx.createGain();// Connections for Imager DSP
|
||
imagerInput.connect(f1_lp);imagerInput.connect(f2_hp);f2_hp.connect(f2_lp);imagerInput.connect(f3_hp);f3_hp.connect(f3_lp);imagerInput.connect(f4_hp);// Band 1
|
||
f1_lp.connect(split1);split1.connect(gainLL1,0);split1.connect(gainLR1,0);split1.connect(gainRL1,1);split1.connect(gainRR1,1);gainLL1.connect(merge1,0,0);gainRL1.connect(merge1,0,0);gainLR1.connect(merge1,0,1);gainRR1.connect(merge1,0,1);merge1.connect(imagerOutput);// Band 2
|
||
f2_lp.connect(split2);split2.connect(gainLL2,0);split2.connect(gainLR2,0);split2.connect(gainRL2,1);split2.connect(gainRR2,1);gainLL2.connect(merge2,0,0);gainRL2.connect(merge2,0,0);gainLR2.connect(merge2,0,1);gainRR2.connect(merge2,0,1);merge2.connect(imagerOutput);// Band 3
|
||
f3_lp.connect(split3);split3.connect(gainLL3,0);split3.connect(gainLR3,0);split3.connect(gainRL3,1);split3.connect(gainRR3,1);gainLL3.connect(merge3,0,0);gainRL3.connect(merge3,0,0);gainLR3.connect(merge3,0,1);gainRR3.connect(merge3,0,1);merge3.connect(imagerOutput);// Band 4
|
||
f4_hp.connect(split4);split4.connect(gainLL4,0);split4.connect(gainLR4,0);split4.connect(gainRL4,1);split4.connect(gainRR4,1);gainLL4.connect(merge4,0,0);gainRL4.connect(merge4,0,0);gainLR4.connect(merge4,0,1);gainRR4.connect(merge4,0,1);merge4.connect(imagerOutput);// Maximizer nodes
|
||
const maximizerBoostGain=ctx.createGain();const maximizerSoftClipper=ctx.createWaveShaper();// NEVER leave the curve null: a WaveShaper with a null/identity curve can
|
||
// output silence in some engines, which would kill the whole mastering path.
|
||
// Use an explicit linear identity table for passthrough.
|
||
maximizerSoftClipper.curve=new Float32Array([-1,1]);maximizerSoftClipper.oversample='4x';const upwardCompressor=ctx.createDynamicsCompressor();upwardCompressor.threshold.value=-30;upwardCompressor.knee.value=10;upwardCompressor.ratio.value=4;upwardCompressor.attack.value=0.01;upwardCompressor.release.value=0.1;const upwardGain=ctx.createGain();upwardGain.gain.value=0.0;const upwardSummingGain=ctx.createGain();maximizerBoostGain.connect(maximizerSoftClipper);maximizerSoftClipper.connect(upwardSummingGain);maximizerBoostGain.connect(upwardCompressor);upwardCompressor.connect(upwardGain);upwardGain.connect(upwardSummingGain);// Brickwall Limiter tại ceiling: WaveShaper HARD CLIP (slope 1 — không boost,
|
||
// clip chính xác tại ceiling) — KHÔNG DynamicsCompressor (NaN trên bass
|
||
// transient → chain state-bad → CÂM + stuck).
|
||
const maximizerCompressor=ctx.createWaveShaper();maximizerCompressor.oversample='2x';let _maxCeil=-0.1;const _buildMaxCurve=db=>{const c=Math.pow(10,Math.max(-60,Math.min(0,db))/20);const _c=new Float32Array(4096);for(let _i=0;_i<4096;_i++){const _x=_i/4095*2-1;_c[_i]=Math.max(-c,Math.min(c,_x));}maximizerCompressor.curve=_c;_maxCeil=db;};_buildMaxCurve(-0.1);maximizerCompressor._setCeiling=db=>{if(db!==_maxCeil)_buildMaxCurve(db);};upwardSummingGain.connect(maximizerCompressor);// ── Bus Compressor module (mastering_expand.md §II.2) ──
|
||
const compInput=ctx.createGain();const compNode=ctx.createDynamicsCompressor();compNode.threshold.value=-16;compNode.knee.value=8;compNode.ratio.value=3;compNode.attack.value=0.02;compNode.release.value=0.25;const compMakeup=ctx.createGain();compMakeup.gain.value=1.0;const compOutput=ctx.createGain();compInput.connect(compNode);compNode.connect(compMakeup);compMakeup.connect(compOutput);// ── Brickwall Limiter module (WaveShaper tanh soft-clip — KHÔNG
|
||
// DynamicsCompressor: NaN trên bass transient → chain stuck) ──
|
||
const limInput=ctx.createGain();const limNode=ctx.createWaveShaper();limNode.oversample='2x';let _limLastThresh=null;const _buildLimCurve=db=>{const tLin=Math.pow(10,Math.max(-24,Math.min(0,db))/20);const k=1/Math.max(0.02,tLin);const _c=new Float32Array(4096);const _tk=Math.tanh(k);for(let _i=0;_i<4096;_i++){const _x=_i/4095*2-1;_c[_i]=Math.tanh(_x*k)/_tk;}limNode.curve=_c;_limLastThresh=db;};_buildLimCurve(-1.0);limNode._setThreshold=db=>{if(db!==_limLastThresh)_buildLimCurve(db);};const limOutput=ctx.createGain();limInput.connect(limNode);limNode.connect(limOutput);// ── Harmonic Exciter module (WaveShaper saturator + high-pass, dry/wet) ──
|
||
const excInput=ctx.createGain();const excHp=ctx.createBiquadFilter();excHp.type='highpass';excHp.frequency.value=clampF(2000);excHp.Q.value=0.7;const excShaper=ctx.createWaveShaper();excShaper.curve=makeDistortionCurve(3);excShaper.oversample='4x';const excDry=ctx.createGain();excDry.gain.value=1.0;const excWet=ctx.createGain();excWet.gain.value=0.0;const excOutput=ctx.createGain();excInput.connect(excDry);excDry.connect(excOutput);excInput.connect(excHp);excHp.connect(excShaper);excShaper.connect(excWet);excWet.connect(excOutput);// ── Master Rebalance module (M/S gains via L/R crossfeed) ──
|
||
const rebalInput=ctx.createGain();const rebalSplit=ctx.createChannelSplitter(2);const rebalMerge=ctx.createChannelMerger(2);const rebalOutput=ctx.createGain();const gLLr=ctx.createGain();const gRLr=ctx.createGain();const gLRr=ctx.createGain();const gRRr=ctx.createGain();rebalInput.connect(rebalSplit);rebalSplit.connect(gLLr,0);rebalSplit.connect(gRLr,0);rebalSplit.connect(gLRr,1);rebalSplit.connect(gRRr,1);gLLr.connect(rebalMerge,0,0);gRLr.connect(rebalMerge,0,0);gLRr.connect(rebalMerge,0,1);gRRr.connect(rebalMerge,0,1);rebalMerge.connect(rebalOutput);// Setup Analysers
|
||
const inputAnalyser=ctx.createAnalyser();inputAnalyser.fftSize=2048;const outputAnalyser=ctx.createAnalyser();outputAnalyser.fftSize=2048;// Global fader / output
|
||
const output=ctx.createGain();output.gain.value=1.0;// Crossfade master-FX activation (het click khi bat/tat master chain FX):
|
||
// path mac dinh outputAnalyser -> masterFxDryGain(1) -> pdcDelay -> output;
|
||
// nhanh FX noi vao masterFxGain(0) -> output. fxRtStart/fxRtStop ramp 2 gain
|
||
// nay thay vi hard-rewire graph (dry path luon noi san — no click, no gap
|
||
// trong luc worklet FILL ~86ms).
|
||
const masterFxDryGain=ctx.createGain();masterFxDryGain.gain.value=1.0;const masterFxGain=ctx.createGain();masterFxGain.gain.value=0.0;// Per-track mastering-bypass dry bus: tracks with bypass ON feed into
|
||
// dryInput -> dryOutput -> output, skipping the mastering modules
|
||
// (EQ / Imager / Maximizer) while still passing the master volume fader
|
||
// and the master output metering.
|
||
const dryInput=ctx.createGain();const dryOutput=ctx.createGain();dryInput.connect(dryOutput);// Gap 8 (PDC realtime): dry path bu delay master chain realtime — worklet
|
||
// round-trip ~2 block + latency VST3 (bridge bao qua WS {cmd:'latency'}).
|
||
// Bypassed tracks align voi mastered (fxRtApplyPdc cap nhat delayTime).
|
||
const pdcDelay=ctx.createDelay(5.0);pdcDelay.delayTime.value=0;dryOutput.connect(pdcDelay);pdcDelay.connect(output);const analyser=ctx.createAnalyser();analyser.fftSize=256;const leftAnalyser=ctx.createAnalyser();leftAnalyser.fftSize=2048;const rightAnalyser=ctx.createAnalyser();rightAnalyser.fftSize=2048;const splitter=ctx.createChannelSplitter(2);splitter.connect(leftAnalyser,0);splitter.connect(rightAnalyser,1);// Master L/R volume + MUTE stage (spec §2: L/R fader + LINK + MUTE master).
|
||
// output -> volSplit -> volLGain/volRGain -> volMerge -> muteGain -> analyser;
|
||
// meters (splitter) lay sau muteGain -> MUTE that (VU ve 0).
|
||
const volSplit=ctx.createChannelSplitter(2);const volLGain=ctx.createGain();volLGain.gain.value=1.0;const volRGain=ctx.createGain();volRGain.gain.value=1.0;const volMerge=ctx.createChannelMerger(2);const muteGain=ctx.createGain();muteGain.gain.value=1.0;output.connect(volSplit);volSplit.connect(volLGain,0);volSplit.connect(volRGain,1);volLGain.connect(volMerge,0,0);volRGain.connect(volMerge,0,1);volMerge.connect(muteGain);masterBus={input:ctx.createGain(),compressor:ctx.createDynamicsCompressor(),analyser,output,masterFxDryGain,masterFxGain,masteringActive:false,dryInput,dryOutput,pdcDelay,splitter,volSplit,volLGain,volRGain,volMerge,muteGain,// Analysers for metering
|
||
inputAnalyser,outputAnalyser,leftAnalyser,rightAnalyser,// Mastering nodes
|
||
eqLowFilter,eqMid1Filter,eqMid2Filter,eqHighFilter,imagerInput,imagerOutput,gainLL1,gainRL1,gainLR1,gainRR1,gainLL2,gainRL2,gainLR2,gainRR2,gainLL3,gainRL3,gainLR3,gainRR3,gainLL4,gainRL4,gainLR4,gainRR4,maximizerBoostGain,maximizerSoftClipper,upwardCompressor,upwardGain,upwardSummingGain,maximizerCompressor,// Extension modules (mastering_expand.md §II.2)
|
||
compInput,compNode,compMakeup,compOutput,limInput,limNode,limOutput,excInput,excHp,excShaper,excDry,excWet,excOutput,rebalInput,rebalSplit,rebalMerge,gLLr,gRLr,gLRr,gRRr,rebalOutput};// Connect EQ chain (internal — the module BOUNDARIES are wired dynamically
|
||
// by rebuildMasteringGraph so modules can be reordered on the CHAIN bar)
|
||
eqLowFilter.connect(eqMid1Filter);eqMid1Filter.connect(eqMid2Filter);eqMid2Filter.connect(eqHighFilter);// Setup default non-mastered routing (KHÔNG compressor mặc định trong path):
|
||
// input -> inputAnalyser -> outputAnalyser -> output -> analyser -> destination
|
||
// Compressor mặc định (ratio 12, threshold -24 — LUÔN-ON) vừa (a) pump-down
|
||
// tín hiệu → âm nhỏ/méo, vừa (b) phát NaN khi gặp bass transient → 11 biquad
|
||
// "state is bad" → CÂM + stuck. Mastering chain có comp/lim module riêng khi bật.
|
||
masterBus.input.connect(masterBus.inputAnalyser);masterBus.inputAnalyser.connect(masterBus.outputAnalyser);masterBus.outputAnalyser.connect(masterBus.masterFxDryGain);masterBus.masterFxDryGain.connect(masterBus.pdcDelay);// Wet bus: CHI nhan node fxRt (fxRtStart noi) — khong noi dry truc tiep vao
|
||
// masterFxGain (no = dry + processed = play double khi PWR bat).
|
||
masterBus.masterFxGain.connect(masterBus.output);masterBus.muteGain.connect(masterBus.analyser);masterBus.muteGain.connect(masterBus.splitter);masterBus.analyser.connect(ctx.destination);window.masterBus=masterBus;try{_mdiagStart();setTimeout(()=>_mdiagSample('load1'),1000);setTimeout(()=>_mdiagSample('load5'),5000);}catch(e){}// Apply mastering once when the chain is first created (and on the
|
||
// masteringSettings effect for subsequent changes — see useEffect).
|
||
if(window.currentMasteringSettings){try{// Reset sig-cache: chain MỚI (biquad/maximizer node mới) giữ giá trị
|
||
// INIT (gain 0, width 100…) — applyMasteringSettings early-return vì
|
||
// _lastMasteringSig không đổi (module-level, persist qua recreation) →
|
||
// chain FLAT ("spectrum hiển thị nhưng không xử lí âm thanh").
|
||
_lastMasteringSig=null;toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected,window.currentMasteringSettings.isBypassed);applyMasteringSettings(window.currentMasteringSettings);}catch(e){console.warn('initMasterBus apply mastering error:',e);}}// M23 Bug B fix: bus vua tao/rebuild — ap lai master fader (state set tu luc
|
||
// project load khi bus chua ton tai).
|
||
try{if(typeof fxMasterVolApply==='function')fxMasterVolApply();}catch(e){}return masterBus;}function setMasterVolume(linear){if(masterBus)masterBus.output.gain.setValueAtTime(linear,audioCtx.currentTime);}// Gap 7: master volume automation lane — schedule envelope tren master output
|
||
// gain (dB tuyen tinh, clamp -30..+3 nhu render_engine._apply_master_volume_automation).
|
||
// Trang thai tu React qua window.__masterAutoOn/__masterAutoVol (pattern
|
||
// window.__currentZoom). playheadSec = vi tri timeline giay luc bat dau nghe.
|
||
function applyMasterAutomationSchedule(playheadSec){const bus=masterBus;if(!bus||!bus.output||!bus.output.gain)return;const ctx=audioCtx||getAudioContext();const now=ctx.currentTime;const env=window.__masterAutoOn&&Array.isArray(window.__masterAutoVol)?window.__masterAutoVol.filter(p=>p&&typeof p.time==='number'&&typeof p.db==='number').slice().sort((a,b)=>a.time-b.time):[];bus.output.gain.cancelScheduledValues(now);if(!env.length){bus.output.gain.setValueAtTime(1,now);return;}const ph=playheadSec||0;let idx=0;while(idx<env.length&&env[idx].time<ph)idx++;let initDb;if(idx===0)initDb=env[0].db;else if(idx>=env.length)initDb=env[env.length-1].db;else{const a=env[idx-1],b=env[idx];const span=b.time-a.time||1;initDb=a.db+(b.db-a.db)*Math.max(0,Math.min(1,(ph-a.time)/span));}bus.output.gain.setValueAtTime(Math.pow(10,Math.max(-30,Math.min(3,initDb))/20),now);for(let k=idx;k<env.length;k++){const t=now+(env[k].time-ph);if(t>now)bus.output.gain.linearRampToValueAtTime(Math.pow(10,Math.max(-30,Math.min(3,env[k].db))/20),t);}}// Master L/R faders + LINK + MUTE - per-channel gain stage (output -> volSplit ->
|
||
// volLGain/volRGain -> volMerge -> muteGain -> analyser). time===undefined ->
|
||
// live (smoothing); co time -> offline (setValueAtTime). -50dB ~ -inf.
|
||
function applyMasterVolumesTo(bus,volL,volR,muted,time){if(!bus||!bus.volLGain||!bus.volRGain||!bus.muteGain)return;var gl=volL<=-50?0:Math.pow(10,volL/20);var gr=volR<=-50?0:Math.pow(10,volR/20);if(time===undefined){var now=audioCtx.currentTime;bus.volLGain.gain.setTargetAtTime(gl,now,0.01);bus.volRGain.gain.setTargetAtTime(gr,now,0.01);bus.muteGain.gain.setTargetAtTime(muted?0:1,now,0.01);}else{bus.volLGain.gain.setValueAtTime(gl,time);bus.volRGain.gain.setValueAtTime(gr,time);bus.muteGain.gain.setValueAtTime(muted?0:1,time);}}// M23 Bug B fix: master fader state global + ap lai khi masterBus (re)create —
|
||
// volume L/R/MUTE phai LUON la MAIN OUT, khong chi khi Mixer panel mo.
|
||
window.__masterVolState=null;function fxMasterVolApply(){try{const _st=window.__masterVolState;if(!_st)return;const _b=masterBus||window.masterBus;if(_b&&_b.volLGain)applyMasterVolumesTo(_b,_st.volL,_st.volR,!!_st.muted);}catch(e){}}let _lastMasteringActive=null;let _lastMasteringSig=null;let _lastChainSig=null;function chainSignature(chainArray){return(chainArray||[]).map(m=>(m.type||'')+(m.active?'1':'0')).join(',');}// ── Track FX module factory (mastering_expand.md §II.4) ──
|
||
// The SAME module DSP used in the mastering chain, instantiated per-track for
|
||
// the [FX] button on track strips. Returns { input, output, nodes, dispose }.
|
||
// ── Track FX module factory (mastering_expand.md §II.4 / unified_fx_rack_panel.md) ──
|
||
// The SAME module DSP used in the mastering chain, instantiated per-track for
|
||
// the [FX] button on track strips. `params` are stored per chain entry so the
|
||
// unified FX Rack panel edits them declaratively. Returns { input, output, nodes }.
|
||
// ── Parametric / Graphic EQ Pro module (graphic_EQ_interactive_module.md) ──
|
||
// Logarithmic freq mapping 20Hz–20kHz, ±24dB gain, RBJ biquad response math.
|
||
const EQPRO_F_MIN=20,EQPRO_F_MAX=20000,EQPRO_MAX_DB=24,EQPRO_MAX_BANDS=8;const EQPRO_BAND_COLORS=[{stroke:'#ef4444',fill:'rgba(239,68,68,0.16)',badge:'#ef4444'},{stroke:'#f59e0b',fill:'rgba(245,158,11,0.16)',badge:'#f59e0b'},{stroke:'#a855f7',fill:'rgba(168,85,247,0.16)',badge:'#a855f7'},{stroke:'#38bdf8',fill:'rgba(56,189,248,0.16)',badge:'#38bdf8'},{stroke:'#10b981',fill:'rgba(16,185,129,0.16)',badge:'#10b981'},{stroke:'#ec4899',fill:'rgba(236,72,153,0.16)',badge:'#ec4899'}];const EQPRO_DEFAULT_BANDS=[{type:'lowshelf',freq:80,gain:0,q:0.7,active:true},{type:'peaking',freq:250,gain:0,q:1.0,active:true},{type:'peaking',freq:1000,gain:0,q:1.0,active:true},{type:'peaking',freq:4000,gain:0,q:1.0,active:true},{type:'highshelf',freq:10000,gain:0,q:0.7,active:true}];function eqproFreqToX(f,w){return w*(Math.log10(f/EQPRO_F_MIN)/Math.log10(EQPRO_F_MAX/EQPRO_F_MIN));}function eqproClamp(v,lo,hi){return typeof v==='number'&&isFinite(v)?Math.min(hi,Math.max(lo,v)):lo;}function eqproXToFreq(x,w){return EQPRO_F_MIN*Math.pow(EQPRO_F_MAX/EQPRO_F_MIN,eqproClamp(x,0,w)/w);}function eqproGainToY(g,h){return h/2-g*(h/2/EQPRO_MAX_DB);}function eqproYToGain(y,h){return(h/2-y)*(EQPRO_MAX_DB/(h/2));}function eqproQToWing(q){return Math.max(12,Math.min(80,110/Math.sqrt(q)));}function eqproWingToQ(offset){return eqproClamp(parseFloat(Math.pow(110/Math.max(12,offset),2).toFixed(2)),0.1,18);}// Filter shapes with a real Gain control (dB). highpass/lowpass/notch/bandpass
|
||
// ignore gain in WebAudio — the UI keeps them meaningful by anchoring the node
|
||
// at the natural −3 dB / notch-dip point of their response curve.
|
||
function eqproBandHasGain(type){return type==='peaking'||type==='lowshelf'||type==='highshelf';}// Vertical dB position where the node handle sits for a band (matches the curve).
|
||
function eqproNodeDb(b){if(eqproBandHasGain(b.type))return b.gain;if(b.type==='notch')return-30;if(b.type==='bandpass')return 0;return-3;// highpass / lowpass at fc
|
||
}// RBJ Audio-EQ-Cookbook biquad magnitude (dB) — analytic band response used for
|
||
// band fills + summed master curve rendering (no AudioContext needed).
|
||
function eqproBiquadMagDb(type,f,f0,gainDb,q,fs){const w0=2*Math.PI*f0/fs,cw=Math.cos(w0),sw=Math.sin(w0);const alpha=sw/(2*Math.max(0.05,q));const A=Math.pow(10,eqproClamp(gainDb||0,-24,24)/40);let b0,b1,b2,a0,a1,a2;if(type==='peaking'){b0=1+alpha*A;b1=-2*cw;b2=1-alpha*A;a0=1+alpha/A;a1=-2*cw;a2=1-alpha/A;}else if(type==='lowshelf'){b0=A*(A+1-(A-1)*cw+2*Math.sqrt(A)*alpha);b1=2*A*(A-1-(A+1)*cw);b2=A*(A+1-(A-1)*cw-2*Math.sqrt(A)*alpha);a0=A+1+(A-1)*cw+2*Math.sqrt(A)*alpha;a1=-2*(A-1+(A+1)*cw);a2=A+1+(A-1)*cw-2*Math.sqrt(A)*alpha;}else if(type==='highshelf'){b0=A*(A+1+(A-1)*cw+2*Math.sqrt(A)*alpha);b1=-2*A*(A-1+(A+1)*cw);b2=A*(A+1+(A-1)*cw-2*Math.sqrt(A)*alpha);a0=A+1-(A-1)*cw+2*Math.sqrt(A)*alpha;a1=2*(A-1-(A+1)*cw);a2=A+1-(A-1)*cw-2*Math.sqrt(A)*alpha;}else if(type==='highpass'){b0=(1+cw)/2;b1=-(1+cw);b2=(1+cw)/2;a0=1+alpha;a1=-2*cw;a2=1-alpha;}else if(type==='lowpass'){b0=(1-cw)/2;b1=1-cw;b2=(1-cw)/2;a0=1+alpha;a1=-2*cw;a2=1-alpha;}else if(type==='notch'){b0=1;b1=-2*cw;b2=1;a0=1+alpha;a1=-2*cw;a2=1-alpha;}else{// bandpass (constant 0dB peak)
|
||
b0=alpha;b1=0;b2=-alpha;a0=1+alpha;a1=-2*cw;a2=1-alpha;}const w=2*Math.PI*f/fs,cw1=Math.cos(w),cw2=Math.cos(2*w);const num=b0*b0+b1*b1+b2*b2+2*(b0*b1+b1*b2)*cw1+2*b0*b2*cw2;const den=a0*a0+a1*a1+a2*a2+2*(a0*a1+a1*a2)*cw1+2*a0*a2*cw2;return 10*Math.log10(Math.max(1e-10,num/Math.max(1e-10,den)));}// DSP module: serial BiquadFilterNode cascade (input → band1 → … → bandN →
|
||
// output), + post-module analyser for the realtime FFT spectrum overlay.
|
||
function createEqProModule(ctx,params){const input=ctx.createGain();const output=ctx.createGain();const analyser=ctx.createAnalyser();analyser.fftSize=2048;output.connect(analyser);const bands=params&&Array.isArray(params.bands)?JSON.parse(JSON.stringify(params.bands)):JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS));const filters=[];let amount=params&¶ms.amount!==undefined?params.amount:100;const rebuild=()=>{try{input.disconnect();}catch(e){}filters.forEach(f=>{try{f.disconnect();}catch(e){}});filters.length=0;let tail=input;const maxF=(ctx.sampleRate||44100)*0.45;bands.forEach(b=>{const f=ctx.createBiquadFilter();f.type=b.type||'peaking';f.frequency.value=eqproClamp(b.freq!==undefined?b.freq:1000,EQPRO_F_MIN,Math.min(EQPRO_F_MAX,maxF));f.Q.value=eqproClamp(b.q!==undefined?b.q:1,0.1,18);f.gain.value=b.active!==false?(b.gain||0)*amount/100:0;tail.connect(f);tail=f;filters.push(f);});tail.connect(output);};rebuild();const setBand=(i,patch)=>{const b=bands[i];if(!b)return;Object.assign(b,patch);const f=filters[i];if(!f)return;const now=ctx.currentTime;const maxF=(ctx.sampleRate||44100)*0.45;if(patch.type!==undefined)f.type=patch.type;if(patch.freq!==undefined)f.frequency.setValueAtTime(eqproClamp(b.freq,EQPRO_F_MIN,Math.min(EQPRO_F_MAX,maxF)),now);if(patch.q!==undefined)f.Q.setValueAtTime(eqproClamp(b.q,0.1,18),now);if(patch.gain!==undefined||patch.active!==undefined)f.gain.setValueAtTime(b.active!==false?(b.gain||0)*amount/100:0,now);};const setAmount=a=>{amount=eqproClamp(a,0,200);const now=ctx.currentTime;filters.forEach((f,i)=>{const b=bands[i];if(b)f.gain.setValueAtTime(b.active!==false?(b.gain||0)*amount/100:0,now);});};// Replace the internal band model + rebuild DSP immediately (add/delete/reset
|
||
// from the UI) — guarantees the audible result matches the added bands at once.
|
||
const syncBands=newBands=>{bands.length=0;(newBands||[]).forEach(b=>bands.push({...b}));rebuild();};return{type:'eqpro',input,output,analyser,get bands(){return bands;},get filters(){return filters;},get amount(){return amount;},setBand,setAmount,rebuild,syncBands,destroy(){try{input.disconnect();output.disconnect();analyser.disconnect();}catch(e){}}};}function createTrackFxModule(type,ctx,params){const num=(v,def)=>{const n=Number(v);return isFinite(n)&&!isNaN(n)?n:def;};const maxF=(ctx.sampleRate||44100)*0.45;const clampF=v=>Math.max(20,Math.min(v,maxF));const input=ctx.createGain();const output=ctx.createGain();const p=params||{};let nodes={};if(type==='compressor'){const comp=ctx.createDynamicsCompressor();comp.threshold.value=num(p.threshold,-16);comp.knee.value=8;comp.ratio.value=num(p.ratio,3);comp.attack.value=0.02;comp.release.value=0.25;const makeup=ctx.createGain();makeup.gain.value=Math.pow(10,num(p.makeup,0)/20);input.connect(comp);comp.connect(makeup);makeup.connect(output);nodes={comp,makeup};}else if(type==='limiter'){// Brickwall Limiter bằng WaveShaper tanh soft-clip — KHÔNG DynamicsCompressor:
|
||
// Chromium compressor phát NaN với bass transient mạnh (pitch thấp + vel cao
|
||
// đồng loạt) → NaN vào master chain → 11 biquad "state is bad" → CÂM + stuck.
|
||
const shaper=ctx.createWaveShaper();shaper.oversample='2x';const ceilingDb=Math.min(0,num(p.ceiling,-1.0));const threshLin=Math.pow(10,ceilingDb/20);const k=1/Math.max(0.02,threshLin);const _curve=new Float32Array(4096);const _tanhK=Math.tanh(k);for(let _i=0;_i<4096;_i++){const _x=_i/4095*2-1;_curve[_i]=Math.tanh(_x*k)/_tanhK;}shaper.curve=_curve;const makeup=ctx.createGain();makeup.gain.value=1.0;input.connect(shaper);shaper.connect(makeup);makeup.connect(output);nodes={shaper,makeup};}else if(type==='exciter'){const hp=ctx.createBiquadFilter();hp.type='highpass';hp.frequency.value=clampF(2000);hp.Q.value=0.7;const shaper=ctx.createWaveShaper();shaper.curve=makeDistortionCurve(3);shaper.oversample='4x';const dry=ctx.createGain();dry.gain.value=1.0;const wet=ctx.createGain();wet.gain.value=num(p.drive,40)/100*0.6;input.connect(dry);dry.connect(output);input.connect(hp);hp.connect(shaper);shaper.connect(wet);wet.connect(output);nodes={hp,shaper,dry,wet};}else if(type==='rebalance'){const split=ctx.createChannelSplitter(2);const merge=ctx.createChannelMerger(2);const gLL=ctx.createGain(),gRL=ctx.createGain(),gLR=ctx.createGain(),gRR=ctx.createGain();const midLin=Math.pow(10,num(p.mid,0)/20);const sideLin=Math.pow(10,num(p.side,0)/20);const a=(midLin+sideLin)/2,b=(midLin-sideLin)/2;gLL.gain.value=a;gRR.gain.value=a;gRL.gain.value=b;gLR.gain.value=b;input.connect(split);split.connect(gLL,0);split.connect(gRL,0);split.connect(gLR,1);split.connect(gRR,1);gLL.connect(merge,0,0);gRL.connect(merge,0,0);gLR.connect(merge,0,1);gRR.connect(merge,0,1);merge.connect(output);nodes={gLL,gRL,gLR,gRR};}else if(type==='eqpro'){return createEqProModule(ctx,params);}else if(type==='vst3'){// VST FX = xử lý NGOÀI (fx_vst_bridge --render-fx khi export) — trong
|
||
// WebAudio graph chỉ là pass-through (không thêm DSP): âm track đi thẳng,
|
||
// đúng vị trí chain.
|
||
input.connect(output);nodes={passthrough:input};}else{// 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB)
|
||
const f1=ctx.createBiquadFilter();f1.type='lowshelf';f1.frequency.value=clampF(100);const f2=ctx.createBiquadFilter();f2.type='peaking';f2.frequency.value=clampF(800);f2.Q.value=0.7;const f3=ctx.createBiquadFilter();f3.type='peaking';f3.frequency.value=clampF(3200);f3.Q.value=1.2;const f4=ctx.createBiquadFilter();f4.type='highshelf';f4.frequency.value=clampF(10000);f1.gain.value=num(p.g1,0);f2.gain.value=num(p.g2,0);f3.gain.value=num(p.g3,0);f4.gain.value=num(p.g4,0);input.connect(f1);f1.connect(f2);f2.connect(f3);f3.connect(f4);f4.connect(output);nodes={f1,f2,f3,f4};}return{input,output,nodes,type};}// Offline-export helper: builds a track node IDENTICAL to playback (track FX
|
||
// chain + mastering route + legacy chorus/reverb) but inside an OfflineAudioContext,
|
||
// keyed in a LOCAL map so the live graph is never touched. Audio clips are then
|
||
// scheduled through node.gainNode, and the offline master bus applies the
|
||
// mastering chain — the exported file therefore matches what you hear.
|
||
function buildOfflineTrackNode(track,ctx,nodeMap,opts){if(!track||nodeMap[track.id])return nodeMap[track.id]||null;// directOut: render track RIÊNG (track FX chain, chưa qua mastering bus) —
|
||
// dùng cho export có VST FX: mỗi track render solo → áp VST chain qua
|
||
// fx_vst_bridge → cộng mix → mastering 1 lần trên tổng.
|
||
const directOut=!!(opts&&opts.directOut);const gainNode=ctx.createGain();const volDb=track.volumeDb??0;gainNode.gain.setValueAtTime(volDb<=-50?0:Math.pow(10,volDb/20),0);const pannerNode=ctx.createStereoPanner();pannerNode.pan.setValueAtTime((track.pan??0)/100,0);const analyserNode=ctx.createAnalyser();analyserNode.fftSize=2048;pannerNode.connect(analyserNode);const node={gainNode,pannerNode,analyserNode,route:null,fxStopFn:null,sfEntry:null,sfOut:null,sfRouteGain:null,sfDryGain:null};if(directOut){analyserNode.connect(ctx.destination);}else{if(!masterBus)initMasterBus(ctx);const route=createMasteringRoute(ctx,track,masterBus);analyserNode.connect(route.routeGain);analyserNode.connect(route.dryGain);node.route=route;}let fxStopFn;const fxEntry=ctx.createGain();const fxLegacyIn=ctx.createGain();gainNode.connect(fxEntry);const fxChain=track.fxChain||[];const fxEnabled=track.fxActive!==false;const chainMods=fxEnabled?fxChain.filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,ctx,m.params);}catch(e){return null;}}).filter(Boolean):[];let fxChainTail=fxEntry;chainMods.forEach(mod=>{fxChainTail.connect(mod.input);fxChainTail=mod.output;});fxChainTail.connect(fxLegacyIn);if(fxEnabled&&track.fxType==='chorus'){const fxInput=ctx.createGain();fxLegacyIn.connect(fxInput);const chorus=createChorusNode(ctx,fxInput,pannerNode);fxStopFn=chorus.stop;}else if(fxEnabled&&track.fxType==='reverb'){const fxInput=ctx.createGain();fxLegacyIn.connect(fxInput);createReverbNode(ctx,fxInput,pannerNode);}else{fxLegacyIn.connect(pannerNode);}node.fxStopFn=fxStopFn||null;// Soundfont (MIDI cache) chain — mirrors the live node so cached MIDI buffers
|
||
// flow through the track FX modules + mastering exactly like playback.
|
||
if(track.midiItems&&track.midiItems.length>0){const sfEntry=ctx.createGain();sfEntry.gain.setValueAtTime(1,0);const sfOut=ctx.createGain();const sfPan=ctx.createStereoPanner();sfPan.pan.setValueAtTime((track.pan??0)/100,0);sfOut.connect(sfPan);const sfRouteGain=ctx.createGain();const sfDryGain=ctx.createGain();const sfBypass=effMidiBypass(track);sfRouteGain.gain.value=sfBypass?0:1;sfDryGain.gain.value=sfBypass?1:0;sfPan.connect(sfRouteGain);sfPan.connect(sfDryGain);if(directOut){sfPan.connect(ctx.destination);}else{sfRouteGain.connect(masterBus.input);sfDryGain.connect(masterBus.dryInput);}const sfMods=fxEnabled?fxChain.filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,ctx,m.params);}catch(e){return null;}}).filter(Boolean):[];let sfTail=sfEntry;sfMods.forEach(mod=>{sfTail.connect(mod.input);sfTail=mod.output;});sfTail.connect(sfOut);node.sfEntry=sfEntry;node.sfOut=sfOut;node.sfRouteGain=sfRouteGain;node.sfDryGain=sfDryGain;}nodeMap[track.id]=node;return node;}// ── VST FX export helpers (fx_vst_bridge --render-fx qua engine /fx-render) ──
|
||
// Track + master VST chains là pass-through trong WebAudio (live + offline
|
||
// graph); xử lý THẬT chỉ chạy được ở export OFFLINE qua fx_vst_bridge (one-shot
|
||
// render, không SHM realtime — xem native_bridge/src/main_fx.cpp).
|
||
function audioBufferToInterleaved(buf){const ch=buf.numberOfChannels;const len=buf.length;const mono=ch<2;const out=new Float32Array(len*(mono?1:2));const L=buf.getChannelData(0);if(mono){out.set(L);}else{const R=buf.getChannelData(1);for(let i=0;i<len;i++){out[i*2]=L[i];out[i*2+1]=R[i];}}return{pcm:out,channels:mono?1:2,sampleRate:buf.sampleRate};}function interleavedToAudioBuffer(pcm,sampleRate,channels,ctx){const n=Math.floor(pcm.length/channels);const buf=ctx.createBuffer(channels,n,sampleRate);for(let ch=0;ch<channels;ch++){const d=buf.getChannelData(ch);for(let i=0;i<n;i++)d[i]=pcm[i*channels+ch];}return buf;}function mixInterleaved(a,b){const n=Math.max(a.length,b.length);const out=new Float32Array(n);for(let i=0;i<n;i++){out[i]=(i<a.length?a[i]:0)+(i<b.length?b[i]:0);}return out;}function activeVstChain(chain){return(chain||[]).filter(s=>s&&s.path&&!s.bypass);}function encodeFloatWavBlob(pcm,sampleRate,channels){// IEEE float32 WAV — bit-exact input cho fx_vst_bridge (không dither/quantize
|
||
// ở đây; final export encode giữ nguyên chính sách hiện có).
|
||
const len=Math.floor(pcm.length/channels);const bps=4;const dataBytes=len*bps*channels;const buf=new ArrayBuffer(44+dataBytes);const v=new DataView(buf);const ws=(o,s)=>{for(let i=0;i<s.length;i++)v.setUint8(o+i,s.charCodeAt(i));};ws(0,'RIFF');v.setUint32(4,36+dataBytes,true);ws(8,'WAVE');ws(12,'fmt ');v.setUint32(16,16,true);v.setUint16(20,3,true);v.setUint16(22,channels,true);v.setUint32(24,sampleRate,true);v.setUint32(28,sampleRate*bps*channels,true);v.setUint16(32,bps,true);v.setUint16(34,32,true);ws(36,'data');v.setUint32(40,dataBytes,true);let o=44;for(let i=0;i<pcm.length;i++)v.setFloat32(o+i*4,pcm[i],true);return new Blob([buf],{type:'audio/wav'});}// ── Realtime VST FX (SHM bridge) — master chain LIVE qua fx_vst_bridge ─────
|
||
// --realtime-fx: engine giữ SHM ring FxRealtimeIPC, spawn bridge; browser gửi
|
||
// block 256 stereo float32 qua WS → engine SHM → bridge xử lý → SHM → engine →
|
||
// WS → AudioWorklet (sf-fx-realtime.js) phát. Master: masterBus.output →
|
||
// 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,chainStructKey:null,latencyTotal:0,fadedIn:false,pdcCalibMs:0,processedSamples:null,processedAnchor:null,warm:false};// M23 Bug C fix: PWR OFF giua play — giu pdcDelay (content dry = content wet,
|
||
// khong nhay/scratch khi warm). Wake/stop ha co (__fxRtWakeRebase,
|
||
// stopAllPlayback, fxRtStop, fxRtStart chain-empty).
|
||
let fxRtWarmPlayHold=false;// 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 /
|
||
// new-project / export-end → teardown bình thường.
|
||
let fxRtKeepForRestart=false;let fxRtWrapRestart=false;// V38: loop wrap restart -- clear_queue mode 'loop' (always-wipe + RESUME_FILL)
|
||
let fxRtSeekRestartFlag=false;// V45.18: seek khi play master FX active -- clear_queue mode 'seek' (wipe queue cu + resume nhanh 8 block ~43ms)
|
||
// V21: user stop (fade=true) giữ master FX session warm — play kế tiếp có FX
|
||
// ngay, không chờ reload VST ~4s. Idle timeout: ngừng chơi lâu → teardown
|
||
// giải phóng CPU/VST (chấp nhận ~4s delay ở play sau khi idle lâu).
|
||
const FX_RT_WARM_IDLE_MS=120000;let fxRtWarmIdleTimer=null;// Native VSTi GUI open/reload đang chạy (C++ reloadForGUI mute 1-3s khi mở
|
||
// lại GUI sau khi đóng) → master-silence watchdog (750ms) sẽ false-positive →
|
||
// rebuild + fxRtStop → dry→wet vài giây. Exempt watchdog trong cửa sổ này.
|
||
let nativeGuiOpenUntilTs=0;// Pace input realtime: worklet sản xuất đúng realtime, nhưng sau khi main
|
||
// thread bị block (solo restart, GUI decode, rAF) các frame 'in' dồn lại —
|
||
// gửi 1 loạt làm SHM ring 4-slot tràn → mất block → ngắt quãng. Gửi tối đa 1
|
||
// burst (4 block ~21ms) mỗi 21ms → ring luôn kịp tiêu thụ, audio hồi phục
|
||
// trơn sau stall (FILL hấp thụ phần đầu).
|
||
const FXRT_BURST_MS=21;// Signature của chain VST realtime — phát hiện chain ĐỔI (insert/remove/bypass/
|
||
// reorder VST) → restart session với chain mới (fxRtStart early-return khi
|
||
// active, nếu không so chainKey thì VST mới insert không bao giờ được áp).
|
||
function fxRtChainKey(chain){return(chain||[]).map(s=>(s.type||'')+':'+(s.type==='vst3'?(s.path||'')+'#'+(s.preset_b64||'').slice(0,16):s.type||'')+'@'+(s.bypass?'1':'0')).join('|')+'|vb:'+(window.currentMasteringSettings&&window.currentMasteringSettings.vstBypass?'1':'0');}// Signature KHÔNG preset (type+path+bypass+vb). Tách khỏi fxRtChainKey để guard
|
||
// fxRtStart phân biệt: preset đổi (GUI capture — DSP đã giữ preset) vs cấu trúc
|
||
// chain đổi (plugin/bypass/order — phải set_chain reload thật).
|
||
function fxRtChainStructKey(chain){return(chain||[]).map(s=>(s.type||'')+':'+(s.type==='vst3'?s.path||'':s.type||'')+'@'+(s.bypass?'1':'0')).join('|')+'|vb:'+(window.currentMasteringSettings&&window.currentMasteringSettings.vstBypass?'1':'0');}function fxRtChain(ignorePwr){const ms=window.currentMasteringSettings;// C: ignorePwr=true (fxRtEnsureWarmForGui) — lấy chain cả khi PWR OFF/bypass
|
||
// (GUI master cần chain thật để warm session). Mặc định gate như cũ.
|
||
if(!ms||!ignorePwr&&(!ms.masterConnected||ms.isBypassed))return[];// Chain hợp nhất (builtin + vst3 theo UI order) — mọi slot active gửi bridge.
|
||
// preset_b64: merge preset GUI mới nhất (_capturedPresetByPath) — nếu chỉ dùng
|
||
// s.preset_b64 thì preset vừa chỉnh trong GUI chưa kịp persist → bridge load
|
||
// default (passthrough) → power ON nghe như OFF. Merge ở đây làm chainKey đổi
|
||
// → fxRtStart tự restart session với preset thật.
|
||
return(ms.chain||[]).filter(s=>s&&s.active!==false&&(s.type!=='vst3'||s.path)).map(s=>s.type==='vst3'&&_capturedPresetByPath[s.path]?{...s,preset_b64:_capturedPresetByPath[s.path]}:s);}// Param master chain entry → C++ BuiltinFx key (mirror Python _BUILTIN_PARAM_MAP).
|
||
const FX_RT_PARAM_MAP={eq:[['g1','eqLowGain'],['g2','eqMid1Gain'],['g3','eqMid2Gain'],['g4','eqHighGain']],compressor:[['threshold','compThreshold'],['ratio','compRatio'],['makeup','compMakeup']],limiter:[['ceiling','limThreshold'],['mode','limMode'],['lookahead_ms','limLookahead']],exciter:[['drive','excDrive']],rebalance:[['mid','rebalMid'],['side','rebalSide']],imager:[['w1','w1'],['w2','w2'],['w3','w3'],['w4','w4']],maximizer:[['boost_db','maxGain'],['soft_clip','maxSoftClip'],['upward','maxUpward'],['ceiling_db','ceiling']]};function fxRtEntryParams(entry,ms){const out=[];if(!entry||entry.type==='vst3')return out;const src=ms||window.currentMasteringSettings||{};const push=(k,v)=>{const n=Number(v);if(isFinite(n))out.push({key:k,value:n});};const map=FX_RT_PARAM_MAP[entry.type]||[];map.forEach(([cppKey,jsKey])=>{if(entry.params&&entry.params[cppKey]!==undefined)push(cppKey,entry.params[cppKey]);else if(entry.params&&entry.params[jsKey]!==undefined)push(jsKey,entry.params[jsKey]);else if(src[jsKey]!==undefined)push(cppKey,src[jsKey]);});if(entry.type==='eqpro'){if(entry.params&&entry.params.amount!==undefined)push('amount',entry.params.amount);const bands=entry.params&&entry.params.bands||[];bands.forEach((b,i)=>{['freq','gain','q'].forEach(k=>{if(b&&b[k]!==undefined)push('band'+i+'_'+k,b[k]);});});}return out;}function fxRtPushAllParams(){if(!fxRt||!fxRt.ws||fxRt.ws.readyState!==1)return;const chain=fxRtChain();chain.forEach((s,i)=>{fxRtEntryParams(s,null).forEach(p=>{try{fxRt.ws.send(JSON.stringify({cmd:'set_param',slot:i,key:p.key,value:p.value}));}catch(e){}});});}async function fxRtSetChain(chain){const sid=fxRt.sessionId;if(!sid||!fxRt.ws||fxRt.ws.readyState!==1)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});try{await window.SonicAPI.fxRealtimeSetChain({session_id:sid,fx_chain:chainPayload});try{fxRtPushAllParams();}catch(e){}}catch(e){console.warn('[FX RT] set-chain fail:',e);}}// ── C: xử lý WS dùng chung master cold/warm — text latency/flush_done +
|
||
// binary output -> worklet. Tách khỏi fxRtStart để wake/fxRtWarmEnsure dùng lại
|
||
// (tránh duplicate ~100 dòng). Warm chưa có node: binary drop tại guard
|
||
// fxRt.node; text latency vẫn cập nhật latencyTotal (PDC không áp —
|
||
// fadedIn=false).
|
||
function fxRtWsOnMessage(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;// 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).
|
||
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;let l=fxRt.outL,r=fxRt.outR;if(!l||l.length!==half){l=fxRt.outL=new Float32Array(half);r=fxRt.outR=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});}// M25: shim giu nguyen API WebSocket cu (readyState/send/onopen/onmessage/
|
||
// onclose/onerror) nhung WS + binary deinterleave chay tren Web Worker; output
|
||
// di THANG worker->worklet qua MessageChannel (directPort). ~20 call-site
|
||
// fxRt.ws giu nguyen - khong sua. Main chi nhan text (latency/flush_done).
|
||
let fxRtWorkerUrl=null;// M25.1: blob URL cua worker (cache 1 lan/session)
|
||
async function fxRtWsConnect(wsUrl,directPort){const _fireClose=sh=>{if(sh._closed)return;sh._closed=true;sh.readyState=3;// CLOSED
|
||
try{if(sh._worker)sh._worker.terminate();}catch(e){}if(sh.onclose){try{sh.onclose();}catch(e){}}};const shim={readyState:0,onopen:null,onmessage:null,onclose:null,onerror:null,_worker:null,_closed:false,send(data){if(this._closed||!this._worker||this.readyState!==1)return;try{if(typeof data==='string')this._worker.postMessage({kind:'send',text:data});// KHONG transfer list - fxRtChunks c.buffer reused (ws.send cu khong neuter)
|
||
else if(data instanceof ArrayBuffer)this._worker.postMessage({kind:'send',buf:data});}catch(e){}},close(){_fireClose(this);}};// M25.1 fix: UI Tauri chay origin tauri://localhost (engine HTML nap qua
|
||
// document.write) — new Worker(http://127.0.0.1:8000/...) CROSS-ORIGIN ->
|
||
// SecurityError -> fxRtStart chet -> master FX khong hoat dong (M24 dung
|
||
// new WebSocket, khong bi same-origin). Fetch text qua engine (CORS * da bat)
|
||
// -> Blob URL same-origin -> worker chay duoc ca Tauri lan http dev.
|
||
try{if(!fxRtWorkerUrl){const _r=await fetch(`${API_BASE_URL}/static/js/fx-rt-worker.js?v=202609071534`,{cache:'no-store'});if(!_r.ok)throw new Error('fx-rt-worker load '+_r.status);fxRtWorkerUrl=URL.createObjectURL(new Blob([await _r.text()],{type:'text/javascript'}));}shim._worker=new Worker(fxRtWorkerUrl);}catch(e){throw e;}// giong ws constructor that -> caller catch cu giu nguyen
|
||
shim._worker.onmessage=ev=>{const d=ev.data;if(!d||shim._closed)return;if(d.kind==='open'){shim.readyState=1;if(shim.onopen){try{shim.onopen();}catch(e){}}return;}if(d.kind==='close'){_fireClose(shim);return;}if(d.kind==='text'){if(shim.onmessage){try{shim.onmessage({data:d.text});}catch(e){}}}};shim._worker.onerror=()=>_fireClose(shim);try{shim._worker.postMessage({kind:'init',wsUrl,port:directPort||undefined},directPort?[directPort]:[]);}catch(e){}return shim;}async function fxRtStart(){// V21: mọi play/chain-change gọi đây — hủy idle timer của lần stop trước
|
||
// (không để session warm bị teardown giữa lúc đang chơi).
|
||
if(fxRtWarmIdleTimer){clearTimeout(fxRtWarmIdleTimer);fxRtWarmIdleTimer=null;}const chain=fxRtChain();const ck=fxRtChainKey(chain);const ckStruct=fxRtChainStructKey(chain);if(fxRt.active){// masterBus bị rebuild (recovery/export) → node thuộc graph cũ → restart.
|
||
if(!fxRt.node||fxRt.node.context!==getAudioContext())fxRtStop(true);// Chain ĐỔI CẤU TRÚC (insert/remove/bypass/reorder/type) — KHÔNG restart
|
||
// (ngắt âm + crackle): set_chain lên session ĐANG CHẠY → bridge worker
|
||
// load chain mới → swap atomic mượt.
|
||
else if(fxRt.chainKey!==ck&&fxRt.chainStructKey!==ckStruct){fxRtSetChain(chain);fxRt.chainKey=ck;fxRt.chainStructKey=ckStruct;return;}// Chỉ PRESET đổi (preset vừa capture từ GUI native — DSP ĐÃ giữ state):
|
||
// KHÔNG set_chain — backend set_chain với JUCE = terminate + respawn bridge
|
||
// → giết editor GUI in-process + gap âm 2-17s (Bug 3 wobble/mất preset).
|
||
// ponytail: preset load từ file JS (chưa vào DSP) sẽ chỉ áp ở lần reload
|
||
// structural/restart sau — nếu cần tức thì: backend endpoint set_preset riêng.
|
||
else if(fxRt.chainKey!==ck){fxRt.chainKey=ck;return;}else return;}// ── C: wake session warm (PWR OFF -> ON / play khi warm) — session realtime +
|
||
// node + ws còn sống từ fxRtWarmBypass -> nối lại graph, KHÔNG reload VST
|
||
// (set_chain chỉ khi chainStructKey đổi). Điều kiện: warm + session/ws còn.
|
||
if(fxRt.warm&&!fxRt.starting&&fxRt.sessionId&&fxRt.ws&&fxRt.ws.readyState===1){// Chain rỗng (PWR OFF/bypass) nhưng warm còn = session đang giữ cho GUI
|
||
// master native — KHÔNG teardown (giết editor + preset). Play đi dry path;
|
||
// PWR ON sau đó wake nối graph ở đây. Teardown khi vô chủ do settings
|
||
// effect (OP12) xử lý.
|
||
if(!chain.length){fxRtWarmPlayHold=false;try{fxRtApplyPdc();}catch(e){}// zero delay — play mới không FX
|
||
return;}const wToken=++fxRt.startToken;fxRt.starting=false;fxRt.warm=false;// Node/context/gains còn nguyên? Nếu masterBus rebuild (recovery/export) từ
|
||
// lúc warm -> node thuộc graph cũ -> không nối được -> fallthrough cold start
|
||
// bên dưới. Edge hẹp: PWR OFF từ đầu + GUI mở + PWR ON chưa từng play ->
|
||
// warm-ensure chỉ tạo session (chưa node) -> editor native bị giết (session
|
||
// mới); GUI tự mở lại khi đóng/mở modal (_autoTryOpen) hoặc bấm chip.
|
||
// ponytail: giữ editor qua restart cần backend move-editor (chưa có).
|
||
const _wNodeOk=!!(fxRt.node&&fxRt.node.context===getAudioContext()&&masterBus&&masterBus.outputAnalyser&&masterBus.masterFxGain);if(!_wNodeOk){fxRtStop(true);// fallthrough -> cold start phía dưới (fxRtStop đã bump token)
|
||
}else{if(fxRt.chainStructKey!==ckStruct){await fxRtSetChain(chain);if(wToken!==fxRt.startToken)return;fxRt.chainStructKey=ckStruct;}fxRt.chainKey=ck;// Reconnect edges: disconnect-then-connect — toggle nhanh <180ms bypass
|
||
// timer chưa kịp cắt -> disconnect ở đây tránh duplicate edge.
|
||
try{if(masterBus&&masterBus.outputAnalyser)masterBus.outputAnalyser.disconnect(fxRt.node);}catch(e){}try{if(masterBus&&masterBus.outputAnalyser)masterBus.outputAnalyser.connect(fxRt.node);}catch(e){}try{if(fxRt.node)fxRt.node.disconnect(masterBus.masterFxGain);}catch(e){}try{if(fxRt.node)fxRt.node.connect(masterBus.masterFxGain);}catch(e){}fxRt.active=true;fxRt.inQueue=[];fxRt.outL=null;fxRt.outR=null;// M19 Bug A fix: wake PWR ON sau warm-bypass. Trước đây chỉ flush engine
|
||
// ring + fadedIn=true NGAY — nhưng worklet lúc bypass rời graph không
|
||
// process: outQueue còn ~FILL block audio CŨ + hasStarted=true (không gửi
|
||
// 'started' lại) → reconnect xong phát stale block ngay + crossfade dry→fx
|
||
// khi wet chưa có audio mới → gap → underrun/wobble (log u4-u8). Giờ giống
|
||
// cold start: clear_queue mode 'play' (worklet wipe queue + resumeFill=FILL
|
||
// + hasStarted=false → gửi 'started' khi đủ FILL block MỚI) → crossfade +
|
||
// PDC chỉ khi audio mới thực sự chảy. Engine flush vẫn giữ (bỏ backlog).
|
||
if(fxRt.ws.readyState===1){try{fxRt.ws.send(JSON.stringify({cmd:'flush'}));}catch(e){}}try{fxRtPushAllParams();}catch(e){}try{fxRt.node.port.postMessage({type:'clear_queue',mode:'play'});}catch(e){}fxRt.fadedIn=false;// port.onmessage hiện tại là closure của cold-start cũ — guard 'started'
|
||
// bằng token CŨ (≠ wToken) nên sẽ bỏ qua. Hook tạm bắt 'started' cho wake
|
||
// (crossfade + PDC đúng lúc), forward mọi message khác; restore sau 3s.
|
||
const _wOld=fxRt.node.port.onmessage;const _wHook=ev=>{try{if(ev.data&&ev.data.type==='started'&&wToken===fxRt.startToken){fxRt.fadedIn=true;fxRtCrossfade(true,wToken);setTimeout(()=>{if(wToken===fxRt.startToken)fxRtApplyPdc();},250);}}catch(e){}try{_wOld(ev);}catch(e){}};fxRt.node.port.onmessage=_wHook;setTimeout(()=>{try{if(fxRt.node&&fxRt.node.port.onmessage===_wHook)fxRt.node.port.onmessage=_wOld;}catch(e){}},3000);return;}}if(!chain.length){fxRtWarmPlayHold=false;try{fxRtApplyPdc();}catch(e){}return;}// C: warm session còn nhưng không wake được (ws đóng/session mất) -> teardown
|
||
// trước cold-spawn (tránh 2 session song song).
|
||
if(fxRt.warm)fxRtStop(true);// Token chống race bypass: user bấm PWR/Bypass khi fxRtStart đang chờ
|
||
// fxRealtimeStart (~4s load VST). Lúc đó fxRt.active vẫn false → effect
|
||
// `else if (fxRt.active) fxRtStop()` KHÔNG chạy → start in-flight vẫn nối
|
||
// 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=202609071534`);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):''}`;// M25: WS chay tren Web Worker - binary output/input di THANG
|
||
// worker<->worklet qua MessageChannel (directPort). Main-thread stall (rAF/
|
||
// React/render luc cold start) khong con tre frame -> het underrun u1/u10.
|
||
// Text (latency/flush_done) van forward main nhu cu.
|
||
const _chan=new MessageChannel();try{fxRt.node.port.postMessage({type:'__direct',port:_chan.port2},[_chan.port2]);}catch(e){}const ws=await fxRtWsConnect(wsUrl,_chan.port1);// M25.1: await fetch worker giua start — stop/restart co the xay ra; ws cu
|
||
// phai dong (worker terminate), khong de ro.
|
||
if(token!==fxRt.startToken){try{ws.close();}catch(e){}fxRt.starting=false;return;}fxRt.ws=ws;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=fxRtWsOnMessage;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
|
||
// buffer 8192B; slice() copy đúng 2048B.
|
||
const fxRtChunks=[0,1,2,3].map(()=>new Float32Array(512));const fxRtSendFrames=d=>{if(!fxRt.ws||fxRt.ws.readyState!==1)return;try{const f=new Float32Array(d.data);if(f.length>512){for(let i=0;i<f.length;i+=512){const c=fxRtChunks[i/512];c.set(f.subarray(i,i+512));fxRt.ws.send(c.buffer);}}else{fxRt.ws.send(d.data);}}catch(e){}};const fxRtInPump=()=>{if(!fxRt.inQueue.length)return;// Không còn dùng (gửi ngay theo đồng hồ audio — xem onmessage 'in').
|
||
// Giữ tham chiếu phòng khi track path còn dùng pattern cũ.
|
||
const now=Date.now();while(fxRt.inQueue.length&&now-fxRt.lastInSent>=FXRT_BURST_MS){fxRt.lastInSent=now;fxRtSendFrames(fxRt.inQueue.shift());}if(fxRt.inQueue.length){if(fxRt.pumpTimer)clearTimeout(fxRt.pumpTimer);fxRt.pumpTimer=setTimeout(fxRtInPump,Math.max(1,FXRT_BURST_MS-(now-fxRt.lastInSent)));}else if(fxRt.pumpTimer){clearTimeout(fxRt.pumpTimer);fxRt.pumpTimer=null;}};fxRt.node.port.onmessage=ev=>{const d=ev.data;if(!d||!fxRt.ws)return;if(d.type==='started'){// Worklet bat dau phat (sau FILL) -> crossfade dry->fx muot. Token guard
|
||
// 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==='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);// V33: log dbg.log — diff giua 2 dong = so su kien trong ~2s (u=underrun,
|
||
// h=hold, f=fade, s=silence, cap=capDrop, fi=fadeIn, q=queueLen).
|
||
try{_dbgLog('fx_stats','u='+d.underruns+' h='+d.holds+' f='+d.fades+' s='+d.silences+' cap='+d.capDrops+' fi='+d.fadeIns+' q='+d.queueLen);}catch(e){}return;}if(d.type==='dbg'){// V32: hop dau sau clear_queue (in/out/play) — do latency master FX path.
|
||
const _now=performance.now();// V40: self-calib PDC — fx_play_first = block đầu tiên thực sự phát ra
|
||
// (đã qua FILL) → latency path thực đo được; snap playhead về đúng
|
||
// startOffset ngay mẫu đầu tiên nghe được (cả 2 chiều: hết chạy trước
|
||
// lẫn hết lag). Native-bridge path dùng _dbgFirstAudiblePending riêng —
|
||
// không đụng flag này.
|
||
// V41: guard ≥50ms — tại loop wrap (clear_queue mode 'loop' giữ queue)
|
||
// worklet phát lại OLD TAIL ~5ms → dbgPlayFirst tiêu thụ → trước đây snap
|
||
// playhead SỚM + calib nhiễu ~50ms → display chạy trước → wrap sớm → vấp
|
||
// beat 0:0. Pass mới thật ≥ ~80ms → snap/calib chỉ trên event hợp lệ.
|
||
// V45.19 Bug 1b: fx_seek_first (seek resume nhanh ~43ms < 50ms) snap KHONG
|
||
// can guard 50ms; fx_play_first giu guard (chan old-tail loop wrap snap som).
|
||
const _seekFirst=d.tag==='fx_seek_first';if((d.tag==='fx_play_first'||_seekFirst)&&(_seekFirst||_now-_dbgPlayStartMs>=50)){fxRt.pdcCalibMs=Math.max(50,Math.min(3000,_now-_dbgPlayStartMs));try{if(window.__fxRtSnapPlayhead)window.__fxRtSnapPlayhead();}catch(e2){}}// V46.2: log them field seam (fx_loop_clear) — pre/keep/lat/wfb/playing de xac minh countdown
|
||
const _fxExtra=d.tag==='fx_loop_clear'?' pre='+d.pre+' keep='+d.keep+' lat='+d.lat+' wfb='+d.wfb+' playing='+d.playing:'';_dbgLog('fx_hop_'+d.tag,'sincePlay='+Math.round(_now-_dbgPlayStartMs)+' sinceClear='+Math.round(_now-_dbgLastClearMs)+' wlT='+d.t+' q='+(d.q!==undefined?d.q:'')+_fxExtra);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
|
||
// dần → crackle sau 3-4s (đã xác nhận với chain RỖNG). Burst sau stall:
|
||
// engine hấp thụ (in ring 4 slot, bridge drain ~1ms, drop-oldest an toàn).
|
||
fxRtSendFrames(d);};// Pace input: tối đa 1 block/kỳ (~5.8ms). Gửi dồn theo burst (main thread bị
|
||
// rAF renderFrame block khi mở MASTERING PANEL) → SHM ring 4-slot tràn → mất
|
||
// block → ngắt quãng.
|
||
// Phase 4.2: fader SAU worklet — worklet chèn giữa outputAnalyser (chain
|
||
// output) và output (fader/destination). output → analyser/splitter giữ
|
||
// nguyên (meter đo âm SAU fader).
|
||
// Crossfade activation: KHONG disconnect dry path — outputAnalyser van noi
|
||
// masterFxDryGain -> pdcDelay -> output (gain 1); nhanh fx noi masterFxGain
|
||
// (gain 0) -> output. Tin hieu 'started' tu worklet -> ramp 2 gain -> switch
|
||
// muot, het click hard-rewire.
|
||
try{masterBus.outputAnalyser.disconnect(masterBus.output);}catch(e){}masterBus.outputAnalyser.connect(fxRt.node);fxRt.node.connect(masterBus.masterFxGain);fxRt.chainKey=ck;fxRt.chainStructKey=ckStruct;}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;}if(fxRt.flushTimer){clearTimeout(fxRt.flushTimer);fxRt.flushTimer=null;}fxRt.inQueue=[];fxRt.flushing=false;fxRtWarmPlayHold=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).
|
||
try{fxRtCrossfade(false);}catch(e){}setTimeout(()=>{try{_fxStopNode.disconnect();}catch(e){}_fxStopNode.port.onmessage=null;if(fxRt.node===_fxStopNode)fxRt.node=null;fxRt.fadedIn=false;fxRtApplyPdc();},180);}else{// Fallback graph cu (khong co crossfade gains) — hard switch nhu cu.
|
||
try{_fxStopNode.disconnect();}catch(e){}if(masterBus&&masterBus.outputAnalyser&&masterBus.output){try{masterBus.outputAnalyser.connect(masterBus.output);}catch(e){}}_fxStopNode.port.onmessage=null;if(fxRt.node===_fxStopNode)fxRt.node=null;}}}catch(e){}try{if(fxRt.ws){fxRt.ws.onclose=null;fxRt.ws.onmessage=null;try{fxRt.ws.close();}catch(e){}fxRt.ws=null;}}catch(e){}const sid=fxRt.sessionId;fxRt.sessionId=null;fxRtGuiPushShm();fxRt.active=false;fxRt.warm=false;fxRt.chainKey=null;fxRt.chainStructKey=null;fxRt.latencyTotal=0;// PDC giam ve 0 sau khi dry chiem cho (trong setTimeout cua nhanh crossfade)
|
||
// hoac ngay neu khong co node — khong goi tai day de tranh gap 80ms.
|
||
if(!_fxStopNode)fxRtApplyPdc();if(sid){try{window.SonicAPI.fxRealtimeStop({session_id:sid}).catch(()=>{});}catch(e){}}}// ── C: FX PWR OFF = bypass giữ session warm (GUI master native + anchored) ──
|
||
// fxRtWarmBypass: WebAudio node rời graph (crossfade dry + cắt 2 cạnh sau
|
||
// 180ms) nhưng session/ws/node/sessionId GIỮ — PDC 0, engine idle (worklet
|
||
// không còn edge -> không process -> không gửi frame). Wake (PWR ON) nối lại
|
||
// graph trong fxRtStart. fxRtWarmEnsure: tạo session KHÔNG node cho GUI native
|
||
// khi PWR OFF. Khác fxRtStop (teardown thật: đóng WS + fxRealtimeStop).
|
||
function fxRtWarmBypass(){if(fxRtWarmIdleTimer){clearTimeout(fxRtWarmIdleTimer);fxRtWarmIdleTimer=null;}if(!fxRt.active)return;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;fxRt.fadedIn=false;const _byToken=fxRt.startToken;try{if(fxRt.node&&masterBus&&masterBus.masterFxGain&&masterBus.masterFxDryGain){// Ramp fx->dry rồi 180ms sau mới cắt CẢ 2 cạnh (input lẫn output) — cắt
|
||
// sớm khi dry chưa chiếm chỗ -> click. Giữ node/ws/session cho wake.
|
||
try{fxRtCrossfade(false);}catch(e){}setTimeout(()=>{// Guard toggle nhanh: PWR ON trong 180ms -> wake đã nối lại node —
|
||
// KHÔNG cắt (cắt ở đây làm đứt node đang chạy).
|
||
if(fxRt.active||fxRt.startToken!==_byToken)return;try{if(masterBus&&masterBus.outputAnalyser)masterBus.outputAnalyser.disconnect(fxRt.node);}catch(e){}try{fxRt.node.disconnect();}catch(e){}fxRt.fadedIn=false;// M23 Bug C: OFF giua play — giu PDC (content lien tuc qua warm); zero
|
||
// delay chi khi stop han (stopAllPlayback clear hold + applyPdc).
|
||
if(!fxRtWarmPlayHold)fxRtApplyPdc();},180);}else if(fxRt.node){// Graph cũ (không crossfade gains) — hard switch + nối lại dry.
|
||
try{if(masterBus&&masterBus.outputAnalyser)masterBus.outputAnalyser.disconnect(fxRt.node);}catch(e){}try{fxRt.node.disconnect();}catch(e){}if(masterBus&&masterBus.outputAnalyser&&masterBus.output){try{masterBus.outputAnalyser.connect(masterBus.output);}catch(e){}}if(!fxRtWarmPlayHold)fxRtApplyPdc();}}catch(e){}// Không null node/ws/onmessage/sessionId — wake cần node cũ để reconnect;
|
||
// worklet rời graph (0 cạnh) -> không process -> không gửi frame -> engine
|
||
// idle an toàn (bridge vẫn sống cho GUI native).
|
||
fxRt.active=false;fxRt.warm=true;fxRt.latencyTotal=0;}// C: warm-ensure — tạo session realtime KHÔNG nối node (GUI native cần session
|
||
// sống + plugin là slot chain để backend mở editor in-process của instance DSP).
|
||
async function fxRtWarmEnsure(chain){if(!chain||!chain.length)return null;if(fxRt.starting)return null;if(fxRt.active&&fxRt.sessionId)return null;const ctx=getAudioContext();if(!ctx||!ctx.audioWorklet)return null;const token=++fxRt.startToken;fxRt.starting=true;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] warm-ensure start fail:',e);fxRt.starting=false;return null;}if(token!==fxRt.startToken){// stop/cold-start đã bump token giữa lúc chờ -> dọn session orphan này.
|
||
try{window.SonicAPI.fxRealtimeStop({session_id:st.session_id}).catch(()=>{});}catch(e2){}return null;// không reset starting — người bump (fxRtStop/fxRtStart) sở hữu nó
|
||
}fxRt.sessionId=st.session_id;fxRtGuiPushShm();const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';// WS to 'localhost' bi tre ~300ms (xem fxRtStart) — dùng 127.0.0.1 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):''}`;// M25: WS chay tren Web Worker - warm chua co node (khong directPort);
|
||
// worker van giu WS, binary drop trong worker nhu cu main drop (!fxRt.node).
|
||
let ws;try{ws=await fxRtWsConnect(wsUrl,null);}catch(e){try{window.SonicAPI.fxRealtimeStop({session_id:st.session_id}).catch(()=>{});}catch(e2){}fxRt.sessionId=null;fxRtGuiPushShm();fxRt.starting=false;return null;}fxRt.ws=ws;ws.onopen=()=>{if(token!==fxRt.startToken){// Bị thay bằng session mới hơn — dọn session mình vừa tạo (nếu vẫn là
|
||
// session hiện tại) + đóng ws.
|
||
if(fxRt.sessionId===st.session_id){fxRt.sessionId=null;try{window.SonicAPI.fxRealtimeStop({session_id:st.session_id}).catch(()=>{});}catch(e){}fxRtGuiPushShm();}try{ws.close();}catch(e){}return;}fxRt.starting=false;fxRt.warm=true;fxRt.chainKey=fxRtChainKey(chain);fxRt.chainStructKey=fxRtChainStructKey(chain);};ws.onmessage=fxRtWsOnMessage;ws.onclose=()=>{// Chỉ teardown khi ws này vẫn là session hiện tại (guard session mới hơn).
|
||
if(token===fxRt.startToken&&fxRt.sessionId===st.session_id)fxRtStop(true);};ws.onerror=()=>{try{ws.close();}catch(e){}};return st.session_id;}// C: bảo đảm session realtime sống cho GUI master native của path: PWR ON đã
|
||
// active -> true; đang start -> chờ; warm -> set_chain nếu struct đổi; chưa có
|
||
// -> warm-ensure. Slot per-plugin OFF/không có trong chain -> false (backend từ
|
||
// chối mở native; fallback embed đã ngưng cho master).
|
||
async function fxRtEnsureWarmForGui(path){const ms=window.currentMasteringSettings;const chain=ms&&ms.chain||[];let entry=chain.find(x=>x&&x.type==='vst3'&&x.path===path);// Slot per-plugin OFF (active=false) — engine bypass node không có editor; trả
|
||
// false NGAY (chủ ý; toast hướng dẫn bật slot). KHÔNG poll dài vô ích.
|
||
if(entry&&entry.active===false)return false;let activeOf=!!(entry&&entry.active!==false);if(!activeOf){// addVstToChain mở GUI NGAY sau setOzState — chain commit sau render; poll
|
||
// 2500 -> 8000ms: commit qua render + App effect có thể >2.5s khi chain dài
|
||
// / main thread bận (Lỗi 2: false sớm -> toast + không mở được GUI).
|
||
const t0=Date.now();while(Date.now()-t0<8000){await new Promise(r=>setTimeout(r,50));const ms2=window.currentMasteringSettings;const ch2=ms2&&ms2.chain||[];entry=ch2.find(x=>x&&x.type==='vst3'&&x.path===path);if(entry&&entry.active===false)return false;activeOf=!!(entry&&entry.active!==false);if(activeOf)break;}if(!activeOf)return false;}const want=fxRtChain(true);if(fxRt.active&&fxRt.sessionId)return true;if(fxRt.starting){// Session đang start/restart (cold load VST chain nặng có thể >6s) — chờ
|
||
// dài hơn 6000 -> 15000ms trước khi báo fail.
|
||
const t1=Date.now();while(Date.now()-t1<15000){await new Promise(r=>setTimeout(r,100));if(fxRt.active&&fxRt.sessionId)return true;if(!fxRt.starting)break;}if(fxRt.starting)return false;}if(fxRt.warm&&fxRt.ws&&fxRt.ws.readyState===1&&fxRt.sessionId){if(fxRt.chainStructKey!==fxRtChainStructKey(want)){// Chain đổi từ lúc warm — set_chain reload (giết editor native nếu đang
|
||
// mở; chấp nhận: chain đổi thì reload bắt buộc). Backend openFxGui còn
|
||
// chờ slot ≤15s — không cần đợi thêm ở đây.
|
||
await fxRtSetChain(want);}return true;}// Warm-ensure lần đầu có thể fail transient (spawn/WS) — retry vài lần trước
|
||
// khi báo lỗi thật.
|
||
for(let _a=0;_a<3;_a++){const sid=await fxRtWarmEnsure(want);if(sid&&fxRt.sessionId===sid)return true;await new Promise(r=>setTimeout(r,600));}return false;}// ── Gap 8: PDC realtime — bù delay dry-bypass path (master chain) ──────────
|
||
// Master chain (worklet → bridge VST3) trễ ~2 block round-trip + latency
|
||
// plugin. Track bypass mastering đi dry path (dryInput→dryOutput→output) KHÔNG
|
||
// qua worklet → lệch pha với mastered. fxRtApplyPdc trễ dry path đúng lượng
|
||
// để align. FX_RT_ROUNDTRIP_SAMPLES là ước lượng — ponytail: đo RTT thật
|
||
// (tag seq trên block) khi cần sample-accurate.
|
||
// M18: FILL 40 (sf-fx-realtime.js V48) → RTT = batching 4 block (~7 block @48k
|
||
// ≈ 1792) + FILL buffer 40 block (10240) = 47 block ≈ 12032 (~251ms @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=12032;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(){const _phPrevAnchor=fxRt&&fxRt.processedAnchor;if(fxRt&&fxRt.active&&typeof fxRt.processedSamples==='number'){fxRt.processedAnchor=fxRt.processedSamples;}else{fxRt.processedAnchor=null;}try{if(_phArmed())_dbgLog('ph_anchor','prevA='+(typeof _phPrevAnchor==='number'?_phPrevAnchor:'na')+' ps='+(typeof fxRt.processedSamples==='number'?fxRt.processedSamples:'na')+' pa='+(typeof fxRt.processedAnchor==='number'?fxRt.processedAnchor:'na')+' act='+(fxRt&&fxRt.active?1:0));}catch(e){}}function fxRtApplyPdc(){const ctx=getAudioContext();const bus=masterBus;if(!ctx||!bus||!bus.pdcDelay)return;// M23 Bug C: bo +FX_RT_ROUNDTRIP_SAMPLES — latencyTotal (bridge 'latency'
|
||
// frame) DA gom round-trip that (log M21/M22: display active = wall - lat).
|
||
// +12032 double-count -> dry path delay > wet lag ~250ms -> crossfade lech pha
|
||
// (scratch). ponytail: neu engine pipeline doi RTT that -> do lai o day.
|
||
const l=fxRt&&fxRt.active?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
|
||
// (path 'audio', splice fxEntry→mods→worklet→fxLegacyIn) và MIDI/SF (path 'sf',
|
||
// splice sfEntry→mods→worklet→sfOut). Track vừa clips vừa MIDI → 2 session
|
||
// cùng chain (khớp app: builtin FX cũng chạy instance riêng mỗi path; offline
|
||
// export áp VST lên track mix tổng). Thứ tự: builtin FX → VST (khớp offline).
|
||
const fxRtTracks={};// 'trackId:audio|sf' → { sessionId, ws, node, ready, path, spliced, chainKey }
|
||
// Embedded VST GUI → live SHM session feed (open_fx_gui shm + bridge /shm route).
|
||
// url → trackId|null (null = master GUI). The GUI process peeks the session's
|
||
// input ring so its meters move with real audio; push on every start/stop.
|
||
const _fxGuiEmbedSources={};function _fxGuiRegisterSource(url,trackId){if(!url)return;_fxGuiEmbedSources[url]=trackId===undefined?null:trackId;}function _fxGuiUnregisterSource(url){if(url)delete _fxGuiEmbedSources[url];}function fxRtTrackShmName(trackId){const key=trackId?Object.keys(fxRtTracks).find(k=>k.startsWith(trackId+':')):'';return key?fxRtTracks[key].sessionId||'':'';}function fxRtGuiPushShm(){Object.keys(_fxGuiEmbedSources).forEach(url=>{const trackId=_fxGuiEmbedSources[url];const name=trackId===null?fxRt.sessionId||'':fxRtTrackShmName(trackId);try{fetch(url+'/shm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({shm:name})}).catch(()=>{});}catch(e){}});}function fxRtTrackChain(track){if(!track)return[];let chain=(track.fxChain||[]).filter(s=>s&&s.active!==false&&(s.type!=='vst3'||s.path));// Legacy: project cũ chỉ có vstFxChain riêng — append sau builtin.
|
||
if(!chain.some(s=>s&&s.type==='vst3')&&!track.vstFxBypass){chain=chain.concat(activeVstChain(track.vstFxChain||[]));}// preset_b64: merge preset GUI mới nhất (giống fxRtChain master) — nếu chỉ
|
||
// dùng s.preset_b64 thì preset chỉnh trong GUI chưa kịp persist → bridge load
|
||
// default (passthrough). chainKey đổi → fxRtTrackStart tự restart session.
|
||
return chain.map(s=>s.type==='vst3'&&_capturedPresetByPath[s.path]?{...s,preset_b64:_capturedPresetByPath[s.path]}:s);}function fxRtTrackChainKey(chain){return(chain||[]).map(s=>(s.type||'')+':'+(s.type==='vst3'?(s.path||'')+'#'+(s.preset_b64||'').slice(0,16):s.type||'')+'@'+(s.bypass?'1':'0')).join('|');}function fxRtTrackPaths(track){const paths=[];if(track&&(track.clips&&track.clips.length>0||!!track.buffer||track.sections&&track.sections.length>0))paths.push('audio');if(track&&(track.midiItems&&track.midiItems.length>0||track.type==='MIDI'||!!track.instrumentId))paths.push('sf');return paths;}// Track-EQ preset library (unified_fx_rack_panel.md §III.1) — band gains only.
|
||
const TRACK_EQ_PRESETS={flat:{name:'Flat / Reset',g:[0,0,0,0]},vocal_clarity:{name:'Vocal Unmask & Clarity',g:[-2.5,-1.8,3.2,2.0]},bass_punch:{name:'EDM Low-End Punch',g:[4.0,-3.0,1.5,1.0]},warm_tape:{name:'Warm Vintage Analog',g:[2.0,1.0,-2.0,-3.0]},guitar_edge:{name:'Guitar Cut & Presence',g:[-3.0,-2.0,3.5,1.5]}};// Dynamic signal-chain reconstruction (mastering_expand.md §II.3):
|
||
// disconnect every module boundary, then wire the ACTIVE modules in series
|
||
// between inputAnalyser (chain input) and outputAnalyser (chain output).
|
||
function rebuildMasteringGraph(activate,chainArray){if(!masterBus)return;try{// Phase 4.2: master chain xử lý TOÀN bộ trong fx_vst_bridge — WebAudio
|
||
// master là pass-through (inputAnalyser → outputAnalyser).
|
||
masterBus.inputAnalyser.disconnect();masterBus.inputAnalyser.connect(masterBus.outputAnalyser);masterBus.masteringActive=!!activate;}catch(e){console.warn('rebuildMasteringGraph error:',e);}}function toggleMasteringOnMaster(activate,isBypassed){if(!masterBus)return;const active=!!(activate&&!isBypassed);const chain=window.currentMasteringSettings&&window.currentMasteringSettings.chain||[];const cSig=chainSignature(chain);// Idempotent: don't rebuild unless active state OR chain layout changed.
|
||
if(_lastMasteringActive===active&&masterBus.masteringActive===active&&_lastChainSig===cSig)return;_lastMasteringActive=active;_lastChainSig=cSig;// Disconnect + immediately reconnect in one synchronous block so the master
|
||
// routing can NEVER be left broken (a mid-swap exception would otherwise
|
||
// disconnect inputAnalyser and silence ALL audio globally).
|
||
try{rebuildMasteringGraph(active,chain);}catch(e){console.warn('toggleMasteringOnMaster error:',e);// Restore a guaranteed-valid default routing regardless of the failure.
|
||
try{masterBus.inputAnalyser.disconnect();masterBus.maximizerCompressor.disconnect();masterBus.inputAnalyser.connect(masterBus.outputAnalyser);masterBus.masteringActive=false;}catch(e2){}}}function getAudioContext(){if(!audioCtx){audioCtx=new(window.AudioContext||window.webkitAudioContext)();if(window.SonicAudio&&window.SonicAudio.initAudioWorklet){window.SonicAudio.initAudioWorklet();}}if(audioCtx.state==='suspended'){audioCtx.resume();}if(!masterBus){initMasterBus(audioCtx);}// The mastering chain is ONLY managed by the masteringSettings effect — NOT
|
||
// here. getAudioContext runs constantly (play, stopAll, VU, double-click…);
|
||
// re-toggling/re-automating the biquad EQ here in bursts is "fast parameter
|
||
// automation" that makes Chromium flag the filters as unstable
|
||
// ("BiquadFilterNode: state is bad") and can leave the master routing broken
|
||
// → global silence. The React effect applies it once per settings change.
|
||
if(window.SonicSF&&window.SonicSF.init){window.SonicSF.init(audioCtx);}// C6: bootstrap native bridge 1 lần — query status, nối audio sink + router.
|
||
if(window.NativeBridgeService&&window.SonicMidiRouter&&!window.__bridgeBootstrapped){window.__bridgeBootstrapped=true;window.SonicMidiRouter.onFallback=function(cmd,ch,pitch,vel){if(!window.SonicSF)return;if(cmd==='PANIC'){if(window.SonicSF.stopAll)window.SonicSF.stopAll();return;}if(cmd==='NOTE_ON')window.SonicSF.playNote(pitch,vel,undefined,undefined,undefined,undefined,ch);else window.SonicSF.stopNote(ch,pitch);};(async function(){try{var st=await window.NativeBridgeService.queryStatus();var connected=!!(st&&st.connected);window.SonicMidiRouter.setBridgeConnected(connected);_dbgLog('bootstrap','bridge_sr='+(st&&st.sample_rate)+' ctx_sr='+audioCtx.sampleRate+' connected='+connected);console.log('[Bridge] bootstrap connected=',connected);if(connected){// V27: bridge_status trả sample_rate thật (BridgeSampleRate state) —
|
||
// nếu đã khớp audioCtx (48k mặc định) thì KHÔNG restart: trước đây
|
||
// luôn kill+respawn → first play sau launch im lặng ~0.5s (chờ bridge
|
||
// respawn + restore instrument từ bridge_state.json).
|
||
if(st.sample_rate&&st.sample_rate!==audioCtx.sampleRate){try{console.log('[Bridge] Requesting restart to match sampleRate:',audioCtx.sampleRate,'(bridge was',st.sample_rate+')');await window.__TAURI__.core.invoke('restart_bridge_with_sample_rate',{sampleRate:audioCtx.sampleRate});// Bridge restart → mất hết instrument → reset map để reconnect reload.
|
||
window.__bridgeLoadedChannels={};window.__bridgeLoadedPaths={};for(var retry=0;retry<10;retry++){await new Promise(r=>setTimeout(r,100));var st2=await window.NativeBridgeService.queryStatus();if(st2&&st2.connected){console.log('[Bridge] Reconnected at sampleRate:',audioCtx.sampleRate);break;}}}catch(err){console.warn('[Bridge] restart error:',err);}}else{console.log('[Bridge] sampleRate match — skip restart:',audioCtx.sampleRate);}window.BridgeAudioNode.init(audioCtx);window.NativeBridgeService.onAudio(function(l,r){try{if(_dbgFirstAudioPending){_dbgFirstAudioPending=false;_dbgLog('play_first_audio','delta_ms='+Math.round(performance.now()-_dbgPlayStartMs));}if(_dbgFirstAudiblePending&&l&&r){var _nz=false;for(var _i=0;_i<l.length;_i++){if(l[_i]!==0||r[_i]!==0){_nz=true;break;}}if(_nz){_dbgFirstAudiblePending=false;_dbgLog('play_first_audible','delta_ms='+Math.round(performance.now()-_dbgPlayStartMs));}}try{if(window.__dbgPreviewAudioArmed){const _p=window.__dbgPreviewAudioArmed;_p.count++;if(l&&r){for(var _j=0;_j<l.length;_j++){if(l[_j]!==0||r[_j]!==0){_p.nz++;break;}}}if(performance.now()-_p.t0>500){_dbgLog('preview_audio','frames='+_p.count+' nz='+_p.nz);window.__dbgPreviewAudioArmed=null;}}}catch(e){}window.BridgeAudioNode.onAudio(l,r);}catch(e){console.warn('[Bridge] audio callback error:',e&&e.message||e);}});// A14: plugin MIDI output -> route notes to the selected track
|
||
// (Scaler2/Instacomposer chords play the selected track's
|
||
// instrument). Feedback guard: skip events whose source channel
|
||
// is the selected track's own channel (would loop generator into
|
||
// itself). command is the VST2 status high nibble (0x90 etc.).
|
||
window.NativeBridgeService.onMidiOut(function(events){if(!events||!events.length)return;var ref=window.__selectedTrackIdRef;var tId=ref&&ref.current?ref.current:'1';for(var i=0;i<events.length;i++){var ev=events[i];var cmd;switch(ev.command){case 0x90:cmd=ev.velocity>0?'NOTE_ON':'NOTE_OFF';break;case 0x80:cmd='NOTE_OFF';break;case 0xB0:cmd='CC';break;case 0xC0:cmd='PROGRAM';break;case 0xE0:cmd='PITCH_BEND';break;default:continue;}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){var tCh=window.SonicMidiRouter.allocateChannel(tId,false);if(tCh===ev.channel)continue;// feedback guard
|
||
window.SonicMidiRouter.pushEvent({cmd:cmd,channel:tId,pitch:ev.pitch,velocity:ev.velocity/127,data2:ev.data2,data3:ev.data3,sampleOffset:ev.sample_offset||0,live:true});}console.log('[Bridge] MIDI-out',cmd,'ch',ev.channel,'note',ev.pitch,'vel',ev.velocity,'-> track',tId);}});if(window.AudioRoutingEngine)window.AudioRoutingEngine.connect(window.BridgeAudioNode,null,null);}}catch(e){console.warn('[Bridge] bootstrap error:',e);window.SonicMidiRouter.setBridgeConnected(false);}})();}return audioCtx;}const formatTime=secs=>{if(isNaN(secs)||secs<0)return"0:00.000";const m=Math.floor(secs/60);const s=Math.floor(secs%60);const ms=Math.floor(secs%1*1000).toString().padStart(3,'0');return`${m}:${s.toString().padStart(2,'0')}.${ms}`;};const formatTimeSimple=secs=>{if(isNaN(secs)||secs<0)return"0.00s";return`${secs.toFixed(2)}s`;};const formatBeat=(secs,bpmVal)=>{if(isNaN(secs)||secs<0)return"0.1.1";const beatDuration=60/bpmVal;const barDuration=beatDuration*4;const bar=Math.floor(secs/barDuration);const beat=Math.floor(secs%barDuration/beatDuration)+1;const sub=Math.floor(secs%beatDuration/(beatDuration/4))+1;return`${bar}.${beat}.${sub}`;};const midiPitchToName=pitch=>{const names=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;return names[pitch%12]+octave;};const getBeatMarkers=(maxDur,bpmVal)=>{const beatDuration=60/bpmVal;const barDuration=beatDuration*4;const markers=[];for(let t=0;t<=maxDur;t+=beatDuration){const isBar=Math.abs(t%barDuration)<0.001||Math.abs(t%barDuration-barDuration)<0.001;markers.push({time:t,isBar,beatNum:Math.floor(t/beatDuration)+1});}return markers;};const findZeroCrossing=(buffer,targetTime)=>{if(!buffer)return targetTime;const sampleRate=buffer.sampleRate;const data=buffer.getChannelData(0);const targetSample=Math.floor(targetTime*sampleRate);const windowSize=Math.floor(0.04*sampleRate);const start=Math.max(0,targetSample-windowSize);const end=Math.min(data.length-2,targetSample+windowSize);let bestSample=targetSample;let minDistance=Infinity;for(let i=start;i<=end;i++){if(data[i]>=0&&data[i+1]<=0||data[i]<=0&&data[i+1]>=0){const dist=Math.abs(i-targetSample);if(dist<minDistance){minDistance=dist;bestSample=i;}}}return bestSample/sampleRate;};class ClientMIDIRecorder{constructor(audioContext,bpm=120,timeSigNumerator=4){this.audioCtx=audioContext;this.bpm=bpm;this.timeSigNum=timeSigNumerator;this.isRecording=false;this.tempMidiItemId=null;this.activeNotes=new Map();// Store pitch -> { noteId, startBeat, velocity }
|
||
this.recordedNotes=[];this.recStartAudioTime=0.0;this.recStartBar=0.0;this.selectedMidiInputId=null;// Compute round-trip browser latency
|
||
this.latencyCompSec=(this.audioCtx.baseLatency||0)+(this.audioCtx.outputLatency||0);}start(startBar=0.0,selectedMidiInputId=null){this.isRecording=true;this.recordedNotes=[];this.activeNotes.clear();this.recStartBar=startBar;this.recStartAudioTime=this.audioCtx.currentTime;this.selectedMidiInputId=selectedMidiInputId;}handleMIDIMessage(event,sourceInputId=null){if(!this.isRecording)return;if(this.selectedMidiInputId&&this.selectedMidiInputId!=='ALL'&&sourceInputId&&sourceInputId!==this.selectedMidiInputId){console.log(`[DevLog] [MIDI Rec] Ignoring input message from "${sourceInputId}" (Selected: "${this.selectedMidiInputId}")`);return;}const[status,pitch,velocity]=event.data;const command=status>>4;// Apply latency compensation formula
|
||
const currentTimeSec=Math.max(0,this.audioCtx.currentTime-this.recStartAudioTime-this.latencyCompSec);const secondsPerBeat=60.0/this.bpm;const currentBeat=currentTimeSec/secondsPerBeat;// Command 0x9: Note On
|
||
if(command===0x9&&velocity>0){const noteId=`rec_${Date.now()}_${pitch}`;const scaledVel=Math.min(1.0,Math.max(0.5,Math.round(velocity/127.0*100)/100));const newActiveNote={id:noteId,pitch:pitch,start_beat:currentBeat,velocity:scaledVel};this.activeNotes.set(pitch,newActiveNote);console.log(`[DevLog] [MIDI Rec] Note On - Pitch: ${pitch}, Velocity: ${velocity}, StartBeat: ${currentBeat.toFixed(3)}, latencyCompSec: ${this.latencyCompSec.toFixed(3)}, noteObj:`,newActiveNote);// Fire visual feedback callback
|
||
if(this.onNoteOn){this.onNoteOn(pitch,currentBeat);}}// Command 0x8: Note Off (or Note On with velocity = 0)
|
||
else if(command===0x8||command===0x9&&velocity===0){if(this.activeNotes.has(pitch)){const note=this.activeNotes.get(pitch);const durationBeats=Math.max(0.125,currentBeat-note.start_beat);// Min 1/32 note
|
||
const finishedNote={id:note.id,pitch:note.pitch,start_beat:note.start_beat,duration_beats:durationBeats,velocity:note.velocity,pan:0.0};this.recordedNotes.push(finishedNote);this.activeNotes.delete(pitch);console.log(`[DevLog] [MIDI Rec] Note Off - Pitch: ${pitch}, DurationBeats: ${durationBeats.toFixed(3)}, FinishedNote:`,finishedNote);if(this.onNoteOff){this.onNoteOff(pitch,finishedNote);}}}}stop(){this.isRecording=false;// Flush remaining active keypresses when stop is triggered
|
||
const currentTimeSec=Math.max(0,this.audioCtx.currentTime-this.recStartAudioTime-this.latencyCompSec);const currentBeat=currentTimeSec/(60.0/this.bpm);for(let[pitch,note]of this.activeNotes.entries()){const durationBeats=Math.max(0.25,currentBeat-note.start_beat);const finishedNote={id:note.id,pitch:note.pitch,start_beat:note.start_beat,duration_beats:durationBeats,velocity:note.velocity,pan:0.0};this.recordedNotes.push(finishedNote);console.log(`[DevLog] [MIDI Rec] Flushing active keypress on stop - Pitch: ${pitch}, DurationBeats: ${durationBeats.toFixed(3)}, FinishedNote:`,finishedNote);}this.activeNotes.clear();console.log(`[DevLog] [MIDI Rec] Recording stopped. Total notes: ${this.recordedNotes.length}. List:`,this.recordedNotes);return this.recordedNotes;}}class ClientAudioRecorder{constructor(audioContext){this.audioCtx=audioContext;this.mediaStream=null;this.sourceNode=null;this.workletNode=null;this.pcmChunks=[];this.isRecording=false;}async initializeInput(deviceId=null){const constraints={audio:{deviceId:deviceId?{exact:deviceId}:undefined,echoCancellation:false,noiseSuppression:false,autoGainControl:false}};this.mediaStream=await navigator.mediaDevices.getUserMedia(constraints);this.sourceNode=this.audioCtx.createMediaStreamSource(this.mediaStream);}async start(destinationTrackGainNode=null,enableMonitoring=true){this.pcmChunks=[];this.isRecording=true;// Load Worklet Processor Module
|
||
await this.audioCtx.audioWorklet.addModule('/static/processors/pcm-recorder-processor.js');this.workletNode=new AudioWorkletNode(this.audioCtx,'pcm-recorder-processor');// Receive PCM data streams from AudioWorklet
|
||
this.workletNode.port.onmessage=event=>{if(this.isRecording&&event.data.type==='PCM_DATA'){const chunk=new Float32Array(event.data.buffer);this.pcmChunks.push(chunk);// VU Meter Level Callback
|
||
if(this.onLevelUpdate){let sum=0;for(let i=0;i<chunk.length;i++){sum+=chunk[i]*chunk[i];}const rms=Math.sqrt(sum/chunk.length);const db=rms>0?20*Math.log10(rms):-96;this.onLevelUpdate(db);}// Live visualization Callback
|
||
if(this.onPCMChunk){this.onPCMChunk(chunk);}}};// Route Audio Nodes
|
||
this.sourceNode.connect(this.workletNode);// Enable Live Input Monitoring if requested
|
||
if(enableMonitoring&&destinationTrackGainNode){this.sourceNode.connect(destinationTrackGainNode);}}async stop(){this.isRecording=false;if(this.sourceNode&&this.workletNode){try{this.sourceNode.disconnect(this.workletNode);}catch(e){}}if(this.mediaStream){this.mediaStream.getTracks().forEach(track=>track.stop());}// Concatenate PCM Float32Array chunks into a single AudioBuffer
|
||
const totalSamples=this.pcmChunks.reduce((sum,chunk)=>sum+chunk.length,0);if(totalSamples===0)return null;const audioBuffer=this.audioCtx.createBuffer(1,totalSamples,this.audioCtx.sampleRate);const channelData=audioBuffer.getChannelData(0);let offset=0;for(const chunk of this.pcmChunks){channelData.set(chunk,offset);offset+=chunk.length;}return audioBuffer;// Return compiled AudioBuffer for timeline insertion
|
||
}}const VolumeKnob=({value,onChange,min=0,max=1})=>{const[isDragging,setIsDragging]=useState(false);const startY=useRef(0);const startValue=useRef(0);const rotation=useMemo(()=>{const percent=(value-min)/(max-min);return-135+percent*270;},[value,min,max]);const handleMouseDown=e=>{setIsDragging(true);startY.current=e.clientY;startValue.current=value;document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleMouseMove=e=>{const deltaY=startY.current-e.clientY;const sensitivity=0.005;const newValue=Math.max(min,Math.min(max,startValue.current+deltaY*sensitivity));onChange(parseFloat(newValue.toFixed(2)));};const handleMouseUp=()=>{setIsDragging(false);document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};return/*#__PURE__*/React.createElement("div",{className:"knob-container cursor-ns-resize flex flex-col items-center",onMouseDown:handleMouseDown,title:`Volume: ${Math.round(value*100)}%`},/*#__PURE__*/React.createElement("svg",{className:"w-7 h-7",viewBox:"0 0 40 40"},/*#__PURE__*/React.createElement("circle",{cx:"20",cy:"20",r:"16",fill:"#141414",stroke:"#444",strokeWidth:"2"}),/*#__PURE__*/React.createElement("g",{transform:`rotate(${rotation} 20 20)`,className:"knob-dial"},/*#__PURE__*/React.createElement("line",{x1:"20",y1:"20",x2:"20",y2:"6",stroke:"#ef4444",strokeWidth:"3",strokeLinecap:"round"}))));};const MixerStrip=({track,index,onUpdateTrack,trackVuRefs})=>{const dbLabel=track.volumeDb==null||track.volumeDb<=-50?'-inf':(track.volumeDb>0?'+':'')+(track.volumeDb||0).toFixed(1)+'dB';const isMuted=track.muted;const isSoloed=track.solo;const isAudioBypassed=!!track.audioBypass;const isMidiBypassed=!!track.midiBypass;const vol=track.volumeDb!=null?track.volumeDb:0;var pct=Math.max(0,Math.min(100,(vol+60)/72*100));var vuColor=pct>=80?'#ef4444':pct>=50?'#eab308':'#22c55e';var trackColor=track.color||'#06b6d4';return React.createElement("div",{className:"flex flex-col items-stretch w-[84px] shrink-0 bg-[#2b2b2b] border border-black/70 overflow-hidden rounded-sm"},React.createElement("div",{className:"flex items-center justify-between px-1 py-0.5 bg-[#222] border-b border-black/60 shrink-0"},React.createElement("span",{className:"text-[9px] font-mono font-bold text-zinc-400"},index+1)),React.createElement("div",{className:"flex items-center justify-center gap-1 py-0.5 shrink-0"},React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!track.muted;if(onUpdateTrack)onUpdateTrack(track.id,{muted:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:next});},title:"Mute",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isMuted?'bg-orange-500 text-black border-orange-400':'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')},"M"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!track.solo;if(onUpdateTrack)onUpdateTrack(track.id,{solo:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:next});},title:"Solo",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isSoloed?'bg-yellow-400 text-black border-yellow-300':'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')},"S"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!(track.audioBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{audioBypass:next});// Live audio re-route (applies immediately to playing tracks).
|
||
if(window.__setTrackBypass)window.__setTrackBypass(track.id,'audio',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},title:"A = Mastering FX Chain cho audio items (clips + sections): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG XANH = qua mastering",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isAudioBypassed?'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100':'bg-sky-400 text-black border-sky-300')},"A"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!(track.midiBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{midiBypass:next});if(window.__setTrackBypass)window.__setTrackBypass(track.id,'midi',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},title:"♪ = Mastering FX Chain cho MIDI (soundfont): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG TÍM = qua mastering",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isMidiBypassed?'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100':'bg-fuchsia-400 text-black border-fuchsia-300')},"\u266A")),React.createElement("div",{className:"flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"},React.createElement("div",{className:"w-[30px] rounded-sm relative overflow-hidden bg-[#0d0d0d] border border-black/60 flex flex-col items-center cursor-pointer",onMouseDown:function(e){e.preventDefault();var rect=e.currentTarget.getBoundingClientRect();var tid=track.id;function onMove(ev){var pct=1-Math.max(0,Math.min(1,(ev.clientY-rect.top)/rect.height));var val=Math.round((pct*72-60)*2)/2;if(onUpdateTrack)onUpdateTrack(tid,{volumeDb:val});}function onUp(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);onMove(e);}},/* 0dB reference line */React.createElement("div",{className:"absolute w-full h-px bg-amber-400/60 z-10 pointer-events-none",style:{bottom:'83.333%'}}),/* Background gradient */React.createElement("div",{className:"absolute inset-0",style:{background:'linear-gradient(to top, #22c55e, #eab308, #ef4444)'}}),/* Level overlay - dark at TOP, gradient visible at bottom */React.createElement("div",{className:"absolute top-0 w-full transition-all duration-75 bg-[#0d0d0d]",style:{height:100-pct+'%'}})),React.createElement("canvas",{ref:el=>{if(el)trackVuRefs.current[track.id+'_mixer']=el;else delete trackVuRefs.current[track.id+'_mixer'];},width:15,height:120,className:"w-[15px] rounded-sm bg-[#0d0d0d] border border-black/60 block h-full"})),React.createElement("div",{className:"text-center text-[9px] font-mono font-bold py-0.5 "+(vol>0?'text-orange-400':'text-zinc-300')+" bg-[#1c1c1c] border-t border-black/50 shrink-0"},dbLabel),React.createElement("div",{className:"text-[8px] font-mono truncate w-full text-center px-1 py-0.5 bg-[#222] border-t border-black/60 shrink-0",style:{color:trackColor}},track.name));};// ── Gap 11: A/B Reference Compare (so LUFS/dBTP/tone voi track tham chieu) ──
|
||
const RefCompareModal=({open,onClose})=>{const[path,setPath]=React.useState('');const[refStats,setRefStats]=React.useState(null);const[masterStats,setMasterStats]=React.useState(null);const[busy,setBusy]=React.useState(false);const[ab,setAb]=React.useState('A');const audioRef=React.useRef(null);const fileInputRef=React.useRef(null);const stopRef=()=>{if(audioRef.current){try{audioRef.current.pause();}catch(e){}if(audioRef.current._objUrl){try{URL.revokeObjectURL(audioRef.current._objUrl);}catch(e){}}audioRef.current=null;}};React.useEffect(()=>()=>stopRef(),[]);React.useEffect(()=>{if(open){setMasterStats(window.__getLastMasterStats&&window.__getLastMasterStats()||null);setRefStats(null);setAb('A');stopRef();}// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[open]);const analyze=async src=>{setBusy(true);try{const res=await window.SonicAPI.referenceAnalyze(src);setRefStats(res);setMasterStats(window.__getLastMasterStats&&window.__getLastMasterStats()||null);}catch(e){window.showToast&&window.showToast('Analyze loi: '+(e.message||e),'error');}finally{setBusy(false);}};const onPickFile=f=>{if(!f)return;const fd=new FormData();fd.append('file',f);setBusy(true);const token=localStorage.getItem('sonic_token')||'';fetch((window.API_BASE_URL||'')+'/api/v1/audio/upload',{method:'POST',headers:token?{'Authorization':'Bearer '+token}:{},body:fd}).then(r=>r.json().then(d=>({ok:r.ok,d}))).then(({ok,d})=>{if(!ok)throw new Error(d.detail||'Upload loi');return analyze({file_id:d.file_id});}).catch(e=>{window.showToast&&window.showToast('Upload loi: '+(e.message||e),'error');setBusy(false);});};const playB=async()=>{if(!refStats)return;stopRef();try{// media/file va download deu can token -> fetch (Authorization header)
|
||
// roi play qua blob URL (Audio element khong gui duoc header).
|
||
const token=localStorage.getItem('sonic_token')||'';const resp=await fetch((window.API_BASE_URL||'')+refStats.url,{headers:token?{'Authorization':'Bearer '+token}:{}});if(!resp.ok)throw new Error('HTTP '+resp.status);const blob=await resp.blob();const objUrl=URL.createObjectURL(blob);const au=new Audio(objUrl);au._objUrl=objUrl;audioRef.current=au;au.loop=true;au.play().then(()=>setAb('B')).catch(()=>{});}catch(e){window.showToast&&window.showToast('Play ref loi: '+(e.message||e),'error');}};const fmt=(v,suf)=>v===null||v===undefined?'--':v+suf;const dlt=(a,b)=>a===null||a===undefined||b===null||b===undefined?'--':(a-b>0?'+':'')+(a-b).toFixed(1);return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[115] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl p-4 space-y-3 shadow-2xl",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-white tracking-wider"},"A/B REFERENCE"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-500 hover:text-slate-300"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"flex gap-1.5"},/*#__PURE__*/React.createElement("input",{value:path,onChange:e=>setPath(e.target.value),placeholder:"Local path (media/browse) hoac file_id",className:"flex-1 bg-[#141414] border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300 focus:outline-none"}),/*#__PURE__*/React.createElement("button",{onClick:()=>path&&analyze({path}),disabled:busy,className:"px-2 py-1 bg-cyan-700 hover:bg-cyan-600 text-white rounded text-xs font-bold"},busy?'...':'Analyze')),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-500 text-center"},"- hoac -"),/*#__PURE__*/React.createElement("button",{onClick:()=>fileInputRef.current&&fileInputRef.current.click(),className:"w-full py-1.5 bg-slate-800 hover:bg-slate-700 text-zinc-200 rounded text-xs font-bold border border-slate-700"},"Upload reference file"),/*#__PURE__*/React.createElement("input",{ref:fileInputRef,type:"file",accept:"audio/*,.wav,.mp3,.flac,.ogg,.aiff,.m4a,.aac,.opus",className:"hidden",onChange:e=>{const f=e.target.files&&e.target.files[0];onPickFile(f);e.target.value='';}}),(refStats||masterStats)&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-1.5 text-[10px] text-center"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800 rounded p-1.5"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold mb-1"},"METER"),/*#__PURE__*/React.createElement("div",{className:"text-emerald-400 font-bold"},"LUFS"),/*#__PURE__*/React.createElement("div",{className:"text-slate-300 font-bold"},"dBTP"),/*#__PURE__*/React.createElement("div",{className:"text-slate-400"},"Tone")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800 rounded p-1.5"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold mb-1"},"MASTER"),/*#__PURE__*/React.createElement("div",{className:"text-cyan-300 font-bold"},fmt(masterStats&&masterStats.lufs,'')),/*#__PURE__*/React.createElement("div",{className:"text-cyan-300 font-bold"},fmt(masterStats&&masterStats.true_peak_db,'')),/*#__PURE__*/React.createElement("div",{className:"text-slate-400"},"-")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800 rounded p-1.5"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold mb-1"},"REF"),/*#__PURE__*/React.createElement("div",{className:"text-fuchsia-300 font-bold"},fmt(refStats&&refStats.lufs,'')),/*#__PURE__*/React.createElement("div",{className:"text-fuchsia-300 font-bold"},fmt(refStats&&refStats.true_peak_db,'')),/*#__PURE__*/React.createElement("div",{className:"text-slate-400"},fmt(refStats&&refStats.centroid_hz,'Hz')))),refStats&&/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-400 text-center"},"Delta: LUFS ",dlt(masterStats&&masterStats.lufs,refStats.lufs)," / dBTP ",dlt(masterStats&&masterStats.true_peak_db,refStats.true_peak_db)," \xA0(+ = master louder)",/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500"},refStats.filename," \xB7 ",refStats.duration,"s \xB7 ",refStats.sample_rate,"Hz \xB7 ",refStats.channels,"ch")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1.5 pt-1"},/*#__PURE__*/React.createElement("button",{onClick:()=>{stopRef();setAb('A');},className:`py-1.5 rounded text-[10px] font-bold border ${ab==='A'?'bg-cyan-600 border-cyan-500 text-white':'bg-slate-800 text-slate-300 border-slate-700'}`},"A \xB7 Master"),/*#__PURE__*/React.createElement("button",{onClick:playB,disabled:!refStats,className:`py-1.5 rounded text-[10px] font-bold border ${ab==='B'?'bg-fuchsia-600 border-fuchsia-500 text-white':'bg-slate-800 text-slate-300 border-slate-700'}`},"B \xB7 Reference"))));};// ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ──
|
||
const MasterStripConsole=({masterVolume,setMasterVolume,masterVolL,setMasterVolL,masterVolR,setMasterVolR,masterLink,setMasterLink,masterMuted,setMasterMuted,masterAutoVol,setMasterAutoVol,masterAutoOn,setMasterAutoOn,showMasteringModal,setShowMasteringModal,masteringSettings,setMasteringSettings,isPlaying})=>{const[isTestPlaying,setIsTestPlaying]=React.useState(false);const[isMono,setIsMono]=React.useState(false);const[pan,setPan]=React.useState(0.0);const[panText,setPanText]=React.useState('center');const vuCanvasRef=React.useRef(null);const animFrameRef=React.useRef(null);const rmsValRef=React.useRef(null);const peakLRef=React.useRef(null);const peakRRef=React.useRef(null);const panPointerRef=React.useRef(null);const isMasterActive=masteringSettings?masteringSettings.masterConnected:false;const panStartYRef=React.useRef(0);const startPanValRef=React.useRef(0);const[refOpen,setRefOpen]=React.useState(false);const[autoOpen,setAutoOpen]=React.useState(false);const addAutoPoint=()=>{const pts=(masterAutoVol||[]).slice();pts.push({time:Math.round(pts.reduce((m,p)=>Math.max(m,p.time||0),0)+1),db:0});setMasterAutoVol(pts);};const updAutoPoint=(i,key,val)=>{const pts=(masterAutoVol||[]).slice();pts[i]=Object.assign({},pts[i],{[key]:val});setMasterAutoVol(pts);};const delAutoPoint=i=>setMasterAutoVol((masterAutoVol||[]).filter((p,j)=>j!==i));const ensureAudio=()=>{getAudioContext();};const handleFaderChangeL=val=>{val=Math.max(-60,Math.min(12,val));ensureAudio();setMasterVolL(val);if(masterLink)setMasterVolR(Math.max(-60,Math.min(12,val+(masterVolR-masterVolL))));};const handleFaderChangeR=val=>{val=Math.max(-60,Math.min(12,val));ensureAudio();setMasterVolR(val);if(masterLink)setMasterVolL(Math.max(-60,Math.min(12,val-(masterVolR-masterVolL))));};// Double-click reset: ca 2 ve 0
|
||
const handleFaderChange=val=>{handleFaderChangeL(val);handleFaderChangeR(val);};React.useEffect(()=>{applyMasterVolumesTo(masterBus,masterVolL,masterVolR,masterMuted);},[masterVolL,masterVolR,masterMuted]);const masterVolDisplay=masterLink?masterVolL<=-50?'-inf':(masterVolL>0?'+':'')+masterVolL.toFixed(1):'L'+(masterVolL<=-50?'-inf':masterVolL.toFixed(1))+' R'+(masterVolR<=-50?'-inf':masterVolR.toFixed(1));const handlePanPointerDown=e=>{isPanDraggingRef.current=true;panStartYRef.current=e.clientY;startPanValRef.current=pan;e.currentTarget.setPointerCapture(e.pointerId);};const handlePanPointerMove=e=>{if(!isPanDraggingRef.current)return;const deltaY=panStartYRef.current-e.clientY;let newPan=startPanValRef.current+deltaY/80;newPan=Math.min(1.0,Math.max(-1.0,newPan));setPan(newPan);const angle=newPan*120;if(panPointerRef.current)panPointerRef.current.style.transform=`rotate(${angle}deg)`;if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));};const handlePanPointerUp=e=>{isPanDraggingRef.current=false;try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(err){}};React.useEffect(()=>{const canvas=vuCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');function render(){animFrameRef.current=requestAnimationFrame(render);canvas.width=canvas.clientWidth;canvas.height=canvas.clientHeight;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);let levelL=0;let levelR=0;if(masterBus&&masterBus.leftAnalyser&&masterBus.rightAnalyser){const leftData=new Uint8Array(256);const rightData=new Uint8Array(256);masterBus.leftAnalyser.getByteTimeDomainData(leftData);masterBus.rightAnalyser.getByteTimeDomainData(rightData);let peakL=0;let peakR=0;for(let i=0;i<leftData.length;i++){const v=Math.abs(leftData[i]-128)/128;if(v>peakL)peakL=v;}for(let i=0;i<rightData.length;i++){const v=Math.abs(rightData[i]-128)/128;if(v>peakR)peakR=v;}levelL=peakL;levelR=peakR;}const padding=4;const gap=4;const barW=Math.max(4,(w-padding*2-gap)/2);const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#10b981');grad.addColorStop(0.7,'#f59e0b');grad.addColorStop(0.95,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(padding,h-levelL*h,barW,levelL*h);ctx.fillRect(padding+barW+gap,h-levelR*h,barW,levelR*h);const maxLevel=Math.max(levelL,levelR);if(rmsValRef.current){rmsValRef.current.innerText=maxLevel>0?(20*Math.log10(maxLevel)-3.2).toFixed(1)+' dB':'-inf';}if(peakLRef.current){peakLRef.current.innerText=levelL>0?(20*Math.log10(levelL)).toFixed(1)+'dB':'-inf';}if(peakRRef.current){peakRRef.current.innerText=levelR>0?(20*Math.log10(levelR)).toFixed(1)+'dB':'-inf';}}render();return()=>{if(animFrameRef.current)cancelAnimationFrame(animFrameRef.current);};},[masterVolL,masterVolR,masterMuted,isMono]);return React.createElement(React.Fragment,null,React.createElement("div",{className:"flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden"},React.createElement("div",{className:"flex flex-col items-center my-0.5"},React.createElement("span",{className:"text-[9px] text-slate-400 font-mono"},panText||'center'),React.createElement("div",{className:"flex items-center gap-1"},React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-right"},pan<0?'L'+Math.abs(Math.round(pan*100)):''),React.createElement("div",{id:"panDial",className:"w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer",title:"Kéo chuột để chỉnh Pan (Left/Right)",onPointerDown:handlePanPointerDown,onPointerMove:handlePanPointerMove,onPointerUp:handlePanPointerUp,onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.05:0.05;var newPan=Math.max(-1,Math.min(1,pan+step));newPan=Math.round(newPan*100)/100;setPan(newPan);if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+newPan*120+'deg)';if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));},onDoubleClick:function(){setPan(0);setPanText('center');if(panPointerRef.current)panPointerRef.current.style.transform='rotate(0deg)';}},React.createElement("div",{ref:panPointerRef,id:"panPointer",className:"w-0.5 h-2 bg-slate-200 rounded absolute top-0.5 transition-transform",style:{transform:'rotate('+pan*120+'deg)'}})),React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-left"},pan>0?'R'+Math.round(pan*100):''),React.createElement("span",{ref:React.createRef?null:null,className:"text-[10px] font-bold font-mono text-slate-200 ml-8",onDoubleClick:function(){handleFaderChange(0);}},masterVolDisplay))),React.createElement("div",{className:"flex gap-1 my-1 justify-between items-stretch min-h-0",style:{flex:'1 1 0%'}},React.createElement("div",{className:"flex-1 flex flex-col bg-black/80 p-1.5 rounded border border-slate-800/90 relative shadow-inner"},React.createElement("div",{className:"flex-1 flex items-stretch justify-between min-h-0"},React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pr-1 text-right border-r border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-54")),React.createElement("div",{className:"flex-1 relative bg-slate-950 rounded overflow-hidden mx-1.5 border border-slate-900"},React.createElement("canvas",{ref:vuCanvasRef,className:"w-[100px] h-full block"}),React.createElement("div",{className:"absolute bottom-0.5 inset-x-0 flex justify-around text-[7px] font-mono text-slate-500 font-bold pointer-events-none bg-black/60 py-0.5"},React.createElement("span",null,"L"),React.createElement("span",null,"R"))),React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pl-1 text-left border-l border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-54"))),React.createElement("div",{className:"flex justify-between text-[9px] font-mono text-slate-400 mt-0.5"},React.createElement("span",{ref:peakLRef},"-inf"),React.createElement("span",{ref:peakRRef},"-inf"))),React.createElement("div",{className:"w-36 flex items-stretch gap-0.5 bg-slate-900/60 p-1 rounded border border-slate-800"},React.createElement("div",{className:"flex-1 flex flex-col items-center justify-center relative fader-track rounded",onWheel:function(e){e.preventDefault();handleFaderChangeL(masterVolL+(e.deltaY>0?-0.5:0.5));}},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute left-1/2 -translate-x-1/2 top-0"}),React.createElement("input",{id:"masterFaderL",type:"range",min:"-60",max:"12",step:"0.5",value:masterVolL,className:"fader-slider w-full z-10",orient:"vertical",onChange:function(e){handleFaderChangeL(parseFloat(e.target.value));},onDoubleClick:function(){handleFaderChangeL(0);}}),React.createElement("span",{className:"text-[7px] font-mono text-slate-500 leading-none mt-0.5"},"L")),React.createElement("div",{className:"relative w-5 text-[7px] font-mono text-slate-500 select-none overflow-hidden"},React.createElement("span",{className:"absolute",style:{top:'0%',right:'2px'}},"+12"),React.createElement("span",{className:"absolute",style:{top:'8.3%',right:'2px'}},"+6"),React.createElement("span",{className:"absolute",style:{top:'16.7%',right:'2px'}},"0"),React.createElement("span",{className:"absolute",style:{top:'25%',right:'2px'}},"-6"),React.createElement("span",{className:"absolute",style:{top:'33.3%',right:'2px'}},"-12"),React.createElement("span",{className:"absolute",style:{top:'50%',right:'2px'}},"-24"),React.createElement("span",{className:"absolute",style:{top:'66.7%',right:'2px'}},"-36"),React.createElement("span",{className:"absolute",style:{top:'91.7%',right:'2px'}},"-54")),React.createElement("div",{className:"flex-1 flex flex-col items-center justify-center relative fader-track rounded",onWheel:function(e){e.preventDefault();handleFaderChangeR(masterVolR+(e.deltaY>0?-0.5:0.5));}},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute left-1/2 -translate-x-1/2 top-0"}),React.createElement("input",{id:"masterFaderR",type:"range",min:"-60",max:"12",step:"0.5",value:masterVolR,className:"fader-slider w-full z-10",orient:"vertical",onChange:function(e){handleFaderChangeR(parseFloat(e.target.value));},onDoubleClick:function(){handleFaderChangeR(0);}}),React.createElement("span",{className:"text-[7px] font-mono text-slate-500 leading-none mt-0.5"},"R"))),React.createElement("div",{className:"w-8 flex flex-col justify-between text-[10px] font-bold"},React.createElement("button",{id:"monoBtn",onClick:function(){setIsMono(function(p){return!p;});},className:"btn-daw h-[18px] rounded flex flex-col items-center justify-center text-[8px]"+(isMono?" btn-mono-active":""),title:"Mono Switch"},React.createElement("i",{className:"fa-solid fa-circle-half-stroke text-[9px]"}),React.createElement("span",null,"MONO")),React.createElement("button",{id:"muteBtn",onClick:function(){setMasterMuted(function(p){return!p;});},className:"btn-daw h-[18px] rounded text-amber-500 font-bold hover:text-amber-400"+(masterMuted?" btn-mute-active":""),title:"Mute Master Output (that: zero output)"},"M"),React.createElement("button",{id:"linkBtn",onClick:function(){setMasterLink(function(p){return!p;});},className:"btn-daw h-[18px] rounded text-xs font-bold"+(masterLink?" btn-teal-active":""),title:"LINK: keo L/R cung luc (giu delta)"},"LK"),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 hover:text-slate-200",title:"Route Matrix"},React.createElement("i",{className:"fa-solid fa-diagram-project text-[9px]"})),React.createElement("button",{id:"fxBtn",className:"btn-daw h-[18px] rounded font-extrabold text-[10px] transition-all",onClick:function(){try{_mdiagSample('fx_click');}catch(e){}setShowMasteringModal(true);},title:"Mở MASTER FX CHAIN để chỉnh sửa"},"FX"),React.createElement("button",{id:"powerBtn",className:"btn-daw h-[18px] rounded text-xs transition-all"+(isMasterActive?" btn-teal-active":""),onClick:function(){try{if(window.__phArmPwr)window.__phArmPwr(isMasterActive?'off':'on');}catch(e){}if(setMasteringSettings){setMasteringSettings(function(prev){return Object.assign({},prev,{masterConnected:!prev.masterConnected,isBypassed:false});});}},title:"Bật/Tắt MASTER FX CHAIN Bypass"},[React.createElement("i",{className:"fa-solid fa-power-off"+(isMasterActive?" text-emerald-400":" text-slate-500"),key:"ico"}),React.createElement("span",{key:"lbl",className:"text-[7px] font-bold"+(isMasterActive?" text-emerald-300":" text-slate-400")},"PWR")]),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 text-[8px]",title:"Trim Envelope"},"TRIM"),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 text-[9px]",title:"Session Info"},React.createElement("i",{className:"fa-solid fa-info"})))),React.createElement("div",{className:"flex flex-col gap-0.5 my-1 shrink-0"},React.createElement("div",{className:"flex items-center justify-between gap-1"},React.createElement("button",{id:"automBtn",onClick:function(){setAutoOpen(function(p){return!p;});},className:"btn-daw rounded px-1 py-0.5 text-[8px] font-bold"+(autoOpen?" btn-teal-active":""),title:"Master Volume Automation (Gap 7)"},"AUTOM"),React.createElement("button",{onClick:function(){setMasterAutoOn(function(p){return!p;});},className:"text-[8px] px-1 py-0.5 rounded font-mono"+(masterAutoOn?" text-emerald-400 bg-emerald-500/10":" text-slate-500"),title:"Bat/tat master volume automation"},masterAutoOn?"ON":"OFF")),autoOpen&&React.createElement("div",{className:"flex flex-col gap-0.5 bg-slate-950 border border-slate-800 rounded p-1 max-h-32 overflow-y-auto"},(masterAutoVol||[]).map(function(pt,i){return React.createElement("div",{key:i,className:"flex items-center gap-1"},React.createElement("input",{type:"number",step:"0.1",min:"0",value:pt.time,onChange:function(e){updAutoPoint(i,'time',parseFloat(e.target.value)||0);},className:"w-12 bg-slate-900 border border-slate-700 rounded px-0.5 text-[8px] font-mono text-slate-200"}),React.createElement("span",{className:"text-[8px] text-slate-500"},"s"),React.createElement("input",{type:"number",step:"0.5",min:"-30",max:"3",value:pt.db,onChange:function(e){updAutoPoint(i,'db',parseFloat(e.target.value)||0);},className:"w-12 bg-slate-900 border border-slate-700 rounded px-0.5 text-[8px] font-mono text-slate-200"}),React.createElement("span",{className:"text-[8px] text-slate-500"},"dB"),React.createElement("button",{onClick:function(){delAutoPoint(i);},className:"text-[8px] text-red-400 hover:text-red-300 ml-auto"},"x"));}),React.createElement("button",{onClick:addAutoPoint,className:"text-[8px] text-cyan-400 hover:text-cyan-300 mt-0.5"},"+ ADD POINT"))),React.createElement("div",{className:"text-center text-[10px] font-bold font-mono text-slate-200 shrink-0"},masterVolDisplay),React.createElement("div",{className:"shrink-0 border-t border-slate-800 flex flex-col items-center"},React.createElement("div",{className:"flex justify-between w-full text-[9px] font-mono py-0.5"},React.createElement("span",{className:"text-emerald-400"},"RMS"),React.createElement("span",{ref:rmsValRef,className:"text-emerald-400 font-bold"},"-inf")),React.createElement("div",{className:"w-full text-center bg-black/80 py-0.5 rounded border border-slate-800 text-[10px] font-extrabold tracking-widest text-slate-100 uppercase"},React.createElement("span",null,"MAIN OUT")))),refOpen&&React.createElement(RefCompareModal,{open:refOpen,onClose:function(){setRefOpen(false);}}));};// ── Track Strip Console Component (from md/48_TRACK_STRIP_COMPONENT.md) ──
|
||
const TrackStripConsole=({track,index,onUpdateTrack,trackVuRefs,style})=>{var vol=track.volumeDb!=null?track.volumeDb:0;var trackColor=track.color||'#06b6d4';var isMuted=track.muted;var isSoloed=track.solo;var isBypassed=!!(track.audioBypass??track.masteringBypass);var isArmed=track.isArmed;var trackName=track.name||'Track '+(index+1);var isMicActive=track.inputSource?.deviceType==='MICROPHONE';const[pan,setPan]=React.useState(0.0);const[panLabel,setPanLabel]=React.useState('center');const[isPhaseInverted,setIsPhaseInverted]=React.useState(false);const panPointerRef=React.useRef(null);const setVuCanvas=React.useCallback(function(el){if(el)trackVuRefs.current[track.id+'_mixer']=el;else delete trackVuRefs.current[track.id+'_mixer'];},[track.id,trackVuRefs]);// Track FX chain editor (mastering_expand.md §II.4): reuse the same module
|
||
// types as the mastering suite inside this track's FX chain.
|
||
var FX_MODULE_TYPES=['compressor','limiter','exciter','rebalance','eq'];const updateFxChain=function(nextChain){if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:nextChain});};const handlePanPointerDown=e=>{e.currentTarget._panStartY=e.clientY;e.currentTarget._startPan=pan;e.currentTarget.setPointerCapture(e.pointerId);function onMove(ev){if(!e.currentTarget)return;var deltaY=e.currentTarget._panStartY-ev.clientY;var newPan=Math.min(1.0,Math.max(-1.0,e.currentTarget._startPan+deltaY/80));setPan(newPan);var angle=newPan*120;if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+angle+'deg)';if(newPan===0)setPanLabel('center');else if(newPan<0)setPanLabel('L'+Math.abs(Math.round(newPan*100)));else setPanLabel('R'+Math.round(newPan*100));}function onUp(){document.removeEventListener('pointermove',onMove);document.removeEventListener('pointerup',onUp);}document.addEventListener('pointermove',onMove);document.addEventListener('pointerup',onUp);};return React.createElement("div",{style:style||undefined,className:"flex flex-col items-stretch w-[145px] shrink-0 strip-bg-track rounded-md text-slate-300 select-none shadow-2xl border border-slate-900 overflow-y-auto"},/* 1. Top Track Color Accent Bar */React.createElement("div",{className:"h-1.5 w-full shrink-0 transition-colors",style:{backgroundColor:trackColor}}),/* 2. Pan Rotary Dial Area */React.createElement("div",{className:"h-[46px] shrink-0 py-1 px-2 flex flex-col items-center justify-center border-b border-slate-700/40",style:{backgroundColor:trackColor+'15'}},React.createElement("div",{className:"w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-400 relative flex items-center justify-center cursor-pointer shadow-md",title:"Kéo chuột lên/xuống để chỉnh Pan",onPointerDown:handlePanPointerDown,onDoubleClick:function(){setPan(0);setPanLabel('center');if(panPointerRef.current)panPointerRef.current.style.transform='rotate(0deg)';},onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.05:0.05;var newPan=Math.max(-1,Math.min(1,pan+step));newPan=Math.round(newPan*100)/100;setPan(newPan);if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+newPan*120+'deg)';if(newPan===0)setPanLabel('center');else if(newPan<0)setPanLabel('L'+Math.abs(Math.round(newPan*100)));else setPanLabel('R'+Math.round(newPan*100));}},React.createElement("div",{ref:panPointerRef,className:"w-0.5 h-2 rounded absolute top-0.5 transition-transform",style:{backgroundColor:trackColor,transform:'rotate(0deg)'}})),React.createElement("span",{className:"text-[8px] font-mono mt-0.5 font-semibold",style:{color:trackColor}},panLabel)),/* 3. Center Area: Peak dB + Fader + VU + Button Stack */React.createElement("div",{className:"flex-1 p-1 flex gap-1 justify-between items-stretch min-h-[170px]"},/* Left Fader & VU Column */React.createElement("div",{className:"flex-1 flex flex-col justify-between items-center bg-black/60 p-1 rounded border border-slate-800/80"},React.createElement("div",{className:"w-full flex justify-center text-[8px] font-mono text-slate-400 h-4 items-center"},React.createElement("span",null,vol<=-50?'-inf':(vol>0?'+':'')+vol.toFixed(1)+'dB')),React.createElement("div",{className:"flex items-stretch justify-around w-full flex-1 relative py-1"},/* Fader Rail */React.createElement("div",{className:"relative fader-track-bg w-3 flex-1 rounded flex items-center justify-center overflow-hidden"},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute"}),React.createElement("input",{type:"range",min:"-60",max:"12",step:"0.5",value:vol,className:"fader-slider w-full z-10",onChange:function(e){var val=parseFloat(e.target.value);if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:val});},onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.5:0.5;var newVol=Math.max(-60,Math.min(12,vol+step));if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:newVol});},onDoubleClick:function(){if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:0});}})),/* VU Meter */React.createElement("div",{className:"w-2.5 flex-1 bg-slate-950 rounded border border-slate-900 overflow-hidden relative",title:"Peak VU Meter"},React.createElement("canvas",{ref:setVuCanvas,className:"w-full h-full block"})))),/* Right Button Stack */React.createElement("div",{className:"w-7 flex flex-col justify-between text-[8px] font-bold shrink-0"},React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!track.muted;if(onUpdateTrack)onUpdateTrack(track.id,{muted:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:next});},className:"btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center"+(isMuted?" btn-mute-active":""),title:"Mute Track"},"M"),React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!track.solo;if(onUpdateTrack)onUpdateTrack(track.id,{solo:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:next});},className:"btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center"+(isSoloed?" btn-solo-active":""),title:"Solo Track"},"S"),React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!(track.audioBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{audioBypass:next});// Live re-route: bypassed channel skips FX + mastering chain at Main out.
|
||
if(window.__setTrackBypass)window.__setTrackBypass(track.id,'audio',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},className:"btn-daw h-[24px] rounded flex items-center justify-center font-extrabold text-[9px] "+(track.audioBypass?" text-slate-500":" text-sky-300 bg-sky-500/30 border-sky-400/70"),title:"A = Mastering FX Chain cho audio items (clips + sections): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG XANH = qua mastering"},"A"),React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!(track.midiBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{midiBypass:next});if(window.__setTrackBypass)window.__setTrackBypass(track.id,'midi',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},className:"btn-daw h-[24px] rounded flex items-center justify-center font-extrabold text-[10px] "+(track.midiBypass?" text-slate-500":" text-fuchsia-300 bg-fuchsia-500/30 border-fuchsia-400/70"),title:"♪ = Mastering FX Chain cho MIDI (soundfont): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG TÍM = qua mastering"},"\u266A"),React.createElement("button",{onClick:function(){if(window.__openFxRack)window.__openFxRack(track.id,track.name);},className:"btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]"+((track.fxChain||[]).length>0?" text-cyan-400":""),title:"Track FX Chain (mastering_expand.md §II.4)"},"FX"),React.createElement("button",{onClick:function(){var next=track.fxActive===false;if(onUpdateTrack)onUpdateTrack(track.id,{fxActive:next});// Live re-route: rebuild both FX chains (audio + SF) so EVERY item
|
||
// (midi/section/audioclip) immediately follows the FX power state.
|
||
if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id,{fxActive:next});},className:"btn-daw h-[24px] rounded flex items-center justify-center text-[8px]"+(track.fxActive!==false?" text-emerald-400":" text-slate-500"),title:"Toggle FX Power: ON (sáng) = MỌI items (MIDI/section/audioclip) qua FX Rack Panel (nút A/♪ chỉ ảnh hưởng Mastering FX Chain); OFF (tối) = bỏ toàn bộ track FX"},React.createElement("i",{className:"fa-solid fa-power-off"})),React.createElement("button",{className:"btn-daw h-[24px] rounded text-slate-400 flex items-center justify-center",title:"Automation Envelopes"},React.createElement("i",{className:"fa-solid fa-chart-line text-[8px]"})),React.createElement("button",{onClick:function(){setIsPhaseInverted(function(p){return!p;});},className:"btn-daw h-[24px] rounded flex items-center justify-center text-[9px]"+(isPhaseInverted?" bg-amber-600 text-white":" text-slate-400"),title:"Phase Invert"},"\u00D8"))),/* 4. Record Arm Button Row */React.createElement("div",{className:"h-[28px] shrink-0 flex items-center justify-between mx-1.5 px-1.5 bg-black/40 rounded border border-slate-800/60"},React.createElement("button",{onClick:function(e){e.stopPropagation();if(!onUpdateTrack)return;if(track.inputSource?.deviceType==='MICROPHONE'){onUpdateTrack(track.id,{inputSource:{deviceType:'NONE',deviceId:''}});}else{onUpdateTrack(track.id,{inputSource:{deviceType:'MICROPHONE',deviceId:'default'}});}},className:"w-4 h-4 rounded flex items-center justify-center text-[7px] font-bold transition-all"+(isMicActive?" bg-sky-600 text-white border border-sky-400 shadow-sm":" bg-slate-800 text-slate-500 border border-slate-700"),title:isMicActive?"Mic Input ON":"Mic Input OFF"},"MIC"),React.createElement("button",{onClick:function(e){e.stopPropagation();if(onUpdateTrack)onUpdateTrack(track.id,{isArmed:!track.isArmed});},className:"w-5 h-5 rounded-full flex items-center justify-center transition-all shadow-inner"+(isArmed?" btn-arm-active border-red-400":" bg-red-950 border-2 border-red-800 text-red-500"),title:"Arm for Recording"},React.createElement("i",{className:"fa-solid fa-circle text-[8px]"}))),/* 5. Track Name Identifier */React.createElement("div",{className:"h-[26px] shrink-0 mx-1.5 flex items-center justify-center bg-slate-950/80 rounded border border-slate-800/80"},React.createElement("span",{className:"text-[11px] font-bold tracking-wider font-sans uppercase",style:{color:trackColor}},trackName)),/* 6. Footer Bar */React.createElement("div",{className:"h-[22px] shrink-0 w-full text-slate-950 flex items-center justify-center font-extrabold text-xs font-mono tracking-widest transition-colors",style:{backgroundColor:trackColor}},index+1));};const WaveformLane=({track,zoom,timelineWidth,viewportWidth,onSelectRange,onPlayheadSet,isSelected,onSelectTrack,markers,selectionMode,localSelectionTrackId,localSelectionStart,currentTime,getLocalAnchor,onClearLocalSelection,onDeselectItem,onAddToSelection,onSetPendingDrag,onSetPendingDragMove,onSetSelectionMode,onSetSelectionStart,onSetSelectionEnd,onSetCurrentTime,onSetLocalSelectionTrackId,onSetLocalSelectionStart,onSetLocalSelectionEnd,localSelLeft,localSelRight,onTrackLaneMouseDown,onContextMenu,onClipDragStart,onClipStretchStart,onSectionItemDragStart,onSectionItemResizeStart,onEditSectionInTab,onEditMidiInTab,onSelectionEdgeDragStart,setSelectedClipId,selectedClipId,activeTool,onSplitTrackAtTime,onEditClipInSubTab,selectedItemIds,onClearSelection,onSweepSelectStart,snapValue,bpm,scrollLeft,recordingState,recTempMidiNotes,recTempAudioBuffer,recStartTimelineTime,canvasRedrawCount})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);const leadInMargin=0;useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;let scrollLeftVal=scrollLeft||0;let el=canvas.parentElement;while(el){if(el.scrollLeft!==undefined&&(el.scrollWidth>el.clientWidth||el.scrollLeft>0)){scrollLeftVal=el.scrollLeft;break;}el=el.parentElement;}const vWidth=viewportWidth||1200;const height=canvas.parentElement?canvas.parentElement.clientHeight:96;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle=isSelected?'#2a2a2a':track.id%2===0?'#181818':'#1d1d1d';ctx.fillRect(0,0,drawWidth,height);// Grid lines based on Snap value
|
||
ctx.strokeStyle='rgba(255, 255, 255, 0.03)';ctx.lineWidth=1;const beatDuration=60.0/(parseFloat(bpm)||120);const barDuration=beatDuration*4;const leadIn=0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=0;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+CLIP_BUFFER/zoom;let snapDivisor=1;if(snapValue&&snapValue!=='free'){if(snapValue==='4')snapDivisor=4;else if(snapValue==='1')snapDivisor=1;else if(snapValue==='1/2')snapDivisor=0.5;else if(snapValue==='1/4')snapDivisor=0.25;else if(snapValue==='1/8')snapDivisor=0.125;else if(snapValue==='1/16')snapDivisor=0.0625;else if(snapValue==='1/32')snapDivisor=0.03125;}const snapInterval=beatDuration*snapDivisor;const firstSnap=Math.floor(tStart/snapInterval)*snapInterval;for(let t=firstSnap;t<=tEnd;t+=snapInterval){const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;const barNum=Math.round(t/barDuration);const onBar=Math.abs(t-barNum*barDuration)<0.001;ctx.strokeStyle=onBar?'rgba(255, 255, 255, 0.12)':'rgba(255, 255, 255, 0.04)';ctx.lineWidth=onBar?1.2:0.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();if(onBar&&zoom>=2){ctx.fillStyle='rgba(255, 255, 255, 0.15)';ctx.font='bold 7px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${barNum}`,localX+2,10);}}// Draw waveform lane
|
||
const clips=[...(track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[])];if(recordingState==='RECORDING'&&track.isArmed&&track.inputSource?.deviceType==='MICROPHONE'&&recTempAudioBuffer){clips.push({id:'rec_temp_'+track.id,buffer:recTempAudioBuffer,startTime:recStartTimelineTime,name:'[GHI ÂM...]',speed:1.0,isTemp:true});}if(clips.length>0){clips.forEach(clip=>{const hasBuffer=!!clip.buffer;const clipSpeed=clip.speed||1.0;const originalDuration=hasBuffer?clip.buffer.duration/clipSpeed:4.0;const duration=originalDuration;const clipStartTime=clip.startTime||0;const clipEndTime=clipStartTime+duration;// Culling: Skip rendering if clip is outside visible viewport window
|
||
if(clipEndTime<tStart||clipStartTime>tEnd)return;const xStartGlobal=clipStartTime*zoom;const wClip=duration*zoom;const xStartLocal=xStartGlobal-scrollLeftVal;const xEndLocal=xStartLocal+wClip;// 1. Draw Clip Layer Background & Border (always draw even without buffer)
|
||
const clipIdentifier=clip.id==='default'?'default_'+track.id:clip.id;const isClipSelected=selectedClipId&&selectedClipId.trackId===track.id&&selectedClipId.clipId===clipIdentifier||selectedItemIds&&selectedItemIds.has(clipIdentifier);ctx.fillStyle=clip.isTemp?'rgba(239, 68, 68, 0.25)':isClipSelected?track.color?track.color+'44':'rgba(6, 182, 212, 0.30)':track.color?track.color+'22':'rgba(6, 182, 212, 0.12)';ctx.strokeStyle=clip.isTemp?'#ef4444':isClipSelected?'#fbbf24':track.color||'#06b6d4';ctx.lineWidth=isClipSelected?1:1.5;const clipTop=4;const clipHeight=height-8;ctx.beginPath();if(ctx.roundRect){ctx.roundRect(xStartLocal,clipTop,wClip,clipHeight,4);}else{ctx.rect(xStartLocal,clipTop,wClip,clipHeight);}ctx.fill();ctx.stroke();// 2. Draw Clip Label & Speed Label
|
||
ctx.fillStyle='#e4e4e7';ctx.font='bold 9px sans-serif';ctx.fillText(clip.name||'Clip',Math.max(xStartLocal+8,8),clipTop+12);if(clipSpeed!==1.0){ctx.fillStyle='#fbbf24';ctx.font='bold 8px sans-serif';ctx.fillText(`Speed: ${(clipSpeed*100).toFixed(1)}%`,Math.max(xStartLocal+8,8),clipTop+22);}// Draw markers
|
||
if(markers&&markers.length>0){markers.forEach(m=>{const mxLocal=m.time*zoom-scrollLeftVal;if(mxLocal>=0&&mxLocal<=drawWidth){ctx.fillStyle='#fbbf24';ctx.fillRect(mxLocal-1,0,2,height);}});}if(!hasBuffer){ctx.fillStyle='rgba(255, 255, 255, 0.15)';ctx.font='italic 8px sans-serif';ctx.fillText('(audio chưa được tải)',Math.max(xStartLocal+8,8),clipTop+clipHeight-6);return;}const numChannels=clip.buffer.numberOfChannels||1;const dataL=clip.buffer.getChannelData(0);const dataR=numChannels>=2?clip.buffer.getChannelData(1):dataL;const sampleRate=clip.buffer.sampleRate;const totalSamples=dataL.length;// 3. Peak / Vector Waveform Drawing (Sound Forge Dual Stereo Channel Split Match)
|
||
const drawXStartLocal=Math.max(0,Math.floor(xStartLocal));const drawXEndLocal=Math.min(drawWidth,Math.ceil(xEndLocal));const samplesPerPixel=sampleRate/zoom*clipSpeed;const isStereo=numChannels>=2;const channelConfigs=isStereo?[{data:dataL,mid:height/4,chHeight:height/2-8,label:'1'},{data:dataR,mid:3*height/4,chHeight:height/2-8,label:'2'}]:[{data:dataL,mid:height/2,chHeight:clipHeight-12,label:'MONO'}];if(isStereo){ctx.strokeStyle='rgba(255, 255, 255, 0.12)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(xStartLocal,height/2);ctx.lineTo(xStartLocal+wClip,height/2);ctx.stroke();}channelConfigs.forEach(ch=>{const data=ch.data;const mid=ch.mid;const peakRatio=ch.chHeight*0.42;// Decibel Amplitude Grid Lines (+6.0dB, -Inf, -6.0dB)
|
||
const yPlus6=mid-peakRatio*0.8;const yMinus6=mid+peakRatio*0.8;ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(xStartLocal,yPlus6);ctx.lineTo(xStartLocal+wClip,yPlus6);ctx.stroke();ctx.beginPath();ctx.moveTo(xStartLocal,mid);ctx.lineTo(xStartLocal+wClip,mid);ctx.stroke();ctx.beginPath();ctx.moveTo(xStartLocal,yMinus6);ctx.lineTo(xStartLocal+wClip,yMinus6);ctx.stroke();// Decibel Text Labels (Sound Forge Monospace)
|
||
ctx.fillStyle='#71717a';ctx.font='8px monospace';const labelX=Math.max(xStartLocal+4,4);ctx.fillText('+6.0',labelX,yPlus6-2);ctx.fillText('-Inf',labelX,mid-2);ctx.fillText('-6.0',labelX,yMinus6+8);// Sound Forge Channel ID Badge (1 or 2)
|
||
if(isStereo){ctx.fillStyle='rgba(6, 182, 212, 0.85)';ctx.font='bold 9px monospace';ctx.fillText(ch.label,Math.min(xStartLocal+wClip-12,drawWidth-16),mid-ch.chHeight*0.35);}ctx.strokeStyle='#5bc0be';// Cornflower Blue
|
||
ctx.lineWidth=1.2;if(samplesPerPixel<4){// High-zoom continuous vector line rendering (#5bc0be Cornflower Blue)
|
||
const visibleTStart=(drawXStartLocal-xStartLocal)/zoom;const visibleTEnd=(drawXEndLocal-xStartLocal)/zoom;const startSample=Math.max(0,Math.floor(visibleTStart*clipSpeed*sampleRate));const endSample=Math.min(totalSamples,Math.ceil(visibleTEnd*clipSpeed*sampleRate));ctx.beginPath();let first=true;const maxSamples=50000;const vectorStep=Math.max(1,Math.floor((endSample-startSample)/maxSamples));for(let i=startSample;i<endSample;i+=vectorStep){const sampleTime=i/sampleRate/clipSpeed;const pxLocal=xStartLocal+sampleTime*zoom;const y=mid-data[i]*peakRatio;if(first){ctx.moveTo(pxLocal,y);first=false;}else{ctx.lineTo(pxLocal,y);}}ctx.stroke();// Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3)
|
||
if(samplesPerPixel<0.3){const maxNodes=5000;const step=Math.max(1,Math.floor((endSample-startSample)/maxNodes));ctx.fillStyle='#6ee7b7';let drawn=0;for(let i=startSample;i<endSample&&drawn<maxNodes;i+=step){const sampleTime=i/sampleRate/clipSpeed;const pxLocal=xStartLocal+sampleTime*zoom;const y=mid-data[i]*peakRatio;ctx.fillRect(pxLocal-1,y-1,2,2);drawn++;}}}else{// Coarse view vertical peak min/max bars per pixel
|
||
for(let pxLocal=drawXStartLocal;pxLocal<drawXEndLocal;pxLocal++){const timeInClip=(pxLocal-xStartLocal)/zoom*clipSpeed;const sampleIdx=Math.floor(timeInClip*sampleRate);const chunkSize=Math.max(1,Math.floor(samplesPerPixel));const chunkStart=Math.max(0,sampleIdx-Math.floor(chunkSize/2));const chunkEnd=Math.min(totalSamples,chunkStart+chunkSize);let maxVal=0;for(let i=chunkStart;i<chunkEnd;i++){const abs=Math.abs(data[i]);if(abs>maxVal)maxVal=abs;}const peakHeight=maxVal*peakRatio;ctx.beginPath();ctx.moveTo(pxLocal,mid-peakHeight);ctx.lineTo(pxLocal,mid+peakHeight);ctx.stroke();}}});});}else{ctx.fillStyle='#444';ctx.font='12px Inter, sans-serif';ctx.textAlign='center';ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này',drawWidth/2,height/2);}// Draw sections
|
||
const sections=track.sections||[];sections.forEach(sec=>{const secStartLocal=sec.start*zoom-scrollLeftVal;// use secStartLocal NOT secStart
|
||
const secWidth=sec.duration*zoom;if(secStartLocal+secWidth<0||secStartLocal>drawWidth)return;var isSecSelected=selectedItemIds&&selectedItemIds.has(sec.id);ctx.fillStyle=isSecSelected?'rgba(245, 158, 11, 0.35)':(sec.color||track.color||'#06b6d4')+'44';ctx.fillRect(secStartLocal,2,secWidth,height-4);ctx.strokeStyle=isSecSelected?'#f59e0b':sec.color||track.color||'#06b6d4';ctx.lineWidth=isSecSelected?2.5:1;ctx.setLineDash(isSecSelected?[]:[4,4]);ctx.strokeRect(secStartLocal,2,secWidth,height-4);ctx.setLineDash([]);ctx.fillStyle='#e4e4e7';ctx.font='bold 9px sans-serif';ctx.fillText(sec.name||'Section',Math.max(secStartLocal+4,4),14);// Draw sub-tracks within section (khôi phục 19:45 — section item phải
|
||
// vẽ lại các item chứa bên trong sau khi mở project; buffers đã được
|
||
// nạp đầy đủ bởi loadAudioBuffersForTracks recursion 19:00)
|
||
const subTracks=sec.tracks||[];const subTrackCount=Math.min(subTracks.length,4);const subTrackHeight=(height-20)/Math.max(1,subTrackCount);const subColors=['#fbbf24','#a78bfa','#ec4899','#10b981'];ctx.save();ctx.beginPath();ctx.rect(secStartLocal,2,secWidth,height-4);ctx.clip();for(let stIdx=0;stIdx<subTrackCount;stIdx++){const sub=subTracks[stIdx];if(!sub)continue;const subY=20+stIdx*subTrackHeight;ctx.fillStyle=sub.color?sub.color+'22':subColors[stIdx]+'22';ctx.fillRect(secStartLocal+1,subY,secWidth-2,subTrackHeight-1);// Draw clips as waveform bars
|
||
const subClips=sub.clips||[];subClips.forEach(cl=>{if(!cl.buffer)return;const sr=cl.buffer.sampleRate;const bufData=cl.buffer.getChannelData(0);const bufLen=bufData.length;const clStartLocal=cl.startTime||0;const clDurLocal=bufLen/sr/(cl.speed||1.0);const clStartMain=secStartLocal+clStartLocal*zoom;const clW=Math.max(1,clDurLocal*zoom);const peakSamples=Math.max(10,Math.min(100,Math.floor(clW/3)));const step=Math.max(1,Math.floor(bufLen/peakSamples));for(let p=0;p<peakSamples;p++){const sIdx=p*step;let maxVal=0;for(let j=0;j<step&&sIdx+j<bufLen;j++){const abs=Math.abs(bufData[sIdx+j]);if(abs>maxVal)maxVal=abs;}const bx=clStartMain+p/peakSamples*clW;const barH=Math.max(1,maxVal*(subTrackHeight*0.7));const barY=subY+(subTrackHeight-barH)/2;ctx.fillStyle=subColors[stIdx]+'99';ctx.fillRect(bx,barY,Math.max(1,clW/peakSamples),barH);}});// Draw MIDI items as colored note bars
|
||
const subMidi=sub.midiItems||[];subMidi.forEach(item=>{const itemStartLocal=secStartLocal+(item.startTime||0)*zoom;const itemDurLocal=(item.duration||1)*zoom;const notes=item.notes||[];const pitchMin=36;const pitchMax=84;notes.forEach(note=>{const beatSec=60.0/(parseInt(bpm)||120);const noteStartSec=(note.start_beat||0)*beatSec;// CLIP theo item duration: item bị KÉO NGẮN (trim — duration 4 bars
|
||
// nhưng notes vẫn còn 8 bars) → note ngoài duration KHÔNG vẽ —
|
||
// canvas MAIN phải hiển thị đúng phần đã trim (user 08:20)
|
||
if(noteStartSec>=(item.duration||0)-0.01)return;const noteDurSec=Math.max(0.02,(note.duration_beats||0.25)*beatSec);const noteStartLocal=itemStartLocal+noteStartSec*zoom;const nw=noteDurSec*zoom;const pitchFrac=Math.max(0,Math.min(1,(note.pitch-pitchMin)/(pitchMax-pitchMin)));const ny=subY+2+(1.0-pitchFrac)*(subTrackHeight-6);const nh=Math.max(4,(subTrackHeight-6)/(pitchMax-pitchMin)*3);ctx.fillStyle=subColors[stIdx]+'cc';ctx.fillRect(Math.max(noteStartLocal,secStartLocal+2),ny,Math.max(2,nw),nh);});});}ctx.restore();});// Draw MIDI items
|
||
const midiItems=track.midiItems||[];midiItems.forEach(midi=>{const midiStartLocal=midi.startTime*zoom-scrollLeftVal;const midiWidth=midi.duration*zoom;if(midiStartLocal+midiWidth<0||midiStartLocal>drawWidth)return;const isRecordingItem=midi.name==='Recording...';if(isRecordingItem&&Math.random()<0.05){console.log(`[DevLog] [Canvas Draw] Rendering MIDI item: ID=${midi.id}, Name="${midi.name}", Start=${midiStartLocal.toFixed(1)}px, Width=${midiWidth.toFixed(1)}px, NotesCount=${midi.notes?.length||0}`);}var isMidiSelected=selectedItemIds&&selectedItemIds.has(midi.id);ctx.fillStyle=isMidiSelected?'rgba(245, 158, 11, 0.35)':isRecordingItem?'rgba(239, 68, 68, 0.2)':(track.color||'#a78bfa')+'33';ctx.fillRect(midiStartLocal,2,midiWidth,height-4);ctx.strokeStyle=isMidiSelected?'#f59e0b':isRecordingItem?'#ef4444':track.color||'#a78bfa';ctx.lineWidth=isMidiSelected?2.5:1.5;ctx.strokeRect(midiStartLocal,2,midiWidth,height-4);ctx.fillStyle=isRecordingItem?'#fca5a5':track.color||'#a78bfa';ctx.font='bold 9px sans-serif';ctx.fillText(isRecordingItem?'[Ghi MIDI...]':midi.name||'MIDI',Math.max(midiStartLocal+4,4),14);const midiNotes=midi.notes||[];if(midiNotes.length>0){const secondsPerBeat=60.0/(parseInt(bpm)||120);const pitchMin=36;const pitchMax=84;midiNotes.forEach(note=>{const noteStartSec=(note.start_beat||0)*secondsPerBeat;const noteDurSec=Math.max(0.02,(note.duration_beats||0.25)*secondsPerBeat);const noteStartLocal=(midi.startTime+noteStartSec)*zoom-scrollLeftVal;const nw=noteDurSec*zoom;if(noteStartLocal+nw<midiStartLocal||noteStartLocal>midiStartLocal+midiWidth)return;const pitchFrac=Math.max(0,Math.min(1,(note.pitch-pitchMin)/(pitchMax-pitchMin)));const ny=18+(1.0-pitchFrac)*(height-26);const nh=Math.max(6,(height-26)/(pitchMax-pitchMin)*4);ctx.fillStyle=isRecordingItem?'#10b981':track.color||'#a78bfa';ctx.fillRect(Math.max(noteStartLocal,midiStartLocal+2),ny,Math.max(2,nw),nh);});}});// Selection highlight - local selection on this track
|
||
if(selectionMode==='local'&&localSelectionTrackId===track.id&&localSelLeft!==null&&localSelRight!==null&&localSelRight>localSelLeft){const hlLeftLocal=localSelLeft*zoom-scrollLeftVal;const hlWidth=(localSelRight-localSelLeft)*zoom;ctx.fillStyle='rgba(245, 158, 11, 0.15)';ctx.fillRect(hlLeftLocal,0,hlWidth,height);ctx.strokeStyle='#f59e0b';ctx.lineWidth=1;ctx.strokeRect(hlLeftLocal,0,hlWidth,height);}},[track,zoom,timelineWidth,viewportWidth,isSelected,markers,selectionMode,localSelectionTrackId,localSelLeft,localSelRight,snapValue,bpm,scrollLeft,recordingState,recTempMidiNotes,recStartTimelineTime,canvasRedrawCount,currentTime,selectedItemIds]);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"virtual-spacer",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseMove:e=>{if(!canvasRef.current)return;const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform
|
||
if(e.shiftKey&&e.buttons>0){e.preventDefault();e.stopPropagation();const currentAnchor=getLocalAnchor?getLocalAnchor():null;const anchor=currentAnchor!==null&¤tAnchor!==undefined?currentAnchor:localSelectionStart!==null?localSelectionStart:currentTime;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);if(onSetSelectionMode)onSetSelectionMode('local');if(onSetLocalSelectionTrackId)onSetLocalSelectionTrackId(track.id);if(onSetLocalSelectionStart)onSetLocalSelectionStart(selS);if(onSetLocalSelectionEnd)onSetLocalSelectionEnd(selE);if(onSetSelectionStart)onSetSelectionStart(selS);if(onSetSelectionEnd)onSetSelectionEnd(selE);if(onSetCurrentTime)onSetCurrentTime(time);if(onSelectTrack)onSelectTrack(track.id);return;}// Check if hovering near local selection boundaries of this track
|
||
const isLocal=selectionMode==='local'&&localSelectionTrackId===track.id;if(isLocal&&localSelLeft!==null&&localSelRight!==null){const leftPx=localSelLeft*zoom;const rightPx=localSelRight*zoom;const distToLeft=Math.abs(x-leftPx);const distToRight=Math.abs(x-rightPx);if(distToLeft<=5||distToRight<=5){canvasRef.current.style.cursor='ew-resize';return;}}// Check if hovering near right edge of a clip for time-stretching (Alt key required)
|
||
const toleranceSec=8/zoom;const rightEdgeClip=clips.find(c=>{if(!c.buffer)return false;const duration=c.buffer.duration/(c.speed||1.0);return Math.abs(time-(c.startTime+duration))<=toleranceSec;});if(rightEdgeClip&&e.altKey){canvasRef.current.style.cursor='ew-resize';return;}// Check section/MIDI item hover for resize or drag
|
||
const allSections=track.sections||[];const allMidiItems=track.midiItems||[];const sectionTolerance=8/zoom;let foundSectionItem=null;let sectionItemEdge=null;const checkEdge=(item,startTime,dur)=>{const leftEdge=Math.abs(time-startTime)<=sectionTolerance;const rightEdge=Math.abs(time-(startTime+dur))<=sectionTolerance;if(leftEdge||rightEdge)return leftEdge?'left':'right';return null;};for(const sec of allSections){const edge=checkEdge(sec,sec.start,sec.duration);if(edge){foundSectionItem={type:'section',item:sec};sectionItemEdge=edge;break;}}if(!foundSectionItem){for(const midi of allMidiItems){const edge=checkEdge(midi,midi.startTime,midi.duration);if(edge){foundSectionItem={type:'midiItem',item:midi};sectionItemEdge=edge;break;}}}if(foundSectionItem&§ionItemEdge){canvasRef.current.style.cursor='ew-resize';return;}// Check body hover for drag
|
||
if(!foundSectionItem){for(const sec of allSections){if(time>=sec.start&&time<sec.start+sec.duration){foundSectionItem={type:'section',item:sec};break;}}}if(!foundSectionItem){for(const midi of allMidiItems){if(time>=midi.startTime&&time<midi.startTime+midi.duration){foundSectionItem={type:'midiItem',item:midi};break;}}}if(foundSectionItem){canvasRef.current.style.cursor='grab';return;}const hoveredClip=clips.find(c=>c.buffer&&time>=c.startTime&&time<c.startTime+c.buffer.duration/(c.speed||1.0));const isOverClip=!!hoveredClip;if(activeTool==='pen'){canvasRef.current.style.cursor=isOverClip?'copy':'not-allowed';}else if(activeTool==='grab'){canvasRef.current.style.cursor=isOverClip?'grab':'default';}else if(activeTool==='razor'){canvasRef.current.style.cursor=isOverClip?'cell':'not-allowed';}else{// select tool
|
||
canvasRef.current.style.cursor=isOverClip&&(e.altKey||e.ctrlKey)?'grab':'crosshair';}},onMouseDown:e=>{// Ignore right-click for local selection drag (context menu handles it)
|
||
if(e.button===2)return;const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);onSelectTrack(track.id);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Check if Ctrl+Click to exit selection
|
||
if(e.ctrlKey&&selectionMode){e.preventDefault();e.stopPropagation();if(onClearLocalSelection)onClearLocalSelection();if(onSetSelectionMode)onSetSelectionMode(null);if(onSetSelectionStart)onSetSelectionStart(null);if(onSetSelectionEnd)onSetSelectionEnd(null);return;}// 1. Shift+Click (TOP PRIORITY): Range selection on track waveform
|
||
if(e.shiftKey){e.preventDefault();e.stopPropagation();const currentAnchor=getLocalAnchor?getLocalAnchor():null;const anchor=currentAnchor!==null&¤tAnchor!==undefined?currentAnchor:localSelectionStart!==null?localSelectionStart:currentTime;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);if(onSetSelectionMode)onSetSelectionMode('local');if(onSetLocalSelectionTrackId)onSetLocalSelectionTrackId(track.id);if(onSetLocalSelectionStart)onSetLocalSelectionStart(selS);if(onSetLocalSelectionEnd)onSetLocalSelectionEnd(selE);if(onSetSelectionStart)onSetSelectionStart(selS);if(onSetSelectionEnd)onSetSelectionEnd(selE);if(onSetCurrentTime)onSetCurrentTime(time);if(onSelectTrack)onSelectTrack(track.id);return;}// Check if dragging selection boundaries (local mode)
|
||
const isLocal=selectionMode==='local'&&localSelectionTrackId===track.id;if(isLocal&&localSelLeft!==null&&localSelRight!==null){const leftPx=localSelLeft*zoom;const rightPx=localSelRight*zoom;const distToLeft=Math.abs(x-leftPx);const distToRight=Math.abs(x-rightPx);if(distToLeft<=5){e.preventDefault();e.stopPropagation();if(onSelectionEdgeDragStart)onSelectionEdgeDragStart(e,track.id,'left');return;}else if(distToRight<=5){e.preventDefault();e.stopPropagation();if(onSelectionEdgeDragStart)onSelectionEdgeDragStart(e,track.id,'right');return;}}// Check if time-stretching (Alt + Right Edge)
|
||
const toleranceSec=8/zoom;const rightEdgeClip=clips.find(c=>{if(!c.buffer)return false;const duration=c.buffer.duration/(c.speed||1.0);return Math.abs(time-(c.startTime+duration))<=toleranceSec;});if(rightEdgeClip&&e.altKey){e.preventDefault();e.stopPropagation();if(onClipStretchStart){onClipStretchStart(track.id,rightEdgeClip.id,time);}return;}// Check section/MIDI item edge for resize, then body for drag
|
||
const secItems=track.sections||[];const midiItems=track.midiItems||[];const secTol=8/zoom;let hitItem=null;let hitEdge=null;for(const sec of secItems){if(Math.abs(time-sec.start)<=secTol){hitItem={type:'section',id:sec.id,start:sec.start,dur:sec.duration};hitEdge='left';break;}if(Math.abs(time-(sec.start+sec.duration))<=secTol){hitItem={type:'section',id:sec.id,start:sec.start,dur:sec.duration};hitEdge='right';break;}}if(!hitItem){for(const midi of midiItems){if(Math.abs(time-midi.startTime)<=secTol){hitItem={type:'midiItem',id:midi.id,start:midi.startTime,dur:midi.duration};hitEdge='left';break;}if(Math.abs(time-(midi.startTime+midi.duration))<=secTol){hitItem={type:'midiItem',id:midi.id,start:midi.startTime,dur:midi.duration};hitEdge='right';break;}}}if(hitItem&&hitEdge){e.preventDefault();e.stopPropagation();if(onSectionItemResizeStart)onSectionItemResizeStart(track.id,hitItem.type,hitItem.id,hitEdge,time);return;}if(!hitItem){for(const sec of secItems){if(time>=sec.start&&time<sec.start+sec.duration){hitItem={type:'section',id:sec.id,start:sec.start};break;}}}if(!hitItem){for(const midi of midiItems){if(time>=midi.startTime&&time<midi.startTime+midi.duration){hitItem={type:'midiItem',id:midi.id,start:midi.startTime};break;}}}if(hitItem&&!e.altKey&&!e.shiftKey){e.preventDefault();e.stopPropagation();// Chọn TRACK chứa item (user 07:55: Ctrl+V phải paste vào track đang
|
||
// select — click item không qua onTrackLaneMouseDown → selectedTrackId
|
||
// giữ track cũ → paste sai track)
|
||
if(onSelectTrack)onSelectTrack(track.id);if(e.ctrlKey){// Ctrl+Click: toggle selection (add/remove from group) + set pending copy-drag
|
||
var preToggle=selectedItemIds?new Set(selectedItemIds):new Set();if(selectedItemIds&&selectedItemIds.has(hitItem.id)){if(onDeselectItem)onDeselectItem(hitItem.id);}else{if(onAddToSelection)onAddToSelection(hitItem.id);}if(onSetPendingDrag)onSetPendingDrag(track.id,hitItem.type,hitItem.id,time-hitItem.start,e.nativeEvent||e,preToggle);}else{// Click: select this item (clear others if not already selected).
|
||
// Drag only starts after a small movement threshold (5px) — click alone
|
||
// just selects; this unifies section/MIDI/clip behavior and stops
|
||
// accidental moves from mouse jitter.
|
||
if(!selectedItemIds||!selectedItemIds.has(hitItem.id)){if(onClearSelection)onClearSelection();if(onAddToSelection)onAddToSelection(hitItem.id);}// Drag all currently selected items (or just this one)
|
||
var dragIds=selectedItemIds&&selectedItemIds.has(hitItem.id)&&selectedItemIds.size>1?selectedItemIds:new Set([hitItem.id]);if(onSetPendingDragMove)onSetPendingDragMove(track.id,hitItem.type,hitItem.id,time-hitItem.start,e.nativeEvent||e,dragIds);}return;}const clickedClip=clips.find(c=>c.buffer&&time>=c.startTime&&time<c.startTime+c.buffer.duration/(c.speed||1.0));// Set selected clip ID
|
||
if(clickedClip){setSelectedClipId({trackId:track.id,clipId:clickedClip.id==='default'?'default_'+track.id:clickedClip.id});}else{setSelectedClipId(null);}if(activeTool==='pen'&&!e.ctrlKey){if(clickedClip){e.preventDefault();e.stopPropagation();if(onEditClipInSubTab){onEditClipInSubTab(track.id,clickedClip.id);}}return;}if(activeTool==='razor'&&!e.ctrlKey){if(clickedClip){e.preventDefault();e.stopPropagation();if(onSplitTrackAtTime){onSplitTrackAtTime(track.id,clickedClip.id,time);}}return;}if(activeTool==='grab'){if(onTrackLaneMouseDown){onTrackLaneMouseDown(track.id,time,e);}if(clickedClip){e.preventDefault();e.stopPropagation();if(onClipDragStart){onClipDragStart(track.id,clickedClip.id,time-clickedClip.startTime,e.ctrlKey);}}else{onPlayheadSet(time);}return;}// Check for click drag clip (plain click = move, Alt = sweep-select duration, Ctrl = duplicate)
|
||
if(clickedClip&&e.ctrlKey){e.preventDefault();e.stopPropagation();// Ctrl+Click: toggle selection, store pending drag w/ pre-toggle snapshot
|
||
var clipCanonicalId=clickedClip.id==='default'?'default_'+track.id:clickedClip.id;var clipPreToggleSnapshot=selectedItemIds?new Set(selectedItemIds):new Set();if(selectedItemIds&&selectedItemIds.has(clipCanonicalId)){if(onDeselectItem)onDeselectItem(clipCanonicalId);}else if(onAddToSelection){onAddToSelection(clipCanonicalId);}if(onSetPendingDrag)onSetPendingDrag(track.id,'clip',clipCanonicalId,time-clickedClip.startTime,e.nativeEvent||e,clipPreToggleSnapshot);return;}// Alt+Click on a clip: sweep-select duration (the OLD plain click+drag
|
||
// behavior moved here). Plain click+drag now MOVES the clip like
|
||
// section/MIDI items.
|
||
if(clickedClip&&e.altKey){e.preventDefault();e.stopPropagation();onPlayheadSet(time);if(onTrackLaneMouseDown){onTrackLaneMouseDown(track.id,time,e);}return;}// Plain click on a clip: select it; drag after a small movement
|
||
// threshold moves it (unified with section/MIDI items).
|
||
if(clickedClip){e.preventDefault();e.stopPropagation();var clipCanonicalId2=clickedClip.id==='default'?'default_'+track.id:clickedClip.id;if(!selectedItemIds||!selectedItemIds.has(clipCanonicalId2)){if(onClearSelection)onClearSelection();if(onAddToSelection)onAddToSelection(clipCanonicalId2);}var clipDragIds=selectedItemIds&&selectedItemIds.has(clipCanonicalId2)&&selectedItemIds.size>1?selectedItemIds:new Set([clipCanonicalId2]);if(onSetPendingDragMove)onSetPendingDragMove(track.id,'clip',clipCanonicalId2,time-clickedClip.startTime,e.nativeEvent||e,clipDragIds);return;}// Ctrl+Click on empty space: deselect all on click, marquee on drag
|
||
if(e.ctrlKey&&!clickedClip&&!hitItem){e.preventDefault();e.stopPropagation();// Start a pending sweep: mouseup with no drag → deselect all; drag → marquee
|
||
if(onSweepSelectStart)onSweepSelectStart(track.id,time,e.clientY);return;}onPlayheadSet(time);if(onTrackLaneMouseDown){onTrackLaneMouseDown(track.id,time,e);}e.stopPropagation();},onDoubleClick:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Check double-click on section first
|
||
const dblSecItems=track.sections||[];let dblSecHit=null;for(const sec of dblSecItems){if(time>=sec.start&&time<sec.start+sec.duration){dblSecHit=sec;break;}}if(dblSecHit){e.preventDefault();e.stopPropagation();if(onEditSectionInTab)onEditSectionInTab(track.id,dblSecHit.id);return;}// Check double-click on MIDI item next
|
||
const dblMidiItems=track.midiItems||[];let dblMidiHit=null;for(const midi of dblMidiItems){if(time>=midi.startTime&&time<midi.startTime+midi.duration){dblMidiHit=midi;break;}}if(dblMidiHit){e.preventDefault();e.stopPropagation();if(onEditMidiInTab)onEditMidiInTab(track.id,dblMidiHit.id);return;}const clickedClip=clips.find(c=>c.buffer&&time>=c.startTime&&time<c.startTime+c.buffer.duration/(c.speed||1.0));if(clickedClip){e.preventDefault();e.stopPropagation();if(onEditClipInSubTab){onEditClipInSubTab(track.id,clickedClip.id);}}},onContextMenu:e=>{e.preventDefault();e.stopPropagation();onSelectTrack(track.id);const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);// Detect section under cursor
|
||
const secList=track.sections||[];let hitSectionId=null;for(const sec of secList){if(time>=sec.start&&time<sec.start+sec.duration){hitSectionId=sec.id;break;}}// Detect MIDI item dưới chuột (user 07:25 — Cut/Copy item phải copy
|
||
// ĐÚNG item — bounds × secondsPerBar (m.duration tính BEATS — như
|
||
// contextMenuDelete 17452) — thiếu × spb → detect miss → copy nhầm track)
|
||
const _spb=60.0/(parseInt(bpm)||120)*4;let hitMidiId=null;for(const m of track.midiItems||[]){if(time>=m.startTime&&time<m.startTime+(m.duration||4)*_spb){hitMidiId=m.id;break;}}// Detect clip dưới chuột
|
||
let hitClipId=null;for(const c of track.clips||[]){if(c.buffer&&time>=c.startTime&&time<c.startTime+c.buffer.duration/(c.speed||1.0)){hitClipId=c.id;break;}}if(onContextMenu)onContextMenu(e,track.id,time,hitSectionId,hitMidiId||hitClipId||null,hitMidiId?'midi':hitClipId?'clip':null);}}));};const TimelineRuler=({bpm,zoom,timelineWidth,viewportWidth,onPlayheadSet,snapValue,onRulerMouseDown,scrollLeft,canvasRedrawCount})=>{const canvasRef=useRef(null);const RULER_HEIGHT=40;const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;let scrollLeftVal=scrollLeft||0;const height=RULER_HEIGHT;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#242424';ctx.fillRect(0,0,drawWidth,height);ctx.strokeStyle='rgba(255,255,255,0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,height-0.5);ctx.lineTo(drawWidth,height-0.5);ctx.stroke();const CLIP_BUFFER=400;const PADDING_LEFT=0;const tStart=scrollLeftVal/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth)/zoom+CLIP_BUFFER/zoom;// Draw bar markers (aligned with TempoTrackLane)
|
||
const beatDuration=60/bpm;const barDuration=beatDuration*4;const firstBarNum=Math.floor(tStart/barDuration);const lastBarNum=Math.ceil(tEnd/barDuration);for(let bn=firstBarNum;bn<=lastBarNum;bn++){const t=bn*barDuration;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 255, 255, 0.25)';ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 255, 255, 0.8)';ctx.font='bold 10px Inter, sans-serif';ctx.textAlign='center';ctx.fillText(`${bn}`,localX,32);}// Draw time duration labels with drag-selection markers
|
||
const minTimePx=60;const rawSecInt=Math.max(1,Math.ceil(minTimePx/zoom));const timePowers=[1,2,5,10,30,60];let secInterval=timePowers.find(p=>p>=rawSecInt)||120;if(secInterval*zoom<minTimePx)secInterval=Math.ceil(minTimePx/zoom);const firstSec=Math.floor(tStart/secInterval)*secInterval;for(let t=firstSec;t<=tEnd;t+=secInterval){const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 180, 100, 0.12)';ctx.lineWidth=0.8;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.strokeStyle='rgba(255, 180, 100, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,10);ctx.stroke();ctx.fillStyle='rgba(255, 180, 100, 0.7)';ctx.font='bold 11px monospace';ctx.textAlign='center';ctx.fillText(formatTimeSimple(t),localX,24);}},[bpm,zoom,timelineWidth,viewportWidth,scrollLeft,canvasRedrawCount]);return React.createElement(React.Fragment,null,React.createElement("div",{key:"virtual-spacer-ruler",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown)onRulerMouseDown(e);else onPlayheadSet(time,e.shiftKey);}}));};const TempoTrackLane=({bpm,zoom,timelineWidth,viewportWidth,onPlayheadSet,snapValue,onRulerMouseDown,scrollLeft,canvasRedrawCount,leadInMargin:propLeadIn})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;// Use scrollLeft prop directly (DOM traversal broken by sticky wrapper)
|
||
let scrollLeftVal=scrollLeft||0;const height=40;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#1a1a2e';ctx.fillRect(0,0,drawWidth,height);const beatDuration=60/bpm;const barDuration=beatDuration*4;const leadIn=propLeadIn!==undefined?propLeadIn:0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=0;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+CLIP_BUFFER/zoom;const firstBeatNum=Math.floor(tStart/beatDuration);const lastBeatNum=Math.ceil(tEnd/beatDuration);for(let bn=firstBeatNum;bn<=lastBeatNum;bn++){const t=bn*beatDuration;const beatNum=bn+1;const isBar=beatNum%4===1;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;if(isBar){ctx.strokeStyle='rgba(255, 255, 255, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 255, 255, 0.7)';ctx.font='bold 9px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${Math.floor((beatNum-1)/4)}`,localX+3,11);}else{ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='7px Inter, sans-serif';const bar=Math.floor((beatNum-1)/4);const beat=(beatNum-1)%4+1;ctx.fillText(`${bar}:${beat}`,localX+2,height-3);}// Draw snap sub-ticks at the bottom
|
||
if(snapValue&&snapValue!=='free'){ctx.strokeStyle='rgba(255, 255, 255, 0.15)';ctx.lineWidth=0.8;let divisor=1;if(snapValue==='4')divisor=4;else if(snapValue==='1')divisor=1;else if(snapValue==='1/2')divisor=0.5;else if(snapValue==='1/4')divisor=0.25;else if(snapValue==='1/8')divisor=0.125;else if(snapValue==='1/16')divisor=0.0625;else if(snapValue==='1/32')divisor=0.03125;const snapInterval=beatDuration*divisor;if(snapInterval*zoom>=4){const firstSnapNum=Math.floor(tStart/snapInterval);const lastSnapNum=Math.ceil(tEnd/snapInterval);for(let sn=firstSnapNum;sn<=lastSnapNum;sn++){const t=sn*snapInterval;const onBeat=Math.abs(t/beatDuration-Math.round(t/beatDuration))<0.001;if(!onBeat){const localX=(t-tStart)*zoom;ctx.beginPath();ctx.moveTo(localX,height-6);ctx.lineTo(localX,height);ctx.stroke();}}}}// Draw time duration labels (restored from original time ruler)
|
||
const minTimePx=60;const rawSecInterval=Math.max(1,Math.ceil(minTimePx/zoom));const timePowers=[1,2,5,10,30,60];let secInterval=timePowers.find(p=>p>=rawSecInterval)||120;if(secInterval*zoom<minTimePx)secInterval=Math.ceil(minTimePx/zoom);const firstSec=Math.floor(tStart/secInterval)*secInterval;for(let t=firstSec;t<=tEnd;t+=secInterval){const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 180, 100, 0.15)';ctx.lineWidth=0.8;ctx.beginPath();ctx.moveTo(localX,16);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 180, 100, 0.5)';ctx.font='8px monospace';ctx.textAlign='center';ctx.fillText(formatTimeSimple(t),localX,12);}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='bold 10px Inter, sans-serif';ctx.textAlign='right';ctx.fillText(`${bpm} BPM`,drawWidth-6,12);},[bpm,zoom,timelineWidth,viewportWidth,snapValue,scrollLeft,canvasRedrawCount,propLeadIn]);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"virtual-spacer-tempo",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown){onRulerMouseDown(e);}else{onPlayheadSet(time,e.shiftKey);}}}));};// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
|
||
const SubTabWaveform=({buffer,subTabId,activeTab,currentTime,selectionStart,selectionEnd,onSelectRange,onPlayheadSet,onContextMenu,activeTool,zoom,timelineWidth,color,name,speed=1.0,onSpeedChange,volumeNodes=[],panningNodes=[],fadeInLen=0,fadeOutLen=0,graphMode=null,onUpdateNodes,onUpdateFade,onModeToggle,selectedNodeTime,setSelectedNodeTime,channelInfo=null})=>{const canvasRef=useRef(null);const isStretchingRef=useRef(false);const stretchStartRef=useRef({mouseX:0,originalDuration:0,originalSpeed:1.0});const subTabAnchorRef=useRef(null);const isStereo=channelInfo?channelInfo.isStereo:buffer&&buffer.numberOfChannels>=2;const channelLabel=channelInfo?channelInfo.label:isStereo?'STEREO':'MONO';// Mono: force volume mode (panning not applicable)
|
||
const effectiveGraphMode=!isStereo&&graphMode==='pan'?null:graphMode;useEffect(()=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const wrapper=canvas.parentElement?canvas.parentElement.parentElement:null;const scrollLeft=wrapper?wrapper.scrollLeft:0;const vWidth=wrapper?wrapper.clientWidth:1200;const drawWidth=Math.min(timelineWidth,Math.max(vWidth,1200));const h=canvas.parentElement?canvas.parentElement.clientHeight:200;canvas.width=Math.round(drawWidth*dpr);canvas.height=Math.round(h*dpr);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.position='absolute';canvas.style.left=`${scrollLeft}px`;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${h}px`;ctx.fillStyle='#181818';ctx.fillRect(0,0,drawWidth,h);const data=buffer.getChannelData(0);const len=data.length;if(len===0)return;// Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline
|
||
const computeHermiteTangents=pts=>{const n=pts.length;if(n<2)return[];const m=new Array(n);for(let i=1;i<n-1;i++){const hP=pts[i].time-pts[i-1].time;const hN=pts[i+1].time-pts[i].time;const sP=(pts[i].db-pts[i-1].db)/hP;const sN=(pts[i+1].db-pts[i].db)/hN;m[i]=(sP+sN)/2;}m[0]=n>1?(pts[1].db-pts[0].db)/(pts[1].time-pts[0].time):0;m[n-1]=n>1?(pts[n-1].db-pts[n-2].db)/(pts[n-1].time-pts[n-2].time):0;return m;};const getVolumeDbAtTime=t=>{const volNodes=volumeNodes||[];if(volNodes.length===0)return 0.0;const sortedNodes=[...volNodes].sort((a,b)=>a.time-b.time);if(sortedNodes.length===1)return sortedNodes[0].db;if(t<=sortedNodes[0].time)return sortedNodes[0].db;if(t>=sortedNodes[sortedNodes.length-1].time)return sortedNodes[sortedNodes.length-1].db;const tangents=computeHermiteTangents(sortedNodes);for(let i=0;i<sortedNodes.length-1;i++){const n1=sortedNodes[i];const n2=sortedNodes[i+1];if(t>=n1.time&&t<=n2.time){const h=n2.time-n1.time;if(h<=0)return n1.db;const frac=(t-n1.time)/h;const frac2=frac*frac,frac3=frac2*frac;return(2*frac3-3*frac2+1)*n1.db+(frac3-2*frac2+frac)*h*tangents[i]+(-2*frac3+3*frac2)*n2.db+(frac3-frac2)*h*tangents[i+1];}}return 0.0;};const getVolumeGainAtTime=t=>{return Math.pow(10,getVolumeDbAtTime(t)/20);};const computeHermiteTangentsForPan=pts=>{const n=pts.length;if(n<2)return[];const m=new Array(n);for(let i=1;i<n-1;i++){const hP=pts[i].time-pts[i-1].time;const hN=pts[i+1].time-pts[i].time;const sP=(pts[i].pan-pts[i-1].pan)/hP;const sN=(pts[i+1].pan-pts[i].pan)/hN;m[i]=(sP+sN)/2;}m[0]=n>1?(pts[1].pan-pts[0].pan)/(pts[1].time-pts[0].time):0;m[n-1]=n>1?(pts[n-1].pan-pts[n-2].pan)/(pts[n-1].time-pts[n-2].time):0;return m;};const getPanningValueAtTime=t=>{const panNodes=panningNodes||[];if(panNodes.length===0)return 0;const sortedNodes=[...panNodes].sort((a,b)=>a.time-b.time);if(sortedNodes.length===1)return Math.round(sortedNodes[0].pan*100);if(t<=sortedNodes[0].time)return Math.round(sortedNodes[0].pan*100);if(t>=sortedNodes[sortedNodes.length-1].time)return Math.round(sortedNodes[sortedNodes.length-1].pan*100);const tangents=computeHermiteTangentsForPan(sortedNodes);for(let i=0;i<sortedNodes.length-1;i++){const n1=sortedNodes[i];const n2=sortedNodes[i+1];if(t>=n1.time&&t<=n2.time){const h=n2.time-n1.time;if(h<=0)return Math.round(n1.pan*100);const frac=(t-n1.time)/h;const frac2=frac*frac,frac3=frac2*frac;const val=(2*frac3-3*frac2+1)*n1.pan+(frac3-2*frac2+frac)*h*tangents[i]+(-2*frac3+3*frac2)*n2.pan+(frac3-frac2)*h*tangents[i+1];return Math.round(val*100);}}return 0;};// Draw clip container (like main session clips)
|
||
const xStart=0;const wClip=buffer.duration/speed*zoom;// speed-adjusted width
|
||
const xEnd=xStart+wClip;const clipTop=8;const clipHeight=h-16;const clipColor=color||'#06b6d4';ctx.fillStyle=clipColor+'22';ctx.strokeStyle=clipColor;ctx.lineWidth=1.5;if(ctx.roundRect){ctx.beginPath();ctx.roundRect(xStart,clipTop,wClip,clipHeight,4);ctx.fill();ctx.stroke();}else{ctx.fillRect(xStart,clipTop,wClip,clipHeight);ctx.strokeRect(xStart,clipTop,wClip,clipHeight);}// Draw clip label with speed %
|
||
ctx.fillStyle='#e4e4e7';ctx.font='bold 10px sans-serif';let displayName=name||'Audio Clip';if(speed!==1.0){displayName+=` (${Math.round(speed*100)}%)`;}ctx.fillText(displayName,xStart+8,clipTop+14);// ── Graph Grid & Axes ──
|
||
const drawGrid=true;if(drawGrid){// Background fill
|
||
ctx.fillStyle='#181818';ctx.fillRect(xStart,clipTop,wClip,clipHeight);// Vertical grid lines (time markers)
|
||
ctx.strokeStyle='#2a2a2a';ctx.lineWidth=0.5;ctx.setLineDash([]);const timeStep=Math.max(0.1,Math.ceil(buffer.duration/20*10)/10);for(let t=0;t<=buffer.duration;t+=timeStep){const px=t*zoom;ctx.beginPath();ctx.moveTo(px,clipTop);ctx.lineTo(px,clipTop+clipHeight);ctx.stroke();}// Always draw the reference Volume 0dB Axis (White) and Panning Center Axis (Brown)
|
||
const volZeroY=clipTop+1/3*clipHeight;const panZeroY=clipTop+1/2*clipHeight;// White line for volume 0dB
|
||
ctx.strokeStyle='#ffffff';ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(xStart,volZeroY);ctx.lineTo(xStart+wClip,volZeroY);ctx.stroke();// Brown line for panning center
|
||
ctx.strokeStyle='#854d0e';ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(xStart,panZeroY);ctx.lineTo(xStart+wClip,panZeroY);ctx.stroke();// Horizontal grid lines (other value markers)
|
||
const isPanMode=effectiveGraphMode==='pan';if(isPanMode){for(let p=-100;p<=100;p+=20){if(p===0)continue;const y=clipTop+clipHeight*(1-(p/100+1)/2);ctx.strokeStyle='#2a2a2a';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(xStart,y);ctx.lineTo(xStart+wClip,y);ctx.stroke();}}else{for(let db=-30;db<=3;db+=3){if(db===0)continue;const y=db>=0?volZeroY-db/3*(2/3*clipHeight):volZeroY+-db/30*(1/3*clipHeight);ctx.strokeStyle='#2a2a2a';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(xStart,y);ctx.lineTo(xStart+wClip,y);ctx.stroke();}}// Y-axis labels (left side)
|
||
ctx.fillStyle='#71717a';ctx.font='7px monospace';ctx.textAlign='right';if(isPanMode){ctx.fillText('L100',xStart-2,clipTop+8);ctx.fillText('R50',xStart-2,clipTop+clipHeight*0.25+2);ctx.fillStyle='#854d0e';// Brown label for active Panning Center
|
||
ctx.fillText('C (Pan)',xStart-2,clipTop+clipHeight*0.5+2);ctx.fillStyle='#71717a';ctx.fillText('L50',xStart-2,clipTop+clipHeight*0.75+2);ctx.fillText('R100',xStart-2,clipTop+clipHeight-2);}else{ctx.fillText('+3dB',xStart-2,clipTop+8);ctx.fillStyle='#ffffff';// White label for active Volume 0dB
|
||
ctx.fillText('0dB (Vol)',xStart-2,volZeroY+2);ctx.fillStyle='#71717a';ctx.fillText('-15dB',xStart-2,volZeroY+clipHeight/6+2);ctx.fillText('-30dB',xStart-2,clipTop+clipHeight-2);}ctx.textAlign='start';}// Drag hint labels for fade endpoints
|
||
ctx.fillStyle='#71717a';ctx.font='8px sans-serif';ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh',8,clipTop+clipHeight+12);// Display speed percentage in the bottom left corner of the waveform area
|
||
ctx.fillStyle='#e4e4e7';ctx.font='bold 9px sans-serif';ctx.fillText(`Tốc độ: ${Math.round(speed*100)}%`,xStart+8,clipTop+clipHeight-8);// Mode toggle button on waveform (bottom-right)
|
||
const modeBtnW=28;const modeBtnH=14;const modeBtnX=wClip-modeBtnW-4;const modeBtnY=clipTop+clipHeight-modeBtnH-2;const isPanMode=effectiveGraphMode==='pan';ctx.fillStyle=isPanMode?'rgba(168, 85, 247, 0.5)':'rgba(6, 182, 212, 0.5)';ctx.beginPath();ctx.roundRect(modeBtnX,modeBtnY,modeBtnW,modeBtnH,3);ctx.fill();ctx.fillStyle='#fff';ctx.font='bold 7px sans-serif';ctx.textAlign='center';ctx.fillText(isPanMode?'PAN':'VOL',modeBtnX+modeBtnW/2,modeBtnY+10);ctx.textAlign='start';// Channel label (L / R for stereo, M for mono)
|
||
ctx.fillStyle='#a1a1aa';ctx.font='bold 8px monospace';if(isStereo){ctx.fillText('L',xStart+2,clipTop+clipHeight*0.28);ctx.fillText('R',xStart+2,clipTop+clipHeight*0.72);}else{ctx.fillText('M',xStart+2,clipTop+clipHeight/2);}// Draw waveform inside clip (speed-adjusted) with fade envelope applied
|
||
const drawXStart=Math.max(0,Math.floor(xStart));const drawXEnd=Math.min(drawWidth,Math.ceil(xEnd));const samplesPerPixel=buffer.sampleRate/zoom*speed;const bufDur=buffer.duration;const mid=h/2;const peakRatio=clipHeight*0.45;const getFadeGainAtTime=t=>{if(fadeInLen>0&&t<fadeInLen){return(1-Math.cos(Math.PI*t/fadeInLen))/2;}else if(fadeOutLen>0&&t>bufDur-fadeOutLen){const ratio=(t-(bufDur-fadeOutLen))/fadeOutLen;return(1+Math.cos(Math.PI*ratio))/2;}return 1;};ctx.strokeStyle='#5bc0be';ctx.lineWidth=1.2;if(samplesPerPixel<4){// High-zoom continuous vector line rendering (#5bc0be Cornflower Blue)
|
||
const startSample=Math.max(0,Math.floor((drawXStart-xStart)/zoom*speed*buffer.sampleRate));const endSample=Math.min(len,Math.ceil((drawXEnd-xStart)/zoom*speed*buffer.sampleRate));ctx.beginPath();let first=true;const maxSamples=50000;const vectorStep=Math.max(1,Math.floor((endSample-startSample)/maxSamples));for(let i=startSample;i<endSample;i+=vectorStep){const timeInClip=i/buffer.sampleRate/speed;const px=xStart+timeInClip*zoom;const fadeGain=getFadeGainAtTime(timeInClip);const volGain=getVolumeGainAtTime(px/zoom);const totalGain=fadeGain*volGain;const y=mid-data[i]*totalGain*peakRatio;if(first){ctx.moveTo(px,y);first=false;}else{ctx.lineTo(px,y);}}ctx.stroke();// Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3)
|
||
if(samplesPerPixel<0.3){const maxNodes=5000;const step=Math.max(1,Math.floor((endSample-startSample)/maxNodes));ctx.fillStyle='#6ee7b7';let drawn=0;for(let i=startSample;i<endSample&&drawn<maxNodes;i+=step){const timeInClip=i/buffer.sampleRate/speed;const px=xStart+timeInClip*zoom;const fadeGain=getFadeGainAtTime(timeInClip);const volGain=getVolumeGainAtTime(px/zoom);const totalGain=fadeGain*volGain;const y=mid-data[i]*totalGain*peakRatio;ctx.fillRect(px-1,y-1,2,2);drawn++;}}}else{// Coarse view vertical peak min/max bars
|
||
for(let px=drawXStart;px<drawXEnd;px++){const timeInClip=px/zoom*speed;const sampleIdx=Math.floor(timeInClip*buffer.sampleRate);const chunkSize=Math.max(1,Math.floor(samplesPerPixel));const chunkStart=Math.max(0,sampleIdx-Math.floor(chunkSize/2));const chunkEnd=Math.min(len,chunkStart+chunkSize);let fadeGain=getFadeGainAtTime(timeInClip);const volGain=getVolumeGainAtTime(px/zoom);const totalGain=fadeGain*volGain;let maxVal=0;let minVal=0;for(let i=chunkStart;i<chunkEnd;i++){const val=data[i];if(val>maxVal)maxVal=val;if(val<minVal)minVal=val;}maxVal*=totalGain;minVal*=totalGain;const yTop=mid+minVal*peakRatio;const yBottom=mid+maxVal*peakRatio;ctx.beginPath();ctx.moveTo(px,yTop);ctx.lineTo(px,yBottom);ctx.stroke();}}// Volume: 0dB at 1/3 from top
|
||
const autoY=node=>{const db=typeof node==='number'?node:node.db;const zeroY=clipTop+1/3*clipHeight;return db>=0?zeroY-db/3*(1/3*clipHeight):zeroY+-db/30*(2/3*clipHeight);};// Panning: 0 at center
|
||
const autoPanY=node=>{const pan=typeof node==='number'?node:node.pan;return clipTop+(1-(pan+1)/2)*clipHeight;};// Helper: compute tangents for monotone Hermite spline
|
||
const computeTangents=(pts,yFn)=>{const n=pts.length;if(n<2)return[];const m=new Array(n);for(let i=1;i<n-1;i++){const hP=pts[i].time-pts[i-1].time;const hN=pts[i+1].time-pts[i].time;const sP=(yFn(pts[i])-yFn(pts[i-1]))/hP;const sN=(yFn(pts[i+1])-yFn(pts[i]))/hN;m[i]=(sP+sN)/2;}m[0]=n>1?(yFn(pts[1])-yFn(pts[0]))/(pts[1].time-pts[0].time):0;m[n-1]=n>1?(yFn(pts[n-1])-yFn(pts[n-2]))/(pts[n-1].time-pts[n-2].time):0;return m;};// Helper: evaluate Hermite at pixel position px
|
||
const hermiteY=(px,x0,y0,m0,x1,y1,m1)=>{const h=x1-x0;if(h<=0)return y0;const t=(px-x0)/h;const t2=t*t,t3=t2*t;return(2*t3-3*t2+1)*y0+(t3-2*t2+t)*h*m0+(-2*t3+3*t2)*y1+(t3-t2)*h*m1;};// Draw automation curve with Hermite spline
|
||
const drawSpline=(nodes,yFn,color,lineDash)=>{if(nodes.length<2){if(nodes.length===1){const px=nodes[0].time*zoom;const y=yFn(nodes[0]);ctx.fillStyle=color;ctx.beginPath();ctx.arc(px,y,4,0,Math.PI*2);ctx.fill();}return;}const tangents=computeTangents(nodes,yFn);ctx.strokeStyle=color;ctx.lineWidth=2;ctx.setLineDash(lineDash||[]);ctx.beginPath();for(let i=0;i<nodes.length-1;i++){const x0=nodes[i].time*zoom,y0=yFn(nodes[i]);const x1=nodes[i+1].time*zoom,y1=yFn(nodes[i+1]);const t0=nodes[i].time,t1=nodes[i+1].time;const m0=tangents[i],m1=tangents[i+1];for(let px=Math.floor(x0);px<Math.ceil(x1);px++){const t=px/zoom;const y=hermiteY(t,t0,y0,m0,t1,y1,m1);if(px===Math.floor(x0)&&i===0)ctx.moveTo(px,y);else ctx.lineTo(px,y);}}ctx.stroke();ctx.setLineDash([]);// Draw node handles
|
||
nodes.forEach(n=>{const px=n.time*zoom,y=yFn(n);const isSelected=n.time===selectedNodeTime;ctx.fillStyle=isSelected?'#ffebb3':'#fff';ctx.beginPath();ctx.arc(px,y,isSelected?6:4,0,Math.PI*2);ctx.fill();ctx.strokeStyle=isSelected?'#fbbf24':color;ctx.lineWidth=isSelected?2.5:1.5;ctx.beginPath();ctx.arc(px,y,isSelected?6:4,0,Math.PI*2);ctx.stroke();// Display value at node
|
||
ctx.fillStyle='#ffffff';ctx.font='bold 12px sans-serif';const labelText=isPanMode?n.pan>0?'R'+Math.round(n.pan*100):n.pan<0?'L'+Math.round(Math.abs(n.pan)*100):'C':`${n.db>=0?'+':''}${n.db.toFixed(1)}dB`;ctx.fillText(labelText,px+8,y+4);});};drawSpline(volumeNodes,autoY,'#f43f5e');drawSpline(panningNodes,autoPanY,'#a855f7',[4,4]);// ── Fade Curves (transparent, only curve lines + endpoint handles) ──
|
||
const FADE_COLOR='#b91c1c';const HANDLE_RADIUS=5;if(fadeInLen>=0){const fiPx=fadeInLen*zoom;if(fadeInLen>0){ctx.strokeStyle=FADE_COLOR;ctx.lineWidth=1.8;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(0,clipTop+clipHeight);for(let px=0;px<=fiPx;px++){const ratio=px/fiPx;const amp=(1-Math.cos(Math.PI*ratio))/2;const y=clipTop+clipHeight-amp*clipHeight;ctx.lineTo(px,y);}ctx.stroke();}// Endpoint handle (draggable)
|
||
const endX=fiPx,endY=clipTop;ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(endX,endY,HANDLE_RADIUS,0,Math.PI*2);ctx.fill();ctx.strokeStyle=FADE_COLOR;ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(endX,endY,HANDLE_RADIUS,0,Math.PI*2);ctx.stroke();// Label
|
||
ctx.fillStyle='#b91c1c';ctx.font='bold 7px sans-serif';ctx.textAlign='center';ctx.fillText('FI',endX,endY-HANDLE_RADIUS-3);ctx.textAlign='start';}if(fadeOutLen>=0){const foPx=fadeOutLen*zoom;const startX=wClip-foPx;if(fadeOutLen>0){ctx.strokeStyle=FADE_COLOR;ctx.lineWidth=1.8;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(startX,clipTop);for(let px=0;px<=foPx;px++){const ratio=px/foPx;const amp=(1+Math.cos(Math.PI*ratio))/2;const y=clipTop+clipHeight-amp*clipHeight;ctx.lineTo(startX+px,y);}ctx.stroke();}// Endpoint handle (draggable)
|
||
const handleX=startX,handleY=clipTop;ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(handleX,handleY,HANDLE_RADIUS,0,Math.PI*2);ctx.fill();ctx.strokeStyle=FADE_COLOR;ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(handleX,handleY,HANDLE_RADIUS,0,Math.PI*2);ctx.stroke();ctx.fillStyle='#b91c1c';ctx.font='bold 7px sans-serif';ctx.textAlign='center';ctx.fillText('FO',handleX,handleY-HANDLE_RADIUS-3);ctx.textAlign='start';}// Draw right-edge stretch handle indicator
|
||
if(wClip>0&&wClip<drawWidth){ctx.strokeStyle=clipColor;ctx.lineWidth=1.5;ctx.setLineDash([3,3]);ctx.beginPath();ctx.moveTo(wClip+xStart,clipTop);ctx.lineTo(wClip+xStart,clipTop+clipHeight);ctx.stroke();ctx.setLineDash([]);}// Highlight selection if active
|
||
if(selectionStart!==null&&selectionEnd!==null&&selectionStart!==selectionEnd){const left=Math.min(selectionStart,selectionEnd);const right=Math.max(selectionStart,selectionEnd);const leftPx=left*zoom;const rightPx=right*zoom;ctx.fillStyle='rgba(245, 158, 11, 0.15)';ctx.fillRect(leftPx,0,rightPx-leftPx,h);ctx.strokeStyle='#f59e0b';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(leftPx,0);ctx.lineTo(leftPx,h);ctx.moveTo(rightPx,0);ctx.lineTo(rightPx,h);ctx.stroke();}// Draw playhead
|
||
if(currentTime!==null&¤tTime>=0&¤tTime<=buffer.duration/speed){const playheadPx=currentTime*zoom;ctx.strokeStyle='#ef4444';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(playheadPx,0);ctx.lineTo(playheadPx,h);ctx.stroke();}// Update TCP slider values in real-time to match the curve at currentTime
|
||
const volInput=document.getElementById(`tcp-vol-${subTabId}`);const volLabel=document.getElementById(`tcp-vol-label-${subTabId}`);if(volInput&&volLabel){const dbVal=getVolumeDbAtTime(currentTime||0);volInput.value=dbVal.toFixed(1);volLabel.textContent=`${dbVal.toFixed(1)}dB`;}const panInput=document.getElementById(`tcp-pan-${subTabId}`);const panLabel=document.getElementById(`tcp-pan-label-${subTabId}`);if(panInput&&panLabel){const panVal=getPanningValueAtTime(currentTime||0);panInput.value=panVal;panLabel.textContent=panVal>0?'R':panVal<0?'L':'C';const panLabelDetailed=document.getElementById(`tcp-pan-label-detailed-${subTabId}`);if(panLabelDetailed){panLabelDetailed.textContent=panVal>0?'R'+panVal:panVal<0?'L'+Math.abs(panVal):'C';}}},[buffer,currentTime,selectionStart,selectionEnd,zoom,timelineWidth,color,name,speed,volumeNodes,panningNodes,fadeInLen,fadeOutLen,graphMode,selectedNodeTime,subTabId]);const handleMouseDown=e=>{if(e.button===2)return;const canvas=canvasRef.current;const rect=canvas.getBoundingClientRect();const parent=canvas.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const bufDuration=buffer.duration;const wallDuration=bufDuration/(speed||1.0);const mouseX=e.clientX-rect.left+scrollLeft;const startTime=Math.max(0,Math.min(wallDuration,mouseX/zoom));// Shift + Click range selection in SubTab Waveform
|
||
if(e.shiftKey){e.preventDefault();e.stopPropagation();const anchor=subTabAnchorRef.current!==null&&subTabAnchorRef.current!==undefined?subTabAnchorRef.current:selectionStart!==null&&selectionStart!==undefined?selectionStart:currentTime;const selS=Math.min(anchor,startTime);const selE=Math.max(anchor,startTime);onSelectRange(selS,selE);onPlayheadSet(startTime);return;}// Alt+Click near right edge → speed stretch
|
||
if(e.altKey&&onSpeedChange){const clipRightEdge=bufDuration/(speed||1.0)*zoom;const tolerance=8;if(Math.abs(mouseX-clipRightEdge)<=tolerance){isStretchingRef.current=true;stretchStartRef.current={mouseX,originalDuration:bufDuration,originalSpeed:speed,finalSpeed:speed,clipName:name||'clip'};canvas.style.cursor='ew-resize';const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const wClipPx=stretchStartRef.current.originalDuration/stretchStartRef.current.originalSpeed*zoom;const deltaX=currentX-stretchStartRef.current.mouseX;const newWClip=Math.max(10,wClipPx+deltaX);const newSpeed=stretchStartRef.current.originalDuration/(newWClip/zoom);stretchStartRef.current.finalSpeed=Math.max(0.05,Math.min(10,newSpeed));if(onSpeedChange)onSpeedChange(stretchStartRef.current.finalSpeed);};const handleMouseUp=()=>{isStretchingRef.current=false;canvas.style.cursor='default';document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);// UNDO/REDO cho alt-click-drag speed stretch: entry SET_CLIP_SPEED —
|
||
// undo về speed gốc, redo về speed cuối (onSpeedChange tự tính lại
|
||
// volumeNodes/panningNodes/fade/label theo ratio nên khớp 2 chiều).
|
||
const st=stretchStartRef.current;if(st&&typeof st.originalSpeed==='number'&&window.UndoRedoEngine){const finalSpeed=st.finalSpeed||st.originalSpeed;if(Math.abs(finalSpeed-st.originalSpeed)>0.001){window.UndoRedoEngine.execute({type:'SET_CLIP_SPEED',scope:'section_tab',label:`Speed ${Math.round(st.originalSpeed*100)}% → ${Math.round(finalSpeed*100)}% (${st.clipName})`,before:st.originalSpeed,after:finalSpeed,undo:e=>{if(onSpeedChange)onSpeedChange(e.before);},redo:e=>{if(onSpeedChange)onSpeedChange(e.after);}});}}stretchStartRef.current=null;};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}}// Mode toggle button click (bottom-right VOL/PAN)
|
||
const wClipPx=bufDuration/(speed||1.0)*zoom;const cTop=8;const cSize=16;const cHeight=rect.height-16;const modeBtnX=wClipPx-28-4;const modeBtnY=cTop+cHeight-14-2;if(mouseX>=modeBtnX&&mouseX<=modeBtnX+28&&e.clientY-rect.top>=modeBtnY&&e.clientY-rect.top<=modeBtnY+14){if(onModeToggle)onModeToggle();return;}// Fade endpoint handle drag (click on FI/FO handle circles)
|
||
const HANDLE_R=5;const fiEndPx=fadeInLen*zoom;const foStartPx=wClipPx-fadeOutLen*zoom;const distToFiHandle=Math.abs(mouseX-fiEndPx)+Math.abs(e.clientY-rect.top-cTop);const distToFoHandle=Math.abs(mouseX-foStartPx)+Math.abs(e.clientY-rect.top-cTop);if(distToFiHandle<=HANDLE_R+6){const handleMouseMove=moveEvent=>{const x=moveEvent.clientX-rect.left+scrollLeft;const t=Math.max(0,Math.min(wallDuration,x/zoom));if(onUpdateFade)onUpdateFade({fadeInLen:t});};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}if(distToFoHandle<=HANDLE_R+6){const handleMouseMove=moveEvent=>{const x=moveEvent.clientX-rect.left+scrollLeft;const t=Math.max(0,Math.min(wallDuration,(wClipPx-x)/zoom));if(onUpdateFade)onUpdateFade({fadeOutLen:t});};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}// Tool-specific behavior
|
||
if(activeTool==='grab'){setSelectedNodeTime(null);onPlayheadSet(startTime);const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));onPlayheadSet(ct);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}if(activeTool==='razor'){setSelectedNodeTime(null);onPlayheadSet(startTime);showToast(`Cut point at ${formatTime(startTime)}`,'info');return;}if(activeTool==='pen'){// Deduplicate by time: keep last occurrence per time key
|
||
const mergeNodes=(existing,incoming)=>{const map=new Map();existing.forEach(n=>map.set(n.time,n));incoming.forEach(n=>map.set(n.time,n));return Array.from(map.values()).sort((a,b)=>a.time-b.time);};onPlayheadSet(startTime);canvas.style.cursor='crosshair';const isPan=(graphMode||'volume')==='pan';const isCtrl=e.ctrlKey||e.metaKey;const cTop=8;const cHeight=rect.height-16;const curNodes=isPan?panningNodes:volumeNodes;const valFromY=y=>{if(isPan)return Math.max(-1,Math.min(1,-(y-cTop)/cHeight*2+1));const yOff=(y-cTop)/cHeight;return yOff<=1/3?Math.max(0,Math.min(3,3*(1-yOff*3))):Math.max(-30,Math.min(0,-30*(yOff-1/3)*(3/2)));};const snapValue=v=>isPan?Math.round(v*20)/20:Math.round(v*2)/2;const snapTime=t=>Math.round(t*10)/10;// Check if clicking near existing node (any mode)
|
||
const volNodeY=v=>{const z=cTop+1/3*cHeight;return v>=0?z-v/3*(1/3*cHeight):z+-v/30*(2/3*cHeight);};const nearNode=curNodes.findIndex(n=>{const t=Math.abs(n.time-startTime);const val=isPan?n.pan:n.db;const ny=isPan?cTop+(1-(val+1)/2)*cHeight:volNodeY(val);const dy=Math.abs(e.clientY-rect.top-ny);return t<0.1/(speed||1)&&dy<10;});if(nearNode>=0){// Drag existing node
|
||
setSelectedNodeTime(curNodes[nearNode].time);let working=[...curNodes];let dragIdx=nearNode;const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));const val=snapValue(valFromY(moveEvent.clientY-rect.top));const updated=isPan?{time:Math.min(snapTime(ct),wallDuration),pan:val}:{time:Math.min(snapTime(ct),wallDuration),db:val};const cleaned=mergeNodes(working.filter((_,i)=>i!==dragIdx),[updated]);if(onUpdateNodes)onUpdateNodes(cleaned);working=[...cleaned];dragIdx=working.findIndex(n=>n.time===updated.time&&(isPan?n.pan===updated.pan:n.db===updated.db));// Follow the selected node during dragging
|
||
setSelectedNodeTime(updated.time);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);canvas.style.cursor='crosshair';};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}if(isCtrl){setSelectedNodeTime(null);const pts=[];let lastKey='';const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));const t=+snapTime(ct).toFixed(3);const v=isPan?+snapValue(valFromY(moveEvent.clientY-rect.top)).toFixed(2):+snapValue(valFromY(moveEvent.clientY-rect.top)).toFixed(1);const key=t+'|'+v;if(key!==lastKey){pts.push({time:t,[isPan?'pan':'db']:v});lastKey=key;}};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);canvas.style.cursor='crosshair';const merged=mergeNodes(curNodes,pts);if(onUpdateNodes)onUpdateNodes(merged);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);}else{const newNode=isPan?{time:+snapTime(startTime).toFixed(3),pan:+snapValue(valFromY(e.clientY-rect.top)).toFixed(2)}:{time:+snapTime(startTime).toFixed(3),db:+snapValue(valFromY(e.clientY-rect.top)).toFixed(1)};setSelectedNodeTime(newNode.time);const merged=mergeNodes(curNodes,[newNode]);if(onUpdateNodes)onUpdateNodes(merged);// Now drag this newly created node
|
||
let working=[...merged];let dragIdx=working.findIndex(n=>n.time===newNode.time&&(isPan?n.pan===newNode.pan:n.db===newNode.db));const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));const val=snapValue(valFromY(moveEvent.clientY-rect.top));const updated=isPan?{time:Math.min(snapTime(ct),wallDuration),pan:val}:{time:Math.min(snapTime(ct),wallDuration),db:val};const cleaned=mergeNodes(working.filter((_,i)=>i!==dragIdx),[updated]);if(onUpdateNodes)onUpdateNodes(cleaned);working=[...cleaned];dragIdx=working.findIndex(n=>n.time===updated.time&&(isPan?n.pan===updated.pan:n.db===updated.db));setSelectedNodeTime(updated.time);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);canvas.style.cursor='crosshair';};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);}return;}// Select tool (default): drag to select range
|
||
subTabAnchorRef.current=startTime;setSelectedNodeTime(null);onSelectRange(startTime,startTime);onPlayheadSet(startTime);const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));const anchor=subTabAnchorRef.current??startTime;onSelectRange(Math.min(anchor,ct),Math.max(anchor,ct));};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleContextMenuInternal=e=>{e.preventDefault();e.stopPropagation();const canvas=canvasRef.current;const rect=canvas.getBoundingClientRect();const parent=canvas.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const x=e.clientX-rect.left+scrollLeft;const clickTime=Math.max(0,Math.min(buffer.duration/(speed||1.0),x/zoom));onContextMenu(e,clickTime);};// Double-click on automation curve to create a new node
|
||
const handleDoubleClick=e=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;if(activeTool!=='select'&&activeTool!=='pen')return;const rect=canvas.getBoundingClientRect();const parent=canvas.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const x=e.clientX-rect.left+scrollLeft;const clickTime=Math.max(0,Math.min(buffer.duration/(speed||1.0),x/zoom));const isPan=(graphMode||'volume')==='pan';const curNodes=isPan?panningNodes:volumeNodes;const cTop=8;const cHeight=rect.height-16;const valFromY=y=>{if(isPan)return Math.max(-1,Math.min(1,-(y-cTop)/cHeight*2+1));const yOff=(y-cTop)/cHeight;return yOff<=2/3?Math.max(0,Math.min(3,3*(1-yOff*3/2))):Math.max(-30,Math.min(0,-30*(yOff-2/3)*3));};// Interpolate value at click position from existing curve
|
||
let interpolatedVal=valFromY(e.clientY-rect.top);if(curNodes.length>=2){const sorted=[...curNodes].sort((a,b)=>a.time-b.time);for(let i=0;i<sorted.length-1;i++){if(clickTime>=sorted[i].time&&clickTime<=sorted[i+1].time){const t=(clickTime-sorted[i].time)/(sorted[i+1].time-sorted[i].time);const v1=isPan?sorted[i].pan:sorted[i].db;const v2=isPan?sorted[i+1].pan:sorted[i+1].db;interpolatedVal=v1+t*(v2-v1);break;}}}const newNode=isPan?{time:+Math.round(clickTime*10)/10,pan:+Math.round(interpolatedVal*20)/20}:{time:+Math.round(clickTime*10)/10,db:+Math.round(interpolatedVal*2)/2};const merged=(()=>{const map=new Map();curNodes.forEach(n=>map.set(n.time,n));map.set(newNode.time,newNode);return Array.from(map.values()).sort((a,b)=>a.time-b.time);})();if(onUpdateNodes)onUpdateNodes(merged);};return/*#__PURE__*/React.createElement("div",{style:{width:`${timelineWidth}px`,height:'100%',position:'relative',overflow:'hidden'}},/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'absolute',top:0,left:0,imageRendering:'pixelated'},className:"cursor-crosshair rounded border border-zinc-800",onMouseDown:handleMouseDown,onDoubleClick:handleDoubleClick,onContextMenu:handleContextMenuInternal,onMouseMove:e=>{if(canvasRef.current&&e.altKey&&onSpeedChange){const rect=canvasRef.current.getBoundingClientRect();const parent=canvasRef.current.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const mx=e.clientX-rect.left+scrollLeft;const wClip=buffer.duration/speed*zoom;const tolerance=8;canvasRef.current.style.cursor=Math.abs(mx-wClip)<=tolerance&&!isStretchingRef.current?'ew-resize':'crosshair';}else if(canvasRef.current&&!isStretchingRef.current){const rect=canvasRef.current.getBoundingClientRect();const parent=canvasRef.current.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const mx=e.clientX-rect.left+scrollLeft;const wClipPx=buffer.duration/(speed||1.0)*zoom;const cH=canvasRef.current.height/(window.devicePixelRatio||1)-16;const btnX=wClipPx-28-4;const btnY=8+cH-14-2;const overBtn=mx>=btnX&&mx<=btnX+28&&e.clientY-rect.top>=btnY&&e.clientY-rect.top<=btnY+14;canvasRef.current.style.cursor=overBtn?'pointer':'crosshair';}}}));};const SubTabToolbar=({st,activeTool,setActiveTool,handleSubTabNormalizeWithValue,handleSubTabGainWithValue,handleSubTabPitch,handleSubTabStretch,handleSubTabFade,onPlayPause,onStop,onRewind,onForward,onLoop,onRecord,onRateChange,onCut,onCopy,onPaste,onGlue,snapValue,onSnapChange})=>{const isPlaying=st?.isPlaying||false;const isLooping=st?.isLooping||false;const isRecording=st?.isRecording||false;return/*#__PURE__*/React.createElement("div",{className:"daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800"},/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1 border-r border-zinc-700 pr-3"},/*#__PURE__*/React.createElement("button",{className:`p-1 rounded ${activeTool==='select'?'bg-cyan-700':'bg-zinc-700'}`,onClick:()=>setActiveTool('select'),title:"Select Tool"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"mouse-pointer",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{className:`p-1 rounded ${activeTool==='grab'?'bg-cyan-700':'bg-zinc-700'}`,onClick:()=>setActiveTool('grab'),title:"Grab Tool"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"hand",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{className:`p-1 rounded ${activeTool==='razor'?'bg-cyan-700':'bg-zinc-700'}`,onClick:()=>setActiveTool('razor'),title:"Razor Tool"},/*#__PURE__*/React.createElement("svg",{className:"w-4 h-4 text-orange-400",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round"},/*#__PURE__*/React.createElement("path",{d:"M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"}),/*#__PURE__*/React.createElement("path",{d:"M4 9h16l-3 9H7z"}),/*#__PURE__*/React.createElement("circle",{cx:"12",cy:"6",r:"1"}))),/*#__PURE__*/React.createElement("button",{className:`p-1 rounded ${activeTool==='pen'?'bg-cyan-700':'bg-zinc-700'}`,onClick:()=>setActiveTool('pen'),title:"Pen Tool"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"pen-tool",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-4 bg-zinc-800 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:onGlue,className:"p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition",title:"Glue Clips"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"link",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:onCut,className:"p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition",title:"Cut (Ctrl+X)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:onCopy,className:"p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition",title:"Copy (Ctrl+C)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:onPaste,className:"p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition",title:"Paste (Ctrl+V)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 border-r border-zinc-700 pr-3"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase"},"Snap"),/*#__PURE__*/React.createElement("select",{value:snapValue||'free',onChange:e=>onSnapChange(e.target.value),className:"bg-zinc-850 text-zinc-300 text-xs px-1 py-0.5 rounded border border-zinc-800 focus:outline-none font-mono"},/*#__PURE__*/React.createElement("option",{value:"free"},"Free"),/*#__PURE__*/React.createElement("option",{value:"4"},"4"),/*#__PURE__*/React.createElement("option",{value:"1"},"1"),/*#__PURE__*/React.createElement("option",{value:"1/2"},"1/2"),/*#__PURE__*/React.createElement("option",{value:"1/4"},"1/4"),/*#__PURE__*/React.createElement("option",{value:"1/8"},"1/8"),/*#__PURE__*/React.createElement("option",{value:"1/16"},"1/16"),/*#__PURE__*/React.createElement("option",{value:"1/32"},"1/32"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-2 flex-1 overflow-x-auto no-scrollbar"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Normalize:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",value:st.effects?.normalizeDb||0,step:"0.1",onChange:e=>handleSubTabNormalizeWithValue(st.id,parseFloat(e.target.value)),className:"w-20 h-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 w-10 text-right"},st.effects?.normalizeDb||0," dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Gain:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-40",max:"24",value:st.effects?.gainDb||0,step:"0.1",onChange:e=>handleSubTabGainWithValue(st.id,parseFloat(e.target.value)),className:"w-20 h-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 w-10 text-right"},st.effects?.gainDb||0," dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Pitch:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"12",value:st.effects?.pitch||0,step:"0.1",onChange:e=>handleSubTabPitch(st.id,parseFloat(e.target.value)),className:"w-20 h-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 w-10 text-right"},st.effects?.pitch||0," st")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Stretch:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"50",max:"200",value:st.effects?.speedStretch||100,step:"1",onChange:e=>handleSubTabStretch(st.id,parseInt(e.target.value)),className:"w-20 h-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 w-10 text-right"},st.effects?.speedStretch||100,"%"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1 border-r border-zinc-700 pr-3"},/*#__PURE__*/React.createElement("button",{onClick:()=>handleSubTabFade(st.id,'in'),className:"px-2 py-1 text-xs rounded hover:bg-zinc-700"},"Fade In"),/*#__PURE__*/React.createElement("button",{onClick:()=>handleSubTabFade(st.id,'out'),className:"px-2 py-1 text-xs rounded hover:bg-zinc-700"},"Fade Out")),/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1 border-r border-zinc-700 pr-3"},/*#__PURE__*/React.createElement("button",{onClick:onRewind,className:"w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Rewind"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"skip-back",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{onClick:onPlayPause,className:`w-8 h-8 flex items-center justify-center rounded border transition ${isPlaying?'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500':'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`,title:isPlaying?"Pause":"Play"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":isPlaying?'pause':'play',className:"w-4 h-4 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:onStop,className:"w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Stop"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"square",className:"w-4 h-4 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:onForward,className:"w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Forward"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"skip-forward",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{onClick:onLoop,className:`w-8 h-8 flex items-center justify-center rounded border transition ${isLooping?'bg-amber-600 text-black border-amber-500 hover:bg-amber-500':'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`,title:isLooping?"Loop On":"Loop Off"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{onClick:onRecord,className:`w-8 h-8 flex items-center justify-center rounded border transition ${isRecording?'bg-red-600 text-white border-red-500':'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`,title:isRecording?"Recording":"Record"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"circle",className:"w-4 h-4"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Rate:"),/*#__PURE__*/React.createElement("select",{value:st.playbackRate||1,onChange:e=>onRateChange(parseFloat(e.target.value)),className:"bg-zinc-700 text-zinc-100 text-xs px-1 py-0.5 rounded border border-zinc-600"},/*#__PURE__*/React.createElement("option",{value:"0.5"},"0.5x"),/*#__PURE__*/React.createElement("option",{value:"0.75"},"0.75x"),/*#__PURE__*/React.createElement("option",{value:"1"},"1x"),/*#__PURE__*/React.createElement("option",{value:"1.25"},"1.25x"),/*#__PURE__*/React.createElement("option",{value:"1.5"},"1.5x"),/*#__PURE__*/React.createElement("option",{value:"2"},"2x"))));};// ── Graph Editor Canvas for Volume/Pan/Fade Automation ──
|
||
const GraphEditorCanvas=({buffer,zoom,timelineWidth,volumeNodes,panningNodes,fadeInLen,fadeOutLen,onUpdateNodes,graphMode})=>{const canvasRef=useRef(null);const isDraggingNode=useRef(false);const dragNodeIdx=useRef(-1);const isCreatingNode=useRef(false);const getNodes=()=>graphMode==='pan'?panningNodes:volumeNodes;const nodeLabel=n=>graphMode==='pan'?`${n.pan.toFixed(2)}`:`${n.db.toFixed(1)}dB`;const nodeY=(n,h)=>{if(graphMode==='pan')return(1-(n.pan+1)/2)*h;const zeroY=2/3*h;return n.db>=0?zeroY-n.db/3*(2/3*h):zeroY+-n.db/30*(1/3*h);};const nodeValFromY=(y,h)=>{if(graphMode==='pan')return-(y/h*2-1);const yOff=y/h;return yOff<=2/3?3*(1-yOff*3/2):-30*(yOff-2/3)*3;};useEffect(()=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const wrapper=canvas.parentElement?canvas.parentElement.parentElement:null;const scrollLeft=wrapper?wrapper.scrollLeft:0;const vWidth=wrapper?wrapper.clientWidth:1200;const drawWidth=Math.min(timelineWidth,Math.max(vWidth,1200));const h=canvas.parentElement?canvas.parentElement.clientHeight:200;canvas.width=Math.round(drawWidth*dpr);canvas.height=Math.round(h*dpr);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.position='absolute';canvas.style.left=`${scrollLeft}px`;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${h}px`;ctx.fillStyle='#1a1a2e';ctx.fillRect(0,0,drawWidth,h);ctx.strokeStyle='#2a2a4e';ctx.lineWidth=0.5;for(let t=0;t<=buffer.duration;t+=0.5){const x=t/buffer.duration*drawWidth;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h);ctx.stroke();}for(let i=0;i<=10;i++){const y=i/10*h;ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(drawWidth,y);ctx.stroke();}if(fadeInLen>0){const fadeX=fadeInLen/buffer.duration*drawWidth;ctx.fillStyle='rgba(16, 185, 129, 0.12)';ctx.fillRect(0,0,fadeX,h);}if(fadeOutLen>0){const fadeX=(buffer.duration-fadeOutLen)/buffer.duration*drawWidth;const fadeW=fadeOutLen/buffer.duration*drawWidth;ctx.fillStyle='rgba(239, 68, 68, 0.12)';ctx.fillRect(fadeX,0,fadeW,h);}const nodes=getNodes();if(nodes.length>0){ctx.strokeStyle=graphMode==='pan'?'#a855f7':'#06b6d4';ctx.lineWidth=2;ctx.beginPath();nodes.forEach((n,i)=>{const x=n.time/buffer.duration*drawWidth;const y=nodeY(n,h);if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);});ctx.stroke();nodes.forEach((n,i)=>{const x=n.time/buffer.duration*drawWidth;const y=nodeY(n,h);ctx.fillStyle=graphMode==='pan'?'#a855f7':'#06b6d4';ctx.beginPath();ctx.arc(x,y,5,0,Math.PI*2);ctx.fill();ctx.fillStyle='#e4e4e7';ctx.font='9px monospace';ctx.fillText(nodeLabel(n),x+8,y+3);});}else{ctx.fillStyle='#52525b';ctx.font='11px sans-serif';ctx.textAlign='center';ctx.fillText(graphMode==='pan'?'Click to add Pan points':'Click to add Volume points',drawWidth/2,h/2);ctx.textAlign='start';}const zeroY=graphMode==='pan'?h/2:nodeY({db:0},h);ctx.strokeStyle=graphMode==='pan'?'#a855f744':'#06b6d444';ctx.lineWidth=1;ctx.setLineDash([4,4]);ctx.beginPath();ctx.moveTo(0,zeroY);ctx.lineTo(drawWidth,zeroY);ctx.stroke();ctx.setLineDash([]);},[buffer,zoom,timelineWidth,volumeNodes,panningNodes,fadeInLen,fadeOutLen,graphMode]);const handleMouseDown=e=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const time=x/rect.width*buffer.duration;const val=nodeValFromY(y,rect.height);const nodes=getNodes();const snapped=Math.max(-30,Math.min(3,val));const snappedPan=Math.max(-1,Math.min(1,val));const threshold=12/rect.width*buffer.duration;const nearIdx=nodes.findIndex(n=>Math.abs(n.time-time)<threshold);if(nearIdx>=0){isDraggingNode.current=true;dragNodeIdx.current=nearIdx;return;}const newNode=graphMode==='pan'?{time:+time.toFixed(3),pan:+snappedPan.toFixed(2)}:{time:+time.toFixed(3),db:+snapped.toFixed(1)};const sorted=[...nodes,newNode].sort((a,b)=>a.time-b.time);onUpdateNodes(sorted);const rearrangeNewIdx=sorted.findIndex(n=>n.time===newNode.time&&(graphMode==='pan'?n.pan:n.db)===(graphMode==='pan'?newNode.pan:newNode.db));isDraggingNode.current=true;dragNodeIdx.current=rearrangeNewIdx;isCreatingNode.current=true;};const handleMouseMove=e=>{if(!isDraggingNode.current||dragNodeIdx.current<0)return;const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const time=Math.max(0,Math.min(buffer.duration,x/rect.width*buffer.duration));const val=nodeValFromY(y,rect.height);const nodes=[...getNodes()];nodes[dragNodeIdx.current]=graphMode==='pan'?{time:+time.toFixed(3),pan:+Math.max(-1,Math.min(1,val)).toFixed(2)}:{time:+time.toFixed(3),db:+Math.max(-30,Math.min(3,val)).toFixed(1)};onUpdateNodes(nodes.sort((a,b)=>a.time-b.time));};const handleMouseUp=()=>{isDraggingNode.current=false;dragNodeIdx.current=-1;isCreatingNode.current=false;};const handleContextMenu=e=>{e.preventDefault();const canvas=canvasRef.current;if(!canvas||!buffer)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const time=x/rect.width*buffer.duration;const threshold=12/rect.width*buffer.duration;const nodes=getNodes();const nearIdx=nodes.findIndex(n=>Math.abs(n.time-time)<threshold);if(nearIdx>=0)onUpdateNodes(nodes.filter((_,i)=>i!==nearIdx));};return/*#__PURE__*/React.createElement("div",{style:{width:`${timelineWidth}px`,height:'100%',position:'relative',overflow:'hidden'}},/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'absolute',top:0,left:0,imageRendering:'pixelated'},className:"cursor-crosshair rounded border border-zinc-700",onMouseDown:handleMouseDown,onMouseMove:handleMouseMove,onMouseUp:handleMouseUp,onMouseLeave:handleMouseUp,onContextMenu:handleContextMenu}));};const AuthModal=({isOpen,mode,forceMandatory,onClose,onSuccess})=>{if(!isOpen)return null;const[activeTab,setActiveTab]=useState(mode||'login');const[username,setUsername]=useState('admin');const[email,setEmail]=useState('');const[password,setPassword]=useState('');const[oldPassword,setOldPassword]=useState('');const[newPassword,setNewPassword]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);const[firstTime,setFirstTime]=useState(true);useEffect(()=>{if(mode)setActiveTab(mode);if(mode==='force_change'&&!oldPassword){setOldPassword('admin123');}},[mode]);// Lần đăng nhập ĐẦU (admin còn mật khẩu mặc định) → hiện gợi ý
|
||
// username/mật khẩu; sau khi đã đổi → ẩn vĩnh viễn.
|
||
useEffect(()=>{if(!isOpen)return;if(window.SonicAPI&&window.SonicAPI.apiRequest){window.SonicAPI.apiRequest('/api/v1/auth/first-time',{method:'GET'}).then(function(d){setFirstTime(!!(d&&d.first_time));})// Fail-closed: endpoint lỗi/404 (backend chưa restart) → ẨN gợi ý
|
||
// (không hiện — user đã yêu cầu bỏ gợi ý sau lần đầu).
|
||
.catch(function(){setFirstTime(false);});}else{setFirstTime(false);}},[isOpen]);const handleSubmit=async e=>{e.preventDefault();setError('');setLoading(true);try{if(activeTab==='login'){const targetUsername=username.trim()||'admin';const targetPwd=password.trim()||'admin123';const res=await window.SonicAPI.login(targetUsername,targetPwd);localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));if(res.user&&res.user.must_change_password){setOldPassword(targetPwd);}onSuccess(res.user,res.access_token);}else if(activeTab==='register'){const res=await window.SonicAPI.register(username.trim(),email.trim(),password.trim());localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));onSuccess(res.user,res.access_token);}else if(activeTab==='force_change'){const res=await window.SonicAPI.changePassword(oldPassword.trim(),newPassword.trim());localStorage.setItem('sonic_token',res.access_token);const user=JSON.parse(localStorage.getItem('sonic_user')||'{}');user.must_change_password=false;localStorage.setItem('sonic_user',JSON.stringify(user));onSuccess(user,res.access_token);}}catch(err){setError(err.message||(activeTab==='login'?'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)':'Thao tác không thành công'));}finally{setLoading(false);}};const isForceMode=activeTab==='force_change';const canClose=!forceMandatory&&!isForceMode;return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-teal-400"},isForceMode?'⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo':activeTab==='login'?'🔐 Đăng Nhập Hệ Thống':'📝 Đăng Ký Tài Khoản'),canClose&&/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),error&&/*#__PURE__*/React.createElement("div",{className:"mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm"},error),/*#__PURE__*/React.createElement("form",{onSubmit:handleSubmit,className:"mt-4 space-y-4"},isForceMode?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("p",{className:"text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed"},"🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu hiện tại (Mặc định: admin123)"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"current-password",required:true,value:oldPassword,onChange:e=>setOldPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu mới"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"new-password",required:true,value:newPassword,onChange:e=>setNewPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"}))):/*#__PURE__*/React.createElement(React.Fragment,null,activeTab==='login'?/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Tên đăng nhập ",firstTime&&/*#__PURE__*/React.createElement("span",{className:"text-teal-400 font-normal"},"(Tùy chọn - Admin có thể bỏ trống)")),/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Mặc định: admin",value:username,onChange:e=>setUsername(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})):/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Tên đăng nhập"),/*#__PURE__*/React.createElement("input",{type:"text",required:true,value:username,onChange:e=>setUsername(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),activeTab==='register'&&/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Email"),/*#__PURE__*/React.createElement("input",{type:"email",required:true,value:email,onChange:e=>setEmail(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu ",activeTab==='login'&&firstTime&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-normal"},"(Lần đầu: admin123)")),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:activeTab==='login'?'current-password':'new-password',required:true,value:password,onChange:e=>setPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"}))),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150"},loading?'Đang xác thực...':isForceMode?'Đổi Mật Khẩu Ngay':activeTab==='login'?'Đăng Nhập System':'Tạo Tài Khoản Mới'),activeTab==='login'&&!isForceMode&&firstTime&&/*#__PURE__*/React.createElement("button",{type:"button",onClick:()=>{setUsername('admin');setPassword('admin123');setError('');},className:"w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5"},"🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")),!isForceMode&&/*#__PURE__*/React.createElement("div",{className:"mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400"},activeTab==='login'?/*#__PURE__*/React.createElement("span",null,"Chưa có tài khoản? ",/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('register'),className:"text-teal-400 hover:underline"},"Đăng ký ngay")):/*#__PURE__*/React.createElement("span",null,"Đã có tài khoản? ",/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('login'),className:"text-teal-400 hover:underline"},"Đăng nhập")))));};const AIConfigModal=({isOpen,onClose,onConfigSaved})=>{if(!isOpen)return null;const defaultProvidersList=[{id:'openai_default',name:'OpenAI Official',provider_type:'openai',api_base_url:'https://api.openai.com/v1',api_key:'',model_name:'gpt-4o',temperature:0.7,is_active:true},{id:'openai_compat_default',name:'OpenAI Compatible (Ollama/LocalAI/DeepSeek)',provider_type:'openai_compatible',api_base_url:'http://localhost:11434/v1',api_key:'ollama',model_name:'deepseek-r1',temperature:0.7,is_active:false},{id:'anthropic_default',name:'Anthropic Claude',provider_type:'anthropic',api_base_url:'https://api.anthropic.com/v1',api_key:'',model_name:'claude-3-5-sonnet',temperature:0.7,is_active:false},{id:'gemini_default',name:'Google Gemini',provider_type:'gemini',api_base_url:'https://generativelanguage.googleapis.com',api_key:'',model_name:'gemini-1.5-pro',temperature:0.7,is_active:false}];const[providers,setProviders]=useState(defaultProvidersList);const[selectedId,setSelectedId]=useState('openai_default');const[msg,setMsg]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);useEffect(()=>{if(isOpen)loadConfigs();},[isOpen]);const loadConfigs=async()=>{setLoading(true);setError('');try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setProviders(data.providers);if(data.providers.length>0)setSelectedId(data.providers[0].id);}}catch(err){setError(err.message||'Lỗi nạp cấu hình AI');}finally{setLoading(false);}};const handleSave=async e=>{e.preventDefault();setMsg('');setError('');setLoading(true);try{const res=await window.SonicAPI.saveAIConfigs(providers);setMsg(res.message||'Đã lưu cấu hình AI Providers thành công!');if(onConfigSaved)onConfigSaved(providers);}catch(err){setError(err.message||'Lỗi khi lưu cấu hình AI');}finally{setLoading(false);}};const updateProviderField=(id,field,value)=>{setProviders(prev=>prev.map(p=>p.id===id?{...p,[field]:value}:p));};const activeProvider=providers.find(p=>p.id===selectedId)||providers[0];return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-cyan-400 flex items-center gap-2"},"🤖 Quản Lý & Cấu Hình AI Providers"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),msg&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"},msg),error&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"},error),/*#__PURE__*/React.createElement("div",{className:"mt-4 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"space-y-1.5 border-r border-[#383838] pr-3"},/*#__PURE__*/React.createElement("span",{className:"text-xs uppercase font-bold text-slate-400 block mb-2"},"Providers"),providers.map(p=>/*#__PURE__*/React.createElement("button",{key:p.id,tabIndex:0,onClick:()=>setSelectedId(p.id),onKeyDown:e=>{if(e.key===' '){e.preventDefault();updateProviderField(p.id,'is_active',!p.is_active);}},className:`w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId===p.id?'bg-cyan-600 text-white shadow':'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}`},/*#__PURE__*/React.createElement("span",{className:"truncate"},p.name),p.is_active&&/*#__PURE__*/React.createElement("span",{className:"w-2 h-2 rounded-full bg-emerald-400"}))),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 mt-2"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();const rearrangeNewId='provider_'+Date.now();setProviders(prev=>[...prev,{id:rearrangeNewId,name:'New Provider',provider_type:'openai_compatible',api_base_url:'https://api.openai.com/v1',api_key:'',model_name:'gpt-4o-mini',temperature:0.7,is_active:false}]);setSelectedId(rearrangeNewId);},className:"flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded"},"+ Thêm"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();if(confirm(`Xóa provider "${providers.find(p=>p.id===selectedId)?.name}"?`)){setProviders(prev=>{const filtered=prev.filter(p=>p.id!==selectedId);if(filtered.length>0)setSelectedId(filtered[0].id);return filtered;});}},className:"px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded"},"Xóa"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:function(e){e.stopPropagation();var provs=providers.slice();var pos=-1;for(var pi=0;pi<provs.length;pi++){if(provs[pi].id===selectedId){pos=pi;break;}}if(pos>0){var tmp=provs[pos];provs[pos]=provs[pos-1];provs[pos-1]=tmp;setProviders(provs);}},className:"px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded",title:"Di chuyển lên"},"▲"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:function(e){e.stopPropagation();var provs=providers.slice();var pos=-1;for(var pi=0;pi<provs.length;pi++){if(provs[pi].id===selectedId){pos=pi;break;}}if(pos<provs.length-1){var tmp=provs[pos];provs[pos]=provs[pos+1];provs[pos+1]=tmp;setProviders(provs);}},className:"px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded",title:"Di chuyển xuống"},"▼"))),activeProvider&&/*#__PURE__*/React.createElement("form",{onSubmit:handleSave,className:"col-span-2 space-y-3.5"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Tên Provider"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.name,onChange:e=>updateProviderField(activeProvider.id,'name',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"API Base URL (Endpoint)"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.api_base_url||'',onChange:e=>updateProviderField(activeProvider.id,'api_base_url',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500",placeholder:"https://api.openai.com/v1"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"API Key Cá Nhân"),/*#__PURE__*/React.createElement("input",{type:"password",value:activeProvider.api_key||'',onChange:e=>updateProviderField(activeProvider.id,'api_key',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500",placeholder:"sk-..."})),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Model Name"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.model_name||'',onChange:e=>updateProviderField(activeProvider.id,'model_name',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Temperature"),/*#__PURE__*/React.createElement("input",{type:"number",step:"0.1",min:"0",max:"2",value:activeProvider.temperature??0.7,onChange:e=>updateProviderField(activeProvider.id,'temperature',parseFloat(e.target.value)),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"}))),/*#__PURE__*/React.createElement("div",{className:"pt-2 flex items-center justify-between"},/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-2 cursor-pointer text-xs text-slate-300"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:activeProvider.is_active,onChange:e=>updateProviderField(activeProvider.id,'is_active',e.target.checked),className:"rounded accent-cyan-500"}),"Kích hoạt Provider này"),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition"},loading?'Đang lưu...':'Lưu Cấu Hình AI'))))));};const PluginManagerModal=({isOpen,onClose,pluginsData,onInsertInstrument})=>{if(!isOpen)return null;const[localData,setLocalData]=React.useState(pluginsData);const[sfUploadStatus,setSfUploadStatus]=React.useState('');const[sfToDelete,setSfToDelete]=React.useState(null);const[pmDirs,setPmDirs]=React.useState([]);const[pmScanning,setPmScanning]=React.useState(false);const[pmScanResult,setPmScanResult]=React.useState('');const[pmScanData,setPmScanData]=React.useState(null);// { vst_found, soundfonts }
|
||
// Instrument bên trong mỗi soundfont (expand) — "Chèn vào Synth" qua onInsertInstrument
|
||
const[pmSfExpanded,setPmSfExpanded]=React.useState({});const[pmSfInstruments,setPmSfInstruments]=React.useState({});const[pmSfLoading,setPmSfLoading]=React.useState({});// D7: trạng thái Native Bridge (query khi mở modal) — badge header.
|
||
const[bridgeStatus,setBridgeStatus]=React.useState(null);React.useEffect(()=>{if(isOpen){window.SonicAPI.bridgeStatus().then(s=>setBridgeStatus(s)).catch(()=>setBridgeStatus(null));}},[isOpen]);React.useEffect(()=>{if(isOpen){window.SonicAPI.listPlugins().then(data=>setLocalData(data)).catch(()=>setLocalData({vst_instruments:[],soundfonts:[]}));window.SonicAPI.getPluginDirs().then(d=>setPmDirs(d.plugin_dirs||[])).catch(()=>{});setTimeout(()=>{try{window.lucide.createIcons();}catch(e){}},50);}},[isOpen]);// Folder picker:
|
||
// 1) Neu page duoc Tauri serve (__TAURI__ co) -> dialog plugin invoke.
|
||
// 2) Binh thuong UI chay tren http://127.0.0.1:8000 (engine) -> __TAURI__
|
||
// KHONG co (Tauri chi inject vao trang no serve; window.prompt cung
|
||
// khong hoat dong trong WebView2) -> mo TRINH DUYET THU MUC in-app
|
||
// (backend /media/computer + /media/browse) — hoat dong moi OS.
|
||
// 3) Cuoi cung: prompt nhap path (browser thuan).
|
||
const[pmPicker,setPmPicker]=React.useState(null);// {path, dirs, parent, roots, loading}
|
||
const openPluginPicker=async()=>{setPmPicker({path:null,dirs:null,parent:null,roots:null,loading:true});try{const data=await window.SonicAPI.browseComputer();setPmPicker({path:null,dirs:null,parent:null,roots:data.roots||[],loading:false});}catch(e){setPmPicker(null);showToast('Không mở được trình duyệt thư mục: '+(e.message||e),'error');}};const browsePluginDir=async path=>{setPmPicker(prev=>({...prev,loading:true}));try{const data=await window.SonicAPI.browseDir(path);setPmPicker({path:data.path,parent:data.parent,dirs:data.dirs||[],roots:null,loading:false});}catch(e){setPmPicker(prev=>({...prev,loading:false}));showToast('Không đọc được thư mục: '+(e.message||e),'error');}};const confirmPluginDir=()=>{const p=pmPicker&&pmPicker.path;if(p&&!pmDirs.includes(p)){const next=[...pmDirs,p];setPmDirs(next);// TỰ ĐỘNG lưu + scan → nút Synth có ngay danh sách instrument
|
||
clearTimeout(window.__pmAutoScanTimer);window.__pmAutoScanTimer=setTimeout(()=>{saveAndScanDirs(next);},600);}setPmPicker(null);};const pickPluginFolder=async()=>{const addDir=p=>{if(p&&!pmDirs.includes(p)){const next=[...pmDirs,p];setPmDirs(next);clearTimeout(window.__pmAutoScanTimer);window.__pmAutoScanTimer=setTimeout(()=>{saveAndScanDirs(next);},600);}};try{// 1) NATIVE dialog qua engine (Tauri bridge → IFileDialog/Explorer;
|
||
// fallback PowerShell/zenity/osascript) — UI chạy localhost:8000 nên
|
||
// __TAURI__ không có, window.prompt vô hiệu trong WebView2.
|
||
try{const d=await window.SonicAPI.pickPluginDir();if(d&&typeof d.path==='string'&&d.path){addDir(d.path);return;}}catch(e){/* fallthrough */}// 2) Tauri dialog trực tiếp (chỉ khi page được Tauri serve)
|
||
if(window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke){const sel=await window.__TAURI__.core.invoke('plugin:dialog|open',{options:{directory:true,multiple:false}});if(typeof sel==='string'&&sel){addDir(sel);return;}}// 3) Trình duyệt thư mục in-app (backend) — fallback mọi OS
|
||
await openPluginPicker();return;}catch(e){// 4) Cuối cùng: prompt nhập tay (browser thuần, không phải WebView2)
|
||
try{const manual=window.prompt('Nhập đường dẫn thư mục plugin (VST / SoundFont):');if(manual&&manual.trim())addDir(manual.trim());}catch(e2){showToast('Browse failed: '+(e.message||e),'error');}}};const removePluginDir=dir=>{const next=pmDirs.filter(d=>d!==dir);setPmDirs(next);// Tự động lưu + scan lại sau khi xóa thư mục
|
||
clearTimeout(window.__pmAutoScanTimer);window.__pmAutoScanTimer=setTimeout(()=>{saveAndScanDirs(next);},600);};// Expand 1 soundfont → đọc danh sách instrument (bank/program/name) bên trong
|
||
// qua API soundfont-instruments/{id} → hiển thị nút "Chèn vào Synth".
|
||
const toggleSfInstruments=async sf=>{const baseId=String(sf.id||'').replace('sf_','');setPmSfExpanded(prev=>({...prev,[baseId]:!prev[baseId]}));if(!pmSfInstruments[baseId]&&!pmSfLoading[baseId]){setPmSfLoading(prev=>({...prev,[baseId]:true}));try{const r=await window.SonicAPI.listSoundfontInstruments(baseId);setPmSfInstruments(prev=>({...prev,[baseId]:r&&r.presets||[]}));}catch(e){setPmSfInstruments(prev=>({...prev,[baseId]:[]}));}finally{setPmSfLoading(prev=>({...prev,[baseId]:false}));}}};// Lưu dirs (user override) + scan cả 2 thư mục → refresh list + catalog.
|
||
// dirsOverride: mảng dirs mới nhất (fix stale-closure: setTimeout giữ
|
||
// pmDirs cũ nên add/remove xong tự scan lại vẫn lưu dir cũ).
|
||
const saveAndScanDirs=async dirsOverride=>{const dirs=dirsOverride||pmDirs;setPmScanning(true);setPmScanResult('');setPmScanData(null);try{await window.SonicAPI.savePluginDirs({plugin_dirs:dirs});const scan=await window.SonicAPI.scanPluginDirs();setPmScanData({vst_found:scan.vst_found||[],soundfonts:scan.soundfonts||[]});const data=await window.SonicAPI.listPlugins();setLocalData(data);try{const cat=await window.SonicAPI.getSoundfontCatalog();window.__soundfontCatalog=cat;}catch(_){}setPmScanResult(`VST: ${scan.vst_count||0} | SoundFonts: ${scan.soundfont_count||0}`);showToast(`Scan xong: ${scan.vst_count||0} VST, ${scan.soundfont_count||0} SoundFonts.`,'success');}catch(err){setPmScanResult('Scan failed: '+(err.message||err));showToast('Scan failed: '+(err.message||err),'error');}finally{setPmScanning(false);}};const handleUploadSF=async e=>{const file=e.target.files?.[0];if(!file)return;setSfUploadStatus('Uploading...');try{const result=await window.SonicAPI.uploadSoundFont(file);setSfUploadStatus('Uploaded: '+result.name);// Refresh plugin list and catalog
|
||
const data=await window.SonicAPI.listPlugins();setLocalData(data);try{const cat=await window.SonicAPI.getSoundfontCatalog();window.__soundfontCatalog=cat;}catch(_){}}catch(err){setSfUploadStatus('Error: '+err.message);}};// D7: Load asset vào Native Bridge — backend resolve path thật (E2) → JS
|
||
// invoke load_native_instrument (C2) → bridge C++ load vào channel 0.
|
||
const loadToBridge=async(name,path,type)=>{try{const r=await window.SonicAPI.bridgeLoad({name:name,path:path||null,instrumentType:type,channel:0});if(!r||!r.path){window.showToast&&window.showToast('Không resolve được asset: '+name,'error');return false;}const ok=await window.NativeBridgeService.loadInstrument(r.path,type,0);window.showToast&&window.showToast(ok?'Đã load vào Native Bridge: '+r.path:'Bridge không khả dụng (dev mode?)',ok?'success':'warning');if(ok)window.SonicAPI.bridgeStatus().then(s=>setBridgeStatus(s)).catch(()=>{});// Requirement 2: VST3 load → mở native GUI ngay (C++ attach editor vào
|
||
// cửa sổ bridge tự tạo — control type=4, hwnd=0).
|
||
if(ok&&(type==='VST3'||type==='VST2')&&window.NativeBridgeService.openNativeGUI){try{await openVstGuiRetry(name,0,20);}catch(e){}}return ok;}catch(err){window.showToast&&window.showToast('Lỗi load bridge: '+(err.message||err),'error');return false;}};const[pmTab,setPmTab]=React.useState('soundfont');return React.createElement('div',{className:'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',onClick:onClose},React.createElement('div',{className:'bg-[#1e1e1e] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-0 text-slate-200 overflow-hidden flex flex-col relative',style:{maxHeight:'80vh'},onClick:e=>e.stopPropagation()},// ── In-app folder picker (Plugin Directories) ─────────────────────
|
||
pmPicker&&React.createElement('div',{className:'absolute inset-0 z-10 bg-[#171717]/97 flex flex-col',style:{padding:16}},React.createElement('div',{className:'flex items-center justify-between mb-2'},React.createElement('div',{className:'text-xs font-bold text-violet-300 uppercase'},'Chọn thư mục plugin'),React.createElement('button',{onClick:()=>setPmPicker(null),className:'text-zinc-500 hover:text-zinc-200 transition'},React.createElement('i',{'data-lucide':'x',className:'w-4 h-4'}))),React.createElement('div',{className:'flex items-center gap-2 mb-2'},React.createElement('button',{onClick:()=>{if(pmPicker.parent)browsePluginDir(pmPicker.parent);},disabled:!pmPicker.parent,className:'px-2 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-[11px] text-zinc-300 disabled:opacity-30 shrink-0'},'Lên'),React.createElement('div',{className:'flex-1 text-[11px] text-zinc-400 font-mono truncate',title:pmPicker.path||''},pmPicker.path||(pmPicker.loading?'Đang tải...':'My Computer'))),pmPicker.loading?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-500 text-xs'},'Đang tải...'):pmPicker.dirs?pmPicker.dirs.length===0?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-600 text-xs italic'},'Thư mục trống'):React.createElement('div',{className:'flex-1 overflow-y-auto space-y-1'},pmPicker.dirs.map((d,i)=>React.createElement('div',{key:'pd_'+i,className:'flex items-center gap-2 px-2 py-1.5 rounded hover:bg-violet-900/30 cursor-pointer group',onClick:()=>browsePluginDir(d.path)},React.createElement('i',{'data-lucide':'folder',className:'w-3.5 h-3.5 text-amber-500/80 shrink-0'}),React.createElement('span',{className:'flex-1 text-[11px] text-zinc-300 truncate'},d.name),React.createElement('i',{'data-lucide':'chevron-right',className:'w-3 h-3 text-zinc-600 group-hover:text-violet-400 shrink-0'})))):pmPicker.roots?pmPicker.roots.length===0?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-600 text-xs'},'Không tìm thấy ổ đĩa'):React.createElement('div',{className:'flex-1 overflow-y-auto space-y-1'},pmPicker.roots.map((r,i)=>React.createElement('div',{key:'pr_'+i,className:'flex items-center gap-2 px-2 py-1.5 rounded hover:bg-violet-900/30 cursor-pointer',onClick:()=>browsePluginDir(r.path)},React.createElement('i',{'data-lucide':'hard-drive',className:'w-3.5 h-3.5 text-cyan-500/80 shrink-0'}),React.createElement('span',{className:'flex-1 text-[11px] text-zinc-300'},r.name||r.path)))):null,React.createElement('div',{className:'flex items-center justify-end gap-2 mt-2 pt-2 border-t border-[#383838]'},React.createElement('button',{onClick:()=>setPmPicker(null),className:'px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-white text-xs rounded'},'Hủy'),React.createElement('button',{onClick:confirmPluginDir,disabled:!pmPicker.path,className:'px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white text-xs font-semibold rounded disabled:opacity-30'},'Chọn thư mục này'))),// Header
|
||
React.createElement('div',{className:'flex items-center justify-between px-5 py-3 bg-[#252525] border-b border-[#383838]'},React.createElement('h3',{className:'text-base font-bold text-cyan-400 flex items-center gap-2'},React.createElement('i',{'data-lucide':'zap',className:'w-4 h-4'}),'Plugin Manager (SoundFont / VSTi)'),React.createElement('div',{className:'flex items-center gap-2'},React.createElement('span',{className:`text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border ${bridgeStatus&&bridgeStatus.connected?'text-emerald-400 border-emerald-700 bg-emerald-900/30':'text-zinc-500 border-zinc-700 bg-zinc-800'}`,title:'Native Bridge (C++ engine) — '+(bridgeStatus?JSON.stringify(bridgeStatus).slice(0,120):'chưa query')},bridgeStatus&&bridgeStatus.connected?'● Bridge ON':'● Bridge OFF'),React.createElement('button',{onClick:onClose,className:'text-zinc-500 hover:text-zinc-200 transition'},React.createElement('i',{'data-lucide':'x',className:'w-4 h-4'})))),// Left-right body
|
||
React.createElement('div',{className:'flex flex-1 overflow-hidden',style:{minHeight:'300px'}},// Left sidebar
|
||
React.createElement('div',{className:'w-40 shrink-0 border-r border-[#383838] bg-[#1a1a1a] p-3 flex flex-col gap-2'},['vst','soundfont'].map(tab=>React.createElement('button',{key:tab,onClick:()=>setPmTab(tab),className:`w-full py-2 text-xs font-bold rounded transition border ${pmTab===tab?tab==='vst'?'bg-violet-900 border-violet-700 text-violet-200':'bg-amber-900 border-amber-700 text-amber-200':'bg-zinc-800 border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700'} flex items-center gap-2 px-3`},React.createElement('i',{'data-lucide':tab==='vst'?'cpu':'music',className:'w-3.5 h-3.5'}),tab==='vst'?'VST Instruments':'SoundFonts'))),// Right content
|
||
React.createElement('div',{className:'flex-1 overflow-y-auto p-4 bg-[#1e1e1e]'},!localData?React.createElement('div',{className:'flex items-center justify-center h-full text-zinc-500 text-xs'},'Loading...'):React.createElement('div',{className:'space-y-2'},pmTab==='vst'?localData.vst_instruments?.length===0?React.createElement('div',{className:'flex items-center justify-center h-32 text-zinc-500 text-xs'},'No VST instruments found on server.'):localData.vst_instruments.map((v,i)=>React.createElement('div',{key:i,className:'flex items-center justify-between bg-[#252525] px-4 py-3 rounded-lg border border-[#333] hover:border-violet-800/50 transition'},React.createElement('div',{className:'flex items-center gap-3'},React.createElement('div',{className:'w-8 h-8 rounded bg-violet-900/30 flex items-center justify-center'},React.createElement('i',{'data-lucide':'cpu',className:'w-4 h-4 text-violet-400'})),React.createElement('div',null,React.createElement('div',{className:'text-xs font-semibold text-slate-200'},v.name||v.id),React.createElement('div',{className:'text-[10px] text-zinc-500'},v.type||'VST3'))),React.createElement('div',{className:'flex items-center gap-2'},React.createElement('button',{onClick:e=>{e.stopPropagation();loadToBridge(v.name||v.id,v.path,v.type||'VST3');},className:'text-[10px] bg-cyan-800 hover:bg-cyan-700 text-white px-2 py-1 rounded transition shrink-0',title:'Load vào Native Bridge (C++ engine)'},'Load Bridge'),React.createElement('span',{className:'text-[10px] bg-violet-950/40 text-violet-400 px-2 py-0.5 rounded-full border border-violet-800/30'},v.type||'VST3')))):localData.soundfonts?.length===0?React.createElement('div',{className:'flex items-center justify-center h-32 text-zinc-500 text-xs'},'No SoundFonts found. Upload one below.'):localData.soundfonts.map((sf,i)=>{const baseId=String(sf.id||'').replace('sf_','');const expanded=!!pmSfExpanded[baseId];const insts=pmSfInstruments[baseId]||[];const loading=!!pmSfLoading[baseId];return React.createElement('div',{key:i,className:'bg-[#252525] rounded-lg border border-[#333] hover:border-amber-800/50 transition group'},React.createElement('div',{className:'flex items-center justify-between px-4 py-3 cursor-pointer',onClick:()=>toggleSfInstruments(sf)},React.createElement('div',{className:'flex items-center gap-3'},React.createElement('div',{className:'w-8 h-8 rounded bg-amber-900/30 flex items-center justify-center'},React.createElement('i',{'data-lucide':'music',className:'w-4 h-4 text-amber-400'})),React.createElement('div',null,React.createElement('div',{className:'text-xs font-semibold text-slate-200'},sf.display||sf.name||sf.id),React.createElement('div',{className:'text-[10px] text-zinc-500'},(sf.file||sf.name)+(insts.length?' — '+insts.length+' instruments':'')))),React.createElement('div',{className:'flex items-center gap-2'},React.createElement('button',{onClick:e=>{e.stopPropagation();loadToBridge(sf.id,sf.file||sf.path,'SF2');},className:'text-[10px] bg-cyan-800 hover:bg-cyan-700 text-white px-2 py-1 rounded transition shrink-0',title:'Load vào Native Bridge (C++ engine)'},'Load Bridge'),React.createElement('span',{className:'text-[10px] text-zinc-500'},expanded?'▾':'▸'),React.createElement('button',{onClick:e=>{e.stopPropagation();setSfToDelete(sf);},className:'text-[10px] text-zinc-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition px-2 py-1'},'Delete'))),expanded&&React.createElement('div',{className:'border-t border-[#333] px-3 py-1 max-h-40 overflow-y-auto'},loading?React.createElement('p',{className:'text-[10px] text-zinc-500 italic py-1'},'Đang đọc instruments...'):insts.length===0?React.createElement('p',{className:'text-[10px] text-zinc-500 italic py-1'},'Không có instrument (SF3 cần chuyển đổi trước)'):insts.map((p,pi)=>React.createElement('div',{key:'si_'+pi,className:'flex items-center gap-2 py-1 text-[11px]'},React.createElement('span',{className:'text-zinc-500 font-mono w-24 shrink-0 text-[9px]'},'B'+(p.bank||0)+' P'+(p.program||0)),React.createElement('span',{className:'flex-1 truncate text-zinc-300'},p.name||'Program '+p.program),React.createElement('button',{onClick:()=>onInsertInstrument&&onInsertInstrument({instrumentId:'sf_'+baseId,bank:p.bank||0,program:p.program||0,name:p.name||'Program '+p.program,displayName:(sf.display||sf.name||sf.id)+' — '+(p.name||'Program '+p.program)}),className:'text-[10px] bg-amber-800 hover:bg-amber-700 text-white px-2 py-0.5 rounded transition shrink-0'},'Chèn vào Synth')))));})),// Plugin directories section (folder picker + save + scan)
|
||
React.createElement('div',{className:'pt-4 mt-4 border-t border-[#383838]'},React.createElement('div',{className:'flex items-center justify-between mb-2'},React.createElement('h4',{className:'text-xs font-bold text-zinc-400 uppercase'},'Plugin Directories'),React.createElement('button',{className:'px-3 py-1.5 bg-violet-800 hover:bg-violet-700 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0',title:'Add plugin directory (VST / SoundFont)',onClick:pickPluginFolder},React.createElement('i',{'data-lucide':'plus',className:'w-3 h-3'}),'Add Directory')),pmDirs.length===0&&React.createElement('p',{className:'text-[10px] text-zinc-600 mb-2 italic'},'Chưa có thư mục nào. Nhấn Add Directory để chọn thư mục chứa VST / SoundFont.'),pmDirs.map((dir,idx)=>React.createElement('div',{key:'pdir_'+idx,className:'flex items-center gap-2 mb-1.5 bg-zinc-800/70 border border-zinc-700 rounded px-2 py-1.5'},React.createElement('button',{className:'text-zinc-500 hover:text-red-400 transition shrink-0',title:'Remove directory',onClick:()=>removePluginDir(dir)},React.createElement('i',{'data-lucide':'x',className:'w-3.5 h-3.5'})),React.createElement('span',{className:'flex-1 text-[11px] text-zinc-300 font-mono truncate',title:dir},dir),React.createElement('i',{'data-lucide':'folder',className:'w-3 h-3 text-zinc-600 shrink-0'}))),React.createElement('div',{className:'flex gap-2 items-center mt-2'},React.createElement('button',{className:'px-4 py-2 bg-emerald-800 hover:bg-emerald-700 text-white text-xs font-semibold rounded transition flex items-center gap-1',onClick:()=>saveAndScanDirs()},React.createElement('i',{'data-lucide':'search',className:'w-3 h-3'}),'Scan'),pmScanning&&React.createElement('span',{className:'text-[10px] text-emerald-400'},'Scanning...'),pmScanResult&&React.createElement('span',{className:'text-[10px] text-zinc-400'},pmScanResult)),pmScanData&&React.createElement('div',{className:'mt-3 space-y-2 max-h-40 overflow-y-auto'},React.createElement('div',{className:'text-[10px] font-bold text-violet-300 uppercase flex items-center gap-1'},React.createElement('i',{'data-lucide':'cpu',className:'w-3 h-3'}),'VST Instruments ('+pmScanData.vst_found.length+')'),pmScanData.vst_found.length===0?React.createElement('p',{className:'text-[10px] text-zinc-600 italic'},'Không tìm thấy VST.'):pmScanData.vst_found.map((v,i)=>React.createElement('div',{key:'sv_'+i,className:'flex items-center gap-2 text-[11px] text-zinc-300'},React.createElement('span',{className:'w-16 shrink-0 text-zinc-500 font-mono text-[9px] truncate'},v.type||'VST'),React.createElement('span',{className:'truncate'},v.name),React.createElement('span',{className:'text-[9px] text-zinc-600 font-mono truncate ml-auto'},v.dir))),React.createElement('div',{className:'text-[10px] font-bold text-amber-300 uppercase flex items-center gap-1 mt-2'},React.createElement('i',{'data-lucide':'music',className:'w-3 h-3'}),'SoundFonts ('+pmScanData.soundfonts.length+')'),pmScanData.soundfonts.length===0?React.createElement('p',{className:'text-[10px] text-zinc-600 italic'},'Không tìm thấy SoundFont.'):pmScanData.soundfonts.map((s,i)=>React.createElement('div',{key:'ss_'+i,className:'flex items-center gap-2 text-[11px] text-zinc-300'},React.createElement('span',{className:'truncate'},s.name),React.createElement('span',{className:'text-[9px] text-zinc-600 font-mono truncate ml-auto'},s.dir))))),// Upload section (bottom of right panel)
|
||
React.createElement('div',{className:'pt-4 mt-4 border-t border-[#383838]'},React.createElement('h4',{className:'text-xs font-bold text-zinc-400 mb-3 uppercase'},pmTab==='vst'?'Add VST Directory':'Upload SoundFont'),pmTab==='vst'?React.createElement('div',{className:'flex gap-2'},React.createElement('input',{type:'text',placeholder:'/opt/daw_engine/vst3',className:'flex-1 bg-zinc-800 border border-zinc-700 rounded px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-violet-600'}),React.createElement('button',{className:'px-4 py-2 bg-violet-800 hover:bg-violet-700 text-white text-xs font-semibold rounded transition',onClick:async()=>{try{const data=await window.SonicAPI.listPlugins();setLocalData(data);showToast('Scanned VST directory.','info');}catch(err){showToast('Scan failed: '+err.message,'error');}}},'Scan')):React.createElement('div',{className:'space-y-2'},React.createElement('label',{className:'flex items-center gap-3 px-4 py-3 border-2 border-dashed border-zinc-700 rounded-lg cursor-pointer hover:border-amber-600/50 bg-zinc-800/40 transition'},React.createElement('i',{'data-lucide':'upload',className:'w-5 h-5 text-zinc-400'}),React.createElement('span',{className:'text-xs text-zinc-400'},'Click to upload .sf2 / .sf3 file'),React.createElement('input',{type:'file',accept:'.sf2,.sf3',onChange:async e=>{const file=e.target.files?.[0];if(!file)return;setSfUploadStatus('Uploading...');try{await window.SonicAPI.uploadSoundFont(file);setSfUploadStatus('Uploaded: '+file.name);const data=await window.SonicAPI.listPlugins();setLocalData(data);}catch(err){setSfUploadStatus('Error: '+err.message);}},className:'hidden'})),sfUploadStatus&&React.createElement('p',{className:'text-[10px] text-zinc-500'},sfUploadStatus)))))),// Status bar at bottom
|
||
React.createElement('div',{className:'px-5 py-2 bg-[#1a1a1a] border-t border-[#383838] flex items-center justify-between text-[10px] text-zinc-500'},React.createElement('span',null,'VST: ',localData?.vst_instruments?.length||0,' | SoundFonts: ',localData?.soundfonts?.length||0),React.createElement('span',null,'Last scanned: ',new Date().toLocaleTimeString())),// Delete confirmation modal
|
||
sfToDelete&&React.createElement('div',{className:'fixed inset-0 z-[60] flex items-center justify-center bg-black/70',onClick:()=>setSfToDelete(null)},React.createElement('div',{className:'bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-sm p-5 text-slate-200',onClick:e=>e.stopPropagation()},React.createElement('h3',{className:'text-sm font-bold text-red-400 mb-3'},'Delete SoundFont?'),React.createElement('p',{className:'text-xs text-zinc-400 mb-1'},'Are you sure you want to delete:'),React.createElement('p',{className:'text-sm font-semibold text-slate-200 mb-4'},sfToDelete.display||sfToDelete.name||sfToDelete.id),React.createElement('div',{className:'flex justify-end gap-2'},React.createElement('button',{onClick:()=>setSfToDelete(null),className:'px-4 py-2 text-xs rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 transition'},'Cancel'),React.createElement('button',{onClick:async()=>{try{if(window.SonicAPI.deleteSoundFont){await window.SonicAPI.deleteSoundFont(sfToDelete.id);}const data=await window.SonicAPI.listPlugins();setLocalData(data);setSfToDelete(null);window.showToast&&window.showToast('SoundFont deleted.','info');}catch(err){window.showToast&&window.showToast('Delete failed: '+err.message,'error');setSfToDelete(null);}},className:'px-4 py-2 text-xs rounded bg-red-700 hover:bg-red-600 text-white font-semibold transition'},'Delete')))));};// ── Bridge VST3 auto-load: dùng chung cho preload project + bridge
|
||
// reconnect + nút GUI (ensure-load rồi mới open — tránh nền trắng khi bridge
|
||
// restart làm mất instrument). __bridgeLoadedChannels (ch→true) đánh dấu đã
|
||
// load trong session; reset khi bridge down/restart → load lại đúng lúc.
|
||
// V9: track.plugin_path/cache path có thể là plugin KHÁC instrumentId (VD
|
||
// WaveObserver trong khi track là Nexus) → basename không chứa tên id → trả
|
||
// null để engine resolve theo name / listPlugins — tránh LOAD nhầm VSTi.
|
||
const _bridgeSafeVstPath=(path,instrumentId)=>{if(!path||!instrumentId)return path;const base=String(path).split(/[\\/]/).pop().toLowerCase().replace(/\s+/g,'');const want=String(instrumentId).toLowerCase().replace(/\s+/g,'');return base.indexOf(want)!==-1?path:null;};const loadVstToBridge=async track=>{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return false;const instrumentId=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!instrumentId||typeof instrumentId==='string'&&instrumentId.startsWith('sf_'))return false;if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};const tId=track.id||track.trackId;const bch=window.SonicMidiRouter.allocateChannel(tId,false);markTrackLoad(tId,{state:'loading',channel:bch});try{// V8 bug 5: engine _resolve_bridge_asset chi match FILE theo filename —
|
||
// KHONG walk folder VST3 ("X.vst3/X.vst3.dll") → 404. Dung path da luu
|
||
// (synth_engine.plugin_path) hoac cache tu listPlugins.
|
||
// VST2 (.dll) scan type 'VST2' - khong hardcode VST3 (se LOAD sai loai).
|
||
let btype='VST3';let knownPath=track.synth_engine&&track.synth_engine.plugin_path;if(!knownPath&&window.__bridgePluginPaths)knownPath=window.__bridgePluginPaths[instrumentId];if(typeof instrumentSelectorData!=='undefined'&&instrumentSelectorData){const v0=(instrumentSelectorData.vst_instruments||[]).find(v=>v.id===instrumentId||v.name===instrumentId);if(v0&&v0.path)knownPath=v0.path;if(v0&&v0.type)btype=v0.type;}// V9: path sai (plugin_path cũ / cache của plugin khác) → bỏ qua, resolve theo tên.
|
||
knownPath=_bridgeSafeVstPath(knownPath,instrumentId);// V10: C++ đã load/restore ĐÚNG instrument trên channel (bridge_state.json,
|
||
// Rust bridge_status trả qua Tauri invoke — không cần HTTP/auth) → dùng path
|
||
// đó load thẳng; C++ double-load guard skip nên không LOAD trùng.
|
||
if(!knownPath&&window.__bridgeLoadedState){const lc=window.__bridgeLoadedState.find(x=>x.channel===bch&&_bridgeSafeVstPath(x.path,instrumentId));if(lc)knownPath=lc.path;}// Đã load ĐÚNG path vào channel này rồi → skip (không LOAD trùng). Bỏ
|
||
// early-return mù theo flag — flag sai khi C++ restore instrument khác.
|
||
if(knownPath&&window.__bridgeLoadedPaths[bch]===knownPath){markTrackLoad(tId,{state:'done'});return true;}// Có path đáng tin → load thẳng qua Tauri invoke (không cần POST/auth/CORS).
|
||
if(knownPath){const ok=await window.NativeBridgeService.loadInstrument(knownPath,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=knownPath;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=knownPath;}if(!ok)markTrackLoad(tId,{state:'failed'});console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',knownPath);return!!ok;}// Không có path → nhờ engine resolve theo name (POST). Fail (auth/CORS) →
|
||
// fallback listPlugins bên dưới.
|
||
const r=await window.SonicAPI.bridgeLoad({name:instrumentId,path:null,instrumentType:btype,channel:bch});let p=r&&r.path?r.path:null;if(!p&&window.SonicAPI.listPlugins){try{const pl=await window.SonicAPI.listPlugins();const v=(pl&&pl.vst_instruments||[]).find(x=>x.id===instrumentId||x.name===instrumentId);if(v&&v.path){p=v.path;if(v&&v.type)btype=v.type;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=v.path;}}catch(e2){console.warn('[Bridge] listPlugins fallback fail:',e2);}}if(!p){markTrackLoad(tId,{state:'failed'});return false;}const ok=await window.NativeBridgeService.loadInstrument(p,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=p;}if(!ok)markTrackLoad(tId,{state:'failed'});console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] loadVstToBridge fail:',track.id,e);markTrackLoad(tId,{state:'failed'});return false;}};// GM font dự phòng khi soundfont track không có trong thư viện (VD id
|
||
// sf_generaluser_gs do AI sinh nhưng user chưa tải font) — ưu tiên font GM
|
||
// đầy đủ patch, fallback font đầu tiên liệt kê được.
|
||
const pickFallbackSoundfont=list=>{if(!list||!list.length)return null;const hay=s=>(String(s.id||'')+' '+String(s.file||'')+' '+String(s.name||'')).toLowerCase();for(const w of['sgm','generaluser','tgsf21x','timgm']){const hit=list.find(s=>hay(s).indexOf(w)!==-1);if(hit)return hit;}return list[0];};// D12: load track instrument (VST *hoặc* SF2) vào bridge. preloadTrackInstruments
|
||
// trước đây chỉ load VST → mở project có SF2 track + bridge active → MIDI tới
|
||
// channel bridge rỗng → C++ silent (không qua mastering chain native).
|
||
const loadAnyTrackToBridge=async track=>{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return false;const inst=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!inst)return false;if(typeof inst==='string'&&inst.startsWith('sf_')){const se=track.synth_engine||{};const bank=se.soundfont_bank!==undefined?se.soundfont_bank:track.soundfont_bank!==undefined?track.soundfont_bank:0;const bch=window.SonicMidiRouter.allocateChannel(track.id||track.trackId,bank===128);markTrackLoad(track.id||track.trackId,{state:'loading',channel:bch});let pl=null;try{const sfId=inst.replace('sf_','');let bpath=null;try{pl=await window.SonicAPI.listPlugins();const s0=(pl&&pl.soundfonts||[]).find(s=>String(s.id).replace('sf_','')===sfId);bpath=s0&&(s0.file||s0.path);}catch(e){}let r=null;try{r=await window.SonicAPI.bridgeLoad({name:inst,path:bpath||null,instrumentType:'SF2',channel:bch});}catch(e){console.warn('[Bridge] SF2 bridgeLoad fail (fallback):',inst,e);}let p=r&&r.path?r.path:null;if(!p){// FALLBACK: soundfont id KHÔNG tồn tại trong thư viện (AI prompt mặc
|
||
// định generaluser_gs nhưng user chưa tải font) → track câm im lặng.
|
||
// Load GM font có sẵn (SGM/GeneralUser/tgsf21x) vào cùng channel —
|
||
// __ensureBridgeProgram vẫn gửi CC0/CC32/PROGRAM → GM patch đúng.
|
||
const fb=pickFallbackSoundfont(pl&&pl.soundfonts);if(fb){console.warn('[Bridge] SF2 not found:',inst,'→ fallback',fb.file||fb.id,'@ch',bch);const r2=await window.SonicAPI.bridgeLoad({name:inst,path:fb.file||fb.path||null,instrumentType:'SF2',channel:bch});p=r2&&r2.path?r2.path:null;}}if(!p){console.warn('[Bridge] SF2 resolve fail:',track.id,inst);markTrackLoad(track.id||track.trackId,{state:'failed'});return false;}const ok=await window.NativeBridgeService.loadInstrument(p,'SF2',bch);if(ok){if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};window.__bridgeLoadedChannels[bch]=true;if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};window.__bridgeLoadedPaths[bch]=p;}if(!ok)markTrackLoad(track.id||track.trackId,{state:'failed'});console.log('[Bridge] preload SF2 track',track.id,'-> ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] preload SF2 fail:',track.id,e);markTrackLoad(track.id||track.trackId,{state:'failed'});return false;}}try{return await loadVstToBridge(track);}catch(e){return false;}};// D16: per-track loading ruler (TCP). window.__trackLoadState[trackId] =
|
||
// { state:'pending'|'loading'|'done'|'failed', channel, startedTs, doneTs }.
|
||
// Poller trong App (loadTick) chuyển loading→done khi channel xuất hiện trong
|
||
// loaded_channels (queryStatus) — đúng cho cả C++ restore (bridge_state.json,
|
||
// chạy từ spawn) lẫn JS load — không đánh dấu done sớm khi C++ guard skip.
|
||
const markTrackLoad=(trackId,patch)=>{if(!trackId)return;if(!window.__trackLoadState)window.__trackLoadState={};const cur=window.__trackLoadState[trackId]||{state:'pending',channel:null,startedTs:0,doneTs:0};const next=Object.assign({},cur,patch);if(patch.state==='loading'&&cur.state!=='loading')next.startedTs=Date.now();if(patch.state==='done'&&!patch.doneTs)next.doneTs=Date.now();window.__trackLoadState[trackId]=next;if(window.__onTrackLoadState){try{window.__onTrackLoadState(trackId);}catch(e){}}};if(typeof document!=='undefined'&&!document.getElementById('sf-ruler-style')){const _st=document.createElement('style');_st.id='sf-ruler-style';_st.textContent='@keyframes sf-ruler { 0% { transform: translateX(-110%); } 100% { transform: translateX(340%); } } .sf-ruler-bar { animation: sf-ruler 1.1s ease-in-out infinite; } @keyframes sf-ruler-done { 0% { opacity: 1; } 100% { opacity: 0; } } .sf-ruler-done { animation: sf-ruler-done 1.4s ease-out forwards; }';document.head.appendChild(_st);}const ensureAndOpenVstGui=async track=>{const trackId=track.trackId||track.id;const instrumentId=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!instrumentId||typeof instrumentId==='string'&&instrumentId.startsWith('sf_'))return;if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};let bch=window.SonicMidiRouter&&window.SonicMidiRouter.allocateChannel?window.SonicMidiRouter.allocateChannel(trackId,false):0;// LAN/standalone (browser không Tauri): NativeBridgeService chỉ log, không
|
||
// invoke được open_vst_gui → nhờ engine đẩy LOAD+OPEN_GUI vào SHM qua route
|
||
// /bridge/gui (E6). GUI hiện trên desktop máy chạy server.
|
||
if(!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive()||!window.NativeBridgeService){try{if(!window.SonicAPI||!window.SonicAPI.bridgeOpenGui)return;const r=await window.SonicAPI.bridgeOpenGui({name:instrumentId,path:null,instrumentType:null,channel:bch});if(r&&r.success)console.log('[Bridge] GUI open via API',instrumentId,'ch',bch,r.path);else console.warn('[Bridge] GUI via API response:',r);}catch(e){console.warn('[Bridge] GUI via API fail:',trackId,e);}return;}// V9: load (nếu chưa có ĐÚNG instrument) rồi CHỈ mở GUI khi load OK — không
|
||
// mở mù khi fail (nếu load Nexus fail mà vẫn OPEN_GUI, C++ mở GUI của
|
||
// instrument KHÁC đang restore trên channel — VD WaveObserver thay vì Nexus).
|
||
// Bridge restore sẵn ĐÚNG instrument → LOAD trùng path → C++ double-load
|
||
// guard skip → ok=true → GUI vẫn mở. Không có instrument → OPEN_GUI defer.
|
||
let loadOk=false;try{loadOk=await loadVstToBridge(track);}catch(e){console.warn('[Bridge] loadVstToBridge fail:',trackId,e);}// V9 regression: loadVstToBridge tra false khi KHONG resolve lai duoc path
|
||
// (synth_engine.plugin_path khong luu / instrumentSelectorData chua load /
|
||
// POST /bridge/load auth-CORS fail / _bridgeSafeVstPath null path cu sai)
|
||
// DU track da co DUNG instrument tren channel tu truoc (flag) → GUI chet
|
||
// im lang. Chi chan khi channel THUC SU chua co instrument — mo GUI khi do
|
||
// se mo cua plugin KHAC (V9 guard WaveObserver vs Nexus, giu nguyen).
|
||
if(!loadOk&&!window.__bridgeLoadedChannels[bch]){// V11: loadVstToBridge fail (token hết hạn / path không resolve được) nhưng
|
||
// C++ ĐÃ restore ĐÚNG instrument từ state file → mở GUI thẳng, không cần
|
||
// LOAD. __bridgeLoadedState chỉ fetch 1 lần lúc init → refresh qua
|
||
// queryStatus (Tauri invoke, không cần auth) trước khi tra. Tìm instrument
|
||
// ở BẤT KỲ channel (restore sau restart có thể nằm channel khác channel
|
||
// router allocate) → mở GUI đúng chỗ, hết silent-return. Giữ V9 guard:
|
||
// chỉ mở khi path khớp instrumentId, không mở GUI của plugin KHÁC.
|
||
let state=window.__bridgeLoadedState;try{const st=await window.NativeBridgeService.queryStatus();if(st&&Array.isArray(st.loaded_channels))state=st.loaded_channels;}catch(e){console.warn('[Bridge] queryStatus refresh fail:',e);}const lc=(state||[]).find(x=>_bridgeSafeVstPath(x.path,instrumentId));// V12: bridge_status thieu loaded_channels (Rust binary cu chua co V10
|
||
// field) -> queryStatus fallback khong co loaded_channels -> lc undefined
|
||
// -> silent return lam GUI chet im lang. Bo return: mo GUI truc tiep tren
|
||
// bch. An toan vi C++ handleOpenGui DEFER khi channel chua co instrument.
|
||
if(!lc){console.warn('[Bridge] V12 fallback: no loaded_channel match for',instrumentId,'state_len=',(state||[]).length,'— trying direct open ch',bch);}else if(lc.channel!==bch){// Restore trên channel khác → thử LOAD bản vào bch (C++ guard skip nếu
|
||
// đã có); fail thì mở GUI ngay trên chính channel restore.
|
||
try{const ok=await window.NativeBridgeService.loadInstrument(lc.path,/\.dll$/i.test(lc.path)?'VST2':'VST3',bch);if(!ok)bch=lc.channel;}catch(e){console.warn('[Bridge] re-load on bch fail:',e);bch=lc.channel;}}if(lc){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=lc.path;}}try{await openVstGuiRetry(instrumentId,bch,20);}catch(e){console.warn('[Bridge] openNativeGUI fail:',e);}};// V8 bug 4 + V9 bug 3/6/7: C++ load instrument ASYNC (worker thread) — OPEN_GUI
|
||
// som → C++ defer va tu fulfil khi assign() xong (pending-GUI sweep) → khong
|
||
// can spam. 20 lan x 500ms tao deferred storm (50+ event/moi restart) va
|
||
// re-attach view dang attached (crash USER32 0xc000041d). Cap 5.
|
||
const openVstGuiRetry=async(instrumentId,bch,attempts)=>{// C++ reloadForGUI (mở lại GUI sau khi đóng) mute audio 1-3s → watchdog
|
||
// master-silence false-positive → rebuild + fxRtStop → dry→wet vài giây.
|
||
// Exempt watchdog 12s kể từ lần mở này (đủ cho retry + load/reload).
|
||
nativeGuiOpenUntilTs=performance.now()+12000;const n=Math.min(attempts&&attempts>0?attempts:5,5);for(let i=0;i<n;i++){try{await window.NativeBridgeService.openNativeGUI(instrumentId,bch);}catch(e){console.warn('[Bridge] openNativeGUI retry',i+1,e);}await new Promise(r=>setTimeout(r,500));}return true;};// Wheel trên range input (Vol/Pan TCP): scale step theo deltaY — trackpad
|
||
// smooth gửi deltaY nhỏ (~1-10px/sự kiện) nhưng rất nhiều sự kiện; wheel
|
||
// notch gửi ~100-120px. Tích lũy theo key → trackpad lăn 100px = 1 nấc
|
||
// (0.5dB vol / 5 pan), wheel notch giữ nguyên tốc độ cũ. deltaMode:
|
||
// 0=px, 1=dòng (≈40px), 2=trang.
|
||
const _wheelAcc=new Map();const wheelSteps=(key,deltaY,deltaMode,pxPerStep=100)=>{const norm=deltaMode===1?deltaY*40:deltaMode===2?deltaY*100:deltaY;// c dương = cuộn lên (deltaY<0) = tăng — đúng convention cũ (deltaY>0 → giảm)
|
||
const c=(_wheelAcc.get(key)||0)-norm;const steps=Math.trunc(c/pxPerStep);_wheelAcc.set(key,c-steps*pxPerStep);return steps;};// Module-level so both ProfileModal (open project) and App (auto-restore) can
|
||
// warm the FluidSynth font cache for each track's instrument. This only loads
|
||
// the soundfonts; the per-track channel re-selection happens at note time.
|
||
const preloadTrackInstruments=async tracks=>{const list=tracks||[];// D13: bridge load TRƯỚC vòng WASM — vòng selectInstrument (parse font WASM
|
||
// từng track, có thể ~20s) trước đây chặn vòng bridge → user bấm play khi
|
||
// app vừa load: NOTE_ON vào channel bridge chưa có instrument → im lặng tới
|
||
// khi bridge load xong (~27s từ bootstrap — dbg.log play_first_audible).
|
||
if(window.NativeBridgeService&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){// D16b: pre-mark TẤT CẢ track có instrument thành loading TRƯỚC loop — loop
|
||
// nối tiếp (C++ g_loadMutex) khiến track 2-9 không có ruler tới khi track 1
|
||
// xong → UI lâu không có loading item. markTrackLoad channel=null hiện ruler
|
||
// ngay; loadAnyTrackToBridge set channel khi tới lượt; poller chuyển done.
|
||
for(let i=0;i<list.length;i++){const t=list[i];const inst=t.instrumentId||t.synth_engine&&t.synth_engine.plugin_id;if(!inst)continue;markTrackLoad(t.id||t.trackId,{state:'loading',channel:null});}for(let i=0;i<list.length;i++){const t=list[i];const inst=t.instrumentId||t.synth_engine&&t.synth_engine.plugin_id;if(!inst)continue;try{await loadAnyTrackToBridge(t);}catch(e){}// V45.11: C++ loadSoundFontFile resets bank/program to 0 on every
|
||
// load; preload only loads the SF2 file -> after reopen the SF
|
||
// preset is wrong (bug: muted SF tracks lost their preset).
|
||
// __ensureBridgeProgram falls back to synth_engine.soundfont_program.
|
||
if(typeof inst==='string'&&inst.startsWith('sf_')){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(t.id||t.trackId,undefined,t.synth_engine);}catch(e){}// V45.12: C++ restore (bridge_state.json) chạy ASYNC sau preload —
|
||
// assign() + selectProgram(bank/program CŨ) ghi đè program JS vừa
|
||
// gửi (race startup). Retry FORCE (bỏ dedupe) sau 1.5s/4s/8s để qua
|
||
// cửa sổ restore, không spam mỗi note.
|
||
if(t.synth_engine&&t.synth_engine.soundfont_program!==undefined){const _fire=()=>{try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(t.id||t.trackId,undefined,t.synth_engine,true);}catch(e){}};[1500,4000,8000].forEach(ms=>setTimeout(_fire,ms));}}}}if(window.SonicSF&&window.SonicSF.selectInstrument){for(let i=0;i<list.length;i++){const t=list[i];const se=t.synth_engine;const sfId=se&&se.soundfont_id||t.soundfont_id;if(!sfId)continue;const bank=se?se.soundfont_bank!==undefined?se.soundfont_bank:0:t.soundfont_bank!==undefined?t.soundfont_bank:0;const prog=se?se.soundfont_program!==undefined?se.soundfont_program:0:t.soundfont_program!==undefined?t.soundfont_program:0;const ch=t.midiChannel!==undefined?t.midiChannel:i%16;try{await window.SonicSF.selectInstrument(ch,bank,prog,sfId);}catch(e){}}}};const ProfileModal=({isOpen,onClose,tracks,setTracks,setSelectedTrackId,projectName,setProjectName,currentProjectId,setCurrentProjectId,showToast,loadAudioBuffersForTracks,setAppWarningModal,bpm,setBpm,setMasteringSettings,setSessionTabs,setSubTabs,trackMidiChannelsRef})=>{if(!isOpen)return null;const[activeTab,setActiveTab]=useState('account');const[profile,setProfile]=useState(null);const[oldPassword,setOldPassword]=useState('');const[newPassword,setNewPassword]=useState('');const[msg,setMsg]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);const[dragOfs,setDragOfs]=useState({x:0,y:0});const dragRef=useRef({active:false,startX:0,startY:0,ofsX:0,ofsY:0});// Projects list state
|
||
const[projectsList,setProjectsList]=useState([]);const[loadingProjects,setLoadingProjects]=useState(false);// Files list state
|
||
const[filesList,setFilesList]=useState([]);const[loadingFiles,setLoadingFiles]=useState(false);// Confirmation modal state
|
||
const[confirmModal,setConfirmModal]=useState(null);// Backup state
|
||
const[expandedBackupId,setExpandedBackupId]=useState(null);const[backupsMap,setBackupsMap]=useState({});// project_id -> [backups]
|
||
const[loadingBackups,setLoadingBackups]=useState({});// project_id -> bool
|
||
const[backupMaxCount,setBackupMaxCount]=useState(()=>parseInt(localStorage.getItem('sonic_backup_max_count')||'10'));const[showBackupConfig,setShowBackupConfig]=useState(false);const handleDragStart=e=>{const r=dragRef.current;r.active=true;r.startX=e.clientX;r.startY=e.clientY;r.ofsX=dragOfs.x;r.ofsY=dragOfs.y;const onMove=ev=>{if(!r.active)return;setDragOfs({x:r.ofsX+ev.clientX-r.startX,y:r.ofsY+ev.clientY-r.startY});};const onUp=()=>{r.active=false;document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};useEffect(()=>{if(isOpen){fetchProfile();if(activeTab==='projects')fetchProjects();if(activeTab==='files')fetchFiles();}},[isOpen,activeTab]);const fetchProfile=async()=>{try{const data=await window.SonicAPI.getProfile();setProfile(data);}catch(e){setError(e.message||'Không thể tải thông tin profile');}};const fetchProjects=async()=>{setLoadingProjects(true);try{const data=await window.SonicAPI.listCloudProjects();setProjectsList(data||[]);}catch(e){showToast(e.message||'Không thể tải danh sách dự án','error');}finally{setLoadingProjects(false);}};const fetchFiles=async()=>{setLoadingFiles(true);try{const activeFileIds=tracks.map(t=>t.serverFileId).filter(Boolean);const data=await window.SonicAPI.listMyFiles(activeFileIds);setFilesList(data||[]);}catch(e){showToast(e.message||'Không thể tải danh sách tệp tin','error');}finally{setLoadingFiles(false);}};const fetchBackups=async projectId=>{setLoadingBackups(prev=>({...prev,[projectId]:true}));try{const data=await window.SonicAPI.listBackups(projectId);setBackupsMap(prev=>({...prev,[projectId]:data||[]}));}catch(e){showToast(e.message||'Không thể tải danh sách backup','error');}finally{setLoadingBackups(prev=>({...prev,[projectId]:false}));}};const handleDeleteBackup=async backupId=>{try{await window.SonicAPI.deleteBackup(backupId);setBackupsMap(prev=>{const next={...prev};Object.keys(next).forEach(pid=>{next[pid]=next[pid].filter(b=>b.id!==backupId);});return next;});fetchProjects();showToast('Đã xóa bản backup','info');}catch(e){showToast(e.message||'Lỗi xóa backup','error');}};const handleCleanupBackups=async()=>{try{const res=await window.SonicAPI.cleanupBackups(backupMaxCount);setBackupsMap({});fetchProjects();showToast(`Đã dọn dẹp ${res.deleted} bản backup cũ (giữ lại ${res.keep})`,'info');}catch(e){showToast(e.message||'Lỗi dọn dẹp backup','error');}};const handleOpenProject=async(projectId,projectName)=>{setAppWarningModal({title:"Mở dự án",message:"Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.",isAlert:false,onConfirm:async()=>{try{const proj=await window.SonicAPI.getCloudProject(projectId);const parsed=JSON.parse(proj.data_json);let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];let restoredSubTabs=[];if(parsed.main_session){const result=deserializeProjectFromSchema(parsed);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;if(result.masteringSettings){setMasteringSettings(result.masteringSettings);}else{setMasteringSettings({masterConnected:false,activeModule:null,eqActive:false,imagerActive:false,maximizerActive:false,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:[],compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,limMode:0,limLookahead:2,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0});}}else{restoredTracks=(parsed.tracks||[]).map(t=>{const{height:_h,...rest}=t;return{...rest,buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null};});}setTracks(restoredTracks);loadAudioBuffersForTracks(restoredTracks).catch(function(err){console.warn('loadAudioBuffersForTracks error:',err);});trackMidiChannelsRef.current={};preloadTrackInstruments(restoredTracks).catch(function(err){console.warn('preloadTrackInstruments error:',err);});setBpm(restoredBpm.toString());setSelectedTrackId(restoredTracks[0]?.id||'1');setProjectName(proj.name);setCurrentProjectId(proj.id);if(restoredSessionTabs.length>0){setSessionTabs(restoredSessionTabs);}if(restoredSubTabs.length>0){setSubTabs(restoredSubTabs);}localStorage.setItem('sonic_project_name',proj.name);localStorage.setItem('sonic_project_id',proj.id);showToast(`Đã nạp dự án "${proj.name}" thành công!`,"success");}catch(e){showToast(e.message||"Lỗi khi nạp dự án","error");}}});};const handleDeleteProject=async(projectId,e)=>{e.stopPropagation();setConfirmModal({title:"Xóa dự án Cloud",message:"Bạn có chắc chắn muốn xóa dự án này khỏi Cloud? Hành động này không thể hoàn tác.",onConfirm:async()=>{try{await window.SonicAPI.deleteCloudProject(projectId);showToast("Đã xóa dự án thành công!","success");fetchProjects();fetchProfile();}catch(err){showToast(err.message||"Lỗi khi xóa dự án","error");}}});};const handleDeleteFile=async fileId=>{if(!confirm(`Bạn có chắc chắn muốn xóa tệp tin ${fileId}?`))return;try{await window.SonicAPI.deleteMyFile(fileId);showToast("Đã xóa tệp tin thành công!","success");fetchFiles();fetchProfile();}catch(err){showToast(err.message||"Lỗi khi xóa tệp tin","error");}};const handleCleanUnusedFiles=async()=>{const unusedFiles=filesList.filter(f=>!f.is_in_use);if(unusedFiles.length===0){showToast("Không có tập tin rác nào để dọn dẹp.","info");return;}if(!confirm(`Bạn có chắc chắn muốn xóa tất cả ${unusedFiles.length} tập tin rác để giải phóng dung lượng?`))return;let successCount=0;for(const file of unusedFiles){try{await window.SonicAPI.deleteMyFile(file.file_id);successCount++;}catch(e){console.error("Lỗi xóa file rác: ",file.file_id,e);}}showToast(`Đã dọn dẹp thành công ${successCount}/${unusedFiles.length} tập tin rác!`,"success");fetchFiles();fetchProfile();};const handleChangePassword=async e=>{e.preventDefault();setMsg('');setError('');setLoading(true);try{const res=await window.SonicAPI.changePassword(oldPassword,newPassword);setMsg(res.message||'Đổi mật khẩu thành công!');setOldPassword('');setNewPassword('');}catch(err){setError(err.message||'Lỗi khi đổi mật khẩu');}finally{setLoading(false);}};const modalStyle={left:`calc(50% + ${dragOfs.x}px)`,top:`calc(50% + ${dragOfs.y}px)`,transform:'translate(-50%, -50%)'};return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"backdrop",className:"fixed inset-0 z-40 bg-black/70 backdrop-blur-sm",onClick:onClose}),confirmModal&&/*#__PURE__*/React.createElement("div",{key:"confirm-overlay",className:"fixed inset-0 z-[60] flex items-center justify-center bg-black/50"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-lg shadow-2xl p-5 max-w-sm w-full text-slate-200"},/*#__PURE__*/React.createElement("h4",{className:"text-sm font-bold text-rose-400 mb-2"},confirmModal.title),/*#__PURE__*/React.createElement("p",{className:"text-xs text-slate-300 mb-4"},confirmModal.message),/*#__PURE__*/React.createElement("div",{className:"flex justify-end gap-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setConfirmModal(null),className:"px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold"},confirmModal.cancelText||"Hủy"),/*#__PURE__*/React.createElement("button",{onClick:()=>{const fn=confirmModal.onConfirm;setConfirmModal(null);fn();},className:"px-3 py-1.5 bg-rose-700 hover:bg-rose-600 text-white rounded text-xs font-semibold"},confirmModal.confirmText||"Xác nhận xóa")))),/*#__PURE__*/React.createElement("div",{key:"dialog",className:"fixed z-50 bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200 flex flex-col max-h-[85vh]",style:modalStyle},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-3 border-b border-[#383838] shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:handleDragStart},/*#__PURE__*/React.createElement("h3",{className:"text-md font-bold text-teal-400 flex items-center gap-1.5"},"👤 Hồ Sơ Cá Nhân & Quản Lý Dự Án"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),/*#__PURE__*/React.createElement("div",{className:"flex gap-2 border-b border-[#383838] py-2 shrink-0 text-xs font-semibold"},[/*#__PURE__*/React.createElement("button",{key:"tab-acc",onClick:()=>setActiveTab('account'),className:`px-3 py-1.5 rounded transition ${activeTab==='account'?'bg-teal-950/60 text-teal-300 border border-teal-800':'text-slate-400 hover:text-slate-200'}`},"Tài Khoản"),/*#__PURE__*/React.createElement("button",{key:"tab-proj",onClick:()=>setActiveTab('projects'),className:`px-3 py-1.5 rounded transition ${activeTab==='projects'?'bg-teal-950/60 text-teal-300 border border-teal-800':'text-slate-400 hover:text-slate-200'}`},"Dự Án Cloud"),/*#__PURE__*/React.createElement("button",{key:"tab-files",onClick:()=>setActiveTab('files'),className:`px-3 py-1.5 rounded transition ${activeTab==='files'?'bg-teal-950/60 text-teal-300 border border-teal-800':'text-slate-400 hover:text-slate-200'}`},"Tập Tin Của Tôi")]),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-y-auto pt-4 space-y-4 pr-1 min-h-[300px]"},activeTab==='account'&&profile?[/*#__PURE__*/React.createElement("div",{key:"quota-info",className:"bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs"},[/*#__PURE__*/React.createElement("div",{key:"username"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-500 block"},"Tên người dùng"),/*#__PURE__*/React.createElement("span",{className:"font-bold text-teal-300 text-sm"},profile.username)]),/*#__PURE__*/React.createElement("div",{key:"role"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-500 block"},"Vai trò"),/*#__PURE__*/React.createElement("span",{className:"uppercase font-semibold text-amber-400"},profile.role)]),/*#__PURE__*/React.createElement("div",{key:"email"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-500 block"},"Email"),/*#__PURE__*/React.createElement("span",null,profile.email)]),/*#__PURE__*/React.createElement("div",{key:"quota"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-500 block"},"Dung lượng Quota"),/*#__PURE__*/React.createElement("span",{className:"font-semibold text-slate-200"},`${profile.quota.used_mb} MB / ${profile.quota.storage_limit_mb} MB`)])]),/*#__PURE__*/React.createElement("div",{key:"progress"},[/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-xs mb-1"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Tiến trình sử dụng bộ nhớ Server"),/*#__PURE__*/React.createElement("span",{className:"font-bold text-teal-400"},`${(profile.quota.used_mb/profile.quota.storage_limit_mb*100).toFixed(1)}%`)]),/*#__PURE__*/React.createElement("div",{className:"w-full h-2 bg-slate-800 rounded-full overflow-hidden"},[/*#__PURE__*/React.createElement("div",{className:"h-full bg-teal-500 rounded-full transition-all duration-300",style:{width:`${Math.min(100,profile.quota.used_mb/profile.quota.storage_limit_mb*100)}%`}})])]),/*#__PURE__*/React.createElement("form",{key:"pwd-form",onSubmit:handleChangePassword,className:"pt-4 border-t border-[#383838] space-y-3"},[/*#__PURE__*/React.createElement("h4",{className:"text-xs font-bold text-slate-300 uppercase"},"Thay Đổi Mật Khẩu"),msg&&/*#__PURE__*/React.createElement("div",{className:"p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"},msg),error&&/*#__PURE__*/React.createElement("div",{className:"p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"},error),/*#__PURE__*/React.createElement("div",{key:"old"},[/*#__PURE__*/React.createElement("label",{className:"block text-xs text-slate-400 mb-1"},"Mật khẩu cũ"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"current-password",required:true,value:oldPassword,onChange:e=>setOldPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"})]),/*#__PURE__*/React.createElement("div",{key:"new"},[/*#__PURE__*/React.createElement("label",{className:"block text-xs text-slate-400 mb-1"},"Mật khẩu mới"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"new-password",required:true,value:newPassword,onChange:e=>setNewPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"})]),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition"},loading?'Đang cập nhật...':'Cập Nhật Mật Khẩu')])]:activeTab==='projects'?[loadingProjects?/*#__PURE__*/React.createElement("div",{key:"loading",className:"text-center py-8 text-xs text-zinc-500"},"Đang tải danh sách dự án..."):projectsList.length===0?/*#__PURE__*/React.createElement("div",{key:"empty",className:"text-center py-8 text-xs text-zinc-500"},"Bạn chưa có dự án nào lưu trên Cloud."):/*#__PURE__*/React.createElement(React.Fragment,{key:"list"},[/*#__PURE__*/React.createElement("div",{key:"backup-config-bar",className:"flex items-center justify-between bg-zinc-900/60 p-2 rounded border border-zinc-800 mb-2 text-xs"},[/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},[/*#__PURE__*/React.createElement("span",{className:"text-zinc-400 text-[10px]"},"⚙️ Tự động lưu 5 phút / Backup 30 phút"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();setShowBackupConfig(!showBackupConfig);},className:"px-2 py-0.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] border border-zinc-700"},showBackupConfig?"ẨN":"CẤU HÌNH")]),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();handleCleanupBackups();},className:"px-2 py-0.5 bg-amber-800 hover:bg-amber-700 text-amber-200 rounded text-[10px] border border-amber-700"},"🧹 DỌN BACKUP")]),showBackupConfig&&/*#__PURE__*/React.createElement("div",{key:"backup-config-detail",className:"bg-[#18181b] border border-zinc-800 rounded p-3 mb-2 text-xs"},[/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between mb-2"},[/*#__PURE__*/React.createElement("label",{className:"text-zinc-300 font-semibold"},"Số bản backup tối đa:"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},[/*#__PURE__*/React.createElement("input",{type:"range",min:5,max:20,value:backupMaxCount,onChange:e=>{const v=parseInt(e.target.value);setBackupMaxCount(v);localStorage.setItem('sonic_backup_max_count',v.toString());},className:"w-24 accent-amber-500"}),/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-bold w-6 text-center"},backupMaxCount)])]),/*#__PURE__*/React.createElement("p",{className:"text-[10px] text-zinc-500"},"Mỗi dự án sẽ giữ tối đa số bản backup này. Backup cũ nhất sẽ tự động bị xóa khi vượt quá giới hạn.")]),/*#__PURE__*/React.createElement("div",{className:"space-y-1.5"},projectsList.map(proj=>/*#__PURE__*/React.createElement("div",{key:proj.id},[/*#__PURE__*/React.createElement("div",{onClick:()=>handleOpenProject(proj.id),className:"flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group"},[/*#__PURE__*/React.createElement("div",{key:"meta"},[/*#__PURE__*/React.createElement("div",{className:"font-bold text-slate-200 group-hover:text-teal-400"},proj.name),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 mt-0.5"},[`Dung lượng: ${proj.size_mb} MB | Cập nhật: ${new Date(proj.updated_at*1000).toLocaleString()}`,proj.backup_count>0&&` | Backup: ${proj.backup_count}`])]),/*#__PURE__*/React.createElement("div",{key:"actions",className:"flex items-center gap-1.5"},[/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();if(expandedBackupId===proj.id){setExpandedBackupId(null);}else{setExpandedBackupId(proj.id);fetchBackups(proj.id);}},className:`px-2 py-1 rounded font-semibold text-[10px] border ${expandedBackupId===proj.id?'bg-amber-800/80 text-amber-200 border-amber-700':'bg-zinc-800 hover:bg-zinc-700 text-zinc-400 border-zinc-700'}`},`Backup (${proj.backup_count})`),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();handleOpenProject(proj.id);},className:"px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]"},"MỞ"),/*#__PURE__*/React.createElement("button",{onClick:e=>handleDeleteProject(proj.id,e),className:"px-2 py-1 bg-rose-950/60 hover:bg-rose-900/80 text-rose-300 rounded font-semibold text-[10px]"},"XÓA")])]),expandedBackupId===proj.id&&/*#__PURE__*/React.createElement("div",{key:"backup-list",className:"ml-4 pl-3 border-l-2 border-amber-800/50 bg-[#161618] rounded-b p-2 mb-1"},[loadingBackups[proj.id]?/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 py-2 text-center"},"Đang tải..."):!backupsMap[proj.id]||backupsMap[proj.id].length===0?/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 py-2 text-center"},"Chưa có bản backup nào."):/*#__PURE__*/React.createElement("div",{className:"space-y-1 max-h-48 overflow-y-auto"},backupsMap[proj.id].map(b=>/*#__PURE__*/React.createElement("div",{key:b.id,className:"flex items-center justify-between py-1.5 px-2 bg-[#1e1e22] rounded border border-zinc-800"},[/*#__PURE__*/React.createElement("div",{key:"info",className:"flex-1 min-w-0"},[/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-300 truncate"},b.name),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-zinc-500 mt-0.5"},`${b.size_mb} MB | ${new Date(b.created_at*1000).toLocaleString()}`)]),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();handleDeleteBackup(b.id);},className:"px-1.5 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded text-[9px] border border-rose-900 ml-2 shrink-0"},"XÓA")])))])])))])]:activeTab==='files'?[/*#__PURE__*/React.createElement("div",{key:"cleanup-header",className:"flex items-center justify-between bg-zinc-900/60 p-2.5 rounded border border-zinc-800 text-xs shrink-0 mb-3"},[/*#__PURE__*/React.createElement("span",{className:"text-zinc-400 text-[10px]"},"💡 Các tập tin không dùng trong dự án nào sẽ được đánh dấu 'Không dùng' và có thể xóa để giải phóng dung lượng."),/*#__PURE__*/React.createElement("button",{onClick:handleCleanUnusedFiles,className:"px-2.5 py-1 bg-rose-700 hover:bg-rose-600 text-white rounded font-bold text-[10px] uppercase flex items-center gap-1"},"🧹 Dọn dẹp tệp rác")]),loadingFiles?/*#__PURE__*/React.createElement("div",{key:"loading",className:"text-center py-8 text-xs text-zinc-500"},"Đang tải danh sách tập tin..."):filesList.length===0?/*#__PURE__*/React.createElement("div",{key:"empty",className:"text-center py-8 text-xs text-zinc-500"},"Chưa có tập tin nào tải lên hoặc tạo ra."):/*#__PURE__*/React.createElement("div",{key:"list",className:"space-y-1.5"},[filesList.map(file=>/*#__PURE__*/React.createElement("div",{key:file.file_id,className:"flex items-center justify-between p-3 bg-[#1e1e1e] rounded border border-zinc-800 text-xs"},[/*#__PURE__*/React.createElement("div",{key:"meta",className:"max-w-[70%]"},[/*#__PURE__*/React.createElement("div",{className:"font-semibold text-slate-300 truncate"},file.original_name||file.file_id),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 mt-0.5"},`Loại: ${file.type} | Dung lượng: ${file.size_mb} MB | Tạo lúc: ${new Date(file.created_at*1000).toLocaleString()}`)]),/*#__PURE__*/React.createElement("div",{key:"actions",className:"flex items-center gap-2"},[file.is_in_use?/*#__PURE__*/React.createElement("span",{className:"px-2 py-0.5 bg-emerald-950 text-emerald-400 border border-emerald-900 rounded text-[9px] uppercase font-bold font-mono"},"Đang dùng"):/*#__PURE__*/React.createElement("span",{className:"px-2 py-0.5 bg-zinc-850 text-zinc-400 border border-zinc-700 rounded text-[9px] uppercase font-bold font-mono"},"Không dùng"),!file.is_in_use&&/*#__PURE__*/React.createElement("button",{onClick:()=>handleDeleteFile(file.file_id),className:"px-2 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded font-semibold text-[10px] border border-rose-900"},"Xóa")])]))])]:null)));};const SaveProjectModal=({isOpen,onClose,onSaveCloud,onSaveLocal,projectName})=>{if(!isOpen)return null;const[name,setName]=useState(projectName||'');const[saveType,setSaveType]=useState(localStorage.getItem('sonic_token')?'cloud':'local');const[cloudProjects,setCloudProjects]=useState([]);const[loading,setLoading]=useState(false);const[selectedExisting,setSelectedExisting]=useState(null);const[confirmOverwriteProject,setConfirmOverwriteProject]=useState(null);React.useEffect(function(){if(!isOpen)return;if(saveType==='cloud'&&window.SonicAPI&&window.SonicAPI.listCloudProjects){setLoading(true);window.SonicAPI.listCloudProjects().then(function(data){setCloudProjects(data||[]);}).catch(function(){setCloudProjects([]);}).finally(function(){setLoading(false);});}},[isOpen,saveType]);const handleSubmit=e=>{e.preventDefault();if(!name.trim())return;var matched=null;for(var i=0;i<cloudProjects.length;i++){if(cloudProjects[i].name===name.trim()){matched=cloudProjects[i];break;}}if(matched){setConfirmOverwriteProject(matched);}else{if(saveType==='cloud'){onSaveCloud(name.trim(),null);onClose();}else{onSaveLocal(name.trim());onClose();}}};if(confirmOverwriteProject){return React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"},React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-lg shadow-2xl p-5 max-w-sm w-full text-slate-200"},React.createElement("h4",{className:"text-sm font-bold text-amber-400 mb-2"},"Ghi \u0111\u00E8 d\u1EF1 \u00E1n"),React.createElement("p",{className:"text-xs text-slate-300 mb-4"},"D\u1EF1 \u00E1n \"",confirmOverwriteProject.name,"\" \u0111\u00E3 t\u1ED3n t\u1EA1i tr\u00EAn Cloud. B\u1EA1n c\u00F3 ch\u1EAFc ch\u1EAFn mu\u1ED1n ghi \u0111\u00E8 n\u00F3 kh\u00F4ng?"),React.createElement("div",{className:"flex justify-end gap-2"},React.createElement("button",{type:"button",onClick:function(){setConfirmOverwriteProject(null);},className:"px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold"},"Quay l\u1EA1i"),React.createElement("button",{type:"button",onClick:function(){setConfirmOverwriteProject(null);onSaveCloud(name.trim(),confirmOverwriteProject.id);onClose();},className:"px-3 py-1.5 bg-rose-700 hover:bg-rose-600 text-white rounded text-xs font-semibold"},"X\u00E1c nh\u1EADn ghi \u0111\u00E8"))));}var filtered=cloudProjects.filter(function(p){return p.name.toLowerCase().includes(name.toLowerCase());});return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200"},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-teal-400 mb-4 uppercase"},"L\u01B0u d\u1EF1 \u00E1n"),/*#__PURE__*/React.createElement("form",{onSubmit:handleSubmit,className:"space-y-4"},[/*#__PURE__*/React.createElement("div",{key:"type-block"},[/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs"},[/*#__PURE__*/React.createElement("button",{key:"btn-cloud",type:"button",onClick:function(){setSaveType('cloud');setSelectedExisting(null);},className:"py-2 rounded border flex flex-col items-center gap-1 font-semibold transition "+(saveType==='cloud'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')},[/*#__PURE__*/React.createElement("span",{key:"title"},"\u2601\uFE0F L\u01B0u Cloud"),/*#__PURE__*/React.createElement("span",{key:"desc",className:"text-[9px] font-normal text-zinc-500"},"L\u01B0u l\u00EAn server")]),/*#__PURE__*/React.createElement("button",{key:"btn-local",type:"button",onClick:function(){setSaveType('local');setSelectedExisting(null);},className:"py-2 rounded border flex flex-col items-center gap-1 font-semibold transition "+(saveType==='local'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')},[/*#__PURE__*/React.createElement("span",{key:"title"},"\U0001F5C2\uFE0F L\u01B0u Local"),/*#__PURE__*/React.createElement("span",{key:"desc",className:"text-[9px] font-normal text-zinc-500"},"L\u01B0u xu\u1ED1ng t\u1EC7p .sfs")])])]),/*#__PURE__*/React.createElement("div",{key:"name-block"},[/*#__PURE__*/React.createElement("label",{className:"block text-[10px] text-zinc-400 uppercase font-bold mb-1"},"T\u00EAn d\u1EF1 \u00E1n"),/*#__PURE__*/React.createElement("input",{key:"name-input",type:"text",placeholder:"Nh\u1EADp t\u00EAn d\u1EF1 \u00E1n...",required:true,value:name,onChange:function(e){setName(e.target.value);setSelectedExisting(null);},className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold",autoFocus:true})]),saveType==='cloud'&&/*#__PURE__*/React.createElement("div",{key:"cloud-list",className:"max-h-40 overflow-y-auto -mx-1"},loading?[/*#__PURE__*/React.createElement("div",{key:"l",className:"text-center py-3 text-xs text-zinc-500"},"\u0110ang t\u1EA3i danh s\u00E1ch d\u1EF1 \u00E1n...")]:filtered.length===0?[/*#__PURE__*/React.createElement("div",{key:"e",className:"text-center py-3 text-xs text-zinc-500"},"Ch\u01B0a c\u00F3 d\u1EF1 \u00E1n n\u00E0o.")]:filtered.map(function(p){var isSelected=selectedExisting&&selectedExisting.id===p.id;var isExactMatch=p.name===name.trim();return/*#__PURE__*/React.createElement("div",{key:p.id,onClick:function(){setName(p.name);setSelectedExisting(p);},className:"flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-xs border transition "+(isSelected?'bg-amber-950/60 border-amber-600 text-amber-300':'bg-[#1e1e1e] hover:bg-zinc-800 border-transparent text-slate-300')},[/*#__PURE__*/React.createElement("span",{key:"n",className:"font-semibold truncate"},p.name),/*#__PURE__*/React.createElement("span",{key:"t",className:"text-[9px] text-zinc-500 shrink-0 ml-2"},isExactMatch?"S\u1EBD ghi \u0111\u00E8":"Ch\u1ECDn")]);})),/*#__PURE__*/React.createElement("div",{key:"actions",className:"flex justify-end gap-2 text-xs"},[/*#__PURE__*/React.createElement("button",{key:"cancel",type:"button",onClick:onClose,className:"px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"},"H\u1EE7y"),selectedExisting?/*#__PURE__*/React.createElement("button",{key:"back",type:"button",onClick:function(){setName('');setSelectedExisting(null);},className:"px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold"},"Quay l\u1EA1i"):null,/*#__PURE__*/React.createElement("button",{key:"save",type:"submit",className:"px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold"},selectedExisting?"Ghi \u0111\u00E8":"L\u01B0u")])])));};const OpenProjectModal=({isOpen,onClose,onOpenCloud,onOpenLocal,onOpenDisk})=>{if(!isOpen)return null;const[tab,setTab]=useState('cloud');const[projects,setProjects]=useState([]);const[loading,setLoading]=useState(false);React.useEffect(function(){if(!isOpen)return;if(tab==='cloud'){setLoading(true);var api=window.SonicAPI;if(api&&api.listCloudProjects){api.listCloudProjects().then(function(data){setProjects(data||[]);}).catch(function(){setProjects([]);}).finally(function(){setLoading(false);});}else{setLoading(false);}}else if(tab==='disk'){setLoading(true);var api2=window.SonicAPI;if(api2&&api2.listDiskProjects){api2.listDiskProjects().then(function(data){setProjects(data||[]);}).catch(function(){setProjects([]);}).finally(function(){setLoading(false);});}else{setLoading(false);}}},[isOpen,tab]);return React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"},React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200"},React.createElement("h3",{className:"text-sm font-bold text-teal-400 mb-4 uppercase"},"Mở dự án"),React.createElement("div",{className:"grid grid-cols-3 gap-2 text-xs mb-4"},[React.createElement("button",{key:"cloud-tab",type:"button",onClick:function(){setTab('cloud');},className:"py-2 rounded border flex flex-col items-center gap-1 font-semibold transition "+(tab==='cloud'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')},[React.createElement("span",{key:"t"},"☁️ Cloud"),React.createElement("span",{key:"d",className:"text-[9px] font-normal text-zinc-500"},"Dự án trên server")]),React.createElement("button",{key:"disk-tab",type:"button",onClick:function(){setTab('disk');},className:"py-2 rounded border flex flex-col items-center gap-1 font-semibold transition "+(tab==='disk'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')},[React.createElement("span",{key:"t"},"📁 Trên máy"),React.createElement("span",{key:"d",className:"text-[9px] font-normal text-zinc-500"},"Đã lưu Ctrl-S / Lưu Local")]),React.createElement("button",{key:"local-tab",type:"button",onClick:function(){setTab('local');},className:"py-2 rounded border flex flex-col items-center gap-1 font-semibold transition "+(tab==='local'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')},[React.createElement("span",{key:"t"},"💾 Local"),React.createElement("span",{key:"d",className:"text-[9px] font-normal text-zinc-500"},"Tập tin .sfs trên máy")])]),tab==='cloud'?React.createElement("div",{className:"space-y-1.5 max-h-64 overflow-y-auto"},loading?[React.createElement("div",{key:"l",className:"text-center py-8 text-xs text-zinc-500"},"Đang tải danh sách dự án...")]:projects.length===0?[React.createElement("div",{key:"e",className:"text-center py-8 text-xs text-zinc-500"},"Bạn chưa có dự án nào trên Cloud.")]:projects.map(function(p){return React.createElement("div",{key:p.id,onClick:function(){onOpenCloud(p.id,p.name);},className:"flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group"},[React.createElement("div",{key:"meta"},[React.createElement("div",{className:"font-bold text-slate-200 group-hover:text-teal-400"},p.name),React.createElement("div",{className:"text-[10px] text-zinc-500 mt-0.5"},"Dung lượng: "+(p.size_mb||0)+" MB | Cập nhật: "+new Date((p.updated_at||0)*1000).toLocaleString())]),React.createElement("button",{onClick:function(e){e.stopPropagation();onOpenCloud(p.id,p.name);},className:"px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]"},"MỞ")]);})):tab==='disk'?React.createElement("div",{className:"space-y-1.5 max-h-64 overflow-y-auto"},loading?[React.createElement("div",{key:"l",className:"text-center py-8 text-xs text-zinc-500"},"Đang tải danh sách dự án trên máy...")]:projects.length===0?[React.createElement("div",{key:"e",className:"text-center py-8 text-xs text-zinc-500"},"Chưa có dự án nào trong Documents/SonicForgeDAW/Projects. Nhấn Ctrl+S (Save) để lưu vào đây.")]:projects.map(function(p){return React.createElement("div",{key:p.filename,onClick:function(){onOpenDisk(p.filename,p.name);},className:"flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group"},[React.createElement("div",{key:"meta"},[React.createElement("div",{className:"font-bold text-slate-200 group-hover:text-teal-400"},p.name),React.createElement("div",{className:"text-[10px] text-zinc-500 mt-0.5"},"Lưu lúc: "+new Date((p.mtime||0)*1000).toLocaleString())]),React.createElement("button",{onClick:function(e){e.stopPropagation();onOpenDisk(p.filename,p.name);},className:"px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]"},"MỞ")]);})):React.createElement("div",{className:"py-4 text-center text-xs text-zinc-400 space-y-3"},[React.createElement("div",{key:"d",className:"text-zinc-500"},"Chọn tệp .sfs để mở dự án từ Local."),React.createElement("button",{key:"b",onClick:function(){onOpenLocal();},className:"px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold text-xs transition"},"Chọn tệp .sfs ...")]),React.createElement("div",{className:"flex justify-end gap-2 text-xs mt-4 pt-3 border-t border-zinc-800"},React.createElement("button",{type:"button",onClick:onClose,className:"px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"},"Hủy"))));};const SaveAsModal=({isOpen,onClose,projectName,onSaveCloud,onSaveLocal})=>{if(!isOpen)return null;const[name,setName]=useState(projectName||'');const[saveType,setSaveType]=useState('cloud');const handleSubmit=e=>{e.preventDefault();if(!name.trim())return;if(saveType==='cloud'){onSaveCloud(name.trim());}else{onSaveLocal(name.trim());}onClose();};return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-teal-400 mb-4 uppercase"},"Lưu dưới tên khác (Save As...)"),/*#__PURE__*/React.createElement("form",{onSubmit:handleSubmit,className:"space-y-4"},[/*#__PURE__*/React.createElement("div",{key:"name-block"},[/*#__PURE__*/React.createElement("label",{className:"block text-[10px] text-zinc-400 uppercase font-bold mb-1"},"Tên dự án mới"),/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Nhập tên mới...",required:true,value:name,onChange:e=>setName(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold",autoFocus:true})]),/*#__PURE__*/React.createElement("div",{key:"type-block"},[/*#__PURE__*/React.createElement("label",{className:"block text-[10px] text-zinc-400 uppercase font-bold mb-1.5"},"Phương thức lưu trữ"),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs"},[/*#__PURE__*/React.createElement("button",{key:"btn-cloud",type:"button",onClick:()=>setSaveType('cloud'),className:`py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType==='cloud'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}`},[/*#__PURE__*/React.createElement("span",{key:"title"},"☁️ Lưu Cloud"),/*#__PURE__*/React.createElement("span",{key:"desc",className:"text-[9px] font-normal text-zinc-500"},"Lưu lên server cá nhân")]),/*#__PURE__*/React.createElement("button",{key:"btn-local",type:"button",onClick:()=>setSaveType('local'),className:`py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType==='local'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}`},[/*#__PURE__*/React.createElement("span",{key:"title"},"💾 Tải về máy (.sfs)"),/*#__PURE__*/React.createElement("span",{key:"desc",className:"text-[9px] font-normal text-zinc-500"},"Tải tệp JSON dự án về máy")])])]),/*#__PURE__*/React.createElement("div",{key:"actions",className:"flex justify-end gap-2 text-xs pt-2"},[/*#__PURE__*/React.createElement("button",{key:"cancel",type:"button",onClick:onClose,className:"px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"},"Hủy"),/*#__PURE__*/React.createElement("button",{key:"save",type:"submit",className:"px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold"},"Thực hiện lưu")])])));};const SystemManagerModal=({isOpen,onClose})=>{if(!isOpen)return null;const[users,setUsers]=useState([]);const[loading,setLoading]=useState(true);const[msg,setMsg]=useState('');const[error,setError]=useState('');const[editingQuotaUser,setEditingQuotaUser]=useState(null);const[newQuotaMb,setNewQuotaMb]=useState(500);useEffect(()=>{if(isOpen)loadUsers();},[isOpen]);const loadUsers=async()=>{setLoading(true);setError('');try{const data=await window.SonicAPI.listUsers();setUsers(data);}catch(err){setError(err.message||'Không thể tải danh sách người dùng hệ thống');}finally{setLoading(false);}};const handleSaveQuota=async userId=>{try{await window.SonicAPI.updateUserQuota(userId,parseInt(newQuotaMb));setMsg('Đã cập nhật hạn mức Quota thành công!');setEditingQuotaUser(null);loadUsers();}catch(err){setError(err.message||'Lỗi cập nhật Quota');}};const handleToggleRole=async user=>{const nextRole=user.role==='admin'?'standard':'admin';try{await window.SonicAPI.updateUserRole(user.id,nextRole,user.is_active);setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`);loadUsers();}catch(err){setError(err.message||'Lỗi cập nhật vai trò');}};const handleDeleteUser=async userId=>{if(!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?'))return;try{await window.SonicAPI.deleteUser(userId);setMsg('Đã xóa người dùng thành công');loadUsers();}catch(err){setError(err.message||'Lỗi khi xóa người dùng');}};return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-amber-400"},"⚙️ Quản Lý Hệ Thống & Phân Quyền Admin"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),msg&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"},msg),error&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"},error),/*#__PURE__*/React.createElement("div",{className:"mt-4 overflow-x-auto max-h-96 no-scrollbar"},loading?/*#__PURE__*/React.createElement("div",{className:"py-8 text-center text-slate-400 text-xs"},"Đang tải thông tin hệ thống..."):/*#__PURE__*/React.createElement("table",{className:"w-full text-left text-xs border-collapse"},/*#__PURE__*/React.createElement("thead",null,/*#__PURE__*/React.createElement("tr",{className:"border-b border-[#383838] text-slate-400 bg-[#1e1e1e]"},/*#__PURE__*/React.createElement("th",{className:"p-3"},"Tên Người Dùng"),/*#__PURE__*/React.createElement("th",{className:"p-3"},"Email"),/*#__PURE__*/React.createElement("th",{className:"p-3"},"Vai Trò"),/*#__PURE__*/React.createElement("th",{className:"p-3"},"Dung Lượng Sử Dụng"),/*#__PURE__*/React.createElement("th",{className:"p-3"},"Hạn Mức Quota"),/*#__PURE__*/React.createElement("th",{className:"p-3 text-right"},"Thao Tác"))),/*#__PURE__*/React.createElement("tbody",{className:"divide-y divide-[#333]"},users.map(u=>/*#__PURE__*/React.createElement("tr",{key:u.id,className:"hover:bg-[#2e2e2e]"},/*#__PURE__*/React.createElement("td",{className:"p-3 font-semibold text-teal-300"},u.username,u.must_change_password&&/*#__PURE__*/React.createElement("span",{className:"ml-2 text-xs bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded"},"Mật khẩu gốc")),/*#__PURE__*/React.createElement("td",{className:"p-3 text-slate-300"},u.email),/*#__PURE__*/React.createElement("td",{className:"p-3 uppercase font-bold text-amber-400"},u.role),/*#__PURE__*/React.createElement("td",{className:"p-3"},u.used_mb," MB"),/*#__PURE__*/React.createElement("td",{className:"p-3"},editingQuotaUser===u.id?/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1"},/*#__PURE__*/React.createElement("input",{type:"number",value:newQuotaMb,onChange:e=>setNewQuotaMb(e.target.value),className:"w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200"}),/*#__PURE__*/React.createElement("span",null,"MB"),/*#__PURE__*/React.createElement("button",{onClick:()=>handleSaveQuota(u.id),className:"px-2 py-0.5 bg-teal-600 rounded text-xs"},"Lưu")):/*#__PURE__*/React.createElement("span",{className:"font-semibold"},u.quota_mb," MB")),/*#__PURE__*/React.createElement("td",{className:"p-3 text-right space-x-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>{setEditingQuotaUser(u.id);setNewQuotaMb(u.quota_mb);},className:"px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-xs"},"Sửa Quota"),/*#__PURE__*/React.createElement("button",{onClick:()=>handleToggleRole(u),className:"px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-xs"},"Đổi Role"),u.role!=='admin'&&/*#__PURE__*/React.createElement("button",{onClick:()=>handleDeleteUser(u.id),className:"px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-xs"},"Xóa")))))))));};/* ═══════════════════════════════════════════════════════════════════
|
||
ABOUT / HELP / PREFERENCES MODALS (menu Help + Tools → Preferences)
|
||
═══════════════════════════════════════════════════════════════════ */const APP_VERSION='1.0.0';// khớp src-tauri/tauri.conf.json
|
||
const AboutModal=({isOpen,onClose})=>{if(!isOpen)return null;return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"bg-[#1e1e24] border border-zinc-700 rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 mb-4"},/*#__PURE__*/React.createElement("img",{src:"/static/img/scorefx_app.png",alt:"ScoreFX Studio",className:"w-12 h-12 rounded-lg bg-zinc-900 border border-zinc-700 shadow-lg object-contain p-0.5"}),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("h2",{className:"text-lg font-bold text-white"},"ScoreFX Studio"),/*#__PURE__*/React.createElement("p",{className:"text-xs text-zinc-400"},"Professional DAW \u2014 v",APP_VERSION))),/*#__PURE__*/React.createElement("div",{className:"space-y-2 text-sm"},/*#__PURE__*/React.createElement("p",{className:"text-zinc-300 leading-relaxed"},"Ph\u1EA7n m\u1EC1m s\u1EA3n xu\u1EA5t \xE2m nh\u1EA1c (DAW) \u2014 so\u1EA1n nh\u1EA1c, ghi \xE2m, ch\u1EC9nh s\u1EEDa MIDI/Audio, SoundFont & VST, tr\u1ED9n v\xE0 master."),/*#__PURE__*/React.createElement("div",{className:"pt-2 border-t border-zinc-800 space-y-1 text-xs"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 w-20 shrink-0"},"Developer"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-semibold"},"L\u1ED9c Ph\u1EA1m")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 w-20 shrink-0"},"Email"),/*#__PURE__*/React.createElement("a",{href:"mailto:tranloclqd@gmail.com",className:"text-cyan-400 hover:underline"},"tranloclqd@gmail.com")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 w-20 shrink-0"},"Version"),/*#__PURE__*/React.createElement("span",{className:"text-zinc-300"},APP_VERSION)),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 w-20 shrink-0"},"Build"),/*#__PURE__*/React.createElement("span",{className:"text-zinc-300"},"Standalone (Tauri v2 + PyInstaller)")))),/*#__PURE__*/React.createElement("div",{className:"mt-5 flex justify-end"},/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-4 py-1.5 bg-cyan-700 hover:bg-cyan-600 rounded text-xs font-bold text-white transition"},"\u0110\xF3ng"))));};const HelpModal=({isOpen,onClose,lang})=>{if(!isOpen)return null;const vi=lang!=='en';const sections=vi?[{title:'🚀 Bắt đầu nhanh',body:'Nhấn phím Cách (Space) để Play/Pause. Nhấn phím / để dừng. Dùng nút Record (●) để ghi âm/MIDI. Tạo track mới từ menu Track hoặc nút "+ Add Track".'},{title:'🎹 MIDI & ARM',body:'Bật nút ARM đỏ trên track để nhận phím từ MIDI keyboard. Vào menu Tools → MIDI Devices để chọn thiết bị. Khi ARM + bấm phím, âm preview phát và VU meter nhảy theo trường độ.'},{title:'🎼 Piano Roll',body:'Nhấp đúp vào MIDI item để mở Piano Roll. Dùng Ctrl+scroll hoặc nút +/− để zoom. Nhấp để vẽ note, kéo để di chuyển, Ctrl+kéo để copy. Phím tắt: C (vẽ), E (tẩy), Space (nghe).'},{title:'🎛️ FX & Master',body:'Chọn track → nút FX để mở FX Rack (thêm EQ, compressor...). Bấm nút PWR ở Master để mở Mastering Panel: EQ 4 băng, compressor, limiter. Mọi thay đổi áp dụng realtime.'},{title:'🧪 SoundFont & VST',body:'Vào Tools → Plugin Manager để quét SoundFont (.sf2/.sf3) và VSTi. Track MIDI dùng SoundFont làm nhạc cụ — chọn instrument từ nút Synth trên track.'},{title:'🤖 AI',body:'Tools → Config AI Providers để cấu hình API (OpenAI, Gemini, Ollama...). Dùng AI Prompt Generator (nút ✨) để sinh MIDI/ý tưởng; AI MIDI Preset Manager để lưu preset.'},{title:'💾 Lưu & Xuất',body:'Ctrl+S lưu project (cloud/temp), Ctrl+Shift+S Save As. Export WAV qua nút Export — chọn vùng, format, bitrate rồi Render.'},{title:'⌨️ Phím tắt',body:'Space: Play/Pause · /: Stop · Ctrl+Z/Y: Undo/Redo · Ctrl+C/X/V: Copy/Cut/Paste · Ctrl+S: Save · Ctrl+N: New · S: Split · Ctrl+E: Edit in new tab · Ctrl+wheel: zoom timeline'}]:[{title:'🚀 Quick start',body:'Press Space to Play/Pause. Press / to stop. Use Record (●) to capture audio/MIDI. Add tracks from the Track menu or the "+ Add Track" button.'},{title:'🎹 MIDI & ARM',body:'Enable the red ARM button on a track to receive keys from a MIDI keyboard. Go to Tools → MIDI Devices to pick your device. When ARM + key press, preview audio plays and the VU meter animates for the note length.'},{title:'🎼 Piano Roll',body:'Double-click a MIDI item to open the Piano Roll. Use Ctrl+scroll or +/− buttons to zoom. Click to draw notes, drag to move, Ctrl+drag to copy. Shortcuts: C (draw), E (erase), Space (listen).'},{title:'🎛️ FX & Master',body:'Select a track → FX button to open the FX Rack (EQ, compressor...). Press the PWR button on the Master to open the Mastering Panel: 4-band EQ, compressor, limiter — all realtime.'},{title:'🧪 SoundFont & VST',body:'Tools → Plugin Manager scans SoundFonts (.sf2/.sf3) and VSTi. MIDI tracks use SoundFonts as instruments — pick one from the Synth button on the track.'},{title:'🤖 AI',body:'Tools → Config AI Providers to set up APIs (OpenAI, Gemini, Ollama...). Use the AI Prompt Generator (✨) to create MIDI/ideas; AI MIDI Preset Manager stores presets.'},{title:'💾 Save & Export',body:'Ctrl+S saves the project (cloud/temp), Ctrl+Shift+S Save As. Export WAV via the Export button — pick range, format, bitrate, then Render.'},{title:'⌨️ Shortcuts',body:'Space: Play/Pause · /: Stop · Ctrl+Z/Y: Undo/Redo · Ctrl+C/X/V: Copy/Cut/Paste · Ctrl+S: Save · Ctrl+N: New · S: Split · Ctrl+E: Edit in new tab · Ctrl+wheel: zoom timeline'}];return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"bg-[#1e1e24] border border-zinc-700 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col text-slate-200",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0"},/*#__PURE__*/React.createElement("h2",{className:"text-base font-bold text-white"},vi?'Hướng dẫn sử dụng ScoreFX Studio':'ScoreFX Studio User Guide'),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"w-6 h-6 rounded hover:bg-zinc-700 text-zinc-400 hover:text-white text-sm"},"\u2715")),/*#__PURE__*/React.createElement("div",{className:"overflow-y-auto px-5 py-4 space-y-3"},sections.map((s,i)=>/*#__PURE__*/React.createElement("div",{key:i,className:"bg-zinc-900/60 border border-zinc-800 rounded-lg p-3"},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-cyan-400 mb-1"},s.title),/*#__PURE__*/React.createElement("p",{className:"text-xs text-zinc-300 leading-relaxed"},s.body)))),/*#__PURE__*/React.createElement("div",{className:"px-5 py-3 border-t border-zinc-800 flex justify-end shrink-0"},/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-4 py-1.5 bg-cyan-700 hover:bg-cyan-600 rounded text-xs font-bold text-white transition"},vi?'Đóng':'Close'))));};const PreferencesModal=({isOpen,onClose,prefs,onPrefsChange})=>{if(!isOpen)return null;const vi=prefs.language!=='en';const themes=[{id:'dark',name:vi?'Tối (mặc định)':'Dark (default)',swatch:'from-zinc-700 to-zinc-900',ring:'ring-cyan-400'},{id:'midnight',name:vi?'Đêm xanh':'Midnight',swatch:'from-sky-800 to-slate-950',ring:'ring-sky-400'},{id:'forest',name:vi?'Rừng xanh':'Forest',swatch:'from-emerald-700 to-green-950',ring:'ring-emerald-400'},{id:'violet',name:vi?'Tím':'Violet',swatch:'from-violet-700 to-purple-950',ring:'ring-violet-400'},{id:'graphite',name:vi?'Than chì':'Graphite',swatch:'from-zinc-600 to-neutral-900',ring:'ring-zinc-300'}];const set=(k,v)=>onPrefsChange({...prefs,[k]:v});return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"bg-[#1e1e24] border border-zinc-700 rounded-xl shadow-2xl w-full max-w-md max-h-[85vh] overflow-y-auto p-5 text-slate-200",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between mb-4"},/*#__PURE__*/React.createElement("h2",{className:"text-base font-bold text-white"},vi?'Tùy chọn (Preferences)':'Preferences'),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"w-6 h-6 rounded hover:bg-zinc-700 text-zinc-400 hover:text-white text-sm"},"\u2715")),/*#__PURE__*/React.createElement("div",{className:"mb-5"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2"},vi?'🎨 Chủ đề màu':'🎨 Theme'),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-1 gap-1.5"},themes.map(th=>/*#__PURE__*/React.createElement("button",{key:th.id,onClick:()=>set('theme',th.id),className:`flex items-center gap-2.5 px-2.5 py-2 rounded-lg border text-xs text-left transition ${prefs.theme===th.id?'border-cyan-500 bg-zinc-800':'border-zinc-700 bg-zinc-900 hover:bg-zinc-800'}`},/*#__PURE__*/React.createElement("span",{className:`w-6 h-6 rounded-md bg-gradient-to-br ${th.swatch} ring-1 ring-black/40 shrink-0 ${prefs.theme===th.id?`ring-2 ${th.ring}`:''}`}),/*#__PURE__*/React.createElement("span",{className:"text-zinc-200"},th.name),prefs.theme===th.id&&/*#__PURE__*/React.createElement("span",{className:"ml-auto text-cyan-400 text-[10px] font-bold"},"\u2713"))))),/*#__PURE__*/React.createElement("div",{className:"mb-5"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2"},vi?'🌐 Ngôn ngữ':'🌐 Language'),/*#__PURE__*/React.createElement("div",{className:"flex gap-1.5"},[{id:'vi',label:'Tiếng Việt'},{id:'en',label:'English'}].map(l=>/*#__PURE__*/React.createElement("button",{key:l.id,onClick:()=>set('language',l.id),className:`flex-1 px-2 py-1.5 rounded-lg border text-xs font-semibold transition ${prefs.language===l.id?'border-cyan-500 bg-cyan-900/40 text-cyan-300':'border-zinc-700 bg-zinc-900 text-zinc-400 hover:bg-zinc-800'}`},l.label))),/*#__PURE__*/React.createElement("p",{className:"text-[10px] text-zinc-500 mt-1"},vi?'Áp dụng ngay cho menu & hướng dẫn.':'Applies immediately to menus & guide.')),/*#__PURE__*/React.createElement("div",{className:"mb-5"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2"},vi?'🔠 Cỡ chữ nút bấm':'🔠 Button font size'),/*#__PURE__*/React.createElement("div",{className:"flex gap-1.5"},[{id:'sm',label:vi?'Nhỏ':'Small'},{id:'md',label:vi?'Vừa':'Medium'},{id:'lg',label:vi?'Lớn':'Large'}].map(f=>/*#__PURE__*/React.createElement("button",{key:f.id,onClick:()=>set('buttonFontSize',f.id),className:`flex-1 px-2 py-1.5 rounded-lg border text-xs font-semibold transition ${prefs.buttonFontSize===f.id?'border-cyan-500 bg-cyan-900/40 text-cyan-300':'border-zinc-700 bg-zinc-900 text-zinc-400 hover:bg-zinc-800'}`},f.label)))),/*#__PURE__*/React.createElement("div",{className:"flex justify-end gap-2 pt-2 border-t border-zinc-800"},/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 rounded text-xs font-bold text-white transition"},vi?'Hủy':'Cancel'),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-4 py-1.5 bg-cyan-700 hover:bg-cyan-600 rounded text-xs font-bold text-white transition"},vi?'Lưu & Đóng':'Save & Close'))));};const AIPresetModal=({isOpen,onClose})=>{if(!isOpen)return null;const mgrRef=React.useRef(null);if(!mgrRef.current)mgrRef.current=new window.PromptTemplateManager();const mgr=mgrRef.current;const[presets,setPresets]=React.useState(()=>[...mgr.getPresets()]);const[search,setSearch]=React.useState('');const[filterCategory,setFilterCategory]=React.useState('ALL');const[showFavoritesOnly,setShowFavoritesOnly]=React.useState(false);const[editingPreset,setEditingPreset]=React.useState(null);const[syncing,setSyncing]=React.useState(false);const[formName,setFormName]=React.useState('');const[formKeywords,setFormKeywords]=React.useState('');const[formCategory,setFormCategory]=React.useState('Orchestral / Film Score');const[formBars,setFormBars]=React.useState(8);const[formBpm,setFormBpm]=React.useState(120);const[formScale,setFormScale]=React.useState('C Minor');const[formTemplate,setFormTemplate]=React.useState('');const[showGeneratorModal,setShowGeneratorModal]=React.useState(false);const[selectedCategory,setSelectedCategory]=React.useState('Orchestral / Film Score');const[isAddingCategory,setIsAddingCategory]=React.useState(false);const[newCategoryValue,setNewCategoryValue]=React.useState('');const[categoriesVersion,setCategoriesVersion]=React.useState(0);const presetCategories=React.useMemo(()=>{const fromPresets=[...new Set(presets.map(p=>p.category).filter(Boolean))];let saved=[];try{const raw=localStorage.getItem('midi_prompt_categories');saved=raw?JSON.parse(raw):[];}catch(e){saved=[];}return[...new Set([...fromPresets,...saved])].sort();},[presets,categoriesVersion]);const addNewCategory=cat=>{const val=(cat||'').trim();if(!val)return;setFormCategory(val);setSelectedCategory(val);try{const raw=localStorage.getItem('midi_prompt_categories');const list=raw?JSON.parse(raw):[];if(!list.includes(val)){list.push(val);localStorage.setItem('midi_prompt_categories',JSON.stringify(list));setCategoriesVersion(v=>v+1);}}catch(e){}};const refreshPresets=()=>{setPresets([...mgr.getPresets()]);};// Sync from backend on mount — merge into local presets, never overwrite
|
||
React.useEffect(()=>{if(!window.SonicAPI)return;setSyncing(true);window.SonicAPI.getAIPresets().then(data=>{if(!data||!data.presets||data.presets.length===0)return;var existing=mgr.presets;var existingIds=new Set(existing.map(function(p){return p.id;}));var merged=existing.slice();data.presets.forEach(function(bp){if(!existingIds.has(bp.id)){merged.push(bp);existingIds.add(bp.id);}});mgr.presets=merged;setPresets(merged);}).catch(function(){}).finally(function(){setSyncing(false);});},[]);// Listen for GENERATOR_PRESET_DATA from the iframe generator modal
|
||
React.useEffect(()=>{const handleMessage=event=>{if(event.data&&event.data.type==='GENERATOR_PRESET_DATA'){const d=event.data;setFormName(d.name||'');setFormCategory(d.category||'Orchestral / Film Score');setSelectedCategory(d.category||'Orchestral / Film Score');setFormKeywords(d.keywords||'');setFormBars(parseInt(d.default_bars)||8);setFormBpm(parseInt(d.default_bpm)||120);setFormScale(d.default_scale||'C Minor');setFormTemplate(d.template||'');setEditingPreset('new');setShowGeneratorModal(false);setIsAddingCategory(false);setNewCategoryValue('');refreshPresets();}};window.addEventListener('message',handleMessage);return()=>window.removeEventListener('message',handleMessage);},[]);const savePresets=newPresets=>{setPresets(newPresets);mgr.presets=newPresets;mgr.savePresets();// Sync to backend if available
|
||
const userDefined=newPresets.filter(p=>p.is_user_defined);if(window.SonicAPI&&userDefined.length>0){userDefined.forEach(p=>{window.SonicAPI.saveAIPreset(p).catch(()=>{});});}};const handleEdit=p=>{setEditingPreset(p);setFormName(p.name);setFormKeywords(p.keywords.join(', '));setFormCategory(p.category);setSelectedCategory(p.category);setFormBars(p.default_bars);setFormBpm(p.default_bpm);setFormScale(p.default_scale);setFormTemplate(p.system_instruction_template);setIsAddingCategory(false);setNewCategoryValue('');};const handleNew=()=>{setEditingPreset('new');setFormName('');setFormKeywords('');setFormCategory('Orchestral / Film Score');setSelectedCategory('Orchestral / Film Score');setFormBars(8);setFormBpm(120);setFormScale('C Minor');setFormTemplate('');setIsAddingCategory(false);setNewCategoryValue('');};const handleNewStructured=()=>{refreshPresets();setShowGeneratorModal(true);};const handleToggleFav=id=>{mgr.toggleFavorite(id);setPresets([...mgr.getPresets()]);const p=mgr.presets.find(x=>x.id===id);if(p&&p.is_user_defined&&window.SonicAPI){window.SonicAPI.saveAIPreset(p).catch(()=>{});}};const handleDelete=id=>{const p=presets.find(x=>x.id===id);if(p&&p.is_user_defined&&window.SonicAPI){window.SonicAPI.deleteAIPreset(id).catch(()=>{});}mgr.deletePreset(id);setPresets([...mgr.getPresets()]);showToast('Đã xóa preset.','info');};const handleSaveForm=e=>{e.preventDefault();if(!formName.trim()||!formTemplate.trim()){showToast('Vui lòng điền đầy đủ tên và mẫu gợi ý.','warning');return;}const keywordsArray=formKeywords.split(',').map(k=>k.trim()).filter(Boolean);const presetObj={id:editingPreset==='new'?'preset_'+Date.now():editingPreset.id,name:formName.trim(),keywords:keywordsArray,category:formCategory,default_bars:parseInt(formBars)||8,default_bpm:parseInt(formBpm)||120,default_scale:formScale,system_instruction_template:formTemplate.trim(),is_user_defined:true,is_favorite:editingPreset==='new'?false:editingPreset.is_favorite||false,created_at:editingPreset==='new'?new Date().toISOString():editingPreset.created_at};mgr.saveUserPreset(presetObj);setPresets([...mgr.getPresets()]);setEditingPreset(null);if(window.SonicAPI){window.SonicAPI.saveAIPreset(presetObj).catch(()=>{});}showToast('Đã lưu preset thành công!','success');};const categories=['ALL','★ Yêu thích','Người dùng',...new Set(presets.map(p=>p.category))];const filtered=presets.filter(p=>{const matchesSearch=p.name.toLowerCase().includes(search.toLowerCase())||p.keywords.some(k=>k.toLowerCase().includes(search.toLowerCase()));let matchesCategory;if(filterCategory==='ALL'){matchesCategory=true;}else if(filterCategory==='★ Yêu thích'){matchesCategory=p.is_favorite;}else if(filterCategory==='Người dùng'){matchesCategory=p.is_user_defined;}else{matchesCategory=p.category===filterCategory;}if(showFavoritesOnly)matchesCategory=matchesCategory&&p.is_favorite;return matchesSearch&&matchesCategory;});return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4 animate-fade-in"},/*#__PURE__*/React.createElement("div",{className:"bg-[#18181b] border border-zinc-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden text-zinc-100"},/*#__PURE__*/React.createElement("div",{className:"p-4 border-b border-zinc-800 flex items-center justify-between shrink-0 bg-[#202024]"},/*#__PURE__*/React.createElement("h2",{className:"text-sm font-bold tracking-wider uppercase text-purple-400 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-4 h-4"}),"AI Prompt Preset Manager",syncing&&/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-zinc-500 ml-2"},"đang đồng bộ...")),/*#__PURE__*/React.createElement("button",{onClick:()=>{setEditingPreset(null);onClose();},className:"text-zinc-400 hover:text-zinc-200 transition"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-y-auto p-4 flex gap-4 min-h-0"},!editingPreset?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col min-w-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 mb-3 shrink-0"},/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Tìm kiếm preset hoặc từ khóa...",value:search,onChange:e=>setSearch(e.target.value),className:"flex-1 bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600"}),/*#__PURE__*/React.createElement("select",{value:filterCategory,onChange:e=>setFilterCategory(e.target.value),className:"bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600"},categories.map(c=>/*#__PURE__*/React.createElement("option",{key:c,value:c},c==='ALL'?'Tất cả danh mục':c))),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowFavoritesOnly(!showFavoritesOnly),className:`px-2.5 py-1 rounded text-xs font-bold transition shrink-0 ${showFavoritesOnly?'bg-yellow-700 text-yellow-300':'bg-zinc-800 text-zinc-400 hover:bg-zinc-700'}`,title:"Chỉ hiện yêu thích"},/*#__PURE__*/React.createElement("i",{"data-lucide":"star",className:"w-3.5 h-3.5 inline-block mr-1"}),"★"),/*#__PURE__*/React.createElement("button",{onClick:handleNew,className:"px-3 py-1 bg-purple-700 hover:bg-purple-600 text-white rounded text-xs font-bold flex items-center gap-1.5 shadow transition shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3.5 h-3.5"}),"Tạo mới")),/*#__PURE__*/React.createElement("div",{className:"flex-1 border border-zinc-800 rounded bg-[#0f0f12] overflow-y-auto"},filtered.length===0?/*#__PURE__*/React.createElement("div",{className:"p-8 text-center text-zinc-500 text-xs italic"},"Không tìm thấy preset nào."):/*#__PURE__*/React.createElement("table",{className:"w-full text-left text-xs border-collapse"},/*#__PURE__*/React.createElement("thead",{className:"bg-[#1f1f23] text-zinc-400 font-bold border-b border-zinc-800"},/*#__PURE__*/React.createElement("tr",null,/*#__PURE__*/React.createElement("th",{className:"p-2.5 w-8"},""),/*#__PURE__*/React.createElement("th",{className:"p-2.5 w-1/4"},"Tên Preset"),/*#__PURE__*/React.createElement("th",{className:"p-2.5 w-1/4"},"Từ khóa kích hoạt"),/*#__PURE__*/React.createElement("th",{className:"p-2.5 w-1/6"},"Số Bar"),/*#__PURE__*/React.createElement("th",{className:"p-2.5 w-1/6"},"BPM"),/*#__PURE__*/React.createElement("th",{className:"p-2.5 w-1/6 text-right"},"Hành động"))),/*#__PURE__*/React.createElement("tbody",null,filtered.map(p=>/*#__PURE__*/React.createElement("tr",{key:p.id,className:"border-b border-zinc-800/50 hover:bg-zinc-850"},/*#__PURE__*/React.createElement("td",{className:"p-2.5 text-center"},/*#__PURE__*/React.createElement("button",{onClick:()=>handleToggleFav(p.id),className:`transition ${p.is_favorite?'text-yellow-400':'text-zinc-600 hover:text-zinc-400'}`,title:p.is_favorite?'Bỏ yêu thích':'Đánh dấu yêu thích'},p.is_favorite?"★":"☆")),/*#__PURE__*/React.createElement("td",{className:"p-2.5 font-semibold text-purple-300"},p.name),/*#__PURE__*/React.createElement("td",{className:"p-2.5 text-zinc-400 font-mono text-[11px] truncate max-w-[150px]"},p.keywords.join(', ')),/*#__PURE__*/React.createElement("td",{className:"p-2.5 text-zinc-400"},p.default_bars," Bars"),/*#__PURE__*/React.createElement("td",{className:"p-2.5 text-zinc-400"},p.default_bpm," BPM"),/*#__PURE__*/React.createElement("td",{className:"p-2.5 text-right flex items-center justify-end gap-1.5 h-full"},/*#__PURE__*/React.createElement("button",{type:"button",onClick:()=>handleEdit(p),className:"px-2 py-0.5 bg-zinc-850 hover:bg-zinc-700 text-zinc-300 rounded border border-zinc-700 text-[10px]"},"Sửa"),p.is_user_defined&&/*#__PURE__*/React.createElement("button",{type:"button",onClick:()=>handleDelete(p.id),className:"px-2 py-0.5 bg-red-950/40 hover:bg-red-800 text-red-400 rounded border border-red-900 text-[10px]"},"Xóa"))))))))):/*#__PURE__*/React.createElement("form",{onSubmit:handleSaveForm,className:"flex-1 flex flex-col gap-3 min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-xs font-bold text-zinc-400 shrink-0 border-b border-zinc-800 pb-1"},editingPreset==='new'?"TẠO PRESET MỚI":`SỬA PRESET: ${editingPreset.name}`),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("label",{className:"text-[10px] uppercase font-bold text-zinc-500"},"Tên Preset"),/*#__PURE__*/React.createElement("input",{type:"text",value:formName,onChange:e=>setFormName(e.target.value),className:"bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("label",{className:"text-[10px] uppercase font-bold text-zinc-500"},"Danh mục"),isAddingCategory?/*#__PURE__*/React.createElement("div",{className:"flex gap-2"},/*#__PURE__*/React.createElement("input",{type:"text",value:newCategoryValue,onChange:e=>setNewCategoryValue(e.target.value),onBlur:()=>{if(newCategoryValue.trim()){addNewCategory(newCategoryValue.trim());}setIsAddingCategory(false);},onKeyDown:e=>{if(e.key==='Enter'){e.preventDefault();if(newCategoryValue.trim()){addNewCategory(newCategoryValue.trim());}setIsAddingCategory(false);}else if(e.key==='Escape'){setIsAddingCategory(false);}},autoFocus:true,className:"flex-1 bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200",placeholder:"Nhập danh mục mới..."})):/*#__PURE__*/React.createElement("select",{value:selectedCategory,onChange:e=>{if(e.target.value==='__add_new__'){setIsAddingCategory(true);setNewCategoryValue('');}else{setSelectedCategory(e.target.value);setFormCategory(e.target.value);}},className:"bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200"},presetCategories.map(c=>/*#__PURE__*/React.createElement("option",{key:c,value:c},c)),/*#__PURE__*/React.createElement("option",{value:'__add_new__'},"+ Nhập danh mục mới...")))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("label",{className:"text-[10px] uppercase font-bold text-zinc-500"},"Từ khóa kích hoạt (ngăn cách bằng dấu phẩy)"),/*#__PURE__*/React.createElement("input",{type:"text",value:formKeywords,onChange:e=>setFormKeywords(e.target.value),placeholder:"Ví dụ: epic orchestra, hoành tráng, nhạc phim epic",className:"bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200"})),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-3"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("label",{className:"text-[10px] uppercase font-bold text-zinc-500"},"Số Bars mặc định"),/*#__PURE__*/React.createElement("input",{type:"number",value:formBars,onChange:e=>setFormBars(e.target.value),className:"bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("label",{className:"text-[10px] uppercase font-bold text-zinc-500"},"BPM mặc định"),/*#__PURE__*/React.createElement("input",{type:"number",value:formBpm,onChange:e=>setFormBpm(e.target.value),className:"bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("label",{className:"text-[10px] uppercase font-bold text-zinc-500"},"Âm giai (Scale) mặc định"),/*#__PURE__*/React.createElement("input",{type:"text",value:formScale,onChange:e=>setFormScale(e.target.value),className:"bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200"}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1 flex-1 min-h-0"},/*#__PURE__*/React.createElement("label",{className:"text-[10px] uppercase font-bold text-zinc-500"},"System Prompt Template / Luật soạn nhạc"),/*#__PURE__*/React.createElement("textarea",{value:formTemplate,onChange:e=>setFormTemplate(e.target.value),rows:6,className:"flex-1 bg-zinc-900 border border-zinc-700 rounded p-2.5 text-xs outline-none focus:border-purple-600 text-zinc-200 font-mono resize-none"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 shrink-0 pt-2 border-t border-zinc-800"},/*#__PURE__*/React.createElement("button",{type:"button",onClick:handleNewStructured,className:"px-3 py-1.5 bg-emerald-800 hover:bg-emerald-700 text-emerald-300 border border-emerald-700 rounded text-xs transition"},"Tạo preset với cấu trúc"),/*#__PURE__*/React.createElement("div",{className:"flex-1"}),/*#__PURE__*/React.createElement("button",{type:"button",onClick:()=>setEditingPreset(null),className:"px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 border border-zinc-700 rounded text-xs transition"},"Quay lại"),/*#__PURE__*/React.createElement("button",{type:"submit",className:"px-3 py-1.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-xs font-bold shadow transition"},"Lưu Preset")))),/*#__PURE__*/React.createElement("div",{className:"p-4 border-t border-zinc-800 flex justify-end shrink-0 bg-[#202024]"},/*#__PURE__*/React.createElement("button",{onClick:()=>{setEditingPreset(null);onClose();},className:"px-4 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs font-bold shadow transition"},"Đóng")))),showGeneratorModal?/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-4",onClick:e=>{if(e.target===e.currentTarget){refreshPresets();setShowGeneratorModal(false);}}},/*#__PURE__*/React.createElement("div",{className:"w-full h-full max-w-6xl max-h-[90vh] bg-[#13141a] border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between p-3 border-b border-zinc-800 shrink-0"},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-purple-400"},"AI Prompt Generator"),/*#__PURE__*/React.createElement("button",{onClick:()=>{refreshPresets();setShowGeneratorModal(false);},className:"text-zinc-400 hover:text-white"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("iframe",{src:"/ai-prompt-generator",className:"flex-1 w-full border-0 bg-white",title:"AI Prompt Generator"}))):null);};const PianoRollTabEditor=({st,zoom,bpm,viewportWidth,activeTracks,onClose,onUpdateNotes,onSaveNotes,setSubTabs,onPlayPause,onStop,isPlaying,playPreviewNote,showToast,midiDevices,recordingState,recTempMidiNotes,onRecord,selectedMidiInputId,onMidiInputSelect,activeMidiPitches,onInstrumentSelect,onRescheduleMidi,onSeekPlayhead,snapValue,onSnapChange,onRealtimePlay,onCopyNotes,clipboardNotes,onExportMidiAudio})=>{const[activeRollTool,setActiveRollTool]=React.useState('select');const[renderTick,setRenderTick]=React.useState(0);const[ccMode,setCcMode]=React.useState('velocity');const[rollZoom,setRollZoom]=React.useState(()=>{// Persist zoom piano roll qua phiên (localStorage) — user yêu cầu giữ kích
|
||
// thước zoom ở phiên làm việc trước.
|
||
try{const v=parseFloat(localStorage.getItem('sf_pr_zoom'));if(isFinite(v)&&v>=15&&v<=250)return v;}catch(e){}return 60;});// local horizontal zoom factor
|
||
React.useEffect(()=>{try{localStorage.setItem('sf_pr_zoom',String(rollZoom));}catch(e){}},[rollZoom]);const[aiBarStart,setAiBarStart]=React.useState(0);const[aiBarEnd,setAiBarEnd]=React.useState(4);const canvasRef=React.useRef(null);const ccCanvasRef=React.useRef(null);const ccWrapperRef=React.useRef(null);const gridScrollRef=React.useRef(null);const keybedRef=React.useRef(null);const keybedMouseDownRef=React.useRef(false);const rulerScrollRef=React.useRef(null);// Audio element phát preview VSTi render — dừng cái cũ khi play cái mới
|
||
const vstiPreviewAudioRef=React.useRef(null);React.useEffect(()=>{const up=()=>{keybedMouseDownRef.current=false;};window.addEventListener('mouseup',up);return()=>window.removeEventListener('mouseup',up);},[]);const NoteHeight=18;const PITCH_START=0;// C0 (render all 128 keys)
|
||
const KeybedPixelHeight=(128-PITCH_START)*NoteHeight;const pixelsPerBeat=rollZoom;const timeSigNum=4;const noteMaxBeat=(st.notes||[]).reduce((max,n)=>Math.max(max,(n.start_beat||0)+(n.duration_beats||1)),0);const[selectionMarquee,setSelectionMarquee]=React.useState(null);// { startBeat, startPitch, currentBeat, currentPitch }
|
||
const[draggedNote,setDraggedNote]=React.useState(null);// { mode: 'move'|'resize', idx, startOffsetBeat, originalStart }
|
||
const draggedNoteRef=React.useRef(draggedNote);draggedNoteRef.current=draggedNote;const[hoveredResizeIdx,setHoveredResizeIdx]=React.useState(-1);const[rollBeats,setRollBeats]=React.useState(Math.max(noteMaxBeat+16,64));const rollBeatsRef=React.useRef(rollBeats);rollBeatsRef.current=rollBeats;const[showGhostNotes,setShowGhostNotes]=React.useState(true);const[sessionSyncMode,setSessionSyncMode]=React.useState(true);const[activePlayTrackIds,setActivePlayTrackIds]=React.useState([]);const[focusItemId,setFocusItemId]=React.useState(st.target_id);const allMidiItems=React.useMemo(()=>{const result=[];(activeTracks||[]).forEach(t=>{if(!t.midiItems||!t.midiItems.length)return;t.midiItems.forEach(m=>{var extended=Object.assign({},m,{_trackId:t.id,_trackName:t.name});result.push(extended);});});return result;},[activeTracks]);const ghostLayers=React.useMemo(function(){// Đang play (main/section) → BỎ ghost: extractGhostLayers duyệt TOÀN BỘ
|
||
// project (tracks + notes) — block main thread 100-500ms → noteon
|
||
// FluidSynth (scheduled qua setTimeout) TRỄ → âm bị lag 1-2 lần khi mở
|
||
// piano roll tab lúc đang play (user bug 06:30). Ghost chỉ là hiển thị
|
||
// tham chiếu — không cần khi âm đang chạy — tính lại khi dừng.
|
||
if(isPlaying)return[];if(!activeTracks||!st||!st.target_id)return[];var fn=window.SonicGhost&&window.SonicGhost.extractGhostLayers;return fn?fn(activeTracks,st.trackId,st.target_id,parseInt(bpm)||120):[];},[activeTracks,st.trackId,st.target_id,bpm,isPlaying]);const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const activeTargetItem=React.useMemo(function(){if(!activeTracks||!st)return null;var trk=activeTracks.find(function(t){return t.id===st.trackId;});return trk?(trk.midiItems||[]).find(function(m){return m.id===st.target_id;}):null;},[activeTracks,st.trackId,st.target_id]);var activeParentTrackName='';if(st.target_id&&activeTracks){var aptTrk=window.SonicPianoRoll?window.SonicPianoRoll.getParentTrackByItemId(st.target_id,activeTracks):null;if(!aptTrk)aptTrk=activeTracks.find(function(t){return t.id===st.trackId;});if(!aptTrk&&activeTargetItem)aptTrk=activeTracks.find(function(t){return(t.midiItems||[]).some(function(m){return m.id===st.target_id;});});if(aptTrk)activeParentTrackName=aptTrk.name||aptTrk.id;}const sessionStartBar=0;const renderBeatOffset=sessionSyncMode&&activeTargetItem?activeTargetItem.startTime/secondsPerBar*timeSigNum:0;const sessionLengthBars=React.useMemo(function(){var maxSec=0;(activeTracks||[]).forEach(function(tr){(tr.midiItems||[]).forEach(function(m){var end=(m.startTime||0)+(m.duration||4);if(end>maxSec)maxSec=end;});});return Math.ceil((maxSec||4)/secondsPerBar);},[activeTracks,secondsPerBar]);const handleSwitchMidiItem=function(itemId){if(itemId===st.target_id)return;var match=allMidiItems.find(function(m){return m.id===itemId;});if(!match)return;var scope=window.SonicPianoRoll?window.SonicPianoRoll.buildActiveScope(itemId,activeTracks):null;var trk=scope?null:(activeTracks||[]).find(function(t){return t.id===match._trackId;});var newBeatOff=match.startTime/secondsPerBar*timeSigNum;var spb=60.0/(parseInt(bpm)||120);var newTime=0;setSubTabs(function(prev){return prev.map(function(s){if(s.id!==st.id)return s;return Object.assign({},s,{trackId:scope?scope.parent_track_id:match._trackId||trk?.id,target_id:match.id,label:'Piano Roll: '+(match.name||'MIDI'),notes:match.notes||[],cc11_global_curve:match.cc11_global_curve||null,duration:match.duration||4,instrumentProgram:scope?scope.instrument_program:trk?trk.instrumentProgram:undefined,instrumentName:scope?scope.instrument_name:trk?trk.instrumentName:undefined,active_scope:scope||null,note_selection:[],currentTime:newTime});});});setSelectedNoteIds([]);};const rawTotalBeats=Math.max(rollBeats,noteMaxBeat+16,64);const drawWidth=rawTotalBeats*pixelsPerBeat;const[rollViewWidth,setRollViewWidth]=React.useState(800);const viewWidth=Math.max(drawWidth,rollViewWidth);const viewBeats=Math.ceil(viewWidth/pixelsPerBeat)+4;const totalBeats=Math.max(rawTotalBeats,viewBeats+32);const[notes,setNotes]=React.useState(st.notes||[]);const notesRef=React.useRef(notes);notesRef.current=notes;React.useEffect(()=>{if(!draggedNoteRef.current)setNotes(st.notes||[]);},[st.notes]);const brushVelocityRef=React.useRef(0.8);const lastNoteDurationRef=React.useRef(null);const previewPitchRef=React.useRef(null);const previewNodesRef=React.useRef(null);var stopPreviewNote=function(){var pn=previewNodesRef.current;if(pn){try{pn.osc.stop();}catch(e){}try{pn.osc.disconnect();}catch(e){}try{pn.gain.disconnect();}catch(e){}previewNodesRef.current=null;}};const[selectedNoteIds,setSelectedNoteIds]=React.useState([]);const[loopStartBeat,setLoopStartBeat]=React.useState(null);const[loopEndBeat,setLoopEndBeat]=React.useState(null);const[isLooping,setIsLooping]=React.useState(false);const rulerDragRef=React.useRef(null);React.useEffect(()=>{const handler=e=>{if((e.ctrlKey||e.metaKey)&&e.key==='a'){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable))return;e.preventDefault();setSelectedNoteIds(notes.map(n=>n.id));return;}// Spec 20:12 shortcuts: Alt+A Arp, Alt+S Strum, Alt+R Humanize,
|
||
// Shift+C chord stamp, Shift+S lock-scale toggle
|
||
const inField=e.target&&(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.isContentEditable);// Ctrl+C/X/V — copy/paste/cut NOTES (thay 2 nút Copy/Paste đã bỏ —
|
||
// user 08:40) — CHỈ tác động NOTES ĐƯỢC CHỌN (user 09:10)
|
||
if((e.ctrlKey||e.metaKey)&&!e.altKey&&!inField){if(e.key==='c'){e.preventDefault();const sel=notes.filter(n=>selectedNoteIds.includes(n.id));if(sel.length){if(onCopyNotes)onCopyNotes(sel);showToast('Đã copy '+sel.length+' nốt được chọn.','success');}else{showToast('Chọn notes trước khi copy (Ctrl+C).','warning');}return;}if(e.key==='x'){e.preventDefault();const sel=notes.filter(n=>selectedNoteIds.includes(n.id));if(sel.length){if(onCopyNotes)onCopyNotes(sel);pushToUndo(notes);setNotes(prev=>(prev||[]).filter(n=>!selectedNoteIds.includes(n.id)));showToast('Đã cắt '+sel.length+' nốt được chọn.','success');}else{showToast('Chọn notes trước khi cắt (Ctrl+X).','warning');}return;}if(e.key==='v'){e.preventDefault();if(!clipboardNotes||!clipboardNotes.length){showToast('Clipboard trống — bấm Ctrl+C trước.','warning');return;}pushToUndo(notes);// Dán bắt đầu tại vị trí CON TRỎ PLAYHEAD (user 09:25) — bù offset
|
||
// sao cho note đầu tiên của clipboard nằm đúng playhead (beat).
|
||
const pasteBeat=(st.currentTime||0)/(60.0/(parseInt(bpm)||120));const minStart=Math.min(...clipboardNotes.map(n=>n.start_beat));const offset=pasteBeat-minStart;setNotes(prev=>[...(prev||[]),...clipboardNotes.map(function(n){return{...n,id:'note_cp_'+Date.now()+'_'+Math.floor(Math.random()*100000),start_beat:Math.max(0,n.start_beat+offset)};})]);showToast('Đã paste '+clipboardNotes.length+' nốt tại playhead.','success');return;}}if(e.altKey&&!e.ctrlKey&&!inField&&e.key==='a'){e.preventDefault();setArpModal({pattern:'UP',rate:'1/16',octaves:2,gate:80,triplet:false,dotted:false});return;}if(e.altKey&&!e.ctrlKey&&!inField&&e.key==='s'){e.preventDefault();setStrumModal({ms:30,direction:'DOWN'});return;}if(e.altKey&&!e.ctrlKey&&!inField&&e.key==='r'){e.preventDefault();setHumanizeModal({timingMs:12,velRange:15,durRange:10});return;}if(e.shiftKey&&!e.ctrlKey&&!e.altKey&&!inField&&e.key==='c'){e.preventDefault();setChordStampMode(m=>!m);return;}if(e.shiftKey&&!e.ctrlKey&&!e.altKey&&!inField&&e.key==='s'){e.preventDefault();setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,snapToScale:!(s.snapToScale!==undefined?s.snapToScale:true)}:s));return;}};window.addEventListener('keydown',handler);return()=>window.removeEventListener('keydown',handler);},[notes,setSelectedNoteIds,clipboardNotes,selectedNoteIds]);// Undo/redo stacks
|
||
const undoStackRef=React.useRef([]);const redoStackRef=React.useRef([]);const notesBeforeDragRef=React.useRef(null);const pushToUndo=React.useCallback(prevNotes=>{undoStackRef.current.push(JSON.parse(JSON.stringify(prevNotes)));redoStackRef.current=[];if(undoStackRef.current.length>50)undoStackRef.current.shift();},[]);// ── Humanize: ngẫu nhiên hóa velocity + timing theo cường độ ──
|
||
const[humanizeStrength,setHumanizeStrength]=React.useState(0.10);// 0.05 nhẹ / 0.10 vừa / 0.18 mạnh
|
||
const applyHumanize=React.useCallback(()=>{if(!notes||!notes.length){showToast('Không có nốt nào để humanize.','warning');return;}const velAmt=humanizeStrength;const timeAmt=humanizeStrength*0.15;// ±0.015 beat @ vừa (~12ms @120bpm)
|
||
pushToUndo(notes);setNotes(prev=>(prev||[]).map(n=>({...n,velocity:Math.max(0.05,Math.min(1.0,(n.velocity||0.8)+(Math.random()*2-1)*velAmt)),start_beat:Math.max(0,(n.start_beat||0)+(Math.random()*2-1)*timeAmt)})));showToast('Đã humanize '+notes.length+' nốt (velocity ±'+Math.round(velAmt*100)+'%, timing ±'+Math.round(timeAmt*1000)+'ms).','success');},[notes,pushToUndo,setNotes,showToast,humanizeStrength]);// ── Transpose semitone: dịch pitch tất cả nốt (clamp 0-127) ──
|
||
const applyTranspose=React.useCallback(semi=>{const s=parseInt(semi);if(isNaN(s)||s===0){showToast('Nhập số semitone khác 0.','warning');return;}if(!notes||!notes.length){showToast('Không có nốt nào để transpose.','warning');return;}pushToUndo(notes);setNotes(prev=>(prev||[]).map(n=>({...n,pitch:Math.max(0,Math.min(127,(n.pitch||60)+s))})));showToast('Đã transpose '+notes.length+' nốt '+(s>0?'+':'')+s+' semitone.','success');},[notes,pushToUndo,setNotes,showToast]);// ── Transpose theo SCALE (chuyển giọng): detect key hiện tại → map degree ──
|
||
const SCALE_PATTERNS={major:[0,2,4,5,7,9,11],minor:[0,2,3,5,7,8,10]};const SCALE_ROOTS={C:0,'C#':1,D:2,'D#':3,E:4,F:5,'F#':6,G:7,'G#':8,A:9,'A#':10,B:11};const detectKey=React.useCallback(noteList=>{const roots=Object.keys(SCALE_ROOTS);let best=null,bestScore=-1;for(let ri=0;ri<roots.length;ri++){for(const st of['major','minor']){const tones=new Set(SCALE_PATTERNS[st].map(s=>(SCALE_ROOTS[roots[ri]]+s)%12));let score=0;(noteList||[]).forEach(n=>{const pc=((n.pitch||60)%12+12)%12;if(tones.has(pc))score++;});if(score>bestScore){bestScore=score;best={root:roots[ri],scale:st};}}}return best||{root:'C',scale:'major'};},[]);const[keyTargetRoot,setKeyTargetRoot]=React.useState('C');const[keyTargetScale,setKeyTargetScale]=React.useState('major');// Auto-detect scale khi MỞ midi item → hiển thị ở dropdown chuyển giọng.
|
||
// Key theo st.target_id (item id) — sửa note cùng item KHÔNG reset lựa chọn
|
||
// của user; mở item khác → detect lại.
|
||
React.useEffect(()=>{const itemNotes=st.notes||[];if(itemNotes.length){const k=detectKey(itemNotes);setKeyTargetRoot(k.root);setKeyTargetScale(k.scale);}// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[st.target_id]);const applyTransposeToKey=React.useCallback(()=>{if(!notes||!notes.length){showToast('Không có nốt nào để chuyển giọng.','warning');return;}const srcKey=detectKey(notes);const dstKey={root:keyTargetRoot,scale:keyTargetScale};if(srcKey.root===dstKey.root&&srcKey.scale===dstKey.scale){showToast('Đã ở giọng '+dstKey.root+' '+dstKey.scale+' rồi.','info');return;}const srcTones=SCALE_PATTERNS[srcKey.scale].map(s=>(SCALE_ROOTS[srcKey.root]+s)%12);const dstTones=SCALE_PATTERNS[dstKey.scale].map(s=>(SCALE_ROOTS[dstKey.root]+s)%12);pushToUndo(notes);setNotes(prev=>(prev||[]).map(n=>{const p=n.pitch||60;const pc=(p%12+12)%12;// Degree gần nhất trong scale nguồn (7 bậc)
|
||
let bestIdx=0,bestDist=99;for(let i=0;i<7;i++){let d=Math.abs(pc-srcTones[i]);if(d>6)d=12-d;if(d<bestDist){bestDist=d;bestIdx=i;}}let shift=dstTones[bestIdx]-srcTones[bestIdx];if(shift>6)shift-=12;else if(shift<-6)shift+=12;return{...n,pitch:Math.max(0,Math.min(127,p+shift))};}));showToast('Chuyển giọng '+srcKey.root+' '+srcKey.scale+' → '+dstKey.root+' '+dstKey.scale+' ('+notes.length+' nốt).','success');},[notes,pushToUndo,setNotes,showToast,detectKey,keyTargetRoot,keyTargetScale]);const[transposeSemis,setTransposeSemis]=React.useState(0);const handleUndo=React.useCallback(()=>{const prev=undoStackRef.current.pop();if(!prev)return;redoStackRef.current.push(JSON.parse(JSON.stringify(notes)));setNotes(prev);setSelectedNoteIds([]);},[notes]);const handleRedo=React.useCallback(()=>{const next=redoStackRef.current.pop();if(!next)return;undoStackRef.current.push(JSON.parse(JSON.stringify(notes)));setNotes(next);setSelectedNoteIds([]);},[notes]);React.useEffect(()=>{const handler=e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'&&!e.shiftKey){e.preventDefault();handleUndo();}else if((e.ctrlKey||e.metaKey)&&(e.key==='y'||e.key==='z'&&e.shiftKey)){e.preventDefault();handleRedo();}else if((e.ctrlKey||e.metaKey)&&e.key==='s'){e.preventDefault();onSaveNotes(st.id,st.trackId,st.target_id,notes);showToast('Đã lưu MIDI notes','info');}else if(e.key==='Delete'||e.key==='Backspace'){if(selectedNoteIds.length>0&&e.target.tagName!=='INPUT'&&e.target.tagName!=='TEXTAREA'){e.preventDefault();pushToUndo(notes);setNotes(prev=>prev.filter(n=>!selectedNoteIds.includes(n.id)));setSelectedNoteIds([]);showToast(`Đã xóa ${selectedNoteIds.length} nốt!`,'info');}}else if(e.key==='F7'){e.preventDefault();e.stopPropagation();var toggleMixer=window.__toggleMixerRef;if(toggleMixer)toggleMixer();}else if(e.key==='F6'){e.preventDefault();e.stopPropagation();var toggleMediaExplorer=window.__toggleMediaExplorerRef;if(toggleMediaExplorer)toggleMediaExplorer();}};window.addEventListener('keydown',handler);return()=>window.removeEventListener('keydown',handler);},[handleUndo,handleRedo,notes,selectedNoteIds,onSaveNotes,showToast]);React.useEffect(()=>{onUpdateNotes(st.id,notes);},[notes]);const getSnapBeat=(beat,mode)=>{let q=0.25;if(mode==='free')return beat;if(mode==='1')q=1.0;else if(mode==='1/2')q=0.5;else if(mode==='1/4')q=0.25;else if(mode==='1/8')q=0.125;else if(mode==='1/16')q=0.0625;else if(mode==='4')q=4.0;else if(mode==='1/32')q=0.03125;return Math.round(beat/q)*q;};const getSnapDuration=mode=>{if(mode==='free')return 0.25;if(mode==='1')return 1.0;if(mode==='1/2')return 0.5;if(mode==='1/4')return 0.25;if(mode==='1/8')return 0.125;if(mode==='1/16')return 0.0625;if(mode==='4')return 4.0;if(mode==='1/32')return 0.03125;return 0.25;};// Local Zoom Wheel Event handler to block browser page zoom
|
||
React.useEffect(()=>{const handleWheelRaw=e=>{if(e.ctrlKey){e.preventDefault();const zoomFactor=e.deltaY<0?1.15:0.85;setRollZoom(prev=>Math.max(15,Math.min(250,prev*zoomFactor)));}};const container=gridScrollRef.current;if(container){container.addEventListener('wheel',handleWheelRaw,{passive:false});}return()=>{if(container){container.removeEventListener('wheel',handleWheelRaw);}};},[]);// Alt + Scroll event listener: fast‑forward playhead + play notes
|
||
React.useEffect(()=>{const handleCanvasWheel=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const mx=e.clientX-rect.left;const my=e.clientY-rect.top;const pitch=127-Math.floor(my/NoteHeight);if(e.shiftKey){e.preventDefault();// Shift+scroll on note → change velocity of single note or all selected
|
||
const scrollBeat=mx/pixelsPerBeat-renderBeatOffset;const clickedNote=notes.find(n=>pitch===n.pitch&&scrollBeat>=n.start_beat&&scrollBeat<n.start_beat+n.duration_beats);if(clickedNote){const delta=e.deltaY<0?0.05:-0.05;if(selectedNoteIds.length>0){setNotes(prev=>prev.map(n=>selectedNoteIds.includes(n.id)?{...n,velocity:Math.max(0.05,Math.min(1,(n.velocity||0.8)+delta))}:n));}else{setNotes(prev=>prev.map(n=>n.id===clickedNote.id?{...n,velocity:Math.max(0.05,Math.min(1,(n.velocity||0.8)+delta))}:n));}}else{// Shift+scroll on empty space → horizontal scroll
|
||
const container=gridScrollRef.current;if(container)container.scrollLeft+=e.deltaY;}return;}if(e.altKey){e.preventDefault();const scrollDelta=e.deltaY;const beatSec=60.0/(parseInt(bpm)||120);const step=scrollDelta<0?-0.25:0.25;const currentBeat=(st.currentTime||0)/beatSec;const maxBeats=totalBeats;const newBeat=Math.max(0,Math.min(maxBeats,currentBeat+step));const newTime=newBeat*beatSec;setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:newTime}:s));if(window.SonicSF){const ctx=getAudioContext();const playing=notes.filter(n=>currentBeat<n.start_beat&&newBeat>=n.start_beat);var pvTrk=activeTracks.find(function(t){return t.id===st.trackId;});var pvCtx=resolveTrackInstrumentCtx(pvTrk,activeTracks);ensureSonicInstrument(pvCtx);playing.forEach(n=>{if(isStandaloneSf()&&isSfTrackEngine(pvCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(pvTrk,n.pitch,n.velocity||0.8,200,undefined,'pv_'+st.trackId);return;}if(isStandaloneSf()&&isVstTrackEngine(pvCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(pvTrk,n.pitch,n.velocity||0.8,200,undefined,'pv_'+st.trackId);return;}scheduleMidiNoteDispatch(pvTrk,n.pitch,n.velocity||0.8,200,ctx.currentTime,pvCtx.program,null,pvCtx.ch,pvCtx.synthEngine,true);});}}};const canvas=canvasRef.current;if(canvas){canvas.addEventListener('wheel',handleCanvasWheel,{passive:false});}return()=>{if(canvas){canvas.removeEventListener('wheel',handleCanvasWheel);}};},[notes,st.currentTime,pixelsPerBeat,st.id,totalBeats,bpm]);React.useLayoutEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=128*NoteHeight;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);// Scale highlight: rows thuộc scale (root+scale) sáng vàng; ngoài scale
|
||
// dim hơn (spec 20:12 — Scale Highlight & Lock)
|
||
const scHighlight=scaleWithRoot();// Draw background rows
|
||
for(let pitch=0;pitch<128;pitch++){const y=(127-pitch)*NoteHeight;const isBlack=[1,3,6,8,10].includes(pitch%12);const inScale=scHighlight?scHighlight.includes(pitch%12):null;ctx.fillStyle=isBlack?inScale===false?'#141419':'#1f1f25':inScale===false?'#1b1b20':inScale===true?'#2c2a1e':'#25252a';ctx.fillRect(0,y,viewWidth,NoteHeight);ctx.strokeStyle='#2d2d35';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(0,y+NoteHeight);ctx.lineTo(viewWidth,y+NoteHeight);ctx.stroke();}// Draw snap lines
|
||
let snapBeats=0.25;if(snapValue==='1')snapBeats=1.0;else if(snapValue==='1/2')snapBeats=0.5;else if(snapValue==='1/4')snapBeats=0.25;else if(snapValue==='1/8')snapBeats=0.125;else if(snapValue==='1/16')snapBeats=0.0625;else if(snapValue==='4')snapBeats=4.0;else if(snapValue==='1/32')snapBeats=0.03125;for(let beat=0;beat<=viewBeats;beat+=snapBeats){const x=beat*pixelsPerBeat;if(x>viewWidth)break;const isBar=beat%timeSigNum===0;ctx.strokeStyle=isBar?'#444450':'#2d2d35';ctx.lineWidth=isBar?1.2:0.6;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h);ctx.stroke();}// Determine which MIDI item is focused (clicked/selected note → its item; playing → item under playhead)
|
||
var focusedItemId=focusItemId||st.target_id;if(selectedNoteIds&&selectedNoteIds.length>0){// Most recently selected note wins: main note → opened item; dim same-track note → its item
|
||
focusedItemId=st.target_id;for(var s2=selectedNoteIds.length-1;s2>=0;s2--){var sid=selectedNoteIds[s2];if(notes.some(function(n){return n.id===sid;})){focusedItemId=st.target_id;break;}var foundGhost2=false;for(var sg2=0;sg2<ghostLayers.length;sg2++){var sl2=ghostLayers[sg2];if(!sl2.isSameTrack||!sl2.notes)continue;var gnote2=sl2.notes.find(function(g){return g.id===sid;});if(gnote2){focusedItemId=gnote2.item_id||st.target_id;foundGhost2=true;break;}}if(foundGhost2)break;}}else if(st.isPlaying&&st.currentTime!=null&&activeTracks){var curSec=st.currentTime||0;var fidxTrk=activeTracks.find(function(t){return t.id===st.trackId;});var itemsArr=fidxTrk?fidxTrk.midiItems||[]:[];for(var fi=0;fi<itemsArr.length;fi++){var fit=itemsArr[fi];var fStart=fit.startTime||0;var fEnd=fStart+(fit.duration||4);if(curSec>=fStart&&curSec<fEnd){focusedItemId=fit.id;break;}}}// Layer 2: Ghost Notes (same-track: focused item bright, siblings dim; other tracks faint)
|
||
if(showGhostNotes&&sessionSyncMode&&ghostLayers.length>0){ghostLayers.forEach(function(layer){ctx.save();var isSameTrackLayer=layer.isSameTrack;layer.notes.forEach(function(note){var isFocusedNote=isSameTrackLayer&¬e.item_id===focusedItemId;var isGhostSelected=selectedNoteIds.indexOf(note.id)!==-1;if(isSameTrackLayer){ctx.globalAlpha=isFocusedNote?0.7:0.3;ctx.fillStyle=isGhostSelected?'rgba(96, 165, 250, 0.65)':isFocusedNote?'rgba(253, 224, 71, 0.7)':'rgba(251, 191, 36, 0.3)';ctx.strokeStyle=isGhostSelected?'#60a5fa':isFocusedNote?'#fde047':'rgba(245, 158, 11, 0.6)';}else{ctx.globalAlpha=0.25;ctx.fillStyle=layer.track_color||'#888';ctx.strokeStyle=layer.track_color||'#888';}var snapStart=snapValue!=='free'?getSnapBeat(note.relative_start_beat,snapValue):note.relative_start_beat;var rawEnd=note.relative_start_beat+note.duration_beats;var snapEnd=snapValue!=='free'?getSnapBeat(rawEnd,snapValue):rawEnd;var x=(renderBeatOffset+snapStart)*pixelsPerBeat;var y=(127-note.pitch)*NoteHeight;var w=Math.max(2,(snapEnd-snapStart)*pixelsPerBeat);var h=NoteHeight-1;ctx.fillRect(x,y,w,h);if(isSameTrackLayer&&(isFocusedNote||isGhostSelected))ctx.strokeRect(x+0.5,y+0.5,w-1,h-1);});ctx.restore();});}// Layer 3: Active notes with velocity layer representation
|
||
notes.forEach(note=>{const snapStart=snapValue!=='free'?getSnapBeat(note.start_beat,snapValue):note.start_beat;const rawEnd=note.start_beat+note.duration_beats;const snapEnd=snapValue!=='free'?getSnapBeat(rawEnd,snapValue):rawEnd;const x=(renderBeatOffset+snapStart)*pixelsPerBeat;const y=(127-note.pitch)*NoteHeight;const w=Math.max(2,(snapEnd-snapStart)*pixelsPerBeat);const isSelected=selectedNoteIds.includes(note.id);// Draw background of note (focused item = brighter, siblings = dim)
|
||
const playingNow=st.isPlaying;const mainFocused=focusedItemId===st.target_id;ctx.fillStyle=isSelected?'rgba(96, 165, 250, 0.6)':mainFocused?playingNow?'rgba(254, 240, 138, 0.75)':'rgba(253, 224, 71, 0.6)':'rgba(120, 100, 40, 0.35)';ctx.strokeStyle=isSelected?'#60a5fa':mainFocused?playingNow?'#fef08a':'#fde047':'#8a7504';ctx.lineWidth=isSelected?1.5:mainFocused?1.2:0.8;ctx.fillRect(x+1,y+1,w-2,NoteHeight-2);ctx.strokeRect(x+1,y+1,w-2,NoteHeight-2);// Draw velocity layer (solid yellow/blue bar inside, proportional to velocity)
|
||
const vel=note.velocity!==undefined?note.velocity:0.8;const velW=Math.max(2,(w-2)*vel);ctx.fillStyle=isSelected?'#60a5fa':mainFocused?playingNow?'#fde047':'#facc15':'#7a6a10';ctx.fillRect(x+1,y+1,velW,NoteHeight-2);});// Draw real-time recording notes
|
||
if(recordingState==='RECORDING'&&recTempMidiNotes&&recTempMidiNotes.length>0){recTempMidiNotes.forEach(note=>{const snapStart=snapValue!=='free'?getSnapBeat(note.start_beat,snapValue):note.start_beat;const rawEnd=note.start_beat+(note.duration_beats||0.25);const snapEnd=snapValue!=='free'?getSnapBeat(rawEnd,snapValue):rawEnd;const x=(renderBeatOffset+snapStart)*pixelsPerBeat;const y=(127-note.pitch)*NoteHeight;const w=Math.max(2,(snapEnd-snapStart)*pixelsPerBeat);ctx.fillStyle='rgba(255, 100, 100, 0.35)';ctx.strokeStyle='#ff6464';ctx.lineWidth=1;ctx.fillRect(x+1,y+1,Math.max(2,w-2),NoteHeight-2);ctx.strokeRect(x+1,y+1,Math.max(2,w-2),NoteHeight-2);const vel=Math.min(1,note.velocity||0.8);ctx.fillStyle='#ff6464';ctx.fillRect(x+1,y+1,Math.max(2,(w-2)*vel),NoteHeight-2);});}// Draw selection marquee if active
|
||
if(selectionMarquee){const minBeat=Math.min(selectionMarquee.startBeat,selectionMarquee.currentBeat);const maxBeat=Math.max(selectionMarquee.startBeat,selectionMarquee.currentBeat);const minPitch=Math.min(selectionMarquee.startPitch,selectionMarquee.currentPitch);const maxPitch=Math.max(selectionMarquee.startPitch,selectionMarquee.currentPitch);const mx=minBeat*pixelsPerBeat;const my=(127-maxPitch)*NoteHeight;const mw=(maxBeat-minBeat)*pixelsPerBeat;const mh=(maxPitch-minPitch+1)*NoteHeight;ctx.fillStyle='rgba(59, 130, 246, 0.15)';ctx.strokeStyle='#3b82f6';ctx.lineWidth=1;ctx.setLineDash([4,4]);ctx.fillRect(mx,my,mw,mh);ctx.strokeRect(mx,my,mw,mh);ctx.setLineDash([]);}// Draw playhead
|
||
if(st.currentTime!==undefined&&st.currentTime!==null){const phBeat=st.currentTime/(60.0/(parseInt(bpm)||120));const phX=phBeat*pixelsPerBeat;if(phX>=0&&phX<=viewWidth){ctx.strokeStyle='#f59e0b';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(phX,0);ctx.lineTo(phX,(128-PITCH_START)*NoteHeight);ctx.stroke();}}},[notes,snapValue,rollZoom,selectedNoteIds,selectionMarquee,st.currentTime,bpm,viewWidth,viewBeats,recordingState,recTempMidiNotes,showGhostNotes,sessionSyncMode,ghostLayers,renderBeatOffset,renderTick,activeTracks,focusItemId]);// showCC/ccHeight khai báo TRƯỚC useLayoutEffect vẽ CC (deps tham chiếu —
|
||
// khai báo sau → TDZ error — user 08:50)
|
||
const[showCC,setShowCC]=React.useState(true);const[ccHeight,setCcHeight]=React.useState(80);// CC11 = GLOBAL curve tu note dau den note cuoi (khong per-note). Khi commit
|
||
// sinh lai per-note expression_curve (engine doc per-note) bang cach sample
|
||
// global tai vung tung note.
|
||
const[cc11GlobalCurve,setCc11GlobalCurve]=React.useState(()=>Array.isArray(st.cc11_global_curve)&&st.cc11_global_curve.length?st.cc11_global_curve.slice():[]);React.useEffect(()=>{setCc11GlobalCurve(Array.isArray(st.cc11_global_curve)&&st.cc11_global_curve.length?st.cc11_global_curve.slice():[]);},[st.cc11_global_curve]);React.useEffect(()=>{if(cc11GlobalCurve&&cc11GlobalCurve.length)return;const n=notes.find(x=>Array.isArray(x.expression_curve)&&x.expression_curve.length);if(n)setCc11GlobalCurve(n.expression_curve.slice());// ponytail: chi khoi phuc khi project cu co curve; chua hop nhat migration
|
||
},[notes]);// CC lane grid: vẽ cột beat/bar GIỐNG HỆT piano roll (cùng pixelsPerBeat +
|
||
// snapBeats + timeSigNum) → không lệch khi zoom. snapBeats copy nguyên từ
|
||
// notes canvas để hai lane trùng khít.
|
||
const paintCCLaneGrid=(ctx,h)=>{let snapBeats=0.25;if(snapValue==='1')snapBeats=1.0;else if(snapValue==='1/2')snapBeats=0.5;else if(snapValue==='1/4')snapBeats=0.25;else if(snapValue==='1/8')snapBeats=0.125;else if(snapValue==='1/16')snapBeats=0.0625;else if(snapValue==='4')snapBeats=4.0;else if(snapValue==='1/32')snapBeats=0.03125;for(let beat=0;beat<=viewBeats;beat+=snapBeats){const x=beat*pixelsPerBeat;if(x>viewWidth)break;const isBar=beat%timeSigNum===0;ctx.strokeStyle=isBar?'#444450':'#2d2d35';ctx.lineWidth=isBar?1.2:0.6;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h);ctx.stroke();}};// Nhan 0/+-: CC 0=min (velocity, expression...) ghi "0" o day; CC luong cuc
|
||
// (pan) ghi "+" dinh, "0" giua, "-" day.
|
||
const paintCCLaneZeroLabel=(ctx,h,bipolar)=>{ctx.fillStyle='#8b8b95';ctx.font='10px sans-serif';if(bipolar){ctx.fillText('+',5,12);ctx.fillText('0',5,h/2+3);ctx.fillText('-',5,h-5);}else{ctx.fillText('0',5,h-5);}};// Ve CC11 lane (graph thread). Tach rieng de drag ve preview truc tiep
|
||
// (truoc day setNotes tung mousemove + doc notes tu closure stale -> node
|
||
// cu/moi xen ke -> flicker). previewNoteId != null: chi ve note do voi
|
||
// previewCurve thay cho expression_curve da commit.
|
||
const paintCC11Lane=previewCurve=>{const canvas=ccCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=ccHeight;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);ctx.fillStyle='#161616';ctx.fillRect(0,0,viewWidth,h);paintCCLaneGrid(ctx,h);// CC11 0=min → ghi "0" o day (khong ve duong giua nua).
|
||
ctx.strokeStyle='#2f2f38';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h-10);ctx.lineTo(viewWidth,h-10);ctx.stroke();paintCCLaneZeroLabel(ctx,h,false);const yOf=v=>h-(v/127*(h-20)+10);if(notes.length===0)return;let minS=Infinity,maxE=-Infinity;notes.forEach(n=>{minS=Math.min(minS,n.start_beat);maxE=Math.max(maxE,n.start_beat+(n.duration_beats||1));});const x0=(renderBeatOffset+minS)*pixelsPerBeat;const x1=(renderBeatOffset+maxE)*pixelsPerBeat;// Dashed lien tuc tu note dau den note cuoi (default line)
|
||
ctx.strokeStyle='#2f6b57';ctx.lineWidth=1.5;ctx.setLineDash([4,4]);ctx.beginPath();ctx.moveTo(x0,yOf(127));ctx.lineTo(x1,yOf(127));ctx.stroke();ctx.setLineDash([]);const curve=(previewCurve||cc11GlobalCurve||[]).slice().sort((a,b)=>a.position_ratio-b.position_ratio);if(!curve.length)return;ctx.strokeStyle='#34d399';ctx.lineWidth=2;ctx.beginPath();if(curve.length===1){ctx.moveTo(x0,yOf(curve[0].value));ctx.lineTo(x1,yOf(curve[0].value));}else{curve.forEach((p,i)=>{const x=x0+p.position_ratio*(x1-x0);const y=yOf(p.value);if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);});}ctx.stroke();curve.forEach(p=>{const x=x0+p.position_ratio*(x1-x0);ctx.fillStyle='#a7f3d0';ctx.beginPath();ctx.arc(x,yOf(p.value),4,0,2*Math.PI);ctx.fill();});};React.useLayoutEffect(()=>{const canvas=ccCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=ccHeight;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);ctx.fillStyle='#161616';ctx.fillRect(0,0,viewWidth,h);paintCCLaneGrid(ctx,h);if(ccMode==='pan'){// Luong cuc: 0 o giua (+ dinh / - day) — giu nguyen nhu cu.
|
||
ctx.strokeStyle='#252525';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h/2);ctx.lineTo(viewWidth,h/2);ctx.stroke();paintCCLaneZeroLabel(ctx,h,true);}else{// 0=min: ve duong day tai gia tri 0 + nhan "0".
|
||
ctx.strokeStyle='#2f2f38';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h-10);ctx.lineTo(viewWidth,h-10);ctx.stroke();paintCCLaneZeroLabel(ctx,h,false);}if(ccMode==='cc11'){// CC11 expression: graph thread cho note dang chon; khi dang drag ve
|
||
// preview tu ccDragRef (khong phu thuoc state da commit).
|
||
const drag=ccDragRef.current;if(drag&&drag.mode==='cc11'&&drag.previewCurve){paintCC11Lane(drag.previewCurve);}else{paintCC11Lane();}return;}else{notes.forEach(note=>{const x=(renderBeatOffset+note.start_beat)*pixelsPerBeat;const isSelected=selectedNoteIds.includes(note.id);let val=note.velocity!==undefined?note.velocity:0.8;if(ccMode==='pan'){val=(note.pan!==undefined?note.pan:0.0)*0.5+0.5;}const stemH=val*(h-20)+10;const y=h-stemH;ctx.strokeStyle=ccMode==='pan'?isSelected?'#60a5fa':'#a78bfa':isSelected?'#3b82f6':'#fbbf24';ctx.lineWidth=2.5;ctx.beginPath();ctx.moveTo(x,h);ctx.lineTo(x,y);ctx.stroke();ctx.fillStyle=ccMode==='pan'?isSelected?'#3b82f6':'#c084fc':isSelected?'#3b82f6':'#fbbf24';ctx.beginPath();ctx.arc(x,y,3.5,0,2*Math.PI);ctx.fill();});}},[notes,ccMode,rollZoom,viewWidth,selectedNoteIds,renderBeatOffset,ccHeight,showCC]);// Follow playhead: khi PLAY — playhead luôn ở GIỮA view, notes trôi sang
|
||
// trái (scroll theo st.currentTime); khi STOP — scroll về đầu (playhead ở
|
||
// vị trí đầu piano roll) — user 09:40. Dùng CẢ isPlaying (main) LẪN
|
||
// st.isPlaying (piano roll play — main isPlaying=false khi tab play — bug
|
||
// 09:50: effect tưởng đang stop → luôn về đầu).
|
||
React.useEffect(function(){const wrapper=gridScrollRef.current;if(!wrapper)return;const playing=isPlaying||!!st.isPlaying;if(!playing){if(wrapper.scrollLeft!==0){wrapper.scrollLeft=0;setRenderTick(t=>t+1);}return;}const beatSec=60.0/(parseInt(bpm)||120);const phBeat=(st.currentTime||0)/beatSec;const midX=Math.max(0,wrapper.clientWidth/2);const targetLeft=Math.max(0,phBeat*pixelsPerBeat-midX);if(Math.abs(wrapper.scrollLeft-targetLeft)>1){wrapper.scrollLeft=targetLeft;}setRenderTick(t=>t+1);},[isPlaying,st.isPlaying,st.currentTime]);React.useEffect(()=>{const scrollToC3=()=>{if(gridScrollRef.current){const ch=gridScrollRef.current.clientHeight||400;gridScrollRef.current.scrollTop=Math.max(0,(127-48)*NoteHeight+NoteHeight-ch);}};scrollToC3();const timer=setTimeout(scrollToC3,100);return()=>clearTimeout(timer);},[]);// Sync ghost play data to subTab state for playback integration
|
||
React.useEffect(function(){if(!sessionSyncMode||!showGhostNotes||!ghostLayers.length){setSubTabs(function(prev){return prev.map(function(s){if(s.id!==st.id)return s;return Object.assign({},s,{ghostPlayLayers:[]});});});return;}var layers=[];ghostLayers.forEach(function(layer){var layerIsSameTrack=layer.isSameTrack;if(!layerIsSameTrack&&(!activePlayTrackIds||activePlayTrackIds.indexOf(layer.track_id)===-1))return;var trk=(activeTracks||[]).find(function(t){return t.id===layer.track_id;});layers.push({trackId:layer.track_id,notes:layer.notes.map(function(n){return{pitch:n.pitch,start_beat:n.relative_start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8};}),instrumentProgram:trk?trk.instrumentProgram:undefined,instrumentName:trk?trk.instrumentName:undefined,synthEngine:trk?trk.synth_engine:undefined});});setSubTabs(function(prev){return prev.map(function(s){if(s.id!==st.id)return s;return Object.assign({},s,{ghostPlayLayers:layers});});});},[ghostLayers,activePlayTrackIds,sessionSyncMode,showGhostNotes,st.id,activeTracks]);// Reset item focus when the opened MIDI item changes
|
||
React.useEffect(function(){setFocusItemId(st.target_id);},[st.id,st.target_id]);const handleGridMouseDown=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const beat=x/pixelsPerBeat-renderBeatOffset;const pitch=127-Math.floor(y/NoteHeight);if(scaleMenuPos)setScaleMenuPos(null);// Right click -> delete note (if on note) or prepare for sweep-drag
|
||
if(e.button===2){e.preventDefault();const clickedNote=notes.find(n=>{return pitch===n.pitch&&beat>=n.start_beat&&beat<n.start_beat+n.duration_beats;});if(clickedNote){pushToUndo(notes);setNotes(prev=>prev.filter(n=>n.id!==clickedNote.id));setSelectedNoteIds(prev=>prev.filter(id=>id!==clickedNote.id));swallowContextMenuRef.current=true;showToast('Đã xóa nốt!','info');}else{rightClickDragRef.current={active:true,startX:e.clientX,startY:e.clientY};}return;}if(e.button!==0)return;// Only handle left click
|
||
// Check if clicking on an existing note
|
||
const clickedNoteIdx=notes.findIndex(n=>{return pitch===n.pitch&&beat>=n.start_beat&&beat<n.start_beat+n.duration_beats;});// Click on note → play note with SoundFont
|
||
if(clickedNoteIdx!==-1&&!e.ctrlKey&&!e.shiftKey&&!e.altKey){if(window.SonicSF){const ctx=getAudioContext();var clTrk=activeTracks.find(function(t){return t.id===st.trackId;});var clCtx=resolveTrackInstrumentCtx(clTrk,activeTracks);ensureSonicInstrument(clCtx);if(isStandaloneSf()&&isSfTrackEngine(clCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(clTrk,notes[clickedNoteIdx].pitch,notes[clickedNoteIdx].velocity||0.8,300,undefined,'pv_'+st.trackId);}else if(isStandaloneSf()&&isVstTrackEngine(clCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(clTrk,notes[clickedNoteIdx].pitch,notes[clickedNoteIdx].velocity||0.8,300,undefined,'pv_'+st.trackId);}else{scheduleMidiNoteDispatch(clTrk,notes[clickedNoteIdx].pitch,notes[clickedNoteIdx].velocity||0.8,300,ctx.currentTime,clCtx.program,null,clCtx.ch,clCtx.synthEngine,true);}}}// Shift+click: toggle selection (multi-select) — user 09:30 (trước đây là
|
||
// Ctrl+click — Ctrl giờ dành cho COPY-drag)
|
||
if(e.shiftKey&&!e.altKey&&!e.ctrlKey){if(clickedNoteIdx!==-1){const clickedNote=notes[clickedNoteIdx];if(selectedNoteIds.includes(clickedNote.id)){setSelectedNoteIds(prev=>prev.filter(id=>id!==clickedNote.id));}else{setSelectedNoteIds(prev=>[...prev,clickedNote.id]);}return;}}// Ctrl+click NOTE + drag → COPY nhanh nhóm notes/note đến vị trí mới
|
||
// (cùng pitch ban đầu; drag đổi vị trí + pitch) — user 09:30
|
||
if(e.ctrlKey&&!e.altKey&&!e.shiftKey){if(clickedNoteIdx!==-1){const clickedNote=notes[clickedNoteIdx];const groupIds=selectedNoteIds.includes(clickedNote.id)&&selectedNoteIds.length>1?selectedNoteIds:[clickedNote.id];const src=notes.filter(n=>groupIds.includes(n.id));if(!src.length)return;pushToUndo(notes);notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));const clones=src.map(n=>({...JSON.parse(JSON.stringify(n)),id:'note_'+Date.now()+Math.random().toString(36).substr(2,8)}));setNotes(prev=>[...prev,...clones]);const cloneIds=clones.map(c=>c.id);setSelectedNoteIds(cloneIds);const cloneOffsets=clones.map(n=>({id:n.id,originalStartBeat:n.start_beat,originalPitch:n.pitch}));setDraggedNote({mode:'move',idx:-1,startOffsetBeat:beat-clickedNote.start_beat,startOffsetPitch:pitch,selectedNotesOffset:cloneOffsets,clickedOriginalStartBeat:clickedNote.start_beat});showToast('Kéo để copy '+clones.length+' nốt.','info');return;}else{// Ctrl+click on a dim (same-track) MIDI note → select it and focus its MIDI item
|
||
var ctrlGhostHit=null;for(var cgi=0;cgi<ghostLayers.length;cgi++){var cgl=ghostLayers[cgi];if(!cgl.isSameTrack||!cgl.notes)continue;for(var cgn=0;cgn<cgl.notes.length;cgn++){var cgnote=cgl.notes[cgn];if(pitch===cgnote.pitch&&beat>=cgnote.relative_start_beat&&beat<cgnote.relative_start_beat+(cgnote.duration_beats||1)){ctrlGhostHit=cgnote;break;}}if(ctrlGhostHit)break;}if(ctrlGhostHit){setFocusItemId(ctrlGhostHit.item_id||st.target_id);if(selectedNoteIds.includes(ctrlGhostHit.id)){setSelectedNoteIds(prev=>prev.filter(id=>id!==ctrlGhostHit.id));}else{setSelectedNoteIds(prev=>[...prev,ctrlGhostHit.id]);}return;}// Ctrl+click on empty space: start selection marquee
|
||
setSelectedNoteIds([]);const snapStart=getSnapBeat(beat,snapValue);setSelectionMarquee({startBeat:snapStart,startPitch:pitch,currentBeat:snapStart,currentPitch:pitch});return;}}// Ctrl+Shift+click on note → split at click position
|
||
if(e.ctrlKey&&e.shiftKey){if(clickedNoteIdx!==-1){const target=notes[clickedNoteIdx];const splitBeat=getSnapBeat(beat,snapValue);if(splitBeat>target.start_beat+0.03125&&splitBeat<target.start_beat+target.duration_beats-0.03125){pushToUndo(notes);const noteA={...JSON.parse(JSON.stringify(target)),id:'note_'+Date.now()+Math.random().toString(36).substr(2,8),duration_beats:splitBeat-target.start_beat};const noteB={...JSON.parse(JSON.stringify(target)),id:'note_'+Date.now()+Math.random().toString(36).substr(2,8),start_beat:splitBeat,duration_beats:target.start_beat+target.duration_beats-splitBeat};setNotes(prev=>{const idx=prev.findIndex(n=>n.id===target.id);if(idx===-1)return prev;const result=[...prev];result.splice(idx,1,noteA);result.splice(idx+1,0,noteB);return result;});setSelectedNoteIds([noteA.id,noteB.id]);showToast('Đã tách nốt!','info');}}else{// Ctrl+Shift+click on empty space → duplicate selected + clicked notes
|
||
pushToUndo(notes);const clones=notes.filter(n=>selectedNoteIds.includes(n.id)).map(n=>({...JSON.parse(JSON.stringify(n)),id:'note_'+Date.now()+Math.random().toString(36).substr(2,8)}));if(clones.length>0){setNotes(prev=>[...prev,...clones]);const cloneIds=clones.map(c=>c.id);setSelectedNoteIds(cloneIds);notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));const cloneOffsets=clones.map(n=>({id:n.id,originalStartBeat:n.start_beat,originalPitch:n.pitch}));setDraggedNote({mode:'move',idx:-1,startOffsetBeat:beat,startOffsetPitch:pitch,selectedNotesOffset:cloneOffsets});showToast('Đã nhân bản '+clones.length+' nốt!','info');}}return;}// Hovered resize edge (Alt+resize for scaling)
|
||
if(hoveredResizeIdx!==-1&&e.altKey){pushToUndo(notes);notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));const allSelected=[...new Set(selectedNoteIds.length>0?selectedNoteIds:[notes[hoveredResizeIdx].id])];const selectedNotes=notes.filter(n=>allSelected.includes(n.id));const firstStart=Math.min(...selectedNotes.map(n=>n.start_beat));const draggedNote=notes[hoveredResizeIdx];setDraggedNote({mode:'scale',idx:hoveredResizeIdx,originalEnd:draggedNote.start_beat+draggedNote.duration_beats,firstStart:firstStart,selectedNoteIds:allSelected});return;}// Hovered resize edge (normal resize)
|
||
if(hoveredResizeIdx!==-1){pushToUndo(notes);notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));setDraggedNote({mode:'resize',idx:hoveredResizeIdx,originalStart:notes[hoveredResizeIdx].start_beat});return;}if(clickedNoteIdx!==-1){// Click on existing note: drag-move → focus its MIDI item
|
||
setFocusItemId(st.target_id);const clickedNote=notes[clickedNoteIdx];let nextSelectedIds;if(!selectedNoteIds.includes(clickedNote.id)){nextSelectedIds=[clickedNote.id];setSelectedNoteIds(nextSelectedIds);}else{nextSelectedIds=selectedNoteIds;}pushToUndo(notes);notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));const selectedNotesOffset=notes.filter(n=>nextSelectedIds.includes(n.id)).map(n=>({id:n.id,originalStartBeat:n.start_beat,originalPitch:n.pitch}));setDraggedNote({mode:'move',idx:clickedNoteIdx,startOffsetBeat:beat-clickedNote.start_beat,startOffsetPitch:pitch,selectedNotesOffset:selectedNotesOffset,clickedOriginalStartBeat:clickedNote.start_beat});}else{// Click on a same-track ghost note → focus that MIDI item (no drawing)
|
||
if(!e.ctrlKey&&!e.shiftKey&&!e.altKey){var ghostHit=null;for(var gi=0;gi<ghostLayers.length;gi++){var gl=ghostLayers[gi];if(!gl.isSameTrack||!gl.notes)continue;for(var gn=0;gn<gl.notes.length;gn++){var gnote=gl.notes[gn];if(pitch===gnote.pitch&&beat>=gnote.relative_start_beat&&beat<gnote.relative_start_beat+(gnote.duration_beats||1)){ghostHit=gnote;break;}}if(ghostHit)break;}if(ghostHit){setFocusItemId(ghostHit.item_id||st.target_id);if(ghostHit.item_id&&ghostHit.item_id!==st.target_id){handleSwitchMidiItem(ghostHit.item_id);}return;}}// Chord stamp mode: click canvas → stamp full chord shape (spec 20:12)
|
||
if(chordStampMode){const start=getSnapBeat(beat,snapValue);applyChordStamp(start,snapToScaleRef.current?snapPitchToScale(pitch,scaleWithRoot()):pitch);return;}// Click on empty space with pen tool: DRAW a new note (brush mode with visitedPitches)
|
||
pushToUndo(notes);const start=getSnapBeat(beat,snapValue);const initialDur=lastNoteDurationRef.current||getSnapDuration(snapValue);const noteId='note_'+Date.now()+Math.random().toString(36).substr(2,5);const newNote={id:noteId,pitch:snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch,start_beat:start,duration_beats:initialDur,velocity:brushVelocityRef.current,pan:0.0};setNotes(prev=>[...prev,newNote]);setSelectedNoteIds([noteId]);setDraggedNote({mode:'draw',idx:-1,startOffsetBeat:start,startOffsetPitch:snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch,drawNoteId:noteId,drawDuration:initialDur,initialBeat:start,initialPitch:snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch,noteStartBeats:[start],lastDrawnPitch:snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch});// Play the note with SoundFont - stop previous preview first
|
||
if(previewNodesRef.current){try{previewNodesRef.current.osc.stop();}catch(e){}try{previewNodesRef.current.osc.disconnect();}catch(e){}try{previewNodesRef.current.gain.disconnect();}catch(e){}previewNodesRef.current=null;}var dwTrk=activeTracks.find(function(t){return t.id===st.trackId;});var dwCh=dwTrk?assignTrackMidiChannel(dwTrk,activeTracks):0;var dwPitch=snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch;var dwDurMs=Math.max(100,Math.round(initialDur*(60/bpm)*1000));if(isStandaloneSf()&&isSfTrackEngine(dwTrk&&dwTrk.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(dwTrk,dwPitch,brushVelocityRef.current||0.8,dwDurMs,undefined,'pvdraw_'+st.trackId);}else if(isStandaloneSf()&&isVstTrackEngine(dwTrk&&dwTrk.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(dwTrk,dwPitch,brushVelocityRef.current||0.8,dwDurMs,undefined,'pvdraw_'+st.trackId);}else if(window.SonicSF&&window.SonicSF.playNote){const ctx=getAudioContext();// playNote (FluidSynth — nhạc cụ THẬT của track). _playNoteFallback chỉ
|
||
// là oscillator beep (sai âm với percussion/soundfont — user: note vẽ
|
||
// mới nghe nhạc cụ track trước).
|
||
scheduleMidiNoteDispatch(dwTrk,dwPitch,brushVelocityRef.current||0.8,dwDurMs,ctx.currentTime,dwTrk?dwTrk.instrumentProgram:undefined,null,dwCh,dwTrk?dwTrk.synth_engine:undefined,true);}}};const handleGridMouseMove=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const beat=x/pixelsPerBeat-renderBeatOffset;const pitch=127-Math.floor(y/NoteHeight);if(selectionMarquee){const snappedBeat=getSnapBeat(beat,snapValue);const marquee={...selectionMarquee,currentBeat:snappedBeat,currentPitch:pitch};setSelectionMarquee(marquee);const minBeat=Math.min(marquee.startBeat,marquee.currentBeat);const maxBeat=Math.max(marquee.startBeat,marquee.currentBeat);const minPitch=Math.min(marquee.startPitch,marquee.currentPitch);const maxPitch=Math.max(marquee.startPitch,marquee.currentPitch);const insideIds=notes.filter(n=>{const withinPitch=n.pitch>=minPitch&&n.pitch<=maxPitch;if(!withinPitch)return false;const noteEnd=n.start_beat+n.duration_beats;if(marquee.startBeat<=marquee.currentBeat){// Left to right: select if any overlap
|
||
return n.start_beat<=maxBeat&¬eEnd>=minBeat;}else{// Right to left: select only if fully covered
|
||
return n.start_beat>=minBeat&¬eEnd<=maxBeat;}}).map(n=>n.id);setSelectedNoteIds(insideIds);return;}// Right-click drag → erase sweep
|
||
const rc=rightClickDragRef.current;if(rc.active&&(Math.abs(e.clientX-rc.startX)>5||Math.abs(e.clientY-rc.startY)>5)){rc.active=false;swallowContextMenuRef.current=true;notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));setDraggedNote({mode:'erase_sweep',visitedPitches:[]});return;}if(!draggedNote){let foundIdx=-1;for(let i=0;i<notes.length;i++){const n=notes[i];if(pitch===n.pitch){const noteRightBeat=n.start_beat+n.duration_beats;const pixelDist=Math.abs((noteRightBeat-beat)*pixelsPerBeat);if(pixelDist<6&&beat>=n.start_beat){foundIdx=i;break;}}}if(foundIdx!==-1){canvas.style.cursor='ew-resize';setHoveredResizeIdx(foundIdx);}else{canvas.style.cursor=activeRollTool==='eraser'?'pointer':'crosshair';setHoveredResizeIdx(-1);}return;}if(draggedNote.mode==='draw'){const snappedPitch=snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch;const lastPitch=draggedNote.lastDrawnPitch!==undefined?draggedNote.lastDrawnPitch:draggedNote.startOffsetPitch;const pitchChanged=snappedPitch!==lastPitch;const noteBeats=draggedNote.noteStartBeats||[];const defaultDur=getSnapDuration(snapValue);function playDrawPreview(p,durMs){stopPreviewNote();// Đồng bộ mastering + routing SF trước khi preview draw (âm qua
|
||
// mastering FX của main out khi chain bật)
|
||
try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}var pvTrk=activeTracks.find(function(t){return t.id===st.trackId;});var pvCtxInst=resolveTrackInstrumentCtx(pvTrk,activeTracks);if(isStandaloneSf()&&isSfTrackEngine(pvCtxInst.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(pvTrk,p,brushVelocityRef.current||0.8,durMs,undefined,'pvdraw_'+st.trackId);}else if(isStandaloneSf()&&isVstTrackEngine(pvCtxInst.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(pvTrk,p,brushVelocityRef.current||0.8,durMs,undefined,'pvdraw_'+st.trackId);}else if(window.SonicSF&&window.SonicSF.playNote){var pvCtx=getAudioContext();// playNote (FluidSynth — nhạc cụ THẬT). _playNoteFallback = oscillator
|
||
// beep sai âm (percussion/soundfont).
|
||
scheduleMidiNoteDispatch(pvTrk,p,brushVelocityRef.current||0.8,durMs,pvCtx.currentTime,pvCtxInst.program,null,pvCtxInst.ch,pvCtxInst.synthEngine,true);previewPitchRef.current=p;}}if(pitchChanged){const brushIds=draggedNote.brushIds||[];if(brushIds.length>0&¬eBeats.length>0){const prevNoteId=brushIds[brushIds.length-1];const prevNoteBeat=noteBeats[noteBeats.length-1];const prevDur=Math.max(0.125,beat-prevNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==prevNoteId)return n;return{...n,duration_beats:prevDur};}));}const newNote={id:'note_'+Date.now()+Math.random().toString(36).substr(2,8)+'_'+(noteBeats.length+1),pitch:snappedPitch,start_beat:beat,duration_beats:defaultDur,velocity:brushVelocityRef.current,pan:0.0};setNotes(prev=>[...prev,newNote]);setSelectedNoteIds(prev=>[...prev,newNote.id]);draggedNote.brushIds=[...brushIds,newNote.id];draggedNote.lastDrawnPitch=snappedPitch;draggedNote.noteStartBeats=[...noteBeats,beat];playDrawPreview(snappedPitch,Math.max(100,Math.round(defaultDur*(60/bpm)*1000)));}else{const brushIds=draggedNote.brushIds||[];const lastBrushId=brushIds.length>0?brushIds[brushIds.length-1]:draggedNote.drawNoteId;const lastNoteBeat=noteBeats.length>0?noteBeats[noteBeats.length-1]:draggedNote.startOffsetBeat;if(lastBrushId){const extDur=Math.max(0.125,beat-lastNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==lastBrushId)return n;return{...n,duration_beats:extDur};}));playDrawPreview(snappedPitch,Math.max(100,Math.round(extDur*(60/bpm)*1000)));}}const container=gridScrollRef.current;if(container){const cr=container.getBoundingClientRect();const visTop=container.scrollTop;const visBot=visTop+container.clientHeight;const pitchPixel=(127-snappedPitch)*NoteHeight;const safeMargin=NoteHeight*2;if(pitchPixel<visTop+safeMargin){const target=Math.max(0,pitchPixel-safeMargin);if(container.scrollTop!==target)container.scrollTop=target;if(!brushAutoScrollRef.current||brushAutoScrollRef.current.direction!=='up'){if(brushAutoScrollRef.current)clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current={direction:'up',id:setInterval(()=>{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.max(0,gridScrollRef.current.scrollTop-Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else if(pitchPixel+NoteHeight>visBot-safeMargin){const target=Math.min(container.scrollHeight-container.clientHeight,pitchPixel-container.clientHeight+safeMargin+NoteHeight);if(container.scrollTop!==target)container.scrollTop=target;if(!brushAutoScrollRef.current||brushAutoScrollRef.current.direction!=='down'){if(brushAutoScrollRef.current)clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current={direction:'down',id:setInterval(()=>{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.min(gridScrollRef.current.scrollHeight-gridScrollRef.current.clientHeight,gridScrollRef.current.scrollTop+Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else{if(brushAutoScrollRef.current){clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current=null;}}}return;}if(draggedNote.mode==='erase_sweep'){const erased=draggedNote.erasedIds||[];const target=notes.find(n=>n.pitch===pitch&&beat>=n.start_beat&&beat<n.start_beat+n.duration_beats);if(target&&!erased.includes(target.id)){draggedNote.erasedIds=[...erased,target.id];setNotes(prev=>prev.filter(n=>n.id!==target.id));setSelectedNoteIds(prev=>prev.filter(id=>id!==target.id));}return;}if(draggedNote.mode==='scale'){const newEnd=getSnapBeat(Math.max(draggedNote.firstStart+0.125,beat),snapValue);const range=draggedNote.originalEnd-draggedNote.firstStart;if(range<=0)return;const scaleFactor=Math.max(0.01,(newEnd-draggedNote.firstStart)/range);const ids=draggedNote.selectedNoteIds||[];const firstStart=draggedNote.firstStart;const notesBefore=notesBeforeDragRef.current;if(!notesBefore)return;setNotes(prev=>prev.map(n=>{if(!ids.includes(n.id))return n;const orig=notesBefore?notesBefore.find(o=>o.id===n.id):null;const origStart=orig?orig.start_beat:n.start_beat;const origDur=orig?orig.duration_beats:n.duration_beats;const relStart=origStart-firstStart;const relEnd=relStart+origDur;return{...n,start_beat:firstStart+relStart*scaleFactor,duration_beats:Math.max(0.125,relEnd*scaleFactor-relStart*scaleFactor)};}));return;}if(draggedNote.mode==='resize'){const newDuration=getSnapBeat(Math.max(0.125,beat-draggedNote.originalStart),snapValue);setNotes(prev=>prev.map((n,idx)=>{if(idx!==draggedNote.idx)return n;return{...n,duration_beats:newDuration};}));}else if(draggedNote.mode==='move'){const refOrigStart=draggedNote.clickedOriginalStartBeat;if(refOrigStart===undefined)return;const deltaBeat=getSnapBeat(beat-draggedNote.startOffsetBeat,snapValue)-refOrigStart;// Clamp so no note goes past beat 0
|
||
const minOrigStart=Math.min(...draggedNote.selectedNotesOffset.map(o=>o.originalStartBeat));const clampedDeltaBeat=minOrigStart+deltaBeat<0?-minOrigStart:deltaBeat;const deltaPitch=Math.round(pitch-draggedNote.startOffsetPitch);setNotes(prev=>prev.map(n=>{const offset=draggedNote.selectedNotesOffset.find(o=>o.id===n.id);if(!offset)return n;return{...n,start_beat:getSnapBeat(Math.max(0,offset.originalStartBeat+clampedDeltaBeat),snapValue),pitch:Math.max(0,Math.min(127,offset.originalPitch+deltaPitch))};}));}};const handleGridMouseUp=()=>{if(draggedNote&&draggedNote.mode==='draw'){const dn=draggedNote;const brushIds=dn.brushIds||[];const lastBrushId=brushIds.length>0?brushIds[brushIds.length-1]:dn.drawNoteId;if(lastBrushId){const lastNote=notes.find(n=>n.id===lastBrushId);if(lastNote)lastNoteDurationRef.current=lastNote.duration_beats;}}setDraggedNote(null);// Marquee (Ctrl+drag): CHỌN notes nằm trong vùng (user 09:15 — trước đây
|
||
// chỉ vẽ highlight, không set selectedNoteIds → Ctrl+X báo "chọn notes")
|
||
if(selectionMarquee){const minBeat=Math.min(selectionMarquee.startBeat,selectionMarquee.currentBeat);const maxBeat=Math.max(selectionMarquee.startBeat,selectionMarquee.currentBeat);const minPitch=Math.min(selectionMarquee.startPitch,selectionMarquee.currentPitch);const maxPitch=Math.max(selectionMarquee.startPitch,selectionMarquee.currentPitch);const inMarquee=notes.filter(n=>{const center=n.start_beat+n.duration_beats/2;return center>=minBeat&¢er<=maxBeat&&n.pitch>=minPitch&&n.pitch<=maxPitch;});setSelectedNoteIds(inMarquee.map(n=>n.id));}setSelectionMarquee(null);rightClickDragRef.current={active:false,startX:0,startY:0};if(brushAutoScrollRef.current){clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current=null;}stopPreviewNote();previewPitchRef.current=null;};const handleContextMenu=e=>{e.preventDefault();if(swallowContextMenuRef.current){swallowContextMenuRef.current=false;return;}const pos={x:e.clientX,y:e.clientY,parentKey:null};scaleMenuOriginRef.current={x:pos.x,y:pos.y};setScaleMenuPos(pos);};const ccDragRef=React.useRef(null);const brushAutoScrollRef=React.useRef(null);const rightClickDragRef=React.useRef({active:false,startX:0,startY:0});const swallowContextMenuRef=React.useRef(false);// Status hint động (user 09:40): theo dõi Shift/Ctrl + mouse trong piano roll
|
||
const prKeyStateRef=React.useRef({shift:false,ctrl:false});const prMouseInRef=React.useRef(false);React.useEffect(function(){const updateHint=function(){if(!window.__setPrHint||!prMouseInRef.current)return;const ks=prKeyStateRef.current;window.__setPrHint(ks.ctrl?"Ctrl+drag: fast copy notes | Ctrl+click empty: marquee chọn notes":ks.shift?"Shift+Click: select/unselect notes | Shift+Click note: thêm/bớt vào nhóm chọn":"Scroll: Up/Down | Drag: Draw notes");};const kd=function(e){const ks=prKeyStateRef.current;if(e.shiftKey!==ks.shift||e.ctrlKey!==ks.ctrl){ks.shift=e.shiftKey;ks.ctrl=e.ctrlKey;updateHint();}};const ku=function(e){const ks=prKeyStateRef.current;if(e.shiftKey!==ks.shift||e.ctrlKey!==ks.ctrl){ks.shift=e.shiftKey;ks.ctrl=e.ctrlKey;updateHint();}};window.addEventListener('keydown',kd);window.addEventListener('keyup',ku);return function(){window.removeEventListener('keydown',kd);window.removeEventListener('keyup',ku);};},[]);const findCCNoteIndex=(b,mouseY,ccH)=>{const snapped=getSnapBeat(b,snapValue);const hits=[];notes.forEach((n,idx)=>{if(snapped>=n.start_beat&&snapped<=n.start_beat+n.duration_beats){const nv=ccMode==='pan'?(n.pan||0)*0.5+0.5:n.velocity!==undefined?n.velocity:0.8;const stemTop=ccH-(nv*(ccH-20)+10);hits.push({idx,dist:Math.abs(stemTop-mouseY)});}});if(hits.length>0){hits.sort((a,b)=>a.dist-b.dist);return hits[0].idx;}let nearest=-1;let minDist=Infinity;notes.forEach((n,idx)=>{const center=n.start_beat+n.duration_beats/2;const d=Math.abs(center-snapped);if(d<minDist){minDist=d;nearest=idx;}});return nearest;};// TẤT CẢ notes bắt đầu tại cùng beat (chord — velocity bars CÙNG cột) —
|
||
// user 08:25: ctrl+draw qua vị trí → vẽ lại TẤT CẢ velocity cùng vị trí
|
||
// (trước đây findCCNoteIndex chỉ trả 1 note → chord khó draw).
|
||
const findCCNoteIndicesAtBeat=b=>{const snapped=getSnapBeat(b,snapValue);const out=[];notes.forEach((n,idx)=>{if(Math.abs(n.start_beat-snapped)<0.01)out.push(idx);});return out;};// ── CC11 EXPRESSION CURVE (piano roll lane) ──
|
||
const cc11ValueAt=(curve,ratio)=>{if(!curve||!curve.length)return 127;if(curve.length===1)return curve[0].value;const pts=curve.slice().sort((a,b)=>a.position_ratio-b.position_ratio);if(ratio<=pts[0].position_ratio)return pts[0].value;if(ratio>=pts[pts.length-1].position_ratio)return pts[pts.length-1].value;for(let i=1;i<pts.length;i++){if(ratio<=pts[i].position_ratio){const a=pts[i-1],b=pts[i];const t=(ratio-a.position_ratio)/(b.position_ratio-a.position_ratio||1);return Math.round(a.value+(b.value-a.value)*t);}}return pts[pts.length-1].value;};const cc11GlobalRange=()=>{const cur=notesRef.current;if(!cur.length)return null;let minS=Infinity,maxE=-Infinity;cur.forEach(n=>{minS=Math.min(minS,n.start_beat);maxE=Math.max(maxE,n.start_beat+(n.duration_beats||1));});return{minS,maxE,span:maxE-minS||1};};// GLOBAL curve -> per-note expression_curve: sample global tai vung tung note
|
||
const commitCC11Curve=curve=>{setCc11GlobalCurve(curve);const curNotes=notesRef.current;const g=cc11GlobalRange();if(!g){setNotes(curNotes);return;}const updatedNotes=curNotes.map(n=>{const s=n.start_beat;const e=s+(n.duration_beats||1);const pts=[];const add=(ratio,value)=>pts.push({position_ratio:ratio,value});add(0,cc11ValueAt(curve,(s-g.minS)/g.span));(curve||[]).forEach(p=>{const b=g.minS+p.position_ratio*g.span;if(b>s&&b<e)add((b-s)/(e-s),p.value);});add(1,cc11ValueAt(curve,(e-g.minS)/g.span));return{...n,expression_curve:pts.sort((a,b)=>a.position_ratio-b.position_ratio)};});setNotes(updatedNotes);// Đồng bộ subTab NGAY (không chờ effect): schedulePianoRollMidi đọc
|
||
// st.notes khi Play (realtime) + cc11_global_curve để persist save/load.
|
||
if(setSubTabs)setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,notes:updatedNotes,cc11_global_curve:curve,isDirty:true}:s));if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);};const handleCC11MouseDown=e=>{const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const g=cc11GlobalRange();if(!g||beat<g.minS||beat>g.maxE){ccDragRef.current=null;return;}const ratio=Math.max(0,Math.min(1,(beat-g.minS)/g.span));const val=Math.max(0,Math.min(127,Math.round((h-y)/h*127)));const curve=(cc11GlobalCurve||[]).slice();const x0=(renderBeatOffset+g.minS)*pixelsPerBeat;const x1=(renderBeatOffset+g.maxE)*pixelsPerBeat;const yOf=v=>h-(v/127*(h-20)+10);let anchorIdx=-1;curve.forEach((p,i)=>{const ax=x0+p.position_ratio*(x1-x0);const ay=yOf(p.value);if(Math.hypot(ax-x,ay-y)<=8)anchorIdx=i;});if(anchorIdx===-1){// Click trong: THEM node tai do ngay lap tuc — khong keo node moi
|
||
const dup=curve.findIndex(p=>Math.abs(p.position_ratio-ratio)<1e-6);let newCurve;if(dup!==-1){newCurve=curve.slice();newCurve[dup]={position_ratio:ratio,value:val};}else{newCurve=curve.concat([{position_ratio:ratio,value:val}]).sort((a,b)=>a.position_ratio-b.position_ratio);}commitCC11Curve(newCurve);ccDragRef.current=null;return;}curve[anchorIdx]={position_ratio:ratio,value:val};// Khong setNotes ngay: ve preview truc tiep, commit khi mouseup/mouseleave
|
||
ccDragRef.current={mode:'cc11',active:true,anchorIdx,gs:g.minS,ge:g.maxE,span:g.span,previewCurve:curve};paintCC11Lane(curve);};const handleCC11ContextMenu=e=>{if(ccMode!=='cc11')return;const canvas=ccCanvasRef.current;if(!canvas)return;e.preventDefault();const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const g=cc11GlobalRange();if(!g||beat<g.minS||beat>g.maxE)return;const x0=(renderBeatOffset+g.minS)*pixelsPerBeat;const x1=(renderBeatOffset+g.maxE)*pixelsPerBeat;const yOf=v=>h-(v/127*(h-20)+10);const curve=(cc11GlobalCurve||[]).slice();let delIdx=-1,delDist=Infinity;curve.forEach((p,i)=>{const ax=x0+p.position_ratio*(x1-x0);const ay=yOf(p.value);const d=Math.hypot(ax-x,ay-y);if(d<delDist){delDist=d;delIdx=i;}});if(delIdx===-1||delDist>8)return;curve.splice(delIdx,1);commitCC11Curve(curve);};// Commit CC11 preview khi mouseup/mouseleave — truoc day drag setNotes moi
|
||
// mousemove (flicker); gio chi cap nhat state 1 lan khi ket thuc drag.
|
||
const commitCC11Drag=()=>{const drag=ccDragRef.current;if(!drag)return;if(drag.mode==='cc11'&&drag.previewCurve){commitCC11Curve(drag.previewCurve);}ccDragRef.current=null;};const handleCCMouseDown=e=>{if(ccMode==='cc11'){handleCC11MouseDown(e);return;}const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const noteIdx=findCCNoteIndex(beat,y,h);const val=Math.max(0,Math.min(1,(h-y)/h));if(e.ctrlKey){if(selectedNoteIds.length>0){const cursorNoteIdx=findCCNoteIndex(beat,y,h);if(cursorNoteIdx!==-1&&selectedNoteIds.includes(notes[cursorNoteIdx]?notes[cursorNoteIdx].id:-1)){const currentNote=notes[cursorNoteIdx];const currentVal=ccMode==='pan'?(currentNote.pan||0)/2.0+0.5:currentNote.velocity!==undefined?currentNote.velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==cursorNoteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}ccDragRef.current={active:true,lastBeat:beat,selectedMode:true,lastPainted:[cursorNoteIdx]};}else{ccDragRef.current={active:true,lastBeat:beat,selectedMode:true,lastPainted:[]};}}else{// Không chọn notes: vẽ TẤT CẢ notes cùng beat (chord — user 08:25)
|
||
const idxs=findCCNoteIndicesAtBeat(beat);ccDragRef.current={active:true,lastBeat:beat,lastPainted:idxs};}return;}if(noteIdx!==-1){const currentVal=ccMode==='pan'?(notes[noteIdx].pan||0)/2.0+0.5:notes[noteIdx].velocity!==undefined?notes[noteIdx].velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==noteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}}};const handleCCMouseMove=e=>{if(!ccDragRef.current||!ccDragRef.current.active)return;if(ccDragRef.current.mode==='cc11'){const drag=ccDragRef.current;const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const ratio=Math.max(0,Math.min(1,(beat-drag.gs)/drag.span));const val=Math.max(0,Math.min(127,Math.round((h-y)/h*127)));const curve=(drag.previewCurve||[]).slice();const idx=Math.min(drag.anchorIdx,curve.length-1);const cur=curve[idx]||{position_ratio:ratio,value:val};// Drag node: doi ca ratio+value, clamp giua 2 node lan can
|
||
let minR=0,maxR=1;for(let i=0;i<curve.length;i++){if(i===idx)continue;const r=curve[i].position_ratio;if(r<cur.position_ratio&&r>minR)minR=r;if(r>cur.position_ratio&&r<maxR)maxR=r;}curve[idx]={position_ratio:Math.min(maxR,Math.max(minR,ratio)),value:val};drag.previewCurve=curve;paintCC11Lane(curve);return;}const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const val=Math.max(0,Math.min(1,(h-y)/h));const drag=ccDragRef.current;const painted=drag.lastPainted||[];if(drag.selectedMode&&selectedNoteIds.length>0){const cursorNoteIdx=findCCNoteIndex(beat,y,h);if(cursorNoteIdx!==-1){const cursorNote=notes[cursorNoteIdx];if(cursorNote&&selectedNoteIds.includes(cursorNote.id)&&!painted.includes(cursorNoteIdx)){const currentVal=ccMode==='pan'?(cursorNote.pan||0)/2.0+0.5:cursorNote.velocity!==undefined?cursorNote.velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==cursorNoteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}drag.lastPainted=[...painted,cursorNoteIdx];}}return;}// Vẽ TẤT CẢ notes cùng beat (chord — user 08:25) — trước đây chỉ 1 note
|
||
const candidateIdxs=findCCNoteIndicesAtBeat(beat);const unpainted=candidateIdxs.filter(ci=>!painted.includes(ci));if(unpainted.length>0){const updatedNotes=notes.map((n,i)=>{if(!unpainted.includes(i))return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);drag.lastPainted=[...painted,...unpainted];}};const renderKeybed=()=>{var kbTrk=activeTracks.find(function(t){return t.id===st.trackId;});var kbCtx=resolveTrackInstrumentCtx(kbTrk,activeTracks);ensureSonicInstrument(kbCtx);// V45.20 Bug 6: renderKeybed CHI chay trong PianoRollTabEditor (st.type === 'PIANO_ROLL'
|
||
// -> luon la PR tab) nen _kbSilent luon false. Bo refs ngoai scope (subTabsRef/
|
||
// activeTabRef) gay ReferenceError render crash -> piano roll trang. NOTE_OFF
|
||
// van gui (vo hai) nhu V45.19.
|
||
const _kbSilent=false;const keys=[];for(let pitch=127;pitch>=PITCH_START;pitch--){const isBlack=[1,3,6,8,10].includes(pitch%12);const notesArray=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;const label=`${notesArray[pitch%12]}${octave}`;const showLabel=pitch%12===0;keys.push(/*#__PURE__*/React.createElement("div",{key:pitch,style:{height:`${NoteHeight}px`},className:`w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${activeMidiPitches&&activeMidiPitches.has(pitch)?'bg-emerald-500 text-white border-emerald-400':isBlack?'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800':'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,onMouseDown:e=>{e.stopPropagation();keybedMouseDownRef.current=true;try{// Đồng bộ mastering + routing SF NGAY trước khi preview — âm
|
||
// keybed PHẢI qua mastering FX của main out khi chain đang bật
|
||
// (trước đây phải bật/tắt power mới có tác dụng).
|
||
try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}// DIAG Bug 2 (keybed ARM khong am): routing + ARM state tai note-on.
|
||
try{let _pdDest='none';if(window.AudioRoutingEngine&&window.AudioRoutingEngine._connected){_pdDest=window.AudioRoutingEngine._dest&&window.AudioRoutingEngine._dest.constructor?window.AudioRoutingEngine._dest.constructor.name:'null';_pdDest+='/'+window.AudioRoutingEngine._trackId;}else{_pdDest='NOT_CONNECTED';}const _pdD=window.BridgeAudioNode?window.BridgeAudioNode.getDiag():null;const _pdTab=activeTabRef.current;const _pdSub=subTabsRef.current?(subTabsRef.current.find(s=>s.id===_pdTab)||{}).type:null;const _kbArmedTab=!!(subTabsRef.current||[]).some(function(s){return s.type==='PIANO_ROLL'&&s.isArmed&&s.trackId===st.trackId;});_dbgLog('preview_diag','src=keybed trk='+st.trackId+' kbTrk='+(kbTrk?kbTrk.id:'UNDEF')+' kbArmedTab='+(_kbArmedTab?1:0)+' armedTrk='+(kbTrk&&kbTrk.isArmed?1:0)+' sl='+(_kbSilent?1:0)+' tab='+_pdTab+' sub='+(_pdSub||'none')+' play='+(isPlayingRef.current?1:0)+' br='+(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()?1:0)+' fxRt='+(fxRt.active?1:0)+' dest='+_pdDest+' bd='+(_pdD?'i='+_pdD.initialized+' wl='+_pdD.useWorklet+' nd='+_pdD.hasWorkletNode+' pre='+_pdD.preBufferLen+' ch='+_pdD.chunksLen+' g='+_pdD.gain+' ws='+(_pdD.ws?_pdD.ws.overruns+'/'+_pdD.ws.underruns:'-'):'MISSING')+' mb='+!!masterBus);}catch(e){}// V45.19 Bug 2: _kbSilent -> bo qua phat (MAIN/SECTION + ARM off = cam).
|
||
if(!_kbSilent){try{window.__dbgPreviewAudioArmed={count:0,nz:0,t0:performance.now()};}catch(e){}if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}const kbNative=isStandaloneSf()&&isSfTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());const kbNativeVst=isStandaloneSf()&&isVstTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if(kbNative){playNativeSfNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}else if(kbNativeVst){playNativeVstNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(kbTrk?kbTrk.id:0,kbCtx.program,kbCtx.synthEngine);}catch(er){}window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:100/127,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&!kbNative&&!kbNativeVst){// ⚠️ FIX: giữ note theo thời gian bấm phím — durationMs lớn (5s)
|
||
// chỉ là auto-off phòng hờ; mouseup/mouseleave gọi stopNote dừng
|
||
// NGAY (trước đây 500ms → note tự tắt giữa chừng khi giữ phím).
|
||
window.SonicSF.playNote(pitch,100,5000,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}}catch(err){console.error('playNote error:',err);}},onMouseEnter:e=>{if(keybedMouseDownRef.current&&!_kbSilent){try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(isStandaloneSf()&&isSfTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}else if(isStandaloneSf()&&isVstTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(kbTrk?kbTrk.id:0,kbCtx.program,kbCtx.synthEngine);}catch(er){}window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:100/127,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&!(isStandaloneSf()&&(isSfTrackEngine(kbCtx.synthEngine)||isVstTrackEngine(kbCtx.synthEngine)))){// giữ note khi kéo qua phím (mouse enter) — dừng bằng mouseup/leave
|
||
window.SonicSF.playNote(pitch,100,5000,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}}},onMouseUp:()=>{keybedMouseDownRef.current=false;// Dừng note khi thả phím — tránh kẹt âm (loop liên tục) với soundfont
|
||
try{if(isStandaloneSf()){stopNativeSfNote('kb_'+st.trackId+'_'+pitch);stopNativeVstNote('kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:0,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&window.SonicSF.stopNote)window.SonicSF.stopNote(kbCtx.ch,pitch);}catch(e){}},onMouseLeave:()=>{if(!keybedMouseDownRef.current)return;// Kéo chuột ra khỏi phím → dừng note của phím đó
|
||
try{if(isStandaloneSf()){stopNativeSfNote('kb_'+st.trackId+'_'+pitch);stopNativeVstNote('kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:0,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&window.SonicSF.stopNote)window.SonicSF.stopNote(kbCtx.ch,pitch);}catch(e){}}},showLabel&&label));}return keys;};const handleScroll=e=>{if(keybedRef.current){keybedRef.current.scrollTop=e.currentTarget.scrollTop;}if(rulerScrollRef.current){rulerScrollRef.current.scrollLeft=e.currentTarget.scrollLeft;}if(ccWrapperRef.current){ccWrapperRef.current.scrollLeft=e.currentTarget.scrollLeft;}const el=e.currentTarget;const THRESHOLD=200;if(el.scrollLeft+el.clientWidth>=el.scrollWidth-THRESHOLD){const newBeats=rollBeatsRef.current+16;setRollBeats(newBeats);}};const handleKeybedScroll=e=>{if(gridScrollRef.current){gridScrollRef.current.scrollTop=e.currentTarget.scrollTop;}};const SCALES={"None":null,"Diatonic":{"Major":[0,2,4,5,7,9,11],"Minor":[0,2,3,5,7,8,10],"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10],"Locrian":[0,1,3,5,6,8,10]},"Pentatonic":{"Major":[0,2,4,7,9],"Minor":[0,3,5,7,10],"Blues":[0,3,5,6,7,10],"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"India":[0,2,5,7,9],"Japan":[0,2,5,7,9],"Africa":[0,3,5,7,10]},"Church":{"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10]},"Jazz":{"Blues":[0,3,5,6,7,10],"Bebop":[0,2,4,5,7,9,10,11],"Diminished":[0,2,3,5,6,8,9,11]},"Asian Traditional":{"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"Japan":[0,2,5,7,9],"India":[0,2,5,7,9],"Gamelan":[0,2,4,7,9],"Korea":[0,2,4,5,7,9]},"Middle Eastern":{"Hijaz":[0,1,4,5,7,8,11],"Nikriz":[0,1,4,5,7,8,10],"Rast":[0,2,4,5,7,8,10],"Saba":[0,1,3,4,7,8,10],"Bayati":[0,2,3,4,7,8,10]}};const[selectedScale,setSelectedScale]=React.useState(null);const selectedScaleRef=React.useRef(null);selectedScaleRef.current=selectedScale;// Root note (0-11 — C..B) — transpose scale highlight + snap (spec 20:12)
|
||
const[scaleRoot,setScaleRoot]=React.useState(0);const scaleRootRef=React.useRef(0);scaleRootRef.current=scaleRoot;// Modal states: Arpeggiator / Strummer / Humanize (spec 20:12)
|
||
const[arpModal,setArpModal]=React.useState(null);// { pattern, rate, octaves, gate }
|
||
const[strumModal,setStrumModal]=React.useState(null);// { ms, direction }
|
||
const[humanizeModal,setHumanizeModal]=React.useState(null);// { timingMs, velRange, durRange }
|
||
const[chordType,setChordType]=React.useState('triad');// chord stamp (spec)
|
||
const[chordStampMode,setChordStampMode]=React.useState(false);// stamp mode toggle
|
||
const[velocityTarget,setVelocityTarget]=React.useState(80);// compress target (spec)
|
||
const snapToScaleRef=React.useRef(true);snapToScaleRef.current=st.snapToScale!==undefined?st.snapToScale:true;const[scaleMenuPos,setScaleMenuPos]=React.useState(null);const scaleMenuOriginRef=React.useRef(null);const snapPitchToScale=(pitch,scale)=>{if(!scale)return pitch;const octave=Math.floor(pitch/12);const noteInOctave=pitch%12;if(scale.includes(noteInOctave))return pitch;let best=noteInOctave;let minDist=12;scale.forEach(s=>{const dist=Math.abs(s-noteInOctave);if(dist<minDist){minDist=dist;best=s;}});return octave*12+best;};// ── MIDI Transform Engine (spec 20:12 — direct integration) ──
|
||
const scaleWithRoot=()=>{const sc=selectedScaleRef.current;if(!sc)return null;return sc.map(s=>(s+scaleRootRef.current)%12);};const commitNotes=updated=>{setNotes(updated);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updated);};const applyArpeggiate=p=>{const sc=scaleWithRoot();const beatSec=60.0/(parseInt(bpm)||120);const rateDiv=p.rate==='1/4'?1:p.rate==='1/8'?0.5:p.rate==='1/16'?0.25:p.rate==='1/32'?0.125:1;const rateBeats=rateDiv*(p.triplet?2/3:p.dotted?1.5:1);const base=notes.filter(n=>selectedNoteIds.includes(n.id));const targets=base.length>0?base:notes;if(!targets.length)return;const chords=[];targets.forEach(n=>{const k=n.start_beat.toFixed(2);const g=chords.find(c=>c.k===k);if(g)g.notes.push(n);else chords.push({k,notes:[n]});});const result=[];const rangeNotes=[];// arpeggiated sequence (pitch per step)
|
||
chords.forEach(ch=>{const sorted=ch.notes.slice().sort((a,b)=>a.pitch-b.pitch);const notesOut=[];for(let o=0;o<p.octaves;o++){sorted.forEach(n=>{const octPitch=n.pitch+o*12;if(!rangeNotes.includes(octPitch))rangeNotes.push(octPitch);});}const seq=[];rangeNotes.splice(0,rangeNotes.length);chords.forEach(()=>{});const sortedAsc=ch.notes.slice().sort((a,b)=>a.pitch-b.pitch);const sortedDesc=sortedAsc.slice().reverse();const pool=p.pattern==='DOWN'?sortedDesc:p.pattern==='UP-DOWN'?[...sortedAsc,...sortedDesc.slice(1,-1)]:p.pattern==='RANDOM'?sortedAsc.slice().sort(()=>Math.random()-0.5):sortedAsc;// UP / CHORD
|
||
const steps=p.pattern==='CHORD'?1:pool.length*p.octaves;const seqPitches=[];for(let i=0;i<steps;i++){if(p.pattern==='CHORD'){pool.forEach(n=>seqPitches.push(n.pitch));break;}seqPitches.push(pool[i%pool.length].pitch+Math.floor(i/pool.length)*12);}const stepDur=p.pattern==='CHORD'?rateBeats*pool.length:rateBeats;const total=seqPitches.length*stepDur;seqPitches.forEach((pitch,i)=>{const dur=stepDur*(p.gate/100);const vel=ch.notes[0]?ch.notes[0].velocity:0.8;notesOut.push({id:'note_'+Math.random().toString(36).substr(2,9),pitch,start_beat:ch.notes[0].start_beat+i*stepDur,duration_beats:Math.max(0.05,dur),velocity:vel});});result.push(...notesOut);});const others=notes.filter(n=>!targets.includes(n));commitNotes([...others,...result].sort((a,b)=>a.start_beat-b.start_beat));setArpModal(null);showToast('Đã arpeggiate '+targets.length+' nốt.','success');};const applyStrum=p=>{const secPerBeat=60.0/(parseInt(bpm)||120);const strumBeats=p.ms/1000.0/secPerBeat;const base=notes.filter(n=>selectedNoteIds.includes(n.id));const targets=base.length>0?base:notes;if(!targets.length)return;const groups=[];targets.forEach(n=>{const k=n.start_beat.toFixed(2);const g=groups.find(g2=>g2.k===k);if(g)g.notes.push(n);else groups.push({k,notes:[n]});});let alternateFlip=false;const result=targets.map(n=>({...n}));groups.forEach(g=>{const sorted=g.notes.slice().sort((a,b)=>a.pitch-b.pitch);const asc=p.direction==='UP'?sorted.slice().reverse():p.direction==='ALTERNATE'?alternateFlip?sorted.slice().reverse():sorted.slice():sorted;alternateFlip=!alternateFlip;const firstStart=sorted[0].start_beat;asc.forEach((note,index)=>{result.forEach(r=>{if(r.id===note.id){r.start_beat=parseFloat((firstStart+index*strumBeats).toFixed(3));r.velocity=Math.max(0.1,Math.min(1.0,parseFloat((r.velocity-index*0.03).toFixed(2))));}});});});commitNotes(result);setStrumModal(null);showToast('Đã strum '+targets.length+' nốt.','success');};const applyHumanizeModal=p=>{const spb=60.0/(parseInt(bpm)||120);const maxJitter=p.timingMs/1000.0/spb;const base=notes.filter(n=>selectedNoteIds.includes(n.id));const targets=base.length>0?base:notes;if(!targets.length)return;const result=targets.map(n=>{const tj=(Math.random()-0.5)*2*maxJitter;const vj=(Math.random()-0.5)*2*(p.velRange/127);const dj=p.durRange?(Math.random()-0.5)*2*(p.durRange/100):0;return{...n,start_beat:Math.max(0,parseFloat((n.start_beat+tj).toFixed(3))),velocity:Math.max(0.05,Math.min(1.0,parseFloat((n.velocity+vj).toFixed(2)))),duration_beats:Math.max(0.05,parseFloat((n.duration_beats*(1+dj)).toFixed(3)))};});const others=notes.filter(n=>!targets.includes(n));commitNotes([...others,...result]);setHumanizeModal(null);showToast('Đã humanize '+targets.length+' nốt.','success');};const applyForceToScale=()=>{const sc=scaleWithRoot();if(!sc){showToast('Chưa chọn scale (nhấp chuột phải chọn Scale).','warning');return;}const base=notes.filter(n=>selectedNoteIds.includes(n.id));const targets=base.length>0?base:notes;const result=targets.map(n=>({...n,pitch:snapPitchToScale(n.pitch,sc)}));const others=notes.filter(n=>!targets.includes(n));commitNotes([...others,...result]);showToast('Đã force '+targets.length+' nốt về scale.','success');};const CHORD_SHAPES={triad:[0,4,7],min7:[0,3,7,10],sus4:[0,5,7],add9:[0,4,7,14]};const applyChordStamp=(beat,pitch)=>{const shape=CHORD_SHAPES[chordType]||CHORD_SHAPES.triad;const beatSec=60.0/(parseInt(bpm)||120);const barBeats=4;const newNotes=shape.map(iv=>({id:'note_'+Math.random().toString(36).substr(2,9),pitch:pitch+iv,start_beat:beat,duration_beats:1,velocity:0.8}));commitNotes([...notes,...newNotes]);showToast('Đã stamp chord ('+shape.length+' nốt).','success');};const applyHarmonize=interval=>{const base=notes.filter(n=>selectedNoteIds.includes(n.id));const targets=base.length>0?base:notes;if(!targets.length)return;const dups=targets.map(n=>({...n,id:'note_'+Math.random().toString(36).substr(2,9),pitch:n.pitch+interval}));commitNotes([...notes,...dups]);showToast('Đã harmonize +'+interval+' ('+dups.length+' nốt).','success');};const applyVelocityCompress=()=>{const base=notes.filter(n=>selectedNoteIds.includes(n.id));const targets=base.length>0?base:notes;if(!targets.length)return;const mean=targets.reduce((s,n)=>s+(n.velocity!==undefined?n.velocity:0.8),0)/targets.length;const target=velocityTarget/127;const result=targets.map(n=>{const v=n.velocity!==undefined?n.velocity:0.8;return{...n,velocity:Math.max(0.05,Math.min(1.0,v+(target-mean)))};});const others=notes.filter(n=>!targets.includes(n));commitNotes([...others,...result]);showToast('Đã compress velocity về '+velocityTarget+' ('+targets.length+' nốt).','success');};const applyVelocityNormalize=()=>{const base=notes.filter(n=>selectedNoteIds.includes(n.id));const targets=base.length>0?base:notes;if(!targets.length)return;const maxV=Math.max(...targets.map(n=>n.velocity!==undefined?n.velocity:0.8));if(maxV<=0)return;const result=targets.map(n=>({...n,velocity:Math.max(0.05,Math.min(1.0,(n.velocity!==undefined?n.velocity:0.8)/maxV))}));const others=notes.filter(n=>!targets.includes(n));commitNotes([...others,...result]);showToast('Đã normalize velocity (max → 127).','success');};const renderScaleContextMenu=()=>{const closeMenu=()=>setScaleMenuPos(null);const origin=scaleMenuOriginRef.current||scaleMenuPos;const items=[];let subMenu=null;const isSameScale=(a,b)=>{if(!a||!b)return a===b;if(a.length!==b.length)return false;return a.every((v,i)=>v===b[i]);};const pushItem=(label,onClick,onHover)=>{const isActive=onClick._scale&&isSameScale(onClick._scale,selectedScale);items.push(React.createElement("div",{key:label,onClick:()=>{onClick();closeMenu();},onMouseEnter:onHover||undefined,className:"px-3 py-1.5 text-xs cursor-pointer hover:bg-amber-600 hover:text-white whitespace-nowrap "+(isActive?"bg-amber-800/40 text-amber-300":"text-zinc-300")},label));};Object.keys(SCALES).forEach(key=>{const val=SCALES[key];if(val===null){pushItem("None",()=>setSelectedScale(null));return;}if(Array.isArray(val)){pushItem(key,()=>setSelectedScale(val));}else{const parentKey=key;const isOpen=scaleMenuPos&&scaleMenuPos.parentKey===parentKey;pushItem(key+" ▸",()=>setSelectedScale(null),()=>{setScaleMenuPos({x:origin.x,y:origin.y,parentKey});});if(isOpen){const subs=[];Object.keys(val).forEach(subKey=>{subs.push(React.createElement("div",{key:subKey,onClick:()=>{setSelectedScale(val[subKey]);closeMenu();},className:"px-3 py-1.5 text-xs cursor-pointer hover:bg-amber-600 hover:text-white whitespace-nowrap "+(isSameScale(val[subKey],selectedScale)?"bg-amber-800/40 text-amber-300":"text-zinc-300")},subKey));});var subH=subs.length*30+16;var subTop=origin.y+subH+20>window.innerHeight?origin.y-subH:origin.y;subMenu=React.createElement("div",{style:{position:"fixed",left:origin.x+150,top:subTop,zIndex:10000},className:"bg-[#2a2a2a] border border-zinc-600 rounded shadow-xl py-1 min-w-[120px]"},...subs);}}});var menuH=items.length*30+16;var menuTop=origin.y+menuH+20>window.innerHeight?Math.max(10,origin.y-menuH):origin.y;return React.createElement(React.Fragment,null,React.createElement("div",{style:{position:"fixed",left:origin.x,top:menuTop,zIndex:9999},className:"bg-[#2a2a2a] border border-zinc-600 rounded shadow-xl py-1 min-w-[140px]"},...items),subMenu);};const renderBarLabels=()=>{const labels=[];const barsCount=Math.ceil(viewBeats/4);const barOffset=Math.floor(sessionStartBar);for(let bar=0;bar<barsCount;bar++){const x=bar*4*pixelsPerBeat;const displayBar=bar+barOffset;labels.push(/*#__PURE__*/React.createElement("div",{key:bar,style:{position:'absolute',left:`${x}px`,top:'4px'},className:"pl-1 border-l border-zinc-700 h-full select-none cursor-pointer hover:bg-zinc-800/30",onClick:e=>{e.stopPropagation();const barTime=bar*4*beatSec;setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:barTime}:s));}},`Bar ${displayBar}`));}return labels;};const beatSec=60.0/(parseInt(bpm)||120);const playHeadX=(st.currentTime||0)/beatSec*pixelsPerBeat;return React.createElement("div",{className:"flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full"},/* 1. TOOLBAR HEADER — 2 hàng (wrap tự nhiên; spacer 100% ép hàng mới) */React.createElement("div",{className:"bg-[#282828] border-b border-zinc-900 flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-1.5 shrink-0 text-slate-200"},React.createElement("div",{className:"flex items-center gap-4"},React.createElement("select",{value:st.trackId||'',onChange:function(e){var trkId=e.target.value;var trkSel=(activeTracks||[]).find(function(t){return t.id===trkId;});if(trkSel&&trkSel.midiItems&&trkSel.midiItems.length)handleSwitchMidiItem(trkSel.midiItems[0].id);},className:"bg-zinc-800 border border-zinc-700 text-yellow-500 font-bold text-xs rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[180px] uppercase"},function(){var seenTrackOpts={};var trackOpts=[];(activeTracks||[]).forEach(function(t){if(!t.midiItems||!t.midiItems.length)return;if(seenTrackOpts[t.id])return;seenTrackOpts[t.id]=true;trackOpts.push(React.createElement("option",{key:t.id,value:t.id},t.name||t.id));});return trackOpts;}()),activeParentTrackName?React.createElement("span",{className:"text-[9px] text-zinc-500 ml-1"},"(Belongs to: ",React.createElement("span",{className:"text-zinc-400 font-semibold"},activeParentTrackName),")"):null,React.createElement("div",{className:"flex items-center gap-1 text-xs"},React.createElement("span",{className:"text-zinc-500 font-semibold"},"Snap:"),React.createElement("select",{value:snapValue,onChange:e=>{onSnapChange(e.target.value);setRenderTick(t=>t+1);},className:"bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500"},['free','4','1','1/2','1/4','1/8','1/16','1/32'].map(v=>React.createElement("option",{key:v,value:v},v)))),React.createElement("button",{onClick:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,isArmed:!s.isArmed}:s)),className:`px-2 py-1 rounded text-xs font-bold ${st.isArmed?'bg-red-600 text-white':'bg-zinc-800 text-zinc-400'}`},"ARM"),React.createElement("select",{value:selectedMidiInputId||'',onChange:e=>onMidiInputSelect(e.target.value),className:"bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-xs outline-none max-w-[100px]"},React.createElement("option",{value:""},"Input"),React.createElement("option",{value:"ALL"},"Omni"),(midiDevices||[]).map(d=>React.createElement("option",{key:d.id,value:d.id},d.name||d.id))),React.createElement("button",{onClick:()=>window.__toggleChordsPanel&&window.__toggleChordsPanel(),title:"Chords panel (Shift+K)",className:"px-1.5 py-0.5 rounded text-xs font-bold bg-zinc-800 hover:bg-zinc-700 text-amber-400"},"Chords"),React.createElement("div",{className:"flex items-center gap-0.5 shrink-0"},React.createElement("button",{onClick:()=>onInstrumentSelect&&onInstrumentSelect(st.trackId),title:st.instrumentName||"Synth",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[70px] ${st.instrumentName?'bg-violet-900 text-violet-300 border-violet-700':'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`},React.createElement("i",{"data-lucide":"music",className:"w-3 h-3 shrink-0"}),React.createElement("span",{className:"truncate text-[9px]"},activeParentTrackName?'('+activeParentTrackName+') '+(st.instrumentName||'Synth'):st.instrumentName||'Synth')),st.instrumentId&&typeof st.instrumentId==='string'&&!st.instrumentId.startsWith('sf_')?React.createElement("button",{onClick:()=>{const _trk=activeTracks.find(t=>t.id===st.trackId);ensureAndOpenVstGui(_trk||st);},title:"Mở lại GUI VSTi",className:"px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 shrink-0 bg-zinc-800 text-violet-300 border-violet-800 hover:bg-violet-900/40"},React.createElement("i",{"data-lucide":"sliders-horizontal",className:"w-3 h-3 shrink-0"}),React.createElement("span",{className:"truncate text-[10px]"},"GUI")):null),React.createElement("div",{className:"flex items-center gap-1 ml-1 text-xs"},React.createElement("span",{className:"text-zinc-500"},"AI:"),React.createElement("input",{type:"number",value:aiBarStart,onChange:e=>setAiBarStart(parseInt(e.target.value)||0),className:"w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center"}),React.createElement("span",{className:"text-zinc-500"},"-"),React.createElement("input",{type:"number",value:aiBarEnd,onChange:e=>setAiBarEnd(parseInt(e.target.value)||1),className:"w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center"}),React.createElement("span",{className:"text-zinc-500"},"bar")),React.createElement("select",{value:ccMode,onChange:e=>setCcMode(e.target.value),className:"bg-zinc-800 text-zinc-200 border border-zinc-700 rounded px-1.5 py-1 text-xs capitalize cursor-pointer"},React.createElement("option",{value:"velocity"},"Velocity"),React.createElement("option",{value:"sustain"},"Sustain"),React.createElement("option",{value:"modulation"},"Modulation"),React.createElement("option",{value:"pitch_bend"},"Pitch Bend"),React.createElement("option",{value:"pan"},"Pan"),React.createElement("option",{value:"cc11"},"Expression (CC11)"))),React.createElement("button",{onClick:()=>setShowCC(!showCC),className:`px-2 py-1 rounded text-xs ${showCC?'bg-purple-900/60 text-purple-300 border border-purple-700':'text-zinc-500 hover:text-zinc-300'}`},ccMode==='velocity'?'Vel':ccMode==='sustain'?'Sus':ccMode==='modulation'?'Mod':ccMode==='pitch_bend'?'Bend':ccMode==='pan'?'Pan':ccMode==='cc11'?'Expr':'CC'),React.createElement("button",{onClick:function(){setSessionSyncMode(function(p){return!p;});},className:function(){var base='px-2 py-1 rounded text-xs ';return sessionSyncMode?base+'bg-cyan-900/60 text-cyan-300 border border-cyan-700':base+'text-zinc-500 hover:text-zinc-300';}(),title:sessionSyncMode?"Session-synced mode (ghost visible)":"Isolated mode (bar 0, no ghost)"},sessionSyncMode?"\uD83C\uDF10 Session":"\uD83D\uDCCB Isolated"),React.createElement("button",{onClick:function(){setShowGhostNotes(function(p){return!p;});},disabled:!sessionSyncMode,className:function(){if(!sessionSyncMode)return'px-2 py-1 rounded text-xs opacity-30 cursor-not-allowed';var base='px-2 py-1 rounded text-xs ';return showGhostNotes?base+'bg-purple-900/60 text-purple-300 border border-purple-700':base+'text-zinc-500 hover:text-zinc-300';}(),title:"Toggle ghost notes visibility"},"👻 MIDI ghost notes"),React.createElement("div",{className:"flex items-center gap-1 ml-auto"},React.createElement("button",{onClick:()=>onSaveNotes(st.id,st.trackId,st.target_id,notes),className:"px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"},React.createElement("i",{"data-lucide":"save",className:"w-3 h-3"}),"L\u01B0u"),React.createElement("button",{onClick:async()=>{if(!notes||!notes.length){showToast('Chưa có nốt nhạc để xuất MIDI','warning');return;}const payload={bpm:parseFloat(bpm)||120,ppq:480,tracks:[{name:st.label||'Track',notes:(notes||[]).map(n=>({pitch:n.pitch||60,start_beat:n.start_beat||0,duration_beats:n.duration_beats||1,velocity:n.velocity!=null?n.velocity:0.8}))}]};try{await serverDownloadMidi(payload,(st.label||'midi')+'.mid');showToast('Đã xuất file MIDI!','success');}catch(err){showToast('Export MIDI thất bại: '+(err&&err.message?err.message:String(err)),'error');}},className:"px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"},React.createElement("i",{"data-lucide":"file-down",className:"w-3 h-3"}),"Export MIDI"),React.createElement("button",{onClick:async()=>{// Preview/export MIDI notes với âm VSTi: midi-render (pedalboard) →
|
||
// play wav ngay.
|
||
const se=st.synth_engine||{};const instrumentId=se.plugin_id||st.instrumentId||null;if(!instrumentId||String(se.type||'').indexOf('vst')===-1){showToast('Chọn VST instrument (nút Synth) trước khi Preview VSTi','warning');return;}if(!notes||!notes.length){showToast('Chưa có nốt nhạc','warning');return;}const payload={instrument_id:instrumentId,notes:(notes||[]).map(n=>({pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity!=null?n.velocity:0.8})),bpm:parseFloat(bpm)||120,preset_id:se.preset_id||undefined,preset_path:se.preset_path||undefined,preset_data:se.preset_data||undefined};try{const res=await window.SonicAPI.midiRender(payload);if(vstiPreviewAudioRef.current){try{vstiPreviewAudioRef.current.pause();}catch(e){}}const audio=new Audio(res.url);vstiPreviewAudioRef.current=audio;audio.play().catch(()=>{});showToast(`Preview VSTi (${(res.duration_sec||0).toFixed(1)}s) — ${se.plugin_id||instrumentId}`,'success');}catch(err){showToast('Preview VSTi thất bại: '+(err&&err.message||'lỗi'),'error');}},className:"px-2.5 py-1 bg-violet-700 hover:bg-violet-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold",title:"Render MIDI notes bằng VSTi (pedalboard) và phát"},React.createElement("i",{"data-lucide":"play",className:"w-3 h-3"}),"Preview VSTi"),React.createElement("button",{onClick:()=>{const se=st.synth_engine||{};const instrumentId=se.plugin_id||st.instrumentId||null;if(!instrumentId||String(se.type||'').indexOf('vst')===-1){showToast('Chọn VST instrument (nút Synth) trước khi Export MIDI → Audio','warning');return;}if(!notes||!notes.length){showToast('Chưa có nốt nhạc','warning');return;}if(onExportMidiAudio)onExportMidiAudio(st,notes,bpm);else showToast('Không có handler export audio','error');},className:"px-2.5 py-1 bg-fuchsia-700 hover:bg-fuchsia-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold",title:"Render MIDI notes bằng VSTi → WAV, chèn clip vào track và tải về"},React.createElement("i",{"data-lucide":"audio-lines",className:"w-3 h-3"}),"Export MIDI → Audio")),React.createElement("div",{style:{flexBasis:"100%",height:0}}),React.createElement("button",{onClick:()=>setArpModal({pattern:'UP',rate:'1/16',octaves:2,gate:80,triplet:false,dotted:false}),className:"px-2 py-1 rounded text-xs bg-cyan-900/40 text-cyan-300 border border-cyan-700/60 hover:bg-cyan-800/50 transition",title:"Arpeggiate (Alt+A)"},"ARP"),React.createElement("button",{onClick:()=>setStrumModal({ms:30,direction:'DOWN'}),className:"px-2 py-1 rounded text-xs bg-teal-900/40 text-teal-300 border border-teal-700/60 hover:bg-teal-800/50 transition",title:"Strum (Alt+S)"},"STRUM"),React.createElement("button",{onClick:()=>setHumanizeModal({timingMs:Math.round(humanizeStrength*100),velRange:Math.round(humanizeStrength*127),durRange:Math.round(humanizeStrength*50)}),className:"px-2 py-1 rounded text-xs bg-amber-900/40 text-amber-300 border border-amber-700/60 hover:bg-amber-800/50 transition",title:"Humanize (Alt+R)"},"HUMANIZE"),React.createElement("select",{key:"humstr",value:humanizeStrength,onChange:function(e){var v=parseFloat(e.target.value);setHumanizeStrength(v);setHumanizeModal({timingMs:Math.round(v*100),velRange:Math.round(v*127),durRange:Math.round(v*50)});},className:"px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",title:"Mức humanize (mở modal theo mức)"},React.createElement("option",{key:"l",value:0.05},"Nh\u1EB9"),React.createElement("option",{key:"m",value:0.10},"V\u1EEBa"),React.createElement("option",{key:"s",value:0.18},"M\u1EA1nh")),React.createElement("div",{key:"transpose",className:"flex items-center gap-1"},React.createElement("input",{key:"in",type:"number",step:1,min:-24,max:24,value:transposeSemis,onChange:function(e){setTransposeSemis(e.target.value);},className:"w-12 px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-center text-zinc-200",title:"Semitone offset (vd 2 = cao hơn 1 tone)"}),React.createElement("button",{key:"btn",onClick:function(){applyTranspose(transposeSemis);},className:"px-2 py-1 rounded text-xs bg-sky-900/40 text-sky-300 border border-sky-700/60 hover:bg-sky-800/50 transition",title:"Transpose all notes by the semitone offset"},"Transpose")),React.createElement("div",{key:"keyshift",className:"flex items-center gap-1"},React.createElement("select",{key:"root",value:keyTargetRoot,onChange:function(e){setKeyTargetRoot(e.target.value);},className:"px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",title:"Giọng đích (root)"},["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"].map(function(r){return React.createElement("option",{key:r,value:r},r);})),React.createElement("select",{key:"scale",value:keyTargetScale,onChange:function(e){setKeyTargetScale(e.target.value);},className:"px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",title:"Thể scale đích"},React.createElement("option",{key:"maj",value:"major"},"major"),React.createElement("option",{key:"min",value:"minor"},"minor")),React.createElement("button",{key:"btn",onClick:applyTransposeToKey,className:"px-2 py-1 rounded text-xs bg-violet-900/40 text-violet-300 border border-violet-700/60 hover:bg-violet-800/50 transition",title:"Chuyển giọng: map degree hiện tại sang giọng đích (auto-detect key nguồn)"},"🎵 Chuyển giọng")),/* ── CÙNG HÀNG (sau Chuyển giọng — user 08:40: gộp 1 hàng, bỏ spacer) ── */React.createElement("div",{className:"flex items-center gap-1 text-xs"},React.createElement("span",{className:"text-zinc-500 font-semibold"},"Snap to Scale"),React.createElement("button",{onClick:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,snapToScale:!(s.snapToScale!==undefined?s.snapToScale:true)}:s)),className:`w-7 h-4 rounded-full transition-colors relative ${(st.snapToScale!==undefined?st.snapToScale:true)?'bg-yellow-600':'bg-zinc-700'}`,style:{padding:0}},React.createElement("div",{className:`w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${(st.snapToScale!==undefined?st.snapToScale:true)?'translate-x-3.5':'translate-x-0.5'}`}))),React.createElement("div",{className:"flex items-center gap-1 text-sm"},React.createElement("span",{className:"text-zinc-500 font-semibold"},"Scale:"),React.createElement("select",{value:JSON.stringify(selectedScale||null),onChange:e=>{const v=e.target.value;setSelectedScale(v==='null'?null:JSON.parse(v));setRenderTick(t=>t+1);},className:"bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[110px]"},React.createElement("option",{value:"null"},"None"),function(){const opts=[];const pushKey=(label,val)=>opts.push(React.createElement("option",{key:label,value:JSON.stringify(val)},label));Object.keys(SCALES||{}).forEach(k=>{const v=SCALES[k];if(v===null)return;if(Array.isArray(v))pushKey(k,v);else Object.keys(v).forEach(sk=>pushKey(sk,v[sk]));});return opts;}()),React.createElement("select",{value:scaleRoot,onChange:e=>{setScaleRoot(parseInt(e.target.value)||0);setRenderTick(t=>t+1);},className:"bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500"},['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'].map((r,i)=>React.createElement("option",{key:r,value:i},r))),React.createElement("button",{onClick:()=>applyForceToScale(),title:"Force selected notes to scale",className:"px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-amber-400 border border-zinc-700 hover:border-amber-600"},"Force")),React.createElement("div",{className:"flex items-center gap-1 text-sm"},React.createElement("span",{className:"text-zinc-500 font-semibold ml-1"},"Chord:"),React.createElement("select",{value:chordType,onChange:e=>setChordType(e.target.value),className:"bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-sm outline-none"},React.createElement("option",{value:"triad"},"Triad"),React.createElement("option",{value:"min7"},"Min7"),React.createElement("option",{value:"sus4"},"Sus4"),React.createElement("option",{value:"add9"},"Add9")),React.createElement("button",{onClick:()=>setChordStampMode(m=>!m),title:"Chord stamp mode — click canvas to stamp chord (Shift+C)",className:`px-1.5 py-0.5 rounded text-sm border ${chordStampMode?'bg-amber-800/60 text-amber-300 border-amber-600':'bg-zinc-800 text-zinc-400 border-zinc-700 hover:border-amber-600'}`},"Stamp"),React.createElement("button",{onClick:()=>applyHarmonize(3),title:"Harmonize +3rd",className:"px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"},"+3"),React.createElement("button",{onClick:()=>applyHarmonize(5),title:"Harmonize +5th",className:"px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"},"+5"),React.createElement("button",{onClick:()=>applyHarmonize(7),title:"Harmonize +7th",className:"px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"},"+7")),React.createElement("div",{className:"flex items-center gap-1 text-sm"},React.createElement("span",{className:"text-zinc-500 font-semibold"},"Vel:"),React.createElement("button",{onClick:()=>applyVelocityCompress(),title:"Compress velocity toward target",className:"px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600"},"Comp"),React.createElement("input",{type:"number",value:velocityTarget,onChange:e=>setVelocityTarget(parseInt(e.target.value)||80),className:"w-14 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-sm text-center"}),React.createElement("button",{onClick:()=>applyVelocityNormalize(),title:"Normalize (max → 127)",className:"px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600"},"Norm"))),/* 2. BAR RULER */React.createElement("div",{className:"h-6 bg-[#1a1a1f] border-b border-zinc-900 flex shrink-0"},React.createElement("div",{className:"w-[120px] bg-[#1e1e22] border-r border-zinc-800 shrink-0 flex items-end"},React.createElement("span",{className:"text-[8px] text-zinc-600 font-mono px-1.5 pb-0.5 uppercase tracking-wider"},"Tracks")),React.createElement("div",{className:"w-[60px] bg-[#222222] border-r border-zinc-900 shrink-0"}),React.createElement("div",{ref:rulerScrollRef,className:"flex-1 overflow-hidden",onMouseDown:e=>{const rect=e.currentTarget.getBoundingClientRect();const x=e.clientX-rect.left+e.currentTarget.scrollLeft;const clickBeat=x/pixelsPerBeat;const clickTime=clickBeat*beatSec;const clickInRange=loopStartBeat!==null&&loopEndBeat!==null&&clickBeat>=loopStartBeat&&clickBeat<=loopEndBeat;if(e.ctrlKey||e.metaKey){setLoopStartBeat(null);setLoopEndBeat(null);setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionStart:null,selectionEnd:null}:s));return;}if(e.shiftKey){const beatSnap=getSnapBeat(clickBeat,snapValue);if(loopStartBeat===null){setLoopStartBeat(Math.max(0,beatSnap-4));setLoopEndBeat(Math.max(4,beatSnap));}else{setLoopEndBeat(Math.max(loopStartBeat+4,beatSnap));}return;}if(clickTime>=0){if(onSeekPlayhead){onSeekPlayhead(clickTime);}else{setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:clickTime}:s));}}const snappedStartBeat=getSnapBeat(clickBeat,snapValue);rulerDragRef.current={startX:e.clientX,startBeat:snappedStartBeat,scrollLeft:e.currentTarget.scrollLeft};const onMove=ev=>{const r=rulerScrollRef.current;if(!r||!rulerDragRef.current)return;const rRect=r.getBoundingClientRect();const bx=ev.clientX-rRect.left+rulerDragRef.current.scrollLeft;const rawBeat=Math.max(0,bx/pixelsPerBeat);const beat=getSnapBeat(rawBeat,snapValue);if(Math.abs(ev.clientX-rulerDragRef.current.startX)>5){if(clickInRange){const rangeWidth=loopEndBeat-loopStartBeat;const offset=rulerDragRef.current.startBeat-loopStartBeat;const centerBeat=beat-offset;const halfRange=rangeWidth/2;const newStart=Math.max(0,centerBeat-halfRange);setLoopStartBeat(newStart);setLoopEndBeat(newStart+rangeWidth);setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionStart:newStart*beatSec,selectionEnd:(newStart+rangeWidth)*beatSec}:s));}else{const sBeat=Math.max(0,Math.min(rulerDragRef.current.startBeat,beat));const eBeat=Math.max(sBeat+1,Math.max(rulerDragRef.current.startBeat,beat));setLoopStartBeat(sBeat);setLoopEndBeat(eBeat);setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionStart:sBeat*beatSec,selectionEnd:eBeat*beatSec}:s));}}};const onUp=()=>{rulerDragRef.current=null;document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);}},React.createElement("div",{style:{width:`${viewWidth}px`,height:'100%'},className:"relative h-full font-mono text-[9px] text-zinc-500 font-bold"},renderBarLabels(),loopStartBeat!==null&&loopEndBeat!==null&&loopEndBeat>loopStartBeat&&React.createElement("div",{style:{left:`${loopStartBeat*pixelsPerBeat}px`,width:`${(loopEndBeat-loopStartBeat)*pixelsPerBeat}px`,top:0,bottom:0},className:"absolute bg-emerald-500/15 border-l border-r border-emerald-400"},React.createElement("div",{style:{position:'absolute',left:0,top:0,bottom:0,width:'4px',cursor:'ew-resize'},onMouseDown:e=>{e.stopPropagation();const startBeat=loopStartBeat;const onMove=ev=>{const r=rulerScrollRef.current;if(!r)return;const rRect=r.getBoundingClientRect();const bx=ev.clientX-rRect.left+r.scrollLeft;const nBeat=Math.max(0,Math.min(loopEndBeat-1,getSnapBeat(bx/pixelsPerBeat,snapValue)));setLoopStartBeat(nBeat);setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionStart:nBeat*beatSec}:s));};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);}}),React.createElement("div",{style:{position:'absolute',right:0,top:0,bottom:0,width:'4px',cursor:'ew-resize'},onMouseDown:e=>{e.stopPropagation();const onMove=ev=>{const r=rulerScrollRef.current;if(!r)return;const rRect=r.getBoundingClientRect();const bx=ev.clientX-rRect.left+r.scrollLeft;const nBeat=Math.max(loopStartBeat+1,getSnapBeat(bx/pixelsPerBeat,snapValue));setLoopEndBeat(nBeat);setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionEnd:nBeat*beatSec}:s));};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);}}))))),/* 3. MAIN PIANO ROLL GRID (KEYBOARD + TRACK COL + CANVAS) */React.createElement("div",{className:"flex-1 flex overflow-hidden min-h-0 relative",onMouseEnter:function(){prMouseInRef.current=true;// Status bar gợi ý động (user 09:40): trong piano roll → base hint;
|
||
// giữ Shift → select/unselect; giữ Ctrl → fast copy
|
||
if(window.__setPrHint){window.__setPrHint(prKeyStateRef.current.ctrl?"Ctrl+drag: fast copy notes | Ctrl+click empty: marquee chọn notes":prKeyStateRef.current.shift?"Shift+Click: select/unselect notes | Shift+Click note: thêm/bớt vào nhóm chọn":"Scroll: Up/Down | Drag: Draw notes");}},onMouseLeave:function(){prMouseInRef.current=false;if(window.__setPrHint)window.__setPrHint(null);}},/* Track column */React.createElement("div",{className:"w-[120px] bg-[#16161a] border-r border-zinc-800 shrink-0 flex flex-col z-10",style:{height:KeybedPixelHeight+'px'}},allMidiItems.length>0?function(){var seenTracks={};var els=[];allMidiItems.forEach(function(m){if(seenTracks[m._trackId])return;seenTracks[m._trackId]=true;var track=(activeTracks||[]).find(function(t){return t.id===m._trackId;});var isActive=m._trackId===st.trackId&&m.id===st.target_id;var isPlayOn=activePlayTrackIds&&activePlayTrackIds.indexOf(m._trackId)!==-1;els.push(React.createElement("div",{key:m._trackId,className:"flex items-center gap-0.5 mx-1 my-[2px]"},React.createElement("button",{onClick:function(){// Click nút tên track → ACTIVE ghost notes của track đó thành MAIN
|
||
// notes để chỉnh sửa (user 09:40)
|
||
handleSwitchMidiItem(m.id);},className:"flex items-center justify-center flex-1 h-[26px] min-w-0 border border-zinc-600 rounded-md cursor-pointer outline-none "+(isActive?'bg-yellow-600 text-black font-bold':'bg-zinc-700 text-zinc-300 hover:bg-zinc-600')},React.createElement("span",{className:"text-[13px] leading-none font-sans truncate px-1",title:track?track.name:m._trackName},track?track.name:m._trackName)),React.createElement("button",{onClick:function(e){e.stopPropagation();var prevList=activePlayTrackIds||[];var nextList=prevList.indexOf(m._trackId)!==-1?prevList.filter(function(id){return id!==m._trackId;}):prevList.concat([m._trackId]);setActivePlayTrackIds(nextList);if(onRealtimePlay)onRealtimePlay(nextList);},title:isPlayOn?"Unmute — ghost track play cùng main notes":"Mute (mặc định) — click để play ghost cùng main",className:"w-6 h-[26px] shrink-0 border border-zinc-600 rounded-md cursor-pointer text-[11px] font-bold outline-none flex items-center justify-center "+(isPlayOn?'bg-green-700 text-white':'bg-zinc-800 text-zinc-500 hover:text-zinc-300')},isPlayOn?"\u266A":"M")));});return els;}():null),React.createElement("div",{className:"w-[60px] shrink-0 flex flex-col border-r border-zinc-900 overflow-y-auto",ref:keybedRef,onScroll:handleKeybedScroll,style:{scrollbarWidth:'none',msOverflowStyle:'none'}},renderKeybed()),React.createElement("div",{ref:gridScrollRef,onScroll:handleScroll,className:"flex-1 overflow-auto bg-[#141414] min-w-0"},React.createElement("div",{style:{width:`${viewWidth}px`,height:`${(128-PITCH_START)*NoteHeight}px`},className:"relative"},React.createElement("canvas",{ref:canvasRef,onMouseDown:handleGridMouseDown,onMouseMove:handleGridMouseMove,onMouseUp:handleGridMouseUp,onMouseLeave:handleGridMouseUp,onContextMenu:handleContextMenu,className:"absolute inset-0 cursor-crosshair"}),loopStartBeat!==null&&loopEndBeat!==null&&loopEndBeat>loopStartBeat&&React.createElement("div",{style:{left:`${loopStartBeat*pixelsPerBeat}px`,width:`${(loopEndBeat-loopStartBeat)*pixelsPerBeat}px`,top:0,bottom:0},className:"absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none"})))),/* 4. CC / VELOCITY LANE (ĐẶT Ở ĐÁY) */showCC&&React.createElement("div",{style:{height:`${ccHeight}px`},className:"bg-[#161616] border-t border-zinc-900 flex shrink-0 relative"},React.createElement("div",{onMouseDown:e=>{e.preventDefault();const startY=e.clientY;const startH=ccHeight;const onMove=ev=>{setCcHeight(Math.max(40,startH+startY-ev.clientY));};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);},className:"absolute top-0 left-0 right-0 h-1.5 cursor-n-resize z-10 hover:bg-purple-600/30"}),React.createElement("div",{className:"w-[120px] shrink-0"}),React.createElement("select",{value:ccMode,onChange:e=>setCcMode(e.target.value),title:"CC control",className:"w-[60px] bg-[#222222] border-r border-zinc-900 text-[10px] text-zinc-200 font-mono shrink-0 font-bold text-center cursor-pointer outline-none"},React.createElement("option",{value:"velocity"},"Vel"),React.createElement("option",{value:"sustain"},"Sus"),React.createElement("option",{value:"modulation"},"Mod"),React.createElement("option",{value:"pitch_bend"},"Bend"),React.createElement("option",{value:"pan"},"Pan"),React.createElement("option",{value:"cc11"},"Expr")),React.createElement("div",{ref:ccWrapperRef,className:"flex-1 overflow-x-hidden min-w-0"},React.createElement("div",{style:{width:`${viewWidth}px`,height:'100%'},className:"relative"},React.createElement("canvas",{ref:ccCanvasRef,onMouseDown:handleCCMouseDown,onMouseMove:handleCCMouseMove,onContextMenu:handleCC11ContextMenu,onMouseUp:commitCC11Drag,onMouseLeave:commitCC11Drag,className:"absolute inset-0"}),loopStartBeat!==null&&loopEndBeat!==null&&loopEndBeat>loopStartBeat&&React.createElement("div",{style:{left:`${loopStartBeat*pixelsPerBeat}px`,width:`${(loopEndBeat-loopStartBeat)*pixelsPerBeat}px`,top:0,bottom:0},className:"absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none"})))),/* 5. OVERLAY / CONTEXT MENU */arpModal&&React.createElement("div",{className:"absolute inset-0 z-40 flex items-center justify-center bg-black/60",onMouseDown:e=>e.stopPropagation(),onClick:e=>e.stopPropagation()},React.createElement("div",{className:"bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"},React.createElement("div",{className:"text-sm font-bold text-cyan-300 mb-3"},"Arpeggiate"),React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs mb-3"},React.createElement("label",{className:"text-zinc-500"},"Pattern"),React.createElement("select",{value:arpModal.pattern,onChange:e=>setArpModal({...arpModal,pattern:e.target.value}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5"},['UP','DOWN','UP-DOWN','RANDOM','CHORD'].map(p=>React.createElement("option",{key:p,value:p},p))),React.createElement("label",{className:"text-zinc-500"},"Rate"),React.createElement("select",{value:arpModal.rate,onChange:e=>setArpModal({...arpModal,rate:e.target.value}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5"},['1/4','1/8','1/16','1/32'].map(r=>React.createElement("option",{key:r,value:r},r))),React.createElement("label",{className:"text-zinc-500"},"Octaves"),React.createElement("input",{type:"number",min:1,max:4,value:arpModal.octaves,onChange:e=>setArpModal({...arpModal,octaves:parseInt(e.target.value)||1}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-14"}),React.createElement("label",{className:"text-zinc-500"},"Gate %"),React.createElement("input",{type:"number",min:10,max:200,value:arpModal.gate,onChange:e=>setArpModal({...arpModal,gate:parseInt(e.target.value)||80}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-14"})),React.createElement("div",{className:"flex gap-1 mb-3"},React.createElement("label",{className:"flex items-center gap-1 text-[10px] text-zinc-400"},React.createElement("input",{type:"checkbox",checked:!!arpModal.triplet,onChange:e=>setArpModal({...arpModal,triplet:e.target.checked})}),"Triplet"),React.createElement("label",{className:"flex items-center gap-1 text-[10px] text-zinc-400"},React.createElement("input",{type:"checkbox",checked:!!arpModal.dotted,onChange:e=>setArpModal({...arpModal,dotted:e.target.checked})}),"Dotted")),React.createElement("div",{className:"flex gap-2"},React.createElement("button",{onClick:()=>applyArpeggiate(arpModal),className:"flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white"},"Apply"),React.createElement("button",{onClick:()=>setArpModal(null),className:"px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300"},"Cancel")))),strumModal&&React.createElement("div",{className:"absolute inset-0 z-40 flex items-center justify-center bg-black/60",onMouseDown:e=>e.stopPropagation(),onClick:e=>e.stopPropagation()},React.createElement("div",{className:"bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"},React.createElement("div",{className:"text-sm font-bold text-cyan-300 mb-3"},"Strum"),React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs mb-3"},React.createElement("label",{className:"text-zinc-500"},"Strum ms"),React.createElement("input",{type:"number",min:0,max:120,value:strumModal.ms,onChange:e=>setStrumModal({...strumModal,ms:parseInt(e.target.value)||0}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16"}),React.createElement("label",{className:"text-zinc-500"},"Direction"),React.createElement("select",{value:strumModal.direction,onChange:e=>setStrumModal({...strumModal,direction:e.target.value}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5"},['DOWN','UP','ALTERNATE'].map(d=>React.createElement("option",{key:d,value:d},d)))),React.createElement("div",{className:"flex gap-2"},React.createElement("button",{onClick:()=>applyStrum(strumModal),className:"flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white"},"Apply"),React.createElement("button",{onClick:()=>setStrumModal(null),className:"px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300"},"Cancel")))),humanizeModal&&React.createElement("div",{className:"absolute inset-0 z-40 flex items-center justify-center bg-black/60",onMouseDown:e=>e.stopPropagation(),onClick:e=>e.stopPropagation()},React.createElement("div",{className:"bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"},React.createElement("div",{className:"text-sm font-bold text-cyan-300 mb-3"},"Humanize"),React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs mb-3"},React.createElement("label",{className:"text-zinc-500"},"Timing ±ms"),React.createElement("input",{type:"number",min:0,max:30,value:humanizeModal.timingMs,onChange:e=>setHumanizeModal({...humanizeModal,timingMs:parseInt(e.target.value)||0}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16"}),React.createElement("label",{className:"text-zinc-500"},"Vel ±(0-127)"),React.createElement("input",{type:"number",min:0,max:20,value:humanizeModal.velRange,onChange:e=>setHumanizeModal({...humanizeModal,velRange:parseInt(e.target.value)||0}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16"}),React.createElement("label",{className:"text-zinc-500"},"Dur ±%"),React.createElement("input",{type:"number",min:0,max:50,value:humanizeModal.durRange,onChange:e=>setHumanizeModal({...humanizeModal,durRange:parseInt(e.target.value)||0}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16"})),React.createElement("div",{className:"flex gap-2"},React.createElement("button",{onClick:()=>applyHumanizeModal(humanizeModal),className:"flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white"},"Apply"),React.createElement("button",{onClick:()=>setHumanizeModal(null),className:"px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300"},"Cancel")))),scaleMenuPos&&renderScaleContextMenu());};const serializeTracksList=(tracksList,secondsPerBar)=>{return(tracksList||[]).map(t=>{let trackType="AUDIO";if(t.type==='MIDI'||t.type==='soundfont'||t.type==='vst3')trackType="MIDI";else if(t.type==='SECTION')trackType="SECTION";else if(t.sections&&t.sections.length>0)trackType="SECTION";else if(t.midiItems&&t.midiItems.length>0)trackType="MIDI";const items=[];// Serialize EVERY item type present on the track (a track can hold audio
|
||
// clips + MIDI items + section items at once). The old if/else-if chain
|
||
// dropped all but one type per track — silent data loss on save.
|
||
if(t.clips&&t.clips.length>0){t.clips.forEach(c=>{const durationSec=c.buffer?c.buffer.duration:4.0;const clipFileId=c.serverFileId||t.serverFileId;items.push({id:c.id,name:c.name||"Audio Clip",type:"AUDIO_ITEM",start_bar:c.startTime/secondsPerBar,duration_bars:durationSec/(c.speed||1.0)/secondsPerBar,clip_start_offset_bars:0.0,source_data:{audio_file_url:clipFileId?`/static/audio/uploads/${clipFileId}`:"",sample_rate:c.buffer?c.buffer.sampleRate:44100,channels:c.buffer?c.buffer.numberOfChannels:2,gain:1.0,server_file_id:clipFileId}});});}if(t.midiItems&&t.midiItems.length>0){t.midiItems.forEach(m=>{items.push({id:m.id,name:m.name||"MIDI Item",type:"MIDI_ITEM",start_bar:m.startTime/secondsPerBar,duration_bars:m.duration?m.duration/secondsPerBar:4.0,clip_start_offset_bars:0.0,source_data:{total_buffer_bars:m.duration?m.duration/secondsPerBar:8.0,...(Array.isArray(m.cc11_global_curve)&&m.cc11_global_curve.length?{cc11_global_curve:m.cc11_global_curve.map(p=>({position_ratio:p.position_ratio,value:p.value}))}:{}),notes:(m.notes||[]).map(n=>({id:n.id||'note_'+Math.random().toString(36).substr(2,9),pitch:n.pitch||60,start_beat:n.start_beat||0.0,duration_beats:n.duration_beats||1.0,velocity:n.velocity||0.8,pan:n.pan||0.0,expression_curve:(Array.isArray(n.expression_curve)?n.expression_curve:[]).map(p=>({position_ratio:p.position_ratio,value:p.value}))}))}});});}if(t.sections&&t.sections.length>0){t.sections.forEach(s=>{// BỎ section item thiếu sectionId (insertSectionAtPlayhead tạo section
|
||
// chưa gắn vào tab → s.sectionId undefined). Nếu serialize thật thì
|
||
// referenced_section_id bị JSON.stringify bỏ → source_data = {} →
|
||
// schema oneOf fail → lưu project báo lỗi xác thực (bug user gặp).
|
||
// Item vô nghĩa (chưa có nội dung tab) — bỏ là đúng, không mất data.
|
||
if(!s.sectionId)return;items.push({id:s.id,name:s.name||"Section Item",type:"SECTION_ITEM",start_bar:s.start/secondsPerBar,duration_bars:s.duration/secondsPerBar,clip_start_offset_bars:0.0,source_data:{referenced_section_id:s.sectionId}});});}return{id:t.id,name:t.name,type:trackType,color:t.color||null,volume_db:t.volumeDb||0.0,pan:t.pan||0.0,mute:t.muted||false,solo:t.solo||false,mastering_bypass:t.audioBypass||false,audio_bypass:t.audioBypass||false,midi_bypass:t.midiBypass||false,fx_active:t.fxActive!==false,instrument_id:t.instrumentId||null,instrument_program:t.instrumentProgram!==undefined?t.instrumentProgram:null,instrument_name:t.instrumentName||null,instrument_source:t.instrument_source||(t.synth_engine?t.synth_engine.type:null),soundfont_id:t.soundfont_id||(t.synth_engine?t.synth_engine.soundfont_id:null),soundfont_bank:t.soundfont_bank!==undefined?t.soundfont_bank:t.synth_engine?t.synth_engine.soundfont_bank:null,soundfont_program:t.soundfont_program!==undefined?t.soundfont_program:t.synth_engine?t.synth_engine.soundfont_program:null,synth_engine:t.synth_engine||undefined,midi_channel:t.midiChannel!==undefined?t.midiChannel:null,fx_chain:(t.fxChain||[]).map(m=>{if(typeof m==='string')return{type:m,active:true};const o={type:m.type,active:m.active!==false};if(m.type==='vst3'){o.path=m.path||'';o.name=m.name||'';o.preset_b64=_capturedPresetByPath[m.path]||m.preset_b64||'';o.bypass=!!m.bypass;}else if(m.params&&Object.keys(m.params).length)o.params=m.params;return o;}),vst_fx_chain:(()=>{const chainVst=(t.fxChain||[]).filter(m=>m&&m.type==='vst3');const vstSrc=chainVst.length?chainVst:t.vstFxChain||[];return vstSrc.map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||'',bypass:!!s.bypass}));})(),vst_fx_bypass:!!t.vstFxBypass,vst_gui_open_path:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.path?_persistFxGuiOpen.path:t.vstGuiOpenPath||null,vst_gui_open_name:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.name?_persistFxGuiOpen.name:t.vstGuiOpenName||null,server_file_id:t.serverFileId||null,items:items};});};const deserializeTracksList=(schemaTracks,secondsPerBar,sectionStore,visitedSections,depth)=>{// Guard CHU TRÌNH: SECTION_ITEM trỏ về section đang được deserialize (hoặc
|
||
// chu trình A→B→A) → recursion vô hạn → RangeError: Maximum call stack size
|
||
// exceeded khi restore project. Visited-set chặn cycle; depth chặn lồng sâu.
|
||
const visited=visitedSections||new Set();const curDepth=depth||0;if(curDepth>12)return[];return(schemaTracks||[]).map(t=>{const clips=[];const sections=[];const midiItems=[];(t.items||[]).forEach(item=>{if(item.type==="AUDIO_ITEM"){const src=item.source_data||{};const clipFileId=src.server_file_id||(src.audio_file_url?src.audio_file_url.split('/').pop():null)||null;clips.push({id:item.id,name:item.name,startTime:item.start_bar*secondsPerBar,speed:1.0,duration:item.duration_bars*secondsPerBar,serverFileId:clipFileId});}else if(item.type==="MIDI_ITEM"){const src=item.source_data||{};midiItems.push({id:item.id,name:item.name,parent_track_id:t.id,startTime:item.start_bar*secondsPerBar,duration:item.duration_bars*secondsPerBar,length_bars:item.duration_bars||4,notes:(src.notes||[]).map(n=>({id:n.id,pitch:n.pitch||60,start_beat:n.start_beat||0.0,duration_beats:n.duration_beats||1.0,velocity:n.velocity||0.8,pan:n.pan||0.0,...(Array.isArray(n.expression_curve)?{expression_curve:n.expression_curve}:{})})),...(Array.isArray(src.cc11_global_curve)&&src.cc11_global_curve.length?{cc11_global_curve:src.cc11_global_curve}:{})});}else if(item.type==="SECTION_ITEM"){const src=item.source_data||{};const secId=src.referenced_section_id;const secContainer=sectionStore?sectionStore[secId]:null;const isCycle=secContainer?visited.has(secId):false;sections.push({id:item.id,name:item.name,start:item.start_bar*secondsPerBar,duration:item.duration_bars*secondsPerBar,length_bars:item.duration_bars||4,sectionId:secId,tracks:secContainer&&!isCycle?deserializeTracksList(secContainer.tracks,secondsPerBar,sectionStore,new Set(visited).add(secId),curDepth+1):null});}});return{id:t.id,name:t.name,type:t.type==='MIDI'?'MIDI':t.type==='SECTION'?'SECTION':'audio',volumeDb:t.volume_db||0.0,pan:t.pan||0.0,muted:t.mute||false,solo:t.solo||false,masteringBypass:t.audio_bypass!==undefined?t.audio_bypass:t.mastering_bypass||false,audioBypass:t.audio_bypass!==undefined?t.audio_bypass:t.mastering_bypass||false,midiBypass:t.midi_bypass!==undefined?t.midi_bypass:t.mastering_bypass||false,fxActive:t.fx_active!==undefined?t.fx_active:true,color:t.color||(t.id==='1'?'#0f766e':'#1d4ed8'),startTime:t.start_time||0,height:t.height||140,markers:t.markers||[],serverFileId:t.server_file_id||t.items&&t.items.find(function(i){return i.type==='AUDIO_ITEM';})?.source_data?.server_file_id||t.items&&t.items.find(function(i){return i.type==='AUDIO_ITEM';})?.source_data?.audio_file_url?.split('/').pop()||null,channelInfo:t.channel_info||null,isArmed:t.is_armed||false,monitoringEnabled:t.monitoring_enabled!==false,inputSource:t.input_source?{deviceType:t.input_source.device_type||'NONE',deviceId:t.input_source.device_id||''}:{deviceType:'NONE',deviceId:''},midiChannel:t.midi_channel!=null?t.midi_channel:undefined,is_percussion:t.is_percussion||false,clips:clips,sections:sections,midiItems:midiItems,instrumentId:t.instrumentId!=null?t.instrumentId:t.instrument_id||null,instrumentProgram:t.instrumentProgram!==undefined&&t.instrumentProgram!==null?t.instrumentProgram:t.instrument_program!==null?t.instrument_program:undefined,instrumentName:t.instrument_name||null,instrument_source:t.instrument_source||null,soundfont_id:t.soundfont_id||null,soundfont_bank:t.soundfont_bank!==null?t.soundfont_bank:undefined,soundfont_program:t.soundfont_program!==null?t.soundfont_program:undefined,synth_engine:t.synth_engine||undefined,fxChain:(()=>{const parsed=(t.fx_chain||[]).map(m=>typeof m==='string'?{type:m,active:true}:{type:m.type||m,active:m.active!==false,...(m.params?{params:m.params}:{}),...(m.type==='vst3'?{path:m.path||'',name:m.name||'',preset_b64:m.preset_b64||'',bypass:!!m.bypass}:{})});const seen=new Set(parsed.filter(m=>m.type==='vst3').map(m=>m.path));(t.vst_fx_chain||[]).forEach(s=>{if(s&&s.path&&!seen.has(s.path)){parsed.push({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass,active:true});seen.add(s.path);}});return parsed;})(),vstFxChain:(()=>{const fromChain=(t.fx_chain||[]).filter(m=>m&&m.type==='vst3');const src=fromChain.length?fromChain:t.vst_fx_chain||[];return src.map(s=>({type:s.type||'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass}));})(),vstFxBypass:t.vst_fx_bypass!==undefined?!!t.vst_fx_bypass:false,vstGuiOpenPath:t.vst_gui_open_path||null,vstGuiOpenName:t.vst_gui_open_name||null};});};const serializeProjectToSchema=(projectId,name,bpmVal,tracksList,subTabsList,sessionTabsList,masteringSettings,masterVolume,masterState)=>{const secondsPerBar=60.0/parseFloat(bpmVal||120)*4;const mainTracks=serializeTracksList(tracksList,secondsPerBar);const sectionStore={};// Helper: compute length_bars from tracks content
|
||
const computeLengthBars=(tracksArr,spb)=>{let maxSec=0;(tracksArr||[]).forEach(tr=>{(tr.clips||[]).forEach(c=>{const end=(c.startTime||0)+(c.buffer?c.buffer.duration/(c.speed||1.0):4);if(end>maxSec)maxSec=end;});(tr.midiItems||[]).forEach(m=>{const end=(m.startTime||0)+(m.duration||4);if(end>maxSec)maxSec=end;});});return Math.ceil((maxSec||4)/spb);};// 1. Populate from sessionTabsList (open tabs)
|
||
(sessionTabsList||[]).forEach(st=>{const serializedTracks=serializeTracksList(st.tracks,secondsPerBar);sectionStore[st.sectionId]={id:st.sectionId,name:st.name,is_root:false,length_bars:computeLengthBars(st.tracks,secondsPerBar),auto_compute_length:true,tracks:serializedTracks,color:st.color||null};});// 2. Also populate from tracksList (closed tabs saved inside Section items)
|
||
const scanForSections=tracks=>{(tracks||[]).forEach(t=>{if(t.sections){t.sections.forEach(s=>{const secId=s.sectionId||s.id;if(s.tracks&&!sectionStore[secId]){sectionStore[secId]={id:secId,name:s.name,is_root:false,length_bars:computeLengthBars(s.tracks,secondsPerBar),auto_compute_length:true,tracks:serializeTracksList(s.tracks,secondsPerBar),color:s.color||null};}if(s.tracks){scanForSections(s.tracks);}});}});};scanForSections(tracksList);const subTabs=(subTabsList||[]).map(st=>{return{id:st.id,label:st.label,type:st.type,track_id:st.trackId,target_id:st.target_id,parent_tab_id:st.parent_tab_id,notes:st.notes||[],...(Array.isArray(st.cc11_global_curve)&&st.cc11_global_curve.length?{cc11_global_curve:st.cc11_global_curve.map(p=>({position_ratio:p.position_ratio,value:p.value}))}:{}),duration:st.duration||4,instrument_program:st.instrumentProgram,instrument_name:st.instrumentName,instrument_id:st.instrumentId||st.synth_engine&&st.synth_engine.plugin_id||null,synth_engine:st.synth_engine||null,current_time:st.currentTime||0,color:st.color||null};});return{project_id:projectId||'proj_'+Date.now(),metadata:{title:name||"Dự án mới",bpm:parseFloat(bpmVal||120),time_signature_numerator:4,time_signature_denominator:4,sample_rate:44100,zoom:window.__sfTimelineZoom||window.__currentZoom||1.0,loop_selection:!!window.__sfLoopSelection,selection_start:window.__sfLoopSelection&&window.__sfSelectionStart!=null?window.__sfSelectionStart:null,selection_end:window.__sfLoopSelection&&window.__sfSelectionEnd!=null?window.__sfSelectionEnd:null},main_session:{id:"main",name:"MAIN SESSION",is_root:true,length_bars:(()=>{let maxBar=16.0;(mainTracks||[]).forEach(t=>{(t.items||[]).forEach(item=>{const end=(item.start_bar||0)+(item.duration_bars||4);if(end>maxBar)maxBar=end;});});return maxBar;})(),auto_compute_length:true,tracks:mainTracks,...(masteringSettings&&Array.isArray(masteringSettings.vstFxChain)&&masteringSettings.vstFxChain.length>0||typeof masterVolume==='number'&&isFinite(masterVolume)&&masterVolume!==0||masterState&&(masterState.volL!==0||masterState.volR!==0||masterState.muted||!masterState.link)||Array.isArray(window.__masterAutoVol)&&window.__masterAutoVol.length>0?{master:{fx_chain:masteringSettings.vstFxChain.map(s=>({type:s.type||'vst3',path:s.path||'',name:s.name||'',preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||'',bypass:!!s.bypass})),volume_db:typeof masterVolume==='number'&&isFinite(masterVolume)?masterVolume:0,volumeL_db:masterState&&typeof masterState.volL==='number'&&isFinite(masterState.volL)?masterState.volL:typeof masterVolume==='number'&&isFinite(masterVolume)?masterVolume:0,volumeR_db:masterState&&typeof masterState.volR==='number'&&isFinite(masterState.volR)?masterState.volR:typeof masterVolume==='number'&&isFinite(masterVolume)?masterVolume:0,link_lr:masterState?!!masterState.link:false,muted:masterState?!!masterState.muted:false,mute:masterState?!!masterState.muted:false,bypass:!masteringSettings.masterConnected||!!masteringSettings.vstBypass,...(Array.isArray(window.__masterAutoVol)&&window.__masterAutoVol.length>0?{automation:{volume_db:window.__masterAutoVol.map(p=>({time:p.time,db:p.db}))}}:{})}}:{})},sub_tabs:subTabs,section_store:sectionStore,mastering_settings:masteringSettings?{...masteringSettings,// FIX preset VST master mất sau save/load: GUI capture (_capturedPresetByPath)
|
||
// trước đây chỉ merge vào main_session.master.fx_chain, còn mastering_settings
|
||
// (nguồn ưu tiên lúc deserialize) giữ preset cũ/rỗng → load lại mất settings.
|
||
// Merge cả vstFxChain + chain (entry vst3) để 2 nơi lưu đồng nhất.
|
||
vstFxChain:(masteringSettings.vstFxChain||[]).map(s=>({...s,preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||''})),chain:(masteringSettings.chain||[]).map(m=>m&&m.type==='vst3'?{...m,preset_b64:_capturedPresetByPath[m.path]||m.preset_b64||''}:m),gui_open_path:masteringSettings&&masteringSettings.gui_open_path||null,gui_open_name:masteringSettings&&masteringSettings.gui_open_name||null}:null};};const deserializeProjectFromSchema=schemaObj=>{// GUI knob captures are per-project - don't leak presets between projects.
|
||
_capturedPresetByPath={};const bpmVal=schemaObj.metadata?schemaObj.metadata.bpm:120;const secondsPerBar=60.0/bpmVal*4;const sectionStore=schemaObj.section_store||{};const restoredTracks=deserializeTracksList(schemaObj.main_session.tracks,secondsPerBar,sectionStore);const restoredSessionTabs=[];Object.keys(sectionStore).forEach(secId=>{const secContainer=sectionStore[secId];const secTracks=deserializeTracksList(secContainer.tracks,secondsPerBar,sectionStore);restoredSessionTabs.push({id:'session_'+secId,name:secContainer.name,sectionId:secId,tracks:secTracks,length_bars:secContainer.length_bars||16.0,auto_compute_length:secContainer.auto_compute_length!==undefined?secContainer.auto_compute_length:true,color:secContainer.color||null});});const restoredSubTabs=(schemaObj.sub_tabs||[]).map(st=>{return{id:st.id,label:st.label,type:st.type,trackId:st.track_id,target_id:st.target_id,parent_tab_id:st.parent_tab_id,notes:st.notes||[],cc11_global_curve:st.cc11_global_curve||null,duration:st.duration||4,instrumentProgram:st.instrument_program,instrumentName:st.instrument_name,instrumentId:st.instrument_id||st.synth_engine&&st.synth_engine.plugin_id||null,synth_engine:st.synth_engine||null,currentTime:st.current_time||0,color:st.color||null};});// Migrate mastering settings saved with the OLD imager width scale
|
||
// (−100..+100, 0 = original width) to the new imager_spec.md scale
|
||
// (0..200, 0% = MONO, 100% = original, 200% = double width): old value v
|
||
// meant S × (1 + v/100), which equals the new value (v + 100). Projects
|
||
// saved after the migration carry `imagerScale: 'v2'` and are kept as-is.
|
||
const _migrateMasteringSettings=ms=>{if(!ms)return null;const migrated=ms.imagerScale==='v2'?{...ms}:{...ms,w1:(ms.w1??0)+100,w2:(ms.w2??0)+100,w3:(ms.w3??0)+100,w4:(ms.w4??0)+100,imagerScale:'v2'};// mastering_expand.md: extension modules + dynamic chain (default = old chain order)
|
||
if(!Array.isArray(migrated.chain)){migrated.chain=[];}if(migrated.compActive===undefined)migrated.compActive=false;if(migrated.compThreshold===undefined)migrated.compThreshold=-16;if(migrated.compRatio===undefined)migrated.compRatio=3;if(migrated.compMakeup===undefined)migrated.compMakeup=0;if(migrated.limActive===undefined)migrated.limActive=false;if(migrated.limThreshold===undefined)migrated.limThreshold=-1.0;if(migrated.limMode===undefined)migrated.limMode=0;if(migrated.limLookahead===undefined)migrated.limLookahead=2;if(migrated.excActive===undefined)migrated.excActive=false;if(migrated.excDrive===undefined)migrated.excDrive=40;if(migrated.rebalActive===undefined)migrated.rebalActive=false;if(migrated.rebalMid===undefined)migrated.rebalMid=0;if(migrated.rebalSide===undefined)migrated.rebalSide=0;return migrated;};const _migratedMastering=_migrateMasteringSettings(schemaObj.mastering_settings)||{};const _masterBus=schemaObj.main_session&&schemaObj.main_session.master||{};const _masterFx=Array.isArray(_masterBus.fx_chain)?_masterBus.fx_chain:[];return{bpm:bpmVal,tracks:restoredTracks,sessionTabs:restoredSessionTabs,subTabs:restoredSubTabs,masteringSettings:(()=>{const migrated={..._migratedMastering};const _masterVstSources=[];// Merge preset: ưu tiên vstFxChain (thứ tự), nhưng nếu preset rỗng mà
|
||
// main_session.master.fx_chain có preset (project cũ lưu trước fix) → lấy
|
||
// preset không rỗng đó (fix: settings VST master mất sau save/load).
|
||
const _mergeVstSource=s=>{if(!s||!s.path)return;const ex=_masterVstSources.find(x=>x.path===s.path);if(ex){if(!ex.preset_b64&&s.preset_b64)ex.preset_b64=s.preset_b64;if(!ex.name&&s.name)ex.name=s.name;if(ex.bypass===undefined&&s.bypass!==undefined)ex.bypass=s.bypass;return;}_masterVstSources.push({...s});};(migrated.vstFxChain||[]).forEach(_mergeVstSource);_masterFx.forEach(_mergeVstSource);const _finalChain=[...(migrated.chain||[])];_masterVstSources.forEach(s=>{if(!_finalChain.some(m=>m.type==='vst3'&&m.path===s.path)){_finalChain.push({id:'mod_vst_'+Date.now()+'_'+Math.random().toString(36).slice(2,8),type:'vst3',path:s.path,name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass,active:true});}});return{...migrated,chain:_finalChain,vstFxChain:_finalChain.filter(m=>m.type==='vst3').map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass})),// FIX (master FX "mất" trên project cũ): main_session.master.bypass là
|
||
// composite cũ (bypass: !masterConnected || vstBypass) — project lưu khi
|
||
// tắt master → bypass=true → deserialize thành vstBypass=true vĩnh viễn
|
||
// → fxRtChain() filter bỏ VST3 + masterVstOn tắt → master FX không xử lý
|
||
// realtime lẫn offline export sau khi load. Không có UI nào set vstBypass
|
||
// cho master (chỉ có slot.bypass riêng) → luôn khôi phục false.
|
||
vstBypass:false,gui_open_path:schemaObj.mastering_settings&&schemaObj.mastering_settings.gui_open_path?schemaObj.mastering_settings.gui_open_path:null,gui_open_name:schemaObj.mastering_settings&&schemaObj.mastering_settings.gui_open_name?schemaObj.mastering_settings.gui_open_name:null};})(),masterVolume:typeof _masterBus.volume_db==='number'&&isFinite(_masterBus.volume_db)?_masterBus.volume_db:0,masterVolL:typeof _masterBus.volumeL_db==='number'&&isFinite(_masterBus.volumeL_db)?_masterBus.volumeL_db:typeof _masterBus.volume_db==='number'&&isFinite(_masterBus.volume_db)?_masterBus.volume_db:0,masterVolR:typeof _masterBus.volumeR_db==='number'&&isFinite(_masterBus.volumeR_db)?_masterBus.volumeR_db:typeof _masterBus.volumeL_db==='number'&&isFinite(_masterBus.volumeL_db)?_masterBus.volumeL_db:typeof _masterBus.volume_db==='number'&&isFinite(_masterBus.volume_db)?_masterBus.volume_db:0,masterLink:!!_masterBus.link_lr,masterMuted:!!(_masterBus.muted||_masterBus.mute),masterAutoVol:_masterBus.automation&&Array.isArray(_masterBus.automation.volume_db)?_masterBus.automation.volume_db.filter(p=>p&&typeof p.time==='number'&&typeof p.db==='number'):[],masterAutoOn:!!(_masterBus.automation&&Array.isArray(_masterBus.automation.volume_db)&&_masterBus.automation.volume_db.length),zoom:schemaObj.metadata&&schemaObj.metadata.zoom?parseFloat(schemaObj.metadata.zoom):1.0,loopSelection:!!(schemaObj.metadata&&schemaObj.metadata.loop_selection),selectionStart:schemaObj.metadata&&schemaObj.metadata.selection_start!=null?schemaObj.metadata.selection_start:null,selectionEnd:schemaObj.metadata&&schemaObj.metadata.selection_end!=null?schemaObj.metadata.selection_end:null};};// ──────────────────────────────────────────────
|
||
// MASTERING KNOB COMPONENT (Dynamic pointer events version)
|
||
// ──────────────────────────────────────────────
|
||
const MasteringKnob=({param,min,max,value,unit,label,color,onChange,size='small'})=>{const[isDragging,setIsDragging]=React.useState(false);const startYRef=React.useRef(0);const startValRef=React.useRef(0);const handlePointerDown=e=>{e.preventDefault();setIsDragging(true);startYRef.current=e.clientY;startValRef.current=value;e.currentTarget.setPointerCapture(e.pointerId);};const handlePointerMove=e=>{if(!isDragging)return;const deltaY=startYRef.current-e.clientY;let newVal=startValRef.current+deltaY/150*(max-min);newVal=Math.min(max,Math.max(min,newVal));onChange(param,newVal);};const handlePointerUp=e=>{setIsDragging(false);try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(err){}};const pct=(value-min)/(max-min);const angle=-135+pct*270;const isLarge=size==='large';const dialClass=isLarge?'w-20 h-20 border-4 bg-slate-900':'w-10 h-10 border-2 bg-slate-800';const pointerHeight=isLarge?'h-6':'h-3';const valClass=isLarge?'text-xs text-cyan-300 font-bold mt-2 z-10':'text-[9px] text-slate-300 font-mono mt-1 font-bold';return/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center select-none"},label&&/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-400 mb-1.5 uppercase tracking-wide"},label),/*#__PURE__*/React.createElement("div",{className:`${dialClass} rounded-full relative flex items-center justify-center cursor-ns-resize shadow-lg`,style:{borderColor:color},onPointerDown:handlePointerDown,onPointerMove:handlePointerMove,onPointerUp:handlePointerUp,onPointerCancel:handlePointerUp},/*#__PURE__*/React.createElement("div",{className:"w-0.5 absolute rounded origin-bottom",style:{backgroundColor:color,height:isLarge?'22px':'12px',top:isLarge?'6px':'4px',transform:`rotate(${angle}deg)`,transformOrigin:'50% 100%'}}),isLarge&&/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-cyan-300 z-10 bg-slate-950/80 px-1 py-0.5 rounded border border-slate-800"},value>0&&unit==='dB'?'+':'',value.toFixed(1)," ",unit)),!isLarge&&/*#__PURE__*/React.createElement("span",{className:valClass},value>0&&unit==='dB'?'+':'',value.toFixed(1),unit));};// ──────────────────────────────────────────────
|
||
// UNIFIED FX RACK (unified_fx_rack_panel.md) — một panel dùng chung,
|
||
// bind động vào track khi bấm [FX]; mọi thay đổi rebuild graph RIÊNG của track.
|
||
// ──────────────────────────────────────────────
|
||
const TRACK_FX_META={eq:{name:'EQ 4-Band',icon:'activity',color:'#22d3ee',sub:'4-Band Peak'},eqpro:{name:'Parametric / Graphic EQ PRO',icon:'chart-area',color:'#2dd4bf',sub:'Pro-Q style · 8 bands · interactive'},compressor:{name:'Bus Compressor',icon:'compress',color:'#fbbf24',sub:'Glue & Punch'},limiter:{name:'Brickwall Limiter',icon:'shield-half',color:'#f43f5e',sub:'True-Peak 20:1'},exciter:{name:'Harmonic Exciter',icon:'wand-2',color:'#c084fc',sub:'Saturation & Air'},rebalance:{name:'Master Rebalance',icon:'sliders-horizontal',color:'#38bdf8',sub:'M/S Balance'}};const TRACK_FX_DEFAULTS={eq:{g1:0,g2:0,g3:0,g4:0},eqpro:{amount:100,bands:EQPRO_DEFAULT_BANDS},compressor:{threshold:-16,ratio:3,makeup:0},limiter:{ceiling:-1.0,mode:'soft',lookahead_ms:2},exciter:{drive:40},rebalance:{mid:0,side:0}};// EQ Pro canvas frame renderer (graphic_EQ_interactive_module.md §I-II): grid,
|
||
// realtime FFT spectrum, per-band fills, summed master white curve, nodes+wings.
|
||
function renderEqProFrame(c,w,h,s,modules,fs){c.clearRect(0,0,w,h);c.font='9px monospace';[20,50,100,200,500,1000,2000,5000,10000,20000].forEach(f=>{const x=eqproFreqToX(f,w);c.strokeStyle='rgba(51,65,85,0.25)';c.lineWidth=1;c.beginPath();c.moveTo(x,0);c.lineTo(x,h);c.stroke();c.fillStyle='#475569';c.fillText(f>=1000?f/1000+'k':''+f,x+3,h-8);});[18,12,6,0,-6,-12,-18].forEach(db=>{const y=eqproGainToY(db,h);c.strokeStyle=db===0?'rgba(45,212,191,0.45)':'rgba(51,65,85,0.25)';c.lineWidth=db===0?1.5:1;c.beginPath();c.moveTo(0,y);c.lineTo(w,y);c.stroke();c.fillStyle='#475569';c.fillText((db>0?'+':'')+db,w-34,y-3);});// Realtime FFT spectrum — one overlay per live module (audioclip path = sky,
|
||
// soundfont/MIDI path = pink), so both sources are visible when playing.
|
||
const specStyles=['rgba(56,189,248,0.20)','rgba(236,72,153,0.16)'];(modules||[]).forEach((module,mi)=>{if(!module||!module.analyser)return;const fft=new Uint8Array(module.analyser.frequencyBinCount);module.analyser.getByteFrequencyData(fft);c.fillStyle=specStyles[mi%specStyles.length];c.beginPath();c.moveTo(0,h);for(let x=0;x<=w;x++){const f=eqproXToFreq(x,w);const bin=Math.floor(f/(fs/2)*fft.length);const v=(fft[bin]||0)/255;c.lineTo(x,h-v*h*0.8);}c.lineTo(w,h);c.closePath();c.fill();});// Per-band translucent fills + summed master curve
|
||
const total=new Float32Array(w);s.bands.forEach((b,bi)=>{if(b.active===false)return;const col=EQPRO_BAND_COLORS[bi%EQPRO_BAND_COLORS.length];c.fillStyle=col.fill;c.beginPath();c.moveTo(0,eqproGainToY(0,h));for(let x=0;x<w;x++){const db=eqproBiquadMagDb(b.type,eqproXToFreq(x,w),b.freq,b.gain,b.q,fs);total[x]+=db;c.lineTo(x,eqproGainToY(db,h));}c.lineTo(w,eqproGainToY(0,h));c.closePath();c.fill();});c.strokeStyle='#ffffff';c.lineWidth=2.5;c.beginPath();for(let x=0;x<w;x++){const y=eqproGainToY(total[x],h);if(x===0)c.moveTo(x,y);else c.lineTo(x,y);}c.stroke();// Nodes + Q-handle wings
|
||
s.bands.forEach((b,bi)=>{const nx=eqproFreqToX(b.freq,w);const ny=eqproGainToY(eqproNodeDb(b),h);const col=EQPRO_BAND_COLORS[bi%EQPRO_BAND_COLORS.length];const sel=s.selected===bi;const wo=eqproQToWing(b.q);if(sel){c.strokeStyle=col.stroke;c.lineWidth=2;c.beginPath();c.moveTo(nx-wo,ny);c.lineTo(nx+wo,ny);c.stroke();[nx-wo,nx+wo].forEach(wx=>{c.fillStyle='#0f172a';c.strokeStyle=col.stroke;c.lineWidth=2;c.beginPath();c.arc(wx,ny,4,0,Math.PI*2);c.fill();c.stroke();});}c.fillStyle=sel?'#ffffff':col.stroke;c.strokeStyle=col.stroke;c.lineWidth=sel?3:2;c.beginPath();c.arc(nx,ny,sel?7:5,0,Math.PI*2);c.fill();c.stroke();if(b.active===false){c.strokeStyle='#ef4444';c.lineWidth=2;c.beginPath();c.moveTo(nx-6,ny+6);c.lineTo(nx+6,ny-6);c.stroke();}c.fillStyle=sel?'#0f172a':'#ffffff';c.font='bold 9px monospace';c.textAlign='center';c.textBaseline='middle';c.fillText(String(bi+1),nx,ny);});}const InteractiveEqPro=({track,params,onChange,getModule,applyTo,spectrumModules})=>{const canvasRef=React.useRef(null);const st=React.useRef(null);if(!st.current){st.current={bands:JSON.parse(JSON.stringify(params&&Array.isArray(params.bands)?params.bands:EQPRO_DEFAULT_BANDS)),amount:params&¶ms.amount!==undefined?params.amount:100,selected:null,dragId:null,mode:null,lastSig:null,hudPos:null,dragHud:false,hudOffsetX:0,hudOffsetY:0};st.current.lastSig=JSON.stringify(st.current.bands)+'|'+st.current.amount;}const[tick,setTick]=React.useState(0);// External param sync (undo/load/rebuild) — skip while dragging to avoid
|
||
// resetting the node mid-gesture (the flicker/“can't move” bug while playing).
|
||
React.useEffect(()=>{if(st.current.dragId!==null)return;if(params&&Array.isArray(params.bands)){const sig=JSON.stringify(params.bands)+'|'+(params.amount!==undefined?params.amount:100);if(sig!==st.current.lastSig){st.current.lastSig=sig;st.current.bands=JSON.parse(JSON.stringify(params.bands));st.current.amount=params.amount!==undefined?params.amount:100;}}},[params]);// Canvas render loop (rAF)
|
||
React.useEffect(()=>{let raf;const draw=()=>{raf=requestAnimationFrame(draw);const cv=canvasRef.current;if(!cv||!cv.clientWidth)return;const s=st.current;const specMods=(spectrumModules?spectrumModules():null)||(getModule?[getModule()]:[]);// V45.46 throttle: đang kéo band hoặc có tín hiệu live → full 60fps; idle
|
||
// (không kéo, analyser im) → chỉ vẽ ~mỗi 12 frame — curve tĩnh không cần
|
||
// redraw 60fps (canvas EQ trong FXRack/Mastering modal tốn GPU khi mở).
|
||
if(s.dragId===null){let live=false;for(const _m of specMods){const _an=_m&&_m.analyser;if(!_an)continue;try{if(!s._freq||s._freq.length!==_an.frequencyBinCount)s._freq=new Uint8Array(_an.frequencyBinCount);_an.getByteFrequencyData(s._freq);for(let _i=0;_i<s._freq.length;_i++){if(s._freq[_i]>4){live=true;break;}}}catch(e){}if(live)break;}s._frame=(s._frame||0)+1;if(!live&&s._frame%12!==1)return;}const w=cv.clientWidth,h=cv.clientHeight;if(cv.width!==w*2){cv.width=w*2;cv.height=h*2;}const c=cv.getContext('2d');c.setTransform(2,0,0,2,0,0);// Spectrum sources: every live module analyser (audioclip path + soundfont
|
||
// path) is overlaid, so playing audio clips OR midi both show up.
|
||
renderEqProFrame(c,w,h,s,specMods,typeof getAudioContext==='function'&&getAudioContext()?getAudioContext().sampleRate:44100);};draw();return()=>cancelAnimationFrame(raf);// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[]);const toLocal=e=>{const r=canvasRef.current.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top};};const commit=()=>{if(onChange)onChange({bands:JSON.parse(JSON.stringify(st.current.bands)),amount:st.current.amount});};// Apply a mutation to the live module instance(s). Default: BOTH track module
|
||
// instances (audio fxMods + sf sfMods) so the soundfont follows too; mastering
|
||
// passes its own applyTo (masterBus.eqProInstances[modId]).
|
||
const applyAll=applyTo||(fn=>{if(typeof window!=='undefined'&&window.__getTrackFxModule){const a=window.__getTrackFxModule(track.id,'eqpro');if(a)fn(a);}if(typeof window!=='undefined'&&window.__getTrackSfFxModule){const b=window.__getTrackSfFxModule(track.id,'eqpro');if(b)fn(b);}});const onDown=e=>{const cv=canvasRef.current,w=cv.clientWidth,h=cv.clientHeight;const{x,y}=toLocal(e);const s=st.current;let hit=false;for(let i=0;i<s.bands.length;i++){const b=s.bands[i];if(Math.hypot(x-eqproFreqToX(b.freq,w),y-eqproGainToY(eqproNodeDb(b),h))<=10){s.selected=i;s.dragId=i;s.mode='center';hit=true;break;}}if(!hit&&s.selected!==null){const b=s.bands[s.selected];const nx=eqproFreqToX(b.freq,w),ny=eqproGainToY(eqproNodeDb(b),h),wo=eqproQToWing(b.q);if(Math.hypot(x-(nx-wo),y-ny)<=8||Math.hypot(x-(nx+wo),y-ny)<=8){s.dragId=s.selected;s.mode='wing';hit=true;}}if(!hit)s.selected=null;try{cv.setPointerCapture(e.pointerId);}catch(err){}setTick(t=>t+1);};const onMove=e=>{const s=st.current;if(s.dragId===null)return;const cv=canvasRef.current,w=cv.clientWidth,h=cv.clientHeight;const{x,y}=toLocal(e);const mx=Math.max(0,Math.min(w,x)),my=Math.max(0,Math.min(h,y));const b=s.bands[s.dragId];if(s.mode==='center'){const f=eqproClamp(parseFloat(eqproXToFreq(mx,w).toFixed(1)),EQPRO_F_MIN,EQPRO_F_MAX);// Filters without a Gain control (highpass/lowpass/notch/bandpass) stay on
|
||
// the 0 dB axis — only frequency is draggable for them.
|
||
const g=eqproBandHasGain(b.type)?eqproClamp(parseFloat(eqproYToGain(my,h).toFixed(1)),-EQPRO_MAX_DB,EQPRO_MAX_DB):0;b.freq=f;b.gain=g;applyAll(mm=>mm.setBand(s.dragId,{freq:f,gain:g}));}else if(s.mode==='wing'){const q=eqproWingToQ(Math.abs(mx-eqproFreqToX(b.freq,w)));b.q=q;applyAll(mm=>mm.setBand(s.dragId,{q}));}setTick(t=>t+1);};const onUp=e=>{const s=st.current;if(s.dragId!==null)commit();s.dragId=null;s.mode=null;try{canvasRef.current.releasePointerCapture(e.pointerId);}catch(err){}};const onWheel=e=>{e.preventDefault();const s=st.current;if(s.selected===null)return;const b=s.bands[s.selected];b.q=eqproClamp(parseFloat((b.q+(e.deltaY<0?0.2:-0.2)).toFixed(1)),0.1,18);applyAll(mm=>mm.setBand(s.selected,{q:b.q}));setTick(t=>t+1);};const onDblClick=e=>{const cv=canvasRef.current,w=cv.clientWidth,h=cv.clientHeight;const{x,y}=toLocal(e);const s=st.current;const hitIdx=s.bands.findIndex(b=>Math.hypot(x-eqproFreqToX(b.freq,w),y-eqproGainToY(b.gain,h))<=10);if(hitIdx!==-1){s.bands.splice(hitIdx,1);if(s.selected===hitIdx)s.selected=null;else if(s.selected!==null&&s.selected>hitIdx)s.selected--;}else{if(s.bands.length>=EQPRO_MAX_BANDS)return;s.bands.push({type:'peaking',freq:eqproClamp(parseFloat(eqproXToFreq(x,w).toFixed(1)),EQPRO_F_MIN,EQPRO_F_MAX),gain:eqproClamp(parseFloat(eqproYToGain(y,h).toFixed(1)),-EQPRO_MAX_DB,EQPRO_MAX_DB),q:1.2,active:true});s.selected=s.bands.length-1;}applyAll(mm=>mm.syncBands(s.bands));commit();setTick(t=>t+1);};const sel=st.current.selected!==null?st.current.bands[st.current.selected]:null;const selIdx=st.current.selected;const cvW=canvasRef.current?canvasRef.current.clientWidth:460;const cvH=240;const hudColor=selIdx!==null?EQPRO_BAND_COLORS[selIdx%EQPRO_BAND_COLORS.length]:EQPRO_BAND_COLORS[0];const hudXY=(()=>{if(st.current.hudPos){// Clamp so the HUD always stays inside the canvas area (buttons clickable).
|
||
return{hx:Math.max(0,Math.min((canvasRef.current?canvasRef.current.clientWidth:460)-268,st.current.hudPos.hx)),hy:Math.max(0,Math.min((canvasRef.current?canvasRef.current.clientHeight:240)-225,st.current.hudPos.hy))};}if(!sel)return null;const nx=eqproFreqToX(sel.freq,cvW),ny=eqproGainToY(sel.gain,cvH);let hx=nx-130,hy=ny-195;if(hx<8)hx=8;if(hx>cvW-268)hx=cvW-268;if(hy<8)hy=ny+24;return{hx,hy};})();return/*#__PURE__*/React.createElement("div",{className:"space-y-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[11px] font-mono"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Bands: ",/*#__PURE__*/React.createElement("span",{className:"text-teal-300 font-bold"},st.current.bands.length),"/8"),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Amount:"),/*#__PURE__*/React.createElement("input",{type:"range",min:0,max:200,value:st.current.amount,onChange:e=>{st.current.amount=parseInt(e.target.value);applyAll(mm=>mm.setAmount(st.current.amount));commit();setTick(t=>t+1);},className:"w-20 h-1 cursor-pointer",style:{accentColor:'#2dd4bf'}}),/*#__PURE__*/React.createElement("span",{className:"text-teal-300 font-bold w-10"},st.current.amount,"%"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:()=>{const s=st.current;s.bands=JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS));s.selected=null;applyAll(mm=>mm.syncBands(s.bands));commit();setTick(t=>t+1);},className:"px-2 py-1 bg-slate-800 hover:bg-red-900/60 text-slate-300 border border-slate-700 rounded text-[10px]"},"Reset"),/*#__PURE__*/React.createElement("button",{onClick:()=>{const s=st.current;if(s.bands.length>=EQPRO_MAX_BANDS)return;s.bands.push({type:'peaking',freq:1000,gain:0,q:1.0,active:true});s.selected=s.bands.length-1;applyAll(mm=>mm.syncBands(s.bands));commit();setTick(t=>t+1);},className:"px-2 py-1 bg-teal-900/50 hover:bg-teal-800 text-teal-300 border border-teal-700 rounded text-[10px]"},"+ Band"))),/*#__PURE__*/React.createElement("div",{className:"relative"},/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,className:"w-full h-60 block cursor-crosshair rounded-lg border border-slate-800 bg-slate-950",onPointerDown:onDown,onPointerMove:onMove,onPointerUp:onUp,onWheel:onWheel,onDoubleClick:onDblClick}),sel&&hudXY&&/*#__PURE__*/React.createElement("div",{className:"absolute z-10 w-64 rounded-xl p-3 text-[11px] font-mono space-y-2 pointer-events-auto cursor-move",style:{left:hudXY.hx,top:hudXY.hy,background:'rgba(15,23,42,0.94)',border:'1px solid rgba(45,212,191,0.3)',boxShadow:'0 10px 30px rgba(0,0,0,0.8)',backdropFilter:'blur(12px)'},onPointerDown:e=>{const s=st.current;s.dragHud=true;const r=e.currentTarget.getBoundingClientRect();s.hudOffsetX=e.clientX-r.left;s.hudOffsetY=e.clientY-r.top;e.currentTarget.setPointerCapture(e.pointerId);},onPointerMove:e=>{const s=st.current;if(!s.dragHud)return;const cv=canvasRef.current;const w=cv?cv.clientWidth:460;s.hudPos={hx:Math.max(0,Math.min(w-268,e.clientX-s.hudOffsetX-(cv?cv.getBoundingClientRect().left:0))),hy:Math.max(0,e.clientY-s.hudOffsetY-(cv?cv.getBoundingClientRect().top:0))};setTick(t=>t+1);},onPointerUp:e=>{st.current.dragHud=false;try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(err){}}},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-700/80 pb-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"w-4 h-4 rounded-full text-slate-950 font-bold flex items-center justify-center text-[10px]",style:{background:hudColor.badge}},selIdx+1),/*#__PURE__*/React.createElement("span",{className:"font-bold text-white uppercase tracking-wider"},"Band ",selIdx+1)),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{title:"Bypass band",onClick:()=>{const s=st.current;s.bands[s.selected].active=!s.bands[s.selected].active;applyAll(mm=>mm.setBand(s.selected,{active:s.bands[s.selected].active}));commit();setTick(t=>t+1);},className:`w-5 h-5 rounded flex items-center justify-center text-[10px] ${sel.active?'bg-slate-800 text-slate-400':'bg-amber-600 text-white'}`},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-power-off"})),/*#__PURE__*/React.createElement("button",{title:"Delete band",onClick:()=>{const s=st.current;s.bands.splice(s.selected,1);s.selected=null;applyAll(mm=>mm.syncBands(s.bands));commit();setTick(t=>t+1);},className:"w-5 h-5 rounded bg-slate-800 hover:bg-red-600 text-slate-400 hover:text-white flex items-center justify-center text-[10px]"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-xmark"})))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[9px] text-slate-400 uppercase tracking-widest mb-1"},"Filter Shape"),/*#__PURE__*/React.createElement("select",{value:sel.type,onChange:ev=>{const s=st.current;s.bands[s.selected].type=ev.target.value;applyAll(mm=>mm.setBand(s.selected,{type:ev.target.value}));commit();setTick(t=>t+1);},className:"w-full bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1 text-[11px] outline-none"},['peaking','highpass','lowpass','lowshelf','highshelf','notch','bandpass'].map(t=>/*#__PURE__*/React.createElement("option",{key:t,value:t},t==='peaking'?'Bell / Peaking':t==='highpass'?'Low Cut / High Pass':t==='lowpass'?'High Cut / Low Pass':t==='lowshelf'?'Low Shelf':t==='highshelf'?'High Shelf':t==='notch'?'Notch / Band Stop':'Band Pass')))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-2 text-center bg-slate-950/80 p-2 rounded-lg border border-slate-800"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500"},"FREQ"),/*#__PURE__*/React.createElement("div",{className:"text-teal-300 font-bold text-[11px]"},sel.freq>=1000?(sel.freq/1000).toFixed(2)+' kHz':Math.round(sel.freq)+' Hz')),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500"},"GAIN"),/*#__PURE__*/React.createElement("div",{className:"text-amber-400 font-bold text-[11px]"},eqproBandHasGain(sel.type)?(sel.gain>0?'+':'')+sel.gain.toFixed(1)+' dB':'0.0 dB')),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500"},"Q"),/*#__PURE__*/React.createElement("div",{className:"text-purple-400 font-bold text-[11px]"},sel.q.toFixed(1)))),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 text-center"},"Drag center: Freq & Gain \xB7 Wings/Wheel: Q \xB7 Dbl-click: add/delete"))));};const ExportModal=({open,onClose,exportSettings,setExportSettings,isExporting,exportProgress,onExport})=>{if(!open)return null;const pickDir=async()=>{try{const r=await fetch(`${API_BASE_URL}/api/v1/plugins/pick-dir`,{method:'POST'});if(r.ok){const d=await r.json();if(d&&d.path)setExportSettings(p=>({...p,exportDir:d.path}));}}catch(e){}};return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[300] bg-black/70 backdrop-blur-sm flex items-center justify-center p-6",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"h-9 bg-slate-800/80 border-b border-slate-700 flex items-center justify-between px-3 select-none"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-cyan-300 font-mono flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"download-cloud",className:"w-3.5 h-3.5"})," EXPORT"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-zinc-400 hover:text-white"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"p-4 max-h-[72vh] overflow-y-auto space-y-3"},/*#__PURE__*/React.createElement("div",{className:"bg-[#141414] border border-zinc-800 rounded px-2 py-1.5 space-y-1.5"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"T\xEAn file export"),/*#__PURE__*/React.createElement("input",{type:"text",value:exportSettings.fileName,onChange:e=>setExportSettings(p=>({...p,fileName:e.target.value})),placeholder:"\u0110\u1EC3 tr\u1ED1ng = d\xF9ng t\xEAn m\u1EB7c \u0111\u1ECBnh",className:"w-full bg-[#0d0d0d] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"})),/*#__PURE__*/React.createElement("div",{className:"flex items-end gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex-1 min-w-0"},/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Th\u01B0 m\u1EE5c export"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 bg-[#0d0d0d] border border-zinc-800 rounded px-1.5 py-0.5 min-h-[22px]"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder text-cyan-400 text-[10px] shrink-0"}),exportSettings.exportDir?/*#__PURE__*/React.createElement("a",{className:"text-cyan-400 underline truncate cursor-pointer",title:exportSettings.exportDir,onClick:pickDir},exportSettings.exportDir):/*#__PURE__*/React.createElement("span",{className:"text-zinc-600 truncate"},"Ch\u01B0a ch\u1ECDn \u2014 xu\u1EA5t s\u1EBD m\u1EDF Save-As"))),/*#__PURE__*/React.createElement("button",{onClick:pickDir,className:"shrink-0 px-2 py-1 bg-cyan-700 hover:bg-cyan-600 text-white rounded text-[10px] font-bold flex items-center gap-1"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder-open text-[10px]"})," Ch\u1ECDn th\u01B0 m\u1EE5c")),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1.5 cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:!!exportSettings.rememberExportDir,onChange:e=>setExportSettings(p=>({...p,rememberExportDir:e.target.checked})),className:"accent-cyan-500 w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-300"},"L\u01B0u th\u01B0 m\u1EE5c export l\xE0m m\u1EB7c \u0111\u1ECBnh"))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Ngu\u1ED3n"),/*#__PURE__*/React.createElement("select",{value:exportSettings.source,onChange:e=>setExportSettings(p=>({...p,source:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"project"},"Project (Mix)"),/*#__PURE__*/React.createElement("option",{value:"track_mix"},"Track Selection"),/*#__PURE__*/React.createElement("option",{value:"active_clip"},"Active Clip"),/*#__PURE__*/React.createElement("option",{value:"clip_selection"},"Clip Selection"))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"\u0110\u1ECBnh d\u1EA1ng"),/*#__PURE__*/React.createElement("select",{value:exportSettings.format,onChange:e=>setExportSettings(p=>({...p,format:e.target.value,sampleRate:'44100',bitDepth:'16',quality:'44khz'})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"wav"},"WAV"),/*#__PURE__*/React.createElement("option",{value:"mp3"},"MP3"),/*#__PURE__*/React.createElement("option",{value:"ogg"},"OGG")))),exportSettings.format==='wav'?/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-2"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"SR (Hz)"),/*#__PURE__*/React.createElement("select",{value:exportSettings.sampleRate,onChange:e=>setExportSettings(p=>({...p,sampleRate:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"44100"},"44100"),/*#__PURE__*/React.createElement("option",{value:"48000"},"48000"),/*#__PURE__*/React.createElement("option",{value:"96000"},"96000"))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Bit"),/*#__PURE__*/React.createElement("select",{value:exportSettings.bitDepth,onChange:e=>setExportSettings(p=>({...p,bitDepth:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"16"},"16"),/*#__PURE__*/React.createElement("option",{value:"24"},"24"),/*#__PURE__*/React.createElement("option",{value:"float"},"32 float"))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"K\xEAnh"),/*#__PURE__*/React.createElement("select",{value:exportSettings.channels,onChange:e=>setExportSettings(p=>({...p,channels:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"mono"},"Mono"),/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo")))):/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Ch\u1EA5t l\u01B0\u1EE3ng"),/*#__PURE__*/React.createElement("select",{value:exportSettings.quality,onChange:e=>setExportSettings(p=>({...p,quality:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"44khz"},"44kHz"),/*#__PURE__*/React.createElement("option",{value:"lossless"},"Lossless"))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"K\xEAnh"),/*#__PURE__*/React.createElement("select",{value:exportSettings.channels,onChange:e=>setExportSettings(p=>({...p,channels:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"mono"},"Mono"),/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo")))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 bg-[#141414] border border-zinc-800 rounded px-2 py-1.5"},/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1.5 cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:!!exportSettings.dither,onChange:e=>setExportSettings(p=>({...p,dither:e.target.checked})),className:"accent-cyan-500 w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-300"},"Dither TPDF (PCM 16/24)")),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1.5 cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:!!exportSettings.normalize,onChange:e=>setExportSettings(p=>({...p,normalize:e.target.checked})),className:"accent-cyan-500 w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-300"},"Normalize")),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1.5 cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:!!exportSettings.monoCheck,onChange:e=>setExportSettings(p=>({...p,monoCheck:e.target.checked})),className:"accent-cyan-500 w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-300"},"C\u1EA3nh b\xE1o mono (side/mid)")),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Normalize target"),/*#__PURE__*/React.createElement("select",{value:exportSettings.normalizeTarget,onChange:e=>setExportSettings(p=>({...p,normalizeTarget:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"-1dbtp"},"-1 dBTP"),/*#__PURE__*/React.createElement("option",{value:"-14lufs"},"-14 LUFS")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 pt-1 bg-[#141414] border border-zinc-800 rounded px-2 py-1.5"},/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1.5 cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:!!exportSettings.loop,onChange:e=>setExportSettings(p=>({...p,loop:e.target.checked})),className:"accent-cyan-500 w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-300"},"Xu\u1EA5t loop (kh\xF4ng fade in / kh\xF4ng fade out)")),exportSettings.loop&&/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1 ml-auto"},/*#__PURE__*/React.createElement("span",{className:"text-[7px] text-zinc-500 font-bold uppercase"},"S\u1ED1 \xF4 nh\u1ECBp"),/*#__PURE__*/React.createElement("input",{type:"number",min:"1",max:"256",value:exportSettings.loopBars,onChange:e=>setExportSettings(p=>({...p,loopBars:Math.max(1,parseInt(e.target.value)||1)})),className:"w-16 bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"}))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-2 pt-1 bg-[#141414] border border-zinc-800 rounded px-2 py-1.5"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Title (BWF/INFO)"),/*#__PURE__*/React.createElement("input",{type:"text",value:exportSettings.metaTitle,maxLength:"254",onChange:e=>setExportSettings(p=>({...p,metaTitle:e.target.value})),placeholder:"T\xEAn b\xE0i",className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Artist"),/*#__PURE__*/React.createElement("input",{type:"text",value:exportSettings.metaArtist,maxLength:"254",onChange:e=>setExportSettings(p=>({...p,metaArtist:e.target.value})),placeholder:"Ngh\u1EC7 s\u0129",className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"ISRC"),/*#__PURE__*/React.createElement("input",{type:"text",value:exportSettings.metaIsrc,maxLength:"12",onChange:e=>setExportSettings(p=>({...p,metaIsrc:e.target.value.toUpperCase()})),placeholder:"US-ABC-26-00001",className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 bg-[#141414] border border-zinc-800 rounded px-2 py-1.5"},/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1.5 cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:!!exportSettings.realtime,onChange:e=>setExportSettings(p=>({...p,realtime:e.target.checked})),className:"accent-cyan-500 w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-300"},"Realtime (bounce \u2014 ch\u1EA1y l\u1EA1i project th\u1EADt, gi\u1EEF nguy\xEAn MIDI + FX Rack + Mastering Chain)")))),/*#__PURE__*/React.createElement("div",{className:"p-4 pt-3 border-t border-slate-700 space-y-2"},/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2"},/*#__PURE__*/React.createElement("button",{onClick:onExport,disabled:isExporting,className:"w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"download-cloud",className:"w-3 h-3"})," ",isExporting?'...':'Export'),/*#__PURE__*/React.createElement("button",{onClick:onClose,disabled:isExporting,className:"w-full py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 font-bold rounded text-xs"},"H\u1EE7y")))));};// ── Export progress modal (RIÊNG, không dùng statusbar — vị trí đó cho tools) ──
|
||
// Settings modal đóng ngay khi bấm Export; modal này hiện tiến trình render +
|
||
// encode + ghi file, tự đóng khi xong (success/error qua toast).
|
||
const ExportProgressModal=({open,exportProgress,isExporting,onClose})=>{if(!open)return null;return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[310] bg-black/70 backdrop-blur-sm flex items-center justify-center p-6"},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden"},/*#__PURE__*/React.createElement("div",{className:"h-9 bg-slate-800/80 border-b border-slate-700 flex items-center justify-between px-3 select-none"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-cyan-300 font-mono flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"w-3 h-3 border-2 border-cyan-300 border-t-transparent rounded-full animate-spin"}),"\u0110ANG XU\u1EA4T \xC2M THANH")),/*#__PURE__*/React.createElement("div",{className:"p-5 space-y-3"},/*#__PURE__*/React.createElement("div",{className:"h-2.5 bg-zinc-800 rounded-full overflow-hidden"},/*#__PURE__*/React.createElement("div",{className:"h-full bg-cyan-500 transition-all duration-200",style:{width:(exportProgress>=0?exportProgress:25)+'%'}})),/*#__PURE__*/React.createElement("div",{className:"text-xs text-zinc-400 text-center"},exportProgress>=0?'Đang xuất... '+Math.round(exportProgress)+'%':'Đang xử lý...'),!isExporting&&/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"w-full py-1.5 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 font-bold rounded text-xs"},"\u0110\xF3ng"))));};// --- Embedded VST GUI (frame-capture embed) ------------------------------------
|
||
// Native plugin window duoc park de len dung rect cua panel nay (POST /show
|
||
// kem screen rect, refresh ~250ms de re-anchor khi scroll/resize); pixel cua
|
||
// no duoc hien trong panel qua JPEG frame capture (GET /frame, ~10fps), su
|
||
// kien chuot/ban phim relay bang POST /input — khong con cua so noi, khong
|
||
// can OS focus. Dong panel chi POST /hide (bridge + plugin con song, mo lai
|
||
// nhanh); dong that su (POST /close) tu closeFxGui/closeMasterGui khi chon
|
||
// module khac, hoac tu dong khi onDead (bridge chet). Khong con toolbar.
|
||
const VstGuiEmbed=({url,onClose,onDead})=>{const imgRef=React.useRef(null);const boxRef=React.useRef(null);const deadRef=React.useRef(false);const screenRectRef=React.useRef(null);const[frameLoaded,setFrameLoaded]=React.useState(false);const post=payload=>{try{fetch(url+'/input',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).catch(()=>{});}catch(e){}};// Poll frame capture ~100ms + ping 2s de phat hien bridge chet.
|
||
React.useEffect(()=>{deadRef.current=false;setFrameLoaded(false);const tick=setInterval(()=>{const img=imgRef.current;if(img&&img.complete)img.src=url+'/frame?t='+Date.now();},100);const ping=setInterval(()=>{fetch(url+'/ping').then(r=>{if(!r.ok)throw new Error('bad');}).catch(()=>{if(!deadRef.current){deadRef.current=true;onDead&&onDead();}});},2000);// Show ngay khi mount, truoc khi co rect: bridge hien lai native window
|
||
// o vi tri cu; ~250ms sau rect refresh ben duoi se re-anchor de len panel.
|
||
try{fetch(url+'/show',{method:'POST'}).catch(()=>{});}catch(e){}return()=>{clearInterval(tick);clearInterval(ping);// Panel dong -> hide native window (bridge con song, mo lai nhanh).
|
||
// Dong that su (POST /close + teardown engine) tu closeFxGui/closeMasterGui
|
||
// khi chon module/chain khac, hoac tu dong khi onDead (bridge chet).
|
||
try{fetch(url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}};// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[url]);// Screen rect (physical px) cua panel cho bridge re-anchor + real-input:
|
||
// Tauri innerPosition + scaleFactor; fallback window.screenX/Y khi khong
|
||
// chay trong Tauri. Refresh ~250ms de theo scroll/resize.
|
||
React.useEffect(()=>{let alive=true;const maybeShow=()=>{const r=screenRectRef.current;if(r&&r.w>=4&&r.h>=4){// Re-POST /show moi lan refresh de native window re-anchor theo panel
|
||
// khi scroll/drag/resize. Idempotent: bridge skip SetWindowPos khi
|
||
// khong co gi doi.
|
||
try{fetch(url+'/show',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rect:r})}).catch(()=>{});}catch(e){}}};const refresh=()=>{const box=boxRef.current;if(!box)return;const r=box.getBoundingClientRect();if(r.width<4||r.height<4)return;try{const tw=window.__TAURI__&&window.__TAURI__.window;if(tw&&tw.getCurrentWindow){Promise.all([tw.getCurrentWindow().innerPosition(),tw.getCurrentWindow().scaleFactor()]).then(([pos,sf])=>{if(!alive||!boxRef.current)return;const rb=boxRef.current.getBoundingClientRect();screenRectRef.current={x:Math.round(pos.x+rb.left*sf),y:Math.round(pos.y+rb.top*sf),w:Math.round(rb.width*sf),h:Math.round(rb.height*sf)};maybeShow();}).catch(()=>{if(!alive||!boxRef.current)return;const rb=boxRef.current.getBoundingClientRect();screenRectRef.current={x:Math.round((window.screenX||0)+rb.left),y:Math.round((window.screenY||0)+rb.top),w:Math.round(rb.width),h:Math.round(rb.height)};maybeShow();});}else{screenRectRef.current={x:Math.round((window.screenX||0)+r.left),y:Math.round((window.screenY||0)+r.top),w:Math.round(r.width),h:Math.round(r.height)};maybeShow();}}catch(e){}};refresh();const iv=setInterval(refresh,250);return()=>{alive=false;clearInterval(iv);};// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[url]);const pt=e=>{const box=boxRef.current,img=imgRef.current;if(!box||!img||!img.naturalWidth||!img.naturalHeight)return null;const r=box.getBoundingClientRect();if(r.width===0||r.height===0)return null;return{x:Math.round((e.clientX-r.left)/r.width*img.naturalWidth),y:Math.round((e.clientY-r.top)/r.height*img.naturalHeight)};};const handle=e=>{const p=pt(e);if(!p)return;post({type:e.type==='mousedown'?'mousedown':e.type==='mouseup'?'mouseup':'mousemove',x:p.x,y:p.y,button:e.button,buttons:e.buttons,rect:screenRectRef.current||undefined});};return/*#__PURE__*/React.createElement("div",{className:"relative w-full h-full flex flex-col"},/*#__PURE__*/React.createElement("div",{ref:boxRef,tabIndex:0,className:"flex-1 relative bg-slate-950 border border-slate-800 rounded-lg overflow-hidden outline-none",onMouseDown:handle,onMouseUp:handle,onMouseMove:handle,onMouseLeave:()=>post({type:'mouseup',x:-1,y:-1,button:0}),onWheel:e=>{const p=pt(e);if(p)post({type:'wheel',x:p.x,y:p.y,deltaY:e.deltaY,rect:screenRectRef.current||undefined});},onKeyDown:e=>{e.preventDefault();post({type:'keydown',keyCode:e.keyCode||e.which});},onKeyUp:e=>{e.preventDefault();post({type:'keyup',keyCode:e.keyCode||e.which});}},!frameLoaded&&/*#__PURE__*/React.createElement("div",{className:"absolute inset-0 flex items-center justify-center bg-slate-950 z-10"},/*#__PURE__*/React.createElement("span",{className:"text-[11px] text-slate-500 font-mono animate-pulse"},"\u0110ang t\u1EA3i plugin VST \u2014 GUI embed s\u1EBD hi\u1EC7n khi plugin s\u1EB5n s\xE0ng...")),/*#__PURE__*/React.createElement("img",{ref:imgRef,alt:"VST GUI",className:"w-full h-full object-contain select-none pointer-events-none",draggable:false,onLoad:()=>setFrameLoaded(true)})));};// --- Native VST GUI anchor (M7: hướng B) --------------------------------------
|
||
// Native editor window ("FX slot N") là OS window thật do engine tạo
|
||
// (juce_fx_bridge). Panel này là chỗ neo: đo rect ô anchor bằng px màn hình
|
||
// vật lý (~250ms, cùng công thức VstGuiEmbed) rồi POST /fx-gui/rect → engine
|
||
// SetWindowPos lại mỗi khi rect đổi (reconcile 30ms, dedupe per-window).
|
||
// M8: bo footer (hang duoi cung) - o anchor chiem tron panel, khong nut Dong.
|
||
// Dong GUI that = X cua so plugin / nut remove module (engine giu DSP+preset
|
||
// toi khi closeFxGui/closeMasterGui).
|
||
const NativeGuiAnchor=({path})=>{const boxRef=React.useRef(null);const lastRectRef=React.useRef(null);React.useEffect(()=>{let alive=true;const send=r=>{if(!alive||!path||!window.SonicAPI||!window.SonicAPI.rectFxGui)return;const lr=lastRectRef.current;if(lr&&lr.x===r.x&&lr.y===r.y&&lr.w===r.w&&lr.h===r.h)return;lastRectRef.current=r;try{window.SonicAPI.rectFxGui({path,rect:r}).catch(()=>{});}catch(e){}};const refresh=()=>{const box=boxRef.current;if(!box)return;const r=box.getBoundingClientRect();if(r.width<4||r.height<4)return;try{const tw=window.__TAURI__&&window.__TAURI__.window;if(tw&&tw.getCurrentWindow){Promise.all([tw.getCurrentWindow().innerPosition(),tw.getCurrentWindow().scaleFactor()]).then(([pos,sf])=>{if(!alive||!boxRef.current)return;const rb=boxRef.current.getBoundingClientRect();send({x:Math.round(pos.x+rb.left*sf),y:Math.round(pos.y+rb.top*sf),w:Math.round(rb.width*sf),h:Math.round(rb.height*sf)});}).catch(()=>{if(!alive||!boxRef.current)return;const rb=boxRef.current.getBoundingClientRect();send({x:Math.round((window.screenX||0)+rb.left),y:Math.round((window.screenY||0)+rb.top),w:Math.round(rb.width),h:Math.round(rb.height)});});}else{send({x:Math.round((window.screenX||0)+r.left),y:Math.round((window.screenY||0)+r.top),w:Math.round(r.width),h:Math.round(r.height)});}}catch(e){}};refresh();const iv=setInterval(refresh,250);return()=>{alive=false;clearInterval(iv);};// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[path]);return/*#__PURE__*/React.createElement("div",{ref:boxRef,className:"flex-1 min-h-0 relative bg-slate-950 border border-slate-800 rounded-lg overflow-hidden select-none"},/*#__PURE__*/React.createElement("div",{className:"absolute inset-0 flex items-center justify-center gap-2 text-slate-500 pointer-events-none"},/*#__PURE__*/React.createElement("i",{"data-lucide":"monitor",className:"w-5 h-5 text-slate-600"}),/*#__PURE__*/React.createElement("span",{className:"text-[11px] font-mono"},"GUI native \u0111ang m\u1EDF \u2014 c\u1EEDa s\u1ED5 plugin \"FX slot N\" neo \u0111\xFAng \xF4 n\xE0y")));};const vstRow=(s,idx,toggleV,changeV,uploadV,moveV,removeV,fxl,chainLen,openGui,activeIdx=-1,onSelect=null)=>/*#__PURE__*/React.createElement("div",{key:idx,onClick:()=>onSelect&&onSelect(s,idx),className:`flex items-center gap-2 bg-slate-900/60 border rounded-lg px-2 py-1.5 flex-wrap cursor-pointer transition-all ${activeIdx===idx?'border-teal-400 bg-slate-800/80':'border-slate-800 hover:border-slate-600'}`},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleV(idx);},className:`w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 ${!s.bypass?'bg-teal-500 text-slate-950':'bg-slate-700 text-slate-300'}`,title:s.bypass?'Bypass':'Active'},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-300 font-mono w-6 shrink-0"},idx+1),/*#__PURE__*/React.createElement("select",{value:s.path||'',onClick:e=>e.stopPropagation(),onChange:e=>changeV(idx,e.target.value),className:"flex-1 min-w-[200px] bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1 text-[11px] outline-none focus:border-teal-500"},/*#__PURE__*/React.createElement("option",{value:""},"\u2014 Ch\u1ECDn VST FX \u2014"),(fxl||[]).map(f=>/*#__PURE__*/React.createElement("option",{key:f.path,value:f.path},f.name||f.id))),/*#__PURE__*/React.createElement("span",{className:`text-[9px] font-mono ${s.preset_b64?'text-emerald-400':'text-slate-600'}`},s.preset_b64?'✓ preset':'no preset'),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();uploadV(idx);},className:"px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[10px] font-semibold rounded transition",title:"Upload .vstpreset (Save preset)"},"Preset"),openGui&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();openGui(s,idx);},disabled:!s.path,className:"px-2 py-1 bg-violet-800 hover:bg-violet-700 disabled:opacity-30 text-violet-100 text-[10px] font-semibold rounded transition",title:"M\u1EDF GUI c\u1EE7a VST (ch\u1EC9nh th\xF4ng s\u1ED1 tr\u1EF1c ti\u1EBFp)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"monitor",className:"w-3 h-3"})," GUI"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();moveV(idx,-1);},disabled:idx===0,className:"w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30",title:"L\xEAn"},/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-up",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();moveV(idx,1);},disabled:idx===chainLen-1,className:"w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30",title:"Xu\u1ED1ng"},/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-down",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeV(idx);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5",title:"X\xF3a slot"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));// ── VST FX Plugin Picker (spec vst_fx_panel_integration.md §II) ──────────
|
||
// Modal chọn VST FX theo category (heuristic từ backend list_fx.category).
|
||
// Dùng chung FXRackModal + MasteringModal: [+] → picker → chèn VST card.
|
||
const VST_FX_CATEGORIES=[{id:'all',label:'Tất cả'},{id:'equalizer',label:'Equalizers / Dynamic EQ'},{id:'reverb_delay',label:'Reverbs & Delays'},{id:'comp_limiter',label:'Compressors & Limiters'},{id:'distortion',label:'Distortion / Saturation'},{id:'utility',label:'Utility'}];const VstPluginPicker=({open,onClose,plugins,onPick,loading})=>{const[cat,setCat]=React.useState('all');React.useEffect(()=>{if(open)setCat('all');},[open]);if(!open)return null;const list=(plugins||[]).filter(p=>cat==='all'||(p.category||'utility')===cat);return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[125] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl max-h-[80vh] flex flex-col",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider font-mono"},"CH\u1ECCN VST FX \u2014 PLUGIN PICKER"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-white"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-wrap gap-1.5 shrink-0"},VST_FX_CATEGORIES.map(c=>/*#__PURE__*/React.createElement("button",{key:c.id,onClick:()=>setCat(c.id),className:`px-2.5 py-1 rounded text-[10px] font-semibold transition ${cat===c.id?'bg-teal-700 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300'}`},c.label))),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-y-auto oz-scrollbar grid grid-cols-2 gap-2"},loading&&/*#__PURE__*/React.createElement("div",{className:"col-span-2 text-[11px] text-amber-300 font-mono py-10 text-center animate-pulse"},"\u0110ang qu\xE9t VST FX..."),!loading&&list.length===0&&/*#__PURE__*/React.createElement("div",{className:"col-span-2 text-[11px] text-slate-500 font-mono py-10 text-center"},"Kh\xF4ng c\xF3 VST FX trong m\u1EE5c n\xE0y."),!loading&&list.map(f=>/*#__PURE__*/React.createElement("button",{key:f.path,onClick:()=>{onPick(f);onClose();},className:"p-2 bg-slate-950 hover:bg-slate-800 border border-slate-800 rounded-lg text-left space-y-0.5 transition-colors"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-teal-300 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plug",className:"w-3 h-3"})," ",f.name||f.id),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono truncate"},f.path))))));};// GUI embed state survives FXRackModal unmount so the native window
|
||
// re-shows automatically when the rack is reopened (close/reopen bug).
|
||
let _persistFxGuiOpen=null;// Track id whose rack owns the currently-open embedded GUI (for save).
|
||
let _persistFxGuiTrackId=null;// Latest preset_b64 captured from each plugin's embedded GUI (poll /preset)
|
||
// - merged into the project on save so knob tweaks survive reload.
|
||
let _capturedPresetByPath={};// Baseline of each plugin's GUI state at embed-open time. /preset round-trips
|
||
// through getState/applyPreset and the blob can differ from the value the
|
||
// session started with; restarting the chain on that first difference reloads
|
||
// the VST and crackles. First capture after open = baseline (no restart); only
|
||
// a CHANGE vs baseline schedules the ~3s restart that syncs realtime to GUI.
|
||
let _fxGuiBaselineByPath={};// Debounced restart khi preset GUI đổi (preset poll) — _capturedPresetByPath
|
||
// đổi làm chainKey đổi nhưng không ai gọi fxRtStart/reconcile → session
|
||
// realtime GIỮ preset cũ (xoay knob trong GUI không đổi âm realtime). Restart
|
||
// ~3s sau khi knob DỪNG xoay (không restart mỗi poll 1.5s giữa lúc kéo — load
|
||
// VST ~4s ngắt âm). fn tự early-return khi chainKey không đổi.
|
||
let _presetRestartTimers={};function _schedulePresetRestart(key,fn,ms){if(_presetRestartTimers[key])clearTimeout(_presetRestartTimers[key]);_presetRestartTimers[key]=setTimeout(()=>{delete _presetRestartTimers[key];try{fn();}catch(e){}},ms);}const FXRackModal=({track,onUpdateTrack,onClose})=>{const[activeType,setActiveType]=React.useState('eq');const[addModuleOpen,setAddModuleOpen]=React.useState(false);const dragChainIndexRef=React.useRef(null);// Wave Observer scope state (unified_fx_rack_panel_update.md §III.3)
|
||
const[scopeChannel,setScopeChannel]=React.useState('stereo');const[scopeMode,setScopeMode]=React.useState('waveform');const[scopeDuration,setScopeDuration]=React.useState(2.0);const[scopeZoom,setScopeZoom]=React.useState(0);const[scopePaused,setScopePaused]=React.useState(false);const scopeCanvasRef=React.useRef(null);const scopeMeterLRef=React.useRef(null);const scopeMeterRRef=React.useRef(null);const eqCurveRef=React.useRef(null);const scopeStateRef=React.useRef({L:null,R:null,head:0,len:0,tmpL:null,tmpR:null,freq:null});// VST FX (native_bridge --render-fx) — list VST3 effect plugins + preview
|
||
const[fxList,setFxList]=React.useState([]);const[fxLoading,setFxLoading]=React.useState(false);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listFx){setFxLoading(true);window.SonicAPI.listFx().then(d=>setFxList(d&&d.plugins||[])).catch(()=>setFxList([])).finally(()=>setFxLoading(false));}},[track&&track.id]);const previewAudioRef=React.useRef(null);const[vstBypass,setVstBypass]=React.useState(!!(track&&track.vstFxBypass));const[activeVstIdx,setActiveVstIdx]=React.useState(-1);const[vstPickerOpen,setVstPickerOpen]=React.useState(false);if(!track)return null;const chain=track.fxChain||[];// ── VST FX chain (native_bridge --render-fx) — tham chiếu từ unified chain ──
|
||
const vstChain=chain.filter(m=>m&&m.type==='vst3');// Unified chain strip: builtin DSP + VST FX chung 1 chain (kéo đổi thứ tự giữa cả 2 loại).
|
||
const unifiedChain=chain.map((m,i)=>({kind:m&&m.type==='vst3'?'vst':'builtin',m,i}));const setVstChain=next=>{const builtin=chain.filter(m=>!m||m.type!=='vst3');const merged=[...builtin,...next.map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass}))];if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:merged,vstFxChain:next,vstFxBypass:vstBypass});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id,{fxChain:merged,vstFxChain:next,vstFxBypass:vstBypass});};// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 3)
|
||
const[fxGuiOpen,setFxGuiOpenState]=React.useState(_persistFxGuiOpen);// { path, url } | null
|
||
const setFxGuiOpen=v=>{if(_persistFxGuiOpen&&_persistFxGuiOpen.url)_fxGuiUnregisterSource(_persistFxGuiOpen.url);_persistFxGuiOpen=v;_persistFxGuiTrackId=v?track&&track.id:null;if(v&&v.url)_fxGuiRegisterSource(v.url,track&&track.id);setFxGuiOpenState(v);};const fxGuiOpenRef=React.useRef(null);fxGuiOpenRef.current=fxGuiOpen;// Bug2: GUI native (cua so OS) luon noi tren modal HTML - hide khi mo picker, show lai khi dong.
|
||
const fxHiddenForPickerRef=React.useRef(null);React.useEffect(()=>{if(vstPickerOpen){const g=fxGuiOpenRef.current;if(g&&g.native&&!g.url&&!fxHiddenForPickerRef.current){const path=g.path;try{window.SonicAPI&&window.SonicAPI.hideFxGui({path}).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}catch(e){}fxHiddenForPickerRef.current=path;}}else if(fxHiddenForPickerRef.current){const path=fxHiddenForPickerRef.current;fxHiddenForPickerRef.current=null;try{window.SonicAPI&&window.SonicAPI.showFxGui({path}).catch(()=>{});}catch(e){}}},[vstPickerOpen]);const closeFxGui=()=>{const g=fxGuiOpenRef.current;if(g){if(g.url){// Capture preset CUỐI trước khi đóng (poll 1.5s có thể chưa kịp khi chỉnh
|
||
// xong đóng ngay) — gọi /preset TRƯỚC /close (close kill process bridge).
|
||
try{fetch(g.url+'/preset').then(r=>r.json()).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{}).then(()=>{try{fetch(g.url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}});}catch(e){try{fetch(g.url+'/close',{method:'POST'}).catch(()=>{});}catch(e2){}}}else if(g.native){// M6: GUI native (cửa sổ ngoài "FX slot N") — engine capture preset + đóng editor.
|
||
try{window.SonicAPI&&window.SonicAPI.closeFxGui({path:g.path}).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}catch(e){}}}setFxGuiOpen(null);if(g&&g.path){const _tk='track:'+(track&&track.id||'')+':'+g.path;if(_presetRestartTimers[_tk]){clearTimeout(_presetRestartTimers[_tk]);delete _presetRestartTimers[_tk];}delete _fxGuiBaselineByPath[g.path];}if(onUpdateTrack&&track)onUpdateTrack(track.id,{vstGuiOpenPath:null,vstGuiOpenName:null});};React.useEffect(()=>()=>{const g=fxGuiOpenRef.current;if(g){if(g.url){try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}else if(g.native){try{window.SonicAPI&&window.SonicAPI.hideFxGui({path:g.path}).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}catch(e){}}const _tk='track:'+(track&&track.id||'')+':'+g.path;if(_presetRestartTimers[_tk]){clearTimeout(_presetRestartTimers[_tk]);delete _presetRestartTimers[_tk];}if(g.path)delete _fxGuiBaselineByPath[g.path];}},[]);// Capture GUI knob tweaks while the embedded GUI is open (bridge /preset).
|
||
React.useEffect(()=>{if(!fxGuiOpen||!fxGuiOpen.url)return;const iv=setInterval(()=>{fetch(fxGuiOpen.url+'/preset').then(r=>r.json()).then(d=>{if(d&&d.preset_b64){const _p=fxGuiOpen.path;const _b=_fxGuiBaselineByPath[_p];if(_b===undefined){_fxGuiBaselineByPath[_p]=d.preset_b64;_capturedPresetByPath[_p]=d.preset_b64;return;}if(d.preset_b64!==_b){_fxGuiBaselineByPath[_p]=d.preset_b64;_capturedPresetByPath[_p]=d.preset_b64;const _tid=track&&track.id;_schedulePresetRestart('track:'+(_tid||'')+':'+_p,()=>{if(_tid&&window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(_tid,{});},3000);}}}).catch(()=>{});},1500);return()=>clearInterval(iv);// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[fxGuiOpen]);// Reopen the saved GUI when the rack is mounted after a project restore.
|
||
React.useEffect(()=>{if(!track||!track.vstGuiOpenPath||fxGuiOpen)return;const s=(track.fxChain||[]).find(x=>x.path===track.vstGuiOpenPath);if(!s||!window.SonicAPI||!window.SonicAPI.openFxGui)return;window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true,shm:fxRtTrackShmName(track&&track.id),preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||''}).then(r=>{if(r&&r.embed_url)setFxGuiOpen({path:s.path,url:r.embed_url});else if(r&&r.native)setFxGuiOpen({path:s.path,url:null,name:s.name||s.path,native:true});}).catch(()=>{});// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[track&&track.id,track&&track.vstGuiOpenPath]);const toggleVst=idx=>setVstChain(vstChain.map((s,i)=>i===idx?{...s,bypass:!s.bypass}:s));const removeVst=idx=>{const rem=vstChain[idx];if(rem&&fxGuiOpen&&fxGuiOpen.path===rem.path)closeFxGui();if(rem)delete _capturedPresetByPath[rem.path];if(idx===activeVstIdx)setActiveVstIdx(-1);setVstChain(vstChain.filter((_,i)=>i!==idx));};const moveVst=(idx,dir)=>{const j=idx+dir;if(j<0||j>=vstChain.length)return;const next=[...vstChain];const mv=next.splice(idx,1)[0];next.splice(j,0,mv);setVstChain(next);};const openVstGui=(s,idx)=>{if(!s||!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}if(fxGuiOpen&&fxGuiOpen.path===s.path){if(idx!==undefined)setActiveVstIdx(idx);return;}if(fxGuiOpen)closeFxGui();if(idx!==undefined)setActiveVstIdx(idx);if(!window.SonicAPI||!window.SonicAPI.openFxGui){window.showToast&&window.showToast('Máy này không hỗ trợ mở GUI VST','warning');return;}window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true,shm:fxRtTrackShmName(track&&track.id),preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||''}).then(r=>{if(r&&r.native){setFxGuiOpen({path:s.path,url:null,name:s.name||s.path,native:true});if(onUpdateTrack&&track)onUpdateTrack(track.id,{vstGuiOpenPath:s.path,vstGuiOpenName:s.name||s.path});}else if(r&&r.embed_url){setFxGuiOpen({path:s.path,url:r.embed_url,name:s.name||s.path});if(onUpdateTrack&&track)onUpdateTrack(track.id,{vstGuiOpenPath:s.path,vstGuiOpenName:s.name||s.path});}else if(r&&r.already_running&&r.embed_url){setFxGuiOpen({path:s.path,url:r.embed_url,name:s.name||s.path});if(onUpdateTrack&&track)onUpdateTrack(track.id,{vstGuiOpenPath:s.path,vstGuiOpenName:s.name||s.path});}else if(r&&r.already_running)window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');else window.showToast&&window.showToast('Không mở được GUI embed','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở GUI: '+(err.message||err),'error'));};const addVst=path=>{const fx=(fxList||[]).find(f=>f.path===path);if(!fx)return;const next=[...vstChain,{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}];setVstChain(next);setActiveVstIdx(next.length-1);};const changeVstPlugin=(idx,path)=>{const fx=(fxList||[]).find(f=>f.path===path);delete _capturedPresetByPath[path];setVstChain(vstChain.map((s,i)=>i===idx?{...s,path,name:fx?fx.name||fx.id:s.name,preset_b64:''}:s));};const uploadVstPreset=idx=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;const reader=new FileReader();reader.onload=()=>{const b64=String(reader.result||'').split(',')[1]||'';setVstChain(vstChain.map((s,i)=>i===idx?{...s,preset_b64:b64}:s));window.showToast&&window.showToast('Đã gắn preset .vstpreset vào slot '+(idx+1),'success');};reader.readAsDataURL(f);};inp.click();};const previewFx=async()=>{try{if(previewAudioRef.current){previewAudioRef.current.pause();previewAudioRef.current=null;}if(!window.__renderProjectPreview){window.showToast&&window.showToast('Không có hàm render preview','warning');return;}window.showToast&&window.showToast('Đang render offline (bridge)...','info');const res=await window.__renderProjectPreview();const au=new Audio((window.API_BASE_URL||'')+res.url);previewAudioRef.current=au;au.onended=()=>{previewAudioRef.current=null;};au.play().then(()=>window.showToast&&window.showToast('Preview offline — âm thật = âm export (VST FX)','success')).catch(()=>{});}catch(e){/* toast ở __renderProjectPreview */}};const setChain=next=>{if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:next});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id,{fxChain:next});};const paramsOf=m=>({...(TRACK_FX_DEFAULTS[m.type]||{}),...(m.params||{})});const setParams=(idx,patch)=>{const next=chain.map((m,i)=>i===idx?{...m,params:{...paramsOf(m),...patch}}:m);setChain(next);};const toggleMod=idx=>{const next=chain.map((m,i)=>i===idx?{...m,active:!(m.active!==false)}:m);setChain(next);};const removeMod=idx=>setChain(chain.filter((_,i)=>i!==idx));const addMod=type=>{const def=TRACK_FX_DEFAULTS[type]||{};// Deep-clone default params so each chain entry owns its data (bands array
|
||
// especially — shared references would corrupt other modules' state).
|
||
const params=Array.isArray(def.bands)?{...def,bands:JSON.parse(JSON.stringify(def.bands))}:{...def};setChain([...chain,{type,active:true,params}]);setActiveType(type);setActiveVstIdx(-1);setAddModuleOpen(false);};const applyPreset=key=>{const preset=TRACK_EQ_PRESETS[key];if(!preset)return;const idx=chain.findIndex(m=>m.type==='eq'&&m.active!==false);if(idx>=0)setParams(idx,{g1:preset.g[0],g2:preset.g[1],g3:preset.g[2],g4:preset.g[3]});};const activeMod=chain.find(m=>m.type===activeType)||chain[chain.length-1]||null;const activeIdx=chain.findIndex(m=>m===activeMod);const ap=activeMod?paramsOf(activeMod):{};const slider=(label,val,min,max,step,color,onChange,fmt)=>/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-[11px] font-mono"},/*#__PURE__*/React.createElement("span",{style:{color,fontWeight:700}},label),/*#__PURE__*/React.createElement("span",{className:"text-slate-300"},fmt?fmt(val):val)),/*#__PURE__*/React.createElement("input",{type:"range",min:min,max:max,step:step||0.1,value:val,onChange:e=>onChange(parseFloat(e.target.value)),className:"w-full h-1 cursor-pointer",style:{accentColor:color}}));// ── Wave Observer real-time scope render loop ──
|
||
React.useEffect(()=>{const canvas=scopeCanvasRef.current;if(!canvas||!track)return;const st=scopeStateRef.current;let raf=null;const draw=()=>{raf=requestAnimationFrame(draw);const w=canvas.width,h=canvas.height;const ctx=canvas.getContext('2d');const ana=window.__getTrackScopeAnalysers?window.__getTrackScopeAnalysers(track.id):null;// V45.46 throttle: inactive (chưa play / paused) → vẽ grid+message ~mỗi 12
|
||
// frame — rAF vẫn chạy làm poll (play start không re-run effect vì deps
|
||
// thiếu isPlaying) nhưng skip các frame khác → hết repaint canvas 60fps.
|
||
if(!ana||scopePaused){st.frame=(st.frame||0)+1;if(st.frame%12!==1)return;ctx.clearRect(0,0,w,h);// Grid
|
||
ctx.strokeStyle='rgba(51, 65, 85, 0.35)';ctx.lineWidth=1;ctx.font='9px monospace';ctx.fillStyle='#475569';for(let i=1;i<6;i++){const y=i/6*h;ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();}ctx.fillText('+0.0 dB',4,12);ctx.fillText('-6.0 dB',4,h/2-4);ctx.fillText('-12 dB',4,h-6);if(!ana){ctx.fillStyle='#334155';ctx.font='11px monospace';ctx.fillText('Không có tín hiệu — bấm Play để xem waveform',w/2-120,h/2);}return;}ctx.clearRect(0,0,w,h);// Grid
|
||
ctx.strokeStyle='rgba(51, 65, 85, 0.35)';ctx.lineWidth=1;ctx.font='9px monospace';ctx.fillStyle='#475569';for(let i=1;i<6;i++){const y=i/6*h;ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();}ctx.fillText('+0.0 dB',4,12);ctx.fillText('-6.0 dB',4,h/2-4);ctx.fillText('-12 dB',4,h-6);const sr=ana.sr||44100;const maxS=Math.floor(5*sr);if(!st.L||st.L.length!==maxS){st.L=new Float32Array(maxS);st.R=new Float32Array(maxS);st.head=0;st.len=0;}if(!st.tmpL||st.tmpL.length!==ana.L.fftSize){st.tmpL=new Float32Array(ana.L.fftSize);st.tmpR=new Float32Array(ana.R.fftSize);}ana.L.getFloatTimeDomainData(st.tmpL);ana.R.getFloatTimeDomainData(st.tmpR);const nRead=st.tmpL.length;for(let i=0;i<nRead;i++){st.L[st.head]=st.tmpL[i];st.R[st.head]=st.tmpR[i];st.head=(st.head+1)%maxS;}st.len=Math.min(st.len+nRead,maxS);const n=Math.min(st.len,Math.floor(scopeDuration*sr));if(n<2)return;const amp=Math.pow(10,scopeZoom/20);const getS=i=>{// i in [0,n)
|
||
const idx=(st.head-n+i+maxS)%maxS;const l=st.L[idx],r=st.R[idx];if(scopeChannel==='left')return[l,null];if(scopeChannel==='right')return[r,null];if(scopeChannel==='mid')return[(l+r)/2,null];if(scopeChannel==='side')return[(l-r)/2,null];return[l,r];};// Meters
|
||
let pL=0,pR=0;for(let i=0;i<n;i+=8){const idx=(st.head-n+i+maxS)%maxS;const al=Math.abs(st.L[idx]),ar=Math.abs(st.R[idx]);if(al>pL)pL=al;if(ar>pR)pR=ar;}if(scopeMeterLRef.current)scopeMeterLRef.current.style.width=Math.min(100,pL*150)+'%';if(scopeMeterRRef.current)scopeMeterRRef.current.style.width=Math.min(100,pR*150)+'%';if(scopeMode==='lissajous'){ctx.fillStyle='rgba(34, 211, 238, 0.55)';const stepN=Math.max(1,Math.floor(n/700));for(let i=0;i<n;i+=stepN){const idx=(st.head-n+i+maxS)%maxS;const x=w/2+st.L[idx]*amp*(w/2);const y=h/2-st.R[idx]*amp*(h/2);ctx.fillRect(x,y,2,2);}ctx.strokeStyle='#0e7490';ctx.beginPath();ctx.moveTo(w/2,0);ctx.lineTo(w/2,h);ctx.stroke();ctx.beginPath();ctx.moveTo(0,h/2);ctx.lineTo(w,h/2);ctx.stroke();}else if(scopeMode==='spectrum'){if(!st.freq||st.freq.length!==ana.L.frequencyBinCount)st.freq=new Uint8Array(ana.L.frequencyBinCount);ana.L.getByteFrequencyData(st.freq);const bins=st.freq.length;const bars=96;const fMax=sr/2;ctx.fillStyle='rgba(34, 211, 238, 0.85)';for(let b=0;b<bars;b++){const f0=20*Math.pow(fMax/20,b/bars);const f1=20*Math.pow(fMax/20,(b+1)/bars);const bi0=Math.max(0,Math.floor(f0/fMax*bins));const bi1=Math.min(bins-1,Math.ceil(f1/fMax*bins));let peak=0;for(let k=bi0;k<=bi1;k++){if(st.freq[k]>peak)peak=st.freq[k];}const bh=peak/255*(h-8);const bx=b/bars*w;ctx.fillRect(bx,h-bh,Math.max(1,w/bars-1),bh);}ctx.fillStyle='#475569';ctx.fillText('20Hz',4,h-4);ctx.fillText('20kHz',w-40,h-4);}else{// waveform (stereo draws L cyan + R amber; single channel draws cyan)
|
||
const stepX=w/n;ctx.lineWidth=1.5;for(const[col,ch]of[['#22d3ee',0],['#fbbf24',1]]){if(ch===1&&scopeChannel!=='stereo')continue;ctx.strokeStyle=col;ctx.beginPath();for(let i=0;i<n;i++){const[l,r]=getS(i);const v=ch===0?l:r;if(v===null)continue;const x=i*stepX;const y=h/2-v*amp*(h/2)*0.9;if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);}ctx.stroke();}}};draw();return()=>{if(raf)cancelAnimationFrame(raf);};},[track&&track.id,scopeChannel,scopeMode,scopeDuration,scopeZoom,scopePaused]);// ── Interactive Module Vector Display: EQ response curve ──
|
||
React.useEffect(()=>{const cv=eqCurveRef.current;if(!cv)return;const w=cv.width=(cv.clientWidth||300)*2;const h=cv.height=88*2;const ctx=cv.getContext('2d');ctx.clearRect(0,0,w,h);// background grid
|
||
ctx.strokeStyle='rgba(51, 65, 85, 0.35)';for(let i=1;i<5;i++){const y=i/5*h;ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();}const midY=h/2;ctx.strokeStyle='rgba(148, 163, 184, 0.25)';ctx.beginPath();ctx.moveTo(0,midY);ctx.lineTo(w,midY);ctx.stroke();// response approximation for 4 cascaded bands (log-domain)
|
||
const bands=[{type:'lowshelf',f0:100,gain:ap.g1||0,q:0.7},{type:'peaking',f0:800,gain:ap.g2||0,q:0.7},{type:'peaking',f0:3200,gain:ap.g3||0,q:1.2},{type:'highshelf',f0:10000,gain:ap.g4||0,q:0.7}];const pts=[];const N=120;for(let i=0;i<=N;i++){const f=20*Math.pow(20000/20,i/N);let db=0;bands.forEach(b=>{const lf=Math.log(f/b.f0);if(b.type==='peaking'){db+=b.gain/(1+Math.pow(lf*b.q,2));}else if(b.type==='highshelf'){db+=b.gain/2*(1+2/Math.PI*Math.atan(lf/(1/b.q)));}else{db+=b.gain/2*(1-2/Math.PI*Math.atan(lf/(1/b.q)));}});const x=i/N*w;const y=midY-db/12*(h/2);pts.push([x,y]);}// fill
|
||
ctx.beginPath();pts.forEach(([x,y],i)=>i===0?ctx.moveTo(x,y):ctx.lineTo(x,y));ctx.lineTo(w,h);ctx.lineTo(0,h);ctx.closePath();ctx.fillStyle='rgba(34, 211, 238, 0.10)';ctx.fill();// curve
|
||
ctx.beginPath();pts.forEach(([x,y],i)=>i===0?ctx.moveTo(x,y):ctx.lineTo(x,y));ctx.strokeStyle='#22d3ee';ctx.lineWidth=2;ctx.stroke();// dB labels
|
||
ctx.fillStyle='#475569';ctx.font='9px monospace';ctx.fillText('+12 dB',4,midY-h/2+10);ctx.fillText('0 dB',4,midY+3);ctx.fillText('-12 dB',4,midY+h/2-4);ctx.fillText('20Hz',4,h-2);ctx.fillText('20kHz',w-38,h-2);},[ap.g1,ap.g2,ap.g3,ap.g4,activeMod]);return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[115] bg-black/80 flex items-center justify-center p-4",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-4xl bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl flex flex-col max-h-[90vh] text-slate-200",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full bg-cyan-400 animate-pulse"}),/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider font-mono"},track.name," \u2014 FX CHAIN")),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-white text-base"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"h-16 bg-slate-950 border border-slate-800 rounded-xl px-3 flex items-center gap-2 overflow-x-auto shrink-0 select-none"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 font-mono shrink-0"},"CHAIN:"),unifiedChain.length===0&&/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 font-mono"},"Ch\u01B0a c\xF3 FX \u2014 b\u1EA5m [+] (DSP) ho\u1EB7c [+VST] \u0111\u1EC3 th\xEAm (signal \u0111i th\u1EB3ng: Source \u2192 Fader)"),unifiedChain.map((u,ui)=>{if(u.kind==='builtin'){const m=u.m;const meta=TRACK_FX_META[m.type]||{name:m.type,icon:'circle',color:'#94a3b8',sub:''};const on=m.active!==false;return/*#__PURE__*/React.createElement("div",{key:`b${u.i}`,draggable:true,onDragStart:e=>{dragChainIndexRef.current=ui;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==ui){const next=[...unifiedChain];const mv=next.splice(from,1)[0];next.splice(ui,0,mv);const nb=next.filter(x=>x.kind==='builtin').map(x=>x.m);const nv=next.filter(x=>x.kind==='vst').map(x=>x.m);setChain(nb);setVstChain(nv);if(mv.kind==='vst')setActiveVstIdx(nv.indexOf(mv.m));}dragChainIndexRef.current=null;},onClick:()=>{closeFxGui();setActiveType(m.type);},className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeType===m.type?'border-2 border-cyan-400 bg-slate-800':'bg-slate-900 border border-slate-800 hover:border-slate-600'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleMod(u.i);},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:on?'#38bdf8':'#334155',color:on?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",{className:"min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-slate-200 truncate"},meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[9px] font-mono truncate",style:{color:meta.color}},u.i+1,". ",meta.sub))),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeMod(u.i);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0",title:"X\xF3a module"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));}const s=u.m;return/*#__PURE__*/React.createElement("div",{key:`v${u.i}`,draggable:true,onDragStart:e=>{dragChainIndexRef.current=ui;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==ui){const next=[...unifiedChain];const mv=next.splice(from,1)[0];next.splice(ui,0,mv);const nb=next.filter(x=>x.kind==='builtin').map(x=>x.m);const nv=next.filter(x=>x.kind==='vst').map(x=>x.m);setChain(nb);setVstChain(nv);if(mv.kind==='vst')setActiveVstIdx(nv.indexOf(mv.m));}dragChainIndexRef.current=null;},onClick:()=>{closeFxGui();setActiveVstIdx(vstChain.indexOf(s));},className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeVstIdx===vstChain.indexOf(s)?'border-2 border-teal-400 bg-slate-800':'bg-slate-900 border border-teal-900/70 hover:border-teal-600'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleVst(vstChain.indexOf(s));},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:!s.bypass?'#2dd4bf':'#334155',color:!s.bypass?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",{className:"min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-teal-200 truncate"},s.name||s.path),/*#__PURE__*/React.createElement("div",{className:"text-[9px] font-mono truncate text-teal-500/80"},u.i+1,". ",s.preset_b64?'✓ preset':'no preset'))),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeVst(vstChain.indexOf(s));},className:"text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0",title:"X\xF3a slot"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));}),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(true),className:"w-16 h-12 rounded-lg border border-dashed border-slate-700 hover:border-cyan-500 flex items-center justify-center text-slate-500 hover:text-cyan-400 cursor-pointer transition-all bg-slate-900/40 shrink-0",title:"Th\xEAm module DSP builtin"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-4 h-4"})),/*#__PURE__*/React.createElement("button",{onClick:()=>setVstPickerOpen(true),className:"w-16 h-12 rounded-lg border border-dashed border-teal-800 hover:border-teal-500 flex items-center justify-center text-teal-500 hover:text-teal-300 cursor-pointer transition-all bg-slate-900/40 shrink-0",title:"Th\xEAm VST FX (Plugin Picker theo category)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plug",className:"w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-[9px] font-bold font-mono ml-0.5"},"VST"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between flex-wrap gap-2 shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 font-mono truncate"},"VST3 \u2014 realtime qua bridge (SHM) khi play + \xE1p d\u1EE5ng l\xFAc Export/Offline render"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 shrink-0"},/*#__PURE__*/React.createElement("button",{onClick:previewFx,className:"px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition flex items-center gap-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"play",className:"w-3 h-3"})," Nghe th\u1EED (render offline)"),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1 text-[10px] text-slate-400 font-mono cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:vstBypass,onChange:e=>{const val=e.target.checked;setVstBypass(val);if(onUpdateTrack)onUpdateTrack(track.id,{vstFxBypass:val});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id,{vstFxBypass:val});},className:"w-3 h-3"})," Bypass"))),fxGuiOpen&&!fxGuiOpen.url&&fxGuiOpen.native?/*#__PURE__*/React.createElement(NativeGuiAnchor,{path:fxGuiOpen.path,name:fxGuiOpen.name,onClose:closeFxGui}):fxGuiOpen?/*#__PURE__*/React.createElement("div",{className:"flex-1 min-h-0"},/*#__PURE__*/React.createElement(VstGuiEmbed,{url:fxGuiOpen.url,onClose:closeFxGui,onDead:()=>{window.showToast&&window.showToast('Plugin VST GUI lỗi khi mở (bridge chết/treo — check %TEMP%\\sf_dumps\\fx_bridge_crash_*.dmp) — đã trả về graph','error');closeFxGui();}})):/*#__PURE__*/React.createElement(React.Fragment,null,activeVstIdx>=0&&activeVstIdx<vstChain.length?/*#__PURE__*/React.createElement("div",{className:"flex-1 bg-slate-950 border border-teal-900/60 rounded-xl p-4 flex flex-col gap-3 min-h-[240px] overflow-y-auto oz-scrollbar"},/*#__PURE__*/React.createElement("div",{className:"h-8 bg-slate-900/80 border-b border-teal-900/60 px-3 flex items-center justify-between text-xs font-mono rounded-t-lg shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Active VST: ",/*#__PURE__*/React.createElement("strong",{className:"text-teal-400"},vstChain[activeVstIdx].name||vstChain[activeVstIdx].path)),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500"},"Slot ",activeVstIdx+1,"/",vstChain.length," \u2014 b\u1EA5m card DSP trong CHAIN \u0111\u1EC3 ch\u1EC9nh module builtin")),vstRow(vstChain[activeVstIdx],activeVstIdx,toggleVst,changeVstPlugin,uploadVstPreset,moveVst,removeVst,fxList,vstChain.length,openVstGui,activeVstIdx,null)):/*#__PURE__*/React.createElement("div",{className:"flex-1 bg-slate-950 border border-slate-800 rounded-xl p-4 flex flex-col gap-4 min-h-[240px] overflow-y-auto oz-scrollbar"},/*#__PURE__*/React.createElement("div",{className:"h-8 bg-slate-900/80 border-b border-slate-800/80 px-3 flex items-center justify-between text-xs font-mono rounded-t-lg shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Active Module: ",/*#__PURE__*/React.createElement("strong",{className:"text-cyan-400"},activeMod?TRACK_FX_META[activeMod.type]?.name||activeMod.type:'—')),activeMod&&activeMod.type==='eq'&&/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"EQ Preset:"),/*#__PURE__*/React.createElement("select",{value:"flat",onChange:e=>applyPreset(e.target.value),className:"bg-slate-950 border border-slate-700 text-cyan-300 rounded px-2 py-0.5 text-xs outline-none focus:border-cyan-500"},Object.keys(TRACK_EQ_PRESETS).map(k=>/*#__PURE__*/React.createElement("option",{key:k,value:k},TRACK_EQ_PRESETS[k].name))))),!activeMod&&/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-500 font-mono text-center py-10"},"Ch\u01B0a c\xF3 module. B\u1EA5m [+] \u0111\u1EC3 th\xEAm EQ / Compressor / Limiter / Exciter / Rebalance."),activeMod&&activeMod.type==='eq'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"space-y-3"},/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-4 gap-4"},[['g1','BAND 1 (LOW)','100 Hz','#22d3ee'],['g2','BAND 2 (MID LOW)','800 Hz','#fbbf24'],['g3','BAND 3 (MID HIGH)','3.2 kHz','#a855f7'],['g4','BAND 4 (HIGH)','10 kHz','#34d399']].map(([key,label,freq,color])=>/*#__PURE__*/React.createElement("div",{key:key,className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg space-y-2"},slider(label,ap[key]!==undefined?ap[key]:0,-12,12,0.1,color,v=>setParams(activeIdx,{[key]:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono text-center"},freq)))),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 rounded-lg p-2"},/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono mb-1 flex justify-between"},/*#__PURE__*/React.createElement("span",null,"VECTOR DISPLAY \u2014 EQ RESPONSE"),/*#__PURE__*/React.createElement("span",null,"20Hz \u2013 20kHz")),/*#__PURE__*/React.createElement("canvas",{ref:eqCurveRef,className:"w-full h-[88px] block"}))),activeMod&&activeMod.type==='eqpro'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"space-y-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-teal-400 font-mono uppercase tracking-widest"},"PARAMETRIC / GRAPHIC EQ PRO \u2014 Pro-Q style interactive"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 font-mono"},"20Hz \u2013 20kHz \xB7 \xB124dB \xB7 ",EQPRO_MAX_BANDS," bands max")),/*#__PURE__*/React.createElement(InteractiveEqPro,{track:track,params:ap,onChange:next=>setParams(activeIdx,next),getModule:()=>window.__getTrackFxModule?window.__getTrackFxModule(track.id,'eqpro'):null,spectrumModules:()=>{const a=window.__getTrackFxModule?window.__getTrackFxModule(track.id,'eqpro'):null;const b=window.__getTrackSfFxModule?window.__getTrackSfFxModule(track.id,'eqpro'):null;return[a,b].filter(Boolean);}})),activeMod&&activeMod.type==='compressor'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('THRESHOLD',ap.threshold,-60,0,0.5,'#fbbf24',v=>setParams(activeIdx,{threshold:v}),v=>`${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('RATIO',ap.ratio,1,20,0.5,'#f59e0b',v=>setParams(activeIdx,{ratio:v}),v=>`${v.toFixed(1)} : 1`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('MAKE-UP',ap.makeup,0,12,0.1,'#f59e0b',v=>setParams(activeIdx,{makeup:v}),v=>`${v.toFixed(1)} dB`))),activeMod&&activeMod.type==='limiter'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('CEILING',ap.ceiling,-24,0,0.1,'#f43f5e',v=>setParams(activeIdx,{ceiling:v}),v=>`${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg flex items-center justify-center text-[10px] text-slate-500 font-mono text-center leading-snug"},"True-Peak limiting",/*#__PURE__*/React.createElement("br",null),"Ratio 20:1 \xB7 Knee 0dB")),activeMod&&activeMod.type==='exciter'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('DRIVE / MIX',ap.drive,0,100,1,'#c084fc',v=>setParams(activeIdx,{drive:v}),v=>`${v}%`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg flex items-center justify-center text-[10px] text-slate-500 font-mono text-center leading-snug"},"WaveShaper saturation",/*#__PURE__*/React.createElement("br",null),"High-pass 2kHz \xB7 4\xD7 oversampled")),activeMod&&activeMod.type==='rebalance'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('MID GAIN',ap.mid,-12,12,0.1,'#38bdf8',v=>setParams(activeIdx,{mid:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('SIDE GAIN',ap.side,-12,12,0.1,'#22d3ee',v=>setParams(activeIdx,{side:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`)))),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800 rounded-xl p-3 flex flex-col gap-2 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-xs font-mono border-b border-slate-800/80 pb-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"font-bold text-white tracking-wider"},"WAVE OBSERVER"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-400 px-1.5 py-0.5 rounded"},"Real-time Oscilloscope")),/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 truncate max-w-[200px]"},"Context: ",track.name)),/*#__PURE__*/React.createElement("div",{className:"relative w-full h-36 bg-slate-950 border border-slate-900 rounded-lg overflow-hidden"},/*#__PURE__*/React.createElement("canvas",{ref:scopeCanvasRef,className:"w-full h-full block cursor-crosshair"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between bg-slate-900/90 border border-slate-800 rounded-lg px-3 py-1.5 text-xs font-mono flex-wrap gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 pr-3 border-r border-slate-800"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-400"},"Input"),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1 w-12"},/*#__PURE__*/React.createElement("div",{className:"h-1.5 bg-slate-950 rounded overflow-hidden flex"},/*#__PURE__*/React.createElement("div",{ref:scopeMeterLRef,className:"h-full bg-cyan-400 w-0 transition-all"})),/*#__PURE__*/React.createElement("div",{className:"h-1.5 bg-slate-950 rounded overflow-hidden flex"},/*#__PURE__*/React.createElement("div",{ref:scopeMeterRRef,className:"h-full bg-cyan-400 w-0 transition-all"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Channel:"),/*#__PURE__*/React.createElement("select",{value:scopeChannel,onChange:e=>setScopeChannel(e.target.value),className:"bg-slate-950 border border-slate-800 text-slate-300 rounded px-1.5 py-0.5 text-[11px] outline-none"},/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo"),/*#__PURE__*/React.createElement("option",{value:"left"},"Left"),/*#__PURE__*/React.createElement("option",{value:"right"},"Right"),/*#__PURE__*/React.createElement("option",{value:"mid"},"Mid"),/*#__PURE__*/React.createElement("option",{value:"side"},"Side"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Mode:"),/*#__PURE__*/React.createElement("select",{value:scopeMode,onChange:e=>setScopeMode(e.target.value),className:"bg-slate-950 border border-slate-800 text-slate-300 rounded px-1.5 py-0.5 text-[11px] outline-none"},/*#__PURE__*/React.createElement("option",{value:"waveform"},"Waveform"),/*#__PURE__*/React.createElement("option",{value:"lissajous"},"Lissajous"),/*#__PURE__*/React.createElement("option",{value:"spectrum"},"Spectrum"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Duration:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold text-[11px] w-12"},scopeDuration.toFixed(2),"s"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0.1",max:"5.0",step:"0.1",value:scopeDuration,onChange:e=>setScopeDuration(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer",style:{accentColor:'#38bdf8'}})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"V.Zoom:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold text-[11px] w-12"},scopeZoom>0?'+':'',scopeZoom.toFixed(1)," dB"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"20",step:"0.5",value:scopeZoom,onChange:e=>setScopeZoom(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer",style:{accentColor:'#38bdf8'}})),/*#__PURE__*/React.createElement("button",{onClick:()=>setScopePaused(p=>!p),className:`px-3 py-1 font-semibold rounded text-[11px] border transition-colors ${scopePaused?'bg-amber-600 border-amber-400 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300 border-slate-700'}`},scopePaused?'Resume':'Pause'))))),addModuleOpen&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[120] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4",onClick:()=>setAddModuleOpen(false)},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-lg bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl max-h-[80vh] overflow-y-auto",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider font-mono"},"TH\xCAM MODULE V\xC0O FX CHAIN"),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(false),className:"text-slate-400 hover:text-white"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3 text-xs"},Object.keys(TRACK_FX_META).map(t=>{const meta=TRACK_FX_META[t];return/*#__PURE__*/React.createElement("button",{key:t,onClick:()=>addMod(t),className:"p-3 bg-slate-950 hover:bg-slate-800 border border-slate-800 rounded-xl text-left space-y-1 transition-colors"},/*#__PURE__*/React.createElement("div",{className:"font-bold flex items-center gap-1.5",style:{color:meta.color}},/*#__PURE__*/React.createElement("i",{"data-lucide":meta.icon,className:"w-3.5 h-3.5"})," ",meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-400"},meta.sub));})))),/*#__PURE__*/React.createElement(VstPluginPicker,{open:vstPickerOpen,onClose:()=>setVstPickerOpen(false),plugins:fxList,loading:fxLoading,onPick:f=>addVst(f.path)}));};// ──────────────────────────────────────────────
|
||
// MASTERING MODAL COMPONENT (from md/45_MASTERING_MODULE.md)
|
||
// ──────────────────────────────────────────────
|
||
const MasteringModal=({isOpen,onClose,masteringSettings,setMasteringSettings})=>{const ozState=masteringSettings;const setOzState=setMasteringSettings;const[isPlaying,setIsPlaying]=React.useState(false);const masterConnected=ozState.masterConnected;const setMasterConnected=val=>{setOzState(prev=>({...prev,masterConnected:typeof val==='function'?val(prev.masterConnected):val}));};// ── VST FX masterbus (native_bridge --render-fx, áp dụng khi Export/Offline render) ──
|
||
const masterVstChain=ozState.vstFxChain||[];// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 2)
|
||
const[masterGuiOpen,setMasterGuiOpenState]=React.useState(null);// { path, url } | null — GUI CHỈ mở theo click
|
||
const[activeMasterVstPath,setActiveMasterVstPath]=React.useState(null);const masterGuiOpenRef=React.useRef(null);masterGuiOpenRef.current=masterGuiOpen;const setMasterGuiOpen=v=>{const prev=masterGuiOpenRef.current;if(prev&&prev.url)_fxGuiUnregisterSource(prev.url);if(v&&v.url)_fxGuiRegisterSource(v.url,null);masterGuiOpenRef.current=v;// sync ref NGAY — guard chuyen slot doc ref
|
||
setMasterGuiOpenState(v);};// C: guard capture preset từ URL GUI embed — khi GUI hiện tại là NATIVE
|
||
// (session editor; preset chuẩn nằm trong DSP/session, backend close/hide
|
||
// capture), KHÔNG fetch /preset từ bridge embed cũ (instance thứ 2, preset
|
||
// lệch).
|
||
const _captureOk=path=>!(masterGuiOpenRef.current&&masterGuiOpenRef.current.native&&masterGuiOpenRef.current.path===path);const closeMasterGui=()=>{const g=masterGuiOpenRef.current;if(g){if(g.url){// Capture preset CUỐI trước khi đóng (giống closeFxGui) — /preset rồi /close.
|
||
try{fetch(g.url+'/preset').then(r=>r.json()).then(d=>{if(d&&d.preset_b64&&g.path&&_captureOk(g.path))_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{}).then(()=>{try{fetch(g.url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}});}catch(e){try{fetch(g.url+'/close',{method:'POST'}).catch(()=>{});}catch(e2){}}}else if(g.native){// M6: GUI native (cửa sổ ngoài "FX slot N") — engine capture preset + đóng editor.
|
||
try{window.SonicAPI&&window.SonicAPI.closeFxGui({path:g.path}).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}catch(e){}}}setMasterGuiOpen(null);if(g&&g.path){const _mk='master:'+g.path;if(_presetRestartTimers[_mk]){clearTimeout(_presetRestartTimers[_mk]);delete _presetRestartTimers[_mk];}delete _fxGuiBaselineByPath[g.path];}setActiveMasterVstPath(null);setOzState(prev=>{const n={...(prev||{})};delete n.gui_open_path;delete n.gui_open_name;return n;});};const hideMasterGuiWindow=()=>{// M8: an GUI nhung GIU process/instance - doi plugin/doi module view
|
||
// khong dong engine (mo lai nhanh, preset con trong instance). Capture
|
||
// preset cho url GUI (poll giu lien tuc); native capture luc close that.
|
||
// KHONG xoa marker gui_open_path — dung khi SWITCH sang GUI khac: neu GUI
|
||
// moi mo that bai, marker cu con lai de _autoTryOpen / mo lai panel tu mo
|
||
// lai GUI cu (Lỗi 2: marker khong bi xoa som).
|
||
const g=masterGuiOpenRef.current;if(g){if(g.url){try{if(g.path){fetch(g.url+'/preset').then(r=>r.json()).then(d=>{if(d&&d.preset_b64&&g.path&&_captureOk(g.path))_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}}catch(e){}try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}else if(g.native){try{window.SonicAPI&&window.SonicAPI.hideFxGui({path:g.path}).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}catch(e){}}const _mk='master:'+g.path;if(_presetRestartTimers[_mk]){clearTimeout(_presetRestartTimers[_mk]);delete _presetRestartTimers[_mk];}if(g.path)delete _fxGuiBaselineByPath[g.path];}setMasterGuiOpen(null);setActiveMasterVstPath(null);};const hideMasterGui=()=>{// hideMasterGui (đóng hẳn view/module): ẩn window + XOÁ marker gui_open_path
|
||
// (mở lại panel sau này KHÔNG tự mở lại GUI).
|
||
hideMasterGuiWindow();setOzState(prev=>{const n={...(prev||{})};delete n.gui_open_path;delete n.gui_open_name;return n;});};React.useEffect(()=>()=>{const g=masterGuiOpenRef.current;if(g){if(g.url){try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}else if(g.native){try{window.SonicAPI&&window.SonicAPI.hideFxGui({path:g.path}).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}catch(e){}}const _mk='master:'+g.path;if(_presetRestartTimers[_mk]){clearTimeout(_presetRestartTimers[_mk]);delete _presetRestartTimers[_mk];}if(g.path)delete _fxGuiBaselineByPath[g.path];}},[]);// Capture GUI knob tweaks while the embedded GUI is open (bridge /preset).
|
||
React.useEffect(()=>{if(!masterGuiOpen||!masterGuiOpen.url)return;const iv=setInterval(()=>{fetch(masterGuiOpen.url+'/preset').then(r=>r.json()).then(d=>{if(d&&d.preset_b64){const _p=masterGuiOpen.path;if(!_captureOk(_p))return;const _b=_fxGuiBaselineByPath[_p];if(_b===undefined){_fxGuiBaselineByPath[_p]=d.preset_b64;_capturedPresetByPath[_p]=d.preset_b64;return;}if(d.preset_b64!==_b){_fxGuiBaselineByPath[_p]=d.preset_b64;_capturedPresetByPath[_p]=d.preset_b64;_schedulePresetRestart('master:'+_p,()=>fxRtStart(),3000);}}}).catch(()=>{});},1500);return()=>clearInterval(iv);// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[masterGuiOpen]);// Modal đóng (modal luôn mounted, KHÔNG unmount): ẩn GUI nhưng GIỮ marker
|
||
// gui_open_path/name + preset cuối → mở lại panel tự mở lại GUI đúng cài đặt.
|
||
// Chỉ POST /hide — bridge còn sống, mở lại nhanh. “Đóng GUI” (closeMasterGui)
|
||
// là thao tác chủ động xoá marker → không auto-mở lại lần sau.
|
||
React.useEffect(()=>{if(isOpen)return;const g=masterGuiOpenRef.current;if(!g)return;if(g.url){try{if(g.path){fetch(g.url+'/preset').then(r=>r.json()).then(d=>{if(d&&d.preset_b64&&g.path&&_captureOk(g.path))_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}}catch(e){}try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}else if(g.native){// Ẩn GUI native khi đóng panel (giữ editor+instance, mở lại panel nhanh).
|
||
try{window.SonicAPI&&window.SonicAPI.hideFxGui({path:g.path}).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}catch(e){}}const _mk='master:'+g.path;if(_presetRestartTimers[_mk]){clearTimeout(_presetRestartTimers[_mk]);delete _presetRestartTimers[_mk];}if(g.path)delete _fxGuiBaselineByPath[g.path];setMasterGuiOpen(null);setActiveMasterVstPath(null);// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[isOpen]);// P2.1 — Mở lại panel khi marker gui_open còn (chưa bấm “Đóng GUI”): tự mở lại
|
||
// GUI embed đúng plugin + preset cuối. Fail/spawn lỗi → GIỮ marker + toast lỗi
|
||
// thật + retry tối đa 4 lần — trước đây catch xoá marker vĩnh viễn + im lặng
|
||
// → GUI không bao giờ tự mở lại sau tắt/mở app (bug P2.1 reload). Chạy lại khi
|
||
// marker/chain xuất hiện sau (project restore chưa xong lúc mở panel).
|
||
const _autoIsOpenRef=React.useRef(isOpen);_autoIsOpenRef.current=isOpen;const _autoOzRef=React.useRef(ozState);_autoOzRef.current=ozState;const _autoOpenRef=React.useRef({timer:null,attempt:0,inFlight:false});const _autoTryOpen=React.useCallback(async()=>{const st=_autoOzRef.current||{};const _p=st.gui_open_path;if(!_p)return;if(!_autoIsOpenRef.current||masterGuiOpenRef.current||_autoOpenRef.current.inFlight)return;const s=(st.vstFxChain||[]).find(x=>x&&x.path===_p);if(!s||!window.SonicAPI||!window.SonicAPI.openFxGui)return;_autoOpenRef.current.inFlight=true;setActiveMasterVstPath(_p);const _name=st.gui_open_name||s.name||_p;try{// C: GUI master luôn qua session realtime (PWR OFF vẫn warm) — bảo đảm
|
||
// session trước khi spawn; nếu không backend từ chối (409) — fallback
|
||
// embed đã ngưng cho master.
|
||
const _warm=await fxRtEnsureWarmForGui(_p);if(!_warm)throw new Error('chain chưa sẵn sàng (bật slot VST trong chain trước)');if(!_autoIsOpenRef.current||masterGuiOpenRef.current){_autoOpenRef.current.inFlight=false;return;}const r=await window.SonicAPI.openFxGui({path:_p,name:_name,embed:true,shm:fxRt.sessionId||'',preset_b64:_capturedPresetByPath[_p]||s.preset_b64||'',master:true});_autoOpenRef.current.inFlight=false;if(r&&r.native&&!masterGuiOpenRef.current){_autoOpenRef.current.attempt=0;setMasterGuiOpen({path:_p,url:null,name:_name,native:true});return;}if(r&&r.embed_url&&!masterGuiOpenRef.current){_autoOpenRef.current.attempt=0;setMasterGuiOpen({path:_p,url:r.embed_url,name:_name});return;}if(r&&r.already_running&&r.embed_url&&!masterGuiOpenRef.current){_autoOpenRef.current.attempt=0;setMasterGuiOpen({path:_p,url:r.embed_url,name:_name});return;}if(r&&r.already_running){_autoOpenRef.current.attempt=0;setActiveMasterVstPath(null);window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');return;}throw new Error(r&&r.detail||'Bridge không mở được GUI embed');}catch(err){_autoOpenRef.current.inFlight=false;console.warn('[MasterAutoOpen] openFxGui fail:',_p,err&&err.message||err);setActiveMasterVstPath(null);if(_autoIsOpenRef.current&&_autoOpenRef.current.attempt<4&&!masterGuiOpenRef.current){_autoOpenRef.current.attempt+=1;if(_autoOpenRef.current.timer)clearTimeout(_autoOpenRef.current.timer);_autoOpenRef.current.timer=setTimeout(()=>{_autoOpenRef.current.timer=null;_autoTryOpen();},3000);}else{_autoOpenRef.current.attempt=0;window.showToast&&window.showToast('Không tự mở được GUI VST: '+(err&&err.message||'lỗi')+' — bấm tên plugin trong chuỗi để mở tay.','error');}}},[]);React.useEffect(()=>{return()=>{if(_autoOpenRef.current.timer){clearTimeout(_autoOpenRef.current.timer);_autoOpenRef.current.timer=null;}};},[]);React.useEffect(()=>{if(!isOpen){_autoOpenRef.current.attempt=0;return;}// M20 Bug B diag: giá trị meter đúng lúc mở MASTERING PANEL + hist 8s trước + post 1/2/4/8s
|
||
try{_mdiagSample('panel_open');_mdiagDump();_mdiagSchedulePost();}catch(e){}if(_autoOpenRef.current.timer){clearTimeout(_autoOpenRef.current.timer);_autoOpenRef.current.timer=null;}_autoTryOpen();// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[isOpen,ozState.gui_open_path,ozState.gui_open_name,ozState.vstFxChain]);const setMasterVstChain=next=>setOzState(prev=>{const chain=prev.chain||[];const out=[];let vi=0;const mk=(s,i)=>({id:'mod_vst_'+Date.now()+'_'+i+'_'+Math.random().toString(36).slice(2,8),type:'vst3',path:s.path,name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass,active:true});chain.forEach(m=>{if(m.type==='vst3'){const s=next[vi];if(s){out.push(mk(s,vi));vi++;}}else out.push(m);});for(;vi<next.length;vi++)out.push(mk(next[vi],vi));return{...prev,vstFxChain:next,chain:out};});// LIVE master FX xử lý bởi fxRtStart() (SHM loop qua fx_vst_bridge
|
||
// --realtime-fx) — KHÔNG push chain xuống native bridge (setFxChain):
|
||
// nếu push, MIDI/VSTi instrument bị xử lý 2 lần (native bridge + SHM loop)
|
||
// → over-process, bể/crackle. setFxChain vẫn dùng khi Export/Offline render.
|
||
const toggleMasterVst=idx=>setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,bypass:!s.bypass}:s));const removeMasterVst=idx=>{const rem=masterVstChain[idx];if(rem)delete _capturedPresetByPath[rem.path];setMasterVstChain(masterVstChain.filter((_,i)=>i!==idx));};const moveMasterVst=(idx,dir)=>{const j=idx+dir;if(j<0||j>=masterVstChain.length)return;const next=[...masterVstChain];const mv=next.splice(idx,1)[0];next.splice(j,0,mv);setMasterVstChain(next);};const openMasterVstGui=async s=>{if(!s||!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}if(masterGuiOpen&&masterGuiOpen.path===s.path){setActiveMasterVstPath(s.path);return;}// Lỗi 2: switch A->B — chỉ ẩn CỬA SỔ A (giữ marker gui_open_path A); B mở
|
||
// thành công sẽ set marker B (bên dưới); B fail -> marker A còn -> _autoTryOpen
|
||
// / mở lại panel tự mở lại GUI A (trước đây hideMasterGui xoá marker ngay).
|
||
if(masterGuiOpen)hideMasterGuiWindow();if(s.path)setActiveMasterVstPath(s.path);if(!window.SonicAPI||!window.SonicAPI.openFxGui){window.showToast&&window.showToast('Máy này không hỗ trợ mở GUI VST','warning');return;}// C: GUI master CHỈ mở qua session realtime (native editor của chính
|
||
// instance DSP) — warm-ensure session khi PWR OFF/bypass; backend từ chối
|
||
// fallback embed cho master (409).
|
||
let _warm=false;// Lỗi 2: retry transient (entry commit sau render / session start / warm-ensure
|
||
// fail thoáng) trước khi toast — trước đây 1 fail = toast ngay + không mở. Slot
|
||
// thật OFF (active=false) -> fxRtEnsureWarmForGui trả false NGAY, không kẹt đây.
|
||
for(let _a=0;_a<3&&!_warm;_a++){_warm=await fxRtEnsureWarmForGui(s.path);if(!_warm&&_a<2)await new Promise(r=>setTimeout(r,800));}if(!_warm){setActiveMasterVstPath(null);window.showToast&&window.showToast('Không mở được GUI VST — chain chưa có plugin active (bật slot trong chain trước)','warning');return;}if(masterGuiOpenRef.current&&masterGuiOpenRef.current.path!==s.path)return;// GUI khác đã mở trong lúc chờ warm — bỏ lượt này
|
||
window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true,shm:fxRt.sessionId||'',preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||'',master:true}).then(r=>{if(r&&r.native){setMasterGuiOpen({path:s.path,url:null,name:s.name||s.path,native:true});setOzState(prev=>({...(prev||{}),gui_open_path:s.path,gui_open_name:s.name||s.path}));}else if(r&&r.embed_url){setMasterGuiOpen({path:s.path,url:r.embed_url,name:s.name||s.path});setOzState(prev=>({...(prev||{}),gui_open_path:s.path,gui_open_name:s.name||s.path}));}else if(r&&r.already_running&&r.embed_url){setMasterGuiOpen({path:s.path,url:r.embed_url,name:s.name||s.path});setOzState(prev=>({...(prev||{}),gui_open_path:s.path,gui_open_name:s.name||s.path}));}else if(r&&r.already_running)window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');else window.showToast&&window.showToast('Không mở được GUI embed','error');}).catch(err=>{setActiveMasterVstPath(null);window.showToast&&window.showToast('Lỗi mở GUI: '+(err.message||err),'error');});};const addMasterVst=path=>{const fx=(masterFxList||[]).find(f=>f.path===path);if(!fx)return;setMasterVstChain([...masterVstChain,{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}]);openMasterVstGui({path:fx.path,name:fx.name||fx.id});};const changeMasterVstPlugin=(idx,path)=>{const fx=(masterFxList||[]).find(f=>f.path===path);delete _capturedPresetByPath[path];setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,path,name:fx?fx.name||fx.id:s.name,preset_b64:''}:s));};const uploadMasterVstPreset=idx=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;const reader=new FileReader();reader.onload=()=>{const b64=String(reader.result||'').split(',')[1]||'';setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,preset_b64:b64}:s));window.showToast&&window.showToast('Đã gắn preset .vstpreset vào slot '+(idx+1),'success');};reader.readAsDataURL(f);};inp.click();};const previewMasterFx=async()=>{try{if(previewAudioRef.current){previewAudioRef.current.pause();previewAudioRef.current=null;}if(!window.__renderProjectPreview){window.showToast&&window.showToast('Không có hàm render preview','warning');return;}window.showToast&&window.showToast('Đang render offline (bridge)...','info');const res=await window.__renderProjectPreview();const au=new Audio((window.API_BASE_URL||'')+res.url);previewAudioRef.current=au;au.onended=()=>{previewAudioRef.current=null;};au.play().then(()=>window.showToast&&window.showToast('Preview offline — âm thật = âm export (VST FX)','success')).catch(()=>{});}catch(e){/* toast ở __renderProjectPreview */}};const audioRef=React.useRef({source:null});const eqCanvasRef=React.useRef(null);const imagerCanvasRef=React.useRef(null);const inMeterCanvasRef=React.useRef(null);const outMeterCanvasRef=React.useRef(null);const animFrameRef=React.useRef(null);const lufsAccRef=React.useRef([]);const knobsInitializedRef=React.useRef(false);// VST FX masterbus (native_bridge --render-fx) — list VST3 effect plugins + preview
|
||
const[masterFxList,setMasterFxList]=React.useState([]);const[masterFxLoading,setMasterFxLoading]=React.useState(false);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listFx){setMasterFxLoading(true);window.SonicAPI.listFx().then(d=>setMasterFxList(d&&d.plugins||[])).catch(()=>setMasterFxList([])).finally(()=>setMasterFxLoading(false));}},[isOpen]);const previewAudioRef=React.useRef(null);const[addMasterVstSel,setAddMasterVstSel]=React.useState('');const[masterVstPickerOpen,setMasterVstPickerOpen]=React.useState(false);// Bug2 (master): GUI native noi tren modal - hide khi mo picker, show lai khi dong.
|
||
const masterHiddenForPickerRef=React.useRef(null);React.useEffect(()=>{if(masterVstPickerOpen){const g=masterGuiOpenRef.current;if(g&&g.native&&!g.url&&!masterHiddenForPickerRef.current){const path=g.path;try{window.SonicAPI&&window.SonicAPI.hideFxGui({path}).then(d=>{if(d&&d.preset_b64&&g.path)_capturedPresetByPath[g.path]=d.preset_b64;}).catch(()=>{});}catch(e){}masterHiddenForPickerRef.current=path;}}else if(masterHiddenForPickerRef.current){const path=masterHiddenForPickerRef.current;masterHiddenForPickerRef.current=null;try{window.SonicAPI&&window.SonicAPI.showFxGui({path}).catch(()=>{});}catch(e){}}},[masterVstPickerOpen]);// Wave Observer Refs & States
|
||
const woCanvasRef=React.useRef(null);const woLeftHistoryRef=React.useRef(new Float32Array(400).fill(0));const woRightHistoryRef=React.useRef(new Float32Array(400).fill(0));const woLeftMeterRef=React.useRef(null);const woRightMeterRef=React.useRef(null);const[woPaused,setWoPaused]=React.useState(false);const[woChannel,setWoChannel]=React.useState('stereo');const[woMode,setWoMode]=React.useState('waveform');const[woDuration,setWoDuration]=React.useState(2.0);const[woZoom,setWoZoom]=React.useState(0.0);// Scope KHÔNG tự chạy khi mở modal (fix: Wave Observer "built-in" auto-load
|
||
// khi nhấn VST FX) — chỉ render khi user bật toggle, mặc định TẮT.
|
||
const[woEnabled,setWoEnabled]=React.useState(false);const woPausedRef=React.useRef(woPaused);woPausedRef.current=woPaused;const woChannelRef=React.useRef(woChannel);woChannelRef.current=woChannel;const woModeRef=React.useRef(woMode);woModeRef.current=woMode;const woDurationRef=React.useRef(woDuration);woDurationRef.current=woDuration;const woZoomRef=React.useRef(woZoom);woZoomRef.current=woZoom;const woEnabledRef=React.useRef(woEnabled);woEnabledRef.current=woEnabled;const ozStateRef=React.useRef(ozState);ozStateRef.current=ozState;function startAudioDemo(){getAudioContext();const ctx=audioCtx;if(ctx.state==='suspended')ctx.resume();stopAudioDemo();const sampleRate=ctx.sampleRate;const bufferSize=sampleRate*4;const buffer=ctx.createBuffer(2,bufferSize,sampleRate);const left=buffer.getChannelData(0);const right=buffer.getChannelData(1);for(let i=0;i<bufferSize;i++){const t=i/sampleRate;const kickEnv=Math.max(0,1-t%0.5*8);const kick=Math.sin(2*Math.PI*(55*Math.exp(-(t%0.5)*18)))*kickEnv;const snareEnv=Math.max(0,1-(t+0.25)%0.5*6);const snare=(Math.random()*2-1)*snareEnv*0.3;const synth=(Math.sin(2*Math.PI*261.63*t)+Math.sin(2*Math.PI*311.13*t)+Math.sin(2*Math.PI*392.0*t))*0.12;left[i]=kick*0.6+snare+synth;right[i]=kick*0.6+snare*0.9+synth*0.95;}const source=ctx.createBufferSource();source.buffer=buffer;source.loop=true;source.connect(masterBus.inputAnalyser);source.start();audioRef.current.source=source;setIsPlaying(true);}function stopAudioDemo(){const src=audioRef.current.source;if(src){try{src.stop();}catch(e){}try{src.disconnect();}catch(e){}audioRef.current.source=null;}setIsPlaying(false);}React.useEffect(()=>{if(!isOpen)return;getAudioContext();function resizeAll(){const resizeCanvas=ref=>{const el=ref.current;if(el){el.width=el.clientWidth;el.height=el.clientHeight;}};resizeCanvas(eqCanvasRef);resizeCanvas(imagerCanvasRef);resizeCanvas(inMeterCanvasRef);resizeCanvas(outMeterCanvasRef);resizeCanvas(woCanvasRef);}resizeAll();window.addEventListener('resize',resizeAll);const fftData=new Uint8Array(1024);let _peakScratch=new Float32Array(2048);function getPeakLevel(analyser){if(!analyser)return 0;const bufferLength=analyser.fftSize;if(_peakScratch.length<bufferLength)_peakScratch=new Float32Array(bufferLength);const dataArray=_peakScratch;analyser.getFloatTimeDomainData(dataArray);let maxVal=0;for(let i=0;i<bufferLength;i++){const val=Math.abs(dataArray[i]);if(val>maxVal){maxVal=val;}}return maxVal;}function renderFrame(){animFrameRef.current=requestAnimationFrame(renderFrame);const s=ozStateRef.current;// EQ Spectrum
|
||
const eqCanvas=eqCanvasRef.current;if(eqCanvas){const w=eqCanvas.width,h=eqCanvas.height;const eqCtx=eqCanvas.getContext('2d');eqCtx.clearRect(0,0,w,h);eqCtx.strokeStyle='rgba(51, 65, 85, 0.3)';eqCtx.lineWidth=1;eqCtx.font='9px JetBrains Mono';eqCtx.fillStyle='#475569';const freqs=[20,50,100,200,500,1000,2000,5000,10000,20000];freqs.forEach(f=>{const x=Math.log10(f/20)/Math.log10(20000/20)*w;eqCtx.beginPath();eqCtx.moveTo(x,0);eqCtx.lineTo(x,h);eqCtx.stroke();if(f>=1000)eqCtx.fillText(`${f/1000}k`,x+3,h-6);else eqCtx.fillText(`${f}`,x+3,h-6);});if(masterBus&&masterBus.outputAnalyser){masterBus.outputAnalyser.getByteFrequencyData(fftData);eqCtx.fillStyle='rgba(56, 189, 248, 0.15)';const barWidth=w/128;for(let i=0;i<128;i++){const val=fftData[i*4]/255;eqCtx.fillRect(i*barWidth,h-val*h,barWidth-1,val*h);}}eqCtx.strokeStyle='#38bdf8';eqCtx.lineWidth=2.5;eqCtx.beginPath();for(let x=0;x<w;x++){const freq=20*Math.pow(20000/20,x/w);let gainDb=0;if(s.eqActive){gainDb+=s.eqLowGain/(1+Math.pow(freq/100,2));gainDb+=s.eqMid1Gain*Math.exp(-Math.pow(Math.log(freq/822),2)*2);gainDb+=s.eqMid2Gain*Math.exp(-Math.pow(Math.log(freq/3200),2)*2);gainDb+=s.eqHighGain/(1+Math.pow(10000/freq,2));}const y=h/2-gainDb/18*(h/2);if(x===0)eqCtx.moveTo(x,y);else eqCtx.lineTo(x,y);}eqCtx.stroke();}// Imager Vectorscope (real M/S polar trace) + Phase Correlation meter
|
||
const imagerCanvas=imagerCanvasRef.current;if(imagerCanvas){const iw=imagerCanvas.width,ih=imagerCanvas.height;const ic=imagerCanvas.getContext('2d');ic.clearRect(0,0,iw,ih);// Reference circle + crosshair
|
||
ic.strokeStyle='rgba(51, 65, 85, 0.4)';ic.lineWidth=1;ic.beginPath();ic.arc(iw/2,ih/2,ih/3,0,Math.PI*2);ic.stroke();ic.beginPath();ic.moveTo(iw/2-ih/3,ih/2);ic.lineTo(iw/2+ih/3,ih/2);ic.moveTo(iw/2,ih/2-ih/3);ic.lineTo(iw/2,ih/2+ih/3);ic.stroke();const hasL=masterBus&&masterBus.leftAnalyser;const hasR=masterBus&&masterBus.rightAnalyser;if(hasL&&hasR){try{masterBus.leftAnalyser.getFloatTimeDomainData(_imagerBufL);masterBus.rightAnalyser.getFloatTimeDomainData(_imagerBufR);// Polar (M/S) projection: X = L + R (mid), Y = L − R (side).
|
||
// A mono signal collapses onto the horizontal axis; widening the
|
||
// stereo field grows the vertical spread.
|
||
const scale=ih/3.2;ic.fillStyle='rgba(56, 189, 248, 0.45)';for(let i=0;i<2048;i+=8){const x=iw/2+(_imagerBufL[i]+_imagerBufR[i])*scale;const y=ih/2-(_imagerBufL[i]-_imagerBufR[i])*scale;if(x>=0&&x<=iw&&y>=0&&y<=ih)ic.fillRect(x,y,2,2);}}catch(e){}}// Phase Correlation meter: −1.0 … +1.0 with spec color zones
|
||
const corr=computeStereoCorrelation();const gaugeW=iw-40;const gaugeY=ih-12;// track
|
||
ic.fillStyle='rgba(30, 41, 59, 0.9)';ic.fillRect(20,gaugeY,gaugeW,7);// zones: danger (<0 red), caution (0..0.5 amber), safe (0.5..1 green)
|
||
const zx=v=>20+(v+1)/2*gaugeW;ic.fillStyle='rgba(239, 68, 68, 0.55)';ic.fillRect(zx(-1),gaugeY,zx(0)-zx(-1),7);ic.fillStyle='rgba(245, 158, 11, 0.55)';ic.fillRect(zx(0),gaugeY,zx(0.5)-zx(0),7);ic.fillStyle='rgba(52, 211, 153, 0.55)';ic.fillRect(zx(0.5),gaugeY,zx(1)-zx(0.5),7);// marker
|
||
ic.fillStyle='#f8fafc';ic.fillRect(zx(corr)-1,gaugeY-2,2,11);// labels
|
||
ic.fillStyle='rgba(148, 163, 184, 0.8)';ic.font='9px monospace';ic.fillText('−1',20,gaugeY-4);ic.fillText('0',iw/2-3,gaugeY-4);ic.fillText('+1',iw-26,gaugeY-4);const corrEl=document.getElementById('corrText');if(corrEl){corrEl.innerText=corr.toFixed(2);corrEl.style.color=corr>=0.5?'#34d399':corr>=0?'#fbbf24':'#ef4444';}}// I/O Meters
|
||
const renderMeter=(analyser,ctxRef,textId)=>{const canvas=ctxRef.current;if(!canvas)return;const w=canvas.width,h=canvas.height;const ctx=canvas.getContext('2d');ctx.clearRect(0,0,w,h);const peak=getPeakLevel(analyser);const barH=Math.min(1.0,peak)*h;const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#38bdf8');grad.addColorStop(0.7,'#f59e0b');grad.addColorStop(1,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(2,h-barH,w-4,barH);const el=document.getElementById(textId);if(el){if(peak>0){const dbVal=20*Math.log10(peak);el.innerText=dbVal<-90?'-inf dB':`${dbVal.toFixed(1)} dB`;}else{el.innerText='-inf dB';}}};renderMeter(masterBus&&masterBus.inputAnalyser,inMeterCanvasRef,'inPeakText');// OUT meter: đọc masterBus.analyser (SAU fxRt VST node khi realtime active)
|
||
// → peak/RMS/LUFS hiển thị audio data ĐÃ QUA chuỗi VST master chain
|
||
// (không phải outputAnalyser — node đó nằm TRƯỚC VST realtime).
|
||
renderMeter(masterBus&&masterBus.analyser,outMeterCanvasRef,'outPeakText');// Loudness measurement (post-mastering output): RMS dBFS + LUFS approx
|
||
// (ITU-R BS.1770 mean-square blocks, ~400ms, without K-weighting — close
|
||
// enough to gauge perceived loudness of the processed master).
|
||
const outAn=masterBus&&masterBus.analyser;if(outAn){const outLen=outAn.fftSize;if(_peakScratch.length<outLen)_peakScratch=new Float32Array(outLen);const outBuf=_peakScratch;outAn.getFloatTimeDomainData(outBuf);let sq=0;for(let i=0;i<outLen;i++)sq+=outBuf[i]*outBuf[i];const rmsDb=20*Math.log10(Math.sqrt(sq/outLen)+1e-12);const rmsEl=document.getElementById('outRmsText');if(rmsEl)rmsEl.innerText=rmsDb<-90?'-inf':rmsDb.toFixed(1)+' dB';if(!lufsAccRef.current)lufsAccRef.current=[];lufsAccRef.current.push(sq/outLen);const accLen=24;// ~0.4s at 60fps rAF
|
||
if(lufsAccRef.current.length>=accLen){const blocks=lufsAccRef.current.splice(0,accLen);const m=blocks.reduce((a,b)=>a+b,0)/blocks.length;const lufs=-0.691+10*Math.log10(m+1e-12);const lufsEl=document.getElementById('lufsText');if(lufsEl)lufsEl.innerText=lufs.toFixed(1)+' LUFS';}}// Safety watchdog: if the mastering chain is broken (signal in, silence
|
||
// out — e.g. a biquad in a bad state), fall back to the direct routing so
|
||
// audio is NEVER globally silent. The user can re-enable mastering after.
|
||
const masterInPk=getPeakLevel(masterBus&&masterBus.inputAnalyser);const masterOutPk=getPeakLevel(masterBus&&masterBus.outputAnalyser);if(masterBus&&masterBus.masteringActive&&masterInPk>0.01&&masterOutPk<0.001){console.warn('[Mastering] Chain broken (signal in, no signal out) — bypassing mastering to restore audio.');toggleMasteringOnMaster(false,false);}// Wave Observer Oscilloscope Rendering
|
||
const woCanvas=woCanvasRef.current;if(woCanvas&&!woEnabledRef.current){// Scope tắt (mặc định) — không chạy tự động: xóa canvas + zero meters.
|
||
const woCtx=woCanvas.getContext('2d');woCtx.clearRect(0,0,woCanvas.width,woCanvas.height);if(woLeftMeterRef.current)woLeftMeterRef.current.style.width='0%';if(woRightMeterRef.current)woRightMeterRef.current.style.width='0%';}if(woCanvas&&woEnabledRef.current){const w=woCanvas.width,h=woCanvas.height;const woCtx=woCanvas.getContext('2d');woCtx.clearRect(0,0,w,h);// Draw grid
|
||
woCtx.strokeStyle='rgba(51, 65, 85, 0.2)';woCtx.lineWidth=1;woCtx.font='8px JetBrains Mono, monospace';woCtx.fillStyle='#475569';const centerY=h/2;const gridLines=[-0.75,-0.5,-0.25,0,0.25,0.5,0.75];gridLines.forEach(g=>{const y=centerY+g*centerY;woCtx.beginPath();woCtx.moveTo(0,y);woCtx.lineTo(w,y);woCtx.stroke();});// Vertical lines
|
||
const ticksCount=10;for(let i=1;i<=ticksCount;i++){const x=i/(ticksCount+1)*w;woCtx.beginPath();woCtx.moveTo(x,0);woCtx.lineTo(x,h);woCtx.stroke();}// dB labels on left side
|
||
woCtx.fillText('-6.0 dB',5,centerY-0.5*centerY+3);woCtx.fillText('-9.0 dB',5,centerY-0.35*centerY+3);woCtx.fillText('-15.0 dB',5,centerY-0.18*centerY+3);woCtx.fillText('-27.0 dB',5,centerY-0.05*centerY+3);woCtx.fillText('-27.0 dB',5,centerY+0.05*centerY+3);woCtx.fillText('-15.0 dB',5,centerY+0.18*centerY+3);woCtx.fillText('-9.0 dB',5,centerY+0.35*centerY+3);woCtx.fillText('-6.0 dB',5,centerY+0.5*centerY+3);// Time indicators at the bottom
|
||
const durationSec=woDurationRef.current;for(let i=1;i<=5;i++){const timeVal=i/6*durationSec;const x=i/6*w;woCtx.fillText(timeVal.toFixed(2)+'s',x-10,h-4);}let leftPeak=0;let rightPeak=0;if(!woPausedRef.current&&masterBus&&masterBus.leftAnalyser&&masterBus.rightAnalyser){const leftData=new Float32Array(512);const rightData=new Float32Array(512);masterBus.leftAnalyser.getFloatTimeDomainData(leftData);masterBus.rightAnalyser.getFloatTimeDomainData(rightData);for(let i=0;i<512;i++){const l=Math.abs(leftData[i]);const r=Math.abs(rightData[i]);if(l>leftPeak)leftPeak=l;if(r>rightPeak)rightPeak=r;}const lHistory=woLeftHistoryRef.current;const rHistory=woRightHistoryRef.current;// Shift history buffer Left
|
||
for(let i=0;i<lHistory.length-1;i++){lHistory[i]=lHistory[i+1];rHistory[i]=rHistory[i+1];}if(woModeRef.current==='envelope'){lHistory[lHistory.length-1]=leftPeak;rHistory[rHistory.length-1]=rightPeak;}else{lHistory[lHistory.length-1]=leftData[0];rHistory[rHistory.length-1]=rightData[0];}}const lHistory=woLeftHistoryRef.current;const rHistory=woRightHistoryRef.current;const zoomGain=Math.pow(10,woZoomRef.current/20);// Update input meter bars
|
||
if(woLeftMeterRef.current&&woRightMeterRef.current){const lPct=Math.min(100,leftPeak*100);const rPct=Math.min(100,rightPeak*100);woLeftMeterRef.current.style.width=`${lPct}%`;woRightMeterRef.current.style.width=`${rPct}%`;}// Left channel line (cyan)
|
||
if(woChannelRef.current==='stereo'||woChannelRef.current==='left'){woCtx.strokeStyle='#22d3ee';woCtx.lineWidth=1.2;woCtx.beginPath();for(let i=0;i<lHistory.length;i++){const x=i/(lHistory.length-1)*w;const val=lHistory[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<lHistory.length;i++){const x=i/(lHistory.length-1)*w;const val=lHistory[i]*zoomGain;const y=centerY+val*centerY;if(i===0)woCtx.moveTo(x,y);else woCtx.lineTo(x,y);}woCtx.stroke();}}// Right channel line (teal)
|
||
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=>{// C: tắt slot VST3 đang có GUI (native/embed) -> đóng GUI + xoá marker
|
||
// trước khi tắt (chain/session đổi sau đó); không thì GUI treo chỉ vào slot
|
||
// đã inactive -> backend từ chối mở lại.
|
||
const _cur=(ozState.chain||[]).find(m=>m.id===modId);if(_cur&&_cur.type==='vst3'&&_cur.active!==false){const _g=masterGuiOpen&&masterGuiOpen.path===_cur.path;if(_g)closeMasterGui();else if(ozState.gui_open_path===_cur.path){setOzState(prev=>{const n={...(prev||{})};delete n.gui_open_path;delete n.gui_open_name;return n;});}}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);const wasGuiOpenFor=removedMod&&removedMod.type==='vst3'&&masterGuiOpen&&masterGuiOpen.path===removedMod.path;if(wasGuiOpenFor)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)];}const n={...prev,chain,...flags,vstFxChain,activeModule:chain.length?chain.some(m=>m.type===activeModule)?activeModule:chain[chain.length-1].type:null};// P2.1 — marker mồ côi khi xoá VST3 mà GUI không mở (panel đang đóng/ẩn)
|
||
if(removed&&removed.type==='vst3'&&!wasGuiOpenFor&&prev.gui_open_path===removed.path){delete n.gui_open_path;delete n.gui_open_name;}return n;});};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]
|
||
setOzState(prev=>({...prev,eqLowGain:preset.bands[0]?preset.bands[0].gain:prev.eqLowGain,eqMid1Gain:preset.bands[1]?preset.bands[1].gain:prev.eqMid1Gain,eqMid2Gain:preset.bands[2]?preset.bands[2].gain:prev.eqMid2Gain,eqHighGain:preset.bands[3]?preset.bands[3].gain:prev.eqHighGain,eqPreset:presetKey}));};const bandKnob=(param,min,max,val,unit,label,freq,color,filterType)=>/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-2 flex flex-col justify-between items-center rounded-lg"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[10px] font-bold w-full",style:{color}},/*#__PURE__*/React.createElement("span",null,label),/*#__PURE__*/React.createElement("span",{className:"text-[9px] font-mono text-slate-400"},freq)),/*#__PURE__*/React.createElement("div",{className:"my-1"},/*#__PURE__*/React.createElement(MasteringKnob,{param:param,min:min,max:max,value:ozState[param]!==undefined?ozState[param]:val,unit:unit,color:color,onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono text-center mt-1"},filterType));return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[100] flex items-center justify-center bg-black/70",onClick:onClose},/*#__PURE__*/React.createElement("div",{id:"masteringModalBody",className:"flex flex-col bg-slate-950 text-slate-200 w-[95vw] h-[92vh] max-w-[1400px] rounded-2xl border border-slate-800/80 shadow-2xl overflow-hidden",onClick:e=>e.stopPropagation(),style:{fontFamily:"'Inter', sans-serif"}},/*#__PURE__*/React.createElement("header",{className:"h-12 bg-slate-900/90 border-b border-slate-800/80 flex items-center justify-between px-4 z-30 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},/*#__PURE__*/React.createElement("div",{className:"w-7 h-7 rounded-lg bg-cyan-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-950"},/*#__PURE__*/React.createElement("i",{"data-lucide":"zap",className:"w-3.5 h-3.5"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("h1",{className:"text-xs font-bold tracking-wider text-white flex items-center gap-2"},"MASTER FX CHAIN ",/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/80 px-1.5 py-0.5 rounded oz-font-mono"},"WEB MASTERING V10.5")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 bg-slate-950 px-3 py-1 rounded-lg border border-slate-800/80"},/*#__PURE__*/React.createElement("button",{onClick:startAudioDemo,className:"px-3 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 transition-all shadow-md shadow-cyan-950"},/*#__PURE__*/React.createElement("i",{"data-lucide":"play",className:"w-3 h-3"})," ",/*#__PURE__*/React.createElement("span",null,"Play Reference")),/*#__PURE__*/React.createElement("button",{onClick:stopAudioDemo,className:"px-3 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs font-semibold flex items-center gap-1.5 transition-all"},/*#__PURE__*/React.createElement("i",{"data-lucide":"square",className:"w-3 h-3"})," ",/*#__PURE__*/React.createElement("span",null,"Stop")),/*#__PURE__*/React.createElement("div",{className:"h-4 w-[1px] bg-slate-800 mx-1"}),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-xs oz-font-mono"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 text-[11px]"},"Preset:"),/*#__PURE__*/React.createElement("select",{className:"bg-slate-900 border border-slate-700 rounded px-2 py-0.5 text-xs text-cyan-300 outline-none focus:border-cyan-500"},/*#__PURE__*/React.createElement("option",{value:"adaptive"},"Adaptive Dynamic Master"),/*#__PURE__*/React.createElement("option",{value:"edm_club"},"EDM / Club Punch Maximizer"),/*#__PURE__*/React.createElement("option",{value:"wide_space"},"Cinematic Stereo Expansion"),/*#__PURE__*/React.createElement("option",{value:"transparent"},"Transparent High-Clarity Limiter")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 text-xs oz-font-mono"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 text-[11px]"},"Target LUFS:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold bg-slate-950 border border-cyan-900/60 px-2 py-0.5 rounded"},"-11.0 LUFS"),/*#__PURE__*/React.createElement("button",{onClick:()=>{try{if(window.__phArmPwr)window.__phArmPwr(masterConnected?'off':'on');}catch(e){}getAudioContext();setMasterConnected(prev=>!prev);},className:`px-2 py-0.5 rounded text-[10px] font-bold border transition-colors ${masterConnected?'bg-emerald-700 border-emerald-500 text-white':'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}`},masterConnected?'Master ON':'Master OFF'),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-2 py-0.5 rounded text-[10px] font-bold border transition-colors bg-slate-800 border-slate-700 text-slate-300 hover:bg-[#c2410c] hover:border-red-600 hover:text-white"},"\u0110\xF3ng"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-500 hover:text-slate-300 ml-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"})))),/*#__PURE__*/React.createElement("div",{className:"h-16 bg-slate-950 border-b border-slate-800 px-4 flex items-center gap-2 overflow-x-auto shrink-0 oz-scrollbar"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 oz-font-mono shrink-0"},"CHAIN:"),(ozState.chain||[]).map((mod,idx)=>{const isVst=mod.type==='vst3';const meta=MODULE_META[mod.type]||{name:mod.type,sub:'',icon:'circle',color:'#94a3b8'};const isEditing=!isVst&&ozState.activeModule===mod.type||isVst&&activeMasterVstPath===mod.path;const isOn=isVst?mod.active!==false:chainActive(mod.type);return/*#__PURE__*/React.createElement("div",{key:mod.id,draggable:true,onDragStart:e=>{dragChainIndexRef.current=idx;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==idx)reorderChain(from,idx);dragChainIndexRef.current=null;},onClick:()=>{if(mod.type==='vst3'){openMasterVstGui({path:mod.path,name:mod.name||mod.path});}else{hideMasterGui();switchModule(mod.type);}},className:`w-40 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${isEditing?'oz-card-active':'oz-card'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleChainModule(mod.id);},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:isOn?'#38bdf8':'#334155',color:isOn?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",{className:"min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-slate-200 truncate"},isVst?mod.name||meta.name:meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[9px] oz-font-mono truncate",style:{color:meta.color}},idx+1,". ",isVst?mod.path?mod.path.split(/[\\/]/).pop():'VST3':meta.sub))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":meta.icon,className:"w-3 h-3 text-slate-500"}),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeChainModule(mod.id);},className:"text-slate-600 hover:text-red-400 text-xs w-4 h-4 flex items-center justify-center shrink-0",title:"X\xF3a module"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))));}),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(true),className:"w-24 h-12 rounded-lg border border-dashed border-slate-800 flex items-center justify-center text-slate-600 hover:text-slate-400 hover:border-slate-600 cursor-pointer transition-all shrink-0",title:"Th\xEAm module v\xE0o chain"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("main",{className:"flex-1 flex overflow-hidden min-h-0"},masterGuiOpen&&!masterGuiOpen.url&&masterGuiOpen.native?/*#__PURE__*/React.createElement(NativeGuiAnchor,{path:masterGuiOpen.path,name:masterGuiOpen.name,onClose:closeMasterGui,accent:"cyan"}):masterGuiOpen?/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col bg-slate-950 relative min-h-0"},/*#__PURE__*/React.createElement(VstGuiEmbed,{url:masterGuiOpen.url,onClose:closeMasterGui,onDead:()=>{window.showToast&&window.showToast('Plugin VST GUI lỗi khi mở (bridge chết/treo — check %TEMP%\\sf_dumps\\fx_bridge_crash_*.dmp) — đã trả về graph','error');closeMasterGui();}})):/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col bg-slate-950 relative overflow-y-auto oz-scrollbar"},/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='eq'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"relative w-full h-64 bg-slate-950 border border-slate-800/90 rounded-xl overflow-hidden shadow-inner cursor-crosshair"},/*#__PURE__*/React.createElement("canvas",{ref:eqCanvasRef,className:"w-full h-full block"})),/*#__PURE__*/React.createElement("div",{className:"oz-panel p-3 rounded-xl grid grid-cols-4 gap-3"},bandKnob('eqLowGain',-12,12,1.5,'dB','BAND 1 (LOW)','100 Hz','#22d3ee','Shelf Filter'),bandKnob('eqMid1Gain',-12,12,-1.0,'dB','BAND 2 (MID LOW)','822 Hz','#fbbf24','Dynamic Bell (Q: 0.7)'),bandKnob('eqMid2Gain',-12,12,2.0,'dB','BAND 3 (MID HIGH)','3.2 kHz','#a855f7','Dynamic Bell (Q: 1.2)'),bandKnob('eqHighGain',-12,12,1.8,'dB','BAND 4 (HIGH)','10 kHz','#34d399','High Shelf'))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='eqpro'?'':'hidden'}`},(()=>{const chainMods=ozState.chain||[];const cm=chainMods.find(m=>m.type==='eqpro'&&m.active!==false)||chainMods.find(m=>m.type==='eqpro');if(!cm)return/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-500 font-mono py-10 text-center"},"Ch\u01B0a c\xF3 module EQ PRO trong chain \u2014 b\u1EA5m [+] \u0111\u1EC3 th\xEAm.");return/*#__PURE__*/React.createElement(InteractiveEqPro,{track:null,params:cm.params||{},onChange:next=>updateChainEntryParams(cm.id,next),getModule:()=>null,spectrumModules:()=>[],applyTo:fn=>{const chain=fxRtChain();const slot=chain.findIndex(m=>m&&m.type==='eqpro');if(slot<0)return;fn({setBand:(idx,patch)=>{Object.keys(patch||{}).forEach(k=>{if(fxRt&&fxRt.ws&&fxRt.ws.readyState===1)fxRt.ws.send(JSON.stringify({cmd:'set_param',slot,key:'band'+idx+'_'+k,value:patch[k]}));});},setAmount:v=>{if(fxRt&&fxRt.ws&&fxRt.ws.readyState===1)fxRt.ws.send(JSON.stringify({cmd:'set_param',slot,key:'amount',value:v}));},syncBands:bands=>{(bands||[]).forEach((b,i)=>{['freq','gain','q'].forEach(k=>{if(fxRt&&fxRt.ws&&fxRt.ws.readyState===1)fxRt.ws.send(JSON.stringify({cmd:'set_param',slot,key:'band'+i+'_'+k,value:b[k]}));});});}});}});})()),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='imager'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-12 gap-4 flex-1"},/*#__PURE__*/React.createElement("div",{className:"col-span-8 bg-slate-950 border border-slate-800/90 rounded-xl p-3 flex flex-col relative shadow-inner"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-cyan-400 uppercase oz-font-mono mb-2 flex items-center justify-between w-full"},/*#__PURE__*/React.createElement("span",null,/*#__PURE__*/React.createElement("i",{"data-lucide":"radio",className:"w-3.5 h-3.5"})," Polar Vectorscope & Correlation Meter"),/*#__PURE__*/React.createElement("span",{className:"text-[11px] text-slate-400 normal-case"},"Corr: ",/*#__PURE__*/React.createElement("span",{id:"corrText",className:"font-bold text-emerald-400"},"1.00"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 relative w-full h-56 flex items-center justify-center"},/*#__PURE__*/React.createElement("canvas",{ref:imagerCanvasRef,className:"w-full h-full block"}))),/*#__PURE__*/React.createElement("div",{className:"col-span-4 oz-panel p-3 rounded-xl flex flex-col justify-between"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-slate-300 uppercase oz-font-mono"},"4-Band Stereo Width"),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-500 oz-font-mono leading-snug"},"0% = Mono \xB7 100% = Original \xB7 200% = 2\xD7 Width"),/*#__PURE__*/React.createElement("div",{className:"space-y-3 my-auto"},[{id:'w1',label:'Band 1 (20-100Hz)',color:'#22d3ee',val:ozState.w1},{id:'w2',label:'Band 2 (100Hz-1kHz)',color:'#fbbf24',val:ozState.w2},{id:'w3',label:'Band 3 (1k-6kHz)',color:'#a855f7',val:ozState.w3},{id:'w4',label:'Band 4 (6k-20kHz)',color:'#34d399',val:ozState.w4}].map(b=>/*#__PURE__*/React.createElement("div",{key:b.id},/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-[11px] font-mono mb-1"},/*#__PURE__*/React.createElement("span",{style:{color:b.color,fontWeight:700}},b.label),/*#__PURE__*/React.createElement("span",{id:b.id+'Val'},b.val,"%")),/*#__PURE__*/React.createElement("input",{type:"range",min:"0",max:"200",value:b.val,onChange:e=>setOzState(prev=>({...prev,[b.id]:parseInt(e.target.value)})),className:"w-full h-1 cursor-pointer",style:{accentColor:b.color}}))))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='maximizer'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-slate-400 uppercase oz-font-mono mb-3"},"Maximizer Gain Boost"),/*#__PURE__*/React.createElement("div",{className:"my-2"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxGain",min:0,max:12,value:ozState.maxGain,unit:"dB",color:"#22d3ee",size:"large",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"w-full mt-4 bg-slate-950 p-2 rounded-lg border border-slate-800 text-xs oz-font-mono flex flex-col gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center w-full"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Ceiling Level:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold"},ozState.ceiling.toFixed(2)," dB")),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",step:"0.1",value:ozState.ceiling,onChange:e=>setOzState(prev=>({...prev,ceiling:parseFloat(e.target.value)})),className:"w-full h-1 cursor-pointer accent-cyan-400"}))),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxUpward",min:0,max:10,value:ozState.maxUpward,unit:"dB",label:"UPWARD COMPRESS",color:"#22d3ee",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxSoftClip",min:0,max:100,value:ozState.maxSoftClip,unit:"%",label:"SOFT CLIPPER",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxTransient",min:0,max:100,value:ozState.maxTransient,unit:"%",label:"TRANSIENT EMPHASIS",color:"#34d399",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))}))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='compressor'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-amber-400 uppercase oz-font-mono mb-3"},"Bus Compressor"),/*#__PURE__*/React.createElement("div",{className:"my-2"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compMakeup",min:0,max:12,value:ozState.compMakeup,unit:"dB",label:"MAKE-UP GAIN",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='compressor')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.compActive?'bg-amber-700 border-amber-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.compActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compThreshold",min:-60,max:0,value:ozState.compThreshold,unit:"dB",label:"THRESHOLD",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compRatio",min:1,max:20,value:ozState.compRatio,unit:":1",label:"RATIO",color:"#f59e0b",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"Glue & Punch",/*#__PURE__*/React.createElement("br",null),"Attack 20ms \xB7 Release 250ms \xB7 Knee 8dB"))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='limiter'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-rose-400 uppercase oz-font-mono mb-3"},"Brickwall Limiter"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='limiter')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.limActive?'bg-rose-700 border-rose-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.limActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"limThreshold",min:-24,max:0,value:ozState.limThreshold,unit:"dB",label:"CEILING",color:"#f43f5e",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center gap-2 h-full"},/*#__PURE__*/React.createElement("button",{onClick:()=>setOzState(prev=>({...prev,limMode:prev.limMode?0:1})),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors ${ozState.limMode?'bg-rose-700 border-rose-500 text-white':'bg-slate-800 border-slate-700 text-slate-400'}`},ozState.limMode?'BRICKWALL':'SOFT'),/*#__PURE__*/React.createElement(MasteringKnob,{param:"limLookahead",min:1,max:5,value:ozState.limLookahead,unit:"ms",label:"LOOKAHEAD",color:"#f43f5e",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))}))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='multiband'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-lime-400 uppercase oz-font-mono mb-3"},"Multiband Compressor"),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 oz-font-mono text-center mb-2"},"LR4 crossover \xB7 low/mid/high"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule(mb?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.mbActive?'bg-lime-700 border-lime-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.mbActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"lf_cross",min:40,max:1000,value:mb?.params?.lf_cross??200,unit:"Hz",label:"LF XOVER",color:"#a3e635",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{lf_cross:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"hf_cross",min:1000,max:12000,value:mb?.params?.hf_cross??4000,unit:"Hz",label:"HF XOVER",color:"#a3e635",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{hf_cross:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"Low < LF",/*#__PURE__*/React.createElement("br",null),"Mid gi\u1EEFa 2 crossover",/*#__PURE__*/React.createElement("br",null),"High > HF \xB7 Release 250ms")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"low_thr",min:-60,max:0,value:mb?.params?.low_thr??-24,unit:"dB",label:"LOW THRESHOLD",color:"#84cc16",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{low_thr:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"low_ratio",min:1,max:20,value:mb?.params?.low_ratio??3,unit:":1",label:"LOW RATIO",color:"#84cc16",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{low_ratio:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"low_makeup",min:0,max:12,value:mb?.params?.low_makeup??0,unit:"dB",label:"LOW MAKE-UP",color:"#84cc16",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{low_makeup:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"mid_thr",min:-60,max:0,value:mb?.params?.mid_thr??-24,unit:"dB",label:"MID THRESHOLD",color:"#65a30d",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{mid_thr:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"mid_ratio",min:1,max:20,value:mb?.params?.mid_ratio??3,unit:":1",label:"MID RATIO",color:"#65a30d",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{mid_ratio:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"mid_makeup",min:0,max:12,value:mb?.params?.mid_makeup??0,unit:"dB",label:"MID MAKE-UP",color:"#65a30d",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{mid_makeup:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"high_thr",min:-60,max:0,value:mb?.params?.high_thr??-24,unit:"dB",label:"HIGH THRESHOLD",color:"#4d7c0f",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{high_thr:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"high_ratio",min:1,max:20,value:mb?.params?.high_ratio??3,unit:":1",label:"HIGH RATIO",color:"#4d7c0f",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{high_ratio:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"high_makeup",min:0,max:12,value:mb?.params?.high_makeup??0,unit:"dB",label:"HIGH MAKE-UP",color:"#4d7c0f",onChange:(p,v)=>mb&&updateChainEntryParams(mb.id,{high_makeup:v})}))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='deesser'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-orange-400 uppercase oz-font-mono mb-3"},"De-Esser"),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 oz-font-mono text-center mb-2"},"Detect sibilance @freq \xB7 envelope release 150ms"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule(de?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.deessActive?'bg-orange-700 border-orange-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.deessActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-4 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"threshold",min:-60,max:0,value:de?.params?.threshold??-30,unit:"dB",label:"THRESHOLD",color:"#fb923c",onChange:(p,v)=>de&&updateChainEntryParams(de.id,{threshold:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"ratio",min:0,max:100,value:de?.params?.ratio??40,unit:"%",label:"STRENGTH",color:"#fb923c",onChange:(p,v)=>de&&updateChainEntryParams(de.id,{ratio:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"freq",min:2000,max:12000,value:de?.params?.freq??6000,unit:"Hz",label:"FREQ",color:"#f97316",onChange:(p,v)=>de&&updateChainEntryParams(de.id,{freq:v})})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"makeup",min:0,max:12,value:de?.params?.makeup??0,unit:"dB",label:"MAKE-UP",color:"#f97316",onChange:(p,v)=>de&&updateChainEntryParams(de.id,{makeup:v})}))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='exciter'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-purple-400 uppercase oz-font-mono mb-3"},"Harmonic Exciter"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='exciter')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.excActive?'bg-purple-700 border-purple-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.excActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"excDrive",min:0,max:100,value:ozState.excDrive,unit:"%",label:"DRIVE / MIX",color:"#c084fc",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"WaveShaper saturation",/*#__PURE__*/React.createElement("br",null),"High-pass 2kHz",/*#__PURE__*/React.createElement("br",null),"4\xD7 oversampled \xB7 wet/dry mix"))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='rebalance'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-sky-400 uppercase oz-font-mono mb-3"},"Master Rebalance (M/S)"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='rebalance')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.rebalActive?'bg-sky-700 border-sky-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.rebalActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"rebalMid",min:-12,max:12,value:ozState.rebalMid,unit:"dB",label:"MID GAIN",color:"#38bdf8",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"rebalSide",min:-12,max:12,value:ozState.rebalSide,unit:"dB",label:"SIDE GAIN",color:"#22d3ee",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"ChannelSplitter + M/S gains",/*#__PURE__*/React.createElement("br",null),"Center (vocal/bass) vs Sides (stereo width)")))))),/*#__PURE__*/React.createElement("div",{className:"w-64 bg-slate-900 border-l border-slate-800/80 p-3 flex flex-col justify-between shadow-2xl oz-font-mono text-xs z-20 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-2 mb-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-white tracking-wider uppercase flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3 h-3 text-cyan-400"})," I/O METERS"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-300 px-1 py-0.5 rounded"},"TRUE PEAK")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-[10px] text-center mb-2"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"IN PEAK"),/*#__PURE__*/React.createElement("div",{id:"inPeakText",className:"text-cyan-400 font-bold oz-font-mono"},"-inf dB")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"OUT PEAK"),/*#__PURE__*/React.createElement("div",{id:"outPeakText",className:"text-emerald-400 font-bold oz-font-mono"},"-inf dB"))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-[10px] text-center mb-2"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"OUT RMS"),/*#__PURE__*/React.createElement("div",{id:"outRmsText",className:"text-sky-300 font-bold oz-font-mono"},"-inf")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"LOUDNESS"),/*#__PURE__*/React.createElement("div",{id:"lufsText",className:"text-fuchsia-300 font-bold oz-font-mono"},"--.-"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 bg-slate-950 border border-slate-800/90 rounded-xl p-2 flex justify-around relative overflow-hidden my-1"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 mb-1"},"IN"),/*#__PURE__*/React.createElement("div",{className:"flex-1 w-5 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end"},/*#__PURE__*/React.createElement("canvas",{ref:inMeterCanvasRef,className:"w-full h-full block"}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 mb-1"},"OUT"),/*#__PURE__*/React.createElement("div",{className:"flex-1 w-6 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end"},/*#__PURE__*/React.createElement("canvas",{ref:outMeterCanvasRef,className:"w-full h-full block"})))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1.5 mt-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setOzState(prev=>({...prev,isBypassed:!prev.isBypassed})),className:`py-1.5 rounded text-[10px] font-bold border transition-colors ${ozState.isBypassed?'bg-cyan-600 border-cyan-500 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}`},"Bypass"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-200 py-1.5 rounded text-[10px] font-bold border border-slate-700 transition-colors"},"Gain Match"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700"},"Codec"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700"},"Dither"))))),addModuleOpen&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[110] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4",onClick:()=>setAddModuleOpen(false)},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-lg bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl max-h-[80vh] overflow-y-auto",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider oz-font-mono"},"TH\xCAM MODULE V\xC0O MASTER FX CHAIN"),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(false),className:"text-slate-400 hover:text-white"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3 text-xs"},[{t:'eqpro',c:'text-teal-400',i:'chart-area',d:'Parametric / Graphic EQ PRO — Pro-Q style, 8 bands, interactive canvas + spectrum.'},{t:'compressor',c:'text-amber-400',i:'compress',d:'Nén dynamic range, glue & punch cho master.'},{t:'limiter',c:'text-rose-400',i:'shield-half',d:'True-Peak ceiling (ratio 20:1, knee 0) chống clipping.'},{t:'exciter',c:'text-purple-400',i:'wand-2',d:'Saturation hài cho warmth và top-end brilliance.'},{t:'rebalance',c:'text-sky-400',i:'sliders-horizontal',d:'Cân bằng Mid/Side (Vocal/Bass vs stereo width).'},{t:'eq',c:'text-cyan-400',i:'activity',d:'EQ 4-band (lowshelf, 2× peaking, highshelf) + presets.'},{t:'imager',c:'text-fuchsia-400',i:'radio',d:'Stereo width 4-band M/S + vectorscope/correlation.'},{t:'maximizer',c:'text-emerald-400',i:'gauge',d:'Maximizer: boost, soft clip, upward comp, ceiling.'},{t:'multiband',c:'text-lime-400',i:'layers',d:'Compressor 3 băng tần low/mid/high với crossover LR4.'},{t:'deesser',c:'text-orange-400',i:'mic-2',d:'Giảm sibilance (âm xuýt) theo tần số phát hiện.'}].map(m=>/*#__PURE__*/React.createElement("button",{key:m.t,onClick:()=>addModuleToChain(m.t),className:"p-3 bg-slate-950 hover:bg-slate-800 border border-slate-800 rounded-xl text-left space-y-1 transition-colors"},/*#__PURE__*/React.createElement("div",{className:`font-bold ${m.c} flex items-center gap-1.5`},/*#__PURE__*/React.createElement("i",{"data-lucide":m.i,className:"w-3.5 h-3.5"})," ",MODULE_META[m.t].name),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-400"},m.d)))),/*#__PURE__*/React.createElement("div",{className:"border-t border-slate-800 pt-3"},/*#__PURE__*/React.createElement("div",{className:"text-[10px] font-bold text-teal-400 uppercase tracking-wider oz-font-mono mb-2"},"VST FX (Ozone...)"),/*#__PURE__*/React.createElement("button",{onClick:()=>setMasterVstPickerOpen(true),className:"w-full bg-slate-950 hover:bg-slate-800 border border-dashed border-slate-700 hover:border-teal-600 text-teal-300 rounded px-2 py-2 text-[11px] text-left transition-colors",title:"Plugin Picker theo category (EQ / Reverb / Comp / Utility)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plug",className:"w-3 h-3 inline-block mr-1"})," Ch\u1ECDn VST FX t\u1EEB th\u01B0 vi\u1EC7n \u2014 Picker: EQ / Reverb / Comp / Utility")))),/*#__PURE__*/React.createElement(VstPluginPicker,{open:masterVstPickerOpen,onClose:()=>setMasterVstPickerOpen(false),plugins:masterFxList,loading:masterFxLoading,onPick:f=>addVstToChain(f.path)}));};// ── Media Explorer Panel (from md/51_MEDIA_EXPLORER.md) ──
|
||
const MEDIA_LIBRARY_SAMPLES=[{name:"MIDI_Loop_01.mid",events:95,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_02_Bass.mid",events:48,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_03_Lead.mid",events:110,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_04.mid",events:76,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_05_Bass.mid",events:52,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_06.mid",events:88,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"}];const MediaExplorerPanel=({height,clipboardRef,active})=>{const[userFiles,setUserFiles]=React.useState([]);const[folder,setFolder]=React.useState('library');const[selected,setSelected]=React.useState(null);const[filterText,setFilterText]=React.useState('');const[viewMode,setViewMode]=React.useState('details');const[isPlaying,setIsPlaying]=React.useState(false);const[isPaused,setIsPaused]=React.useState(false);const[isLooping,setIsLooping]=React.useState(false);const[autoPlay,setAutoPlay]=React.useState(true);const[pitch,setPitch]=React.useState(0.0);const[rate,setRate]=React.useState(1.0);const[volumeDb,setVolumeDb]=React.useState(0.0);const[currentTime,setCurrentTime]=React.useState(0);const[peaks,setPeaks]=React.useState(null);const[audioBuffer,setAudioBuffer]=React.useState(null);const[audioDuration,setAudioDuration]=React.useState(0);const[midiNotes,setMidiNotes]=React.useState(null);const[midiTotal,setMidiTotal]=React.useState(4);const[midiBars,setMidiBars]=React.useState(1);const[midiTotalBeats,setMidiTotalBeats]=React.useState(16);const[midiFileBpm,setMidiFileBpm]=React.useState(120);const[tempo,setTempo]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_tempo');return saved?parseInt(saved):120;});const[tempoText,setTempoText]=React.useState(String(function(){var saved=localStorage.getItem('studio_media_explorer_tempo');return saved?parseInt(saved):120;}()));const[zoom,setZoom]=React.useState(1.0);// Đồng bộ zoom ra window ref — serializeProjectToSchema lưu vào metadata.zoom
|
||
// (module-level không truy cập state) → restore/open khôi phục đúng zoom.
|
||
React.useEffect(()=>{window.__currentZoom=zoom;},[zoom]);const[scrollOffset,setScrollOffset]=React.useState(0);const scrollOffsetRef=React.useRef(0);scrollOffsetRef.current=scrollOffset;const[selStart,setSelStart]=React.useState(null);const[selEnd,setSelEnd]=React.useState(null);const[isDragging,setIsDragging]=React.useState(false);const[previewCtxMenu,setPreviewCtxMenu]=React.useState(null);// { x, y }
|
||
const containerRef=React.useRef(null);// Refs mirror latest state so drawCanvas (also called from rAF clock with a
|
||
// stale closure) always draws the currently selected file, not the old one.
|
||
const selectedRef=React.useRef(null);const peaksRef=React.useRef(null);const audioBufferRef=React.useRef(null);const audioDurationRef=React.useRef(0);const midiNotesRef=React.useRef(null);const midiTotalRef=React.useRef(4);const midiBarsRef=React.useRef(1);const midiTotalBeatsRef=React.useRef(16);const midiFileBpmRef=React.useRef(120);const isPlayingRef=React.useRef(false);const isPausedRef=React.useRef(false);const folderRef=React.useRef('library');const tempoRef=React.useRef(120);const currentTimeRef=React.useRef(0);const selStartRef=React.useRef(null);const selEndRef=React.useRef(null);const isLoopingRef=React.useRef(false);selectedRef.current=selected;peaksRef.current=peaks;audioBufferRef.current=audioBuffer;audioDurationRef.current=audioDuration;midiNotesRef.current=midiNotes;midiTotalRef.current=midiTotal;midiBarsRef.current=midiBars;midiTotalBeatsRef.current=midiTotalBeats;midiFileBpmRef.current=midiFileBpm;isPlayingRef.current=isPlaying;_timelinePlaying=isPlaying;isPausedRef.current=isPaused;folderRef.current=folder;tempoRef.current=tempo;currentTimeRef.current=currentTime;selStartRef.current=selStart;selEndRef.current=selEnd;isLoopingRef.current=isLooping;const computerPathRef=React.useRef(null);const computerTreeRef=React.useRef({});const computerRootsRef=React.useRef(null);const treePaneRef=React.useRef(null);const browseComputerDirRef=React.useRef(null);React.useEffect(()=>{setScrollOffset(0);},[selected]);// Keep the tempo text field in sync when tempo changes from elsewhere
|
||
// (e.g. auto-set from a clicked MIDI file).
|
||
React.useEffect(()=>{setTempoText(String(tempo));},[tempo]);React.useEffect(()=>{window.mediaExplorerActive=true;const handleDocumentClick=e=>{if(containerRef.current&&containerRef.current.contains(e.target)){window.mediaExplorerActive=true;}else{window.mediaExplorerActive=false;}};document.addEventListener('mousedown',handleDocumentClick,{capture:true});return()=>{document.removeEventListener('mousedown',handleDocumentClick,{capture:true});window.mediaExplorerActive=false;};},[]);const getCanvasLayout=()=>{const canvas=canvasRef.current;if(!canvas)return{w:300,h:100,pxPerBeat:42,pxPerSec:84,offset:0,dur:0,contentW:0};const w=canvas.clientWidth;const h=canvas.clientHeight;const f=selectedRef.current;const isMidi=isMidiFile(f);const curTempo=tempoRef.current||120;const pxPerBeat=42*zoom;const pxPerSec=pxPerBeat*curTempo/60;const dur=fileDuration(f);let contentW=0;if(isMidi){const isRealMidi=midiNotesRef.current&&midiNotesRef.current.length&&midiTotalBeatsRef.current>0;const totalBeats=isRealMidi?Math.max(midiTotalBeatsRef.current,4):Math.max(4,Math.ceil(dur*curTempo/60)||16);contentW=Math.max(1,totalBeats*pxPerBeat);}else{contentW=Math.max(1,dur*pxPerSec);}let offset=scrollOffset;if(isPlayingRef.current&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,currentTimeRef.current*pxPerSec-w/2));}else{offset=Math.max(0,Math.min(contentW-w,scrollOffset));}return{w,h,pxPerBeat,pxPerSec,offset,dur,contentW};};const getSelectedAudioBuffer=async()=>{if(audioBufferRef.current)return audioBufferRef.current;if(!selectedRef.current)return null;const buf=await readLocalFileBuffer(selectedRef.current);if(!buf)return null;try{const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);setAudioBuffer(decoded);return decoded;}catch(e){console.error(e);return null;}};const handleCanvasWheel=e=>{if(!selected)return;e.preventDefault();if(e.shiftKey){const scrollSpeed=45;const direction=e.deltaY>0?1:-1;setScrollOffset(prev=>{const{contentW,w}=getCanvasLayout();const maxScroll=Math.max(0,contentW-w);return Math.max(0,Math.min(maxScroll,prev+direction*scrollSpeed));});return;}const zoomFactor=1.15;if(e.deltaY<0){setZoom(prev=>Math.min(10.0,prev*zoomFactor));}else{setZoom(prev=>Math.max(0.2,prev/zoomFactor));}};// React attaches onWheel passively at the root, so e.preventDefault() there is
|
||
// ignored and Chrome logs "Unable to preventDefault inside passive event listener".
|
||
// Use a native non-passive wheel listener so page scroll is actually blocked.
|
||
const handleCanvasWheelRef=React.useRef(handleCanvasWheel);handleCanvasWheelRef.current=handleCanvasWheel;React.useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const h=e=>handleCanvasWheelRef.current(e);canvas.addEventListener('wheel',h,{passive:false});return()=>canvas.removeEventListener('wheel',h);},[]);const handleCanvasMouseDown=e=>{if(!selected)return;if(e.button===2)return;// Right click context menu
|
||
const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const clientX=e.clientX-rect.left;const{pxPerSec,pxPerBeat,offset}=getCanvasLayout();const isMidi=isMidiFile(selected);const value=isMidi?(clientX+offset)/pxPerBeat// in beats
|
||
:(clientX+offset)/pxPerSec;// in seconds
|
||
setSelStart(value);setSelEnd(value);setIsDragging(true);setPreviewCtxMenu(null);};const handleCanvasMouseMove=e=>{if(!isDragging||!selected)return;const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const clientX=e.clientX-rect.left;const{pxPerSec,pxPerBeat,offset}=getCanvasLayout();const isMidi=isMidiFile(selected);const value=isMidi?(clientX+offset)/pxPerBeat:(clientX+offset)/pxPerSec;setSelEnd(value);};const handleCanvasMouseUp=e=>{if(isDragging){setIsDragging(false);}};const handleCanvasContextMenu=e=>{e.preventDefault();if(!selected||selStart===null||selEnd===null||Math.abs(selStart-selEnd)<0.01)return;setPreviewCtxMenu({x:e.clientX,y:e.clientY});};const handleCopySelection=async()=>{if(!selected||selStart===null||selEnd===null)return;const isMidi=isMidiFile(selected);const startVal=Math.min(selStart,selEnd);const endVal=Math.max(selStart,selEnd);if(isMidi){if(!midiNotes||!midiNotes.length){window.showToast&&window.showToast('Không có dữ liệu MIDI để sao chép','warning');return;}const copiedNotes=midiNotes.filter(n=>n.start_beat>=startVal&&n.start_beat<=endVal).map(n=>({...n,start_beat:n.start_beat-startVal}));if(!copiedNotes.length){window.showToast&&window.showToast('Không có note MIDI nào trong vùng chọn','warning');return;}const selectDurBeats=endVal-startVal;const secondsPerBeat=60/(tempo||120);const selectDurSec=selectDurBeats*secondsPerBeat;const clipObj={type:'midi',notes:copiedNotes,duration:selectDurSec,name:selected.name||'MIDI Selection',color:'#a855f7'};if(clipboardRef)clipboardRef.current=clipObj;window.globalStudioClipboard=clipObj;window.showToast&&window.showToast(`Đã sao chép ${copiedNotes.length} notes MIDI.`,'success');}else{const activeBuf=await getSelectedAudioBuffer();if(!activeBuf){window.showToast&&window.showToast('Không thể tải dữ liệu âm thanh để sao chép','error');return;}const sr=activeBuf.sampleRate;const startSample=Math.floor(startVal*sr);const endSample=Math.floor(endVal*sr);const len=Math.max(1,endSample-startSample);try{const ctx=getAudioContext();const numCh=activeBuf.numberOfChannels||1;const clipBuffer=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch<numCh;ch++){clipBuffer.copyToChannel(activeBuf.getChannelData(ch).subarray(startSample,endSample),ch);}const clipObj={buffer:clipBuffer,name:selected.name||'Audio Selection',volumeDb:0,pan:0,color:'#10b981',sampleRate:sr,channels:numCh,speed:1.0};if(clipboardRef)clipboardRef.current=clipObj;window.globalStudioClipboard=clipObj;window.showToast&&window.showToast(`Đã sao chép đoạn audio dài ${(len/sr).toFixed(2)}s.`,'success');}catch(e){console.error(e);window.showToast&&window.showToast('Lỗi khi trích xuất âm thanh vùng chọn','error');}}};React.useEffect(()=>{const handleGlobalClick=()=>setPreviewCtxMenu(null);window.addEventListener('click',handleGlobalClick);return()=>window.removeEventListener('click',handleGlobalClick);},[]);React.useEffect(()=>{const handleKeyDown=e=>{if(e.key===' '||e.code==='Space'){if(window.mediaExplorerActive){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable)){return;}e.preventDefault();e.stopPropagation();if(isPlayingRef.current){stopMediaPlayback();}else{const cur=selectedRef.current;if(cur){selectTokenRef.current++;playSelected(cur,selectTokenRef.current);}}}}if(e.key==='ArrowUp'||e.key==='ArrowDown'||e.key==='ArrowLeft'||e.key==='ArrowRight'){if(window.mediaExplorerActive&&folderRef.current==='computer'){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable)){return;}e.preventDefault();e.stopPropagation();// Build the flat list of visible (expanded) tree nodes.
|
||
const paths=[];const walk=(entry,depth)=>{if(!entry||!entry.path)return;paths.push({path:entry.path,entry,depth});const n=computerTreeRef.current[entry.path];if(n&&n.expanded&&n.dirs)(n.dirs||[]).forEach(d=>walk(d,depth+1));};(computerRootsRef.current||[]).forEach(r=>walk(r,0));if(!paths.length)return;const curPath=computerPathRef.current;let idx=paths.findIndex(p=>p.path===curPath);if(e.key==='ArrowUp'){const ni=idx<0?paths.length-1:Math.max(0,idx-1);navigateTreeTo(paths[ni].path);}else if(e.key==='ArrowDown'){const ni=Math.min(paths.length-1,(idx<0?-1:idx)+1);navigateTreeTo(paths[ni].path);}else if(e.key==='ArrowRight'){const node=computerTreeRef.current[curPath];if(node&&!node.expanded){setComputerTree(prev=>({...prev,[curPath]:{...(prev[curPath]||{}),expanded:true}}));if(!node.dirs||!node.dirs.length){if(browseComputerDirRef.current)browseComputerDirRef.current({name:String(curPath).split('/').pop()||curPath,path:curPath,is_dir:true,handle:node.handle});}}else if(idx<0){const p0=paths[0];if(p0)navigateTreeTo(p0.path);}}else if(e.key==='ArrowLeft'){const node=computerTreeRef.current[curPath];if(node&&node.expanded){setComputerTree(prev=>({...prev,[curPath]:{...(prev[curPath]||{}),expanded:false}}));}else if(curPath){const i=curPath.lastIndexOf('/');if(i>0){navigateTreeTo(curPath.substring(0,i));}}}}}if(e.key==='c'&&(e.ctrlKey||e.metaKey)){if(window.mediaExplorerActive){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable)){return;}if(selStartRef.current!==null&&selEndRef.current!==null&&Math.abs(selStartRef.current-selEndRef.current)>0.01){e.preventDefault();e.stopPropagation();handleCopySelection();}}}};window.addEventListener('keydown',handleKeyDown,{capture:true});return()=>{window.removeEventListener('keydown',handleKeyDown,{capture:true});};},[]);const[computerRoots,setComputerRoots]=React.useState(null);const[computerTree,setComputerTree]=React.useState({});const[computerPath,setComputerPath]=React.useState(null);const[computerFiles,setComputerFiles]=React.useState([]);const[computerMode,setComputerMode]=React.useState('server');const[clientRoot,setClientRoot]=React.useState(null);computerPathRef.current=computerPath;computerTreeRef.current=computerTree;computerRootsRef.current=computerRoots;const[favorites,setFavorites]=React.useState(function(){try{return JSON.parse(localStorage.getItem('studio_media_explorer_favorites_v1')||'[]');}catch(e){return[];}}());const[favContext,setFavContext]=React.useState(null);const[favoritedExpanded,setFavoritedExpanded]=React.useState(true);const[colWidths,setColWidths]=React.useState({file:260,size:100,type:100});const startColResize=(colKey,e)=>{e.preventDefault();const startX=e.clientX;const startWidth=colWidths[colKey];const onMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const newWidth=Math.max(50,startWidth+deltaX);setColWidths(prev=>({...prev,[colKey]:newWidth}));};const onMouseUp=()=>{document.removeEventListener('mousemove',onMouseMove);document.removeEventListener('mouseup',onMouseUp);};document.addEventListener('mousemove',onMouseMove);document.addEventListener('mouseup',onMouseUp);};const[synthInst,setSynthInst]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_synth');return saved?JSON.parse(saved):null;}());const[synthOpen,setSynthOpen]=React.useState(false);const[synthFilter,setSynthFilter]=React.useState('');const[synthList,setSynthList]=React.useState(null);const[synthLoading,setSynthLoading]=React.useState(false);const synthListRef=React.useRef(null);synthListRef.current=synthList;const synthInstRef=React.useRef(null);synthInstRef.current=synthInst;const btnSynthRef=React.useRef(null);const[treeWidth,setTreeWidth]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_tree_width');return saved?parseInt(saved):176;}());const treeWidthRef=React.useRef(176);treeWidthRef.current=treeWidth;const startTreeResize=e=>{e.preventDefault();const startX=e.clientX;const startW=treeWidthRef.current;const onMove=ev=>{const newW=Math.max(110,Math.min(420,startW+(ev.clientX-startX)));treeWidthRef.current=newW;setTreeWidth(newW);};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);localStorage.setItem('studio_media_explorer_tree_width',treeWidthRef.current.toString());};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};const canvasRef=React.useRef(null);const playStateRef=React.useRef(null);const rafRef=React.useRef(null);const loopTimerRef=React.useRef(null);const selectTokenRef=React.useRef(0);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listMyFiles){window.SonicAPI.listMyFiles([]).then(data=>setUserFiles(data||[])).catch(()=>{});}return()=>{stopMediaPlayback();};// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[]);// ── Session persistence: keep loaded folder/files/tree across panel toggles & reloads ──
|
||
const SESSION_KEY='studio_media_explorer_session_v1';const saveClientRootHandle=(key='client_root',handle=clientRoot)=>{if(!handle||!window.indexedDB)return;try{const req=indexedDB.open('sonicforge_media_explorer',1);req.onupgradeneeded=e=>{const db=e.target.result;if(!db.objectStoreNames.contains('root_handle'))db.createObjectStore('root_handle');};req.onsuccess=()=>{const db=req.result;const tx=db.transaction('root_handle','readwrite');tx.objectStore('root_handle').put(handle,key);};}catch(e){}};const loadClientRootHandle=(key='client_root')=>{return new Promise(resolve=>{if(!window.indexedDB){resolve(null);return;}try{const req=indexedDB.open('sonicforge_media_explorer',1);req.onupgradeneeded=e=>{const db=e.target.result;if(!db.objectStoreNames.contains('root_handle'))db.createObjectStore('root_handle');};req.onsuccess=()=>{const db=req.result;try{const tx=db.transaction('root_handle','readonly');const g=tx.objectStore('root_handle').get(key);g.onsuccess=()=>resolve(g.result||null);g.onerror=()=>resolve(null);}catch(e2){resolve(null);}};req.onerror=()=>resolve(null);}catch(e){resolve(null);}});};const saveSession=React.useCallback(()=>{try{const stripHandle=o=>{if(Array.isArray(o))return o.map(stripHandle);if(o&&typeof o==='object'){const out={};for(const k of Object.keys(o)){if(k==='handle')continue;out[k]=stripHandle(o[k]);}return out;}return o;};const treeSnapshot={};Object.keys(computerTree).forEach(path=>{const node=computerTree[path];treeSnapshot[path]={dirs:stripHandle(node?node.dirs:[]),expanded:!!(node&&node.expanded)};});const snap={folder,computerMode,computerPath,clientRootName:clientRoot?clientRoot.name:null,computerRoots:stripHandle(computerRoots||[]),computerTree:treeSnapshot,computerFiles:stripHandle(computerFiles||[]),selected:selected?stripHandle({name:selected.name,path:selected.path,kind:selected.kind,is_dir:selected.is_dir,size_mb:selected.size_mb}):null,savedAt:Date.now()};localStorage.setItem(SESSION_KEY,JSON.stringify(snap));}catch(e){}},[folder,computerMode,computerPath,clientRoot,computerRoots,computerTree,computerFiles,selected]);React.useEffect(()=>{saveSession();if(computerMode==='client'&&clientRoot&&clientRoot.kind==='directory')saveClientRootHandle();},[saveSession,computerMode,clientRoot]);React.useEffect(()=>{(async()=>{let lastFolder='library';let lastComputerPath='favorited';try{const raw=localStorage.getItem(SESSION_KEY);if(raw){const snap=JSON.parse(raw);if(snap.folder)lastFolder=snap.folder;if(snap.computerPath)lastComputerPath=snap.computerPath;setFolder(lastFolder);}}catch(e){}// 1. Khôi phục client-side root handle từ IndexedDB
|
||
const savedHandle=await loadClientRootHandle();let restoredClientRoot=null;if(savedHandle&&savedHandle.kind==='directory'){setClientRoot(savedHandle);restoredClientRoot=savedHandle;setComputerMode('client');const rootEntry={name:savedHandle.name,path:'root',is_dir:true,handle:savedHandle};setComputerRoots([rootEntry]);// Quét đúng 1 cấp con dưới root của client
|
||
await browseComputerDir(rootEntry);}else{setComputerRoots(null);setComputerMode('client');}// 2. Khôi phục thư mục của phiên làm việc trước
|
||
if(lastFolder==='computer'&&lastComputerPath&&lastComputerPath!=='my_computer'&&lastComputerPath!=='favorited'){if(restoredClientRoot){if(lastComputerPath==='root'){browseComputerDir({name:restoredClientRoot.name,path:'root',is_dir:true,handle:restoredClientRoot});}else if(lastComputerPath.startsWith('root/')){const segments=lastComputerPath.split('/').slice(1);let curHandle=restoredClientRoot;let success=true;for(const seg of segments){try{curHandle=await curHandle.getDirectoryHandle(seg);}catch(err){success=false;break;}}if(success){browseComputerDir({name:segments[segments.length-1]||restoredClientRoot.name,path:lastComputerPath,is_dir:true,handle:curHandle});}else{setComputerPath('favorited');}}else{setComputerPath('favorited');}}else{setComputerPath('favorited');}}else{setComputerPath(lastComputerPath==='my_computer'?'favorited':lastComputerPath||'favorited');}})();// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
},[]);const isMidiFile=f=>f&&(f.kind==='midi'||/\.(mid|midi)$/i.test(f.name||f.original_name||''));const fileDuration=f=>{if(!f)return 0;if(isMidiFile(f)){if(midiTotalRef.current&&midiNotesRef.current&&midiNotesRef.current.length)return midiTotalRef.current;return(f.lengthQn||16)*60/(f.bpm||tempoRef.current||120);}const sel=selectedRef.current;const matches=sel&&(f.path&&f.path===sel.path||!f.path&&(f.file_id||f.fileId)===(sel.file_id||sel.fileId));return f.duration||(matches&&audioDurationRef.current?audioDurationRef.current:0)||(audioBufferRef.current&&matches?audioBufferRef.current.duration:0)||0;};const folderFiles=React.useMemo(()=>{if(folder==='library')return MEDIA_LIBRARY_SAMPLES;if(folder==='computer'){if(computerPath==='my_computer'||computerPath==='favorited'||!computerPath){return favorites||[];}const node=computerTree[computerPath]||{dirs:[]};return[...(node.dirs||[]),...computerFiles];}return userFiles.filter(f=>folder==='uploads'?(f.type||'Upload')==='Upload':(f.type||'Processed')==='Processed');},[folder,userFiles,computerFiles,computerTree,computerPath,favorites]);const visibleFiles=React.useMemo(()=>{const q=filterText.toLowerCase().trim();if(!q)return folderFiles;return folderFiles.filter(f=>(f.name||f.original_name||'').toLowerCase().includes(q));},[folderFiles,filterText]);const loadWaveform=async f=>{const token=selectTokenRef.current;if(f&&(f.handle||f.path)){try{const buf=await readLocalFileBuffer(f);if(selectTokenRef.current!==token)return;if(!buf){setPeaks(null);return;}const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);if(selectTokenRef.current!==token)return;setAudioBuffer(decoded);setAudioDuration(decoded.duration);const data=decoded.getChannelData(0);const count=600;const step=Math.max(1,Math.floor(data.length/count));const pk=[];for(let i=0;i<data.length;i+=step){let m=0;for(let j=i;j<Math.min(i+step,data.length);j++){const v=Math.abs(data[j]);if(v>m)m=v;}pk.push(m);}setPeaks(pk);}catch(e){if(selectTokenRef.current===token)setPeaks(null);}return;}const fid=f.file_id||f.fileId;if(!fid){setPeaks(null);return;}try{const resp=await fetch(`${API_BASE_URL}/api/v1/audio/waveform/${fid}?num_peaks=600`);const data=await resp.json();if(selectTokenRef.current!==token)return;setPeaks(data.peaks||[]);if(data.duration)setAudioDuration(data.duration);}catch(e){setPeaks(null);}};const openFavorited=()=>{setFolder('computer');setComputerPath('favorited');setComputerFiles([]);};const openMyComputer=async()=>{setFolder('computer');const useClientRoot=async rootHandle=>{setComputerTree({});// Dọn sạch cache cây thư mục cũ
|
||
setComputerMode('client');setClientRoot(rootHandle);const rootEntry={name:rootHandle.name,path:'root',is_dir:true,handle:rootHandle};setComputerRoots([rootEntry]);setComputerPath('root');setComputerFiles([]);// Lưu handle vào IndexedDB làm client_root và làm link Favorited
|
||
const favKey='client:'+rootHandle.name;saveClientRootHandle('client_root',rootHandle);saveClientRootHandle(favKey,rootHandle);// Tự động thêm link đến thư mục đó ở Favorited (không ghi đè các mục cũ)
|
||
setFavorites(prev=>{const exists=prev.some(f=>f.path===favKey);if(exists)return prev;const next=[...prev,{path:favKey,name:rootHandle.name,is_dir:true}];try{localStorage.setItem('studio_media_explorer_favorites_v1',JSON.stringify(next));}catch(e2){}return next;});window.showToast&&window.showToast('Đã thêm thư mục client vào Favorited: '+rootHandle.name,'info');await browseComputerDir(rootEntry);return true;};// Luôn bắt buộc hiện Window Picker chọn thư mục của client-side khi click vào My Computer
|
||
if(window.showDirectoryPicker){try{const picked=await window.showDirectoryPicker({mode:'read'});if(picked&&picked.kind==='directory'){return useClientRoot(picked);}}catch(e){// User cancelled or error
|
||
}}// Fallback sang Server-side chỉ khi browser không hỗ trợ
|
||
setComputerMode('server');setComputerRoots(null);try{const resp=await fetch(`${API_BASE_URL}/api/v1/media/computer`);if(!resp.ok)throw new Error('HTTP '+resp.status);const data=await resp.json();const roots=data.roots||[];setComputerRoots(roots.length?roots:[{path:'/',name:'Root (/)',is_dir:true}]);if(!roots.length)window.showToast&&window.showToast('Không tìm thấy ổ đĩa nào','warning');const rootList=roots.length?roots:[{path:'/',name:'Root (/)',is_dir:true}];rootList.slice(0,10).forEach(async root=>{await browseComputerDir(root);});}catch(e){setComputerRoots([{path:'/',name:'Root (/)',is_dir:true}]);window.showToast&&window.showToast('Không thể truy cập My Computer: '+e.message,'error');}};const browseClientDir=async entry=>{const handle=entry&&entry.handle;if(!handle||!handle.entries)return null;const dirs=[];const files=[];const parentPath=entry.path;try{for await(const[name,h]of handle.entries()){if(name.startsWith('.'))continue;if(h.kind==='directory'){dirs.push({name,path:parentPath+'/'+name,is_dir:true,handle:h,parent:handle});}else{const ext=(name.split('.').pop()||'').toLowerCase();const kind=ext==='mid'||ext==='midi'?'midi':['wav','mp3','ogg','flac','aiff','aif','m4a','aac','opus'].includes(ext)?'audio':'other';let size=0;try{const f=await h.getFile();size=f.size;}catch(e2){}files.push({name,path:parentPath+'/'+name,is_dir:false,size_mb:size?+(size/1048576).toFixed(2):0,ext:'.'+ext,kind,handle:h});}}dirs.sort((a,b)=>a.name.localeCompare(b.name));files.sort((a,b)=>a.name.localeCompare(b.name));setComputerPath(parentPath);setComputerFiles(files);setSelected(null);setCurrentTime(0);stopMediaPlayback();setComputerTree(prev=>({...prev,[parentPath]:{handle,parent:entry.parent||null,dirs,expanded:true}}));return{dirs,files};}catch(e){window.showToast&&window.showToast('Không thể đọc thư mục: '+e.message,'error');return null;}};const browseComputerDir=async entry=>{if(!entry)return null;if(computerMode==='client'||entry.handle){if(entry.handle&&entry.handle.entries){const parentInfo=computerTree[entry.path];return await browseClientDir({...entry,parent:parentInfo?parentInfo.parent:entry.parent});}}const path=entry.path||entry;if(!path)return null;try{const resp=await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);if(!resp.ok)throw new Error('HTTP '+resp.status);const data=await resp.json();setComputerPath(data.path);setComputerFiles(data.files||[]);setSelected(null);setCurrentTime(0);stopMediaPlayback();setComputerTree(prev=>({...prev,[path]:{dirs:data.dirs||[],expanded:true}}));return{dirs:data.dirs||[],files:data.files||[]};}catch(e){window.showToast&&window.showToast('Không thể mở thư mục: '+e.message,'error');return null;}};browseComputerDirRef.current=browseComputerDir;const toggleComputerDir=async entry=>{if(!entry)return;const path=entry.path||entry;const node=computerTree[path];if(node&&node.expanded){setComputerTree(prev=>({...prev,[path]:{...prev[path],expanded:false}}));}else{await browseComputerDir(entry);}};// ── Favorites: thư mục yêu thích ──
|
||
const isFavorite=entry=>{if(!entry)return false;return(favorites||[]).some(f=>f.path===(entry.path||entry));};const toggleFavorite=(entry,e)=>{if(e&&e.stopPropagation)e.stopPropagation();if(!entry)return;const path=entry.path||entry;const name=entry.name||path.split('/').pop()||path;setFavorites(prev=>{const exists=prev.some(f=>f.path===path);const next=exists?prev.filter(f=>f.path!==path):[...prev,{path,name,is_dir:true}];try{localStorage.setItem('studio_media_explorer_favorites_v1',JSON.stringify(next));}catch(e2){}return next;});window.showToast&&window.showToast('Đã '+(isFavorite(entry)?'gỡ khỏi':'thêm vào')+' Favorited: '+name,'info');};const openFavorite=fav=>{if(!fav)return;setFolder('computer');const entry={path:fav.path,name:fav.name,is_dir:true};// Cố gắng tìm handle trong cây đã load để mở trực tiếp
|
||
const node=computerTree[fav.path];if(node&&node.handle){setComputerTree({});// Reset cache
|
||
browseComputerDir({...entry,handle:node.handle});}else if(fav.path&&fav.path.startsWith('client:')){(async()=>{let favHandle=null;try{favHandle=await loadClientRootHandle(fav.path);}catch(e){}if(favHandle){// Yêu cầu quyền đọc (permission có thể bị thu hồi)
|
||
let permitted=true;try{if(typeof favHandle.queryPermission==='function'){const st=await favHandle.queryPermission({mode:'read'});if(st!=='granted'&&typeof favHandle.requestPermission==='function'){const r=await favHandle.requestPermission({mode:'read'});permitted=r==='granted';}}}catch(e){permitted=false;}if(permitted){setComputerTree({});// Reset cache
|
||
setClientRoot(favHandle);setComputerMode('client');const rootEntry={name:favHandle.name,path:'root',is_dir:true,handle:favHandle};setComputerRoots([rootEntry]);setComputerPath('root');setComputerFiles([]);await browseComputerDir(rootEntry);}else{window.showToast&&window.showToast('Không có quyền truy cập thư mục này','warning');}}else{window.showToast&&window.showToast('Không thể khôi phục quyền truy cập thư mục này','error');}})();}else if(fav.path==='root'&&clientRoot){browseComputerDir({name:clientRoot.name,path:'root',is_dir:true,handle:clientRoot});}else if(fav.path&&fav.path.startsWith('root/')&&clientRoot){// Duyệt lại từ root handle tới folder favorite
|
||
const segments=fav.path.split('/').slice(1);let curHandle=clientRoot;const walk=async idx=>{if(idx>=segments.length){browseComputerDir({name:segments[idx-1]||clientRoot.name,path:fav.path,is_dir:true,handle:curHandle});return;}try{const child=await curHandle.getDirectoryHandle(segments[idx]);curHandle=child;walk(idx+1);}catch(e){window.showToast&&window.showToast('Không mở được thư mục favorite','error');}};walk(0);}else if(fav.path){browseComputerDir(entry);}};const goComputerParent=()=>{if(!computerPath)return;if(computerMode==='client'||clientRoot){const node=computerTree[computerPath];const parentHandle=node&&node.parent;if(parentHandle){const parentEntry=computerRoots&&computerRoots[0]&&parentHandle===clientRoot?computerRoots[0]:{name:parentHandle.name,path:computerPath.split('/').slice(0,-1).join('/')||'root',is_dir:true,handle:parentHandle};browseComputerDir({...parentEntry,parent:parentHandle===clientRoot?null:computerTree[parentEntry.path]?computerTree[parentEntry.path].parent:null});}return;}const isUnix=computerPath.startsWith('/');const parts=computerPath.split(/[\\/]/).filter(Boolean);parts.pop();if(isUnix){browseComputerDir(parts.length?'/'+parts.join('/'):'/');}else{// Windows: quay về root ổ đĩa nếu đã lên tới đỉnh
|
||
browseComputerDir(parts.length?parts.join('\\'):computerPath.split(/[\\/]/)[0]+'\\');}};const readLocalFileBuffer=async f=>{if(f&&f.handle&&typeof f.handle.getFile==='function'){const file=await f.handle.getFile();return await file.arrayBuffer();}const url=filePreviewUrl(f);if(!url)return null;const resp=await fetch(url);return await resp.arrayBuffer();};const filePreviewUrl=f=>{if(f&&f.path&&!(f.handle&&f.handle.getFile))return`${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(f.path)}`;// Media Library demo MIDI: chỉ có name/kind — bytes ở /static/midi (bản
|
||
// resolveMediaExplorerDropFile cũng dùng cùng endpoint).
|
||
if(f&&f.kind==='midi'&&f.name)return`${API_BASE_URL}/static/midi/${encodeURIComponent(f.name)}`;const fid=f&&(f.file_id||f.fileId);return fid?`${API_BASE_URL}/api/v1/audio/download/${fid}`:null;};const toggleSynthDropdown=()=>{if(synthOpen){setSynthOpen(false);return;}setSynthFilter('');// Clear search filter on open
|
||
setSynthOpen(true);if(synthListRef.current)return;setSynthLoading(true);(window.SonicAPI&&window.SonicAPI.listPlugins?window.SonicAPI.listPlugins():Promise.resolve({soundfonts:[]})).then(async data=>{const sfonts=data&&data.soundfonts||[];if(!sfonts.length){setSynthList([]);setSynthLoading(false);return;}const results=await Promise.all(sfonts.map(sf=>{const baseId=String(sf.id||'').replace('sf_','');return(window.SonicAPI.listSoundfontInstruments?window.SonicAPI.listSoundfontInstruments(baseId):Promise.resolve({presets:[]})).then(r=>({sf,presets:r&&r.presets||[]})).catch(()=>({sf,presets:[]}));}));setSynthList(results);setSynthLoading(false);}).catch(()=>{setSynthList([]);setSynthLoading(false);});};const selectSynthInst=inst=>{setSynthInst(inst);synthInstRef.current=inst;setSynthOpen(false);localStorage.setItem('studio_media_explorer_synth',JSON.stringify(inst));// Realtime: re-schedule current MIDI preview with the newly selected instrument
|
||
const cur=selectedRef.current;if(isPlayingRef.current&&cur&&isMidiFile(cur)&&(cur.handle||cur.path||cur.file_id||cur.fileId||cur.kind==='midi'&&cur.name)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicMidiRouter)window.SonicMidiRouter.panic();else if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}stopAllNativeSfNotes();stopAllNativeVstNotes();playMidiPreview(cur,token);}};const filteredSynthList=React.useMemo(()=>{if(!synthList)return[];if(!synthFilter.trim())return synthList;const query=synthFilter.toLowerCase().trim();return synthList.map(group=>{const presets=(group.presets||[]).filter(p=>(p.name||'').toLowerCase().includes(query)||String(p.program).includes(query));return{...group,presets};}).filter(group=>group.presets.length>0);},[synthList,synthFilter]);// Media Explorer preview qua native bridge (FluidSynth trong daw_vst_bridge):
|
||
// thay soundfontRender backend — backend trả 501 khi pyfluidsynth không có
|
||
// trong bundle (engine.log: "pyfluidsynth không khả dụng") → playMidiPreview
|
||
// return im lặng → autoplay câm. Bridge load SF2 vào channel riêng rồi
|
||
// pushEvent NOTE_ON/OFF theo beat (giống scheduleMidiNoteDispatch cho track).
|
||
const _mePreview={sfId:null,ch:null};const playMediaExplorerBridgePreview=async(notes,o)=>{try{const isPerc=!!(o.bank===128);const ch=window.SonicMidiRouter.allocateChannel('media_explorer',isPerc);const sfName=String(o.sfId||'').replace(/^sf_/,'');if(!sfName)return false;const needLoad=_mePreview.sfId!==sfName||_mePreview.ch!==ch;if(needLoad){const r=await window.SonicAPI.bridgeLoad({name:sfName,instrumentType:'SF2',channel:ch});if(!r||!r.path)return false;const ok=await window.NativeBridgeService.loadInstrument(r.path,'SF2',ch);if(!ok)return false;_mePreview.sfId=sfName;_mePreview.ch=ch;}window.SonicMidiRouter.pushEvent({cmd:'CC',channel:ch,pitch:0,velocity:0,data2:(o.bank||0)>>7&0x7F,percussion:isPerc});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:ch,pitch:32,velocity:0,data2:(o.bank||0)&0x7F,percussion:isPerc});window.SonicMidiRouter.pushEvent({cmd:'PROGRAM',channel:ch,pitch:0,velocity:0,data2:o.prog||0,percussion:isPerc});const ctx=getAudioContext();const beatSec=60.0/(o.bpm||120);// Load SF2 vào bridge bất đồng bộ → note đầu trễ thêm khi load mới.
|
||
const passStart=ctx.currentTime+(needLoad?0.5:0.08);const schedulePassBridge=passStartTime=>{notes.forEach(note=>{const nStart=note.start_beat||0;if(o.hasSelection&&(nStart<o.loopStartBeats||nStart>=o.loopEndBeats))return;const shifted=o.hasSelection?nStart-o.loopStartBeats:nStart;const startSec=shifted*beatSec;const durMs=Math.max(80,(note.duration_beats||1)*beatSec*1000);const at=passStartTime+startSec;const delayMs=Math.max(0,(at-ctx.currentTime)*1000);const pitch=note.pitch||60;const vel=note.velocity!=null?note.velocity:0.8;// NOTE_ON cũng delay theo beat (giống scheduleMidiNoteDispatch) — gửi
|
||
// ngay t=0 khi needLoad=true sẽ mất note vì SF2 chưa load xong trong
|
||
// bridge (uiWorker async bên C++).
|
||
setTimeout(()=>{if(selectTokenRef.current!==(o.token||selectTokenRef.current))return;window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:ch,pitch:pitch,velocity:vel,percussion:isPerc,live:true});},delayMs);setTimeout(()=>{if(selectTokenRef.current!==(o.token||selectTokenRef.current))return;window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:ch,pitch:pitch,velocity:0,percussion:isPerc,live:true});},delayMs+durMs+30);});};schedulePassBridge(passStart);if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}if(o.isLooping){loopTimerRef.current=setInterval(()=>{if(selectTokenRef.current!==(o.token||selectTokenRef.current)){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}if(!isPlayingRef.current||isPausedRef.current)return;if(!isLoopingRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}schedulePassBridge(ctx.currentTime+0.05);},Math.max(200,o.loopDurationSec*1000));}return true;}catch(e){console.warn('[MediaExplorer] bridge preview fail → WASM:',e);return false;}};const playMidiPreview=async(f,token)=>{// Play real MIDI file through selected synth instrument (SonicSF)
|
||
if(!f||!window.SonicSF)return;try{const buf=await readLocalFileBuffer(f);if(!buf)return;if(selectTokenRef.current!==(token||selectTokenRef.current))return;const midiResult=(typeof parseMidiFile==='function'?parseMidiFile:window.parseMidiFile)(buf);if(!midiResult||!midiResult.length)return;// Feature: selecting/playing a MIDI file auto-sets the playback tempo to
|
||
// the file's own BPM (so the preview plays in time).
|
||
const fileBpm=midiResult[0]&&midiResult[0].bpm||120;const newTempo=Math.max(40,Math.min(300,Math.round(fileBpm)));setTempo(newTempo);setTempoText(String(newTempo));tempoRef.current=newTempo;try{localStorage.setItem('studio_media_explorer_tempo',String(newTempo));}catch(e){}const ctx=getAudioContext();// Đồng bộ mastering + routing SF trước khi preview MIDI file (âm qua
|
||
// mastering FX của main out khi chain bật)
|
||
try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e3){}const bpmVal=tempoRef.current||120;const secondsPerBeat=60.0/bpmVal;const startWallTime=ctx.currentTime+0.05;const curInst=synthInstRef.current;const program=curInst?curInst.program:undefined;const sfId=curInst?curInst.sfId:undefined;const bank=curInst?curInst.bank:0;// Preview dùng channel RIÊNG (qua _engineChMap) — KHÔNG đè channel 0 mà
|
||
// track đang dùng → không làm sai instrument của track/soundfont khác
|
||
// (trước đây cứng channel 0: preview MIDI Keyboard có thể chơi sai
|
||
// instrument khi track khác đang dùng chung channel).
|
||
let pvCh=0;if(curInst&&window.SonicSF.applyAITrackInstrument){try{pvCh=window.SonicSF.applyAITrackInstrument(bank,program,{soundfont_id:sfId,soundfont_bank:bank,soundfont_program:program!==undefined?program:0})||0;}catch(e2){pvCh=0;}}else if(curInst&&window.SonicSF.selectInstrument){try{await window.SonicSF.selectInstrument(pvCh,bank,program,sfId);}catch(e2){}}// Re-check token after the async await — stale playMidiPreview (older file)
|
||
// must not schedule notes over the newly selected file.
|
||
if(selectTokenRef.current!==(token||selectTokenRef.current))return;const prog=curInst&&curInst.program!==undefined?curInst.program:undefined;const eng=curInst?{soundfont_id:sfId,soundfont_bank:bank,soundfont_program:prog!==undefined?prog:0}:undefined;const totalSec=midiResult[0].duration||4;const totalBeats=midiResult[0].totalBeats||16;const hasSelection=selStart!==null&&selEnd!==null&&Math.abs(selStart-selEnd)>0.01;const loopStartBeats=hasSelection?Math.min(selStart,selEnd):0;const loopEndBeats=hasSelection?Math.max(selStart,selEnd):totalBeats;const loopDurationBeats=loopEndBeats-loopStartBeats;const loopDurationSec=loopDurationBeats*secondsPerBeat;const loopStartSec=loopStartBeats*secondsPerBeat;const allNotes=[];midiResult.forEach(track=>{(track.notes||[]).forEach(note=>{allNotes.push(Object.assign({},note,{trackOffset:track.startTime||0}));});});// Standalone + instrument soundfont → preview qua native bridge
|
||
// (FluidSynth trong daw_vst_bridge): thay soundfontRender backend (501 khi
|
||
// pyfluidsynth không có trong bundle) → câm. Bridge down → fallback WASM.
|
||
const nativeNotes=allNotes.filter(note=>!(hasSelection&&((note.start_beat||0)<loopStartBeats||(note.start_beat||0)>=loopEndBeats))).map(note=>{const shiftedStartBeat=hasSelection?(note.start_beat||0)-loopStartBeats:note.start_beat||0;return{pitch:note.pitch||60,start_beat:shiftedStartBeat+(note.trackOffset||0)/secondsPerBeat,duration_beats:note.duration_beats||1,velocity:note.velocity!=null?note.velocity:0.8};});let bridgeScheduled=false;if(nativeNotes.length&&sfId&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.NativeBridgeService){bridgeScheduled=await playMediaExplorerBridgePreview(nativeNotes,{sfId:sfId,bank:bank,prog:prog,bpm:bpmVal,hasSelection:hasSelection,loopStartBeats:loopStartBeats,loopEndBeats:loopEndBeats,loopDurationSec:loopDurationSec,token:token,isLooping:!!isLoopingRef.current});}if(bridgeScheduled){const startedAtTime=startWallTime-loopStartSec;playStateRef.current={source:null,ctx,startedAt:startedAtTime,fakeStart:startedAtTime,midiTotal:totalSec};setMidiNotes(allNotes);setMidiTotal(totalSec);setMidiBars(midiResult[0].bars||1);setMidiTotalBeats(midiResult[0].totalBeats||16);setMidiFileBpm(midiResult[0].bpm||120);setIsPlaying(true);setIsPaused(false);startCanvasClock();return;}// bridge down / chưa load → rơi xuống schedulePass WASM bên dưới
|
||
const schedulePass=passStartTime=>{allNotes.forEach(note=>{const noteStartBeat=note.start_beat||0;if(hasSelection){if(noteStartBeat<loopStartBeats||noteStartBeat>=loopEndBeats)return;}const shiftedStartBeat=hasSelection?noteStartBeat-loopStartBeats:noteStartBeat;const startSec=shiftedStartBeat*secondsPerBeat+(note.trackOffset||0);const durMs=Math.max(80,(note.duration_beats||1)*secondsPerBeat*1000);window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,durMs,passStartTime+startSec,prog,null,pvCh,eng);});};schedulePass(startWallTime);// Loop scheduling: keep looping continuously until Stop is pressed.
|
||
if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}if(isLoopingRef.current){loopTimerRef.current=setInterval(()=>{if(selectTokenRef.current!==(token||selectTokenRef.current)){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}if(!isPlayingRef.current||isPausedRef.current)return;if(!isLoopingRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}const passStart=ctx.currentTime+0.05;schedulePass(passStart);playStateRef.current=Object.assign({},playStateRef.current,{startedAt:passStart,fakeStart:passStart});},Math.max(200,loopDurationSec*1000));}// Keep a fake clock so canvas playhead animates; loop uses playStateRef
|
||
const startedAtTime=startWallTime-loopStartSec;playStateRef.current={source:null,ctx,startedAt:startedAtTime,fakeStart:startedAtTime,midiTotal:totalSec};setMidiNotes(allNotes);setMidiTotal(totalSec);setMidiBars(midiResult[0].bars||1);setMidiTotalBeats(midiResult[0].totalBeats||16);setMidiFileBpm(midiResult[0].bpm||120);setIsPlaying(true);setIsPaused(false);startCanvasClock();}catch(e){console.error('MIDI preview failed',e);}};const stopMediaPlayback=React.useCallback(()=>{if(playStateRef.current){try{if(playStateRef.current.source)playStateRef.current.source.stop();}catch(e){}try{if(playStateRef.current.source)playStateRef.current.source.disconnect();}catch(e){}playStateRef.current=null;}if(rafRef.current)cancelAnimationFrame(rafRef.current);rafRef.current=null;if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}stopNativeSfNote('midifile');stopNativeVstNote('midifile');if(window.SonicSF&&typeof window.SonicSF.stopAll==='function'){try{window.SonicSF.stopAll();}catch(e){}}// Dừng note bridge đang ngân từ Media Explorer preview (NOTE_OFF toàn pitch).
|
||
if(_mePreview.ch!==null&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{const ch=_mePreview.ch;for(let p=0;p<128;p++){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:ch,pitch:p,velocity:0,percussion:ch===9,live:true});}}catch(e){}}setIsPlaying(false);setIsPaused(false);},[]);// F6 close / panel bị ẩn (display:none — KHÔNG unmount) → tắt preview âm
|
||
// thanh (trước đây preview tiếp tục phát sau khi đóng Media Explorer).
|
||
React.useEffect(function(){if(active===false)stopMediaPlayback();},[active,stopMediaPlayback]);const playSelected=async(f,token)=>{if(!f)return;stopMediaPlayback();if(isMidiFile(f)){if(f.handle||f.path||f.file_id||f.fileId||f.kind==='midi'&&f.name){// Real MIDI file: play through selected synth instrument
|
||
await playMidiPreview(f,token);return;}playStateRef.current={source:null,ctx:null,fakeStart:performance.now()/1000};setIsPlaying(true);setIsPaused(false);startCanvasClock();return;}const fid=f.file_id||f.fileId;const buf=await readLocalFileBuffer(f);if(selectTokenRef.current!==(token||selectTokenRef.current))return;if(!buf)return;try{const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);if(selectTokenRef.current!==(token||selectTokenRef.current))return;setAudioBuffer(decoded);setAudioDuration(decoded.duration);const src=ctx.createBufferSource();src.buffer=decoded;const sStart=selStartRef.current;const sEnd=selEndRef.current;const hasSelection=sStart!==null&&sEnd!==null&&Math.abs(sStart-sEnd)>0.01;let startOffset=0;if(hasSelection){startOffset=Math.min(sStart,sEnd);}src.loop=isLoopingRef.current;if(isLoopingRef.current){if(hasSelection){src.loopStart=Math.min(sStart,sEnd);src.loopEnd=Math.max(sStart,sEnd);}else{src.loopStart=0;src.loopEnd=decoded.duration;}}src.playbackRate.value=rate;const gain=ctx.createGain();const linear=volumeDb<=-50?0:Math.pow(10,volumeDb/20);gain.gain.value=linear;src.connect(gain);gain.connect(ctx.destination);src.start(0,startOffset);const startedAt=ctx.currentTime-startOffset;playStateRef.current={source:src,ctx,startedAt};setIsPlaying(true);setIsPaused(false);startCanvasClock();}catch(e){console.error('Preview failed',e);}};const togglePause=()=>{const st=playStateRef.current;if(!st)return;if(isPaused){if(st.ctx)st.ctx.resume();setIsPaused(false);}else{if(st.ctx)st.ctx.suspend();setIsPaused(true);}};const startCanvasClock=()=>{if(rafRef.current)cancelAnimationFrame(rafRef.current);const tick=()=>{rafRef.current=requestAnimationFrame(tick);let t=currentTimeRef.current;const st=playStateRef.current;if(st&&!isPausedRef.current){const rawElapsed=st.ctx?st.ctx.currentTime-st.startedAt:performance.now()/1000-st.fakeStart;const f=selectedRef.current;const dur=fileDuration(f);const isMidi=isMidiFile(f);const bpmVal=tempoRef.current||120;const secondsPerBeat=60.0/bpmVal;const sStart=selStartRef.current;const sEnd=selEndRef.current;const hasSelection=sStart!==null&&sEnd!==null&&Math.abs(sStart-sEnd)>0.01;// Loop plays continuously (forever) until Stop is pressed.
|
||
if(isLoopingRef.current){if(isMidi){const totalBeats=midiTotalBeatsRef.current||16;const loopStartBeats=hasSelection?Math.min(sStart,sEnd):0;const loopEndBeats=hasSelection?Math.max(sStart,sEnd):totalBeats;const loopDurationBeats=loopEndBeats-loopStartBeats;const loopDurationSec=loopDurationBeats*secondsPerBeat;const loopStartSec=loopStartBeats*secondsPerBeat;t=loopStartSec+Math.max(0,(rawElapsed-loopStartSec)%Math.max(0.1,loopDurationSec));}else{const loopStartSec=hasSelection?Math.min(sStart,sEnd):0;const loopEndSec=hasSelection?Math.max(sStart,sEnd):dur;const loopDurationSec=loopEndSec-loopStartSec;t=loopStartSec+Math.max(0,(rawElapsed-loopStartSec)%Math.max(0.1,loopDurationSec));}}else{t=rawElapsed;const endLimit=hasSelection?isMidi?Math.max(sStart,sEnd)*secondsPerBeat:Math.max(sStart,sEnd):dur;if(t>=endLimit){t=endLimit;stopMediaPlayback();}}}setCurrentTime(t);currentTimeRef.current=t;drawCanvas(t);};tick();};const drawCanvas=t=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const w=canvas.clientWidth,h=canvas.clientHeight;if(!w||!h)return;canvas.width=w*2;canvas.height=h*2;ctx.scale(2,2);ctx.clearRect(0,0,w,h);ctx.fillStyle='#181818';ctx.fillRect(0,0,w,h);const f=selectedRef.current;const curPeaks=peaksRef.current;const curAudioBuffer=audioBufferRef.current;const curMidiNotes=midiNotesRef.current;const curMidiTotal=midiTotalRef.current;const curMidiBars=midiBarsRef.current;const curMidiTotalBeats=midiTotalBeatsRef.current;const curTempo=tempoRef.current;const playing=isPlayingRef.current;if(!f){ctx.fillStyle='#555';ctx.font='12px JetBrains Mono, monospace';ctx.fillText('No file selected',10,h/2);return;}const dur=fileDuration(f);// Read selection from refs so rAF-driven redraws always show current selection
|
||
const drawSelStart=selStartRef.current;const drawSelEnd=selEndRef.current;const drawHasSel=drawSelStart!==null&&drawSelEnd!==null&&Math.abs(drawSelStart-drawSelEnd)>0.01;// Scale layout parameters using zoom
|
||
const pxPerBeat=42*zoom;const pxPerSec=pxPerBeat*(curTempo||120)/60;if(isMidiFile(f)){ctx.strokeStyle='#333';for(let y=0;y<h-14;y+=10){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();}ctx.strokeStyle='#444';const isRealMidi=curMidiNotes&&curMidiNotes.length&&curMidiTotalBeats>0;const totalBeats=isRealMidi?curMidiTotalBeats:Math.max(curMidiTotal*(curTempo||120)/60,4);const beats=Math.max(totalBeats,4);const contentW=Math.max(w,beats*pxPerBeat);const offsetVal=scrollOffsetRef.current;let offset=offsetVal;if(playing&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,t*pxPerSec-w/2));}else{offset=Math.max(0,Math.min(contentW-w,offsetVal));}// bar lines (every 4 beats)
|
||
for(let b=0;b<=beats;b+=4){const x=b*pxPerBeat-offset;if(x<-10||x>w+10)continue;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h-14);ctx.stroke();}ctx.fillStyle='#9ca3af';if(curMidiNotes&&curMidiNotes.length){const pitchMin=48,pitchMax=84;const pitchRange=Math.max(1,pitchMax-pitchMin);curMidiNotes.forEach(n=>{const x=n.start_beat*pxPerBeat-offset;if(x<-30||x>w+30)return;const nw=Math.max(3,(n.duration_beats||1)*pxPerBeat);const y=h-14-8-(Math.min(pitchMax,Math.max(pitchMin,n.pitch))-pitchMin)/pitchRange*(h-30);ctx.fillRect(x,y,nw,5);});}else{const events=f.events||76;for(let i=0;i<events;i++){const nx=(i*17+76)%95/100*contentW-offset;const ny=(i*13+76)%(h-30)+4;ctx.fillRect(nx,ny,Math.max(8,(i%5+1)*10),3);}}// Draw selection overlay
|
||
if(drawHasSel){const startVal=Math.min(drawSelStart,drawSelEnd);const endVal=Math.max(drawSelStart,drawSelEnd);const xStart=startVal*pxPerBeat-offset;const xEnd=endVal*pxPerBeat-offset;ctx.fillStyle='rgba(59, 130, 246, 0.25)';ctx.fillRect(xStart,0,xEnd-xStart,h-14);ctx.strokeStyle='#3b82f6';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(xStart,0);ctx.lineTo(xStart,h-14);ctx.moveTo(xEnd,0);ctx.lineTo(xEnd,h-14);ctx.stroke();}const midiPlayheadX=contentW>w?Math.max(0,Math.min(w,t*pxPerSec-offset)):Math.min(w,t*pxPerSec);if(playing&&dur>0){ctx.fillStyle='#ef4444';ctx.fillRect(midiPlayheadX-1,0,2,h-14);}}else{const pk=curPeaks&&curPeaks.length>0?curPeaks:null;if(pk){const contentW=Math.max(1,dur*pxPerSec);const offsetVal=scrollOffsetRef.current;let offset=offsetVal;if(playing&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,t*pxPerSec-w/2));}else{offset=Math.max(0,Math.min(contentW-w,offsetVal));}const mid=h/2;ctx.fillStyle='#22c55e';const barW=Math.max(1,contentW/pk.length);for(let i=0;i<pk.length;i++){const x=i/pk.length*contentW-offset;if(x<-3||x>w+3)continue;const ph=Math.max(2,pk[i]*(h/2-4));ctx.fillRect(x,mid-ph,barW,ph*2);}// Draw selection overlay
|
||
if(drawHasSel){const startVal=Math.min(drawSelStart,drawSelEnd);const endVal=Math.max(drawSelStart,drawSelEnd);const xStart=startVal*pxPerSec-offset;const xEnd=endVal*pxPerSec-offset;ctx.fillStyle='rgba(59, 130, 246, 0.25)';ctx.fillRect(xStart,0,xEnd-xStart,h-14);ctx.strokeStyle='#3b82f6';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(xStart,0);ctx.lineTo(xStart,h-14);ctx.moveTo(xEnd,0);ctx.lineTo(xEnd,h-14);ctx.stroke();}const audioPlayheadX=contentW>w?Math.max(0,Math.min(w,t*pxPerSec-offset)):t*pxPerSec;if(playing&&dur>0){ctx.fillStyle='#ef4444';ctx.fillRect(audioPlayheadX-1,0,2,h-14);}}else{ctx.fillStyle='#666';ctx.font='11px monospace';ctx.fillText('Waveform unavailable',10,h/2);}}// ruler
|
||
ctx.fillStyle='#111';ctx.fillRect(0,h-14,w,14);ctx.fillStyle='#888';ctx.font='9px JetBrains Mono, monospace';const isRealMidi=isMidiFile(f)&&curMidiNotes&&curMidiNotes.length&&curMidiTotalBeats>0;const rulerTotalBeats=isRealMidi?Math.max(curMidiTotalBeats,4):Math.max(4,Math.ceil(dur*(curTempo||120)/60)||16);const rulerContentW=Math.max(1,rulerTotalBeats*pxPerBeat);const rulerOffset=playing&&rulerContentW>w&&dur>0?Math.max(0,Math.min(rulerContentW-w,t*pxPerSec-w/2)):0;for(let b=0;b<=rulerTotalBeats;b+=4){const x=b*pxPerBeat-rulerOffset;if(x<-20||x>w+20)continue;ctx.fillText(String(Math.floor(b/4)),x+2,h-3);}};React.useEffect(()=>{drawCanvas(currentTime);},[peaks,audioBuffer,audioDuration,midiNotes,selected,folder,isPlaying,zoom,selStart,selEnd,scrollOffset]);React.useEffect(()=>()=>{if(rafRef.current)cancelAnimationFrame(rafRef.current);},[]);const commitTempo=v=>{const clamped=Math.max(40,Math.min(300,Math.round(v)||120));setTempo(clamped);setTempoText(String(clamped));tempoRef.current=clamped;try{localStorage.setItem('studio_media_explorer_tempo',String(clamped));}catch(e){}// Re-schedule a currently playing MIDI preview at the new tempo.
|
||
const cur=selectedRef.current;if(isPlayingRef.current&&cur&&isMidiFile(cur)&&(cur.handle||cur.path||cur.file_id||cur.fileId||cur.kind==='midi'&&cur.name)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicMidiRouter)window.SonicMidiRouter.panic();else if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}stopAllNativeSfNotes();stopAllNativeVstNotes();playMidiPreview(cur,token);}};// Center a folder node vertically in the tree pane (feature: file click focuses
|
||
// its parent folder in the middle of the tree).
|
||
const centerTreeNodeInPane=path=>{setTimeout(()=>{const pane=treePaneRef.current;if(!pane)return;const el=document.querySelector(`[data-tree-path="${path}"]`);if(!el)return;const paneRect=pane.getBoundingClientRect();const elRect=el.getBoundingClientRect();const target=pane.scrollTop+(elRect.top-paneRect.top)-paneRect.height/2+elRect.height/2;pane.scrollTo({top:Math.max(0,target),behavior:'smooth'});},80);};// Flat list of currently visible (expanded) tree nodes, in render order.
|
||
const getVisibleTreePaths=()=>{const out=[];const walk=(entry,depth)=>{if(!entry||!entry.path)return;out.push({path:entry.path,entry,depth});const node=computerTreeRef.current[entry.path];if(node&&node.expanded&&node.dirs){(node.dirs||[]).forEach(d=>walk(d,depth+1));}};(computerRootsRef.current||[]).forEach(r=>walk(r,0));return out;};const navigateTreeTo=path=>{const paths=getVisibleTreePaths();const target=paths.find(p=>p.path===path);if(!target)return;// Expand all ancestor nodes so the target is visible in the tree.
|
||
setComputerTree(prev=>{const next={...prev};let current=path;while(current){const n=next[current];if(n)next[current]={...n,expanded:true};const idx=current.lastIndexOf('/');if(idx<=0)break;current=current.substring(0,idx);}return next;});setComputerPath(path);if(browseComputerDirRef.current)browseComputerDirRef.current(target.entry);centerTreeNodeInPane(path);};const handleSelect=f=>{if(!f||f.is_dir)return;// When a MIDI file is clicked, automatically set the play tempo to the
|
||
// MIDI file's tempo (BPM from metadata, or after parsing in playMidiPreview).
|
||
if(isMidiFile(f)&&f.bpm){const v=Math.max(40,Math.min(300,Math.round(parseFloat(f.bpm)||120)));setTempo(v);setTempoText(String(v));tempoRef.current=v;try{localStorage.setItem('studio_media_explorer_tempo',String(v));}catch(e){}}// Find parent path of selected file and scroll it into view in Tree pane
|
||
if(f.path){const lastSlash=f.path.lastIndexOf('/');if(lastSlash>0){const parentPath=f.path.substring(0,lastSlash);setComputerPath(parentPath);// Expand all parent nodes in computerTree
|
||
setComputerTree(prev=>{const next={...prev};let current=parentPath;while(current){if(!next[current]){next[current]={dirs:[],expanded:true};}else{next[current]={...next[current],expanded:true};}const idx=current.lastIndexOf('/');if(idx<=0)break;current=current.substring(0,idx);}return next;});// Center the parent folder node in the middle of the tree pane
|
||
centerTreeNodeInPane(parentPath);}}selectTokenRef.current++;const token=selectTokenRef.current;setSelected(f);setCurrentTime(0);setPeaks(null);setAudioBuffer(null);setAudioDuration(0);setMidiNotes(null);setSelStart(null);setSelEnd(null);setPreviewCtxMenu(null);stopMediaPlayback();if(f.kind==='other')return;if(autoPlay){playSelected(f,token);}if(!isMidiFile(f)&&(f.path||f.file_id||f.fileId))loadWaveform(f);};const renderComputerNode=(entry,depth,isRoot)=>{const nodePath=entry.path;const node=computerTree[nodePath];const expanded=node&&node.expanded;const dirs=node?node.dirs:[];const pad=12+depth*12;const fav=isFavorite(entry);return/*#__PURE__*/React.createElement(React.Fragment,{key:nodePath},/*#__PURE__*/React.createElement("div",{"data-tree-path":nodePath,className:`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${computerPath===nodePath?'bg-slate-300 text-slate-900':'hover:bg-slate-200 text-slate-800'}`,style:{paddingLeft:pad},onClick:()=>browseComputerDir(entry),onDoubleClick:e=>{e.stopPropagation();toggleComputerDir(entry);},onContextMenu:e=>{e.preventDefault();e.stopPropagation();setFavContext(Object.assign({},entry,{x:e.clientX,y:e.clientY}));}},/*#__PURE__*/React.createElement("i",{className:`fa-solid ${expanded?'fa-minus':'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`,onClick:e=>{e.stopPropagation();toggleComputerDir(entry);}}),/*#__PURE__*/React.createElement("i",{className:`fa-solid ${isRoot?'fa-hard-drive text-[#6ea8dc]':'fa-folder text-[#d9a752]'} shrink-0`}),/*#__PURE__*/React.createElement("span",{className:"truncate flex-1"},entry.name),fav&&/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-star text-amber-500 text-[9px] shrink-0"})),expanded&&dirs.map(d=>renderComputerNode(d,depth+1,false)));};const toggleLoop=()=>{setIsLooping(prev=>{const next=!prev;// Sync ref immediately so playMidiPreview (called below) sees the new value
|
||
isLoopingRef.current=next;const cur=selectedRef.current;const st=playStateRef.current;const sStart=selStartRef.current;const sEnd=selEndRef.current;const hasSelection=sStart!==null&&sEnd!==null&&Math.abs(sStart-sEnd)>0.01;if(st&&st.source){st.source.loop=next;// When enabling loop for a currently playing audio buffer, also update
|
||
// the loop points to the current selection so it loops continuously
|
||
// over the selected region until Stop is pressed.
|
||
if(next&&st.source.buffer){if(hasSelection){st.source.loopStart=Math.min(sStart,sEnd);st.source.loopEnd=Math.max(sStart,sEnd);}else{st.source.loopStart=0;st.source.loopEnd=st.source.buffer.duration;}}}if(next&&isMidiFile(cur)){// Re-schedule loop for the currently previewing MIDI file
|
||
if(cur&&(cur.handle||cur.path||cur.file_id||cur.fileId||cur.kind==='midi'&&cur.name)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicMidiRouter)window.SonicMidiRouter.panic();else if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}stopAllNativeSfNotes();stopAllNativeVstNotes();playMidiPreview(cur,token);}}return next;});};const toggleRate=dir=>setRate(prev=>Math.max(0.25,Math.min(4.0,Math.round((prev+dir*0.1)*100)/100)));const togglePitch=dir=>setPitch(prev=>Math.max(-24,Math.min(24,prev+dir*0.5)));const selIsMidi=isMidiFile(selected);const selDur=fileDuration(selected);const selBpm=selected&&selected.bpm||tempo;return/*#__PURE__*/React.createElement("div",{ref:containerRef,className:"flex flex-col w-full h-full text-slate-900 overflow-hidden select-none",style:{fontFamily:"'Inter', sans-serif"}},/*#__PURE__*/React.createElement("div",{className:"h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 flex-1 mr-2"},/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Back",onClick:()=>folder==='computer'?goComputerParent():setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-left"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Forward",onClick:()=>setFolder('uploads')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-right"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Up Directory",onClick:()=>folder==='computer'?goComputerParent():setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-up"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Refresh",onClick:()=>{if(folder==='computer'){const node=computerTree[computerPath];if(node&&node.handle)browseComputerDir({name:computerPath.split('/').pop()||computerPath,path:computerPath,is_dir:true,handle:node.handle});else if(computerPath)browseComputerDir({name:computerPath.split(/[\\/]/).pop()||computerPath,path:computerPath,is_dir:true});}else if(window.SonicAPI&&window.SonicAPI.listMyFiles)window.SonicAPI.listMyFiles([]).then(d=>setUserFiles(d||[]));}},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-rotate-right"})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex items-center bg-white border border-[#808080] h-5 px-1"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"}),/*#__PURE__*/React.createElement("span",{className:"flex-1 text-xs text-slate-800 truncate"},folder==='computer'?computerPath||'My Computer':folder==='library'?'Media Library':folder==='uploads'?'Uploads':'Processed'),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center bg-white border border-[#808080] h-5 px-1 w-40"},/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Filter/Search...",value:filterText,onChange:e=>setFilterText(e.target.value),className:"w-full text-xs outline-none bg-transparent font-sans text-slate-800"}),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"})),/*#__PURE__*/React.createElement("button",{className:"px-2 py-0.5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[11px] flex items-center gap-1 font-semibold",onClick:()=>setViewMode(viewMode==='details'?'list':'details')},/*#__PURE__*/React.createElement("span",null,viewMode==='details'?'Details':'List')," ",/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-[9px]"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden min-h-0 relative"},/*#__PURE__*/React.createElement("div",{className:"flex bg-white border border-[#808080] m-1 mr-0 text-xs select-none shrink-0 overflow-hidden"},/*#__PURE__*/React.createElement("div",{ref:treePaneRef,className:"overflow-y-auto p-1",style:{width:treeWidth+'px',minWidth:treeWidth+'px',maxWidth:treeWidth+'px'}},/*#__PURE__*/React.createElement("div",{className:"space-y-0.5 font-sans"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 px-1 py-0.5 text-slate-700"},/*#__PURE__*/React.createElement("span",{className:"w-3"})," <Track Templates>"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 px-1 py-0.5 text-slate-700"},/*#__PURE__*/React.createElement("span",{className:"w-3"})," <Project Directory>"),/*#__PURE__*/React.createElement("div",{className:`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm font-bold ${folder==='computer'&&computerPath==='favorited'?'bg-slate-300 text-slate-900':'hover:bg-slate-200 text-slate-800'}`,onClick:()=>{openFavorited();setFavoritedExpanded(!favoritedExpanded);}},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-star text-amber-500 text-[11px] font-bold"})," Favorited"),favoritedExpanded&&/*#__PURE__*/React.createElement("div",{className:"pl-3 space-y-0.5"},favorites.map((fav,fi)=>/*#__PURE__*/React.createElement("div",{key:fav.path+fi,"data-tree-path":fav.path,className:"flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm hover:bg-amber-100 text-slate-800",style:{paddingLeft:20},onClick:()=>openFavorite(fav),onContextMenu:e=>{e.preventDefault();e.stopPropagation();setFavContext(Object.assign({},fav,{x:e.clientX,y:e.clientY}));}},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder text-[#d9a752] shrink-0"}),/*#__PURE__*/React.createElement("span",{className:"truncate flex-1"},fav.name),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-star text-amber-500 text-[9px] shrink-0"}))),favorites.length===0&&/*#__PURE__*/React.createElement("div",{className:"pl-4 py-0.5 text-slate-400 italic text-[11px]"},"No favorites")),/*#__PURE__*/React.createElement("div",{className:`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${folder==='computer'&&computerPath!=='favorited'?'bg-slate-300 text-slate-900 font-semibold':'hover:bg-slate-200 text-slate-800'}`,onClick:openMyComputer},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-computer text-[11px] text-slate-600"})," My Computer"),folder==='computer'&&computerPath!=='favorited'&&computerRoots&&/*#__PURE__*/React.createElement("div",{className:"pl-3 space-y-0.5"},computerRoots.map(root=>renderComputerNode(root,0,true))),/*#__PURE__*/React.createElement("div",{className:`flex items-center gap-1 px-1 py-0.5 cursor-pointer font-semibold rounded-sm ${folder==='library'?'bg-slate-300 text-slate-900':'hover:bg-slate-200 text-slate-800'}`,onClick:()=>setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder-open text-[#d9a752]"})," Media Library"),/*#__PURE__*/React.createElement("div",{className:"pl-3 space-y-0.5"},/*#__PURE__*/React.createElement("div",{className:`flex items-center gap-1 px-1 py-0.5 cursor-pointer pl-4 rounded-sm ${folder==='uploads'?'bg-slate-300 text-slate-900':'hover:bg-slate-200 text-slate-800'}`,onClick:()=>setFolder('uploads')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder text-[#d9a752]"})," Uploads"),/*#__PURE__*/React.createElement("div",{className:`flex items-center gap-1 px-1 py-0.5 cursor-pointer pl-4 rounded-sm ${folder==='processed'?'bg-slate-300 text-slate-900':'hover:bg-slate-200 text-slate-800'}`,onClick:()=>setFolder('processed')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder text-[#d9a752]"})," Processed")))),/*#__PURE__*/React.createElement("div",{className:"w-1.5 cursor-ew-resize hover:bg-blue-500/40 active:bg-blue-500/60 transition-colors shrink-0",onMouseDown:startTreeResize,title:"K\xE9o \u0111\u1EC3 thay \u0111\u1ED5i chi\u1EC1u r\u1ED9ng"})),favContext&&/*#__PURE__*/React.createElement("div",{className:"fixed z-[100] bg-white border border-[#808080] shadow-lg rounded-sm text-xs font-sans text-slate-800 min-w-[180px]",style:{left:favContext.x,top:favContext.y},onMouseLeave:()=>setFavContext(null)},/*#__PURE__*/React.createElement("div",{className:`px-2 py-1 cursor-pointer hover:bg-blue-100 flex items-center gap-1.5 ${isFavorite(favContext)?'text-amber-700':''}`,onClick:()=>{toggleFavorite(favContext);setFavContext(null);}},/*#__PURE__*/React.createElement("i",{className:`fa-solid ${isFavorite(favContext)?'fa-star text-amber-500':'fa-star text-slate-400'} text-[11px]`}),isFavorite(favContext)?'Gỡ khỏi Favorited':'Thêm vào Favorited'),/*#__PURE__*/React.createElement("div",{className:"px-2 py-1 cursor-pointer hover:bg-blue-100 flex items-center gap-1.5",onClick:()=>{if(favContext)browseComputerDir(favContext);setFavContext(null);}},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder-open text-[#d9a752] text-[11px]"})," M\u1EDF th\u01B0 m\u1EE5c")),/*#__PURE__*/React.createElement("div",{className:"flex-1 bg-white border border-[#808080] m-1 overflow-y-auto relative"},/*#__PURE__*/React.createElement("table",{className:"w-full text-xs text-left border-collapse",style:{tableLayout:'fixed'}},/*#__PURE__*/React.createElement("thead",{className:"sticky top-0 bg-[#e0e0e0] border-b border-[#a0a0a0] text-slate-800 font-semibold select-none shadow-sm z-10"},/*#__PURE__*/React.createElement("tr",null,/*#__PURE__*/React.createElement("th",{className:"py-1 px-2 border-r border-[#b0b0b0] relative",style:viewMode==='details'?{width:colWidths.file}:undefined},/*#__PURE__*/React.createElement("div",{className:"truncate pr-2"},"File"),/*#__PURE__*/React.createElement("div",{className:"absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-slate-400",onMouseDown:e=>startColResize('file',e)})),viewMode==='details'&&/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("th",{className:"py-1 px-2 border-r border-[#b0b0b0] relative",style:{width:colWidths.size}},/*#__PURE__*/React.createElement("div",{className:"truncate pr-2"},"Size"),/*#__PURE__*/React.createElement("div",{className:"absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-slate-400",onMouseDown:e=>startColResize('size',e)})),/*#__PURE__*/React.createElement("th",{className:"py-1 px-2 border-r border-[#b0b0b0] relative",style:{width:colWidths.type}},/*#__PURE__*/React.createElement("div",{className:"truncate pr-2"},"Type"),/*#__PURE__*/React.createElement("div",{className:"absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-slate-400",onMouseDown:e=>startColResize('type',e)}))))),/*#__PURE__*/React.createElement("tbody",{className:"font-sans text-slate-800"},visibleFiles.map((f,i)=>{const isSel=selected&&(selected.name||selected.file_id)===(f.name||f.file_id);const isMidi=isMidiFile(f);const icon=f.is_dir?'fa-folder text-[#d9a752]':isMidi?'fa-music text-purple-600':f.kind==='audio'?'fa-file-audio text-emerald-600':'fa-file text-zinc-500';return/*#__PURE__*/React.createElement("tr",{key:(f.path||f.file_id||f.name)+i,draggable:!f.is_dir,onDragStart:e=>{if(f.is_dir){e.preventDefault();return;}e.dataTransfer.setData('text/plain',f.name||f.original_name||'');e.dataTransfer.effectAllowed='copy';window.__mediaExplorerDragFile=f;},onDragEnd:()=>{window.__mediaExplorerDragFile=null;},className:`cursor-pointer hover:bg-blue-100 ${isSel?'file-row-selected':''}`,onClick:()=>f.is_dir?browseComputerDir(f):handleSelect(f),onDoubleClick:()=>f.is_dir&&browseComputerDir(f),onContextMenu:e=>{if(f.is_dir){e.preventDefault();e.stopPropagation();setFavContext(Object.assign({},f,{x:e.clientX,y:e.clientY}));}}},/*#__PURE__*/React.createElement("td",{className:"py-1 px-2 truncate"},/*#__PURE__*/React.createElement("i",{className:`fa-solid ${icon} mr-2`}),f.name||f.original_name),viewMode==='details'&&/*#__PURE__*/React.createElement("td",{className:"py-1 px-2 truncate"},f.size_mb!=null?f.size_mb.toFixed(2)+' MB':isMidi?(f.tpqn||'MIDI')+' TPQN':'-'),viewMode==='details'&&/*#__PURE__*/React.createElement("td",{className:"py-1 px-2 truncate"},f.is_dir?'Folder':isMidi?'MIDI':f.kind==='audio'?'Audio':'File'));}),visibleFiles.length===0&&/*#__PURE__*/React.createElement("tr",null,/*#__PURE__*/React.createElement("td",{className:"py-3 px-2 text-slate-400 italic",colSpan:viewMode==='details'?3:1},"No files")))))),/*#__PURE__*/React.createElement("div",{className:"h-[38%] min-h-[110px] bg-[#d4d0c8] border-t border-[#808080] p-1.5 flex flex-col justify-between text-xs shrink-0 select-none"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{id:"btnStop",className:"w-6 h-6 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-slate-800",title:"Stop",onClick:stopMediaPlayback},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-square text-[10px]"})),/*#__PURE__*/React.createElement("button",{id:"btnPlay",className:"w-6 h-6 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-emerald-700 font-bold",title:"Play",onClick:()=>isPlaying?togglePause():playSelected(selected)},/*#__PURE__*/React.createElement("i",{className:`fa-solid ${isPlaying&&!isPaused?'fa-play':'fa-play'} text-xs`})),/*#__PURE__*/React.createElement("button",{id:"btnPause",className:"w-6 h-6 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-amber-700",title:"Pause",onClick:togglePause},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-pause text-xs"})),/*#__PURE__*/React.createElement("button",{id:"btnLoop",className:`w-6 h-6 border rounded-sm flex items-center justify-center text-xs ${isLooping?'bg-cyan-600 text-white border-cyan-700':'bg-[#e0e0e0] hover:bg-white text-slate-700 border-[#707070]'}`,title:"Loop / Repeat",onClick:toggleLoop},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-rotate-right"})),/*#__PURE__*/React.createElement("button",{id:"btnAutoPlay",className:`h-6 px-2 border rounded-sm font-bold text-[10px] flex items-center gap-1 ${autoPlay?'bg-gradient-to-r from-cyan-600 to-emerald-600 text-white border-slate-700':'bg-[#e0e0e0] text-slate-600 border-[#707070]'}`,onClick:()=>setAutoPlay(p=>!p)},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-bolt text-[9px]"})," ",/*#__PURE__*/React.createElement("span",null,"Auto-Play")),/*#__PURE__*/React.createElement("div",{className:"relative"},/*#__PURE__*/React.createElement("button",{id:"btnSynth",className:`h-6 px-2 border rounded-sm font-bold text-[10px] flex items-center gap-1 ${synthInst?'bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white border-slate-700':'bg-[#e0e0e0] text-slate-600 border-[#707070]'}`,ref:btnSynthRef,title:"Ch\u1ECDn instrument \u0111\u1EC3 preview MIDI",onClick:toggleSynthDropdown},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-music text-[9px]"})," ",/*#__PURE__*/React.createElement("span",null,"Synth",synthInst?': '+(synthInst.name||'?'):''),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-[8px]"})),synthOpen&&(()=>{// Portal ra document.body: dropdown absolute bottom-8 bị clip bởi
|
||
// overflow-hidden của root Media Explorer khi list dài (max-h-72
|
||
// trồi lên trên) → header + ô tìm kiếm mất khi mở lần 2+.
|
||
const btnRect=btnSynthRef.current?btnSynthRef.current.getBoundingClientRect():null;return ReactDOM.createPortal(/*#__PURE__*/React.createElement("div",{className:"fixed z-40 w-64 bg-white border border-[#808080] shadow-xl rounded-sm text-xs text-slate-800 font-sans max-h-72 overflow-y-auto",style:{left:btnRect?btnRect.left:0,bottom:btnRect?window.innerHeight-btnRect.top+8:0}},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 bg-[#e0e0e0] px-2 py-1 font-bold border-b border-[#a0a0a0] flex items-center justify-between z-20"},/*#__PURE__*/React.createElement("span",null,"Select Instrument"),/*#__PURE__*/React.createElement("button",{onClick:()=>setSynthOpen(false),className:"text-slate-500 hover:text-slate-900"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-xmark"}))),/*#__PURE__*/React.createElement("div",{className:"sticky top-[23px] bg-white p-1 border-b border-[#c0c0c0] z-20 flex items-center gap-1"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-magnifying-glass text-slate-400 pl-1 text-[10px]"}),/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"T\xECm nh\u1EA1c c\u1EE5...",value:synthFilter,onChange:e=>setSynthFilter(e.target.value),onClick:e=>e.stopPropagation(),className:"w-full px-1 py-0.5 border border-[#c0c0c0] rounded-sm text-xs font-sans focus:outline-none focus:border-blue-500"}),synthFilter&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();setSynthFilter('');},className:"text-slate-400 hover:text-slate-700 px-1"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-xmark text-[10px]"}))),synthLoading&&/*#__PURE__*/React.createElement("div",{className:"px-2 py-2 text-slate-400 italic"},"Loading..."),!synthLoading&&(!synthList||synthList.length===0)&&/*#__PURE__*/React.createElement("div",{className:"px-2 py-2 text-slate-400 italic"},"Kh\xF4ng c\xF3 SoundFont n\xE0o"),/*#__PURE__*/React.createElement("div",{className:`flex items-center gap-1 px-2 py-1 cursor-pointer hover:bg-blue-100 ${!synthInst?'bg-slate-200':''}`,onClick:()=>selectSynthInst(null)},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-ban text-slate-400"})," None (m\u1EB7c \u0111\u1ECBnh)"),!synthLoading&&filteredSynthList&&filteredSynthList.map(group=>/*#__PURE__*/React.createElement("div",{key:group.sf.id||group.sf.name},/*#__PURE__*/React.createElement("div",{className:"px-2 py-1 bg-[#f0f0f0] font-semibold text-slate-600 border-t border-[#e0e0e0] truncate"},group.sf.display||group.sf.name||group.sf.id),(group.presets||[]).slice(0,200).map((p,pi)=>{const progId=p.id||p.name||'preset_'+pi;return/*#__PURE__*/React.createElement("div",{key:progId,className:`flex items-center gap-1 px-2 pl-4 py-0.5 cursor-pointer hover:bg-blue-100 truncate ${synthInst&&synthInst.program===p.program&&synthInst.sfId===(group.sf.id||group.sf.name)?'bg-slate-200':''}`,onClick:()=>selectSynthInst({sfId:group.sf.id,sfName:group.sf.display||group.sf.name||group.sf.id,bank:p.bank||0,program:p.program,name:p.name||'Program '+p.program})},p.bank===128?/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-drum text-slate-400"}):/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-music text-slate-400"})," ",p.name||'Program '+p.program);}))),!synthLoading&&filteredSynthList&&filteredSynthList.length===0&&synthFilter&&/*#__PURE__*/React.createElement("div",{className:"px-2 py-2 text-slate-400 italic"},"Kh\xF4ng t\xECm th\u1EA5y nh\u1EA1c c\u1EE5 tr\xF9ng kh\u1EDBp")),document.body);})()),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1",title:"Tempo preview MIDI"},/*#__PURE__*/React.createElement("span",{className:"font-mono text-[10px] text-slate-700"},"Tempo:"),/*#__PURE__*/React.createElement("button",{className:"px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]",onClick:()=>commitTempo((tempo||120)-1)},"-"),/*#__PURE__*/React.createElement("div",{className:"bg-white border border-[#808080] px-1 h-5 flex items-center w-14"},/*#__PURE__*/React.createElement("input",{type:"number",min:"40",max:"300",value:tempoText,onChange:e=>{const raw=e.target.value;setTempoText(raw);const n=parseInt(raw);if(n>=40&&n<=300)commitTempo(n);},onBlur:()=>{const n=parseInt(tempoText);commitTempo(n);},onKeyDown:e=>{if(e.key==='Enter'){e.currentTarget.blur();}},className:"w-full text-xs text-right outline-none bg-transparent"})),/*#__PURE__*/React.createElement("button",{className:"px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]",onClick:()=>commitTempo((tempo||120)+1)},"+"),/*#__PURE__*/React.createElement("span",{className:"text-slate-600 text-[10px]"},"BPM"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 font-mono text-[11px]"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",null,"Pitch:"),/*#__PURE__*/React.createElement("button",{className:"px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]",onClick:()=>togglePitch(-0.5)},"-"),/*#__PURE__*/React.createElement("div",{className:"bg-white border border-[#808080] px-1 h-5 flex items-center w-14"},/*#__PURE__*/React.createElement("input",{type:"number",value:pitch.toFixed(1),step:"0.5",onChange:e=>setPitch(parseFloat(e.target.value)||0),className:"w-full text-xs text-right outline-none bg-transparent"})),/*#__PURE__*/React.createElement("button",{className:"px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]",onClick:()=>togglePitch(0.5)},"+")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",null,"Rate:"),/*#__PURE__*/React.createElement("div",{className:"bg-white border border-[#808080] px-1 h-5 flex items-center w-12"},/*#__PURE__*/React.createElement("input",{type:"number",value:rate.toFixed(2),step:"0.1",onChange:e=>setRate(Math.max(0.25,Math.min(4,parseFloat(e.target.value)||1))),className:"w-full text-xs text-right outline-none bg-transparent"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[9px]",onClick:()=>toggleRate(-1)},"-"),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[9px]",onClick:()=>toggleRate(1)},"+"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"font-sans text-slate-700"},"Volume:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-60",max:"12",step:"0.5",value:volumeDb,onChange:e=>setVolumeDb(parseFloat(e.target.value)),className:"me-fader-slider w-24"}),/*#__PURE__*/React.createElement("div",{className:"bg-white border border-[#808080] px-1 h-5 flex items-center justify-center w-14 font-mono text-[11px]"},volumeDb<=-50?'-inf':volumeDb.toFixed(1)," dB")),/*#__PURE__*/React.createElement("div",{className:`px-2 py-0.5 border font-mono font-bold text-[10px] rounded-sm ${selIsMidi?'bg-purple-950 text-purple-300 border-purple-800':'bg-emerald-950 text-emerald-300 border-emerald-800'}`},selIsMidi?'MIDI':'Audio')),/*#__PURE__*/React.createElement("div",{className:"flex items-stretch gap-2 my-1 flex-1 min-h-0"},/*#__PURE__*/React.createElement("div",{className:"flex-1 bg-[#181818] border border-[#3a3a3a] relative overflow-hidden p-0.5"},/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,className:"w-full h-full block cursor-pointer",onMouseDown:handleCanvasMouseDown,onMouseMove:handleCanvasMouseMove,onMouseUp:handleCanvasMouseUp,onContextMenu:handleCanvasContextMenu}),/*#__PURE__*/React.createElement("div",{className:"absolute top-1.5 right-1.5 flex gap-1 z-10"},/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#2a2a2a] hover:bg-slate-700 border border-[#444] rounded flex items-center justify-center text-white text-xs font-bold select-none cursor-pointer",title:"Zoom In",onClick:()=>setZoom(prev=>Math.min(10.0,prev*1.25))},"+"),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#2a2a2a] hover:bg-slate-700 border border-[#444] rounded flex items-center justify-center text-white text-xs font-bold select-none cursor-pointer",title:"Zoom Out",onClick:()=>setZoom(prev=>Math.max(0.2,prev/1.25))},"-"),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#2a2a2a] hover:bg-slate-700 border border-[#444] rounded flex items-center justify-center text-white text-[9px] select-none cursor-pointer",title:"Reset Zoom",onClick:()=>setZoom(1.0)},"1x")),previewCtxMenu&&/*#__PURE__*/React.createElement("div",{className:"fixed bg-[#1e1e24] border border-[#3e3e4a] rounded shadow-md z-[9999] py-1 font-sans text-xs text-slate-300 w-32 cursor-pointer select-none",style:{top:previewCtxMenu.y,left:previewCtxMenu.x},onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"px-3 py-1.5 hover:bg-slate-700 hover:text-white flex items-center gap-2",onClick:()=>{handleCopySelection();setPreviewCtxMenu(null);}},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-copy"})," Copy"),/*#__PURE__*/React.createElement("div",{className:"px-3 py-1.5 hover:bg-slate-700 hover:text-white border-t border-[#3e3e4a] flex items-center gap-2",onClick:()=>setPreviewCtxMenu(null)},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-xmark"})," Cancel"))),/*#__PURE__*/React.createElement("div",{className:"w-48 bg-[#181818] border border-[#3a3a3a] p-1.5 font-mono text-[10px] text-slate-300 leading-tight overflow-y-auto shrink-0"},selected?selIsMidi&&!selected.path?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",null,selected.events," MIDI events"),/*#__PURE__*/React.createElement("div",null,"Length: ",selected.lengthQn," quarter notes"),/*#__PURE__*/React.createElement("div",null,"Length: ",selected.time," (est)"),/*#__PURE__*/React.createElement("div",null,"Ticks per quarter note: ",selected.tpqn)):/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",null,"Size: ",selected.size_mb!=null?selected.size_mb.toFixed(2)+' MB':'-'),selIsMidi?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",null,"Bars: ",midiBars||1),/*#__PURE__*/React.createElement("div",null,"Beats: ",Math.round(midiTotalBeats||16)),/*#__PURE__*/React.createElement("div",null,"BPM: ",midiFileBpm||120),/*#__PURE__*/React.createElement("div",null,"Duration: ",selDur.toFixed(2),"s")):/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",null,"Duration: ",selDur.toFixed(2),"s"),/*#__PURE__*/React.createElement("div",null,"Sample Rate: 44100 Hz"),/*#__PURE__*/React.createElement("div",null,"Type: ",selected.path?selected.kind==='other'?'Local File':'Local Audio':selected.type||'Audio'))):/*#__PURE__*/React.createElement("div",null,"No file selected"))),/*#__PURE__*/React.createElement("div",{className:"h-5 bg-[#c0c0c0] border-t border-white flex items-center justify-between text-[11px] font-mono px-1 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},selIsMidi&&midiNotes&&midiNotes.length?/*#__PURE__*/React.createElement("div",{className:"bg-white border border-[#808080] px-1.5 text-slate-900 font-bold"},"Bar ",Math.max(1,Math.floor(currentTime/(4*60/(tempo||120)))+1)," / ",midiBars||1,/*#__PURE__*/React.createElement("span",{className:"text-slate-500 ml-1"},"| ",formatTime(currentTime)," / ",formatTime(selDur))):/*#__PURE__*/React.createElement("div",{className:"bg-white border border-[#808080] px-1.5 text-slate-900 font-bold"},formatTime(currentTime)," / ",formatTime(selDur))),/*#__PURE__*/React.createElement("div",{className:"text-slate-800 font-bold truncate max-w-[40%]"},selected?selected.name||selected.original_name:'No file selected'),/*#__PURE__*/React.createElement("div",{className:"text-slate-700"},selBpm," bpm x",rate.toFixed(2)))));};// ── Virtual MIDI Keyboard component (Phase 5) ──────────────────────────────
|
||
// Floating draggable QWERTY keyboard — F2 / menu Tools. Không nuốt Space/R
|
||
// (handler toàn cục chỉ bắt phím trong VKB_MAP). Persist localStorage.
|
||
const VKB_WHITE_OFFSETS=[0,2,4,5,7,9,11,12,14,16,17,19,21,23,24];const VKB_BLACK_OFFSETS=[1,3,6,8,10,13,15,18,20,22];const VKB_KEY_LABELS=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const VKB_SCALES={'None':null,'Major':[0,2,4,5,7,9,11],'Minor':[0,2,3,5,7,8,10],'Pentatonic Major':[0,2,4,7,9],'Pentatonic Minor':[0,3,5,7,10],'Dorian':[0,2,3,5,7,9,10],'Mixolydian':[0,2,4,5,7,9,10]};const VirtualMidiKeyboard=({settings,onChange,tracks,activePitches,onNoteOn,onNoteOff,onClose})=>{const KEY_W=24;const KEY_H=84;const BLACK_W=14;const BLACK_H=54;const octave=Math.max(0,Math.min(7,parseInt(settings.octave)||4));const basePitch=(octave+1)*12;const scale=VKB_SCALES[settings.scale]||null;const inScale=pitch=>{if(!scale)return false;const pc=(pitch%12+12)%12;return scale.indexOf(pc)!==-1;};const velNum=Math.max(1,Math.min(127,parseInt(settings.velocity)||100));// Channel hiển thị: user đã chọn (1..16) hoặc mặc định = channel track arm/select đầu tiên
|
||
let displayChannel=parseInt(settings.channel)||null;if(displayChannel===null){try{const trks=tracks||[];if(trks.length){const t=trks.find(x=>x.isArmed)||trks[0];displayChannel=assignTrackMidiChannel(t,trks)+1;}}catch(e){}if(displayChannel===null)displayChannel=1;}const startDrag=e=>{e.preventDefault();const sx=e.clientX,sy=e.clientY;const ox=settings.x,oy=settings.y;const onMove=ev=>{onChange({x:Math.max(-280,Math.min(window.innerWidth-80,ox+(ev.clientX-sx))),y:Math.max(0,Math.min(window.innerHeight-36,oy+(ev.clientY-sy)))});};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};const fireOn=(e,pitch)=>{e.preventDefault();e.stopPropagation();onNoteOn(pitch,velNum/127);};const whiteKeys=VKB_WHITE_OFFSETS.map(function(off,wi){const pitch=Math.min(127,basePitch+off);const active=activePitches&&activePitches.has(pitch);const sc=inScale(pitch);return/*#__PURE__*/React.createElement("div",{key:'vkbw'+off,onMouseDown:e=>fireOn(e,pitch),onMouseUp:()=>onNoteOff(pitch),onMouseLeave:()=>onNoteOff(pitch),style:{position:'absolute',left:wi*KEY_W,top:0,width:KEY_W,height:KEY_H,boxSizing:'border-box'},className:"flex items-end justify-center pb-1 text-[9px] font-bold cursor-pointer rounded-b transition "+(active?'bg-emerald-500 text-white':'bg-zinc-100 text-zinc-600 hover:bg-amber-100')+(sc?' ring-1 ring-inset ring-amber-400':'')},off%12===0?VKB_KEY_LABELS[0]+(Math.floor(pitch/12)-1):'');});const blackKeys=VKB_BLACK_OFFSETS.map(function(off){const pitch=Math.min(127,basePitch+off);const active=activePitches&&activePitches.has(pitch);const sc=inScale(pitch);const wi=VKB_WHITE_OFFSETS.indexOf(off-1);return/*#__PURE__*/React.createElement("div",{key:'vkbb'+off,onMouseDown:e=>fireOn(e,pitch),onMouseUp:()=>onNoteOff(pitch),onMouseLeave:()=>onNoteOff(pitch),style:{position:'absolute',left:(wi+1)*KEY_W-BLACK_W/2,top:0,width:BLACK_W,height:BLACK_H,boxSizing:'border-box',zIndex:5},className:"cursor-pointer rounded-b transition "+(active?'bg-emerald-600':'bg-zinc-950 hover:bg-zinc-800')+(sc?' ring-1 ring-inset ring-amber-400':'')});});return/*#__PURE__*/React.createElement("div",{style:{position:'fixed',left:settings.x,top:settings.y,zIndex:9990,width:15*KEY_W+12},className:"bg-[#1c1c1e] border border-zinc-700 rounded-xl shadow-2xl select-none"},/*#__PURE__*/React.createElement("div",{onMouseDown:startDrag,className:"flex items-center justify-between px-3 py-1.5 cursor-move bg-zinc-800 rounded-t-xl border-b border-zinc-700"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-zinc-300 tracking-wide"},"VIRTUAL MIDI KEYBOARD"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-zinc-500"},"F2"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();onClose();},className:"text-zinc-500 hover:text-red-400 text-xs px-1"},"\u2715"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 px-2 py-1.5 border-b border-zinc-800 text-[10px] text-zinc-400 flex-wrap"},/*#__PURE__*/React.createElement("button",{onClick:()=>onChange({octave:Math.max(0,octave-1)}),className:"px-1.5 py-0.5 bg-zinc-800 hover:bg-zinc-700 rounded"},"Oct -"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-amber-400"},"Octave: "+octave),/*#__PURE__*/React.createElement("button",{onClick:()=>onChange({octave:Math.min(7,octave+1)}),className:"px-1.5 py-0.5 bg-zinc-800 hover:bg-zinc-700 rounded"},"Oct +"),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1"},"Vel",/*#__PURE__*/React.createElement("input",{type:"range",min:1,max:127,value:velNum,onChange:e=>onChange({velocity:parseInt(e.target.value)||100}),className:"w-16"}),/*#__PURE__*/React.createElement("span",{className:"font-mono w-7 text-right"},velNum)),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1"},"Ch",/*#__PURE__*/React.createElement("select",{value:displayChannel,onChange:e=>onChange({channel:parseInt(e.target.value)}),className:"bg-zinc-800 text-zinc-300 rounded px-1 py-0.5"},Array.from({length:16},function(_,i){return/*#__PURE__*/React.createElement("option",{key:i+1,value:i+1},i+1);}))),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1"},"Tr",/*#__PURE__*/React.createElement("input",{type:"number",min:-24,max:24,value:settings.transpose,onChange:e=>onChange({transpose:parseInt(e.target.value)||0}),className:"bg-zinc-800 text-zinc-300 rounded px-1 py-0.5 w-10 font-mono"})),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1"},"Scale",/*#__PURE__*/React.createElement("select",{value:settings.scale,onChange:e=>onChange({scale:e.target.value}),className:"bg-zinc-800 text-zinc-300 rounded px-1 py-0.5"},Object.keys(VKB_SCALES).map(function(s){return/*#__PURE__*/React.createElement("option",{key:s,value:s},s);})))),/*#__PURE__*/React.createElement("div",{className:"relative mx-1 my-2",style:{width:15*KEY_W,height:KEY_H}},whiteKeys,blackKeys),/*#__PURE__*/React.createElement("div",{className:"px-2 pb-1.5 text-[9px] text-zinc-600"},"QWERTY: Z S X D C V G B H N J M / Q 2 W 3 E R 5 T 6 Y 7 U I"));};// ── Chords Panel (Phase 6) ─────────────────────────────────────────────
|
||
// Floating chord progression panel — Insert menu / Shift+K / piano-roll
|
||
// toolbar. Presets theo virtual_keyboard_and_chords_panel_spec.md §III.2.
|
||
const CHORD_PRESETS=[{id:'pop',label:'Pop',genre:'Pop',tokens:'I-V-vi-IV',key:'C',voicing:'root',rhythm:'whole',hint:'C → G → Am → F'},{id:'jazz',label:'Jazz Neo-Soul',genre:'Jazz Neo-Soul',tokens:'ii7-V7-Imaj7-VI7',key:'C',voicing:'drop2',rhythm:'whole',hint:'Dm7 → G7 → Cmaj7 → A7'},{id:'cinematic',label:'Cinematic',genre:'Cinematic',tokens:'i-VI-III-VII',key:'C',voicing:'root',rhythm:'whole',hint:'Cm → Ab → Eb → Bb'},{id:'edm',label:'EDM',genre:'EDM',tokens:'vi-IV-I-V',key:'C',voicing:'first',rhythm:'quarter',hint:'Am → F → C → G'},{id:'lofi',label:'Lo-Fi',genre:'Lo-Fi',tokens:'i7-iv7-v7-i7',key:'C',voicing:'root',rhythm:'whole',hint:'Cm7 → Fm7 → Gm7 → Cm7'},{id:'rnb',label:'R&B',genre:'R&B',tokens:'Imaj7-iii7-IVmaj7-V13',key:'C',voicing:'root',rhythm:'whole',hint:'Cmaj7 → Em7 → Fmaj7 → G13'}];const CHORD_RHYTHM_BEATS={whole:4,quarter:1,arpeggio:1,strum:1};const CHORD_RHYTHM_LABELS={whole:'Whole (4 beats)',quarter:'Quarter (1 beat)',arpeggio:'Arpeggio',strum:'Strum'};const CHORD_KEYS=['C','C#','D','Eb','E','F','F#','G','Ab','A','Bb','B'];const CHORD_VOICING_LABELS={root:'Root',first:'1st Inv',drop2:'Drop 2'};const ChordsPanel=({onClose,onInsert,onNoteOn,onNoteOff,bpm})=>{const[tab,setTab]=useState('preset');const[search,setSearch]=useState('');const[key,setKey]=useState('C');const[favorites,setFavorites]=useState(function(){try{return JSON.parse(localStorage.getItem('daw_chord_favorites')||'[]');}catch(e){return[];}});const[customList,setCustomList]=useState(function(){try{return JSON.parse(localStorage.getItem('daw_user_custom_chords')||'[]');}catch(e){return[];}});const[cName,setCName]=useState('');const[cGenre,setCGenre]=useState('');const[cTokens,setCTokens]=useState('I-vi-IV-V');const[cRhythm,setCRhythm]=useState('whole');const[cVoicing,setCVoicing]=useState('root');const[customKey,setCustomKey]=useState('C');const previewTimerRef=useRef(null);useEffect(function(){const onSaved=function(){try{setCustomList(JSON.parse(localStorage.getItem('daw_user_custom_chords')||'[]'));}catch(e){}};window.addEventListener('CUSTOM_CHORD_SAVED',onSaved);return function(){window.removeEventListener('CUSTOM_CHORD_SAVED',onSaved);};},[]);useEffect(function(){try{localStorage.setItem('daw_chord_favorites',JSON.stringify(favorites));}catch(e){}},[favorites]);useEffect(function(){return function(){if(previewTimerRef.current)clearTimeout(previewTimerRef.current);};},[]);const resolveProgression=function(tokens,progKey,progVoicing){try{return(window.ChordTheory||ChordTheory).progressionToChords(tokens,progKey||key,progVoicing||'root');}catch(e){return[];}};const previewChord=function(chord){if(!chord||!chord.pitches||!chord.pitches.length)return;const vel=0.8;chord.pitches.forEach(function(p){try{onNoteOn(p,vel);}catch(e){}});if(previewTimerRef.current)clearTimeout(previewTimerRef.current);previewTimerRef.current=setTimeout(function(){chord.pitches.forEach(function(p){try{onNoteOff(p);}catch(e){}});},900);};const toggleFavorite=function(id){setFavorites(function(prev){return prev.indexOf(id)!==-1?prev.filter(function(x){return x!==id;}):prev.concat([id]);});};const insertProg=function(label,chords,rhythm){const dur=CHORD_RHYTHM_BEATS[rhythm]!=null?CHORD_RHYTHM_BEATS[rhythm]:4;const payload=(chords||[]).filter(function(c){return c.pitches&&c.pitches.length;}).map(function(c){return{name:c.name||c.token,pitches:c.pitches,duration_beats:dur};});if(!payload.length){if(window.showToast)window.showToast('Không có chord để chèn','warning');return;}onInsert({label:label,chords:payload});};const saveCustom=function(){const tokens=(cTokens||'').trim();if(!tokens){if(window.showToast)window.showToast('Nhập progression tokens','warning');return;}let chords=[];try{chords=(window.ChordTheory||ChordTheory).progressionToChords(tokens,customKey,cVoicing);}catch(e){}if(!chords.length||chords.some(function(c){return!c.pitches||!c.pitches.length;})){if(window.showToast)window.showToast('Progression không parse được','warning');return;}const entry={id:'custom_'+Date.now(),name:(cName||'').trim()||'My Chord',genre:(cGenre||'').trim()||'Custom',tokens:tokens,key:customKey,rhythm:cRhythm,voicing:cVoicing,chords:chords};try{const list=JSON.parse(localStorage.getItem('daw_user_custom_chords')||'[]');list.push(entry);localStorage.setItem('daw_user_custom_chords',JSON.stringify(list));setCustomList(list);window.dispatchEvent(new CustomEvent('CUSTOM_CHORD_SAVED'));if(window.showToast)window.showToast('Đã lưu chord custom: '+entry.name,'success');}catch(e){if(window.showToast)window.showToast('Lưu thất bại','error');}};const deleteCustom=function(id){try{const list=JSON.parse(localStorage.getItem('daw_user_custom_chords')||'[]').filter(function(x){return x.id!==id;});localStorage.setItem('daw_user_custom_chords',JSON.stringify(list));setCustomList(list);window.dispatchEvent(new CustomEvent('CUSTOM_CHORD_SAVED'));}catch(e){}};const q=(search||'').trim().toLowerCase();const filteredPresets=CHORD_PRESETS.filter(function(p){if(!q)return true;const chords=resolveProgression(p.tokens,key,p.voicing);const hay=(p.label+' '+p.genre+' '+p.hint+' '+chords.map(function(c){return c.name;}).join(' ')).toLowerCase();return hay.indexOf(q)!==-1;});return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[9995] flex items-center justify-center bg-black/60 backdrop-blur-sm",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-5 text-slate-200 max-h-[85vh] overflow-y-auto",onClick:function(e){e.stopPropagation();}},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between pb-3 border-b border-[#383838] mb-3"},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-amber-400"},"Chords Panel"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-zinc-500 hover:text-red-400 text-xs px-1",title:"Close"},"\u2715")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 mb-3 flex-wrap"},/*#__PURE__*/React.createElement("div",{className:"flex gap-1"},[['preset','Presets'],['custom','Custom'],['ai','AI']].map(function(t){return/*#__PURE__*/React.createElement("button",{key:t[0],onClick:function(){setTab(t[0]);},className:"px-3 py-1 rounded text-[11px] font-bold "+(tab===t[0]?'bg-amber-700 text-white':'bg-zinc-800 text-zinc-400 hover:bg-zinc-700')},t[1]);})),tab!=='ai'&&/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("select",{value:key,onChange:function(e){setKey(e.target.value);},className:"bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-2 py-1 text-[11px] outline-none",title:"Key (transpose)"},CHORD_KEYS.map(function(k){return/*#__PURE__*/React.createElement("option",{key:k,value:k},k);})),tab==='preset'&&/*#__PURE__*/React.createElement("input",{value:search,onChange:function(e){setSearch(e.target.value);},placeholder:"Search preset...",className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-2 py-1 text-[11px] outline-none focus:border-amber-500 flex-1 min-w-[120px]"}))),tab==='preset'&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-1 sm:grid-cols-2 gap-3"},filteredPresets.map(function(p){const chords=resolveProgression(p.tokens,key,p.voicing);const display=chords.map(function(c){return c.name;}).join(' → ');const fav=favorites.indexOf(p.id)!==-1;return/*#__PURE__*/React.createElement("div",{key:p.id,className:"border border-[#383838] rounded-lg p-3 bg-[#2a2a2a]"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between mb-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-amber-400"},p.label),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-zinc-500"},p.genre," \xB7 ",CHORD_VOICING_LABELS[p.voicing])),/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-mono text-zinc-300 mb-2"},display||p.hint),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 flex-wrap"},/*#__PURE__*/React.createElement("button",{onClick:function(){previewChord(chords[0]);},disabled:!chords.length,className:"px-2 py-1 rounded text-[10px] font-semibold bg-emerald-800 hover:bg-emerald-700 text-emerald-100 disabled:opacity-40"},"Preview Sound"),/*#__PURE__*/React.createElement("button",{onClick:function(){insertProg(p.label,chords,p.rhythm);},disabled:!chords.length,className:"px-2 py-1 rounded text-[10px] font-semibold bg-blue-800 hover:bg-blue-700 text-blue-100 disabled:opacity-40"},"Insert to Timeline"),/*#__PURE__*/React.createElement("button",{onClick:function(){toggleFavorite(p.id);},className:"px-2 py-1 rounded text-[10px] font-semibold "+(fav?'bg-yellow-700 text-yellow-100':'bg-zinc-700 text-zinc-300 hover:bg-zinc-600')},fav?'★ Favorite':'☆ Favorite')));}),!filteredPresets.length&&/*#__PURE__*/React.createElement("div",{className:"col-span-full text-[11px] text-zinc-500"},"Kh\xF4ng t\xECm th\u1EA5y preset.")),tab==='custom'&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"border border-[#383838] rounded-lg p-3 bg-[#2a2a2a]"},/*#__PURE__*/React.createElement("div",{className:"text-xs font-bold text-amber-400 mb-2"},"Build Custom Progression"),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-2"},/*#__PURE__*/React.createElement("input",{value:cName,onChange:function(e){setCName(e.target.value);},placeholder:"T\xEAn (vd: Ballad)",className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-2 py-1 text-xs outline-none focus:border-amber-500"}),/*#__PURE__*/React.createElement("input",{value:cGenre,onChange:function(e){setCGenre(e.target.value);},placeholder:"Genre (vd: Ballad)",className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-2 py-1 text-xs outline-none focus:border-amber-500"}),/*#__PURE__*/React.createElement("input",{value:cTokens,onChange:function(e){setCTokens(e.target.value);},placeholder:"Tokens (vd: I-vi-IV-V)",className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-2 py-1 text-xs outline-none focus:border-amber-500"}),/*#__PURE__*/React.createElement("div",{className:"flex gap-2 flex-wrap"},/*#__PURE__*/React.createElement("select",{value:customKey,onChange:function(e){setCustomKey(e.target.value);},className:"bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-2 py-1 text-[11px] outline-none"},CHORD_KEYS.map(function(k){return/*#__PURE__*/React.createElement("option",{key:k,value:k},k);})),/*#__PURE__*/React.createElement("select",{value:cRhythm,onChange:function(e){setCRhythm(e.target.value);},className:"bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-2 py-1 text-[11px] outline-none"},Object.keys(CHORD_RHYTHM_BEATS).map(function(r){return/*#__PURE__*/React.createElement("option",{key:r,value:r},CHORD_RHYTHM_LABELS[r]);})),/*#__PURE__*/React.createElement("select",{value:cVoicing,onChange:function(e){setCVoicing(e.target.value);},className:"bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-2 py-1 text-[11px] outline-none"},Object.keys(CHORD_VOICING_LABELS).map(function(v){return/*#__PURE__*/React.createElement("option",{key:v,value:v},CHORD_VOICING_LABELS[v]);}))),/*#__PURE__*/React.createElement("button",{onClick:saveCustom,className:"px-3 py-1.5 rounded text-xs font-bold bg-amber-700 hover:bg-amber-600 text-white"},"Save Custom"))),/*#__PURE__*/React.createElement("div",{className:"border border-[#383838] rounded-lg p-3 bg-[#2a2a2a]"},/*#__PURE__*/React.createElement("div",{className:"text-xs font-bold text-amber-400 mb-2"},"My Customs (",customList.length,")"),customList.length===0?/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500"},"Ch\u01B0a c\xF3 custom n\xE0o \u2014 t\u1EA1o b\xEAn tr\xE1i r\u1ED3i Save."):/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-2 max-h-48 overflow-y-auto"},customList.map(function(c){return/*#__PURE__*/React.createElement("div",{key:c.id,className:"border border-zinc-700 rounded p-2 flex items-center justify-between gap-2"},/*#__PURE__*/React.createElement("div",{className:"min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-zinc-200"},c.name," ",/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-zinc-500"},"(",c.genre,")")),/*#__PURE__*/React.createElement("div",{className:"text-[10px] font-mono text-zinc-400 truncate"},c.chords.map(function(x){return x.name;}).join(' → '))),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 shrink-0"},/*#__PURE__*/React.createElement("button",{onClick:function(){previewChord(c.chords[0]);},className:"px-2 py-1 rounded text-[10px] font-semibold bg-emerald-800 hover:bg-emerald-700 text-emerald-100"},"Preview"),/*#__PURE__*/React.createElement("button",{onClick:function(){insertProg(c.name,c.chords,c.rhythm);},className:"px-2 py-1 rounded text-[10px] font-semibold bg-blue-800 hover:bg-blue-700 text-blue-100"},"Insert"),/*#__PURE__*/React.createElement("button",{onClick:function(){deleteCustom(c.id);},className:"px-2 py-1 rounded text-[10px] font-semibold bg-red-900 hover:bg-red-800 text-red-100"},"Del")));})))),tab==='ai'&&/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center justify-center py-12 text-center"},/*#__PURE__*/React.createElement("div",{className:"text-3xl mb-2"},"\u266A"),/*#__PURE__*/React.createElement("div",{className:"text-sm text-zinc-300 font-semibold mb-1"},"AI Chord Suggestions"),/*#__PURE__*/React.createElement("div",{className:"text-[11px] text-zinc-500 max-w-sm"},"AI ch\u01B0a c\u1EA5u h\xECnh. B\u1EADt AI Gateway trong Settings \u0111\u1EC3 nh\u1EADn g\u1EE3i \xFD chord theo genre / c\u1EA3m x\xFAc."))));};const App=()=>{// ── State Definitions ──
|
||
const[tracks,setTracks]=useState([{id:'1',name:'Track 01',buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:'#0f766e',markers:[],serverFileId:null,clips:[],sections:[],midiItems:[],isArmed:false,monitoringEnabled:true,inputSource:{deviceType:'NONE',deviceId:''}},{id:'2',name:'Track 02',buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:'#1d4ed8',markers:[],serverFileId:null,clips:[],sections:[],midiItems:[],isArmed:false,monitoringEnabled:true,inputSource:{deviceType:'NONE',deviceId:''}}]);const[appWarningModal,setAppWarningModal]=useState(null);const[bpm,setBpm]=useState(localStorage.getItem('studio_bpm')||'120');const prevBpmRef=useRef(bpm);const[draggedClip,setDraggedClip]=useState(null);const[hoveredTrackId,setHoveredTrackId]=useState(null);// Recalculate item/section/selection durations when BPM changes
|
||
useEffect(()=>{const oldSpb=prevBpmRef.current?60.0/parseFloat(prevBpmRef.current)*4:null;const bpmVal=parseFloat(bpm)||120;const secondsPerBar=60.0/bpmVal*4;// Recalculate range loop selection to maintain bar count (tempo mode only)
|
||
if(oldSpb&&selectionFollowsTempo&&selectionStart!==null&&selectionEnd!==null&&selectionEnd>selectionStart){const startBar=selectionStart/oldSpb;const endBar=selectionEnd/oldSpb;if(endBar-startBar>0.01){setSelectionStart(startBar*secondsPerBar);setSelectionEnd(endBar*secondsPerBar);}}prevBpmRef.current=bpm;// Force canvas redraw
|
||
setCanvasRedrawCount(n=>n+1);// Recalculate item/section durations
|
||
updateActiveTracks(prev=>prev.map(t=>({...t,midiItems:(t.midiItems||[]).map(m=>{if(m.length_bars)return{...m,duration:m.length_bars*secondsPerBar};if(m.duration){// Legacy item without length_bars: compute bars from current duration/BPM
|
||
const bars=Math.max(0.25,Math.round(m.duration/secondsPerBar*4)/4);return{...m,length_bars:bars,duration:bars*secondsPerBar};}return m;}),sections:(t.sections||[]).map(s=>{if(s.length_bars)return{...s,duration:s.length_bars*secondsPerBar};if(s.duration){const bars=Math.max(0.25,Math.round(s.duration/secondsPerBar*4)/4);return{...s,length_bars:bars,duration:bars*secondsPerBar};}return s;})})));},[bpm]);// LAN badge removed per request: server IP now shown in SERVER label.
|
||
const openPanel=id=>{if(id==='export')setShowExportPanel(true);else if(id==='ai')setShowAIPanel(true);else if(id==='python_tools')setShowPythonToolsPanel(true);else if(id==='selection')setShowSelectionPanel(true);};const[instrumentSelectorTrackId,setInstrumentSelectorTrackId]=useState(null);const[selectorLoadedInst,setSelectorLoadedInst]=useState(null);// instrument dang load cua track (de highlight)
|
||
const[synthTrackDropdownId,setSynthTrackDropdownId]=useState(null);const[fxSelectorTrackId,setFxSelectorTrackId]=useState(null);const handleSetTrackFx=(trackId,fxType)=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,fxType}:t));setFxSelectorTrackId(null);setTimeout(()=>lucide.createIcons(),50);};const[instrumentSelectorData,setInstrumentSelectorData]=useState(null);const[instrumentRefreshKey,setInstrumentRefreshKey]=useState(0);const openInstrumentSelector=trackId=>{setInstrumentSelectorTrackId(trackId);setSfPresetSearchQuery('');setInstrumentRefreshKey(k=>k+1);setSfPresets(null);// Highlight instrument dang load cua track (v17: nhan lai nut Synth -> show lai instrument da load)
|
||
var selTracks=activeTracksRef.current||tracks;var selTrk=null;for(var si=0;si<selTracks.length;si++){if(selTracks[si].id===trackId){selTrk=selTracks[si];break;}}// Piano roll cua SECTION subTrack: trackId nam trong session tab (KHONG
|
||
// co trong activeTracksRef) -> tim them trong sessionTabsRef.
|
||
if(!selTrk&&sessionTabsRef&&sessionTabsRef.current){for(var sti=0;sti<sessionTabsRef.current.length&&!selTrk;sti++){for(var sji=0;sji<(sessionTabsRef.current[sti].tracks||[]).length;sji++){if(sessionTabsRef.current[sti].tracks[sji].id===trackId){selTrk=sessionTabsRef.current[sti].tracks[sji];break;}}}}var loaded=null;if(selTrk&&selTrk.instrumentId&&String(selTrk.instrumentId).indexOf('sf_')===0){var sfEng=selTrk.synth_engine||{};loaded={sfId:selTrk.instrumentId,bank:selTrk.soundfont_bank!==undefined?selTrk.soundfont_bank:sfEng.soundfont_bank!==undefined?sfEng.soundfont_bank:0,prog:selTrk.instrumentProgram!==undefined?selTrk.instrumentProgram:sfEng.soundfont_program!==undefined?sfEng.soundfont_program:0};setSelectedSoundFontId(selTrk.instrumentId);setSynthCategory('soundfont');}else{if(selTrk&&selTrk.instrumentId)loaded={instId:selTrk.instrumentId};setSelectedSoundFontId(null);setSynthCategory(null);}setSelectorLoadedInst(loaded);// Fetch instruments fresh from API for all soundfonts
|
||
window.SonicAPI.listPlugins().then(async data=>{var sfonts=data.soundfonts||[];if(sfonts.length===0){setSfPresets([]);return;}try{var results=await Promise.all(sfonts.map(function(sf){var sfId=sf.id.replace('sf_','');return window.SonicAPI.listSoundfontInstruments(sfId).then(function(r){return{sf:sf,presets:r.presets||[]};}).catch(function(){return{sf:sf,presets:[]};});}));var all=[];results.forEach(function(r){var sf=r.sf;var sfId=sf.id.startsWith('sf_')?sf.id:'sf_'+sf.id;var sfName=sf.display||sf.name||sf.id;(r.presets||[]).forEach(function(p){all.push({...p,_sfId:sfId,_sfName:sfName,_sfDisplay:sfName.substring(0,30)});});});setSfPresets(all.length>0?all:[]);}catch(e){setSfPresets([]);}}).catch(function(){setSfPresets([]);});};const closeInstrumentSelector=()=>{setInstrumentSelectorTrackId(null);setSynthCategory(null);setSelectedSoundFontId(null);setSelectorLoadedInst(null);setSfPresetSearchQuery('');};const[synthCategory,setSynthCategory]=useState(null);// 'vst' | 'soundfont'
|
||
const[selectedSoundFontId,setSelectedSoundFontId]=useState(null);const[sfPresets,setSfPresets]=useState(null);// presets from SoundFont
|
||
const[instrumentDropdownTrackId,setInstrumentDropdownTrackId]=useState(null);const[instrumentDropdownBtnRect,setInstrumentDropdownBtnRect]=useState(null);const[instrumentSearchQuery,setInstrumentSearchQuery]=useState('');const[sfPresetSearchQuery,setSfPresetSearchQuery]=useState('');const filteredInstruments=useMemo(()=>{if(!instrumentSelectorData||!instrumentSearchQuery)return{soundfonts:instrumentSelectorData?.soundfonts||[],vst:instrumentSelectorData?.vst_instruments||[]};const q=instrumentSearchQuery.toLowerCase();return{soundfonts:(instrumentSelectorData.soundfonts||[]).filter(sf=>(sf.display||sf.name||sf.id).toLowerCase().includes(q)),vst:(instrumentSelectorData.vst_instruments||[]).filter(v=>(v.name||v.id).toLowerCase().includes(q))};},[instrumentSearchQuery,instrumentSelectorData]);useEffect(()=>{window.SonicAPI.listPlugins().then(async data=>{try{const catResp=(await window.SonicAPI.getSoundfontCatalog?.())??(await fetch('/api/v1/plugins/soundfonts/catalog').then(r=>r.json()));const catalog=catResp.full_catalog||{};data.soundfonts=(data.soundfonts||[]).map(sf=>{const sfId=sf.id.replace('sf_','');const catEntry=catalog[sfId.toLowerCase()]||catalog[sfId];if(catEntry&&catEntry.instruments){return{...sf,presets:catEntry.instruments};}return sf;});}catch(e){console.warn('Catalog fetch error:',e);}setInstrumentSelectorData(data);}).catch(()=>{});},[instrumentRefreshKey]);useEffect(()=>{if(!instrumentDropdownTrackId)return;const handler=e=>{setInstrumentDropdownTrackId(null);setInstrumentDropdownBtnRect(null);setInstrumentSearchQuery('');};document.addEventListener('click',handler);return()=>document.removeEventListener('click',handler);},[instrumentDropdownTrackId]);const GM_INSTRUMENTS=["Acoustic Grand Piano","Bright Acoustic Piano","Electric Grand Piano","Honky-tonk Piano","Electric Piano 1","Electric Piano 2","Harpsichord","Clavi","Celesta","Glockenspiel","Music Box","Vibraphone","Marimba","Xylophone","Tubular Bells","Dulcimer","Drawbar Organ","Percussive Organ","Rock Organ","Church Organ","Reed Organ","Accordion","Harmonica","Tango Accordion","Acoustic Guitar (nylon)","Acoustic Guitar (steel)","Electric Guitar (jazz)","Electric Guitar (clean)","Electric Guitar (muted)","Overdriven Guitar","Distortion Guitar","Guitar harmonics","Acoustic Bass","Electric Bass (finger)","Electric Bass (pick)","Fretless Bass","Slap Bass 1","Slap Bass 2","Synth Bass 1","Synth Bass 2","Violin","Viola","Cello","Contrabass","Tremolo Strings","Pizzicato Strings","Orchestral Harp","Timpani","String Ensemble 1","String Ensemble 2","Synth Strings 1","Synth Strings 2","Choir Aahs","Voice Oohs","Synth Voice","Orchestra Hit","Trumpet","Trombone","Tuba","Muted Trumpet","French Horn","Brass Section","Synth Brass 1","Synth Brass 2","Soprano Sax","Alto Sax","Tenor Sax","Baritone Sax","Oboe","English Horn","Bassoon","Clarinet","Piccolo","Flute","Recorder","Pan Flute","Blown Bottle","Shakuhachi","Whistle","Ocarina","Lead 1 (square)","Lead 2 (sawtooth)","Lead 3 (calliope)","Lead 4 (chiff)","Lead 5 (charang)","Lead 6 (voice)","Lead 7 (fifths)","Lead 8 (bass+lead)","Pad 1 (new age)","Pad 2 (warm)","Pad 3 (polysynth)","Pad 4 (choir)","Pad 5 (bowed)","Pad 6 (metallic)","Pad 7 (halo)","Pad 8 (sweep)","FX 1 (rain)","FX 2 (soundtrack)","FX 3 (crystal)","FX 4 (atmosphere)","FX 5 (brightness)","FX 6 (goblins)","FX 7 (echoes)","FX 8 (sci-fi)","Sitar","Banjo","Shamisen","Koto","Kalimba","Bag pipe","Fiddle","Shanai","Tinkle Bell","Agogo","Steel Drums","Woodblock","Taiko Drum","Melodic Tom","Synth Drum","Reverse Cymbal","Guitar Fret Noise","Breath Noise","Seashore","Bird Tweet","Telephone Ring","Helicopter","Applause","Gunshot"];// D7: bridge active → load instrument vào bridge tại channel router dùng cho
|
||
// track (SonicMidiRouter.allocateChannel — cùng allocation scheduleMidiNoteDispatch/
|
||
// keybed dùng khi push NOTE_ON). Không load → MIDI tới channel rỗng → C++ silent.
|
||
const loadTrackInstrumentToBridge=(trackId,instrumentId,bank)=>{if(!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive()||!instrumentId)return;const isSf=typeof instrumentId==='string'&&instrumentId.startsWith('sf_');const bch=window.SonicMidiRouter.allocateChannel(trackId,bank===128);// V9 GUI-button bug: switch VSTi → SF2 phải xóa flag "đã load VST" của
|
||
// channel, nếu không ensureAndOpenVstGui sẽ bỏ qua load khi chọn lại VST
|
||
// (flag cũ còn true) → OPEN_GUI đến bridge khi channel chưa có instrument
|
||
// → deferred mãi, nút GUI không mở gì.
|
||
if(isSf){if(window.__bridgeLoadedChannels)delete window.__bridgeLoadedChannels[bch];if(window.__bridgeLoadedPaths)delete window.__bridgeLoadedPaths[bch];}// VST2 (.dll) scan type la 'VST2' - khong hardcode VST3 (se LOAD sai
|
||
// loai -> create_instrument nullptr -> fail im lang).
|
||
let btype=isSf?'SF2':'VST3';let bpath=null;if(instrumentSelectorData){if(isSf){const sid=instrumentId.replace('sf_','');const s0=(instrumentSelectorData.soundfonts||[]).find(s=>String(s.id).replace('sf_','')===sid);bpath=s0&&(s0.file||s0.path);}else{const v0=(instrumentSelectorData.vst_instruments||[]).find(v=>v.id===instrumentId||v.name===instrumentId);bpath=v0&&v0.path;if(v0&&v0.type)btype=v0.type;}}// V9: path sai (plugin khác instrumentId) → bỏ qua, resolve theo name.
|
||
if(!isSf)bpath=_bridgeSafeVstPath(bpath,instrumentId);// V9: VST→VST đổi instrument khác → path khác path đã load → xóa flag để
|
||
// load mới (trước đây chỉ xóa khi isSf — flag cũ làm skip nhầm).
|
||
if(window.__bridgeLoadedPaths&&window.__bridgeLoadedPaths[bch]!==bpath){delete window.__bridgeLoadedPaths[bch];if(window.__bridgeLoadedChannels)delete window.__bridgeLoadedChannels[bch];}(async()=>{let p=bpath;try{const r=await window.SonicAPI.bridgeLoad({name:instrumentId,path:bpath||null,instrumentType:btype,channel:bch});if(r&&r.path)p=r.path;}catch(e){console.warn('[Bridge] resolve fail:',e);}if(p){const ok=await window.NativeBridgeService.loadInstrument(p,btype,bch);console.log('[Bridge] track',trackId,'-> bridge',btype,'ch',bch,ok?'OK':'FAIL',p);// Reset program-change dedupe của track — LOAD async (C++ worker): CC/
|
||
// PROGRAM gửi trước khi load xong bị drop (inst chưa có) → note kế tiếp
|
||
// phải gửi lại program change để ra ĐÚNG instrument.
|
||
if(ok&&window.__bridgeLastProgram){const _pfx=trackId+':';Object.keys(window.__bridgeLastProgram).forEach(k=>{if(k.indexOf(_pfx)===0)delete window.__bridgeLastProgram[k];});}if(ok){if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};window.__bridgeLoadedChannels[bch]=true;if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};window.__bridgeLoadedPaths[bch]=p;}if(ok&&btype==='VST3'&&p){try{if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=p;updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,synth_engine:{...(t.synth_engine||{}),plugin_path:p}}:t));}catch(e){}}// Requirement 2: chọn VST3 qua nút Synth → load xong mở native GUI
|
||
// (C++ attach editor vào cửa sổ bridge tự tạo — control type=4, hwnd=0).
|
||
// LEVEL-1 (2026-08-15): mở GUI ngay cả khi đang play. C++ đã bỏ defer
|
||
// transport STOP; setReloading trước job (G0.1b) + 1 UiThread (G1.2)
|
||
// chặn race 2 thread 1 DLL. Mute ngắn ~0.1-0.3s lần đầu, note đang
|
||
// ngân không mất. Reopen sau khi đóng vẫn mute 1-3s (reload) — level 2.
|
||
if(ok&&(btype==='VST3'||btype==='VST2')&&window.NativeBridgeService.openNativeGUI){try{await openVstGuiRetry(instrumentId,bch,20);}catch(e){}}}})();};const setTrackInstrumentWithProgram=(trackId,instrumentId,programNumber,displayName,bankNumber)=>{const isSfInstrument=instrumentId&&typeof instrumentId==='string'&&instrumentId.startsWith('sf_');const sfBank=bankNumber!==undefined?bankNumber:isSfInstrument?0:undefined;const sfProg=programNumber!==undefined?programNumber:undefined;var mt=activeTracksRef.current||tracks;var curTrk=null;for(var ci=0;ci<mt.length;ci++){if(mt[ci].id===trackId){curTrk=mt[ci];break;}}// ── Panic khi chuyển từ VSTi sang instrument KHÔNG phải VST ──
|
||
// (soundfont/GM/default) — dừng mọi note đang vang từ VSTi cũ.
|
||
try{const _hasInst=!!instrumentId;const _wasVst=curTrk&&curTrk.synth_engine&&String(curTrk.synth_engine.type||'').indexOf('vst')!==-1;const _nowVst=_hasInst&&!isSfInstrument;if(_wasVst&&!_nowVst){try{if(window.SonicMidiRouter)window.SonicMidiRouter.panic();else if(window.SonicSF&&window.SonicSF.stopAll)window.SonicSF.stopAll();}catch(e){}stopAllNativeSfNotes();stopAllNativeVstNotes();}}catch(e){}var mch=curTrk?assignTrackMidiChannel(curTrk,mt):sfBank===128?9:0;const hasInstrument=!!instrumentId;const instrType=isSfInstrument?'soundfont':hasInstrument?'vst3':'default';const synthEngine=hasInstrument?{type:instrType,plugin_id:instrumentId,soundfont_bank:sfBank!==undefined?sfBank:0,soundfont_program:sfProg!==undefined?sfProg:0,soundfont_id:isSfInstrument?instrumentId.replace('sf_',''):''}:undefined;updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiChannel:mch,instrumentId,instrumentProgram:sfProg,instrumentName:displayName,soundfont_bank:sfBank,soundfont_program:sfProg,synth_engine:synthEngine,type:hasInstrument?'MIDI':t.type==='MIDI'?'audio':t.type};}));// Piano roll cua SECTION subTrack: trackId thuoc session tab (khong trong
|
||
// activeTracks) -> cap nhat track trong tab do de lan play sau dung inst.
|
||
if(!curTrk&&sessionTabsRef&&sessionTabsRef.current){try{setSessionTabs(prev=>prev.map(sec=>(sec.tracks||[]).some(t=>t.id===trackId)?{...sec,tracks:(sec.tracks||[]).map(t=>t.id===trackId?{...t,midiChannel:mch,instrumentId,instrumentProgram:sfProg,instrumentName:displayName,soundfont_bank:sfBank,soundfont_program:sfProg,synth_engine:synthEngine,type:hasInstrument?'MIDI':t.type==='MIDI'?'audio':t.type}:t)}:sec));}catch(e){console.warn('[Instr] section sync fail:',e);}}// Dong bo snapshot instrument cua PIANO_ROLL subtab dang mo cho track nay
|
||
// (toolbar Synth/GUI hien thi dung instrument vua chon).
|
||
try{if(setSubTabs)setSubTabs(prev=>prev.map(s=>s.type==='PIANO_ROLL'&&s.trackId===trackId?{...s,instrumentId,instrumentName:displayName,instrumentProgram:sfProg,synth_engine:synthEngine}:s));}catch(e){console.warn('[Instr] subtab sync fail:',e);}setInstrumentDropdownTrackId(null);setInstrumentDropdownBtnRect(null);setSynthCategory(null);// Trigger FluidSynth load + program change when soundfont instrument selected
|
||
if(window.SonicSF&&window.SonicSF.selectInstrument&&instrumentId&&isSfInstrument){const sfId=instrumentId.replace('sf_','');window.SonicSF.selectInstrument(mch,sfBank||0,sfProg||0,sfId);}// D7: bridge active → load instrument vào bridge tại channel router sẽ dùng
|
||
loadTrackInstrumentToBridge(trackId,instrumentId,sfBank);setSubTabs(prev=>prev.map(s=>{if(s.trackId!==trackId)return s;return{...s,instrumentProgram:programNumber!==undefined?programNumber:undefined,instrumentName:displayName,instrumentId};}));const playingSub=subTabs.find(s=>s.trackId===trackId&&s.type==='PIANO_ROLL'&&s.isPlaying);if(playingSub){const pid=playingSub.id;const ctx=getAudioContext();const tNode=activeTrackNodesRef.current[trackId];if(tNode&&tNode.gainNode){tNode.gainNode.gain.setValueAtTime(tNode.gainNode.gain.value||1,ctx.currentTime);tNode.gainNode.gain.linearRampToValueAtTime(0.001,ctx.currentTime+0.04);}setTimeout(()=>{fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();if(tNode&&tNode.gainNode){const trackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===trackId):null;const volDb=trackData?trackData.volumeDb??0:0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);tNode.gainNode.gain.setValueAtTime(0.001,ctx.currentTime);tNode.gainNode.gain.linearRampToValueAtTime(volLinear||0.8,ctx.currentTime+0.015);}const context=getAudioContext();const offset=playingSub.currentTime||0;startOffsetTimeRef.current=offset;startAudioTimeRef.current=context.currentTime;startBufferOffsetRef.current=offset*(playingSub.speed||1.0);schedulePianoRollMidi(playingSub,offset);startSubTabPlayback(playingSub,offset);setSubTabs(prev=>prev.map(s=>s.id===pid?{...s,isPlaying:true}:s));if(subTabsRef.current){subTabsRef.current=subTabsRef.current.map(s=>s.id===pid?{...s,isPlaying:true}:s);}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);},60);}setTimeout(()=>lucide.createIcons(),50);};const setTrackInstrument=(trackId,instrumentId,displayName)=>{setInstrumentDropdownTrackId(null);setInstrumentDropdownBtnRect(null);if(instrumentId&&instrumentId.startsWith('sf_')){// Set instrument on track immediately so Synth button shows the name
|
||
var mt2=activeTracksRef.current||tracks;var qTrk=null;for(var qi=0;qi<mt2.length;qi++){if(mt2[qi].id===trackId){qTrk=mt2[qi];break;}}var qch=qTrk?assignTrackMidiChannel(qTrk,mt2):0;updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const sfClean=instrumentId.replace('sf_','');const synthEngine={type:'soundfont',plugin_id:instrumentId,soundfont_bank:0,soundfont_program:0,soundfont_id:sfClean};return{...t,midiChannel:qch,instrumentId,instrumentProgram:undefined,instrumentName:displayName,synth_engine:synthEngine};}));setSelectedSoundFontId(instrumentId);setSynthCategory('soundfont');setInstrumentSelectorTrackId(trackId);setSfPresets(null);setSfPresetSearchQuery('');const sfIdParam=instrumentId.replace('sf_','');// Use cached presets from instrumentSelectorData
|
||
const cachedSf=(instrumentSelectorData?.soundfonts||[]).find(s=>s.id===instrumentId||s.id===sfIdParam);if(cachedSf&&cachedSf.presets){setSfPresets(cachedSf.presets);}else{window.SonicAPI.listSoundfontInstruments(sfIdParam).then(data=>setSfPresets(data.presets||[])).catch(e=>{console.error('listSoundfontInstruments failed:',e);setSfPresets([]);});}}else{setTrackInstrumentWithUndo(trackId,instrumentId,displayName);}};const[activeTool,setActiveTool]=useState('select');// 'select' | 'grab' | 'razor'
|
||
const[snapValue,onSnapChangeue]=useState('1');// 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32'
|
||
const snapTime=(time,snapValue,bpmVal)=>{if(snapValue==='free')return time;const beatDuration=60/parseFloat(bpmVal||120);let divisor=1;if(snapValue==='4')divisor=4;else if(snapValue==='1')divisor=1;else if(snapValue==='1/2')divisor=0.5;else if(snapValue==='1/4')divisor=0.25;else if(snapValue==='1/8')divisor=0.125;else if(snapValue==='1/16')divisor=0.0625;else if(snapValue==='1/32')divisor=0.03125;const gridSpacing=beatDuration*divisor;return Math.round(time/gridSpacing)*gridSpacing;};const snapValueRef=useRef(snapValue);snapValueRef.current=snapValue;const bpmRef=useRef(bpm);bpmRef.current=bpm;// BMP for Tempo Track - LOOP_EDITOR_2.md §6
|
||
const[selectedTrackId,setSelectedTrackId]=useState('1');const[selectedItemIds,setSelectedItemIds]=useState(new Set());const selectedItemIdsRef=useRef(new Set());selectedItemIdsRef.current=selectedItemIds;const[currentTime,setCurrentTime]=useState(0);const[isPlaying,setIsPlaying]=useState(false);// Ref theo dõi isPlaying — setTimeout VU trigger (main-midi delayed + section
|
||
// delayed) check ref này trước khi fire: stop → isPlaying=false → setTimeout
|
||
// sót không fire → VU không nhảy sau khi âm đã dừng (user: VU vẫn animation
|
||
// khi hết âm).
|
||
const isPlayingRef=useRef(false);isPlayingRef.current=isPlaying;_timelinePlaying=isPlaying;// Bounce realtime = project đang phát thật; guardPlay (scheduleMidiNoteDispatch)
|
||
// phải cho note fire dù isPlayingRef bị render ghi đè =false khi bounce setState.
|
||
// (Không sync từ state ở đây — isExporting khai báo sau; bounce set true/false
|
||
// tường minh ở triggerBounceExport/finally.)
|
||
const isExportingRef=useRef(false);const[selectionStart,setSelectionStart]=useState(null);const[selectionEnd,setSelectionEnd]=useState(null);const[selectionFollowsTempo,setSelectionFollowsTempo]=useState(true);const selectionRef=useRef({start:null,end:null});selectionRef.current={start:selectionStart,end:selectionEnd};const[selectionMode,setSelectionMode]=useState(null);// 'global' (from ruler) | 'local' (from track)
|
||
const[sweepSelect,setSweepSelect]=useState(null);const isSweepingRef=useRef(false);const sweepStartRef=useRef(0);const sweepTrackIdRef=useRef(null);const sweepStartYRef=useRef(0);const sweepEndYRef=useRef(0);const sweepSelectRef=useRef(null);const pendingDragRef=useRef(null);// { trackId, itemType, itemId, clickOffset, startX, startY }
|
||
const handleSectionItemDragStartRef=useRef(null);const[localSelectionTrackId,setLocalSelectionTrackId]=useState(null);const[localSelectionStart,setLocalSelectionStart]=useState(null);const[localSelectionEnd,setLocalSelectionEnd]=useState(null);const[zoom,setZoom]=useState(()=>{// Persist zoom qua reload (user yêu cầu giữ kích thước items sau refresh)
|
||
try{const v=parseFloat(localStorage.getItem('sf_zoom'));if(isFinite(v)&&v>0)return v;}catch(e){}return 100;});React.useEffect(()=>{try{localStorage.setItem('sf_zoom',String(zoom));}catch(e){}// Zoom TIMELINE (App) — serialize project dùng cái này (window.__currentZoom
|
||
// là zoom của Media Explorer — lưu nhầm làm project load ra zoom 1.0 →
|
||
// timeline thu nhỏ ở góc — user phải zoom in mới về đúng).
|
||
window.__sfTimelineZoom=zoom;},[zoom]);const[isLoopingSelection,setIsLoopingSelection]=useState(false);// Persist transport loop selection qua save/reload — serialize đọc từ window
|
||
// refs (module-level không truy cập state). Lưu cả vùng chọn (giây) để loop
|
||
// vùng chọn hoạt động lại đúng sau khi mở project.
|
||
React.useEffect(()=>{window.__sfLoopSelection=isLoopingSelection;},[isLoopingSelection]);React.useEffect(()=>{window.__sfSelectionStart=selectionStart;window.__sfSelectionEnd=selectionEnd;},[selectionStart,selectionEnd]);const[beginBar,setBeginBar]=useState(0);const[endBar,setEndBar]=useState(0);const[numberBar,setNumberBar]=useState(1);// Quét chọn (sweep — tempo track/timeline) → đồng bộ bars hiển thị dưới
|
||
// statusbar: beginBar = bar chứa selectionStart, endBar = bar chứa
|
||
// selectionEnd, numberBar = số bar được chọn. Trước đây beginBar/endBar/
|
||
// numberBar CHỈ đổi khi nhập tay input — quét không cập nhật → statusbar
|
||
// luôn hiển thị 0/0/1.
|
||
useEffect(()=>{if(selectionStart===null||selectionEnd===null||selectionEnd<=selectionStart)return;const _beatDur=60/(parseInt(bpm)||120);const _secPerBar=_beatDur*4;const _b1=Math.floor((selectionStart||0)/_secPerBar);// endBar = bar KẾT THÚC vùng quét (chạm vạch) — KHÔNG trừ 1: quét 8 bars
|
||
// từ bar 0 → hiển thị "0 đến 8" (user yêu cầu) — số bars = ceil − floor
|
||
// (8 − 0 = 8 — KHÔNG cộng 1).
|
||
const _b2=Math.ceil((selectionEnd||0)/_secPerBar);if(_b2>_b1){setBeginBar(_b1);setEndBar(_b2);setNumberBar(_b2-_b1);}},[selectionStart,selectionEnd,bpm]);const[subTabHeight,setSubTabHeight]=useState(96);const[isExporting,setIsExporting]=useState(false);const[exportProgress,setExportProgress]=useState(-1);const[projectName,setProjectName]=useState(()=>localStorage.getItem('sonic_project_name')||'');const[currentProjectId,setCurrentProjectId]=useState(()=>localStorage.getItem('sonic_project_id')||null);const[saveProjectModalOpen,setSaveProjectModalOpen]=useState(false);const[saveAsModalOpen,setSaveAsModalOpen]=useState(false);const[openProjectModalOpen,setOpenProjectModalOpen]=useState(false);const hasAnySolo=tracks.some(t=>t.solo);const[toastMessage,setToastMessage]=useState(null);const[audioDevices,setAudioDevices]=useState([]);const[midiDevices,setMidiDevices]=useState([]);const[selectedMidiInputId,setSelectedMidiInputId]=useState('');const selectedMidiInputIdRef=useRef('');const handleMidiInputSelect=id=>{setSelectedMidiInputId(id);selectedMidiInputIdRef.current=id;if(window.SonicRecorderManager){window.SonicRecorderManager.setSelectedMidiInputId(id);}};useEffect(()=>{if(navigator.mediaDevices&&navigator.mediaDevices.enumerateDevices){navigator.mediaDevices.enumerateDevices().then(devices=>{setAudioDevices(devices.filter(d=>d.kind==='audioinput'));}).catch(err=>console.log('Enumerate audio devices error:',err));}if(navigator.requestMIDIAccess){navigator.requestMIDIAccess().then(access=>{const inputs=[];function attachMidiHandler(input){input.onmidimessage=msg=>{// Filter by selected MIDI input device
|
||
const selId=selectedMidiInputIdRef.current;if(selId&&selId!=='ALL'&&input.id!==selId)return;console.log(`[DevLog] [Raw MIDI] Received from "${input.name}" (ID: ${input.id}) - Data:`,Array.from(msg.data));if(msg.data.length<3)return;const cmd=msg.data[0]>>4;const pitch=msg.data[1];const rawVel=msg.data[2];// Scale low MIDI velocity naturally (min 1, max 127)
|
||
const scaledVel=Math.min(127,Math.max(1,Math.round(rawVel)));// V45.21: ARM piano roll chi hieu luc khi dang o PIANO_ROLL tab
|
||
// (giong vkbNoteOn V45.19) — quay lai MAIN/SESSION khong leak preview.
|
||
const _prTabNow=(subTabsRef.current||[]).find(function(s){return s.id===activeTabRef.current;});const _isPrTabNow=!!(_prTabNow&&_prTabNow.type==='PIANO_ROLL');if(cmd===0x9&&rawVel>0){lastMidiNoteRef.current={pitch,velocity:scaledVel,startTime:performance.now(),length:0};setLastMidiNote({pitch,velocity:scaledVel,length:0,time:Date.now()});activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));// MIDI Keyboard → Mastering FX Chain (main out): đồng bộ routing
|
||
// mastering + SF NGAY trước khi phát — âm MIDI keyboard hardware
|
||
// PHẢI qua mastering FX khi chain đang bật (giống keybed; trước
|
||
// đây chỉ keybed ảo gọi __ensureMasteringRouting).
|
||
try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}// Route MIDI input to ALL armed tracks on their dedicated channels
|
||
// Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F)
|
||
if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){// D1: bridge active -> 1 cổng router (per-track channel alloc) →
|
||
// Rust SHM → C++ bridge; giữ SonicSF fallback ở nhánh else.
|
||
var bTracks=activeTracksRef.current||[];// ARM piano roll (sub-tab isArmed, cuc bo) cung tinh nhu ARM
|
||
// track - preview note tu piano roll tab phai toi track dang
|
||
// edit du track main KHONG ARM (ARM piano roll khong truyen).
|
||
var bPrArmed={};if(_isPrTabNow){try{// V45.27: chi sub-tab PR dang active VA co ARM (isArmed); khong gom ARM sub-tab khac / MAIN.
|
||
var _actPrSub=(subTabsRef.current||[]).find(function(s){return s.id===activeTabRef.current;});if(_actPrSub&&_actPrSub.type==='PIANO_ROLL'&&_actPrSub.isArmed&&_actPrSub.trackId)bPrArmed[_actPrSub.trackId]=true;}catch(e){}}bTracks.forEach(function(bt){if(_isPrTabNow?!bPrArmed[bt.id]:!bt.isArmed)return;// V45.24: PR preview doc lap voi mute/solo MAIN/SECTION —
|
||
// khoi phuc volume track (bo qua mute/solo) khi o PR tab.
|
||
if(_isPrTabNow){try{ensureTrackPrAudible(bt,bTracks,activeTrackNodesRef.current[bt.id]);}catch(e){}}if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(bt.id,scaledVel);}try{heldMidiNotesRef.current[bt.id]=(heldMidiNotesRef.current[bt.id]||0)+1;}catch(err){}try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:bt.id,pitch:pitch,velocity:scaledVel/127,percussion:!!(bt.synth_engine&&bt.synth_engine.soundfont_bank===128),live:true});}catch(e){}});}else if(window.SonicSF){var allTracks=activeTracksRef.current||[];var arSubs=[];// V45.27: chi sub-tab PR dang active VA co ARM (isArmed); khong gom ARM sub-tab khac / MAIN.
|
||
if(_isPrTabNow&&subTabsRef&&subTabsRef.current){var _actPrSub=subTabsRef.current.find(function(s){return s.id===activeTabRef.current;});if(_actPrSub&&_actPrSub.type==='PIANO_ROLL'&&_actPrSub.isArmed&&_actPrSub.trackId)arSubs.push(_actPrSub);}var armedTracks=_isPrTabNow?[]:allTracks.filter(function(t){return t.isArmed;});// Piano Roll arming has priority: route to each armed sub-tab's parent track
|
||
if(arSubs.length>0){arSubs.forEach(function(as){var asTrk=allTracks.find(function(t){return t.id===as.trackId;});// V45.24: PR preview doc lap voi mute/solo MAIN/SECTION.
|
||
if(_isPrTabNow&&asTrk){try{ensureTrackPrAudible(asTrk,allTracks,activeTrackNodesRef.current[as.trackId]);}catch(e){}}var asCh=asTrk?assignTrackMidiChannel(asTrk,allTracks):0;var asProg=as.instrumentProgram;var asSe=as.synth_engine;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(as.trackId,scaledVel);}// Giữ VU theo trường độ: tăng counter note đang giữ
|
||
// (tick giữ peak cho tới khi note-off)
|
||
try{heldMidiNotesRef.current[as.trackId]=(heldMidiNotesRef.current[as.trackId]||0)+1;}catch(err){}if(isStandaloneSf()&&isSfTrackEngine(asSe)){playNativeSfNote(asTrk,pitch,scaledVel/127,60000,undefined,'midi_'+as.trackId+'_'+pitch);}else if(isStandaloneSf()&&isVstTrackEngine(asSe)){playNativeVstNote(asTrk,pitch,scaledVel/127,60000,undefined,'midi_'+as.trackId+'_'+pitch);}else{window.SonicSF.playNote(pitch,scaledVel,60000,undefined,asProg,null,asCh,asSe);}});}// Route to ALL armed tracks (not just the first one)
|
||
armedTracks.forEach(function(at){// V45.24: PR preview doc lap voi mute/solo MAIN/SECTION.
|
||
if(_isPrTabNow){try{ensureTrackPrAudible(at,allTracks,activeTrackNodesRef.current[at.id]);}catch(e){}}var atCh=assignTrackMidiChannel(at,allTracks);var atProg=at.instrumentProgram;var atSe=at.synth_engine;var atDest=activeTrackNodesRef.current[at.id]?.gainNode||null;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(at.id,scaledVel);}// Giữ VU theo trường độ: tăng counter note đang giữ
|
||
try{heldMidiNotesRef.current[at.id]=(heldMidiNotesRef.current[at.id]||0)+1;}catch(err){}if(isStandaloneSf()&&isSfTrackEngine(atSe)){playNativeSfNote(at,pitch,scaledVel/127,60000,undefined,'midi_'+at.id+'_'+pitch);}else if(isStandaloneSf()&&isVstTrackEngine(atSe)){playNativeVstNote(at,pitch,scaledVel/127,60000,undefined,'midi_'+at.id+'_'+pitch);}else{window.SonicSF.playNote(pitch,scaledVel,60000,undefined,atProg,atDest,atCh,atSe);}});}}else if(cmd===0x8||cmd===0x9&&rawVel===0){activeMidiPitchesRef.current.delete(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));const current=lastMidiNoteRef.current;if(current&¤t.pitch===pitch){const lenSec=(performance.now()-current.startTime)/1000;lastMidiNoteRef.current={...current,length:lenSec};setLastMidiNote(prev=>prev&&prev.pitch===pitch?{...prev,length:lenSec,time:Date.now()}:prev);}// Stop the note on ALL tracks (not just armed) to prevent stuck notes
|
||
// when ARM is toggled off while a key is held
|
||
if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){// D1: bridge note-off qua router (channel trùng NOTE_ON — track.id).
|
||
var bStopTracks=activeTracksRef.current||[];bStopTracks.forEach(function(bst){try{if(heldMidiNotesRef.current[bst.id]!==undefined){heldMidiNotesRef.current[bst.id]=Math.max(0,heldMidiNotesRef.current[bst.id]-1);if(heldMidiNotesRef.current[bst.id]===0){delete heldMidiNotesRef.current[bst.id];}}}catch(err){}try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:bst.id,pitch:pitch,velocity:0,percussion:!!(bst.synth_engine&&bst.synth_engine.soundfont_bank===128),live:true});}catch(e){}});}else if(window.SonicSF&&window.SonicSF.stopNote){var stopTracks=activeTracksRef.current||[];stopTracks.forEach(function(st){// Only stop channels that actually carry this track's notes —
|
||
// an index-based fallback could hit another track's dedicated
|
||
// channel and kill its sound.
|
||
if(!st.synth_engine&&st.midiChannel===undefined)return;var stCh=assignTrackMidiChannel(st,stopTracks);if(isStandaloneSf())stopNativeSfNote('midi_'+st.id+'_'+pitch);stopNativeVstNote('midi_'+st.id+'_'+pitch);window.SonicSF.stopNote(stCh,pitch);// Giảm counter note đang giữ — hết note → VU được phép decay/tắt
|
||
// (tick sẽ thấy counter = 0 và không còn giữ peak nữa)
|
||
try{if(heldMidiNotesRef.current[st.id]!==undefined){heldMidiNotesRef.current[st.id]=Math.max(0,heldMidiNotesRef.current[st.id]-1);if(heldMidiNotesRef.current[st.id]===0){delete heldMidiNotesRef.current[st.id];}}}catch(err){}});}}// ── Sustain (CC64), Modulation (CC1), Pitch Bend ──
|
||
const midiCh=msg.data[0]&0x0F;if(cmd===0xB){// Controller Change: forward to ALL armed tracks' dedicated channels
|
||
const cc=msg.data[1];const val=msg.data[2];if(window.SonicSF&&window.SonicSF.controllerChange){var ccTracks=activeTracksRef.current||[];var hasArmed=ccTracks.some(function(t){return t.isArmed;});if(hasArmed){ccTracks.forEach(function(ct){if(!ct.isArmed)return;var ctCh=assignTrackMidiChannel(ct,ccTracks);window.SonicSF.controllerChange(ctCh,cc,val);});}else{window.SonicSF.controllerChange(midiCh,cc,val);}}}else if(cmd===0xE){// Pitch Bend: forward to ALL armed tracks' dedicated channels
|
||
const lsb=msg.data[1];const msb=msg.data[2];const bendVal=msb<<7|lsb;if(window.SonicSF&&window.SonicSF.pitchBend){var pbTracks=activeTracksRef.current||[];var hasArmedPB=pbTracks.some(function(t){return t.isArmed;});if(hasArmedPB){pbTracks.forEach(function(pt){if(!pt.isArmed)return;var ptCh=assignTrackMidiChannel(pt,pbTracks);window.SonicSF.pitchBend(ptCh,bendVal);});}else{window.SonicSF.pitchBend(midiCh,bendVal);}}}// Forward to active MIDI recorders
|
||
if(activeMIDIRecordersRef.current){for(let trackId in activeMIDIRecordersRef.current){const rec=activeMIDIRecordersRef.current[trackId];if(rec){rec.handleMIDIMessage(msg,input.id);}}}};}for(let input of access.inputs.values()){inputs.push(input);attachMidiHandler(input);}setMidiDevices(inputs);access.onstatechange=()=>{const inputs=[];for(let input of access.inputs.values()){inputs.push(input);// Re-attach handler to ensure new devices get it
|
||
if(!input.onmidimessage){attachMidiHandler(input);}}setMidiDevices(inputs);};}).catch(err=>console.log('MIDI access error:',err));}},[]);const[showAIConfig,setShowAIConfig]=useState(false);const[recordingState,setRecordingState]=useState('IDLE');// 'IDLE' | 'COUNT_IN' | 'RECORDING'
|
||
const[recTempMidiNotes,setRecTempMidiNotes]=useState([]);const[recTempAudioBuffer,setRecTempAudioBuffer]=useState(null);const[recStartTimelineTime,setRecStartTimelineTime]=useState(0);const[canvasRedrawCount,setCanvasRedrawCount]=useState(0);const[lastMidiNote,setLastMidiNote]=useState(null);const lastMidiNoteRef=useRef(null);const activeMIDIRecordersRef=useRef({});const activeAudioRecordersRef=useRef({});const pianoRollRecorderRef=useRef(null);const activeMidiPitchesRef=useRef(new Set());const timelinePitchesRef=useRef({});// D18c: per-track Set timeline MIDI pitches dang keu (NOTE_ON fired)
|
||
const tracksRef=useRef([]);// D18c: tracks state moi nhat (guard mute/solo fire-time)
|
||
const[activeMidiPitches,setActiveMidiPitches]=useState(new Set());const recordingPCMDataRef=useRef({});const recordingStartTimeRef=useRef(0);const recordingSyncRef=useRef(null);const recordingStateRef=useRef(recordingState);recordingStateRef.current=recordingState;const lastTempCompileTimeRef=useRef(0);const nextMetronomeBeatRef=useRef(0);const[showExportPanel,setShowExportPanel]=useState(false);const[showExportProgress,setShowExportProgress]=useState(false);// modal tiến trình export RIÊNG
|
||
React.useEffect(()=>{if(showExportPanel)setTimeout(()=>{try{window.lucide&&window.lucide.createIcons();}catch(e){}},80);},[showExportPanel]);// ── Virtual MIDI Keyboard (Phase 5) ─────────────────────────────────────
|
||
const[vkbOpen,setVkbOpen]=useState(false);const vkbOpenRef=useRef(false);vkbOpenRef.current=vkbOpen;const[chordsPanelOpen,setChordsPanelOpen]=useState(false);const vkbHeldRef=useRef(new Set());const[vkbSettings,setVkbSettings]=useState(function(){try{const saved=JSON.parse(localStorage.getItem('sonic_vkb_settings_v1')||'null');if(saved&&typeof saved==='object')return Object.assign({x:40,y:120,octave:4,velocity:100,channel:null,transpose:0,scale:'None'},saved);}catch(e){}return{x:40,y:120,octave:4,velocity:100,channel:null,transpose:0,scale:'None'};}());const vkbSettingsRef=useRef(vkbSettings);vkbSettingsRef.current=vkbSettings;const vkbNoteOnRef=useRef(null);const vkbNoteOffRef=useRef(null);const vkbUpdateSettings=patch=>{setVkbSettings(prev=>{const next=Object.assign({},prev,patch);try{localStorage.setItem('sonic_vkb_settings_v1',JSON.stringify(next));}catch(e){}return next;});};// Global keydown: F2 toggle + QWERTY notes khi VKB mở. capture:true + đăng ký
|
||
// TRƯỚC handler transport (~19402) → stopPropagation chặn phím tắt trùng
|
||
// (s=split, v/g sub-tab) khi VKB đang mở; KHÔNG chặn Space/R. Note KHÔNG bắn
|
||
// khi focus trong input/textarea (pass-through gõ văn bản bình thường).
|
||
React.useEffect(()=>{const VKB_MAP={z:0,s:1,x:2,d:3,c:4,v:5,g:6,b:7,h:8,n:9,j:10,m:11,q:12,'2':13,w:14,'3':15,e:16,r:17,'5':18,t:19,'6':20,y:21,'7':22,u:23,i:24};const handler=e=>{if(e.type==='keydown'&&e.key==='F2'){e.preventDefault();if(window.__toggleVkb)window.__toggleVkb();else setVkbOpen(p=>!p);return;}if(!vkbOpenRef.current)return;const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable))return;if(e.ctrlKey||e.metaKey||e.altKey)return;const k=e.key&&e.key.length===1?e.key.toLowerCase():null;if(k===null||!(k in VKB_MAP))return;const s=vkbSettingsRef.current;const offset=VKB_MAP[k];const pitch=Math.max(0,Math.min(127,(s.octave+1)*12+offset+(parseInt(s.transpose)||0)));if(e.type==='keydown'){if(e.repeat)return;e.preventDefault();// stopImmediatePropagation (không chỉ stopPropagation): chặn CÁC
|
||
// handler capture khác CÙNG window (transport hotkeys ~20193 — phím v
|
||
// = Gain, s = split, ...) — stopPropagation chỉ chặn propagation tới
|
||
// element khác, không chặn listener cùng node cùng phase (bug user:
|
||
// VKB mở, phím V vẫn bật prompt Gain).
|
||
e.stopImmediatePropagation();const vel=Math.max(1,Math.min(127,parseInt(s.velocity)||100))/127;if(vkbNoteOnRef.current)vkbNoteOnRef.current(pitch,vel);}else{e.preventDefault();e.stopImmediatePropagation();if(vkbNoteOffRef.current)vkbNoteOffRef.current(pitch);}};window.addEventListener('keydown',handler,{capture:true});window.addEventListener('keyup',handler,{capture:true});return()=>{window.removeEventListener('keydown',handler,{capture:true});window.removeEventListener('keyup',handler,{capture:true});};},[]);const[showAIPanel,setShowAIPanel]=useState(true);const[showSelectionPanel,setShowSelectionPanel]=useState(false);const[showPythonToolsPanel,setShowPythonToolsPanel]=useState(false);const[showMediaExplorer,setShowMediaExplorer]=useState(false);const[scrollBufferExtra,setScrollBufferExtra]=useState(0);const scrollBufferExtraRef=useRef(0);scrollBufferExtraRef.current=scrollBufferExtra;const[showFxRack,setShowFxRack]=useState(false);const[showMidiEvents,setShowMidiEvents]=useState(false);const[showMixer,setShowMixer]=useState(false);const setShowMixerRef=useRef(setShowMixer);setShowMixerRef.current=setShowMixer;const setShowMediaExplorerRef=useRef(setShowMediaExplorer);setShowMediaExplorerRef.current=setShowMediaExplorer;// F6/F7 mutual exclusion: enabling one panel disables the other
|
||
window.__toggleMixerRef=function(){// M22 Bug B diag: F7 mở/đóng MIXER PANEL (user: mở panel → âm lượng giảm
|
||
// nhỏ; M20 chỉ instrument MasteringModal panel_open — F7 đi đường khác nên
|
||
// chưa có dB). Chụp dB master OUT + hist + post như panel_open.
|
||
try{_mdiagSample('mixer_toggle');_mdiagDump();_mdiagSchedulePost();}catch(e){}setShowMixer(function(p){const next=!p;if(next)setShowMediaExplorerRef.current(false);return next;});};// Live per-track bypass (split into two buttons per user request):
|
||
// - 'audio': bypass FX+mastering for AUDIO items (audio clips + section items)
|
||
// - 'midi' : bypass FX+mastering for MIDI items (soundfont instrument)
|
||
window.__setTrackBypass=function(trackId,which,on){if(which==='audio'){trackAudioBypassMap[trackId]=!!on;trackMasteringBypassMap[trackId]=!!on;// Live re-route the main track node AND every section sub-node of this
|
||
// track (keys "<parent>_sub_<trackId>") so section items bypass instantly.
|
||
Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k===trackId||k.endsWith('_sub_'+trackId)){const n=activeTrackNodesRef.current[k];if(n&&n.route)setMasteringRoute(n.route,!!on);}});}else if(which==='midi'){trackMidiBypassMap[trackId]=!!on;// ♪ bypasses Mastering FX Chain for the soundfont ONLY — the SF path
|
||
// keeps flowing through its own sfModules (track FX Rack, PWR-controlled).
|
||
const node=activeTrackNodesRef.current[trackId];if(node&&node.sfRouteGain&&node.sfDryGain){setMasteringRoute({routeGain:node.sfRouteGain,dryGain:node.sfDryGain,_trackId:trackId},!!on);}// SECTION: nút ♪ cũng toggle route của sub-node MIDI (key <parent>_sub_<trackId>)
|
||
Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k.endsWith('_sub_'+trackId)){const sn=activeTrackNodesRef.current[k];if(sn&&sn.route)setMasteringRoute(sn.route,!!on);}});try{if(updateSfRoutingRef.current)updateSfRoutingRef.current();}catch(e){}}};// Legacy single-button bypass (kept for backward compatibility): sets both.
|
||
window.__setTrackMasteringBypass=function(trackId,bypass){trackAudioBypassMap[trackId]=!!bypass;trackMasteringBypassMap[trackId]=!!bypass;trackMidiBypassMap[trackId]=!!bypass;const node=activeTrackNodesRef.current[trackId];if(node&&node.route)setMasteringRoute(node.route,!!bypass);try{if(updateSfRoutingRef.current)updateSfRoutingRef.current();}catch(e){}};// Realtime mute/solo: applies the (patched) mute/solo state to every active
|
||
// track node in the current context. Called from the M/S buttons of the
|
||
// mixer strips + track strips so toggling muting/soloing affects the items
|
||
// already playing on that track immediately (20ms crossfade, no click).
|
||
const applyAllTrackMuteSolo=(trackId,patch)=>{const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const effective=trackId&&patch?list.map(t=>t.id===trackId?{...t,...patch}:t):list;// V45.3: LUON ghi ref voi state da patch (mute/solo). Truoc day chi ghi trong
|
||
// nhanh becameAudible roi restore STALE -> solo-out khong co track became
|
||
// audible -> D18b guard (25423) + scheduler (25498) + toggleTrackSoloEvaluate
|
||
// (setTimeout 60ms restart) thay trang thai PRE-solo -> Qin note van fire
|
||
// (tremolo). Render ke tiep tu dong dong bo lai ref.
|
||
activeTracksRef.current=effective;try{if(window.__dbgLog)window.__dbgLog('solo_apply','track='+trackId+' patch='+(patch?JSON.stringify(patch):'-')+' n='+effective.length+' solo=['+effective.filter(function(t){return t.solo;}).map(function(t){return t.id;}).join(',')+'] muted=['+effective.filter(function(t){return t.muted;}).map(function(t){return t.id;}).join(',')+']');}catch(e){}let becameAudible=false;effective.forEach(t=>{const audible=computeTrackAudibleGain(effective,t)>0;const wasAudible=trackAudibleRef.current[t.id]!==false;const node=activeTrackNodesRef.current[t.id];const audibleGain=computeTrackAudibleGain(effective,t);// V45.24: PR tab doc lap voi mute/solo MAIN/SECTION — track dang co
|
||
// sub-tab PIANO_ROLL PLAY thi KHONG ap mute/solo realtime (gain=0,
|
||
// CC7=0, kill note) de PR khong bi cam; mute/solo chi anh huong
|
||
// MAIN/SECTION playback.
|
||
const prPlayingForTrack=!!(subTabsRef.current||[]).some(function(s){return s.type==='PIANO_ROLL'&&s.trackId===t.id&&s.isPlaying;});const _volDb=Number(t.volumeDb);const _volLin=isNaN(_volDb)||!isFinite(_volDb)?1.0:_volDb<=-50?0:Math.pow(10,_volDb/20);const effGain=prPlayingForTrack?_volLin:audibleGain;if(node)setTrackNodeGain(node,effGain);// Section sub-nodes (key: <parent>_sub_<trackId>) — mute/solo của
|
||
// SECTION-TAB phải áp gain cho node play section — CHỈ trong section
|
||
// (activeTracksRef = tracks section), KHÔNG đụng track MAIN.
|
||
Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k.endsWith('_sub_'+t.id)){const sn=activeTrackNodesRef.current[k];if(sn&&sn.gainNode)setTrackNodeGain(sn,audibleGain);}});if(node&&node.sfEntry){try{node.sfEntry.gain.setTargetAtTime(effGain,getAudioContext().currentTime,0.02);}catch(e){}}// MIDI/bridge tracks: CC7 (channel volume) realtime — even for notes
|
||
// already sounding. Bridge active → qua SonicMidiRouter (bridge); bridge
|
||
// down → SonicSF (WASM). CC7 = VOLUME của track (không chỉ 100/0).
|
||
if(isMidiSoundTrack(t)){try{sendMidiVolumeCC7(t,effective,audible||prPlayingForTrack);}catch(e){}}// D18: track vừa MẤT tiếng (mute/solo) — dừng note đang giữ ngay. CC7=0
|
||
// (sendMidiVolumeCC7) không ăn với VST như Qin (bỏ qua channel volume)
|
||
// → note kêu tiếp (tremolo) khi solo track khác. NOTE_OFF từng pitch đang
|
||
// giữ + CC123/CC120 all-notes-off trên channel track đó.
|
||
if(!audible&&wasAudible&&!prPlayingForTrack&&isMidiSoundTrack(t)){try{const _isPerc=!!(t.synth_engine&&t.synth_engine.soundfont_bank===128)||!!t.is_percussion;try{if(window.__dbgLog)window.__dbgLog('solo_kill','track='+t.id+' tset='+((timelinePitchesRef.current||{})[t.id]||new Set()).size+' held='+(activeMidiPitchesRef.current||new Set()).size);}catch(e){}const _held=activeMidiPitchesRef.current||new Set();// D18c: timeline MIDI notes (per-track) dang keu - NOTE_OFF tung pitch;
|
||
// truoc day chi kill preview keyboard (activeMidiPitchesRef) -> note
|
||
// timeline (Qin VST2) cu ngan/loop (tremolo) khi solo track khac.
|
||
const _tSet=(timelinePitchesRef.current||{})[t.id];if(_tSet&&_tSet.size){_tSet.forEach(function(_p){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:t.id,pitch:_p,velocity:0,percussion:_isPerc,live:true});});}_held.forEach(function(_p){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:t.id,pitch:_p,velocity:0,percussion:_isPerc,live:true});});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:t.id,pitch:123,velocity:0,data2:0,percussion:_isPerc});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:t.id,pitch:120,velocity:0,data2:0,percussion:_isPerc});if(heldMidiNotesRef.current)delete heldMidiNotesRef.current[t.id];if(timelinePitchesRef.current)delete timelinePitchesRef.current[t.id];}catch(e){}}if(audible&&!wasAudible)becameAudible=true;trackAudibleRef.current[t.id]=prPlayingForTrack?true:audible;});// A track that just became audible may have had its audio sources dropped
|
||
// by a loop restart while it was inaudible (startTrackPlayback skips
|
||
// muted/soloed tracks, so unmute alone cannot resurrect them). Re-schedule
|
||
// from the current playhead — the same approach toggleTrackSoloEvaluate
|
||
// already uses for solo.
|
||
if(becameAudible&&isPlaying&&recordingStateRef.current!=='RECORDING'&&activeTracksRef.current&&activeTracksRef.current.length){try{fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();const ctx=getAudioContext();const resumeAt=currentTimeRef.current||0;startOffsetTimeRef.current=resumeAt;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=resumeAt;setIsPlaying(true);// V22: activeTracksRef.current vẫn STALE (React chưa re-render trong
|
||
// click handler) → track vừa unmute vẫn thấy muted=true → bị skip ở
|
||
// startTrackPlayback (isPlayable) → câm + CC11 không chạy. Dùng
|
||
// `effective` (đã patch mute/solo) làm nguồn restart; ref tự phục hồi
|
||
// ở render kế tiếp (activeTracksRef.current = activeTracks).
|
||
// V45.3: ref da = effective (dau ham) -> restart thay trang thai moi ngay.
|
||
startTrackPlayback(resumeAt);}catch(e){console.warn('mute/solo resume error:',e);}}updateSfRouting();};window.__applyTrackMuteSolo=applyAllTrackMuteSolo;window.__toggleMediaExplorerRef=function(){setShowMediaExplorer(function(p){const next=!p;if(next)setShowMixerRef.current(false);return next;});};const[mixerHeight,setMixerHeight]=useState(function(){var saved=localStorage.getItem('studio_mixer_height');return saved?parseInt(saved):200;}());const[mediaExplorerPanelHeight,setMediaExplorerPanelHeight]=useState(function(){var saved=localStorage.getItem('studio_media_explorer_height');return saved?parseInt(saved):240;}());const[masterVolume,setMasterVolume]=useState(0);// dB (legacy single fader)
|
||
const[masterVolL,setMasterVolL]=useState(0);// dB - master L fader
|
||
const[masterVolR,setMasterVolR]=useState(0);// dB - master R fader
|
||
const[masterLink,setMasterLink]=useState(true);// LINK L/R
|
||
const[masterMuted,setMasterMuted]=useState(false);// MUTE master
|
||
const[masterAutoVol,setMasterAutoVol]=useState([]);// Gap 7: master volume automation [{time s, db}]
|
||
const[masterAutoOn,setMasterAutoOn]=useState(false);// Gap 7: automation active
|
||
React.useEffect(()=>{window.__masterAutoVol=masterAutoVol;window.__masterAutoOn=masterAutoOn;},[masterAutoVol,masterAutoOn]);// M23 Bug B fix: master fader ap NGAY moi luc (main out) — khong cho Mixer
|
||
// panel mount (MasterStripConsole). State global de initMasterBus ap lai.
|
||
React.useEffect(()=>{window.__masterVolState={volL:masterVolL,volR:masterVolR,muted:!!masterMuted};const _mb=masterBus||window.masterBus;if(_mb&&_mb.volLGain&&typeof audioCtx!=='undefined'&&audioCtx){try{applyMasterVolumesTo(_mb,masterVolL,masterVolR,!!masterMuted);}catch(e){}}},[masterVolL,masterVolR,masterMuted]);React.useEffect(()=>{if(isPlaying&&typeof applyMasterAutomationSchedule==='function'){try{applyMasterAutomationSchedule(currentTimeRef.current||0);}catch(e){console.warn('[Auto] reschedule error:',e);}}},[masterAutoVol,masterAutoOn,isPlaying]);const[masterVU,setMasterVU]=useState(0);// 0-1
|
||
const[masterMeterPeak,setMasterMeterPeak]=useState(0);const[rightSidebarWidth,setRightSidebarWidth]=useState(320);const[tcpWidth,setTcpWidth]=useState(320);const[mediaExplorerHeight,setMediaExplorerHeight]=useState(50);const[panelPositions,setPanelPositions]=useState({export:'bottom',ai:'right',python_tools:'bottom',selection:'bottom',media_explorer:'bottom',fx_rack:'bottom',midi_events:'bottom'});const[panelDropZone,setPanelDropZone]=useState(null);const[dragGhostPos,setDragGhostPos]=useState(null);const[dragGhostPanel,setDragGhostPanel]=useState(null);const panelDragRef=useRef(null);const trackVuRefs=useRef({});const workspaceRef=useRef(null);const colResizerRef=useRef(null);const rowResizerRef=useRef(null);const[aiConfig,setAiConfig]=useState({baseUrl:localStorage.getItem('ai_base_url')||`${API_BASE_URL}`,apiKey:localStorage.getItem('ai_api_key')||'',model:localStorage.getItem('ai_model')||'deepseek-chat'});const[aiProviders,setAiProviders]=useState([]);useEffect(()=>{(async()=>{try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){/* server may not have config endpoint */}})();},[]);const[analysisState,setAnalysisState]=useState({status:'Sẵn sàng. Chạy AI để phân tích nhịp.',data:null,isRunning:false});const[aiPrompt,setAiPrompt]=useState('');const[aiComposeMode,setAiComposeMode]=useState('SIMILAR_VARIATION');const[promptHistory,setPromptHistory]=useState([]);const[promptHistIdx,setPromptHistIdx]=useState(-1);const promptHistRef=useRef([]);const aiPromptUndoRef=useRef({stack:[],idx:-1,max:30});const aiPromptUndoPush=text=>{const u=aiPromptUndoRef.current;u.stack.push(text);if(u.stack.length>u.max)u.stack.shift();u.idx=u.stack.length-1;};const[aiProvider,setAiProvider]=useState('OpenAI');const[aiModel,setAiModel]=useState('GPT-4o');const[aiActionLog,setAiActionLog]=useState([]);const actionLogContainerRef=useRef(null);useEffect(()=>{if(actionLogContainerRef.current){actionLogContainerRef.current.scrollTop=actionLogContainerRef.current.scrollHeight;}},[aiActionLog]);const[aiProcessing,setAiProcessing]=useState(false);const[aiSuggestions,setAiSuggestions]=useState([]);const[showAiTypeahead,setShowAiTypeahead]=useState(false);const[showAISuggestions,setShowAISuggestions]=useState(true);const[showAIActionLog,setShowAIActionLog]=useState(false);const aiPromptMgrRef=useRef(null);const aiTypeaheadRef=useRef(null);const[selectedProviderId,setSelectedProviderId]=useState('');const[exportSettings,setExportSettings]=useState(()=>{let expDir='',remExpDir=false;try{expDir=localStorage.getItem('sonic_export_dir')||'';}catch(e){}try{remExpDir=localStorage.getItem('sonic_remember_export_dir')==='1';}catch(e){}return{sampleRate:'44100',bitDepth:'16',format:'wav',source:'project',quality:'44khz',channels:'stereo',loop:false,loopBars:4,dither:false,normalize:true,normalizeTarget:'-14lufs',monoCheck:true,realtime:false,// Gap 13: BWF/INFO metadata cho WAV mastering export
|
||
metaTitle:'',metaArtist:'',metaIsrc:'',// Export file + thư mục (ExportModal) — dir lưu localStorage nếu remember
|
||
fileName:'',exportDir:expDir,rememberExportDir:remExpDir};});// Persist export dir mặc định (chỉ khi user bật remember) — ExportModal
|
||
useEffect(()=>{try{if(exportSettings.rememberExportDir&&exportSettings.exportDir){localStorage.setItem('sonic_export_dir',exportSettings.exportDir);}else{localStorage.removeItem('sonic_export_dir');}localStorage.setItem('sonic_remember_export_dir',exportSettings.rememberExportDir?'1':'0');}catch(e){}},[exportSettings.exportDir,exportSettings.rememberExportDir]);const[serverStatus,setServerStatus]=useState('checking...');const _lan=window.SonicRuntime&&window.SonicRuntime.capabilities&&window.SonicRuntime.capabilities.lan;const serverAddr=_lan&&_lan.urls&&_lan.urls[0]?function(){try{return new URL(_lan.urls[0]).host;}catch(e){return null;}}():window.location.host;const[menuOpen,setMenuOpen]=useState(null);const[selectedClipId,setSelectedClipId]=useState(null);// { trackId, clipId }
|
||
const[stretchedClip,setStretchedClip]=useState(null);// { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap }
|
||
const[draggedSectionItem,setDraggedSectionItem]=useState(null);const[resizedSectionItem,setResizedSectionItem]=useState(null);const[editingTrackName,setEditingTrackName]=useState(null);// trackId being edited
|
||
const[editingClipName,setEditingClipName]=useState(null);// { trackId, clipId }
|
||
const[editNameInput,setEditNameInput]=useState('');// D16: per-track loading ruler — ticker buộc re-render khi có track đang
|
||
// loading; poller queryStatus chuyển loading→done khi channel sẵn sàng
|
||
// (đúng cho cả C++ restore từ spawn lẫn JS load — không phụ thuộc thứ tự).
|
||
const[loadTick,setLoadTick]=useState(0);useEffect(()=>{window.__onTrackLoadState=()=>setLoadTick(t=>t+1);const iv=setInterval(async()=>{try{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return;const m=window.__trackLoadState||{};const loadingKeys=Object.keys(m).filter(k=>m[k].state==='loading');if(!loadingKeys.length)return;const st=await window.NativeBridgeService.queryStatus();const lc=st&&Array.isArray(st.loaded_channels)?st.loaded_channels:null;let changed=false;loadingKeys.forEach(k=>{const s=m[k];if(lc&&s.channel!=null&&lc.some(x=>x.channel===s.channel)){m[k]=Object.assign({},s,{state:'done',doneTs:Date.now()});changed=true;}else if(!s.startedTs||Date.now()-s.startedTs>90000){m[k]=Object.assign({},s,{state:'failed'});changed=true;}});if(changed)setLoadTick(t=>t+1);}catch(e){}},400);return()=>{delete window.__onTrackLoadState;clearInterval(iv);};},[]);// ── Context Menu & Clipboard ──
|
||
const[contextMenu,setContextMenu]=useState(null);// { x, y, trackId }
|
||
// Gợi ý động PIANO ROLL trên status bar (user 09:40) — piano roll set qua
|
||
// window.__setPrHint (mouse in/out + Shift/Ctrl state)
|
||
const[prHint,setPrHint]=useState(null);React.useEffect(()=>{window.__setPrHint=h=>setPrHint(h||null);return()=>{delete window.__setPrHint;};},[]);const clipboardRef=useRef(null);// { buffer, name, volume, color } for copy/paste
|
||
// ── Undo/Redo Engine (LOOP_EDITOR.md §4 + Global Extension) ──
|
||
const[undoStack,setUndoStack]=useState([]);const[redoStack,setRedoStack]=useState([]);const MAX_UNDO=30;const pushAction=(actionType,trackId,beforeState,afterState)=>{const node={action_type:actionType,track_id:trackId,timestamp:Date.now(),before_state:beforeState,after_state:afterState};setUndoStack(prev=>{const next=[...prev,node];if(next.length>MAX_UNDO)next.shift();return next;});setRedoStack([]);};const handleUndo=()=>{if(window.UndoRedoEngine&&window.UndoRedoEngine.canUndo()){const entry=window.UndoRedoEngine.undo();if(entry){if(entry.undo&&typeof entry.undo==='function')entry.undo(entry);showToast(`Undo: ${entry.label||entry.type}`,'info');return;}}if(undoStack.length===0)return;const last=undoStack[undoStack.length-1];setUndoStack(prev=>prev.slice(0,-1));setRedoStack(prev=>[...prev,last]);applyTrackState(last.track_id,last.before_state);showToast(`Undo: ${last.action_type}`,'info');};const handleRedo=()=>{if(window.UndoRedoEngine&&window.UndoRedoEngine.canRedo()){const entry=window.UndoRedoEngine.redo();if(entry){if(entry.redo&&typeof entry.redo==='function')entry.redo(entry);showToast(`Redo: ${entry.label||entry.type}`,'info');return;}}if(redoStack.length===0)return;const last=redoStack[redoStack.length-1];setRedoStack(prev=>prev.slice(0,-1));setUndoStack(prev=>[...prev,last]);applyTrackState(last.track_id,last.after_state);showToast(`Redo: ${last.action_type}`,'info');};const applyTrackState=(trackId,state)=>{if(trackId==='ALL_TRACKS'){state.forEach(entry=>{applyTrackState(entry.trackId,entry.state);});return;}updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;var updated={...t,...state};if(state.sections!==undefined){updated.sections=state.sections;}if(state.midiItems!==undefined){updated.midiItems=state.midiItems;}if(state.clips!==undefined){updated.clips=state.clips;}return updated;}));};const getSelectedMidiItemInfo=()=>{if(!selectedItemIds||selectedItemIds.size!==1)return null;const selId=selectedItemIds.values().next().value;const tlist=activeTracks||tracks||[];for(const t of tlist){const found=(t.midiItems||[]).find(m=>m.id===selId);if(found)return{itemName:found.name,trackName:t.name,trackId:t.id,itemId:selId,notes:found.notes||[],startTime:found.startTime||0,duration:found.duration||4};}return null;};const captureTrackSnapshot=trackId=>{const track=activeTracks.find(t=>t.id===trackId);if(!track)return null;return{volumeDb:track.volumeDb,pan:track.pan,muted:track.muted,name:track.name,markers:JSON.parse(JSON.stringify(track.markers||[])),buffer:track.buffer,startTime:track.startTime||0,clips:track.clips?track.clips.map(c=>({id:c.id,buffer:c.buffer,startTime:c.startTime,name:c.name})):null,sections:track.sections?track.sections.map(function(s){return{id:s.id,start:s.start,duration:s.duration,name:s.name,color:s.color,notes:s.notes?s.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null,tracks:s.tracks?s.tracks.map(function(st){return{id:st.id,name:st.name,color:st.color,clips:st.clips?st.clips.map(function(c){return{id:c.id,startTime:c.startTime,name:c.name,speed:c.speed,buffer:c.buffer};}):null,midiItems:st.midiItems?st.midiItems.map(function(m){return{id:m.id,startTime:m.startTime,duration:m.duration,name:m.name,notes:m.notes?m.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null};}):null};}):null};}):null,midiItems:track.midiItems?track.midiItems.map(function(m){return{id:m.id,startTime:m.startTime,duration:m.duration,name:m.name,notes:m.notes?m.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null};}):null};};const captureAllTracksSnapshot=()=>{const curTracks=activeTracksRef.current||activeTracks||[];return curTracks.map(t=>({trackId:t.id,state:captureTrackSnapshot(t.id)}));};const setBpmWithUndo=newBpm=>{const oldBpm=bpmRef.current;if(String(oldBpm)===String(newBpm))return;const entry={type:'SET_BPM',scope:'global',label:`BPM ${oldBpm} → ${newBpm}`,before:oldBpm,after:newBpm,undo:e=>{setBpm(e.before);showToast(`Undo: BPM → ${e.before}`,'info');},redo:e=>{setBpm(e.after);showToast(`Redo: BPM → ${e.after}`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setBpm(String(newBpm));};const setPlayheadWithUndo=newTime=>{const oldTime=currentTime;if(Math.abs(oldTime-newTime)<0.001)return;const entry={type:'SET_PLAYHEAD',scope:'global',label:`Playhead ${formatTimeSimple(oldTime)} → ${formatTimeSimple(newTime)}`,before:oldTime,after:newTime,undo:e=>{applyPlayheadDirect(e.before);showToast(`Undo: Playhead`,'info');},redo:e=>{applyPlayheadDirect(e.after);showToast(`Redo: Playhead`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);applyPlayheadDirect(newTime);};const applyPlayheadDirect=time=>{localSelectionAnchorRef.current=time;if(isPlaying){// V45.17: seek khi đang play — startOffset + freeze NGAY: playhead nhảy
|
||
// tới click + đứng tại đó (không chạy theo processed anchor cũ 50ms).
|
||
startOffsetTimeRef.current=time;setCurrentTime(time);fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
fxRtSeekRestartFlag=true;// V45.18: seek khi play — startTrackPlayback clear_queue mode 'seek' (wipe queue cu + resume nhanh)
|
||
stopAllPlayback(false,true);// V45.21: skipRouting nhu seekPlaybackTo (V45.10)
|
||
try{if(window.__fxRtPlayheadFreeze)window.__fxRtPlayheadFreeze(true);}catch(e){}setTimeout(()=>{startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(time);setIsPlaying(true);},50);}else{setCurrentTime(time);}};const setSelectionWithUndo=(newStart,newEnd,mode)=>{const oldStart=selectionRef.current.start;const oldEnd=selectionRef.current.end;const oldMode=selectionMode;if(oldStart===newStart&&oldEnd===newEnd&&oldMode===mode)return;const entry={type:'SET_SELECTION',scope:'global',label:`Selection`,before:{start:oldStart,end:oldEnd,mode:oldMode},after:{start:newStart,end:newEnd,mode:mode},undo:e=>{setSelectionStart(e.before.start);setSelectionEnd(e.before.end);setSelectionMode(e.before.mode);showToast(`Undo: Selection`,'info');},redo:e=>{setSelectionStart(e.after.start);setSelectionEnd(e.after.end);setSelectionMode(e.after.mode);showToast(`Redo: Selection`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setSelectionStart(newStart);setSelectionEnd(newEnd);if(mode!==undefined)setSelectionMode(mode);};const setSelectedItemsWithUndo=newSet=>{const oldSet=selectedItemIdsRef.current;if(oldSet&&newSet&&oldSet.size===newSet.size&&[...oldSet].every(x=>newSet.has(x)))return;const entry={type:'SELECT_ITEMS',scope:'global',label:`Selection`,before:[...oldSet],after:[...newSet],undo:e=>{setSelectedItemIds(new Set(e.before));showToast(`Undo: Selection`,'info');},redo:e=>{setSelectedItemIds(new Set(e.after));showToast(`Redo: Selection`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setSelectedItemIds(newSet);};const setAiPromptWithUndo=newText=>{const oldText=aiPrompt;if(oldText===newText)return;const entry={type:'SET_AI_PROMPT',scope:'global',label:`AI Prompt`,before:oldText,after:newText,undo:e=>{setAiPrompt(e.before);showToast(`Undo: AI Prompt`,'info');},redo:e=>{setAiPrompt(e.after);showToast(`Redo: AI Prompt`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setAiPrompt(newText);};// Gán preset VST3 (.vstpreset từ thư viện storage/presets) vào track:
|
||
// preset_id nằm trong synth_engine → render engine tải qua load_preset khi
|
||
// render → âm render = âm đã chỉnh trong preset.
|
||
const setTrackPreset=(trackId,presetId)=>{if(!trackId||!presetId)return;var mt=activeTracksRef.current||tracks;var cur=null;for(var ci=0;ci<mt.length;ci++){if(mt[ci].id===trackId){cur=mt[ci];break;}}if(!cur)return;var se=cur.synth_engine||{};if(!se.plugin_id||String(se.type||'').indexOf('vst')===-1){showToast('Chọn VST instrument trước khi gán preset','warning');return;}updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,synth_engine:{...(t.synth_engine||{}),preset_id:presetId}}:t));setInstrumentDropdownTrackId(null);setInstrumentDropdownBtnRect(null);var p=(window.SonicRuntime&&window.SonicRuntime.presets||[]).find(function(x){return x.id===presetId;});showToast('Đã gán preset: '+(p&&p.name||presetId),'success');};const setTrackInstrumentWithUndo=(trackId,instrumentId,displayName,bankNumber,programNumber)=>{let track=activeTracks.find(t=>t.id===trackId);if(!track&&sessionTabsRef&&sessionTabsRef.current){for(let _ti=0;_ti<sessionTabsRef.current.length&&!track;_ti++){track=(sessionTabsRef.current[_ti].tracks||[]).find(t=>t.id===trackId);}}if(!track)return;const oldInstrumentId=track.instrumentId;const oldInstrumentName=track.instrumentName;if(oldInstrumentId===instrumentId&&oldInstrumentName===displayName)return;const entry={type:'SET_INSTRUMENT',scope:'track:'+trackId,label:`Instrument ${track.name}`,before:{instrumentId:oldInstrumentId,instrumentName:oldInstrumentName,bankNumber:track.soundfont_bank,programNumber:track.instrumentProgram},after:{instrumentId,instrumentName:displayName,bankNumber,programNumber},undo:e=>{setTrackInstrumentWithProgram(trackId,e.before.instrumentId,e.before.programNumber,e.before.instrumentName,e.before.bankNumber);showToast(`Undo: Instrument`,'info');},redo:e=>{setTrackInstrumentWithProgram(trackId,e.after.instrumentId,e.after.programNumber,e.after.instrumentName,e.after.bankNumber);showToast(`Redo: Instrument`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setTrackInstrumentWithProgram(trackId,instrumentId,programNumber,displayName,bankNumber);};const createSectionWithUndo=(trackId,section)=>{const entry={type:'CREATE_SECTION',scope:'track:'+trackId,label:`Create Section`,before:null,after:section,undo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,sections:(t.sections||[]).filter(s=>s.id!==e.after.id)}:t));showToast(`Undo: Create Section`,'info');},redo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,sections:[...(t.sections||[]),e.after]}:t));showToast(`Redo: Create Section`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);};const createMidiWithUndo=(trackId,midiItem)=>{const entry={type:'CREATE_MIDI',scope:'track:'+trackId,label:`Create MIDI Item`,before:null,after:midiItem,undo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,midiItems:(t.midiItems||[]).filter(m=>m.id!==e.after.id)}:t));showToast(`Undo: Create MIDI`,'info');},redo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,midiItems:[...(t.midiItems||[]),e.after]}:t));showToast(`Redo: Create MIDI`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);};// Export MIDI notes → audio bằng VSTi: midi-render (pedalboard) → chèn clip
|
||
// vào track (cùng flow insert sound clip, có undo) + download file wav.
|
||
const handleExportMidiAudio=async(st,notes,bpm)=>{const se=st.synth_engine||{};const instrumentId=se.plugin_id||st.instrumentId||null;if(!instrumentId||String(se.type||'').indexOf('vst')===-1){showToast('Chọn VST instrument (nút Synth) trước khi Export MIDI → Audio','warning');return;}if(!notes||!notes.length){showToast('Chưa có nốt nhạc','warning');return;}const payload={instrument_id:instrumentId,notes:(notes||[]).map(n=>({pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity!=null?n.velocity:0.8})),bpm:parseFloat(bpm)||120,preset_id:se.preset_id||undefined,preset_path:se.preset_path||undefined,preset_data:se.preset_data||undefined};try{const res=await window.SonicAPI.midiRender(payload);// Decode wav để chèn clip có buffer (phát được ngay trong track)
|
||
const resp=await fetch(res.url);if(!resp.ok)throw new Error('Không tải được wav render');const blob=await resp.blob();const{audioBuffer,channelInfo}=await window.SonicAudio.decodeAudioFile(blob);const clipId=`clip_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;const track=activeTracks.find(t=>t.id===st.trackId);const newClip={id:clipId,buffer:audioBuffer,channelInfo:channelInfo,startTime:currentTime||0,name:`MIDI ${st.label||'render'}`,speed:1.0,serverFileId:res.file_id};createClipWithUndo(st.trackId,newClip);updateActiveTracks(prev=>prev.map(t=>t.id===st.trackId?{...t,clips:[...(t.clips||[]),newClip],serverFileId:t.serverFileId||res.file_id}:t));// Download wav (đã lưu trên server processed)
|
||
const a=document.createElement('a');a.href=res.url;a.download=(st.label||'midi').replace(/[^a-z0-9-_]/gi,'_')+'_vsti.wav';document.body.appendChild(a);a.click();document.body.removeChild(a);showToast(`Đã render MIDI → Audio (${(res.duration_sec||0).toFixed(1)}s) + chèn clip`,'success');}catch(err){showToast('Export MIDI → Audio thất bại: '+(err&&err.message||'lỗi'),'error');}};const createClipWithUndo=(trackId,clip)=>{const entry={type:'CREATE_CLIP',scope:'track:'+trackId,label:`Create Audio Clip`,before:null,after:clip,undo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,clips:(t.clips||[]).filter(c=>c.id!==e.after.id),buffer:(t.clips||[]).filter(c=>c.id!==e.after.id)[0]?.buffer||null,startTime:(t.clips||[]).filter(c=>c.id!==e.after.id)[0]?.startTime||0,name:(t.clips||[]).filter(c=>c.id!==e.after.id)[0]?.name||t.name}:t));showToast(`Undo: Create Clip`,'info');},redo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,clips:[...(t.clips||[]),e.after]}:t));showToast(`Redo: Create Clip`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);};const deleteTrackWithUndo=(trackId,trackData)=>{const entry={type:'DELETE_TRACK',scope:'global',label:`Delete Track`,before:trackData,after:null,undo:e=>{if(e.before){setTracks(prev=>[...prev,e.before]);showToast(`Undo: Delete Track`,'info');}},redo:e=>{setTracks(prev=>prev.filter(t=>t.id!==trackId));showToast(`Redo: Delete Track`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);};// ── Tab System (LOOP_EDITOR_2.md §1) ──
|
||
const[activeTab,setActiveTab]=useState('main');const[subTabSelectedNodeTime,setSubTabSelectedNodeTime]=useState(null);const[subTabNormVal,setSubTabNormVal]=useState(0);const[subTabGainVal,setSubTabGainVal]=useState(100);const[subTabPitchVal,setSubTabPitchVal]=useState(0);const[subTabs,setSubTabs]=useState([]);// [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
|
||
const[pianoRollClipboard,setPianoRollClipboard]=useState(null);// notes copy giữa các PIANO ROLL TAB
|
||
const[sessionTabs,setSessionTabs]=useState([]);// [{id, name, tracks}, ...]
|
||
const[tabContextMenu,setTabContextMenu]=useState(null);// { x, y, tabId, tabType }
|
||
const handleSetTabColor=(tabId,tabType,color)=>{if(tabType==='session'){setSessionTabs(prev=>prev.map(s=>s.id===tabId?{...s,color:color}:s));const tab=sessionTabs.find(s=>s.id===tabId);if(tab){setTracks(prev=>prev.map(t=>{if(!t.sections||t.sections.length===0)return t;return{...t,sections:t.sections.map(s=>{if(s.sectionId===tab.sectionId){return{...s,color:color};}return s;})};}));}}else{setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,color:color}:s));}};const activeTracks=useMemo(()=>{var st=sessionTabs.find(s=>s.id===activeTab);if(st)return st.tracks;var pr=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(pr&&pr.parent_tab_id&&pr.parent_tab_id.startsWith('session_')){var parentSt=sessionTabs.find(s=>s.id===pr.parent_tab_id);if(parentSt)return parentSt.tracks;}return tracks;},[activeTab,sessionTabs,subTabs,tracks]);const activeTracksRef=useRef([]);activeTracksRef.current=activeTracks;tracksRef.current=tracks;// D18c: sync tracks state moi nhat (guard fire-time)
|
||
const sessionTabsRef=useRef(sessionTabs);sessionTabsRef.current=sessionTabs;// Vị trí MIDI item (trong context của piano roll) — dùng để đồng bộ playhead
|
||
// 2 chiều SECTION-TAB ↔ PIANO ROLL: item.startTime (local section / main) +
|
||
// st.currentTime (vị trí TRONG item). Dùng REFS (activeTracksRef/
|
||
// sessionTabsRef) — an toàn với closure cũ (updatePlayhead/rAF loop).
|
||
const getPianoRollItemInfo=st=>{try{if(!st)return null;let list=null;if(st.parent_tab_id&&st.parent_tab_id.startsWith('session_')){const _pst=sessionTabsRef.current.find(s=>s.id===st.parent_tab_id);list=_pst?_pst.tracks:null;}else{list=activeTracksRef.current;}if(!list)return null;const _trk=list.find(t=>t.id===st.trackId);if(!_trk)return null;const _item=(_trk.midiItems||[]).find(m=>m.id===st.target_id);if(!_item)return null;return{startTime:_item.startTime||0,duration:_item.duration||4};}catch(e){return null;}};// Track mute/solo/volume signature: only re-apply gains when something that
|
||
// affects audibility actually changed (loads, undo, AI ops, section tabs).
|
||
const trackMuteSoloSigRef=useRef({});// Last-known audibility per track id (used to detect inaudible→audible
|
||
// transitions that need a playback re-schedule).
|
||
const trackAudibleRef=useRef({});// Audio-relevant signature (mute/solo/vol/bypass/items count) — đổi màu/
|
||
// rename KHÔNG trigger re-sync/re-route (tránh mất âm khi play).
|
||
const trackAudioSyncSigRef=useRef(null);// Keep the mastering-bypass routing map + realtime mute/solo in sync with the
|
||
// tracks state (loads, undo, AI ops…). Placed AFTER the tracks/subTabs/
|
||
// sessionTabs declarations (TDZ-safe).
|
||
useEffect(()=>{// Sync theo thứ tự: session/clone TRƯỚC, MAIN tracks SAU — khi id trùng
|
||
// (section clone giữ id sub-track = id track main), MAIN thắng: map là
|
||
// fallback cho track không có field; clone có field (reset 0330) nên route
|
||
// dùng field object (context đúng) — map chéo không ảnh hưởng.
|
||
const all=[...(sessionTabs||[]).reduce((acc,s)=>acc.concat(s.tracks||[]),[]),...(tracks||[])];all.forEach(t=>{trackMasteringBypassMap[t.id]=!!t.masteringBypass;trackAudioBypassMap[t.id]=!!t.audioBypass;trackMidiBypassMap[t.id]=!!t.midiBypass;});const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:all;// Audio-relevant signature: đổi MÀU/rename track (không liên quan audio)
|
||
// KHÔNG được trigger re-sync/re-route (tránh mất âm khi play sau đó —
|
||
// updateSfRouting chạy thừa có thể đặt SF destination sai thời điểm).
|
||
const audioSig=list.map(t=>(t.muted?'1':'0')+(t.solo?'1':'0')+':'+(t.volumeDb??0)+':'+(t.audioBypass?'1':'0')+(t.midiBypass?'1':'0')+':'+(t.midiItems||[]).length+':'+(t.clips||[]).length+':'+(t.sections||[]).length).join('|');if(trackAudioSyncSigRef.current===audioSig)return;trackAudioSyncSigRef.current=audioSig;list.forEach(t=>{// ♪ state → SF mastering route (sfRouteGain/sfDryGain), live on existing nodes
|
||
const sn=activeTrackNodesRef.current[t.id];if(sn&&sn.sfRouteGain&&sn.sfDryGain&&sn.sfRouteGain.gain.value>0!==!effMidiBypass(t)){const _b=effMidiBypass(t);sn.sfRouteGain.gain.value=_b?0:1;sn.sfDryGain.gain.value=_b?1:0;}const sig=(t.muted?'1':'0')+(t.solo?'1':'0')+':'+(t.volumeDb??0);if(trackMuteSoloSigRef.current[t.id]===sig)return;trackMuteSoloSigRef.current[t.id]=sig;const audible=computeTrackAudibleGain(list,t)>0;const node=activeTrackNodesRef.current[t.id];const audibleGain=computeTrackAudibleGain(list,t);// V45.24: PR tab doc lap — track co sub-tab PR PLAY thi khong ap
|
||
// mute/solo realtime (gain=0/CC7=0) trong sync effect.
|
||
const prPlayingForTrack=!!(subTabsRef.current||[]).some(function(s){return s.type==='PIANO_ROLL'&&s.trackId===t.id&&s.isPlaying;});const _volDb=Number(t.volumeDb);const _volLin=isNaN(_volDb)||!isFinite(_volDb)?1.0:_volDb<=-50?0:Math.pow(10,_volDb/20);const effGain=prPlayingForTrack?_volLin:audibleGain;if(node)setTrackNodeGain(node,effGain);if(node&&node.sfEntry){try{const g=typeof effGain==='number'&&isFinite(effGain)&&!isNaN(effGain)?effGain:1.0;node.sfEntry.gain.setTargetAtTime(g,getAudioContext().currentTime,0.02);}catch(e){}}// MIDI/bridge tracks: mirror the gain decision onto CC7 (channel volume)
|
||
// so FluidSynth/bridge-rendered notes respect mute/solo too. Bridge
|
||
// active → SonicMidiRouter; down → SonicSF. CC7 = VOLUME track.
|
||
if(isMidiSoundTrack(t)){try{sendMidiVolumeCC7(t,list,audible||prPlayingForTrack);}catch(e){}}trackAudibleRef.current[t.id]=prPlayingForTrack?true:audible;});updateSfRouting();},[tracks,sessionTabs]);const prevActiveTabRef=useRef(activeTab);useEffect(()=>{const prevTab=prevActiveTabRef.current;prevActiveTabRef.current=activeTab;updateSfRouting();// Log chẩn đoán zoom khi đổi tab (user: zoom reset khi chuyển tab)
|
||
if(prevTab!==activeTab){console.log('[Zoom] đổi tab',prevTab,'→',activeTab,'| zoom=',zoom,'| minZoom=',(minZoom||0).toFixed(2));}// Tab DEACTIVE → stop âm của tab đó (user requirement): rời khỏi sub-tab
|
||
// (PIANO_ROLL/section/audio tab) đang play → stop playback của tab đó.
|
||
// Tab mới hoạt động bình thường; MAIN giữ hành vi cũ (mở piano-roll lúc
|
||
// main play → main tiếp tục — handleEditMidiInTab đã xử lý).
|
||
if(prevTab!==activeTab){// Vào SECTION-TAB lúc MAIN đang play → CHỈ play items CỦA SECTION-TAB,
|
||
// TẮT âm của MAIN items (user yêu cầu 06:40): restart play với
|
||
// activeTracks = section clones (activeTab mới → activeTracksRef mới).
|
||
if(prevTab==='main'&&activeTab&&activeTab.startsWith('session_')&&isPlaying){try{const _ctx=getAudioContext();// Vị trí play → LOCAL của section tab (trừ secStart — vị trí section
|
||
// trên main): section tab items dùng time local (0-based)
|
||
let _secStart=0;const _tab=sessionTabsRef.current.find(st=>st.id===activeTab);if(_tab&&_tab.sectionId){(tracks||[]).forEach(_t=>(_t.sections||[]).forEach(_s=>{if(_s.sectionId===_tab.sectionId||_s.id===_tab.sectionId)_secStart=_s.start||0;}));}const _pt=Math.max(0,startOffsetTimeRef.current+(_ctx.currentTime-startAudioTimeRef.current)-_secStart);fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();startOffsetTimeRef.current=_pt;startAudioTimeRef.current=_ctx.currentTime;startTrackPlayback(_pt);setIsPlaying(true);console.log('[SectionTab] play CHỈ section items tại local',_pt.toFixed(2),'(secStart',_secStart.toFixed(2)+')');}catch(e){console.warn('[SectionTab] restart error:',e);}}// Vào PIANO ROLL lúc MAIN/SECTION đang play → CHỈ play MIDI notes CỦA
|
||
// TRACK đang edit, TẮT âm MỌI track khác (user: vào piano roll phải play
|
||
// only midi notes đang active). Dừng main → play lại CHỈ track piano roll
|
||
// tại vị trí TƯƠNG ỨNG trong item (mainPos − item.startTime). Rời tab →
|
||
// nhánh resume (dưới) về main/section đúng vị trí (mainResumeRef vẫn giữ).
|
||
const _prTabNew=activeTab&&activeTab.startsWith('midi_')?subTabsRef.current.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL'):null;if(_prTabNew&&!_prTabNew.isPlaying&&isPlaying){try{const _ctx=getAudioContext();const _mainPos=startOffsetTimeRef.current+(_ctx.currentTime-startAudioTimeRef.current);const _itemInfo=getPianoRollItemInfo(_prTabNew);const _prPos=_itemInfo?Math.max(0,_mainPos-(_itemInfo.startTime||0)):_prTabNew.currentTime||0;fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();startOffsetTimeRef.current=_prPos;startAudioTimeRef.current=_ctx.currentTime;startBufferOffsetRef.current=_prPos*(_prTabNew.speed||1.0);schedulePianoRollMidi(_prTabNew,_prPos);startSubTabPlayback(_prTabNew,_prPos);setSubTabs(prev=>prev.map(s=>s.id===_prTabNew.id?{...s,isPlaying:true,currentTime:_prPos}:s));animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);console.log('[PianoRollTab] play CHỈ track',_prTabNew.trackId,'tại local',_prPos.toFixed(2),'(mainPos',_mainPos.toFixed(2),'itemStart',_itemInfo?_itemInfo.startTime.toFixed(2):'?',')');}catch(e){console.warn('[PianoRollTab] restart error:',e);}}const prevSub=subTabsRef.current.find(s=>s.id===prevTab);if(prevSub&&prevSub.isPlaying){// Chuyển sang SUB-TAB KHÁC (piano roll khác/audio tab) → STOP âm tab
|
||
// cũ — play ĐÚNG nội dung TỪNG TAB (user 07:30: piano roll tab 1 →
|
||
// tab 2 — không được nghe âm tab 1). Về MAIN/SECTION → giữ luật cũ
|
||
// (04:15+ — âm tiếp/resume).
|
||
const _newIsSub=activeTab&&activeTab!=='main'&&!activeTab.startsWith('session_');if(prevSub.type!=='PIANO_ROLL'||_newIsSub){try{stopAllPlayback();}catch(e){}}setSubTabs(prev=>prev.map(s=>s.id===prevTab?{...s,isPlaying:false}:s));// Resume main/session play khi rời PIANO ROLL tab đã play (Space):
|
||
// play piano roll → stopAllPlayback dừng main → quay lại MAIN/SECTION
|
||
// bị CÂM. Resume startTrackPlayback(offset + elapsed thật) — chỉ khi
|
||
// trước đó mở tab lúc main đang play (mainResumeRef đã set).
|
||
const _resume=mainResumeRef.current;mainResumeRef.current=null;if(prevSub.type==='PIANO_ROLL'&&(activeTab==='main'||activeTab&&activeTab.startsWith('session_'))){// Resume section/main play tại vị trí TƯƠNG ỨNG với piano roll:
|
||
// resumeAt = item.startTime + st.currentTime (vị trí TRONG item —
|
||
// yêu cầu: play piano roll ở vị trí nào → về SECTION play đúng vị trí
|
||
// đó). Ưu tiên item info; fallback mainResumeRef (offset+elapsed);
|
||
// fallback cuối: setIsPlaying(true) — playhead chạy theo âm còn kêu.
|
||
let _resumeAt=null;const _itemInfo=getPianoRollItemInfo(prevSub);if(_itemInfo){_resumeAt=_itemInfo.startTime+(prevSub.currentTime||0);// Về MAIN mà item thuộc SECTION: vị trí main = secStart + local
|
||
if(activeTab==='main'&&prevSub.parent_tab_id&&prevSub.parent_tab_id.startsWith('session_')){const _pst=sessionTabsRef.current.find(s=>s.id===prevSub.parent_tab_id);if(_pst&&_pst.sectionId){(activeTracksRef.current||[]).forEach(_t=>(_t.sections||[]).forEach(_s=>{if(_s.sectionId===_pst.sectionId||_s.id===_pst.sectionId)_resumeAt+=_s.start||0;}));}}}else if(_resume){_resumeAt=_resume.offset+(getAudioContext().currentTime-_resume.audioTime);}if(_resumeAt!==null){try{console.log('[Resume] play tại',_resumeAt.toFixed(2),'s (item.startTime='+(_itemInfo?_itemInfo.startTime.toFixed(2):'?')+' + st.currentTime='+(prevSub.currentTime||0).toFixed(2)+')');fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();startOffsetTimeRef.current=_resumeAt;startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(_resumeAt);setIsPlaying(true);}catch(e){console.warn('[Resume] error:',e);}}else{console.log('[Playhead] tiếp tục theo piano roll (không stop âm) — activeTab='+activeTab);setIsPlaying(true);}}}else if(prevSub&&prevSub.type==='PIANO_ROLL'){// Rời PIANO ROLL tab KHÔNG play: main/section play ngầm vẫn chạy (âm
|
||
// tiếp tục tự nhiên — không cần resume) → clear resume point.
|
||
// ⚠️ KHÔNG clear khi prevTab = main/session (đang DI CHUYỂN VÀO piano
|
||
// roll — mainResumeRef vừa set bởi handleEditMidiInTab — clear nhầm ở
|
||
// đây làm resume chết → quay lại main/section bị CÂM).
|
||
mainResumeRef.current=null;}}},[activeTab]);const[selectionCleared,setSelectionCleared]=useState(false);// LOOP_EDITOR_2.md §4.2
|
||
// Detect MAIN/SECTION play vừa DỪNG (hết notes/projectEnd/stop): nếu đang ở
|
||
// PIANO ROLL tab (không play tab) → playhead về 0 — user: main play hết →
|
||
// không còn midi note → piano roll phải về đầu (không giữ local cuối).
|
||
const prevIsPlayingRef=useRef(false);useEffect(()=>{const wasPlaying=prevIsPlayingRef.current;prevIsPlayingRef.current=isPlaying;if(wasPlaying&&!isPlaying){const _tabId=activeTabRef.current;if(_tabId&&_tabId.startsWith('midi_')){const _prSt=subTabsRef.current.find(s=>s.id===_tabId);if(_prSt&&!_prSt.isPlaying){setSubTabs(prev=>prev.map(s=>s.id===_tabId?{...s,currentTime:0}:s));}}}},[isPlaying]);const midiVuActivityRef=useRef({});// Đếm số note MIDI đang GIỮ per-track (ARM + MIDI keyboard live input).
|
||
// VU tick dùng ref này: còn note giữ → giữ peak (không decay) → VU animate
|
||
// ĐÚNG trường độ âm thanh (user bug: nhấn phím giữ âm còn kêu nhưng VU tắt
|
||
// sau ~0.5s vì decay 0.75/frame). Hết note (note-off) → mới decay/tắt.
|
||
const heldMidiNotesRef=useRef({});const triggerMidiVuActivity=(trackId,velocity)=>{if(!trackId)return;const velFactor=typeof velocity==='number'?velocity>1?velocity/127:velocity:0.8;const peak=Math.min(1.0,Math.max(0.15,velFactor));midiVuActivityRef.current[trackId]=peak;// Log nguồn trigger (rate-limited 1/20 fire): xác định ai còn fire VU khi
|
||
// âm đã ngừng (user: VU vẫn animation sau khi hết âm).
|
||
try{const _tf=window.__trigFrame=(window.__trigFrame||0)+1;if(_tf%20===1){console.log('[VUTrig]',trackId,'vel=',velocity,'peak=',peak.toFixed(2),'play=',isPlaying,'tab=',activeTabRef.current);}}catch(e){}};window.triggerMidiVuActivity=triggerMidiVuActivity;// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
|
||
const[tempTabActive,setTempTabActive]=useState(false);const[tempTabBuffer,setTempTabBuffer]=useState(null);const[tempTabTrackId,setTempTabTrackId]=useState(null);const[tempTabOrigStart,setTempTabOrigStart]=useState(0);const[tempTabOrigEnd,setTempTabOrigEnd]=useState(0);const tempTabCanvasRef=useRef(null);// Effect parameters for temp tab
|
||
const[tempTabEffects,setTempTabEffects]=useState({reverse:false,gainDb:0,fadeInMs:0,fadeOutMs:0});// ── Auth / User State ──
|
||
const[currentUser,setCurrentUser]=useState(null);const[authModalOpen,setAuthModalOpen]=useState(false);const[authMode,setAuthMode]=useState('login');// 'login' | 'register' | 'force_change'
|
||
const[isMandatoryLogin,setIsMandatoryLogin]=useState(false);const[profileModalOpen,setProfileModalOpen]=useState(false);const[systemManagerModalOpen,setSystemManagerModalOpen]=useState(false);const[aiConfigModalOpen,setAiConfigModalOpen]=useState(false);const[aiPresetModalOpen,setAiPresetModalOpen]=useState(false);const[aiPresetVersion,setAiPresetVersion]=useState(0);const[showMasteringModal,setShowMasteringModal]=useState(false);const masteringModalOpenRef=useRef(false);masteringModalOpenRef.current=showMasteringModal;// ── Help / About / Preferences (menu Help + Tools) ──
|
||
const[aboutModalOpen,setAboutModalOpen]=useState(false);const[helpModalOpen,setHelpModalOpen]=useState(false);const[preferencesModalOpen,setPreferencesModalOpen]=useState(false);const[prefs,setPrefs]=useState(()=>{try{const saved=localStorage.getItem('sf_prefs');if(saved)return JSON.parse(saved);}catch(e){}return{theme:'dark',language:'vi',buttonFontSize:'md'};});// Áp dụng theme + font size lên <html> ngay khi đổi
|
||
useEffect(()=>{try{document.documentElement.setAttribute('data-theme',prefs.theme||'dark');document.documentElement.setAttribute('data-btnfont',prefs.buttonFontSize||'md');localStorage.setItem('sf_prefs',JSON.stringify(prefs));}catch(e){}},[prefs]);// Load preferences từ server (nếu có tài khoản) khi khởi động
|
||
useEffect(()=>{let cancelled=false;if(window.SonicAPI&&window.SonicAPI.getPreferences){window.SonicAPI.getPreferences().then(res=>{if(cancelled||!res||!res.success||!res.preferences)return;const p=res.preferences;if(p.theme||p.language||p.buttonFontSize){setPrefs(prev=>({theme:p.theme||prev.theme,language:p.language||prev.language,buttonFontSize:p.buttonFontSize||prev.buttonFontSize}));}}).catch(()=>{});}return()=>{cancelled=true;};},[]);// Lưu preferences lên server khi đổi
|
||
const handlePrefsChange=next=>{setPrefs(next);try{localStorage.setItem('sf_prefs',JSON.stringify(next));}catch(e){}if(window.SonicAPI&&window.SonicAPI.savePreferences){window.SonicAPI.savePreferences(next).catch(()=>{});}};// Unified FX Rack target context (unified_fx_rack_panel.md): { trackId, trackName } | null
|
||
const[fxRackTarget,setFxRackTarget]=useState(null);window.__openFxRack=(trackId,trackName)=>setFxRackTarget({trackId,trackName});// Render offline toàn project qua native_bridge (VST FX masterbus + track) → WAV.
|
||
// Dùng cho nút "Nghe thử" trong FX Rack / Mastering — preview = âm export.
|
||
window.__renderProjectPreview=async()=>{const schema=serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});try{const res=await window.SonicAPI.renderProject(schema,'preview_'+Date.now()+'.wav');_lastMasterStats={lufs:res.lufs,true_peak_db:res.true_peak_db};return res;}catch(e){window.showToast&&window.showToast('Render preview lỗi: '+(e.message||e),'error');throw e;}};const[masteringSettings,setMasteringSettings]=useState({masterConnected:true,// ⚠️ FIX: bật ON mặc định — MIDI item/preview PHẢI được xử lí qua mastering FX ngay khi chạy app (không cần bật/tắt power)
|
||
activeModule:null,imagerActive:false,maximizerActive:false,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:[],compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,limMode:0,limLookahead:2,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0,vstFxChain:[],vstBypass:false});useEffect(()=>{window.currentMasteringSettings=masteringSettings;const _fxPrevAct=!!(fxRt&&fxRt.active);const _fxPrevWarm=!!(fxRt&&fxRt.warm);if(audioCtx&&masterBus){toggleMasteringOnMaster(masteringSettings.masterConnected,masteringSettings.isBypassed);applyMasteringSettings(masteringSettings);// Đồng bộ realtime VST master với PWR/Bypass: WebAudio graph đã đổi theo
|
||
// masterConnected nhưng fxRt không tự dừng → tắt PWR mà âm không đổi.
|
||
try{if(fxRtChain().length){fxRtStart();// V45.46: start từ settings-change lúc KHÔNG có playback (mount mở
|
||
// project có master chain / chỉnh PWR khi đang dừng) → engine từng chạy
|
||
// VÔ HẠN khi idle (fx_stats spam play=0 → CPU cao). Arm warm-idle
|
||
// teardown như stopAllPlayback(fade): play path (fxRtStart trong
|
||
// startTrackPlayback) và section play (startSubTabPlayback) clear timer
|
||
// ngay → chỉ teardown khi thực sự idle lâu. MIDI-live/rec giữ warm.
|
||
const _recNow=activeAudioRecordersRef&&activeAudioRecordersRef.current&&Object.keys(activeAudioRecordersRef.current).length>0||recordingStateRef&&recordingStateRef.current==='RECORDING';let _liveMidi=false;try{for(const _k in midiVuActivityRef.current){if(midiVuActivityRef.current[_k]>0.001){_liveMidi=true;break;}}}catch(e){}const _demand=isPlayingRef&&isPlayingRef.current||_recNow||_liveMidi||subTabsRef&&subTabsRef.current&&subTabsRef.current.some(s=>s&&s.isPlaying);if(!_demand){// C: GUI marker còn (plugin VST active trong chain) -> session giữ
|
||
// vô hạn — arm idle 120s sẽ teardown giữa lúc GUI đang mở (editor
|
||
// native chết + preset mất). ponytail: session giữ khi marker còn;
|
||
// user đóng GUI (closeMasterGui xoá marker) -> nhánh này arm lại.
|
||
const _guiVst=masteringSettings.gui_open_path&&(masteringSettings.chain||[]).some(x=>x&&x.type==='vst3'&&x.path===masteringSettings.gui_open_path&&x.active!==false)?masteringSettings.gui_open_path:null;if(!_guiVst){clearTimeout(fxRtWarmIdleTimer);fxRtWarmIdleTimer=setTimeout(()=>{fxRtWarmIdleTimer=null;try{fxRtStop();}catch(e){console.warn('[FX RT] idle stop error:',e);}},FX_RT_WARM_IDLE_MS);}else{clearTimeout(fxRtWarmIdleTimer);fxRtWarmIdleTimer=null;}}}else{// C: PWR OFF / bypass / chain rỗng. GUI marker còn + session sống ->
|
||
// giữ WARM (WebAudio bypass + PDC 0, session/node/ws sống cho GUI
|
||
// native); không marker -> teardown thật (hành vi cũ).
|
||
const _hasVst=!!(masteringSettings.gui_open_path&&(masteringSettings.chain||[]).some(x=>x&&x.type==='vst3'&&x.path===masteringSettings.gui_open_path&&x.active!==false));if(fxRt.active||fxRt.starting||fxRt.warm){if(_hasVst&&fxRt.sessionId&&(fxRt.active||fxRt.warm)){if(fxRt.active){// M23 Bug C: PWR OFF giua play — giu PDC (dry = content, khong
|
||
// jump/scratch) + rebase display ve content de warm display lien
|
||
// tuc (khong nhay +lat nhu M22).
|
||
try{const _plNow=!!(isPlayingRef&&isPlayingRef.current)||!!(subTabsRef&&subTabsRef.current&&subTabsRef.current.some(s=>s&&s.isPlaying));if(_plNow){fxRtWarmPlayHold=true;const _c2=getAudioContext();const _sr2=_c2.sampleRate||44100;const _off2=startOffsetTimeRef.current||0;const _at2=startAudioTimeRef.current||_c2.currentTime;const _eng2=typeof fxRt.processedSamples==='number'&&fxRt.processedAnchor!=null?Math.max(_off2,_off2+(fxRt.processedSamples-fxRt.processedAnchor-(fxRt.latencyTotal||0))/_sr2):null;const _wall2=_off2+(_c2.currentTime-_at2);const _content2=_eng2!==null?_eng2:_wall2-(fxRt.latencyTotal||0)/_sr2;startOffsetTimeRef.current=_content2;startAudioTimeRef.current=_c2.currentTime;_dbgLog('ph_offhold','cur='+(currentTimeRef.current==null?'na':currentTimeRef.current.toFixed(3))+' wall='+_wall2.toFixed(3)+' content='+_content2.toFixed(3)+' off='+startOffsetTimeRef.current.toFixed(3)+' at='+startAudioTimeRef.current.toFixed(3)+' ctx='+_c2.currentTime.toFixed(3)+' lat='+(fxRt.latencyTotal||0)+' hold=1');}else{_dbgLog('ph_offhold','cur='+(currentTimeRef.current==null?'na':currentTimeRef.current.toFixed(3))+' hold=0');}}catch(e){try{_dbgLog('ph_offhold','err '+String(e));}catch(e2){}}fxRtWarmBypass();}}else{fxRtStop();}}}}catch(e){console.warn('[FX RT] resync fail:',e);}// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
|
||
// override bởi effMidiBypass/effAudioBypass) — nút PWR bật/tắt phải áp
|
||
// ngay lên node đang phát (không chờ tracks effect).
|
||
try{const _list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;_list.forEach(_t=>{const _n=activeTrackNodesRef.current[_t.id];if(_n){if(_n.sfRouteGain&&_n.sfDryGain){const _b=effMidiBypass(_t);_n.sfRouteGain.gain.value=_b?0:1;_n.sfDryGain.gain.value=_b?1:0;}if(_n.route&&_n.route.routeGain&&_n.route.dryGain){const _ab=routeBypassFor(_t);_n.route.routeGain.gain.value=_ab?0:1;_n.route.dryGain.gain.value=_ab?1:0;}}});// Sub-nodes (section tabs, key '<parent>_sub_<id>'): PWR master ON →
|
||
// ép qua master chain; OFF → khôi phục theo nút A/♪ riêng của section
|
||
// (route._bypass lưu trạng thái gốc lúc tạo/toggle).
|
||
Object.keys(activeTrackNodesRef.current).forEach(_k=>{if(!_k.includes('_sub_'))return;const _sn=activeTrackNodesRef.current[_k];if(_sn&&_sn.route&&_sn.route.routeGain&&_sn.route.dryGain){const _sab=masteringChainOn()?false:!!_sn.route._bypass;_sn.route.routeGain.gain.value=_sab?0:1;_sn.route.dryGain.gain.value=_sab?1:0;}});updateSfRouting();}catch(e){}}try{if(!_fxPrevAct&&_fxPrevWarm&&fxRt.active&&window.__fxRtWakeRebase)window.__fxRtWakeRebase();}catch(e){}try{if(_phArmed()&&window.__phSnapAll){window.__phSnapAll('ph_effect');_dbgLog('ph_effect','mc='+(masteringSettings.masterConnected?1:0)+' bp='+(masteringSettings.isBypassed?1:0)+' chain='+fxRtChain().length+' gui='+(masteringSettings.gui_open_path||'na'));}}catch(e){}},[masteringSettings]);const[pluginManagerModalOpen,setPluginManagerModalOpen]=useState(false);const[pluginsData,setPluginsData]=useState(null);// D10: bridge indicator + "Ép dùng WASM" debug toggle.
|
||
const[bridgeUi,setBridgeUi]=useState({connected:false,forceWasm:false});React.useEffect(()=>{if(!window.NativeBridgeService)return;setBridgeUi(s=>({...s,connected:!!window.NativeBridgeService.isBridgeConnected}));window.NativeBridgeService.onStatusChange(function(st){// V8 bug 4: reset map NGAY DAU callback (ca connected lan !connected) —
|
||
// Rust auto-restart KHONG emit bridge-down khi thanh cong → map cu true →
|
||
// GUI click skip load → attach null → nen trang.
|
||
window.__bridgeLoadedChannels={};window.__bridgeLoadedPaths={};setBridgeUi(s=>({...s,connected:!!st.connected}));if(!st.connected){// Bridge down/restart → C++ mất hết instrument → reset map để lần tới
|
||
// load lại (nút GUI ensure-load trước khi open — tránh cửa sổ trắng).
|
||
window.__bridgeLoadedChannels={};window.__bridgeLoadedPaths={};// FIX auto-play: bridge restart → page co the con isPlaying=true (stale)
|
||
// → master-silence watchdog tu resume phat lai → am tu dong. Stop ngay
|
||
// de page khong con "dang phat" — user muon nghe thi bam play.
|
||
try{stopAllPlayback();setIsPlaying(false);}catch(e){console.warn('[Bridge] stop-on-down fail:',e);}return;}// Bridge (re)connect → load lại toàn bộ VST track (không mở GUI).
|
||
try{const _tl=activeTracksRef.current||[];for(let i=0;i<_tl.length;i++){const _t=_tl[i];const _inst=_t.instrumentId||_t.synth_engine&&_t.synth_engine.plugin_id;if(!_inst)continue;loadAnyTrackToBridge(_t).catch(function(){});}}catch(e){console.warn('[Bridge] reconnect reload fail:',e);}});return()=>window.NativeBridgeService.onStatusChange(null);},[]);const loadAudioBuffersForTracks=async tracksList=>{let hasLoadedAny=false;const loadBuffer=async url=>{const res=await fetch(url);if(!res.ok)return null;const blob=await res.blob();return await window.SonicAudio.decodeAudioFile(blob);};const tryLoad=async fileId=>{if(!fileId)return null;try{const result=await loadBuffer('/static/audio/uploads/'+fileId);if(result)return result;}catch(_){}try{const result=await loadBuffer(`${API_AUDIO}/download/${fileId}`);if(result)return result;}catch(_){}return null;};const updatedTracks=await Promise.all(tracksList.map(async t=>{let trackBuffer=t.buffer;let trackChannelInfo=t.channelInfo;if(t.serverFileId&&!trackBuffer){const result=await tryLoad(t.serverFileId);if(result){trackBuffer=result.audioBuffer;trackChannelInfo=result.channelInfo;hasLoadedAny=true;}}const updatedClips=await Promise.all((t.clips||[]).map(async c=>{let clipBuffer=c.buffer;const targetFileId=c.serverFileId||t.serverFileId;if(targetFileId&&!clipBuffer){const result=await tryLoad(targetFileId);if(result){clipBuffer=result.audioBuffer;hasLoadedAny=true;}}return{...c,buffer:clipBuffer};}));// Section items: nạp CẢ audioclip bên trong section (trước đây bỏ sót →
|
||
// audioclip trong section hiện "(audio chưa được tải)" sau save+open).
|
||
const updatedSections=await Promise.all((t.sections||[]).map(async s=>{const updatedSTracks=await Promise.all((s.tracks||[]).map(async st=>{const updatedSClips=await Promise.all((st.clips||[]).map(async c=>{let clipBuffer=c.buffer;const targetFileId=c.serverFileId||st.serverFileId;if(targetFileId&&!clipBuffer){const result=await tryLoad(targetFileId);if(result){clipBuffer=result.audioBuffer;hasLoadedAny=true;}}return{...c,buffer:clipBuffer};}));return{...st,clips:updatedSClips};}));return{...s,tracks:updatedSTracks};}));if(trackBuffer&&updatedClips.length===0){const clipId=`default_${t.id}`;return{...t,buffer:trackBuffer,channelInfo:trackChannelInfo,clips:[{id:clipId,buffer:trackBuffer,startTime:t.startTime||0,name:t.name,speed:1.0}],sections:updatedSections};}return{...t,buffer:trackBuffer,channelInfo:trackChannelInfo,clips:updatedClips,sections:updatedSections};}));if(hasLoadedAny){setTracks(prev=>{// Merge by TRACK ID (not array index): if the state changed between the
|
||
// fetch start and now (e.g. another project opened), index-based merging
|
||
// would scramble tracks and drop items into the wrong track. updatedTracks
|
||
// is authoritative; patch clip buffers from prev by matching clip ids.
|
||
const prevById=new Map((prev||[]).map(pt=>[pt.id,pt]));return updatedTracks.map(ut=>{const pt=prevById.get(ut.id);if(!pt)return ut;const ptClips=pt.clips||[];const utClips=ut.clips||[];const mergedClips=ptClips.map(pc=>{const uc=utClips.find(c=>c.id===pc.id);if(uc)return{...pc,buffer:pc.buffer||uc.buffer};return pc;});return{...ut,clips:mergedClips};});});// Merge cho SESSION TABS: track của session tab có id TRÙNG track MAIN
|
||
// (vd id 1) — match vào allLoadedTracks (main đứng trước) → session tab
|
||
// Track 01 bị THAY bằng track MAIN (chứa SECTION_ITEM + không có midi)
|
||
// → block lồng + mất MIDI. FIX: session tab CHỈ match với INNER TRACKS
|
||
// của section items (đúng tầng), KHÔNG match track main top-level.
|
||
const sectionInnerTracks=[];updatedTracks.forEach(function(ut){(ut.sections||[]).forEach(function(s){(s.tracks||[]).forEach(function(st){sectionInnerTracks.push(st);});});});setSessionTabs(prev=>prev.map(st=>({...st,tracks:(st.tracks||[]).map(t=>{const found=sectionInnerTracks.find(u=>u.id===t.id);return found||t;})})));}};const restoreLastSessionProject=async()=>{var pendingWasNull=!window.__pendingSfsProject;loadPendingSfsProject();if(!pendingWasNull)return true;// da xu li .sfs pending - khong de bang temp
|
||
var lastId=localStorage.getItem('sonic_project_id');var lastName=localStorage.getItem('sonic_project_name')||'Dự án';var parsed=null;if(!lastId){// Máy mới / browser ẩn danh: localStorage TRỐNG (id/name không tồn tại
|
||
// trên máy này) → tự mở project Cloud GẦN NHẤT của tài khoản đang đăng
|
||
// nhập — project tạo trên máy khác vẫn mở được ngay.
|
||
const prof=currentUser||function(){try{return JSON.parse(localStorage.getItem('sonic_user')||'null');}catch(e){return null;}}();if(prof&&window.SonicAPI&&window.SonicAPI.listCloudProjects){try{const cloudList=await window.SonicAPI.listCloudProjects();if(cloudList&&cloudList.length>0){const latest=cloudList[0];// backend ORDER BY updated_at DESC
|
||
lastId=latest.id;lastName=latest.name||lastName;const proj=await window.SonicAPI.getCloudProject(lastId);if(proj&&proj.data_json)parsed=JSON.parse(proj.data_json);}}catch(e){console.warn('restore cloud fallback error:',e);}}}else{try{if(lastId.startsWith('local_')){var localData=localStorage.getItem('sonic_local_project_data');if(localData)parsed=JSON.parse(localData);}else if(lastId.startsWith('disk:')){// Ctrl-S desktop: mở lại file .sonicforge.json trong thư mục tài liệu
|
||
var diskFname=lastId.slice(5);var diskProj=await window.SonicAPI.getDiskProject(diskFname);if(diskProj&&diskProj.data_json)parsed=JSON.parse(diskProj.data_json);}else{var proj=await window.SonicAPI.getCloudProject(lastId);if(proj)parsed=JSON.parse(proj.data_json);}}catch(e){console.warn('restore load error:',e);if(lastId&&lastId.startsWith('disk:')){// File trên đĩa không còn (bị xoá) → bỏ id cũ, không lặp lại mỗi lần mở
|
||
localStorage.removeItem('sonic_project_id');localStorage.removeItem('sonic_project_name');}}}try{if(!parsed)return false;var restoredBpm=bpm;var restoredTracks=[];var restoredSessionTabs=[];var restoredSubTabs=[];if(parsed.main_session){var result=deserializeProjectFromSchema(parsed);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;// Bỏ qua zoom ≤ 1 (data cũ lưu 1.0 — lỗi __currentZoom của Media
|
||
// Explorer) — áp dụng mới giữ zoom hiện tại/localStorage — hết thu nhỏ.
|
||
if(result.zoom>1&&setZoom)setZoom(result.zoom);// Restore transport loop selection (loop vùng chọn sau save/reload)
|
||
if(result.loopSelection!==undefined&&result.loopSelection){setIsLoopingSelection(true);if(result.selectionStart!=null&&result.selectionEnd!=null){setSelectionStart(result.selectionStart);setSelectionEnd(result.selectionEnd);}}if(result.masteringSettings)setMasteringSettings(result.masteringSettings);if(result.masterVolume!==undefined&&result.masterVolume!==null)setMasterVolume(result.masterVolume);if(result.masterVolL!==undefined&&result.masterVolL!==null)setMasterVolL(result.masterVolL);if(result.masterVolR!==undefined&&result.masterVolR!==null)setMasterVolR(result.masterVolR);if(result.masterLink!==undefined&&result.masterLink!==null)setMasterLink(result.masterLink);if(result.masterMuted!==undefined&&result.masterMuted!==null)setMasterMuted(result.masterMuted);if(result.masterAutoVol!==undefined)setMasterAutoVol(result.masterAutoVol);if(result.masterAutoOn!==undefined)setMasterAutoOn(result.masterAutoOn);}else{restoredTracks=(parsed.tracks||[]).map(function(t){var rest=Object.assign({},t);delete rest.height;return Object.assign({},rest,{buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null});});}setTracks(restoredTracks);loadAudioBuffersForTracks(restoredTracks).catch(function(err){console.warn('loadAudioBuffersForTracks error:',err);});trackMidiChannelsRef.current={};preloadTrackInstruments(restoredTracks).catch(function(err){console.warn('preloadTrackInstruments error:',err);});prerenderNativeTracks(restoredTracks,restoredBpm);setBpm(restoredBpm.toString());setSelectedTrackId(restoredTracks[0]?.id||'1');setProjectName(lastName);setCurrentProjectId(lastId);localStorage.setItem('sonic_project_id',lastId);setSessionTabs(restoredSessionTabs);setSubTabs(restoredSubTabs);// DIAG section-tab block: log tracks + sections của section tab sau restore
|
||
try{(restoredSessionTabs||[]).forEach(function(st){var secCount=0,midiCount=0,clipCount=0;(st.tracks||[]).forEach(function(tr){secCount+=(tr.sections||[]).length;midiCount+=(tr.midiItems||[]).length;clipCount+=(tr.clips||[]).length;});console.log('[Restore] sessionTab',st.id,'sectionId',st.sectionId,'tracks',(st.tracks||[]).length,'sections',secCount,'midi',midiCount,'clips',clipCount);});}catch(e){}var restoredItemCount=0;restoredTracks.forEach(function(rt){if(rt.clips)restoredItemCount+=rt.clips.length;if(rt.midiItems)restoredItemCount+=rt.midiItems.length;if(rt.sections)restoredItemCount+=rt.sections.length;});showToast('Đã khôi phục dự án "'+lastName+'" ('+restoredItemCount+' items).','info');return true;}catch(e){console.warn('restoreLastSessionProject failed:',e);// Stale session id (project deleted / DB reset): clear it so the error
|
||
// does not repeat on every page load.
|
||
localStorage.removeItem('sonic_project_id');localStorage.removeItem('sonic_project_name');return false;}};// Khoi phuc du an dang lam do tu temp autosave, CHI khi chua co project nao
|
||
// duoc restore (restoreLastSessionProject tra false). Goi o ca luong login
|
||
// modal (handleAuthSuccess) lan luong auto-login (checkAuthStatus).
|
||
const restoreTempProjectIfFresh=async()=>{try{const tmp=await window.SonicAPI.getTempProject();if(tmp&&tmp.has_temp&&tmp.data_json){let proj=tmp.data_json;if(typeof proj==='string'){try{proj=JSON.parse(proj);}catch(e){proj=null;}}if(proj&&proj.main_session){const freshStart=!(tracks&&tracks.some(t=>t.clips&&t.clips.length>0||t.midiItems&&t.midiItems.length>0||t.buffer));if(freshStart){const result=deserializeProjectFromSchema(proj);if(result&&result.tracks&&result.tracks.length>0){setTracks(result.tracks);loadAudioBuffersForTracks(result.tracks);setBpm((result.bpm||120).toString());if(result.sessionTabs&&result.sessionTabs.length>0)setSessionTabs(result.sessionTabs);if(result.subTabs&&result.subTabs.length>0)setSubTabs(result.subTabs);if(result.masteringSettings)setMasteringSettings(result.masteringSettings);if(result.masterVolume!==undefined&&result.masterVolume!==null)setMasterVolume(result.masterVolume);if(result.masterVolL!==undefined&&result.masterVolL!==null)setMasterVolL(result.masterVolL);if(result.masterVolR!==undefined&&result.masterVolR!==null)setMasterVolR(result.masterVolR);if(result.masterLink!==undefined&&result.masterLink!==null)setMasterLink(result.masterLink);if(result.masterMuted!==undefined&&result.masterMuted!==null)setMasterMuted(result.masterMuted);if(result.masterAutoVol!==undefined)setMasterAutoVol(result.masterAutoVol);if(result.masterAutoOn!==undefined)setMasterAutoOn(result.masterAutoOn);const tmpName=proj.metadata&&proj.metadata.title||'Dự án tạm';setProjectName(tmpName);localStorage.setItem('sonic_project_name',tmpName);showToast('Đã khôi phục dự án đang làm dở từ bản lưu tạm','success');}}}}}catch(e){console.warn('[TempRestore] error:',e);}};useEffect(()=>{const checkAuthStatus=async()=>{const savedToken=localStorage.getItem('sonic_token');if(!savedToken){// Standalone (loopback): auto-login không cần mật khẩu. LAN client:
|
||
// /auth/local trả 403 → hiện modal đăng nhập như cũ.
|
||
try{const res=await window.SonicAPI.localLogin();if(res&&res.access_token){localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));setCurrentUser(res.user);setIsMandatoryLogin(false);setAuthModalOpen(false);(async()=>{const restored=await restoreLastSessionProject();if(!restored)await restoreTempProjectIfFresh();})();return;}}catch(e){}setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);return;}try{const profile=await window.SonicAPI.getProfile();setCurrentUser(profile);localStorage.setItem('sonic_user',JSON.stringify(profile));if(profile.must_change_password){setIsMandatoryLogin(true);setAuthMode('force_change');setAuthModalOpen(true);}else{setIsMandatoryLogin(false);setAuthModalOpen(false);(async()=>{const restored=await restoreLastSessionProject();if(!restored)await restoreTempProjectIfFresh();})();}}catch(err){const cached=localStorage.getItem('sonic_user');if(cached){try{setCurrentUser(JSON.parse(cached));}catch(_){}setIsMandatoryLogin(false);setAuthModalOpen(false);(async()=>{const restored=await restoreLastSessionProject();if(!restored)await restoreTempProjectIfFresh();})();}else{localStorage.removeItem('sonic_token');localStorage.removeItem('sonic_user');setCurrentUser(null);setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);}}};checkAuthStatus();},[]);const loadPendingSfsProject=()=>{const proj=window.__pendingSfsProject;if(!proj)return;try{let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];let restoredSubTabs=[];if(proj.main_session){const result=deserializeProjectFromSchema(proj);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;if(result.masteringSettings){setMasteringSettings(result.masteringSettings);}else{setMasteringSettings({masterConnected:false,activeModule:null,eqActive:false,imagerActive:false,maximizerActive:false,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:[],compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,limMode:0,limLookahead:2,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0});}if(result.masterVolume!==undefined&&result.masterVolume!==null)setMasterVolume(result.masterVolume);if(result.masterVolL!==undefined&&result.masterVolL!==null)setMasterVolL(result.masterVolL);if(result.masterVolR!==undefined&&result.masterVolR!==null)setMasterVolR(result.masterVolR);if(result.masterLink!==undefined&&result.masterLink!==null)setMasterLink(result.masterLink);if(result.masterMuted!==undefined&&result.masterMuted!==null)setMasterMuted(result.masterMuted);if(result.masterAutoVol!==undefined)setMasterAutoVol(result.masterAutoVol);if(result.masterAutoOn!==undefined)setMasterAutoOn(result.masterAutoOn);}else{restoredTracks=(proj.tracks||[]).map(t=>{const{height:_h,...rest}=t;return{...rest,buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null};});}if(restoredTracks.length>0){setTracks(restoredTracks);setBpm(restoredBpm.toString());prerenderNativeTracks(restoredTracks,restoredBpm);if(restoredSessionTabs.length>0){setSessionTabs(restoredSessionTabs);}if(restoredSubTabs.length>0){setSubTabs(restoredSubTabs);}showToast(`Đã tải dự án "${proj.metadata?.title||proj.name||'Dự án mới'}" từ liên kết .sfs thành công!`,"success");}}catch(e){showToast("Lỗi tải dự án từ .sfs","error");}finally{window.__pendingSfsProject=null;}};const handleAuthSuccess=user=>{setCurrentUser(user);if(user.must_change_password){setIsMandatoryLogin(true);setAuthMode('force_change');setAuthModalOpen(true);}else{setIsMandatoryLogin(false);setAuthModalOpen(false);(async()=>{const restored=await restoreLastSessionProject();if(!restored)await restoreTempProjectIfFresh();})();const loadPrefs=p=>{if(!p)return;if(p.showAIPanel!==undefined)setShowAIPanel(p.showAIPanel);if(p.showExportPanel!==undefined)setShowExportPanel(p.showExportPanel);if(p.showSelectionPanel!==undefined)setShowSelectionPanel(p.showSelectionPanel);if(p.showPythonToolsPanel!==undefined)setShowPythonToolsPanel(p.showPythonToolsPanel);if(p.showMediaExplorer!==undefined)setShowMediaExplorer(p.showMediaExplorer);if(p.showMixer!==undefined)setShowMixer(p.showMixer);if(p.showFxRack!==undefined)setShowFxRack(p.showFxRack);if(p.showMidiEvents!==undefined)setShowMidiEvents(p.showMidiEvents);if(p.panelPositions)setPanelPositions(p.panelPositions);if(p.rightSidebarWidth)setRightSidebarWidth(p.rightSidebarWidth);if(p.mediaExplorerHeight)setMediaExplorerHeight(p.mediaExplorerHeight);if(p.selectedProviderId)setSelectedProviderId(p.selectedProviderId);};(async()=>{try{const data=await window.SonicAPI.getPreferences();if(data&&data.preferences)loadPrefs(data.preferences);else{const cached=localStorage.getItem('sonic_preferences');if(cached)loadPrefs(JSON.parse(cached));}}catch(e){const cached=localStorage.getItem('sonic_preferences');if(cached)loadPrefs(JSON.parse(cached));}try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){}try{window.SonicAPI.getSoundfontCatalog().then(cat=>{window.__soundfontCatalog=cat;}).catch(()=>{});}catch(e){}// Re-fetch instrument data after auth (useEffect on mount runs before token is set)
|
||
try{window.SonicAPI.listPlugins().then(async data=>{try{const catResp=(await window.SonicAPI.getSoundfontCatalog?.())??(await fetch('/api/v1/plugins/soundfonts/catalog').then(r=>r.json()));const catalog=catResp.full_catalog||{};data.soundfonts=(data.soundfonts||[]).map(sf=>{const sfId=sf.id.replace('sf_','');const catEntry=catalog[sfId.toLowerCase()]||catalog[sfId];if(catEntry&&catEntry.instruments)return{...sf,presets:catEntry.instruments};return sf;});}catch(e){console.warn('Catalog fetch error:',e);}setInstrumentSelectorData(data);}).catch(()=>{});}catch(e){}})();}};const handleLogout=()=>{localStorage.removeItem('sonic_token');localStorage.removeItem('sonic_user');setCurrentUser(null);setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);};// ── Temp project auto-save (local + server) ──
|
||
useEffect(()=>{const serializeSafe=arr=>(arr||[]).map(t=>({id:t.id,name:t.name,startTime:t.startTime,height:t.height,volumeDb:t.volumeDb,pan:t.pan,muted:t.muted,solo:t.solo,color:t.color,markers:t.markers||[],serverFileId:t.serverFileId||null,channelInfo:t.channelInfo?{channels:t.channelInfo.channels,isStereo:t.channelInfo.isStereo,label:t.channelInfo.label}:null}));window.SonicStorage.scheduleTempAutoSave(()=>{return serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});});},[tracks,subTabs,sessionTabs,masteringSettings,masterVolume]);// ── Temp autosave SERVER khi tắt ứng dụng (Bug: đóng app mất dự án làm dở) ──
|
||
// Lưu temp vào thư mục temp của ứng dụng (storage/temp + DB) khi:
|
||
// 1) đóng tab/window (beforeunload/pagehide — sendBeacon keepalive)
|
||
// 2) định kỳ 30s khi có thay đổi (phòng Tauri kill engine ngay khi close)
|
||
// Load lại app → khôi phục dự án đang làm dở (getTempProject ở mount).
|
||
useEffect(()=>{const buildTempPayload=()=>{try{const schemaObj=serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const cid=window.SonicStorage&&window.SonicStorage.getClientId?window.SonicStorage.getClientId():'';return JSON.stringify({data_json:JSON.stringify(schemaObj),client_id:cid});}catch(e){return null;}};const saveTempServer=()=>{const payload=buildTempPayload();if(!payload)return;try{if(navigator.sendBeacon){// sendBeacon không đặt header JSON được — backend đọc body JSON thuần
|
||
const blob=new Blob([payload],{type:'application/json'});navigator.sendBeacon(window.API_BASE_URL+'/api/v1/projects/temp',blob);}else{fetch(window.API_BASE_URL+'/api/v1/projects/temp',{method:'POST',headers:{'Content-Type':'application/json'},body:payload,keepalive:true}).catch(()=>{});}}catch(e){/* fire-and-forget */}};const onUnload=e=>{saveTempServer();// Để sendBeacon có cơ hội gửi trước khi WebView bị destroy
|
||
try{navigator.sendBeacon&&navigator.sendBeacon(window.API_BASE_URL+'/health',new Blob(['ping'],{type:'text/plain'}));}catch(e2){}};window.addEventListener('beforeunload',onUnload);window.addEventListener('pagehide',onUnload);const interval=setInterval(saveTempServer,30*1000);return()=>{window.removeEventListener('beforeunload',onUnload);window.removeEventListener('pagehide',onUnload);clearInterval(interval);};},[currentProjectId,projectName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume]);// ── Realtime sync giữa các client (LAN browser ↔ Tauri standalone) ──
|
||
// Bug 3: 2 session cùng tài khoản không sync. Poll revision nhẹ 3s; khi
|
||
// updated_at mới hơn bản đã apply + client_id KHÁC mình → tải full temp →
|
||
// deserialize → apply. Skip nếu data_json giống hệt bản cuối (chống churn
|
||
// khi 30s writer bump updated_at với nội dung không đổi). Ping-pong chặn
|
||
// bởi: bỏ qua bản do mình ghi (client_id) + hash-skip khi autosave.
|
||
useEffect(()=>{if(!window.SonicAPI||!window.SonicAPI.getTempRevision)return;const myId=window.SonicStorage&&window.SonicStorage.getClientId?window.SonicStorage.getClientId():'';let lastAppliedAt=0;let lastAppliedJson='';let timer=null;let stopped=false;const applyRemote=async()=>{try{const rev=await window.SonicAPI.getTempRevision();if(!rev||!rev.updated_at)return;if(rev.client_id===myId){lastAppliedAt=rev.updated_at;return;}// bản do mình ghi
|
||
if(rev.updated_at<=lastAppliedAt)return;// không mới hơn bản đã apply
|
||
const tmp=await window.SonicAPI.getTempProject();if(!tmp||!tmp.has_temp||!tmp.data_json)return;const projStr=typeof tmp.data_json==='string'?tmp.data_json:JSON.stringify(tmp.data_json);let proj=tmp.data_json;if(typeof proj==='string'){try{proj=JSON.parse(proj);}catch(e){proj=null;}}if(!proj||!proj.main_session)return;if(projStr===lastAppliedJson){lastAppliedAt=rev.updated_at;return;}// nội dung không đổi
|
||
const result=deserializeProjectFromSchema(proj);if(result&&result.tracks&&result.tracks.length>0){lastAppliedJson=projStr;lastAppliedAt=rev.updated_at;setTracks(result.tracks);loadAudioBuffersForTracks(result.tracks).catch(function(err){console.warn('loadAudioBuffersForTracks (sync) error:',err);});setBpm((result.bpm||120).toString());if(result.sessionTabs&&result.sessionTabs.length>0)setSessionTabs(result.sessionTabs);if(result.subTabs&&result.subTabs.length>0)setSubTabs(result.subTabs);// Không đè masteringSettings khi user đang mở Mastering panel
|
||
// (modal đang chỉnh) — bản đồng bộ từ thiết bị khác
|
||
// không được revert chain đang sửa (ví dụ vừa xóa VST).
|
||
if(result.masteringSettings&&!masteringModalOpenRef.current)setMasteringSettings(result.masteringSettings);if(result.masterVolume!==undefined&&result.masterVolume!==null)setMasterVolume(result.masterVolume);if(result.masterVolL!==undefined&&result.masterVolL!==null)setMasterVolL(result.masterVolL);if(result.masterVolR!==undefined&&result.masterVolR!==null)setMasterVolR(result.masterVolR);if(result.masterLink!==undefined&&result.masterLink!==null)setMasterLink(result.masterLink);if(result.masterMuted!==undefined&&result.masterMuted!==null)setMasterMuted(result.masterMuted);if(result.masterAutoVol!==undefined)setMasterAutoVol(result.masterAutoVol);if(result.masterAutoOn!==undefined)setMasterAutoOn(result.masterAutoOn);const tmpName=proj.metadata&&proj.metadata.title||'Dự án tạm';setProjectName(tmpName);localStorage.setItem('sonic_project_name',tmpName);showToast('Đã đồng bộ dự án từ thiết bị khác','info');}}catch(e){console.warn('[Sync] error:',e);}};const tick=()=>{if(stopped)return;applyRemote().finally(()=>{if(!stopped)timer=setTimeout(tick,3000);});};timer=setTimeout(tick,4000);// sau mount + restore temp
|
||
return()=>{stopped=true;if(timer)clearTimeout(timer);};},[]);// ── Timer-based auto-save (5 min) + backup (30 min) ──
|
||
useEffect(()=>{const BACKUP_MAX_KEY='sonic_backup_max_count';if(!localStorage.getItem(BACKUP_MAX_KEY)){localStorage.setItem(BACKUP_MAX_KEY,'10');}const interval5=setInterval(()=>{if(!currentProjectId)return;// Auto-save: giống handleSaveProject
|
||
if(currentProjectId.startsWith('local_')){const finalName=projectName||'Dự án mới';const localId=currentProjectId;const schemaObj=serializeProjectToSchema(localId,finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const dataStr=JSON.stringify(schemaObj);localStorage.setItem('sonic_local_project_data',dataStr);localStorage.setItem('sonic_project_id',localId);localStorage.setItem('sonic_project_name',finalName);}else if(currentUser&&window.SonicAPI){try{const schemaObj=serializeProjectToSchema(currentProjectId,projectName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const dataJson=JSON.stringify(schemaObj);window.SonicAPI.updateCloudProject(currentProjectId,projectName||'Dự án mới',dataJson).catch(()=>{});}catch(e){console.warn('Auto-save error:',e);}}},5*60*1000);const interval30=setInterval(()=>{if(!currentProjectId||currentProjectId.startsWith('local_')||!currentUser||!window.SonicAPI)return;try{window.SonicAPI.createBackup(currentProjectId).catch(()=>{});}catch(e){console.warn('Backup error:',e);}},30*60*1000);return()=>{clearInterval(interval5);clearInterval(interval30);};},[currentProjectId,projectName,currentUser,tracks,subTabs,sessionTabs,masteringSettings,bpm]);// Lucide icons initialization
|
||
useEffect(()=>{setTimeout(()=>{if(window.lucide){window.lucide.createIcons();}},50);},[activeTool,activeTab,contextMenu,activeTracks]);const timelineWrapperRef=useRef(null);const[timelineWrapperNode,setTimelineWrapperNode]=useState(null);const handleTimelineWrapperRef=useCallback(node=>{timelineWrapperRef.current=node;setTimelineWrapperNode(node);},[]);const tcpContainerRef=useRef(null);const[scrollLeft,setScrollLeft]=useState(0);const handleTimelineScroll=e=>{if(tcpContainerRef.current){tcpContainerRef.current.scrollTop=e.currentTarget.scrollTop;}setScrollLeft(e.currentTarget.scrollLeft);};const handleTCPScroll=e=>{if(timelineWrapperRef.current){timelineWrapperRef.current.scrollTop=e.currentTarget.scrollTop;}};const panelDropZoneRef=useRef(null);const startPanelDrag=(panelId,e)=>{panelDragRef.current={panelId,startX:e.clientX,startY:e.clientY};setPanelDropZone(null);setDragGhostPanel(panelId);setDragGhostPos({x:e.clientX-120,y:e.clientY-20});const onMove=ev=>{if(!panelDragRef.current||!workspaceRef.current)return;const rect=workspaceRef.current.getBoundingClientRect();const x=ev.clientX-rect.left;const y=ev.clientY-rect.top;const w=rect.width;const h=rect.height;const margin=60;let zone=null;if(x<margin&&x>10)zone='left';else if(x>w-margin&&x<w-10)zone='right';else if(y<margin&&y>10)zone='top';else if(y>h-margin&&y<h-10)zone='bottom';panelDropZoneRef.current=zone;setPanelDropZone(zone);setDragGhostPos({x:ev.clientX-120,y:ev.clientY-20});};const onUp=()=>{if(panelDragRef.current){const pid=panelDragRef.current.panelId;const targetZone=panelDropZoneRef.current;if(targetZone&&targetZone!==panelPositions[pid]){setPanelPositions(prev=>({...prev,[pid]:targetZone}));}panelDragRef.current=null;panelDropZoneRef.current=null;setPanelDropZone(null);setDragGhostPos(null);setDragGhostPanel(null);}document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};// ── Right Sidebar Column Resizer ──
|
||
const startColResize=e=>{e.preventDefault();const startX=e.clientX;const startWidth=rightSidebarWidth;const onMove=ev=>{const deltaX=startX-ev.clientX;const newWidth=Math.max(200,Math.min(600,startWidth+deltaX));setRightSidebarWidth(newWidth);};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};// ── TCP Resizer ──
|
||
const startTcpResize=e=>{e.preventDefault();const startX=e.clientX;const startWidth=tcpWidth;const onMove=ev=>{const deltaX=ev.clientX-startX;const newWidth=Math.max(280,Math.min(600,startWidth+deltaX));setTcpWidth(newWidth);};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};// ── Right Sidebar Row Resizer (Media Explorer / AI Panel) ──
|
||
const startRowResize=e=>{e.preventDefault();const startY=e.clientY;const startHeight=mediaExplorerHeight;const sidebarEl=document.getElementById('right-sidebar');const sidebarHeight=sidebarEl?sidebarEl.getBoundingClientRect().height:400;const onMove=ev=>{const deltaY=startY-ev.clientY;const pct=(startHeight/100*sidebarHeight+deltaY)/sidebarHeight*100;const newPct=Math.max(20,Math.min(80,pct));setMediaExplorerHeight(newPct);};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};const rulerRef=useRef(null);const activeSourcesRef=useRef([]);const activeTrackNodesRef=useRef({});// { [trackId]: { gainNode, pannerNode } }
|
||
const nativeLoopSrcsRef=useRef(new Map());// V42A: { [trackId+'::'+clipId]: { src, loopStart, loopEnd } } — native WebAudio loop qua wrap
|
||
const startOffsetTimeRef=useRef(0);const startBufferOffsetRef=useRef(0);const startAudioTimeRef=useRef(0);const wrapHoldRef=useRef(null);// Lỗi 1: wrap-hold — giữ playhead wall tới đúng end khi wrap FX active (audible còn tail ~latencyTotal)
|
||
// V40: real PDC — freeze playhead theo latency FX path (thay V39 freeze giả
|
||
// định 0.31s → under-estimate → playhead chạy trước ~0.66s). __fxRtPlayheadFreeze:
|
||
// gọi ở mọi play start (allowCold=true ở startTrackPlayback che cold-start VST
|
||
// 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(_phArmed()){window.__phSnapAll('ph_freeze_pre');_dbgLog('ph_freeze_pre','allowCold='+(allowCold?1:0)+' chain='+fxRtChain().length);}}catch(e){}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();// V45.16: seek/restart FX active — giữ playhead tại startOffset cho tới khi
|
||
// worklet phát block đầu (fx_play_first). Trước đây playhead chạy theo
|
||
// processedSamples trong lúc re-buffer FILL 24 (~128ms) → playhead trước
|
||
// âm (Bug 1b). Fail-safe 1500ms: engine không phát (session fail) → thả.
|
||
_fxRtSeekFreeze=true;if(_fxRtSeekFreezeTimer){clearTimeout(_fxRtSeekFreezeTimer);}_fxRtSeekFreezeTimer=setTimeout(function(){_fxRtSeekFreeze=false;_fxRtSeekFreezeTimer=null;fxRtAnchorProcessed();},1500);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
|
||
// cho pass mới (old-tail tiêu thụ dbgPlayFirst) nên không có event để snap. Cold
|
||
// start (fxRt.active=false) KHÔNG snap sớm: chờ fx_play_first thật (VST load 4s).
|
||
_fxRtFreezeTimer=setTimeout(function freezeSnapRetry(){_fxRtFreezeTimer=null;if(_wasActiveAtFreeze&&_fxRtPlayheadSnapPending){_fxRtPlayheadSnapPending=false;startAudioTimeRef.current=getAudioContext().currentTime;return;}if(fxRt&&fxRt.starting){if(_fxRtFreezeTries<20){_fxRtFreezeTries++;_fxRtFreezeTimer=setTimeout(freezeSnapRetry,500);}return;}if(!fxRt||!fxRt.active){startAudioTimeRef.current=getAudioContext().currentTime;}// session fail → bỏ freeze
|
||
},_estMs);};window.__fxRtSnapPlayhead=function(){try{if(_phArmed())window.__phSnapAll('ph_snap_pre');}catch(e){}// V45.16: hết seek-freeze — playhead bắt đầu từ startOffset tại mẫu nghe được.
|
||
if(_fxRtSeekFreeze){_fxRtSeekFreeze=false;if(_fxRtSeekFreezeTimer){clearTimeout(_fxRtSeekFreezeTimer);_fxRtSeekFreezeTimer=null;}fxRtAnchorProcessed();return;}if(fxRt&&fxRt.active&&_fxRtPlayheadSnapPending){_fxRtPlayheadSnapPending=false;startAudioTimeRef.current=getAudioContext().currentTime;}};const animationFrameIdRef=useRef(null);const toastTimeoutRef=useRef(null);const rulerDragStartRef=useRef(null);const rulerAnchorRef=useRef(null);const isDraggingRulerRef=useRef(false);const subTabDragStartRef=useRef(null);const isDraggingSubTabRef=useRef(false);const handlePlayPauseRef=useRef(null);const currentTimeRef=useRef(currentTime);// D9: playhead sample counter cho native bridge (A11/A13 dùng để đồng bộ
|
||
// timeline sample-accurate); cập nhật trong updatePlayhead khi bridge active.
|
||
const bridgePlayheadSampleRef=useRef(0);// V10: timer của transport STOP#2 (150ms sau STOP#1, V8 bug 3 guard) — loop
|
||
// restart gọi stopAllPlayback() + startTrackPlayback() cùng lúc, STOP#2 trễ
|
||
// đến sau PLAY → C++ transportStopped=true vĩnh viễn → note-on loop tiếp bị
|
||
// drop → VSTi chỉ play 1 vòng. PLAY hủy timer này.
|
||
const bridgeStopRetryTimerRef=useRef(null);// V11: epoch của lần play hiện tại — stopAllPlayback tăng epoch → mọi note
|
||
// timer (NOTE_ON/NOTE_OFF setTimeout) của vòng cũ bị hủy khi loop restart;
|
||
// nếu không NOTE_OFF trễ của vòng cũ bắn vào note ĐANG kêu vòng mới → cắt
|
||
// đột ngột (click) + chồng voice → mx>1.0 clip → cracking khi loop.
|
||
const bridgeNoteEpochRef=useRef(0);// Resume main/session play khi rời PIANO ROLL tab: mở piano roll lúc main
|
||
// đang play → bấm Space (play piano roll) → stopAllPlayback dừng main →
|
||
// quay lại MAIN/SECTION → CÂM. Lưu {offset, audioTime} lúc mở tab → khi
|
||
// rời tab resume startTrackPlayback(offset + elapsed thật).
|
||
const mainResumeRef=useRef(null);currentTimeRef.current=currentTime;// M22 Bug C fix hook: rebase timeline khi wake warm-session (PWR ON giữa lúc
|
||
// play) — engine processedSamples bị đóng băng suốt OFF-warm (worklet rời
|
||
// graph) nên ps−anchor cũ tụt cả khoảng OFF; nếu không rebase playhead
|
||
// engine-derived nhảy LÙI về vị trí trước OFF (ph_jump d âm lớn). Rebase:
|
||
// dời startOffsetTimeRef/startAudioTimeRef về wall hiện tại + anchor=ps →
|
||
// engine clock nối lại đúng chỗ, playhead liên tục (lệch ~latencyTotal).
|
||
window.__fxRtWakeRebase=function(){try{if(!fxRt||!fxRt.active)return;const _c=getAudioContext();const _wall=(startOffsetTimeRef.current||0)+(_c.currentTime-(startAudioTimeRef.current||_c.currentTime));const _pa=typeof fxRt.processedSamples==='number'?fxRt.processedSamples:null;if(fxRtWarmPlayHold){// M23 Bug C: wake tu OFF-giua-play — off/at DA rebase ve content tai OFF;
|
||
// rebase tiep toi content-hien-tai (off + elapsed-warm) roi anchor pa =
|
||
// ps - lat de engine-display noi lien content (het freeze ~0.37s + am
|
||
// resume tre 1/4 beat nhu M22). Ha co hold.
|
||
const _contentNow=(startOffsetTimeRef.current||0)+(_c.currentTime-(startAudioTimeRef.current||_c.currentTime));startOffsetTimeRef.current=_contentNow;startAudioTimeRef.current=_c.currentTime;if(_pa!==null)fxRt.processedAnchor=_pa-Math.round(fxRt.latencyTotal||0);fxRtWarmPlayHold=false;}else{// (M22) rebase wall — khi khong OFF-giua-play (warm tu stop), off/at la
|
||
// grid that nen giu hanh vi cu.
|
||
startOffsetTimeRef.current=_wall;startAudioTimeRef.current=_c.currentTime;if(_pa!==null)fxRt.processedAnchor=_pa;}if(_fxRtSeekFreeze){_fxRtSeekFreeze=false;if(_fxRtSeekFreezeTimer){clearTimeout(_fxRtSeekFreezeTimer);_fxRtSeekFreezeTimer=null;}}try{_dbgLog('ph_rebase','wall='+_wall.toFixed(3)+' at='+startAudioTimeRef.current.toFixed(3)+' off='+startOffsetTimeRef.current.toFixed(3)+' ps='+(typeof fxRt.processedSamples==='number'?fxRt.processedSamples:'na')+' pa='+(typeof fxRt.processedAnchor==='number'?fxRt.processedAnchor:'na')+' lat='+(fxRt.latencyTotal||0));}catch(e){}}catch(e){try{_dbgLog('ph_rebase','err '+String(e));}catch(e2){}}};// M21 ph-diag hooks (Bug C — playhead nhay khi PWR master FX toggle)
|
||
// __phArmPwr: user bam PWR OFF/ON -> arm cua so log 6s + snapshot ngay lap tuc.
|
||
// __phSnapAll: snapshot refs/fxRt tai diem goi (chi log khi cua so dang mo).
|
||
window.__phArmPwr=function(dir){try{_phDiagArmedUntil=Date.now()+6000;_dbgLog('ph_pwr','dir='+dir+' cur='+(currentTimeRef.current==null?'na':currentTimeRef.current.toFixed(3))+' off='+(startOffsetTimeRef.current==null?'na':startOffsetTimeRef.current.toFixed(3))+' at='+(startAudioTimeRef.current==null?'na':startAudioTimeRef.current.toFixed(3))+' ctx='+(getAudioContext().currentTime||0).toFixed(3)+' fx='+(fxRt?'act='+(fxRt.active?1:0)+' warm='+(fxRt.warm?1:0)+' start='+(fxRt.starting?1:0)+' ps='+(typeof fxRt.processedSamples==='number'?fxRt.processedSamples:'na')+' pa='+(typeof fxRt.processedAnchor==='number'?fxRt.processedAnchor:'na')+' lat='+(fxRt.latencyTotal||0)+' pdc='+(fxRt.pdcCalibMs||0)+' sid='+(fxRt.sessionId||'na'):'na')+' freeze='+(_fxRtSeekFreeze?1:0)+' snap='+(_fxRtPlayheadSnapPending?1:0)+' wh='+(wrapHoldRef.current?wrapHoldRef.current.end.toFixed(3)+'/'+wrapHoldRef.current.v0.toFixed(3):'na'));}catch(e){try{_dbgLog('ph_pwr','err '+String(e));}catch(e2){}}};window.__phSnapAll=function(tag){try{if(!_phArmed())return;_dbgLog(tag,'cur='+(currentTimeRef.current==null?'na':currentTimeRef.current.toFixed(3))+' off='+(startOffsetTimeRef.current==null?'na':startOffsetTimeRef.current.toFixed(3))+' at='+(startAudioTimeRef.current==null?'na':startAudioTimeRef.current.toFixed(3))+' ctx='+(getAudioContext().currentTime||0).toFixed(3)+' fx='+(fxRt?'act='+(fxRt.active?1:0)+' warm='+(fxRt.warm?1:0)+' start='+(fxRt.starting?1:0)+' ps='+(typeof fxRt.processedSamples==='number'?fxRt.processedSamples:'na')+' pa='+(typeof fxRt.processedAnchor==='number'?fxRt.processedAnchor:'na')+' lat='+(fxRt.latencyTotal||0)+' pdc='+(fxRt.pdcCalibMs||0)+' sid='+(fxRt.sessionId||'na'):'na')+' freeze='+(_fxRtSeekFreeze?1:0)+' snap='+(_fxRtPlayheadSnapPending?1:0)+' wh='+(wrapHoldRef.current?wrapHoldRef.current.end.toFixed(3)+'/'+wrapHoldRef.current.v0.toFixed(3):'na'));}catch(e){try{_dbgLog(tag,'err '+String(e));}catch(e2){}}};// ── Keyboard Shortcuts ──
|
||
const handleUndoRef=useRef(handleUndo);const handleRedoRef=useRef(handleRedo);handleUndoRef.current=handleUndo;handleRedoRef.current=handleRedo;const handleDeleteSelectedItemsRef=useRef(()=>{});handleDeleteSelectedItemsRef.current=idsToDelete=>{const count=idsToDelete.size;// Close piano roll tabs của midi items bị xóa (user 07:40 — áp cả
|
||
// SECTION-TAB: xóa midi item → close tab đã mở của item đó)
|
||
setSubTabs(prev=>{const next=prev.filter(s=>!(s.target_id&&idsToDelete.has(s.target_id)));// CHỈ về main khi tab đang active là PIANO ROLL (midi_*) bị close —
|
||
// KHÔNG đá SECTION-TAB về main (user 07:45)
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('midi_')&&!next.some(s=>s.id===activeTabRef.current)){setActiveTab('main');}return next;});// Sync nội dung section tab → section item trên MAIN (canvas vẽ lại —
|
||
// user 07:45) — setTimeout 0: chạy SAU updateActiveTracks (data mới)
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('session_')){const _tabId=activeTabRef.current;setTimeout(()=>{try{syncSectionTabToMain(_tabId);}catch(e){}},0);}setSelectedItemIds(new Set());updateActiveTracks(prev=>prev.map(t=>{let changed=false;const sections=(t.sections||[]).filter(s=>{if(idsToDelete.has(s.id)){changed=true;return false;}return true;});const midiItems=(t.midiItems||[]).filter(m=>{if(idsToDelete.has(m.id)){changed=true;return false;}return true;});let clips=t.clips||[];const hasVirtualClip=!t.clips||t.clips.length===0;if(hasVirtualClip&&t.buffer){const canonical='default_'+t.id;if(idsToDelete.has(canonical)){changed=true;return{...t,sections,midiItems,clips:[],buffer:null,startTime:0};}}const updatedClips=clips.filter(c=>{const canonical=c.id==='default'?'default_'+t.id:c.id;if(idsToDelete.has(canonical)){changed=true;return false;}return true;});if(!changed&§ions.length===(t.sections||[]).length&&midiItems.length===(t.midiItems||[]).length){return t;}return{...t,sections,midiItems,clips:updatedClips,buffer:updatedClips.length>0?updatedClips[0].buffer:null,startTime:updatedClips.length>0?updatedClips[0].startTime:0,name:updatedClips.length>0?updatedClips[0].name:t.name};}));showToast(count===1?'Đã xóa 1 item.':`Đã xóa ${count} items.`,'info');};const selectedClipIdRef=useRef(null);selectedClipIdRef.current=selectedClipId;const selectedTrackIdRef=useRef(selectedTrackId);selectedTrackIdRef.current=selectedTrackId;// A14: plugin MIDI-out routing target — bootstrap handler (module scope)
|
||
// reads the live selection through this window ref (avoid stale closure).
|
||
window.__selectedTrackIdRef=selectedTrackIdRef;const handleDeleteTrackRef=useRef(()=>{});const handleSplitTrackRef=useRef(()=>{});const handleRecordClickRef=useRef(()=>{});const activeTabRef=useRef(activeTab);activeTabRef.current=activeTab;const activePlaybackSpeedRef=useRef(1.0);const subTabsRef=useRef(subTabs);subTabsRef.current=subTabs;const subTabSelectedNodeTimeRef=useRef(null);subTabSelectedNodeTimeRef.current=subTabSelectedNodeTime;const handleSubTabNormalize=tabId=>{setSubTabs(prev=>prev.map(st=>{if(st.id!==tabId||!st.buffer)return st;const ctx=getAudioContext();const data=st.buffer.getChannelData(0);const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):0;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):st.buffer.duration;const startSample=Math.floor(left*st.buffer.sampleRate);const endSample=Math.floor(right*st.buffer.sampleRate);let maxVal=0;for(let i=startSample;i<endSample;i++){const abs=Math.abs(data[i]);if(abs>maxVal)maxVal=abs;}if(maxVal===0)return st;const scale=1.0/maxVal;const clonedBuffer=ctx.createBuffer(1,Math.max(1,data.length),st.buffer.sampleRate);const clonedData=clonedBuffer.getChannelData(0);clonedData.set(data);for(let i=startSample;i<endSample;i++){clonedData[i]=Math.max(-1,Math.min(1,clonedData[i]*scale));}showToast('Đã Normalize vùng chọn.','success');return{...st,buffer:clonedBuffer};}));};const handleSubTabGain=(tabId,gainDb)=>{setSubTabs(prev=>prev.map(st=>{if(st.id!==tabId||!st.buffer)return st;const ctx=getAudioContext();const data=st.buffer.getChannelData(0);const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):0;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):st.buffer.duration;const startSample=Math.floor(left*st.buffer.sampleRate);const endSample=Math.floor(right*st.buffer.sampleRate);const scale=Math.pow(10,gainDb/20);const clonedBuffer=ctx.createBuffer(1,Math.max(1,data.length),st.buffer.sampleRate);const clonedData=clonedBuffer.getChannelData(0);clonedData.set(data);for(let i=startSample;i<endSample;i++){clonedData[i]=Math.max(-1,Math.min(1,clonedData[i]*scale));}showToast(`Đã điều chỉnh Gain: ${gainDb} dB.`,'success');return{...st,buffer:clonedBuffer};}));};const handleSubTabFade=(tabId,type)=>{setSubTabs(prev=>prev.map(st=>{if(st.id!==tabId||!st.buffer)return st;const ctx=getAudioContext();const data=st.buffer.getChannelData(0);const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):0;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):st.buffer.duration;const startSample=Math.floor(left*st.buffer.sampleRate);const endSample=Math.floor(right*st.buffer.sampleRate);const durationSamples=endSample-startSample;if(durationSamples<=0)return st;const clonedBuffer=ctx.createBuffer(1,Math.max(1,data.length),st.buffer.sampleRate);const clonedData=clonedBuffer.getChannelData(0);clonedData.set(data);if(type==='in'){for(let i=0;i<durationSamples;i++){const alpha=i/durationSamples;clonedData[startSample+i]*=alpha;}showToast('Đã áp dụng Fade In.','success');}else if(type==='out'){for(let i=0;i<durationSamples;i++){const alpha=(durationSamples-i)/durationSamples;clonedData[startSample+i]*=alpha;}showToast('Đã áp dụng Fade Out.','success');}return{...st,buffer:clonedBuffer};}));};const handleSubTabCut=tabId=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):null;if(left===null||right===null||left===right){showToast('Vui lòng chọn vùng để Cut.','warning');return;}const ctx=getAudioContext();const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const startSample=Math.floor(left*sr);const endSample=Math.floor(right*sr);const len=Math.max(1,endSample-startSample);// guard 0 frames → createBuffer crash
|
||
const cutBuffer=ctx.createBuffer(1,len,sr);cutBuffer.copyToChannel(data.subarray(startSample,endSample),0);clipboardRef.current={buffer:cutBuffer,name:'Subtab Clip'};const newBuffer=ctx.createBuffer(1,Math.max(1,data.length-len),sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;i<startSample;i++)newData[idx++]=data[i];for(let i=endSample;i<data.length;i++)newData[idx++]=data[i];setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,buffer:newBuffer,currentTime:left,selectionStart:null,selectionEnd:null}:s));showToast('Đã Cut vùng chọn.','success');};const handleSubTabCopy=tabId=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):null;if(left===null||right===null||left===right){showToast('Vui lòng chọn vùng để Copy.','warning');return;}const ctx=getAudioContext();const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const startSample=Math.floor(left*sr);const endSample=Math.floor(right*sr);const len=Math.max(1,endSample-startSample);// guard 0 frames → createBuffer crash
|
||
const copyBuffer=ctx.createBuffer(1,len,sr);copyBuffer.copyToChannel(data.subarray(startSample,endSample),0);clipboardRef.current={buffer:copyBuffer,name:'Subtab Clip',sampleRate:sr,channels:1,speed:1.0};showToast('Đã Copy vùng chọn.','success');};const handleSubTabPaste=tabId=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st)return;// Check if Piano Roll Tab
|
||
if(st.type==='PIANO_ROLL'){const clip=clipboardRef.current||window.globalStudioClipboard;if(!clip||clip.type!=='midi'||!clip.notes){showToast('Clipboard không chứa dữ liệu MIDI.','warning');return;}const secondsPerBeat=60/(bpm||120);const pasteBeat=(st.currentTime||0)/secondsPerBeat;const newNotes=clip.notes.map(n=>({id:'note_'+Math.random().toString(36).substr(2,9),pitch:n.pitch,start_beat:pasteBeat+n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8}));setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,notes:[...(s.notes||[]),...newNotes]}:s));showToast('Đã dán note MIDI vào Piano Roll.','success');return;}// Default Waveform Audio Paste
|
||
if(!st.buffer)return;const clip=clipboardRef.current||window.globalStudioClipboard;if(!clip||!clip.buffer){showToast('Clipboard trống hoặc không chứa dữ liệu âm thanh.','warning');return;}const ctx=getAudioContext();const clipBuf=clip.buffer;const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const insertTime=st.currentTime||0;const insertSample=Math.floor(insertTime*sr);const newBuffer=ctx.createBuffer(1,Math.max(1,data.length+clipBuf.length),sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;i<insertSample;i++)newData[idx++]=data[i];const clipData=clipBuf.getChannelData(0);for(let i=0;i<clipBuf.length;i++)newData[idx++]=clipData[i];for(let i=insertSample;i<data.length;i++)newData[idx++]=data[i];setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,buffer:newBuffer,currentTime:insertTime+clipBuf.duration,selectionStart:null,selectionEnd:null}:s));showToast('Đã dán dữ liệu âm thanh.','success');};const handleSubTabDelete=tabId=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):null;if(left===null||right===null||left===right){showToast('Vui lòng chọn vùng để Delete.','warning');return;}const ctx=getAudioContext();const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const startSample=Math.floor(left*sr);const endSample=Math.floor(right*sr);const len=endSample-startSample;const newBuffer=ctx.createBuffer(1,data.length-len,sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;i<startSample;i++)newData[idx++]=data[i];for(let i=endSample;i<data.length;i++)newData[idx++]=data[i];setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,buffer:newBuffer,currentTime:left,selectionStart:null,selectionEnd:null}:s));showToast('Đã xóa vùng chọn.','success');};const handleSubTabLoop=(tabId,loopCount)=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):null;if(left===null||right===null||left===right){showToast('Vui lòng chọn vùng để Loop.','warning');return;}const ctx=getAudioContext();const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const startSample=Math.floor(left*sr);const endSample=Math.floor(right*sr);const len=endSample-startSample;// Loop payload N times
|
||
const segmentData=data.subarray(startSample,endSample);const addedSamples=len*(loopCount-1);const newBuffer=ctx.createBuffer(1,data.length+addedSamples,sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;i<endSample;i++)newData[idx++]=data[i];for(let n=0;n<loopCount-1;n++){for(let i=0;i<len;i++)newData[idx++]=segmentData[i];}for(let i=endSample;i<data.length;i++)newData[idx++]=data[i];setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,buffer:newBuffer,selectionStart:null,selectionEnd:null}:s));showToast(`Đã lặp vùng chọn ${loopCount} lần.`,'success');};// ── Item copy/cut helpers (Ctrl+C/X — user 07:50: phải copy/cut ITEM được
|
||
// chọn, không phải track) ──
|
||
const findItemContext=itemId=>{for(const t of activeTracksRef.current||[]){if((t.midiItems||[]).some(m=>m.id===itemId))return{trackId:t.id,itemType:'midi'};if((t.clips||[]).some(c=>c.id===itemId))return{trackId:t.id,itemType:'clip'};}return null;};const copyItemToClipboard=(trackId,itemId,itemType)=>{// Dùng REF (không phải activeTracks closure cũ): Ctrl+C/X chạy trong keydown
|
||
// effect deps [] → closure giữ activeTracks RENDER ĐẦU — item TẠO SAU phiên
|
||
// (vd midi_1786099252336) không tồn tại trong closure cũ → copy fail → cắt
|
||
// nhầm track (user 08:10 — log [CutItem] found= 2/midi nhưng không cắt).
|
||
const trk=(activeTracksRef.current||[]).find(t=>t.id===trackId);if(!trk)return false;const isMidi=itemType==='midi'||!itemType&&(trk.midiItems||[]).some(m=>m.id===itemId);if(isMidi){const item=(trk.midiItems||[]).find(m=>m.id===itemId);if(!item)return false;// duration midiItem tính GIÂY (insertMidiItem = 4*spb — scheduling dùng
|
||
// seconds) — KHÔNG nhân (60/bpm) — nhân làm duration gấp đôi → block
|
||
// paste bị dài/ngắn sai (user 07:55).
|
||
clipboardRef.current={type:'midi',notes:(item.notes||[]).map(n=>({...n})),duration:item.duration||4,name:item.name||'MIDI Item',color:item.color||'#a855f7'};window.globalStudioClipboard=clipboardRef.current;return true;}const item=(trk.clips||[]).find(c=>c.id===itemId);if(item&&item.buffer){clipboardRef.current={buffer:item.buffer,name:item.name||trk.name,volumeDb:trk.volumeDb,pan:trk.pan,color:item.color||trk.color,sampleRate:item.buffer.sampleRate,channels:item.buffer.numberOfChannels,speed:item.speed||1.0};window.globalStudioClipboard=clipboardRef.current;return true;}return false;};useEffect(()=>{const handler=e=>{// Bypass global hotkeys when typing inside input/textarea/contentEditable elements
|
||
if(e.target&&(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.isContentEditable)){// PIANO ROLL tab: Space LUÔN play/stop (user yêu cầu — transport phải
|
||
// capture được kể cả khi có input đang focus trong tab) — các phím
|
||
// khác vẫn bypass để gõ liệu bình thường.
|
||
if(!(e.key===' '||e.code==='Space')||!activeTabRef.current||!activeTabRef.current.startsWith('midi_')){return;}}const ctrl=e.ctrlKey||e.metaKey;const alt=e.altKey;// Global space play/pause shortcut for transport
|
||
if(e.key===' '||e.code==='Space'){if(window.mediaExplorerActive)return;e.preventDefault();// Ngăn space kích hoạt BUTTON đang focus (tab header/control):
|
||
// browser fire click tổng hợp ở KEYUP space → click tab header →
|
||
// CHUYỂN TAB (nhìn như space không play/stop). Blur button trước khi
|
||
// play/stop — keyup sau đó target body → không click.
|
||
try{const _ae=document.activeElement;if(_ae&&_ae.tagName==='BUTTON')_ae.blur();}catch(err){}if(recordingStateRef.current==='RECORDING'||recordingStateRef.current==='COUNT_IN'){handleRecordClickRef.current();return;}if(handlePlayPauseRef.current)handlePlayPauseRef.current();return;}if(activeTabRef.current!=='main'&&!activeTabRef.current.startsWith('session_')){// Sub-Tab keyboard shortcuts mapping
|
||
const curTabId=activeTabRef.current;if(ctrl&&alt&&e.key==='n'){e.preventDefault();handleSubTabNormalize(curTabId);return;}if(e.key==='f'||e.key==='F'){e.preventDefault();handleSubTabFade(curTabId,'in');return;}if(e.key==='g'||e.key==='G'){e.preventDefault();handleSubTabFade(curTabId,'out');return;}if(ctrl&&e.key==='l'){e.preventDefault();handleSubTabLoop(curTabId,4);return;}if(!ctrl&&!e.metaKey&&!e.altKey&&(e.key==='v'||e.key==='V')){e.preventDefault();const val=prompt("Nhập Gain điều chỉnh (dB):","0");if(val)handleSubTabGain(curTabId,parseFloat(val)||0);return;}if(ctrl&&e.key==='x'){e.preventDefault();handleSubTabCut(curTabId);return;}if(ctrl&&e.key==='c'){e.preventDefault();handleSubTabCopy(curTabId);return;}if(ctrl&&e.key==='v'){e.preventDefault();handleSubTabPaste(curTabId);return;}if(e.key==='Delete'||e.key==='Backspace'||e.key==='Del'){e.preventDefault();if(subTabSelectedNodeTimeRef.current!==null){const selTime=subTabSelectedNodeTimeRef.current;setSubTabs(prev=>prev.map(s=>{if(s.id!==curTabId)return s;const curNodes=s.graphMode==='pan'?s.panningNodes||[]:s.volumeNodes||[];const updated=curNodes.filter(n=>n.time!==selTime);return{...s,[s.graphMode==='pan'?'panningNodes':'volumeNodes']:updated};}));setSubTabSelectedNodeTime(null);}else{handleSubTabDelete(curTabId);}return;}return;}if(ctrl&&e.key==='z'&&!e.shiftKey){const tag=document.activeElement?.tagName;if(tag==='INPUT'||tag==='TEXTAREA')return;e.preventDefault();handleUndoRef.current();return;}if(ctrl&&(e.key==='y'||e.key==='z'&&e.shiftKey)){const tag=document.activeElement?.tagName;if(tag==='INPUT'||tag==='TEXTAREA')return;e.preventDefault();handleRedoRef.current();return;}if(ctrl&&!alt&&e.key==='o'){e.preventDefault();handleImportSFS();return;}if(ctrl&&!alt&&e.key==='n'){e.preventDefault();// New project: đóng hết tab + reset MỌI trạng thái về mặc định
|
||
try{stopAllPlayback();}catch(e){}setSubTabs([]);setSessionTabs([]);setActiveTab('main');trackMidiChannelsRef.current={};Object.keys(trackMidiBypassMap).forEach(function(k){delete trackMidiBypassMap[k];});Object.keys(trackAudioBypassMap).forEach(function(k){delete trackAudioBypassMap[k];});Object.keys(trackMasteringBypassMap).forEach(function(k){delete trackMasteringBypassMap[k];});setSelectedItemIds(new Set());setTracks([{id:'1',name:'Track 01',buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:'#0f766e',markers:[],serverFileId:null},{id:'2',name:'Track 02',buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:'#1d4ed8',markers:[],serverFileId:null}]);setSelectedTrackId('1');setProjectName('');setCurrentProjectId(null);localStorage.removeItem('sonic_project_name');localStorage.removeItem('sonic_project_id');showToast('New project created','info');return;}if(ctrl&&!alt&&!e.shiftKey&&e.key==='a'){e.preventDefault();const curTab=activeTabRef.current;if(curTab!=='main'&&!curTab.startsWith('session_'))return;captureSelectionUndo();const allIds=new Set();(activeTracksRef.current||[]).forEach(t=>{(t.sections||[]).forEach(s=>allIds.add(s.id));(t.midiItems||[]).forEach(m=>allIds.add(m.id));const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default',buffer:t.buffer,startTime:t.startTime||0,name:t.name,speed:t.speed||1.0}]:[];clips.forEach(c=>allIds.add(c.id==='default'?'default_'+t.id:c.id));});setSelectedItemIds(allIds);pushSelectionUndo();return;}if(ctrl&&!alt&&e.key==='s'){e.preventDefault();const curTab=activeTabRef.current;if(curTab&&curTab.startsWith('session_')){// SECTION-TAB: Ctrl+S = Lưu section (như nút Lưu section)
|
||
handleSaveSectionTabRef.current(curTab);showToast('Đã lưu Section','success');}else{handleSaveProjectRef.current();}return;}if(ctrl&&alt&&e.key==='s'||ctrl&&e.shiftKey&&e.key==='s'){e.preventDefault();handleExportSFS();return;}if(ctrl&&!alt&&e.key==='i'){e.preventDefault();addNewTrack();return;}if(ctrl&&alt&&e.key==='i'){e.preventDefault();showToast('Import audio','info');return;}if(ctrl&&!alt&&e.key==='e'){e.preventDefault();openTempTab();return;}if(ctrl&&!alt&&e.key==='m'){e.preventDefault();handleMergeTracks();return;}if(ctrl&&!alt&&e.key==='c'){e.preventDefault();const selItems=selectedItemIdsRef.current;if(selItems&&selItems.size>0){// Copy ITEM đầu tiên được chọn (user 07:50 — Ctrl+C phải copy item)
|
||
const firstId=selItems.values().next().value;const found=findItemContext(firstId);if(found&©ItemToClipboard(found.trackId,firstId,found.itemType)){showToast('Đã sao chép item.','info');}else{handleCopyTrack();}}else{handleCopyTrack();}return;}if(ctrl&&!alt&&e.key==='x'){e.preventDefault();e.stopPropagation();// chặn browser cut (user 07:55 — Ctrl-X bị capture)
|
||
const selItems=selectedItemIdsRef.current;if(selItems&&selItems.size>0){// Cut ITEM đầu tiên được chọn: copy + xóa item (+ close piano roll
|
||
// tab + sync section — handleDeleteSelectedItemsRef đã xử lý)
|
||
const firstId=selItems.values().next().value;const found=findItemContext(firstId);console.log('[CutItem] Ctrl+X — itemId=',firstId,'found=',found?found.trackId+'/'+found.itemType:'null','selSize=',selItems.size);if(found&©ItemToClipboard(found.trackId,firstId,found.itemType)){handleDeleteSelectedItemsRef.current(new Set([firstId]));showToast('Đã cắt item.','info');}else{handleCutTrack();}}else{handleCutTrack();}return;}if(ctrl&&!alt&&e.key==='v'){e.preventDefault();// Dùng REF — effect deps [] → handlePasteTrack closure CŨ (selectedTrackId
|
||
// stale = '1' → paste sai track — user 08:05)
|
||
handlePasteTrackRef.current();return;}if(e.key==='Delete'||e.key==='Backspace'||e.key==='Del'){const selItems=selectedItemIdsRef.current;if(selItems.size>0){e.preventDefault();const idsToDelete=new Set(selItems);handleDeleteSelectedItemsRef.current(idsToDelete);return;}const selClip=selectedClipIdRef.current;if(selClip){e.preventDefault();const{trackId,clipId}=selClip;setTracks(prev=>{const track=prev.find(t=>t.id===trackId);if(!track)return prev;const beforeSnap=captureTrackSnapshotRef.current?captureTrackSnapshotRef.current(trackId):null;const updatedClips=(track.clips||[]).filter(c=>c.id!==clipId);const updatedTracks=prev.map(t=>{if(t.id===trackId){return{...t,clips:updatedClips,buffer:updatedClips.length>0?updatedClips[0].buffer:null,startTime:updatedClips.length>0?updatedClips[0].startTime:0,name:updatedClips.length>0?updatedClips[0].name:`Track ${t.id}`};}return t;});setTimeout(()=>{const afterSnap=captureTrackSnapshotRef.current?captureTrackSnapshotRef.current(trackId):null;pushAction('DELETE_CLIP',trackId,beforeSnap,afterSnap);},50);return updatedTracks;});setSelectedClipId(null);showToast('Đã xóa clip.','info');return;}else{e.preventDefault();handleDeleteTrackRef.current();return;}}if(ctrl&&!alt&&e.key==='s'){e.preventDefault();const curTab=activeTabRef.current;if(curTab==='main'||curTab&&curTab.startsWith('session_')){// MAIN (hoặc SECTION): save project + save TẤT CẢ dirty sub-tabs
|
||
// (piano roll/audio/section) — user 07:35: nhấn lưu ở MAIN phải lưu
|
||
// cả dirty tab (trước đây nhánh main RỖNG — dirty piano roll bị mất).
|
||
handleSaveProject();subTabsRef.current.filter(s=>s.isDirty).forEach(st=>{if(st.type==='PIANO_ROLL'){handleSaveMidiNotes(st.id,st.trackId,st.target_id,st.notes||[]);}else if(st.type==='SECTION'){handleSaveSectionTab(st.id);}else if(st.buffer){// Audio clip sub-tab: save buffer to track
|
||
const subTrack=activeTracksRef.current.find(t=>t.id===st.trackId);if(subTrack){updateActiveTracks(prev=>prev.map(t=>t.id===st.trackId?{...t,buffer:st.buffer}:t));}}});if(curTab.startsWith('session_')){// SECTION-TAB: cũng lưu section hiện tại
|
||
handleSaveSectionTabRef.current(curTab);}showToast('Đã lưu dự án + các tab đã sửa','success');}else{// Sub-tab: save current tab
|
||
const st=subTabsRef.current.find(s=>s.id===curTab);if(st){if(st.type==='PIANO_ROLL'){handleSaveMidiNotes(st.id,st.trackId,st.target_id,st.notes||[]);showToast('Đã lưu Piano Roll','success');}else if(st.type==='SECTION'){handleSaveSectionTab(st.id);showToast('Đã lưu Section Tab','success');}else if(st.buffer){const subTrack=activeTracksRef.current.find(t=>t.id===st.trackId);if(subTrack){updateActiveTracks(prev=>prev.map(t=>t.id===st.trackId?{...t,buffer:st.buffer}:t));}showToast('Đã lưu Audio Tab','success');}}}return;}if(!ctrl&&!alt&&e.key==='s'){e.preventDefault();handleSplitTrackRef.current(selectedTrackIdRef.current);return;}if(e.shiftKey&&!e.ctrlKey&&!e.altKey&&(e.key==='K'||e.key==='k')){e.preventDefault();e.stopPropagation();if(window.__toggleChordsPanel)window.__toggleChordsPanel();return;}if(e.key==='F7'){e.preventDefault();e.stopPropagation();window.__toggleMixerRef();return;}if(e.key==='F6'){e.preventDefault();e.stopPropagation();window.__toggleMediaExplorerRef();return;}};window.addEventListener('keydown',handler,{capture:true});return()=>window.removeEventListener('keydown',handler,{capture:true});},[]);// ── Temp Tab: draw isolated waveform ──
|
||
useEffect(()=>{if(!tempTabActive||!tempTabBuffer||!tempTabCanvasRef.current)return;const canvas=tempTabCanvasRef.current;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const rect=canvas.getBoundingClientRect();canvas.width=rect.width*dpr;canvas.height=rect.height*dpr;ctx.scale(dpr,dpr);const w=rect.width;const h=rect.height;ctx.fillStyle='#181818';ctx.fillRect(0,0,w,h);const data=tempTabBuffer.getChannelData(0);const sr=tempTabBuffer.sampleRate;const totalSamples=data.length;if(totalSamples===0)return;ctx.strokeStyle='#6ee7b7';ctx.lineWidth=1;for(let px=0;px<w;px++){const startSample=Math.floor(px/w*totalSamples);const endSample=Math.floor((px+1)/w*totalSamples);let maxVal=0;for(let i=startSample;i<endSample&&i<totalSamples;i++){const abs=Math.abs(data[i]);if(abs>maxVal)maxVal=abs;}const mid=h/2;const peakHeight=maxVal*(h*0.4);ctx.beginPath();ctx.moveTo(px,mid-peakHeight);ctx.lineTo(px,mid+peakHeight);ctx.stroke();}},[tempTabActive,tempTabBuffer]);// ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ──
|
||
const openTempTab=()=>{const useLocal=selectionMode==='local'&&localSelectionTrackId;const trackId=useLocal?localSelectionTrackId:selectedTrackId;const t=tracks.find(x=>x.id===trackId);if(!t||!t.buffer){showToast('Vui lòng chọn track có dữ liệu âm thanh.','warning');return;}if(selLeft===null||selRight===null||selRight<=selLeft){showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.','warning');return;}// Check if a subtab for this track+range already exists
|
||
const existing=subTabs.find(s=>s.trackId===trackId&&s.startTime===selLeft&&s.endTime===selRight);if(existing){setActiveTab(existing.id);showToast(`Sub Tab already open.`,'info');return;}const sr=t.buffer.sampleRate;const trackStart=t.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.max(0,Math.floor(relSelLeft*sr));const endSample=Math.min(t.buffer.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len<100){showToast('Khoảng chọn quá ngắn.','warning');return;}const ctx=getAudioContext();const numChannels=t.buffer.numberOfChannels||1;const subBuffer=ctx.createBuffer(numChannels,len,sr);for(let c=0;c<numChannels;c++){subBuffer.copyToChannel(t.buffer.getChannelData(c).subarray(startSample,endSample),c);}const subChannelInfo=window.SonicAudio.analyzeAudioBufferChannels(subBuffer);const tabId='subtab_'+Date.now();const tabLabel=`Edit_${t.name.replace('.wav','').slice(0,10)}_${selLeft.toFixed(1)}s`;setSubTabs(prev=>[...prev,{id:tabId,label:tabLabel,trackId:trackId,startTime:selLeft,endTime:selRight,buffer:subBuffer,channelInfo:subChannelInfo,effects:{reverse:false,gainDb:0,fadeInMs:0,fadeOutMs:0,normalizeDb:0,pitch:0,speedStretch:100},currentTime:0,selectionStart:null,selectionEnd:null,isPlaying:false,speed:1.0,volumeNodes:[],panningNodes:[],fadeInLen:0,fadeOutLen:0,graphMode:null,isLooping:false,loopCount:0}]);setActiveTab(tabId);};const handleEditClipInSubTab=(trackId,clipId)=>{const track=activeTracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];const clip=clips.find(c=>c.id===clipId||clipId==='default'&&c.id==='default_'+trackId);if(!clip||!clip.buffer)return;const resolvedClipId=clip.id==='default'?'default_'+trackId:clip.id;const existing=subTabs.find(s=>s.clipId===resolvedClipId&&s.trackId===trackId);if(existing){setActiveTab(existing.id);showToast(`Sub Tab for "${clip.name}" already open.`,'info');return;}const sr=clip.buffer.sampleRate;const len=clip.buffer.length;const numChannels=clip.buffer.numberOfChannels||1;const ctx=getAudioContext();const subBuffer=ctx.createBuffer(numChannels,len,sr);for(let c=0;c<numChannels;c++){subBuffer.copyToChannel(clip.buffer.getChannelData(c),c);}const subChannelInfo=window.SonicAudio.analyzeAudioBufferChannels(subBuffer);const tabId='subtab_'+Date.now();const tabLabel=`Edit_${clip.name.replace('.wav','').slice(0,10)}`;setSubTabs(prev=>[...prev,{id:tabId,label:tabLabel,trackId:trackId,clipId:resolvedClipId,startTime:clip.startTime,endTime:clip.startTime+clip.buffer.duration,buffer:subBuffer,channelInfo:subChannelInfo,effects:{reverse:false,gainDb:0,fadeInMs:0,fadeOutMs:0,normalizeDb:0,pitch:0,speedStretch:100},currentTime:0,selectionStart:null,selectionEnd:null,isPlaying:false,speed:1.0,volumeNodes:[],panningNodes:[],fadeInLen:0,fadeOutLen:0,graphMode:null,isLooping:false,loopCount:0}]);setActiveTab(tabId);};const handleEditMidiInTab=(trackId,midiItemId)=>{const track=activeTracks.find(t=>t.id===trackId);if(!track)return;const midiItem=(track.midiItems||[]).find(m=>m.id===midiItemId);if(!midiItem)return;// Main đang play → CHỈ lưu điểm resume; useEffect [activeTab] tự
|
||
// stopAllPlayback + play CHỈ track piano roll khi vào tab (user: piano roll
|
||
// play only midi notes đang active — mọi track khác mute). mainResumeRef
|
||
// giữ để quay lại MAIN/SECTION resume đúng vị trí.
|
||
if(isPlaying){// Lưu điểm resume: nếu user bấm Space trong piano roll (play piano roll
|
||
// → stopAllPlayback dừng main) → khi quay lại MAIN/SECTION resume main
|
||
// từ đúng vị trí âm (offset + elapsed thật) — hết câm.
|
||
mainResumeRef.current={offset:startOffsetTimeRef.current,audioTime:getAudioContext().currentTime};}else{mainResumeRef.current=null;stopAllPlayback();}const ctx=getAudioContext();const silentBuffer=ctx.createBuffer(1,128,ctx.sampleRate);const existing=subTabs.find(s=>s.type==='PIANO_ROLL'&&s.target_id===midiItemId);if(existing){setSubTabs(prev=>prev.map(s=>s.id===existing.id?{...s,buffer:s.buffer||silentBuffer,instrumentProgram:track.instrumentProgram!==undefined?track.instrumentProgram:track.synth_engine?track.synth_engine.soundfont_program:undefined,instrumentName:track.instrumentName,instrumentId:track.instrumentId,synth_engine:track.synth_engine}:s));setActiveTab(existing.id);showToast('Piano Roll cho nốt MIDI đã được mở.','info');return;}const tabId='midi_'+Date.now();const tabLabel=`Piano Roll: ${midiItem.name||'MIDI'}`;const newTab={id:tabId,label:tabLabel,type:'PIANO_ROLL',trackId:trackId,target_id:midiItemId,parent_tab_id:activeTab==='main'?null:activeTab,notes:midiItem.notes||[],cc11_global_curve:midiItem.cc11_global_curve||null,duration:midiItem.duration||4,buffer:silentBuffer,instrumentProgram:track.instrumentProgram!==undefined?track.instrumentProgram:track.synth_engine?track.synth_engine.soundfont_program:undefined,instrumentName:track.instrumentName,instrumentId:track.instrumentId,synth_engine:track.synth_engine,currentTime:0,isPlaying:false,isLooping:true,selectionStart:null,selectionEnd:null,viewport_start_bar:0.0,viewport_bar_width:8.0,scroll_y_pitch:60,snap_resolution:snapValue||"1/16",note_selection:[]};setSubTabs(prev=>[...prev,newTab]);setActiveTab(tabId);// Đồng bộ routing SF + mastering ngay khi mở piano roll — preview keybed
|
||
// phải đi qua mastering FX của main out (không cần bật/tắt power).
|
||
setTimeout(()=>{try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e){}},60);// KHÔNG loadSoundFont ở đây (kể cả nền): sfload SF mới bất đồng bộ làm
|
||
// WASM heap 256MB đầy → FluidSynth stall → CÂM TOÀN CỤC (mọi âm thanh
|
||
// chết sau khi mở tab). playNote TỰ load + retry đúng lúc note cần.
|
||
};const handleUpdateMidiNotes=(tabId,notes)=>{setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,notes:notes,isDirty:true}:s));};const handleSaveMidiNotes=(tabId,trackId,midiItemId,updatedNotes)=>{const pianoRollTab=subTabs.find(s=>s.id===tabId);const parentTabId=pianoRollTab?pianoRollTab.parent_tab_id:null;if(parentTabId&&parentTabId.startsWith('session_')){setSessionTabs(prev=>prev.map(s=>{if(s.id!==parentTabId)return s;return{...s,tracks:s.tracks.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:(t.midiItems||[]).map(m=>{if(m.id!==midiItemId)return m;return{...m,notes:updatedNotes,cc11_global_curve:pianoRollTab?pianoRollTab.cc11_global_curve||null:null};})};})};}));}else{setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:(t.midiItems||[]).map(m=>{if(m.id!==midiItemId)return m;return{...m,notes:updatedNotes,cc11_global_curve:pianoRollTab?pianoRollTab.cc11_global_curve||null:null};})};}));}setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,isDirty:false}:s));showToast('Đã lưu các chỉnh sửa nốt MIDI vào item cha!','success');};// Sync nội dung SECTION-TAB → section item trên MAIN (sec.tracks) — cập nhật
|
||
// canvas section item ngay khi thay đổi nội dung tab (user 07:45 — xóa item
|
||
// trong SECTION-TAB → canvas section item ở MAIN phải vẽ lại). KHÔNG đổi
|
||
// duration (0505 — giữ kích thước user resize).
|
||
const syncSectionTabToMain=tabId=>{const tab=sessionTabsRef.current.find(s=>s.id===tabId);if(!tab||!tab.sectionId)return;setTracks(prev=>prev.map(t=>({...t,sections:(t.sections||[]).map(s=>{if(s.sectionId!==tab.sectionId&&s.id!==tab.sectionId)return s;return{...s,tracks:tab.tracks};})})));};const handleSaveSectionTab=async tabId=>{const tab=sessionTabs.find(s=>s.id===tabId);if(!tab)return;// Upload buffer-only clips (chưa có serverFileId — upload sớm thất bại/
|
||
// race) TRƯỚC khi ghi vào section item → serialized AUDIO_ITEM có file →
|
||
// reload không mất audioclip.
|
||
const ensureClipsUploaded=async tracksArr=>{for(const tr of tracksArr||[]){for(const c of tr.clips||[]){if(!c.serverFileId&&c.buffer&&c.buffer.duration>0.05){try{const blob=encodeWavBlob(c.buffer);const up=await uploadToServer(new File([blob],(c.name||'clip').replace(/\.[^.]+$/,'')+'.wav',{type:'audio/wav'}),tr.id);if(up&&up.file_id){c.serverFileId=up.file_id;}}catch(err){console.warn('section clip upload failed:',err);}}}}};// Gồm CẢ track buffer-only (audioclip vừa chèn, upload chưa tạo clip —
|
||
// race: save trước khi upload xong → trước đây bị LỌC mất → audioclip
|
||
// không lưu). Buffer-only → tạo default clip để serialize bắt được item.
|
||
// LỌC BỎ section item TỰ TRỎ (sectionId/section item trỏ về CHÍNH section
|
||
// đang lưu — insertSectionAtPlayhead tạo item thiếu sectionId → fallback
|
||
// s.id → block lồng + ghi đè sectionStore → mất track MIDI).
|
||
const contentTracks=tab.tracks?tab.tracks.filter(tr=>tr.clips?.length>0||tr.midiItems?.length>0||!!tr.buffer).map(tr=>{const nextTr={...tr};if(nextTr.sections&&nextTr.sections.length>0){nextTr.sections=nextTr.sections.filter(s=>(s.sectionId||s.id)!==tab.sectionId);}if(tr.buffer&&(!tr.clips||tr.clips.length===0)){return{...nextTr,clips:[{id:'default_'+tr.id,buffer:tr.buffer,startTime:tr.startTime||0,name:tr.name,speed:1.0}]};}return nextTr;}):[];// Đảm bảo mọi clip có serverFileId (upload buffer-only trước khi lưu)
|
||
await ensureClipsUploaded(contentTracks);setTracks(prev=>prev.map(t=>{if(!t.sections||t.sections.length===0)return t;return{...t,sections:t.sections.map(s=>{if(s.sectionId!==tab.sectionId&&s.id!==tab.sectionId)return s;return{...s,name:tab.name,// ⚠️ KHÔNG ghi đè duration: MAIN SESSION chỉ play ĐÚNG duration
|
||
// của section item (user đã resize thủ công) — mặc kệ SECTION-TAB
|
||
// dài bao nhiêu. Trước đây duration = durationSec (theo content
|
||
// tab) → item tự giãn/thu lại mỗi lần lưu → mất kích thước user
|
||
// set. Play MAIN đã clamp theo sec.duration (19154/19191).
|
||
tracks:contentTracks};})};}));// Clear isDirty flag
|
||
setSessionTabs(prev=>prev.map(s=>s.id===tabId?{...s,isDirty:false}:s));showToast(`Đã lưu nội dung Section "${tab.name}" vào Main Session!`,'success');};// ── Double-click/Edit Section: open Main Session in new tab ──
|
||
const handleEditSectionInTab=(trackId,sectionId)=>{const track=tracks.find(t=>t.id===trackId);if(!track)return;const section=(track.sections||[]).find(s=>s.id===sectionId);if(!section)return;const existing=sessionTabs.find(s=>s.sectionId===sectionId);if(existing){setActiveTab(existing.id);showToast(`Tab "${section.name}" already open.`,'info');return;}const tabId='session_'+Date.now();const tabName=section.name||'Section';const clonedTracks=section.tracks?section.tracks.map(t=>{// Giữ CLIPS + gắn lại buffer THẬT (JSON.parse(JSON.stringify()) DROP
|
||
// AudioBuffer → audioclip mất khỏi section editor). Trước đây clips:[]
|
||
// → section item mở bằng nhấp đôi không chứa audioclip.
|
||
const base=JSON.parse(JSON.stringify(t));const srcClips=(t.clips||[]).map(c=>({...c,buffer:c.buffer||null}));return{...base,clips:srcClips,sections:[],markers:[],isArmed:false,monitoringEnabled:true,instrumentId:t.instrumentId||null,instrumentProgram:t.instrumentProgram,instrumentName:t.instrumentName||null,// Reset bypass theo CONTEXT SECTION (KHÔNG kế thừa từ track gốc):
|
||
// track gốc có thể audioByPass/masteringBypass=true (data lưu cũ) →
|
||
// kế thừa làm track MIDI section bị kéo vào dry bus khi play (bypass
|
||
// mastering dù ♪ tắt). Section default: audio raw (A sáng = bypass),
|
||
// MIDI qua mastering (♪ tắt).
|
||
masteringBypass:false,audioBypass:true,midiBypass:false,_isSectionClone:true};}):tracks.filter(t=>t.id===trackId).map(t=>({...t,clips:[],sections:[],midiItems:[],markers:[],isArmed:false,monitoringEnabled:true,instrumentId:null,instrumentProgram:undefined,instrumentName:null,soundfont_id:null,soundfont_bank:undefined,soundfont_program:undefined,instrument_source:null,synth_engine:undefined,masteringBypass:false,audioBypass:true,midiBypass:false,_isSectionClone:true}));setSessionTabs(prev=>[...prev,{id:tabId,name:tabName,sectionId:sectionId,tracks:clonedTracks,color:section.color||null}]);setActiveTab(tabId);};// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
|
||
const applySubTab=tabId=>{const subTab=subTabs.find(s=>s.id===tabId);if(!subTab||!subTab.buffer)return;const track=activeTracks.find(t=>t.id===subTab.trackId);if(!track||!track.buffer)return;const beforeSnap=captureTrackSnapshot(subTab.trackId);// Clone buffer and apply effects
|
||
const ctx=getAudioContext();const sr=subTab.buffer.sampleRate;const eff=subTab.buffer.getChannelData(0);let resultBuffer=ctx.createBuffer(1,eff.length,sr);let resultData=resultBuffer.getChannelData(0);resultData.set(eff);// Apply effects inline
|
||
const fx=subTab.effects||{};const applyResample=(data,ratio)=>{const newLen=Math.round(data.length*ratio);const out=new Float32Array(newLen);for(let i=0;i<newLen;i++){const srcIdx=i/ratio;const idx0=Math.floor(srcIdx);const idx1=Math.min(idx0+1,data.length-1);const frac=srcIdx-idx0;out[i]=data[idx0]*(1-frac)+data[idx1]*frac;}return out;};if(fx.reverse){const rev=new Float32Array(resultData);for(let i=0;i<resultData.length;i++)rev[i]=resultData[resultData.length-1-i];resultData.set(rev);}if(fx.gainDb!==0){const gain=Math.pow(10,fx.gainDb/20);for(let i=0;i<resultData.length;i++)resultData[i]=Math.max(-1,Math.min(1,resultData[i]*gain));}if(fx.fadeInMs>0){const fadeSamples=Math.min(resultData.length,Math.floor(fx.fadeInMs/1000*sr));for(let i=0;i<fadeSamples;i++)resultData[i]*=i/fadeSamples;}// Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3)
|
||
const gFadeIn=subTab.fadeInLen||0;const gFadeOut=subTab.fadeOutLen||0;if(gFadeIn>0){const fadeSamples=Math.min(resultData.length,Math.floor(gFadeIn*sr));for(let i=0;i<fadeSamples;i++){resultData[i]*=(1-Math.cos(Math.PI*i/fadeSamples))/2;}}if(gFadeOut>0){const fadeSamples=Math.min(resultData.length,Math.floor(gFadeOut*sr));for(let i=resultData.length-fadeSamples;i<resultData.length;i++){const t=i-(resultData.length-fadeSamples);resultData[i]*=(1+Math.cos(Math.PI*t/fadeSamples))/2;}}// Graph Editor volume automation spline (Monotone Cubic Hermite Spline, 16_FIX_GRAPH.md §2.1)
|
||
const volNodes=subTab.volumeNodes||[];if(volNodes.length>0){const sortedNodes=[...volNodes].sort((a,b)=>a.time-b.time);const computeHermiteTangents=pts=>{const n=pts.length;if(n<2)return[];const m=new Array(n);for(let i=1;i<n-1;i++){const hP=pts[i].time-pts[i-1].time;const hN=pts[i+1].time-pts[i].time;const sP=(pts[i].db-pts[i-1].db)/hP;const sN=(pts[i+1].db-pts[i].db)/hN;m[i]=(sP+sN)/2;}m[0]=n>1?(pts[1].db-pts[0].db)/(pts[1].time-pts[0].time):0;m[n-1]=n>1?(pts[n-1].db-pts[n-2].db)/(pts[n-1].time-pts[n-2].time):0;return m;};const tangents=computeHermiteTangents(sortedNodes);const getVolumeGainAtTime=t=>{if(sortedNodes.length===1){return Math.pow(10,sortedNodes[0].db/20);}if(t<=sortedNodes[0].time){return Math.pow(10,sortedNodes[0].db/20);}if(t>=sortedNodes[sortedNodes.length-1].time){return Math.pow(10,sortedNodes[sortedNodes.length-1].db/20);}for(let i=0;i<sortedNodes.length-1;i++){const n1=sortedNodes[i];const n2=sortedNodes[i+1];if(t>=n1.time&&t<=n2.time){const h=n2.time-n1.time;if(h<=0)return Math.pow(10,n1.db/20);const frac=(t-n1.time)/h;const frac2=frac*frac,frac3=frac2*frac;const db=(2*frac3-3*frac2+1)*n1.db+(frac3-2*frac2+frac)*h*tangents[i]+(-2*frac3+3*frac2)*n2.db+(frac3-frac2)*h*tangents[i+1];return Math.pow(10,db/20);}}return 1.0;};for(let i=0;i<resultData.length;i++){const t=i/sr;resultData[i]*=getVolumeGainAtTime(t);}}if(fx.normalizeDb!==0){let maxVal=0;for(let i=0;i<resultData.length;i++){const abs=Math.abs(resultData[i]);if(abs>maxVal)maxVal=abs;}if(maxVal>0){const targetAmp=Math.pow(10,fx.normalizeDb/20);const scale=targetAmp/maxVal;for(let i=0;i<resultData.length;i++)resultData[i]=Math.max(-1,Math.min(1,resultData[i]*scale));}}if(fx.pitch!==0){const ratio=Math.pow(2,fx.pitch/12);const newData=applyResample(resultData,1/ratio);resultBuffer=ctx.createBuffer(1,newData.length,sr);resultData=resultBuffer.getChannelData(0);resultData.set(newData);}const stretchSpeed=subTab.speed||1.0;if(stretchSpeed!==1.0){const ratio=1.0/stretchSpeed;const newData=applyResample(resultData,ratio);resultBuffer=ctx.createBuffer(1,newData.length,sr);resultData=resultBuffer.getChannelData(0);resultData.set(newData);}if(fx.speedStretch!==100){const ratio=fx.speedStretch/100;const newData=applyResample(resultData,ratio);resultBuffer=ctx.createBuffer(1,newData.length,sr);resultData=resultBuffer.getChannelData(0);resultData.set(newData);}const isSectionTrack=sessionTabs.some(s=>s.tracks.some(t=>t.id===subTab.trackId));if(isSectionTrack){if(subTab.clipId){setSessionTabs(prev=>prev.map(s=>{if(!s.tracks.some(t=>t.id===subTab.trackId))return s;return{...s,tracks:s.tracks.map(t=>{if(t.id!==subTab.trackId)return t;const updatedClips=(t.clips||[]).map(c=>{if(c.id===subTab.clipId){return{...c,buffer:resultBuffer,name:c.name.endsWith('(edited)')?c.name:c.name+' (edited)'};}return c;});return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};})};}));}else{// Crossfade merge into original track (§2.2)
|
||
const sr=track.buffer.sampleRate;const origData=track.buffer.getChannelData(0);const trackStart=track.startTime||0;const startSample=Math.floor((subTab.startTime-trackStart)*sr);const endSample=Math.floor((subTab.endTime-trackStart)*sr);const crossfadeLen=Math.min(100,Math.floor(0.01*sr));// 10ms
|
||
const mergedBuffer=ctx.createBuffer(1,track.buffer.length,sr);const mergedData=mergedBuffer.getChannelData(0);for(let i=0;i<startSample;i++)mergedData[i]=origData[i];for(let i=endSample;i<track.buffer.length;i++)mergedData[i]=origData[i];for(let i=0;i<resultData.length;i++){const globalIdx=startSample+i;let val=resultData[i];if(i<crossfadeLen){const alpha=i/crossfadeLen;val=(1-alpha)*(origData[globalIdx]||0)+alpha*resultData[i];}else if(i>resultData.length-crossfadeLen){const distFromEnd=resultData.length-1-i;const alpha=distFromEnd/crossfadeLen;const origEndIdx=endSample-(resultData.length-i);val=alpha*(origEndIdx>=0?origData[origEndIdx]:0)+(1-alpha)*resultData[i];}mergedData[globalIdx]=val;}setSessionTabs(prev=>prev.map(s=>{if(!s.tracks.some(t=>t.id===subTab.trackId))return s;return{...s,tracks:s.tracks.map(t=>{if(t.id!==subTab.trackId)return t;return{...t,buffer:mergedBuffer,name:t.name+' (edited)'};})};}));}}else{if(subTab.clipId){// Clip-based merge
|
||
setTracks(prev=>prev.map(t=>{if(t.id!==subTab.trackId)return t;const updatedClips=(t.clips||[]).map(c=>{if(c.id===subTab.clipId){return{...c,buffer:resultBuffer,name:c.name.endsWith('(edited)')?c.name:c.name+' (edited)'};}return c;});return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));}else{// Crossfade merge into original track (§2.2)
|
||
const sr=track.buffer.sampleRate;const origData=track.buffer.getChannelData(0);const trackStart=track.startTime||0;const startSample=Math.floor((subTab.startTime-trackStart)*sr);const endSample=Math.floor((subTab.endTime-trackStart)*sr);const crossfadeLen=Math.min(100,Math.floor(0.01*sr));// 10ms
|
||
const mergedBuffer=ctx.createBuffer(1,track.buffer.length,sr);const mergedData=mergedBuffer.getChannelData(0);for(let i=0;i<startSample;i++)mergedData[i]=origData[i];for(let i=endSample;i<track.buffer.length;i++)mergedData[i]=origData[i];for(let i=0;i<resultData.length;i++){const globalIdx=startSample+i;let val=resultData[i];if(i<crossfadeLen){const alpha=i/crossfadeLen;val=(1-alpha)*(origData[globalIdx]||0)+alpha*resultData[i];}else if(i>resultData.length-crossfadeLen){const distFromEnd=resultData.length-1-i;const alpha=distFromEnd/crossfadeLen;const origEndIdx=endSample-(resultData.length-i);val=alpha*(origEndIdx>=0?origData[origEndIdx]:0)+(1-alpha)*resultData[i];}mergedData[globalIdx]=val;}setTracks(prev=>prev.map(t=>{if(t.id!==subTab.trackId)return t;return{...t,buffer:mergedBuffer,name:t.name+' (edited)'};}));}}const afterSnap=captureTrackSnapshot(subTab.trackId);pushAction('EDIT_TAB',subTab.trackId,beforeSnap,afterSnap);// Tab Lifetime: Engaging the Apply trigger propagates data back to the primary environment but does not close down the active sub-tab view.
|
||
// closeSubTab(tabId);
|
||
showToast('Đã áp dụng chỉnh sửa vào track chính.','success');};const applySubTabEffect=(tabId,effectType,value)=>{const subTab=subTabs.find(s=>s.id===tabId);if(!subTab||!subTab.buffer)return;const ctx=getAudioContext();const sr=subTab.buffer.sampleRate;const eff=subTab.buffer.getChannelData(0);// Clone buffer
|
||
let resultBuffer=ctx.createBuffer(1,eff.length,sr);let resultData=resultBuffer.getChannelData(0);resultData.set(eff);// Calculate selection start/end in unstretched sample indices
|
||
const speed=subTab.speed||1.0;const hasSelection=subTab.selectionStart!==null&&subTab.selectionEnd!==null&&subTab.selectionStart!==subTab.selectionEnd;const tStart=hasSelection?Math.min(subTab.selectionStart,subTab.selectionEnd)*speed:0;const tEnd=hasSelection?Math.max(subTab.selectionStart,subTab.selectionEnd)*speed:subTab.buffer.duration;const startSample=Math.max(0,Math.min(eff.length,Math.floor(tStart*sr)));const endSample=Math.max(0,Math.min(eff.length,Math.floor(tEnd*sr)));if(effectType==='normalize'){let maxVal=0;for(let i=startSample;i<endSample;i++){const abs=Math.abs(resultData[i]);if(abs>maxVal)maxVal=abs;}if(maxVal>0){const targetAmp=Math.pow(10,value/20);const scale=targetAmp/maxVal;for(let i=startSample;i<endSample;i++){resultData[i]=Math.max(-1,Math.min(1,resultData[i]*scale));}}}else if(effectType==='gain'){const gainScale=value/100;for(let i=startSample;i<endSample;i++){resultData[i]=Math.max(-1,Math.min(1,resultData[i]*gainScale));}}else if(effectType==='pitch'){const ratio=Math.pow(2,value/12);const applyResample=(data,r)=>{const newLen=Math.round(data.length*r);const out=new Float32Array(newLen);for(let i=0;i<newLen;i++){const srcIdx=i/r;const idx0=Math.floor(srcIdx);const idx1=Math.min(idx0+1,data.length-1);const frac=srcIdx-idx0;out[i]=data[idx0]*(1-frac)+data[idx1]*frac;}return out;};const sub=resultData.slice(startSample,endSample);const subResampled=applyResample(sub,1/ratio);for(let i=0;i<endSample-startSample;i++){resultData[startSample+i]=i<subResampled.length?subResampled[i]:0.0;}}else if(effectType==='invert_phase'){// DSP: đảo pha — nhân -1 toàn bộ vùng chọn/clip
|
||
for(let i=startSample;i<endSample;i++){resultData[i]=-resultData[i];}}else if(effectType==='swap_channels'){// DSP: đảo kênh L/R — buffer 2 kênh (nếu có), hoán đổi dữ liệu
|
||
const srcBuffer=subTab.buffer;if(srcBuffer.numberOfChannels>=2){const l=srcBuffer.getChannelData(0).slice();const r=srcBuffer.getChannelData(1).slice();const out=ctx.createBuffer(2,eff.length,sr);out.getChannelData(0).set(r);out.getChannelData(1).set(l);resultBuffer=out;resultData=resultBuffer.getChannelData(0);}else{// Mono → không đổi kênh được, giữ nguyên
|
||
showToast('Buffer mono — không có kênh L/R để hoán đổi.','info');return;}}else if(effectType==='reverse'){// DSP: đảo ngược thời gian vùng chọn/clip
|
||
const seg=resultData.slice(startSample,endSample);for(let i=0;i<seg.length;i++){resultData[startSample+i]=seg[seg.length-1-i];}}// Update subTab buffer state
|
||
setSubTabs(prev=>prev.map(s=>{if(s.id!==tabId)return s;return{...s,buffer:resultBuffer,selectionStart:null,selectionEnd:null};}));const effectLabels={normalize:'Normalize',gain:'Gain',pitch:'Pitch',invert_phase:'Phase Invert',swap_channels:'Swap L/R',reverse:'Reverse'};showToast(`Đã áp dụng ${effectLabels[effectType]||effectType} cho ${hasSelection?'vùng chọn':'toàn bộ clip'}.`,'success');};const exportSubTabBuffer=async tabId=>{const subTab=subTabs.find(s=>s.id===tabId);if(!subTab||!subTab.buffer)return;try{const buffer=subTab.buffer;const sr=buffer.sampleRate;const monoData=buffer.getChannelData(0);const bufferLength=monoData.length;const bitDepth=32;const bytesPerSample=4;const headerSize=44;const fileSizeBytes=headerSize+bufferLength*bytesPerSample;const fileBuffer=new ArrayBuffer(fileSizeBytes);const view=new DataView(fileBuffer);const writeString=(offset,string)=>{for(let i=0;i<string.length;i++){view.setUint8(offset+i,string.charCodeAt(i));}};writeString(0,'RIFF');view.setUint32(4,fileSizeBytes-8,true);writeString(8,'WAVE');writeString(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,3,true);// IEEE float (lossless)
|
||
view.setUint16(22,1,true);view.setUint32(24,sr,true);view.setUint32(28,sr*bytesPerSample,true);view.setUint16(32,bytesPerSample,true);view.setUint16(34,bitDepth,true);writeString(36,'data');view.setUint32(40,bufferLength*bytesPerSample,true);let offset=44;for(let i=0;i<bufferLength;i++){view.setFloat32(offset,monoData[i],true);// bit-exact, no clamp/quantize
|
||
offset+=bytesPerSample;}const blob=new Blob([view],{type:'audio/wav'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=`${subTab.label||'subtab-export'}.wav`;a.click();URL.revokeObjectURL(url);showToast("Xuất bản sub-tab hoàn tất!","success");}catch(err){showToast("Lỗi xuất âm thanh: "+err.message,"error");}};const closeSubTab=tabId=>{const tab=subTabs.find(s=>s.id===tabId);if(!tab)return;const performClose=()=>{setSubTabs(prev=>prev.filter(s=>s.id!==tabId));if(activeTab===tabId)setActiveTab('main');};if(tab.isDirty){setAppWarningModal({title:"Đóng Tab?",message:`Bạn chưa lưu các thay đổi của tab "${tab.label||'MIDI'}". Bạn có chắc chắn muốn đóng tab này và mất hết thay đổi?`,isAlert:false,onConfirm:performClose});}else{performClose();}};const closeSessionTab=tabId=>{const targetTab=sessionTabs.find(s=>s.id===tabId);if(!targetTab)return;const activeChildTabs=subTabs.filter(s=>s.parent_tab_id===tabId);if(activeChildTabs.length>0){setAppWarningModal({title:"Không thể đóng Tab Section",message:`Vui lòng đóng các Tab con biên tập trước khi đóng Tab Section này:\n\n`+activeChildTabs.map(t=>`• ${t.label||t.name}`).join("\n"),isAlert:true});return;}const performClose=()=>{setSessionTabs(prev=>prev.filter(s=>s.id!==tabId));if(activeTab===tabId)setActiveTab('main');};if(targetTab.isDirty){setAppWarningModal({title:"Đóng Tab Section?",message:`Bạn chưa lưu các thay đổi của Section "${targetTab.name}". Bạn có chắc chắn muốn đóng tab này và mất hết thay đổi?`,isAlert:false,onConfirm:performClose});}else{performClose();}};const updateSubTabEffects=(tabId,effects)=>{setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,effects:{...s.effects,...effects}}:s));};const handleSubTabNormalizeWithValue=(tabId,normalizeDb)=>{updateSubTabEffects(tabId,{normalizeDb});};const handleSubTabGainWithValue=(tabId,gainDb)=>{updateSubTabEffects(tabId,{gainDb});};const handleSubTabPitch=(tabId,pitch)=>{updateSubTabEffects(tabId,{pitch});};const handleSubTabStretch=(tabId,speedStretch)=>{updateSubTabEffects(tabId,{speedStretch});};const handleSubTabFadeState=(tabId,type)=>{const st=subTabs.find(s=>s.id===tabId);if(!st)return;const fx=st.effects||{};if(type==='in'){updateSubTabEffects(tabId,{fadeInMs:(fx.fadeInMs||0)+10});}else if(type==='out'){updateSubTabEffects(tabId,{fadeOutMs:(fx.fadeOutMs||0)+10});}};// ── Context Menu Handlers ──
|
||
const handleContextMenu=(e,trackId,clickTime,sectionId,itemId,itemType)=>{e.preventDefault();e.stopPropagation();setContextMenu({x:e.clientX,y:e.clientY,trackId,time:clickTime||currentTime,sectionId:sectionId||null,itemId:itemId||null,itemType:itemType||null});};const closeContextMenu=()=>setContextMenu(null);const contextMenuEditSection=()=>{if(contextMenu.sectionId){handleEditSectionInTab(contextMenu.trackId,contextMenu.sectionId);}closeContextMenu();};// Close context menu on any click outside
|
||
useEffect(()=>{const handler=()=>{if(contextMenu)closeContextMenu();};if(contextMenu){window.addEventListener('click',handler);return()=>window.removeEventListener('click',handler);}},[contextMenu]);const contextMenuEdit=()=>{const track=activeTracks.find(t=>t.id===contextMenu.trackId);if(track)setSelectedTrackId(contextMenu.trackId);closeContextMenu();if(!track)return;const secondsPerBar=60.0/(parseInt(bpm)||120)*4;// Check MIDI items first
|
||
const midiItems=track.midiItems||[];const clickedMidi=midiItems.find(m=>contextMenu.time>=m.startTime&&contextMenu.time<m.startTime+(m.duration||4)*secondsPerBar);if(clickedMidi){handleEditMidiInTab(contextMenu.trackId,clickedMidi.id);return;}// Check Audio clips next
|
||
const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];const clickedClip=clips.find(c=>contextMenu.time>=c.startTime&&contextMenu.time<c.startTime+c.buffer.duration/(c.speed||1.0));if(clickedClip){handleEditClipInSubTab(contextMenu.trackId,clickedClip.id);}else{openTempTab();}};const contextMenuSplit=()=>{closeContextMenu();handleSplitTrack(contextMenu.trackId);};const contextMenuDelete=()=>{const tid=contextMenu.trackId;const track=activeTracks.find(t=>t.id===tid);if(!track){closeContextMenu();return;}const secondsPerBeat=60.0/(parseInt(bpm)||120);const secondsPerBar=secondsPerBeat*4;const time=contextMenu.time;// Check for Section item under cursor
|
||
const secList=track.sections||[];const clickedSec=secList.find(s=>time>=s.start&&time<s.start+s.duration);// Check for MIDI item under cursor
|
||
const midiItems=track.midiItems||[];const clickedMidi=midiItems.find(m=>time>=m.startTime&&time<m.startTime+(m.duration||4)*secondsPerBar);// Check for Audio clip under cursor
|
||
const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];const clickedClip=clips.find(c=>time>=c.startTime&&time<c.startTime+c.buffer.duration/(c.speed||1.0));if(clickedSec){const beforeSnap=captureTrackSnapshot(tid);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==tid)return t;return{...t,sections:(t.sections||[]).filter(s=>s.id!==clickedSec.id)};}));const afterSnap=captureTrackSnapshot(tid);pushAction('DELETE_SECTION',tid,beforeSnap,afterSnap);closeContextMenu();// Sync section tab → section item trên MAIN (canvas vẽ lại — 07:45)
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('session_')){setTimeout(()=>{try{syncSectionTabToMain(activeTabRef.current);}catch(e){}},0);}showToast('Đã xoá section item.','info');return;}if(clickedMidi){const beforeSnap=captureTrackSnapshot(tid);// Close piano roll tab mở của midi item này (user 07:35)
|
||
setSubTabs(prev=>{const next=prev.filter(s=>s.target_id!==clickedMidi.id);// CHỈ về main khi tab active là PIANO ROLL bị close — giữ SECTION-TAB
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('midi_')&&!next.some(s=>s.id===activeTabRef.current)){setActiveTab('main');}return next;});// Sync section tab → section item trên MAIN (canvas vẽ lại — 07:45)
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('session_')){setTimeout(()=>{try{syncSectionTabToMain(activeTabRef.current);}catch(e){}},0);}updateActiveTracks(prev=>prev.map(t=>{if(t.id!==tid)return t;return{...t,midiItems:(t.midiItems||[]).filter(m=>m.id!==clickedMidi.id)};}));const afterSnap=captureTrackSnapshot(tid);pushAction('DELETE_MIDI',tid,beforeSnap,afterSnap);closeContextMenu();showToast('Đã xoá MIDI item.','info');return;}if(clickedClip){const beforeSnap=captureTrackSnapshot(tid);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==tid)return t;const updatedClips=(t.clips||[]).filter(c=>c.id!==clickedClip.id);return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));const afterSnap=captureTrackSnapshot(tid);pushAction('DELETE_CLIP',tid,beforeSnap,afterSnap);closeContextMenu();// Sync section tab → section item trên MAIN (canvas vẽ lại — 07:45)
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('session_')){setTimeout(()=>{try{syncSectionTabToMain(activeTabRef.current);}catch(e){}},0);}showToast('Đã xoá audio clip.','info');return;}// No item clicked under cursor -> Attempt to delete the track itself
|
||
// Item LỖI/rỗng (clip không buffer — khung viền "audio chưa được tải",
|
||
// midi không notes, section không nội dung) KHÔNG chặn xoá track.
|
||
const hasClips=track.clips&&track.clips.some(c=>c.buffer)||!!track.buffer;const hasMidi=track.midiItems&&track.midiItems.some(m=>m.notes&&m.notes.length>0);const hasSections=track.sections&&track.sections.some(s=>s.tracks&&s.tracks.some(st=>st.clips&&st.clips.some(c=>c.buffer)||st.midiItems&&st.midiItems.some(m=>m.notes&&m.notes.length>0)));const isTrackEmpty=!hasClips&&!hasMidi&&!hasSections;if(!isTrackEmpty){closeContextMenu();setAppWarningModal({title:'Không thể xoá Track',message:'Không thể xoá track chứa dữ liệu. Vui lòng xoá hết các item trước khi xoá track.',isAlert:true});return;}const sessionTab=sessionTabs.find(s=>s.id===activeTab);if(sessionTab){updateActiveTracks(prev=>{const filtered=prev.filter(t=>t.id!==tid);if(filtered.length>0)setSelectedTrackId(filtered[0].id);return filtered;});closeContextMenu();showToast('Đã xoá track.','info');}else{const beforeSnap=captureTrackSnapshot(tid);setTracks(prev=>{const filtered=prev.filter(t=>t.id!==tid);if(filtered.length>0)setSelectedTrackId(filtered[0].id||'1');return filtered;});const afterSnap=captureTrackSnapshot(tid);pushAction('DELETE',tid,beforeSnap,afterSnap);if(selectedTrackId===tid)setSelectedTrackId(tracks.filter(t=>t.id!==tid)[0]?.id||'1');closeContextMenu();showToast('Đã xoá track.','info');}};const contextMenuCopy=()=>{// ⚠️ Context menu trên ITEM → copy ĐÚNG item (user 07:25 — trước đây copy
|
||
// nhầm track buffer → paste ra audio item dù cut/copy midi item).
|
||
if(contextMenu.itemType==='midi'&&contextMenu.itemId){const trk=activeTracks.find(t=>t.id===contextMenu.trackId);const item=trk&&(trk.midiItems||[]).find(m=>m.id===contextMenu.itemId);if(item){clipboardRef.current={type:'midi',notes:(item.notes||[]).map(n=>({...n})),duration:item.duration||4,name:item.name||'MIDI Item',color:item.color||'#a855f7'};window.globalStudioClipboard=clipboardRef.current;closeContextMenu();showToast(`Đã sao chép MIDI item "${item.name||''}".`,'info');return;}}if(contextMenu.itemType==='clip'&&contextMenu.itemId){const trk=activeTracks.find(t=>t.id===contextMenu.trackId);const item=trk&&(trk.clips||[]).find(c=>c.id===contextMenu.itemId);if(item&&item.buffer){clipboardRef.current={buffer:item.buffer,name:item.name||trk.name,volumeDb:trk.volumeDb,pan:trk.pan,color:item.color||trk.color,sampleRate:item.buffer.sampleRate,channels:item.buffer.numberOfChannels,speed:item.speed||1.0};window.globalStudioClipboard=clipboardRef.current;closeContextMenu();showToast(`Đã sao chép clip "${item.name||''}".`,'info');return;}}const track=activeTracks.find(t=>t.id===contextMenu.trackId);if(!track||!track.buffer)return;const sr=track.buffer.sampleRate;const data=track.buffer.getChannelData(0);// Copy selected region if selection exists
|
||
if(selLeft!==null&&selRight!==null&&selRight>selLeft){const trackStart=track.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.floor(relSelLeft*sr);const endSample=Math.min(data.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len>0){const ctx=getAudioContext();const numCh=track.buffer.numberOfChannels||1;const clipBuffer=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch<numCh;ch++){clipBuffer.copyToChannel(track.buffer.getChannelData(ch).subarray(startSample,endSample),ch);}clipboardRef.current={buffer:clipBuffer,name:track.name,volumeDb:track.volumeDb,pan:track.pan,color:track.color,sampleRate:clipBuffer.sampleRate,channels:numCh,speed:track.speed||1.0};closeContextMenu();showToast('Đã sao chép vùng chọn.','info');return;}}// No selection: copy entire track
|
||
clipboardRef.current={buffer:track.buffer,name:track.name,volumeDb:track.volumeDb,pan:track.pan,color:track.color,sampleRate:track.buffer.sampleRate,channels:track.buffer.numberOfChannels,speed:track.speed||1.0};closeContextMenu();showToast('Đã sao chép toàn bộ track.','info');};const contextMenuCut=()=>{// ⚠️ Context menu trên ITEM → cut ĐÚNG item (copy + xóa item — trước đây
|
||
// cut nhầm track buffer/audio — user 07:25).
|
||
if(contextMenu.itemType==='midi'&&contextMenu.itemId){const trk=activeTracks.find(t=>t.id===contextMenu.trackId);const item=trk&&(trk.midiItems||[]).find(m=>m.id===contextMenu.itemId);if(item){clipboardRef.current={type:'midi',notes:(item.notes||[]).map(n=>({...n})),duration:item.duration||4,name:item.name||'MIDI Item',color:item.color||'#a855f7'};window.globalStudioClipboard=clipboardRef.current;// Close piano roll tab mở của midi item này (user 07:35)
|
||
setSubTabs(prev=>{const next=prev.filter(s=>s.target_id!==contextMenu.itemId);// CHỈ về main khi tab active là PIANO ROLL bị close — giữ SECTION-TAB
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('midi_')&&!next.some(s=>s.id===activeTabRef.current)){setActiveTab('main');}return next;});// Sync section tab → section item trên MAIN (canvas vẽ lại — 07:45)
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('session_')){setTimeout(()=>{try{syncSectionTabToMain(activeTabRef.current);}catch(e){}},0);}updateActiveTracks(prev=>prev.map(t=>t.id===contextMenu.trackId?{...t,midiItems:(t.midiItems||[]).filter(m=>m.id!==contextMenu.itemId)}:t));closeContextMenu();showToast(`Đã cắt MIDI item "${item.name||''}".`,'info');return;}}if(contextMenu.itemType==='clip'&&contextMenu.itemId){const trk=activeTracks.find(t=>t.id===contextMenu.trackId);const item=trk&&(trk.clips||[]).find(c=>c.id===contextMenu.itemId);if(item&&item.buffer){clipboardRef.current={buffer:item.buffer,name:item.name||trk.name,volumeDb:trk.volumeDb,pan:trk.pan,color:item.color||trk.color,sampleRate:item.buffer.sampleRate,channels:item.buffer.numberOfChannels,speed:item.speed||1.0};window.globalStudioClipboard=clipboardRef.current;updateActiveTracks(prev=>prev.map(t=>t.id===contextMenu.trackId?{...t,clips:(t.clips||[]).filter(c=>c.id!==contextMenu.itemId)}:t));closeContextMenu();showToast(`Đã cắt clip "${item.name||''}".`,'info');return;}}const t=tracks.find(x=>x.id===contextMenu.trackId);if(!t||!t.buffer){contextMenuDelete();return;}const beforeSnap=captureTrackSnapshot(contextMenu.trackId);const sr=t.buffer.sampleRate;const data=t.buffer.getChannelData(0);if(selLeft!==null&&selRight!==null&&selRight>selLeft){const trackStart=t.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.floor(relSelLeft*sr);const endSample=Math.min(data.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len>0){const ctx=getAudioContext();const numCh=t.buffer.numberOfChannels||1;const clipBuffer=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch<numCh;ch++){clipBuffer.copyToChannel(t.buffer.getChannelData(ch).subarray(startSample,endSample),ch);}clipboardRef.current={buffer:clipBuffer,name:t.name,volumeDb:t.volumeDb,pan:t.pan,color:t.color,sampleRate:sr,channels:numCh,speed:t.speed||1.0};const newLen=data.length-len;const newBuffer=ctx.createBuffer(numCh,newLen,sr);for(let ch=0;ch<numCh;ch++){const src=t.buffer.getChannelData(ch);const dst=newBuffer.getChannelData(ch);let idx=0;for(let i=0;i<startSample;i++)dst[idx++]=src[i];for(let i=endSample;i<src.length;i++)dst[idx++]=src[i];}setTracks(p=>p.map(tr=>tr.id===contextMenu.trackId?{...tr,buffer:newBuffer}:tr));const afterSnap=captureTrackSnapshot(contextMenu.trackId);pushAction('CUT',contextMenu.trackId,beforeSnap,afterSnap);closeContextMenu();showToast('Đã cắt vùng chọn vào clipboard.','info');return;}}contextMenuCopy();contextMenuDelete();};const doPaste=(targetTrackId,pasteTime)=>{const clip=clipboardRef.current||window.globalStudioClipboard;if(!clip){showToast('Clipboard trống.','warning');return null;}// Support MIDI Clip Paste
|
||
if(clip.type==='midi'){const{notes,name,duration,color}=clip;const targetTrack=activeTracks.find(t=>t.id===targetTrackId);const newMidiItem={id:'midi_'+Date.now(),startTime:pasteTime,duration:duration||4,name:(name||'Pasted MIDI').replace(/\.\w+$/,'')+' (Pasted)',notes:notes.map(n=>({id:'note_'+Math.random().toString(36).substr(2,9),pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8}))};if(targetTrack){updateActiveTracks(p=>p.map(t=>{if(t.id===targetTrackId){const existingMidi=t.midiItems||[];return{...t,midiItems:[...existingMidi,newMidiItem]};}return t;}));setCurrentTime(pasteTime);showToast('Đã dán MIDI vào track.','success');return targetTrackId;}else{const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const rearrangeNewId='track_pasted_midi_'+Date.now();updateActiveTracks(prev=>[...prev,{id:rearrangeNewId,name:`Pasted_${name||'MIDI'}`,buffer:null,startTime:0,clips:[],midiItems:[newMidiItem],volumeDb:0,pan:0,muted:false,solo:false,color:color||colors[prev.length%colors.length],markers:[],serverFileId:null}]);setSelectedTrackId(rearrangeNewId);setCurrentTime(pasteTime);showToast('Đã dán track MIDI mới từ clipboard.','success');return rearrangeNewId;}}// Default Audio Clip Paste
|
||
if(!clip.buffer){showToast('Clipboard không chứa dữ liệu âm thanh hợp lệ.','warning');return null;}const{buffer:clipBuffer,name,volumeDb,pan,color,sampleRate,channels,speed}=clip;const ctx=getAudioContext();const targetTrack=activeTracks.find(t=>t.id===targetTrackId);const newClip={id:nextClipId(),startTime:pasteTime,buffer:clipBuffer,name:(name||'Pasted Clip').replace(/\.\w+$/,'')+' (Pasted)',...(volumeDb!==undefined?{volumeDb}:{}),...(pan!==undefined?{pan}:{}),...(color?{color}:{}),...(speed!==undefined?{speed}:{})};if(targetTrack){updateActiveTracks(p=>p.map(t=>{if(t.id===targetTrackId){const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const updatedClips=[...existingClips,newClip];return{...t,clips:updatedClips,buffer:updatedClips[0].buffer,startTime:updatedClips[0].startTime,name:name||t.name,volumeDb:volumeDb??t.volumeDb,pan:pan??t.pan,color:color||t.color};}return t;}));setCurrentTime(pasteTime);showToast('Đã dán clip vào track.','success');return targetTrackId;}// No matching track — create a new one
|
||
const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const rearrangeNewId='track_pasted_'+Date.now();updateActiveTracks(prev=>[...prev,{id:rearrangeNewId,name:`Pasted_${name||'track'}`,buffer:clipBuffer,startTime:pasteTime,clips:[newClip],volumeDb:volumeDb??0,pan:pan??0,muted:false,solo:false,color:color||colors[prev.length%colors.length],markers:[],serverFileId:null}]);setSelectedTrackId(rearrangeNewId);setCurrentTime(pasteTime);showToast('Đã dán track mới từ clipboard.','success');return rearrangeNewId;};const handlePasteTrack=()=>{// Paste vào TRACK của ITEM đang chọn (nếu có) — không phải track 1 mặc
|
||
// định (user 07:55: Ctrl+V dán vào track 1 dù track khác được chọn item).
|
||
let targetId=selectedTrackId;try{const selItems=selectedItemIdsRef.current;if(selItems&&selItems.size>0){const firstId=selItems.values().next().value;const found=findItemContext(firstId);if(found)targetId=found.trackId;}}catch(e){}// Paste tại vị trí CLICK (bên phải playhead) — không phải playhead;
|
||
// click bên TRÁI playhead → paste tại playhead (user 08:05)
|
||
const _clickT=lastClickTimeRef.current;const pasteTime=_clickT!=null&&_clickT>currentTime?_clickT:currentTime;doPaste(targetId,pasteTime);};// Ref cho keydown handler (effect deps [] — closure STALE: selectedTrackId
|
||
// render đầu = '1' → Ctrl+V luôn paste track 1 — user 08:05)
|
||
const handlePasteTrackRef=useRef(handlePasteTrack);handlePasteTrackRef.current=handlePasteTrack;const contextMenuPaste=()=>{const result=doPaste(contextMenu.trackId,contextMenu.time||currentTime);closeContextMenu();};const contextMenuMerge=()=>{const activeTracks=tracks.filter(t=>t.buffer&&!t.muted);if(activeTracks.length<2){showToast('Cần ít nhất 2 track có dữ liệu để merge.','warning');closeContextMenu();return;}const ctx=getAudioContext();const maxDur=Math.max(...activeTracks.map(t=>(t.startTime||0)+t.buffer.duration));const sr=activeTracks[0].buffer.sampleRate;const merged=ctx.createBuffer(1,Math.ceil(maxDur*sr),sr);const mergedData=merged.getChannelData(0);activeTracks.forEach(t=>{const data=t.buffer.getChannelData(0);const startSample=Math.floor((t.startTime||0)*sr);const volLinear=(t.volumeDb??0)<=-50?0:Math.pow(10,(t.volumeDb??0)/20);for(let i=0;i<data.length;i++){if(startSample+i<mergedData.length){mergedData[startSample+i]+=data[i]*volLinear;}}});let maxPeak=0;for(let i=0;i<mergedData.length;i++){const abs=Math.abs(mergedData[i]);if(abs>maxPeak)maxPeak=abs;}if(maxPeak>1.0){for(let i=0;i<mergedData.length;i++)mergedData[i]/=maxPeak;}const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const rearrangeNewId='track_merged_'+Date.now();const names=activeTracks.map(t=>t.name).join('+').slice(0,30);setTracks(prev=>[...prev,{id:rearrangeNewId,name:`Merged_${names}.wav`,buffer:merged,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:colors[prev.length%colors.length],markers:[],serverFileId:null}]);setSelectedTrackId(rearrangeNewId);closeContextMenu();showToast(`Đã merge ${activeTracks.length} tracks.`,'success');};// Menu bar direct handlers (don't rely on contextMenu state)
|
||
const handleMergeTracks=()=>{const at=tracks.filter(t=>t.buffer&&!t.muted);if(at.length<2){showToast('Cần 2+ tracks để merge.','warning');return;}const actx=getAudioContext();const maxDur=Math.max(...at.map(t=>(t.startTime||0)+t.buffer.duration));const sr=at[0].buffer.sampleRate;const mb=actx.createBuffer(1,Math.ceil(maxDur*sr),sr);const mdata=mb.getChannelData(0);at.forEach(t=>{const d=t.buffer.getChannelData(0);const startSample=Math.floor((t.startTime||0)*sr);const volLinear=(t.volumeDb??0)<=-50?0:Math.pow(10,(t.volumeDb??0)/20);for(let i=0;i<d.length;i++){if(startSample+i<mdata.length){mdata[startSample+i]+=d[i]*volLinear;}}});let mp=0;for(let i=0;i<mdata.length;i++){const a=Math.abs(mdata[i]);if(a>mp)mp=a;}if(mp>1.0)for(let i=0;i<mdata.length;i++)mdata[i]/=mp;setTracks(p=>[...p,{id:'merged_'+Date.now(),name:'Merged_mix.wav',buffer:mb,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:['#0f766e','#1d4ed8'][p.length%2],markers:[],serverFileId:null}]);showToast('Merged all unmuted tracks.','success');};const handleCopyTrack=()=>{const t=activeTracks.find(x=>x.id===selectedTrackId);if(!t||!t.buffer)return;const sr=t.buffer.sampleRate;const data=t.buffer.getChannelData(0);// If selection exists, copy only the selected region
|
||
if(selLeft!==null&&selRight!==null&&selRight>selLeft){const trackStart=t.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.floor(relSelLeft*sr);const endSample=Math.min(data.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len>0){const ctx=getAudioContext();const clipBuffer=ctx.createBuffer(1,len,sr);clipBuffer.copyToChannel(data.subarray(startSample,endSample),0);clipboardRef.current={buffer:clipBuffer,name:t.name,volumeDb:t.volumeDb,color:t.color};showToast('Copied selection to clipboard.','info');return;}}// No selection: copy entire track
|
||
clipboardRef.current={buffer:t.buffer,name:t.name,volumeDb:t.volumeDb,pan:t.pan,color:t.color,sampleRate:t.buffer.sampleRate,channels:t.buffer.numberOfChannels,speed:t.speed||1.0};showToast('Copied track to clipboard.','info');};const handleCutTrack=()=>{const t=activeTracks.find(x=>x.id===selectedTrackId);if(!t||!t.buffer)return;const beforeSnap=captureTrackSnapshot(selectedTrackId);const sr=t.buffer.sampleRate;const data=t.buffer.getChannelData(0);// If selection exists, cut only the selected region
|
||
if(selLeft!==null&&selRight!==null&&selRight>selLeft){const trackStart=t.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.floor(relSelLeft*sr);const endSample=Math.min(data.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len>0){// Copy selection to clipboard
|
||
const ctx=getAudioContext();const clipBuffer=ctx.createBuffer(1,len,sr);clipBuffer.copyToChannel(data.subarray(startSample,endSample),0);clipboardRef.current={buffer:clipBuffer,name:t.name,volumeDb:t.volumeDb,color:t.color};// Remove selection from track, glue the two remaining parts
|
||
const newLen=data.length-len;const newBuffer=ctx.createBuffer(1,newLen,sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;i<startSample;i++)newData[idx++]=data[i];for(let i=endSample;i<data.length;i++)newData[idx++]=data[i];updateActiveTracks(p=>p.map(tr=>tr.id===selectedTrackId?{...tr,buffer:newBuffer}:tr));const afterSnap=captureTrackSnapshot(selectedTrackId);pushAction('CUT',selectedTrackId,beforeSnap,afterSnap);showToast('Cut selection to clipboard.','info');return;}}// No selection: cut entire track (copy + delete)
|
||
handleCopyTrack();handleDeleteTrack();};const handleDeleteTrack=()=>{const tid=selectedTrackId;const sessionTab=sessionTabs.find(s=>s.id===activeTab);if(sessionTab){const trackData=activeTracks.find(t=>t.id===tid);if(trackData)deleteTrackWithUndo(tid,JSON.parse(JSON.stringify(trackData)));updateActiveTracks(prev=>prev.filter(t=>t.id!==tid));setSelectedTrackId(activeTracks.filter(t=>t.id!==tid)[0]?.id||'1');}else{const curTracks=tracks;const track=curTracks.find(t=>t.id===tid);if(track&&track.sections&&track.sections.length>0){showToast('Không thể xoá track chứa Section item.','warning');return;}if(track)deleteTrackWithUndo(tid,JSON.parse(JSON.stringify(track)));setTracks(p=>p.filter(t=>t.id!==tid));setSelectedTrackId(curTracks.filter(t=>t.id!==tid)[0]?.id||'1');}showToast('Deleted track.','info');};handleDeleteTrackRef.current=handleDeleteTrack;// ── Server Health Check ──
|
||
const[viewportWidth,setViewportWidth]=useState(1200);useEffect(()=>{if(!timelineWrapperNode)return;const observer=new ResizeObserver(entries=>{for(let entry of entries){setViewportWidth(entry.contentRect.width);}});observer.observe(timelineWrapperNode);return()=>observer.disconnect();},[timelineWrapperNode]);useEffect(()=>{let cancelled=false,busy=false,iv=null,fails=0;// Hysteresis: chỉ báo offline sau 3 lần fail LIÊN TIẾP (~12-18s), 1 lần
|
||
// trễ/timeout đơn lẻ (engine khởi động chậm máy khác) KHÔNG làm chip
|
||
// nhấp nháy connected/offline — fix "Server không stable".
|
||
const ok=()=>{fails=0;if(!cancelled)setServerStatus('connected');};const bad=()=>{fails++;if(!cancelled&&fails>=3)setServerStatus('offline');};const check=()=>{if(busy||cancelled)return;busy=true;const ctl=new AbortController();const t=setTimeout(()=>ctl.abort(),6000);fetch(API_BASE_URL,{signal:ctl.signal}).then(r=>{if(r.ok)ok();else bad();}).catch(()=>bad()).finally(()=>{busy=false;clearTimeout(t);});};check();iv=setInterval(()=>{if(!cancelled)check();},4000);return()=>{cancelled=true;if(iv)clearInterval(iv);};},[]);// ── Computed Values ──
|
||
const leadInMargin=0;const leadInMarginRef=useRef(0);leadInMarginRef.current=0;const maxDuration=useMemo(()=>{const secPerBar=60.0/(parseInt(bpm)||120)*4;// MAIN SESSION: endtime của items trên track MAIN (mặc kệ SECTION-TAB dài bao nhiêu —
|
||
// sub-track items bị clamp trong section bounds khi play trong main session).
|
||
// SECTION-TAB: endtime của items trên track CỦA TAB (tính theo vị trí section trên main).
|
||
let max;if(activeTab==='main'){max=computeMainSessionEndTime(activeTracks);}else{const tab=sessionTabs.find(st=>st.id===activeTab);const tabMax=tab?computeMainSessionEndTime(tab.tracks||[]):0;let secStart=0;const secRef=tab?tab.sectionId:null;if(secRef){activeTracks.forEach(t=>(t.sections||[]).forEach(s=>{if(s.sectionId===secRef||s.id===secRef)secStart=s.start||0;}));}max=secStart+tabMax;}if(recordingState==='RECORDING'||recordingState==='COUNT_IN'){max=Math.max(max,currentTime+60);}max+=secPerBar*12+scrollBufferExtra;return max;},[activeTab,activeTracks,sessionTabs,recordingState,currentTime,bpm,scrollBufferExtra]);const maxDurationRef=useRef(maxDuration);maxDurationRef.current=maxDuration;// ── Project REAL end-time (KHÔNG buffer) ──
|
||
// maxDuration ở trên cộng 12 bars + scrollBuffer cho vùng SCROLL/ZOOM; nếu
|
||
// dùng nó làm điểm dừng LOOP thì loop kéo dài quá duration thật. projectEnd
|
||
// = endtime của session (main: items trên track main; section-tab: secStart +
|
||
// items của tab) — dùng cho updatePlayhead dừng/loop lại đúng cuối bài.
|
||
const projectEnd=useMemo(()=>{let max;if(activeTab==='main'){max=computeMainSessionEndTime(activeTracks);// Content-min CHỈ khi nút LOOP BẬT (user: projectEnd chỉ kích hoạt khi
|
||
// LOOP bật — play loop liên tục tại content end): content NGẮN hơn item
|
||
// → loop ngắn đúng content; content DÀI hơn item → loop tại item end.
|
||
// LOOP TẮT → play 1 lần tới END ITEMS (computeMainSessionEndTime —
|
||
// không cắt sớm theo content).
|
||
if(isLoopingSelection){(activeTracks||[]).forEach(_t=>(_t.sections||[]).forEach(_s=>{const _tab=sessionTabs.find(st=>st.sectionId===_s.sectionId||st.sectionId===_s.id);let _contentEnd=0;if(_tab){(_tab.tracks||[]).forEach(_tr=>{(_tr.clips||[]).forEach(_c=>{const _e=(_c.startTime||0)+(_c.buffer?_c.buffer.duration/(_c.speed||1.0):4);if(_e>_contentEnd)_contentEnd=_e;});(_tr.midiItems||[]).forEach(_m=>{const _e=(_m.startTime||0)+(_m.duration||4);if(_e>_contentEnd)_contentEnd=_e;});});}if(_contentEnd>0){const _itemEnd=(_s.start||0)+(_s.duration||0);const _realEnd=(_s.start||0)+Math.min(_s.duration||0,_contentEnd);if(_realEnd<_itemEnd&&max<=_itemEnd+0.001)max=_realEnd;}}));}}else{const tab=sessionTabs.find(st=>st.id===activeTab);const tabMax=tab?computeMainSessionEndTime(tab.tracks||[]):0;let secStart=0;const secRef=tab?tab.sectionId:null;if(secRef){activeTracks.forEach(t=>(t.sections||[]).forEach(s=>{if(s.sectionId===secRef||s.id===secRef)secStart=s.start||0;}));}max=secStart+tabMax;}if(recordingState==='RECORDING'||recordingState==='COUNT_IN'){max=Math.max(max,currentTime+60);}return Math.max(1,max);},[activeTab,activeTracks,sessionTabs,recordingState,currentTime,isLoopingSelection]);const projectEndRef=useRef(projectEnd);projectEndRef.current=projectEnd;const minZoom=useMemo(()=>{return viewportWidth/maxDuration;},[viewportWidth,maxDuration]);const timelineWidth=useMemo(()=>{return Math.max(zoom*(maxDuration+leadInMargin),viewportWidth);},[zoom,maxDuration,viewportWidth,leadInMargin]);useEffect(()=>{// ⚠️ KHÔNG ép minZoom khi activeTab là SUB-TAB (PIANO ROLL/audio):
|
||
// maxDuration tính theo activeTab=sub-tab → chỉ còn 12 bars buffer →
|
||
// minZoom LỚN → ép zoom lên (log: vào midi_ → ép 32.78→37.54) → quay lại
|
||
// MAIN/SECTION zoom giữ giá trị bị ép (KHÔNG khôi phục) = "zoom reset khi
|
||
// chuyển tab". Timeline không hiển thị ở sub-tab → ép vô nghĩa.
|
||
const _tabNow=activeTabRef.current;if(_tabNow!=='main'&&!(_tabNow&&_tabNow.startsWith('session_')))return;if(zoom<minZoom){console.log('[Zoom] ép lên minZoom=',minZoom.toFixed(2),'(zoom=',zoom.toFixed(2),') tab=',activeTab,'maxDuration=',maxDuration.toFixed(2));setZoom(minZoom);}},[minZoom]);const playheadLeftPos=useMemo(()=>(currentTime+leadInMargin)*zoom,[currentTime,zoom,leadInMargin]);const selLeft=useMemo(()=>{if(selectionMode==='local'&&localSelectionStart!==null&&localSelectionEnd!==null){return Math.max(0,Math.min(localSelectionStart,localSelectionEnd));}if(selectionStart===null||selectionEnd===null)return null;return Math.max(0,Math.min(selectionStart,selectionEnd));},[selectionStart,selectionEnd,selectionMode,localSelectionStart,localSelectionEnd]);const selRight=useMemo(()=>{if(selectionMode==='local'&&localSelectionStart!==null&&localSelectionEnd!==null){return Math.max(0,Math.max(localSelectionStart,localSelectionEnd));}if(selectionStart===null||selectionEnd===null)return null;return Math.max(0,Math.max(selectionStart,selectionEnd));},[selectionStart,selectionEnd,selectionMode,localSelectionStart,localSelectionEnd]);const dspSelectionStats=useMemo(()=>{const track=tracks.find(t=>t.id===selectedTrackId);if(!track)return null;const numChannels=track.buffer?track.buffer.numberOfChannels||1:0;if(selLeft===null||selRight===null||selRight<=selLeft||!track.buffer){return{trackName:track.name,channels:numChannels,timeRange:'Chưa chọn vùng',peakVolume:'N/A'};}const buffer=track.buffer;const sampleRate=buffer.sampleRate;const startSample=Math.max(0,Math.min(buffer.length-1,Math.floor(selLeft*sampleRate)));const endSample=Math.max(0,Math.min(buffer.length,Math.floor(selRight*sampleRate)));let maxVal=0;for(let c=0;c<numChannels;c++){const data=buffer.getChannelData(c);for(let i=startSample;i<endSample;i++){const val=Math.abs(data[i]);if(val>maxVal)maxVal=val;}}let peakDb='N/A';if(maxVal>0){const db=20*Math.log10(maxVal);peakDb=db.toFixed(2)+' dB';}else{peakDb='-∞ dB';}return{trackName:track.name,channels:numChannels,timeRange:`${selLeft.toFixed(2)}s - ${selRight.toFixed(2)}s (${(selRight-selLeft).toFixed(2)}s)`,peakVolume:peakDb};},[tracks,selectedTrackId,selLeft,selRight]);// ── Toast helper ──
|
||
const showToast=(text,type='info',actionText=null,onActionClick=null)=>{if(toastTimeoutRef.current)clearTimeout(toastTimeoutRef.current);setToastMessage({text,type,actionText,onActionClick});toastTimeoutRef.current=setTimeout(()=>setToastMessage(null),actionText?8000:3500);};window.showToast=showToast;// ── Virtual MIDI Keyboard note routing (Phase 5) ────────────────────────
|
||
// NOTE_ON/NOTE_OFF theo pattern attachMidiHandler (hardware MIDI): bridge →
|
||
// per-track channel alloc; native SF/VST; SonicSF fallback; VU + giữ
|
||
// counter (heldMidiNotesRef); forward recorder (live record vào midiItems).
|
||
const vkbNoteOn=(pitch,vel)=>{try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}const allTracks=activeTracksRef.current||[];// V45.19 Bug 2: ARM piano roll chi tac dung khi DANG o PIANO_ROLL tab. O
|
||
// MAIN/SECTION chi tinh track isArmed that su; ARM tu piano roll KHONG truyen
|
||
// sang. Khong track nao ARM o MAIN/SECTION -> targets rong -> preview cam.
|
||
const _prTabNow=(subTabsRef.current||[]).find(function(s){return s.id===activeTabRef.current;});const _isPrTabNow=!!(_prTabNow&&_prTabNow.type==='PIANO_ROLL');const prArmedIds={};// V45.27: PR tab preview chi track dang edit (sub-tab active) VA chi khi sub-tab do ARM (isArmed).
|
||
// ARM tu sub-tab PR khac / track MAIN KHONG leak sang PR tab nay.
|
||
try{if(_isPrTabNow){if(_prTabNow.isArmed&&_prTabNow.trackId)prArmedIds[_prTabNow.trackId]=true;}}catch(e){}const armed=allTracks.filter(function(t){return t.isArmed;});const targets=_isPrTabNow?allTracks.filter(function(t){return prArmedIds[t.id];}):armed.length?armed:[];if(!_isPrTabNow&&!armed.length&&!window.__vkbNoArmToast){window.__vkbNoArmToast=true;showToast('Không có track nào ARM — preview MIDI câm (MAIN/SECTION).','info');}// DIAG Bug 3/4 (preview khong am): trang thai routing tai note-on.
|
||
try{let _pdDest='none';if(window.AudioRoutingEngine&&window.AudioRoutingEngine._connected){_pdDest=window.AudioRoutingEngine._dest&&window.AudioRoutingEngine._dest.constructor?window.AudioRoutingEngine._dest.constructor.name:'null';_pdDest+='/'+window.AudioRoutingEngine._trackId;}else{_pdDest='NOT_CONNECTED';}const _pdD=window.BridgeAudioNode?window.BridgeAudioNode.getDiag():null;const _pdTab=activeTabRef.current;const _pdSub=subTabsRef.current?(subTabsRef.current.find(s=>s.id===_pdTab)||{}).type:null;_dbgLog('preview_diag','armed='+armed.length+' trg='+targets.length+' prTab='+(_isPrTabNow?1:0)+' tab='+_pdTab+' sub='+(_pdSub||'none')+' play='+(isPlayingRef.current?1:0)+' br='+(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()?1:0)+' fxRt='+(fxRt.active?1:0)+' dest='+_pdDest+' bd='+(_pdD?'i='+_pdD.initialized+' wl='+_pdD.useWorklet+' nd='+_pdD.hasWorkletNode+' pre='+_pdD.preBufferLen+' ch='+_pdD.chunksLen+' g='+_pdD.gain+' ws='+(_pdD.ws?_pdD.ws.overruns+'/'+_pdD.ws.underruns:'-'):'MISSING')+' mb='+!!masterBus);}catch(e){}// DIAG Bug 3/4: arm bo dem audio frames (500ms sau note-on) de biet
|
||
// engine co gui audio ve JS khong (tach engine vs JS routing).
|
||
try{window.__dbgPreviewAudioArmed={count:0,nz:0,t0:performance.now()};}catch(e){}activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));vkbHeldRef.current.add(pitch);targets.forEach(function(t){// V45.24: PR preview doc lap voi mute/solo MAIN/SECTION — khoi phuc
|
||
// volume track khi o PR tab.
|
||
if(_isPrTabNow){try{ensureTrackPrAudible(t,allTracks,activeTrackNodesRef.current[t.id]);}catch(e){}}const ctx=resolveTrackInstrumentCtx(t,allTracks);ensureSonicInstrument(ctx);if(window.triggerMidiVuActivity)window.triggerMidiVuActivity(t.id,Math.round(vel*127));try{heldMidiNotesRef.current[t.id]=(heldMidiNotesRef.current[t.id]||0)+1;}catch(err){}const percussion=!!(ctx&&ctx.synthEngine&&ctx.synthEngine.soundfont_bank===128);if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(t.id,ctx.program,ctx.synthEngine);}catch(er){}try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:t.id,pitch:pitch,velocity:vel,percussion:percussion,live:true});}catch(e){}}else if(isStandaloneSf()&&(isSfTrackEngine(ctx.synthEngine)||isVstTrackEngine(ctx.synthEngine))){if(isSfTrackEngine(ctx.synthEngine))playNativeSfNote(t,pitch,vel,60000,undefined,'vkb_'+t.id+'_'+pitch);else playNativeVstNote(t,pitch,vel,60000,undefined,'vkb_'+t.id+'_'+pitch);}else if(window.SonicSF){const dest=activeTrackNodesRef.current[t.id]&&activeTrackNodesRef.current[t.id].gainNode||null;window.SonicSF.playNote(pitch,Math.max(1,Math.round(vel*127)),60000,undefined,ctx.program,dest,ctx.ch,ctx.synthEngine);}});// Live record: forward tới mọi recorder đang chạy (timeline take / piano roll)
|
||
if(activeMIDIRecordersRef.current){const msg={data:[0x90,pitch,Math.max(1,Math.round(vel*127))]};for(const trackId in activeMIDIRecordersRef.current){const rec=activeMIDIRecordersRef.current[trackId];if(rec){try{rec.handleMIDIMessage(msg,null);}catch(e){}}}}};const vkbNoteOff=pitch=>{vkbHeldRef.current.delete(pitch);activeMidiPitchesRef.current.delete(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));const allTracks=activeTracksRef.current||[];allTracks.forEach(function(t){const ctx=resolveTrackInstrumentCtx(t,allTracks);const percussion=!!(ctx&&ctx.synthEngine&&ctx.synthEngine.soundfont_bank===128);if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:t.id,pitch:pitch,velocity:0,percussion:percussion,live:true});}catch(e){}}else if(window.SonicSF&&window.SonicSF.stopNote){if(isStandaloneSf()){stopNativeSfNote('vkb_'+t.id+'_'+pitch);stopNativeVstNote('vkb_'+t.id+'_'+pitch);}window.SonicSF.stopNote(ctx.ch,pitch);}try{if(heldMidiNotesRef.current[t.id]!==undefined){heldMidiNotesRef.current[t.id]=Math.max(0,heldMidiNotesRef.current[t.id]-1);if(heldMidiNotesRef.current[t.id]===0)delete heldMidiNotesRef.current[t.id];}}catch(err){}});if(activeMIDIRecordersRef.current){const msg={data:[0x80,pitch,0]};for(const trackId in activeMIDIRecordersRef.current){const rec=activeMIDIRecordersRef.current[trackId];if(rec){try{rec.handleMIDIMessage(msg,null);}catch(e){}}}}};vkbNoteOnRef.current=vkbNoteOn;vkbNoteOffRef.current=vkbNoteOff;window.__toggleVkb=function(){setVkbOpen(function(p){const next=!p;if(!next&&vkbHeldRef.current.size){Array.from(vkbHeldRef.current).forEach(function(pitch){try{vkbNoteOffRef.current(pitch);}catch(e){}});}return next;});};window.__toggleChordsPanel=function(){setChordsPanelOpen(function(p){return!p;});};// ── Server-side upload ──
|
||
// WAV encoder tối thiểu — upload buffer-only clip khi LƯU (nếu chưa có
|
||
// serverFileId — upload sớm thất bại/race → clip mất sau reload).
|
||
// ── Lossless WAV encode helpers (lossless-audio-compliance skill) ─────────
|
||
// Internal clip/IPC + default export = IEEE Float32 (format 3), bit-exact.
|
||
// Single-stage TPDF dither applied ONLY at final PCM quantization (16/24/8).
|
||
const tpdfDither=(v,lsb)=>v+(Math.random()-Math.random())*lsb*0.5;// TPDF ±0.5 LSB peak-to-peak = 1 LSB
|
||
const encodeWavBlob=audioBuffer=>{const numCh=Math.max(1,audioBuffer.numberOfChannels||1);const sr=audioBuffer.sampleRate||44100;const len=audioBuffer.length;const interleaved=new Float32Array(len*numCh);for(let ch=0;ch<numCh;ch++){const data=audioBuffer.getChannelData(ch);for(let i=0;i<len;i++)interleaved[i*numCh+ch]=data[i];}const buffer=new ArrayBuffer(44+interleaved.length*4);const view=new DataView(buffer);const writeStr=(off,s)=>{for(let i=0;i<s.length;i++)view.setUint8(off+i,s.charCodeAt(i));};writeStr(0,'RIFF');view.setUint32(4,36+interleaved.length*4,true);writeStr(8,'WAVE');writeStr(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,3,true);// IEEE float
|
||
view.setUint16(22,numCh,true);view.setUint32(24,sr,true);view.setUint32(28,sr*numCh*4,true);view.setUint16(32,numCh*4,true);view.setUint16(34,32,true);writeStr(36,'data');view.setUint32(40,interleaved.length*4,true);for(let i=0;i<interleaved.length;i++)view.setFloat32(44+i*4,interleaved[i],true);return new Blob([buffer],{type:'audio/wav'});};const uploadToServer=async(file,trackId)=>{const formData=new FormData();formData.append('file',file);try{const resp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!resp.ok)throw new Error(`Upload failed: ${resp.status}`);const data=await resp.json();serverFileIdMap[trackId]=data.file_id;return data;}catch(err){console.warn('Server upload failed, using client-side only:',err.message);return null;}};// ── Server-side waveform loading ──
|
||
const loadServerWaveform=async fileId=>{try{const resp=await fetch(`${API_AUDIO}/waveform/${fileId}?num_peaks=800`);if(!resp.ok)return null;return await resp.json();}catch{return null;}};// ── Check Celery task result ──
|
||
const pollTaskResult=async(taskId,maxPoll=10)=>{for(let i=0;i<maxPoll;i++){await new Promise(r=>setTimeout(r,1500));try{const resp=await fetch(`${API_TASKS}/${taskId}`);if(!resp.ok)continue;const data=await resp.json();if(data.status==='SUCCESS')return data.result;if(data.status==='FAILURE')throw new Error(data.error||'Task failed');}catch(err){throw err;}}throw new Error('Task polling timeout');};// ── Wheel Zoom ──
|
||
useEffect(()=>{const timeline=timelineWrapperRef.current;if(!timeline)return;const handleWheel=e=>{if(e.ctrlKey||e.metaKey){e.preventDefault();const zoomFactor=e.deltaY>0?0.9:1.1;setZoom(prevZoom=>{let newZoom=prevZoom*zoomFactor;if(newZoom<minZoom)newZoom=minZoom;if(newZoom>50000)newZoom=50000;requestAnimationFrame(()=>{const centerTime=currentTimeRef.current;const newCenterX=centerTime*newZoom;const viewportWidth=timeline.clientWidth;timeline.scrollLeft=newCenterX-viewportWidth/2;});return newZoom;});}else if(e.shiftKey){e.preventDefault();timeline.scrollLeft+=e.deltaY;}};timeline.addEventListener('wheel',handleWheel,{passive:false});return()=>timeline.removeEventListener('wheel',handleWheel);},[minZoom]);// ── Update Playhead ──
|
||
const startSubTabPlayback=(st,offsetWallTime)=>{const context=getAudioContext();_nativePlayEpoch++;// new play — vô hiệu doHardStop đang fade
|
||
// V21: section play không gọi fxRtStart — hủy idle timer thủ công (không
|
||
// để master FX bị teardown giữa lúc section đang phát).
|
||
if(fxRtWarmIdleTimer){clearTimeout(fxRtWarmIdleTimer);fxRtWarmIdleTimer=null;}restorePendingFadeSession();// V17: huy fade dang treo — replay khong bi dip gain
|
||
// D18: loop WRAP (fxRtWrapRestart) — KHONG flush ring bridge: ring dang chua
|
||
// tail pass cu + block moi (bridge render lien tuc); flush tai seam = gap
|
||
// trong → giat beat 0:0. Play that van flush (V30) de bo block idle cu.
|
||
const _wrapRestart=fxRtWrapRestart;const _wrapMode=_wrapRestart?'loop':'play';fxRtWrapRestart=false;// V38
|
||
try{if(!_wrapRestart&&window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong
|
||
// cho phia sau; flush ring bridge (V30) khong chaym queue nay.
|
||
try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:_wrapMode});}catch(e){}if(!st.buffer){st.buffer=context.createBuffer(1,128,context.sampleRate);}const speed=st.speed||1.0;const offsetBuffer=offsetWallTime*speed;const eff=st.buffer.getChannelData(0);const edBuffer=context.createBuffer(1,eff.length,st.buffer.sampleRate);const edData=edBuffer.getChannelData(0);edData.set(eff);const fx=st.effects||{};if(fx.reverse){const reversed=new Float32Array(edData);for(let i=0;i<edData.length;i++)reversed[i]=edData[edData.length-1-i];edBuffer.copyToChannel(reversed,0);}if(fx.gainDb!==0){const gain=Math.pow(10,fx.gainDb/20);for(let i=0;i<edData.length;i++)edData[i]=Math.max(-1,Math.min(1,edData[i]*gain));}if(fx.fadeInMs>0){const sr=edBuffer.sampleRate;const fadeSamples=Math.min(edData.length,Math.floor(fx.fadeInMs/1000*sr));for(let i=0;i<fadeSamples;i++)edData[i]=edData[i]*(i/fadeSamples);}if(fx.fadeOutMs>0){const sr=edBuffer.sampleRate;const fadeSamples=Math.min(edData.length,Math.floor(fx.fadeOutMs/1000*sr));for(let i=edData.length-fadeSamples;i<edData.length;i++){edData[i]=edData[i]*((edData.length-1-i)/fadeSamples);}}const source=context.createBufferSource();source.buffer=edBuffer;source.playbackRate.value=speed;activePlaybackSpeedRef.current=speed;// Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3)
|
||
// source → volumeGainNode → pannerNode → fadeGainNode → destination
|
||
const volumeGainNode=context.createGain();volumeGainNode.gain.setValueAtTime(1.0,context.currentTime);const pannerNode=context.createStereoPanner();pannerNode.pan.setValueAtTime(0.0,context.currentTime);const fadeGainNode=context.createGain();fadeGainNode.gain.setValueAtTime(1.0,context.currentTime);// Schedule volume automation nodes
|
||
const volNodes=st.volumeNodes||[];if(volNodes.length>0){volumeGainNode.gain.cancelScheduledValues(context.currentTime);volNodes.forEach((n,i)=>{const t=context.currentTime+n.time/speed;const linearGain=Math.pow(10,n.db/20);if(i===0)volumeGainNode.gain.setValueAtTime(linearGain,t);else volumeGainNode.gain.linearRampToValueAtTime(linearGain,t);});}// Schedule panning automation nodes
|
||
const panNodes=st.panningNodes||[];if(panNodes.length>0){pannerNode.pan.cancelScheduledValues(context.currentTime);panNodes.forEach((n,i)=>{const t=context.currentTime+n.time/speed;const clamped=Math.max(-1,Math.min(1,n.pan));if(i===0)pannerNode.pan.setValueAtTime(clamped,t);else pannerNode.pan.linearRampToValueAtTime(clamped,t);});}// Schedule fade curves
|
||
const duration=st.buffer.duration;const fIn=st.fadeInLen||0;const fOut=st.fadeOutLen||0;if(fIn>0){fadeGainNode.gain.setValueAtTime(0.0,context.currentTime);fadeGainNode.gain.linearRampToValueAtTime(1.0,context.currentTime+fIn/speed);}if(fOut>0){const fadeOutStart=(duration-fOut)/speed;fadeGainNode.gain.setValueAtTime(1.0,context.currentTime+Math.max(0,fadeOutStart));fadeGainNode.gain.linearRampToValueAtTime(0.0,context.currentTime+duration/speed);}source.connect(volumeGainNode);volumeGainNode.connect(pannerNode);pannerNode.connect(fadeGainNode);// Route through mastering chain unless this track has mastering bypass ON.
|
||
const route=createMasteringRoute(context,{masteringBypass:!!trackAudioBypassMap[st.trackId]},masterBus);fadeGainNode.connect(route.routeGain);fadeGainNode.connect(route.dryGain);source.start(context.currentTime,offsetBuffer);activeSourcesRef.current=[source];// PIANO_ROLL: GIỮ track node thật trong ref (SF notes đã schedule tới nó —
|
||
// ghi đè bằng silent node làm mất mastering route/updateSfRouting đúng).
|
||
if(st.type!=='PIANO_ROLL'){activeTrackNodesRef.current[st.trackId]={gainNode:volumeGainNode,pannerNode,source};}// Realtime mute/solo for the newly created playback chain.
|
||
if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(st.trackId,null);startOffsetTimeRef.current=offsetWallTime;startBufferOffsetRef.current=offsetBuffer;startAudioTimeRef.current=context.currentTime;// V40: real PDC — freeze playhead theo latency FX path (đo được, không ước
|
||
// như V39). Section play không khởi động FX session → allowCold=false.
|
||
try{if(window.__fxRtPlayheadFreeze)window.__fxRtPlayheadFreeze(false);}catch(e){}};const playMetronomeClick=(time,isDownbeat=false)=>{try{const ctx=getAudioContext();const osc=ctx.createOscillator();const gainNode=ctx.createGain();osc.connect(gainNode);gainNode.connect(masterBus?masterBus.input:ctx.destination);osc.frequency.setValueAtTime(isDownbeat?1000:800,time);gainNode.gain.setValueAtTime(0.08,time);gainNode.gain.exponentialRampToValueAtTime(0.001,time+0.08);osc.start(time);osc.stop(time+0.1);}catch(e){console.log('Metronome click play error:',e);}};// Realtime re-schedule tracking (loop play cập nhật khi items đổi vị trí)
|
||
const scheduledItemsSigRef=React.useRef('');const playheadFrameCountRef=React.useRef(0);const pendingRescheduleRef=React.useRef(null);// Master-silence watchdog counter: đếm frame im lặng liên tục khi đang play
|
||
const masterSilenceFramesRef=React.useRef(0);// Cooldown rebuild (ms) — phòng watchdog trigger lặp trong đoạn im tự nhiên
|
||
const lastMasterRebuildTimeRef=React.useRef(0);const lastRescheduleTimeRef=React.useRef(0);// Re-schedule NGAY (reset cooldown) sau khi THẢ chuột — check kế tiếp trong
|
||
// 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();// 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ừ
|
||
// playhead hiện tại để item mới phát đúng vị trí mới (không phát nội dung
|
||
// cũ ở vị trí cũ). Kiểm tra ~10fps (mỗi 6 frame) để kéo item cập nhật gần
|
||
// như realtime mà không tốn CPU mỗi frame.
|
||
if(isPlaying&&activeTabRef.current==='main'&&recordingStateRef.current!=='RECORDING'){playheadFrameCountRef.current++;if(playheadFrameCountRef.current%6===0){// ⚠️ Dùng REFS (không phải state closure) — rAF loop giữ updatePlayhead
|
||
// của render cũ nên activeTracks/sessionTabs trong closure là STALE →
|
||
// signature không bao giờ đổi khi kéo item → không re-schedule.
|
||
const sig=buildItemsSignature(activeTracksRef.current,sessionTabsRef.current);if(sig!==scheduledItemsSigRef.current){// THROTTLE ~300ms: re-schedule định kỳ NGAY CẢ TRONG LÚC KÉO (clip
|
||
// kéo đi → âm thanh cũ dừng ≤300ms, clip mới phát khi playhead tới
|
||
// vị trí mới — realtime) mà không stop/start mỗi frame (giật). Sau
|
||
// khi thả chuột triggerRescheduleNow reset cooldown → re-schedule
|
||
// ở check kế tiếp (~100ms).
|
||
const nowT=performance.now();if(nowT-(lastRescheduleTimeRef.current||0)>300){lastRescheduleTimeRef.current=nowT;scheduledItemsSigRef.current=sig;const pt=currentTimeRef.current;fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();setIsPlaying(true);// Re-schedule theo ĐÚNG chế độ play hiện tại: loop local / solo chỉ
|
||
// phát track liên quan (không phát nhầm track khác); ngược lại play
|
||
// toàn session. Cả 2 hàm đều tính playOffset = pt − clip.startTime
|
||
// → clip vừa kéo tới đúng playhead phát TỪ ĐẦU clip (realtime).
|
||
const curTracks=activeTracksRef.current||activeTracks;const soloed=curTracks.some(t=>t.solo);if(soloed){curTracks.filter(t=>t.solo).forEach(t=>startLocalTrackPlayback(t.id,pt));}else if(selectionMode==='local'&&localSelectionTrackId){startLocalTrackPlayback(localSelectionTrackId,pt);}else{startTrackPlayback(pt);}try{applyMasterAutomationSchedule(pt);}catch(e){}}}else{pendingRescheduleRef.current=null;}}}// Master-silence watchdog: đang play + có source ĐANG TRONG KHOẢNG PHÁT
|
||
// (theo lịch) nhưng master output im lặng liên tục ~750ms → graph bị hỏng
|
||
// (BiquadFilter "state is bad" — node cache dính) → rebuild nodes.
|
||
// Áp cho main + sub-tab AUDIO. PIANO_ROLL tab KHÔNG có nguồn liên tục
|
||
// (chỉ notes MIDI schedule rời rạc) → rest/im >750ms là TỰ NHIÊN, watchdog
|
||
// sẽ rebuild loop + panic hủy notes chờ → CÂM TOÀN CỤC → exempt hoàn toàn
|
||
// (các fix setValueAtTime/NaN guard đã hết "state is bad" — watchdog chỉ
|
||
// còn là lớp cứu cuối cho main/audio-tab).
|
||
// PIANO_ROLL: watchdog CHỈ rebuild khi output chứa NaN (chain chết —
|
||
// state-bad). KHÔNG rebuild khi im lặng thường (rests tự nhiên giữa các
|
||
// note — false-positive = stopAllPlayback + reschedule = glitch).
|
||
const _anySubPlaying=subTabsRef.current.some(s=>s.isPlaying);const activeSub=subTabsRef.current.find(s=>s.id===activeTabRef.current);const isPianoRoll=activeSub&&activeSub.type==='PIANO_ROLL';// SECTION-TAB cũng chỉ rebuild khi NaN (20:00): audioclip mất/rest tự
|
||
// nhiên → im lặng > 750ms là BÌNH THƯỜNG — pk<0.001 → recovery hủy play +
|
||
// playhead về đầu track (đúng lỗi user: "playhead về đầu + không play").
|
||
const isSectionTab=activeTabRef.current&&activeTabRef.current.startsWith('session_');if((isPlaying||_anySubPlaying)&&masterBus&&masterBus.analyser&&(isPianoRoll||activeSourcesRef.current.length>0)){try{// "Đáng lẽ đang có âm" — quyết định watchdog có được rebuild không:
|
||
// - Main / sub-tab audio: source thật đang trong khoảng phát.
|
||
// - PIANO_ROLL: tab đang play (notes đã schedule) = đáng lẽ có âm.
|
||
const ctxNow=getAudioContext().currentTime;let anyPlaying=false;if(isPianoRoll){anyPlaying=_anySubPlaying;}else if(_anySubPlaying){const _subs=subTabsRef.current||[];for(let _si=0;_si<_subs.length;_si++){const s=_subs[_si];if(!s.isPlaying)continue;if(s.buffer){if(activeSourcesRef.current.some(src=>typeof src.startTime==='number'&&ctxNow>=src.startTime&&ctxNow<=src.startTime+(src.buffer?src.buffer.duration:0)+0.1)){anyPlaying=true;break;}}}}else{anyPlaying=activeSourcesRef.current.some(s=>typeof s.startTime==='number'&&ctxNow>=s.startTime&&ctxNow<=s.startTime+(s.buffer?s.buffer.duration:0)+0.1);}if(anyPlaying){const d=new Uint8Array(128);masterBus.analyser.getByteTimeDomainData(d);let pk=0;for(let i=0;i<d.length;i++){const v=Math.abs(d[i]-128)/128;if(v>pk)pk=v;}// PIANO_ROLL: chain chết xuất NaN → getByteTimeDomainData đọc NaN →
|
||
// byte 0/128 → pk CAO → mù. Check float data (chỉ piano roll — rests
|
||
// tự nhiên thì KHÔNG rebuild).
|
||
let nanOut=false;if(isPianoRoll){try{const f=new Float32Array(128);masterBus.analyser.getFloatTimeDomainData(f);for(let i=0;i<f.length;i++){if(!isFinite(f[i])){nanOut=true;break;}}}catch(e){}}// PIANO_ROLL: CHỈ rebuild khi output NaN (chain chết — state-bad).
|
||
// pk<0.001 (im lặng) KHÔNG trigger cho piano roll — rests tự nhiên
|
||
// giữa các note > 750ms là BÌNH THƯỜNG → false-positive = recovery
|
||
// hủy play + restart notes (glitch) — đúng chuỗi log recovery trước.
|
||
if(isPianoRoll||isSectionTab?nanOut:pk<0.001||nanOut){// Native VSTi GUI đang mở/reload (reloadForGUI mute 1-3s) — im
|
||
// lặng là TỰ NHIÊN (C++ đang rebuild instance), không phải graph
|
||
// hỏng. Không rebuild → không stopAllPlayback → không fxRtStop →
|
||
// hết chuỗi dry→wet vài giây sau mỗi lần mở GUI track.
|
||
if(performance.now()<nativeGuiOpenUntilTs){masterSilenceFramesRef.current=0;}else{masterSilenceFramesRef.current++;const sinceRebuild=performance.now()-(lastMasterRebuildTimeRef.current||0);// NaN = chain CHẾT chắc chắn → rebuild NGAY (3 frame ≈ 50ms).
|
||
// pk<0.001 (im lặng nghi ngờ — main) giữ 45 frame (750ms) để loại
|
||
// transient gap false-positive. Cooldown 3000 chống rebuild-loop.
|
||
if(masterSilenceFramesRef.current>(nanOut?3:45)&&sinceRebuild>3000){masterSilenceFramesRef.current=0;lastMasterRebuildTimeRef.current=performance.now();console.warn('[Recovery] Master silent while sources active — rebuilding audio graph');try{const pt=currentTimeRef.current;stopAllPlayback();Object.keys(activeTrackNodesRef.current).forEach(k=>{const n=activeTrackNodesRef.current[k];try{if(n&&n.gainNode&&n.gainNode.disconnect)n.gainNode.disconnect();}catch(e){}});activeTrackNodesRef.current={};// Tháo chain cũ khỏi destination rồi rebuild THẬT — initMasterBus
|
||
// early-return khi masterBus còn tồn tại → recovery trước đây là
|
||
// no-op → chain "state is bad" bị STUCK vĩnh viễn.
|
||
try{if(masterBus){try{if(masterBus.analyser)masterBus.analyser.disconnect();}catch(e){}try{if(masterBus.output)masterBus.output.disconnect();}catch(e){}try{if(masterBus.dryOutput)masterBus.dryOutput.disconnect();}catch(e){}}masterBus=null;initMasterBus(getAudioContext());}catch(e){console.warn('[Recovery] initMasterBus error:',e);}// Resume ĐÚNG chế độ play hiện tại (main hoặc sub-tab piano roll)
|
||
const curTab=activeTabRef.current;const subSt=subTabsRef.current.find(s=>s.id===curTab);if(subSt&&(subSt.type==='PIANO_ROLL'||subSt.buffer)){const resumeAt=subSt.currentTime||0;if(subSt.type==='PIANO_ROLL')schedulePianoRollMidi(subSt,resumeAt);startSubTabPlayback(subSt,resumeAt);setSubTabs(prev=>prev.map(s=>s.id===curTab?{...s,buffer:s.buffer||getAudioContext().createBuffer(1,128,getAudioContext().sampleRate),isPlaying:true,currentTime:resumeAt}:s));animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{setIsPlaying(true);startTrackPlayback(pt);}}catch(e){console.warn('[Recovery] rebuild error:',e);}}}}else{masterSilenceFramesRef.current=0;}}else{masterSilenceFramesRef.current=0;}}catch(e){masterSilenceFramesRef.current=0;}}else{masterSilenceFramesRef.current=0;}if(recordingStateRef.current==='RECORDING'){const audioCtx=getAudioContext();const lookahead=0.1;// 100ms
|
||
const secondsPerBeat=60.0/(parseInt(bpmRef.current)||120);// Metronome Click Scheduler
|
||
while(true){const beatNum=nextMetronomeBeatRef.current;const elapsedBeats=beatNum-recordingStartTimeRef.current/secondsPerBeat;const beatTime=startAudioTimeRef.current+elapsedBeats*secondsPerBeat;if(beatTime<audioCtx.currentTime+lookahead){const isDownbeat=beatNum%4===0;playMetronomeClick(beatTime,isDownbeat);nextMetronomeBeatRef.current++;}else{break;}}// Throttled Live Preview compilation (every 200ms)
|
||
const now=Date.now();if(now-lastTempCompileTimeRef.current>200){lastTempCompileTimeRef.current=now;// Audio preview
|
||
for(let trackId in recordingPCMDataRef.current){const data=recordingPCMDataRef.current[trackId];if(data&&data.length>0){const tempBuf=audioCtx.createBuffer(1,data.length,audioCtx.sampleRate);tempBuf.getChannelData(0).set(data);setRecTempAudioBuffer(tempBuf);}}// MIDI preview
|
||
let combinedNotes=[];const armedTracks=activeTracksRef.current.filter(t=>t.isArmed);for(let track of armedTracks){const midiRec=activeMIDIRecordersRef.current[track.id];if(midiRec){const currentBeat=(audioCtx.currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec)/secondsPerBeat;const notes=[...midiRec.recordedNotes,...Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}))];combinedNotes=combinedNotes.concat(notes);}}if(combinedNotes.length>0||armedTracks.some(t=>activeMIDIRecordersRef.current[t.id])){setRecTempMidiNotes(combinedNotes);setCanvasRedrawCount(n=>n+1);}}}const isSubTab=subTabsRef.current.some(sub=>sub.id===activeTabRef.current);if(isSubTab){const st=subTabsRef.current.find(s=>s.id===activeTabRef.current);if(!st)return;// PIANO ROLL KHÔNG cần buffer: tab restored từ phiên trước — AudioBuffer
|
||
// KHÔNG serialize → st.buffer undefined → return cũ làm playhead ĐỨNG IM
|
||
// (user bug 06:15 — mở lại piano roll đã mở phiên trước khi đang play).
|
||
// startSubTabPlayback tự tạo silent buffer khi play (18103).
|
||
if(!st.buffer&&st.type!=='PIANO_ROLL')return;if(!st.isPlaying){// PIANO ROLL / sub-tab KHÔNG play: playhead theo MAIN/SECTION nếu đang
|
||
// play — ĐỒNG BỘ 2 CHIỀU: vị trí play (section/main) → vị trí TRONG
|
||
// 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();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;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
|
||
try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}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
|
||
let maxEnd=0;notes.forEach(n=>{const end=(n.start_beat||0)+(n.duration_beats||1);if(end>maxEnd)maxEnd=end;});return Math.max(maxEnd*beatSec,16*beatSec*4)+1.0;})():st.buffer.duration;if(bufferPos>=effectiveDuration){fxRtWrapRestart=true;// V38: loop wrap restart
|
||
try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}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;// G3: playhead display engine-derived khi FX active (wrap/loop trigger giu
|
||
// wall-clock — transport truth).
|
||
const _playheadSec=_fxRtSeekFreeze&&fxRt&&fxRt.active?startOffsetTimeRef.current: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
|
||
try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback(false,true);// V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble)
|
||
startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=context.currentTime;if(hasAnySolo){const soloed=tracks.filter(t=>t.solo);soloed.forEach(t=>startLocalTrackPlayback(t.id,selLeft));}else{startLocalTrackPlayback(localSelectionTrackId,selLeft);}if(fxRt&&fxRt.active){// Lỗi 1: wrap-hold — content audible (đã trừ latency) còn cách selRight;
|
||
// giữ playhead chạy wall tới đúng end rồi mới về selLeft (pass mới audible).
|
||
wrapHoldRef.current={end:selRight,t0:context.currentTime,v0:Math.min(selRight,_playheadSec)};setCurrentTime(Math.min(selRight,_playheadSec));}else{setCurrentTime(selLeft);}setIsPlaying(true);}else{fxRtWrapRestart=true;// V38: loop wrap restart
|
||
try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback(false,true);// V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble)
|
||
startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=context.currentTime;startTrackPlayback(selLeft);if(fxRt&&fxRt.active){wrapHoldRef.current={end:selRight,t0:context.currentTime,v0:Math.min(selRight,_playheadSec)};setCurrentTime(Math.min(selRight,_playheadSec));}else{setCurrentTime(selLeft);}setIsPlaying(true);}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}}// Hết bài: dừng/loop lại tại ENDTIME THẬT của session (projectEnd — không
|
||
// 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
|
||
try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
const _wrapT0=performance.now();stopAllPlayback(false,true);// V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble)
|
||
const _wrapT1=performance.now();startOffsetTimeRef.current=0;startAudioTimeRef.current=context.currentTime;startTrackPlayback(0);const _wrapT2=performance.now();if(fxRt&&fxRt.active){// Lỗi 1: wrap-hold projectEnd — audible còn tail ~latencyTotal sau wall wrap;
|
||
// giữ playhead chạy wall tới ĐÚNG projectEnd rồi mới về 0 (pass mới audible).
|
||
wrapHoldRef.current={end:projectEndRef.current,t0:context.currentTime,v0:Math.min(projectEndRef.current,_playheadSec)};setCurrentTime(Math.min(projectEndRef.current,_playheadSec));}else{setCurrentTime(0);}setIsPlaying(true);try{_dbgLog('wrap_timing','stop='+Math.round(_wrapT1-_wrapT0)+' start='+Math.round(_wrapT2-_wrapT1));}catch(e){}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}stopAllPlayback();setCurrentTime(0);return;}if(wrapHoldRef.current){// Lỗi 1: đang trong wrap-hold (wrap xảy ra, session FX realtime còn tail
|
||
// pipeline) — playhead chạy wall tới end; chạm end -> thả (engine-derived
|
||
// pass restart ≈ startOffset khi content mới audible). Hết nhảy về đầu sớm.
|
||
const _wh=wrapHoldRef.current;const _whv=Math.min(_wh.end,_wh.v0+(context.currentTime-_wh.t0));if(_whv>=_wh.end){wrapHoldRef.current=null;setCurrentTime(_wh.end);}else{setCurrentTime(_whv);}}else{if(Date.now()<_phDiagArmedUntil){const _pd=_playheadSec-(currentTimeRef.current||0);if(_pd>0.25||_pd<-0.25){try{_dbgLog('ph_jump','prevShown='+(currentTimeRef.current||0).toFixed(3)+' now='+_playheadSec.toFixed(3)+' d='+_pd.toFixed(3));}catch(e){}}}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<len;i++){const decay=Math.exp(-i/(rate*0.5));left[i]=(Math.random()*2-1)*decay;right[i]=(Math.random()*2-1)*decay;}convolver.buffer=impulse;inputNode.connect(dryGain);inputNode.connect(convolver);convolver.connect(wetGain);dryGain.connect(outputNode);wetGain.connect(outputNode);};// ── Playback ──
|
||
const getOrCreateTrackNode=(track,context)=>{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):
|
||
// L/R split at the context OUTPUT (post-FX, after panner) so the rack's
|
||
// oscilloscope can render Stereo/Left/Right/Mid/Side channels.
|
||
const scopeSplitter=context.createChannelSplitter(2);const scopeAnalyserL=context.createAnalyser();const scopeAnalyserR=context.createAnalyser();scopeAnalyserL.fftSize=2048;scopeAnalyserR.fftSize=2048;pannerNode.connect(scopeSplitter);scopeSplitter.connect(scopeAnalyserL,0);scopeSplitter.connect(scopeAnalyserR,1);// Dual mastering route: routeGain -> mastering chain (normal), dryGain ->
|
||
// dry bus (skip mastering ONLY). BOTH tap POST-FX (analyser) so the A
|
||
// button bypasses JUST the Mastering FX Chain — the track FX Rack chain
|
||
// (fxEntry → modules) is controlled ONLY by the PWR button (fxActive).
|
||
const route=createMasteringRoute(context,track,masterBus);analyserNode.connect(route.routeGain);analyserNode.connect(route.dryGain);let fxStopFn;// Track FX chain (unified_fx_rack_panel.md §II): reusable module instances
|
||
// in series between fxEntry (from gainNode) and fxLegacyIn (into the
|
||
// legacy chorus/reverb path). fxEntry/fxLegacyIn let rebuildTrackFxGraph
|
||
// re-route THIS track's chain without touching other tracks or the bus.
|
||
const fxEntry=context.createGain();const fxLegacyIn=context.createGain();gainNode.connect(fxEntry);const fxChain=track.fxChain||[];// FX Power (nút PWR): ON = mọi items con (midi/section/audioclip) qua FX
|
||
// Rack Panel chain; OFF = bỏ toàn bộ track FX (fxChain + chorus/reverb).
|
||
const fxEnabled=track.fxActive!==false;// Phase 4.3: track builtin DSP chạy trong bridge — WebAudio pass-through.
|
||
fxEntry.connect(fxLegacyIn);if(fxEnabled&&track.fxType==='chorus'){const fxInput=context.createGain();fxLegacyIn.connect(fxInput);const chorus=createChorusNode(context,fxInput,pannerNode);fxStopFn=chorus.stop;}else if(fxEnabled&&track.fxType==='reverb'){const fxInput=context.createGain();fxLegacyIn.connect(fxInput);createReverbNode(context,fxInput,pannerNode);fxStopFn=null;}else{fxLegacyIn.connect(pannerNode);}// Independent MIDI/soundfont FX path (user request): SF audio flows
|
||
// gainNode → sfEntry → [own module instances] → sfOut → pan → master bus,
|
||
// fully separate from the audio-clips chain — so the A (audio) bypass
|
||
// NEVER affects the soundfont instrument, and the ♪ bypass never touches
|
||
// audio clips/sections.
|
||
let sfEntry=null,sfOut=null,sfPan=null,sfAnalyser=null,sfRouteGain=null,sfDryGain=null,sfMods=[];const isMidiTrack=track.midiItems&&track.midiItems.length>0||track.type==='MIDI'||!!track.instrumentId;if(isMidiTrack){sfEntry=context.createGain();sfOut=context.createGain();sfPan=context.createStereoPanner();sfPan.pan.setValueAtTime((track.pan??0)/100,context.currentTime);sfAnalyser=context.createAnalyser();sfAnalyser.fftSize=2048;// NOTE: sfEntry is NOT fed from gainNode — the FluidSynth output is
|
||
// routed into sfEntry directly by updateSfRouting(). Connecting
|
||
// gainNode → sfEntry here would LEAK every audio clip/section into the
|
||
// SF chain → masterBus.input → mastering even when bypassed (double
|
||
// audio + still processed). sfEntry.gain mirrors the track's audible
|
||
// gain (volume/mute/solo) instead.
|
||
sfEntry.gain.value=1;sfOut.connect(sfPan);// Feed the soundfont into the shared scope analysers too, so the Wave
|
||
// Observer shows the FULL post-FX track signal: audioclips + section
|
||
// items (pannerNode) AND midi items (sfOut) mixed.
|
||
sfOut.connect(scopeSplitter);// ♪ button: bypass Mastering FX Chain for the soundfont ONLY (track FX
|
||
// Rack modules are still applied — PWR controls those). sfRouteGain →
|
||
// masterBus.input (mastering), sfDryGain → dry bus (skip mastering).
|
||
const sfBypass=effMidiBypass(track);sfRouteGain=context.createGain();sfDryGain=context.createGain();sfRouteGain.gain.value=sfBypass?0:1;sfDryGain.gain.value=sfBypass?1:0;console.log('[Bypass] sfEntry created track',track.id,'midiBypass='+sfBypass,sfBypass?'DRY':'MASTERING','| midiItems:',(track.midiItems||[]).length);sfPan.connect(sfRouteGain);sfPan.connect(sfDryGain);sfRouteGain.connect(masterBus.input);sfDryGain.connect(masterBus.dryInput);sfOut.connect(sfAnalyser);sfMods=[];sfEntry.connect(sfOut);}node={gainNode,pannerNode,fxStopFn,analyserNode,route,fxEntry,fxLegacyIn,scopeAnalyserL,scopeAnalyserR,sfEntry,sfOut,sfPan,sfAnalyser,sfRouteGain,sfDryGain,fxMods:[],sfMods:[]};// Realtime mute/solo: apply the track's current mute/solo/volume state to
|
||
// the fresh node so items of muted/soloed tracks start correctly.
|
||
const trackList=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:[track];const nodeAudibleGain=computeTrackAudibleGain(trackList,track);setTrackNodeGain(node,nodeAudibleGain);if(node.sfEntry){try{const g=typeof nodeAudibleGain==='number'&&isFinite(nodeAudibleGain)&&!isNaN(nodeAudibleGain)?nodeAudibleGain:1.0;node.sfEntry.gain.setTargetAtTime(g,context.currentTime,0.02);}catch(e){}}activeTrackNodesRef.current[track.id]=node;console.log('[Bypass] node created track',track.id,'initial audioBypass=',!!trackAudioBypassMap[track.id],'routeGain=',node.route.routeGain.gain.value,'dryGain=',node.route.dryGain.gain.value);updateSfRouting();}return node.gainNode;};// Per-context audio graph reconstruction (unified_fx_rack_panel.md §III.2):
|
||
// re-routes ONLY this track's fx chain (fxEntry → active modules → fxLegacyIn),
|
||
// leaving other tracks and the master bus untouched. Called by the FX Rack
|
||
// panel on add/remove/reorder/toggle/param change — live while playing.
|
||
const rebuildTrackFxGraph=(trackId,patch)=>{const node=activeTrackNodesRef.current[trackId];if(!node||!node.fxEntry||!node.fxLegacyIn)return;try{const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;let track=list.find(t=>t.id===trackId);if(!track)return;if(patch)track={...track,...patch};node.fxEntry.disconnect();node.fxEntry.connect(node.fxLegacyIn);node.fxMods=[];// Rebuild the independent SF chain too (same modules, separate instances)
|
||
if(node.sfEntry&&node.sfOut){node.sfEntry.disconnect();node.sfEntry.connect(node.sfOut);node.sfMods=[];}if(node.sfPan&&getAudioContext()){node.sfPan.pan.setTargetAtTime((track.pan??0)/100,getAudioContext().currentTime,0.02);}// Realtime VST FX per-track: chain mới → reconcile session/worklet
|
||
// (start nếu chain active khi play / stop nếu chain bị bypass / re-splice
|
||
// nếu mods đổi). VST áp SAU builtin FX — khớp offline export.
|
||
fxRtTrackReconcile(trackId,node,track);}catch(e){console.warn('rebuildTrackFxGraph error:',e);}};const rebuildTrackFxGraphRef=useRef(null);rebuildTrackFxGraphRef.current=rebuildTrackFxGraph;window.__rebuildTrackFxGraph=rebuildTrackFxGraph;// ── Realtime VST FX per-track (SHM bridge) — component scope ──────────────
|
||
// 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=202609071534`);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
|
||
// (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.
|
||
// Gửi NGAY (không pace setTimeout) như master path (proven): worklet đã
|
||
// pace 1 burst/21.33ms theo đồng hồ audio; timer main-thread bận/trễ →
|
||
// input gửi trễ → engine chỉ sản xuất khi có input → cushion cạn →
|
||
// crackle. Burst sau stall: engine hấp thụ (in ring 4 slot, drop-oldest).
|
||
if(entry.ws&&entry.ws.readyState===1){try{const f=new Float32Array(d.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(d.data);}}catch(e){}}};}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;}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.
|
||
if(entry.ready)fxRtTrackSetChain(trackId,track,p,chainKey);else{fxRtTrackStop(trackId,p,true);fxRtTrackStart(trackId,track,p);}}else if(entry.ready&&node){fxRtTrackUnsplice(trackId,p);// chain mới (sau rebuild) — splice lại đúng mods
|
||
fxRtTrackSplice(trackId,p);}});}catch(e){console.warn('fxRtTrackReconcile error:',e);}};const fxRtTrackStopAll=()=>{Object.keys(fxRtTracks).forEach(k=>{const i=k.lastIndexOf(':');fxRtTrackStop(k.slice(0,i),k.slice(i+1),true);});};// Route the SHARED FluidSynth output through the gainNode of the single
|
||
// audible MIDI track so its FX Rack chain / fader / pan affect the soundfont
|
||
// instrument (unified_fx_rack_panel.md + user request). With more than one
|
||
// audible MIDI track the shared worklet cannot split per channel, so we fall
|
||
// back to the master bus (FX not applied — CC7 still mutes correctly).
|
||
const updateSfRouting=()=>{try{const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;// PIANO_ROLL tab đang active → ưu tiên route FluidSynth tới NODE CỦA
|
||
// TRACK đang edit (sfEntry → FX Rack riêng, fallback gainNode) để fader/
|
||
// pan/FX áp đúng cho notes đang nghe — kể cả khi project có >1 track MIDI
|
||
// audible (bình thường rơi về masterBus.input, mất FX track).
|
||
const _activeTabId=activeTabRef.current;const _activeSub=subTabsRef.current?subTabsRef.current.find(s=>s.id===_activeTabId):null;// MAIN/SECTION đang play + đang ở SUB-TAB (piano roll/audio): KHÔNG đổi
|
||
// SF destination — âm đang phát thuộc MAIN context (route đã set ĐÚNG
|
||
// lúc schedule — masterBus.input — qua mastering). Đổi đích khi âm đang
|
||
// phát gây: (a) swap _gainNode → LAG ~1s (bug 06:20); (b) route vào node
|
||
// MAIN trùng id (midiAudible nhánh) → sfEntry/sfRouteGain theo ♪ main →
|
||
// âm dry NHỎ/không rõ (bug 06:25 — fix 0620). Giữ nguyên route hiện tại.
|
||
if(isPlayingRef.current&&_activeTabId&&_activeTabId!=='main'&&!_activeTabId.startsWith('session_')){return;}// MAIN/SESSION dang play -> KHONG doi bridge sink khi mute/solo giua play:
|
||
// disconnect/reconnect _gainNode FluidSynth giua play -> glitch delay/wobble/crackle
|
||
// (bug V45.9: un-solo VSTi -> soundfont con lai delay ngay). Mute/solo da xu ly
|
||
// bang CC7 + track gain; routing se set lai o lan updateSfRouting sau (STOP).
|
||
if(isPlayingRef.current){console.log('[SFRoute] skip sink re-point while playing tab='+_activeTabId);return;}// MAIN/SECTION đang play (isPlayingRef) → BỎ QUA nhánh PIANO_ROLL:
|
||
// đổi SF destination (setOutputDestination — disconnect/reconnect
|
||
// _gainNode FluidSynth) khi âm đang phát → âm MIDI bị NGẮT ~1s (user
|
||
// bug 06:20 — mở lại piano roll tab restored lúc main play → lag 1s
|
||
// rồi mới phát tiếp). Chỉ route vào node piano roll khi KHÔNG play
|
||
// (preview tab) — handlePlayPause sub-tab tự stopAllPlayback trước.
|
||
if(_activeSub&&_activeSub.type==='PIANO_ROLL'&&!isPlayingRef.current){// PIANO ROLL của track TRONG SECTION (parent_tab_id = session_*): id
|
||
// track trùng main track id → lookup key = id trúng node MAIN (route
|
||
// theo ♪ main — sai context → MIDI bị kéo dry). ƯU TIÊN SUB-NODE
|
||
// (key <parent>_sub_<id> — route theo ♪ section + thẳng masterBus);
|
||
// không có sub-node → null (masterBus.input — qua mastering).
|
||
let _prNode=activeTrackNodesRef.current[_activeSub.trackId];if(_activeSub.parent_tab_id&&_activeSub.parent_tab_id.startsWith('session_')){const _prSubKeys=Object.keys(activeTrackNodesRef.current).filter(k=>k.endsWith('_sub_'+_activeSub.trackId));_prNode=_prSubKeys.length?activeTrackNodesRef.current[_prSubKeys[0]]:null;}// D5: bridge active → nối BridgeAudioNode vào node track (sfEntry →
|
||
// FX chain → mastering) thay vì SonicSF.setOutputDestination.
|
||
if(_prNode&&window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){_dbgLog('sf_route','PR_CONNECT track='+_activeSub.trackId+' dest='+(_prNode.sfEntry?'sfEntry':_prNode.gainNode?'gainNode':'NULL'));window.AudioRoutingEngine.connect(window.BridgeAudioNode,_prNode,_activeSub.trackId);return;}if(_prNode&&window.SonicSF&&window.SonicSF.setOutputDestination){if(_prNode.sfEntry){window.SonicSF.setOutputDestination(_prNode.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing (node có
|
||
// thể tạo khi mastering OFF → route dry — sửa ngay nếu chain ON).
|
||
if(_prNode.sfRouteGain&&_prNode.sfDryGain){const _prTrk=list.find(t=>t.id===_activeSub.trackId);const _b=effMidiBypass(_prTrk||{id:_activeSub.trackId});_prNode.sfRouteGain.gain.value=_b?0:1;_prNode.sfDryGain.gain.value=_b?1:0;}return;}if(_prNode.gainNode){window.SonicSF.setOutputDestination(_prNode.gainNode);// ⚠️ FIX: sync route gains NGAY tại thời điểm routing — trước đây
|
||
// bỏ qua → node giữ bypass cũ (tạo khi mastering OFF / audioBypass
|
||
// true) → preview âm soundfont KHÔNG qua mastering cho tới khi bật/
|
||
// tắt power (effect re-sync). MIDI preview route theo ♪ (midiBypass).
|
||
if(_prNode.route&&_prNode.route.routeGain&&_prNode.route.dryGain){const _prTrk2=list.find(t=>t.id===_activeSub.trackId);const _b2=effMidiBypass(_prTrk2||{id:_activeSub.trackId});_prNode.route.routeGain.gain.value=_b2?0:1;_prNode.route.dryGain.gain.value=_b2?1:0;}return;}}}const midiAudible=list.filter(t=>(t.midiItems&&t.midiItems.length>0||t.isArmed||t.type==='MIDI'||!!t.instrumentId)&&computeTrackAudibleGain(list,t)>0);_dbgLog('sf_route','tab='+_activeTabId+' sub='+(_activeSub?_activeSub.type:'none')+' play='+(isPlayingRef.current?1:0)+' audible=['+midiAudible.map(t=>t.id).join(',')+'] eng='+(window.AudioRoutingEngine&&window.AudioRoutingEngine._connected?'conn:'+(window.AudioRoutingEngine._dest?window.AudioRoutingEngine._dest.constructor.name:'null'):'disconn')+' br='+(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()?1:0));console.log('[SFRoute] activeTab='+_activeTabId,'midiAudible='+midiAudible.map(t=>t.id+'(midi'+(t.midiItems||[]).length+')').join(',')||'none');if(midiAudible.length===1){const t=midiAudible[0];// SECTION-TAB: track id của section clone TRÙNG main track id → node
|
||
// chính (key = id) là node MAIN (route theo ♪ main — SAI context, MIDI
|
||
// section bị kéo vào dry). ƯU TIÊN SUB-NODE (key <parent>_sub_<id> —
|
||
// route theo ♪ section + thẳng masterBus.input) — không có sub-node →
|
||
// null (SF về masterBus.input — qua mastering, KHÔNG bypass).
|
||
let node=activeTrackNodesRef.current[t.id];if(_activeTabId&&_activeTabId.startsWith('session_')){const subKeys=Object.keys(activeTrackNodesRef.current).filter(k=>k.endsWith('_sub_'+t.id));node=subKeys.length?activeTrackNodesRef.current[subKeys[0]]:null;}// SF ALWAYS enters the track's own FX chain (sfEntry → sfModules when
|
||
// PWR ON). The ♪ button only switches the post-FX route (sfRouteGain /
|
||
// sfDryGain) to skip or include the Mastering FX Chain.
|
||
// D5: bridge active → nối vào node track (sfEntry → FX chain).
|
||
if(node&&window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){_dbgLog('sf_route','SINGLE_CONNECT track='+t.id+' dest='+(node.sfEntry?'sfEntry':node.gainNode?'gainNode':'NULL'));window.AudioRoutingEngine.connect(window.BridgeAudioNode,node,t.id);return;}if(node&&node.sfEntry&&window.SonicSF&&window.SonicSF.setOutputDestination){console.log('[SFRoute] dest=sfEntry node',t.id,'sfRouteGain='+(node.sfRouteGain?node.sfRouteGain.gain.value:'MISSING'),'sfDryGain='+(node.sfDryGain?node.sfDryGain.gain.value:'MISSING'));window.SonicSF.setOutputDestination(node.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing.
|
||
if(node.sfRouteGain&&node.sfDryGain){// ⚠️ FIX: truyền track OBJECT (t) — trước đây truyền t.id (string)
|
||
// → effMidiBypass đọc track.id=undefined → map lookup sai → _b luôn
|
||
// false → sfRouteGain bị ép 1 (không bypass) — nút ♪ vô hiệu qua
|
||
// đường này.
|
||
const _b=effMidiBypass(t);node.sfRouteGain.gain.value=_b?0:1;node.sfDryGain.gain.value=_b?1:0;console.log('[Bypass] sf route sync track',t.id,'midiBypass='+_b,_b?'DRY':'MASTERING');}return;}if(node&&node.gainNode&&window.SonicSF&&window.SonicSF.setOutputDestination){console.log('[SFRoute] dest=gainNode node',t.id,'(no sfEntry) — route.routeGain='+(node.route?node.route.routeGain.gain.value:'MISSING'),'route.dryGain='+(node.route?node.route.dryGain.gain.value:'MISSING'));window.SonicSF.setOutputDestination(node.gainNode);// ⚠️ FIX: sync route gains — node giữ bypass cũ → preview không qua
|
||
// mastering cho tới khi bật/tắt power. MIDI preview theo ♪.
|
||
if(node.route&&node.route.routeGain&&node.route.dryGain){const _b3=effMidiBypass(t);node.route.routeGain.gain.value=_b3?0:1;node.route.dryGain.gain.value=_b3?1:0;}return;}}// D5: bridge active + không track nào audible → ngắt khỏi graph (về
|
||
// masterBus qua updateSfRouting lần sau khi có track).
|
||
if(window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){_dbgLog('sf_route','MASTER_FALLBACK');window.AudioRoutingEngine.connect(window.BridgeAudioNode,null,null);}else if(window.SonicSF&&window.SonicSF.setOutputDestination){window.SonicSF.setOutputDestination(null);}}catch(e){console.warn('updateSfRouting error:',e);}};const updateSfRoutingRef=useRef(null);// ── Đồng bộ mastering + routing NGAY TRƯỚC KHI PREVIEW (keybed/draw/MIDI file) ──
|
||
// Bug: preview instrument soundfont với MIDI keyboard KHÔNG tự động đi qua
|
||
// mastering FX của main out — phải bật/tắt nút power mới có tác dụng. Nguyên
|
||
// nhân: routing SF (setOutputDestination + sfRouteGain/sfDryGain) và graph
|
||
// mastering chỉ được đồng bộ khi masteringSettings ĐỔI (toggle power), không
|
||
// chạy khi bắt đầu preview. Hàm này ép đồng bộ 3 lớp (graph theo trạng thái
|
||
// hiện tại — early-return nếu đã đúng; tham số module; route SF + route
|
||
// gains) — gọi tại mọi điểm preview → âm preview LUÔN qua mastering khi bật.
|
||
const ensureMasteringRouting=()=>{try{const _s=window.currentMasteringSettings;if(!_s)return;// Đảm bảo audioCtx + masterBus đã tồn tại trước khi sync (lần preview
|
||
// đầu tiên audio chưa init → getAudioContext tạo ngay)
|
||
getAudioContext();if(!masterBus||!audioCtx)return;toggleMasteringOnMaster(!!_s.masterConnected,!!_s.isBypassed);applyMasteringSettings(_s);updateSfRouting();// Force-sync MỌI node đang tồn tại (route + sf route) theo trạng thái
|
||
// hiện tại — phòng node tạo từ trước với bypass cũ (mastering OFF lúc
|
||
// tạo node / audioBypass default true của section) → preview LUÔN qua
|
||
// mastering khi chain bật, không cần bật/tắt power.
|
||
try{const _list2=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;_list2.forEach(_t=>{const _n=activeTrackNodesRef.current[_t.id];if(!_n)return;if(_n.sfRouteGain&&_n.sfDryGain){const _mb=effMidiBypass(_t);_n.sfRouteGain.gain.value=_mb?0:1;_n.sfDryGain.gain.value=_mb?1:0;}if(_n.route&&_n.route.routeGain&&_n.route.dryGain){const _ab=routeBypassFor(_t);_n.route.routeGain.gain.value=_ab?0:1;_n.route.dryGain.gain.value=_ab?1:0;}});}catch(e2){}}catch(e){console.warn('ensureMasteringRouting error:',e);}};const ensureMasteringRoutingRef=useRef(null);ensureMasteringRoutingRef.current=ensureMasteringRouting;window.__ensureMasteringRouting=ensureMasteringRouting;// ── MIDI preview cache (bounce nhanh offline) ──
|
||
// Khi preview: capture tiếng đàn (SF output — pre track-FX) vào cache theo
|
||
// track. Khi export: nếu cache đủ độ dài → render OFFLINE NHANH bằng cache
|
||
// (qua FX Rack + Mastering như playback) — không cần chờ bounce realtime.
|
||
const midiCacheRef=useRef({});// trackId -> { chunks:[Float32Array], duration, sampleRate }
|
||
const midiCaptureNodesRef=useRef({});// trackId -> { script, node }
|
||
const ensureMidiCapture=(track,node)=>{if(!track||!node||!node.sfEntry)return;const id=track.id;const audioCtx=getAudioContext();// Play lại từ đầu → reset cache để ghi bản mới nhất
|
||
if(currentTimeRef.current<=0.05){midiCacheRef.current[id]={chunks:[],duration:0,sampleRate:audioCtx.sampleRate};}if(!midiCacheRef.current[id])midiCacheRef.current[id]={chunks:[],duration:0,sampleRate:audioCtx.sampleRate};if(midiCaptureNodesRef.current[id])return;// đã capture
|
||
try{const script=audioCtx.createScriptProcessor(4096,2,2);script.onaudioprocess=e=>{const c=midiCacheRef.current[id];if(!c)return;const L=e.inputBuffer.getChannelData(0);const R=e.inputBuffer.getChannelData(1);const n=L.length;const chunk=new Float32Array(n*2);for(let i=0;i<n;i++){chunk[i*2]=L[i];chunk[i*2+1]=R[i];}c.chunks.push(chunk);c.duration+=n/c.sampleRate;};node.sfEntry.connect(script);script.connect(audioCtx.destination);midiCaptureNodesRef.current[id]={script,node};}catch(e){console.warn('midi capture err',e);}};const stopMidiCapture=()=>{Object.keys(midiCaptureNodesRef.current).forEach(id=>{const cap=midiCaptureNodesRef.current[id];try{cap.script.disconnect();}catch(e){}try{if(cap.node&&cap.node.sfEntry)cap.node.sfEntry.disconnect(cap.script);}catch(e){}});midiCaptureNodesRef.current={};};// Build an AudioBuffer from a captured track cache (interleaved stereo).
|
||
const midiCacheToBuffer=trackId=>{const c=midiCacheRef.current[trackId];if(!c||!c.chunks.length)return null;const total=c.chunks.reduce((a,ch)=>a+ch.length/2,0);if(total<100)return null;const audioCtx=getAudioContext();const buf=audioCtx.createBuffer(2,total,c.sampleRate||audioCtx.sampleRate);const L=buf.getChannelData(0),R=buf.getChannelData(1);let off=0;c.chunks.forEach(ch=>{for(let i=0;i<ch.length;i+=2){L[off]=ch[i];R[off]=ch[i+1];off++;}});return buf;};updateSfRoutingRef.current=updateSfRouting;window.__updateSfRouting=updateSfRouting;window.__getTrackScopeAnalysers=trackId=>{const node=activeTrackNodesRef.current[trackId];if(!node)return null;// The shared scope splitter carries the FULL post-FX track signal:
|
||
// pannerNode (audioclips + section items) mixed with sfOut (midi items).
|
||
if(!node.scopeAnalyserL||!node.scopeAnalyserR)return null;return{L:node.scopeAnalyserL,R:node.scopeAnalyserR,sr:getAudioContext().sampleRate};};// UI helper: fetch a live module instance (e.g. 'eqpro') from the active track
|
||
// node so interactive panels can drive its DSP params in realtime.
|
||
window.__getTrackFxModule=(trackId,type)=>{if(type!=='eqpro')return null;const findSlot=()=>{const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const track=list.find(t=>t.id===trackId);return track?fxRtTrackChain(track).findIndex(m=>m&&m.type==='eqpro'):-1;};const sendAll=fn=>{const slot=findSlot();if(slot<0)return;Object.keys(fxRtTracks).forEach(k=>{if(!k.startsWith(trackId+':'))return;const e=fxRtTracks[k];if(e&&e.ws&&e.ws.readyState===1)fn(e.ws,slot);});};return{setBand:(idx,patch)=>sendAll((ws,slot)=>Object.keys(patch||{}).forEach(k=>{try{ws.send(JSON.stringify({cmd:'set_param',slot,key:'band'+idx+'_'+k,value:patch[k]}));}catch(e){}})),setAmount:v=>sendAll((ws,slot)=>{try{ws.send(JSON.stringify({cmd:'set_param',slot,key:'amount',value:v}));}catch(e){}}),syncBands:bands=>sendAll((ws,slot)=>(bands||[]).forEach((b,i)=>['freq','gain','q'].forEach(k=>{try{ws.send(JSON.stringify({cmd:'set_param',slot,key:'band'+i+'_'+k,value:b[k]}));}catch(e){}})))};};// Same for the soundfont (MIDI) module instances — lets the EQ PRO panel drive
|
||
// BOTH the audio-clip chain and the SF chain in realtime.
|
||
window.__getTrackSfFxModule=(trackId,type)=>window.__getTrackFxModule(trackId,type);const getOrCreateSubTrackNode=(track,subTrack,context)=>{if(!track||!subTrack)return null;const subKey=track.id+'_sub_'+subTrack.id;let node=activeTrackNodesRef.current[subKey];if(!node){const gainNode=context.createGain();const volDb=subTrack.volumeDb??0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);gainNode.gain.setValueAtTime(volLinear,context.currentTime);const parentNode=getOrCreateTrackNode(track,context);// Route RIÊNG cho sub-track (nút A/♪ trên section strip): mastering path →
|
||
// parent; dry path → masterBus.dryInput — bypass áp đúng track section.
|
||
// Dùng map THẬT (trackAudioBypassMap/trackMidiBypassMap) — KHÔNG qua
|
||
// effAudioBypass (override mastering-ON ép false → route luôn no-bypass
|
||
// lúc tạo node → nút A phải bấm 2 lần; stop+play → node tái tạo → mất).
|
||
// RULES: sub-track có CLIPS → nút A (audioBypass); có MIDI → nút ♪ (midiBypass).
|
||
const subHasClips=subTrack.clips&&subTrack.clips.length>0||!!subTrack.buffer;// PWR master ON → sub-track cũng bắt buộc qua master chain (nút A/♪ của
|
||
// section chỉ có hiệu lực khi chain TẮT).
|
||
const subBypass=masteringChainOn()?false:subHasClips?subTrack.audioBypass!==undefined?!!subTrack.audioBypass:trackAudioBypassMap[subTrack.id]!==undefined?!!trackAudioBypassMap[subTrack.id]:false:subTrack.midiBypass!==undefined?!!subTrack.midiBypass:trackMidiBypassMap[subTrack.id]!==undefined?!!trackMidiBypassMap[subTrack.id]:false;const routeGain=context.createGain();const dryGain=context.createGain();routeGain.gain.value=subBypass?0:1;dryGain.gain.value=subBypass?1:0;// analyserNode cho VU meter của SECTION-TAB track strip (VU tick đọc
|
||
// node.analyserNode — sub-node trước đây không có → VU không animation).
|
||
const subAnalyser=context.createAnalyser();subAnalyser.fftSize=256;subAnalyser.smoothingTimeConstant=0.4;gainNode.connect(subAnalyser);subAnalyser.connect(routeGain);subAnalyser.connect(dryGain);// Mastering path → masterBus.input TRỰC TIẾP (không qua parent node:
|
||
// parent có route riêng với bypass riêng — parent audio_bypass=true →
|
||
// nối qua parent làm MIDI/audio section bị kéo vào dry bus = bypass
|
||
// mastering dù sub-track ♪/A đang tắt).
|
||
if(window.masterBus&&window.masterBus.input){routeGain.connect(window.masterBus.input);}else{routeGain.connect(parentNode);}if(window.masterBus&&window.masterBus.dryInput){dryGain.connect(window.masterBus.dryInput);}else{dryGain.connect(parentNode);}node={gainNode,pannerNode:null,analyserNode:subAnalyser,route:{routeGain:routeGain,dryGain:dryGain,_trackId:subTrack.id}};activeTrackNodesRef.current[subKey]=node;}return node.gainNode;};const playMidiPreviewNote=(pitch,velocity=0.8,durationMs=500)=>{if(!window.SonicSF)return;const context=getAudioContext();const tab=subTabs.find(s=>s.id===activeTabRef.current);const trackId=tab?tab.trackId:selectedTrackId;const track=activeTracks.find(t=>t.id===trackId);const destNode=getOrCreateTrackNode(track,context);const program=track?track.instrumentProgram:undefined;var prevCh=track?assignTrackMidiChannel(track,activeTracks):0;// D7: bridge active → preview qua bridge (channel router allocate — đúng
|
||
// instrument đã load khi chèn vào track).
|
||
if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){const isPerc=!!(track&&track.synth_engine&&track.synth_engine.soundfont_bank===128);const tch=track?track.id:0;try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(tch,program,track&&track.synth_engine);}catch(e){}try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:tch,pitch:pitch||60,velocity:velocity,percussion:isPerc,live:true});}catch(e){}setTimeout(function(){try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:tch,pitch:pitch||60,velocity:0,percussion:isPerc,live:true});}catch(e){}},(durationMs||500)+30);return;}if(isStandaloneSf()&&isSfTrackEngine(track&&track.synth_engine)){playNativeSfNote(track,pitch,velocity,durationMs,null,track?track.id:'global');return;}if(isStandaloneSf()&&isVstTrackEngine(track&&track.synth_engine)){playNativeVstNote(track,pitch,velocity,durationMs,null,track?track.id:'global');return;}window.SonicSF.playNote(pitch,velocity,durationMs,context.currentTime,program,destNode,prevCh,track?track.synth_engine:undefined);};// D2: timeline MIDI note -> native bridge (khi bridge active) thay vì
|
||
// SonicSF (FluidSynth WASM). Giữ setTimeout scheduling (như SonicSF cũ);
|
||
// bridge tự render note-off sau duration. A11 (sample-accurate) sẽ thay
|
||
// setTimeout bằng sampleOffset đẩy thẳng vào SHM.
|
||
const scheduleMidiNoteDispatch=(track,pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine,prNote)=>{if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){const ctx=getAudioContext();const startAt=startTime||ctx.currentTime;// undefined/null = phát ngay
|
||
const delayMs=Math.max(0,(startAt-ctx.currentTime)*1000);// Guard chống note-on trễ sau Stop: CHỈ khi là timeline (startTime thật).
|
||
const guardPlay=startTime!=null?function(){return isPlayingRef.current||subTabsRef.current&&subTabsRef.current.some(s=>s.isPlaying)||isExportingRef.current;}:function(){return true;};const trkId=track?track.id:0;// Percussion (bank 128) → router allocateChannel ch 9; melodic → round-robin.
|
||
const isPerc=!!(synthEngine&&synthEngine.soundfont_bank===128);// D8: track đổi instrument → gửi CC0/CC32 (bank THẬT từ synthEngine) +
|
||
// program change qua bridge (A12) — helper dedupe theo track+bank+program.
|
||
if(program!==undefined&&program!==null){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(trkId,program,synthEngine);}catch(e){}}// V11: epoch guard — hủy timer cũ khi stop/loop restart (xem ref).
|
||
const epoch=bridgeNoteEpochRef.current;setTimeout(function(){if(epoch!==bridgeNoteEpochRef.current)return;if(!guardPlay())return;// D18b: mute/solo guard tai thoi diem fire — note-on dang cho cua track
|
||
// vua tro nen inaudible (solo track khac / mute) khong duoc phat ra.
|
||
// V45.24: note PR tab (prNote=true) BO QUA guard + khoi phuc volume —
|
||
// mute/solo chi cuc bo MAIN/SECTION, PR khong bao gio bi cam.
|
||
const _list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracksRef.current&&tracksRef.current.length?tracksRef.current:activeTracks;const _trk=_list.find(t=>t.id===trkId)||track;if(prNote){try{ensureTrackPrAudible(_trk,_list,destNode);}catch(e){}}else if(!_trk||computeTrackAudibleGain(_list,_trk)<=0){try{if(window.__dbgLog)window.__dbgLog('solo_guard','block ch='+trkId+' p='+(pitch||60));}catch(e){}return;}// D18c: track pitch dang keu (timeline) - de applyAllTrackMuteSolo kill
|
||
// dung note khi track thanh inaudible giua chung (Qin VST2 tremolo).
|
||
try{var _tps=timelinePitchesRef.current;(_tps[trkId]||(_tps[trkId]=new Set())).add(pitch||60);}catch(e){}var _rt=window.SonicMidiRouter;try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:trkId,pitch:pitch||60,velocity:velocity||0.8,percussion:isPerc});}catch(e){console.warn('[Midi] pushEvent error:',e&&e.message);}},delayMs);scheduleNoteOff('B:'+trkId+':'+(pitch||60),function(){if(epoch!==bridgeNoteEpochRef.current)return;if(!guardPlay())return;try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:trkId,pitch:pitch||60,velocity:0,percussion:isPerc});}catch(e){}// D18c: xoa khoi per-track set (note da tat - khong con can kill).
|
||
try{var _tps2=timelinePitchesRef.current;if(_tps2[trkId])_tps2[trkId].delete(pitch||60);}catch(e){}},delayMs,durMs||1000);return;}// Native VSTi (standalone browser, bridge inactive) → midi-render thay vì
|
||
// FluidSynth GM sai âm — safety net cho mọi caller (ghost notes, per-note).
|
||
if(isStandaloneSf()&&isVstTrackEngine(synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(track,pitch,velocity,durMs,startTime,track?track.id:'global');return;}window.SonicSF.playNote(pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine);};const startTrackPlayback=offsetTime=>{const context=getAudioContext();_nativePlayEpoch++;// new play — vô hiệu doHardStop đang fade (play trong cửa sổ 80ms)
|
||
restorePendingFadeSession();// V17: huy fade dang treo — replay khong bi dip gain
|
||
// D18: loop WRAP (fxRtWrapRestart) — KHONG flush ring bridge: ring dang chua
|
||
// tail pass cu + block moi (bridge render lien tuc); flush tai seam = gap
|
||
// trong → giat beat 0:0. Play that van flush (V30) de bo block idle cu.
|
||
const _wrapRestart=fxRtWrapRestart;const _wrapMode=_wrapRestart?'loop':fxRtSeekRestartFlag?'seek':'play';fxRtWrapRestart=false;fxRtSeekRestartFlag=false;// V38/V45.18
|
||
try{if(!_wrapRestart&&window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong
|
||
// cho phia sau; flush ring bridge (V30) khong chaym queue nay.
|
||
try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:_wrapMode});}catch(e){}_dbgPlayStartMs=performance.now();_dbgFirstAudioPending=true;_dbgFirstAudiblePending=true;_dbgLog('play_start','offset='+offsetTime+' epoch='+_nativePlayEpoch+' '+_dbgBridgeGain()+' '+_dbgWorkletState()+' fxrt='+(fxRt.active?1:0)+' node='+(fxRt.node?1:0)+' q='+(fxRt.stats?fxRt.stats.queueLen:'n/a'));// Realtime VST FX (master chain qua SHM bridge): bật khi play + chain active.
|
||
try{fxRtStart();}catch(e){console.warn('[FX RT] start error:',e);}// V40: real PDC — freeze playhead theo latency FX path; allowCold=true che
|
||
// cold-start VST pre-roll (playhead không chạy trước khi có tiếng). Bỏ V39
|
||
// freeze giả định 0.31s (under-estimate → playhead chạy trước ~0.66s).
|
||
// V45.19 Bug 1b: loop WRAP khong re-buffer (audio lien tuc qua KEEP tail) — chi
|
||
// re-anchor processed (playhead theo anchor), KHONG seek-freeze 1500ms (truoc day
|
||
// freeze -> playhead dung ~1.5s sau wrap = 'delay kha lau'). Seek/play that freeze.
|
||
try{if(_wrapRestart&&fxRt&&fxRt.active){fxRtAnchorProcessed();}else if(window.__fxRtPlayheadFreeze){window.__fxRtPlayheadFreeze(true);}}catch(e){}// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng
|
||
// bộ timeline; A13 C++ xử lý arg1=playheadSamples sau này).
|
||
if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected){// V10: loop restart — stopAllPlayback vừa schedule STOP#2 (150ms); hủy
|
||
// ngay khi PLAY, nếu không STOP#2 đến sau PLAY → bridge drop mọi note-on
|
||
// (V8 guard transportStopped) → VSTi chỉ play 1 vòng loop.
|
||
if(bridgeStopRetryTimerRef.current){clearTimeout(bridgeStopRetryTimerRef.current);bridgeStopRetryTimerRef.current=null;}try{window.NativeBridgeService.transport('play');}catch(e){}_dbgLog('play_transport','offset='+offsetTime);}// V12: guardPlay (scheduleMidiNoteDispatch) đọc isPlayingRef.current — nếu
|
||
// để React render set (setIsPlaying(true) sau) thì note-on delay=0 của
|
||
// loop restart (rAF callback → React commit trễ hơn setTimeout 0) bị drop
|
||
// → loop pass 2+ câm. Set NGAY tại scheduler.
|
||
isPlayingRef.current=true;// Gap 7: master volume automation — schedule envelope tu playhead.
|
||
try{applyMasterAutomationSchedule(offsetTime||0);}catch(e){console.warn('[Auto] schedule error:',e);}// ⚠️ FIX: đồng bộ mastering NGAY khi play — MIDI item phải qua mastering
|
||
// FX (khi bật).
|
||
try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e){}const allPlayTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:activeTracks;try{if(window.__dbgLog)window.__dbgLog('play_tracks','n='+allPlayTracks.length+' solo='+(allPlayTracks.some(function(t){return t.solo;})?1:0)+' ids='+allPlayTracks.map(function(t){return t.id+':'+(t.solo?'S':t.muted?'M':'A');}).join(','));}catch(e){}// Capture items signature at schedule time — updatePlayhead so sánh với
|
||
// signature này để phát hiện items đổi vị trí giữa lúc play (re-schedule).
|
||
// V28: defer bookkeeping signature — tính đồng bộ chặn main thread trước
|
||
// first-audio (góp vào delay onset); signature chỉ so sánh ~10fps trong
|
||
// updatePlayhead → trễ 0ms là đủ (re-schedule phát hiện muộn không sao).
|
||
setTimeout(function(){try{scheduledItemsSigRef.current=buildItemsSignature(allPlayTracks,sessionTabs);}catch(e){}},0);console.log('[Play] offset='+offsetTime+' tracks='+allPlayTracks.length+' masterBus='+!!masterBus+' dest='+(context.destination?'ok':'MISSING')+' projectEnd='+(projectEndRef.current||0).toFixed(2));const hasSolo=allPlayTracks.some(t=>t.solo);// V42A: native loop region — loop selection hợp lệ → [selLeft, selRight];
|
||
// isLoopingSelection không selection → [0, projectEnd] (project-end wrap 24407).
|
||
// Không loop → _loopActive=false → fallback dip V42.
|
||
const _loopSel=!selectionCleared&&isLoopingSelection&&selLeft!==null&&selRight!==null&&selRight>selLeft;const _loopLs=_loopSel?selLeft:0;const _loopLe=_loopSel?selRight:projectEndRef.current;const _loopActive=isLoopingSelection&&_loopLe>_loopLs;allPlayTracks.forEach(track=>{const isPlayable=hasSolo?track.solo:!track.muted;if(!isPlayable)return;const gainNode=getOrCreateTrackNode(track,context);// Realtime VST FX per-track (SHM bridge): mọi luồng track qua bridge —
|
||
// session/worklet cho TỪNG path có âm (audio-clips + MIDI/SF); splice
|
||
// vào graph khi WS ready.
|
||
try{const rtPaths=fxRtTrackPaths(track);if(rtPaths.indexOf('audio')>=0)fxRtTrackStart(track.id,track,'audio');if(rtPaths.indexOf('sf')>=0)fxRtTrackStart(track.id,track,'sf');}catch(e){console.warn('[FX RT track] start error:',e);}const pannerNode=activeTrackNodesRef.current[track.id].pannerNode;// Re-apply route gains theo content-type NGAY tại thời điểm play — node
|
||
// có thể STALE (tạo từ MAIN play với track cùng id — route theo data cũ)
|
||
// → first play sau double-click vào SECTION-TAB bị bypass. Re-apply
|
||
// routeBypassFor(track) mỗi lần schedule sẽ hết chu kỳ "stop+play mới đúng".
|
||
const playNode=activeTrackNodesRef.current[track.id];if(playNode&&playNode.route&&playNode.route.routeGain){const rb=routeBypassFor(track);playNode.route.routeGain.gain.value=rb?0:1;playNode.route.dryGain.gain.value=rb?1:0;}const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];clips.forEach(clip=>{if(!clip.buffer)return;const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;// V42A: native WebAudio loop — wrap mẫu-chính-xác tại loopEnd, không
|
||
// restart → không click (thay dip V42). Chỉ khi loop active + track 1
|
||
// clip + speed=1 + clip phủ đủ vùng loop. Source giữ nguyên qua wrap
|
||
// (doHardStop skip khi fxRtWrapRestart) → seam-free.
|
||
const _loopable=_loopActive&&clips.length===1&&Math.abs((clip.speed||1.0)-1)<0.0001&&clipStart<=_loopLs+0.001&&clipEnd>=_loopLe-0.001;const _key=track.id+'::'+(clip.id||'default');const _kept=nativeLoopSrcsRef.current.get(_key);if(_loopable&&_kept&&_kept.src&&_kept.src.playbackState!=='ended'){if(Math.abs(_kept.loopStart-_loopLs)<0.001&&Math.abs(_kept.loopEnd-_loopLe)<0.001){activeSourcesRef.current.push(_kept.src);// giữ source loop đang chạy qua wrap
|
||
return;}try{_kept.src.stop();}catch(e){}nativeLoopSrcsRef.current.delete(_key);}const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(gainNode);if(_loopable){source.loop=true;source.loopStart=(_loopLs-clipStart)/(clip.speed||1.0);source.loopEnd=(_loopLe-clipStart)/(clip.speed||1.0);nativeLoopSrcsRef.current.set(_key,{src:source,loopStart:_loopLs,loopEnd:_loopLe});}if(offsetTime<clipStart){const delay=clipStart-offsetTime;source.start(context.currentTime+delay,0);activeSourcesRef.current.push(source);}else if(offsetTime<clipEnd){const playOffset=offsetTime-clipStart;source.start(context.currentTime,playOffset*(clip.speed||1.0));activeSourcesRef.current.push(source);}});// MIDI items playback
|
||
const midiItems=track.midiItems||[];const nativeSf=isStandaloneSf()&&isSfTrackEngine(track.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());const nativeVst=isStandaloneSf()&&isVstTrackEngine(track.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if((track.type==='MIDI'||midiItems.length>0)&&(window.SonicSF||nativeSf||nativeVst)){if(nativeSf||nativeVst){scheduleNativeTrack(track,offsetTime,context,gainNode,bpm,{playheadNow:()=>startOffsetTimeRef.current+(getAudioContext().currentTime-startAudioTimeRef.current),sources:activeSourcesRef.current,isActive:()=>!!isPlayingRef.current});}else{// Preview cache: capture the soundfont (pre track-FX) for fast offline export
|
||
ensureMidiCapture(track,activeTrackNodesRef.current[track.id]);var trkCh=assignTrackMidiChannel(track,allPlayTracks);// Ensure instrument is loaded in FluidSynth
|
||
if(window.SonicSF&&track.synth_engine&&track.synth_engine.type==='soundfont'&&track.synth_engine.soundfont_id){window.SonicSF.selectInstrument(trkCh,track.synth_engine.soundfont_bank||0,track.synth_engine.soundfont_program||0,track.synth_engine.soundfont_id);}const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;midiItems.forEach(item=>{const itemEndSec=item.startTime+(item.duration||4);const notes=item.notes||[];notes.forEach(note=>{// note start/duration is in beats (for MIDI items)
|
||
const noteStartSec=item.startTime+(note.start_beat||0)*secondsPerBeat;if(noteStartSec>=itemEndSec)return;// skip notes outside item's visual duration
|
||
const noteDurSec=(note.duration_beats||1)*secondsPerBeat;const noteEndSec=noteStartSec+noteDurSec;if(offsetTime<noteEndSec){const durationMs=noteDurSec*1000;const program=track._isSectionClone?track.instrumentProgram:track.instrumentProgram!==undefined?track.instrumentProgram:0;if(offsetTime<noteStartSec){const delay=noteStartSec-offsetTime;const startTime=context.currentTime+delay;if(window.SonicSF){scheduleMidiNoteDispatch(track,note.pitch||60,note.velocity||0.8,durationMs,startTime,program,gainNode,trkCh,track.synth_engine);scheduleNoteExpressionCC11(track,note,trkCh,context.currentTime,delay,noteDurSec);}// Trigger VU meter flash when the note starts playing
|
||
setTimeout(()=>{// ⚠️ Guard: stop → setTimeout sót không được fire (VU nhảy
|
||
// sau khi âm đã dừng — user bug 05:45).
|
||
if(!isPlayingRef.current)return;if(window.triggerMidiVuActivity){// VU key theo CONTEXT (tách MAIN vs SECTION — user 07:20)
|
||
window.triggerMidiVuActivity((activeTab&&activeTab.startsWith('session_')?'_sess_':'')+track.id,note.velocity||0.8);}},delay*1000);}else{const playOffset=offsetTime-noteStartSec;const remainingDurMs=(noteEndSec-offsetTime)*1000;if(window.SonicSF){scheduleMidiNoteDispatch(track,note.pitch||60,note.velocity||0.8,remainingDurMs,context.currentTime,program,gainNode,trkCh,track.synth_engine);scheduleNoteExpressionCC11(track,note,trkCh,context.currentTime,0,remainingDurMs/1000);}// Trigger VU meter flash instantly
|
||
if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((activeTab&&activeTab.startsWith('session_')?'_sess_':'')+track.id,note.velocity||0.8);}}}});});}}// If track has instrumentId set but no MIDI items, create scheduled oscillators
|
||
if(track.instrumentId&&midiItems.length===0&&track.buffer){// Play audio normally (the buffer has the audio data)
|
||
// This block is intentionally empty - audio clips already play above
|
||
}// SECTION items playback
|
||
const sections=track.sections||[];if(activeTab==='main'&§ions.length>0){const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;const secondsPerBar=secondsPerBeat*4;sections.forEach(sec=>{const secStart=sec.start||0;const secEnd=secStart+(sec.duration||0);const openTab=sessionTabsRef.current?sessionTabsRef.current.find(st=>st.sectionId===sec.sectionId||st.sectionId===sec.id):null;const secTracks=openTab?openTab.tracks:sec.tracks||[];const hasSubSolo=secTracks.some(st=>st.solo);secTracks.forEach((subTrack,subIdx)=>{const isSubPlayable=hasSubSolo?subTrack.solo:!subTrack.muted;if(!isSubPlayable)return;const subNode=getOrCreateSubTrackNode(track,subTrack,context);if(!subNode)return;// Re-apply route sub-node theo content-type mỗi lần schedule —
|
||
// sub-node có thể STALE (route theo trạng thái cũ) → midi section
|
||
// bị kéo vào dry. routeBypassFor: MIDI-only → ♪, clips → A.
|
||
const subNodeObj=activeTrackNodesRef.current[track.id+'_sub_'+subTrack.id];if(subNodeObj&&subNodeObj.route&&subNodeObj.route.routeGain){const srb=routeBypassFor(subTrack);subNodeObj.route.routeGain.gain.value=srb?0:1;subNodeObj.route.dryGain.gain.value=srb?1:0;}// 1. Play clips in subTrack
|
||
const subClips=subTrack.clips||[];subClips.forEach(clip=>{if(!clip.buffer)return;const clipStartLocal=clip.startTime||0;const clipDurLocal=clip.buffer.duration/(clip.speed||1.0);const clipStartMain=secStart+clipStartLocal;const clipEndMain=clipStartMain+clipDurLocal;// Only play within the bounds of [secStart, secEnd]
|
||
const activeStart=Math.max(clipStartMain,secStart);const activeEnd=Math.min(clipEndMain,secEnd);if(activeStart<activeEnd&&offsetTime<activeEnd){const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(subNode);if(offsetTime<activeStart){const delay=activeStart-offsetTime;const playOffset=activeStart-clipStartMain;const playDuration=activeEnd-activeStart;source.start(context.currentTime+delay,playOffset*(clip.speed||1.0),playDuration);activeSourcesRef.current.push(source);}else{const playOffset=offsetTime-clipStartMain;const playDuration=activeEnd-offsetTime;source.start(context.currentTime,playOffset*(clip.speed||1.0),playDuration);activeSourcesRef.current.push(source);}}});// 2. Play MIDI items in subTrack
|
||
const subMidiItems=subTrack.midiItems||[];const subNativeSf=isStandaloneSf()&&isSfTrackEngine(subTrack.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());const subNativeVst=isStandaloneSf()&&isVstTrackEngine(subTrack.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if(subMidiItems.length>0&&(window.SonicSF||subNativeSf||subNativeVst)){if(subNativeSf||subNativeVst){scheduleNativeTrack(subTrack,offsetTime,context,subNode,bpm,{baseOffsetSec:secStart,limitSec:secEnd,playheadNow:()=>startOffsetTimeRef.current+(getAudioContext().currentTime-startAudioTimeRef.current),sources:activeSourcesRef.current,isActive:()=>!!isPlayingRef.current});return;// native render → audio buffer (không qua WASM per-note)
|
||
}subMidiItems.forEach(item=>{const notes=item.notes||[];notes.forEach(note=>{const noteStartSecLocal=item.startTime+(note.start_beat||0)*secondsPerBeat;const noteDurSec=(note.duration_beats||1)*secondsPerBeat;const noteStartMain=secStart+noteStartSecLocal;const noteEndMain=noteStartMain+noteDurSec;// Only play if the note starts inside the Section item bounds and hasn't finished yet
|
||
if(noteStartMain>=secStart&¬eStartMain<secEnd&&offsetTime<noteEndMain){const notePlayEndMain=Math.min(noteEndMain,secEnd);const program=subTrack.instrumentProgram;var subCh=assignTrackMidiChannel(subTrack,secTracks);if(offsetTime<noteStartMain){const delay=noteStartMain-offsetTime;const startTime=context.currentTime+delay;const playDurMs=(notePlayEndMain-noteStartMain)*1000;if(window.SonicSF){scheduleMidiNoteDispatch(subTrack,note.pitch||60,note.velocity||0.8,playDurMs,startTime,program,subNode,subCh,subTrack.synth_engine);scheduleNoteExpressionCC11(subTrack,note,subCh,context.currentTime,delay,playDurMs/1000);}// Trigger VU meter flash đúng nhịp (cả subTrack — VU
|
||
// section tab + parent track — VU MAIN): MIDI section đi
|
||
// SF → masterBus.input (không qua sub-node analyser) →
|
||
// VU track chỉ nhảy qua midiVuActivity. (Bị mất khi
|
||
// commit gộp — khôi phục fix 00:30/02:00.)
|
||
setTimeout(()=>{// ⚠️ Guard: stop → setTimeout sót không được fire (VU
|
||
// nhảy sau khi âm đã dừng — user bug 05:45).
|
||
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);}}}});});}});});}});// V42A: sweep native-loop sources không được schedule pass này (track muted/
|
||
// solo-off) → dừng + xóa — tránh source loop chơi âm thầm mãi.
|
||
try{nativeLoopSrcsRef.current.forEach((v,k)=>{if(!v||!v.src||v.src.playbackState==='ended'){nativeLoopSrcsRef.current.delete(k);return;}if(activeSourcesRef.current.indexOf(v.src)===-1){try{v.src.stop();}catch(e){}nativeLoopSrcsRef.current.delete(k);}});}catch(e){}updateSfRouting();};// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
|
||
const startLocalTrackPlayback=(trackId,offsetTime)=>{const context=getAudioContext();// V42A: local/solo path khong dung native loop — dung + xoa src loop con lai
|
||
// tu main play (wrap local giu chung qua doHardStop -> double audio neu khong).
|
||
try{nativeLoopSrcsRef.current.forEach(v=>{try{if(v&&v.src)v.src.stop();}catch(e){}});nativeLoopSrcsRef.current.clear();}catch(e){}// 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);}const _wrapMode=fxRtWrapRestart?'loop':'play';fxRtWrapRestart=false;// V38
|
||
try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:_wrapMode});}catch(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).
|
||
if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected){if(bridgeStopRetryTimerRef.current){clearTimeout(bridgeStopRetryTimerRef.current);bridgeStopRetryTimerRef.current=null;}try{window.NativeBridgeService.transport('play');}catch(e){}}isPlayingRef.current=true;try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e){}const track=tracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Get or create persistent gain & panner for real-time control
|
||
const gainNode=getOrCreateTrackNode(track,context);const pannerNode=activeTrackNodesRef.current[track.id].pannerNode;clips.forEach(clip=>{if(!clip.buffer)return;const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(gainNode);// NOTE: do NOT connect gainNode → pannerNode directly here — the track
|
||
// node already wires gainNode → fxEntry → [FX modules] → fxLegacyIn →
|
||
// chorus/reverb → pannerNode. A direct tap would BYPASS the FX Rack
|
||
// chain for audio clips (double path: with + without FX).
|
||
const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;if(offsetTime<clipStart){const delay=clipStart-offsetTime;source.start(context.currentTime+delay,0);activeSourcesRef.current.push(source);}else if(offsetTime<clipEnd){const playOffset=offsetTime-clipStart;source.start(context.currentTime,playOffset*(clip.speed||1.0));activeSourcesRef.current.push(source);}});// MIDI items playback
|
||
const midiItems=track.midiItems||[];const nativeSf=isStandaloneSf()&&isSfTrackEngine(track.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());const nativeVst=isStandaloneSf()&&isVstTrackEngine(track.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if((track.type==='MIDI'||midiItems.length>0)&&(window.SonicSF||nativeSf||nativeVst)){if(nativeSf||nativeVst){scheduleNativeTrack(track,offsetTime,context,gainNode,bpm,{playheadNow:()=>startOffsetTimeRef.current+(getAudioContext().currentTime-startAudioTimeRef.current),sources:activeSourcesRef.current,isActive:()=>!!isPlayingRef.current});return;// native render → audio buffer (không qua WASM per-note)
|
||
}var lcCh=assignTrackMidiChannel(track,tracks);const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;midiItems.forEach(item=>{const notes=item.notes||[];notes.forEach(note=>{const noteStartSec=item.startTime+(note.start_beat||0)*secondsPerBeat;const noteDurSec=(note.duration_beats||1)*secondsPerBeat;const noteEndSec=noteStartSec+noteDurSec;if(offsetTime<noteEndSec){const durationMs=noteDurSec*1000;const program=track.instrumentProgram!==undefined?track.instrumentProgram:0;if(offsetTime<noteStartSec){const delay=noteStartSec-offsetTime;const startTime=context.currentTime+delay;if(window.SonicSF){scheduleMidiNoteDispatch(track,note.pitch||60,note.velocity||0.8,durationMs,startTime,program,gainNode,lcCh,track.synth_engine);scheduleNoteExpressionCC11(track,note,lcCh,context.currentTime,delay,noteDurSec);}}else{const remainingDurMs=(noteEndSec-offsetTime)*1000;if(window.SonicSF){scheduleMidiNoteDispatch(track,note.pitch||60,note.velocity||0.8,remainingDurMs,context.currentTime,program,gainNode,lcCh,track.synth_engine);scheduleNoteExpressionCC11(track,note,lcCh,context.currentTime,0,remainingDurMs/1000);}}}});});}};const schedulePianoRollMidi=(st,offsetSeconds,notesOverride)=>{if(st.type!=='PIANO_ROLL')return;const context=getAudioContext();_nativePlayEpoch++;// new play — vô hiệu doHardStop đang fade
|
||
restorePendingFadeSession();// V17: huy fade dang treo — replay khong bi dip gain
|
||
// D18: piano-roll wrap (fxRtWrapRestart set o loop restart) — KHONG flush
|
||
// ring + clear_queue mode 'loop' (giu queue + wrapFade) nhu main path; truoc
|
||
// day luon mode 'play' → wipe + re-buffer moi pass = giat. KHONG reset flag
|
||
// (startSubTabPlayback goi sau van can doc de clear dung mode).
|
||
try{if(!fxRtWrapRestart&&window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:fxRtWrapRestart?'loop':'play'});}catch(e){}// V10: piano roll loop restart cũng gọi stopAllPlayback() trước (STOP#1 →
|
||
// bridge transportStopped=true) → mọi note-on bị drop nếu không PLAY lại.
|
||
// Hủy STOP#2 (150ms) + báo PLAY để bridge nhận note mới (loop liên tục).
|
||
if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected){if(bridgeStopRetryTimerRef.current){clearTimeout(bridgeStopRetryTimerRef.current);bridgeStopRetryTimerRef.current=null;}try{window.NativeBridgeService.transport('play');}catch(e){}}// V12: guardPlay race tương tự startTrackPlayback — set NGAY để note-on
|
||
// delay=0 của sub-tab loop restart không bị drop.
|
||
isPlayingRef.current=true;try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e){}const midiNotes=notesOverride||st.notes||[];const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;const startWallTime=context.currentTime;const track=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===st.trackId):null;if(!track){console.warn('[Play] Piano Roll track not found:',st.trackId);return;}const destNode=getOrCreateTrackNode(track,context);// Instrument context resolve từ TRACK live (nguồn duy nhất) — track "đã
|
||
// loaded instrument" (instrumentProgram GM hoặc synth_engine soundfont)
|
||
// phải chơi ĐÚNG instrument đó. ensureSonicInstrument select channel đúng
|
||
// trước khi notes bắn (fire-and-forget — playNote tự load+retry nếu SF
|
||
// chưa xong).
|
||
const instCtx=resolveTrackInstrumentCtx(track,activeTracksRef.current||[]);const instrumentProgram=instCtx.program;const synthEngine=instCtx.synthEngine;const mainCh=instCtx.ch;var allTracks=activeTracksRef.current||[];ensureSonicInstrument(instCtx);// Bridge active: gui program change (CC0/CC32/PROGRAM) de SF preset dung
|
||
// (instCtx.program undefined cho SF -> __ensureBridgeProgram fallback
|
||
// synthEngine.soundfont_program da set khi load qua modal). Thieu ->
|
||
// bridge choi preset mac dinh, khong dung preset da chon.
|
||
try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(st.trackId,instCtx.program,instCtx.synthEngine);}catch(e){}console.log('[Play] PianoRoll schedule:',st.id,'| notes=',midiNotes.length,'| track=',st.trackId,'| program=',instrumentProgram,'| sf=',instCtx.sfId?instCtx.sfId:'-','| dest=',destNode?'ok':'null','| ch=',mainCh,'| sfEngine=',synthEngine?'yes':'no','| bypassA=',!!trackAudioBypassMap[st.trackId],'| bypassMidi=',!!trackMidiBypassMap[st.trackId]);// 0 = item start. Notes must be scheduled relative to the item too, otherwise
|
||
// items placed later in the project play `item.startTime` seconds in the
|
||
// future (silence when pressing play). Ghost notes are already relative to
|
||
// the item window, so no absolute-session offset is applied anywhere here.
|
||
if(isStandaloneSf()&&(isSfTrackEngine(synthEngine)||isVstTrackEngine(synthEngine))&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){scheduleNativeTrack(track,offsetSeconds,context,destNode,bpm,{notesOverride:midiNotes,playheadNow:()=>startOffsetTimeRef.current+(getAudioContext().currentTime-startAudioTimeRef.current),sources:activeSourcesRef.current,isActive:()=>!!isPlayingRef.current});return;// native render cả tab → audio buffer (không per-note WASM)
|
||
}midiNotes.forEach(note=>{const noteOnBeat=note.start_beat||0;const noteDurBeat=note.duration_beats||1;const noteStartSec=noteOnBeat*secondsPerBeat;const noteDurSec=noteDurBeat*secondsPerBeat;if(noteStartSec+noteDurSec>offsetSeconds){const effectiveStart=Math.max(0,noteStartSec-offsetSeconds);const effectiveDur=noteDurSec-Math.max(0,offsetSeconds-noteStartSec);const scheduledTime=startWallTime+effectiveStart;const durMs=effectiveDur*1000;if(window.SonicSF){scheduleMidiNoteDispatch(track,note.pitch||60,note.velocity||0.8,durMs,scheduledTime,instrumentProgram,destNode,mainCh,synthEngine,true);}scheduleNoteExpressionCC11(track,note,mainCh,startWallTime,effectiveStart,effectiveDur);}});// Play ghost notes from active tracks
|
||
var ghostLayers=st.ghostPlayLayers||[];ghostLayers.forEach(function(layer){var ghostTrack=allTracks.find(function(t){return t.id===layer.trackId;});var ghostProg=layer.instrumentProgram!==undefined?layer.instrumentProgram:ghostTrack?ghostTrack.instrumentProgram:undefined;var ghostSynth=layer.synthEngine||(ghostTrack?ghostTrack.synth_engine:undefined);if(ghostProg===undefined&&!ghostSynth)return;var ghostDest=getOrCreateTrackNode(ghostTrack,context);var ghostCh=ghostTrack?assignTrackMidiChannel(ghostTrack,allTracks):0;layer.notes.forEach(function(note){var beat=note.start_beat||0;var dur=note.duration_beats||1;var startSec=beat*secondsPerBeat;var durSec=dur*secondsPerBeat;if(startSec+durSec>offsetSeconds){var effStart=Math.max(0,startSec-offsetSeconds);var effDur=durSec-Math.max(0,offsetSeconds-startSec);var schedTime=startWallTime+effStart;var durMs=effDur*1000;if(window.SonicSF){scheduleMidiNoteDispatch(ghostTrack,note.pitch||60,note.velocity||0.8,durMs,schedTime,ghostProg,ghostDest,ghostCh,ghostSynth,true);}}});});};const handlePlayPause=()=>{console.log('[Play] click activeTab=',activeTab,'isPlaying=',isPlaying,'subPlaying=',subTabs.filter(s=>s.isPlaying).length);if(activeTab!=='main'&&!activeTab.startsWith('session_')){// Sub-tab playback transport
|
||
const st=subTabs.find(s=>s.id===activeTab);if(!st||!st.buffer&&st.type!=='PIANO_ROLL')return;// MAIN/SECTION đang play (ngầm — MIDI section/main còn kêu sau khi mở
|
||
// tab): Space phải STOP trước — không được play sub-tab (user: trong
|
||
// PIANO ROLL không phát hiện MIDI đang play → space 2 lần mới stop).
|
||
if(isPlaying){stopAllPlayback(true);// user stop — fade out
|
||
setSubTabs(prev=>prev.map(s=>s.isPlaying?{...s,isPlaying:false}:s));setIsPlaying(false);return;}if(st.isPlaying){stopAllPlayback(true);// user stop — fade out
|
||
setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,isPlaying:false}:s));}else{try{fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();const startOffset=st.currentTime||0;if(st.type==='PIANO_ROLL'){schedulePianoRollMidi(st,startOffset);startSubTabPlayback(st,startOffset);}else{startSubTabPlayback(st,startOffset);}setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,buffer:s.buffer||getAudioContext().createBuffer(1,128,getAudioContext().sampleRate),isPlaying:true,currentTime:startOffset}:s));// BẮT BUỘC start rAF loop updatePlayhead — nếu không, playhead sub-tab
|
||
// không di chuyển (và loop/stop không bao giờ chạy).
|
||
animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}catch(e){console.error('[Play] Piano Roll play error:',e);showToast('Lỗi phát Piano Roll: '+e.message,'error');}}return;}const context=getAudioContext();if(isPlaying){stopAllPlayback(true);// user stop — fade out
|
||
}else{startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(currentTime);setIsPlaying(true);// V28: rAF kế tiếp sau setState → đo jank main thread (chênh với play_start).
|
||
requestAnimationFrame(function(){_dbgLog('play_rAF','delta_ms='+Math.round(performance.now()-_dbgPlayStartMs));});}};handlePlayPauseRef.current=handlePlayPause;const handlePianoRollRealtimePlay=trackIds=>{const prSt=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(!prSt)return;var prLayers=[];var ghostData=window.SonicGhost&&window.SonicGhost.extractGhostLayers?window.SonicGhost.extractGhostLayers(activeTracks,prSt.trackId,prSt.target_id,parseInt(bpm)||120):[];(ghostData||[]).forEach(function(layer){if(!trackIds||trackIds.indexOf(layer.track_id)===-1)return;var ltrk=(activeTracks||[]).find(function(t){return t.id===layer.track_id;});prLayers.push({trackId:layer.track_id,notes:layer.notes.map(function(n){return{pitch:n.pitch,start_beat:n.relative_start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8};}),instrumentProgram:ltrk?ltrk.instrumentProgram:undefined,instrumentName:ltrk?ltrk.instrumentName:undefined,synthEngine:ltrk?ltrk.synth_engine:undefined});});fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();const prCtx=getAudioContext();const prTNode=activeTrackNodesRef.current[prSt.trackId];if(prTNode&&prTNode.gainNode){prTNode.gainNode.gain.setValueAtTime(prTNode.gainNode.gain.value||1,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(0.001,prCtx.currentTime+0.04);}setTimeout(()=>{window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();if(prTNode&&prTNode.gainNode){const prTrackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===prSt.trackId):null;const prVolDb=prTrackData?prTrackData.volumeDb??0:0;const prVolLinear=prVolDb<=-50?0:Math.pow(10,prVolDb/20);prTNode.gainNode.gain.setValueAtTime(0.001,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(prVolLinear||0.8,prCtx.currentTime+0.015);}const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=prCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);const playTab=Object.assign({},prSt,{ghostPlayLayers:prLayers});schedulePianoRollMidi(playTab,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{ghostPlayLayers:prLayers,isPlaying:true,currentTime:prOffset}):s));},60);};const handlePause=()=>{if(isPlaying||subTabs.some(s=>s.isPlaying))stopAllPlayback(true);};const stopAllPlayback=(fade,skipRouting)=>{try{// D14: bỏ dip muteGain tại wrap (loop) — 5ms master→0.0001 mỗi vòng =
|
||
// âm beat 0:0 nhỏ hơn hẳn âm cuối pass (bug seam volume). Click seam giờ
|
||
// xử bằng release mượt (CC123, không CC120 hard-cut — D15) + worklet
|
||
// WRAP_FADE 256 (FX path) → không cần che bằng dropout master.
|
||
_dbgLog('stop_start','fade='+!!fade+' '+_dbgBridgeGain()+' call='+((new Error().stack||'').split('\n').slice(2,4).join('|')||'').trim());isPlayingRef.current=false;wrapHoldRef.current=null;// Lỗi 1: hủy wrap-hold khi stop/seek giữa chừng
|
||
// Gap 7: xoa automation schedule — tra gain master ve 1.0 (fader rieng).
|
||
try{if(masterBus&&masterBus.output&&masterBus.output.gain){const _actx=getAudioContext();masterBus.output.gain.cancelScheduledValues(_actx.currentTime);masterBus.output.gain.setValueAtTime(1,_actx.currentTime);}}catch(e){}// Realtime VST FX: dừng session + khôi phục output→analyser. Nhánh restart
|
||
// (loop/seek/reschedule/solo...) set fxRtKeepForRestart=true → GIỮ session
|
||
// (fxRtStart early-return khi active + chain khớp) → hết bypass+reload ~4s
|
||
// mỗi pass loop. User stop thật (fade=true) → V21 giữ master FX warm (idle
|
||
// timeout FX_RT_WARM_IDLE_MS) — play kế tiếp FX ngay; per-track teardown.
|
||
const _keepFx=fxRtKeepForRestart||!!fade;fxRtKeepForRestart=false;if(!_keepFx){try{fxRtStop();}catch(e){console.warn('[FX RT] stop error:',e);}// Realtime VST FX per-track: dừng mọi session track + restore chain trực tiếp.
|
||
try{fxRtTrackStopAll();}catch(e){console.warn('[FX RT track] stop error:',e);}}else if(fade){// V21: user stop — GIỮ master FX session warm (play kế tiếp có FX ngay,
|
||
// không chờ reload VST ~4s). Per-track vẫn teardown (VSTi nhiều track nặng).
|
||
// Idle timeout (FX_RT_WARM_IDLE_MS) giải phóng CPU khi ngừng chơi lâu.
|
||
// C: GUI master native đang mở (marker + slot active trong chain) -> KHÔNG
|
||
// arm — session cần sống cho editor; teardown sẽ giết editor + mất preset.
|
||
// closeMasterGui xoá marker -> settings effect (OP12) arm lại khi dừng.
|
||
try{fxRtTrackStopAll();}catch(e){console.warn('[FX RT track] stop error:',e);}const _stopGuiVst=masteringSettings&&masteringSettings.gui_open_path&&(masteringSettings.chain||[]).some(x=>x&&x.type==='vst3'&&x.path===masteringSettings.gui_open_path&&x.active!==false);clearTimeout(fxRtWarmIdleTimer);if(_stopGuiVst){fxRtWarmIdleTimer=null;}else{fxRtWarmIdleTimer=setTimeout(()=>{fxRtWarmIdleTimer=null;try{fxRtStop();}catch(e){console.warn('[FX RT] idle stop error:',e);}},FX_RT_WARM_IDLE_MS);}// M23 Bug C: dung han (fade) — ha hold + PDC ve 0 (am dang fade -> khong
|
||
// nghe jump; warm sau do dry delay = 0 nhu cu).
|
||
fxRtWarmPlayHold=false;try{fxRtApplyPdc();}catch(e){}}_nativePlayEpoch++;// hủy mọi render/decode pending của lần play cũ
|
||
// V11: hủy mọi note timer của lần play cũ (stop/seek/loop restart) — timer
|
||
// cũ bắn vào vòng mới = NOTE_OFF cắt note đang kêu → crack (xem ref).
|
||
bridgeNoteEpochRef.current++;// D18e: seam ring-out - chup pitch dang keu (timelinePitchesRef) co NOTE_OFF
|
||
// tu nhien sap toi (entry.offAt - now <= RING_GRACE_MS) TRUOC khi
|
||
// clearAllPendingNoteOffs xoa map. Chi khi wrap + bridge active. doHardStop
|
||
// se khong panic cac pitch nay (skip CC123 channel co ring, bo NOTE_OFF
|
||
// per-pitch) va re-arm NOTE_OFF tren epoch MOI (da ++) - voice ring qua
|
||
// KEEP/crossfade tail thay vi bi cat tai seam (bug: note cuoi loop khong
|
||
// ngan du). Ngan dai qua seam van panic (D18d).
|
||
let _seamRing=null;try{const _ringWrap=!!fxRtWrapRestart&&!!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive&&window.SonicMidiRouter.isBridgeActive());if(_ringWrap){const _tpsAll=timelinePitchesRef.current||{};const _tids=Object.keys(_tpsAll);_seamRing=[];for(let _si=0;_si<_tids.length;_si++){const _tid=_tids[_si];const _set=_tpsAll[_tid];if(!_set||!_set.size)continue;_set.forEach(function(_p){const _entry=pendingNoteOffMap['B:'+_tid+':'+_p];if(_entry&&_entry.offAt&&_entry.offAt-performance.now()<=RING_GRACE_MS){_seamRing.push({tid:_tid,pitch:_p,remainingMs:_entry.offAt-performance.now()});}});}}_dbgLog('seam_ring','n='+(_seamRing?_seamRing.length:0)+' wrap='+(fxRtWrapRestart?1:0));}catch(e){console.warn('[Stop] seam ring capture error:',e);}try{clearAllPendingNoteOffs();}catch(e){}// V45.6: GIU timelinePitchesRef qua stop - kill branch (solo/mute) can
|
||
// per-pitch set de NOTE_OFF tung note dang keu (Qin VST2 sandbox).
|
||
_cc11Epoch++;// CC11 timers lan play cu vo hieu (da clear o tren)
|
||
try{clearAllCC11Timers();}catch(e){}try{if(subTabsRef.current)subTabsRef.current=subTabsRef.current.map(s=>({...s,isPlaying:false}));}catch(e){}// Fade-out khi user nhấn Stop/Pause (fade=true): ramp gain track + bridge
|
||
// xuống 0 trong FADE_MS rồi mới hard-stop — hết click, âm ngân mượt.
|
||
// Loop restart/seek (fade=false) giữ stop tức thì như cũ (không fade).
|
||
// Lưu ref trước khi clear để doHardStop trễ không đụng playback mới.
|
||
const ctx=getAudioContext();const stopEpoch=_nativePlayEpoch;const FADE_MS=200;const capturedSources=activeSourcesRef.current.slice();const capturedNodes=Object.values(activeTrackNodesRef.current);activeSourcesRef.current=[];// V42A: wrap (fade=false) GIỮ track nodes — source native-loop kept vẫn
|
||
// routed (node cũ). User stop (fade=true) mới xóa nodes.
|
||
if(fade)activeTrackNodesRef.current={};const fadedGains=[];// V16: stop NGAY clip source CHƯA phát (playbackState='scheduled') — nếu
|
||
// chờ tới doHardStop (sau FADE_MS) chúng đã bắt đầu phát trong lúc fade →
|
||
// nghe như "play lại rồi tắt". Source đang kêu ('playing') giữ lại cho ramp.
|
||
try{capturedSources.forEach(src=>{try{if(src.playbackState==='scheduled')src.stop();}catch(e){}});}catch(e){}let bridgeGainNode=null;if(fade&&ctx){// V17: restore fade session cu TRUOC khi capture prev — double-stop trong
|
||
// cua so fade: neu khong, session cu stale -> bo restore, session moi capture
|
||
// prev giua ramp (vd 0.5) -> restore sai -> gain ket.
|
||
restorePendingFadeSession();try{const now=ctx.currentTime;capturedNodes.forEach(n=>{if(n&&n.gainNode&&n.gainNode.gain){fadedGains.push({node:n.gainNode,prev:n.gainNode.gain.value});n.gainNode.gain.cancelScheduledValues(now);n.gainNode.gain.setValueAtTime(n.gainNode.gain.value,now);n.gainNode.gain.linearRampToValueAtTime(0.0001,now+FADE_MS/1000);}});if(window.BridgeAudioNode&&window.BridgeAudioNode.getOutputNode){const bg=window.BridgeAudioNode.getOutputNode();if(bg&&bg.gain){bridgeGainNode=bg;bg.gain.cancelScheduledValues(now);bg.gain.setValueAtTime(bg.gain.value,now);bg.gain.linearRampToValueAtTime(0.0001,now+FADE_MS/1000);}}}catch(e){console.warn('[Stop] fade setup error:',e);}_pendingFadeSession={fadedGains,bridgeGainNode,stopEpoch};}// Bug1 (user stop "van 1 am nua moi tat"): flush master FX tail NGAY tai T0,
|
||
// khong cho doHardStop +200ms - engine backlog (~0.3-0.5s audio da render)
|
||
// phat full gain trong luc fade. Worklet tu fade-out 21ms (anti-click) roi
|
||
// bao main flush engine ring; doHardStop giu lam fallback (skip neu da gui).
|
||
let fxTailFlushed=false;if(fade&&fxRt&&fxRt.node&&fxRt.active){try{fxRt.node.port.postMessage({type:'flush_for_stop'});fxTailFlushed=true;}catch(e){console.warn('[Stop] fx flush error:',e);}}const doHardStop=()=>{try{const stale=_nativePlayEpoch!==stopEpoch;// play mới bắt đầu trong lúc fade
|
||
// V42A: wrap (fxRtWrapRestart, fade=false) — giữ source native-loop đang
|
||
// chạy qua restart (seam-free tại loopEnd); stop/seek thật → dừng + xóa.
|
||
const _wrapKeepLoop=!stale&&fxRtWrapRestart&&nativeLoopSrcsRef.current.size>0;const _loopSrcs=_wrapKeepLoop?new Set(Array.from(nativeLoopSrcsRef.current.values(),v=>v.src)):null;capturedSources.forEach(src=>{try{if(_loopSrcs&&_loopSrcs.has(src))return;src.stop();}catch(e){}});capturedNodes.forEach(n=>{if(n.fxStopFn){try{n.fxStopFn();}catch(e){}}});// V17: restore gain LUON — play moi trong cua so fade (stale) truoc day bo qua
|
||
// restore -> track/bridge gain ket 0.0001 -> moi am sau bi fade-out dai dang.
|
||
// Chi skip khi co fade session MOI hon (session nay het pending — session moi tu restore).
|
||
if(stale){if(_pendingFadeSession&&_pendingFadeSession.stopEpoch===stopEpoch){restorePendingFadeSession();}return;// play mới đang chạy — không dừng bridge/SF của nó
|
||
}if(!_wrapKeepLoop)nativeLoopSrcsRef.current.clear();// V42A: stop/seek thật — quên native loop
|
||
// V26: user stop (fade) — dừng master FX tail: xóa outQueue worklet + flush
|
||
// engine ring (audio đã xử lí còn trong pipeline phát tiếp ~0.3-0.5s sau stop
|
||
// = "ding"). Worklet tự fade-out 21ms (hết click) rồi báo main flush engine;
|
||
// session giữ warm (play kế tiếp FX ngay, không reload VST).
|
||
try{if(!fxTailFlushed&&fade&&fxRt&&fxRt.node&&fxRt.active){fxRt.node.port.postMessage({type:'flush_for_stop'});}}catch(e){console.warn('[Stop] fx flush error:',e);}stopMidiCapture();stopAllNativeSfNotes();stopAllNativeVstNotes();// D2/D6: bridge active -> transport STOP (bridge flush toàn pitch, hết
|
||
// âm ngân tức thì; A13 C++ flush note-off).
|
||
// D17: wrap (loop restart) — BỎ transport STOP. C++ STOP làm allNotesOff
|
||
// + CC64=0 trên cả 16 channel → cắt mọi note (kể cả note ngân qua seam)
|
||
// + có thể stall audio loop ~20ms (dbg.log: wrap play_start→first_audio
|
||
// δ17-32ms vs restart thường δ3-5ms) = "reload" tại beat 0:0. CC123 (D15)
|
||
// đã lo release JS-side; PLAY vẫn reset playhead (playheadSamples chỉ đổi
|
||
// qua transport); transportStopped giữ false → note-on pass mới không bị
|
||
// drop (V8 guard). Stop/seek thật vẫn gửi STOP.
|
||
const _wrapNoStop=!!(fxRtWrapRestart&&!stale);if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected){if(!_wrapNoStop){try{window.NativeBridgeService.transport('stop');}catch(e){}}_dbgLog('stop_transport','epoch='+stopEpoch+(_wrapNoStop?' wrap-skip':''));// V39: MIDI panic CC123/CC120 tai wrap — VSTi note treo (khong nhan note-off) phai tat ngay.
|
||
// D15: wrap (loop) → chỉ CC123 (all-notes-off, release mượt); bỏ CC120
|
||
// (all-sound-off, hard cut → click + âm beat 0:0 tụt). Release chồng
|
||
// lên re-noteon pass mới → seam như cùng khuôn nhạc. Stop thật giữ CC120.
|
||
try{const _wrapRelease=!!(fxRtWrapRestart&&!stale);if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive&&window.SonicMidiRouter.isBridgeActive()){Object.keys(trackMidiChannelsRef.current||{}).forEach(function(_tid){var _ch=trackMidiChannelsRef.current[_tid];var _tps=(timelinePitchesRef.current||{})[_tid];// D18e: track co pitch ring (NOTE_OFF tu nhien trong RING_GRACE_MS sau
|
||
// seam) -> KHONG gui CC123 (all-notes-off channel giet luon voice
|
||
// ring-out); NOTE_OFF per-pitch van release pitch bi cat. Channel
|
||
// khong co ring giu nguyen CC123 (D15).
|
||
var _ringP=null;if(_wrapRelease&&_seamRing){_ringP={};_seamRing.forEach(function(_r){if(String(_r.tid)===String(_tid))_ringP[_r.pitch]=_r;});}var _hasRing=!!(_ringP&&Object.keys(_ringP).length);if(!_hasRing)window.NativeBridgeService.dispatchMidiEvent('CC',_ch,123,0,0,0,0,false);if(!_wrapRelease)window.NativeBridgeService.dispatchMidiEvent('CC',_ch,120,0,0,0,0,false);// D18d: wrap - NOTE_OFF tung pitch dang keu (timelinePitchesRef).
|
||
// CC123 khong an voi VST2 (Qin bo qua all-notes-off CC) -> note cu
|
||
// ring qua seam -> re-trigger cung pitch chong nhau = crackle.
|
||
// Per-note OFF da chung minh an (panic 128 noteOff o STOP; D18c).
|
||
if(_wrapRelease&&_tps&&_tps.size){_tps.forEach(function(_p){if(_ringP&&_ringP[_p]!==undefined){// D18e: ring-out - khong panic, re-arm NOTE_OFF epoch moi (epoch
|
||
// da ++ truoc clearAllPendingNoteOffs). Dua entry lai vao map de
|
||
// V18 dedupe van doi NOTE_OFF cu NGAY TRUOC note-on cung pitch
|
||
// cua pass moi (khong crackle chong). Giu pitch trong set - xoa
|
||
// khi NOTE_OFF fire.
|
||
var _rkey='B:'+_tid+':'+_p;var _rDelay=Math.max(0,Math.round(_ringP[_p].remainingMs));var _ringFn=function(){try{var _re=pendingNoteOffMap[_rkey];if(_re&&_re.timer===_rTimer)delete pendingNoteOffMap[_rkey];window.NativeBridgeService.dispatchMidiEvent('NOTE_OFF',_ch,_p,0,0,0,0,false);var _rset=(timelinePitchesRef.current||{})[_tid];if(_rset){_rset.delete(_p);if(!_rset.size&&timelinePitchesRef.current)delete timelinePitchesRef.current[_tid];}}catch(e2){console.warn('[Stop] seam ring noteoff error:',e2);}};var _rTimer=setTimeout(_ringFn,_rDelay);pendingNoteOffMap[_rkey]={timer:_rTimer,fn:_ringFn,offDelay:_rDelay,offAt:performance.now()+_rDelay};return;}window.NativeBridgeService.dispatchMidiEvent('NOTE_OFF',_ch,_p,0,0,0,0,false);try{if(_tps)_tps.delete(_p);}catch(e3){}});var _tpsLeft=(timelinePitchesRef.current||{})[_tid];if(_tpsLeft&&!_tpsLeft.size&&timelinePitchesRef.current)delete timelinePitchesRef.current[_tid];}});}}catch(e){console.warn('[Stop] midi panic error:',e);}// V27: xóa audio còn buffer trong pipeline (preBuffer/chunks + worklet
|
||
// ring) — nếu không, âm trước stop còn trong ring phát tiếp sau fade.
|
||
// D18: wrap (_wrapNoStop) — KHÔNG flush (giữ dòng liên tục qua seam);
|
||
// flush ở đây + ở startTrackPlayback = ring rỗng → gap giật beat 0:0.
|
||
if(!_wrapNoStop){try{if(window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){console.warn('[Stop] bridge flush error:',e);}}// V8 bug 3: transport stop thu 2 sau 150ms — note-on timer guard sync
|
||
// qua React effect (cham) van bay toi bridge sau STOP → retrigger am
|
||
// treo. V10: timer lưu ref — startTrackPlayback (loop restart) hủy
|
||
// khi PLAY, tránh STOP#2 giết loop tiếp theo (VSTi chỉ play 1 vòng).
|
||
if(bridgeStopRetryTimerRef.current){clearTimeout(bridgeStopRetryTimerRef.current);}bridgeStopRetryTimerRef.current=setTimeout(()=>{bridgeStopRetryTimerRef.current=null;try{window.NativeBridgeService.transport('stop');}catch(e){}},150);}if(window.SonicSF){try{window.SonicSF.stopAll();}catch(e){console.warn('[Stop] stopAll error:',e);}// Dừng triệt để: noteoff từng note + hủy scheduled note-on (hết âm stuck)
|
||
if(window.SonicSF.panic){try{window.SonicSF.panic();}catch(e){console.warn('[Stop] panic error:',e);}}}// V28: KHÔNG auto-restore gain sau stop — VSTi release tail có thể dài
|
||
// hơn 800ms (V27 vẫn còn "ding"). Gain giữ ~0.0001 vô thời hạn; restore
|
||
// khi: play mới (startTrackPlayback entry) hoặc midi bất kỳ (pushEvent →
|
||
// __restorePendingFadeSession — keyboard/pad preview). Tail vẫn render
|
||
// nhưng im lặng qua bridge gain.
|
||
_dbgLog('stop_hard','epoch='+stopEpoch+' '+_dbgBridgeGain());}catch(e){console.warn('[Stop] stopAllPlayback error:',e);}};if(fade&&ctx){try{setTimeout(doHardStop,FADE_MS);}catch(e){doHardStop();}}else{doHardStop();}// Clear VU activity NGAY: hết âm → VU tắt tức thì (không decay 0.35s từ
|
||
// âm cũ — user 06:45: tắt âm MAIN items khi vào SECTION-TAB → VU main
|
||
// items phải tắt theo, không "diễn" tiếp).
|
||
try{midiVuActivityRef.current={};}catch(e){}// Clear luôn counter note đang giữ — nếu không, sau stop (âm đã dừng)
|
||
// tick vẫn thấy heldCnt > 0 → giữ peak → VU dính mãi (user bug 08:08).
|
||
try{heldMidiNotesRef.current={};}catch(e){}}catch(e){console.warn('[Stop] stopAllPlayback error:',e);}setIsPlaying(false);setSubTabs(prev=>prev.map(s=>({...s,isPlaying:false})));if(!skipRouting)updateSfRouting();};const seekPlaybackTo=time=>{const isSubTab=subTabs.some(sub=>sub.id===activeTab);if(isSubTab){const st=subTabs.find(s=>s.id===activeTab);if(!st)return;if(st.isPlaying){fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback(false,true);window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:time,isPlaying:true}:s));const ctx=getAudioContext();startOffsetTimeRef.current=time;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=time*(st.speed||1.0);if(st.type==='PIANO_ROLL'){schedulePianoRollMidi(st,time);}startSubTabPlayback(st,time);}else{setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:time}:s));}}else{if(isPlaying){fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback(false,true);setCurrentTime(time);startOffsetTimeRef.current=time;startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(time);setIsPlaying(true);}else{setCurrentTime(time);}}};const handleStop=()=>{if(recordingStateRef.current==='RECORDING'||recordingStateRef.current==='COUNT_IN'){stopRecordingTake();return;}stopAllPlayback(true);// user stop transport — fade out
|
||
if(activeTab!=='main'&&!activeTab.startsWith('session_')){setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:0}:s));}else{setCurrentTime(0);}};const drawVuMeter=(canvas,db)=>{if(!canvas)return;const ctx=canvas.getContext('2d');if(!ctx)return;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);const minDb=-60;const maxDb=0;const frac=Math.max(0,Math.min(1,(db-minDb)/(maxDb-minDb)));ctx.fillStyle='#18181b';ctx.fillRect(0,0,w,h);const grad=ctx.createLinearGradient(0,0,w,0);grad.addColorStop(0,'#10b981');grad.addColorStop(0.7,'#eab308');grad.addColorStop(0.95,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(0,0,w*frac,h);if(db>=-0.5){ctx.fillStyle='#ff0000';ctx.fillRect(w-6,0,6,h);}};const handleRecordClick=async()=>{if(recordingState==='RECORDING'||recordingState==='COUNT_IN'){await stopRecordingTake();return;}// Check if piano roll tab is active and armed
|
||
const activePianoRoll=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL'&&s.isArmed);if(activePianoRoll&&selectedMidiInputId){setRecordingState('COUNT_IN');const secondsPerBeat=60.0/(parseInt(bpm)||120);const countInDuration=secondsPerBeat*4;const audioCtx=getAudioContext();const now=audioCtx.currentTime;showToast('Metronome Count-in: 4... 3... 2... 1... (MIDI Piano Roll)','info');for(let i=0;i<4;i++){playMetronomeClick(now+i*secondsPerBeat,i===0);}setTimeout(()=>{startPianoRollRecording(activePianoRoll);},countInDuration*1000);return;}const armed=activeTracks.filter(t=>t.isArmed&&t.inputSource?.deviceType&&t.inputSource.deviceType!=='NONE');if(armed.length===0){showToast('Vui lòng Arm (R) ít nhất một track và chọn cổng Input để ghi âm.','warning');return;}setRecordingState('COUNT_IN');const secondsPerBeat=60.0/(parseInt(bpm)||120);const countInDuration=secondsPerBeat*4;const audioCtx=getAudioContext();const now=audioCtx.currentTime;showToast('Metronome Count-in: 4... 3... 2... 1...','info');for(let i=0;i<4;i++){playMetronomeClick(now+i*secondsPerBeat,i===0);}setTimeout(()=>{startRecordingTake(armed);},countInDuration*1000);};const startPianoRollRecording=tab=>{try{const context=getAudioContext();const secondsPerBeat=60.0/(parseInt(bpm)||120);const startTime=currentTime;const startBeat=startTime/secondsPerBeat;nextMetronomeBeatRef.current=Math.ceil(startBeat);const midiRec=new ClientMIDIRecorder(context,parseInt(bpm)||120,4);midiRec.tempTabId=tab.id;midiRec.selectedMidiInputId=selectedMidiInputId||'ALL';midiRec.start(startTime/secondsPerBeat,midiRec.selectedMidiInputId);pianoRollRecorderRef.current=midiRec;activeMIDIRecordersRef.current['piano_roll']=midiRec;setRecStartTimelineTime(startTime);recordingStartTimeRef.current=startTime;setRecordingState('RECORDING');setRecTempMidiNotes([]);const ctx=getAudioContext();const silentBuf=ctx.createBuffer(1,128,ctx.sampleRate);setSubTabs(prev=>prev.map(s=>s.id===tab.id?{...s,buffer:silentBuf,isPlaying:true}:s));startSubTabPlayback({...tab,buffer:silentBuf},startTime);startOffsetTimeRef.current=startTime;startAudioTimeRef.current=context.currentTime;setIsPlaying(true);midiRec.onNoteOn=(pitch,currentBeat)=>{const elapsedBeats=Math.max(0,currentBeat);const sec=elapsedBeats*(60.0/(parseInt(bpm)||120));const activeNotes=Array.from(midiRec.activeNotes.values()).map(n=>({id:'rec_'+n.pitch+'_'+currentBeat,pitch:n.pitch,start_beat:n.start_beat,duration_beats:Math.max(0.125,currentBeat-n.start_beat),velocity:Math.min(1,n.velocity||0.8),pan:0.0}));const rec=midiRec.recordedNotes.map((n,i)=>({id:'rec_'+Date.now()+'_'+i,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:Math.min(1,n.velocity||0.8),pan:0.0}));const allNotes=[...rec,...activeNotes];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);setSubTabs(prev=>prev.map(s=>s.id===tab.id?{...s,currentTime:startTime+sec,isDirty:true}:s));};if(!tab._recordingStarted){tab._recordingStarted=true;}showToast('Recording MIDI to Piano Roll...','info');}catch(err){console.error('startPianoRollRecording error:',err);showToast('Lỗi khi bắt đầu ghi âm Piano Roll: '+err.message,'warning');setRecordingState('IDLE');}};handleRecordClickRef.current=handleRecordClick;const startRecordingTake=async armedTracks=>{const context=getAudioContext();if(context.state==='suspended'){await context.resume();}setRecordingState('RECORDING');setRecTempMidiNotes([]);setRecTempAudioBuffer(null);const startTimelineTime=currentTime;setRecStartTimelineTime(startTimelineTime);recordingStartTimeRef.current=startTimelineTime;const secondsPerBeat=60.0/(parseInt(bpm)||120);const startBeat=startTimelineTime/secondsPerBeat;nextMetronomeBeatRef.current=Math.ceil(startBeat);activeMIDIRecordersRef.current={};activeAudioRecordersRef.current={};recordingPCMDataRef.current={};startOffsetTimeRef.current=startTimelineTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(startTimelineTime);setIsPlaying(true);let midiRecList=[];for(let track of armedTracks){if(track.inputSource.deviceType==='MIDI_KEYBOARD'){const midiRec=new ClientMIDIRecorder(context,parseInt(bpm)||120,4);const tempMidiItemId='midi_rec_'+Date.now()+'_'+track.id;midiRec.tempMidiItemId=tempMidiItemId;updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:[...(t.midiItems||[]),{id:tempMidiItemId,name:'Recording...',startTime:startTimelineTime,duration:4*(secondsPerBeat*4),notes:[]}]};}));midiRec.onNoteOn=(pitch,currentBeat)=>{const canvas=trackVuRefs.current[track.id];if(canvas){drawVuMeter(canvas,0);setTimeout(()=>drawVuMeter(canvas,-60),100);}const elapsedBeats=Math.max(0,currentBeat);const elapsedSec=elapsedBeats*secondsPerBeat;const durationSec=Math.max(4*(secondsPerBeat*4),elapsedSec);const allNotes=[...midiRec.recordedNotes,...Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}))];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);if(midiRec.tempMidiItemId){updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}};midiRec.onNoteOff=()=>{const currentBeat=(context.currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec)/(60.0/midiRec.bpm);const elapsedBeats=Math.max(0,currentBeat);const elapsedSec=elapsedBeats*secondsPerBeat;const durationSec=Math.max(4*(secondsPerBeat*4),elapsedSec);const activeNotesArray=Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}));const allNotes=[...midiRec.recordedNotes,...activeNotesArray];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);if(midiRec.tempMidiItemId){updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}};midiRec.start(startTimelineTime/(secondsPerBeat*4),track.inputSource.deviceId);activeMIDIRecordersRef.current[track.id]=midiRec;midiRecList.push({trackId:track.id,midiRec});}else if(track.inputSource.deviceType==='MICROPHONE'){const audioRec=new ClientAudioRecorder(context);try{await audioRec.initializeInput(track.inputSource.deviceId);recordingPCMDataRef.current[track.id]=[];audioRec.onLevelUpdate=db=>{const canvas=trackVuRefs.current[track.id];if(canvas){drawVuMeter(canvas,db);}};audioRec.onPCMChunk=chunk=>{if(recordingPCMDataRef.current[track.id]){const currentData=recordingPCMDataRef.current[track.id];const newData=new Float32Array(currentData.length+chunk.length);newData.set(currentData);newData.set(chunk,currentData.length);recordingPCMDataRef.current[track.id]=newData;}};const monitorGain=track.monitoringEnabled?masterBus?masterBus.input:context.destination:null;await audioRec.start(monitorGain,track.monitoringEnabled);activeAudioRecordersRef.current[track.id]=audioRec;}catch(err){console.error('Failed to initialize microphone:',err);showToast('Không khởi động được micro: '+err.message,'warning');}}}recordingSyncRef.current=setInterval(()=>{const secondsPerBeatInt=60.0/(parseInt(bpm)||120);for(let{trackId,midiRec}of midiRecList){if(!midiRec.isRecording||!midiRec.tempMidiItemId)continue;const currentTimeSec=Math.max(0,getAudioContext().currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec);const currentBeat=currentTimeSec/secondsPerBeatInt;const allNotes=[...midiRec.recordedNotes,...Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}))];const durationSec=Math.max(4*(secondsPerBeatInt*4),currentTimeSec);if(Math.random()<0.2){// Throttle log to prevent flooding (approx 2 logs/sec)
|
||
console.log(`[DevLog] [MIDI Rec Sync] Temp Item ID: ${midiRec.tempMidiItemId}, Duration: ${durationSec.toFixed(2)}s, ActiveNotes: ${midiRec.activeNotes.size}, RecordedNotes: ${midiRec.recordedNotes.length}`);}setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}},100);showToast('Đang ghi âm...','info');};const stopRecordingTake=async()=>{setRecordingState('IDLE');stopAllPlayback();const context=getAudioContext();const secondsPerBeat=60.0/(parseInt(bpm)||120);const secondsPerBar=secondsPerBeat*4;const midiRecorders=activeMIDIRecordersRef.current;const audioRecorders=activeAudioRecordersRef.current;Object.keys(trackVuRefs.current).forEach(tid=>{const canvas=trackVuRefs.current[tid];if(canvas)drawVuMeter(canvas,-60);});let hasRecordedAnything=false;for(let trackId in midiRecorders){const midiRec=midiRecorders[trackId];const recordedNotes=midiRec.stop();if(midiRec.tempTabId){if(recordedNotes.length>0){const newNotes=recordedNotes.map((n,i)=>({id:'rec_'+Date.now()+'_'+i,pitch:n.pitch,start_beat:Math.max(0,n.start_beat||0),duration_beats:Math.max(0.125,n.duration_beats||0.25),velocity:Math.min(1,n.velocity||0.8),pan:0.0}));setSubTabs(prev=>prev.map(s=>s.id===midiRec.tempTabId?{...s,notes:[...(s.notes||[]),...newNotes],isDirty:true}:s));setCanvasRedrawCount(n=>n+1);showToast(`Đã ghi ${recordedNotes.length} notes vào Piano Roll.`,'success');}hasRecordedAnything=true;}else if(midiRec.tempMidiItemId){const recCurrentTimeSec=Math.max(0,context.currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec);const recElapsedBeats=recCurrentTimeSec/(60.0/midiRec.bpm);const totalDurationBeats=Math.max(4.0,recordedNotes.length>0?Math.max(recElapsedBeats,...recordedNotes.map(n=>n.start_beat+n.duration_beats)):recElapsedBeats);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const itemIndex=(t.midiItems||[]).findIndex(m=>m.id===midiRec.tempMidiItemId);if(itemIndex>=0){const updatedItems=[...t.midiItems];updatedItems[itemIndex]={...updatedItems[itemIndex],name:recordedNotes.length>0?'Recorded MIDI':'Empty MIDI',notes:recordedNotes,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar};return{...t,midiItems:updatedItems};}const newMidiItem={id:'midi_rec_'+Date.now(),name:'Recorded MIDI',parent_track_id:t.id,startTime:recordingStartTimeRef.current,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar,length_bars:Math.ceil(totalDurationBeats/4),notes:recordedNotes};return{...t,midiItems:[...(t.midiItems||[]),newMidiItem]};}));if(recordedNotes.length>0){hasRecordedAnything=true;}}else if(recordedNotes.length>0){hasRecordedAnything=true;const totalDurationBeats=Math.max(4.0,...recordedNotes.map(n=>n.start_beat+n.duration_beats));const newMidiItem={id:'midi_rec_'+Date.now(),name:'Recorded MIDI',parent_track_id:trackId,startTime:recordingStartTimeRef.current,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar,length_bars:Math.ceil(totalDurationBeats/4),notes:recordedNotes};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:[...(t.midiItems||[]),newMidiItem]};}));}}for(let trackId in audioRecorders){const audioRec=audioRecorders[trackId];const audioBuffer=await audioRec.stop();if(audioBuffer&&audioBuffer.duration>0.05){hasRecordedAnything=true;const newClip={id:'clip_rec_'+Date.now(),name:'Recorded Audio.wav',buffer:audioBuffer,startTime:recordingStartTimeRef.current,speed:1.0};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const updatedClips=[...(t.clips||[]),newClip];return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));}}if(recordingSyncRef.current){clearInterval(recordingSyncRef.current);recordingSyncRef.current=null;}activeMIDIRecordersRef.current={};activeAudioRecordersRef.current={};recordingPCMDataRef.current={};setRecTempMidiNotes([]);setRecTempAudioBuffer(null);if(hasRecordedAnything){showToast('Đã thu và lưu bản ghi vào timeline.','success');}else{showToast('Đã dừng ghi âm (không phát hiện tín hiệu đầu vào).','info');}};const handleSubTabResizeMouseDown=e=>{e.preventDefault();e.stopPropagation();const startY=e.clientY;const startHeight=subTabHeight;const handleMouseMove=moveEvent=>{const deltaY=moveEvent.clientY-startY;const newHeight=Math.max(48,Math.min(400,startHeight+deltaY));setSubTabHeight(newHeight);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};// ── Playhead set with seek+play ──
|
||
// Vị trí click gần nhất trên timeline — paste dùng vị trí CLICK (bên phải
|
||
// playhead) thay vì playhead (user 08:05: paste tại con trỏ click; click bên
|
||
// trái playhead → paste tại playhead)
|
||
const lastClickTimeRef=useRef(null);const handlePlayheadSet=(time,shiftKey)=>{lastClickTimeRef.current=time;setPlayheadWithUndo(time);};const clearLocalSelection=()=>{setSelectionMode(null);setLocalSelectionTrackId(null);setLocalSelectionStart(null);setLocalSelectionEnd(null);};const handleRulerMouseDown=e=>{if(e.ctrlKey){e.preventDefault();e.stopPropagation();clearLocalSelection();const wrapper=timelineWrapperRef.current;if(wrapper){const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const rawTime=Math.max(0,(e.clientX-rect.left+scrollLeft)/zoom-leadInMargin);const time=snapValue!=='free'?snapTime(rawTime,snapValue,bpm):rawTime;rulerDragStartRef.current=time;rulerAnchorRef.current=time;isDraggingRulerRef.current=true;captureSelectionUndo();setSelectionMode('global');setSelectionStart(time);setSelectionEnd(time);}return;}const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const rawTime=Math.max(0,mouseX/zoom-leadInMargin);const time=snapValue!=='free'?snapTime(rawTime,snapValue,bpm):rawTime;clearLocalSelection();captureSelectionUndo();setSelectionMode('global');rulerDragStartRef.current=time;isDraggingRulerRef.current=true;if(e.shiftKey){e.preventDefault();e.stopPropagation();const anchor=rulerAnchorRef.current!==null&&rulerAnchorRef.current!==undefined?rulerAnchorRef.current:selectionStart!==null&&selectionStart!==undefined?selectionStart:currentTime;const selS=Math.max(0,Math.min(anchor,time));const selE=Math.max(0,Math.max(anchor,time));setSelectionStart(selS);setSelectionEnd(selE);}else{rulerAnchorRef.current=Math.max(0,time);}handlePlayheadSet(time);};// Global Ruler mousemove is tracked via document listener set up in useEffect
|
||
useEffect(()=>{const handleMouseMove=e=>{if(!isDraggingRulerRef.current)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const rawTime=Math.max(0,Math.min(maxDuration,mouseX/zoom-leadInMarginRef.current));const time=snapValueRef.current!=='free'?snapTime(rawTime,snapValueRef.current,bpmRef.current):rawTime;const anchor=rulerAnchorRef.current??rulerDragStartRef.current??time;setSelectionStart(Math.min(anchor,time));setSelectionEnd(Math.max(anchor,time));};const handleMouseUp=()=>{if(isDraggingRulerRef.current){isDraggingRulerRef.current=false;rulerDragStartRef.current=null;pushSelectionUndo();}};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);// Sub-tab ruler drag (section/editor tabs)
|
||
const handleSubTabMove=e=>{if(!isDraggingSubTabRef.current)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const sl=wrapper.scrollLeft;const raw=Math.max(0,(e.clientX-rect.left+sl)/zoom);const time=snapValueRef.current!=='free'?snapTime(raw,snapValueRef.current,bpmRef.current):raw;const anchor=subTabDragStartRef.current??0;setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,selectionStart:Math.min(anchor,time),selectionEnd:Math.max(anchor,time)}:s));};const handleSubTabUp=()=>{if(isDraggingSubTabRef.current){isDraggingSubTabRef.current=false;subTabDragStartRef.current=null;}};document.addEventListener('mousemove',handleSubTabMove);document.addEventListener('mouseup',handleSubTabUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);document.removeEventListener('mousemove',handleSubTabMove);document.removeEventListener('mouseup',handleSubTabUp);};},[zoom,maxDuration]);// ── Track Lane Local Selection Drag ──
|
||
const localDragInProgressRef=useRef(false);const localDragTrackRef=useRef(null);const localDragStartTimeRef=useRef(0);const localSelectionAnchorRef=useRef(null);const handleTrackLaneMouseDown=(trackId,time)=>{setSelectedTrackId(trackId);captureSelectionUndo();clearLocalSelection();// A fresh region selection re-enables selection-looping (a previous
|
||
// Ctrl+click "clear selection" must not keep blocking the new region).
|
||
setSelectionCleared(false);localSelectionAnchorRef.current=time;setSelectionMode('local');setLocalSelectionTrackId(trackId);setLocalSelectionStart(time);setLocalSelectionEnd(time);setSelectionStart(time);setSelectionEnd(time);localDragInProgressRef.current=true;localDragTrackRef.current=trackId;localDragStartTimeRef.current=time;};// Document-level mousemove/mouseup for local selection drag
|
||
useEffect(()=>{const handleMouseMove=e=>{if(!localDragInProgressRef.current)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=Math.max(0,Math.min(maxDuration,mouseX/zoom));const anchor=localSelectionAnchorRef.current??localDragStartTimeRef.current;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);setLocalSelectionStart(selS);setLocalSelectionEnd(selE);setSelectionStart(selS);setSelectionEnd(selE);};const handleMouseUp=()=>{if(localDragInProgressRef.current){localDragInProgressRef.current=false;localDragTrackRef.current=null;localDragStartTimeRef.current=0;pushSelectionUndo();}};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom,maxDuration]);const draggedClipRef=useRef(null);draggedClipRef.current=draggedClip;const hoveredTrackIdRef=useRef(null);hoveredTrackIdRef.current=hoveredTrackId;const captureTrackSnapshotRef=useRef(null);captureTrackSnapshotRef.current=captureTrackSnapshot;const draggedSectionItemRef=useRef(null);draggedSectionItemRef.current=draggedSectionItem;const resizedSectionItemRef=useRef(null);resizedSectionItemRef.current=resizedSectionItem;const selectionUndoRef=useRef(null);const pushSelectionUndo=()=>{var cur=selectionRef.current;var before=selectionUndoRef.current;if(!before||before.start===cur.start&&before.end===cur.end&&before.mode===cur.mode)return;var entry={type:'SELECTION',scope:'global',label:'Selection',before:before,after:{start:cur.start,end:cur.end,mode:cur.mode},undo:e=>{setSelectionStart(e.before.start);setSelectionEnd(e.before.end);setSelectionMode(e.before.mode);showToast('Undo: Selection','info');},redo:e=>{setSelectionStart(e.after.start);setSelectionEnd(e.after.end);setSelectionMode(e.after.mode);showToast('Redo: Selection','info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);selectionUndoRef.current=null;};const captureSelectionUndo=()=>{if(selectionUndoRef.current!==null)return;selectionUndoRef.current={start:selectionRef.current.start,end:selectionRef.current.end,mode:selectionMode};};let clipSeqCounter=0;const nextClipId=()=>`clip_${Date.now()}_${++clipSeqCounter}`;const handleClipDragStart=(trackId,clipId,clickOffset,isDuplicate=false)=>{const curTracks=activeTracksRef.current||activeTracks;const track=curTracks.find(t=>t.id===trackId);if(!track)return;const existingClips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];const clip=existingClips.find(c=>c.id===clipId||clipId==='default'&&c.id==='default_'+track.id);if(!clip)return;const beforeSnap=captureTrackSnapshot(trackId);if(isDuplicate){const cloneId=nextClipId();const clone={...clip,id:cloneId,startTime:clip.startTime||0,name:clip.name+' (Copy)'};updateActiveTracks(prev=>prev.map(t=>{if(t.id===trackId){const newClips=[...existingClips,clone];return{...t,clips:newClips,buffer:newClips[0].buffer,startTime:newClips[0].startTime,name:newClips[0].name};}return t;}));setDraggedClip({trackId,clipId:cloneId,clickOffset,buffer:clip.buffer,name:clip.name+' (Copy)',beforeSnap,isDuplicate:false});return;}if(!track.clips||track.clips.length===0){updateActiveTracks(prev=>prev.map(t=>{if(t.id===trackId){return{...t,clips:existingClips};}return t;}));}setDraggedClip({trackId,clipId:clip.id,clickOffset,buffer:clip.buffer,name:clip.name,beforeSnap,isDuplicate:false});};const handleClipDragStartRef=useRef(null);handleClipDragStartRef.current=handleClipDragStart;const stretchedClipRef=useRef(null);stretchedClipRef.current=stretchedClip;const handleClipStretchStart=(trackId,clipId,clickTime)=>{const curTracks=activeTracksRef.current||activeTracks;const track=curTracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];const clip=clips.find(c=>c.id===clipId||clipId==='default'&&c.id==='default_'+trackId);if(!clip||!clip.buffer)return;const beforeSnap=captureTrackSnapshot(trackId);if(!track.clips||track.clips.length===0){updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,clips}:t));}setStretchedClip({trackId,clipId:clip.id==='default'?'default_'+trackId:clip.id,originalDuration:clip.buffer.duration,startTime:clip.startTime,originalSpeed:clip.speed||1.0,beforeSnap});};const handleSelectionEdgeDragStart=(e,trackId,side)=>{const startX=e.clientX;const initialLeft=Math.min(localSelectionStart,localSelectionEnd);const initialRight=Math.max(localSelectionStart,localSelectionEnd);const handleMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const deltaSec=deltaX/zoom;if(side==='left'){const newLeft=Math.max(0,Math.min(initialRight-0.05,initialLeft+deltaSec));setLocalSelectionStart(newLeft);setLocalSelectionEnd(initialRight);}else{const newRight=Math.max(initialLeft+0.05,Math.min(maxDuration,initialRight+deltaSec));setLocalSelectionStart(initialLeft);setLocalSelectionEnd(newRight);}};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleTrackResizeMouseDown=(e,trackId)=>{e.preventDefault();e.stopPropagation();const startY=e.clientY;const track=tracks.find(t=>t.id===trackId);const startHeight=track?track.height||140:140;const handleMouseMove=moveEvent=>{const deltaY=moveEvent.clientY-startY;const newHeight=Math.max(110,Math.min(300,startHeight+deltaY));setTracks(prev=>prev.map(t=>t.id===trackId?{...t,height:newHeight}:t));};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const deleteTrack=trackId=>{const sessionTab=sessionTabs.find(s=>s.id===activeTab);const trackList=sessionTab?sessionTab.tracks:tracks;const track=trackList.find(t=>t.id===trackId);if(!track)return;const hasClips=track.clips&&track.clips.some(c=>c.buffer)||!!track.buffer;const hasMidi=track.midiItems&&track.midiItems.some(m=>m.notes&&m.notes.length>0);const hasSections=track.sections&&track.sections.some(s=>s.tracks&&s.tracks.some(st=>st.clips&&st.clips.some(c=>c.buffer)||st.midiItems&&st.midiItems.some(m=>m.notes&&m.notes.length>0)));const isTrackEmpty=!hasClips&&!hasMidi&&!hasSections;if(!isTrackEmpty){showToast('Không thể xoá track chứa dữ liệu. Vui lòng xoá hết các item trước khi xoá track.','warning');return;}if(sessionTab){updateActiveTracks(prev=>{const filtered=prev.filter(t=>t.id!==trackId);if(filtered.length>0)setSelectedTrackId(filtered[0].id);return filtered;});}else{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>{const filtered=prev.filter(t=>t.id!==trackId);if(filtered.length>0)setSelectedTrackId(filtered[0].id);return filtered;});}showToast('Đã xóa track.','info');};// Shared auto-scroll: when mouse near right edge, scroll container right
|
||
const autoScrollTimeline=clientX=>{const wrapper=timelineWrapperRef.current;if(!wrapper)return;const wr=wrapper.getBoundingClientRect();const margin=200;if(clientX>wr.right-margin){const depth=(clientX-(wr.right-margin))/margin;const speed=Math.round(5+depth*depth*40);wrapper.scrollLeft+=speed;if(wrapper.scrollLeft>wrapper.scrollWidth-wrapper.clientWidth-100){const secPerBar=60.0/(parseInt(bpm)||120)*4;setScrollBufferExtra(prev=>prev+secPerBar*4);}}else if(clientX<wr.left+margin){const depth=(wr.left+margin-clientX)/margin;const speed=Math.round(5+depth*depth*40);wrapper.scrollLeft=Math.max(0,wrapper.scrollLeft-speed);}};useEffect(()=>{const handleMouseMove=e=>{const drag=draggedClipRef.current;if(!drag)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;autoScrollTimeline(e.clientX);const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=Math.max(0,mouseX/zoom-leadInMarginRef.current);const beatSec=60.0/(parseInt(bpmRef.current)||120);const rawStart=Math.max(0,time-drag.clickOffset);const secPerBar=beatSec*4;const marginBar=maxDurationRef.current-secPerBar;const clampedStart=Math.min(rawStart,marginBar);const newStart=snapTime(Math.max(0,clampedStart),snapValueRef.current,bpmRef.current);const itemPx=newStart*zoom;const keepMargin=80;if(itemPx>scrollLeft+rect.width-keepMargin){wrapper.scrollLeft=itemPx-rect.width+keepMargin;setCanvasRedrawCount(n=>n+1);}else if(itemPx<scrollLeft+keepMargin){wrapper.scrollLeft=Math.max(0,itemPx-keepMargin);setCanvasRedrawCount(n=>n+1);}const targetTrackId=hoveredTrackIdRef.current||drag.trackId;updateActiveTracks(prev=>prev.map(t=>{// Clear the clip from its previous track if it moved to a new track
|
||
if(t.id===drag.trackId&&drag.trackId!==targetTrackId){const updatedClips=(t.clips||[]).filter(c=>c.id!==drag.clipId);return{...t,clips:updatedClips,buffer:updatedClips.length>0?updatedClips[0].buffer:null,startTime:updatedClips.length>0?updatedClips[0].startTime:0,name:updatedClips.length>0?updatedClips[0].name:`Track ${t.id}`};}// Update/set clip on target track
|
||
if(t.id===targetTrackId){const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const hasClip=existingClips.some(c=>c.id===drag.clipId);let updatedClips;if(hasClip){updatedClips=existingClips.map(c=>c.id===drag.clipId?{...c,startTime:newStart}:c);}else{updatedClips=[...existingClips,{id:drag.clipId,buffer:drag.buffer,startTime:newStart,name:drag.name}];}return{...t,clips:updatedClips,buffer:updatedClips[0].buffer,startTime:updatedClips[0].startTime,name:updatedClips[0].name};}return t;}));if(drag.trackId!==targetTrackId){setDraggedClip(prev=>({...prev,trackId:targetTrackId}));}};const handleMouseUp=()=>{const drag=draggedClipRef.current;if(!drag)return;const afterSnap=captureTrackSnapshotRef.current(drag.trackId);pushAction('MOVE_CLIP',drag.trackId,drag.beforeSnap,afterSnap);setDraggedClip(null);showToast('Đã di chuyển clip.','success');// Realtime: clip vừa thả — re-schedule NGAY để phát theo vị trí mới
|
||
// (kéo clip về đúng playhead → phát ngay, không chờ debounce).
|
||
triggerRescheduleNow();};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom]);// Document-level mousemove/mouseup for clip stretching
|
||
useEffect(()=>{const handleMouseMove=e=>{const stretch=stretchedClipRef.current;if(!stretch)return;autoScrollTimeline(e.clientX);const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=mouseX/zoom;const secPerBar=60.0/(parseInt(bpmRef.current)||120)*4;const marginBar=maxDurationRef.current-secPerBar;const maxEnd=Math.min(time,marginBar);const newDuration=Math.max(0.1,maxEnd-stretch.startTime);const speedRatio=stretch.originalDuration/newDuration;updateActiveTracks(prev=>prev.map(t=>{if(t.id===stretch.trackId){const updatedClips=(t.clips||[]).map(c=>{if(c.id===stretch.clipId){return{...c,speed:speedRatio};}return c;});return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer,startTime:updatedClips[0]?.startTime||0,speed:updatedClips[0]?.speed||1.0};}return t;}));};const handleMouseUp=()=>{const stretch=stretchedClipRef.current;if(!stretch)return;const afterSnap=captureTrackSnapshotRef.current(stretch.trackId);pushAction('STRETCH_CLIP',stretch.trackId,stretch.beforeSnap,afterSnap);setStretchedClip(null);showToast('Đã giãn thời gian clip.','success');};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom]);// ── Deselect single item ──
|
||
const handleDeselectItem=itemId=>{captureSelectionUndo();setSelectedItemIds(prev=>{var next=new Set(prev);next.delete(itemId);return next;});};// ── Add item to selectedItemIds ──
|
||
const handleSaveSectionTabRef=useRef(handleSaveSectionTab);handleSaveSectionTabRef.current=handleSaveSectionTab;const handleAddToSelection=itemId=>{captureSelectionUndo();setSelectedItemIds(prev=>{var next=new Set(prev);next.add(itemId);return next;});};const handleSetPendingDrag=(trackId,itemType,itemId,clickOffset,e,preToggleSnapshot)=>{// Compute selectedIds AFTER the toggle that just happened:
|
||
// - If itemId was in snapshot (was selected) → toggle removed it → delete
|
||
// - If itemId was not in snapshot (was not selected) → toggle added it → add
|
||
var ids=preToggleSnapshot?new Set(preToggleSnapshot):new Set(selectedItemIds);if(preToggleSnapshot&&preToggleSnapshot.has(itemId)){ids.delete(itemId);}else{ids.add(itemId);}pendingDragRef.current={trackId,itemType,itemId,clickOffset,startX:e.clientX,startY:e.clientY,selectedIds:ids,duplicate:true};};// Plain click on an item: select only; the real drag (move) starts after a
|
||
// 5px movement threshold. Unifies section/MIDI/clip behavior and prevents
|
||
// accidental moves from mouse jitter.
|
||
const handleSetPendingDragMove=(trackId,itemType,itemId,clickOffset,e,dragIds)=>{pendingDragRef.current={trackId,itemType,itemId,clickOffset,startX:e.clientX,startY:e.clientY,selectedIds:dragIds,duplicate:false};};// ── Sweep Select ──
|
||
const handleSweepSelectStart=(trackId,startTime,startY)=>{isSweepingRef.current=true;sweepStartRef.current=startTime;sweepTrackIdRef.current=trackId;sweepStartYRef.current=startY||0;sweepEndYRef.current=startY||0;var init={startTime,endTime:startTime};sweepSelectRef.current=init;setSweepSelect(init);// Do NOT clear selection here – wait until mouseup.
|
||
// Small movement → deselect all; large movement → marquee toggle.
|
||
};// ── Section / MIDI Item Drag Start ──
|
||
const handleSectionItemDragStart=(trackId,itemType,itemId,clickOffset,isDuplicate,pendingSelectedIds)=>{var curTracks=activeTracksRef.current||activeTracks;var multiIds=null;var selIds=pendingSelectedIds||selectedItemIds;if(selIds&&selIds.size>0&&selIds.has(itemId)){var selArr=Array.from(selIds);var originals={};curTracks.forEach(function(t){(t.sections||[]).forEach(function(s){if(selArr.indexOf(s.id)>=0)originals[s.id]={type:'section',start:s.start,trackId:t.id};});(t.midiItems||[]).forEach(function(m){if(selArr.indexOf(m.id)>=0)originals[m.id]={type:'midiItem',start:m.startTime,trackId:t.id};});(t.clips||[]).forEach(function(c){var cid=c.id==='default'?'default_'+t.id:c.id;if(selArr.indexOf(cid)>=0)originals[cid]={type:'clip',start:c.startTime,trackId:t.id};});});if(Object.keys(originals).length>0)multiIds=originals;}if(isDuplicate){var track=curTracks.find(function(t){return t.id===trackId;});if(!track)return;if(multiIds){var newOriginals={};var dupBeforeSnap=captureAllTracksSnapshot();updateActiveTracks(function(prev){return prev.map(function(t){var updatedSections=t.sections?t.sections.slice():[];var updatedMidi=t.midiItems?t.midiItems.slice():[];var updatedClips=t.clips?t.clips.slice():[];Object.keys(multiIds).forEach(function(oid){var info=multiIds[oid];if(info.type==='section'){var sec=(t.sections||[]).find(function(s){return s.id===oid;});if(sec){var rearrangeNewId='sec_dup_'+Date.now()+'_'+Math.random().toString(36).substr(2,5);updatedSections.push({...sec,id:rearrangeNewId,name:sec.name+' (Copy)'});newOriginals[rearrangeNewId]={type:'section',start:sec.start};}}else if(info.type==='midiItem'){var mid=(t.midiItems||[]).find(function(m){return m.id===oid;});if(mid){var rearrangeNewId='midi_dup_'+Date.now()+'_'+Math.random().toString(36).substr(2,5);updatedMidi.push({...mid,id:rearrangeNewId,name:mid.name+' (Copy)'});newOriginals[rearrangeNewId]={type:'midiItem',start:mid.startTime};}}else if(info.type==='clip'){var clip=(t.clips||[]).find(function(c){return c.id===oid||'default_'+t.id===oid;});if(clip){var rearrangeNewId='clip_dup_'+Date.now()+'_'+Math.random().toString(36).substr(2,5);updatedClips.push({...clip,id:rearrangeNewId,startTime:clip.startTime,name:clip.name+' (Copy)'});newOriginals[rearrangeNewId]={type:'clip',start:clip.startTime};}}});return{...t,sections:updatedSections,midiItems:updatedMidi,clips:updatedClips};});});var newItemId=Object.keys(newOriginals)[0]||itemId;var origPos=newOriginals[newItemId]||{start:0};setDraggedSectionItem({trackId,itemType,itemId:newItemId,clickOffset,multiIds:newOriginals,originalPositions:{[newItemId]:origPos.start},beforeSnap:dupBeforeSnap});}else{var items=itemType==='section'?track.sections||[]:track.midiItems||[];var item=items.find(function(it){return it.id===itemId;});if(!item)return;var rearrangeNewId=itemType+'_dup_'+Date.now();var newItem={...item,id:rearrangeNewId,name:item.name+' (Copy)'};var singleDupBeforeSnap=captureAllTracksSnapshot();updateActiveTracks(function(prev){return prev.map(function(t){if(t.id!==trackId)return t;var updated=itemType==='section'?[...(t.sections||[]),newItem]:[...(t.midiItems||[]),newItem];return itemType==='section'?{...t,sections:updated}:{...t,midiItems:updated};});});setDraggedSectionItem({trackId,itemType,itemId:rearrangeNewId,clickOffset,isDuplicate:false,originalPositions:{[rearrangeNewId]:itemType==='section'?item.start:item.startTime},beforeSnap:singleDupBeforeSnap});}}else{var curTrk=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===trackId):null;var its=itemType==='section'?curTrk?.sections||[]:itemType==='clip'?curTrk?.clips||[]:curTrk?.midiItems||[];var it=its.find(function(x){return x.id===itemId||itemType==='clip'&&itemId==='default_'+curTrk?.id&&x.id==='default';});var origPos=it?itemType==='section'?it.start:it.startTime:0;var beforeSnap=captureAllTracksSnapshot();setDraggedSectionItem({trackId,itemType,itemId,clickOffset,multiIds,originalPositions:{[itemId]:origPos},beforeSnap});}};handleSectionItemDragStartRef.current=handleSectionItemDragStart;// ── Section / MIDI Item Resize Start ──
|
||
const handleSectionItemResizeStart=(trackId,itemType,itemId,side,clickTime)=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===trackId);if(!track)return;const items=itemType==='section'?track.sections:track.midiItems;const item=(items||[]).find(it=>it.id===itemId);if(!item)return;const start=itemType==='section'?item.start:item.startTime;setResizedSectionItem({trackId,itemType,itemId,side,originalStart:start,originalDuration:item.duration});};// ── Document-level mousemove/mouseup for Section/MIDI item drag ──
|
||
useEffect(()=>{const handleMouseMove=e=>{const drag=draggedSectionItemRef.current;if(!drag)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;autoScrollTimeline(e.clientX);const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=Math.max(0,mouseX/zoom-leadInMarginRef.current);const beatSec=60.0/(parseInt(bpm)||120);const secondsPerBar=beatSec*4;const marginBar=maxDurationRef.current-secondsPerBar;const rawStart=Math.max(0,Math.min(time-drag.clickOffset,marginBar));const newStart=snapTime(rawStart,snapValueRef.current,bpmRef.current);const itemPx=newStart*zoom;const keepMargin=80;if(itemPx>scrollLeft+rect.width-keepMargin){wrapper.scrollLeft=itemPx-rect.width+keepMargin;setCanvasRedrawCount(n=>n+1);}else if(itemPx<scrollLeft+keepMargin){wrapper.scrollLeft=Math.max(0,itemPx-keepMargin);setCanvasRedrawCount(n=>n+1);}// Compute target track from mouse Y position
|
||
var allTrks=activeTracksRef.current||[];var mouseY=e.clientY-rect.top;var trackTop=0;var targetIdx=-1;for(var ti=0;ti<allTrks.length;ti++){var tH=allTrks[ti].height||(allTrks[ti].isArmed?164:140);if(mouseY>=trackTop&&mouseY<trackTop+tH){targetIdx=ti;break;}trackTop+=tH;}if(targetIdx<0)targetIdx=mouseY<trackTop?0:Math.max(0,allTrks.length-1);var targetTrackId=allTrks[targetIdx]?allTrks[targetIdx].id:drag.trackId;// Use ORIGINAL trackId from drag start for crossOffset math (never changes)
|
||
var dragBaseTrackId=drag.multiIds&&drag.multiIds[drag.itemId]?drag.multiIds[drag.itemId].trackId:drag.trackId;// Pre-compute crossOffset from current allTrks (stable per mousemove)
|
||
var baseIdx=allTrks.findIndex(function(tr){return tr.id===dragBaseTrackId;});if(baseIdx<0)baseIdx=0;var trgIdx=allTrks.findIndex(function(tr){return tr.id===targetTrackId;});if(trgIdx<0)trgIdx=allTrks.length-1;if(trgIdx<0)trgIdx=0;var crossOffset=trgIdx-baseIdx;// Special path for multi-select drag: handle items from multiple tracks
|
||
if(drag.multiIds){var dragOrigStart=drag.multiIds[drag.itemId]?drag.multiIds[drag.itemId].start:0;var delta=newStart-dragOrigStart;// Extend activeTracks if target track index exceeds track count
|
||
var outTracks=[];Object.keys(drag.multiIds).forEach(function(mid){var inf=drag.multiIds[mid];var oIdx=allTrks.findIndex(function(tr){return tr.id===inf.trackId;});if(oIdx<0)oIdx=baseIdx;var needIdx=oIdx+crossOffset;if(needIdx>=allTrks.length){for(var ai=allTrks.length;ai<=needIdx;ai++){var exists=outTracks.some(function(ot){return ot.id===(ai+1).toString();});if(!exists){var colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];outTracks.push({id:(ai+1).toString(),name:'Track '+(ai+1),buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:colors[ai%colors.length],clips:[],midiItems:[],sections:[],markers:[],isArmed:false,height:140});}}}});updateActiveTracks(function(prev){var trkIds=prev.map(function(tr){return tr.id;});var merged=outTracks.length>0?prev.concat(outTracks.filter(function(ot){return!trkIds.includes(ot.id);})):prev;var allIds=merged.map(function(tr){return tr.id;});return merged.map(function(t){var resS=(t.sections||[]).slice();var resM=(t.midiItems||[]).slice();var resC=(t.clips||[]).slice();Object.keys(drag.multiIds).forEach(function(mid){var inf=drag.multiIds[mid];var newVal=inf.start+delta;var oIdx=allIds.indexOf(inf.trackId);if(oIdx<0)oIdx=baseIdx;var needIdx=Math.max(0,oIdx+crossOffset);var targetTid=needIdx<merged.length?merged[needIdx].id:merged[merged.length-1].id;if(inf.type==='section'){resS=resS.filter(function(s){return s.id!==mid;});if(t.id===targetTid){var src=null;for(var p=0;p<prev.length;p++){src=(prev[p].sections||[]).find(function(s){return s.id===mid;});if(src)break;}if(src)resS.push({...src,start:Math.max(0,newVal)});}}else if(inf.type==='midiItem'){resM=resM.filter(function(mx){return mx.id!==mid;});if(t.id===targetTid){var src=null;for(var p=0;p<prev.length;p++){src=(prev[p].midiItems||[]).find(function(mx){return mx.id===mid;});if(src)break;}if(src)resM.push({...src,startTime:Math.max(0,newVal)});}}else if(inf.type==='clip'){var cid3='default_'+t.id;resC=resC.filter(function(cx){return cx.id!==mid&&cx.id!==cid3;});if(t.id===targetTid){var src=null;for(var p=0;p<prev.length;p++){var c2=(prev[p].clips||[]).find(function(cx){return cx.id===mid||'default_'+prev[p].id===mid;});if(c2){src=c2;break;}}if(src)resC.push({...src,startTime:Math.max(0,newVal)});}}});return{...t,sections:resS,midiItems:resM,clips:resC};});});}else{// Single item drag path
|
||
updateActiveTracks(function(prev){let movedItem=null;for(let track of prev){const sec=(track.sections||[]).find(function(it){return it.id===drag.itemId;});const mid=(track.midiItems||[]).find(function(it){return it.id===drag.itemId;});if(sec||mid){movedItem=sec||mid;break;}}return prev.map(function(tr){var its=drag.itemType==='section'?(tr.sections||[]).filter(function(it){return it.id!==drag.itemId;}):(tr.midiItems||[]).filter(function(it){return it.id!==drag.itemId;});if(tr.id===targetTrackId&&movedItem){its.push(drag.itemType==='section'?{...movedItem,start:newStart}:{...movedItem,startTime:newStart});}return drag.itemType==='section'?{...tr,sections:its}:{...tr,midiItems:its};});});}};const handleMouseUp=()=>{const drag=draggedSectionItemRef.current;if(!drag)return;var changed=false;var trackId=drag.trackId;var beforeSnap=drag.beforeSnap;var origs=drag.originalPositions||{};updateActiveTracks(function(prev){return prev.map(function(t){var allItemIds=Object.keys(origs);var updatedSections=(t.sections||[]).slice();var updatedMidi=(t.midiItems||[]).slice();var hasChange=false;for(var i=0;i<allItemIds.length;i++){var oid=allItemIds[i];var origStart=origs[oid];if(t.id===trackId||drag.multiIds&&Object.values(drag.multiIds).some(function(v){return v.trackId===t.id&&v.type===(oid.startsWith('sec_')?'section':oid.startsWith('midi_')?'midiItem':'clip');})){var sec=updatedSections.find(function(s){return s.id===oid;});var mid=updatedMidi.find(function(m){return m.id===oid;});var item=sec||mid;if(item){var curStart=sec?item.start:item.startTime;if(Math.abs(curStart-origStart)>0.001){hasChange=true;changed=true;}}}}if(!hasChange)return t;return{...t,sections:updatedSections,midiItems:updatedMidi};});});if(changed||drag.beforeSnap){var afterSnap=captureAllTracksSnapshot();pushAction('MOVE_ITEM','ALL_TRACKS',drag.beforeSnap||beforeSnap,afterSnap);}// Realtime: items vừa thả — re-schedule NGAY theo vị trí mới.
|
||
triggerRescheduleNow();if(drag.itemType==='midiItem'){let finalTrackId=null;const curTrks=activeTracksRef.current||activeTracks;for(const t of curTrks){if((t.midiItems||[]).some(m=>m.id===drag.itemId)){finalTrackId=t.id;break;}}if(finalTrackId){const targetTrack=curTrks.find(t=>t.id===finalTrackId);if(targetTrack){setSubTabs(prev=>prev.map(st=>{if(st.target_id===drag.itemId){return{...st,trackId:finalTrackId,instrumentProgram:targetTrack.instrumentProgram!==undefined?targetTrack.instrumentProgram:st.instrumentProgram,instrumentName:targetTrack.instrumentName||st.instrumentName};}return st;}));}}}setDraggedSectionItem(null);// Sync section tab → section item trên MAIN sau khi MOVE item (user
|
||
// 08:15 — vị trí item đổi → canvas section item ở MAIN phải theo)
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('session_')){const _tabId=activeTabRef.current;setTimeout(()=>{try{syncSectionTabToMain(_tabId);}catch(e){}},0);}showToast('Đã di chuyển '+(drag.itemType==='section'?'section':'MIDI item')+'.','success');};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom,activeTab,sessionTabs]);// ── Pending drag: click selects; mousemove > 5px threshold starts the real drag ──
|
||
useEffect(()=>{const handleMouseMove=e=>{var pd=pendingDragRef.current;if(!pd)return;var dx=e.clientX-pd.startX;if(Math.abs(dx)>5){var pdSnap=pendingDragRef.current;pendingDragRef.current=null;if(pdSnap.itemType==='clip'&&!pdSnap.duplicate&&(!pdSnap.selectedIds||pdSnap.selectedIds.size<=1)){// Single clip move → clip drag machinery
|
||
if(handleClipDragStartRef.current)handleClipDragStartRef.current(pdSnap.trackId,pdSnap.itemId,pdSnap.clickOffset,false);}else if(handleSectionItemDragStartRef.current){handleSectionItemDragStartRef.current(pdSnap.trackId,pdSnap.itemType,pdSnap.itemId,pdSnap.clickOffset,!!pdSnap.duplicate,pdSnap.selectedIds);}}};document.addEventListener('mousemove',handleMouseMove);var handleMouseUp=function(){pendingDragRef.current=null;};document.addEventListener('mouseup',handleMouseUp);return function(){document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);pendingDragRef.current=null;};},[]);// ── Document-level mousemove/mouseup for Section/MIDI item resize ──
|
||
useEffect(()=>{const handleMouseMove=e=>{const resize=resizedSectionItemRef.current;if(!resize)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;autoScrollTimeline(e.clientX);const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=Math.max(0,mouseX/zoom-leadInMarginRef.current);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==resize.trackId)return t;const items=resize.itemType==='section'?[...(t.sections||[])]:[...(t.midiItems||[])];const idx=items.findIndex(it=>it.id===resize.itemId);if(idx===-1)return t;const item=items[idx];if(resize.side==='left'){const beatSec=60.0/(parseInt(bpm)||120);const newStart=Math.max(0,Math.min(time,resize.originalStart+resize.originalDuration-0.1));const end=resize.originalStart+resize.originalDuration;const newDuration=end-newStart;if(newDuration<0.1)return t;items[idx]=resize.itemType==='section'?{...item,start:newStart,duration:newDuration}:{...item,startTime:newStart,duration:newDuration};}else{const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const marginBar=maxDurationRef.current-secondsPerBar;const clampedTime=Math.min(time,marginBar);const snappedDuration=snapValueRef.current!=='free'?snapTime(clampedTime-resize.originalStart,snapValueRef.current,bpm):clampedTime-resize.originalStart;const newDuration=Math.max(0.1,snappedDuration);items[idx]={...item,duration:newDuration};}return resize.itemType==='section'?{...t,sections:items}:{...t,midiItems:items};}));setCanvasRedrawCount(n=>n+1);const edgePx=(resize.side==='left'?Math.max(0,time):time)*zoom;const keepMargin=80;if(edgePx>scrollLeft+rect.width-keepMargin){wrapper.scrollLeft=edgePx-rect.width+keepMargin;setCanvasRedrawCount(n=>n+1);}else if(edgePx<scrollLeft+keepMargin){wrapper.scrollLeft=Math.max(0,edgePx-keepMargin);setCanvasRedrawCount(n=>n+1);}};const handleMouseUp=()=>{const resize=resizedSectionItemRef.current;if(!resize)return;setResizedSectionItem(null);// Sync nội dung section tab → section item trên MAIN sau khi RESIZE item
|
||
// (user 08:15 — kéo ngắn midi item trong SECTION-TAB → canvas section
|
||
// item ở MAIN phải theo đúng duration mới — trước đây chỉ sync khi XÓA)
|
||
if(activeTabRef.current&&activeTabRef.current.startsWith('session_')){const _tabId=activeTabRef.current;setTimeout(()=>{try{syncSectionTabToMain(_tabId);}catch(e){}},0);}};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom,activeTab,sessionTabs]);// ── Sweep Select mousemove/mouseup ──
|
||
useEffect(()=>{const handleMouseMove=e=>{if(!isSweepingRef.current)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=Math.max(0,mouseX/zoom-leadInMarginRef.current);setSweepSelect(prev=>{var updated=prev?{...prev,endTime:time}:null;sweepSelectRef.current=updated;return updated;});};const handleMouseUp=()=>{if(!isSweepingRef.current)return;isSweepingRef.current=false;sweepTrackIdRef.current=null;const sweep=sweepSelectRef.current;sweepSelectRef.current=null;captureSelectionUndo();if(sweep){const start=Math.min(sweep.startTime,sweep.endTime);const end=Math.max(sweep.startTime,sweep.endTime);if(Math.abs(end-start)<0.02){setSelectedItemIds(new Set());setSweepSelect(null);pushSelectionUndo();return;}const curTracks=activeTracksRef.current||[];const found=new Set();curTracks.forEach(t=>{(t.midiItems||[]).forEach(m=>{if(m.startTime<end&&m.startTime+m.duration>start){found.add(m.id);}});(t.sections||[]).forEach(s=>{if(s.start<end&&s.start+s.duration>start){found.add(s.id);}});(t.clips||[]).forEach(c=>{const dur=c.buffer?c.buffer.duration/(c.speed||1.0):4;if(c.startTime<end&&c.startTime+dur>start){const cid=c.id==='default'?'default_'+t.id:c.id;found.add(cid);}});});setSelectedItemIds(prev=>{const next=new Set(prev);found.forEach(id=>{if(next.has(id))next.delete(id);else next.add(id);});return next;});setSweepSelect(null);sweepTrackIdRef.current=null;pushSelectionUndo();}};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom]);const handleSelectRange=(start,end,reset)=>{captureSelectionUndo();const maxLen=maxDuration;const cleanStart=Math.max(0,Math.min(maxLen,start));const cleanEnd=Math.max(0,Math.min(maxLen,end));if(reset){setSelectionStart(cleanStart);setSelectionEnd(cleanEnd);}else{setSelectionEnd(cleanEnd);}setSelectionCleared(false);pushSelectionUndo();};const handleSelectionInputChange=(field,val)=>{captureSelectionUndo();const numericVal=Math.max(0,parseFloat(val)||0);if(selectionMode==='local'){if(field==='start'){setLocalSelectionStart(numericVal);}else{setLocalSelectionEnd(numericVal);}}else{if(field==='start'){setSelectionStart(numericVal);}else{setSelectionEnd(numericVal);}}pushSelectionUndo();};const selectionStats=useMemo(()=>{if(selLeft===null||selRight===null){return{start:0,end:0,length:0};}const s=Math.min(selLeft,selRight);const e=Math.max(selLeft,selRight);return{start:parseFloat(s.toFixed(3)),end:parseFloat(e.toFixed(3)),length:parseFloat((e-s).toFixed(3))};},[selLeft,selRight]);// ── Handle Drag (selection resize) ──
|
||
const handleHandleDragStart=(e,side)=>{e.preventDefault();e.stopPropagation();const startX=e.clientX;const useLocal=selectionMode==='local';const currentStart=useLocal?localSelectionStart:selectionStart;const currentEnd=useLocal?localSelectionEnd:selectionEnd;const initialLeft=Math.min(currentStart,currentEnd);const initialRight=Math.max(currentStart,currentEnd);const setStart=useLocal?setLocalSelectionStart:setSelectionStart;const setEnd=useLocal?setLocalSelectionEnd:setSelectionEnd;const handleMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const deltaSec=deltaX/zoom;if(side==='left'){const newLeft=Math.max(0,Math.min(initialRight-0.05,initialLeft+deltaSec));setStart(newLeft);setEnd(initialRight);}else{const newRight=Math.max(initialLeft+0.05,Math.min(maxDuration,initialRight+deltaSec));setStart(initialLeft);setEnd(newRight);}};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleSelectionBodyDragStart=e=>{e.preventDefault();e.stopPropagation();const startX=e.clientX;const useLocal=selectionMode==='local';const currentStart=useLocal?localSelectionStart:selectionStart;const currentEnd=useLocal?localSelectionEnd:selectionEnd;const initialLeft=Math.min(currentStart,currentEnd);const initialRight=Math.max(currentStart,currentEnd);const widthSec=initialRight-initialLeft;const setStart=useLocal?setLocalSelectionStart:setSelectionStart;const setEnd=useLocal?setLocalSelectionEnd:setSelectionEnd;const handleMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const deltaSec=deltaX/zoom;let newLeft=initialLeft+deltaSec;let newRight=initialRight+deltaSec;if(newLeft<0){newLeft=0;newRight=widthSec;}if(newRight>maxDuration){newRight=maxDuration;newLeft=maxDuration-widthSec;}setStart(newLeft);setEnd(newRight);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};// ── Track Controls ──
|
||
const toggleTrackSoloEvaluate=trackId=>{const wasPlaying=isPlaying;try{if(window.__dbgLog)window.__dbgLog('solo_toggle','track='+trackId+' wasPlaying='+(wasPlaying?1:0)+' refSolo='+((activeTracksRef.current||[]).find(function(t){return t.id===trackId;})||{}).solo+' n='+(activeTracksRef.current||[]).length);}catch(e){}const playingSubTab=subTabs.find(s=>s.isPlaying&&s.type==='PIANO_ROLL');const wasSolo=!!((activeTracksRef.current||[]).find(function(t){return t.id===trackId;})||{}).solo;fxRtKeepForRestart=true;// restart/resume - giu session FX realtime
|
||
updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,solo:!t.solo,muted:false}:t));if(playingSubTab){// PIANO_ROLL sub-tab dang play: giu stop+restart cu (scheduler sub-tab
|
||
// can restart de ap solo moi; main session khong play song song).
|
||
stopAllPlayback();setTimeout(()=>{const soloCtx=getAudioContext();if(wasPlaying){startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=soloCtx.currentTime;startBufferOffsetRef.current=currentTime;setIsPlaying(true);startTrackPlayback(currentTime);}if(playingSubTab){const prSt=subTabs.find(s=>s.id===playingSubTab.id);if(prSt){const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=soloCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);schedulePianoRollMidi(prSt,prOffset);startSubTabPlayback(prSt,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{isPlaying:true,currentTime:prOffset}):s));}}},60);setTimeout(()=>lucide.createIcons(),50);return;}// V45.6: main-session - KHONG stopAllPlayback nua (track solo dang keu
|
||
// giu nguyen, het wobble re-attack 60ms). applyAllTrackMuteSolo tu kill
|
||
// track im (NOTE_OFF tung pitch - timelinePitchesRef giu set tu V45.5)
|
||
// + restart khi co track became audible. IPC toggleSolo chi goi ham nay
|
||
// -> tu apply luon (truoc day khong apply -> ref khong patch -> Qin khong im).
|
||
try{applyAllTrackMuteSolo(trackId,{solo:!wasSolo,muted:false});}catch(e){}setTimeout(()=>lucide.createIcons(),50);};const toggleTrackMute=trackId=>{const beforeSnap=captureTrackSnapshot(trackId);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,muted:!t.muted}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'MUTE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});setTimeout(()=>lucide.createIcons(),50);};const updateTrackProp=(trackId,props)=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,...props}:t));// Mixer fader dùng updateTrackProp({volumeDb}) — áp gain realtime cho node
|
||
// thường + sub-node SECTION (key <parent>_sub_<trackId>)
|
||
if(props&&props.volumeDb!==undefined){const volLinear=props.volumeDb<=-50?0:Math.pow(10,props.volumeDb/20);const ctx=getAudioContext();const nodes=activeTrackNodesRef.current[trackId];if(nodes)nodes.gainNode.gain.setValueAtTime(volLinear,ctx.currentTime);Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k.endsWith('_sub_'+trackId)){const sn=activeTrackNodesRef.current[k];if(sn&&sn.gainNode)sn.gainNode.gain.setValueAtTime(volLinear,ctx.currentTime);}});}setTimeout(()=>lucide.createIcons(),50);};const toggleTrackDrum=trackId=>{var mt=tracks||[];var tidx=mt.findIndex(function(tr){return tr.id===trackId;});if(tidx<0)tidx=0;setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const becomingPercussion=!t.is_percussion;let newBank=becomingPercussion?128:t._saved_sf_bank!==undefined?t._saved_sf_bank:0;let newProgram=becomingPercussion?0:t._saved_sf_program!==undefined?t._saved_sf_program:0;let newCh=becomingPercussion?9:t._saved_sf_channel!==undefined?t._saved_sf_channel:tidx%16;return{...t,is_percussion:becomingPercussion,soundfont_bank:newBank,instrumentProgram:newProgram,midiChannel:newCh,_saved_sf_bank:becomingPercussion?t.soundfont_bank:t._saved_sf_bank,_saved_sf_program:becomingPercussion?t.instrumentProgram:t._saved_sf_program,_saved_sf_channel:becomingPercussion?t.midiChannel:t._saved_sf_channel,synth_engine:t.synth_engine?{...t.synth_engine,soundfont_bank:newBank,soundfont_program:newProgram}:t.synth_engine};}));setTimeout(()=>lucide.createIcons(),50);};const updateTrackVolumeDb=(trackId,val)=>{const beforeSnap=captureTrackSnapshot(trackId);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,volumeDb:val}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'VOLUME_CHANGE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});// Real-time update during playback
|
||
const volLinear=val<=-50?0:Math.pow(10,val/20);const ctx=getAudioContext();const nodes=activeTrackNodesRef.current[trackId];if(nodes){nodes.gainNode.gain.setValueAtTime(volLinear,ctx.currentTime);}// Section sub-nodes (key: <parent>_sub_<trackId>) — volume slider của
|
||
// SECTION-TAB phải đổi âm section ngay lập tức
|
||
Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k.endsWith('_sub_'+trackId)){const sn=activeTrackNodesRef.current[k];if(sn&&sn.gainNode)sn.gainNode.gain.setValueAtTime(volLinear,ctx.currentTime);}});// MIDI/bridge track: gainNode WebAudio không đổi âm bridge (bridge dùng MỘT
|
||
// shared sink) → gửi CC7 realtime qua SonicMidiRouter (bridge active) hoặc
|
||
// SonicSF (WASM fallback).
|
||
const trkList=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const trk=trkList.find(x=>x.id===trackId);if(trk&&isMidiSoundTrack(trk)){try{const nt={...trk,volumeDb:val};sendMidiVolumeCC7(nt,trkList,computeTrackAudibleGain(trkList,nt)>0);}catch(e){}}};const updateTrackPan=(trackId,val)=>{const beforeSnap=captureTrackSnapshot(trackId);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,pan:val}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'PAN_CHANGE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});// Real-time update during playback
|
||
const nodes=activeTrackNodesRef.current[trackId];if(nodes){nodes.pannerNode.pan.setValueAtTime(val/100,getAudioContext().currentTime);}};const updateTrackName=(trackId,name)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,name}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'RENAME',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});};const updateTrackColor=(trackId,color)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,color}:t));// Lưu màu NGAY (flush — không chờ debounce 2s — tránh mất màu khi reload nhanh)
|
||
try{if(window.SonicStorage){const nextTracks=(tracks||[]).map(t=>t.id===trackId?{...t,color}:t);window.SonicStorage.scheduleTempAutoSave(()=>serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,nextTracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted}));if(window.SonicStorage.flushTempAutoSave)window.SonicStorage.flushTempAutoSave();}}catch(e){}setUndoStack(prev=>{const next=[...prev,{action_type:'RECOLOR',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});};const updateClipName=(trackId,clipId,newName)=>{setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const clips=t.clips&&t.clips.length>0?t.clips:[];return{...t,clips:clips.map(c=>c.id===clipId?{...c,name:newName}:c)};}));};// ── MIDI .mid file parser ──
|
||
const parseMidiFile=arrayBuffer=>{const data=new Uint8Array(arrayBuffer);if(data.length<14)return null;var pos=0;var read32=function(){var v=data[pos]<<24|data[pos+1]<<16|data[pos+2]<<8|data[pos+3];pos+=4;return v;};var read16=function(){var v=data[pos]<<8|data[pos+1];pos+=2;return v;};var readVLQ=function(){var v=0,b;do{b=data[pos++];v=v<<7|b&0x7f;}while(b&0x80);return v;};var header=String.fromCharCode(data[0],data[1],data[2],data[3]);if(header!=='MThd')return null;pos=8;var fmt=read16();var numTracks=read16();var division=read16();var ticksPerBeat=division&0x8000?480:division||480;var bpm=120;var result=[];for(var t=0;t<numTracks;t++){if(pos+8>data.length)break;var trkId=String.fromCharCode(data[pos],data[pos+1],data[pos+2],data[pos+3]);pos+=4;var trkLen=read32();var endPos=Math.min(pos+trkLen,data.length);if(trkId!=='MTrk'){pos=endPos;continue;}var absTicks=0;var runningStatus=0;var trackName='MIDI Track '+(t+1);var midiNotes=[];var pendingNotes={};var maxAbsTick=0;while(pos<endPos){try{var delta=readVLQ();absTicks+=delta;var status=data[pos];if(status>=0x80){if(status<0xf0){runningStatus=status;}pos++;}else{status=runningStatus;}var cmd=status>>4;if(cmd===0x9||cmd===0x8){var chan=status&0x0F;var pitch=data[pos++];var vel=pos<endPos?data[pos++]:0;var noteKey=chan+'_'+pitch;if(cmd===0x9&&vel>0){pendingNotes[noteKey]={tick:absTicks,vel:vel};if(absTicks>maxAbsTick)maxAbsTick=absTicks;}else{var pn=pendingNotes[noteKey];if(pn){var durTicks=absTicks-pn.tick;if(durTicks<=0)durTicks=240;midiNotes.push({id:'mn_'+t+'_'+pitch+'_'+pn.tick,pitch:pitch,start_beat:pn.tick/ticksPerBeat,duration_beats:durTicks/ticksPerBeat,velocity:Math.min(1,pn.vel/127)});delete pendingNotes[noteKey];}}}else if(status>=0xc0&&status<0xe0){if(pos<endPos)pos+=1;}else if(status>=0xe0&&status<0xf0){if(pos+2<=endPos)pos+=2;}else if(status>=0xa0&&status<0xc0){if(pos+2<=endPos)pos+=2;}else if(status>=0xf0&&status<0xf8){if(pos<endPos){var sl=readVLQ();pos+=sl;}}else if(status===0xff){if(pos>=endPos)break;var metaType=data[pos++];var metaLen=readVLQ();if(metaType===0x03){try{trackName=String.fromCharCode.apply(null,Array.from(data.subarray(pos,pos+metaLen)));}catch(e){}}else if(metaType===0x51&&pos+3<=endPos){bpm=Math.round(60000000/(data[pos]<<16|data[pos+1]<<8|data[pos+2]));}pos+=Math.min(metaLen,endPos-pos);}else{if(pos+2<=endPos)pos+=2;}}catch(e){pos=endPos;}}Object.keys(pendingNotes).forEach(function(k){var pn=pendingNotes[k];var parts=k.split('_');var p=parseInt(parts[1]);var dur=Math.max(240,maxAbsTick-pn.tick);midiNotes.push({id:'mn_'+t+'_'+p+'_'+pn.tick,pitch:p,start_beat:pn.tick/ticksPerBeat,duration_beats:dur/ticksPerBeat,velocity:Math.min(1,pn.vel/127)});});if(midiNotes.length>0){var lastEnd=0;var maxEndBeat=0;midiNotes.forEach(function(n){var e=(n.start_beat+n.duration_beats)*60/bpm;if(e>lastEnd)lastEnd=e;var eb=n.start_beat+n.duration_beats;if(eb>maxEndBeat)maxEndBeat=eb;});result.push({name:trackName,notes:midiNotes,duration:lastEnd||4,startTime:0,id:'midi_'+t+'_'+Date.now(),totalBeats:maxEndBeat||16,bars:Math.max(1,Math.ceil((maxEndBeat||16)/4)),bpm:bpm,ticksPerBeat:ticksPerBeat});}pos=endPos;}return result.length>0?result:null;};window.parseMidiFile=parseMidiFile;// ── Load File on Track (with server upload) ──
|
||
// ── Resolve Media Explorer drag file → real File (for timeline drop) ──
|
||
const resolveMediaExplorerDropFile=async()=>{const mef=window.__mediaExplorerDragFile;if(!mef)return null;try{if(mef.handle&&typeof mef.handle.getFile==='function'){const f=await mef.handle.getFile();return new File([f],f.name||mef.name,{type:f.type||'application/octet-stream'});}if(mef.file_id||mef.fileId){const fid=mef.file_id||mef.fileId;const resp=await fetch(`${API_BASE_URL}/api/v1/audio/download/${fid}`);const blob=await resp.blob();return new File([blob],mef.name||mef.original_name||fid,{type:blob.type||'audio/wav'});}if(mef.path){const resp=await fetch(`${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(mef.path)}`);const blob=await resp.blob();return new File([blob],mef.name||mef.path.split(/[\\/]/).pop(),{type:blob.type||'application/octet-stream'});}// Media Library samples: only metadata (name/kind) — real bytes served
|
||
// from /static/midi (generated demo loops, see app/static/midi).
|
||
if(mef.kind==='midi'&&mef.name){const resp=await fetch(`${API_BASE_URL}/static/midi/${encodeURIComponent(mef.name)}`);const blob=await resp.blob();return new File([blob],mef.name,{type:'audio/midi'});}}catch(e){showToast('Không thể nạp file từ Media Explorer: '+e.message,'error');}return null;};const loadFileOnTrack=async(trackId,file)=>{if(!file)return;var fileName=file.name||'';var isMidi=/\.mid$|\.midi$/i.test(fileName);showToast(`Đang nạp file ${fileName}...`,'info');try{if(isMidi){var arrayBuffer=await file.arrayBuffer();var midiResult=parseMidiFile(arrayBuffer);if(!midiResult||midiResult.length===0){showToast('Không tìm thấy nốt nhạc trong file MIDI.','error');return;}if(midiResult.length===1){updateActiveTracks(prev=>prev.map(function(t){if(t.id!==trackId)return t;return{...t,name:fileName,midiItems:[midiResult[0]]};}));showToast('Đã tải MIDI: '+fileName,'success');}else{var colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309','#ca8a04','#dc2626','#0891b2'];var firstDone=false;midiResult.forEach(function(midiItem,idx){if(!firstDone){updateActiveTracks(prev=>prev.map(function(t){if(t.id!==trackId)return t;return{...t,name:midiItem.name||fileName,midiItems:[midiItem]};}));firstDone=true;}else{var newId='midi_track_'+Date.now()+'_'+idx;updateActiveTracks(function(prev){var curLen=prev.length;return prev.concat([{id:newId,name:midiItem.name||'MIDI Track '+(idx+1),buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:colors[idx%colors.length],markers:[],serverFileId:null,clips:[],sections:[],midiItems:[midiItem],isArmed:false,monitoringEnabled:true,inputSource:{deviceType:'NONE',deviceId:''}}]);});}});showToast('Đã tải MIDI: '+midiResult.length+' tracks from '+fileName,'success');}return;}// Upload to server
|
||
uploadToServer(file,trackId);// Decode locally for playback + analyze channels (stereo/mono)
|
||
const{audioBuffer:decodedBuffer,channelInfo}=await window.SonicAudio.decodeAudioFile(file);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,name:fileName,buffer:decodedBuffer,channelInfo:channelInfo}:t));showToast(`Nạp file thành công: ${fileName} (${channelInfo.label})`,'success');}catch(err){showToast(isMidi?"Lỗi giải mã MIDI.":"Lỗi giải mã âm thanh. Định dạng file không tương thích.",'error');}};// ── Drop MIDI file → create new track(s) ──
|
||
const handleDropMidiToNewTracks=async file=>{if(!file)return;showToast('Đang nạp MIDI: '+file.name+'...','info');try{var arrayBuffer=await file.arrayBuffer();var midiResult=parseMidiFile(arrayBuffer);if(!midiResult||midiResult.length===0){showToast('Không tìm thấy nốt nhạc trong file MIDI.','error');return;}var colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309','#ca8a04','#dc2626','#0891b2'];var now=Date.now();var newTracks=midiResult.map(function(midiItem,idx){return{id:'midi_track_'+now+'_'+idx,name:midiItem.name||(midiResult.length>1?'MIDI Track '+(idx+1):(file.name||'MIDI').replace(/\.midi?$/i,'')),buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,masteringBypass:false,audioBypass:false,midiBypass:false,fxActive:true,color:colors[idx%colors.length],markers:[],serverFileId:null,clips:[],sections:[],midiItems:[midiItem],isArmed:false,monitoringEnabled:true,inputSource:{deviceType:'NONE',deviceId:''}};});updateActiveTracks(function(prev){return prev.concat(newTracks);});if(newTracks.length>0)setSelectedTrackId(newTracks[0].id);showToast('Đã tải MIDI: '+newTracks.length+' track(s) từ '+file.name,'success');}catch(err){showToast('Lỗi giải mã MIDI.','error');}};// ── Synth Generators ──
|
||
const generateSynthToTrack=(trackId,type)=>{const sampleRate=44100;const duration=12.0;const context=getAudioContext();const frameCount=sampleRate*duration;const newBuffer=context.createBuffer(1,frameCount,sampleRate);const channelData=newBuffer.getChannelData(0);if(type==='kick'){for(let i=0;i<frameCount;i++){const t=i/sampleRate;const beatTime=t%0.5;const freq=120*Math.exp(-35*beatTime);channelData[i]=Math.sin(2*Math.PI*freq*beatTime)*Math.exp(-6*beatTime);}}else{const notes=[220.00,261.63,293.66,329.63,392.00];for(let i=0;i<frameCount;i++){const t=i/sampleRate;const noteIdx=Math.floor(t*2)%notes.length;const freq=notes[noteIdx];channelData[i]=Math.sin(2*Math.PI*freq*t)*0.25*(1.0-t%0.5/0.5);}}updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,name:`Demo_${type.toUpperCase()}.wav`,buffer:newBuffer}:t));showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`,'success');};// ── Add Track ──
|
||
const addNewTrack=()=>{const curTracks=activeTracks;const rearrangeNewId=(curTracks.length+1).toString();const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[curTracks.length%colors.length];// SECTION-TAB: audioclip item mặc định BYPASS mastering (raw) — nút A xám;
|
||
// MAIN session: qua mastering (A sáng). User requirement 15:00.
|
||
const isSectionCtx=activeTabRef.current&&activeTabRef.current.startsWith('session_');updateActiveTracks(prev=>[...prev,{id:rearrangeNewId,name:`Track ${rearrangeNewId}`,buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,masteringBypass:false,audioBypass:isSectionCtx?true:false,midiBypass:false,fxActive:true,color:selectColor,markers:[],serverFileId:null,clips:[],sections:[],midiItems:[],isArmed:false,monitoringEnabled:true,inputSource:{deviceType:'NONE',deviceId:''}}]);showToast(`Đã thêm Track ${rearrangeNewId}.`,'info');setTimeout(()=>lucide.createIcons(),200);return rearrangeNewId;};// ── Update tracks in active context (main session or section tab) ──
|
||
const updateActiveTracksRef=useCallback(updater=>{const currentSessionTabs=sessionTabsRef.current;const currentActiveTab=activeTabRef.current;var st=currentSessionTabs.find(s=>s.id===currentActiveTab);if(st){setSessionTabs(prev=>prev.map(s=>s.id===currentActiveTab?{...s,tracks:updater(s.tracks),isDirty:true}:s));return;}var pr=subTabsRef.current?subTabsRef.current.find(s=>s.id===currentActiveTab&&s.type==='PIANO_ROLL'):null;if(pr&&pr.parent_tab_id&&pr.parent_tab_id.startsWith('session_')){var parentSt=currentSessionTabs.find(s=>s.id===pr.parent_tab_id);if(parentSt){setSessionTabs(prev=>prev.map(s=>s.id===pr.parent_tab_id?{...s,tracks:updater(s.tracks),isDirty:true}:s));return;}}setTracks(updater);},[]);const updateActiveTracks=updateActiveTracksRef;const toggleTrackArm=trackId=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,isArmed:!t.isArmed};}));setTimeout(()=>lucide.createIcons(),50);};const toggleTrackMonitor=trackId=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,monitoringEnabled:!t.monitoringEnabled};}));setTimeout(()=>lucide.createIcons(),50);};const updateTrackInputSource=(trackId,type,deviceId)=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,inputSource:{deviceType:type,deviceId}};}));};// ── Save-As qua NATIVE dialog (fix bug: export không hỏi nơi lưu) ──
|
||
// WebView2 chặn anchor-download → engine mở SaveFileDialog qua
|
||
// /api/v1/files/save-dialog (PowerShell hoặc Tauri bridge) → ghi bytes qua
|
||
// /api/v1/files/write. Trả path đã ghi; null nếu user hủy / không có dialog.
|
||
async function saveExportViaDialog(defaultName,blob){// Export dir đã chọn trong ExportModal → ghi thẳng file, không mở Save-As.
|
||
// fileName từ modal nếu user nhập (thêm đuôi theo defaultName nếu thiếu),
|
||
// ngược lại dùng tên mặc định của flow export.
|
||
const dir=(exportSettings.exportDir||'').trim();if(dir){try{const ext=defaultName.includes('.')?defaultName.split('.').pop():'bin';let fname=(exportSettings.fileName||'').trim();if(fname){if(!fname.includes('.'))fname=fname+'.'+ext;else if(!fname.toLowerCase().endsWith('.'+ext.toLowerCase()))fname=fname+'.'+ext;}else{fname=defaultName;}const path=dir.replace(/[\\/]+$/,'')+'\\'+fname;const fd=new FormData();fd.append('path',path);fd.append('file',new File([blob],fname,{type:blob.type||'application/octet-stream'}));const wr=await fetch(`${API_BASE_URL}/api/v1/files/write`,{method:'POST',body:fd});if(wr.ok){const w=await wr.json();return w.path;}return null;}catch(e){return null;}}try{const dlg=await fetch(`${API_BASE_URL}/api/v1/files/save-dialog`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({default_name:defaultName,filter_name:defaultName.toLowerCase().endsWith('.mid')?'MIDI':'Audio',filter_ext:defaultName.split('.').pop()||'bin'})});if(!dlg.ok)return null;const d=await dlg.json();if(!d.path)return null;const fd=new FormData();fd.append('path',d.path);fd.append('file',new File([blob],defaultName,{type:blob.type||'application/octet-stream'}));const wr=await fetch(`${API_BASE_URL}/api/v1/files/write`,{method:'POST',body:fd});if(!wr.ok)return null;const w=await wr.json();return w.path;}catch(e){return null;}}// ── MIDI Export: tạo file MIDI từ tất cả tracks ──
|
||
// ── Server-side MIDI export (BIG_PHASE_3 Phase 1) ──
|
||
// Blob download bị WebView2 chặn sau khi chuyển standalone → POST
|
||
// /api/v1/export/midi (server sinh file .mid) + GET download (network
|
||
// download, Content-Disposition attachment — pattern đã chạy ổn định).
|
||
// velocity gửi dạng 0..1 — backend scale sang 1..127 (sửa bug *100 cũ).
|
||
function serverDownloadMidi(payload,filename){return fetch(`${API_AUDIO}/export/midi`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(async res=>{if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.detail||'HTTP '+res.status);}return res.json();}).then(async data=>{// NATIVE Save-As dialog (WebView2 chặn anchor download) → ghi file thật.
|
||
try{const blobResp=await fetch(`${API_BASE_URL}${data.download_url}`);if(blobResp.ok){const blob=await blobResp.blob();const savedPath=await saveExportViaDialog(filename,blob);if(savedPath){return{...data,saved_path:savedPath};}}}catch(e){/* fallback anchor dưới */}const a=document.createElement('a');a.href=`${API_BASE_URL}${data.download_url}`;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);return data;});}// ── Export audio: tải qua server (network download, WebView2 không chặn) ──
|
||
// Blob download bị WebView2 chặn sau khi chuyển standalone → upload file
|
||
// tạm lên engine local (POST /api/v1/audio/upload) → tải về qua GET
|
||
// /api/v1/audio/download/{file_id} (Content-Disposition attachment).
|
||
// Nếu upload thất bại (server offline) → fallback blob download cũ.
|
||
async function downloadBlobViaServer(blob,filename){// NATIVE Save-As dialog trước (WebView2 chặn anchor download) → ghi file thật.
|
||
try{const savedPath=await saveExportViaDialog(filename,blob);if(savedPath)return{saved_path:savedPath};}catch(e){/* fallback upload+anchor dưới */}try{const fd=new FormData();fd.append('file',new File([blob],filename,{type:blob.type||'audio/wav'}));const up=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:fd});if(!up.ok)throw new Error('HTTP '+up.status);const data=await up.json();const a=document.createElement('a');a.href=`${API_BASE_URL}/api/v1/audio/download/${data.file_id}`;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);return data;}catch(err){// fallback: blob download (một số môi trường vẫn cho phép)
|
||
const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url);throw err;}}const triggerMidiExport=async()=>{const bpmNum=parseFloat(bpm)||120;const midiTracks=[];tracks.forEach(track=>{const notes=[];(track.midiItems||[]).forEach(item=>{(item.notes||[]).forEach(note=>{notes.push({pitch:note.pitch||60,start_beat:note.startBeat||note.start_beat||0,duration_beats:note.durationBeats||note.duration_beats||1,velocity:note.velocity!=null?note.velocity:0.8});});});// bỏ track không có nốt MIDI (trước đây chèn "rest note" pitch 0)
|
||
if(notes.length)midiTracks.push({name:track.name||'Track',notes});});if(!midiTracks.length){showToast("Không có dữ liệu MIDI nào để xuất.","warning");return;}try{const r=await serverDownloadMidi({bpm:bpmNum,ppq:480,tracks:midiTracks},(projectName||'Project')+'.mid');if(r&&r.saved_path){showToast(`Đã xuất file MIDI (${midiTracks.length} tracks) → ${r.saved_path}`,"success");}else{showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`,"success");}}catch(err){showToast('Export MIDI thất bại: '+(err&&err.message?err.message:String(err)),'error');}};// ── Insert Track Below Selected ──
|
||
const insertTrackBelow=()=>{const curTracks=activeTracks;const rearrangeNewId=`t${Date.now()}`;const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[curTracks.length%colors.length];const newTrack={id:rearrangeNewId,name:`Track ${rearrangeNewId}`,buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,masteringBypass:false,audioBypass:false,midiBypass:false,fxActive:true,color:selectColor,markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]};updateActiveTracks(prev=>{const idx=prev.findIndex(t=>t.id===selectedTrackId);if(idx===-1)return[...prev,newTrack];const copy=[...prev];copy.splice(idx+1,0,newTrack);return copy;});setSelectedTrackId(rearrangeNewId);showToast(`Đã thêm Track ${rearrangeNewId}.`,'info');setTimeout(()=>lucide.createIcons(),200);};// ── Insert Section at Playhead ──
|
||
const insertSectionAtPlayhead=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const section={id:`sec_${Date.now()}`,name:'Section',start:currentTime,duration:4*secondsPerBar,length_bars:4,color:track.color||'#06b6d4'};createSectionWithUndo(selectedTrackId,section);updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,sections:[...(t.sections||[]),section]}:t));showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`,'success');};// ── Insert MIDI Item at Playhead ──
|
||
const insertMidiItemAtPlayhead=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const midiItem={id:`midi_${Date.now()}`,name:'MIDI Item',startTime:currentTime,duration:4*secondsPerBar,length_bars:4,notes:[],color:'#a78bfa'};createMidiWithUndo(selectedTrackId,midiItem);updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,midiItems:[...(t.midiItems||[]),midiItem]}:t));showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`,'success');};// ── Insert Sound Clip at Cursor ──
|
||
const insertSoundClipAtCursor=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const input=document.createElement('input');input.type='file';input.accept='audio/*';input.multiple=true;input.onchange=async e=>{const files=Array.from(e.target.files||[]);if(!files.length)return;let offset=currentTime;let count=0;for(const file of files){try{const uploadResult=await uploadToServer(file,track.id);const fileId=uploadResult?.file_id||null;const{audioBuffer:decodedBuffer,channelInfo}=await window.SonicAudio.decodeAudioFile(file);const clipId=`clip_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;const newClip={id:clipId,buffer:decodedBuffer,startTime:offset,name:file.name,speed:1.0,serverFileId:fileId};createClipWithUndo(selectedTrackId,newClip);updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,serverFileId:t.serverFileId||fileId,clips:[...(t.clips||[]),newClip]}:t));offset+=decodedBuffer.duration;count++;}catch(err){showToast(`Lỗi nạp ${file.name}`,'error');}}if(count>0)showToast(`Đã chèn ${count} file âm thanh`,'success');};input.click();};// ── Chords Panel insert (Phase 6) ───────────────────────────────────────
|
||
const insertChordProgression=prog=>{const curTracks=activeTracks;const pr=prog||{};const chordList=pr.chords||[];if(!chordList.length){showToast('Không có chord để chèn','warning');return;}const beatSec=60.0/(parseInt(bpm)||120);const secondsPerBar=beatSec*4;const activeSub=activeTab&&subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');let trackId=null;let item=null;let insertBeat=0;if(activeSub){trackId=activeSub.trackId;const trk=curTracks.find(t=>t.id===trackId);if(trk)item=(trk.midiItems||[]).find(m=>m.id===activeSub.target_id)||null;insertBeat=Math.max(0,(activeSub.currentTime||0)/beatSec);}else{trackId=selectedTrackId;const trk=curTracks.find(t=>t.id===trackId);if(!trk){showToast('Chọn track trước','warning');return;}const items=trk.midiItems||[];if(!items.length){const newItem={id:'midi_'+Date.now(),name:'Chords',startTime:currentTime,duration:secondsPerBar*4,length_bars:4,notes:[],color:'#a78bfa'};createMidiWithUndo(trackId,newItem);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,midiItems:[...(t.midiItems||[]),newItem]}:t));item=newItem;insertBeat=0;}else{item=items[items.length-1];insertBeat=Math.max(0,(currentTime-(item.startTime||0))/beatSec);}}if(!item){showToast('Không tìm thấy MIDI item','warning');return;}const merged=(item.notes||[]).slice();let beat=insertBeat;chordList.forEach(function(ch,ci){const dur=ch.duration_beats!=null?ch.duration_beats:4;(ch.pitches||[]).forEach(function(p){merged.push({id:'note_chord_'+Date.now()+'_'+ci+'_'+p,pitch:p,start_beat:beat,duration_beats:dur,velocity:0.8,pan:0.0});});beat+=dur;});const finalItem={...item,notes:merged};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const has=(t.midiItems||[]).some(m=>m.id===item.id);if(!has)return{...t,midiItems:[...(t.midiItems||[]),finalItem]};return{...t,midiItems:(t.midiItems||[]).map(m=>m.id===item.id?finalItem:m)};}));if(activeSub){setSubTabs(prev=>prev.map(s=>s.id===activeSub.id?{...s,notes:merged,isDirty:true}:s));}setCanvasRedrawCount(n=>n+1);showToast('Đã chèn '+chordList.length+' chord ('+merged.length+' notes)','success');window.__lastChordInsert={trackId:trackId,itemId:item.id,notes:merged};};// ── Server-side Export ──
|
||
// Render MIDI offline qua engine khi chưa play (midiCacheRef rỗng → export
|
||
// offline câm MIDI/VSTi). Render từng track qua native_bridge --render (VSTi/
|
||
// SF2 trong bộ nhớ, preset đúng bridge channel), decode WAV → đổ vào
|
||
// midiCacheRef cùng cấu trúc capture preview (chunks interleaved stereo) →
|
||
// clientSideExport có âm không cần play trước. Schema per-track giữ NGUYÊN
|
||
// mọi melodic track (channel round-robin phụ thuộc thứ tự melodic), chỉ clear
|
||
// items track khác; items track target giữ MIDI_ITEM (audio clips client đã
|
||
// schedule riêng qua t.buffer — không nhân đôi âm).
|
||
const ensureMidiCacheViaEngine=async midiTracks=>{if(serverStatus!=='connected'||!window.SonicAPI||!window.SonicAPI.renderProject)return false;const baseSchema=serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm',bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});let okCount=0;for(const t of midiTracks){const schema=JSON.parse(JSON.stringify(baseSchema));(schema.main_session.tracks||[]).forEach(st=>{if(st.id===t.id){st.items=(st.items||[]).filter(it=>it.type==='MIDI_ITEM');}else{st.items=[];}});// Section store: bỏ hết items — âm section không được lẫn vào cache track MIDI.
|
||
Object.keys(schema.section_store||{}).forEach(sk=>{(schema.section_store[sk].tracks||[]).forEach(st=>{st.items=[];});});try{const rate=parseInt(exportSettings.sampleRate)||44100;const res=await window.SonicAPI.renderProject(schema,'midicache_'+t.id+'_'+Date.now()+'.wav',{bit_depth:16,sample_rate:rate,dither:false,normalize:false});const br=await fetch(res.url);if(!br.ok)throw new Error('HTTP '+br.status);const ab=await br.arrayBuffer();const dctx=new OfflineAudioContext(2,1,rate);const buf=await dctx.decodeAudioData(ab);if(!buf||buf.length<100)continue;const L=buf.getChannelData(0);const R=buf.numberOfChannels>1?buf.getChannelData(1):L;const chunks=[];const CH=4096;for(let i=0;i<L.length;i+=CH){const n=Math.min(CH,L.length-i);const ch=new Float32Array(n*2);for(let j=0;j<n;j++){ch[j*2]=L[i+j];ch[j*2+1]=R[i+j];}chunks.push(ch);}midiCacheRef.current[t.id]={chunks,duration:buf.duration,sampleRate:buf.sampleRate};okCount++;}catch(e){console.warn('[MidiCache] engine render track',t.id,'fail:',e);}}return okCount>0;};const triggerWavExport=async()=>{// Stop transport ngay khi bắt đầu export offline → tắt âm thanh đang phát.
|
||
try{if(typeof stopAllPlayback==='function')stopAllPlayback();}catch(e){}try{if(window.SonicSF&&window.SonicSF.stopAllNotes)window.SonicSF.stopAllNotes();}catch(e){}// Realtime UNCHECKED → export OFFLINE thuần (server-side nếu connected, fallback client).
|
||
// MIDI không render được trong OfflineAudioContext: nếu project có MIDI items mà
|
||
// cache preview chưa đủ → file xuất có thể thiếu âm MIDI. Cảnh báo, vẫn xuất offline.
|
||
const allT=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const hasMidiItems=allT.some(t=>t.midiItems&&t.midiItems.length>0)||(sessionTabs||[]).some(s=>(s.tracks||[]).some(t=>t.midiItems&&t.midiItems.length>0));if(hasMidiItems){const midiTracks=allT.filter(t=>t.midiItems&&t.midiItems.length>0&&!t.muted);let needDur=1.0;midiTracks.forEach(t=>{(t.midiItems||[]).forEach(m=>{if(m.endTime)needDur=Math.max(needDur,m.endTime);else if(m.startTime)needDur=Math.max(needDur,m.startTime+(m.duration||8));});});const cacheHasEnergy=c=>{if(!c)return false;const chunks=c.chunks||[];for(let i=0;i<chunks.length;i++){const ch=chunks[i];for(let j=0;j<ch.length;j++){if(Math.abs(ch[j])>0.0005)return true;}}return false;};const cacheOK=midiTracks.length>0&&midiTracks.every(t=>{const c=midiCacheRef.current[t.id];return c&&c.chunks.length>0&&c.duration>=needDur-0.3&&cacheHasEnergy(c);});// sfEntry → file offline đủ duration nhưng KHÔNG CÓ ÂM THANH. Khi cache
|
||
// không đủ (chưa play preview) → render MIDI qua engine ngay (VSTi/SF2
|
||
// trong bộ nhớ, preset đúng bridge channel) đổ vào midiCacheRef — export
|
||
// offline có âm KHÔNG cần phát trước. Chỉ khi engine không render được
|
||
// mới cảnh báo (file có thể thiếu âm MIDI) rồi vẫn xuất.
|
||
if(midiTracks.length>0&&!cacheOK&&exportSettings.source!=='active_clip'&&exportSettings.source!=='clip_selection'){const filled=await ensureMidiCacheViaEngine(midiTracks);if(filled){showToast("Đã render MIDI/VSTi offline qua engine — export không cần phát trước.","success");}else{showToast("Project có MIDI items chưa đủ cache preview — file offline có thể thiếu âm MIDI. Bật Realtime để bounce trung thực.","warning");}}}let exportTracks;let clipStart=0;let clipEnd=0;const src=exportSettings.source;if(src==='active_clip'||src==='clip_selection'){const selTrack=selectedTrackId?tracks.find(t=>t.id===selectedTrackId):null;if(!selTrack||!selTrack.buffer){showToast("Không có clip nào được chọn.","warning");return;}let rangeStart=selectionStart;let rangeEnd=selectionEnd;if(rangeStart===null||rangeEnd===null||rangeEnd<=rangeStart){rangeStart=0;rangeEnd=selTrack.buffer.duration;}const ctx=getAudioContext();const numCh=selTrack.buffer.numberOfChannels||1;const sr=selTrack.buffer.sampleRate;const startSample=Math.max(0,Math.floor(rangeStart*sr));const endSample=Math.min(selTrack.buffer.length,Math.floor(rangeEnd*sr));const len=endSample-startSample;if(len<=100){showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.","warning");return;}const clipBuf=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch<numCh;ch++){clipBuf.copyToChannel(selTrack.buffer.getChannelData(ch).subarray(startSample,endSample),ch);}exportTracks=[{...selTrack,buffer:clipBuf,startTime:0,clips:[{id:'export_clip',buffer:clipBuf,startTime:0,name:selTrack.name}]}];}else if(src==='track_mix'){const sel=tracks.filter(t=>!t.muted&&(t.buffer||t.midiItems&&t.midiItems.length>0));const selTrk=selectedTrackId?sel.filter(t=>t.id===selectedTrackId):sel;if(selTrk.length===0){showToast("Track được chọn không có dữ liệu.","warning");return;}exportTracks=selTrk;}else{exportTracks=tracks.filter(t=>!t.muted&&(t.buffer||t.midiItems&&t.midiItems.length>0));}if(exportTracks.length===0){showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.","warning");return;}// LOOP export (BIG_PHASE_3 Phase 2): cắt chính xác vùng loop — bắt đầu từ
|
||
// selection (nếu có) hoặc playhead, độ dài = loopBars * secondsPerBar.
|
||
// Không fade in/out, không kéo đuôi (cắt cứng tại biên).
|
||
let loopStart=0,loopDur=0;if(exportSettings.loop){const loopSecPerBar=60.0/(parseFloat(bpm)||120)*4;loopStart=selectionStart!==null&&selectionEnd!==null&&selectionEnd>selectionStart?selectionStart:currentTime;loopDur=Math.max(0.1,(exportSettings.loopBars||4)*loopSecPerBar);const lctx=getAudioContext();exportTracks=exportTracks.map(t=>{if(!t.buffer)return null;const ls=Math.max(0,Math.floor(loopStart*lctx.sampleRate));const le=Math.min(t.buffer.length,Math.ceil((loopStart+loopDur)*lctx.sampleRate));if(le<=ls)return null;const buf=lctx.createBuffer(t.buffer.numberOfChannels,le-ls,t.buffer.sampleRate);for(let ch=0;ch<t.buffer.numberOfChannels;ch++){buf.copyToChannel(t.buffer.getChannelData(ch).subarray(ls,le),ch);}return{...t,buffer:buf,startTime:0,clips:[{id:'export_loop_clip',buffer:buf,startTime:0,name:t.name,speed:1}]};}).filter(Boolean);if(exportTracks.length===0){showToast("Vùng loop không có dữ liệu âm thanh.","warning");return;}}// Check if all active tracks have server file IDs
|
||
const allOnServer=exportTracks.every(t=>serverFileIdMap[t.id]);// Loop đã cắt buffer phía client → không đi server path (server chỉ có file gốc)
|
||
if(!exportSettings.loop&&allOnServer&&serverStatus==='connected'){// Use server-side export
|
||
setIsExporting(true);setShowExportPanel(false);setShowExportProgress(true);setExportProgress(-1);showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...","info");try{const sessionId=`session_${Date.now()}`;const tracksMeta=exportTracks.map(t=>({track_id:t.id,file_id:serverFileIdMap[t.id],volume_db:t.volumeDb,muted:false,clips:[{clip_id:`clip_${t.id}`,start_time_seconds:t.startTime||0,end_time_seconds:(t.startTime||0)+t.buffer.duration,loop_count:1,apply_zero_crossing:true,fade_in_ms:0,fade_out_ms:0}]}));const resp=await fetch(`${API_MULTITRACK}/mix`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:sessionId,export_settings:{sample_rate:parseInt(exportSettings.sampleRate),bit_depth:parseInt(exportSettings.bitDepth),format:exportSettings.format,channels:exportSettings.channels},tracks:tracksMeta})});if(!resp.ok)throw new Error(`Server export failed: ${resp.status}`);const data=await resp.json();showToast("Đang xử lý trên máy chủ...","info");// Poll for result
|
||
const result=await pollTaskResult(data.task_id,20);if(result.success){// Get the uploaded file from server
|
||
const fileId=result.output_file_id||result.output_path?.split('/').pop();if(fileId){const downloadUrl=`${API_AUDIO}/download/${fileId}`;showToast("Xuất bản âm thanh từ máy chủ hoàn tất!","success","Tải về",async()=>{try{const br=await fetch(downloadUrl);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog((projectName||'Project')+'.wav',b);if(sp)showToast(`Đã lưu → ${sp}`,"success");}catch(e){const a=document.createElement('a');a.href=downloadUrl;a.download=fileId;a.click();}});// Export server hoàn tất → tự đóng modal (toast Tải về vẫn hiện).
|
||
setShowExportPanel(false);}}else{throw new Error(result.error||'Server processing failed');}}catch(err){showToast("Lỗi xuất máy chủ: "+err.message+". Chuyển sang xuất client.","warning");// Fall back to client-side export
|
||
clientSideExport(exportTracks);}finally{setIsExporting(false);setShowExportProgress(false);}}else{// Client-side export (existing working code)
|
||
clientSideExport(exportTracks,{loopStart,loopDur});}};// ── D4: Mastering render qua engine (D3 export stage) ──
|
||
// /api/v1/plugins/render — project schema + mastering chain qua native_bridge
|
||
// (VST3 masterbus), resample/normalize/dither PCM, mono side/mid check.
|
||
const renderMasteringExport=async()=>{if(isExporting)return;if(serverStatus!=='connected'){showToast('Cần kết nối server để render mastering.','warning');return;}setIsExporting(true);try{const schema=serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const isFloat=exportSettings.bitDepth==='float';const outExt=exportSettings.format==='ogg'?'ogg':'wav';const res=await window.SonicAPI.renderProject(schema,'master_'+Date.now()+'.'+outExt,{bit_depth:isFloat?32:parseInt(exportSettings.bitDepth)||16,sample_rate:parseInt(exportSettings.sampleRate)||44100,dither:!!exportSettings.dither,normalize:!!exportSettings.normalize,normalize_target:exportSettings.normalizeTarget||'-1dbtp',mono_check:!!exportSettings.monoCheck,metadata:{title:exportSettings.metaTitle||'',artist:exportSettings.metaArtist||'',isrc:exportSettings.metaIsrc||''}});_lastMasterStats={lufs:res.lufs,true_peak_db:res.true_peak_db};const lufsTxt=res.lufs!==null&&res.lufs!==undefined?res.lufs+' LUFS':'-- LUFS';const tpTxt=res.true_peak_db!==null&&res.true_peak_db!==undefined?res.true_peak_db+' dBTP':'-- dBTP';// Tự tải file về exportDir đã chọn (không chờ click "Tải về") — giống
|
||
// bounce cũ: render xong là file xuất hiện ngay.
|
||
try{const br=await fetch(res.url);if(br.ok){const b=await br.blob();const sp=await saveExportViaDialog((projectName||'Project')+'_master.'+outExt,b);if(sp)showToast(`Đã lưu → ${sp} • ${lufsTxt} / ${tpTxt}`,'success');else showToast(`Mastering xong — ${lufsTxt} / ${tpTxt}`,'success');}else{showToast(`Mastering xong — ${lufsTxt} / ${tpTxt}`,'success','Tải về',async()=>{const a=document.createElement('a');a.href=res.url;a.download='master.'+outExt;a.click();});}}catch(e){showToast(`Mastering xong — ${lufsTxt} / ${tpTxt}`,'success','Tải về',async()=>{const a=document.createElement('a');a.href=res.url;a.download='master.'+outExt;a.click();});}}catch(e){showToast('Render mastering lỗi: '+(e.message||e),'error');}finally{setIsExporting(false);}};// ── Realtime Bounce Export (đầy đủ MIDI + FX Rack + Mastering) ──
|
||
// MIDI/soundfont chạy qua FluidSynth (ScriptProcessor) — không render được
|
||
// trong OfflineAudioContext. Vì vậy bounce REALTIME: chạy lại toàn bộ project
|
||
// (audio + MIDI + sections qua FX racks + mastering chain, đúng như nghe) và
|
||
// capture PCM từ master bus, sau đó encode WAV trung thực.
|
||
const triggerBounceExport=async()=>{if(isExporting)return;isExportingRef.current=true;// guardPlay: bounce = đang phát thật
|
||
setIsExporting(true);setShowExportPanel(false);setShowExportProgress(true);const audioCtx=getAudioContext();try{const allTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;// MAIN SESSION end time (items trên track main — sections tính theo bounds,
|
||
// không kéo dài theo nội dung SECTION-TAB) — bounce không cắt sớm/cắt thiếu.
|
||
// LOOP export: độ dài = loopBars*secondsPerBar, bắt đầu từ selection/playhead,
|
||
// cắt cứng biên — không fade, không kéo tail.
|
||
let loopStart=0,loopDur=0;if(exportSettings.loop){const loopSecPerBar=60.0/(parseFloat(bpm)||120)*4;loopStart=selectionStart!==null&&selectionEnd!==null&&selectionEnd>selectionStart?selectionStart:currentTime;loopDur=Math.max(0.1,(exportSettings.loopBars||4)*loopSecPerBar);}let durationLimit=loopDur||Math.max(1.0,computeMainSessionEndTime(allTracks))+1.2;// FX/mastering tail (chỉ khi không loop)
|
||
// ── OFFLINE render (ưu tiên): engine render full project qua native
|
||
// bridge --render (MIDI sample-accurate, audio items, track FX, mastering
|
||
// chain) — không gap, nhanh hơn realtime, đúng timeline hiển thị.
|
||
// Chỉ khi server connected; loop vẫn dùng realtime (cần cắt selection).
|
||
if(!loopDur&&serverStatus==='connected'&&window.SonicAPI&&window.SonicAPI.renderProject){try{const schema=serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const isFloat=exportSettings.bitDepth==='float';const outExt=exportSettings.format==='ogg'?'ogg':'wav';const res=await window.SonicAPI.renderProject(schema,'bounce_'+Date.now()+'.'+outExt,{bit_depth:isFloat?32:parseInt(exportSettings.bitDepth)||16,sample_rate:parseInt(exportSettings.sampleRate)||44100,dither:!!exportSettings.dither,normalize:!!exportSettings.normalize,normalize_target:exportSettings.normalizeTarget||'-6dbtp',mono_check:!!exportSettings.monoCheck,metadata:{title:exportSettings.metaTitle||'',artist:exportSettings.metaArtist||'',isrc:exportSettings.metaIsrc||''}});const lufsTxt=res.lufs!==null&&res.lufs!==undefined?res.lufs+' LUFS':'-- LUFS';const tpTxt=res.true_peak_db!==null&&res.true_peak_db!==undefined?res.true_peak_db+' dBTP':'-- dBTP';const br=await fetch(res.url);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog(`sonicforge-bounce-${parseInt(exportSettings.sampleRate)||44100}hz-${isFloat?32:parseInt(exportSettings.bitDepth)||16}bit.${outExt}`,b);if(sp)showToast(`Bounce hoàn tất → ${sp} • ${lufsTxt} / ${tpTxt}`,'success');else showToast(`Bounce hoàn tất — ${lufsTxt} / ${tpTxt}`,'success');return;}catch(err){console.warn('[Bounce] offline render fail — fallback realtime:',err);}}// ── Realtime fallback (server offline / render fail): chạy lại project
|
||
// qua WebAudio, capture master bus post-FX + post-mastering. ──
|
||
if(!masterBus){showToast("Hãy phát thử một lần để khởi tạo audio engine trước.","warning");return;}const captureRate=audioCtx.sampleRate||44100;const isFloatBit=exportSettings.bitDepth==='float';const bitDepth=isFloatBit?32:parseInt(exportSettings.bitDepth)||16;const outChannels=exportSettings.channels==='mono'?1:2;const fmtTag=isFloatBit?3:1;// IEEE float vs PCM
|
||
showToast(`Bounce realtime ~${Math.round(durationLimit)}s — giữ nguyên âm thanh đang phát...`,"info");// Capture tap on the master bus output (post-FX + post-mastering)
|
||
const chunks=[];const script=audioCtx.createScriptProcessor(4096,2,2);script.onaudioprocess=e=>{const L=e.inputBuffer.getChannelData(0);const R=e.inputBuffer.getChannelData(1);const n=L.length;const chunk=new Float32Array(n*2);for(let i=0;i<n;i++){chunk[i*2]=L[i];chunk[i*2+1]=R[i];}chunks.push(chunk);};masterBus.output.connect(script);script.connect(audioCtx.destination);// Rewind + play the FULL project from the start (FluidSynth + FX racks +
|
||
// mastering chain — exactly what you hear).
|
||
fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback();try{if(window.SonicSF&&window.SonicSF.stopAllNotes)window.SonicSF.stopAllNotes();}catch(e){}startTrackPlayback(0);// Realtime wait, then stop & detach
|
||
await new Promise(res=>setTimeout(res,Math.ceil(durationLimit*1000)+900));stopAllPlayback();try{if(window.SonicSF&&window.SonicSF.stopAllNotes)window.SonicSF.stopAllNotes();}catch(e){}try{script.disconnect();}catch(e){}try{masterBus.output.disconnect(script);}catch(e){}// Encode interleaved PCM chunks → WAV
|
||
const totalFrames=chunks.reduce((a,c)=>a+c.length/2,0);let stereoBuf=new Float32Array(totalFrames*2);let off=0;chunks.forEach(c=>{stereoBuf.set(c,off);off+=c.length;});// LOOP export: cắt cứng [loopStart, loopStart+loopDur) — bỏ phần trước + phần dư tail
|
||
if(loopDur>0){const sf=Math.max(0,Math.floor(loopStart*captureRate))*2;const ef=Math.min(stereoBuf.length,Math.floor((loopStart+loopDur)*captureRate)*2);if(ef>sf)stereoBuf=stereoBuf.subarray(sf,ef);}const frames=stereoBuf.length/2;const bytesPerSample=isFloatBit?4:bitDepth/8;const headerSize=44;const fileSizeBytes=headerSize+frames*bytesPerSample*outChannels;const view=new DataView(new ArrayBuffer(fileSizeBytes));const writeString=(o,s)=>{for(let i=0;i<s.length;i++)view.setUint8(o+i,s.charCodeAt(i));};writeString(0,'RIFF');view.setUint32(4,fileSizeBytes-8,true);writeString(8,'WAVE');writeString(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,fmtTag,true);view.setUint16(22,outChannels,true);view.setUint32(24,captureRate,true);view.setUint32(28,captureRate*bytesPerSample*outChannels,true);view.setUint16(32,bytesPerSample,true);view.setUint16(34,bitDepth,true);writeString(36,'data');view.setUint32(40,frames*bytesPerSample*outChannels,true);let o=44;const lsb=bitDepth===24?1/0x800000:bitDepth===8?1/128:1/0x8000;// 1 LSB in float
|
||
for(let i=0;i<frames;i++){for(let ch=0;ch<outChannels;ch++){const raw=stereoBuf[i*2+ch];if(isFloatBit){view.setFloat32(o,raw,true);// bit-exact, no clamp/quantize
|
||
}else{// Single-stage TPDF dither, then clamp, then quantize (final export only)
|
||
const s=Math.max(-1,Math.min(1,tpdfDither(raw,lsb)));if(bitDepth===24){const v=Math.floor(s<0?s*0x800000:s*0x7FFFFF)&0xFFFFFF;view.setUint8(o,v&0xFF);view.setUint8(o+1,v>>8&0xFF);view.setUint8(o+2,v>>16&0xFF);}else if(bitDepth===16)view.setInt16(o,Math.floor(s<0?s*0x8000:s*0x7FFF),true);else view.setUint8(o,Math.floor((s+1)*127.5),true);}o+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});await downloadBlobViaServer(blob,`sonicforge-bounce-${captureRate}hz-${bitDepth}bit.wav`).catch(()=>{});showToast("Bounce hoàn tất — file gồm MIDI + FX Rack + Mastering Chain!","success");}catch(err){showToast("Lỗi bounce: "+err.message,"error");}finally{isExportingRef.current=false;setIsExporting(false);setShowExportProgress(false);}};const handleSaveLocalProject=async name=>{const finalName=name||projectName||'Dự án mới';const runtime=window.SonicRuntime&&window.SonicRuntime.capabilities&&window.SonicRuntime.capabilities.runtime||'';if(runtime==='desktop'&&window.SonicAPI&&window.SonicAPI.saveProjectToDisk){// Desktop: "Lưu Local" = lưu vào thư mục mặc định của máy
|
||
// (Documents/SonicForgeDAW/Projects) — sống sót khi cài lại. Id
|
||
// disk:<file> giúp lần khởi động sau tự mở lại dự án này.
|
||
const schemaObj=serializeProjectToSchema(currentProjectId||'proj_'+Date.now(),finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const res=await window.SonicAPI.saveProjectToDisk(finalName,JSON.stringify(schemaObj));const diskId=res&&res.filename?'disk:'+res.filename:null;if(diskId){setCurrentProjectId(diskId);localStorage.setItem('sonic_project_id',diskId);}setProjectName(finalName);localStorage.setItem('sonic_project_name',finalName);showToast(`Đã lưu dự án "${finalName}" vào thư mục tài liệu.`,"success");return;}const localId=currentProjectId&¤tProjectId.startsWith('local_')?currentProjectId:'local_'+Date.now();const projectSchemaObj=serializeProjectToSchema(localId,finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const dataStr=JSON.stringify(projectSchemaObj);localStorage.setItem('sonic_local_project_data',dataStr);localStorage.setItem('sonic_project_id',localId);localStorage.setItem('sonic_project_name',finalName);setCurrentProjectId(localId);setProjectName(finalName);window.SonicStorage.exportProjectToSFS(projectSchemaObj);showToast(`Đã lưu dự án local "${finalName}" thành công!`,"success");// Đã đăng nhập → đồng bộ lên Cloud (fire-and-forget): project mở được từ
|
||
// máy khác / browser ẩn danh khi đăng nhập cùng tài khoản.
|
||
if(currentUser&&window.SonicAPI&&window.SonicAPI.saveCloudProject){window.SonicAPI.saveCloudProject(finalName,dataStr).then(function(res){if(res&&res.project_id){console.log('[Cloud] local project synced:',res.project_id);}}).catch(function(e){console.warn('Cloud sync local project failed:',e);});}};const handleSaveCloudProject=async(name,existingProjectId)=>{const finalName=name||projectName||'Dự án mới';var useProjectId=existingProjectId||currentProjectId;const projectSchemaObj=serializeProjectToSchema(useProjectId||'project_'+Date.now(),finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});// Diagnostic: count source vs serialized items
|
||
var srcMidi=0,srcClip=0,srcSec=0;tracks.forEach(function(t){srcMidi+=(t.midiItems||[]).length;srcClip+=(t.clips||[]).length;srcSec+=(t.sections||[]).length;});var serItems=0;if(projectSchemaObj.main_session&&projectSchemaObj.main_session.tracks){projectSchemaObj.main_session.tracks.forEach(function(st){if(st.items)serItems+=st.items.length;});}Object.keys(projectSchemaObj.section_store||{}).forEach(function(sk){(projectSchemaObj.section_store[sk].tracks||[]).forEach(function(st){if(st.items)serItems+=st.items.length;});});const dataJson=JSON.stringify(projectSchemaObj);try{if(existingProjectId){await window.SonicAPI.updateCloudProject(existingProjectId,finalName,dataJson);setProjectName(finalName);setCurrentProjectId(existingProjectId);localStorage.setItem('sonic_project_name',finalName);localStorage.setItem('sonic_project_id',existingProjectId);showToast('Đã ghi đè Cloud "'+finalName+'" (src midi='+srcMidi+' clip='+srcClip+' sec='+srcSec+' | ser='+serItems+').','success');}else if(currentProjectId&&!currentProjectId.startsWith('local_')){await window.SonicAPI.updateCloudProject(currentProjectId,finalName,dataJson);setProjectName(finalName);localStorage.setItem('sonic_project_name',finalName);localStorage.setItem('sonic_project_id',currentProjectId);showToast('Đã lưu Cloud "'+finalName+'" (src midi='+srcMidi+' clip='+srcClip+' sec='+srcSec+' | ser='+serItems+' id='+currentProjectId+').',serItems===0?'warning':'success');}else{const res=await window.SonicAPI.saveCloudProject(finalName,dataJson);const newProjId=res.project_id;setProjectName(finalName);localStorage.setItem('sonic_project_name',finalName);if(newProjId){setCurrentProjectId(newProjId);localStorage.setItem('sonic_project_id',newProjId);}showToast('Đã lưu Cloud mới "'+finalName+'" (src midi='+srcMidi+' clip='+srcClip+' sec='+srcSec+' | ser='+serItems+').',serItems===0?'warning':'success');}}catch(err){showToast(err.message||"Lỗi lưu dự án lên Cloud","error");}};const handleSaveProject=async()=>{if(!currentProjectId){setSaveProjectModalOpen(true);return;}if(currentProjectId&&(currentProjectId.startsWith('local_')||currentProjectId.startsWith('disk:'))){handleSaveLocalProject(projectName);}else{if(!currentUser){showToast('Cần đăng nhập để lưu Cloud. currentUser='+(currentUser?'OK':'NULL')+' token='+(localStorage.getItem('sonic_token')?'exists':'missing'),'warning');return;}handleSaveCloudProject(projectName);}};const handleSaveProjectRef=useRef(handleSaveProject);handleSaveProjectRef.current=handleSaveProject;// ── Ctrl-S (Save) toàn cục ──
|
||
// Desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH (Documents/SonicForgeDAW/
|
||
// Projects). Docker/headless: lưu lên Cloud (đã login) hoặc local (.sfs).
|
||
const handleGlobalSave=async()=>{try{const finalName=projectName||'Dự án mới';const schemaObj=serializeProjectToSchema(currentProjectId||'proj_'+Date.now(),finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const dataJson=JSON.stringify(schemaObj);const runtime=window.SonicRuntime&&window.SonicRuntime.capabilities&&window.SonicRuntime.capabilities.runtime||'';if(runtime==='desktop'&&window.SonicAPI&&window.SonicAPI.saveProjectToDisk){// Desktop → thư mục của hệ điều hành (Documents/SonicForgeDAW/Projects)
|
||
const res=await window.SonicAPI.saveProjectToDisk(finalName,dataJson);// Đăng ký id disk:<file> → lần khởi động sau tự mở lại dự án này
|
||
const diskId=res&&res.filename?'disk:'+res.filename:null;if(diskId){setCurrentProjectId(diskId);setProjectName(finalName);localStorage.setItem('sonic_project_id',diskId);localStorage.setItem('sonic_project_name',finalName);}showToast('Đã lưu dự án: '+(res&&res.path||finalName),'success');}else if(window.SonicAPI){// Docker / headless → Cloud (nếu đã đăng nhập), ngược lại local
|
||
if(currentProjectId&&!currentProjectId.startsWith('local_')&¤tUser){await window.SonicAPI.updateCloudProject(currentProjectId,finalName,dataJson);showToast('Đã lưu dự án lên Cloud','success');}else if(currentUser){await handleSaveAsCloud(finalName);}else{handleSaveLocalProject(finalName);}}}catch(err){showToast('Lỗi lưu dự án: '+(err.message||err),'error');}};const handleGlobalSaveRef=useRef(handleGlobalSave);handleGlobalSaveRef.current=handleGlobalSave;useEffect(()=>{const handler=e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable))return;e.preventDefault();handleGlobalSaveRef.current();}};window.addEventListener('keydown',handler);return()=>window.removeEventListener('keydown',handler);},[]);const handleSaveAsCloud=async newName=>{const projectSchemaObj=serializeProjectToSchema('project_'+Date.now(),newName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const dataJson=JSON.stringify(projectSchemaObj);try{const res=await window.SonicAPI.saveCloudProject(newName,dataJson);const newProjId=res.project_id;setProjectName(newName);localStorage.setItem('sonic_project_name',newName);if(newProjId){setCurrentProjectId(newProjId);localStorage.setItem('sonic_project_id',newProjId);}showToast(`Đã lưu dự án dưới tên mới "${newName}" lên server!`,"success");}catch(err){showToast(err.message||"Lỗi Save As lên server","error");}};const handleSaveCloud=handleSaveProject;const handleExportSFS=(customName=null)=>{const finalName=customName||projectName||'Dự án ScoreFX';const projectSchemaObj=serializeProjectToSchema(currentProjectId||'proj_'+Date.now(),finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});window.SonicStorage.exportProjectToSFS(projectSchemaObj);showToast("Đã xuất dự án (.sfs) thành công!","success");};const handleImportSFS=()=>{const input=document.createElement('input');input.type='file';input.accept='.sfs,application/json';input.onchange=async e=>{if(!e.target.files[0])return;try{const proj=await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]);let restored=[];let restoredBpm=bpm;let restoredSessionTabs=[];let restoredSubTabs=[];if(proj.main_session){const result=deserializeProjectFromSchema(proj);restored=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;if(result.masteringSettings)setMasteringSettings(result.masteringSettings);if(result.masterVolume!==undefined&&result.masterVolume!==null)setMasterVolume(result.masterVolume);if(result.masterVolL!==undefined&&result.masterVolL!==null)setMasterVolL(result.masterVolL);if(result.masterVolR!==undefined&&result.masterVolR!==null)setMasterVolR(result.masterVolR);if(result.masterLink!==undefined&&result.masterLink!==null)setMasterLink(result.masterLink);if(result.masterMuted!==undefined&&result.masterMuted!==null)setMasterMuted(result.masterMuted);if(result.masterAutoVol!==undefined)setMasterAutoVol(result.masterAutoVol);if(result.masterAutoOn!==undefined)setMasterAutoOn(result.masterAutoOn);}else{restored=(proj.tracks||[]).map(t=>({...t,buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null}));}if(restored.length>0){setTracks(restored);loadAudioBuffersForTracks(restored);setBpm(restoredBpm.toString());setProjectName(proj.name||'Dự án mới');setCurrentProjectId(null);if(restoredSessionTabs.length>0)setSessionTabs(restoredSessionTabs);if(restoredSubTabs.length>0)setSubTabs(restoredSubTabs);localStorage.setItem('sonic_project_name',proj.name||'Dự án mới');localStorage.removeItem('sonic_project_id');showToast(`Đã nạp dự án "${proj.name||'Dự án mới'}" từ tệp .sfs thành công!`,"success");}}catch(err){showToast(err.message||"Lỗi mở tệp .sfs","error");}};input.click();};const clientSideExport=async(activeTracks,loopInfo)=>{setIsExporting(true);setShowExportPanel(false);setShowExportProgress(true);setExportProgress(5);showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...","info");// Yield 1 frame → React vẽ thanh "Đang xử lý..." trước khi graph build +
|
||
// OfflineAudioContext render chặn main thread.
|
||
await new Promise(res=>setTimeout(res,30));let lastPaint=0;// throttle setProgress 150ms — tránh re-render toàn App mỗi chunk
|
||
try{const targetRate=parseInt(exportSettings.sampleRate);const isFloatWav=exportSettings.bitDepth==='float';const bitDepth=isFloatWav?32:parseInt(exportSettings.bitDepth);// MAIN SESSION end time (items trên track main — sections theo bounds) —
|
||
// offline render không cắt sớm; MIDI cache (preview) giữ max với cache.
|
||
// LOOP export: độ dài chính xác = loopDur (không kéo đuôi, không fade)
|
||
let durationLimit=loopInfo&&loopInfo.loopDur?loopInfo.loopDur:Math.max(0.1,computeMainSessionEndTime(activeTracks));// Include preview-captured MIDI cache length (soundfont tracks have no clips)
|
||
// LOOP: midi buffer đã cắt đúng loopDur → không cho cache kéo dài file
|
||
const loopActive=!!(loopInfo&&loopInfo.loopDur);Object.keys(midiCacheRef.current).forEach(id=>{const c=midiCacheRef.current[id];if(c&&!loopActive&&c.duration>durationLimit)durationLimit=c.duration;});const outChannels=exportSettings.channels==='mono'?1:2;const scheduleTrackInto=(t,ctx,nodes,directOut)=>{const node=buildOfflineTrackNode(t,ctx,nodes,{directOut});if(!node)return null;const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default',buffer:t.buffer,startTime:t.startTime||0,name:t.name,speed:t.speed||1.0}]:[];clips.forEach(clip=>{if(!clip.buffer)return;const source=ctx.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(node.gainNode);const clipStart=clip.startTime||0;source.start(clipStart);});// MIDI preview cache → schedule through the SF chain (track FX + mastering)
|
||
if(node.sfEntry){const midiBuf=midiCacheToBuffer(t.id);if(midiBuf){let mSrcBuf=midiBuf;if(loopInfo&&loopInfo.loopDur){const ln=Math.min(midiBuf.length,Math.max(1,Math.ceil(loopInfo.loopDur*midiBuf.sampleRate)));mSrcBuf=ctx.createBuffer(midiBuf.numberOfChannels,ln,midiBuf.sampleRate);for(let mc=0;mc<midiBuf.numberOfChannels;mc++)mSrcBuf.copyToChannel(midiBuf.getChannelData(mc).subarray(0,ln),mc);}const mSrc=ctx.createBufferSource();mSrc.buffer=mSrcBuf;mSrc.connect(node.sfEntry);mSrc.start(0);}}return node;};// VST FX THẬT qua fx_vst_bridge (engine /fx-render): encode WAV float32 →
|
||
// upload → bridge render → download → decode. Lỗi → giữ nguyên PCM (export
|
||
// vẫn chạy, toast cảnh báo) — không chặn export.
|
||
const applyVstChainToPcm=async(pcm,rate,chain,label)=>{const active=activeVstChain(chain);if(!active.length)return pcm;try{const blob=encodeFloatWavBlob(pcm,rate,2);const fd=new FormData();fd.append('file',blob,'vst_fx_input.wav');const up=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:fd});if(!up.ok)throw new Error('upload VST input thất bại (HTTP '+up.status+')');const upData=await up.json();const fxChain=active.map(s=>({type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:false}));const r=await window.SonicAPI.fxRender({input_file_id:upData.file_id,fx_chain:fxChain,sample_rate:rate});if(!r||!r.url)throw new Error('fx-render không trả url');const resp=await fetch(`${API_BASE_URL}${r.url}`);if(!resp.ok)throw new Error('download VST output thất bại (HTTP '+resp.status+')');const ab=await resp.arrayBuffer();const dctx=new OfflineAudioContext(2,1,rate);const audioBuf=await dctx.decodeAudioData(ab);try{if(window.SonicAPI.deleteMyFile)window.SonicAPI.deleteMyFile(upData.file_id);}catch(e){}return audioBufferToInterleaved(audioBuf).pcm;}catch(e){console.warn('[VST FX]',label,'không áp dụng:',e);showToast(`VST FX "${label}" không áp dụng được: ${e.message||e} — export không có VST này.`,'warning');return pcm;}};const trackVstChains=activeTracks.filter(t=>activeVstChain(t.vstFxChain||[]).length>0);const masterVstChain=activeVstChain(masteringSettings&&masteringSettings.vstFxChain||[]);const masterVstOn=masterVstChain.length>0&&!(masteringSettings&&masteringSettings.vstBypass);const offlineCtx=new OfflineAudioContext(outChannels,Math.ceil(targetRate*Math.max(0.1,durationLimit)),targetRate);// Build the FULL graph (per-track FX Rack chains + mastering chain — the
|
||
// same DSP as playback), so the exported file matches what you hear.
|
||
const savedMasterBus=masterBus;const offlineNodes={};try{window.currentMasteringSettings=masteringSettings||window.currentMasteringSettings;try{if(typeof stopAllPlayback==='function')stopAllPlayback();}catch(e){}masterBus=null;initMasterBus(offlineCtx);try{toggleMasteringOnMaster(!!(masteringSettings&&masteringSettings.masterConnected),!!(masteringSettings&&masteringSettings.isBypassed));}catch(e){console.warn('offline mastering toggle error:',e);}// Main out volume (master fader) áp vào offline bus — file export phải
|
||
// đúng độ lớn nghe được (flow: ... → Mastering FX Chain → Main out).
|
||
// Trước đây offline masterBus.output luôn = 1.0 → export sai volume.
|
||
try{if(masterBus&&masterBus.output){const mv=typeof masterVolume==='number'&&isFinite(masterVolume)?masterVolume:0;applyMasterVolumesTo(masterBus,masterVolL,masterLink?masterVolL:masterVolR,!!masterMuted,0);}}catch(e){console.warn('offline main-out volume error:',e);}let renderedBuffer;if(trackVstChains.length>0||masterVstOn){// Export có VST FX → render TRACK RIÊNG (không mastering) → áp track
|
||
// VST chain qua fx_vst_bridge → cộng mix → mastering built-in 1 lần
|
||
// trên tổng → master VST chain cuối chain. Đúng thứ tự live: track fx
|
||
// → (sum) → master fx → out.
|
||
let mix=null;let vstTi=0;for(const t of activeTracks){const now=Date.now();if(now-lastPaint>=150){setExportProgress(10+Math.round(vstTi/Math.max(1,activeTracks.length)*40));lastPaint=now;}vstTi++;const chain=activeVstChain(t.vstFxChain||[]);const tctx=new OfflineAudioContext(2,Math.ceil(targetRate*Math.max(0.1,durationLimit)),targetRate);const tNodes={};const node=scheduleTrackInto(t,tctx,tNodes,true);if(!node)continue;const tb=await tctx.startRendering();try{if(node.fxStopFn)node.fxStopFn();}catch(e){}let pcm=audioBufferToInterleaved(tb).pcm;if(chain.length)pcm=await applyVstChainToPcm(pcm,targetRate,chain,t.name||'track');mix=mix?mixInterleaved(mix,pcm):pcm;}if(!mix)mix=new Float32Array(Math.ceil(targetRate*Math.max(0.1,durationLimit))*2);const mctx=new OfflineAudioContext(2,Math.floor(mix.length/2),targetRate);masterBus=null;initMasterBus(mctx);try{toggleMasteringOnMaster(!!(masteringSettings&&masteringSettings.masterConnected),!!(masteringSettings&&masteringSettings.isBypassed));}catch(e){console.warn('offline mastering toggle error (vst):',e);}try{if(masterBus&&masterBus.output){const mv=typeof masterVolume==='number'&&isFinite(masterVolume)?masterVolume:0;applyMasterVolumesTo(masterBus,masterVolL,masterLink?masterVolL:masterVolR,!!masterMuted,0);}}catch(e){console.warn('offline main-out volume error (vst):',e);}const src=mctx.createBufferSource();src.buffer=interleavedToAudioBuffer(mix,targetRate,2,mctx);src.connect(masterBus.input);src.start(0);renderedBuffer=await mctx.startRendering();if(masterVstOn){const conv=audioBufferToInterleaved(renderedBuffer);let outPcm=await applyVstChainToPcm(conv.pcm,targetRate,masterVstChain,'master');if(outChannels===1){const n=Math.floor(outPcm.length/2);const mono=new Float32Array(n);for(let i=0;i<n;i++)mono[i]=(outPcm[i*2]+outPcm[i*2+1])*0.5;outPcm=mono;}renderedBuffer=interleavedToAudioBuffer(outPcm,targetRate,outChannels,mctx);}}else{activeTracks.forEach(t=>{scheduleTrackInto(t,offlineCtx,offlineNodes,false);});renderedBuffer=await offlineCtx.startRendering();}setExportProgress(60);const numExportCh=renderedBuffer.numberOfChannels;const exportLength=renderedBuffer.length;const bytesPerSample=bitDepth/8;const headerSize=44;const fileSizeBytes=headerSize+exportLength*bytesPerSample*numExportCh;const fileBuffer=new ArrayBuffer(fileSizeBytes);const view=new DataView(fileBuffer);const writeString=(offset,string)=>{for(let i=0;i<string.length;i++){view.setUint8(offset+i,string.charCodeAt(i));}};writeString(0,'RIFF');view.setUint32(4,fileSizeBytes-8,true);writeString(8,'WAVE');writeString(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,isFloatWav?3:1,true);view.setUint16(22,numExportCh,true);view.setUint32(24,targetRate,true);view.setUint32(28,targetRate*bytesPerSample*numExportCh,true);view.setUint16(32,bytesPerSample,true);view.setUint16(34,bitDepth,true);writeString(36,'data');view.setUint32(40,exportLength*bytesPerSample*numExportCh,true);let offset=44;let peak=0;// Chunk encode + yield giữa các chunk → React vẽ thanh tiến độ thật sự
|
||
// (vòng lặp đồng bộ dài sẽ chặn main thread, bar không bao giờ hiện).
|
||
const ENC_CHUNK=65536;for(let base=0;base<exportLength;base+=ENC_CHUNK){const encEnd=Math.min(exportLength,base+ENC_CHUNK);for(let i=base;i<encEnd;i++){for(let ch=0;ch<numExportCh;ch++){const chData=renderedBuffer.getChannelData(ch);const sample=Math.max(-1,Math.min(1,chData[i]));if(Math.abs(sample)>peak)peak=Math.abs(sample);if(bitDepth===8){view.setUint8(offset,Math.floor((sample+1.0)*127.5));}else if(bitDepth===16){view.setInt16(offset,Math.floor(sample<0?sample*0x8000:sample*0x7FFF),true);}else if(bitDepth===24){const val24=Math.floor(sample<0?sample*0x800000:sample*0x7FFFFF);view.setUint8(offset,val24&0xFF);view.setUint8(offset+1,val24>>8&0xFF);view.setUint8(offset+2,val24>>16&0xFF);}else if(isFloatWav){view.setFloat32(offset,sample,true);}offset+=bytesPerSample;}}// Throttle setState 150ms → tránh re-render toàn App (1.7MB JSX) mỗi
|
||
// chunk — đó là thứ làm export chậm rõ rệt.
|
||
const now=Date.now();if(now-lastPaint>=150){setExportProgress(60+Math.round(encEnd/Math.max(1,exportLength)*32));lastPaint=now;}await new Promise(res=>setTimeout(res,0));}if(peak<0.001){showToast("Cảnh báo: file export gần như CÂM (không có tín hiệu âm) — kiểm tra nguồn track / MIDI cache preview.","warning");}const blob=new Blob([view],{type:'audio/wav'});const wavName=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;setExportProgress(95);if((exportSettings.format==='mp3'||exportSettings.format==='ogg')&&serverStatus==='connected'){setIsExporting(true);setExportProgress(-1);showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...","info");try{const file=new File([blob],`session_export.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload to transcode server failed");const uploadData=await uploadResp.json();const fileId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fileId,format:exportSettings.format,sample_rate:targetRate,bit_depth:bitDepth})});if(!exportResp.ok)throw new Error("Transcode request failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const outId=result.output_file_id;const downloadUrl=`${API_AUDIO}/download/${outId}`;showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`,"success","Tải về",async()=>{try{const br=await fetch(downloadUrl);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog(`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`,b);if(sp)showToast(`Đã lưu → ${sp}`,"success");}catch(e){const a=document.createElement('a');a.href=downloadUrl;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;a.click();}});}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`,"warning","Tải WAV thay thế",()=>{downloadBlobViaServer(blob,wavName).catch(()=>{});});}}else{if(exportSettings.format!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}const dres=await downloadBlobViaServer(blob,wavName).catch(()=>null);if(dres&&dres.saved_path){showToast(`Xuất bản âm thanh hoàn tất → ${dres.saved_path}`,"success");}else{showToast("Không thể ghi file export — engine không lưu được file. Kiểm tra kết nối engine và thư mục export.","error");}}// Export hoàn tất → tự đóng modal.
|
||
setShowExportPanel(false);}finally{// Restore the LIVE master bus after offline render: initMasterBus(offlineCtx)
|
||
// ghi đè cả `masterBus` lẫn `window.masterBus` bằng graph OfflineAudioContext
|
||
// (state closed) → track route (window.masterBus.input) đổ vào context câm →
|
||
// play/bounce sau export CÂM. Khôi phục bus live (giữ wiring cũ nếu còn sống,
|
||
// ngược lại rebuild graph trên audioCtx thật) và luôn trả lại window.masterBus.
|
||
try{const liveCtx=audioCtx;let restored=savedMasterBus;if(!(restored&&liveCtx&&restored.output&&restored.output.context===liveCtx&&liveCtx.state!=='closed')){masterBus=null;initMasterBus(liveCtx||getAudioContext());restored=masterBus;}masterBus=restored;window.masterBus=restored;}catch(e){masterBus=savedMasterBus;window.masterBus=savedMasterBus;}try{toggleMasteringOnMaster(!!(masteringSettings&&masteringSettings.masterConnected),!!(masteringSettings&&masteringSettings.isBypassed));}catch(e){}Object.keys(offlineNodes).forEach(k=>{try{if(offlineNodes[k].fxStopFn)offlineNodes[k].fxStopFn();}catch(e){}});}}catch(err){showToast("Lỗi xuất âm thanh: "+err.message,"error");}finally{setExportProgress(-1);setIsExporting(false);setShowExportProgress(false);}};// ── AI Analysis (server-side with fallback) ──
|
||
const triggerAIAnalysis=async()=>{setAnalysisState({status:'Connecting to AI Engine...',data:null,isRunning:true});// Check if we have a file on the server to analyze
|
||
const activeTrack=tracks.find(t=>t.id===selectedTrackId);const serverFileId=serverFileIdMap[selectedTrackId];if(serverFileId&&serverStatus==='connected'){try{const resp=await fetch(`${API_AUDIO}/analyze-ai`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:serverFileId,api_base_url:aiConfig.baseUrl!==API_BASE_URL?aiConfig.baseUrl:null,model:aiConfig.model})});if(resp.ok){const data=await resp.json();showToast("Đang phân tích cấu trúc trên máy chủ...","info");try{const result=await pollTaskResult(data.task_id,15);if(result.bpm){if(selLeft!==null&&selRight!==null&&activeTrack&&activeTrack.buffer){const snapStart=findZeroCrossing(activeTrack.buffer,selLeft);const snapEnd=findZeroCrossing(activeTrack.buffer,selRight);if(selectionMode==='local'){setLocalSelectionStart(snapStart);setLocalSelectionEnd(snapEnd);}else{setSelectionStart(snapStart);setSelectionEnd(snapEnd);}}setAnalysisState({status:'Hoàn thành phân tích (Server)',data:result,isRunning:false});showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length||0} beats detected (Zero-Crossing Aligned)`,'success');return;}}catch(e){console.warn('Server AI poll failed, using fallback:',e);}}}catch(err){console.warn('Server AI analysis failed, using fallback:',err);}}// Fallback: client-side analysis with simulated BPM
|
||
setAnalysisState({status:'Processing structural detection...',data:null,isRunning:true});setTimeout(()=>{let bpm=120;// Try to detect BPM from buffer
|
||
if(activeTrack&&activeTrack.buffer){const data=activeTrack.buffer.getChannelData(0);const sr=activeTrack.buffer.sampleRate;// Simple autocorrelation for BPM estimation
|
||
const windowSize=Math.min(sr*3,data.length);if(windowSize>sr){let maxCorr=0;let bestLag=Math.floor(sr*0.25);// ~240 BPM max
|
||
for(let lag=Math.floor(sr*0.25);lag<Math.floor(sr*2);lag++){let corr=0;const n=Math.floor(windowSize/2);for(let i=0;i<n;i++){corr+=data[i]*data[i+lag];}corr/=n;if(corr>maxCorr){maxCorr=corr;bestLag=lag;}}if(bestLag>0){bpm=Math.round(60*sr/bestLag);bpm=Math.max(60,Math.min(200,bpm));}}}if(selLeft!==null&&selRight!==null&&activeTrack&&activeTrack.buffer){const snapStart=findZeroCrossing(activeTrack.buffer,selLeft);const snapEnd=findZeroCrossing(activeTrack.buffer,selRight);if(selectionMode==='local'){setLocalSelectionStart(snapStart);setLocalSelectionEnd(snapEnd);}else{setSelectionStart(snapStart);setSelectionEnd(snapEnd);}}setAnalysisState({status:'Hoàn thành phân tích (Client)',data:{bpm,bars:Math.max(4,Math.round(bpm/30)),timeSig:'4/4',detectedKey:'Am'},isRunning:false});showToast(`Phân tích: ${bpm} BPM (Zero-Crossing Aligned)`,'success');},1500);};// ── AI Analysic Loop: scan track, detect beats, place markers for loop selection ──
|
||
const handleAIAnalysicLoop=async()=>{const forcedTrackId=subTabAiTrackIdRef.current;subTabAiTrackIdRef.current=null;const activeTrackId=forcedTrackId||selectedTrackId;const activeTrack=tracks.find(t=>t.id===activeTrackId);if(!activeTrack||!activeTrack.buffer){showToast("Vui lòng chọn một Track có âm thanh để AI phân tích.","warning");return;}setAnalysisState({status:'AI Analysic Loop: đang phát hiện nhịp...',data:null,isRunning:true});showToast("AI Analysic Loop: đang quét cấu trúc nhịp điệu...","info");try{const buffer=activeTrack.buffer;const data=buffer.getChannelData(0);const sr=buffer.sampleRate;const windowSize=Math.min(sr*3,data.length);// Client-side BPM detection via autocorrelation
|
||
let detectedBPM=120;if(windowSize>sr){let maxCorr=0;for(let lag=Math.floor(sr*0.3);lag<=Math.floor(sr*2.0);lag++){let corr=0;const step=4;for(let i=0;i<windowSize&&i+lag<data.length;i+=step){corr+=data[i]*data[i+lag];}corr/=windowSize/step;if(corr>maxCorr){maxCorr=corr;detectedBPM=60/(lag/sr);}}}detectedBPM=Math.round(Math.min(240,Math.max(60,detectedBPM)));const beatDuration=60/detectedBPM;const barDuration=beatDuration*4;const totalDuration=buffer.duration;// Place markers at each bar (strong beat) position
|
||
const barMarkers=[];for(let t=0;t<totalDuration;t+=barDuration){const zcTime=findZeroCrossing(buffer,t);barMarkers.push({id:'ai_bar_'+barMarkers.length+'_'+Date.now(),time:zcTime,label:`Bar ${barMarkers.length+1}`,color:'#06b6d4'});// Add beat markers within each bar
|
||
for(let b=1;b<4;b++){const bt=t+b*beatDuration;if(bt<totalDuration){const zcBt=findZeroCrossing(buffer,bt);barMarkers.push({id:'ai_beat_'+barMarkers.length+'_'+Date.now(),time:zcBt,label:`Beat ${b+1}`,color:'#a855f7'});}}}setTracks(prev=>prev.map(t=>{if(t.id!==activeTrack.id)return t;const existingMarkers=t.markers||[];return{...t,markers:[...existingMarkers,...barMarkers]};}));// Find the first strong beat to set as selection start
|
||
const firstBeat=barMarkers.length>0?barMarkers[0].time:0;const secondBar=barMarkers.length>4?barMarkers[Math.min(4,barMarkers.length-1)].time:Math.min(totalDuration,firstBeat+barDuration);setSelectionStart(firstBeat);setSelectionEnd(secondBar);setAnalysisState({status:`AI Analysic Loop: ${detectedBPM} BPM, ${barMarkers.length} markers (Bar/Beat)`,data:{bpm:detectedBPM,bars:Math.floor(totalDuration/barDuration)},isRunning:false});showToast(`AI Analysic Loop: ${detectedBPM} BPM - ${Math.floor(totalDuration/barDuration)} bars detected`,"success");}catch(err){setAnalysisState({status:'Lỗi AI Analysic Loop',data:null,isRunning:false});showToast(err.message||'Lỗi khi phân tích nhịp','error');}};// ── Mark Selection ──
|
||
// ── Helper: set selection range from buffer (used by sub-tab AI) ──
|
||
const setSelectionRangeOnBuffer=(buffer,startTime,endTime)=>{setSelectionStart(startTime);setSelectionEnd(endTime);};// Ref for forced trackId (used by sub-tab AI buttons, overrides selectedTrackId)
|
||
const subTabAiTrackIdRef=useRef(null);const handleAIScan=async()=>{// Resolve track: prefer sub-tab override, then selectedTrackId
|
||
const forcedTrackId=subTabAiTrackIdRef.current;subTabAiTrackIdRef.current=null;const activeTrackId=forcedTrackId||selectedTrackId;const activeTrack=tracks.find(t=>t.id===activeTrackId);if(!activeTrack||!activeTrack.buffer){showToast("Vui lòng chọn một Track có âm thanh để AI quét Loop.","warning");return;}setAnalysisState({status:'AI Loop Scan đang quét nhịp điệu và phách mạnh...',data:null,isRunning:true});showToast("AI Scan đang phân tích tempo và phách mạnh...","info");try{const buffer=activeTrack.buffer;const data=buffer.getChannelData(0);const sr=buffer.sampleRate;const windowSize=Math.min(sr*3,data.length);// Detect BPM via autocorrelation
|
||
let detectedBPM=120;if(windowSize>sr){let maxCorr=0;for(let lag=Math.floor(sr*0.3);lag<=Math.floor(sr*2.0);lag++){let corr=0;const step=4;for(let i=0;i<windowSize&&i+lag<data.length;i+=step){corr+=data[i]*data[i+lag];}corr/=windowSize/step;if(corr>maxCorr){maxCorr=corr;detectedBPM=60/(lag/sr);}}}detectedBPM=Math.round(Math.min(240,Math.max(60,detectedBPM)));// Calculate bar grid
|
||
const beatDuration=60/detectedBPM;const barDuration=beatDuration*4;const totalDuration=buffer.duration;// Place markers at bar starts (strong beats / downbeats)
|
||
const barMarkers=[];for(let t=0;t<totalDuration;t+=barDuration){const zcTime=findZeroCrossing(buffer,t);barMarkers.push({id:'ai_bar_'+barMarkers.length+'_'+Date.now(),time:zcTime,label:`Downbeat ${barMarkers.length+1}`,color:'#06b6d4'});}setTracks(prev=>prev.map(t=>{if(t.id!==activeTrack.id)return t;const existingMarkers=t.markers||[];return{...t,markers:[...existingMarkers,...barMarkers]};}));// Set selection to the first downbeat
|
||
const firstBarStart=barMarkers.length>0?barMarkers[0].time:0;const secondBarStart=barMarkers.length>1?barMarkers[1].time:Math.min(totalDuration,firstBarStart+barDuration);setSelectionStart(firstBarStart);setSelectionEnd(secondBarStart);setAnalysisState({status:`AI Scan: ${detectedBPM} BPM, ${barMarkers.length} downbeats (đã snap zero-crossing)`,data:{bpm:detectedBPM},isRunning:false});showToast(`AI Scan: ${detectedBPM} BPM - ${barMarkers.length} downbeats detected`,"success");}catch(err){setAnalysisState({status:'Lỗi khi AI Scan',data:null,isRunning:false});showToast(err.message||'Lỗi khi quét AI Loop','error');}};const runPythonTool=async toolType=>{const activeTrack=tracks.find(t=>t.id===selectedTrackId);if(!activeTrack||!activeTrack.buffer){showToast("Vui lòng chọn một Track để xử lý công cụ Python.","warning");return;}try{if(toolType==='normalize'){const channelData=activeTrack.buffer.getChannelData(0);let maxVal=0;for(let i=0;i<channelData.length;i++){maxVal=Math.max(maxVal,Math.abs(channelData[i]));}if(maxVal>0){const gain=1.0/maxVal;for(let i=0;i<channelData.length;i++){channelData[i]*=gain;}}showToast("Đã Chuẩn Hóa Peak âm thanh về 0 dB!","success");}else if(toolType==='invert_phase'){const channelData=activeTrack.buffer.getChannelData(0);for(let i=0;i<channelData.length;i++){channelData[i]*=-1;}showToast("Đã Đảo Pha (180°) âm thanh thành công!","success");}else if(toolType==='swap_channels'){if(activeTrack.buffer.numberOfChannels>=2){const left=activeTrack.buffer.getChannelData(0);const right=activeTrack.buffer.getChannelData(1);for(let i=0;i<left.length;i++){const temp=left[i];left[i]=right[i];right[i]=temp;}showToast("Đã Đổi Kênh Left / Right thành công!","success");}else{showToast("Track hiện tại là Mono. Chỉ áp dụng Đổi Kênh cho Stereo.","info");}}else if(toolType==='synth_wave'){generateSynthToTrack(activeTrack.id,'synth');showToast("Đã tạo Tín Hiệu Sóng Tổng Hợp bằng công cụ Python!","success");}}catch(err){showToast(err.message||"Lỗi khi chạy công cụ Python","error");}};const handleMarkSelection=()=>{if(selLeft===null||selRight===null||selectionStats.length===0){showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.","warning");return;}const targetTrack=tracks.find(t=>t.id===selectedTrackId);if(!targetTrack||!targetTrack.buffer){showToast("Vui lòng nhấp chọn một Track có sóng âm để gán Marker.","warning");return;}// For local selection, force mark on the local-selected track
|
||
const markTrackId=selectionMode==='local'&&localSelectionTrackId?localSelectionTrackId:selectedTrackId;setTracks(prev=>prev.map(t=>{if(t.id!==markTrackId)return t;const snapStart=findZeroCrossing(t.buffer,selLeft);const snapEnd=findZeroCrossing(t.buffer,selRight);const newMarkers=[...t.markers,{id:Date.now()+'_s',time:snapStart},{id:Date.now()+'_e',time:snapEnd}].sort((a,b)=>a.time-b.time);return{...t,markers:newMarkers};}));showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`,"success");};// ── AI Cut to New Track (Music Theory Loop Detection) ──
|
||
const handleAICutToNewTrack=()=>{const forcedTrackId=subTabAiTrackIdRef.current;subTabAiTrackIdRef.current=null;const activeTrackId=forcedTrackId||selectedTrackId;const activeTrack=tracks.find(t=>t.id===activeTrackId);if(!activeTrack||!activeTrack.buffer){showToast("Vui lòng chọn một Track có dữ liệu âm thanh trước.","warning");return;}if(selectionStart===null||selectionEnd===null||selectionStats.length===0){showToast("Vui lòng kéo chọn một khoảng thời gian trên sóng âm.","warning");return;}setAnalysisState({status:'AI Cut: đang phân tích nhịp và tìm loop point...',data:null,isRunning:true});showToast("AI Cut: đang phân tích nhịp điệu và tìm điểm loop chính xác...","info");setTimeout(()=>{try{const buffer=activeTrack.buffer;const sampleRate=buffer.sampleRate;const channelData=buffer.getChannelData(0);const dataLen=channelData.length;const windowSize=Math.min(sampleRate*3,dataLen);// Detect BPM via autocorrelation
|
||
let detectedBPM=120;if(windowSize>sampleRate){let maxCorr=0;for(let lag=Math.floor(sampleRate*0.3);lag<=Math.floor(sampleRate*2.0);lag++){let corr=0;const step=4;for(let i=0;i<windowSize&&i+lag<dataLen;i+=step){corr+=channelData[i]*channelData[i+lag];}corr/=windowSize/step;if(corr>maxCorr){maxCorr=corr;detectedBPM=60/(lag/sampleRate);}}}detectedBPM=Math.round(Math.min(240,Math.max(60,detectedBPM)));const beatDuration=60/detectedBPM;const barDuration=beatDuration*4;// Use the selection range
|
||
const rawStart=selectionStats.start;const rawEnd=selectionStats.end;const selDuration=rawEnd-rawStart;// Find the nearest bar start (downbeat) for loop start
|
||
const barsFromZero=rawStart/barDuration;const nearestBarStart=Math.round(barsFromZero)*barDuration;const loopStart=Math.max(0,Math.min(rawStart+barDuration,nearestBarStart));// Find the nearest beat 4 (bar end) for loop end
|
||
// In 4/4 time: beat 4 = barStart + 3*beatDuration = barEnd
|
||
const barsFromStart=rawEnd/barDuration;const nearestBarEnd=Math.round(barsFromStart)*barDuration;// Ensure minimum 1 bar loop
|
||
let loopEnd=Math.max(loopStart+barDuration,nearestBarEnd);if(loopEnd>rawEnd+beatDuration)loopEnd=loopStart+Math.ceil(selDuration/barDuration)*barDuration;// Snap to zero-crossing for click-free loop
|
||
const snapLoopStart=findZeroCrossing(buffer,loopStart);const snapLoopEnd=findZeroCrossing(buffer,loopEnd);// Place markers for the loop points
|
||
setTracks(prev=>prev.map(t=>{if(t.id!==activeTrack.id)return t;const existingMarkers=t.markers||[];const filtered=existingMarkers.filter(m=>!m.id.startsWith('ai_loop_'));return{...t,markers:[...filtered,{id:'ai_loop_start_'+Date.now(),time:snapLoopStart,label:'Loop Start (Bar '+(Math.floor(snapLoopStart/barDuration)+1)+')',color:'#06b6d4'},{id:'ai_loop_end_'+Date.now(),time:snapLoopEnd,label:'Loop End (Beat 4)',color:'#a855f7'}]};}));setSelectionStart(snapLoopStart);setSelectionEnd(snapLoopEnd);const startSample=Math.max(0,Math.min(dataLen-1,Math.floor(snapLoopStart*sampleRate)));const endSample=Math.max(0,Math.min(dataLen,Math.floor(snapLoopEnd*sampleRate)));const sliceLength=endSample-startSample;if(sliceLength<=0){showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.","error");setAnalysisState({status:'Thất bại',data:null,isRunning:false});return;}const context=getAudioContext();const numChannels=buffer.numberOfChannels||1;const slicedBuffer=context.createBuffer(numChannels,sliceLength,sampleRate);for(let c=0;c<numChannels;c++){const srcData=buffer.getChannelData(c);const dstData=slicedBuffer.getChannelData(c);dstData.set(srcData.subarray(startSample,endSample));}const rearrangeNewId='track_ai_cut_'+Date.now();const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[tracks.length%colors.length];const barNum=Math.floor(snapLoopStart/barDuration)+1;const barsCount=Math.max(1,Math.round((snapLoopEnd-snapLoopStart)/barDuration));const newTrack={id:rearrangeNewId,name:`Loop_${barNum}bar_${activeTrack.name.replace('.wav','').slice(0,10)}_${snapLoopStart.toFixed(1)}s.wav`,buffer:slicedBuffer,channelInfo:activeTrack.channelInfo?{...activeTrack.channelInfo}:null,startTime:snapLoopStart,clips:[{id:'clip_'+rearrangeNewId,buffer:slicedBuffer,startTime:snapLoopStart,name:`Loop_${barNum}bar_${snapLoopStart.toFixed(1)}s`}],volumeDb:0,pan:0,muted:false,solo:false,color:selectColor,markers:[{id:Date.now()+'_s',time:0},{id:Date.now()+'_e',time:snapLoopEnd-snapLoopStart}],serverFileId:null};setTracks(prev=>{const idx=prev.findIndex(t=>t.id===selectedTrackId);const updated=[...prev];if(idx!==-1){updated.splice(idx+1,0,newTrack);}else{updated.push(newTrack);}return updated;});setSelectedTrackId(rearrangeNewId);setAnalysisState({status:`AI Cut: ${detectedBPM} BPM, ${barsCount} bars loop (Zero-Crossing aligned)`,data:{bpm:detectedBPM,bars:barsCount,timeSig:'4/4'},isRunning:false});showToast(`AI Cut: ${barsCount} bars loop at ${snapLoopStart.toFixed(3)}s - ${snapLoopEnd.toFixed(3)}s [${detectedBPM} BPM]`,"success");setTimeout(()=>lucide.createIcons(),200);}catch(err){showToast("Lỗi khi AI Cut: "+err.message,"error");setAnalysisState({status:'Lỗi AI Cut',data:null,isRunning:false});}},800);};// ── AI Prompt Send to Active Provider ──
|
||
const handleAISend=async()=>{const prompt=aiPrompt.trim();if(!prompt){showToast('Vui lòng nhập nội dung prompt.','warning');return;}// Check if piano roll tab is active → route prompt to MIDI generation
|
||
const activePianoRoll=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(activePianoRoll){setAiProcessing(true);setAiActionLog(prev=>[...prev,{type:'status',text:` ⏳ Đang gửi prompt đến AI cho Piano Roll...`,time:Date.now()}]);try{if(aiProviders.length===0||!selectedProviderId){try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers&&data.providers.length>0){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){}}const prv=aiProviders.find(p=>p.id===selectedProviderId)||(aiProviders.length>0?aiProviders[0]:null);const provider=prv||aiConfig;const baseUrl=provider.api_base_url||provider.baseUrl||`${API_BASE_URL}`;const apiKey=provider.api_key||provider.apiKey||'';const model=provider.model_name||provider.model||'deepseek-chat';setAiActionLog(prev=>[...prev,{type:'info',text:` Provider: ${provider.name||'default'} | Model: ${model}`,time:Date.now()}]);let pianoSystemInstruction='You are a MIDI composer. Output ONLY valid JSON array of notes. No markdown, no explanation. Use 4/4 time. pitch=C4=60, D4=62, E4=64, F4=65, G4=67, A4=69, B4=71, C5=72.';const result=await window.AIGateway.executeAIPrompt({prompt:'Generate MIDI notes for a piano roll. Return ONLY a JSON array: [{pitch(0-127), start_beat, duration_beats, velocity(0.0-1.0)}]. '+prompt,provider:provider.name||'default',model:model,apiKey:apiKey,baseUrl:baseUrl.replace(/\/chat\/completions$/,'').replace(/\/$/,''),systemInstruction:pianoSystemInstruction});if(!result)throw new Error('AI không phản hồi');let notesData=null;const textResp=result.textResponse||result.text||'';if(textResp&&typeof textResp==='string'){try{const cleaned=textResp.replace(/```json?\s*/g,'').replace(/```/g,'').trim();notesData=JSON.parse(cleaned);}catch(e1){console.error('Parse notes error:',e1);}}if(!notesData&&result.functionCalls){for(const fc of result.functionCalls){if(fc.arguments&&fc.arguments.notes){notesData=fc.arguments.notes;break;}}}if(Array.isArray(notesData)&¬esData.length>0){const newNotes=notesData.map((n,i)=>({id:'note_ai_'+Date.now()+'_'+i,pitch:Math.max(0,Math.min(127,n.pitch||60)),start_beat:Math.max(0,parseFloat(n.start_beat)||0),duration_beats:Math.max(0.125,parseFloat(n.duration_beats)||0.25),velocity:Math.max(0.1,Math.min(1.0,n.velocity??0.8)),pan:0.0}));setSubTabs(prev=>prev.map(s=>s.id===activePianoRoll.id?{...s,notes:[...(s.notes||[]),...newNotes],isDirty:true}:s));setAiActionLog(prev=>[...prev,{type:'status',text:` ✅ Đã thêm ${newNotes.length} notes vào Piano Roll`,time:Date.now()}]);setCanvasRedrawCount(n=>n+1);}else{setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ AI không trả về notes hợp lệ.`,time:Date.now()}]);}}catch(err){setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ Lỗi: ${err.message}`,time:Date.now()}]);}setAiProcessing(false);return;}// ── MIDI Rearrange Flow: selected MIDI item → use rearrange tool ──
|
||
if(selectedItemIds&&selectedItemIds.size===1){const selId=selectedItemIds.values().next().value;let isMidiItem=false;let sourceTrackId=null;let sourceItemName=null;for(const t of activeTracks||[]){const found=(t.midiItems||[]).find(m=>m.id===selId);if(found){isMidiItem=true;sourceTrackId=t.id;sourceItemName=found.name;break;}}if(isMidiItem&&window.SonicMidiExtractor){try{setAiProcessing(true);setAiActionLog(prev=>[...prev,{type:'status',text:` 🎯 Phát hiện MIDI item được chọn — chuyển sang chế độ Rearrange...`,time:Date.now()}]);if(aiProviders.length===0||!selectedProviderId){try{const d=await window.SonicAPI.getAIConfigs();if(d&&d.providers&&d.providers.length>0){setAiProviders(d.providers);const a=d.providers.find(p=>p.is_active)||d.providers[0];if(a)setSelectedProviderId(a.id);}}catch(e){}}const prv=aiProviders.find(p=>p.id===selectedProviderId)||(aiProviders.length>0?aiProviders[0]:null);const provider=prv||aiConfig;const baseUrl=provider.api_base_url||provider.baseUrl||`${API_BASE_URL}`;const apiKey=provider.api_key||provider.apiKey||'';const model=provider.model_name||provider.model||'deepseek-chat';setAiActionLog(prev=>[...prev,{type:'info',text:` Rearrange Provider: ${provider.name||'default'} | Model: ${model}`,time:Date.now()}]);const srcContext=window.SonicMidiExtractor.extractSelectedMIDIContext(activeTracks||tracks,selId,bpm);setAiActionLog(prev=>[...prev,{type:'info',text:` 📋 Trích xuất ${srcContext.total_notes} notes từ "${srcContext.item_name}" (${srcContext.track_name})`,time:Date.now()}]);if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.rearrangeSourceTrackId=sourceTrackId;window.DAWCommandDispatcher.rearrangeSourceItemName=sourceItemName;}const messages=window.AIGateway.buildRearrangeMessage(prompt,srcContext);const completion=await window.AIGateway.callLLM({provider:provider.name||'default',model,apiKey,baseUrl:baseUrl.replace(/\/chat\/completions$/,'').replace(/\/$/,''),messages,tools:[window.AIGateway.REARRANGE_TOOL_SPEC.function],toolChoice:'auto'});const functionCalls=window.AIGateway.extractFunctionCalls(completion);if(functionCalls&&functionCalls.length>0){for(const fc of functionCalls){setAiActionLog(prev=>[...prev,{type:'info',text:` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`,time:Date.now()}]);const cmdName=fc.name.toUpperCase();if(window.DAWCommandDispatcher){try{let cmdResult=window.DAWCommandDispatcher.execute(cmdName,fc.arguments);if(cmdResult&&typeof cmdResult.then==='function')cmdResult=await cmdResult;setAiActionLog(prev=>[...prev,{type:'status',text:` ✅ ${fc.name}: thành công — ${fc.arguments.rearrange_title||''}`,time:Date.now()}]);}catch(cmdErr){setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ ${fc.name}: ${cmdErr.message}`,time:Date.now()}]);}}}}else{setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ AI không trả về lệnh rearrange hợp lệ.`,time:Date.now()}]);}const textResp=functionCalls.length===0&&completion.choices&&completion.choices[0]&&completion.choices[0].message&&completion.choices[0].message.content;if(textResp){setAiActionLog(prev=>[...prev,{type:'status',text:` AI: ${textResp.slice(0,500)}`,time:Date.now()}]);}if(prompt){promptHistRef.current=[...promptHistRef.current.slice(-49),prompt];setPromptHistory(promptHistRef.current);}setPromptHistIdx(-1);setAiActionLog(prev=>[...prev,{type:'status',text:` Hoàn tất Rearrange.`,time:Date.now()}]);setAiPrompt('');setTimeout(()=>lucide.createIcons(),200);setAiProcessing(false);return;}catch(err){setAiActionLog(prev=>[...prev,{type:'error',text:` Lỗi Rearrange: ${err.message}`,time:Date.now()}]);showToast(`AI Rearrange Error: ${err.message}`,'error');setAiProcessing(false);return;}}}if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentSelectedTrackId=selectedTrackId;window.DAWCommandDispatcher.currentTracks=activeTracks;window.DAWCommandDispatcher.lastCutSourceTrackId=null;window.DAWCommandDispatcher.lastCutNewTrackId=null;}setAiProcessing(true);setAiActionLog(prev=>[...prev,{type:'status',text:` ⏳ Đang gửi prompt đến AI...`,time:Date.now()}]);try{if(aiProviders.length===0||!selectedProviderId){try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers&&data.providers.length>0){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){}}const prv=aiProviders.find(p=>p.id===selectedProviderId)||(aiProviders.length>0?aiProviders[0]:null);const provider=prv||aiConfig;const baseUrl=provider.api_base_url||provider.baseUrl||`${API_BASE_URL}`;const apiKey=provider.api_key||provider.apiKey||'';const model=provider.model_name||provider.model||'deepseek-chat';setAiActionLog(prev=>[...prev,{type:'info',text:` Provider: ${provider.name||'default'} | Model: ${model} | URL: ${baseUrl.slice(0,40)}`,time:Date.now()}]);const dawContext=window.AIGateway.buildAIPromptContext({tracks:activeTracks,bpm,selectedTrackId,currentTime,selLeft,selRight});let matchedInstruction='';if(aiPromptMgrRef.current){const matchResult=aiPromptMgrRef.current.matchPreset(prompt);if(matchResult&&matchResult.preset){matchedInstruction=matchResult.preset.system_instruction_template;setAiActionLog(prev=>[...prev,{type:'info',text:` Khớp với preset: "${matchResult.preset.name}"`,time:Date.now()}]);}}const result=await window.AIGateway.executeAIPrompt({prompt:prompt,provider:provider.name||'default',model:model,apiKey:apiKey,baseUrl:baseUrl.replace(/\/chat\/completions$/,'').replace(/\/$/,''),dawContext,tools:window.AIGateway.DEFAULT_TOOLS,systemInstruction:matchedInstruction});const hasText=!!result.textResponse;const hasCalls=result.functionCalls&&result.functionCalls.length>0;if(hasText){setAiActionLog(prev=>[...prev,{type:'status',text:` AI: ${result.textResponse.slice(0,500)}`,time:Date.now()}]);}if(hasCalls){setAiActionLog(prev=>[...prev,{type:'info',text:` Gọi ${result.functionCalls.length} lệnh...`,time:Date.now()}]);if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.isExecutingAI=true;}try{for(const fc of result.functionCalls){setAiActionLog(prev=>[...prev,{type:'info',text:` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`,time:Date.now()}]);const cmdName=fc.name.toUpperCase();if(window.DAWCommandDispatcher){try{let cmdResult=window.DAWCommandDispatcher.execute(cmdName,fc.arguments);if(cmdResult&&typeof cmdResult.then==='function')cmdResult=await cmdResult;setAiActionLog(prev=>[...prev,{type:'status',text:` ✅ ${fc.name}: ${cmdResult&&cmdResult.success?'thành công':'thất bại: '+(cmdResult&&cmdResult.error||'unknown')}`,time:Date.now()}]);}catch(cmdErr){setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ ${fc.name}: ${cmdErr.message}`,time:Date.now()}]);}}else{setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ ${fc.name}: DAWCommandDispatcher not available`,time:Date.now()}]);}}}finally{if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.isExecutingAI=false;}}}if(!hasText&&!hasCalls){const rawKeys=result.raw?Object.keys(result.raw).join(', '):'null';const errDetail=result.raw&&result.raw.error?` (${result.raw.error.message||result.raw.error})`:'';setAiActionLog(prev=>[...prev,{type:'error',text:` AI không trả về lệnh hoặc text. Keys: [${rawKeys}]${errDetail}`,time:Date.now()}]);}if(prompt){promptHistRef.current=[...promptHistRef.current.slice(-49),prompt];setPromptHistory(promptHistRef.current);}setPromptHistIdx(-1);setAiActionLog(prev=>[...prev,{type:'status',text:` Hoàn tất.`,time:Date.now()}]);setAiPrompt('');setTimeout(()=>lucide.createIcons(),200);}catch(err){setAiActionLog(prev=>[...prev,{type:'error',text:` Lỗi: ${err.message}`,time:Date.now()}]);showToast(`AI Error: ${err.message}`,'error');}finally{setAiProcessing(false);}};// ── AI Compose Similar → NEXT TRACK (user request) ──
|
||
// Click MIDI item + gõ prompt → AI phân tích cấu trúc giai điệu trong item
|
||
// (pitch range, intervals, rhythm) → sáng tác giai điệu TƯƠNG TỰ → ghi MIDI
|
||
// notes vào track KẾ TIẾP; nếu track kế tiếp KHÔNG RỖNG → chèn track mới
|
||
// (ngay sau track hiện tại) và ghi MIDI item vào đó.
|
||
// ── AI Compose (ai_midi_rearrange_specification.md) ──
|
||
// Click MIDI item + chọn mode (Variation/Extend) + gõ prompt:
|
||
// 1. SonicMidiExtractor nén note (pitch + note_name, round 2dp) tiết kiệm token
|
||
// 2. Function Tool Schema rearrange_or_extend_midi_melody (2 mode)
|
||
// 3. Prompt engineering: ORIGINAL CONTEXT + USER DIRECTIVE + MODE DIRECTIVE + CONSTRAINTS
|
||
// 4. Ingest: SIMILAR_VARIATION → track mới ngay dưới (A/B); EXTEND_CONTINUATION → append item sau item gốc
|
||
const handleAiComposeFromItem=async mode=>{const selMidi=getSelectedMidiItemInfo();if(!selMidi){showToast('Hãy chọn 1 MIDI item trên timeline trước.','warning');return;}const prompt=aiPrompt.trim();if(!prompt){showToast('Vui lòng gõ prompt yêu cầu giai điệu.','warning');return;}const composeMode=mode==='EXTEND_CONTINUATION'?'EXTEND_CONTINUATION':'SIMILAR_VARIATION';setAiProcessing(true);setAiActionLog(prev=>[...prev,{type:'status',text:` 🎵 Đang phân tích "${selMidi.itemName}" (${(selMidi.notes||[]).length} notes) + gửi AI [${composeMode==='EXTEND_CONTINUATION'?'Extend':'Variation'}]...`,time:Date.now()}]);try{if(aiProviders.length===0||!selectedProviderId){try{const d=await window.SonicAPI.getAIConfigs();if(d&&d.providers&&d.providers.length>0){setAiProviders(d.providers);const a=d.providers.find(p=>p.is_active)||d.providers[0];if(a)setSelectedProviderId(a.id);}}catch(e){}}const prv=aiProviders.find(p=>p.id===selectedProviderId)||(aiProviders.length>0?aiProviders[0]:null);const provider=prv||aiConfig;const baseUrl=provider.api_base_url||provider.baseUrl||`${API_BASE_URL}`;const apiKey=provider.api_key||provider.apiKey||'';const model=provider.model_name||provider.model||'deepseek-chat';setAiActionLog(prev=>[...prev,{type:'info',text:` Provider: ${provider.name||'default'} | Model: ${model}`,time:Date.now()}]);// STEP 1: extract + nén MIDI context (note_name, round 2dp) qua SonicMidiExtractor
|
||
if(!window.SonicMidiExtractor)throw new Error('SonicMidiExtractor chưa được nạp.');const midiContext=window.SonicMidiExtractor.extractSelectedMIDIContext(activeTracksRef.current||activeTracks,selMidi.itemId,bpm);const bpmVal=parseInt(bpm)||120;const secPerBar=60.0/bpmVal*4;const srcItem=(activeTracksRef.current||activeTracks).flatMap(t=>(t.midiItems||[]).map(m=>({...m,trackId:t.id}))).find(m=>m.id===selMidi.itemId);const startBar=srcItem?(srcItem.startTime||0)/secPerBar:0;midiContext.start_bar=startBar;midiContext.time_signature='4/4';// STEP 3: prompt engineering (spec §4)
|
||
const notesJSON=JSON.stringify(midiContext.notes,null,2);const nextStartBar=startBar+midiContext.duration_bars;const modeDirective=composeMode==='EXTEND_CONTINUATION'?`MODE: MELODIC EXTENSION (EXTEND / CONTINUATION)
|
||
- Source Section: Bars ${startBar.toFixed(2)} -> ${nextStartBar.toFixed(2)} (${midiContext.duration_bars} bars).
|
||
- DIRECTIVE: Write a CONTINUATION starting at Bar ${nextStartBar.toFixed(2)} spanning the next ${midiContext.duration_bars} bars.
|
||
- Analyze the ending motif/cadence of the original sequence to craft a smooth transition, then develop the melody toward a climax or resolution.
|
||
- Set target_start_bar = ${nextStartBar.toFixed(2)}, target_duration_bars = ${midiContext.duration_bars}.`:`MODE: REARRANGEMENT / VARIATION (SIMILAR VARIATION / REARRANGE)
|
||
- DIRECTIVE: Generate a NEW variation of EQUAL LENGTH (${midiContext.duration_bars} bars, spanning Beats 0.0 -> ${midiContext.total_beats}).
|
||
- Retain the core harmonic framework/chord progression while applying variation techniques (Syncopation, Arpeggiator, Passing tones, Swing feel, Harmonization) as requested by the user.
|
||
- Set target_start_bar = ${startBar.toFixed(2)}, target_duration_bars = ${midiContext.duration_bars}.`;const promptText=`You are a professional AI Music Composer & Arranger.
|
||
|
||
ORIGINAL MIDI MELODY CONTEXT FROM DAW:
|
||
- Track: "${midiContext.track_name}" | Item: "${midiContext.item_name}"
|
||
- Tempo: ${midiContext.bpm} BPM | Time Signature: ${midiContext.time_signature}
|
||
- Location: Bar ${startBar.toFixed(2)} (Length: ${midiContext.duration_bars} bars / ${midiContext.total_beats} beats)
|
||
- Array of ${midiContext.total_notes} original MIDI source notes:
|
||
${notesJSON}
|
||
|
||
USER DIRECTIVE:
|
||
"${prompt}"
|
||
|
||
${modeDirective}
|
||
|
||
STRICT CONSTRAINTS:
|
||
1. Return your answer as a SINGLE valid JSON object (NO markdown, NO code fences, NO explanation) with EXACTLY this shape:
|
||
{"mode":"SIMILAR_VARIATION or EXTEND_CONTINUATION","composition_title":"short style title","target_start_bar":number,"target_duration_bars":number,"generated_notes":[{"pitch":60,"start_beat":0.0,"duration_beats":1.0,"velocity":0.8}]}
|
||
2. Notes in generated_notes MUST cover the full beat range from 0.0 to ${midiContext.total_beats} without stopping prematurely.
|
||
3. Ensure the final note sustains or resolves cleanly at beat ${midiContext.total_beats}.
|
||
4. generated_notes start_beat is relative to the start of the generated item (beat 0.0 = first bar of the new section).`;// STEP 2 + dispatch: provider hiện tại trả tool-call rác (_unknown) nên
|
||
// KHÔNG gửi tools — yêu cầu JSON thuần trong prompt (text-based, giống
|
||
// flow Piano Roll đã hoạt động).
|
||
const result=await window.AIGateway.executeAIPrompt({prompt:promptText,provider:provider.name||'default',model:model,apiKey:apiKey,baseUrl:baseUrl.replace(/\/chat\/completions$/,'').replace(/\/$/,''),systemInstruction:'You are a professional AI Music Composer & Arranger. Output ONLY a single valid JSON object (no markdown, no code fences) matching the exact shape requested in the prompt.',tools:[]});if(!result)throw new Error('AI không phản hồi');// STEP 4: decode — ƯU TIÊN textResponse (JSON thuần); functionCalls chỉ
|
||
// dùng khi đúng tên tool rearrange_or_extend_midi_melody (provider cũ
|
||
// trả tool-call rác _unknown chỉ chứa "reason" — bỏ qua).
|
||
let aiResult=null;let rawDebug='';const pickNotes=obj=>{if(!obj)return null;if(Array.isArray(obj))return obj;for(const k of['generated_notes','notes','rearranged_notes','midi_notes']){if(Array.isArray(obj[k])&&obj[k].length>0)return obj[k];}return null;};const textResp=result.textResponse||result.text||'';if(textResp&&typeof textResp==='string'&&textResp.trim()){try{const cleaned=textResp.replace(/```json?\s*/g,'').replace(/```/g,'').trim();const parsed=JSON.parse(cleaned);const n=pickNotes(parsed);if(n)aiResult={mode:parsed.mode,composition_title:parsed.composition_title,target_start_bar:parsed.target_start_bar,target_duration_bars:parsed.target_duration_bars,generated_notes:n};}catch(e1){rawDebug='text: '+textResp.slice(0,400);console.error('Parse AI result error:',e1);}}if(!aiResult||!pickNotes(aiResult)){if(result.functionCalls&&result.functionCalls.length>0){const real=result.functionCalls.find(fc=>fc.name==='rearrange_or_extend_midi_melody');if(real){try{const a=typeof real.arguments==='string'?JSON.parse(real.arguments):real.arguments;const n=pickNotes(a);if(n)aiResult={mode:a.mode,composition_title:a.composition_title,target_start_bar:a.target_start_bar,target_duration_bars:a.target_duration_bars,generated_notes:n};}catch(e2){rawDebug+=' | FC-args-raw: '+String(real.arguments).slice(0,300);}}else{rawDebug+=' | FC names: '+result.functionCalls.map(fc=>fc.name||'_unknown').join(',')+' (không phải tool đúng — bỏ qua)';}}}if(!aiResult||!pickNotes(aiResult)){console.error('[AI Compose] Không parse được notes. Debug:',rawDebug,'| functionCalls:',JSON.stringify(result.functionCalls||[]).slice(0,500),'| textResponse:',(result.textResponse||result.text||'').slice(0,500));setAiActionLog(prev=>[...prev,{type:'error',text:' ❌ AI không trả về notes hợp lệ. '+(rawDebug?'Raw: '+rawDebug.slice(0,200):''),time:Date.now()}]);return;}const aiNotes=pickNotes(aiResult);const finalMode=aiResult.mode==='EXTEND_CONTINUATION'?'EXTEND_CONTINUATION':'SIMILAR_VARIATION';const title=aiResult.composition_title||'AI Composition';const targetStartBar=typeof aiResult.target_start_bar==='number'?aiResult.target_start_bar:finalMode==='EXTEND_CONTINUATION'?nextStartBar:startBar;const targetDurBars=typeof aiResult.target_duration_bars==='number'?aiResult.target_duration_bars:midiContext.duration_bars;const newNotes=aiNotes.map((n,i)=>({id:'note_ai_'+Date.now()+'_'+i,pitch:Math.max(0,Math.min(127,parseInt(n.pitch)||60)),start_beat:Math.max(0,parseFloat(n.start_beat)||0),duration_beats:Math.max(0.125,parseFloat(n.duration_beats)||0.25),velocity:Math.max(0.1,Math.min(1.0,n.velocity??0.8)),pan:0.0}));const newItem={id:'item_ai_'+Date.now(),startTime:Math.max(0,targetStartBar*secPerBar),duration:Math.max(1,targetDurBars*secPerBar),name:(finalMode==='EXTEND_CONTINUATION'?'[Extend] ':'[Variation] ')+title,notes:newNotes};// STEP 5: ingest — Variation → track mới ngay dưới (A/B); Extend → append item cuối track gốc
|
||
const curTracks=activeTracksRef.current||activeTracks;const srcIdx=curTracks.findIndex(t=>t.id===selMidi.trackId);if(srcIdx===-1){setAiActionLog(prev=>[...prev,{type:'error',text:' ❌ Không tìm thấy track nguồn.',time:Date.now()}]);return;}const sf=aiResult.soundfont_id||midiContext.instrument&&midiContext.instrument.soundfont_id||'generaluser_gs';const sfBank=aiResult.soundfont_bank??(midiContext.instrument&&midiContext.instrument.soundfont_bank)??0;const sfProg=aiResult.soundfont_program??(midiContext.instrument&&midiContext.instrument.soundfont_program)??0;if(finalMode==='EXTEND_CONTINUATION'){// Append item mới vào CUỐI track gốc
|
||
updateActiveTracks(prev=>prev.map(t=>t.id===selMidi.trackId?{...t,midiItems:[...(t.midiItems||[]),newItem]}:t));setAiActionLog(prev=>[...prev,{type:'status',text:` ✅ [Extend] "${newItem.name}" (${newNotes.length} notes) → append sau item gốc (bar ${targetStartBar.toFixed(2)}).`,time:Date.now()}]);}else{// Tạo track mới ngay dưới track nguồn (A/B testing) — spec §5.
|
||
// TUÂN THỦ RULES như track gốc: clone toàn bộ cấu trúc track nguồn
|
||
// (synth_engine, instrumentProgram/instrumentName, volumeDb, pan,
|
||
// fxActive, bypass flags, color...) rồi reset phần content.
|
||
const newTrackId='track_ai_var_'+Date.now();const srcTrack=curTracks[srcIdx];const newTrack={...(srcTrack||{}),id:newTrackId,name:'[AI Var] '+title,// KHÔNG kế thừa midiChannel của track gốc: nếu dùng chung channel,
|
||
// solo/mute track gốc gửi CC7=0 trên channel đó → clone (cùng
|
||
// channel) bị NHỎ/CÂM. ensureTrackMidiChannel/assignTrackMidiChannel
|
||
// sẽ cấp channel RIÊNG cho track mới.
|
||
midiChannel:undefined,buffer:null,startTime:0,clips:[],sections:[],midiItems:[newItem],muted:false,solo:false,markers:[],serverFileId:null,synth_engine:srcTrack&&srcTrack.synth_engine?srcTrack.synth_engine:{type:'soundfont',soundfont_id:sf,soundfont_bank:sfBank,soundfont_program:sfProg},instrumentProgram:srcTrack?srcTrack.instrumentProgram:sfProg,instrumentName:srcTrack?srcTrack.instrumentName:'AI '+title};updateActiveTracks(prev=>{const copy=[...prev];copy.splice(srcIdx+1,0,newTrack);return copy;});setAiActionLog(prev=>[...prev,{type:'status',text:` ✅ [Variation] "${newItem.name}" (${newNotes.length} notes) → track mới "[AI Var] ${title}" ngay dưới (A/B) — kế thừa synth/instrument của track gốc.`,time:Date.now()}]);}setCanvasRedrawCount(n=>n+1);setTimeout(()=>lucide.createIcons(),200);}catch(err){setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ Lỗi: ${err.message}`,time:Date.now()}]);showToast(`AI Error: ${err.message}`,'error');}finally{setAiProcessing(false);}};// Alias cũ giữ cho nút UI hiện tại (Compose) hoạt động — mode mặc định Variation
|
||
const handleAiComposeToNextTrack=()=>handleAiComposeFromItem('SIMILAR_VARIATION');// ── Split Track at Playhead ──
|
||
const handleSplitTrackAtTime=(trackId,clipId,time)=>{const track=tracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];const targetClipId=clipId||clips.find(c=>time>=c.startTime&&time<c.startTime+c.buffer.duration)?.id;if(!targetClipId)return;const clip=clips.find(c=>c.id===targetClipId);if(!clip||!clip.buffer)return;const relTime=Math.max(0,time-clip.startTime);const sr=clip.buffer.sampleRate;const cutSample=Math.floor(relTime*sr);const originalData=clip.buffer.getChannelData(0);if(cutSample<=0||cutSample>=originalData.length){showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.","warning");return;}const beforeSnap=captureTrackSnapshot(trackId);const ctx=getAudioContext();const b1=ctx.createBuffer(1,cutSample,sr);b1.copyToChannel(originalData.subarray(0,cutSample),0);const b2=ctx.createBuffer(1,originalData.length-cutSample,sr);b2.copyToChannel(originalData.subarray(cutSample),0);const clip1={id:'clip_'+Date.now()+'_p1',name:`${clip.name.replace(' (Part 1)','').replace(' (Part 2)','')} (Part 1)`,buffer:b1,startTime:clip.startTime};const clip2={id:'clip_'+Date.now()+'_p2',name:`${clip.name.replace(' (Part 1)','').replace(' (Part 2)','')} (Part 2)`,buffer:b2,startTime:clip.startTime+cutSample/sr};setTracks(prev=>prev.map(t=>{if(t.id===trackId){const remainingClips=clips.filter(c=>c.id!==targetClipId);const updatedClips=[...remainingClips,clip1,clip2];return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}return t;}));setTimeout(()=>{const afterSnap=captureTrackSnapshot(trackId);pushAction('SPLIT_CLIP',trackId,beforeSnap,afterSnap);},50);showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`,"info");};const handleSplitTrack=trackId=>{handleSplitTrackAtTime(trackId,null,currentTime);};handleSplitTrackRef.current=handleSplitTrack;// ── Glue (Merge) Clips on Selected Track ──
|
||
const handleGlueTracks=()=>{const track=tracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Vui lòng chọn một track để thực hiện gộp (glue).','warning');return;}const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length<2){showToast('Cần ít nhất 2 clip trên track này để gộp (glue).','warning');return;}const beforeSnap=captureTrackSnapshot(track.id);const ctx=getAudioContext();const sr=clips[0].buffer.sampleRate;let minStart=Infinity;let maxEnd=-Infinity;clips.forEach(c=>{const start=c.startTime||0;const end=start+c.buffer.duration;minStart=Math.min(minStart,start);maxEnd=Math.max(maxEnd,end);});const newDur=maxEnd-minStart;const newBuffer=ctx.createBuffer(1,Math.ceil(newDur*sr),sr);const newData=newBuffer.getChannelData(0);clips.forEach(c=>{const data=c.buffer.getChannelData(0);const offset=Math.floor(((c.startTime||0)-minStart)*sr);for(let i=0;i<data.length;i++){if(offset+i<newData.length){newData[offset+i]+=data[i];}}});let maxPeak=0;for(let i=0;i<newData.length;i++){const abs=Math.abs(newData[i]);if(abs>maxPeak)maxPeak=abs;}if(maxPeak>1.0){for(let i=0;i<newData.length;i++)newData[i]/=maxPeak;}const mergedClip={id:'clip_merged_'+Date.now(),name:`${track.name.replace(' (Part 1)','').replace(' (Part 2)','')} (Glued)`,buffer:newBuffer,startTime:minStart};setTracks(prev=>prev.map(t=>{if(t.id===track.id){return{...t,clips:[mergedClip],buffer:newBuffer,startTime:minStart,name:mergedClip.name};}return t;}));setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('GLUE',track.id,beforeSnap,afterSnap);},50);showToast(`Đã gộp ${clips.length} clips thành công.`,'success');};// ── DAW Command Registration for AI (28_AI_PANEL.md §1 & §2) ──
|
||
useEffect(()=>{if(typeof window.DAWCommandDispatcher==='undefined')return;const api={createTrack:args=>{const name=args.name||`AI_Track_${Date.now()}`;const type=args.type||'audio';const rearrangeNewId=addNewTrack();if(name&&name!==`AI_Track_${Date.now()}`){updateTrackName(rearrangeNewId,name);}return{success:true,trackId:rearrangeNewId,name};},deleteTrack:args=>{const tid=args.track_id||selectedTrackId;if(!tid)return{success:false,error:'No track_id provided'};deleteTrack(tid);return{success:true,trackId:tid};},addClip:args=>{const trackId=args.track_id||selectedTrackId;const barDur=60/parseInt(bpm||120)*4;let startTime;if(args.start_time!==undefined&&args.start_time!==null)startTime=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)startTime=args.start_bar*barDur;else startTime=currentTime;const track=tracks.find(t=>t.id===trackId);if(!track)return{success:false,error:'Track not found'};const ctx=getAudioContext();const sr=44100;let duration;if(args.duration_seconds!==undefined&&args.duration_seconds!==null)duration=args.duration_seconds;else if(args.length_bars!==undefined&&args.length_bars!==null)duration=args.length_bars*barDur;else duration=2;const buffer=ctx.createBuffer(1,Math.floor(sr*duration),sr);const data=buffer.getChannelData(0);for(let i=0;i<data.length;i++)data[i]=0;const clipId=nextClipId();setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];return{...t,clips:[...clips,{id:clipId,buffer,startTime,name:args.name||'AI Clip'}],buffer:clips.length>0?clips[0].buffer:buffer,startTime:clips.length>0?clips[0].startTime:startTime,name:clips.length>0?clips[0].name:args.name||t.name};}));return{success:true,clipId,trackId};},removeClip:args=>{const trackId=args.track_id||selectedTrackId;const clipId=args.clip_id;setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const updatedClips=(t.clips||[]).filter(c=>c.id!==clipId);return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));return{success:true};},setTrackVolume:args=>{const trackId=args.track_id||selectedTrackId;const vol=args.volume_db??args.volume??0;updateTrackVolumeDb(trackId,parseFloat(vol));return{success:true,trackId,volumeDb:vol};},setTrackPan:args=>{const trackId=args.track_id||selectedTrackId;const pan=args.pan??0;updateTrackPan(trackId,parseInt(pan));return{success:true,trackId,pan};},toggleMute:args=>{const trackId=args.track_id||selectedTrackId;toggleTrackMute(trackId);const track=tracks.find(t=>t.id===trackId);return{success:true,trackId,muted:track?track.muted:null};},toggleSolo:args=>{const trackId=args.track_id||selectedTrackId;toggleTrackSoloEvaluate(trackId);const track=tracks.find(t=>t.id===trackId);return{success:true,trackId,solo:track?track.solo:null};},processAudioDsp:args=>{const trackId=args.track_id||selectedTrackId;const action=args.action;const params=args.params||{};const track=tracks.find(t=>t.id===trackId);if(!track||!track.buffer)return{success:false,error:'Track has no audio buffer'};if(action==='normalize'){const channelData=track.buffer.getChannelData(0);let maxVal=0;for(let i=0;i<channelData.length;i++)maxVal=Math.max(maxVal,Math.abs(channelData[i]));if(maxVal>0){const gain=1.0/maxVal;for(let i=0;i<channelData.length;i++)channelData[i]*=gain;}return{success:true,action:'normalize'};}else if(action==='invert_phase'){const channelData=track.buffer.getChannelData(0);for(let i=0;i<channelData.length;i++)channelData[i]*=-1;return{success:true,action:'invert_phase'};}else if(action==='gain'){const gainDb=params.gain_db??0;const scale=Math.pow(10,gainDb/20);const channelData=track.buffer.getChannelData(0);for(let i=0;i<channelData.length;i++)channelData[i]=Math.max(-1,Math.min(1,channelData[i]*scale));return{success:true,action:'gain',gainDb};}else if(action==='pitch_shift'){const semitones=params.semitones??0;const ratio=Math.pow(2,semitones/12);const applyResample=(data,r)=>{const newLen=Math.round(data.length*r);const out=new Float32Array(newLen);for(let i=0;i<newLen;i++){const srcIdx=i/r;const idx0=Math.floor(srcIdx);const idx1=Math.min(idx0+1,data.length-1);const frac=srcIdx-idx0;out[i]=data[idx0]*(1-frac)+data[idx1]*frac;}return out;};const channelData=track.buffer.getChannelData(0);const newData=applyResample(channelData,1/ratio);const ctx=getAudioContext();const newBuffer=ctx.createBuffer(1,newData.length,track.buffer.sampleRate);newBuffer.copyToChannel(newData,0);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,buffer:newBuffer}:t));return{success:true,action:'pitch_shift',semitones};}return{success:false,error:`Unknown action: ${action}`};},renameTrack:args=>{const tid=args.track_id||selectedTrackId;const name=args.name;if(!tid)return{success:false,error:'No track_id provided'};if(!name)return{success:false,error:'No name provided'};updateTrackName(tid,name);return{success:true,trackId:tid,name};},setSelection:args=>{const barDur=60/parseInt(bpm||120)*4;let start,end;if(args.start_time!==undefined&&args.start_time!==null)start=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)start=args.start_bar*barDur;else start=currentTime;if(args.end_time!==undefined&&args.end_time!==null)end=args.end_time;else if(args.length_bars!==undefined&&args.length_bars!==null)end=start+args.length_bars*barDur;else if(args.end_bar!==undefined&&args.end_bar!==null)end=args.end_bar*barDur;else end=start+barDur;clearLocalSelection();setSelectionMode('global');setSelectionStart(start);setSelectionEnd(end);selectionRef.current={start,end};return{success:true,start:parseFloat(start.toFixed(3)),end:parseFloat(end.toFixed(3)),length:parseFloat((end-start).toFixed(3))};},cutAudio:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;const tid=args.track_id||currentSelTrackId;const track=currentTracks.find(t=>t.id===String(tid));if(!track)return{success:false,error:'Track not found'};if(!track.buffer)return{success:false,error:'Track has no audio buffer'};const barDur=60/parseInt(bpm||120)*4;const sel=selectionRef.current;let rawStart,rawEnd;if(args.start_time!==undefined&&args.start_time!==null)rawStart=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)rawStart=args.start_bar*barDur;else if(sel.start!==null)rawStart=sel.start;else rawStart=currentTime;if(args.end_time!==undefined&&args.end_time!==null)rawEnd=args.end_time;else if(args.end_bar!==undefined&&args.end_bar!==null)rawEnd=args.end_bar*barDur;else if(args.length_bars!==undefined&&args.length_bars!==null)rawEnd=rawStart+args.length_bars*barDur;else if(sel.end!==null&&sel.end>rawStart)rawEnd=sel.end;else return{success:false,error:'No end position provided. Provide end_time, end_bar, or length_bars.'};if(rawEnd<=rawStart)return{success:false,error:'End position must be after start position.'};const buffer=track.buffer;const ctx=getAudioContext();const snap=args.snap_silence!==false;const loopStart=snap?findZeroCrossing(buffer,rawStart):rawStart;const loopEnd=snap?findZeroCrossing(buffer,rawEnd):rawEnd;const sampleRate=buffer.sampleRate;const startSample=Math.max(0,Math.min(buffer.length-1,Math.floor(loopStart*sampleRate)));const endSample=Math.max(0,Math.min(buffer.length,Math.floor(loopEnd*sampleRate)));const sliceLength=endSample-startSample;if(sliceLength<=100)return{success:false,error:'Selection too short or invalid'};const numChannels=buffer.numberOfChannels||1;const slicedBuffer=ctx.createBuffer(numChannels,sliceLength,sampleRate);for(let c=0;c<numChannels;c++){const src=buffer.getChannelData(c);const dst=slicedBuffer.getChannelData(c);dst.set(src.subarray(startSample,endSample));}const rearrangeNewId='track_cut_'+Date.now();const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const color=colors[currentTracks.length%colors.length];const cutName=args.new_track_name||`Cut_${track.name}`;const newTrack={id:rearrangeNewId,name:cutName,buffer:slicedBuffer,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color,markers:[],clips:[{id:'clip_'+rearrangeNewId,buffer:slicedBuffer,startTime:0,name:cutName}],serverFileId:null};const idx=currentTracks.findIndex(t=>t.id===tid);const nextTracks=[...currentTracks];if(idx!==-1){nextTracks.splice(idx+1,0,newTrack);}else{nextTracks.push(newTrack);}if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;window.DAWCommandDispatcher.currentSelectedTrackId=rearrangeNewId;window.DAWCommandDispatcher.lastCutSourceTrackId=tid;window.DAWCommandDispatcher.lastCutNewTrackId=rearrangeNewId;}setTracks(nextTracks);setSelectedTrackId(rearrangeNewId);selectionRef.current={start:0,end:slicedBuffer.duration};clearLocalSelection();setSelectionMode('global');setSelectionStart(0);setSelectionEnd(slicedBuffer.duration);setTimeout(()=>lucide.createIcons(),200);return{success:true,trackId:rearrangeNewId,trackName:cutName,cutStart:parseFloat(loopStart.toFixed(3)),cutEnd:parseFloat(loopEnd.toFixed(3)),duration:parseFloat(slicedBuffer.duration.toFixed(3))};},scanTrack:args=>{const tid=args.track_id||selectedTrackId;const track=tracks.find(t=>t.id===tid);if(!track)return{success:false,error:'Track not found'};if(!track.buffer)return{success:false,error:'Track has no audio buffer. Load audio first.'};const buffer=track.buffer;const data=buffer.getChannelData(0);const sr=buffer.sampleRate;const channels=buffer.numberOfChannels;const duration=buffer.duration;const totalSamples=buffer.length;const windowSize=Math.min(sr*3,data.length);let detectedBPM=0;if(windowSize>sr){let maxCorr=0;for(let lag=Math.floor(sr*0.3);lag<=Math.floor(sr*2.0);lag++){let corr=0;const step=4;for(let i=0;i<windowSize&&i+lag<data.length;i+=step)corr+=data[i]*data[i+lag];corr/=windowSize/step;if(corr>maxCorr){maxCorr=corr;detectedBPM=60/(lag/sr);}}}detectedBPM=Math.round(Math.min(300,Math.max(30,detectedBPM)));if(args.set_tempo!==false&&detectedBPM>0){setBpm(String(detectedBPM));}const bitDepth=16;const bitrate=Math.round(sr*channels*bitDepth/1000);return{success:true,trackId:tid,trackName:track.name,bpm:detectedBPM,sampleRate:sr,channels,duration:parseFloat(duration.toFixed(3)),totalSamples,bitDepth,bitrateKbps:bitrate,hasAudio:true};},setBpm:args=>{const bpmVal=args.bpm||args.tempo||120;setBpm(String(bpmVal));return{success:true,bpm:bpmVal};},setPlayhead:args=>{const barDur=60/parseInt(bpm||120)*4;let time;if(args.time!==undefined&&args.time!==null)time=args.time;else if(args.bar!==undefined&&args.bar!==null)time=args.bar*barDur;else time=0;handlePlayheadSet(time);return{success:true,time:parseFloat(time.toFixed(3))};},exportAudio:async args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let tid=args.track_id;if(tid&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(tid)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+tid===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){tid=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!tid)tid=currentSelTrackId;const track=tid&¤tTracks.find(t=>t.id===String(tid)||t.id==='track_'+tid);if(!track)return{success:false,error:'No track found'};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0&&(!track.midiItems||track.midiItems.length===0))return{success:false,error:'Track has no audio clips'};const beatsPerSec=parseFloat(bpm||120)/60;const totalDuration=Math.max(clips.length>0?Math.max(...clips.map(c=>(c.startTime||0)+(c.buffer?c.buffer.duration/(c.speed||1.0):0))):0,...(track.midiItems||[]).map(m=>(m.startTime||0)+(m.duration||4)),...(track.sections||[]).map(s=>(s.start||0)+(s.duration||4)));const barDur=60/parseInt(bpm||120)*4;const sel=selectionRef.current;let rawStart,rawEnd;if(args.start_time!==undefined)rawStart=args.start_time;else if(args.start_bar!==undefined)rawStart=args.start_bar*barDur;else if(sel.start!==null)rawStart=sel.start;else rawStart=0;if(args.end_time!==undefined)rawEnd=args.end_time;else if(args.length_bars!==undefined)rawEnd=(rawStart||0)+args.length_bars*barDur;else if(args.end_bar!==undefined)rawEnd=args.end_bar*barDur;else if(sel.end!==null&&sel.end>rawStart)rawEnd=sel.end;else rawEnd=totalDuration;if(rawEnd<=rawStart)return{success:false,error:'Export range is empty or invalid.'};const ctx=getAudioContext();const sr=parseInt(args.sample_rate||'44100');const firstBuffer=clips.find(c=>c.buffer)?.buffer;const numCh=args.channels==='mono'?1:firstBuffer?firstBuffer.numberOfChannels:2;const bd=parseInt(args.bit_depth||'16');const fmt=args.format||'wav';const renderLength=rawEnd-rawStart;const offlineCtx=new OfflineAudioContext(numCh,Math.ceil(sr*renderLength),sr);clips.forEach(clip=>{if(!clip.buffer)return;const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;if(clipEnd<=rawStart||clipStart>=rawEnd)return;const source=offlineCtx.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(offlineCtx.destination);if(rawStart<clipStart){const delay=clipStart-rawStart;const playDur=Math.min(clipDuration,rawEnd-clipStart);source.start(delay,0,playDur*(clip.speed||1.0));}else{const offsetInClip=rawStart-clipStart;const playDur=Math.min(clipEnd-rawStart,renderLength);source.start(0,offsetInClip*(clip.speed||1.0),playDur*(clip.speed||1.0));}});const renderedBuffer=await offlineCtx.startRendering();const len=renderedBuffer.length;const bps=bd/8;const hdrSz=44;const fileBuf=new ArrayBuffer(hdrSz+len*bps*numCh);const vw=new DataView(fileBuf);const ws=(off,s)=>{for(let i=0;i<s.length;i++)vw.setUint8(off+i,s.charCodeAt(i));};ws(0,'RIFF');vw.setUint32(4,fileBuf.byteLength-8,true);ws(8,'WAVE');ws(12,'fmt ');vw.setUint32(16,16,true);vw.setUint16(20,1,true);vw.setUint16(22,numCh,true);vw.setUint32(24,sr,true);vw.setUint32(28,sr*bps*numCh,true);vw.setUint16(32,bps*numCh,true);vw.setUint16(34,bd,true);ws(36,'data');vw.setUint32(40,len*bps*numCh,true);let ofs=44;for(let i=0;i<len;i++){for(let ch=0;ch<numCh;ch++){const smp=Math.max(-1,Math.min(1,renderedBuffer.getChannelData(ch)[i]));if(bd===8)vw.setUint8(ofs,Math.floor((smp+1)*127.5));else if(bd===16)vw.setInt16(ofs,Math.floor(smp<0?smp*0x8000:smp*0x7FFF),true);else{const v24=Math.floor(smp<0?smp*0x800000:smp*0x7FFFFF);vw.setUint8(ofs,v24&0xFF);vw.setUint8(ofs+1,v24>>8&0xFF);vw.setUint8(ofs+2,v24>>16&0xFF);}ofs+=bps;}}const blob=new Blob([fileBuf],{type:'audio/wav'});const localUrl=URL.createObjectURL(blob);const targetFilename=`export_${Date.now()}.${fmt}`;const triggerDownload=(downloadUrl,finalFilename)=>{if(window.DAWCommandDispatcher?.isExecutingAI){showToast(`Xuất nhạc thành công (${fmt.toUpperCase()})!`,"success","Tải về",()=>{const a=document.createElement('a');a.href=downloadUrl;a.download=finalFilename;a.click();if(downloadUrl.startsWith('blob:')){URL.revokeObjectURL(downloadUrl);}});}else{const a=document.createElement('a');a.href=downloadUrl;a.download=finalFilename;a.click();showToast("Xuất bản âm thanh hoàn tất!","success");if(downloadUrl.startsWith('blob:')){URL.revokeObjectURL(downloadUrl);}}};if((fmt==='mp3'||fmt==='ogg')&&serverStatus==='connected'){try{const file=new File([blob],`export_ai.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload failed");const uploadData=await uploadResp.json();const uploadId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:uploadId,format:fmt,sample_rate:sr,bit_depth:bd})});if(!exportResp.ok)throw new Error("Export failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const downloadUrl=`${API_AUDIO}/download/${result.output_file_id}`;triggerDownload(downloadUrl,targetFilename);}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}. Tải về dạng WAV thay thế.`,"warning");triggerDownload(localUrl,`export_${Date.now()}.wav`);}}else{const finalFilename=fmt==='wav'?`export_${Date.now()}.wav`:`export_${Date.now()}.wav`;if(fmt!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}triggerDownload(localUrl,finalFilename);}return{success:true,trackId:tid,range:parseFloat((rawEnd-rawStart).toFixed(3))+'s',format:fmt,channels:numCh===1?'mono':'stereo'};},selectItem:args=>{if(args.select_all){setSelectedTrackId(null);clearLocalSelection();setSelectionMode('global');setSelectionStart(0);const maxDur=tracks.reduce((max,t)=>{const dur=t.buffer?t.buffer.duration:0;const clips=t.clips||[];const clipMax=clips.reduce((m,c)=>Math.max(m,(c.startTime||0)+(c.buffer?c.buffer.duration:0)),0);return Math.max(max,dur,clipMax);},0);setSelectionEnd(Math.max(maxDur,currentTime+10));return{success:true,selection:'all',duration:parseFloat(Math.max(maxDur,currentTime+10).toFixed(3))};}const tid=args.track_id||selectedTrackId;const track=tracks.find(t=>t.id===tid);if(!track)return{success:false,error:`Track ${tid} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,name:track.name,buffer:track.buffer,startTime:track.startTime||0}]:[];if(args.item_name){const match=clips.find(c=>c.name&&c.name.toLowerCase().includes(args.item_name.toLowerCase()));if(!match)return{success:false,error:`No clip matching "${args.item_name}" on track ${track.name}`};setSelectedTrackId(tid);clearLocalSelection();setSelectionMode('global');const start=match.startTime||0;const end=start+(match.buffer?match.buffer.duration:2);setSelectionStart(start);setSelectionEnd(end);return{success:true,trackId:tid,trackName:track.name,clipId:match.id,clipName:match.name,start:parseFloat(start.toFixed(3)),end:parseFloat(end.toFixed(3))};}setSelectedTrackId(tid);clearLocalSelection();setSelectionMode('global');const trackEnd=track.buffer?track.buffer.duration:clips.length>0?Math.max(...clips.map(c=>(c.startTime||0)+(c.buffer?c.buffer.duration:0))):4;setSelectionStart(0);setSelectionEnd(trackEnd);return{success:true,trackId:tid,trackName:track.name,duration:parseFloat(trackEnd.toFixed(3))};},addMarker:args=>{const trackId=args.track_id||selectedTrackId;const time=args.time??currentTime;const track=tracks.find(t=>t.id===trackId);if(!track)return{success:false,error:'Track not found'};setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,markers:[...(t.markers||[]),{id:'ai_marker_'+Date.now(),time,label:args.label||'AI Marker'}]};}));return{success:true,trackId,time};},fadeIn:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let trackIdRaw=args.track_id;if(trackIdRaw&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(trackIdRaw)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+trackIdRaw===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){trackIdRaw=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!trackIdRaw)trackIdRaw=currentSelTrackId;const track=currentTracks.find(t=>t.id===String(trackIdRaw)||t.id==='track_'+trackIdRaw);if(!track)return{success:false,error:`Track ${trackIdRaw} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0)return{success:false,error:'No clips on track'};let clip=null;if(args.clip_id){clip=clips.find(c=>c.id===args.clip_id);}else if(args.clip_index!==undefined){const idx=parseInt(args.clip_index);const realIdx=idx>0?idx-1:0;clip=clips[realIdx]||clips[0];}else{clip=clips[0];}if(!clip||!clip.buffer)return{success:false,error:'Clip has no audio buffer'};const duration=parseFloat(args.duration_seconds||3);const buffer=clip.buffer;const sr=buffer.sampleRate;const numChannels=buffer.numberOfChannels;const length=buffer.length;const ctx=getAudioContext();const newBuffer=ctx.createBuffer(numChannels,length,sr);for(let c=0;c<numChannels;c++){newBuffer.getChannelData(c).set(buffer.getChannelData(c));}const fadeSamples=Math.min(length,Math.floor(duration*sr));for(let c=0;c<numChannels;c++){const data=newBuffer.getChannelData(c);for(let i=0;i<fadeSamples;i++){const factor=(1-Math.cos(Math.PI*i/fadeSamples))/2;data[i]*=factor;}}const beforeSnap=captureTrackSnapshot(track.id);const nextTracks=currentTracks.map(t=>{if(t.id!==track.id)return t;const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const updatedClips=existingClips.map(c=>{if(c.id===clip.id||clip.id.startsWith('default_')&&c.id==='default'){return{...c,buffer:newBuffer};}return c;});const mainBuffer=updatedClips[0]?.buffer||t.buffer;return{...t,clips:updatedClips,buffer:mainBuffer};});if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;}setTracks(nextTracks);setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('AI_FADE_IN',track.id,beforeSnap,afterSnap);},50);return{success:true,trackId:track.id,clipId:clip.id,duration_seconds:duration};},fadeOut:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let trackIdRaw=args.track_id;if(trackIdRaw&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(trackIdRaw)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+trackIdRaw===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){trackIdRaw=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!trackIdRaw)trackIdRaw=currentSelTrackId;const track=currentTracks.find(t=>t.id===String(trackIdRaw)||t.id==='track_'+trackIdRaw);if(!track)return{success:false,error:`Track ${trackIdRaw} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0)return{success:false,error:'No clips on track'};let clip=null;if(args.clip_id){clip=clips.find(c=>c.id===args.clip_id);}else if(args.clip_index!==undefined){const idx=parseInt(args.clip_index);const realIdx=idx>0?idx-1:0;clip=clips[realIdx]||clips[0];}else{clip=clips[0];}if(!clip||!clip.buffer)return{success:false,error:'Clip has no audio buffer'};const duration=parseFloat(args.duration_seconds||3);const buffer=clip.buffer;const sr=buffer.sampleRate;const numChannels=buffer.numberOfChannels;const length=buffer.length;const ctx=getAudioContext();const newBuffer=ctx.createBuffer(numChannels,length,sr);for(let c=0;c<numChannels;c++){newBuffer.getChannelData(c).set(buffer.getChannelData(c));}const fadeSamples=Math.min(length,Math.floor(duration*sr));for(let c=0;c<numChannels;c++){const data=newBuffer.getChannelData(c);for(let i=0;i<fadeSamples;i++){const idx=length-fadeSamples+i;const factor=(1+Math.cos(Math.PI*i/fadeSamples))/2;data[idx]*=factor;}}const beforeSnap=captureTrackSnapshot(track.id);const nextTracks=currentTracks.map(t=>{if(t.id!==track.id)return t;const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const updatedClips=existingClips.map(c=>{if(c.id===clip.id||clip.id.startsWith('default_')&&c.id==='default'){return{...c,buffer:newBuffer};}return c;});const mainBuffer=updatedClips[0]?.buffer||t.buffer;return{...t,clips:updatedClips,buffer:mainBuffer};});if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;}setTracks(nextTracks);setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('AI_FADE_OUT',track.id,beforeSnap,afterSnap);},50);return{success:true,trackId:track.id,clipId:clip.id,duration_seconds:duration};},generateMultitrackMidi:args=>{const{composition_title,bpm:aiBpm,total_bars,tracks:aiTracks}=args;if(aiBpm){setBpm(aiBpm.toString());}const bpmVal=aiBpm||parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;const secondsPerBar=secondsPerBeat*4;const durationSec=total_bars*secondsPerBar;updateActiveTracks(prev=>{let updatedTracks=[...prev];aiTracks.forEach(aiTrack=>{let targetTrack=updatedTracks.find(t=>t.name.toLowerCase()===aiTrack.track_name.toLowerCase());if(!targetTrack){const rearrangeNewId=(updatedTracks.length+1).toString();const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[updatedTracks.length%colors.length];targetTrack={id:rearrangeNewId,name:aiTrack.track_name,type:'MIDI',volumeDb:0,pan:0,muted:false,solo:false,color:selectColor,markers:[],serverFileId:null,clips:[],sections:[],midiItems:[],isArmed:false,monitoringEnabled:true,inputSource:{deviceType:'NONE',deviceId:''}};updatedTracks.push(targetTrack);}// Place MIDI item at current playhead position
|
||
const itemStartTimeSec=currentTime;const newMidiItem={id:'item_ai_'+Date.now()+'_'+Math.random().toString(36).substr(2,5),name:`${composition_title||'AI Theme'} - ${aiTrack.track_name}`,parent_track_id:targetTrack.id,startTime:itemStartTimeSec,duration:durationSec,length_bars:total_bars,notes:aiTrack.notes.map((note,index)=>({id:`note_ai_${Date.now()}_${index}`,pitch:note.pitch,start_beat:note.start_beat,duration_beats:note.duration_beats,velocity:note.velocity||0.8,pan:0.0}))};targetTrack.midiItems=[...(targetTrack.midiItems||[]),newMidiItem];if(aiTrack.soundfont_bank!==undefined&&aiTrack.soundfont_program!==undefined){targetTrack.soundfont_bank=aiTrack.soundfont_bank;targetTrack.soundfont_program=aiTrack.soundfont_program;targetTrack.soundfont_id=aiTrack.soundfont_id||'';const sfEngine={type:'soundfont',plugin_id:aiTrack.soundfont_id?'sf_'+aiTrack.soundfont_id:null,soundfont_bank:aiTrack.soundfont_bank,soundfont_program:aiTrack.soundfont_program,soundfont_id:aiTrack.soundfont_id||''};targetTrack.synth_engine=sfEngine;if(window.SonicSF&&window.SonicSF.applyAITrackInstrument){window.SonicSF.applyAITrackInstrument(aiTrack.soundfont_bank,aiTrack.soundfont_program,sfEngine);}}});return updatedTracks;});showToast(`Đã nạp ${aiTracks.length} tracks MIDI thế hệ AI!`,'success');return{success:true};},rearrangeMidiMelody:args=>{const{rearrange_title,rearranged_notes,soundfont_id,soundfont_bank,soundfont_program}=args;if(!rearranged_notes||rearranged_notes.length===0){return{success:false,error:'No rearranged notes provided'};}const sourceTrackId=window.DAWCommandDispatcher?.rearrangeSourceTrackId||selectedTrackId;const sourceItemName=window.DAWCommandDispatcher?.rearrangeSourceItemName||'Source';const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;const secondsPerBar=secondsPerBeat*4;const totalBeats=rearranged_notes.reduce((max,n)=>Math.max(max,(n.start_beat||0)+(n.duration_beats||1)),0);const totalBars=Math.max(1,Math.ceil(totalBeats/4));const durationSec=totalBars*secondsPerBar;const rearrangeNewId=(activeTracks.length+1).toString();updateActiveTracks(prev=>{let updatedTracks=[...prev];// Find source track index to insert new track right after it
|
||
const sourceIdx=sourceTrackId?updatedTracks.findIndex(t=>t.id===sourceTrackId||t.id==='track_'+sourceTrackId):-1;const insertIdx=sourceIdx>=0?sourceIdx+1:updatedTracks.length;const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const newTrack={id:rearrangeNewId,name:`[AI Rearrange] ${rearrange_title||'Variation'}`,type:'MIDI',volumeDb:0,pan:0,muted:false,solo:false,color:colors[insertIdx%colors.length],markers:[],serverFileId:null,clips:[],sections:[],midiItems:[{id:'item_rearr_'+Date.now()+'_'+Math.random().toString(36).substr(2,5),name:`[AI] ${rearrange_title||'Rearranged'} - var`,parent_track_id:rearrangeNewId,startTime:currentTime,duration:durationSec,length_bars:totalBars,notes:rearranged_notes.map((n,i)=>({id:`note_rearr_${Date.now()}_${i}`,pitch:Math.max(0,Math.min(127,n.pitch||60)),start_beat:Math.max(0,parseFloat(n.start_beat)||0),duration_beats:Math.max(0.125,parseFloat(n.duration_beats)||0.25),velocity:Math.max(0.1,Math.min(1.0,n.velocity??0.8)),pan:0.0}))}]};if(soundfont_id||soundfont_bank!==undefined&&soundfont_program!==undefined){newTrack.soundfont_id=soundfont_id||'';newTrack.soundfont_bank=soundfont_bank??0;newTrack.soundfont_program=soundfont_program??0;newTrack.synth_engine={type:'soundfont',plugin_id:soundfont_id?'sf_'+soundfont_id:null,soundfont_bank:soundfont_bank??0,soundfont_program:soundfont_program??0,soundfont_id:soundfont_id||''};}updatedTracks.splice(insertIdx,0,newTrack);return updatedTracks;});showToast(`✅ AI Rearrange: "${rearrange_title}" — ${rearranged_notes.length} notes`,'success');return{success:true,trackId:rearrangeNewId,totalBars};},createMidiItem:args=>{const trackId=String(args.track_id||selectedTrackId);if(!trackId)return{success:false,error:'No track_id provided'};const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const startBar=args.start_bar!==undefined?parseFloat(args.start_bar):0;const lengthBars=args.length_bars!==undefined?parseFloat(args.length_bars):4;const midiItem={id:`midi_${Date.now()}_${Math.random().toString(36).substr(2,5)}`,name:'MIDI Item',parent_track_id:trackId,startTime:startBar*secondsPerBar,duration:lengthBars*secondsPerBar,length_bars:lengthBars,notes:[],color:'#a78bfa'};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId&&t.id!=='track_'+trackId)return t;return{...t,midiItems:[...(t.midiItems||[]),midiItem]};}));return{success:true,itemId:midiItem.id,trackId};},modifyMidiNotes:args=>{const itemId=args.item_id;if(!itemId)return{success:false,error:'No item_id provided'};const noteNameToMidi=name=>{if(typeof name==='number')return name;if(!name||typeof name!=='string')return 60;const match=name.match(/^([A-Ga-g]#?|b?)(-?\d+)$/);if(!match){const num=parseInt(name);return isNaN(num)?60:num;}const noteNames={'c':0,'c#':1,'db':1,'d':2,'d#':3,'eb':3,'e':4,'f':5,'f#':6,'gb':6,'g':7,'g#':8,'ab':8,'a':9,'a#':10,'bb':10,'b':11};const key=match[1].toLowerCase();const octave=parseInt(match[2]);const base=noteNames[key]!==undefined?noteNames[key]:0;return(octave+1)*12+base;};const newNotes=(args.notes||[]).map((n,index)=>({id:`note_mod_${Date.now()}_${index}`,pitch:noteNameToMidi(n.pitch),start_beat:parseFloat(n.start_time||0),duration_beats:parseFloat(n.duration||1),velocity:n.velocity!==undefined?n.velocity/127.0:0.8,pan:0.0}));updateActiveTracks(prev=>prev.map(t=>{const items=t.midiItems||[];const exists=items.some(m=>m.id===itemId);if(!exists)return t;return{...t,midiItems:items.map(m=>m.id===itemId?{...m,notes:newNotes}:m)};}));return{success:true,itemId};}};window.DAWCommandDispatcher.registerDAWCommands(api);},[tracks,selectedTrackId,currentTime,bpm]);// ── Save AI config to localStorage ──
|
||
useEffect(()=>{localStorage.setItem('ai_base_url',aiConfig.baseUrl);localStorage.setItem('ai_api_key',aiConfig.apiKey);localStorage.setItem('ai_model',aiConfig.model);},[aiConfig]);// ── Auto-save user preferences (panel state, provider) ──
|
||
const prefsRef=useRef({});prefsRef.current={showAIPanel,showExportPanel,showSelectionPanel,showPythonToolsPanel,showMediaExplorer,showMixer,showFxRack,showMidiEvents,panelPositions,rightSidebarWidth,mediaExplorerHeight,selectedProviderId};useEffect(()=>{const prefs=prefsRef.current;localStorage.setItem('sonic_preferences',JSON.stringify(prefs));if(!currentUser||currentUser==='cached')return;const timer=setTimeout(async()=>{try{await window.SonicAPI.savePreferences(prefs);}catch(e){}},2000);return()=>clearTimeout(timer);},[showAIPanel,showExportPanel,showSelectionPanel,showPythonToolsPanel,showMediaExplorer,showMixer,showFxRack,showMidiEvents,panelPositions,rightSidebarWidth,mediaExplorerHeight,selectedProviderId,currentUser]);const drawMixerVuMeter=(canvas,peak)=>{if(!canvas)return;const ctx=canvas.getContext('2d');if(!ctx)return;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);ctx.fillStyle='#0d0d0d';ctx.fillRect(0,0,w,h);const barH=peak*h;const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#22c55e');grad.addColorStop(0.5,'#eab308');grad.addColorStop(0.85,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(0,h-barH,w,barH);};// Master & Track VU meter animation loop
|
||
const masterVUAnimRef=useRef(null);useEffect(()=>{let lastIdleDraw=0;function tick(){try{// Khi STOP (không play, không recording): vẽ VU RỖNG — không đọc analyser/
|
||
// activity → hết animation dính + master không full sau khi dừng play.
|
||
// ⚠️ NGOẠI LỆ: nếu có MIDI activity live (bật ARM track + bấm phím MIDI
|
||
// keyboard — KHÔNG play/record) thì KHÔNG early-return — track VU phải
|
||
// nhảy theo midiVuActivityRef + decay (user bug: ARM + phím MIDI → VU
|
||
// đứng yên vì block này vẽ rỗng và return trước khi đọc activity).
|
||
const isRecActive=activeAudioRecordersRef.current&&Object.keys(activeAudioRecordersRef.current).length>0;let liveMidiVu=false;try{for(const _k in midiVuActivityRef.current){if(midiVuActivityRef.current[_k]>0.001){liveMidiVu=true;break;}}}catch(e){}if(!isPlaying&&!isRecActive&&!liveMidiVu){// V45.46 throttle: vẽ VU rỗng ~10fps (throttle 100ms) — trước đây 60fps
|
||
// mỗi frame setState + vẽ canvas → GPU process CPU cao khi STOP. Vòng rAF
|
||
// vẫn chạy (skip-frame đóng vai poll: record/liveMidi không re-run effect
|
||
// vì deps chỉ [isPlaying]) nhưng không repaint các frame thừa.
|
||
const _nowIdle=performance.now();if(_nowIdle-lastIdleDraw>=100){lastIdleDraw=_nowIdle;setMasterVU(0);setMasterMeterPeak(0);Object.keys(trackVuRefs.current).forEach(k=>{const c=trackVuRefs.current[k];if(!c)return;if(k.endsWith('_mixer'))drawMixerVuMeter(c,0);else drawVuMeter(c,-60);});}masterVUAnimRef.current=requestAnimationFrame(tick);return;}// 1. Master VU Meter
|
||
if(masterBus&&masterBus.analyser){const data=new Uint8Array(128);masterBus.analyser.getByteTimeDomainData(data);let peak=0;for(let i=0;i<data.length;i++){const v=Math.abs(data[i]-128)/128;if(v>peak)peak=v;}// Deadband: bỏ noise nền (log 06:00 — master peak 0.008 liên tục dù
|
||
// không âm → master VU nhảy nhẹ mãi)
|
||
if(peak<0.01)peak=0;setMasterVU(peak);setMasterMeterPeak(prev=>Math.max(prev*0.97,peak));// Log master VU khi đang nhảy (chẩn đoán 06:00 — VU nhảy ngoài vùng âm)
|
||
if(peak>0.001){try{console.log('[VU-master] peak=',peak.toFixed(3));}catch(e){}}}// 2. Track VU Meters
|
||
const trackNodes=activeTrackNodesRef.current||{};const activeKeys=Object.keys(trackVuRefs.current);activeKeys.forEach(key=>{// Key phân biệt context (user 07:20 — tách VU MAIN vs SECTION-TAB):
|
||
// - '1' / '1_mixer' → MAIN track 1
|
||
// - '_sess_1' → SECTION-TAB track 1 (clone — id trùng main)
|
||
// trackId = id thuần (node lookup); vuKey = key midiVuActivityRef
|
||
// (context riêng — trigger fire theo context — hết lẫn nhau).
|
||
const trackIdRaw=key.replace('_mixer','');const isSessVu=trackIdRaw.indexOf('_sess_')===0;const trackId=isSessVu?trackIdRaw.slice(6):trackIdRaw;const vuKey=trackIdRaw;const isRecordingThisTrack=activeAudioRecordersRef.current&&activeAudioRecordersRef.current[trackId];if(isRecordingThisTrack)return;const node=trackNodes[trackId];const canvas=trackVuRefs.current[key];if(!canvas)return;// Section tab: track nodes nằm dưới key <parent>_sub_<trackId> —
|
||
// lookup plain key trả undefined → VU không animation. Fallback scan _sub_.
|
||
const vNode=node||function(){for(var k in trackNodes){if(k.endsWith('_sub_'+trackId))return trackNodes[k];}return null;}();// Only animate when real audio actually passes through the track (solo/mute aware)
|
||
var vuTracks=activeTracksRef.current||[];var anySoloVU=vuTracks.some(function(t){return t.solo;});var vuTrack=vuTracks.find(function(t){return t.id===trackId;});var isAudible=vuTrack?anySoloVU?!!vuTrack.solo:!vuTrack.muted:true;let audioPeak=0;if(isAudible&&vNode&&vNode.analyserNode){const analyser=vNode.analyserNode;const data=new Uint8Array(128);analyser.getByteTimeDomainData(data);for(let i=0;i<data.length;i++){const v=Math.abs(data[i]-128)/128;if(v>audioPeak)audioPeak=v;}}// Section item trên MAIN track: VU theo sub-nodes của track này (key
|
||
// <trackId>_sub_*) — âm section đi thẳng masterBus.input (không qua
|
||
// node chính) → node chính không có tín hiệu → VU track đứng yên.
|
||
// ⚠️ CHỈ fallback cho canvas SECTION (_sess_) — KHÔNG cho canvas MAIN:
|
||
// section-tab đang play → chuyển sang MAIN SESSION → VU track MAIN
|
||
// không được animate theo âm section (user bug).
|
||
if(audioPeak<=0.001&&isSessVu){for(var sk in trackNodes){if(sk.indexOf(trackId+'_sub_')===0){const sn=trackNodes[sk];if(sn&&sn.analyserNode){const d2=new Uint8Array(128);sn.analyserNode.getByteTimeDomainData(d2);for(let i=0;i<d2.length;i++){const v=Math.abs(d2[i]-128)/128;if(v>audioPeak)audioPeak=v;}}}}}// midiVuActivityRef được set bởi triggerMidiVuActivity — fire ĐÚNG NHỊP
|
||
// note (nhánh delayed: setTimeout khớp thời điểm phát; nhánh instant:
|
||
// note đang kêu). KHÔNG gate theo amplitude SF — gate chặn note đơn /
|
||
// velocity thấp (âm SF nhỏ < 0.03) → VU không nhảy (user bug 07:10).
|
||
let midiPeak=isAudible?midiVuActivityRef.current[vuKey]||0:0;if(midiPeak>0){// ARM + MIDI keyboard: còn note đang GIỮ → giữ nguyên peak, KHÔNG
|
||
// decay → VU animate đúng trường độ âm thanh (user bug 08:08: âm còn
|
||
// kêu nhưng VU tắt sau ~0.5s vì decay 0.75/frame). Hết note → decay
|
||
// 0.75 (~0.5s) tắt nhanh như trước.
|
||
const heldCnt=heldMidiNotesRef.current[vuKey]||0;if(heldCnt>0){// Giữ nguyên peak trong khi âm đang kêu (không decay)
|
||
}else{midiVuActivityRef.current[vuKey]=midiPeak*0.75;if(midiVuActivityRef.current[vuKey]<0.01){midiVuActivityRef.current[vuKey]=0;}}}// ⚠️ KHÔNG gộp sfAudioPeak vào audioPeak: SF là 1 output CHUNG cho mọi
|
||
// track MIDI → gộp làm track VU nhảy CÙNG NHAU (user bug 07:00).
|
||
// audioPeak chỉ theo âm TRACK thật (vNode/sub-node analyser).
|
||
// Deadband: bỏ noise nền analyser (log 07:15 — vNode noise dao động
|
||
// quanh 0.04 — track KHÔNG item (track 2 section) vẫn nhảy qua deadband
|
||
// 0.04) — hết âm → VU tắt NGAY LẬP TỨC (user yêu cầu).
|
||
if(audioPeak<0.05)audioPeak=0;const peak=Math.max(audioPeak,midiPeak);const db=peak>0?20*Math.log10(peak):-60;// Log chẩn đoán VU — in MỌI key (kể cả peak 0, rate ~1.3/giây) — xác
|
||
// định track nào + nguồn (audio/midi) khi VU diễn sai (user bug 07:15:
|
||
// track 2 section không item vẫn nhảy).
|
||
try{const _vf=window.__vuFrame=(window.__vuFrame||0)+1;if(_vf%45===0){console.log('[VU]',trackId,'audio=',audioPeak.toFixed(3),'midi=',midiPeak.toFixed(3),'key=',key,'peak=',Math.max(audioPeak,midiPeak).toFixed(3));}}catch(e){}if(peak>0.001){if(key.endsWith('_mixer')){drawMixerVuMeter(canvas,peak);}else{drawVuMeter(canvas,db);}}else{if(key.endsWith('_mixer')){drawMixerVuMeter(canvas,0);}else{drawVuMeter(canvas,-60);}}});}catch(e){console.warn('VU tick error:',e);}masterVUAnimRef.current=requestAnimationFrame(tick);}masterVUAnimRef.current=requestAnimationFrame(tick);return()=>{if(masterVUAnimRef.current)cancelAnimationFrame(masterVUAnimRef.current);};},[isPlaying]);const handleOpenProject=async(projectId,projectName)=>{setAppWarningModal({title:"Mở dự án",message:"Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.",isAlert:false,onConfirm:async()=>{try{const proj=await window.SonicAPI.getCloudProject(projectId);const parsed=JSON.parse(proj.data_json);let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];let restoredSubTabs=[];if(parsed.main_session){const result=deserializeProjectFromSchema(parsed);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;// Bỏ qua zoom ≤ 1 (data cũ lưu 1.0 — lỗi __currentZoom của Media
|
||
// Explorer) — áp dụng mới giữ zoom hiện tại/localStorage.
|
||
if(result.zoom>1&&setZoom)setZoom(result.zoom);// Restore transport loop selection (loop vùng chọn sau save/reload)
|
||
if(result.loopSelection!==undefined&&result.loopSelection){setIsLoopingSelection(true);if(result.selectionStart!=null&&result.selectionEnd!=null){setSelectionStart(result.selectionStart);setSelectionEnd(result.selectionEnd);}}if(result.masteringSettings)setMasteringSettings(result.masteringSettings);if(result.masterVolume!==undefined&&result.masterVolume!==null)setMasterVolume(result.masterVolume);if(result.masterVolL!==undefined&&result.masterVolL!==null)setMasterVolL(result.masterVolL);if(result.masterVolR!==undefined&&result.masterVolR!==null)setMasterVolR(result.masterVolR);if(result.masterLink!==undefined&&result.masterLink!==null)setMasterLink(result.masterLink);if(result.masterMuted!==undefined&&result.masterMuted!==null)setMasterMuted(result.masterMuted);if(result.masterAutoVol!==undefined)setMasterAutoVol(result.masterAutoVol);if(result.masterAutoOn!==undefined)setMasterAutoOn(result.masterAutoOn);}else{restoredTracks=(parsed.tracks||[]).map(function(t){const rest=Object.assign({},t);delete rest.height;return Object.assign({},rest,{buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null});});}setTracks(restoredTracks);loadAudioBuffersForTracks(restoredTracks).catch(function(err){console.warn('loadAudioBuffersForTracks error:',err);});trackMidiChannelsRef.current={};preloadTrackInstruments(restoredTracks).catch(function(err){console.warn('preloadTrackInstruments error:',err);});setBpm(restoredBpm.toString());setSelectedTrackId(restoredTracks[0]?.id||'1');setProjectName(proj.name);setCurrentProjectId(proj.id);if(restoredSessionTabs.length>0)setSessionTabs(restoredSessionTabs);if(restoredSubTabs.length>0)setSubTabs(restoredSubTabs);localStorage.setItem('sonic_project_name',proj.name);localStorage.setItem('sonic_project_id',proj.id);showToast('Đã nạp dự án "'+proj.name+'" thành công!',"success");}catch(e){showToast(e.message||"Lỗi khi nạp dự án","error");}}});};return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"h-full w-full flex flex-col bg-[#1e1e1e]"},/*#__PURE__*/React.createElement("header",{className:"h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none"},[{label:'File',items:[{label:'New Project',icon:'file-plus',shortcut:'Ctrl+N',action:()=>{// New project: đóng hết tab + reset MỌI trạng thái về mặc định
|
||
try{stopAllPlayback();}catch(e){}setSubTabs([]);setSessionTabs([]);setActiveTab('main');// Reset instrument/channel/route context đã load
|
||
trackMidiChannelsRef.current={};Object.keys(trackMidiBypassMap).forEach(function(k){delete trackMidiBypassMap[k];});Object.keys(trackAudioBypassMap).forEach(function(k){delete trackAudioBypassMap[k];});Object.keys(trackMasteringBypassMap).forEach(function(k){delete trackMasteringBypassMap[k];});setSelectedItemIds(new Set());setTracks([{id:'1',name:'Track 01',buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:'#0f766e',markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]},{id:'2',name:'Track 02',buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:'#1d4ed8',markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]}]);setSelectedTrackId('1');setProjectName('');setCurrentProjectId(null);localStorage.removeItem('sonic_project_name');localStorage.removeItem('sonic_project_id');showToast('New project created','info');}},{label:'Open Project...',icon:'folder-open',shortcut:'Ctrl+O',action:()=>setOpenProjectModalOpen(true)},{label:'Save Project',icon:'upload-cloud',shortcut:'Ctrl+S',action:()=>{// Save project + save TẤT CẢ dirty sub-tabs (user 07:35 — nhấn lưu ở
|
||
// MAIN phải lưu cả piano roll/audio tab đã sửa)
|
||
handleSaveProject();subTabsRef.current.filter(s=>s.isDirty).forEach(st=>{if(st.type==='PIANO_ROLL'){handleSaveMidiNotes(st.id,st.trackId,st.target_id,st.notes||[]);}else if(st.type==='SECTION'){handleSaveSectionTab(st.id);}else if(st.buffer){const subTrack=activeTracksRef.current.find(t=>t.id===st.trackId);if(subTrack){updateActiveTracks(prev=>prev.map(t=>t.id===st.trackId?{...t,buffer:st.buffer}:t));}}});showToast('Đã lưu dự án + các tab đã sửa','success');}},{label:'Save As...',icon:'download',shortcut:'Ctrl+Alt+S',action:()=>setSaveAsModalOpen(true)},{sep:true},{label:'Config AI Providers...',icon:'settings',action:()=>setAiConfigModalOpen(true)},{label:'Import Audio...',icon:'file-input',shortcut:'Ctrl+Alt+I',action:()=>{const input=document.createElement('input');input.type='file';input.accept='audio/*';input.onchange=async e=>{if(e.target.files[0]){addNewTrack();const rearrangeNewId=(tracks.length+1).toString();setTimeout(()=>loadFileOnTrack(rearrangeNewId,e.target.files[0]),100);}};input.click();showToast('Import audio','info');}},{label:'Export Mix...',icon:'file-output',action:()=>setShowExportPanel(true)},{label:'Bounce MIDI...',icon:'radio',action:()=>triggerBounceExport()},{label:'Export MIDI...',icon:'music',action:()=>triggerMidiExport()},{label:'Master FX Chain',icon:'wand-2',shortcut:'Ctrl+Shift+M',action:()=>setShowMasteringModal(true)},{sep:true},...(currentUser?[{label:'Profile',icon:'user',action:()=>setProfileModalOpen(true)}]:[]),...(currentUser&¤tUser.role==='admin'?[{label:'System Manager',icon:'settings',action:()=>setSystemManagerModalOpen(true)}]:[]),{label:'Logout',icon:'log-out',action:()=>handleLogout()}]},{label:'Edit',items:[{label:'Insert New Track',icon:'plus',shortcut:'Ctrl+I',action:addNewTrack},{label:'Insert Music to Track',icon:'music',shortcut:'Ctrl+Alt+I',action:()=>showToast('Select music file to insert','info')},{sep:true},{label:'Edit in New Tab',icon:'file-edit',shortcut:'Ctrl+E',action:()=>openTempTab()},{label:'Split at Playhead',icon:'scissors',shortcut:'S',action:()=>handleSplitTrack(selectedTrackId)},{label:'Merge Tracks',icon:'combine',shortcut:'Ctrl+M',action:()=>{handleMergeTracks();}},{sep:true},{label:'Undo',icon:'undo',shortcut:'Ctrl+Z',action:()=>{handleUndo();}},{label:'Redo',icon:'redo',shortcut:'Ctrl+Y',action:()=>{handleRedo();}},{sep:true},{label:'Copy',icon:'copy',shortcut:'Ctrl+C',action:()=>{handleCopyTrack();}},{label:'Cut',icon:'scissors',shortcut:'Ctrl+X',action:()=>{handleCutTrack();}},{label:'Paste',icon:'clipboard',shortcut:'Ctrl+V',action:handlePasteTrack},{sep:true},{label:'Delete Track',icon:'trash-2',shortcut:'Del',action:()=>{handleDeleteTrack();}}]},{label:'Insert',items:[...(!sessionTabs.some(s=>s.id===activeTab)?[{label:'Insert Section',icon:'folder-plus',action:insertSectionAtPlayhead}]:[]),{label:'Insert MIDI item',icon:'music',action:insertMidiItemAtPlayhead},{label:'Insert sound clip',icon:'file-input',action:insertSoundClipAtCursor},{label:'Chords panel',icon:'guitar',shortcut:'Shift+K',action:()=>window.__toggleChordsPanel()},{label:'Insert track',icon:'plus',action:insertTrackBelow}]},{label:'View',items:[{label:'Master Track',icon:'disc',action:()=>showToast('Master track view','info')},{label:'Maker View',icon:'layout',action:()=>showToast('Maker view','info')},{label:'Mixer',icon:'sliders',action:()=>window.__toggleMixerRef()},{label:'Tempo Track',icon:'timer',action:()=>showToast('Tempo track','info')},{label:'Video',icon:'film',action:()=>showToast('Video panel','info')},{label:'Media Explorer',icon:'folder-search',action:()=>window.__toggleMediaExplorerRef()}]},{label:'Tools',items:[{label:'Config AI Providers...',icon:'settings',action:()=>setAiConfigModalOpen(true)},{label:'AI MIDI Preset Manager...',icon:'sliders',action:()=>setAiPresetModalOpen(true)},{label:'DSP Tools Panel',icon:'wrench',action:()=>{// DSP Tool đã được move vào SUB-TAB editor (audioclip). Mở sub-tab
|
||
// edit cho track/clip đang chọn — nếu chưa có clip → mở panel cũ.
|
||
const t=activeTracks.find(x=>x.id===selectedTrackId);if(t&&(t.buffer||t.clips&&t.clips.length)){const clipId=t.clips&&t.clips[0]?t.clips[0].id:'default';handleEditClipInSubTab(t.id,clipId);}else{openPanel('python_tools');}}},{label:'Virtual MIDI Keyboard',icon:'piano',shortcut:'F2',action:()=>window.__toggleVkb()},{sep:true},{label:'Plugin Manager (SoundFont/VSTi)',icon:'zap',action:()=>{setPluginManagerModalOpen(true);window.SonicAPI.listPlugins().then(data=>setPluginsData(data)).catch(()=>{});}},{sep:true},{label:'Preferences...',icon:'settings-2',action:()=>setPreferencesModalOpen(true)}]},{label:'Help',items:[{label:'Hướng dẫn sử dụng...',icon:'book-open',action:()=>setHelpModalOpen(true)},{sep:true},{label:'About ScoreFX Studio...',icon:'info',action:()=>setAboutModalOpen(true)}]}].map(menu=>/*#__PURE__*/React.createElement("div",{key:menu.label,className:"relative"},/*#__PURE__*/React.createElement("button",{onClick:()=>setMenuOpen(menuOpen===menu.label?null:menu.label),className:`px-3 py-1 text-xs font-medium transition rounded ${menuOpen===menu.label?'bg-zinc-700 text-zinc-100':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`},menu.label),menuOpen===menu.label&&/*#__PURE__*/React.createElement("div",{className:`absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 z-50 ${menu.label==='Edit'?'w-72':'w-64'}`,onClick:()=>setMenuOpen(null)},menu.items.map((item,i)=>item.sep?/*#__PURE__*/React.createElement("div",{key:i,className:"h-px bg-zinc-700 my-1"}):/*#__PURE__*/React.createElement("button",{key:item.label,onClick:e=>{e.stopPropagation();item.action();setMenuOpen(null);},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":item.icon,className:"w-3.5 h-3.5 text-zinc-500 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},item.label),item.shortcut&&/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},item.shortcut)))))),/*#__PURE__*/React.createElement("div",{className:"flex-1"}),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 px-2"},/*#__PURE__*/React.createElement("span",{className:`text-xs font-bold uppercase px-1.5 py-0.5 rounded ${serverStatus==='connected'?'bg-emerald-950 text-emerald-400':serverStatus==='checking...'?'bg-amber-950 text-amber-400':'bg-red-950 text-red-400'}`},"Server: ",serverStatus,serverAddr?" · "+serverAddr:""),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowAIConfig(!showAIConfig),className:`px-1.5 py-0.5 rounded text-xs border transition ${showAIConfig?'bg-purple-900 text-purple-200 border-purple-700':'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3 h-3"}))))),menuOpen&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-40",onClick:()=>setMenuOpen(null)}),/*#__PURE__*/React.createElement("div",{className:"h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto"},/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('main'),className:`px-3 text-xs font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${activeTab==='main'?'text-cyan-400 border-cyan-500 bg-zinc-800/50':'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"layout-dashboard",className:"w-3 h-3"}))," Main Session"),sessionTabs.map(st=>{const isTabActive=activeTab===st.id;const tabColor=st.color||(isTabActive?'#06b6d4':null);const borderStyle=isTabActive?{borderBottomColor:tabColor,color:tabColor}:st.color?{color:st.color,borderBottomColor:'transparent'}:{};return/*#__PURE__*/React.createElement("div",{key:st.id,className:"flex items-stretch"},/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab(st.id),onAuxClick:e=>{if(e.button===1){e.preventDefault();closeSessionTab(st.id);}},onContextMenu:e=>{e.preventDefault();setTabContextMenu({x:e.clientX,y:e.clientY,tabId:st.id,tabType:'session'});},style:borderStyle,className:`px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${isTabActive?'bg-zinc-800/50':'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"layers",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"max-w-[120px] truncate"},st.name),st.isDirty&&/*#__PURE__*/React.createElement("span",{className:"w-1.5 h-1.5 rounded-full bg-amber-500 ml-1",title:"Chưa lưu thay đổi"})),/*#__PURE__*/React.createElement("button",{onClick:()=>closeSessionTab(st.id),className:"px-1 text-zinc-600 hover:text-red-400 transition text-xs",title:"Close tab"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))));}),subTabs.map(st=>{const isTabActive=activeTab===st.id;const tabColor=st.color||(isTabActive?'#f59e0b':null);const borderStyle=isTabActive?{borderBottomColor:tabColor,color:tabColor}:st.color?{color:st.color,borderBottomColor:'transparent'}:{};const iconName=st.type==='PIANO_ROLL'?'music':'file-edit';return/*#__PURE__*/React.createElement("div",{key:st.id,className:"flex items-stretch"},/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab(st.id),onAuxClick:e=>{if(e.button===1){e.preventDefault();closeSubTab(st.id);}},onContextMenu:e=>{e.preventDefault();setTabContextMenu({x:e.clientX,y:e.clientY,tabId:st.id,tabType:'sub'});},style:borderStyle,className:`px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${isTabActive?'bg-zinc-800/50':'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":iconName,className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"max-w-[100px] truncate"},st.label),st.isDirty&&/*#__PURE__*/React.createElement("span",{className:"w-1.5 h-1.5 rounded-full bg-amber-500 ml-1",title:"Chưa lưu thay đổi"})),/*#__PURE__*/React.createElement("button",{onClick:()=>closeSubTab(st.id),className:"px-1 text-zinc-600 hover:text-red-400 transition text-xs",title:"Close tab"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))));})),showAIConfig&&/*#__PURE__*/React.createElement("div",{className:"bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all"},/*#__PURE__*/React.createElement("div",{className:"text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-4 h-4"}))," Cấu hình cổng kết nối API"),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-1 md:grid-cols-3 gap-2 text-xs"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase"},"Endpoint Base URL"),/*#__PURE__*/React.createElement("input",{type:"text",value:aiConfig.baseUrl,onChange:e=>setAiConfig(prev=>({...prev,baseUrl:e.target.value})),className:"bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs",placeholder:"https://api.openai.com/v1"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase"},"API Token Key"),/*#__PURE__*/React.createElement("input",{type:"password",value:aiConfig.apiKey,onChange:e=>setAiConfig(prev=>({...prev,apiKey:e.target.value})),className:"bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs",placeholder:"sk-..."})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase"},"Model Name"),/*#__PURE__*/React.createElement("input",{type:"text",value:aiConfig.model,onChange:e=>setAiConfig(prev=>({...prev,model:e.target.value})),className:"bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs",placeholder:"gpt-4o-mini"})))),/*#__PURE__*/React.createElement("div",{className:"h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 shadow-lg",title:"Kéo để di chuyển toolbar",style:{cursor:'grab'}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-0.5 mr-1 text-zinc-600"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{setActiveTool('select');showToast('Select Tool','info');},className:`w-7 h-7 flex items-center justify-center rounded ${activeTool==='select'?'bg-cyan-700 text-white':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`,title:"Select Tool (V)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"mouse-pointer",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{setActiveTool('grab');showToast('Grab Tool','info');},className:`w-7 h-7 flex items-center justify-center rounded ${activeTool==='grab'?'bg-cyan-700 text-white':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`,title:"Grab Tool (H)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"hand",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5"},/*#__PURE__*/React.createElement("button",{onClick:()=>{setActiveTool('razor');showToast('Razor Tool','info');},className:`w-7 h-7 flex items-center justify-center rounded ${activeTool==='razor'?'bg-cyan-700 text-white':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`,title:"Razor Tool (C)"},/*#__PURE__*/React.createElement("svg",{className:"w-3.5 h-3.5 text-orange-400",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round"},/*#__PURE__*/React.createElement("path",{d:"M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"}),/*#__PURE__*/React.createElement("path",{d:"M4 9h16l-3 9H7z"}),/*#__PURE__*/React.createElement("circle",{cx:"12",cy:"6",r:"1"}))),/*#__PURE__*/React.createElement("button",{onClick:handleGlueTracks,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800",title:"Glue Clips"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"link",className:"w-3.5 h-3.5"})))),/*#__PURE__*/React.createElement("button",{onClick:()=>{setActiveTool('pen');showToast('Pen Tool','info');},className:`w-7 h-7 flex items-center justify-center rounded ${activeTool==='pen'?'bg-cyan-700 text-white':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`,title:"Pen Tool (P)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"pen-tool",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-700 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:handleCutTrack,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800",title:"Cut (Ctrl+X)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:handleCopyTrack,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800",title:"Copy (Ctrl+C)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:handlePasteTrack,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800",title:"Paste (Ctrl+V)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-700 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:addNewTrack,className:"px-2 py-1 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition",title:"Thêm Track Mới (Ctrl+I)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3.5 h-3.5"})),/*#__PURE__*/React.createElement("span",null,"Track")),sessionTabs.some(s=>s.id===activeTab)&&/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-700 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:()=>handleSaveSectionTab(activeTab),className:"px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition",title:"Lưu Section vào Main Session"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-3.5 h-3.5"})),/*#__PURE__*/React.createElement("span",null,"Lưu Section"))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-700 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:handleUndo,disabled:undoStack.length===0&&(!window.UndoRedoEngine||!window.UndoRedoEngine.canUndo()),className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30",title:"Undo (Ctrl+Z)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"undo",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:handleRedo,disabled:redoStack.length===0&&(!window.UndoRedoEngine||!window.UndoRedoEngine.canRedo()),className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30",title:"Redo (Ctrl+Y)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"redo",className:"w-3.5 h-3.5"})))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-800 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:()=>seekPlaybackTo(0),className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Quay lại đầu"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"skip-back",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{const isSubTab=subTabs.some(sub=>sub.id===activeTab);if(isSubTab){const st=subTabs.find(s=>s.id===activeTab);if(!st)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;if(left!==null)seekPlaybackTo(left);}else{if(selLeft!==null)seekPlaybackTo(selLeft);}},className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Đầu vùng chọn"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"step-back",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:handlePlayPause,className:`w-7 h-7 flex items-center justify-center rounded border transition ${isPlaying?'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500':'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`,title:isPlaying?"Tạm dừng":"Play"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":isPlaying?"pause":"play",className:"w-3.5 h-3.5 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:handleStop,className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Stop"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"square",className:"w-3.5 h-3.5 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:handleRecordClick,className:`w-7 h-7 flex items-center justify-center rounded border transition ${recordingState==='RECORDING'?'bg-red-600 text-white border-red-500 hover:bg-red-500 animate-pulse':recordingState==='COUNT_IN'?'bg-amber-500 text-black border-amber-400 hover:bg-amber-400 animate-pulse':'bg-zinc-800 text-red-500 border-zinc-700 hover:bg-zinc-700 hover:text-red-400'}`,title:recordingState==='RECORDING'?"Đang ghi âm...":recordingState==='COUNT_IN'?"Chuẩn bị ghi âm...":"Ghi âm (Record)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"circle",className:"w-3.5 h-3.5 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{const isSubTab=subTabs.some(sub=>sub.id===activeTab);if(isSubTab){setSubTabs(prev=>prev.map(s=>{if(s.id!==activeTab)return s;const right=s.selectionStart!==null&&s.selectionEnd!==null?Math.max(s.selectionStart,s.selectionEnd):null;return right!==null?{...s,currentTime:right}:s;}));}else{if(selRight!==null)setCurrentTime(selRight);}},className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Cuối vùng chọn"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"step-forward",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{const isSubTab=subTabs.some(sub=>sub.id===activeTab);if(isSubTab){setSubTabs(prev=>prev.map(s=>{if(s.id!==activeTab)return s;const duration=s.buffer?s.buffer.duration/(s.speed||1.0):0;return{...s,currentTime:duration};}));}else{setCurrentTime(maxDuration);}},className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Đến cuối"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"skip-forward",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-800 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:()=>{setIsLoopingSelection(prev=>!prev);// Sync loop state with active sub-tab (piano roll / audio editor)
|
||
const activeSub=activeTab&&subTabs.find(s=>s.id===activeTab&&['PIANO_ROLL','AUDIO_CLIP_EDITOR','SECTION_EDITOR'].includes(s.type));if(activeSub){const newLoop=!activeSub.isLooping;setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,isLooping:newLoop}:s));if(newLoop){const bpmVal=parseInt(bpm)||120;const beatSec=60.0/bpmVal;let maxEnd=0;if(activeSub.type==='PIANO_ROLL'){(activeSub.notes||[]).forEach(n=>{const end=(n.start_beat||0)+(n.duration_beats||1);if(end>maxEnd)maxEnd=end;});}else if(activeSub.type==='SECTION_EDITOR'){(activeSub.clips||[]).forEach(c=>{const end=(c.startTime||0)+(c.duration||0);if(end>maxEnd)maxEnd=end;});}else if(activeSub.type==='AUDIO_CLIP_EDITOR'){const dur=activeSub.buffer?.duration||0;if(dur>maxEnd)maxEnd=dur;}const loopEndTime=activeSub.type==='PIANO_ROLL'?Math.max(maxEnd,16)*beatSec+1.0:Math.max(maxEnd,1);setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,selectionStart:0,selectionEnd:loopEndTime}:s));}}else{// Main timeline / section tab: nếu user đã chọn vùng loop riêng →
|
||
// giữ nguyên (loop vùng chọn — nhánh updatePlayhead selLeft/
|
||
// selRight). KHÔNG auto-derive selection: LOOP bật → loop tại
|
||
// projectEnd (content end khi LOOP — 0515/0520) — nhánh
|
||
// updatePlayhead `updatedTime >= projectEndRef` loop lại từ 0 —
|
||
// tránh selection 0→end items ép loop toàn bài (sai ý "loop tại
|
||
// projectEnd").
|
||
const hasSel=selectionMode==='local'&&selLeft!==null&&selRight!==null&&selRight>selLeft||selectionStart!==null&&selectionEnd!==null;if(hasSel){// giữ selection — loop vùng chọn
|
||
}else{// không selection → loop tại projectEnd (không tạo selection)
|
||
}}},className:`w-7 h-7 flex items-center justify-center rounded border transition ${isLoopingSelection?'bg-amber-600 text-black border-amber-500 hover:bg-amber-500':'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`,title:isLoopingSelection?selLeft!==null&&selRight!==null?"Loop vùng chọn":"Loop timeline":"Bật loop"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("span",{className:"text-[14px] text-zinc-500 font-bold uppercase ml-2"},"Snap"),/*#__PURE__*/React.createElement("select",{value:snapValue,onChange:e=>onSnapChangeue(e.target.value),className:"bg-black text-white text-[14px] px-1.5 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer"},/*#__PURE__*/React.createElement("option",{value:"free"},"Free"),/*#__PURE__*/React.createElement("option",{value:"1"},"1"),/*#__PURE__*/React.createElement("option",{value:"1/2"},"1/2"),/*#__PURE__*/React.createElement("option",{value:"1/4"},"1/4"),/*#__PURE__*/React.createElement("option",{value:"1/8"},"1/8"),/*#__PURE__*/React.createElement("option",{value:"1/16"},"1/16"),/*#__PURE__*/React.createElement("option",{value:"1/32"},"1/32"),/*#__PURE__*/React.createElement("option",{value:"4"},"4")),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-6 bg-zinc-800 mx-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-[20px] text-zinc-500 font-bold ml-1"},"Time:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-[20px] text-cyan-300 font-bold tabular-nums",title:"Vị trí playhead"},formatTime(currentTime)),/*#__PURE__*/React.createElement("span",{className:"text-[20px] text-zinc-600 font-mono"},"/"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-[20px] text-zinc-400 tabular-nums",title:"Tổng thời gian dự án"},formatTime(projectEnd)),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 ml-3",title:bridgeUi.connected&&!bridgeUi.forceWasm?"Engine phát qua Native Bridge (C++ FluidSynth/sfizz/VST3)":"Engine phát qua SonicSF WASM"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border "+(bridgeUi.connected&&!bridgeUi.forceWasm?"text-emerald-400 border-emerald-700 bg-emerald-900/30":"text-zinc-500 border-zinc-700 bg-zinc-800")},bridgeUi.connected&&!bridgeUi.forceWasm?"Bridge":"WASM"),/*#__PURE__*/React.createElement("button",{onClick:()=>{const v=!bridgeUi.forceWasm;setBridgeUi(s=>({...s,forceWasm:v}));if(window.SonicMidiRouter&&window.SonicMidiRouter.setForceWasm)window.SonicMidiRouter.setForceWasm(v);},className:`px-1.5 py-0.5 rounded text-[9px] font-bold border transition ${bridgeUi.forceWasm?"bg-amber-600 text-black border-amber-500":"bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200"}`,title:"Debug: ép dùng SonicSF WASM bỏ qua bridge"},"Ép WASM")),/*#__PURE__*/React.createElement("div",{className:"flex-1"})),(()=>{const dockPanels={top:[],right:[],bottom:[],left:[]};const addPanel=(id,pos,visible)=>{if(visible)dockPanels[pos].push(id);};// Export panel giờ là MODAL FLOAT giữa màn hình (không nằm trong dock rows).
|
||
// Selection / FX Rack / MIDI Events panels đã REMOVE khỏi dock (theo yêu cầu
|
||
// user — FX Rack dùng modal float; Selection & MIDI Events không cần).
|
||
addPanel('ai',panelPositions.ai,showAIPanel);addPanel('python_tools',panelPositions.python_tools||'bottom',showPythonToolsPanel);const closePanel=id=>{if(id==='export')setShowExportPanel(false);else if(id==='ai')setShowAIPanel(false);else if(id==='python_tools')setShowPythonToolsPanel(false);else if(id==='selection')setShowSelectionPanel(false);else if(id==='fx_rack')setShowFxRack(false);else if(id==='midi_events')setShowMidiEvents(false);};const renderPanelContent=panelId=>{const h=id=>e=>{startPanelDrag(id,e);};if(panelId==='export')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('export',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-zinc-200 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-3.5 h-3.5 text-cyan-400"}))," Export"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('export'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Ngu\u1ed3n"),/*#__PURE__*/React.createElement("select",{value:exportSettings.source,onChange:e=>setExportSettings(p=>({...p,source:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"project"},"Project (Mix)"),/*#__PURE__*/React.createElement("option",{value:"track_mix"},"Track Selection"),/*#__PURE__*/React.createElement("option",{value:"active_clip"},"Active Clip"),/*#__PURE__*/React.createElement("option",{value:"clip_selection"},"Clip Selection"))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"\u0110\u1ecbnh d\u1ea1ng"),/*#__PURE__*/React.createElement("select",{value:exportSettings.format,onChange:e=>setExportSettings(p=>({...p,format:e.target.value,sampleRate:e.target.value==='wav'?'44100':e.target.value==='mp3'?'44100':'44100',bitDepth:e.target.value==='wav'?'16':'16',quality:'44khz'})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"wav"},"WAV"),/*#__PURE__*/React.createElement("option",{value:"mp3"},"MP3"),/*#__PURE__*/React.createElement("option",{value:"ogg"},"OGG")))),exportSettings.format==='wav'?/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"SR (Hz)"),/*#__PURE__*/React.createElement("select",{value:exportSettings.sampleRate,onChange:e=>setExportSettings(p=>({...p,sampleRate:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"22500"},"22500"),/*#__PURE__*/React.createElement("option",{value:"44100"},"44100"))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Bit"),/*#__PURE__*/React.createElement("select",{value:exportSettings.bitDepth,onChange:e=>setExportSettings(p=>({...p,bitDepth:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"8"},"8"),/*#__PURE__*/React.createElement("option",{value:"16"},"16"),/*#__PURE__*/React.createElement("option",{value:"24"},"24")))):/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Ch\u1ea5t l\u01b0\u1ee3ng"),/*#__PURE__*/React.createElement("select",{value:exportSettings.quality,onChange:e=>setExportSettings(p=>({...p,quality:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"44khz"},"44kHz"),/*#__PURE__*/React.createElement("option",{value:"lossless"},"Lossless"))),/*#__PURE__*/React.createElement("div",null)),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Kênh"),/*#__PURE__*/React.createElement("select",{value:exportSettings.channels,onChange:e=>setExportSettings(p=>({...p,channels:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"mono"},"Mono"),/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo"))),/*#__PURE__*/React.createElement("div",null)),/*#__PURE__*/React.createElement("button",{onClick:triggerBounceExport,disabled:isExporting,title:"Bounce realtime - file WAV day du MIDI + FX Rack + Mastering Chain (chay lai project that, thoi gian = do dai bai)",className:"w-full py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"radio",className:"w-3 h-3"})),isExporting?'...':'Bounce MIDI'),/*#__PURE__*/React.createElement("button",{onClick:triggerWavExport,disabled:isExporting,className:"w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"download-cloud",className:"w-3 h-3"})),isExporting?'...':'Export'));if(panelId==='ai'){const selMidiInfo=getSelectedMidiItemInfo();const hasSelItem=!!selMidiInfo;if(window.PromptTemplateManager&&!aiPromptMgrRef.current){aiPromptMgrRef.current=new window.PromptTemplateManager();}// Re-read from localStorage when presets change (e.g., AIPresetModal saved)
|
||
if(aiPromptMgrRef.current&&window.__aiPresetVersion!==aiPresetVersion){window.__aiPresetVersion=aiPresetVersion;aiPromptMgrRef.current.loadPresets();}const promptMgr=aiPromptMgrRef.current;const suggestions=promptMgr?promptMgr.presets:[];const handleApplySuggestion=preset=>{if(hasSelItem){setAiPrompt(`Rearrange this melody line in ${preset.name} style`);}else{setAiPrompt(preset.system_instruction_template);}setShowAiTypeahead(false);setAiSuggestions([]);};return/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1.5 flex-1 min-h-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('ai',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-zinc-200 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3.5 h-3.5 text-purple-400"}))," AI Copilot"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:()=>setAiPresetModalOpen(true),className:"text-zinc-600 hover:text-zinc-300 mr-0.5",title:"Preset Manager"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{setAiActionLog([]);showToast('Đã xoá nhật ký AI.','info');},className:"text-zinc-600 hover:text-zinc-300",title:"Clear log"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('ai'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 w-full min-w-0 pb-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3 h-3 text-purple-400"})),/*#__PURE__*/React.createElement("select",{value:selectedProviderId,onChange:e=>setSelectedProviderId(e.target.value),className:"flex-1 min-w-0 max-w-full bg-[#141414] text-zinc-200 border border-zinc-700 rounded px-1 py-0.5 text-xs font-mono focus:outline-none focus:border-purple-600 truncate"},aiProviders.length===0?/*#__PURE__*/React.createElement("option",{value:""},"Chưa có provider"):aiProviders.map(p=>/*#__PURE__*/React.createElement("option",{key:p.id,value:p.id},p.name,p.is_active?'':' (inactive)')))),/*#__PURE__*/React.createElement("div",{className:"border-t border-zinc-800 pt-1 mt-1 shrink-0"},/*#__PURE__*/React.createElement("button",{onClick:()=>{if(window.DAWCommandDispatcher&&window.DAWCommandDispatcher.undo){const entry=window.DAWCommandDispatcher.undo();if(entry){setAiActionLog(prev=>[...prev,{type:'undo',text:`Undo: ${entry.name}`,time:Date.now()}]);showToast(`Undo AI: ${entry.name}`,'info');}}else{handleUndo();setAiActionLog(prev=>[...prev,{type:'undo',text:'Undo (Ctrl+Z)',time:Date.now()}]);}},className:"w-full text-[10px] text-amber-400 hover:text-amber-300 border border-amber-800 rounded py-0.5"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"rotate-ccw",className:"w-3 h-3"}),"Undo"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 min-h-0 flex flex-col overflow-hidden mt-1"},/*#__PURE__*/React.createElement("div",{className:"text-[10px] font-bold text-zinc-400 uppercase shrink-0 pb-0.5 cursor-pointer hover:text-zinc-200 select-none",onClick:()=>setShowAIActionLog(!showAIActionLog)},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center gap-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"list",className:"w-3 h-3"})," Action Log",showAIActionLog?" \u2212":" +")),/*#__PURE__*/React.createElement("div",{ref:actionLogContainerRef,className:"flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text"+(showAIActionLog?'':' hidden')},aiActionLog.length===0?/*#__PURE__*/React.createElement("div",{className:"text-xs text-zinc-600 italic select-text p-1"},"Ch\u01B0a c\u00F3 h\u00E0nh \u0111\u1ED9ng n\u00E0o."):aiActionLog.map(function(entry,i){return/*#__PURE__*/React.createElement("div",{key:i,className:'text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 '+(entry.type==='error'?'text-red-400':entry.type==='status'?'text-zinc-400 italic':entry.type==='undo'?'text-amber-400':'text-zinc-300')},new Date(entry.time).toLocaleTimeString(),entry.text);}))),showAISuggestions?/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1 mb-1 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 text-[10px] font-bold text-zinc-400 uppercase"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sparkles",className:"w-3 h-3 text-purple-400"}))," AI Suggestion",hasSelItem?/*#__PURE__*/React.createElement("span",{className:"flex-1 text-right text-[10px] text-amber-400 font-semibold uppercase normal-case truncate ml-2"},"MIDI: ",selMidiInfo.itemName||selMidiInfo.itemId):null),/*#__PURE__*/React.createElement("div",{className:"overflow-y-auto no-scrollbar max-h-36 bg-[#0f0f0f] rounded border border-zinc-800"},suggestions.slice(0,50).map(p=>/*#__PURE__*/React.createElement("button",{key:p.id,onClick:()=>handleApplySuggestion(p),className:"w-full text-left px-2 py-1 text-[11px] hover:bg-zinc-800 border-b border-zinc-900 last:border-0 flex items-center justify-between gap-2"},/*#__PURE__*/React.createElement("span",{className:"truncate flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-400"},p.is_favorite?"★ ":"✨ "),/*#__PURE__*/React.createElement("span",{className:"text-zinc-200 font-semibold"},p.name)),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-zinc-500 shrink-0"},p.category))))):null,/*#__PURE__*/React.createElement("div",{className:"border-t border-zinc-800 pt-1.5 mt-1 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"message-square",className:"w-3 h-3"}))," Copilot Prompt",/*#__PURE__*/React.createElement("button",{onClick:()=>setShowAISuggestions(!showAISuggestions),className:"ml-auto text-[9px] px-1.5 py-0.5 rounded border font-semibold "+(showAISuggestions?'bg-zinc-800 text-zinc-400 border-zinc-700 hover:bg-zinc-700':'bg-indigo-950/40 text-indigo-400 border-indigo-800/50 hover:bg-indigo-900/50'),title:showAISuggestions?'Ẩn AI Suggestion':'Hiện AI Suggestion'},"Sug")),/*#__PURE__*/React.createElement("textarea",{value:aiPrompt,onChange:e=>{const v=e.target.value;aiPromptUndoPush(v);setAiPrompt(v);if(v.trim().length>=2&&promptMgr){const matches=promptMgr.presets.filter(p=>p.keywords.some(kw=>kw.toLowerCase().includes(v.toLowerCase()))||p.name.toLowerCase().includes(v.toLowerCase()));setAiSuggestions(matches);setShowAiTypeahead(matches.length>0);}else{setShowAiTypeahead(false);}},placeholder:hasSelItem?"Nhập lệnh rearrange... (VD: Jazz Swing, Arpeggio)":"Nhập lệnh điều khiển DAW... (VD: Tạo track mới tên Beat, đặt BPM 128)",className:"w-full bg-[#1e1e1e] text-zinc-200 p-1.5 rounded border border-zinc-700 focus:outline-none focus:border-purple-600 font-mono text-xs resize-y",rows:8,onKeyDown:e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'&&!e.shiftKey){e.preventDefault();e.stopPropagation();const u=aiPromptUndoRef.current;if(u.idx>0){u.idx--;setAiPrompt(u.stack[u.idx]);showToast('Undo: AI Prompt','info');}return;}if((e.ctrlKey||e.metaKey)&&(e.key==='y'||e.key==='z'&&e.shiftKey)){e.preventDefault();e.stopPropagation();const u=aiPromptUndoRef.current;if(u.idx<u.stack.length-1){u.idx++;setAiPrompt(u.stack[u.idx]);showToast('Redo: AI Prompt','info');}return;}if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();handleAISend();}else if(e.key==='Tab'&&showAiTypeahead&&aiSuggestions.length>0){e.preventDefault();handleApplySuggestion(aiSuggestions[0]);}else if(e.key==='ArrowUp'&&promptHistRef.current.length>0&&e.target.selectionStart===0){e.preventDefault();const idx=promptHistIdx===-1?promptHistRef.current.length-1:Math.max(0,promptHistIdx-1);setPromptHistIdx(idx);setAiPrompt(promptHistRef.current[idx]);}else if(e.key==='ArrowDown'&&e.target.selectionStart===aiPrompt.length){e.preventDefault();if(promptHistIdx===-1)return;const idx=promptHistIdx+1;if(idx>=promptHistRef.current.length){setPromptHistIdx(-1);setAiPrompt('');}else{setPromptHistIdx(idx);setAiPrompt(promptHistRef.current[idx]);}}}}),showAiTypeahead&&aiSuggestions.length>0&&/*#__PURE__*/React.createElement("div",{ref:aiTypeaheadRef,className:"absolute bottom-full left-0 right-0 bg-[#1e1e1e] border border-indigo-600/50 rounded-lg shadow-2xl z-50 max-h-36 overflow-y-auto mb-1"},/*#__PURE__*/React.createElement("div",{className:"px-2 py-1 text-[10px] uppercase tracking-wider font-semibold text-indigo-400 bg-[#141414] border-b border-zinc-800"},"Gợi ý (",aiSuggestions.length,")"),aiSuggestions.slice(0,8).map(p=>/*#__PURE__*/React.createElement("div",{key:p.id,onClick:()=>handleApplySuggestion(p),className:"px-2 py-1 hover:bg-indigo-700/30 cursor-pointer border-b border-zinc-800/30 flex items-center justify-between text-[11px]"},/*#__PURE__*/React.createElement("span",null,/*#__PURE__*/React.createElement("span",{className:"font-semibold text-zinc-200"},p.name),/*#__PURE__*/React.createElement("span",{className:"ml-1.5 text-zinc-500"},"(",p.category,")")),/*#__PURE__*/React.createElement("span",{className:"text-[10px] bg-zinc-800 text-zinc-400 px-1 py-0.5 rounded"},"Tab"))))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 mt-1 shrink-0"},/*#__PURE__*/React.createElement("button",{onClick:handleAISend,disabled:aiProcessing,className:"flex-1 py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-xs border border-purple-500 flex items-center justify-center gap-1"},aiProcessing?'Đang suy luận...':/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"send",className:"w-3 h-3"}))," Gửi")),/*#__PURE__*/React.createElement("button",{onClick:()=>setAiComposeMode(aiComposeMode==='EXTEND_CONTINUATION'?'SIMILAR_VARIATION':'EXTEND_CONTINUATION'),title:"Chọn mode AI: Variation (biến tấu cùng độ dài) / Extend (viết tiếp các bar sau)",className:"px-2 py-1 rounded text-[10px] border font-semibold flex items-center justify-center gap-1 "+(aiComposeMode==='EXTEND_CONTINUATION'?'bg-sky-800 text-sky-100 border-sky-600':'bg-amber-800 text-amber-100 border-amber-600')},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":aiComposeMode==='EXTEND_CONTINUATION'?"arrow-right":"shuffle",className:"w-3 h-3"})),aiComposeMode==='EXTEND_CONTINUATION'?'Extend':'Variation'),/*#__PURE__*/React.createElement("button",{onClick:()=>handleAiComposeFromItem(aiComposeMode),disabled:aiProcessing,title:"AI phân tích giai điệu MIDI item đang chọn → sáng tác theo mode đã chọn → ghi kết quả (Variation: track mới ngay dưới A/B; Extend: append sau item gốc)",className:"px-2 py-1 bg-teal-800 hover:bg-teal-700 text-white rounded text-[10px] border border-teal-600 flex items-center justify-center gap-1"+(hasSelItem?'':' opacity-40 pointer-events-none')},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music-2",className:"w-3 h-3"}))," Compose"),/*#__PURE__*/React.createElement("button",{onClick:()=>{setAiPrompt('');setAiActionLog([]);},className:"px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700"},"Clear")),/*#__PURE__*/React.createElement("div",{className:"text-xs text-zinc-600 shrink-0"},hasSelItem?"Enter gửi rearrange | Tab chọn gợi ý":"Enter để gửi nhanh"));}if(panelId==='python_tools')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('python_tools',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-amber-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"wrench",className:"w-3.5 h-3.5 text-amber-400"}))," DSP Tools"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('python_tools'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"p-1.5 bg-[#141414] rounded border border-zinc-800 text-[10px] font-mono text-zinc-400 space-y-0.5 leading-relaxed"},dspSelectionStats?[/*#__PURE__*/React.createElement("div",{key:"track"},`Track: ${dspSelectionStats.trackName}`),/*#__PURE__*/React.createElement("div",{key:"range"},`Range: ${dspSelectionStats.timeRange}`),/*#__PURE__*/React.createElement("div",{key:"ch"},`Channels: ${dspSelectionStats.channels}`),/*#__PURE__*/React.createElement("div",{key:"peak"},`Peak Vol: ${dspSelectionStats.peakVolume}`)]:"Chưa chọn track"),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1 text-xs"},/*#__PURE__*/React.createElement("button",{onClick:()=>runPythonTool('normalize'),className:"py-1 bg-amber-950/80 hover:bg-amber-900 text-amber-300 border border-amber-800/80 rounded font-bold flex items-center justify-center gap-1"},"⚡ Peak Norm (0dB)"),/*#__PURE__*/React.createElement("button",{onClick:()=>runPythonTool('invert_phase'),className:"py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1"},"🔄 Phase Invert"),/*#__PURE__*/React.createElement("button",{onClick:()=>runPythonTool('swap_channels'),className:"py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1"},"🔀 Swap L/R"),/*#__PURE__*/React.createElement("button",{onClick:()=>runPythonTool('synth_wave'),className:"py-1 bg-teal-950/80 hover:bg-teal-900 text-teal-300 border border-teal-800/80 rounded font-bold flex items-center justify-center gap-1"},"🎹 Gen Synth Tone")));if(panelId==='selection')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('selection',e)},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"}))," Selection"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('selection'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("span",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Start"),/*#__PURE__*/React.createElement("input",{type:"number",step:"0.01",value:selectionStats.start,onChange:e=>handleSelectionInputChange('start',e.target.value),className:"w-full bg-[#242424] text-amber-400 text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("span",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"End"),/*#__PURE__*/React.createElement("input",{type:"number",step:"0.01",value:selectionStats.end,onChange:e=>handleSelectionInputChange('end',e.target.value),className:"w-full bg-[#242424] text-amber-400 text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col justify-center"},/*#__PURE__*/React.createElement("span",{className:"text-[7px] text-zinc-500 font-bold uppercase"},"Len"),/*#__PURE__*/React.createElement("span",{className:"text-zinc-200 font-mono text-xs font-semibold mt-0.5"},selectionStats.length,"s"))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800 mt-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("span",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Begin Bar"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",value:beginBar,onChange:e=>{const b=parseInt(e.target.value)||0;setBeginBar(b);const beatDuration=60/parseInt(bpm||120);const t=b*beatDuration*4;clearLocalSelection();setSelectionMode('global');setSelectionStart(t);setSelectionEnd(t+beatDuration*4);},className:"w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("span",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"End Bar"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",value:endBar,onChange:e=>{const b=parseInt(e.target.value)||0;setEndBar(b);const beatDuration=60/parseInt(bpm||120);const t=b*beatDuration*4;// selectionEnd = vạch bar b (không +1 bar) — khớp hiển thị endBar
|
||
// (quét 8 bars hiển thị "0 đến 8"): nhập 8 → selection tới vạch 8.
|
||
setSelectionEnd(t);setNumberBar(Math.max(0,b-beginBar));},className:"w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col justify-center"},/*#__PURE__*/React.createElement("span",{className:"text-[7px] text-zinc-500 font-bold uppercase"},"# Bars"),/*#__PURE__*/React.createElement("span",{className:"text-zinc-200 font-mono text-xs font-semibold mt-0.5"},numberBar))));if(panelId==='fx_rack')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('fx_rack',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-rose-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3.5 h-3.5 text-rose-400"}))," Plugin FX Rack"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('fx_rack'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 text-xs text-zinc-500 italic flex items-center justify-center"},"No FX plugins loaded"));if(panelId==='midi_events')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('midi_events',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-sky-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-3.5 h-3.5 text-sky-400"}))," MIDI Event List"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('midi_events'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 text-xs text-zinc-500 italic flex items-center justify-center"},"No MIDI events selected"));return null;};const renderDock=(pos,title)=>{const panels=dockPanels[pos];if(panels.length===0)return null;const isSide=pos==='left'||pos==='right';const borderClass=pos==='left'?'border-r':pos==='right'?'border-l':pos==='top'?'border-b':'border-t';const bgClass='bg-[#1e1e1e]';const highlight=panelDragRef.current&&panelDropZone===pos;if(pos==='right')return/*#__PURE__*/React.createElement("div",{id:"right-sidebar",className:`${borderClass} ${bgClass} flex flex-col overflow-hidden select-none h-full`,style:{width:`${rightSidebarWidth}px`,minWidth:'200px',maxWidth:'600px',flexShrink:0}},/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col gap-2 p-2 overflow-hidden h-full"},panels.map((p,idx)=>/*#__PURE__*/React.createElement(React.Fragment,{key:p},/*#__PURE__*/React.createElement("div",{className:'flex flex-col flex-1 min-h-0 border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-3'},renderPanelContent(p)),idx<panels.length-1&&/*#__PURE__*/React.createElement("div",{className:"h-1.5 cursor-ns-resize hover:bg-cyan-500/50 transition-colors rounded shrink-0",onMouseDown:startRowResize})))));const sideClass=isSide?'shrink-0 overflow-y-auto':'shrink-0';return/*#__PURE__*/React.createElement("div",{id:"daw-bottom",className:`${sideClass} ${borderClass} ${bgClass} flex ${isSide?'flex-col p-2 gap-2':'flex-row p-1.5 gap-3'} select-none ${highlight?'ring-2 ring-cyan-500 ring-inset':''}`},panels.map(p=>/*#__PURE__*/React.createElement("div",{key:p,className:`${isSide?'w-full':'flex-none w-[320px]'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2`},renderPanelContent(p))));};return/*#__PURE__*/React.createElement("div",{ref:workspaceRef,className:"flex-1 flex flex-col overflow-hidden select-none daw-bg relative"},panelDragRef.current&&panelDropZone&&/*#__PURE__*/React.createElement("div",{className:"absolute inset-0 z-50 pointer-events-none"},panelDropZone==='top'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='bottom'&&/*#__PURE__*/React.createElement("div",{className:"absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='left'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='right'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"})),dragGhostPanel&&dragGhostPos&&/*#__PURE__*/React.createElement("div",{className:"fixed z-[100] pointer-events-none opacity-80 border border-cyan-500 rounded-lg bg-[#262626] shadow-2xl p-3 w-56",style:{left:dragGhostPos.x,top:dragGhostPos.y}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-xs text-zinc-200 font-bold"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"move",className:"w-3.5 h-3.5 text-cyan-400"})),dragGhostPanel==='export'?'Export Panel':dragGhostPanel==='ai'?'AI Panel':dragGhostPanel==='python_tools'?'Audio Processing Panel':'Selection Panel'),/*#__PURE__*/React.createElement("div",{className:"text-xs text-zinc-500 mt-1"},"Drop at edge to dock")),renderDock('top','Top'),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden"},renderDock('left','Left'),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col overflow-hidden min-w-0"},/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden"},activeTab==='main'||sessionTabs.some(s=>s.id===activeTab)?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{ref:tcpContainerRef,onScroll:handleTCPScroll,className:"shrink-0 z-20 bg-[#262626] overflow-y-auto flex flex-col border-r border-zinc-900 no-scrollbar",style:{width:tcpWidth+'px',scrollbarWidth:'none',msOverflowStyle:'none'}},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-300 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3.5 h-3.5 text-cyan-400"})),"TRACKS (",activeTracks.length,")"),/*#__PURE__*/React.createElement("button",{onClick:addNewTrack,className:"px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3 h-3"}))," Add Track")),/*#__PURE__*/React.createElement("div",{className:"sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between w-full"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-purple-400 font-mono"},"TM"),/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300"},"Tempo")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("input",{type:"number",value:bpm,onChange:e=>{setBpm(e.target.value);},onBlur:e=>{const v=e.target.value;if(v&&String(bpm)!==v)setBpmWithUndo(v);localStorage.setItem('studio_bpm',bpm);},onKeyDown:e=>{if(e.key==='Enter'){e.target.blur();}},className:"w-12 bg-zinc-800 border border-zinc-700 rounded text-xs text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500",min:"40",max:"300"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500"},"BPM")))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"},activeTracks.length===0?/*#__PURE__*/React.createElement("div",{className:"p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus-circle",className:"w-8 h-8 text-cyan-400 opacity-80"})),/*#__PURE__*/React.createElement("p",{className:"text-xs font-medium"},"Chưa có Track nào trong dự án."),/*#__PURE__*/React.createElement("button",{onClick:addNewTrack,className:"px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 shadow"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus-circle",className:"w-3.5 h-3.5"}))," Thêm Track Mới")):activeTracks.map((track,idx)=>{const isSelected=selectedTrackId===track.id;const autoHeight=track.height||(track.isArmed?164:140);return/*#__PURE__*/React.createElement("div",{key:track.id,style:{height:`${autoHeight}px`},className:`shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected?'border-cyan-500 bg-[#252525]':'border-transparent hover:bg-zinc-800/20'}`,onClick:()=>{// Chỉ CHỌN track — KHÔNG tự dừng main play + chuyển sang piano roll
|
||
// tab (trước đây gây "mất âm thanh khi click track number").
|
||
setSelectedTrackId(track.id);}},/*#__PURE__*/React.createElement("div",{className:"flex items-start justify-between"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-500 font-mono"},(idx+1).toString().padStart(2,'0')),/*#__PURE__*/React.createElement("label",{className:"cursor-pointer relative block"},/*#__PURE__*/React.createElement("input",{type:"color",value:track.color||'#0f766e',onClick:e=>e.stopPropagation(),onMouseDown:e=>e.stopPropagation(),onChange:e=>{e.stopPropagation();updateTrackColor(track.id,e.target.value);},className:"absolute inset-0 w-full h-full opacity-0 cursor-pointer"}),/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",style:{backgroundColor:track.color}})),editingTrackName===track.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{updateTrackName(track.id,editNameInput||track.name);setEditingTrackName(null);},onKeyDown:e=>{if(e.key==='Enter'){updateTrackName(track.id,editNameInput||track.name);setEditingTrackName(null);}if(e.key==='Escape')setEditingTrackName(null);},onClick:e=>e.stopPropagation(),className:"text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none"}):/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors",title:"Click to rename",onClick:e=>{e.stopPropagation();setEditingTrackName(track.id);setEditNameInput(track.name);}},track.name)),/*#__PURE__*/React.createElement("div",{className:"flex flex-wrap gap-0.5 max-w-[100px] mb-0.5"},(track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',name:track.name,startTime:track.startTime}]:[]).slice(0,3).map(c=>/*#__PURE__*/React.createElement("span",{key:c.id,className:"text-[9px] font-mono text-zinc-500 bg-zinc-800/60 rounded px-0.5 truncate max-w-[90px] cursor-pointer hover:text-cyan-400 hover:bg-zinc-700",title:c.name||track.name,onClick:e=>{e.stopPropagation();setSelectedTrackId(track.id);clearLocalSelection();setSelectionMode('global');const start=c.startTime||0;const end=start+(c.buffer?c.buffer.duration:2);setSelectionStart(start);setSelectionEnd(end);showToast(`Selected: ${c.name||track.name}`,'info');}},c.name||track.name),editingClipName&&editingClipName.trackId===track.id&&editingClipName.clipId===c.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{if(editNameInput.trim())updateClipName(track.id,c.id,editNameInput.trim());setEditingClipName(null);},onKeyDown:e=>{if(e.key==='Enter'){if(editNameInput.trim())updateClipName(track.id,c.id,editNameInput.trim());setEditingClipName(null);}if(e.key==='Escape')setEditingClipName(null);},onClick:e=>e.stopPropagation(),className:"w-20 text-[9px] font-mono bg-black text-cyan-300 border border-cyan-500 rounded px-0.5 py-0 outline-none"}):/*#__PURE__*/React.createElement("button",{className:"text-[9px] text-zinc-600 hover:text-cyan-400 ml-0.5 shrink-0",title:"Sửa tên clip",onClick:e=>{e.stopPropagation();setEditingClipName({trackId:track.id,clipId:c.id});setEditNameInput(c.name||track.name);}},/*#__PURE__*/React.createElement("i",{"data-lucide":"pencil",className:"w-2.5 h-2.5"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMute(track.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:!track.muted});},title:"Mute",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.muted?'bg-red-950 text-red-400 border-red-700':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.muted?"volume-x":"volume-2",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackSoloEvaluate(track.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:!track.solo});},title:"Solo",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.solo?'bg-amber-950 text-amber-400 border-amber-600':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.solo?"headphones":"headphone-off",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackDrum(track.id);},title:track.is_percussion?"Drum Channel (CH 10) - Click to disable":"Toggle Drum Channel (CH 10)",className:`px-1.5 py-0.5 text-[9px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.is_percussion?'bg-rose-900 text-rose-300 border-rose-700':'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`},/*#__PURE__*/React.createElement("span",{className:"text-[11px]"},"🥁"),track.is_percussion?/*#__PURE__*/React.createElement("span",{className:"text-[9px]"},"D"):null),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackArm(track.id);},title:"ARM (Record)",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.isArmed?'bg-red-600 text-white border-red-500 hover:bg-red-500':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":"circle",className:`w-2.5 h-2.5 ${track.isArmed?'fill-white':''}`})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMonitor(track.id);},title:"Input Monitor",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.monitoringEnabled?'bg-amber-600 text-white border-amber-500 hover:bg-amber-500':'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-305'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.monitoringEnabled?"mic":"mic-off",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();deleteTrack(track.id);},className:"p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3 h-3"}))))),function(){const _ls=window.__trackLoadState&&window.__trackLoadState[track.id];if(_ls&&_ls.state==='loading'){return/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 mt-0.5",title:"Đang nạp instrument vào bridge — track sẽ phát sau khi load xong"},/*#__PURE__*/React.createElement("div",{className:"flex-1 h-1 rounded bg-zinc-800 overflow-hidden"},/*#__PURE__*/React.createElement("div",{className:"h-full rounded sf-ruler-bar",style:{background:'#06b6d4',width:'45%'}})),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-cyan-400 font-mono shrink-0"},"Loading…"));}if(_ls&&_ls.state==='failed'){return/*#__PURE__*/React.createElement("div",{className:"mt-0.5 text-[9px] text-red-500 font-mono"},"Load failed");}if(_ls&&_ls.state==='done'&&_ls.doneTs&&Date.now()-_ls.doneTs<2500){return/*#__PURE__*/React.createElement("div",{className:"mt-0.5 h-1 rounded bg-green-600/30 overflow-hidden"},/*#__PURE__*/React.createElement("div",{className:"h-full bg-green-500 sf-ruler-done"}));}return null;}(),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-0.5 text-xs",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('vol:'+track.id,e.deltaY,e.deltaMode,100);if(!steps)return;const nv=Math.max(-50,Math.min(7,(track.volumeDb??0)+steps*0.5));updateTrackVolumeDb(track.id,nv);},onDoubleClick:e=>{e.stopPropagation();updateTrackVolumeDb(track.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-xs"},"Vol:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-50",max:"7",step:"0.5",value:track.volumeDb??0,onChange:e=>updateTrackVolumeDb(track.id,parseFloat(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-12 text-right font-mono text-zinc-300 text-xs"},track.volumeDb??0,"dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('pan:'+track.id,e.deltaY,e.deltaMode,100);if(!steps)return;const np=Math.max(-100,Math.min(100,(track.pan??0)+steps*5));updateTrackPan(track.id,np);},onDoubleClick:e=>{e.stopPropagation();updateTrackPan(track.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-xs"},"Pan:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",step:"1",value:track.pan??0,onChange:e=>updateTrackPan(track.id,parseInt(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-12 text-right font-mono text-zinc-300 text-xs"},track.pan>0?'R'+track.pan:track.pan<0?'L'+Math.abs(track.pan):'C')),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 mt-0.5"},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-[10px]"},"In:"),/*#__PURE__*/React.createElement("select",{value:`${track.inputSource?.deviceType||'NONE'}:${track.inputSource?.deviceId||''}`,onChange:e=>{const val=e.target.value;const parts=val.split(':');const type=parts[0];const id=parts.slice(1).join(':');updateTrackInputSource(track.id,type,id);},className:"flex-1 bg-[#18181b] text-zinc-300 text-[10px] rounded border border-zinc-700 focus:outline-none py-0.5 px-1 truncate max-w-[120px]"},/*#__PURE__*/React.createElement("option",{value:"NONE:"},"No Input"),/*#__PURE__*/React.createElement("optgroup",{label:"Microphones"},audioDevices.map(d=>/*#__PURE__*/React.createElement("option",{key:d.deviceId,value:`MICROPHONE:${d.deviceId}`},d.label||`Microphone ${d.deviceId.slice(0,5)}`))),/*#__PURE__*/React.createElement("optgroup",{label:"MIDI Keyboards"},/*#__PURE__*/React.createElement("option",{value:"MIDI_KEYBOARD:ALL"},"Any MIDI Keyboard"),midiDevices.map(d=>/*#__PURE__*/React.createElement("option",{key:d.id,value:`MIDI_KEYBOARD:${d.id}`},d.name||`MIDI Input ${d.id.slice(0,5)}`)))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-0.5 w-16 shrink-0 ml-1"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 text-[9px]"},"VU"),/*#__PURE__*/React.createElement("canvas",{ref:el=>{// TÁCH VU MAIN vs SECTION-TAB (user 07:20): section clone id TRÙNG
|
||
// main id → key canvas trùng → VU main/section lẫn nhau. Section
|
||
// strip dùng key '_sess_<id>' — VU tick parse theo key.
|
||
const _vuK=track._isSectionClone?'_sess_'+track.id:track.id;if(el)trackVuRefs.current[_vuK]=el;else delete trackVuRefs.current[_vuK];},width:100,height:4,className:"flex-1 bg-[#18181b] rounded h-1"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 mt-1",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("input",{type:"file",id:`upload-${track.id}`,accept:"audio/*",className:"hidden",onChange:e=>loadFileOnTrack(track.id,e.target.files[0])}),/*#__PURE__*/React.createElement("label",{htmlFor:`upload-${track.id}`,className:"px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-xs flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"upload",className:"w-3 h-3"}))," File"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();window.__openFxRack(track.id,track.name);},title:"Mở Track FX Chain",className:"px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-xs font-bold flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"wand-2",className:"w-3 h-3"}))," FX: ",/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 font-normal"},track.fxType||"None"),/*#__PURE__*/React.createElement("span",{title:"FX nhanh (cũ)",className:"ml-0.5 text-zinc-500 hover:text-purple-300 cursor-pointer",onClick:e=>{e.stopPropagation();setFxSelectorTrackId(track.id);}},"▾")),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();openInstrumentSelector(track.id);},className:"px-1.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-xs font-bold flex items-center gap-1 max-w-[90px] shrink-0"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-2.5 h-2.5 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"truncate text-xs min-w-0"},track.instrumentName||track.instrumentId||"Synth"),/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-down",className:"w-3 h-3 shrink-0"})),track.instrumentId&&typeof track.instrumentId==='string'&&!track.instrumentId.startsWith('sf_')?/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();ensureAndOpenVstGui(track);},title:"Mở lại GUI VSTi",className:"px-1.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-violet-300 border border-violet-800 rounded text-xs font-bold flex items-center gap-1 shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders-horizontal",className:"w-3 h-3 shrink-0"}),/*#__PURE__*/React.createElement("span",{className:"truncate text-xs min-w-0"},"GUI")):null),/*#__PURE__*/React.createElement("div",{onMouseDown:e=>handleTrackResizeMouseDown(e,track.id),className:"absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors",onClick:e=>e.stopPropagation()}));})),/*#__PURE__*/React.createElement("div",{className:"h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0"})),/*#__PURE__*/React.createElement("div",{className:"w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",onMouseDown:startTcpResize}),/*#__PURE__*/React.createElement("div",{ref:handleTimelineWrapperRef,onScroll:handleTimelineScroll,className:"flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${timelineWidth}px`},className:"relative flex flex-col min-h-full"},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-30 bg-[#1a1a1a]"},/*#__PURE__*/React.createElement(TimelineRuler,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:timelineWidth,viewportWidth:viewportWidth,onPlayheadSet:handlePlayheadSet,snapValue:snapValue,onRulerMouseDown:e=>{setSelectionFollowsTempo(false);handleRulerMouseDown(e);},scrollLeft:scrollLeft,canvasRedrawCount:canvasRedrawCount}),/*#__PURE__*/React.createElement(TempoTrackLane,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:timelineWidth,viewportWidth:viewportWidth,onPlayheadSet:handlePlayheadSet,snapValue:snapValue,onRulerMouseDown:e=>{setSelectionFollowsTempo(true);handleRulerMouseDown(e);},scrollLeft:scrollLeft,canvasRedrawCount:canvasRedrawCount})),selectionMode==='global'&&selLeft!==null&&selRight!==null&&selRight>selLeft&&/*#__PURE__*/React.createElement("div",{className:"absolute inset-0 pointer-events-none z-20",style:{left:`${selLeft*zoom}px`,width:`${(selRight-selLeft)*zoom}px`,top:'80px'}},/*#__PURE__*/React.createElement("div",{className:"w-full h-full bg-amber-500/10",style:{borderLeft:'1px solid #f59e0b',borderRight:'1px solid #f59e0b'}})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full",onDragOver:e=>e.preventDefault(),onDrop:async e=>{e.preventDefault();// Media Explorer drag (client FS handle / server file_id / local path)
|
||
if(window.__mediaExplorerDragFile){const f=await resolveMediaExplorerDropFile();window.__mediaExplorerDragFile=null;if(!f)return;if(/\.mid$|\.midi$/i.test(f.name||'')){handleDropMidiToNewTracks(f);}else if(selectedTrackId){loadFileOnTrack(selectedTrackId,f);}return;}const f=e.dataTransfer.files&&e.dataTransfer.files[0];if(!f)return;if(/\.mid$|\.midi$/i.test(f.name||'')){handleDropMidiToNewTracks(f);}else if(selectedTrackId){loadFileOnTrack(selectedTrackId,f);}},onMouseDown:e=>{if(e.ctrlKey&&!e.shiftKey&&!e.altKey&&e.button===0){const wrapper=timelineWrapperRef.current;if(wrapper){const rect=wrapper.getBoundingClientRect();const sl=wrapper.scrollLeft;const raw=Math.max(0,(e.clientX-rect.left+sl)/zoom-leadInMargin);const time=snapValue!=='free'?snapTime(raw,snapValue,bpm):raw;handleSweepSelectStart(null,time);}}}},activeTracks.map((track,idx)=>{const isSelected=selectedTrackId===track.id;const autoHeight=track.height||(track.isArmed?164:140);return/*#__PURE__*/React.createElement("div",{key:track.id,style:{height:`${autoHeight}px`},className:`shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected?'bg-zinc-800/10':''}`,onDragOver:e=>e.preventDefault(),onDrop:async e=>{e.preventDefault();e.stopPropagation();// Media Explorer drag (client FS handle / server file_id / local path)
|
||
if(window.__mediaExplorerDragFile){const f=await resolveMediaExplorerDropFile();window.__mediaExplorerDragFile=null;if(f)loadFileOnTrack(track.id,f);return;}const f=e.dataTransfer.files&&e.dataTransfer.files[0];if(!f)return;loadFileOnTrack(track.id,f);},onMouseEnter:()=>{setHoveredTrackId(track.id);hoveredTrackIdRef.current=track.id;}},/*#__PURE__*/React.createElement(WaveformLane,{track:track,zoom:zoom,timelineWidth:timelineWidth,viewportWidth:viewportWidth,scrollLeft:scrollLeft,onSelectRange:handleSelectRange,onPlayheadSet:handlePlayheadSet,isSelected:isSelected,selectedItemIds:selectedItemIds,onSelectTrack:setSelectedTrackId,markers:track.markers,onTrackLaneMouseDown:handleTrackLaneMouseDown,onClearSelection:()=>{captureSelectionUndo();setSelectedItemIds(new Set());},onSweepSelectStart:handleSweepSelectStart,onDeselectItem:handleDeselectItem,onAddToSelection:handleAddToSelection,onSetPendingDrag:handleSetPendingDrag,onSetPendingDragMove:handleSetPendingDragMove,onContextMenu:handleContextMenu,onClipDragStart:handleClipDragStart,onClipStretchStart:handleClipStretchStart,onSectionItemDragStart:handleSectionItemDragStart,onSectionItemResizeStart:handleSectionItemResizeStart,onSelectionEdgeDragStart:handleSelectionEdgeDragStart,setSelectedClipId:setSelectedClipId,selectedClipId:selectedClipId,activeTool:activeTool,onSplitTrackAtTime:handleSplitTrackAtTime,onEditClipInSubTab:handleEditClipInSubTab,onEditSectionInTab:handleEditSectionInTab,onEditMidiInTab:handleEditMidiInTab,snapValue:snapValue,bpm:bpm,selectionMode:selectionMode,localSelectionTrackId:localSelectionTrackId,localSelectionStart:localSelectionStart,currentTime:currentTime,getLocalAnchor:()=>localSelectionAnchorRef.current,onClearLocalSelection:()=>{captureSelectionUndo();clearLocalSelection();},onSetSelectionMode:mode=>{captureSelectionUndo();setSelectionMode(mode);},onSetSelectionStart:val=>{captureSelectionUndo();setSelectionStart(val);},onSetSelectionEnd:val=>{captureSelectionUndo();setSelectionEnd(val);},onSetCurrentTime:setCurrentTime,onSetLocalSelectionTrackId:setLocalSelectionTrackId,onSetLocalSelectionStart:setLocalSelectionStart,onSetLocalSelectionEnd:setLocalSelectionEnd,localSelLeft:localSelectionStart!==null&&localSelectionEnd!==null?Math.min(localSelectionStart,localSelectionEnd):null,localSelRight:localSelectionStart!==null&&localSelectionEnd!==null?Math.max(localSelectionStart,localSelectionEnd):null,scrollLeft:scrollLeft,recordingState:recordingState,recTempMidiNotes:recTempMidiNotes,recTempAudioBuffer:recTempAudioBuffer,recStartTimelineTime:recStartTimelineTime,canvasRedrawCount:canvasRedrawCount}),selectionMode==='local'&&localSelectionTrackId===track.id&&localSelectionStart!==null&&localSelectionEnd!==null&&Math.abs(localSelectionEnd-localSelectionStart)>0&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",style:{left:`${Math.min(localSelectionStart,localSelectionEnd)*zoom}px`,width:`${Math.abs(localSelectionEnd-localSelectionStart)*zoom}px`}},/*#__PURE__*/React.createElement("div",{className:"absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",onMouseDown:e=>handleHandleDragStart(e,'left')}),/*#__PURE__*/React.createElement("div",{className:"absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",onMouseDown:e=>handleHandleDragStart(e,'right')})),sweepSelect&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/10 z-20 pointer-events-none",style:{left:`${Math.min(sweepSelect.startTime,sweepSelect.endTime)*zoom}px`,width:`${Math.abs(sweepSelect.endTime-sweepSelect.startTime)*zoom}px`}}),track.buffer&&/*#__PURE__*/React.createElement("div",{className:"absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100"},/*#__PURE__*/React.createElement("button",{onClick:()=>handleSplitTrack(track.id),className:"px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-xs flex items-center gap-1 border border-zinc-700/50"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-2.5 h-2.5 text-cyan-400"}))," Cắt")),/*#__PURE__*/React.createElement("div",{onMouseDown:e=>handleTrackResizeMouseDown(e,track.id),className:"absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors",onClick:e=>e.stopPropagation()}));}),/*#__PURE__*/React.createElement("div",{className:"h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800",onMouseEnter:()=>{if(draggedClipRef.current||draggedSectionItemRef.current){setHoveredTrackId(addNewTrack());}},onClick:addNewTrack},/*#__PURE__*/React.createElement("span",{className:"flex items-center gap-1 text-zinc-400"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3.5 h-3.5"}))," Kéo clip xuống hoặc Click tạo Track")),selectionMode==='global'&&selLeft!==null&&selRight!==null&&selRight>selLeft&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing",style:{left:`${selLeft*zoom}px`,width:`${(selRight-selLeft)*zoom}px`},onMouseDown:handleSelectionBodyDragStart},/*#__PURE__*/React.createElement("div",{className:"absolute -left-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30",onMouseDown:e=>handleHandleDragStart(e,'left')}),/*#__PURE__*/React.createElement("div",{className:"absolute -right-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30",onMouseDown:e=>handleHandleDragStart(e,'right')})),/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none",style:{left:`${playheadLeftPos}px`}},/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"})))))):(()=>{const st=subTabs.find(s=>s.id===activeTab);if(!st)return null;if(st.type==='PIANO_ROLL'){return/*#__PURE__*/React.createElement(PianoRollTabEditor,{st:st,zoom:zoom,bpm:bpm,viewportWidth:viewportWidth,activeTracks:activeTracks,onClose:()=>closeSubTab(st.id),onUpdateNotes:handleUpdateMidiNotes,onSaveNotes:handleSaveMidiNotes,setSubTabs:setSubTabs,onPlayPause:handlePlayPause,onStop:()=>stopAllPlayback(true),isPlaying:isPlaying,playPreviewNote:playMidiPreviewNote,showToast:showToast,midiDevices:midiDevices,recordingState:recordingState,recTempMidiNotes:recTempMidiNotes,onRecord:handleRecordClick,selectedMidiInputId:selectedMidiInputId,onMidiInputSelect:handleMidiInputSelect,activeMidiPitches:activeMidiPitches,onInstrumentSelect:trackId=>{openInstrumentSelector(trackId);},onRealtimePlay:handlePianoRollRealtimePlay,onCopyNotes:n=>setPianoRollClipboard(JSON.parse(JSON.stringify(n||[]))),clipboardNotes:pianoRollClipboard,onExportMidiAudio:handleExportMidiAudio,onRescheduleMidi:updatedNotes=>{const playingSub=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL'&&s.isPlaying);if(playingSub){const offset=playingSub.currentTime||0;const ctx=getAudioContext();const tNode=activeTrackNodesRef.current[playingSub.trackId];if(tNode&&tNode.gainNode){tNode.gainNode.gain.setValueAtTime(tNode.gainNode.gain.value||1,ctx.currentTime);tNode.gainNode.gain.linearRampToValueAtTime(0.001,ctx.currentTime+0.04);}setTimeout(()=>{window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();if(tNode&&tNode.gainNode){const trackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===playingSub.trackId):null;const volDb=trackData?trackData.volumeDb??0:0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);tNode.gainNode.gain.setValueAtTime(0.001,ctx.currentTime);tNode.gainNode.gain.linearRampToValueAtTime(volLinear||0.8,ctx.currentTime+0.015);}startOffsetTimeRef.current=offset;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=offset*(playingSub.speed||1.0);schedulePianoRollMidi(playingSub,offset,updatedNotes);},50);}},onSeekPlayhead:clickTime=>{const seekSt=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(!seekSt)return;if(seekSt.isPlaying){fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime
|
||
stopAllPlayback(false,true);window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();setSubTabs(prev=>prev.map(s=>s.id===seekSt.id?{...s,currentTime:clickTime,isPlaying:true}:s));const ctx=getAudioContext();startOffsetTimeRef.current=clickTime;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=clickTime*(seekSt.speed||1.0);schedulePianoRollMidi(seekSt,clickTime);startSubTabPlayback(seekSt,clickTime);}else{setSubTabs(prev=>prev.map(s=>s.id===seekSt.id?{...s,currentTime:clickTime}:s));}}});}const subTrack=tracks.find(t=>t.id===st.trackId);const vTrack=subTrack?{...subTrack,buffer:st.buffer,isSubTab:true}:null;const subTabDuration=st.type==='PIANO_ROLL'?(()=>{const notes=st.notes||[];const beatSec=60.0/(parseFloat(bpm)||120);let maxEnd=0;notes.forEach(n=>{const end=(n.start_beat||0)+(n.duration_beats||1);if(end>maxEnd)maxEnd=end;});return maxEnd*beatSec+1.0;})():st.buffer&&'duration'in st.buffer?st.buffer.duration:4.0;const subTabTimelineWidth=Math.max(zoom*subTabDuration,viewportWidth);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",style:{width:tcpWidth+'px',scrollbarWidth:'none',msOverflowStyle:'none'}},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-500 uppercase"},"Sub-Tab"),/*#__PURE__*/React.createElement("button",{onClick:()=>closeSubTab(st.id),className:"px-1.5 py-0.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))," Close")),vTrack?/*#__PURE__*/React.createElement("div",{key:vTrack.id,className:"flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between mb-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("label",{className:"cursor-pointer relative block"},/*#__PURE__*/React.createElement("input",{type:"color",value:vTrack.color||'#0f766e',onClick:e=>e.stopPropagation(),onMouseDown:e=>e.stopPropagation(),onChange:e=>{e.stopPropagation();updateTrackColor(vTrack.id,e.target.value);},className:"absolute inset-0 w-full h-full opacity-0 cursor-pointer"}),/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",style:{backgroundColor:vTrack.color}})),editingTrackName===vTrack.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{updateTrackName(vTrack.id,editNameInput||vTrack.name);setEditingTrackName(null);},onKeyDown:e=>{if(e.key==='Enter'){updateTrackName(vTrack.id,editNameInput||vTrack.name);setEditingTrackName(null);}if(e.key==='Escape')setEditingTrackName(null);},onClick:e=>e.stopPropagation(),className:"text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none"}):/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors",title:"Click to rename",onClick:e=>{e.stopPropagation();setEditingTrackName(vTrack.id);setEditNameInput(vTrack.name);}},vTrack.name)),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMute(vTrack.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(vTrack.id,{muted:!vTrack.muted});},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.muted?'bg-red-950 text-red-400 border-red-700':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"M"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackSoloEvaluate(vTrack.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(vTrack.id,{solo:!vTrack.solo});},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.solo?'bg-amber-950 text-amber-400 border-amber-600':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"S"))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-2.5 text-[14px] mb-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('vol:'+vTrack.id,e.deltaY,e.deltaMode,100);if(!steps)return;const nv=Math.max(-50,Math.min(7,(vTrack.volumeDb??0)+steps*0.5));updateTrackVolumeDb(vTrack.id,nv);},onDoubleClick:e=>{e.stopPropagation();updateTrackVolumeDb(vTrack.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Vol:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-50",max:"7",step:"0.5",id:`tcp-vol-${st.id}`,value:vTrack.volumeDb??0,onChange:e=>updateTrackVolumeDb(vTrack.id,parseFloat(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-16 text-right font-mono text-zinc-300 text-[14px]",id:`tcp-vol-label-${st.id}`},vTrack.volumeDb??0,"dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('pan:'+vTrack.id,e.deltaY,e.deltaMode,100);if(!steps)return;const np=Math.max(-100,Math.min(100,(vTrack.pan??0)+steps*5));updateTrackPan(vTrack.id,np);},onDoubleClick:e=>{e.stopPropagation();updateTrackPan(vTrack.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Pan:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",step:"1",id:`tcp-pan-${st.id}`,value:vTrack.pan??0,onChange:e=>updateTrackPan(vTrack.id,parseInt(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-16 text-right font-mono text-zinc-300 text-[14px]",id:`tcp-pan-label-detailed-${st.id}`},vTrack.pan>0?'R'+vTrack.pan:vTrack.pan<0?'L'+Math.abs(vTrack.pan):'C')),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 text-[14px]"},/*#__PURE__*/React.createElement("button",{onClick:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,isLooping:!s.isLooping}:s)),className:`px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping?'bg-amber-700 text-white border-amber-500':'bg-zinc-800 text-zinc-400 border-zinc-700'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>updateSubTabEffects(st.id,{reverse:!(st.effects||{}).reverse}),className:`px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects||{}).reverse?'bg-zinc-600 text-white border-zinc-500':'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`,title:"Reverse"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-left-right",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Loop:"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",max:"999",value:st.loopCount||0,onChange:e=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,loopCount:Math.max(0,parseInt(e.target.value)||0)}:s)),className:"w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono",title:"Loop count"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase mt-2"},/*#__PURE__*/React.createElement("span",null,"DSP"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-600 text-[11px] normal-case"},"áp dụng vùng chọn / cả clip")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-1 mb-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'invert_phase',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-down-up",className:"w-3 h-3"})),"Phase Inv"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'swap_channels',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"shuffle",className:"w-3 h-3"})),"Swap L/R"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'reverse',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-left-right",className:"w-3 h-3"})),"Reverse")),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 justify-between my-2.5"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Normalize"},"Norm"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",step:"0.1",value:subTabNormVal,onChange:e=>setSubTabNormVal(parseFloat(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabNormVal,"dB"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'normalize',subTabNormVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply")),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Pitch Shift"},"Pitch"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"12",step:"0.5",value:subTabPitchVal,onChange:e=>setSubTabPitchVal(parseFloat(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabPitchVal>0?'+':'',subTabPitchVal,"st"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'pitch',subTabPitchVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply")),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Gain Multiplier"},"Gain"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0",max:"150",step:"1",value:subTabGainVal,onChange:e=>setSubTabGainVal(parseInt(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabGainVal,"%"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'gain',subTabGainVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply"))),/*#__PURE__*/React.createElement("div",{className:"flex-1"}),/*#__PURE__*/React.createElement("div",{className:"mt-auto pt-2.5 border-t border-zinc-800"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase"},/*#__PURE__*/React.createElement("span",null,"Duration:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-300"},st.buffer?formatTime(subTabDuration):'0s')),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase"},/*#__PURE__*/React.createElement("span",null,"SR:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-300"},st.buffer?st.buffer.sampleRate:0," Hz")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1 mb-2"},/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;handleAIScan();},disabled:analysisState.isRunning,className:"py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"map-pin",className:"w-3 h-3"}))," Scan"),/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;handleAICutToNewTrack();},disabled:analysisState.isRunning,className:"py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3 h-3"}))," Cut"),/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;if(st.buffer){setSelectionRangeOnBuffer(st.buffer,st.selectionStart||0,st.selectionEnd||st.buffer.duration);}handleAIAnalysicLoop();},disabled:analysisState.isRunning,className:"py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1 col-span-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sparkles",className:"w-3 h-3"}))," AI Analysic Loop")),/*#__PURE__*/React.createElement("button",{onClick:()=>exportSubTabBuffer(st.id),className:"w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"download",className:"w-4 h-4"}))," Export"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTab(st.id),className:"w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-4 h-4"}))," Save"))):/*#__PURE__*/React.createElement("div",{className:"flex-1 flex items-center justify-center text-xs text-zinc-500"},"Track not found")),/*#__PURE__*/React.createElement("div",{className:"w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",onMouseDown:startTcpResize}),/*#__PURE__*/React.createElement("div",{ref:handleTimelineWrapperRef,className:"flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${subTabTimelineWidth}px`},className:"relative flex flex-col min-h-full"},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-30 bg-[#1a1a1a]"},/*#__PURE__*/React.createElement(TimelineRuler,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:subTabTimelineWidth,viewportWidth:viewportWidth,onPlayheadSet:setCurrentTime,snapValue:snapValue,onRulerMouseDown:e=>{setSelectionFollowsTempo(false);const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const sl=wrapper.scrollLeft;const raw=Math.max(0,(e.clientX-rect.left+sl)/zoom);const t=snapValue!=='free'?snapTime(raw,snapValue,bpm):raw;if(e.ctrlKey){e.preventDefault();e.stopPropagation();setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,selectionStart:null,selectionEnd:null}:s));return;}if(e.shiftKey){e.preventDefault();e.stopPropagation();}handlePlayheadSet(t);subTabDragStartRef.current=t;isDraggingSubTabRef.current=true;},scrollLeft:scrollLeft,canvasRedrawCount:canvasRedrawCount})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col relative bg-[#111111] min-h-full"},vTrack&&/*#__PURE__*/React.createElement("div",{style:{height:`${subTabHeight}px`},className:"relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0"},/*#__PURE__*/React.createElement(SubTabWaveform,{buffer:st.buffer,subTabId:st.id,activeTab:activeTab,activeTool:activeTool,currentTime:st.currentTime,selectionStart:st.selectionStart,selectionEnd:st.selectionEnd,onSelectRange:(start,end)=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionStart:start,selectionEnd:end}:s)),onPlayheadSet:time=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:time}:s)),onContextMenu:(e,clickTime)=>setContextMenu({x:e.clientX,y:e.clientY,isSubTab:true,subTabId:st.id,time:clickTime}),zoom:zoom,timelineWidth:subTabTimelineWidth,color:vTrack.color,name:vTrack.name,speed:st.speed||1.0,volumeNodes:st.volumeNodes||[],panningNodes:st.panningNodes||[],fadeInLen:st.fadeInLen||0,fadeOutLen:st.fadeOutLen||0,graphMode:st.graphMode,channelInfo:st.channelInfo,selectedNodeTime:subTabSelectedNodeTime,setSelectedNodeTime:setSubTabSelectedNodeTime,onUpdateNodes:nodes=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,[s.graphMode==='pan'?'panningNodes':'volumeNodes']:nodes}:s)),onUpdateFade:fade=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,fadeInLen:fade.fadeInLen??s.fadeInLen,fadeOutLen:fade.fadeOutLen??s.fadeOutLen}:s)),onModeToggle:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,graphMode:s.graphMode==='pan'?null:'pan'}:s)),onSpeedChange:newSpeed=>{setSubTabs(prev=>prev.map(s=>{if(s.id!==st.id)return s;const oldSpeed=s.speed||1.0;const ratio=oldSpeed/newSpeed;const newVolumeNodes=(s.volumeNodes||[]).map(n=>({...n,time:n.time*ratio}));const newPanningNodes=(s.panningNodes||[]).map(n=>({...n,time:n.time*ratio}));return{...s,speed:newSpeed,volumeNodes:newVolumeNodes,panningNodes:newPanningNodes,fadeInLen:(s.fadeInLen||0)*ratio,fadeOutLen:(s.fadeOutLen||0)*ratio,currentTime:(s.currentTime||0)*ratio,label:s.label.replace(/\s\(\d+%\)$/,'')+` (${Math.round(newSpeed*100)}%)`};}));const n=activeTrackNodesRef.current[st.trackId];if(n&&n.source)n.source.playbackRate.value=newSpeed;// Reset time refs to prevent playhead jump when speed changes mid-playback
|
||
const ctx=getAudioContext();const elapsed=ctx.currentTime-startAudioTimeRef.current;const oldSpeed=activePlaybackSpeedRef.current;const ratio=oldSpeed/newSpeed;startBufferOffsetRef.current=startBufferOffsetRef.current+elapsed*oldSpeed;startOffsetTimeRef.current=(startOffsetTimeRef.current+elapsed)*ratio;startAudioTimeRef.current=ctx.currentTime;activePlaybackSpeedRef.current=newSpeed;}}),/*#__PURE__*/React.createElement("div",{onMouseDown:handleSubTabResizeMouseDown,className:"absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors"})),st.selectionStart!==null&&st.selectionEnd!==null&&st.selectionEnd>st.selectionStart&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 pointer-events-none",style:{left:`${Math.min(st.selectionStart,st.selectionEnd)*zoom}px`,width:`${Math.abs(st.selectionEnd-st.selectionStart)*zoom}px`}})))));})())),/*#__PURE__*/React.createElement("div",{className:"w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",onMouseDown:startColResize}),renderDock('right','Right')),renderDock('bottom','Bottom'),showMixer&&/*#__PURE__*/React.createElement("div",{className:"flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",style:{height:mixerHeight+'px'}},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize",onMouseDown:e=>{e.preventDefault();var startY=e.clientY;var startH=mixerHeight;var onMove=function(ev){var newH=Math.max(80,Math.min(400,startH-(ev.clientY-startY)));setMixerHeight(newH);localStorage.setItem('studio_mixer_height',newH.toString());};var onUp=function(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-horizontal",className:"w-3 h-3 text-zinc-600"})),/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-zinc-400 uppercase tracking-wider"},"MIXER")),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowMixer(false),className:"p-0.5 rounded text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-x-auto p-1.5 gap-1.5 items-stretch"},/*#__PURE__*/React.createElement(MasterStripConsole,{masterVolume:masterVolume,setMasterVolume:setMasterVolume,masterVolL:masterVolL,setMasterVolL:setMasterVolL,masterVolR:masterVolR,setMasterVolR:setMasterVolR,masterLink:masterLink,setMasterLink:setMasterLink,masterMuted:masterMuted,setMasterMuted:setMasterMuted,masterAutoVol:masterAutoVol,setMasterAutoVol:setMasterAutoVol,masterAutoOn:masterAutoOn,setMasterAutoOn:setMasterAutoOn,showMasteringModal:showMasteringModal,setShowMasteringModal:setShowMasteringModal,masteringSettings:masteringSettings,setMasteringSettings:setMasteringSettings,isPlaying:isPlaying}),activeTracks.length>0&&/*#__PURE__*/React.createElement("div",{className:"w-px bg-zinc-700 shrink-0 self-stretch mx-0.5"}),activeTracks.map(function(track,idx){return/*#__PURE__*/React.createElement(TrackStripConsole,{key:track.id,track:track,index:idx,onUpdateTrack:updateTrackProp,trackVuRefs:trackVuRefs});}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",style:{height:mediaExplorerPanelHeight+'px',display:showMediaExplorer?'flex':'none'}},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize",onMouseDown:e=>{e.preventDefault();var startY=e.clientY;var startH=mediaExplorerPanelHeight;var onMove=function(ev){var newH=Math.max(120,Math.min(520,startH-(ev.clientY-startY)));setMediaExplorerPanelHeight(newH);localStorage.setItem('studio_media_explorer_height',newH.toString());};var onUp=function(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-horizontal",className:"w-3 h-3 text-zinc-600"})),/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-emerald-400 uppercase tracking-wider"},"Media Explorer (F6)")),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowMediaExplorer(false),className:"p-0.5 rounded text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 min-h-0 overflow-hidden bg-[#262626]"},/*#__PURE__*/React.createElement(MediaExplorerPanel,{height:mediaExplorerPanelHeight,clipboardRef:clipboardRef,active:showMediaExplorer}))));})(),/*#__PURE__*/React.createElement("div",{className:"h-9 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[18px] text-zinc-500 select-none shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-4"},/*#__PURE__*/React.createElement("span",null,"Status: ",isPlaying?'Playing':'Stopped'),activeTab!=='main'&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-semibold uppercase"},"Sub-Tab"),activeTab==='main'&&/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-semibold uppercase"},"Track: ID ",selectedTrackId),selectionMode==='local'&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-semibold uppercase text-[18px]"},"Local Sel"),selectionMode==='global'&&/*#__PURE__*/React.createElement("span",{className:"text-purple-400 font-semibold uppercase text-[18px]"},"Global Sel"),hasAnySolo&&/*#__PURE__*/React.createElement("span",{className:"text-amber-500 font-semibold"},"Solo: ",tracks.filter(t=>t.solo).length," track(s)"),isLoopingSelection&&selectionMode==='local'&&/*#__PURE__*/React.createElement("span",{className:"text-emerald-400 font-semibold uppercase text-[18px]"},"Solo Loop"),isLoopingSelection&&selectionMode!=='local'&&/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-semibold uppercase text-[18px]"},"Master Loop")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 ml-3"},/*#__PURE__*/React.createElement("span",{className:"text-[18px] font-bold text-zinc-400 uppercase"},"Bars:"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",value:beginBar,onChange:e=>{const b=parseInt(e.target.value)||0;setBeginBar(b);const beatDuration=60/parseInt(bpm||120);const t=b*beatDuration*4;clearLocalSelection();setSelectionMode('global');setSelectionStart(t);setSelectionEnd(t+beatDuration*4);},className:"w-[60px] bg-black text-white text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"-"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",value:endBar,onChange:e=>{const b=parseInt(e.target.value)||0;setEndBar(b);const beatDuration=60/parseInt(bpm||120);const t=b*beatDuration*4;// selectionEnd = vạch bar b (không +1 bar) — khớp hiển thị endBar
|
||
// (quét 8 bars hiển thị "0 đến 8"): nhập 8 → selection tới vạch 8.
|
||
setSelectionEnd(t);setNumberBar(Math.max(0,b-beginBar));},className:"w-[60px] bg-black text-white text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"#"),/*#__PURE__*/React.createElement("input",{type:"number",min:"1",value:numberBar,readOnly:true,className:"w-[60px] bg-black text-zinc-400 text-[18px] px-1 py-0 rounded border border-zinc-800 text-center font-mono"}),/*#__PURE__*/React.createElement("button",{onClick:()=>setSelectionFollowsTempo(prev=>!prev),className:`px-1.5 py-0 text-[18px] rounded border font-bold ${selectionFollowsTempo?'bg-amber-700 text-white border-amber-500':'bg-zinc-800 text-zinc-400 border-zinc-700'}`,title:selectionFollowsTempo?"Selection theo tempo (đổi BPM → selection thay đổi)":"Selection theo thời gian (cố định)"},selectionFollowsTempo?"♪T":"⏱T"),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-6 bg-zinc-800 mx-1"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"Start:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selLeft!==null?formatTime(selLeft):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));clearLocalSelection();setSelectionMode('global');setSelectionStart(secs);}},className:"w-24 bg-black text-zinc-200 text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"End:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selRight!==null?formatTime(selRight):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));setSelectionEnd(secs);}},className:"w-24 bg-black text-zinc-200 text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"Len:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selLeft!==null&&selRight!==null?formatTime(Math.abs(selRight-selLeft)):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3&&selLeft!==null){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));setSelectionEnd(selLeft+secs);}},className:"w-24 bg-black text-amber-300 text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setShowExportPanel(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showExportPanel?'bg-cyan-900 text-cyan-300':'text-zinc-500 hover:text-zinc-300'}`,title:`Export (floating modal)`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showExportPanel?panelPositions.export[0].toUpperCase():'')),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowAIPanel(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showAIPanel?'bg-purple-900 text-purple-300':'text-zinc-500 hover:text-zinc-300'}`,title:`AI Panel (${panelPositions.ai})`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showAIPanel?panelPositions.ai[0].toUpperCase():'')),/*#__PURE__*/React.createElement("button",{onClick:()=>window.__toggleMixerRef(),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMixer?'bg-indigo-900 text-indigo-300':'text-zinc-500 hover:text-zinc-300'}`,title:"Mixer Panel (F7)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders-horizontal",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>window.__toggleMediaExplorerRef(),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMediaExplorer?'bg-emerald-900 text-emerald-300':'text-zinc-500 hover:text-zinc-300'}`,title:"Media Explorer Panel (F6)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"folder-open",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("span",{className:"w-[1px] h-3 bg-zinc-800 mx-1"}),/*#__PURE__*/React.createElement("span",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"info",className:"w-3 h-3 text-zinc-600"})),prHint?/*#__PURE__*/React.createElement("span",{className:"text-cyan-400"},prHint):/*#__PURE__*/React.createElement("span",{className:"text-zinc-600 italic"},"Adaptive tips: hover vào vùng làm việc để xem hướng dẫn")))),contextMenu&&(contextMenu.isSubTab?/*#__PURE__*/React.createElement("div",{className:"fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto",style:{left:Math.min(contextMenu.x,window.innerWidth-260),top:contextMenu.y+200>window.innerHeight?undefined:contextMenu.y,bottom:contextMenu.y+200>window.innerHeight?window.innerHeight-contextMenu.y:undefined,maxHeight:'60vh'},onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"px-3 py-1 text-xs text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1"},"Selection: ",formatTime(subTabs.find(s=>s.id===contextMenu.subTabId)?.selectionStart||0)," - ",formatTime(subTabs.find(s=>s.id===contextMenu.subTabId)?.selectionEnd||0)),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabCut(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-rose-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Cut"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+X")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabCopy(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5 text-zinc-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Copy"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+C")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabPaste(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5 text-emerald-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Paste"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+V")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabDelete(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3.5 h-3.5 text-red-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Delete Selected Segment"),/*#__PURE__*/React.createElement("span",{className:"text-amber-400 text-xs font-semibold font-mono ml-auto"},"Del")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabLoop(contextMenu.subTabId,4);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5 text-cyan-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Loop Selection 4 times"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+L"))):/*#__PURE__*/React.createElement("div",{className:"fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto",style:{left:Math.min(contextMenu.x,window.innerWidth-260),top:contextMenu.y+260>window.innerHeight?undefined:contextMenu.y,bottom:contextMenu.y+260>window.innerHeight?window.innerHeight-contextMenu.y:undefined,maxHeight:'60vh'},onClick:e=>e.stopPropagation()},contextMenu.sectionId?/*#__PURE__*/React.createElement("button",{onClick:contextMenuEditSection,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-edit",className:"w-3.5 h-3.5 text-amber-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Edit Section"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+E")):/*#__PURE__*/React.createElement("button",{onClick:contextMenuEdit,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-edit",className:"w-3.5 h-3.5 text-amber-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Edit"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+E")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuSplit,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-cyan-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Split"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"S")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuMerge,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"combine",className:"w-3.5 h-3.5 text-purple-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Merge"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+M")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("div",{className:"px-3 py-0.5 text-[10px] text-zinc-500 font-bold uppercase tracking-wider"},"Insert"),!sessionTabs.some(s=>s.id===activeTab)&&/*#__PURE__*/React.createElement("button",{onClick:()=>{insertSectionAtPlayhead();closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"folder-plus",className:"w-3.5 h-3.5 text-amber-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Insert Section")),/*#__PURE__*/React.createElement("button",{onClick:()=>{insertMidiItemAtPlayhead();closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-3.5 h-3.5 text-purple-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Insert MIDI Item")),/*#__PURE__*/React.createElement("button",{onClick:()=>{insertSoundClipAtCursor();closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-audio",className:"w-3.5 h-3.5 text-emerald-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Insert Sound Clip")),/*#__PURE__*/React.createElement("button",{onClick:()=>{insertTrackBelow();closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus-square",className:"w-3.5 h-3.5 text-cyan-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Insert Track")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:contextMenuCopy,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5 text-zinc-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Copy"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+C")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuCut,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-rose-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Cut"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+X")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuPaste,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5 text-emerald-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Paste"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+V")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:contextMenuDelete,className:"w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3.5 h-3.5 text-red-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Delete"),/*#__PURE__*/React.createElement("span",{className:"text-amber-400 text-xs font-semibold font-mono ml-auto pl-8"},"Del")))),toastMessage&&/*#__PURE__*/React.createElement("div",{className:"absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"bell",className:`w-4 h-4 ${toastMessage.type==='success'?'text-emerald-400':toastMessage.type==='error'?'text-rose-400':toastMessage.type==='warning'?'text-amber-400':'text-cyan-400'}`})),toastMessage.text,toastMessage.onActionClick&&/*#__PURE__*/React.createElement("button",{onClick:()=>{toastMessage.onActionClick();setToastMessage(null);},className:"ml-2 px-2 py-0.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-[10px] uppercase font-bold"},toastMessage.actionText||'Tải về'))),appWarningModal&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#222222] border border-zinc-800 rounded-lg shadow-2xl p-6 max-w-md w-full text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 mb-3 text-cyan-400 font-bold"},/*#__PURE__*/React.createElement("h3",{className:"text-base font-bold"},appWarningModal.title)),/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-300 mb-5 leading-relaxed whitespace-pre-line"},appWarningModal.message),/*#__PURE__*/React.createElement("div",{className:"flex justify-end gap-2"},appWarningModal.isAlert?/*#__PURE__*/React.createElement("button",{onClick:()=>setAppWarningModal(null),className:"px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition"},"Đóng"):/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("button",{onClick:()=>setAppWarningModal(null),className:"px-4 py-2 bg-zinc-700 hover:bg-zinc-600 active:bg-zinc-800 text-slate-300 rounded text-xs font-semibold transition"},"Hủy"),/*#__PURE__*/React.createElement("button",{onClick:()=>{const fn=appWarningModal.onConfirm;setAppWarningModal(null);if(fn)fn();},className:"px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition"},"Xác nhận"))))),tabContextMenu&&/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[190]",onClick:()=>setTabContextMenu(null),onContextMenu:e=>{e.preventDefault();setTabContextMenu(null);}}),/*#__PURE__*/React.createElement("div",{className:"fixed bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl p-2 z-[200] flex gap-1.5",style:{top:tabContextMenu.y,left:tabContextMenu.x},onClick:e=>e.stopPropagation()},['#f43f5e','#f59e0b','#10b981','#06b6d4','#8b5cf6','#64748b'].map(color=>/*#__PURE__*/React.createElement("button",{key:color,className:"w-5 h-5 rounded-full border border-zinc-700 hover:scale-110 active:scale-95 transition",style:{backgroundColor:color},onClick:()=>{handleSetTabColor(tabContextMenu.tabId,tabContextMenu.tabType,color);setTabContextMenu(null);}})))),/*#__PURE__*/React.createElement(AuthModal,{isOpen:authModalOpen,mode:authMode,forceMandatory:isMandatoryLogin,onClose:()=>setAuthModalOpen(false),onSuccess:handleAuthSuccess}),/*#__PURE__*/React.createElement(ProfileModal,{isOpen:profileModalOpen,onClose:()=>setProfileModalOpen(false),tracks:tracks,setTracks:setTracks,setSelectedTrackId:setSelectedTrackId,projectName:projectName,setProjectName:setProjectName,currentProjectId:currentProjectId,setCurrentProjectId:setCurrentProjectId,showToast:showToast,loadAudioBuffersForTracks:loadAudioBuffersForTracks,setAppWarningModal:setAppWarningModal,bpm:bpm,setBpm:setBpm,setMasteringSettings:setMasteringSettings,setSessionTabs:setSessionTabs,setSubTabs:setSubTabs,trackMidiChannelsRef:trackMidiChannelsRef}),/*#__PURE__*/React.createElement(SaveProjectModal,{isOpen:saveProjectModalOpen,onClose:()=>setSaveProjectModalOpen(false),projectName:projectName,onSaveCloud:(newName,existingId)=>{handleSaveCloudProject(newName,existingId);},onSaveLocal:newName=>{handleSaveLocalProject(newName);}}),/*#__PURE__*/React.createElement(SaveAsModal,{isOpen:saveAsModalOpen,onClose:()=>setSaveAsModalOpen(false),projectName:projectName,onSaveCloud:newName=>{handleSaveAsCloud(newName);},onSaveLocal:newName=>{handleExportSFS(newName);setProjectName(newName);localStorage.setItem('sonic_project_name',newName);}}),/*#__PURE__*/React.createElement(OpenProjectModal,{isOpen:openProjectModalOpen,onClose:()=>setOpenProjectModalOpen(false),onOpenCloud:(projId,projName)=>{setOpenProjectModalOpen(false);handleOpenProject(projId,projName);},onOpenLocal:()=>{setOpenProjectModalOpen(false);handleImportSFS();},onOpenDisk:(filename,name)=>{setOpenProjectModalOpen(false);setAppWarningModal({title:"Mở dự án từ máy",message:'Bạn có muốn mở dự án "'+(name||filename)+'" lưu trên máy? Các thay đổi chưa lưu sẽ bị mất.',isAlert:false,onConfirm:async()=>{localStorage.setItem('sonic_project_id','disk:'+filename);localStorage.setItem('sonic_project_name',name||filename);window.location.reload();}});}}),/*#__PURE__*/React.createElement(AIConfigModal,{isOpen:aiConfigModalOpen,onClose:()=>setAiConfigModalOpen(false),onConfigSaved:providers=>{setAiProviders(providers);const active=providers.find(p=>p.is_active)||providers[0];if(active)setSelectedProviderId(active.id);}}),/*#__PURE__*/React.createElement(AboutModal,{isOpen:aboutModalOpen,onClose:()=>setAboutModalOpen(false)}),/*#__PURE__*/React.createElement(HelpModal,{isOpen:helpModalOpen,onClose:()=>setHelpModalOpen(false),lang:prefs.language}),/*#__PURE__*/React.createElement(PreferencesModal,{isOpen:preferencesModalOpen,onClose:()=>setPreferencesModalOpen(false),prefs:prefs,onPrefsChange:handlePrefsChange}),/*#__PURE__*/React.createElement(SystemManagerModal,{isOpen:systemManagerModalOpen,onClose:()=>setSystemManagerModalOpen(false)}),/*#__PURE__*/React.createElement(PluginManagerModal,{isOpen:pluginManagerModalOpen,onClose:()=>setPluginManagerModalOpen(false),pluginsData:pluginsData,onInsertInstrument:inst=>{// Chèn instrument (soundfont bank/program) vào track đang chọn — nút Synth
|
||
const tid=selectedTrackId||activeTracks&&activeTracks[0]&&activeTracks[0].id;if(tid&&inst&&inst.instrumentId){setTrackInstrumentWithProgram(tid,inst.instrumentId,inst.program,inst.displayName||inst.name,inst.bank);showToast('Đã chèn nhạc cụ: '+(inst.displayName||inst.name),'success');}setPluginManagerModalOpen(false);}}),/*#__PURE__*/React.createElement(AIPresetModal,{isOpen:aiPresetModalOpen,onClose:()=>{setAiPresetModalOpen(false);setAiPresetVersion(v=>v+1);}}),/*#__PURE__*/React.createElement(MasteringModal,{isOpen:showMasteringModal,onClose:()=>setShowMasteringModal(false),masteringSettings:masteringSettings,setMasteringSettings:setMasteringSettings}),fxRackTarget&&/*#__PURE__*/React.createElement(FXRackModal,{track:(activeTracksRef.current||[]).find(t=>t.id===fxRackTarget.trackId)||tracks.find(t=>t.id===fxRackTarget.trackId)||null,onUpdateTrack:updateTrackProp,onClose:()=>setFxRackTarget(null)}),vkbOpen&&/*#__PURE__*/React.createElement(VirtualMidiKeyboard,{settings:vkbSettings,onChange:vkbUpdateSettings,tracks:activeTracks,activePitches:activeMidiPitches,onNoteOn:vkbNoteOn,onNoteOff:vkbNoteOff,onClose:()=>window.__toggleVkb()}),chordsPanelOpen&&/*#__PURE__*/React.createElement(ChordsPanel,{onClose:()=>window.__toggleChordsPanel(),onInsert:insertChordProgression,onNoteOn:vkbNoteOn,onNoteOff:vkbNoteOff,bpm:bpm}),/*#__PURE__*/React.createElement(ExportProgressModal,{open:showExportProgress,exportProgress:exportProgress,isExporting:isExporting,onClose:()=>setShowExportProgress(false)}),/*#__PURE__*/React.createElement(ExportModal,{open:showExportPanel,onClose:()=>setShowExportPanel(false),exportSettings:exportSettings,setExportSettings:setExportSettings,isExporting:isExporting,exportProgress:exportProgress,onExport:()=>exportSettings.realtime?triggerBounceExport():triggerWavExport()}),instrumentSelectorTrackId&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",onClick:closeInstrumentSelector},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-5 text-slate-200",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-3 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-amber-400"},"Select Instrument"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("select",{value:instrumentSelectorTrackId||'',onChange:e=>{var tid=e.target.value;if(tid){openInstrumentSelector(tid);}},className:"bg-[#1e1e1e] text-zinc-300 border border-zinc-700 rounded px-2 py-1 text-xs font-mono cursor-pointer"},function(){var _opts=activeTracks.map(function(at){return{id:at.id,name:at.name||at.id,inst:at.instrumentName||'Synth'};});if(sessionTabsRef&&sessionTabsRef.current){(sessionTabsRef.current||[]).forEach(function(sec){(sec.tracks||[]).forEach(function(tr){if(_opts.some(function(o){return o.id===tr.id;}))return;_opts.push({id:tr.id,name:tr.name||tr.id,inst:tr.instrumentName||'Synth'});});});}return _opts.map(function(o){return/*#__PURE__*/React.createElement("option",{key:o.id,value:o.id},o.name+' ('+o.inst+')');});}()),/*#__PURE__*/React.createElement("button",{onClick:closeInstrumentSelector,className:"text-slate-400 hover:text-slate-200"},"\u2715"))),/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"T\u00ecm nh\u1ea1c c\u1ee5...",value:sfPresetSearchQuery,onChange:e=>setSfPresetSearchQuery(e.target.value),autoFocus:true,className:"w-full bg-black border border-zinc-700 rounded px-2 py-1.5 outline-none my-3 text-sm"}),/*#__PURE__*/React.createElement("div",{className:"flex gap-4",style:{height:"420px"}},/*#__PURE__*/React.createElement("div",{className:"w-1/3 border-r border-[#383838] overflow-y-auto space-y-0.5 pr-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>{setSelectedSoundFontId(null);setSynthCategory(null);},className:"w-full text-left px-3 py-2 text-sm rounded "+(!selectedSoundFontId&&!(selectorLoadedInst&&selectorLoadedInst.instId)?"bg-amber-700 text-white":"bg-zinc-800 hover:bg-zinc-700 text-zinc-300")},"All Instruments"),(instrumentSelectorData?.soundfonts||[]).map(sf=>{const sfId=sf.id.startsWith('sf_')?sf.id:'sf_'+sf.id;const sfName=sf.display||sf.name||sf.id;return/*#__PURE__*/React.createElement("button",{key:sfId,onClick:()=>{setSelectedSoundFontId(sfId);setSfPresets(null);const baseId=sfId.replace('sf_','');window.SonicAPI.listSoundfontInstruments(baseId).then(data=>{if(data&&data.presets){const mapping=data.presets.map(p=>({...p,_sfId:sfId,_sfName:sfName,_sfDisplay:sfName.substring(0,30)}));setSfPresets(mapping);setInstrumentSelectorData(prev=>prev?{...prev,soundfonts:(prev.soundfonts||[]).map(s=>s.id===sf.id?{...s,presets:data.presets}:s)}:prev);}else{setSfPresets([]);}}).catch(()=>{setSfPresets([]);});},className:"w-full text-left px-3 py-2 text-sm rounded "+(selectedSoundFontId===sfId?"bg-amber-700 text-white":"bg-zinc-800 hover:bg-zinc-700 text-zinc-300")},sfName);}),// ── VST Instruments (scanned bởi Plugin Manager) ──
|
||
instrumentSelectorData?.vst_instruments?.length>0&&/*#__PURE__*/React.createElement("div",{className:"mt-3 text-[10px] text-zinc-500 uppercase font-bold px-1 flex items-center gap-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3 h-3 text-violet-400"}),"VST Instruments"),(instrumentSelectorData?.vst_instruments||[]).map(v=>/*#__PURE__*/React.createElement("button",{key:'vst_modal_'+(v.id||v.name),onClick:()=>{setTrackInstrumentWithUndo(instrumentSelectorTrackId,v.id,v.name||v.id);closeInstrumentSelector();},className:"w-full text-left px-3 py-2 text-sm rounded "+(selectorLoadedInst&&selectorLoadedInst.instId===v.id?"bg-violet-800 text-white":"bg-zinc-800 hover:bg-violet-800 text-zinc-300")+" flex items-center justify-between gap-2"},/*#__PURE__*/React.createElement("span",{className:"truncate"},v.name||v.id),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-cyan-400 shrink-0"},v.type||"VST")))),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-y-auto space-y-0.5"},/*#__PURE__*/React.createElement("button",{onClick:()=>setTrackInstrumentWithUndo(instrumentSelectorTrackId,null),className:"w-full text-left px-3 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400"},"None (Default Synth)"),sfPresets===null?(/*#__PURE__*/React.createElement("p",{className:"text-sm text-zinc-500 py-2"},"Loading instruments...")):sfPresets.length>0?sfPresets.filter(p=>!selectedSoundFontId||p._sfId===selectedSoundFontId).filter(p=>!sfPresetSearchQuery||(p.name||'').toLowerCase().includes(sfPresetSearchQuery.toLowerCase())||(p._sfName||'').toLowerCase().includes(sfPresetSearchQuery.toLowerCase())).map((p,i)=>/*#__PURE__*/React.createElement("button",{key:i,onClick:()=>setTrackInstrumentWithUndo(instrumentSelectorTrackId,p._sfId,p.name||'Preset '+p.program,p.bank,p.program),onDoubleClick:()=>{setTrackInstrumentWithUndo(instrumentSelectorTrackId,p._sfId,p.name||'Preset '+p.program,p.bank,p.program);closeInstrumentSelector();},className:"w-full text-left px-3 py-2 text-sm rounded "+(selectorLoadedInst&&selectorLoadedInst.sfId===p._sfId&&selectorLoadedInst.bank===p.bank&&selectorLoadedInst.prog===p.program?"bg-amber-800/80 text-white ring-1 ring-amber-400/60":"bg-zinc-800/60 hover:bg-amber-800 text-zinc-300")+" truncate flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 shrink-0"},p._sfDisplay),p.bank===128?/*#__PURE__*/React.createElement("span",{className:"mr-1"},"🥁"):null,p.name||'Preset '+p.program)):(/*#__PURE__*/React.createElement("p",{className:"text-sm text-zinc-500 py-2"},sfPresetSearchQuery?"Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 ph\u00f9 h\u1ee3p.":"No presets found.")))))),fxSelectorTrackId&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",onClick:()=>setFxSelectorTrackId(null)},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-64 p-4 text-slate-200",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-purple-400 mb-3"},"Track FX"),/*#__PURE__*/React.createElement("div",{className:"space-y-1"},/*#__PURE__*/React.createElement("button",{onClick:()=>handleSetTrackFx(fxSelectorTrackId,null),className:"w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400"},"None"),/*#__PURE__*/React.createElement("button",{onClick:()=>handleSetTrackFx(fxSelectorTrackId,'chorus'),className:"w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-cyan-800 text-cyan-300"},"Chorus"),/*#__PURE__*/React.createElement("button",{onClick:()=>handleSetTrackFx(fxSelectorTrackId,'reverb'),className:"w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-purple-800 text-purple-300"},"Reverb")))),instrumentDropdownTrackId&&instrumentDropdownBtnRect&&/*#__PURE__*/React.createElement("div",{"data-instr-dropdown":"",className:"fixed z-[100] bg-[#1e1e1e] border border-zinc-700 rounded shadow-xl w-[220px] max-h-[300px] flex flex-col",style:{top:instrumentDropdownBtnRect.bottom+4,left:Math.max(4,Math.min(instrumentDropdownBtnRect.left,window.innerWidth-224))}},/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"T\u00ecm nh\u1ea1c c\u1ee5...",value:instrumentSearchQuery,onChange:e=>setInstrumentSearchQuery(e.target.value),autoFocus:true,className:"w-full bg-black border-b border-zinc-700 px-2 py-1.5 text-xs outline-none"}),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-y-auto"},/*#__PURE__*/React.createElement("button",{onClick:()=>setTrackInstrumentWithUndo(instrumentDropdownTrackId,null),className:"w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400"},"None (Default Synth)"),filteredInstruments.soundfonts.length>0&&/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold"},"SoundFonts"),filteredInstruments.soundfonts.map((sf,i)=>/*#__PURE__*/React.createElement("button",{key:"sfd_"+i,onClick:()=>{setInstrumentDropdownTrackId(null);setInstrumentDropdownBtnRect(null);setTrackInstrumentWithUndo(instrumentDropdownTrackId,"sf_"+sf.id,sf.display||sf.name||sf.id,undefined,undefined);},className:"w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-amber-800 text-zinc-200 flex items-center justify-between"},/*#__PURE__*/React.createElement("span",{className:"truncate"},sf.display||sf.name||sf.id),/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-amber-400 shrink-0 ml-1"},"SF"))),filteredInstruments.vst.length>0&&/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold"},"VST Instruments"),filteredInstruments.vst.map((v,i)=>/*#__PURE__*/React.createElement("div",{key:"vstd_"+i,className:"flex items-stretch"},/*#__PURE__*/React.createElement("button",{onClick:()=>{setInstrumentDropdownTrackId(null);setInstrumentDropdownBtnRect(null);setTrackInstrumentWithUndo(instrumentDropdownTrackId,v.id,v.name||v.id);},className:"flex-1 min-w-0 text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-violet-800 text-zinc-200 flex items-center justify-between"},/*#__PURE__*/React.createElement("span",{className:"truncate"},v.name||v.id),/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-cyan-400 shrink-0 ml-1"},v.type||"VST")))),filteredInstruments.vst.length>0&&/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold"},"VST Presets (.vstpreset)"),(window.SonicRuntime&&window.SonicRuntime.presets||[]).map((p,i)=>/*#__PURE__*/React.createElement("button",{key:"psd_"+i,onClick:()=>setTrackPreset(instrumentDropdownTrackId,p.id),className:"w-full text-left px-3 py-1 text-xs bg-zinc-800 hover:bg-emerald-800 text-zinc-300 flex items-center justify-between"},/*#__PURE__*/React.createElement("span",{className:"truncate"},p.name||p.id),/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-emerald-400 shrink-0 ml-1"},"PRESET"))),/*#__PURE__*/React.createElement("button",{onClick:()=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;window.SonicAPI.uploadPreset(f).then(function(r){if(r&&r.success){if(window.SonicRuntime)window.SonicRuntime.refreshPresets();showToast('Đã upload preset: '+(r.original_name||r.name||''),'success');}else{showToast('Upload preset thất bại','error');}}).catch(function(err){showToast('Upload lỗi: '+(err.message||err),'error');});};inp.click();},className:"w-full text-left px-3 py-1 text-xs bg-zinc-800 hover:bg-teal-800 text-teal-300 border-t border-zinc-700",title:"Upload preset .vstpreset xuất từ native GUI"},"\u2B06 Upload preset (t\u1EEB native GUI...)"),!filteredInstruments.soundfonts.length&&!filteredInstruments.vst.length&&/*#__PURE__*/React.createElement("p",{className:"text-xs text-zinc-500 py-4 text-center"},"Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 n\u00e0o"))));};// React onWheel is passive -> e.preventDefault() can't stop panel scroll, so the
|
||
// ruler slips away from the cursor mid-scroll. Native capture listener (non-passive)
|
||
// blocks scrolling whenever the wheel starts over a [data-sf-wheel] container.
|
||
document.addEventListener('wheel',e=>{const t=e.target;if(t&&t.closest&&t.closest('[data-sf-wheel]'))e.preventDefault();},{passive:false,capture:true});const root=ReactDOM.createRoot(document.getElementById('root'));root.render(/*#__PURE__*/React.createElement(App,null));setTimeout(()=>lucide.createIcons(),300); |