From db12698ba25db28e68038b199432422141612079 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Sun, 23 Aug 2026 08:34:09 +0700 Subject: [PATCH] =?UTF-8?q?Gap=2011:=20A/B=20reference=20track=20=E2=80=94?= =?UTF-8?q?=20/reference-analyze=20LUFS/dBTP/centroid=20+=20RefCompareModa?= =?UTF-8?q?l=20A/B=20listen=20(5=20test,=20174=20passed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/audio.py | 84 +++++++++++++++++ app/static/js/app.jsx | 152 ++++++++++++++++++++++++++++++- app/static/js/app.precompiled.js | 17 ++-- app/static/js/services/api.js | 2 + app/templates/index.html | 2 +- tests/test_reference_analyze.py | 87 ++++++++++++++++++ 6 files changed, 336 insertions(+), 8 deletions(-) create mode 100644 tests/test_reference_analyze.py diff --git a/app/api/v1/audio.py b/app/api/v1/audio.py index ba3d669..0fb60b9 100644 --- a/app/api/v1/audio.py +++ b/app/api/v1/audio.py @@ -485,6 +485,90 @@ async def list_user_files(req: MyFilesRequest, current_user: dict = Depends(get_ user_files.sort(key=lambda x: x["created_at"], reverse=True) return user_files +class ReferenceAnalyzeRequest(BaseModel): + path: Optional[str] = None # local path (media/browse server mode) + file_id: Optional[str] = None # uploaded/processed file id + +def _spectral_centroid_hz(audio, sr): + """Tone proxy: mean spectral centroid (Hz) over 1 s windows. + Mono downmix + decimate to ~8 kHz (bounded cost). 0.0 for silence.""" + import numpy as np + from scipy.signal import resample_poly + x = audio + if x.ndim > 1: + x = np.mean(x, axis=0) + if x.size == 0 or not np.any(x): + return 0.0 + g = max(1, int(round(sr / 8000.0))) + x = resample_poly(x, 1, g) + sr8 = float(sr) / g + win = int(round(1.0 * sr8)) + if win < 64 or x.size < win: + return 0.0 + hann = np.hanning(win) + freqs = np.fft.rfftfreq(win, 1.0 / sr8) + cents = [] + step = win // 2 + for i in range(0, x.size - win, step): + mag = np.abs(np.fft.rfft(x[i:i + win] * hann)) + msum = float(mag.sum()) + if msum > 1e-9: + cents.append(float((freqs * mag).sum() / msum)) + if not cents: + return 0.0 + return float(np.mean(cents)) + +@router.post("/reference-analyze") +async def analyze_reference(req: ReferenceAnalyzeRequest, current_user: Optional[dict] = Depends(get_optional_user)): + """Gap 11: A/B reference track - analyze LUFS/dBTP/centroid of a + reference file (local path from media/browse, or uploaded file_id), + return compare metrics + playback url for the A/B toggle.""" + import numpy as np + import soundfile as sf + from urllib.parse import quote + from app.core import loudness + path = "" + url = "" + if req.file_id: + path = _resolve_storage_path(req.file_id) + if not path: + raise HTTPException(status_code=404, detail="File not found") + url = "/api/v1/audio/download/%s" % _safe_file_id(req.file_id) + elif req.path: + path = os.path.normpath(req.path) + if not os.path.isfile(path): + raise HTTPException(status_code=404, detail="Khong tim thay file") + ext = os.path.splitext(path)[1].lower().lstrip(".") + if ext not in ALLOWED_AUDIO_EXTENSIONS: + raise HTTPException(status_code=400, detail="Dinh dang khong ho tro: %s" % ext) + url = "/api/v1/media/file?path=" + quote(path) + else: + raise HTTPException(status_code=400, detail="Can path hoac file_id") + try: + info = sf.info(path) + if info.samplerate and info.frames / info.samplerate > 900: + raise HTTPException(status_code=413, detail="File qua dai (>15 phut)") + data, sr = sf.read(path, dtype="float32", always_2d=True) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail="Khong doc duoc audio: %s" % e) + audio = np.asarray(data.T, dtype=np.float64) # (ch, n) + lufs = loudness.integrated_lufs(audio, sr) + tp = loudness.true_peak_db(audio, sr) + centroid = await asyncio.to_thread(_spectral_centroid_hz, audio, sr) + dur = float(info.frames / max(1, info.samplerate or 1)) + return { + "filename": os.path.basename(path), + "duration": round(dur, 2), + "sample_rate": int(info.samplerate or 0), + "channels": int(info.channels or 0), + "lufs": round(lufs, 2) if np.isfinite(lufs) else None, + "true_peak_db": round(tp, 2) if np.isfinite(tp) else None, + "centroid_hz": round(centroid, 0) if centroid > 0 else None, + "url": url, + } + @router.delete("/my-files/{file_id}") async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)): user_id = current_user["user_id"] diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 0249950..a9ddf8c 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -545,6 +545,9 @@ const prerenderNativeTracks = (tracks, bpm) => { 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. @@ -2372,6 +2375,143 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => { }, 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 ( +
+
e.stopPropagation()}> +
+ A/B REFERENCE + +
+
+ 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" /> + +
+
- hoac -
+ + { const f = e.target.files && e.target.files[0]; onPickFile(f); e.target.value = ''; }} /> + {(refStats || masterStats) && ( +
+
+
METER
+
LUFS
+
dBTP
+
Tone
+
+
+
MASTER
+
{fmt(masterStats && masterStats.lufs, '')}
+
{fmt(masterStats && masterStats.true_peak_db, '')}
+
-
+
+
+
REF
+
{fmt(refStats && refStats.lufs, '')}
+
{fmt(refStats && refStats.true_peak_db, '')}
+
{fmt(refStats && refStats.centroid_hz, 'Hz')}
+
+
+ )} + {refStats && ( +
+ Delta: LUFS {dlt(masterStats && masterStats.lufs, refStats.lufs)} / dBTP {dlt(masterStats && masterStats.true_peak_db, refStats.true_peak_db)}  (+ = master louder) +
{refStats.filename} · {refStats.duration}s · {refStats.sample_rate}Hz · {refStats.channels}ch
+
+ )} +
+ + +
+
+
+ ); +}; + // ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ── const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMasterVolL, masterVolR, setMasterVolR, masterLink, setMasterLink, masterMuted, setMasterMuted, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings, isPlaying }) => { const [isTestPlaying, setIsTestPlaying] = React.useState(false); @@ -2388,6 +2528,7 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast const isMasterActive = masteringSettings ? masteringSettings.masterConnected : false; const panStartYRef = React.useRef(0); const startPanValRef = React.useRef(0); + const [refOpen, setRefOpen] = React.useState(false); const ensureAudio = () => { getAudioContext(); @@ -2500,7 +2641,8 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast }; }, [masterVolL, masterVolR, masterMuted, isMono]); - return React.createElement("div", { + 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: "space-y-1.5 mb-2" }, @@ -2508,6 +2650,10 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast onClick: () => setShowMasteringModal(true), className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight" }, "FX CHAIN"), + React.createElement("button", { + onClick: () => setRefOpen(true), + className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-cyan-300 tracking-tight" + }, "A/B REF"), React.createElement("div", { className: "flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono" }, @@ -2696,6 +2842,8 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast React.createElement("span", null, "MAIN OUT") ) ) + ), + refOpen && React.createElement(RefCompareModal, { open: refOpen, onClose: function() { setRefOpen(false); } }) ); }; @@ -18680,6 +18828,7 @@ const App = () => { 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'); @@ -27425,6 +27574,7 @@ const App = () => { normalize_target: exportSettings.normalizeTarget || '-1dbtp', mono_check: !!exportSettings.monoCheck }); + _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'; showToast(`Mastering xong — ${lufsTxt} / ${tpTxt}${res.mono_warning ? ' • ' + res.mono_warning : ''}`, 'success', 'Tải về', async () => { diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index beade9c..33e38f0 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -80,7 +80,8 @@ let _prerenderNativeTracksTimer=null;const prerenderNativeTracks=(tracks,bpm)=>{ (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 } -// Per-track mastering-bypass state (trackId -> bool), kept in sync with the +// 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 @@ -332,9 +333,13 @@ this.sourceNode.connect(this.workletNode);// Enable Live Input Monitoring if req 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));};// ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ── -const MasterStripConsole=({masterVolume,setMasterVolume,masterVolL,setMasterVolL,masterVolR,setMasterVolR,masterLink,setMasterLink,masterMuted,setMasterMuted,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 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;ipeakL)peakL=v;}for(let i=0;ipeakR)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("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:"space-y-1.5 mb-2"},React.createElement("button",{onClick:()=>setShowMasteringModal(true),className:"w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"},"FX CHAIN"),React.createElement("div",{className:"flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"},React.createElement("span",{className:"text-slate-400 truncate"},"Output 1 / Output 2"),React.createElement("i",{className:"fa-solid fa-circle-notch text-[9px] text-slate-500"}))),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(){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(){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:"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"))));};// ── Track Strip Console Component (from md/48_TRACK_STRIP_COMPONENT.md) ── +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,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 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;ipeakL)peakL=v;}for(let i=0;ipeakR)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:"space-y-1.5 mb-2"},React.createElement("button",{onClick:()=>setShowMasteringModal(true),className:"w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"},"FX CHAIN"),React.createElement("button",{onClick:()=>setRefOpen(true),className:"w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-cyan-300 tracking-tight"},"A/B REF"),React.createElement("div",{className:"flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"},React.createElement("span",{className:"text-slate-400 truncate"},"Output 1 / Output 2"),React.createElement("i",{className:"fa-solid fa-circle-notch text-[9px] text-slate-500"}))),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(){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(){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:"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. @@ -1182,7 +1187,7 @@ useEffect(()=>{let cancelled=false;if(window.SonicAPI&&window.SonicAPI.getPrefer 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');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) +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;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();}else if(fxRt.active){fxRtStop();}}catch(e){console.warn('[FX RT] resync fail:',e);}// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị @@ -1955,7 +1960,7 @@ clientSideExport(exportTracks);}finally{setIsExporting(false);}}else{// Client-s 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 res=await window.SonicAPI.renderProject(schema,'master_'+Date.now()+'.wav',{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});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';showToast(`Mastering xong — ${lufsTxt} / ${tpTxt}${res.mono_warning?' • '+res.mono_warning:''}`,'success','Tải về',async()=>{try{const br=await fetch(res.url);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog((projectName||'Project')+'_master.wav',b);if(sp)showToast(`Đã lưu → ${sp}`,'success');}catch(e){const a=document.createElement('a');a.href=res.url;a.download='master.wav';a.click();}});}catch(e){showToast('Render mastering lỗi: '+(e.message||e),'error');}finally{setIsExporting(false);}};// ── Realtime Bounce Export (đầy đủ MIDI + FX Rack + Mastering) ── +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 res=await window.SonicAPI.renderProject(schema,'master_'+Date.now()+'.wav',{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});_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';showToast(`Mastering xong — ${lufsTxt} / ${tpTxt}${res.mono_warning?' • '+res.mono_warning:''}`,'success','Tải về',async()=>{try{const br=await fetch(res.url);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog((projectName||'Project')+'_master.wav',b);if(sp)showToast(`Đã lưu → ${sp}`,'success');}catch(e){const a=document.createElement('a');a.href=res.url;a.download='master.wav';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à diff --git a/app/static/js/services/api.js b/app/static/js/services/api.js index 18632be..857ce70 100644 --- a/app/static/js/services/api.js +++ b/app/static/js/services/api.js @@ -59,6 +59,8 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\ aiScan: (trackId, fileId, minLoopDuration = 2.0, maxLoopDuration = 6.0) => apiRequest('/api/v1/audio/ai-scan', { method: 'POST', body: JSON.stringify({ track_id: trackId, file_id: fileId, min_loop_duration: minLoopDuration, max_loop_duration: maxLoopDuration }) }), aiCut: (sourceTrackId, fileId, selectionStart, selectionEnd) => apiRequest('/api/v1/audio/ai-cut', { method: 'POST', body: JSON.stringify({ source_track_id: sourceTrackId, file_id: fileId, selection_start: selectionStart, selection_end: selectionEnd }) }), runPythonTool: (toolType, trackId, fileId, timePos = 0.0, freq = 440.0, duration = 2.0, waveType = "sine") => apiRequest('/api/v1/audio/python-tool', { method: 'POST', body: JSON.stringify({ tool_type: toolType, track_id: trackId, file_id: fileId, time_pos: timePos, freq: freq, duration: duration, wave_type: waveType }) }), + // Gap 11: A/B reference track — analyze LUFS/dBTP/centroid (path hoac file_id) + referenceAnalyze: (payload) => apiRequest('/api/v1/audio/reference-analyze', { method: 'POST', body: JSON.stringify(payload) }), getAIConfigs: () => apiRequest('/api/v1/user/config/ai', { method: 'GET' }), saveAIConfigs: (providers) => apiRequest('/api/v1/user/config/ai', { method: 'POST', body: JSON.stringify({ providers }) }), diff --git a/app/templates/index.html b/app/templates/index.html index f9d1ec3..21d7fd4 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -51,7 +51,7 @@ - +