diff --git a/.gitignore b/.gitignore index 602cb95..fe1fcc6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ native_bridge/debug/*.out native_bridge/debug/pidtest.txt native_bridge/debug/shm_selfcheck.exe *.obj + +# Linux-only runtime artifacts (built via Docker; never committed) +install/daw_vst_bridge +install/libsfizz.so.1 diff --git a/Dockerfile b/Dockerfile index cf90a81..425f30b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cmake pkg-config libfluidsynth-dev libfreetype-dev git \ && rm -rf /var/lib/apt/lists/* \ && if [ -d native_bridge/sfizz-src ]; then cp -r native_bridge/sfizz-src /tmp/sfizz; \ - else git clone --recursive --depth 1 --branch v1.2.3 https://github.com/sfztools/sfizz.git /tmp/sfizz; fi \ + else git clone --recursive --depth 1 https://github.com/sfztools/sfizz.git /tmp/sfizz; fi \ && cmake -S /tmp/sfizz -B /tmp/sfizz/build -DCMAKE_BUILD_TYPE=Release \ -DSFIZZ_SHARED=ON -DSFIZZ_JACK=OFF -DSFIZZ_RENDER=OFF -DSFIZZ_TESTS=OFF \ -DSFIZZ_BENCHMARKS=OFF -DSFIZZ_DEMOS=OFF -DSFIZZ_DEVTOOLS=OFF \ diff --git a/TASKS_WINDOWS_AUDIO_NORMALIZE.md b/TASKS_WINDOWS_AUDIO_NORMALIZE.md new file mode 100644 index 0000000..3200dd3 --- /dev/null +++ b/TASKS_WINDOWS_AUDIO_NORMALIZE.md @@ -0,0 +1,84 @@ +# TASKS WINDOWS — Chuẩn hóa luồng audio lossless (SonicForgeStudio) + +Branch: `standalone-shm-bridge` — Pull code mới nhất trước khi làm. + +## Bối cảnh + +Linux đã hoàn tất chuẩn hóa luồng audio theo skill `lossless-audio-compliance` +(Float32 nội bộ, WAV IEEE Float32 khi render/export, TPDF dither chỉ ở bước +lượng tử cuối, resample Kaiser ≥130dB, null-test gate). Code C++/Python/JS đã +sửa trên Linux và **commit sẵn** — phần còn lại trên Windows chỉ là BUILD lại +binary, COPY vào `install/`, VERIFY, COMMIT. + +## Đã sửa (kèm theo branch này) + +| File | Thay đổi | +|---|---| +| `native_bridge/src/RenderJob.cpp`, `RenderFxJob.cpp` | WAV writer: 16-bit PCM (truncate, không dither) → **IEEE Float32** (format 3, 32-bit) | +| `app/core/render_engine.py`, `app/api/v1/audio.py`, `app/api/v1/plugins.py` | `sf.write(...)` → `subtype="FLOAT"` (4 chỗ) | +| `app/core/audio_features.py`, `app/core/render_engine.py` | `resample_poly` → `window=("kaiser", 16)` (stopband ≥130dB) | +| `app/static/js/app.jsx` + `app.precompiled.js` (đã build trên Linux) | Encoder client → Float32 mặc định; TPDF dither cho 16/24/8-bit; sửa bug 24-bit ghi nhầm 8-bit; UI thêm "32 float" | +| `app/templates/index.html` | bump cache `?v=20260818` | +| `tests/test_lossless_compliance.py` (mới) | Null-test gate (Rule 6) + assert float32 WAV (Rule 7) + bridge render deterministic | +| `native_bridge/tests/test_offline_render.py` | `read_wav` hiểu cả IEEE float32 lẫn PCM 16-bit | +| `Dockerfile` | fix `git clone --branch v1.2.3` (tag không tồn tại) → clone default | + +## Tasks trên Windows (theo thứ tự) + +### 1. Pull code mới nhất +```powershell +cd C:\Users\locpham\SonicForgeStudio +git checkout standalone-shm-bridge +git pull origin standalone-shm-bridge +``` + +### 2. Build lại `daw_vst_bridge.exe` (WAV writer float32) +Bridge build bằng CMake + vcpkg (fluidsynth) + sfizz-src (xem +`native_bridge/CMakeLists.txt`, `build/_sfizz.bat`): +```powershell +# sfizz từ source nếu chưa có (native_bridge/sfizz-src) +# vcpkg install fluidsynth +cmake -S native_bridge -B build/bridge-win -DCMAKE_BUILD_TYPE=Release ` + -DCMAKE_TOOLCHAIN_FILE=\scripts\buildsystems\vcpkg.cmake +cmake --build build/bridge-win --target daw_vst_bridge --config Release +Copy-Item build/bridge-win/Release/daw_vst_bridge.exe install/daw_vst_bridge.exe -Force +``` +Nếu có script build sẵn dùng script đó; **bắt buộc copy đè** `install/daw_vst_bridge.exe`. + +### 3. Build lại engine + app (`build_windows.ps1`) +```powershell +powershell -ExecutionPolicy Bypass -File build_windows.ps1 +``` +(Lưu ý: `app.precompiled.js` đã build sẵn trên Linux — bước [2/6] chỉ cần chạy +lại nếu bạn sửa `app.jsx`.) + +### 4. Copy binary mới vào `install/` +```powershell +Copy-Item src-tauri\target\release\sonicforge-daw.exe install\sonicforge-daw.exe -Force +# hoặc từ NSIS/MSI output tùy quy trình hiện tại +``` + +### 5. Verify (null-test gate + định dạng float32) +```powershell +python tests\test_lossless_compliance.py +python native_bridge\tests\test_offline_render.py +``` +Kỳ vọng: +- `bridge output IEEE float32 (tag 3)` PASS — WAV render ra format 3, 32-bit +- `bridge render deterministic (byte-identical)` PASS +- `null test ... bit-exact` PASS +- `test_offline_render.py` 3 test đầu PASS với `read_wav` mới + +### 6. Commit + push +```powershell +git add install/daw_vst_bridge.exe install/sonicforge-daw.exe +git commit -m "feat(audio): lossless float32 WAV render/export (bridge + engine) — chuẩn hóa luồng audio" +git push origin standalone-shm-bridge +``` + +## Lưu ý +- Không đổi lại WAV writer sang 16-bit: render nội bộ phải là IEEE Float32 + (skill Rule 4/7); dither TPDF chỉ áp dụng ở bước export cuối (client 16/24-bit). +- `install/daw_vst_bridge` (ELF Linux) không commit — chỉ commit `.exe`. +- Nếu `sonicforge-daw.exe` bundle cũ vẫn dùng `app.precompiled.js` cũ: xóa cache + trình duyệt / dùng `?v=` mới đã bump. diff --git a/app/api/v1/audio.py b/app/api/v1/audio.py index 9728526..7a5c796 100644 --- a/app/api/v1/audio.py +++ b/app/api/v1/audio.py @@ -280,7 +280,7 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends if data.ndim > 1: data = data.T sliced, z_start, z_end = await asyncio.to_thread(AIDSPEngine.slice_and_copy_with_zero_crossing, data, sr, req.selection_start, req.selection_end) - sf.write(out_path, sliced.T if sliced.ndim > 1 else sliced, sr) + sf.write(out_path, sliced.T if sliced.ndim > 1 else sliced, sr, subtype="FLOAT") dur = z_end - z_start else: z_start = round(req.selection_start, 4) @@ -313,7 +313,7 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic wave = PythonToolsEngine.generate_synth_wave(req.wave_type or "sine", req.freq or 440.0, req.duration or 2.0) output_file_id = f"user_{user_id}_synth_{req.wave_type}_{uuid.uuid4().hex[:6]}.wav" out_path = os.path.join(settings.PROCESSED_DIR, output_file_id) - sf.write(out_path, wave, 44100) + sf.write(out_path, wave, 44100, subtype="FLOAT") return { "success": True, "message": f"Generated {req.wave_type} synth wave ({req.freq}Hz)", diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index df64cc6..27c27f6 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -1249,7 +1249,7 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep ) from app.core import native_render audio = native_render.normalize_audio_peak(audio) - sf.write(out_path, audio.T, req.sample_rate) + sf.write(out_path, audio.T, req.sample_rate, subtype="FLOAT") return { "success": True, "file_id": os.path.basename(out_path), diff --git a/app/core/audio_editor.py b/app/core/audio_editor.py index 3cc2250..7951f52 100644 --- a/app/core/audio_editor.py +++ b/app/core/audio_editor.py @@ -5,6 +5,20 @@ import soundfile as sf from pydub import AudioSegment from app.core.dsp_utils import find_nearest_zero_crossing_file, apply_micro_fade + +def _write_wav_lossless(output_path, y, sample_rate, bit_depth): + """Lossless WAV export (lossless-audio-compliance skill): + - 32-bit -> IEEE Float32, bit-exact (no dither, format 3) + - 8/16/24-bit -> single-stage TPDF dither ONLY at final quantization""" + if bit_depth == 32: + sf.write(output_path, y, sample_rate, subtype="FLOAT") + return + subtype = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}.get(bit_depth, "PCM_24") + rng = np.random.default_rng() + y = y + (rng.random(y.shape) - rng.random(y.shape)) # TPDF, [-1,1) + y = np.clip(y, -1.0, 1.0) # clamp after dither — no wrap on quantization + sf.write(output_path, y, sample_rate, subtype=subtype) + def edit_audio_file(config: dict, input_path: str, output_path: str): """ Applies editing commands on the audio file based on config: @@ -192,12 +206,8 @@ def mix_multitrack_session(tracks_meta: list, output_path: str, sample_rate: int master_mix.export(temp_wav, format="wav") y, sr_read = sf.read(temp_wav) - # Xác định subtype mã hóa bit-depth - subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"} - selected_subtype = subtype_map.get(bit_depth, "PCM_16") - - # Ghi tệp WAV chất lượng cao - sf.write(output_path, y, sample_rate, subtype=selected_subtype) + # Ghi tệp WAV chất lượng cao (float32 / TPDF-dithered) + _write_wav_lossless(output_path, y, sample_rate, bit_depth) finally: # Dọn dẹp tệp tạm (luôn thực hiện) if os.path.exists(temp_wav): @@ -254,10 +264,7 @@ def export_audio(input_path: str, output_path: str, format: str = "wav", sound.export(temp_wav, format="wav") y, sr_read = sf.read(temp_wav) - subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"} - selected_subtype = subtype_map.get(bit_depth, "PCM_16") - - sf.write(output_path, y, sample_rate, subtype=selected_subtype) + _write_wav_lossless(output_path, y, sample_rate, bit_depth) finally: if os.path.exists(temp_wav): os.remove(temp_wav) diff --git a/app/core/audio_features.py b/app/core/audio_features.py index 8418c99..2dcf4f2 100644 --- a/app/core/audio_features.py +++ b/app/core/audio_features.py @@ -60,7 +60,7 @@ def load(path, sr=None, mono=True, offset=0.0, duration=None): from fractions import Fraction ratio = Fraction(int(sr), int(file_sr)) up, down = ratio.numerator, ratio.denominator - data = _signal.resample_poly(data, up, down).astype(np.float32) + data = _signal.resample_poly(data, up, down, window=("kaiser", 16)).astype(np.float32) file_sr = sr return data, file_sr diff --git a/app/core/render_engine.py b/app/core/render_engine.py index 3d4c7d1..3e9b89b 100644 --- a/app/core/render_engine.py +++ b/app/core/render_engine.py @@ -146,6 +146,7 @@ class PythonRenderEngine: up=self.sample_rate // g, down=sr // g, axis=-1, + window=("kaiser", 16), # stopband >= 130 dB (lossless-audio-compliance) ) sr = self.sample_rate @@ -499,5 +500,5 @@ class PythonRenderEngine: master_buffer /= max_peak # Write final output file - sf.write(output_filepath, master_buffer.T, self.sample_rate) + sf.write(output_filepath, master_buffer.T, self.sample_rate, subtype="FLOAT") return output_filepath diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 96bbd0d..0d343f1 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -11683,7 +11683,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
@@ -16832,7 +16833,7 @@ const App = () => { const [selectedProviderId, setSelectedProviderId] = useState(''); const [exportSettings, setExportSettings] = useState({ sampleRate: '44100', - bitDepth: '16', + bitDepth: 'float', format: 'wav', source: 'project', quality: '44khz', @@ -20130,8 +20131,8 @@ const App = () => { const sr = buffer.sampleRate; const monoData = buffer.getChannelData(0); const bufferLength = monoData.length; - const bitDepth = 16; - const bytesPerSample = 2; + const bitDepth = 32; + const bytesPerSample = 4; const headerSize = 44; const fileSizeBytes = headerSize + bufferLength * bytesPerSample; const fileBuffer = new ArrayBuffer(fileSizeBytes); @@ -20146,7 +20147,7 @@ const App = () => { writeString(8, 'WAVE'); writeString(12, 'fmt '); view.setUint32(16, 16, true); - view.setUint16(20, 1, 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); @@ -20156,8 +20157,7 @@ const App = () => { view.setUint32(40, bufferLength * bytesPerSample, true); let offset = 44; for (let i = 0; i < bufferLength; i++) { - const sample = Math.max(-1, Math.min(1, monoData[i])); - view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); + view.setFloat32(offset, monoData[i], true); // bit-exact, no clamp/quantize offset += bytesPerSample; } const blob = new Blob([view], { @@ -21249,6 +21249,10 @@ const App = () => { // ── 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 => v + (Math.random() - Math.random()); const encodeWavBlob = (audioBuffer) => { const numCh = Math.max(1, audioBuffer.numberOfChannels || 1); const sr = audioBuffer.sampleRate || 44100; @@ -21258,18 +21262,15 @@ const App = () => { 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 * 2); + 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 * 2, true); writeStr(8, 'WAVE'); - writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); + 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 * 2, true); view.setUint16(32, numCh * 2, true); view.setUint16(34, 16, true); - writeStr(36, 'data'); view.setUint32(40, interleaved.length * 2, true); - for (let i = 0; i < interleaved.length; i++) { - const s = Math.max(-1, Math.min(1, interleaved[i])); - view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, 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) => { @@ -25903,8 +25904,10 @@ const App = () => { durationLimit += 1.2; // FX/mastering tail const captureRate = audioCtx.sampleRate || 44100; - const bitDepth = parseInt(exportSettings.bitDepth) || 16; + 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) @@ -25940,13 +25943,13 @@ const App = () => { let off = 0; chunks.forEach(c => { stereoBuf.set(c, off); off += c.length; }); const frames = totalFrames; - const bytesPerSample = bitDepth / 8; + 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, 1, true); + 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); @@ -25954,9 +25957,18 @@ const App = () => { let o = 44; for (let i = 0; i < frames; i++) { for (let ch = 0; ch < outChannels; ch++) { - const s = Math.max(-1, Math.min(1, stereoBuf[i * 2 + ch])); - 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); + 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))); + 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; } } diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 06b1497..546aed9 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -688,7 +688,7 @@ const specMods=(spectrumModules?spectrumModules():null)||(getModule?[getModule() 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;it+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,onExport,onBounce})=>{if(!open)return null;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:"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-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"},"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-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("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"},"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",null)),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 pt-1"},/*#__PURE__*/React.createElement("button",{onClick:onBounce,disabled:isExporting,title:"Bounce realtime \u2014 file WAV \u0111\u1EA7y \u0111\u1EE7 MIDI + FX Rack + Mastering Chain (ch\u1EA1y l\u1EA1i project th\u1EADt)",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("i",{"data-lucide":"radio",className:"w-3 h-3"})," ",isExporting?'...':'Bounce MIDI'),/*#__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')))));};// --- Embedded VST GUI (PLAN_MASTERBUS_VST_GUI_EMBED.md) -------------------- +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,onExport,onBounce})=>{if(!open)return null;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:"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:'float',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-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:"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("option",{value:"float"},"32 float")))):/*#__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("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"},"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",null)),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 pt-1"},/*#__PURE__*/React.createElement("button",{onClick:onBounce,disabled:isExporting,title:"Bounce realtime \u2014 file WAV \u0111\u1EA7y \u0111\u1EE7 MIDI + FX Rack + Mastering Chain (ch\u1EA1y l\u1EA1i project th\u1EADt)",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("i",{"data-lucide":"radio",className:"w-3 h-3"})," ",isExporting?'...':'Bounce MIDI'),/*#__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')))));};// --- Embedded VST GUI (PLAN_MASTERBUS_VST_GUI_EMBED.md) -------------------- // Frame-capture + input relay: hiển thị native GUI qua HTTP /frame (~10fps), // gửi pointer/key về bridge qua /input. Dùng chung MasteringModal + FXRackModal. // url = embed_url từ API fx-gui open (embed:true); onClose khi user đóng; @@ -970,7 +970,7 @@ if(isMidiSoundTrack(t)){try{sendMidiVolumeCC7(t,effective,audible);}catch(e){}}i // already uses for solo. if(becameAudible&&isPlaying&&recordingStateRef.current!=='RECORDING'&&activeTracksRef.current&&activeTracksRef.current.length){try{stopAllPlayback();const ctx=getAudioContext();const resumeAt=currentTimeRef.current||0;startOffsetTimeRef.current=resumeAt;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=resumeAt;setIsPlaying(true);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 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({sampleRate:'44100',bitDepth:'16',format:'wav',source:'project',quality:'44khz',channels:'stereo'});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[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({sampleRate:'44100',bitDepth:'float',format:'wav',source:'project',quality:'44khz',channels:'stereo'});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 } @@ -1254,7 +1254,9 @@ for(let i=startSample;i=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;iprev.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=16;const bytesPerSample=2;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{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 ── +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{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{if(toastTimeoutRef.current)clearTimeout(toastTimeoutRef.current);setToastMessage({text,type,actionText,onActionClick});toastTimeoutRef.current=setTimeout(()=>setToastMessage(null),actionText?8000:3500);};window.showToast=showToast;// ── 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). -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{for(let i=0;i{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 ── +// ── 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=>v+(Math.random()-Math.random());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{for(let i=0;i{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;isetTimeout(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(newZoom50000)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 ── @@ -1780,12 +1786,15 @@ clientSideExport(exportTracks);}};// ── Realtime Bounce Export (đầy đủ const triggerBounceExport=async()=>{if(!masterBus){showToast("Hãy phát thử một lần để khởi tạo audio engine trước.","warning");return;}if(isExporting)return;setIsExporting(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. let durationLimit=Math.max(1.0,computeMainSessionEndTime(allTracks));durationLimit+=1.2;// FX/mastering tail -const captureRate=audioCtx.sampleRate||44100;const bitDepth=parseInt(exportSettings.bitDepth)||16;const outChannels=exportSettings.channels==='mono'?1:2;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 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;isetTimeout(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);const stereoBuf=new Float32Array(totalFrames*2);let off=0;chunks.forEach(c=>{stereoBuf.set(c,off);off+=c.length;});const frames=totalFrames;const bytesPerSample=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{const finalName=name||projectName||'Dự án mới';const localId=currentProjectId&¤tProjectId.startsWith('local_')?currentProjectId:'local_'+Date.now();const projectSchemaObj=serializeProjectToSchema(localId,finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings);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ừ +const totalFrames=chunks.reduce((a,c)=>a+c.length/2,0);const stereoBuf=new Float32Array(totalFrames*2);let off=0;chunks.forEach(c=>{stereoBuf.set(c,off);off+=c.length;});const frames=totalFrames;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>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'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=`sonicforge-bounce-${captureRate}hz-${bitDepth}bit.wav`;a.click();URL.revokeObjectURL(url);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{setIsExporting(false);}};const handleSaveLocalProject=name=>{const finalName=name||projectName||'Dự án mới';const localId=currentProjectId&¤tProjectId.startsWith('local_')?currentProjectId:'local_'+Date.now();const projectSchemaObj=serializeProjectToSchema(localId,finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings);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);// 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.startsWith('local_')){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 ── diff --git a/app/templates/index.html b/app/templates/index.html index 9c70007..7686ef5 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -50,7 +50,7 @@ - +