diff --git a/.gitignore b/.gitignore index a5992cb..7b547c6 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ native_bridge/debug/shm_selfcheck.exe install/daw_vst_bridge install/libsfizz.so.1 build_juce + +# build artifact (root, khong phai deploy dir) +target/ diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index 52716c6..27edf1d 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -1,13 +1,10 @@ import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio, logging -import numpy as np -import soundfile as sf from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Query, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse from pydantic import BaseModel from typing import Optional, Any from app.config import settings from app.core.vst_engine import PluginManager, HAS_PYFLUIDSYNTH, render_soundfont_midi_to_audio, RENDER_LOCK, _render_locked -from app.core.render_engine import PythonRenderEngine, _find_sf2_path, _find_default_sf2 from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_scanner import SoundFontAutoScanner @@ -593,6 +590,7 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_ @router.get("/soundfonts/download/{sf_id}") async def download_soundfont_asset(sf_id: str): + from app.core.render_engine import _find_sf2_path, _find_default_sf2 clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id # Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước # đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng) @@ -986,6 +984,7 @@ class FxRenderRequest(BaseModel): @router.post("/fx-render") async def fx_render(req: FxRenderRequest, current_user: dict = Depends(get_current_user)): """Áp fx_chain lên WAV đầu vào → WAV mới trong PROCESSED_DIR.""" + import soundfile as sf enforce_password_changed(current_user) src = (req.input_path or "").strip() if not src: @@ -1265,6 +1264,8 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep Khác FluidSynthWASM (docker): âm render server-side bằng pyfluidsynth, trả WAV để client play. Dùng cho: preview/keyboard, play MIDI item khi chạy transport ở môi trường standalone.""" + from app.core.render_engine import _find_sf2_path, _find_default_sf2 + import soundfile as sf enforce_password_changed(current_user) if not req.notes: raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để render") @@ -1320,6 +1321,9 @@ def render_project( current_user: dict = Depends(get_current_user) ): enforce_password_changed(current_user) + from app.core.render_engine import PythonRenderEngine + import soundfile as sf + import numpy as np engine = PythonRenderEngine() # Prevent path traversal: strip directory components; force .wav/.ogg. safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/")) diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py index 83b2db2..048c97c 100644 --- a/app/core/fx_realtime.py +++ b/app/core/fx_realtime.py @@ -23,10 +23,7 @@ import threading import time import uuid -import numpy as np - from app.config import settings -from app.core.native_render import find_fx_bridge_exe, find_juce_fx_bridge_exe FXRT_MAGIC = 0x46585254 FXRT_BLOCK = 256 @@ -91,6 +88,7 @@ class FxRealtimeSession: """Một session realtime: SHM + subprocess fx_vst_bridge + I/O block.""" def __init__(self, fx_chain, sample_rate, block_size=FXRT_BLOCK): + import numpy as np from multiprocessing import shared_memory self.fx_chain = fx_chain or [] self.sample_rate = int(sample_rate) @@ -163,6 +161,7 @@ class FxRealtimeSession: return out def _spawn(self): + from app.core.native_render import find_fx_bridge_exe, find_juce_fx_bridge_exe juce_exe = os.getenv("SF_JUCE_FX_BRIDGE_PATH", "").strip() # G3: mặc định dùng JUCE bridge (latency graph thật + processed samples); # fallback bridge cũ khi không có exe JUCE (regression an toàn). @@ -265,6 +264,7 @@ class FxRealtimeSession: """Gom FXRT_IN_SLOTS frame vào pending, đủ 4 → publish atomic vào ring (ghi data toàn bộ slot trước, sau đó in_write += n 1 lần). Ring đầy khi publish → drop batch cũ nhất (giữ latency thấp).""" + import numpy as np arr = np.frombuffer(interleaved, dtype=np.float32) if arr.size != self.block_size * 2: return @@ -333,6 +333,7 @@ class FxRealtimeSession: self.stats['flush'] += 1 def read_output(self): + import numpy as np """Drain output ring → list bytes (mỗi block 2048 bytes interleaved).""" out = [] with self._lock: diff --git a/app/core/vst_engine.py b/app/core/vst_engine.py index 992406e..a6a2632 100644 --- a/app/core/vst_engine.py +++ b/app/core/vst_engine.py @@ -1,9 +1,9 @@ # SonicForge Studio VST / VSTi Engine Service +from __future__ import annotations import os import json import re import time as _time -import numpy as np import functools import threading from ctypes import c_char_p @@ -52,6 +52,7 @@ def render_soundfont_midi_to_audio(midi_events: list, sf_path: str, bank: int = Xử lí đúng note CHỒNG nhau + đuôi release (event-stream noteon/noteoff). Trả stereo float32 (2, N). Raise RuntimeError nếu pyfluidsynth thiếu / SF không load được.""" + import numpy as np import fluidsynth as _fs settings = _fs.new_fluid_settings() _fs.fluid_settings_setnum(settings, b'synth.sample-rate', float(sr)) @@ -112,6 +113,7 @@ def render_soundfont_midi_to_audio(midi_events: list, sf_path: str, bank: int = @_render_locked def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray: + import numpy as np beat_duration_sec = 60.0 / max(30.0, bpm) max_duration_sec = 2.0 for event in midi_events: diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index a0fa184..52690cd 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -73,7 +73,7 @@ const resolveTrackInstrumentCtx = (track, tracks) => { // note; reset khi load instrument mới). Dùng chung mọi đường: timeline, // preview, keybed. Trước đây bank hardcode 0 + preview/keybed KHÔNG gửi // program change → SF2 preset ở bank≠0 ra piano sai. -window.__ensureBridgeProgram = (trkId, program, synthEngine) => { +window.__ensureBridgeProgram = (trkId, program, synthEngine, force) => { try { if (!window.SonicMidiRouter || !window.SonicMidiRouter.isBridgeActive()) return; const _bank = synthEngine && synthEngine.soundfont_bank !== undefined ? synthEngine.soundfont_bank : 0; @@ -83,7 +83,7 @@ window.__ensureBridgeProgram = (trkId, program, synthEngine) => { if (_prog === undefined || _prog === null) return; const _key = trkId + ':' + _bank + ':' + _prog; const _lp = window.__bridgeLastProgram || (window.__bridgeLastProgram = {}); - if (_lp[_key]) return; + if (!force && _lp[_key]) return; _lp[_key] = true; const _isPerc = _bank === 128; window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: trkId, pitch: 0, velocity: 0, data2: (_bank >> 7) & 0x7F, percussion: _isPerc }); @@ -280,7 +280,8 @@ const _dbgWorkletState = () => { try { if (window.BridgeAudioNode && window.BridgeAudioNode.getDiag) { const _d = window.BridgeAudioNode.getDiag(); - return 'wl=' + (_d.useWorklet ? 'y' : 'n') + ' pre=' + _d.preBufferLen + ' ch=' + _d.chunksLen; + const _ws = _d.ws ? (' ov=' + _d.ws.overruns + ' un=' + _d.ws.underruns + ' ring=' + _d.ws.ring) : ''; + return 'wl=' + (_d.useWorklet ? 'y' : 'n') + ' pre=' + _d.preBufferLen + ' ch=' + _d.chunksLen + _ws; } } catch (e) {} return 'wl=n/a'; @@ -292,9 +293,15 @@ window.__dbgWorkletState = _dbgWorkletState; window.__restorePendingFadeSession = restorePendingFadeSession; // V31: live note (ARM keyboard) — xoa backlog outQueue master FX truoc khi // note preview render (idle block ~341ms lam ARM lag ~0.3s). +// V45.32: preview latency — flush ring bridge sink (audio cu render truoc +// ~170ms) tai idle->active note-on de note moi nghe ngay, khong phai cho +// backlog cu chay het. Guard: khong flush khi timeline dang play (tranh gap +// audio playback dang phat). +let _timelinePlaying = false; window.__clearMasterFxQueue = function () { _dbgLastClearMs = performance.now(); try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: 'live' }); } catch (e) {} + try { if (!_timelinePlaying && window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {} }; // V32: queueLen master FX worklet hien tai (midi_push log — ARM latency). window.__fxRtQueueLen = function () { @@ -310,6 +317,8 @@ let _dbgFirstAudiblePending = false; let _fxRtPlayheadSnapPending = false; let _fxRtFreezeTimer = null; let _fxRtFreezeTries = 0; +let _fxRtSeekFreeze = false; +let _fxRtSeekFreezeTimer = null; const _nativeTrackUrls = {}; const _nativeTrackPending = {}; // ── MIDI overlap cleanup + polyphony gain (spec midi_polyphony_loudness) ─ @@ -784,6 +793,20 @@ function sendMidiVolumeCC7(t, list, audible) { } catch (e) {} } +// V45.24: PR tab audio DOC LAP voi mute/solo cua MAIN/SECTION — khoi phuc +// volume track (theo volumeDb, bo qua mute/solo) truoc khi note PR phat. +// sendMidiVolumeCC7(t, list, true) gui CC7=vol (channel khong con bi mute/solo +// tu toggle MAIN); setTrackNodeGain phuc hoi gainNode (SonicSF fallback dest). +function ensureTrackPrAudible(t, list, node) { + if (!t) return; + try { + const volDb = Number(t.volumeDb); + const volLin = (isNaN(volDb) || !isFinite(volDb)) ? 1.0 : (volDb <= -50 ? 0 : Math.pow(10, volDb / 20)); + if (node) setTrackNodeGain(node, volLin); + sendMidiVolumeCC7(t, list, true); + } catch (e) {} +} + // ── CC11 expression (realtime) ────────────────────────────────────────── // Mỗi note có expression_curve → chuỗi CC11 theo grid 16ms. Timer đăng ký // vào _cc11Timers để stopAllPlayback hủy sạch; epoch guard (_nativePlayEpoch) @@ -1777,6 +1800,7 @@ const fxRt = { active: false, starting: false, startToken: 0, sessionId: null, w // new-project / export-end → teardown bình thường. let fxRtKeepForRestart = false; let fxRtWrapRestart = false; // V38: loop wrap restart -- clear_queue mode 'loop' (always-wipe + RESUME_FILL) +let fxRtSeekRestartFlag = false; // V45.18: seek khi play master FX active -- clear_queue mode 'seek' (wipe queue cu + resume nhanh 8 block ~43ms) // V21: user stop (fade=true) giữ master FX session warm — play kế tiếp có FX // ngay, không chờ reload VST ~4s. Idle timeout: ngừng chơi lâu → teardown // giải phóng CPU/VST (chấp nhận ~4s delay ở play sau khi idle lâu). @@ -1908,7 +1932,7 @@ async function fxRtStart() { fxRt.sessionId = st.session_id; fxRtGuiPushShm(); try { - await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608272300`); + await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608291749`); if (token !== fxRt.startToken) { fxRt.starting = false; return; } fxRt.node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] }); const u = new URL(API_BASE_URL); @@ -2067,7 +2091,10 @@ async function fxRtStart() { // worklet phát lại OLD TAIL ~5ms → dbgPlayFirst tiêu thụ → trước đây snap // playhead SỚM + calib nhiễu ~50ms → display chạy trước → wrap sớm → vấp // beat 0:0. Pass mới thật ≥ ~80ms → snap/calib chỉ trên event hợp lệ. - if (d.tag === 'fx_play_first' && (_now - _dbgPlayStartMs) >= 50) { + // V45.19 Bug 1b: fx_seek_first (seek resume nhanh ~43ms < 50ms) snap KHONG + // can guard 50ms; fx_play_first giu guard (chan old-tail loop wrap snap som). + const _seekFirst = d.tag === 'fx_seek_first'; + if ((d.tag === 'fx_play_first' || _seekFirst) && (_seekFirst || (_now - _dbgPlayStartMs) >= 50)) { fxRt.pdcCalibMs = Math.max(50, Math.min(3000, _now - _dbgPlayStartMs)); try { if (window.__fxRtSnapPlayhead) window.__fxRtSnapPlayhead(); } catch (e2) {} } @@ -2390,6 +2417,17 @@ function getAudioContext() { _dbgLog('play_first_audible', 'delta_ms=' + Math.round(performance.now() - _dbgPlayStartMs)); } } + try { + if (window.__dbgPreviewAudioArmed) { + const _p = window.__dbgPreviewAudioArmed; + _p.count++; + if (l && r) { for (var _j = 0; _j < l.length; _j++) { if (l[_j] !== 0 || r[_j] !== 0) { _p.nz++; break; } } } + if (performance.now() - _p.t0 > 500) { + _dbgLog('preview_audio', 'frames=' + _p.count + ' nz=' + _p.nz); + window.__dbgPreviewAudioArmed = null; + } + } + } catch (e) {} window.BridgeAudioNode.onAudio(l, r); } catch (e) { console.warn('[Bridge] audio callback error:', e && e.message || e); @@ -7318,6 +7356,7 @@ const loadVstToBridge = async (track) => { if (!window.__bridgeLoadedPaths) window.__bridgeLoadedPaths = {}; const tId = track.id || track.trackId; const bch = window.SonicMidiRouter.allocateChannel(tId, false); + markTrackLoad(tId, { state: 'loading', channel: bch }); try { // V8 bug 5: engine _resolve_bridge_asset chi match FILE theo filename — // KHONG walk folder VST3 ("X.vst3/X.vst3.dll") → 404. Dung path da luu @@ -7326,7 +7365,7 @@ const loadVstToBridge = async (track) => { let btype = 'VST3'; let knownPath = track.synth_engine && track.synth_engine.plugin_path; if (!knownPath && window.__bridgePluginPaths) knownPath = window.__bridgePluginPaths[instrumentId]; - if (instrumentSelectorData) { + if (typeof instrumentSelectorData !== 'undefined' && instrumentSelectorData) { const v0 = (instrumentSelectorData.vst_instruments || []).find(v => v.id === instrumentId || v.name === instrumentId); if (v0 && v0.path) knownPath = v0.path; if (v0 && v0.type) btype = v0.type; @@ -7342,11 +7381,12 @@ const loadVstToBridge = async (track) => { } // Đã load ĐÚNG path vào channel này rồi → skip (không LOAD trùng). Bỏ // early-return mù theo flag — flag sai khi C++ restore instrument khác. - if (knownPath && window.__bridgeLoadedPaths[bch] === knownPath) return true; + if (knownPath && window.__bridgeLoadedPaths[bch] === knownPath) { markTrackLoad(tId, { state: 'done' }); return true; } // Có path đáng tin → load thẳng qua Tauri invoke (không cần POST/auth/CORS). if (knownPath) { const ok = await window.NativeBridgeService.loadInstrument(knownPath, btype, bch); if (ok) { window.__bridgeLoadedChannels[bch] = true; window.__bridgeLoadedPaths[bch] = knownPath; if (!window.__bridgePluginPaths) window.__bridgePluginPaths = {}; window.__bridgePluginPaths[instrumentId] = knownPath; } + if (!ok) markTrackLoad(tId, { state: 'failed' }); console.log('[Bridge] auto-load track', track.id, '->', btype, 'ch', bch, ok ? 'OK' : 'FAIL', knownPath); return !!ok; } @@ -7366,12 +7406,13 @@ const loadVstToBridge = async (track) => { } } catch (e2) { console.warn('[Bridge] listPlugins fallback fail:', e2); } } - if (!p) return false; + if (!p) { markTrackLoad(tId, { state: 'failed' }); return false; } const ok = await window.NativeBridgeService.loadInstrument(p, btype, bch); if (ok) { window.__bridgeLoadedChannels[bch] = true; window.__bridgeLoadedPaths[bch] = p; } + if (!ok) markTrackLoad(tId, { state: 'failed' }); console.log('[Bridge] auto-load track', track.id, '->', btype, 'ch', bch, ok ? 'OK' : 'FAIL', p); return !!ok; - } catch (e) { console.warn('[Bridge] loadVstToBridge fail:', track.id, e); return false; } + } catch (e) { console.warn('[Bridge] loadVstToBridge fail:', track.id, e); markTrackLoad(tId, { state: 'failed' }); return false; } }; // GM font dự phòng khi soundfont track không có trong thư viện (VD id // sf_generaluser_gs do AI sinh nhưng user chưa tải font) — ưu tiên font GM @@ -7396,6 +7437,7 @@ const loadAnyTrackToBridge = async (track) => { const se = track.synth_engine || {}; const bank = se.soundfont_bank !== undefined ? se.soundfont_bank : (track.soundfont_bank !== undefined ? track.soundfont_bank : 0); const bch = window.SonicMidiRouter.allocateChannel(track.id || track.trackId, bank === 128); + markTrackLoad(track.id || track.trackId, { state: 'loading', channel: bch }); let pl = null; try { const sfId = inst.replace('sf_', ''); @@ -7405,7 +7447,9 @@ const loadAnyTrackToBridge = async (track) => { const s0 = ((pl && pl.soundfonts) || []).find(s => String(s.id).replace('sf_', '') === sfId); bpath = s0 && (s0.file || s0.path); } catch (e) {} - let r = await window.SonicAPI.bridgeLoad({ name: inst, path: bpath || null, instrumentType: 'SF2', channel: bch }); + let r = null; + try { r = await window.SonicAPI.bridgeLoad({ name: inst, path: bpath || null, instrumentType: 'SF2', channel: bch }); } + catch (e) { console.warn('[Bridge] SF2 bridgeLoad fail (fallback):', inst, e); } let p = r && r.path ? r.path : null; if (!p) { // FALLBACK: soundfont id KHÔNG tồn tại trong thư viện (AI prompt mặc @@ -7419,15 +7463,37 @@ const loadAnyTrackToBridge = async (track) => { p = r2 && r2.path ? r2.path : null; } } - if (!p) { console.warn('[Bridge] SF2 resolve fail:', track.id, inst); return false; } + if (!p) { console.warn('[Bridge] SF2 resolve fail:', track.id, inst); markTrackLoad(track.id || track.trackId, { state: 'failed' }); return false; } const ok = await window.NativeBridgeService.loadInstrument(p, 'SF2', bch); if (ok) { if (!window.__bridgeLoadedChannels) window.__bridgeLoadedChannels = {}; window.__bridgeLoadedChannels[bch] = true; if (!window.__bridgeLoadedPaths) window.__bridgeLoadedPaths = {}; window.__bridgeLoadedPaths[bch] = p; } + if (!ok) markTrackLoad(track.id || track.trackId, { state: 'failed' }); console.log('[Bridge] preload SF2 track', track.id, '-> ch', bch, ok ? 'OK' : 'FAIL', p); return !!ok; - } catch (e) { console.warn('[Bridge] preload SF2 fail:', track.id, e); return false; } + } catch (e) { console.warn('[Bridge] preload SF2 fail:', track.id, e); markTrackLoad(track.id || track.trackId, { state: 'failed' }); return false; } } try { return await loadVstToBridge(track); } catch (e) { return false; } }; +// D16: per-track loading ruler (TCP). window.__trackLoadState[trackId] = +// { state:'pending'|'loading'|'done'|'failed', channel, startedTs, doneTs }. +// Poller trong App (loadTick) chuyển loading→done khi channel xuất hiện trong +// loaded_channels (queryStatus) — đúng cho cả C++ restore (bridge_state.json, +// chạy từ spawn) lẫn JS load — không đánh dấu done sớm khi C++ guard skip. +const markTrackLoad = (trackId, patch) => { + if (!trackId) return; + if (!window.__trackLoadState) window.__trackLoadState = {}; + const cur = window.__trackLoadState[trackId] || { state: 'pending', channel: null, startedTs: 0, doneTs: 0 }; + const next = Object.assign({}, cur, patch); + if (patch.state === 'loading' && cur.state !== 'loading') next.startedTs = Date.now(); + if (patch.state === 'done' && !patch.doneTs) next.doneTs = Date.now(); + window.__trackLoadState[trackId] = next; + if (window.__onTrackLoadState) { try { window.__onTrackLoadState(trackId); } catch (e) {} } +}; +if (typeof document !== 'undefined' && !document.getElementById('sf-ruler-style')) { + const _st = document.createElement('style'); + _st.id = 'sf-ruler-style'; + _st.textContent = '@keyframes sf-ruler { 0% { transform: translateX(-110%); } 100% { transform: translateX(340%); } } .sf-ruler-bar { animation: sf-ruler 1.1s ease-in-out infinite; } @keyframes sf-ruler-done { 0% { opacity: 1; } 100% { opacity: 0; } } .sf-ruler-done { animation: sf-ruler-done 1.4s ease-out forwards; }'; + document.head.appendChild(_st); +} const ensureAndOpenVstGui = async (track) => { const trackId = track.trackId || track.id; const instrumentId = track.instrumentId || (track.synth_engine && track.synth_engine.plugin_id); @@ -7533,6 +7599,43 @@ const wheelSteps = (key, deltaY, deltaMode, pxPerStep = 100) => { // the soundfonts; the per-track channel re-selection happens at note time. const preloadTrackInstruments = async (tracks) => { const list = tracks || []; + // D13: bridge load TRƯỚC vòng WASM — vòng selectInstrument (parse font WASM + // từng track, có thể ~20s) trước đây chặn vòng bridge → user bấm play khi + // app vừa load: NOTE_ON vào channel bridge chưa có instrument → im lặng tới + // khi bridge load xong (~27s từ bootstrap — dbg.log play_first_audible). + if (window.NativeBridgeService && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) { + // D16b: pre-mark TẤT CẢ track có instrument thành loading TRƯỚC loop — loop + // nối tiếp (C++ g_loadMutex) khiến track 2-9 không có ruler tới khi track 1 + // xong → UI lâu không có loading item. markTrackLoad channel=null hiện ruler + // ngay; loadAnyTrackToBridge set channel khi tới lượt; poller chuyển done. + for (let i = 0; i < list.length; i++) { + const t = list[i]; + const inst = t.instrumentId || (t.synth_engine && t.synth_engine.plugin_id); + if (!inst) continue; + markTrackLoad(t.id || t.trackId, { state: 'loading', channel: null }); + } + for (let i = 0; i < list.length; i++) { + const t = list[i]; + const inst = t.instrumentId || (t.synth_engine && t.synth_engine.plugin_id); + if (!inst) continue; + try { await loadAnyTrackToBridge(t); } catch (e) {} + // V45.11: C++ loadSoundFontFile resets bank/program to 0 on every + // load; preload only loads the SF2 file -> after reopen the SF + // preset is wrong (bug: muted SF tracks lost their preset). + // __ensureBridgeProgram falls back to synth_engine.soundfont_program. + if (typeof inst === 'string' && inst.startsWith('sf_')) { + try { if (window.__ensureBridgeProgram) window.__ensureBridgeProgram(t.id || t.trackId, undefined, t.synth_engine); } catch (e) {} + // V45.12: C++ restore (bridge_state.json) chạy ASYNC sau preload — + // assign() + selectProgram(bank/program CŨ) ghi đè program JS vừa + // gửi (race startup). Retry FORCE (bỏ dedupe) sau 1.5s/4s/8s để qua + // cửa sổ restore, không spam mỗi note. + if (t.synth_engine && t.synth_engine.soundfont_program !== undefined) { + const _fire = () => { try { if (window.__ensureBridgeProgram) window.__ensureBridgeProgram(t.id || t.trackId, undefined, t.synth_engine, true); } catch (e) {} }; + [1500, 4000, 8000].forEach(ms => setTimeout(_fire, ms)); + } + } + } + } if (window.SonicSF && window.SonicSF.selectInstrument) { for (let i = 0; i < list.length; i++) { const t = list[i]; @@ -7547,17 +7650,7 @@ const preloadTrackInstruments = async (tracks) => { } catch (e) {} } } - // Lỗi 3: mở lại project → VST3 đã lưu phải load lại vào bridge (không mở GUI). - if (window.NativeBridgeService && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) { - for (let i = 0; i < list.length; i++) { - const t = list[i]; - const inst = t.instrumentId || (t.synth_engine && t.synth_engine.plugin_id); - if (!inst) continue; - try { await loadAnyTrackToBridge(t); } catch (e) {} - } - } }; - const ProfileModal = ({ isOpen, onClose, @@ -9605,7 +9698,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos playNativeVstNote(pvTrk, n.pitch, n.velocity || 0.8, 200, undefined, 'pv_' + st.trackId); return; } - scheduleMidiNoteDispatch(pvTrk, n.pitch, n.velocity || 0.8, 200, ctx.currentTime, pvCtx.program, null, pvCtx.ch, pvCtx.synthEngine); + scheduleMidiNoteDispatch(pvTrk, n.pitch, n.velocity || 0.8, 200, ctx.currentTime, pvCtx.program, null, pvCtx.ch, pvCtx.synthEngine, true); }); } } @@ -10120,7 +10213,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos } else if (isStandaloneSf() && isVstTrackEngine(clCtx.synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive())) { playNativeVstNote(clTrk, notes[clickedNoteIdx].pitch, notes[clickedNoteIdx].velocity || 0.8, 300, undefined, 'pv_' + st.trackId); } else { - scheduleMidiNoteDispatch(clTrk, notes[clickedNoteIdx].pitch, notes[clickedNoteIdx].velocity || 0.8, 300, ctx.currentTime, clCtx.program, null, clCtx.ch, clCtx.synthEngine); + scheduleMidiNoteDispatch(clTrk, notes[clickedNoteIdx].pitch, notes[clickedNoteIdx].velocity || 0.8, 300, ctx.currentTime, clCtx.program, null, clCtx.ch, clCtx.synthEngine, true); } } } @@ -10390,7 +10483,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos // playNote (FluidSynth — nhạc cụ THẬT của track). _playNoteFallback chỉ // là oscillator beep (sai âm với percussion/soundfont — user: note vẽ // mới nghe nhạc cụ track trước). - scheduleMidiNoteDispatch(dwTrk, dwPitch, brushVelocityRef.current || 0.8, dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined); + scheduleMidiNoteDispatch(dwTrk, dwPitch, brushVelocityRef.current || 0.8, dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined, true); } } }; @@ -10492,7 +10585,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos var pvCtx = getAudioContext(); // playNote (FluidSynth — nhạc cụ THẬT). _playNoteFallback = oscillator // beep sai âm (percussion/soundfont). - scheduleMidiNoteDispatch(pvTrk, p, brushVelocityRef.current || 0.8, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine); + scheduleMidiNoteDispatch(pvTrk, p, brushVelocityRef.current || 0.8, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine, true); previewPitchRef.current = p; } } @@ -11006,6 +11099,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos var kbTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var kbCtx = resolveTrackInstrumentCtx(kbTrk, activeTracks); ensureSonicInstrument(kbCtx); + // V45.20 Bug 6: renderKeybed CHI chay trong PianoRollTabEditor (st.type === 'PIANO_ROLL' + // -> luon la PR tab) nen _kbSilent luon false. Bo refs ngoai scope (subTabsRef/ + // activeTabRef) gay ReferenceError render crash -> piano roll trang. NOTE_OFF + // van gui (vo hai) nhu V45.19. + const _kbSilent = false; const keys = []; for (let pitch = 127; pitch >= PITCH_START; pitch--) { const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12); @@ -11027,31 +11125,48 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos // keybed PHẢI qua mastering FX của main out khi chain đang bật // (trước đây phải bật/tắt power mới có tác dụng). try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (er) {} - if (window.triggerMidiVuActivity) { - window.triggerMidiVuActivity((st.parent_tab_id && st.parent_tab_id.startsWith('session_') ? '_sess_' : '') + st.trackId, 100); - } - const kbNative = isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()); - const kbNativeVst = isStandaloneSf() && isVstTrackEngine(kbCtx.synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()); - if (kbNative) { - playNativeSfNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch); - } else if (kbNativeVst) { - playNativeVstNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch); - } - if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) { - try { if (window.__ensureBridgeProgram) window.__ensureBridgeProgram(kbTrk ? kbTrk.id : 0, kbCtx.program, kbCtx.synthEngine); } catch (er) {} - window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 100 / 127, percussion: !!(kbCtx && kbCtx.synthEngine && kbCtx.synthEngine.soundfont_bank === 128), live: true }); - } else if (window.SonicSF && !kbNative && !kbNativeVst) { - // ⚠️ FIX: giữ note theo thời gian bấm phím — durationMs lớn (5s) - // chỉ là auto-off phòng hờ; mouseup/mouseleave gọi stopNote dừng - // NGAY (trước đây 500ms → note tự tắt giữa chừng khi giữ phím). - window.SonicSF.playNote(pitch, 100, 5000, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine); + // DIAG Bug 2 (keybed ARM khong am): routing + ARM state tai note-on. + try { + let _pdDest = 'none'; + if (window.AudioRoutingEngine && window.AudioRoutingEngine._connected) { + _pdDest = (window.AudioRoutingEngine._dest && window.AudioRoutingEngine._dest.constructor) ? window.AudioRoutingEngine._dest.constructor.name : 'null'; + _pdDest += '/' + window.AudioRoutingEngine._trackId; + } else { _pdDest = 'NOT_CONNECTED'; } + const _pdD = window.BridgeAudioNode ? window.BridgeAudioNode.getDiag() : null; + const _pdTab = activeTabRef.current; + const _pdSub = subTabsRef.current ? (subTabsRef.current.find(s => s.id === _pdTab) || {}).type : null; + const _kbArmedTab = !!(subTabsRef.current || []).some(function (s) { return s.type === 'PIANO_ROLL' && s.isArmed && s.trackId === st.trackId; }); + _dbgLog('preview_diag', 'src=keybed trk=' + st.trackId + ' kbTrk=' + (kbTrk ? kbTrk.id : 'UNDEF') + ' kbArmedTab=' + (_kbArmedTab ? 1 : 0) + ' armedTrk=' + (kbTrk && kbTrk.isArmed ? 1 : 0) + ' sl=' + (_kbSilent ? 1 : 0) + ' tab=' + _pdTab + ' sub=' + (_pdSub || 'none') + ' play=' + (isPlayingRef.current ? 1 : 0) + ' br=' + (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() ? 1 : 0) + ' fxRt=' + (fxRt.active ? 1 : 0) + ' dest=' + _pdDest + ' bd=' + (_pdD ? ('i=' + _pdD.initialized + ' wl=' + _pdD.useWorklet + ' nd=' + _pdD.hasWorkletNode + ' pre=' + _pdD.preBufferLen + ' ch=' + _pdD.chunksLen + ' g=' + _pdD.gain + ' ws=' + (_pdD.ws ? (_pdD.ws.overruns + '/' + _pdD.ws.underruns) : '-')) : 'MISSING') + ' mb=' + (!!masterBus)); + } catch (e) {} + // V45.19 Bug 2: _kbSilent -> bo qua phat (MAIN/SECTION + ARM off = cam). + if (!_kbSilent) { + try { window.__dbgPreviewAudioArmed = { count: 0, nz: 0, t0: performance.now() }; } catch (e) {} + if (window.triggerMidiVuActivity) { + window.triggerMidiVuActivity((st.parent_tab_id && st.parent_tab_id.startsWith('session_') ? '_sess_' : '') + st.trackId, 100); + } + const kbNative = isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()); + const kbNativeVst = isStandaloneSf() && isVstTrackEngine(kbCtx.synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()); + if (kbNative) { + playNativeSfNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch); + } else if (kbNativeVst) { + playNativeVstNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch); + } + if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) { + try { if (window.__ensureBridgeProgram) window.__ensureBridgeProgram(kbTrk ? kbTrk.id : 0, kbCtx.program, kbCtx.synthEngine); } catch (er) {} + window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 100 / 127, percussion: !!(kbCtx && kbCtx.synthEngine && kbCtx.synthEngine.soundfont_bank === 128), live: true }); + } else if (window.SonicSF && !kbNative && !kbNativeVst) { + // ⚠️ FIX: giữ note theo thời gian bấm phím — durationMs lớn (5s) + // chỉ là auto-off phòng hờ; mouseup/mouseleave gọi stopNote dừng + // NGAY (trước đây 500ms → note tự tắt giữa chừng khi giữ phím). + window.SonicSF.playNote(pitch, 100, 5000, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine); + } } } catch (err) { console.error('playNote error:', err); } }, onMouseEnter: (e) => { - if (keybedMouseDownRef.current) { + if (keybedMouseDownRef.current && !_kbSilent) { try { if (window.triggerMidiVuActivity) { window.triggerMidiVuActivity((st.parent_tab_id && st.parent_tab_id.startsWith('session_') ? '_sess_' : '') + st.trackId, 100); @@ -15539,6 +15654,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => { midiTotalBeatsRef.current = midiTotalBeats; midiFileBpmRef.current = midiFileBpm; isPlayingRef.current = isPlaying; + _timelinePlaying = isPlaying; isPausedRef.current = isPaused; folderRef.current = folder; tempoRef.current = tempo; @@ -18428,6 +18544,7 @@ const App = () => { // khi hết âm). const isPlayingRef = useRef(false); isPlayingRef.current = isPlaying; + _timelinePlaying = isPlaying; // Bounce realtime = project đang phát thật; guardPlay (scheduleMidiNoteDispatch) // phải cho note fire dù isPlayingRef bị render ghi đè =false khi bounce setState. // (Không sync từ state ở đây — isExporting khai báo sau; bounce set true/false @@ -18543,6 +18660,10 @@ const App = () => { const rawVel = msg.data[2]; // Scale low MIDI velocity naturally (min 1, max 127) const scaledVel = Math.min(127, Math.max(1, Math.round(rawVel))); + // V45.21: ARM piano roll chi hieu luc khi dang o PIANO_ROLL tab + // (giong vkbNoteOn V45.19) — quay lai MAIN/SESSION khong leak preview. + const _prTabNow = (subTabsRef.current || []).find(function (s) { return s.id === activeTabRef.current; }); + const _isPrTabNow = !!(_prTabNow && _prTabNow.type === 'PIANO_ROLL'); if (cmd === 0x9 && rawVel > 0) { lastMidiNoteRef.current = { pitch, velocity: scaledVel, startTime: performance.now(), length: 0 }; setLastMidiNote({ pitch, velocity: scaledVel, length: 0, time: Date.now() }); @@ -18559,8 +18680,22 @@ const App = () => { // D1: bridge active -> 1 cổng router (per-track channel alloc) → // Rust SHM → C++ bridge; giữ SonicSF fallback ở nhánh else. var bTracks = activeTracksRef.current || []; + // ARM piano roll (sub-tab isArmed, cuc bo) cung tinh nhu ARM + // track - preview note tu piano roll tab phai toi track dang + // edit du track main KHONG ARM (ARM piano roll khong truyen). + var bPrArmed = {}; + if (_isPrTabNow) { + try { + // V45.27: chi sub-tab PR dang active VA co ARM (isArmed); khong gom ARM sub-tab khac / MAIN. + var _actPrSub = (subTabsRef.current || []).find(function (s) { return s.id === activeTabRef.current; }); + if (_actPrSub && _actPrSub.type === 'PIANO_ROLL' && _actPrSub.isArmed && _actPrSub.trackId) bPrArmed[_actPrSub.trackId] = true; + } catch (e) {} + } bTracks.forEach(function (bt) { - if (!bt.isArmed) return; + if (_isPrTabNow ? !bPrArmed[bt.id] : !bt.isArmed) return; + // V45.24: PR preview doc lap voi mute/solo MAIN/SECTION — + // khoi phuc volume track (bo qua mute/solo) khi o PR tab. + if (_isPrTabNow) { try { ensureTrackPrAudible(bt, bTracks, activeTrackNodesRef.current[bt.id]); } catch (e) {} } if (window.triggerMidiVuActivity) { window.triggerMidiVuActivity(bt.id, scaledVel); } @@ -18571,12 +18706,19 @@ const App = () => { }); } else if (window.SonicSF) { var allTracks = activeTracksRef.current || []; - var arSubs = subTabsRef && subTabsRef.current ? subTabsRef.current.filter(function(s) { return s.type === 'PIANO_ROLL' && s.isArmed; }) : []; - var armedTracks = allTracks.filter(function(t) { return t.isArmed; }); + var arSubs = []; + // V45.27: chi sub-tab PR dang active VA co ARM (isArmed); khong gom ARM sub-tab khac / MAIN. + if (_isPrTabNow && subTabsRef && subTabsRef.current) { + var _actPrSub = subTabsRef.current.find(function(s) { return s.id === activeTabRef.current; }); + if (_actPrSub && _actPrSub.type === 'PIANO_ROLL' && _actPrSub.isArmed && _actPrSub.trackId) arSubs.push(_actPrSub); + } + var armedTracks = _isPrTabNow ? [] : allTracks.filter(function(t) { return t.isArmed; }); // Piano Roll arming has priority: route to each armed sub-tab's parent track if (arSubs.length > 0) { arSubs.forEach(function(as) { var asTrk = allTracks.find(function(t) { return t.id === as.trackId; }); + // V45.24: PR preview doc lap voi mute/solo MAIN/SECTION. + if (_isPrTabNow && asTrk) { try { ensureTrackPrAudible(asTrk, allTracks, activeTrackNodesRef.current[as.trackId]); } catch (e) {} } var asCh = asTrk ? assignTrackMidiChannel(asTrk, allTracks) : 0; var asProg = as.instrumentProgram; var asSe = as.synth_engine; @@ -18599,6 +18741,8 @@ const App = () => { } // Route to ALL armed tracks (not just the first one) armedTracks.forEach(function(at) { + // V45.24: PR preview doc lap voi mute/solo MAIN/SECTION. + if (_isPrTabNow) { try { ensureTrackPrAudible(at, allTracks, activeTrackNodesRef.current[at.id]); } catch (e) {} } var atCh = assignTrackMidiChannel(at, allTracks); var atProg = at.instrumentProgram; var atSe = at.synth_engine; @@ -18752,6 +18896,8 @@ const App = () => { const activeAudioRecordersRef = useRef({}); const pianoRollRecorderRef = useRef(null); const activeMidiPitchesRef = useRef(new Set()); + const timelinePitchesRef = useRef({}); // D18c: per-track Set timeline MIDI pitches dang keu (NOTE_ON fired) + const tracksRef = useRef([]); // D18c: tracks state moi nhat (guard mute/solo fire-time) const [activeMidiPitches, setActiveMidiPitches] = useState(new Set()); const recordingPCMDataRef = useRef({}); const recordingStartTimeRef = useRef(0); @@ -18908,13 +19054,28 @@ const App = () => { const applyAllTrackMuteSolo = (trackId, patch) => { const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks; const effective = (trackId && patch) ? list.map(t => t.id === trackId ? { ...t, ...patch } : t) : list; + // V45.3: LUON ghi ref voi state da patch (mute/solo). Truoc day chi ghi trong + // nhanh becameAudible roi restore STALE -> solo-out khong co track became + // audible -> D18b guard (25423) + scheduler (25498) + toggleTrackSoloEvaluate + // (setTimeout 60ms restart) thay trang thai PRE-solo -> Qin note van fire + // (tremolo). Render ke tiep tu dong dong bo lai ref. + activeTracksRef.current = effective; + try { if (window.__dbgLog) window.__dbgLog('solo_apply', 'track=' + trackId + ' patch=' + (patch ? JSON.stringify(patch) : '-') + ' n=' + effective.length + ' solo=[' + effective.filter(function (t) { return t.solo; }).map(function (t) { return t.id; }).join(',') + '] muted=[' + effective.filter(function (t) { return t.muted; }).map(function (t) { return t.id; }).join(',') + ']'); } catch (e) {} let becameAudible = false; effective.forEach(t => { const audible = computeTrackAudibleGain(effective, t) > 0; const wasAudible = trackAudibleRef.current[t.id] !== false; const node = activeTrackNodesRef.current[t.id]; const audibleGain = computeTrackAudibleGain(effective, t); - if (node) setTrackNodeGain(node, audibleGain); + // V45.24: PR tab doc lap voi mute/solo MAIN/SECTION — track dang co + // sub-tab PIANO_ROLL PLAY thi KHONG ap mute/solo realtime (gain=0, + // CC7=0, kill note) de PR khong bi cam; mute/solo chi anh huong + // MAIN/SECTION playback. + const prPlayingForTrack = !!(subTabsRef.current || []).some(function (s) { return s.type === 'PIANO_ROLL' && s.trackId === t.id && s.isPlaying; }); + const _volDb = Number(t.volumeDb); + const _volLin = (isNaN(_volDb) || !isFinite(_volDb)) ? 1.0 : (_volDb <= -50 ? 0 : Math.pow(10, _volDb / 20)); + const effGain = prPlayingForTrack ? _volLin : audibleGain; + if (node) setTrackNodeGain(node, effGain); // Section sub-nodes (key: _sub_) — mute/solo của // SECTION-TAB phải áp gain cho node play section — CHỈ trong section // (activeTracksRef = tracks section), KHÔNG đụng track MAIN. @@ -18925,16 +19086,43 @@ const App = () => { } }); if (node && node.sfEntry) { - try { node.sfEntry.gain.setTargetAtTime(audibleGain, getAudioContext().currentTime, 0.02); } catch (e) {} + try { node.sfEntry.gain.setTargetAtTime(effGain, getAudioContext().currentTime, 0.02); } catch (e) {} } // MIDI/bridge tracks: CC7 (channel volume) realtime — even for notes // already sounding. Bridge active → qua SonicMidiRouter (bridge); bridge // down → SonicSF (WASM). CC7 = VOLUME của track (không chỉ 100/0). if (isMidiSoundTrack(t)) { - try { sendMidiVolumeCC7(t, effective, audible); } catch (e) {} + try { sendMidiVolumeCC7(t, effective, audible || prPlayingForTrack); } catch (e) {} + } + // D18: track vừa MẤT tiếng (mute/solo) — dừng note đang giữ ngay. CC7=0 + // (sendMidiVolumeCC7) không ăn với VST như Qin (bỏ qua channel volume) + // → note kêu tiếp (tremolo) khi solo track khác. NOTE_OFF từng pitch đang + // giữ + CC123/CC120 all-notes-off trên channel track đó. + if (!audible && wasAudible && !prPlayingForTrack && isMidiSoundTrack(t)) { + try { + const _isPerc = !!(t.synth_engine && t.synth_engine.soundfont_bank === 128) || !!t.is_percussion; + try { if (window.__dbgLog) window.__dbgLog('solo_kill', 'track=' + t.id + ' tset=' + (((timelinePitchesRef.current || {})[t.id] || new Set()).size) + ' held=' + ((activeMidiPitchesRef.current || new Set()).size)); } catch (e) {} + const _held = activeMidiPitchesRef.current || new Set(); + // D18c: timeline MIDI notes (per-track) dang keu - NOTE_OFF tung pitch; + // truoc day chi kill preview keyboard (activeMidiPitchesRef) -> note + // timeline (Qin VST2) cu ngan/loop (tremolo) khi solo track khac. + const _tSet = (timelinePitchesRef.current || {})[t.id]; + if (_tSet && _tSet.size) { + _tSet.forEach(function (_p) { + window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: t.id, pitch: _p, velocity: 0, percussion: _isPerc, live: true }); + }); + } + _held.forEach(function (_p) { + window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: t.id, pitch: _p, velocity: 0, percussion: _isPerc, live: true }); + }); + window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: t.id, pitch: 123, velocity: 0, data2: 0, percussion: _isPerc }); + window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: t.id, pitch: 120, velocity: 0, data2: 0, percussion: _isPerc }); + if (heldMidiNotesRef.current) delete heldMidiNotesRef.current[t.id]; + if (timelinePitchesRef.current) delete timelinePitchesRef.current[t.id]; + } catch (e) {} } if (audible && !wasAudible) becameAudible = true; - trackAudibleRef.current[t.id] = audible; + trackAudibleRef.current[t.id] = prPlayingForTrack ? true : audible; }); // A track that just became audible may have had its audio sources dropped // by a loop restart while it was inaudible (startTrackPlayback skips @@ -18956,13 +19144,8 @@ const App = () => { // startTrackPlayback (isPlayable) → câm + CC11 không chạy. Dùng // `effective` (đã patch mute/solo) làm nguồn restart; ref tự phục hồi // ở render kế tiếp (activeTracksRef.current = activeTracks). - const _prevActiveTracks = activeTracksRef.current; - activeTracksRef.current = effective; - try { - startTrackPlayback(resumeAt); - } finally { - activeTracksRef.current = _prevActiveTracks; - } + // V45.3: ref da = effective (dau ham) -> restart thay trang thai moi ngay. + startTrackPlayback(resumeAt); } catch (e) { console.warn('mute/solo resume error:', e); } @@ -19125,6 +19308,31 @@ const App = () => { const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId } const [editNameInput, setEditNameInput] = useState(''); + // D16: per-track loading ruler — ticker buộc re-render khi có track đang + // loading; poller queryStatus chuyển loading→done khi channel sẵn sàng + // (đúng cho cả C++ restore từ spawn lẫn JS load — không phụ thuộc thứ tự). + const [loadTick, setLoadTick] = useState(0); + useEffect(() => { + window.__onTrackLoadState = () => setLoadTick(t => t + 1); + const iv = setInterval(async () => { + try { + if (!window.NativeBridgeService || !window.SonicMidiRouter || !window.SonicMidiRouter.isBridgeActive()) return; + const m = window.__trackLoadState || {}; + const loadingKeys = Object.keys(m).filter(k => m[k].state === 'loading'); + if (!loadingKeys.length) return; + const st = await window.NativeBridgeService.queryStatus(); + const lc = (st && Array.isArray(st.loaded_channels)) ? st.loaded_channels : null; + let changed = false; + loadingKeys.forEach(k => { + const s = m[k]; + if (lc && s.channel != null && lc.some(x => x.channel === s.channel)) { m[k] = Object.assign({}, s, { state: 'done', doneTs: Date.now() }); changed = true; } + else if (!s.startedTs || Date.now() - s.startedTs > 90000) { m[k] = Object.assign({}, s, { state: 'failed' }); changed = true; } + }); + if (changed) setLoadTick(t => t + 1); + } catch (e) {} + }, 400); + return () => { delete window.__onTrackLoadState; clearInterval(iv); }; + }, []); // ── Context Menu & Clipboard ── const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId } @@ -19283,11 +19491,15 @@ const App = () => { const applyPlayheadDirect = (time) => { localSelectionAnchorRef.current = time; if (isPlaying) { + // V45.17: seek khi đang play — startOffset + freeze NGAY: playhead nhảy + // tới click + đứng tại đó (không chạy theo processed anchor cũ 50ms). + startOffsetTimeRef.current = time; setCurrentTime(time); fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime - stopAllPlayback(); + fxRtSeekRestartFlag = true; // V45.18: seek khi play — startTrackPlayback clear_queue mode 'seek' (wipe queue cu + resume nhanh) + stopAllPlayback(false, true); // V45.21: skipRouting nhu seekPlaybackTo (V45.10) + try { if (window.__fxRtPlayheadFreeze) window.__fxRtPlayheadFreeze(true); } catch (e) { } setTimeout(() => { - startOffsetTimeRef.current = time; startAudioTimeRef.current = getAudioContext().currentTime; startTrackPlayback(time); setIsPlaying(true); @@ -19593,6 +19805,7 @@ const App = () => { }, [activeTab, sessionTabs, subTabs, tracks]); const activeTracksRef = useRef([]); activeTracksRef.current = activeTracks; + tracksRef.current = tracks; // D18c: sync tracks state moi nhat (guard fire-time) const sessionTabsRef = useRef(sessionTabs); sessionTabsRef.current = sessionTabs; @@ -19666,10 +19879,16 @@ const App = () => { const audible = computeTrackAudibleGain(list, t) > 0; const node = activeTrackNodesRef.current[t.id]; const audibleGain = computeTrackAudibleGain(list, t); - if (node) setTrackNodeGain(node, audibleGain); + // V45.24: PR tab doc lap — track co sub-tab PR PLAY thi khong ap + // mute/solo realtime (gain=0/CC7=0) trong sync effect. + const prPlayingForTrack = !!(subTabsRef.current || []).some(function (s) { return s.type === 'PIANO_ROLL' && s.trackId === t.id && s.isPlaying; }); + const _volDb = Number(t.volumeDb); + const _volLin = (isNaN(_volDb) || !isFinite(_volDb)) ? 1.0 : (_volDb <= -50 ? 0 : Math.pow(10, _volDb / 20)); + const effGain = prPlayingForTrack ? _volLin : audibleGain; + if (node) setTrackNodeGain(node, effGain); if (node && node.sfEntry) { try { - const g = (typeof audibleGain === 'number' && isFinite(audibleGain) && !isNaN(audibleGain)) ? audibleGain : 1.0; + const g = (typeof effGain === 'number' && isFinite(effGain) && !isNaN(effGain)) ? effGain : 1.0; node.sfEntry.gain.setTargetAtTime(g, getAudioContext().currentTime, 0.02); } catch (e) {} } @@ -19677,9 +19896,9 @@ const App = () => { // so FluidSynth/bridge-rendered notes respect mute/solo too. Bridge // active → SonicMidiRouter; down → SonicSF. CC7 = VOLUME track. if (isMidiSoundTrack(t)) { - try { sendMidiVolumeCC7(t, list, audible); } catch (e) {} + try { sendMidiVolumeCC7(t, list, audible || prPlayingForTrack); } catch (e) {} } - trackAudibleRef.current[t.id] = audible; + trackAudibleRef.current[t.id] = prPlayingForTrack ? true : audible; }); updateSfRouting(); }, [tracks, sessionTabs]); @@ -20883,6 +21102,17 @@ const App = () => { if (fxRt && fxRt.active) { _fxRtPlayheadSnapPending = false; fxRtAnchorProcessed(); + // V45.16: seek/restart FX active — giữ playhead tại startOffset cho tới khi + // worklet phát block đầu (fx_play_first). Trước đây playhead chạy theo + // processedSamples trong lúc re-buffer FILL 24 (~128ms) → playhead trước + // âm (Bug 1b). Fail-safe 1500ms: engine không phát (session fail) → thả. + _fxRtSeekFreeze = true; + if (_fxRtSeekFreezeTimer) { clearTimeout(_fxRtSeekFreezeTimer); } + _fxRtSeekFreezeTimer = setTimeout(function () { + _fxRtSeekFreeze = false; + _fxRtSeekFreezeTimer = null; + fxRtAnchorProcessed(); + }, 1500); return; } // V41: gate theo CHAIN (không chỉ active) — session FX warm (stop giữ warm, @@ -20913,6 +21143,13 @@ const App = () => { }, _estMs); }; window.__fxRtSnapPlayhead = function () { + // V45.16: hết seek-freeze — playhead bắt đầu từ startOffset tại mẫu nghe được. + if (_fxRtSeekFreeze) { + _fxRtSeekFreeze = false; + if (_fxRtSeekFreezeTimer) { clearTimeout(_fxRtSeekFreezeTimer); _fxRtSeekFreezeTimer = null; } + fxRtAnchorProcessed(); + return; + } if (fxRt && fxRt.active && _fxRtPlayheadSnapPending) { _fxRtPlayheadSnapPending = false; startAudioTimeRef.current = getAudioContext().currentTime; @@ -23702,16 +23939,47 @@ const App = () => { const vkbNoteOn = (pitch, vel) => { try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (er) {} const allTracks = activeTracksRef.current || []; + // V45.19 Bug 2: ARM piano roll chi tac dung khi DANG o PIANO_ROLL tab. O + // MAIN/SECTION chi tinh track isArmed that su; ARM tu piano roll KHONG truyen + // sang. Khong track nao ARM o MAIN/SECTION -> targets rong -> preview cam. + const _prTabNow = (subTabsRef.current || []).find(function (s) { return s.id === activeTabRef.current; }); + const _isPrTabNow = !!(_prTabNow && _prTabNow.type === 'PIANO_ROLL'); + const prArmedIds = {}; + // V45.27: PR tab preview chi track dang edit (sub-tab active) VA chi khi sub-tab do ARM (isArmed). + // ARM tu sub-tab PR khac / track MAIN KHONG leak sang PR tab nay. + try { + if (_isPrTabNow) { + if (_prTabNow.isArmed && _prTabNow.trackId) prArmedIds[_prTabNow.trackId] = true; + } + } catch (e) {} const armed = allTracks.filter(function (t) { return t.isArmed; }); - const targets = armed.length ? armed : allTracks; - if (!armed.length && !window.__vkbNoArmToast) { + const targets = _isPrTabNow ? allTracks.filter(function (t) { return prArmedIds[t.id]; }) : (armed.length ? armed : []); + if (!_isPrTabNow && !armed.length && !window.__vkbNoArmToast) { window.__vkbNoArmToast = true; - showToast('Không có track nào ARM — chơi vào tất cả track.', 'info'); + showToast('Không có track nào ARM — preview MIDI câm (MAIN/SECTION).', 'info'); } + // DIAG Bug 3/4 (preview khong am): trang thai routing tai note-on. + try { + let _pdDest = 'none'; + if (window.AudioRoutingEngine && window.AudioRoutingEngine._connected) { + _pdDest = (window.AudioRoutingEngine._dest && window.AudioRoutingEngine._dest.constructor) ? window.AudioRoutingEngine._dest.constructor.name : 'null'; + _pdDest += '/' + window.AudioRoutingEngine._trackId; + } else { _pdDest = 'NOT_CONNECTED'; } + const _pdD = window.BridgeAudioNode ? window.BridgeAudioNode.getDiag() : null; + const _pdTab = activeTabRef.current; + const _pdSub = subTabsRef.current ? (subTabsRef.current.find(s => s.id === _pdTab) || {}).type : null; + _dbgLog('preview_diag', 'armed=' + armed.length + ' trg=' + targets.length + ' prTab=' + (_isPrTabNow ? 1 : 0) + ' tab=' + _pdTab + ' sub=' + (_pdSub || 'none') + ' play=' + (isPlayingRef.current ? 1 : 0) + ' br=' + (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() ? 1 : 0) + ' fxRt=' + (fxRt.active ? 1 : 0) + ' dest=' + _pdDest + ' bd=' + (_pdD ? ('i=' + _pdD.initialized + ' wl=' + _pdD.useWorklet + ' nd=' + _pdD.hasWorkletNode + ' pre=' + _pdD.preBufferLen + ' ch=' + _pdD.chunksLen + ' g=' + _pdD.gain + ' ws=' + (_pdD.ws ? (_pdD.ws.overruns + '/' + _pdD.ws.underruns) : '-')) : 'MISSING') + ' mb=' + (!!masterBus)); + } catch (e) {} + // DIAG Bug 3/4: arm bo dem audio frames (500ms sau note-on) de biet + // engine co gui audio ve JS khong (tach engine vs JS routing). + try { window.__dbgPreviewAudioArmed = { count: 0, nz: 0, t0: performance.now() }; } catch (e) {} activeMidiPitchesRef.current.add(pitch); setActiveMidiPitches(new Set(activeMidiPitchesRef.current)); vkbHeldRef.current.add(pitch); targets.forEach(function (t) { + // V45.24: PR preview doc lap voi mute/solo MAIN/SECTION — khoi phuc + // volume track khi o PR tab. + if (_isPrTabNow) { try { ensureTrackPrAudible(t, allTracks, activeTrackNodesRef.current[t.id]); } catch (e) {} } const ctx = resolveTrackInstrumentCtx(t, allTracks); ensureSonicInstrument(ctx); if (window.triggerMidiVuActivity) window.triggerMidiVuActivity(t.id, Math.round(vel * 127)); @@ -23894,12 +24162,14 @@ const App = () => { // để master FX bị teardown giữa lúc section đang phát). if (fxRtWarmIdleTimer) { clearTimeout(fxRtWarmIdleTimer); fxRtWarmIdleTimer = null; } restorePendingFadeSession(); // V17: huy fade dang treo — replay khong bi dip gain - // V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc - // khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu). - try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {} + // D18: loop WRAP (fxRtWrapRestart) — KHONG flush ring bridge: ring dang chua + // tail pass cu + block moi (bridge render lien tuc); flush tai seam = gap + // trong → giat beat 0:0. Play that van flush (V30) de bo block idle cu. + const _wrapRestart = fxRtWrapRestart; + const _wrapMode = _wrapRestart ? 'loop' : 'play'; fxRtWrapRestart = false; // V38 + try { if (!_wrapRestart && window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {} // V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong // cho phia sau; flush ring bridge (V30) khong chaym queue nay. - const _wrapMode = fxRtWrapRestart ? 'loop' : 'play'; fxRtWrapRestart = false; // V38 try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: _wrapMode }); } catch (e) {} if (!st.buffer) { st.buffer = context.createBuffer(1, 128, context.sampleRate); @@ -24360,6 +24630,7 @@ const App = () => { const end = Math.max(st.selectionStart, st.selectionEnd); if (bufferPos >= end) { fxRtWrapRestart = true; // V38: loop wrap restart + try { _dbgLog('wrap_info', 'solo=' + (hasAnySolo ? 1 : 0) + ' local=' + (selectionMode === 'local' ? 1 : 0) + ' tracks=' + (tracks ? tracks.length : -1) + ' nl=' + nativeLoopSrcsRef.current.size); } catch (e) {} fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime stopAllPlayback(); setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { @@ -24388,6 +24659,7 @@ const App = () => { })() : st.buffer.duration; if (bufferPos >= effectiveDuration) { fxRtWrapRestart = true; // V38: loop wrap restart + try { _dbgLog('wrap_info', 'solo=' + (hasAnySolo ? 1 : 0) + ' local=' + (selectionMode === 'local' ? 1 : 0) + ' tracks=' + (tracks ? tracks.length : -1) + ' nl=' + nativeLoopSrcsRef.current.size); } catch (e) {} fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime stopAllPlayback(); if (st.isLooping) { @@ -24424,9 +24696,11 @@ const App = () => { const updatedTime = startOffsetTimeRef.current + elapsed; // G3: playhead display engine-derived khi FX active (wrap/loop trigger giu // wall-clock — transport truth). - const _playheadSec = (fxRt && fxRt.active && typeof fxRt.processedSamples === 'number' && fxRt.processedAnchor != null) - ? Math.max(startOffsetTimeRef.current, startOffsetTimeRef.current + (fxRt.processedSamples - fxRt.processedAnchor - (fxRt.latencyTotal || 0)) / (context.sampleRate || 44100)) - : updatedTime; + const _playheadSec = (_fxRtSeekFreeze && fxRt && fxRt.active) + ? startOffsetTimeRef.current + : ((fxRt && fxRt.active && typeof fxRt.processedSamples === 'number' && fxRt.processedAnchor != null) + ? Math.max(startOffsetTimeRef.current, startOffsetTimeRef.current + (fxRt.processedSamples - fxRt.processedAnchor - (fxRt.latencyTotal || 0)) / (context.sampleRate || 44100)) + : updatedTime); // Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 // If selection cleared by user, play linearly (don't loop) @@ -24434,8 +24708,9 @@ const App = () => { if (selRight > selLeft && updatedTime >= selRight) { if (hasAnySolo || selectionMode === 'local') { fxRtWrapRestart = true; // V38: loop wrap restart + try { _dbgLog('wrap_info', 'solo=' + (hasAnySolo ? 1 : 0) + ' local=' + (selectionMode === 'local' ? 1 : 0) + ' tracks=' + (tracks ? tracks.length : -1) + ' nl=' + nativeLoopSrcsRef.current.size); } catch (e) {} fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime - stopAllPlayback(); + stopAllPlayback(false, true); // V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble) startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; if (hasAnySolo) { @@ -24448,8 +24723,9 @@ const App = () => { setIsPlaying(true); } else { fxRtWrapRestart = true; // V38: loop wrap restart + try { _dbgLog('wrap_info', 'solo=' + (hasAnySolo ? 1 : 0) + ' local=' + (selectionMode === 'local' ? 1 : 0) + ' tracks=' + (tracks ? tracks.length : -1) + ' nl=' + nativeLoopSrcsRef.current.size); } catch (e) {} fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime - stopAllPlayback(); + stopAllPlayback(false, true); // V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble) startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; startTrackPlayback(selLeft); @@ -24471,13 +24747,18 @@ const App = () => { } if (isLoopingSelection) { fxRtWrapRestart = true; // V38: loop wrap restart + try { _dbgLog('wrap_info', 'solo=' + (hasAnySolo ? 1 : 0) + ' local=' + (selectionMode === 'local' ? 1 : 0) + ' tracks=' + (tracks ? tracks.length : -1) + ' nl=' + nativeLoopSrcsRef.current.size); } catch (e) {} fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime - stopAllPlayback(); + const _wrapT0 = performance.now(); + stopAllPlayback(false, true); // V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble) + const _wrapT1 = performance.now(); startOffsetTimeRef.current = 0; startAudioTimeRef.current = context.currentTime; startTrackPlayback(0); + const _wrapT2 = performance.now(); setCurrentTime(0); setIsPlaying(true); + try { _dbgLog('wrap_timing', 'stop=' + Math.round(_wrapT1 - _wrapT0) + ' start=' + Math.round(_wrapT2 - _wrapT1)); } catch (e) {} animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); return; } @@ -24732,7 +25013,7 @@ const App = () => { fxRtTracks[key] = entry; fxRtGuiPushShm(); try { - await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608272300`); + await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608291749`); const node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] }); entry.node = node; const u = new URL(API_BASE_URL); @@ -24957,6 +25238,15 @@ const App = () => { if (isPlayingRef.current && _activeTabId && _activeTabId !== 'main' && !_activeTabId.startsWith('session_')) { return; } + + // MAIN/SESSION dang play -> KHONG doi bridge sink khi mute/solo giua play: + // disconnect/reconnect _gainNode FluidSynth giua play -> glitch delay/wobble/crackle + // (bug V45.9: un-solo VSTi -> soundfont con lai delay ngay). Mute/solo da xu ly + // bang CC7 + track gain; routing se set lai o lan updateSfRouting sau (STOP). + if (isPlayingRef.current) { + console.log('[SFRoute] skip sink re-point while playing tab=' + _activeTabId); + return; + } // MAIN/SECTION đang play (isPlayingRef) → BỎ QUA nhánh PIANO_ROLL: // đổi SF destination (setOutputDestination — disconnect/reconnect // _gainNode FluidSynth) khi âm đang phát → âm MIDI bị NGẮT ~1s (user @@ -24977,6 +25267,7 @@ const App = () => { // D5: bridge active → nối BridgeAudioNode vào node track (sfEntry → // FX chain → mastering) thay vì SonicSF.setOutputDestination. if (_prNode && window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() && window.BridgeAudioNode) { + _dbgLog('sf_route', 'PR_CONNECT track=' + _activeSub.trackId + ' dest=' + (_prNode.sfEntry ? 'sfEntry' : (_prNode.gainNode ? 'gainNode' : 'NULL'))); window.AudioRoutingEngine.connect(window.BridgeAudioNode, _prNode, _activeSub.trackId); return; } @@ -25010,6 +25301,7 @@ const App = () => { } } const midiAudible = list.filter(t => ((t.midiItems && t.midiItems.length > 0) || t.isArmed || t.type === 'MIDI' || !!t.instrumentId) && computeTrackAudibleGain(list, t) > 0); + _dbgLog('sf_route', 'tab=' + _activeTabId + ' sub=' + (_activeSub ? _activeSub.type : 'none') + ' play=' + (isPlayingRef.current ? 1 : 0) + ' audible=[' + midiAudible.map(t => t.id).join(',') + '] eng=' + (window.AudioRoutingEngine && window.AudioRoutingEngine._connected ? ('conn:' + (window.AudioRoutingEngine._dest ? window.AudioRoutingEngine._dest.constructor.name : 'null')) : 'disconn') + ' br=' + (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() ? 1 : 0)); console.log('[SFRoute] activeTab=' + _activeTabId, 'midiAudible=' + midiAudible.map(t => t.id + '(midi' + (t.midiItems || []).length + ')').join(',') || 'none'); if (midiAudible.length === 1) { const t = midiAudible[0]; @@ -25028,6 +25320,7 @@ const App = () => { // sfDryGain) to skip or include the Mastering FX Chain. // D5: bridge active → nối vào node track (sfEntry → FX chain). if (node && window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() && window.BridgeAudioNode) { + _dbgLog('sf_route', 'SINGLE_CONNECT track=' + t.id + ' dest=' + (node.sfEntry ? 'sfEntry' : (node.gainNode ? 'gainNode' : 'NULL'))); window.AudioRoutingEngine.connect(window.BridgeAudioNode, node, t.id); return; } @@ -25063,6 +25356,7 @@ const App = () => { // D5: bridge active + không track nào audible → ngắt khỏi graph (về // masterBus qua updateSfRouting lần sau khi có track). if (window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() && window.BridgeAudioNode) { + _dbgLog('sf_route', 'MASTER_FALLBACK'); window.AudioRoutingEngine.connect(window.BridgeAudioNode, null, null); } else if (window.SonicSF && window.SonicSF.setOutputDestination) { window.SonicSF.setOutputDestination(null); @@ -25312,7 +25606,7 @@ const App = () => { // SonicSF (FluidSynth WASM). Giữ setTimeout scheduling (như SonicSF cũ); // bridge tự render note-off sau duration. A11 (sample-accurate) sẽ thay // setTimeout bằng sampleOffset đẩy thẳng vào SHM. - const scheduleMidiNoteDispatch = (track, pitch, velocity, durMs, startTime, program, destNode, ch, synthEngine) => { + const scheduleMidiNoteDispatch = (track, pitch, velocity, durMs, startTime, program, destNode, ch, synthEngine, prNote) => { if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) { const ctx = getAudioContext(); const startAt = startTime || ctx.currentTime; // undefined/null = phát ngay @@ -25332,6 +25626,18 @@ const App = () => { setTimeout(function () { if (epoch !== bridgeNoteEpochRef.current) return; if (!guardPlay()) return; + // D18b: mute/solo guard tai thoi diem fire — note-on dang cho cua track + // vua tro nen inaudible (solo track khac / mute) khong duoc phat ra. + // V45.24: note PR tab (prNote=true) BO QUA guard + khoi phuc volume — + // mute/solo chi cuc bo MAIN/SECTION, PR khong bao gio bi cam. + const _list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : (tracksRef.current && tracksRef.current.length ? tracksRef.current : activeTracks); + const _trk = _list.find(t => t.id === trkId) || track; + if (prNote) { + try { ensureTrackPrAudible(_trk, _list, destNode); } catch (e) {} + } else if (!_trk || computeTrackAudibleGain(_list, _trk) <= 0) { try { if (window.__dbgLog) window.__dbgLog('solo_guard', 'block ch=' + trkId + ' p=' + (pitch || 60)); } catch (e) {} return; } + // D18c: track pitch dang keu (timeline) - de applyAllTrackMuteSolo kill + // dung note khi track thanh inaudible giua chung (Qin VST2 tremolo). + try { var _tps = timelinePitchesRef.current; (_tps[trkId] || (_tps[trkId] = new Set())).add(pitch || 60); } catch (e) {} var _rt = window.SonicMidiRouter; try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: trkId, pitch: pitch || 60, velocity: velocity || 0.8, percussion: isPerc }); } catch (e) { console.warn('[Midi] pushEvent error:', e && e.message); } }, delayMs); @@ -25339,6 +25645,8 @@ const App = () => { if (epoch !== bridgeNoteEpochRef.current) return; if (!guardPlay()) return; try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: trkId, pitch: pitch || 60, velocity: 0, percussion: isPerc }); } catch (e) {} + // D18c: xoa khoi per-track set (note da tat - khong con can kill). + try { var _tps2 = timelinePitchesRef.current; if (_tps2[trkId]) _tps2[trkId].delete(pitch || 60); } catch (e) {} }, delayMs, durMs || 1000); return; } @@ -25355,12 +25663,14 @@ const App = () => { const context = getAudioContext(); _nativePlayEpoch++; // new play — vô hiệu doHardStop đang fade (play trong cửa sổ 80ms) restorePendingFadeSession(); // V17: huy fade dang treo — replay khong bi dip gain - // V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc - // khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu). - try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {} + // D18: loop WRAP (fxRtWrapRestart) — KHONG flush ring bridge: ring dang chua + // tail pass cu + block moi (bridge render lien tuc); flush tai seam = gap + // trong → giat beat 0:0. Play that van flush (V30) de bo block idle cu. + const _wrapRestart = fxRtWrapRestart; + const _wrapMode = _wrapRestart ? 'loop' : (fxRtSeekRestartFlag ? 'seek' : 'play'); fxRtWrapRestart = false; fxRtSeekRestartFlag = false; // V38/V45.18 + try { if (!_wrapRestart && window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {} // V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong // cho phia sau; flush ring bridge (V30) khong chaym queue nay. - const _wrapMode = fxRtWrapRestart ? 'loop' : 'play'; fxRtWrapRestart = false; // V38 try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: _wrapMode }); } catch (e) {} _dbgPlayStartMs = performance.now(); _dbgFirstAudioPending = true; @@ -25371,7 +25681,16 @@ const App = () => { // V40: real PDC — freeze playhead theo latency FX path; allowCold=true che // cold-start VST pre-roll (playhead không chạy trước khi có tiếng). Bỏ V39 // freeze giả định 0.31s (under-estimate → playhead chạy trước ~0.66s). - try { if (window.__fxRtPlayheadFreeze) window.__fxRtPlayheadFreeze(true); } catch (e) { } + // V45.19 Bug 1b: loop WRAP khong re-buffer (audio lien tuc qua KEEP tail) — chi + // re-anchor processed (playhead theo anchor), KHONG seek-freeze 1500ms (truoc day + // freeze -> playhead dung ~1.5s sau wrap = 'delay kha lau'). Seek/play that freeze. + try { + if (_wrapRestart && fxRt && fxRt.active) { + fxRtAnchorProcessed(); + } else if (window.__fxRtPlayheadFreeze) { + window.__fxRtPlayheadFreeze(true); + } + } catch (e) { } // D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng // bộ timeline; A13 C++ xử lý arg1=playheadSamples sau này). if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) { @@ -25396,6 +25715,7 @@ const App = () => { // FX (khi bật). try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (e) {} const allPlayTracks = activeTracksRef.current && activeTracksRef.current.length ? activeTracksRef.current : activeTracks; + try { if (window.__dbgLog) window.__dbgLog('play_tracks', 'n=' + allPlayTracks.length + ' solo=' + (allPlayTracks.some(function (t) { return t.solo; }) ? 1 : 0) + ' ids=' + allPlayTracks.map(function (t) { return t.id + ':' + (t.solo ? 'S' : t.muted ? 'M' : 'A'); }).join(',')); } catch (e) {} // Capture items signature at schedule time — updatePlayhead so sánh với // signature này để phát hiện items đổi vị trí giữa lúc play (re-schedule). // V28: defer bookkeeping signature — tính đồng bộ chặn main thread trước @@ -25863,12 +26183,12 @@ const App = () => { const context = getAudioContext(); _nativePlayEpoch++; // new play — vô hiệu doHardStop đang fade restorePendingFadeSession(); // V17: huy fade dang treo — replay khong bi dip gain - // V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc - // khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu). - try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {} - // V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong - // cho phia sau; flush ring bridge (V30) khong chaym queue nay. - try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: 'play' }); } catch (e) {} + // D18: piano-roll wrap (fxRtWrapRestart set o loop restart) — KHONG flush + // ring + clear_queue mode 'loop' (giu queue + wrapFade) nhu main path; truoc + // day luon mode 'play' → wipe + re-buffer moi pass = giat. KHONG reset flag + // (startSubTabPlayback goi sau van can doc de clear dung mode). + try { if (!fxRtWrapRestart && window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {} + try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: fxRtWrapRestart ? 'loop' : 'play' }); } catch (e) {} // V10: piano roll loop restart cũng gọi stopAllPlayback() trước (STOP#1 → // bridge transportStopped=true) → mọi note-on bị drop nếu không PLAY lại. // Hủy STOP#2 (150ms) + báo PLAY để bridge nhận note mới (loop liên tục). @@ -25926,7 +26246,7 @@ const App = () => { const scheduledTime = startWallTime + effectiveStart; const durMs = effectiveDur * 1000; if (window.SonicSF) { - scheduleMidiNoteDispatch(track, note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine); + scheduleMidiNoteDispatch(track, note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine, true); } scheduleNoteExpressionCC11(track, note, mainCh, startWallTime, effectiveStart, effectiveDur); } @@ -25951,7 +26271,7 @@ const App = () => { var schedTime = startWallTime + effStart; var durMs = effDur * 1000; if (window.SonicSF) { - scheduleMidiNoteDispatch(ghostTrack, note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth); + scheduleMidiNoteDispatch(ghostTrack, note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth, true); } } }); @@ -26066,21 +26386,14 @@ const App = () => { const handlePause = () => { if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback(true); }; - const stopAllPlayback = (fade) => { + const stopAllPlayback = (fade, skipRouting) => { try { - if (fxRtWrapRestart && fxRtChain().length === 0 && nativeLoopSrcsRef.current.size === 0 && masterBus && masterBus.muteGain && masterBus.muteGain.gain) { - // V41: dip CHỈ khi bypass (chain rỗng). FX active → seam thật xảy ra sau - // latency (~80ms) tại _now+latency; dip tại _now chỉ tạo dropout 14ms giữa - // đuôi pass cũ (audible) mà không che được seam click. - // V42: rút ngắn dip 14ms → 5ms (2ms xuống + 3ms lên) — đủ che click seam - // nhưng dropout quá ngắn để nghe thấy (V39 14ms tạo dropout rõ). - const _actx = getAudioContext(); const _now = _actx.currentTime; - masterBus.muteGain.gain.cancelScheduledValues(_now); - masterBus.muteGain.gain.setValueAtTime(masterBus.muteGain.gain.value || 1, _now); - masterBus.muteGain.gain.linearRampToValueAtTime(0.0001, _now + 0.002); - masterBus.muteGain.gain.linearRampToValueAtTime(1, _now + 0.005); - } // V39: dip input truoc hard-stop - _dbgLog('stop_start', 'fade=' + !!fade + ' ' + _dbgBridgeGain()); + // D14: bỏ dip muteGain tại wrap (loop) — 5ms master→0.0001 mỗi vòng = + // âm beat 0:0 nhỏ hơn hẳn âm cuối pass (bug seam volume). Click seam giờ + // xử bằng release mượt (CC123, không CC120 hard-cut — D15) + worklet + // WRAP_FADE 256 (FX path) → không cần che bằng dropout master. + + _dbgLog('stop_start', 'fade=' + !!fade + ' ' + _dbgBridgeGain() + ' call=' + ((new Error().stack || '').split('\n').slice(2, 4).join('|') || '').trim()); isPlayingRef.current = false; // Gap 7: xoa automation schedule — tra gain master ve 1.0 (fader rieng). try { if (masterBus && masterBus.output && masterBus.output.gain) { const _actx = getAudioContext(); masterBus.output.gain.cancelScheduledValues(_actx.currentTime); masterBus.output.gain.setValueAtTime(1, _actx.currentTime); } } catch (e) { } @@ -26111,6 +26424,8 @@ const App = () => { // cũ bắn vào vòng mới = NOTE_OFF cắt note đang kêu → crack (xem ref). bridgeNoteEpochRef.current++; try { clearAllPendingNoteOffs(); } catch (e) {} + // V45.6: GIU timelinePitchesRef qua stop - kill branch (solo/mute) can + // per-pitch set de NOTE_OFF tung note dang keu (Qin VST2 sandbox). _cc11Epoch++; // CC11 timers lan play cu vo hieu (da clear o tren) try { clearAllCC11Timers(); } catch (e) {} try { if (subTabsRef.current) subTabsRef.current = subTabsRef.current.map(s => ({ ...s, isPlaying: false })); } catch (e) {} @@ -26200,22 +26515,49 @@ const App = () => { stopAllNativeSfNotes(); stopAllNativeVstNotes(); // D2/D6: bridge active -> transport STOP (bridge flush toàn pitch, hết // âm ngân tức thì; A13 C++ flush note-off). + // D17: wrap (loop restart) — BỎ transport STOP. C++ STOP làm allNotesOff + // + CC64=0 trên cả 16 channel → cắt mọi note (kể cả note ngân qua seam) + // + có thể stall audio loop ~20ms (dbg.log: wrap play_start→first_audio + // δ17-32ms vs restart thường δ3-5ms) = "reload" tại beat 0:0. CC123 (D15) + // đã lo release JS-side; PLAY vẫn reset playhead (playheadSamples chỉ đổi + // qua transport); transportStopped giữ false → note-on pass mới không bị + // drop (V8 guard). Stop/seek thật vẫn gửi STOP. + const _wrapNoStop = !!(fxRtWrapRestart && !stale); if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) { - try { window.NativeBridgeService.transport('stop'); } catch (e) {} - _dbgLog('stop_transport', 'epoch=' + stopEpoch); + if (!_wrapNoStop) { try { window.NativeBridgeService.transport('stop'); } catch (e) {} } + _dbgLog('stop_transport', 'epoch=' + stopEpoch + (_wrapNoStop ? ' wrap-skip' : '')); // V39: MIDI panic CC123/CC120 tai wrap — VSTi note treo (khong nhan note-off) phai tat ngay. + // D15: wrap (loop) → chỉ CC123 (all-notes-off, release mượt); bỏ CC120 + // (all-sound-off, hard cut → click + âm beat 0:0 tụt). Release chồng + // lên re-noteon pass mới → seam như cùng khuôn nhạc. Stop thật giữ CC120. try { + const _wrapRelease = !!(fxRtWrapRestart && !stale); if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive && window.SonicMidiRouter.isBridgeActive()) { Object.keys(trackMidiChannelsRef.current || {}).forEach(function (_tid) { var _ch = trackMidiChannelsRef.current[_tid]; window.NativeBridgeService.dispatchMidiEvent('CC', _ch, 123, 0, 0, 0, 0, false); - window.NativeBridgeService.dispatchMidiEvent('CC', _ch, 120, 0, 0, 0, 0, false); + if (!_wrapRelease) window.NativeBridgeService.dispatchMidiEvent('CC', _ch, 120, 0, 0, 0, 0, false); + // D18d: wrap - NOTE_OFF tung pitch dang keu (timelinePitchesRef). + // CC123 khong an voi VST2 (Qin bo qua all-notes-off CC) -> note cu + // ring qua seam -> re-trigger cung pitch chong nhau = crackle. + // Per-note OFF da chung minh an (panic 128 noteOff o STOP; D18c). + if (_wrapRelease) { + var _tps = (timelinePitchesRef.current || {})[_tid]; + if (_tps && _tps.size) { + _tps.forEach(function (_p) { + window.NativeBridgeService.dispatchMidiEvent('NOTE_OFF', _ch, _p, 0, 0, 0, 0, false); + }); + } + if (timelinePitchesRef.current) delete timelinePitchesRef.current[_tid]; + } }); } } catch (e) { console.warn('[Stop] midi panic error:', e); } // V27: xóa audio còn buffer trong pipeline (preBuffer/chunks + worklet // ring) — nếu không, âm trước stop còn trong ring phát tiếp sau fade. - try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) { console.warn('[Stop] bridge flush error:', e); } + // D18: wrap (_wrapNoStop) — KHÔNG flush (giữ dòng liên tục qua seam); + // flush ở đây + ở startTrackPlayback = ring rỗng → gap giật beat 0:0. + if (!_wrapNoStop) { try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) { console.warn('[Stop] bridge flush error:', e); } } // V8 bug 3: transport stop thu 2 sau 150ms — note-on timer guard sync // qua React effect (cham) van bay toi bridge sau STOP → retrigger am // treo. V10: timer lưu ref — startTrackPlayback (loop restart) hủy @@ -26261,7 +26603,7 @@ const App = () => { ...s, isPlaying: false }))); - updateSfRouting(); + if (!skipRouting) updateSfRouting(); }; const seekPlaybackTo = (time) => { const isSubTab = subTabs.some(sub => sub.id === activeTab); @@ -26270,7 +26612,7 @@ const App = () => { if (!st) return; if (st.isPlaying) { fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime - stopAllPlayback(); + stopAllPlayback(false, true); window.SonicSF.stopAll(); stopAllNativeSfNotes(); stopAllNativeVstNotes(); setSubTabs(prev => prev.map(s => s.id === activeTab ? { @@ -26295,7 +26637,7 @@ const App = () => { } else { if (isPlaying) { fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime - stopAllPlayback(); + stopAllPlayback(false, true); setCurrentTime(time); startOffsetTimeRef.current = time; startAudioTimeRef.current = getAudioContext().currentTime; @@ -27979,34 +28321,48 @@ const App = () => { // ── Track Controls ── const toggleTrackSoloEvaluate = trackId => { const wasPlaying = isPlaying; + try { if (window.__dbgLog) window.__dbgLog('solo_toggle', 'track=' + trackId + ' wasPlaying=' + (wasPlaying ? 1 : 0) + ' refSolo=' + ((activeTracksRef.current || []).find(function (t) { return t.id === trackId; }) || {}).solo + ' n=' + (activeTracksRef.current || []).length); } catch (e) {} const playingSubTab = subTabs.find(s => s.isPlaying && s.type === 'PIANO_ROLL'); - fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime - stopAllPlayback(); + const wasSolo = !!((activeTracksRef.current || []).find(function (t) { return t.id === trackId; }) || {}).solo; + fxRtKeepForRestart = true; // restart/resume - giu session FX realtime updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, solo: !t.solo, muted: false } : t )); - setTimeout(() => { - const soloCtx = getAudioContext(); - if (wasPlaying) { - startOffsetTimeRef.current = currentTime; - startAudioTimeRef.current = soloCtx.currentTime; - startBufferOffsetRef.current = currentTime; - setIsPlaying(true); - startTrackPlayback(currentTime); - } - if (playingSubTab) { - const prSt = subTabs.find(s => s.id === playingSubTab.id); - if (prSt) { - const prOffset = prSt.currentTime || 0; - startOffsetTimeRef.current = prOffset; + if (playingSubTab) { + // PIANO_ROLL sub-tab dang play: giu stop+restart cu (scheduler sub-tab + // can restart de ap solo moi; main session khong play song song). + stopAllPlayback(); + setTimeout(() => { + const soloCtx = getAudioContext(); + if (wasPlaying) { + startOffsetTimeRef.current = currentTime; startAudioTimeRef.current = soloCtx.currentTime; - startBufferOffsetRef.current = prOffset * (prSt.speed || 1.0); - schedulePianoRollMidi(prSt, prOffset); - startSubTabPlayback(prSt, prOffset); - setSubTabs(prev => prev.map(s => s.id === prSt.id ? Object.assign({}, s, { isPlaying: true, currentTime: prOffset }) : s)); + startBufferOffsetRef.current = currentTime; + setIsPlaying(true); + startTrackPlayback(currentTime); } - } - }, 60); + if (playingSubTab) { + const prSt = subTabs.find(s => s.id === playingSubTab.id); + if (prSt) { + const prOffset = prSt.currentTime || 0; + startOffsetTimeRef.current = prOffset; + startAudioTimeRef.current = soloCtx.currentTime; + startBufferOffsetRef.current = prOffset * (prSt.speed || 1.0); + schedulePianoRollMidi(prSt, prOffset); + startSubTabPlayback(prSt, prOffset); + setSubTabs(prev => prev.map(s => s.id === prSt.id ? Object.assign({}, s, { isPlaying: true, currentTime: prOffset }) : s)); + } + } + }, 60); + setTimeout(() => lucide.createIcons(), 50); + return; + } + // V45.6: main-session - KHONG stopAllPlayback nua (track solo dang keu + // giu nguyen, het wobble re-attack 60ms). applyAllTrackMuteSolo tu kill + // track im (NOTE_OFF tung pitch - timelinePitchesRef giu set tu V45.5) + // + restart khi co track became audible. IPC toggleSolo chi goi ham nay + // -> tu apply luon (truoc day khong apply -> ref khong patch -> Qin khong im). + try { applyAllTrackMuteSolo(trackId, { solo: !wasSolo, muted: false }); } catch (e) {} setTimeout(() => lucide.createIcons(), 50); }; const toggleTrackMute = trackId => { @@ -34012,7 +34368,38 @@ STRICT CONSTRAINTS: }, /*#__PURE__*/React.createElement("i", { "data-lucide": "trash-2", className: "w-3 h-3" - }))))), /*#__PURE__*/React.createElement("div", { + }))))), (function () { + const _ls = window.__trackLoadState && window.__trackLoadState[track.id]; + if (_ls && _ls.state === 'loading') { + return /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1.5 mt-0.5", + title: "Đang nạp instrument vào bridge — track sẽ phát sau khi load xong" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 h-1 rounded bg-zinc-800 overflow-hidden" + }, /*#__PURE__*/React.createElement("div", { + className: "h-full rounded sf-ruler-bar", + style: { + background: '#06b6d4', + width: '45%' + } + })), /*#__PURE__*/React.createElement("span", { + className: "text-[9px] text-cyan-400 font-mono shrink-0" + }, "Loading…")); + } + if (_ls && _ls.state === 'failed') { + return /*#__PURE__*/React.createElement("div", { + className: "mt-0.5 text-[9px] text-red-500 font-mono" + }, "Load failed"); + } + if (_ls && _ls.state === 'done' && _ls.doneTs && Date.now() - _ls.doneTs < 2500) { + return /*#__PURE__*/React.createElement("div", { + className: "mt-0.5 h-1 rounded bg-green-600/30 overflow-hidden" + }, /*#__PURE__*/React.createElement("div", { + className: "h-full bg-green-500 sf-ruler-done" + })); + } + return null; + })(), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-0.5 text-xs", onClick: e => e.stopPropagation() }, /*#__PURE__*/React.createElement("div", { @@ -34457,7 +34844,7 @@ STRICT CONSTRAINTS: if (!seekSt) return; if (seekSt.isPlaying) { fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime - stopAllPlayback(); + stopAllPlayback(false, true); window.SonicSF.stopAll(); stopAllNativeSfNotes(); stopAllNativeVstNotes(); setSubTabs(prev => prev.map(s => s.id === seekSt.id ? { ...s, currentTime: clickTime, isPlaying: true } : s)); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 2289a04..1632e58 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -17,7 +17,7 @@ synthEngine:se,sfId:se.soundfont_id,bank:se.soundfont_bank||0,prog:se.soundfont_ // note; reset khi load instrument mới). Dùng chung mọi đường: timeline, // preview, keybed. Trước đây bank hardcode 0 + preview/keybed KHÔNG gửi // program change → SF2 preset ở bank≠0 ra piano sai. -window.__ensureBridgeProgram=(trkId,program,synthEngine)=>{try{if(!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return;const _bank=synthEngine&&synthEngine.soundfont_bank!==undefined?synthEngine.soundfont_bank:0;const _prog=program!==undefined&&program!==null?program:synthEngine&&synthEngine.soundfont_program!==undefined?synthEngine.soundfont_program:undefined;if(_prog===undefined||_prog===null)return;const _key=trkId+':'+_bank+':'+_prog;const _lp=window.__bridgeLastProgram||(window.__bridgeLastProgram={});if(_lp[_key])return;_lp[_key]=true;const _isPerc=_bank===128;window.SonicMidiRouter.pushEvent({cmd:'CC',channel:trkId,pitch:0,velocity:0,data2:_bank>>7&0x7F,percussion:_isPerc});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:trkId,pitch:32,velocity:0,data2:_bank&0x7F,percussion:_isPerc});window.SonicMidiRouter.pushEvent({cmd:'PROGRAM',channel:trkId,pitch:0,velocity:0,data2:_prog,percussion:_isPerc});}catch(e){console.warn('[Bridge] ensureBridgeProgram error:',e);}};// Đảm bảo FluidSynth channel của track đã select ĐÚNG instrument trước khi +window.__ensureBridgeProgram=(trkId,program,synthEngine,force)=>{try{if(!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return;const _bank=synthEngine&&synthEngine.soundfont_bank!==undefined?synthEngine.soundfont_bank:0;const _prog=program!==undefined&&program!==null?program:synthEngine&&synthEngine.soundfont_program!==undefined?synthEngine.soundfont_program:undefined;if(_prog===undefined||_prog===null)return;const _key=trkId+':'+_bank+':'+_prog;const _lp=window.__bridgeLastProgram||(window.__bridgeLastProgram={});if(!force&&_lp[_key])return;_lp[_key]=true;const _isPerc=_bank===128;window.SonicMidiRouter.pushEvent({cmd:'CC',channel:trkId,pitch:0,velocity:0,data2:_bank>>7&0x7F,percussion:_isPerc});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:trkId,pitch:32,velocity:0,data2:_bank&0x7F,percussion:_isPerc});window.SonicMidiRouter.pushEvent({cmd:'PROGRAM',channel:trkId,pitch:0,velocity:0,data2:_prog,percussion:_isPerc});}catch(e){console.warn('[Bridge] ensureBridgeProgram error:',e);}};// Đảm bảo FluidSynth channel của track đã select ĐÚNG instrument trước khi // notes bắn. Fire-and-forget: playNote tự load + retry nếu SF chưa load xong // (dedup sẵn trong loadSoundFont) — không chặn, không gây stall khi mở tab. const ensureSonicInstrument=ctx=>{try{if(!window.SonicSF||!window.SonicSF.selectInstrument)return;if(ctx.sfId){window.SonicSF.selectInstrument(ctx.ch,ctx.bank,ctx.prog,ctx.sfId);}else if(ctx.program!==undefined){window.SonicSF.selectInstrument(ctx.ch,0,ctx.program,null);}}catch(e){console.warn('[Instrument] ensureSonicInstrument error:',e);}};// ── MIDI note-off scheduling helper (pending map chống note chồng) ───────── @@ -63,14 +63,18 @@ let _cc11Epoch=0;// V17: fade session dang treo (gain ramp FADE_MS) — restore let _pendingFadeSession=null;const restorePendingFadeSession=()=>{if(!_pendingFadeSession)return;let _bgBefore=-1;try{if(_pendingFadeSession.bridgeGainNode)_bgBefore=_pendingFadeSession.bridgeGainNode.gain.value;}catch(e){}try{const t=getAudioContext().currentTime;_pendingFadeSession.fadedGains.forEach(g=>{try{g.node.gain.cancelScheduledValues(t);g.node.gain.setValueAtTime(g.prev,t);}catch(e){}});if(_pendingFadeSession.bridgeGainNode){try{_pendingFadeSession.bridgeGainNode.gain.cancelScheduledValues(t);_pendingFadeSession.bridgeGainNode.gain.setValueAtTime(1.0,t);}catch(e){}}}catch(e){}_dbgLog('restore','gains='+_pendingFadeSession.fadedGains.length+' bg='+_bgBefore.toFixed(4)+'->1.0');_pendingFadeSession=null;};// V28: timing diagnostics -> %APPDATA%/SonicForgeDAW/logs/dbg.log (Rust dbg_log). // Fire-and-forget invoke — khong chan main thread; tag+extra ghi kem unix_ms. const _dbgLog=(tag,extra)=>{try{if(window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke){window.__TAURI__.core.invoke('dbg_log',{tag:tag,extra:extra===undefined?'':String(extra)}).catch(function(){});}}catch(e){}};// V29: expose dbg hook cho BridgeAudioNode (worklet state) + helpers. -window.__dbgLog=_dbgLog;if(window.PerformanceObserver){try{new PerformanceObserver(list=>{for(const e of list.getEntries())_dbgLog('longtask','d='+Math.round(e.duration)+' s='+Math.round(e.startTime));}).observe({entryTypes:['longtask']});}catch(e){}}const _dbgBridgeGain=()=>{try{if(window.BridgeAudioNode&&window.BridgeAudioNode.getOutputNode){const _g=window.BridgeAudioNode.getOutputNode();if(_g&&_g.gain)return'bg='+_g.gain.value.toFixed(4);return'bg=none';}}catch(e){}return'bg=n/a';};const _dbgWorkletState=()=>{try{if(window.BridgeAudioNode&&window.BridgeAudioNode.getDiag){const _d=window.BridgeAudioNode.getDiag();return'wl='+(_d.useWorklet?'y':'n')+' pre='+_d.preBufferLen+' ch='+_d.chunksLen;}}catch(e){}return'wl=n/a';};window.__dbgBridgeGain=_dbgBridgeGain;window.__dbgWorkletState=_dbgWorkletState;// V28: ding fix — midi bat ky (keyboard/pad/timeline) restore bridge gain sau +window.__dbgLog=_dbgLog;if(window.PerformanceObserver){try{new PerformanceObserver(list=>{for(const e of list.getEntries())_dbgLog('longtask','d='+Math.round(e.duration)+' s='+Math.round(e.startTime));}).observe({entryTypes:['longtask']});}catch(e){}}const _dbgBridgeGain=()=>{try{if(window.BridgeAudioNode&&window.BridgeAudioNode.getOutputNode){const _g=window.BridgeAudioNode.getOutputNode();if(_g&&_g.gain)return'bg='+_g.gain.value.toFixed(4);return'bg=none';}}catch(e){}return'bg=n/a';};const _dbgWorkletState=()=>{try{if(window.BridgeAudioNode&&window.BridgeAudioNode.getDiag){const _d=window.BridgeAudioNode.getDiag();const _ws=_d.ws?' ov='+_d.ws.overruns+' un='+_d.ws.underruns+' ring='+_d.ws.ring:'';return'wl='+(_d.useWorklet?'y':'n')+' pre='+_d.preBufferLen+' ch='+_d.chunksLen+_ws;}}catch(e){}return'wl=n/a';};window.__dbgBridgeGain=_dbgBridgeGain;window.__dbgWorkletState=_dbgWorkletState;// V28: ding fix — midi bat ky (keyboard/pad/timeline) restore bridge gain sau // stop (gain giu ~0.0001 — V27 restore 800ms khong du cho VSTi release tail dai). window.__restorePendingFadeSession=restorePendingFadeSession;// V31: live note (ARM keyboard) — xoa backlog outQueue master FX truoc khi // note preview render (idle block ~341ms lam ARM lag ~0.3s). -window.__clearMasterFxQueue=function(){_dbgLastClearMs=performance.now();try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:'live'});}catch(e){}};// V32: queueLen master FX worklet hien tai (midi_push log — ARM latency). +// V45.32: preview latency — flush ring bridge sink (audio cu render truoc +// ~170ms) tai idle->active note-on de note moi nghe ngay, khong phai cho +// backlog cu chay het. Guard: khong flush khi timeline dang play (tranh gap +// audio playback dang phat). +let _timelinePlaying=false;window.__clearMasterFxQueue=function(){_dbgLastClearMs=performance.now();try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:'live'});}catch(e){}try{if(!_timelinePlaying&&window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}};// V32: queueLen master FX worklet hien tai (midi_push log — ARM latency). window.__fxRtQueueLen=function(){try{return fxRt&&fxRt.stats?fxRt.stats.queueLen:'n/a';}catch(e){return'n/a';}};// V28: do jank — play_start timestamp; play_rAF / play_first_audio log delta. let _dbgPlayStartMs=0;let _dbgLastClearMs=0;let _dbgFirstAudioPending=false;let _dbgFirstAudiblePending=false;// V40: real PDC — freeze playhead theo latency đo được (fx_play_first self-calib). -let _fxRtPlayheadSnapPending=false;let _fxRtFreezeTimer=null;let _fxRtFreezeTries=0;const _nativeTrackUrls={};const _nativeTrackPending={};// ── MIDI overlap cleanup + polyphony gain (spec midi_polyphony_loudness) ─ +let _fxRtPlayheadSnapPending=false;let _fxRtFreezeTimer=null;let _fxRtFreezeTries=0;let _fxRtSeekFreeze=false;let _fxRtSeekFreezeTimer=null;const _nativeTrackUrls={};const _nativeTrackPending={};// ── MIDI overlap cleanup + polyphony gain (spec midi_polyphony_loudness) ─ // Dedup: note cùng pitch + cùng start_beat → gộp 1, giữ velocity cao hơn // (tránh synth instantiate nhiều voice cùng pitch — spec §II.5). // Legato cleanup: note trước cùng pitch kéo dài qua start_beat note sau → cắt @@ -151,7 +155,11 @@ function isMidiSoundTrack(t){return!!(t&&(t.midiItems&&t.midiItems.length>0||t.t // gainNode KHÔNG ảnh hưởng âm bridge. Bridge active → gửi qua // SonicMidiRouter.pushEvent (đúng channel allocation của note-on, trackId làm // channel); bridge down → SonicSF (WASM local) fallback. -function sendMidiVolumeCC7(t,list,audible){const volDb=typeof t.volumeDb==='number'&&isFinite(t.volumeDb)?t.volumeDb:0;const volLin=volDb<=-50?0:Math.pow(10,volDb/20);const cc7=audible?Math.max(1,Math.min(127,Math.round(volLin*100))):0;try{if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive&&window.SonicMidiRouter.isBridgeActive()){const isPerc=!!(t.synth_engine&&t.synth_engine.soundfont_bank===128)||!!t.is_percussion;window.SonicMidiRouter.pushEvent({cmd:'CC',channel:t.id,pitch:7,velocity:0,data2:cc7,percussion:isPerc});return;}}catch(e){}try{if(window.SonicSF&&typeof window.SonicSF.controllerChange==='function'){const ch=ensureTrackMidiChannel(t,list);window.SonicSF.controllerChange(ch,7,cc7);}}catch(e){}}// ── CC11 expression (realtime) ────────────────────────────────────────── +function sendMidiVolumeCC7(t,list,audible){const volDb=typeof t.volumeDb==='number'&&isFinite(t.volumeDb)?t.volumeDb:0;const volLin=volDb<=-50?0:Math.pow(10,volDb/20);const cc7=audible?Math.max(1,Math.min(127,Math.round(volLin*100))):0;try{if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive&&window.SonicMidiRouter.isBridgeActive()){const isPerc=!!(t.synth_engine&&t.synth_engine.soundfont_bank===128)||!!t.is_percussion;window.SonicMidiRouter.pushEvent({cmd:'CC',channel:t.id,pitch:7,velocity:0,data2:cc7,percussion:isPerc});return;}}catch(e){}try{if(window.SonicSF&&typeof window.SonicSF.controllerChange==='function'){const ch=ensureTrackMidiChannel(t,list);window.SonicSF.controllerChange(ch,7,cc7);}}catch(e){}}// V45.24: PR tab audio DOC LAP voi mute/solo cua MAIN/SECTION — khoi phuc +// volume track (theo volumeDb, bo qua mute/solo) truoc khi note PR phat. +// sendMidiVolumeCC7(t, list, true) gui CC7=vol (channel khong con bi mute/solo +// tu toggle MAIN); setTrackNodeGain phuc hoi gainNode (SonicSF fallback dest). +function ensureTrackPrAudible(t,list,node){if(!t)return;try{const volDb=Number(t.volumeDb);const volLin=isNaN(volDb)||!isFinite(volDb)?1.0:volDb<=-50?0:Math.pow(10,volDb/20);if(node)setTrackNodeGain(node,volLin);sendMidiVolumeCC7(t,list,true);}catch(e){}}// ── CC11 expression (realtime) ────────────────────────────────────────── // Mỗi note có expression_curve → chuỗi CC11 theo grid 16ms. Timer đăng ký // vào _cc11Timers để stopAllPlayback hủy sạch; epoch guard (_nativePlayEpoch) // chống timer stale sau stop/loop restart (pattern scheduleMidiNoteDispatch). @@ -307,6 +315,7 @@ const fxRt={active:false,starting:false,startToken:0,sessionId:null,ws:null,node // stop thật (fade=true) giữ master FX warm + idle timeout (V21); bridge-down / // new-project / export-end → teardown bình thường. let fxRtKeepForRestart=false;let fxRtWrapRestart=false;// V38: loop wrap restart -- clear_queue mode 'loop' (always-wipe + RESUME_FILL) +let fxRtSeekRestartFlag=false;// V45.18: seek khi play master FX active -- clear_queue mode 'seek' (wipe queue cu + resume nhanh 8 block ~43ms) // V21: user stop (fade=true) giữ master FX session warm — play kế tiếp có FX // ngay, không chờ reload VST ~4s. Idle timeout: ngừng chơi lâu → teardown // giải phóng CPU/VST (chấp nhận ~4s delay ở play sau khi idle lâu). @@ -339,7 +348,7 @@ else if(fxRt.chainKey!==ck){fxRtSetChain(chain);fxRt.chainKey=ck;return;}else re // node → bypass vô hiệu. Mỗi fxRtStop() bump startToken → mọi checkpoint // dưới đây thấy mismatch → hủy session vừa tạo, không nối node. const token=++fxRt.startToken;fxRt.starting=true;const ctx=getAudioContext();if(!ctx||!ctx.audioWorklet||!masterBus||!masterBus.output||!masterBus.analyser||!masterBus.masterFxGain||!masterBus.masterFxDryGain)return;const chainPayload=chain.map(s=>s.type==='vst3'?{type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:!!s.bypass||!!(window.currentMasteringSettings||{}).vstBypass}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:!!s.bypass});let st;try{st=await window.SonicAPI.fxRealtimeStart({fx_chain:chainPayload,sample_rate:ctx.sampleRate||44100});}catch(e){console.warn('[FX RT] start fail:',e);showToast('Không khởi động VST FX realtime: '+(e.message||e),'warning');fxRt.starting=false;return;}if(token!==fxRt.startToken){// Bypass/stop giữa lúc engine đang load chain — hủy session vừa tạo. -console.warn('[FX RT] start cancelled mid-flight — session stopped');try{window.SonicAPI.fxRealtimeStop({session_id:st.session_id}).catch(()=>{});}catch(e2){}fxRt.starting=false;return;}fxRt.sessionId=st.session_id;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608281631`);if(token!==fxRt.startToken){fxRt.starting=false;return;}fxRt.node=new AudioWorkletNode(ctx,'sf-fx-realtime',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2]});const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';// WS to 'localhost' bi tre ~300ms: Chromium thu ::1 truoc (Windows khong +console.warn('[FX RT] start cancelled mid-flight — session stopped');try{window.SonicAPI.fxRealtimeStop({session_id:st.session_id}).catch(()=>{});}catch(e2){}fxRt.starting=false;return;}fxRt.sessionId=st.session_id;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608311858`);if(token!==fxRt.startToken){fxRt.starting=false;return;}fxRt.node=new AudioWorkletNode(ctx,'sf-fx-realtime',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2]});const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';// WS to 'localhost' bi tre ~300ms: Chromium thu ::1 truoc (Windows khong // co listener IPv6 -> SYN hang ~2s) roi moi fallback 127.0.0.1. Dung // thang 127.0.0.1 (loopback) -> WS open ~5ms. Server chap nhan loopback. const wsHost=u.hostname==='localhost'||u.hostname==='::1'?'127.0.0.1'+(u.port?':'+u.port:''):u.host;const tok=localStorage.getItem('sonic_token')||'';const wsUrl=`${wsProto}${wsHost}${st.ws_url}${tok?'?token='+encodeURIComponent(tok):''}`;const ws=new WebSocket(wsUrl);fxRt.ws=ws;ws.binaryType='arraybuffer';ws.onopen=()=>{if(token!==fxRt.startToken){try{ws.close();}catch(e){}fxRt.starting=false;return;}fxRt.active=true;fxRt.flushing=false;fxRt.starting=false;fxRt.inQueue=[];// bỏ audio stale sản xuất trước khi WS open (~4s load VST) — hết lag khởi đầu @@ -381,7 +390,9 @@ const _now=performance.now();// V40: self-calib PDC — fx_play_first = block đ // worklet phát lại OLD TAIL ~5ms → dbgPlayFirst tiêu thụ → trước đây snap // playhead SỚM + calib nhiễu ~50ms → display chạy trước → wrap sớm → vấp // beat 0:0. Pass mới thật ≥ ~80ms → snap/calib chỉ trên event hợp lệ. -if(d.tag==='fx_play_first'&&_now-_dbgPlayStartMs>=50){fxRt.pdcCalibMs=Math.max(50,Math.min(3000,_now-_dbgPlayStartMs));try{if(window.__fxRtSnapPlayhead)window.__fxRtSnapPlayhead();}catch(e2){}}_dbgLog('fx_hop_'+d.tag,'sincePlay='+Math.round(_now-_dbgPlayStartMs)+' sinceClear='+Math.round(_now-_dbgLastClearMs)+' wlT='+d.t+' q='+(d.q!==undefined?d.q:''));return;}if(d.type!=='in')return;// Gửi NGAY, không pace setTimeout: input pacing = đồng hồ audio worklet +// V45.19 Bug 1b: fx_seek_first (seek resume nhanh ~43ms < 50ms) snap KHONG +// can guard 50ms; fx_play_first giu guard (chan old-tail loop wrap snap som). +const _seekFirst=d.tag==='fx_seek_first';if((d.tag==='fx_play_first'||_seekFirst)&&(_seekFirst||_now-_dbgPlayStartMs>=50)){fxRt.pdcCalibMs=Math.max(50,Math.min(3000,_now-_dbgPlayStartMs));try{if(window.__fxRtSnapPlayhead)window.__fxRtSnapPlayhead();}catch(e2){}}_dbgLog('fx_hop_'+d.tag,'sincePlay='+Math.round(_now-_dbgPlayStartMs)+' sinceClear='+Math.round(_now-_dbgLastClearMs)+' wlT='+d.t+' q='+(d.q!==undefined?d.q:''));return;}if(d.type!=='in')return;// Gửi NGAY, không pace setTimeout: input pacing = đồng hồ audio worklet // (1 frame/21.33ms), không phải timer main-thread. Timer bận/trễ → input // gửi trễ → engine/bridge chỉ sản xuất output khi có input → cushion cạn // dần → crackle sau 3-4s (đã xác nhận với chain RỖNG). Burst sau stall: @@ -416,7 +427,7 @@ if(!_fxStopNode)fxRtApplyPdc();if(sid){try{window.SonicAPI.fxRealtimeStop({sessi // ≈ 1792) + FILL buffer 16 block (4096) = 23 block ≈ 5888 (~123ms @48k). Giữ // đồng bộ với sf-fx-realtime.js. FX_RT_START_EST_MS: ước lượng khởi đầu khi // chưa có phép đo (fx_play_first) — dùng cho freeze playhead thay V39 0.31s. -const FX_RT_ROUNDTRIP_SAMPLES=5888;const FX_RT_START_EST_MS=450;// G3: anchor processed — playhead = startOffset + (processed - anchor - latency)/sr +const FX_RT_ROUNDTRIP_SAMPLES=7936;const FX_RT_START_EST_MS=450;// G3: anchor processed — playhead = startOffset + (processed - anchor - latency)/sr // (da tru latency -> bam vi tri mau NGHE DUOC; pre-roll tu giu tai startOffset — // thay freeze/snap V40). Anchor moi play/wrap restart qua __fxRtPlayheadFreeze // (moi play/resume path deu goi); cold start (chua active) -> null -> WS message @@ -460,7 +471,7 @@ if(window.NativeBridgeService&&window.SonicMidiRouter&&!window.__bridgeBootstrap // luôn kill+respawn → first play sau launch im lặng ~0.5s (chờ bridge // respawn + restore instrument từ bridge_state.json). if(st.sample_rate&&st.sample_rate!==audioCtx.sampleRate){try{console.log('[Bridge] Requesting restart to match sampleRate:',audioCtx.sampleRate,'(bridge was',st.sample_rate+')');await window.__TAURI__.core.invoke('restart_bridge_with_sample_rate',{sampleRate:audioCtx.sampleRate});// Bridge restart → mất hết instrument → reset map để reconnect reload. -window.__bridgeLoadedChannels={};window.__bridgeLoadedPaths={};for(var retry=0;retry<10;retry++){await new Promise(r=>setTimeout(r,100));var st2=await window.NativeBridgeService.queryStatus();if(st2&&st2.connected){console.log('[Bridge] Reconnected at sampleRate:',audioCtx.sampleRate);break;}}}catch(err){console.warn('[Bridge] restart error:',err);}}else{console.log('[Bridge] sampleRate match — skip restart:',audioCtx.sampleRate);}window.BridgeAudioNode.init(audioCtx);window.NativeBridgeService.onAudio(function(l,r){try{if(_dbgFirstAudioPending){_dbgFirstAudioPending=false;_dbgLog('play_first_audio','delta_ms='+Math.round(performance.now()-_dbgPlayStartMs));}if(_dbgFirstAudiblePending&&l&&r){var _nz=false;for(var _i=0;_i route notes to the selected track +window.__bridgeLoadedChannels={};window.__bridgeLoadedPaths={};for(var retry=0;retry<10;retry++){await new Promise(r=>setTimeout(r,100));var st2=await window.NativeBridgeService.queryStatus();if(st2&&st2.connected){console.log('[Bridge] Reconnected at sampleRate:',audioCtx.sampleRate);break;}}}catch(err){console.warn('[Bridge] restart error:',err);}}else{console.log('[Bridge] sampleRate match — skip restart:',audioCtx.sampleRate);}window.BridgeAudioNode.init(audioCtx);window.NativeBridgeService.onAudio(function(l,r){try{if(_dbgFirstAudioPending){_dbgFirstAudioPending=false;_dbgLog('play_first_audio','delta_ms='+Math.round(performance.now()-_dbgPlayStartMs));}if(_dbgFirstAudiblePending&&l&&r){var _nz=false;for(var _i=0;_i500){_dbgLog('preview_audio','frames='+_p.count+' nz='+_p.nz);window.__dbgPreviewAudioArmed=null;}}}catch(e){}window.BridgeAudioNode.onAudio(l,r);}catch(e){console.warn('[Bridge] audio callback error:',e&&e.message||e);}});// A14: plugin MIDI output -> route notes to the selected track // (Scaler2/Instacomposer chords play the selected track's // instrument). Feedback guard: skip events whose source channel // is the selected track's own channel (would loop generator into @@ -669,30 +680,35 @@ sfToDelete&&React.createElement('div',{className:'fixed inset-0 z-[60] flex item // V9: track.plugin_path/cache path có thể là plugin KHÁC instrumentId (VD // WaveObserver trong khi track là Nexus) → basename không chứa tên id → trả // null để engine resolve theo name / listPlugins — tránh LOAD nhầm VSTi. -const _bridgeSafeVstPath=(path,instrumentId)=>{if(!path||!instrumentId)return path;const base=String(path).split(/[\\/]/).pop().toLowerCase().replace(/\s+/g,'');const want=String(instrumentId).toLowerCase().replace(/\s+/g,'');return base.indexOf(want)!==-1?path:null;};const loadVstToBridge=async track=>{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return false;const instrumentId=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!instrumentId||typeof instrumentId==='string'&&instrumentId.startsWith('sf_'))return false;if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};const tId=track.id||track.trackId;const bch=window.SonicMidiRouter.allocateChannel(tId,false);try{// V8 bug 5: engine _resolve_bridge_asset chi match FILE theo filename — +const _bridgeSafeVstPath=(path,instrumentId)=>{if(!path||!instrumentId)return path;const base=String(path).split(/[\\/]/).pop().toLowerCase().replace(/\s+/g,'');const want=String(instrumentId).toLowerCase().replace(/\s+/g,'');return base.indexOf(want)!==-1?path:null;};const loadVstToBridge=async track=>{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return false;const instrumentId=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!instrumentId||typeof instrumentId==='string'&&instrumentId.startsWith('sf_'))return false;if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};const tId=track.id||track.trackId;const bch=window.SonicMidiRouter.allocateChannel(tId,false);markTrackLoad(tId,{state:'loading',channel:bch});try{// V8 bug 5: engine _resolve_bridge_asset chi match FILE theo filename — // KHONG walk folder VST3 ("X.vst3/X.vst3.dll") → 404. Dung path da luu // (synth_engine.plugin_path) hoac cache tu listPlugins. // VST2 (.dll) scan type 'VST2' - khong hardcode VST3 (se LOAD sai loai). -let btype='VST3';let knownPath=track.synth_engine&&track.synth_engine.plugin_path;if(!knownPath&&window.__bridgePluginPaths)knownPath=window.__bridgePluginPaths[instrumentId];if(instrumentSelectorData){const v0=(instrumentSelectorData.vst_instruments||[]).find(v=>v.id===instrumentId||v.name===instrumentId);if(v0&&v0.path)knownPath=v0.path;if(v0&&v0.type)btype=v0.type;}// V9: path sai (plugin_path cũ / cache của plugin khác) → bỏ qua, resolve theo tên. +let btype='VST3';let knownPath=track.synth_engine&&track.synth_engine.plugin_path;if(!knownPath&&window.__bridgePluginPaths)knownPath=window.__bridgePluginPaths[instrumentId];if(typeof instrumentSelectorData!=='undefined'&&instrumentSelectorData){const v0=(instrumentSelectorData.vst_instruments||[]).find(v=>v.id===instrumentId||v.name===instrumentId);if(v0&&v0.path)knownPath=v0.path;if(v0&&v0.type)btype=v0.type;}// V9: path sai (plugin_path cũ / cache của plugin khác) → bỏ qua, resolve theo tên. knownPath=_bridgeSafeVstPath(knownPath,instrumentId);// V10: C++ đã load/restore ĐÚNG instrument trên channel (bridge_state.json, // Rust bridge_status trả qua Tauri invoke — không cần HTTP/auth) → dùng path // đó load thẳng; C++ double-load guard skip nên không LOAD trùng. if(!knownPath&&window.__bridgeLoadedState){const lc=window.__bridgeLoadedState.find(x=>x.channel===bch&&_bridgeSafeVstPath(x.path,instrumentId));if(lc)knownPath=lc.path;}// Đã load ĐÚNG path vào channel này rồi → skip (không LOAD trùng). Bỏ // early-return mù theo flag — flag sai khi C++ restore instrument khác. -if(knownPath&&window.__bridgeLoadedPaths[bch]===knownPath)return true;// Có path đáng tin → load thẳng qua Tauri invoke (không cần POST/auth/CORS). -if(knownPath){const ok=await window.NativeBridgeService.loadInstrument(knownPath,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=knownPath;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=knownPath;}console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',knownPath);return!!ok;}// Không có path → nhờ engine resolve theo name (POST). Fail (auth/CORS) → +if(knownPath&&window.__bridgeLoadedPaths[bch]===knownPath){markTrackLoad(tId,{state:'done'});return true;}// Có path đáng tin → load thẳng qua Tauri invoke (không cần POST/auth/CORS). +if(knownPath){const ok=await window.NativeBridgeService.loadInstrument(knownPath,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=knownPath;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=knownPath;}if(!ok)markTrackLoad(tId,{state:'failed'});console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',knownPath);return!!ok;}// Không có path → nhờ engine resolve theo name (POST). Fail (auth/CORS) → // fallback listPlugins bên dưới. -const r=await window.SonicAPI.bridgeLoad({name:instrumentId,path:null,instrumentType:btype,channel:bch});let p=r&&r.path?r.path:null;if(!p&&window.SonicAPI.listPlugins){try{const pl=await window.SonicAPI.listPlugins();const v=(pl&&pl.vst_instruments||[]).find(x=>x.id===instrumentId||x.name===instrumentId);if(v&&v.path){p=v.path;if(v&&v.type)btype=v.type;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=v.path;}}catch(e2){console.warn('[Bridge] listPlugins fallback fail:',e2);}}if(!p)return false;const ok=await window.NativeBridgeService.loadInstrument(p,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=p;}console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] loadVstToBridge fail:',track.id,e);return false;}};// GM font dự phòng khi soundfont track không có trong thư viện (VD id +const r=await window.SonicAPI.bridgeLoad({name:instrumentId,path:null,instrumentType:btype,channel:bch});let p=r&&r.path?r.path:null;if(!p&&window.SonicAPI.listPlugins){try{const pl=await window.SonicAPI.listPlugins();const v=(pl&&pl.vst_instruments||[]).find(x=>x.id===instrumentId||x.name===instrumentId);if(v&&v.path){p=v.path;if(v&&v.type)btype=v.type;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=v.path;}}catch(e2){console.warn('[Bridge] listPlugins fallback fail:',e2);}}if(!p){markTrackLoad(tId,{state:'failed'});return false;}const ok=await window.NativeBridgeService.loadInstrument(p,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=p;}if(!ok)markTrackLoad(tId,{state:'failed'});console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] loadVstToBridge fail:',track.id,e);markTrackLoad(tId,{state:'failed'});return false;}};// GM font dự phòng khi soundfont track không có trong thư viện (VD id // sf_generaluser_gs do AI sinh nhưng user chưa tải font) — ưu tiên font GM // đầy đủ patch, fallback font đầu tiên liệt kê được. const pickFallbackSoundfont=list=>{if(!list||!list.length)return null;const hay=s=>(String(s.id||'')+' '+String(s.file||'')+' '+String(s.name||'')).toLowerCase();for(const w of['sgm','generaluser','tgsf21x','timgm']){const hit=list.find(s=>hay(s).indexOf(w)!==-1);if(hit)return hit;}return list[0];};// D12: load track instrument (VST *hoặc* SF2) vào bridge. preloadTrackInstruments // trước đây chỉ load VST → mở project có SF2 track + bridge active → MIDI tới // channel bridge rỗng → C++ silent (không qua mastering chain native). -const loadAnyTrackToBridge=async track=>{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return false;const inst=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!inst)return false;if(typeof inst==='string'&&inst.startsWith('sf_')){const se=track.synth_engine||{};const bank=se.soundfont_bank!==undefined?se.soundfont_bank:track.soundfont_bank!==undefined?track.soundfont_bank:0;const bch=window.SonicMidiRouter.allocateChannel(track.id||track.trackId,bank===128);let pl=null;try{const sfId=inst.replace('sf_','');let bpath=null;try{pl=await window.SonicAPI.listPlugins();const s0=(pl&&pl.soundfonts||[]).find(s=>String(s.id).replace('sf_','')===sfId);bpath=s0&&(s0.file||s0.path);}catch(e){}let r=await window.SonicAPI.bridgeLoad({name:inst,path:bpath||null,instrumentType:'SF2',channel:bch});let p=r&&r.path?r.path:null;if(!p){// FALLBACK: soundfont id KHÔNG tồn tại trong thư viện (AI prompt mặc +const loadAnyTrackToBridge=async track=>{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return false;const inst=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!inst)return false;if(typeof inst==='string'&&inst.startsWith('sf_')){const se=track.synth_engine||{};const bank=se.soundfont_bank!==undefined?se.soundfont_bank:track.soundfont_bank!==undefined?track.soundfont_bank:0;const bch=window.SonicMidiRouter.allocateChannel(track.id||track.trackId,bank===128);markTrackLoad(track.id||track.trackId,{state:'loading',channel:bch});let pl=null;try{const sfId=inst.replace('sf_','');let bpath=null;try{pl=await window.SonicAPI.listPlugins();const s0=(pl&&pl.soundfonts||[]).find(s=>String(s.id).replace('sf_','')===sfId);bpath=s0&&(s0.file||s0.path);}catch(e){}let r=null;try{r=await window.SonicAPI.bridgeLoad({name:inst,path:bpath||null,instrumentType:'SF2',channel:bch});}catch(e){console.warn('[Bridge] SF2 bridgeLoad fail (fallback):',inst,e);}let p=r&&r.path?r.path:null;if(!p){// FALLBACK: soundfont id KHÔNG tồn tại trong thư viện (AI prompt mặc // định generaluser_gs nhưng user chưa tải font) → track câm im lặng. // Load GM font có sẵn (SGM/GeneralUser/tgsf21x) vào cùng channel — // __ensureBridgeProgram vẫn gửi CC0/CC32/PROGRAM → GM patch đúng. -const fb=pickFallbackSoundfont(pl&&pl.soundfonts);if(fb){console.warn('[Bridge] SF2 not found:',inst,'→ fallback',fb.file||fb.id,'@ch',bch);const r2=await window.SonicAPI.bridgeLoad({name:inst,path:fb.file||fb.path||null,instrumentType:'SF2',channel:bch});p=r2&&r2.path?r2.path:null;}}if(!p){console.warn('[Bridge] SF2 resolve fail:',track.id,inst);return false;}const ok=await window.NativeBridgeService.loadInstrument(p,'SF2',bch);if(ok){if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};window.__bridgeLoadedChannels[bch]=true;if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};window.__bridgeLoadedPaths[bch]=p;}console.log('[Bridge] preload SF2 track',track.id,'-> ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] preload SF2 fail:',track.id,e);return false;}}try{return await loadVstToBridge(track);}catch(e){return false;}};const ensureAndOpenVstGui=async track=>{const trackId=track.trackId||track.id;const instrumentId=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!instrumentId||typeof instrumentId==='string'&&instrumentId.startsWith('sf_'))return;if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};let bch=window.SonicMidiRouter&&window.SonicMidiRouter.allocateChannel?window.SonicMidiRouter.allocateChannel(trackId,false):0;// LAN/standalone (browser không Tauri): NativeBridgeService chỉ log, không +const fb=pickFallbackSoundfont(pl&&pl.soundfonts);if(fb){console.warn('[Bridge] SF2 not found:',inst,'→ fallback',fb.file||fb.id,'@ch',bch);const r2=await window.SonicAPI.bridgeLoad({name:inst,path:fb.file||fb.path||null,instrumentType:'SF2',channel:bch});p=r2&&r2.path?r2.path:null;}}if(!p){console.warn('[Bridge] SF2 resolve fail:',track.id,inst);markTrackLoad(track.id||track.trackId,{state:'failed'});return false;}const ok=await window.NativeBridgeService.loadInstrument(p,'SF2',bch);if(ok){if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};window.__bridgeLoadedChannels[bch]=true;if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};window.__bridgeLoadedPaths[bch]=p;}if(!ok)markTrackLoad(track.id||track.trackId,{state:'failed'});console.log('[Bridge] preload SF2 track',track.id,'-> ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] preload SF2 fail:',track.id,e);markTrackLoad(track.id||track.trackId,{state:'failed'});return false;}}try{return await loadVstToBridge(track);}catch(e){return false;}};// D16: per-track loading ruler (TCP). window.__trackLoadState[trackId] = +// { state:'pending'|'loading'|'done'|'failed', channel, startedTs, doneTs }. +// Poller trong App (loadTick) chuyển loading→done khi channel xuất hiện trong +// loaded_channels (queryStatus) — đúng cho cả C++ restore (bridge_state.json, +// chạy từ spawn) lẫn JS load — không đánh dấu done sớm khi C++ guard skip. +const markTrackLoad=(trackId,patch)=>{if(!trackId)return;if(!window.__trackLoadState)window.__trackLoadState={};const cur=window.__trackLoadState[trackId]||{state:'pending',channel:null,startedTs:0,doneTs:0};const next=Object.assign({},cur,patch);if(patch.state==='loading'&&cur.state!=='loading')next.startedTs=Date.now();if(patch.state==='done'&&!patch.doneTs)next.doneTs=Date.now();window.__trackLoadState[trackId]=next;if(window.__onTrackLoadState){try{window.__onTrackLoadState(trackId);}catch(e){}}};if(typeof document!=='undefined'&&!document.getElementById('sf-ruler-style')){const _st=document.createElement('style');_st.id='sf-ruler-style';_st.textContent='@keyframes sf-ruler { 0% { transform: translateX(-110%); } 100% { transform: translateX(340%); } } .sf-ruler-bar { animation: sf-ruler 1.1s ease-in-out infinite; } @keyframes sf-ruler-done { 0% { opacity: 1; } 100% { opacity: 0; } } .sf-ruler-done { animation: sf-ruler-done 1.4s ease-out forwards; }';document.head.appendChild(_st);}const ensureAndOpenVstGui=async track=>{const trackId=track.trackId||track.id;const instrumentId=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!instrumentId||typeof instrumentId==='string'&&instrumentId.startsWith('sf_'))return;if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};let bch=window.SonicMidiRouter&&window.SonicMidiRouter.allocateChannel?window.SonicMidiRouter.allocateChannel(trackId,false):0;// LAN/standalone (browser không Tauri): NativeBridgeService chỉ log, không // invoke được open_vst_gui → nhờ engine đẩy LOAD+OPEN_GUI vào SHM qua route // /bridge/gui (E6). GUI hiện trên desktop máy chạy server. if(!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive()||!window.NativeBridgeService){try{if(!window.SonicAPI||!window.SonicAPI.bridgeOpenGui)return;const r=await window.SonicAPI.bridgeOpenGui({name:instrumentId,path:null,instrumentType:null,channel:bch});if(r&&r.success)console.log('[Bridge] GUI open via API',instrumentId,'ch',bch,r.path);else console.warn('[Bridge] GUI via API response:',r);}catch(e){console.warn('[Bridge] GUI via API fail:',trackId,e);}return;}// V9: load (nếu chưa có ĐÚNG instrument) rồi CHỈ mở GUI khi load OK — không @@ -735,8 +751,23 @@ const _wheelAcc=new Map();const wheelSteps=(key,deltaY,deltaMode,pxPerStep=100)= const c=(_wheelAcc.get(key)||0)-norm;const steps=Math.trunc(c/pxPerStep);_wheelAcc.set(key,c-steps*pxPerStep);return steps;};// Module-level so both ProfileModal (open project) and App (auto-restore) can // warm the FluidSynth font cache for each track's instrument. This only loads // the soundfonts; the per-track channel re-selection happens at note time. -const preloadTrackInstruments=async tracks=>{const list=tracks||[];if(window.SonicSF&&window.SonicSF.selectInstrument){for(let i=0;i{if(!isOpen)return null;const[activeTab,setActiveTab]=useState('account');const[profile,setProfile]=useState(null);const[oldPassword,setOldPassword]=useState('');const[newPassword,setNewPassword]=useState('');const[msg,setMsg]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);const[dragOfs,setDragOfs]=useState({x:0,y:0});const dragRef=useRef({active:false,startX:0,startY:0,ofsX:0,ofsY:0});// Projects list state +const preloadTrackInstruments=async tracks=>{const list=tracks||[];// D13: bridge load TRƯỚC vòng WASM — vòng selectInstrument (parse font WASM +// từng track, có thể ~20s) trước đây chặn vòng bridge → user bấm play khi +// app vừa load: NOTE_ON vào channel bridge chưa có instrument → im lặng tới +// khi bridge load xong (~27s từ bootstrap — dbg.log play_first_audible). +if(window.NativeBridgeService&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){// D16b: pre-mark TẤT CẢ track có instrument thành loading TRƯỚC loop — loop +// nối tiếp (C++ g_loadMutex) khiến track 2-9 không có ruler tới khi track 1 +// xong → UI lâu không có loading item. markTrackLoad channel=null hiện ruler +// ngay; loadAnyTrackToBridge set channel khi tới lượt; poller chuyển done. +for(let i=0;i after reopen the SF +// preset is wrong (bug: muted SF tracks lost their preset). +// __ensureBridgeProgram falls back to synth_engine.soundfont_program. +if(typeof inst==='string'&&inst.startsWith('sf_')){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(t.id||t.trackId,undefined,t.synth_engine);}catch(e){}// V45.12: C++ restore (bridge_state.json) chạy ASYNC sau preload — +// assign() + selectProgram(bank/program CŨ) ghi đè program JS vừa +// gửi (race startup). Retry FORCE (bỏ dedupe) sau 1.5s/4s/8s để qua +// cửa sổ restore, không spam mỗi note. +if(t.synth_engine&&t.synth_engine.soundfont_program!==undefined){const _fire=()=>{try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(t.id||t.trackId,undefined,t.synth_engine,true);}catch(e){}};[1500,4000,8000].forEach(ms=>setTimeout(_fire,ms));}}}}if(window.SonicSF&&window.SonicSF.selectInstrument){for(let i=0;i{if(!isOpen)return null;const[activeTab,setActiveTab]=useState('account');const[profile,setProfile]=useState(null);const[oldPassword,setOldPassword]=useState('');const[newPassword,setNewPassword]=useState('');const[msg,setMsg]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);const[dragOfs,setDragOfs]=useState({x:0,y:0});const dragRef=useRef({active:false,startX:0,startY:0,ofsX:0,ofsY:0});// Projects list state const[projectsList,setProjectsList]=useState([]);const[loadingProjects,setLoadingProjects]=useState(false);// Files list state const[filesList,setFilesList]=useState([]);const[loadingFiles,setLoadingFiles]=useState(false);// Confirmation modal state const[confirmModal,setConfirmModal]=useState(null);// Backup state @@ -781,7 +812,7 @@ let bestIdx=0,bestDist=99;for(let i=0;i<7;i++){let d=Math.abs(pc-srcTones[i]);if React.useEffect(()=>{const handleWheelRaw=e=>{if(e.ctrlKey){e.preventDefault();const zoomFactor=e.deltaY<0?1.15:0.85;setRollZoom(prev=>Math.max(15,Math.min(250,prev*zoomFactor)));}};const container=gridScrollRef.current;if(container){container.addEventListener('wheel',handleWheelRaw,{passive:false});}return()=>{if(container){container.removeEventListener('wheel',handleWheelRaw);}};},[]);// Alt + Scroll event listener: fast‑forward playhead + play notes React.useEffect(()=>{const handleCanvasWheel=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const mx=e.clientX-rect.left;const my=e.clientY-rect.top;const pitch=127-Math.floor(my/NoteHeight);if(e.shiftKey){e.preventDefault();// Shift+scroll on note → change velocity of single note or all selected const scrollBeat=mx/pixelsPerBeat-renderBeatOffset;const clickedNote=notes.find(n=>pitch===n.pitch&&scrollBeat>=n.start_beat&&scrollBeat0){setNotes(prev=>prev.map(n=>selectedNoteIds.includes(n.id)?{...n,velocity:Math.max(0.05,Math.min(1,(n.velocity||0.8)+delta))}:n));}else{setNotes(prev=>prev.map(n=>n.id===clickedNote.id?{...n,velocity:Math.max(0.05,Math.min(1,(n.velocity||0.8)+delta))}:n));}}else{// Shift+scroll on empty space → horizontal scroll -const container=gridScrollRef.current;if(container)container.scrollLeft+=e.deltaY;}return;}if(e.altKey){e.preventDefault();const scrollDelta=e.deltaY;const beatSec=60.0/(parseInt(bpm)||120);const step=scrollDelta<0?-0.25:0.25;const currentBeat=(st.currentTime||0)/beatSec;const maxBeats=totalBeats;const newBeat=Math.max(0,Math.min(maxBeats,currentBeat+step));const newTime=newBeat*beatSec;setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:newTime}:s));if(window.SonicSF){const ctx=getAudioContext();const playing=notes.filter(n=>currentBeat=n.start_beat);var pvTrk=activeTracks.find(function(t){return t.id===st.trackId;});var pvCtx=resolveTrackInstrumentCtx(pvTrk,activeTracks);ensureSonicInstrument(pvCtx);playing.forEach(n=>{if(isStandaloneSf()&&isSfTrackEngine(pvCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(pvTrk,n.pitch,n.velocity||0.8,200,undefined,'pv_'+st.trackId);return;}if(isStandaloneSf()&&isVstTrackEngine(pvCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(pvTrk,n.pitch,n.velocity||0.8,200,undefined,'pv_'+st.trackId);return;}scheduleMidiNoteDispatch(pvTrk,n.pitch,n.velocity||0.8,200,ctx.currentTime,pvCtx.program,null,pvCtx.ch,pvCtx.synthEngine);});}}};const canvas=canvasRef.current;if(canvas){canvas.addEventListener('wheel',handleCanvasWheel,{passive:false});}return()=>{if(canvas){canvas.removeEventListener('wheel',handleCanvasWheel);}};},[notes,st.currentTime,pixelsPerBeat,st.id,totalBeats,bpm]);React.useLayoutEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=128*NoteHeight;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);// Scale highlight: rows thuộc scale (root+scale) sáng vàng; ngoài scale +const container=gridScrollRef.current;if(container)container.scrollLeft+=e.deltaY;}return;}if(e.altKey){e.preventDefault();const scrollDelta=e.deltaY;const beatSec=60.0/(parseInt(bpm)||120);const step=scrollDelta<0?-0.25:0.25;const currentBeat=(st.currentTime||0)/beatSec;const maxBeats=totalBeats;const newBeat=Math.max(0,Math.min(maxBeats,currentBeat+step));const newTime=newBeat*beatSec;setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:newTime}:s));if(window.SonicSF){const ctx=getAudioContext();const playing=notes.filter(n=>currentBeat=n.start_beat);var pvTrk=activeTracks.find(function(t){return t.id===st.trackId;});var pvCtx=resolveTrackInstrumentCtx(pvTrk,activeTracks);ensureSonicInstrument(pvCtx);playing.forEach(n=>{if(isStandaloneSf()&&isSfTrackEngine(pvCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(pvTrk,n.pitch,n.velocity||0.8,200,undefined,'pv_'+st.trackId);return;}if(isStandaloneSf()&&isVstTrackEngine(pvCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(pvTrk,n.pitch,n.velocity||0.8,200,undefined,'pv_'+st.trackId);return;}scheduleMidiNoteDispatch(pvTrk,n.pitch,n.velocity||0.8,200,ctx.currentTime,pvCtx.program,null,pvCtx.ch,pvCtx.synthEngine,true);});}}};const canvas=canvasRef.current;if(canvas){canvas.addEventListener('wheel',handleCanvasWheel,{passive:false});}return()=>{if(canvas){canvas.removeEventListener('wheel',handleCanvasWheel);}};},[notes,st.currentTime,pixelsPerBeat,st.id,totalBeats,bpm]);React.useLayoutEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=128*NoteHeight;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);// Scale highlight: rows thuộc scale (root+scale) sáng vàng; ngoài scale // dim hơn (spec 20:12 — Scale Highlight & Lock) const scHighlight=scaleWithRoot();// Draw background rows for(let pitch=0;pitch<128;pitch++){const y=(127-pitch)*NoteHeight;const isBlack=[1,3,6,8,10].includes(pitch%12);const inScale=scHighlight?scHighlight.includes(pitch%12):null;ctx.fillStyle=isBlack?inScale===false?'#141419':'#1f1f25':inScale===false?'#1b1b20':inScale===true?'#2c2a1e':'#25252a';ctx.fillRect(0,y,viewWidth,NoteHeight);ctx.strokeStyle='#2d2d35';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(0,y+NoteHeight);ctx.lineTo(viewWidth,y+NoteHeight);ctx.stroke();}// Draw snap lines @@ -826,7 +857,7 @@ React.useEffect(function(){setFocusItemId(st.target_id);},[st.id,st.target_id]); if(e.button===2){e.preventDefault();const clickedNote=notes.find(n=>{return pitch===n.pitch&&beat>=n.start_beat&&beatprev.filter(n=>n.id!==clickedNote.id));setSelectedNoteIds(prev=>prev.filter(id=>id!==clickedNote.id));swallowContextMenuRef.current=true;showToast('Đã xóa nốt!','info');}else{rightClickDragRef.current={active:true,startX:e.clientX,startY:e.clientY};}return;}if(e.button!==0)return;// Only handle left click // Check if clicking on an existing note const clickedNoteIdx=notes.findIndex(n=>{return pitch===n.pitch&&beat>=n.start_beat&&beatprev.filter(id=>id!==clickedNote.id));}else{setSelectedNoteIds(prev=>[...prev,clickedNote.id]);}return;}}// Ctrl+click NOTE + drag → COPY nhanh nhóm notes/note đến vị trí mới // (cùng pitch ban đầu; drag đổi vị trí + pitch) — user 09:30 @@ -844,14 +875,14 @@ pushToUndo(notes);const start=getSnapBeat(beat,snapValue);const initialDur=lastN if(previewNodesRef.current){try{previewNodesRef.current.osc.stop();}catch(e){}try{previewNodesRef.current.osc.disconnect();}catch(e){}try{previewNodesRef.current.gain.disconnect();}catch(e){}previewNodesRef.current=null;}var dwTrk=activeTracks.find(function(t){return t.id===st.trackId;});var dwCh=dwTrk?assignTrackMidiChannel(dwTrk,activeTracks):0;var dwPitch=snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch;var dwDurMs=Math.max(100,Math.round(initialDur*(60/bpm)*1000));if(isStandaloneSf()&&isSfTrackEngine(dwTrk&&dwTrk.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(dwTrk,dwPitch,brushVelocityRef.current||0.8,dwDurMs,undefined,'pvdraw_'+st.trackId);}else if(isStandaloneSf()&&isVstTrackEngine(dwTrk&&dwTrk.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(dwTrk,dwPitch,brushVelocityRef.current||0.8,dwDurMs,undefined,'pvdraw_'+st.trackId);}else if(window.SonicSF&&window.SonicSF.playNote){const ctx=getAudioContext();// playNote (FluidSynth — nhạc cụ THẬT của track). _playNoteFallback chỉ // là oscillator beep (sai âm với percussion/soundfont — user: note vẽ // mới nghe nhạc cụ track trước). -scheduleMidiNoteDispatch(dwTrk,dwPitch,brushVelocityRef.current||0.8,dwDurMs,ctx.currentTime,dwTrk?dwTrk.instrumentProgram:undefined,null,dwCh,dwTrk?dwTrk.synth_engine:undefined);}}};const handleGridMouseMove=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const beat=x/pixelsPerBeat-renderBeatOffset;const pitch=127-Math.floor(y/NoteHeight);if(selectionMarquee){const snappedBeat=getSnapBeat(beat,snapValue);const marquee={...selectionMarquee,currentBeat:snappedBeat,currentPitch:pitch};setSelectionMarquee(marquee);const minBeat=Math.min(marquee.startBeat,marquee.currentBeat);const maxBeat=Math.max(marquee.startBeat,marquee.currentBeat);const minPitch=Math.min(marquee.startPitch,marquee.currentPitch);const maxPitch=Math.max(marquee.startPitch,marquee.currentPitch);const insideIds=notes.filter(n=>{const withinPitch=n.pitch>=minPitch&&n.pitch<=maxPitch;if(!withinPitch)return false;const noteEnd=n.start_beat+n.duration_beats;if(marquee.startBeat<=marquee.currentBeat){// Left to right: select if any overlap +scheduleMidiNoteDispatch(dwTrk,dwPitch,brushVelocityRef.current||0.8,dwDurMs,ctx.currentTime,dwTrk?dwTrk.instrumentProgram:undefined,null,dwCh,dwTrk?dwTrk.synth_engine:undefined,true);}}};const handleGridMouseMove=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const beat=x/pixelsPerBeat-renderBeatOffset;const pitch=127-Math.floor(y/NoteHeight);if(selectionMarquee){const snappedBeat=getSnapBeat(beat,snapValue);const marquee={...selectionMarquee,currentBeat:snappedBeat,currentPitch:pitch};setSelectionMarquee(marquee);const minBeat=Math.min(marquee.startBeat,marquee.currentBeat);const maxBeat=Math.max(marquee.startBeat,marquee.currentBeat);const minPitch=Math.min(marquee.startPitch,marquee.currentPitch);const maxPitch=Math.max(marquee.startPitch,marquee.currentPitch);const insideIds=notes.filter(n=>{const withinPitch=n.pitch>=minPitch&&n.pitch<=maxPitch;if(!withinPitch)return false;const noteEnd=n.start_beat+n.duration_beats;if(marquee.startBeat<=marquee.currentBeat){// Left to right: select if any overlap return n.start_beat<=maxBeat&¬eEnd>=minBeat;}else{// Right to left: select only if fully covered return n.start_beat>=minBeat&¬eEnd<=maxBeat;}}).map(n=>n.id);setSelectedNoteIds(insideIds);return;}// Right-click drag → erase sweep const rc=rightClickDragRef.current;if(rc.active&&(Math.abs(e.clientX-rc.startX)>5||Math.abs(e.clientY-rc.startY)>5)){rc.active=false;swallowContextMenuRef.current=true;notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));setDraggedNote({mode:'erase_sweep',visitedPitches:[]});return;}if(!draggedNote){let foundIdx=-1;for(let i=0;i=n.start_beat){foundIdx=i;break;}}}if(foundIdx!==-1){canvas.style.cursor='ew-resize';setHoveredResizeIdx(foundIdx);}else{canvas.style.cursor=activeRollTool==='eraser'?'pointer':'crosshair';setHoveredResizeIdx(-1);}return;}if(draggedNote.mode==='draw'){const snappedPitch=snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch;const lastPitch=draggedNote.lastDrawnPitch!==undefined?draggedNote.lastDrawnPitch:draggedNote.startOffsetPitch;const pitchChanged=snappedPitch!==lastPitch;const noteBeats=draggedNote.noteStartBeats||[];const defaultDur=getSnapDuration(snapValue);function playDrawPreview(p,durMs){stopPreviewNote();// Đồng bộ mastering + routing SF trước khi preview draw (âm qua // mastering FX của main out khi chain bật) try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}var pvTrk=activeTracks.find(function(t){return t.id===st.trackId;});var pvCtxInst=resolveTrackInstrumentCtx(pvTrk,activeTracks);if(isStandaloneSf()&&isSfTrackEngine(pvCtxInst.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(pvTrk,p,brushVelocityRef.current||0.8,durMs,undefined,'pvdraw_'+st.trackId);}else if(isStandaloneSf()&&isVstTrackEngine(pvCtxInst.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(pvTrk,p,brushVelocityRef.current||0.8,durMs,undefined,'pvdraw_'+st.trackId);}else if(window.SonicSF&&window.SonicSF.playNote){var pvCtx=getAudioContext();// playNote (FluidSynth — nhạc cụ THẬT). _playNoteFallback = oscillator // beep sai âm (percussion/soundfont). -scheduleMidiNoteDispatch(pvTrk,p,brushVelocityRef.current||0.8,durMs,pvCtx.currentTime,pvCtxInst.program,null,pvCtxInst.ch,pvCtxInst.synthEngine);previewPitchRef.current=p;}}if(pitchChanged){const brushIds=draggedNote.brushIds||[];if(brushIds.length>0&¬eBeats.length>0){const prevNoteId=brushIds[brushIds.length-1];const prevNoteBeat=noteBeats[noteBeats.length-1];const prevDur=Math.max(0.125,beat-prevNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==prevNoteId)return n;return{...n,duration_beats:prevDur};}));}const newNote={id:'note_'+Date.now()+Math.random().toString(36).substr(2,8)+'_'+(noteBeats.length+1),pitch:snappedPitch,start_beat:beat,duration_beats:defaultDur,velocity:brushVelocityRef.current,pan:0.0};setNotes(prev=>[...prev,newNote]);setSelectedNoteIds(prev=>[...prev,newNote.id]);draggedNote.brushIds=[...brushIds,newNote.id];draggedNote.lastDrawnPitch=snappedPitch;draggedNote.noteStartBeats=[...noteBeats,beat];playDrawPreview(snappedPitch,Math.max(100,Math.round(defaultDur*(60/bpm)*1000)));}else{const brushIds=draggedNote.brushIds||[];const lastBrushId=brushIds.length>0?brushIds[brushIds.length-1]:draggedNote.drawNoteId;const lastNoteBeat=noteBeats.length>0?noteBeats[noteBeats.length-1]:draggedNote.startOffsetBeat;if(lastBrushId){const extDur=Math.max(0.125,beat-lastNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==lastBrushId)return n;return{...n,duration_beats:extDur};}));playDrawPreview(snappedPitch,Math.max(100,Math.round(extDur*(60/bpm)*1000)));}}const container=gridScrollRef.current;if(container){const cr=container.getBoundingClientRect();const visTop=container.scrollTop;const visBot=visTop+container.clientHeight;const pitchPixel=(127-snappedPitch)*NoteHeight;const safeMargin=NoteHeight*2;if(pitchPixel{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.max(0,gridScrollRef.current.scrollTop-Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else if(pitchPixel+NoteHeight>visBot-safeMargin){const target=Math.min(container.scrollHeight-container.clientHeight,pitchPixel-container.clientHeight+safeMargin+NoteHeight);if(container.scrollTop!==target)container.scrollTop=target;if(!brushAutoScrollRef.current||brushAutoScrollRef.current.direction!=='down'){if(brushAutoScrollRef.current)clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current={direction:'down',id:setInterval(()=>{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.min(gridScrollRef.current.scrollHeight-gridScrollRef.current.clientHeight,gridScrollRef.current.scrollTop+Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else{if(brushAutoScrollRef.current){clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current=null;}}}return;}if(draggedNote.mode==='erase_sweep'){const erased=draggedNote.erasedIds||[];const target=notes.find(n=>n.pitch===pitch&&beat>=n.start_beat&&beatprev.filter(n=>n.id!==target.id));setSelectedNoteIds(prev=>prev.filter(id=>id!==target.id));}return;}if(draggedNote.mode==='scale'){const newEnd=getSnapBeat(Math.max(draggedNote.firstStart+0.125,beat),snapValue);const range=draggedNote.originalEnd-draggedNote.firstStart;if(range<=0)return;const scaleFactor=Math.max(0.01,(newEnd-draggedNote.firstStart)/range);const ids=draggedNote.selectedNoteIds||[];const firstStart=draggedNote.firstStart;const notesBefore=notesBeforeDragRef.current;if(!notesBefore)return;setNotes(prev=>prev.map(n=>{if(!ids.includes(n.id))return n;const orig=notesBefore?notesBefore.find(o=>o.id===n.id):null;const origStart=orig?orig.start_beat:n.start_beat;const origDur=orig?orig.duration_beats:n.duration_beats;const relStart=origStart-firstStart;const relEnd=relStart+origDur;return{...n,start_beat:firstStart+relStart*scaleFactor,duration_beats:Math.max(0.125,relEnd*scaleFactor-relStart*scaleFactor)};}));return;}if(draggedNote.mode==='resize'){const newDuration=getSnapBeat(Math.max(0.125,beat-draggedNote.originalStart),snapValue);setNotes(prev=>prev.map((n,idx)=>{if(idx!==draggedNote.idx)return n;return{...n,duration_beats:newDuration};}));}else if(draggedNote.mode==='move'){const refOrigStart=draggedNote.clickedOriginalStartBeat;if(refOrigStart===undefined)return;const deltaBeat=getSnapBeat(beat-draggedNote.startOffsetBeat,snapValue)-refOrigStart;// Clamp so no note goes past beat 0 +scheduleMidiNoteDispatch(pvTrk,p,brushVelocityRef.current||0.8,durMs,pvCtx.currentTime,pvCtxInst.program,null,pvCtxInst.ch,pvCtxInst.synthEngine,true);previewPitchRef.current=p;}}if(pitchChanged){const brushIds=draggedNote.brushIds||[];if(brushIds.length>0&¬eBeats.length>0){const prevNoteId=brushIds[brushIds.length-1];const prevNoteBeat=noteBeats[noteBeats.length-1];const prevDur=Math.max(0.125,beat-prevNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==prevNoteId)return n;return{...n,duration_beats:prevDur};}));}const newNote={id:'note_'+Date.now()+Math.random().toString(36).substr(2,8)+'_'+(noteBeats.length+1),pitch:snappedPitch,start_beat:beat,duration_beats:defaultDur,velocity:brushVelocityRef.current,pan:0.0};setNotes(prev=>[...prev,newNote]);setSelectedNoteIds(prev=>[...prev,newNote.id]);draggedNote.brushIds=[...brushIds,newNote.id];draggedNote.lastDrawnPitch=snappedPitch;draggedNote.noteStartBeats=[...noteBeats,beat];playDrawPreview(snappedPitch,Math.max(100,Math.round(defaultDur*(60/bpm)*1000)));}else{const brushIds=draggedNote.brushIds||[];const lastBrushId=brushIds.length>0?brushIds[brushIds.length-1]:draggedNote.drawNoteId;const lastNoteBeat=noteBeats.length>0?noteBeats[noteBeats.length-1]:draggedNote.startOffsetBeat;if(lastBrushId){const extDur=Math.max(0.125,beat-lastNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==lastBrushId)return n;return{...n,duration_beats:extDur};}));playDrawPreview(snappedPitch,Math.max(100,Math.round(extDur*(60/bpm)*1000)));}}const container=gridScrollRef.current;if(container){const cr=container.getBoundingClientRect();const visTop=container.scrollTop;const visBot=visTop+container.clientHeight;const pitchPixel=(127-snappedPitch)*NoteHeight;const safeMargin=NoteHeight*2;if(pitchPixel{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.max(0,gridScrollRef.current.scrollTop-Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else if(pitchPixel+NoteHeight>visBot-safeMargin){const target=Math.min(container.scrollHeight-container.clientHeight,pitchPixel-container.clientHeight+safeMargin+NoteHeight);if(container.scrollTop!==target)container.scrollTop=target;if(!brushAutoScrollRef.current||brushAutoScrollRef.current.direction!=='down'){if(brushAutoScrollRef.current)clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current={direction:'down',id:setInterval(()=>{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.min(gridScrollRef.current.scrollHeight-gridScrollRef.current.clientHeight,gridScrollRef.current.scrollTop+Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else{if(brushAutoScrollRef.current){clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current=null;}}}return;}if(draggedNote.mode==='erase_sweep'){const erased=draggedNote.erasedIds||[];const target=notes.find(n=>n.pitch===pitch&&beat>=n.start_beat&&beatprev.filter(n=>n.id!==target.id));setSelectedNoteIds(prev=>prev.filter(id=>id!==target.id));}return;}if(draggedNote.mode==='scale'){const newEnd=getSnapBeat(Math.max(draggedNote.firstStart+0.125,beat),snapValue);const range=draggedNote.originalEnd-draggedNote.firstStart;if(range<=0)return;const scaleFactor=Math.max(0.01,(newEnd-draggedNote.firstStart)/range);const ids=draggedNote.selectedNoteIds||[];const firstStart=draggedNote.firstStart;const notesBefore=notesBeforeDragRef.current;if(!notesBefore)return;setNotes(prev=>prev.map(n=>{if(!ids.includes(n.id))return n;const orig=notesBefore?notesBefore.find(o=>o.id===n.id):null;const origStart=orig?orig.start_beat:n.start_beat;const origDur=orig?orig.duration_beats:n.duration_beats;const relStart=origStart-firstStart;const relEnd=relStart+origDur;return{...n,start_beat:firstStart+relStart*scaleFactor,duration_beats:Math.max(0.125,relEnd*scaleFactor-relStart*scaleFactor)};}));return;}if(draggedNote.mode==='resize'){const newDuration=getSnapBeat(Math.max(0.125,beat-draggedNote.originalStart),snapValue);setNotes(prev=>prev.map((n,idx)=>{if(idx!==draggedNote.idx)return n;return{...n,duration_beats:newDuration};}));}else if(draggedNote.mode==='move'){const refOrigStart=draggedNote.clickedOriginalStartBeat;if(refOrigStart===undefined)return;const deltaBeat=getSnapBeat(beat-draggedNote.startOffsetBeat,snapValue)-refOrigStart;// Clamp so no note goes past beat 0 const minOrigStart=Math.min(...draggedNote.selectedNotesOffset.map(o=>o.originalStartBeat));const clampedDeltaBeat=minOrigStart+deltaBeat<0?-minOrigStart:deltaBeat;const deltaPitch=Math.round(pitch-draggedNote.startOffsetPitch);setNotes(prev=>prev.map(n=>{const offset=draggedNote.selectedNotesOffset.find(o=>o.id===n.id);if(!offset)return n;return{...n,start_beat:getSnapBeat(Math.max(0,offset.originalStartBeat+clampedDeltaBeat),snapValue),pitch:Math.max(0,Math.min(127,offset.originalPitch+deltaPitch))};}));}};const handleGridMouseUp=()=>{if(draggedNote&&draggedNote.mode==='draw'){const dn=draggedNote;const brushIds=dn.brushIds||[];const lastBrushId=brushIds.length>0?brushIds[brushIds.length-1]:dn.drawNoteId;if(lastBrushId){const lastNote=notes.find(n=>n.id===lastBrushId);if(lastNote)lastNoteDurationRef.current=lastNote.duration_beats;}}setDraggedNote(null);// Marquee (Ctrl+drag): CHỌN notes nằm trong vùng (user 09:15 — trước đây // chỉ vẽ highlight, không set selectedNoteIds → Ctrl+X báo "chọn notes") if(selectionMarquee){const minBeat=Math.min(selectionMarquee.startBeat,selectionMarquee.currentBeat);const maxBeat=Math.max(selectionMarquee.startBeat,selectionMarquee.currentBeat);const minPitch=Math.min(selectionMarquee.startPitch,selectionMarquee.currentPitch);const maxPitch=Math.max(selectionMarquee.startPitch,selectionMarquee.currentPitch);const inMarquee=notes.filter(n=>{const center=n.start_beat+n.duration_beats/2;return center>=minBeat&¢er<=maxBeat&&n.pitch>=minPitch&&n.pitch<=maxPitch;});setSelectedNoteIds(inMarquee.map(n=>n.id));}setSelectionMarquee(null);rightClickDragRef.current={active:false,startX:0,startY:0};if(brushAutoScrollRef.current){clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current=null;}stopPreviewNote();previewPitchRef.current=null;};const handleContextMenu=e=>{e.preventDefault();if(swallowContextMenuRef.current){swallowContextMenuRef.current=false;return;}const pos={x:e.clientX,y:e.clientY,parentKey:null};scaleMenuOriginRef.current={x:pos.x,y:pos.y};setScaleMenuPos(pos);};const ccDragRef=React.useRef(null);const brushAutoScrollRef=React.useRef(null);const rightClickDragRef=React.useRef({active:false,startX:0,startY:0});const swallowContextMenuRef=React.useRef(false);// Status hint động (user 09:40): theo dõi Shift/Ctrl + mouse trong piano roll @@ -869,13 +900,19 @@ ccDragRef.current={mode:'cc11',active:true,anchorIdx,gs:g.minS,ge:g.maxE,span:g. const commitCC11Drag=()=>{const drag=ccDragRef.current;if(!drag)return;if(drag.mode==='cc11'&&drag.previewCurve){commitCC11Curve(drag.previewCurve);}ccDragRef.current=null;};const handleCCMouseDown=e=>{if(ccMode==='cc11'){handleCC11MouseDown(e);return;}const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const noteIdx=findCCNoteIndex(beat,y,h);const val=Math.max(0,Math.min(1,(h-y)/h));if(e.ctrlKey){if(selectedNoteIds.length>0){const cursorNoteIdx=findCCNoteIndex(beat,y,h);if(cursorNoteIdx!==-1&&selectedNoteIds.includes(notes[cursorNoteIdx]?notes[cursorNoteIdx].id:-1)){const currentNote=notes[cursorNoteIdx];const currentVal=ccMode==='pan'?(currentNote.pan||0)/2.0+0.5:currentNote.velocity!==undefined?currentNote.velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==cursorNoteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}ccDragRef.current={active:true,lastBeat:beat,selectedMode:true,lastPainted:[cursorNoteIdx]};}else{ccDragRef.current={active:true,lastBeat:beat,selectedMode:true,lastPainted:[]};}}else{// Không chọn notes: vẽ TẤT CẢ notes cùng beat (chord — user 08:25) const idxs=findCCNoteIndicesAtBeat(beat);ccDragRef.current={active:true,lastBeat:beat,lastPainted:idxs};}return;}if(noteIdx!==-1){const currentVal=ccMode==='pan'?(notes[noteIdx].pan||0)/2.0+0.5:notes[noteIdx].velocity!==undefined?notes[noteIdx].velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==noteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}}};const handleCCMouseMove=e=>{if(!ccDragRef.current||!ccDragRef.current.active)return;if(ccDragRef.current.mode==='cc11'){const drag=ccDragRef.current;const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const ratio=Math.max(0,Math.min(1,(beat-drag.gs)/drag.span));const val=Math.max(0,Math.min(127,Math.round((h-y)/h*127)));const curve=(drag.previewCurve||[]).slice();const idx=Math.min(drag.anchorIdx,curve.length-1);const cur=curve[idx]||{position_ratio:ratio,value:val};// Drag node: doi ca ratio+value, clamp giua 2 node lan can let minR=0,maxR=1;for(let i=0;iminR)minR=r;if(r>cur.position_ratio&&r0){const cursorNoteIdx=findCCNoteIndex(beat,y,h);if(cursorNoteIdx!==-1){const cursorNote=notes[cursorNoteIdx];if(cursorNote&&selectedNoteIds.includes(cursorNote.id)&&!painted.includes(cursorNoteIdx)){const currentVal=ccMode==='pan'?(cursorNote.pan||0)/2.0+0.5:cursorNote.velocity!==undefined?cursorNote.velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==cursorNoteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}drag.lastPainted=[...painted,cursorNoteIdx];}}return;}// Vẽ TẤT CẢ notes cùng beat (chord — user 08:25) — trước đây chỉ 1 note -const candidateIdxs=findCCNoteIndicesAtBeat(beat);const unpainted=candidateIdxs.filter(ci=>!painted.includes(ci));if(unpainted.length>0){const updatedNotes=notes.map((n,i)=>{if(!unpainted.includes(i))return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);drag.lastPainted=[...painted,...unpainted];}};const renderKeybed=()=>{var kbTrk=activeTracks.find(function(t){return t.id===st.trackId;});var kbCtx=resolveTrackInstrumentCtx(kbTrk,activeTracks);ensureSonicInstrument(kbCtx);const keys=[];for(let pitch=127;pitch>=PITCH_START;pitch--){const isBlack=[1,3,6,8,10].includes(pitch%12);const notesArray=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;const label=`${notesArray[pitch%12]}${octave}`;const showLabel=pitch%12===0;keys.push(/*#__PURE__*/React.createElement("div",{key:pitch,style:{height:`${NoteHeight}px`},className:`w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${activeMidiPitches&&activeMidiPitches.has(pitch)?'bg-emerald-500 text-white border-emerald-400':isBlack?'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800':'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,onMouseDown:e=>{e.stopPropagation();keybedMouseDownRef.current=true;try{// Đồng bộ mastering + routing SF NGAY trước khi preview — âm +const candidateIdxs=findCCNoteIndicesAtBeat(beat);const unpainted=candidateIdxs.filter(ci=>!painted.includes(ci));if(unpainted.length>0){const updatedNotes=notes.map((n,i)=>{if(!unpainted.includes(i))return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);drag.lastPainted=[...painted,...unpainted];}};const renderKeybed=()=>{var kbTrk=activeTracks.find(function(t){return t.id===st.trackId;});var kbCtx=resolveTrackInstrumentCtx(kbTrk,activeTracks);ensureSonicInstrument(kbCtx);// V45.20 Bug 6: renderKeybed CHI chay trong PianoRollTabEditor (st.type === 'PIANO_ROLL' +// -> luon la PR tab) nen _kbSilent luon false. Bo refs ngoai scope (subTabsRef/ +// activeTabRef) gay ReferenceError render crash -> piano roll trang. NOTE_OFF +// van gui (vo hai) nhu V45.19. +const _kbSilent=false;const keys=[];for(let pitch=127;pitch>=PITCH_START;pitch--){const isBlack=[1,3,6,8,10].includes(pitch%12);const notesArray=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;const label=`${notesArray[pitch%12]}${octave}`;const showLabel=pitch%12===0;keys.push(/*#__PURE__*/React.createElement("div",{key:pitch,style:{height:`${NoteHeight}px`},className:`w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${activeMidiPitches&&activeMidiPitches.has(pitch)?'bg-emerald-500 text-white border-emerald-400':isBlack?'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800':'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,onMouseDown:e=>{e.stopPropagation();keybedMouseDownRef.current=true;try{// Đồng bộ mastering + routing SF NGAY trước khi preview — âm // keybed PHẢI qua mastering FX của main out khi chain đang bật // (trước đây phải bật/tắt power mới có tác dụng). -try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}const kbNative=isStandaloneSf()&&isSfTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());const kbNativeVst=isStandaloneSf()&&isVstTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if(kbNative){playNativeSfNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}else if(kbNativeVst){playNativeVstNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(kbTrk?kbTrk.id:0,kbCtx.program,kbCtx.synthEngine);}catch(er){}window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:100/127,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&!kbNative&&!kbNativeVst){// ⚠️ FIX: giữ note theo thời gian bấm phím — durationMs lớn (5s) +try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}// DIAG Bug 2 (keybed ARM khong am): routing + ARM state tai note-on. +try{let _pdDest='none';if(window.AudioRoutingEngine&&window.AudioRoutingEngine._connected){_pdDest=window.AudioRoutingEngine._dest&&window.AudioRoutingEngine._dest.constructor?window.AudioRoutingEngine._dest.constructor.name:'null';_pdDest+='/'+window.AudioRoutingEngine._trackId;}else{_pdDest='NOT_CONNECTED';}const _pdD=window.BridgeAudioNode?window.BridgeAudioNode.getDiag():null;const _pdTab=activeTabRef.current;const _pdSub=subTabsRef.current?(subTabsRef.current.find(s=>s.id===_pdTab)||{}).type:null;const _kbArmedTab=!!(subTabsRef.current||[]).some(function(s){return s.type==='PIANO_ROLL'&&s.isArmed&&s.trackId===st.trackId;});_dbgLog('preview_diag','src=keybed trk='+st.trackId+' kbTrk='+(kbTrk?kbTrk.id:'UNDEF')+' kbArmedTab='+(_kbArmedTab?1:0)+' armedTrk='+(kbTrk&&kbTrk.isArmed?1:0)+' sl='+(_kbSilent?1:0)+' tab='+_pdTab+' sub='+(_pdSub||'none')+' play='+(isPlayingRef.current?1:0)+' br='+(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()?1:0)+' fxRt='+(fxRt.active?1:0)+' dest='+_pdDest+' bd='+(_pdD?'i='+_pdD.initialized+' wl='+_pdD.useWorklet+' nd='+_pdD.hasWorkletNode+' pre='+_pdD.preBufferLen+' ch='+_pdD.chunksLen+' g='+_pdD.gain+' ws='+(_pdD.ws?_pdD.ws.overruns+'/'+_pdD.ws.underruns:'-'):'MISSING')+' mb='+!!masterBus);}catch(e){}// V45.19 Bug 2: _kbSilent -> bo qua phat (MAIN/SECTION + ARM off = cam). +if(!_kbSilent){try{window.__dbgPreviewAudioArmed={count:0,nz:0,t0:performance.now()};}catch(e){}if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}const kbNative=isStandaloneSf()&&isSfTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());const kbNativeVst=isStandaloneSf()&&isVstTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if(kbNative){playNativeSfNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}else if(kbNativeVst){playNativeVstNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(kbTrk?kbTrk.id:0,kbCtx.program,kbCtx.synthEngine);}catch(er){}window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:100/127,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&!kbNative&&!kbNativeVst){// ⚠️ FIX: giữ note theo thời gian bấm phím — durationMs lớn (5s) // chỉ là auto-off phòng hờ; mouseup/mouseleave gọi stopNote dừng // NGAY (trước đây 500ms → note tự tắt giữa chừng khi giữ phím). -window.SonicSF.playNote(pitch,100,5000,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}},onMouseEnter:e=>{if(keybedMouseDownRef.current){try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(isStandaloneSf()&&isSfTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}else if(isStandaloneSf()&&isVstTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(kbTrk?kbTrk.id:0,kbCtx.program,kbCtx.synthEngine);}catch(er){}window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:100/127,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&!(isStandaloneSf()&&(isSfTrackEngine(kbCtx.synthEngine)||isVstTrackEngine(kbCtx.synthEngine)))){// giữ note khi kéo qua phím (mouse enter) — dừng bằng mouseup/leave +window.SonicSF.playNote(pitch,100,5000,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}}catch(err){console.error('playNote error:',err);}},onMouseEnter:e=>{if(keybedMouseDownRef.current&&!_kbSilent){try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(isStandaloneSf()&&isSfTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeSfNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}else if(isStandaloneSf()&&isVstTrackEngine(kbCtx.synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(kbTrk,pitch,100/127,5000,undefined,'kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(kbTrk?kbTrk.id:0,kbCtx.program,kbCtx.synthEngine);}catch(er){}window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:100/127,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&!(isStandaloneSf()&&(isSfTrackEngine(kbCtx.synthEngine)||isVstTrackEngine(kbCtx.synthEngine)))){// giữ note khi kéo qua phím (mouse enter) — dừng bằng mouseup/leave window.SonicSF.playNote(pitch,100,5000,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}}},onMouseUp:()=>{keybedMouseDownRef.current=false;// Dừng note khi thả phím — tránh kẹt âm (loop liên tục) với soundfont try{if(isStandaloneSf()){stopNativeSfNote('kb_'+st.trackId+'_'+pitch);stopNativeVstNote('kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:0,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&window.SonicSF.stopNote)window.SonicSF.stopNote(kbCtx.ch,pitch);}catch(e){}},onMouseLeave:()=>{if(!keybedMouseDownRef.current)return;// Kéo chuột ra khỏi phím → dừng note của phím đó try{if(isStandaloneSf()){stopNativeSfNote('kb_'+st.trackId+'_'+pitch);stopNativeVstNote('kb_'+st.trackId+'_'+pitch);}if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:kbTrk?kbTrk.id:0,pitch:pitch,velocity:0,percussion:!!(kbCtx&&kbCtx.synthEngine&&kbCtx.synthEngine.soundfont_bank===128),live:true});}else if(window.SonicSF&&window.SonicSF.stopNote)window.SonicSF.stopNote(kbCtx.ch,pitch);}catch(e){}}},showLabel&&label));}return keys;};const handleScroll=e=>{if(keybedRef.current){keybedRef.current.scrollTop=e.currentTarget.scrollTop;}if(rulerScrollRef.current){rulerScrollRef.current.scrollLeft=e.currentTarget.scrollLeft;}if(ccWrapperRef.current){ccWrapperRef.current.scrollLeft=e.currentTarget.scrollLeft;}const el=e.currentTarget;const THRESHOLD=200;if(el.scrollLeft+el.clientWidth>=el.scrollWidth-THRESHOLD){const newBeats=rollBeatsRef.current+16;setRollBeats(newBeats);}};const handleKeybedScroll=e=>{if(gridScrollRef.current){gridScrollRef.current.scrollTop=e.currentTarget.scrollTop;}};const SCALES={"None":null,"Diatonic":{"Major":[0,2,4,5,7,9,11],"Minor":[0,2,3,5,7,8,10],"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10],"Locrian":[0,1,3,5,6,8,10]},"Pentatonic":{"Major":[0,2,4,7,9],"Minor":[0,3,5,7,10],"Blues":[0,3,5,6,7,10],"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"India":[0,2,5,7,9],"Japan":[0,2,5,7,9],"Africa":[0,3,5,7,10]},"Church":{"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10]},"Jazz":{"Blues":[0,3,5,6,7,10],"Bebop":[0,2,4,5,7,9,10,11],"Diminished":[0,2,3,5,6,8,9,11]},"Asian Traditional":{"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"Japan":[0,2,5,7,9],"India":[0,2,5,7,9],"Gamelan":[0,2,4,7,9],"Korea":[0,2,4,5,7,9]},"Middle Eastern":{"Hijaz":[0,1,4,5,7,8,11],"Nikriz":[0,1,4,5,7,8,10],"Rast":[0,2,4,5,7,8,10],"Saba":[0,1,3,4,7,8,10],"Bayati":[0,2,3,4,7,8,10]}};const[selectedScale,setSelectedScale]=React.useState(null);const selectedScaleRef=React.useRef(null);selectedScaleRef.current=selectedScale;// Root note (0-11 — C..B) — transpose scale highlight + snap (spec 20:12) @@ -1085,7 +1122,7 @@ const MEDIA_LIBRARY_SAMPLES=[{name:"MIDI_Loop_01.mid",events:95,lengthQn:16,time React.useEffect(()=>{window.__currentZoom=zoom;},[zoom]);const[scrollOffset,setScrollOffset]=React.useState(0);const scrollOffsetRef=React.useRef(0);scrollOffsetRef.current=scrollOffset;const[selStart,setSelStart]=React.useState(null);const[selEnd,setSelEnd]=React.useState(null);const[isDragging,setIsDragging]=React.useState(false);const[previewCtxMenu,setPreviewCtxMenu]=React.useState(null);// { x, y } const containerRef=React.useRef(null);// Refs mirror latest state so drawCanvas (also called from rAF clock with a // stale closure) always draws the currently selected file, not the old one. -const selectedRef=React.useRef(null);const peaksRef=React.useRef(null);const audioBufferRef=React.useRef(null);const audioDurationRef=React.useRef(0);const midiNotesRef=React.useRef(null);const midiTotalRef=React.useRef(4);const midiBarsRef=React.useRef(1);const midiTotalBeatsRef=React.useRef(16);const midiFileBpmRef=React.useRef(120);const isPlayingRef=React.useRef(false);const isPausedRef=React.useRef(false);const folderRef=React.useRef('library');const tempoRef=React.useRef(120);const currentTimeRef=React.useRef(0);const selStartRef=React.useRef(null);const selEndRef=React.useRef(null);const isLoopingRef=React.useRef(false);selectedRef.current=selected;peaksRef.current=peaks;audioBufferRef.current=audioBuffer;audioDurationRef.current=audioDuration;midiNotesRef.current=midiNotes;midiTotalRef.current=midiTotal;midiBarsRef.current=midiBars;midiTotalBeatsRef.current=midiTotalBeats;midiFileBpmRef.current=midiFileBpm;isPlayingRef.current=isPlaying;isPausedRef.current=isPaused;folderRef.current=folder;tempoRef.current=tempo;currentTimeRef.current=currentTime;selStartRef.current=selStart;selEndRef.current=selEnd;isLoopingRef.current=isLooping;const computerPathRef=React.useRef(null);const computerTreeRef=React.useRef({});const computerRootsRef=React.useRef(null);const treePaneRef=React.useRef(null);const browseComputerDirRef=React.useRef(null);React.useEffect(()=>{setScrollOffset(0);},[selected]);// Keep the tempo text field in sync when tempo changes from elsewhere +const selectedRef=React.useRef(null);const peaksRef=React.useRef(null);const audioBufferRef=React.useRef(null);const audioDurationRef=React.useRef(0);const midiNotesRef=React.useRef(null);const midiTotalRef=React.useRef(4);const midiBarsRef=React.useRef(1);const midiTotalBeatsRef=React.useRef(16);const midiFileBpmRef=React.useRef(120);const isPlayingRef=React.useRef(false);const isPausedRef=React.useRef(false);const folderRef=React.useRef('library');const tempoRef=React.useRef(120);const currentTimeRef=React.useRef(0);const selStartRef=React.useRef(null);const selEndRef=React.useRef(null);const isLoopingRef=React.useRef(false);selectedRef.current=selected;peaksRef.current=peaks;audioBufferRef.current=audioBuffer;audioDurationRef.current=audioDuration;midiNotesRef.current=midiNotes;midiTotalRef.current=midiTotal;midiBarsRef.current=midiBars;midiTotalBeatsRef.current=midiTotalBeats;midiFileBpmRef.current=midiFileBpm;isPlayingRef.current=isPlaying;_timelinePlaying=isPlaying;isPausedRef.current=isPaused;folderRef.current=folder;tempoRef.current=tempo;currentTimeRef.current=currentTime;selStartRef.current=selStart;selEndRef.current=selEnd;isLoopingRef.current=isLooping;const computerPathRef=React.useRef(null);const computerTreeRef=React.useRef({});const computerRootsRef=React.useRef(null);const treePaneRef=React.useRef(null);const browseComputerDirRef=React.useRef(null);React.useEffect(()=>{setScrollOffset(0);},[selected]);// Keep the tempo text field in sync when tempo changes from elsewhere // (e.g. auto-set from a clicked MIDI file). React.useEffect(()=>{setTempoText(String(tempo));},[tempo]);React.useEffect(()=>{window.mediaExplorerActive=true;const handleDocumentClick=e=>{if(containerRef.current&&containerRef.current.contains(e.target)){window.mediaExplorerActive=true;}else{window.mediaExplorerActive=false;}};document.addEventListener('mousedown',handleDocumentClick,{capture:true});return()=>{document.removeEventListener('mousedown',handleDocumentClick,{capture:true});window.mediaExplorerActive=false;};},[]);const getCanvasLayout=()=>{const canvas=canvasRef.current;if(!canvas)return{w:300,h:100,pxPerBeat:42,pxPerSec:84,offset:0,dur:0,contentW:0};const w=canvas.clientWidth;const h=canvas.clientHeight;const f=selectedRef.current;const isMidi=isMidiFile(f);const curTempo=tempoRef.current||120;const pxPerBeat=42*zoom;const pxPerSec=pxPerBeat*curTempo/60;const dur=fileDuration(f);let contentW=0;if(isMidi){const isRealMidi=midiNotesRef.current&&midiNotesRef.current.length&&midiTotalBeatsRef.current>0;const totalBeats=isRealMidi?Math.max(midiTotalBeatsRef.current,4):Math.max(4,Math.ceil(dur*curTempo/60)||16);contentW=Math.max(1,totalBeats*pxPerBeat);}else{contentW=Math.max(1,dur*pxPerSec);}let offset=scrollOffset;if(isPlayingRef.current&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,currentTimeRef.current*pxPerSec-w/2));}else{offset=Math.max(0,Math.min(contentW-w,scrollOffset));}return{w,h,pxPerBeat,pxPerSec,offset,dur,contentW};};const getSelectedAudioBuffer=async()=>{if(audioBufferRef.current)return audioBufferRef.current;if(!selectedRef.current)return null;const buf=await readLocalFileBuffer(selectedRef.current);if(!buf)return null;try{const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);setAudioBuffer(decoded);return decoded;}catch(e){console.error(e);return null;}};const handleCanvasWheel=e=>{if(!selected)return;e.preventDefault();if(e.shiftKey){const scrollSpeed=45;const direction=e.deltaY>0?1:-1;setScrollOffset(prev=>{const{contentW,w}=getCanvasLayout();const maxScroll=Math.max(0,contentW-w);return Math.max(0,Math.min(maxScroll,prev+direction*scrollSpeed));});return;}const zoomFactor=1.15;if(e.deltaY<0){setZoom(prev=>Math.min(10.0,prev*zoomFactor));}else{setZoom(prev=>Math.max(0.2,prev/zoomFactor));}};// React attaches onWheel passively at the root, so e.preventDefault() there is // ignored and Chrome logs "Unable to preventDefault inside passive event listener". @@ -1232,7 +1269,7 @@ const[selectedTrackId,setSelectedTrackId]=useState('1');const[selectedItemIds,se // delayed) check ref này trước khi fire: stop → isPlaying=false → setTimeout // sót không fire → VU không nhảy sau khi âm đã dừng (user: VU vẫn animation // khi hết âm). -const isPlayingRef=useRef(false);isPlayingRef.current=isPlaying;// Bounce realtime = project đang phát thật; guardPlay (scheduleMidiNoteDispatch) +const isPlayingRef=useRef(false);isPlayingRef.current=isPlaying;_timelinePlaying=isPlaying;// Bounce realtime = project đang phát thật; guardPlay (scheduleMidiNoteDispatch) // phải cho note fire dù isPlayingRef bị render ghi đè =false khi bounce setState. // (Không sync từ state ở đây — isExporting khai báo sau; bounce set true/false // tường minh ở triggerBounceExport/finally.) @@ -1255,7 +1292,9 @@ useEffect(()=>{if(selectionStart===null||selectionEnd===null||selectionEnd<=sele // (8 − 0 = 8 — KHÔNG cộng 1). const _b2=Math.ceil((selectionEnd||0)/_secPerBar);if(_b2>_b1){setBeginBar(_b1);setEndBar(_b2);setNumberBar(_b2-_b1);}},[selectionStart,selectionEnd,bpm]);const[subTabHeight,setSubTabHeight]=useState(96);const[isExporting,setIsExporting]=useState(false);const[exportProgress,setExportProgress]=useState(-1);const[projectName,setProjectName]=useState(()=>localStorage.getItem('sonic_project_name')||'');const[currentProjectId,setCurrentProjectId]=useState(()=>localStorage.getItem('sonic_project_id')||null);const[saveProjectModalOpen,setSaveProjectModalOpen]=useState(false);const[saveAsModalOpen,setSaveAsModalOpen]=useState(false);const[openProjectModalOpen,setOpenProjectModalOpen]=useState(false);const hasAnySolo=tracks.some(t=>t.solo);const[toastMessage,setToastMessage]=useState(null);const[audioDevices,setAudioDevices]=useState([]);const[midiDevices,setMidiDevices]=useState([]);const[selectedMidiInputId,setSelectedMidiInputId]=useState('');const selectedMidiInputIdRef=useRef('');const handleMidiInputSelect=id=>{setSelectedMidiInputId(id);selectedMidiInputIdRef.current=id;if(window.SonicRecorderManager){window.SonicRecorderManager.setSelectedMidiInputId(id);}};useEffect(()=>{if(navigator.mediaDevices&&navigator.mediaDevices.enumerateDevices){navigator.mediaDevices.enumerateDevices().then(devices=>{setAudioDevices(devices.filter(d=>d.kind==='audioinput'));}).catch(err=>console.log('Enumerate audio devices error:',err));}if(navigator.requestMIDIAccess){navigator.requestMIDIAccess().then(access=>{const inputs=[];function attachMidiHandler(input){input.onmidimessage=msg=>{// Filter by selected MIDI input device const selId=selectedMidiInputIdRef.current;if(selId&&selId!=='ALL'&&input.id!==selId)return;console.log(`[DevLog] [Raw MIDI] Received from "${input.name}" (ID: ${input.id}) - Data:`,Array.from(msg.data));if(msg.data.length<3)return;const cmd=msg.data[0]>>4;const pitch=msg.data[1];const rawVel=msg.data[2];// Scale low MIDI velocity naturally (min 1, max 127) -const scaledVel=Math.min(127,Math.max(1,Math.round(rawVel)));if(cmd===0x9&&rawVel>0){lastMidiNoteRef.current={pitch,velocity:scaledVel,startTime:performance.now(),length:0};setLastMidiNote({pitch,velocity:scaledVel,length:0,time:Date.now()});activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));// MIDI Keyboard → Mastering FX Chain (main out): đồng bộ routing +const scaledVel=Math.min(127,Math.max(1,Math.round(rawVel)));// V45.21: ARM piano roll chi hieu luc khi dang o PIANO_ROLL tab +// (giong vkbNoteOn V45.19) — quay lai MAIN/SESSION khong leak preview. +const _prTabNow=(subTabsRef.current||[]).find(function(s){return s.id===activeTabRef.current;});const _isPrTabNow=!!(_prTabNow&&_prTabNow.type==='PIANO_ROLL');if(cmd===0x9&&rawVel>0){lastMidiNoteRef.current={pitch,velocity:scaledVel,startTime:performance.now(),length:0};setLastMidiNote({pitch,velocity:scaledVel,length:0,time:Date.now()});activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));// MIDI Keyboard → Mastering FX Chain (main out): đồng bộ routing // mastering + SF NGAY trước khi phát — âm MIDI keyboard hardware // PHẢI qua mastering FX khi chain đang bật (giống keybed; trước // đây chỉ keybed ảo gọi __ensureMasteringRouting). @@ -1263,11 +1302,20 @@ try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch( // Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F) if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){// D1: bridge active -> 1 cổng router (per-track channel alloc) → // Rust SHM → C++ bridge; giữ SonicSF fallback ở nhánh else. -var bTracks=activeTracksRef.current||[];bTracks.forEach(function(bt){if(!bt.isArmed)return;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(bt.id,scaledVel);}try{heldMidiNotesRef.current[bt.id]=(heldMidiNotesRef.current[bt.id]||0)+1;}catch(err){}try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:bt.id,pitch:pitch,velocity:scaledVel/127,percussion:!!(bt.synth_engine&&bt.synth_engine.soundfont_bank===128),live:true});}catch(e){}});}else if(window.SonicSF){var allTracks=activeTracksRef.current||[];var arSubs=subTabsRef&&subTabsRef.current?subTabsRef.current.filter(function(s){return s.type==='PIANO_ROLL'&&s.isArmed;}):[];var armedTracks=allTracks.filter(function(t){return t.isArmed;});// Piano Roll arming has priority: route to each armed sub-tab's parent track -if(arSubs.length>0){arSubs.forEach(function(as){var asTrk=allTracks.find(function(t){return t.id===as.trackId;});var asCh=asTrk?assignTrackMidiChannel(asTrk,allTracks):0;var asProg=as.instrumentProgram;var asSe=as.synth_engine;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(as.trackId,scaledVel);}// Giữ VU theo trường độ: tăng counter note đang giữ +var bTracks=activeTracksRef.current||[];// ARM piano roll (sub-tab isArmed, cuc bo) cung tinh nhu ARM +// track - preview note tu piano roll tab phai toi track dang +// edit du track main KHONG ARM (ARM piano roll khong truyen). +var bPrArmed={};if(_isPrTabNow){try{// V45.27: chi sub-tab PR dang active VA co ARM (isArmed); khong gom ARM sub-tab khac / MAIN. +var _actPrSub=(subTabsRef.current||[]).find(function(s){return s.id===activeTabRef.current;});if(_actPrSub&&_actPrSub.type==='PIANO_ROLL'&&_actPrSub.isArmed&&_actPrSub.trackId)bPrArmed[_actPrSub.trackId]=true;}catch(e){}}bTracks.forEach(function(bt){if(_isPrTabNow?!bPrArmed[bt.id]:!bt.isArmed)return;// V45.24: PR preview doc lap voi mute/solo MAIN/SECTION — +// khoi phuc volume track (bo qua mute/solo) khi o PR tab. +if(_isPrTabNow){try{ensureTrackPrAudible(bt,bTracks,activeTrackNodesRef.current[bt.id]);}catch(e){}}if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(bt.id,scaledVel);}try{heldMidiNotesRef.current[bt.id]=(heldMidiNotesRef.current[bt.id]||0)+1;}catch(err){}try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:bt.id,pitch:pitch,velocity:scaledVel/127,percussion:!!(bt.synth_engine&&bt.synth_engine.soundfont_bank===128),live:true});}catch(e){}});}else if(window.SonicSF){var allTracks=activeTracksRef.current||[];var arSubs=[];// V45.27: chi sub-tab PR dang active VA co ARM (isArmed); khong gom ARM sub-tab khac / MAIN. +if(_isPrTabNow&&subTabsRef&&subTabsRef.current){var _actPrSub=subTabsRef.current.find(function(s){return s.id===activeTabRef.current;});if(_actPrSub&&_actPrSub.type==='PIANO_ROLL'&&_actPrSub.isArmed&&_actPrSub.trackId)arSubs.push(_actPrSub);}var armedTracks=_isPrTabNow?[]:allTracks.filter(function(t){return t.isArmed;});// Piano Roll arming has priority: route to each armed sub-tab's parent track +if(arSubs.length>0){arSubs.forEach(function(as){var asTrk=allTracks.find(function(t){return t.id===as.trackId;});// V45.24: PR preview doc lap voi mute/solo MAIN/SECTION. +if(_isPrTabNow&&asTrk){try{ensureTrackPrAudible(asTrk,allTracks,activeTrackNodesRef.current[as.trackId]);}catch(e){}}var asCh=asTrk?assignTrackMidiChannel(asTrk,allTracks):0;var asProg=as.instrumentProgram;var asSe=as.synth_engine;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(as.trackId,scaledVel);}// Giữ VU theo trường độ: tăng counter note đang giữ // (tick giữ peak cho tới khi note-off) try{heldMidiNotesRef.current[as.trackId]=(heldMidiNotesRef.current[as.trackId]||0)+1;}catch(err){}if(isStandaloneSf()&&isSfTrackEngine(asSe)){playNativeSfNote(asTrk,pitch,scaledVel/127,60000,undefined,'midi_'+as.trackId+'_'+pitch);}else if(isStandaloneSf()&&isVstTrackEngine(asSe)){playNativeVstNote(asTrk,pitch,scaledVel/127,60000,undefined,'midi_'+as.trackId+'_'+pitch);}else{window.SonicSF.playNote(pitch,scaledVel,60000,undefined,asProg,null,asCh,asSe);}});}// Route to ALL armed tracks (not just the first one) -armedTracks.forEach(function(at){var atCh=assignTrackMidiChannel(at,allTracks);var atProg=at.instrumentProgram;var atSe=at.synth_engine;var atDest=activeTrackNodesRef.current[at.id]?.gainNode||null;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(at.id,scaledVel);}// Giữ VU theo trường độ: tăng counter note đang giữ +armedTracks.forEach(function(at){// V45.24: PR preview doc lap voi mute/solo MAIN/SECTION. +if(_isPrTabNow){try{ensureTrackPrAudible(at,allTracks,activeTrackNodesRef.current[at.id]);}catch(e){}}var atCh=assignTrackMidiChannel(at,allTracks);var atProg=at.instrumentProgram;var atSe=at.synth_engine;var atDest=activeTrackNodesRef.current[at.id]?.gainNode||null;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(at.id,scaledVel);}// Giữ VU theo trường độ: tăng counter note đang giữ try{heldMidiNotesRef.current[at.id]=(heldMidiNotesRef.current[at.id]||0)+1;}catch(err){}if(isStandaloneSf()&&isSfTrackEngine(atSe)){playNativeSfNote(at,pitch,scaledVel/127,60000,undefined,'midi_'+at.id+'_'+pitch);}else if(isStandaloneSf()&&isVstTrackEngine(atSe)){playNativeVstNote(at,pitch,scaledVel/127,60000,undefined,'midi_'+at.id+'_'+pitch);}else{window.SonicSF.playNote(pitch,scaledVel,60000,undefined,atProg,atDest,atCh,atSe);}});}}else if(cmd===0x8||cmd===0x9&&rawVel===0){activeMidiPitchesRef.current.delete(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));const current=lastMidiNoteRef.current;if(current&¤t.pitch===pitch){const lenSec=(performance.now()-current.startTime)/1000;lastMidiNoteRef.current={...current,length:lenSec};setLastMidiNote(prev=>prev&&prev.pitch===pitch?{...prev,length:lenSec,time:Date.now()}:prev);}// Stop the note on ALL tracks (not just armed) to prevent stuck notes // when ARM is toggled off while a key is held if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){// D1: bridge note-off qua router (channel trùng NOTE_ON — track.id). @@ -1282,7 +1330,9 @@ const cc=msg.data[1];const val=msg.data[2];if(window.SonicSF&&window.SonicSF.con const lsb=msg.data[1];const msb=msg.data[2];const bendVal=msb<<7|lsb;if(window.SonicSF&&window.SonicSF.pitchBend){var pbTracks=activeTracksRef.current||[];var hasArmedPB=pbTracks.some(function(t){return t.isArmed;});if(hasArmedPB){pbTracks.forEach(function(pt){if(!pt.isArmed)return;var ptCh=assignTrackMidiChannel(pt,pbTracks);window.SonicSF.pitchBend(ptCh,bendVal);});}else{window.SonicSF.pitchBend(midiCh,bendVal);}}}// Forward to active MIDI recorders if(activeMIDIRecordersRef.current){for(let trackId in activeMIDIRecordersRef.current){const rec=activeMIDIRecordersRef.current[trackId];if(rec){rec.handleMIDIMessage(msg,input.id);}}}};}for(let input of access.inputs.values()){inputs.push(input);attachMidiHandler(input);}setMidiDevices(inputs);access.onstatechange=()=>{const inputs=[];for(let input of access.inputs.values()){inputs.push(input);// Re-attach handler to ensure new devices get it if(!input.onmidimessage){attachMidiHandler(input);}}setMidiDevices(inputs);};}).catch(err=>console.log('MIDI access error:',err));}},[]);const[showAIConfig,setShowAIConfig]=useState(false);const[recordingState,setRecordingState]=useState('IDLE');// 'IDLE' | 'COUNT_IN' | 'RECORDING' -const[recTempMidiNotes,setRecTempMidiNotes]=useState([]);const[recTempAudioBuffer,setRecTempAudioBuffer]=useState(null);const[recStartTimelineTime,setRecStartTimelineTime]=useState(0);const[canvasRedrawCount,setCanvasRedrawCount]=useState(0);const[lastMidiNote,setLastMidiNote]=useState(null);const lastMidiNoteRef=useRef(null);const activeMIDIRecordersRef=useRef({});const activeAudioRecordersRef=useRef({});const pianoRollRecorderRef=useRef(null);const activeMidiPitchesRef=useRef(new Set());const[activeMidiPitches,setActiveMidiPitches]=useState(new Set());const recordingPCMDataRef=useRef({});const recordingStartTimeRef=useRef(0);const recordingSyncRef=useRef(null);const recordingStateRef=useRef(recordingState);recordingStateRef.current=recordingState;const lastTempCompileTimeRef=useRef(0);const nextMetronomeBeatRef=useRef(0);const[showExportPanel,setShowExportPanel]=useState(false);const[showExportProgress,setShowExportProgress]=useState(false);// modal tiến trình export RIÊNG +const[recTempMidiNotes,setRecTempMidiNotes]=useState([]);const[recTempAudioBuffer,setRecTempAudioBuffer]=useState(null);const[recStartTimelineTime,setRecStartTimelineTime]=useState(0);const[canvasRedrawCount,setCanvasRedrawCount]=useState(0);const[lastMidiNote,setLastMidiNote]=useState(null);const lastMidiNoteRef=useRef(null);const activeMIDIRecordersRef=useRef({});const activeAudioRecordersRef=useRef({});const pianoRollRecorderRef=useRef(null);const activeMidiPitchesRef=useRef(new Set());const timelinePitchesRef=useRef({});// D18c: per-track Set timeline MIDI pitches dang keu (NOTE_ON fired) +const tracksRef=useRef([]);// D18c: tracks state moi nhat (guard mute/solo fire-time) +const[activeMidiPitches,setActiveMidiPitches]=useState(new Set());const recordingPCMDataRef=useRef({});const recordingStartTimeRef=useRef(0);const recordingSyncRef=useRef(null);const recordingStateRef=useRef(recordingState);recordingStateRef.current=recordingState;const lastTempCompileTimeRef=useRef(0);const nextMetronomeBeatRef=useRef(0);const[showExportPanel,setShowExportPanel]=useState(false);const[showExportProgress,setShowExportProgress]=useState(false);// modal tiến trình export RIÊNG React.useEffect(()=>{if(showExportPanel)setTimeout(()=>{try{window.lucide&&window.lucide.createIcons();}catch(e){}},80);},[showExportPanel]);// ── Virtual MIDI Keyboard (Phase 5) ───────────────────────────────────── const[vkbOpen,setVkbOpen]=useState(false);const vkbOpenRef=useRef(false);vkbOpenRef.current=vkbOpen;const[chordsPanelOpen,setChordsPanelOpen]=useState(false);const vkbHeldRef=useRef(new Set());const[vkbSettings,setVkbSettings]=useState(function(){try{const saved=JSON.parse(localStorage.getItem('sonic_vkb_settings_v1')||'null');if(saved&&typeof saved==='object')return Object.assign({x:40,y:120,octave:4,velocity:100,channel:null,transpose:0,scale:'None'},saved);}catch(e){}return{x:40,y:120,octave:4,velocity:100,channel:null,transpose:0,scale:'None'};}());const vkbSettingsRef=useRef(vkbSettings);vkbSettingsRef.current=vkbSettings;const vkbNoteOnRef=useRef(null);const vkbNoteOffRef=useRef(null);const vkbUpdateSettings=patch=>{setVkbSettings(prev=>{const next=Object.assign({},prev,patch);try{localStorage.setItem('sonic_vkb_settings_v1',JSON.stringify(next));}catch(e){}return next;});};// Global keydown: F2 toggle + QWERTY notes khi VKB mở. capture:true + đăng ký // TRƯỚC handler transport (~19402) → stopPropagation chặn phím tắt trùng @@ -1307,13 +1357,29 @@ window.__setTrackMasteringBypass=function(trackId,bypass){trackAudioBypassMap[tr // track node in the current context. Called from the M/S buttons of the // mixer strips + track strips so toggling muting/soloing affects the items // already playing on that track immediately (20ms crossfade, no click). -const applyAllTrackMuteSolo=(trackId,patch)=>{const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const effective=trackId&&patch?list.map(t=>t.id===trackId?{...t,...patch}:t):list;let becameAudible=false;effective.forEach(t=>{const audible=computeTrackAudibleGain(effective,t)>0;const wasAudible=trackAudibleRef.current[t.id]!==false;const node=activeTrackNodesRef.current[t.id];const audibleGain=computeTrackAudibleGain(effective,t);if(node)setTrackNodeGain(node,audibleGain);// Section sub-nodes (key: _sub_) — mute/solo của +const applyAllTrackMuteSolo=(trackId,patch)=>{const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const effective=trackId&&patch?list.map(t=>t.id===trackId?{...t,...patch}:t):list;// V45.3: LUON ghi ref voi state da patch (mute/solo). Truoc day chi ghi trong +// nhanh becameAudible roi restore STALE -> solo-out khong co track became +// audible -> D18b guard (25423) + scheduler (25498) + toggleTrackSoloEvaluate +// (setTimeout 60ms restart) thay trang thai PRE-solo -> Qin note van fire +// (tremolo). Render ke tiep tu dong dong bo lai ref. +activeTracksRef.current=effective;try{if(window.__dbgLog)window.__dbgLog('solo_apply','track='+trackId+' patch='+(patch?JSON.stringify(patch):'-')+' n='+effective.length+' solo=['+effective.filter(function(t){return t.solo;}).map(function(t){return t.id;}).join(',')+'] muted=['+effective.filter(function(t){return t.muted;}).map(function(t){return t.id;}).join(',')+']');}catch(e){}let becameAudible=false;effective.forEach(t=>{const audible=computeTrackAudibleGain(effective,t)>0;const wasAudible=trackAudibleRef.current[t.id]!==false;const node=activeTrackNodesRef.current[t.id];const audibleGain=computeTrackAudibleGain(effective,t);// V45.24: PR tab doc lap voi mute/solo MAIN/SECTION — track dang co +// sub-tab PIANO_ROLL PLAY thi KHONG ap mute/solo realtime (gain=0, +// CC7=0, kill note) de PR khong bi cam; mute/solo chi anh huong +// MAIN/SECTION playback. +const prPlayingForTrack=!!(subTabsRef.current||[]).some(function(s){return s.type==='PIANO_ROLL'&&s.trackId===t.id&&s.isPlaying;});const _volDb=Number(t.volumeDb);const _volLin=isNaN(_volDb)||!isFinite(_volDb)?1.0:_volDb<=-50?0:Math.pow(10,_volDb/20);const effGain=prPlayingForTrack?_volLin:audibleGain;if(node)setTrackNodeGain(node,effGain);// Section sub-nodes (key: _sub_) — mute/solo của // SECTION-TAB phải áp gain cho node play section — CHỈ trong section // (activeTracksRef = tracks section), KHÔNG đụng track MAIN. -Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k.endsWith('_sub_'+t.id)){const sn=activeTrackNodesRef.current[k];if(sn&&sn.gainNode)setTrackNodeGain(sn,audibleGain);}});if(node&&node.sfEntry){try{node.sfEntry.gain.setTargetAtTime(audibleGain,getAudioContext().currentTime,0.02);}catch(e){}}// MIDI/bridge tracks: CC7 (channel volume) realtime — even for notes +Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k.endsWith('_sub_'+t.id)){const sn=activeTrackNodesRef.current[k];if(sn&&sn.gainNode)setTrackNodeGain(sn,audibleGain);}});if(node&&node.sfEntry){try{node.sfEntry.gain.setTargetAtTime(effGain,getAudioContext().currentTime,0.02);}catch(e){}}// MIDI/bridge tracks: CC7 (channel volume) realtime — even for notes // already sounding. Bridge active → qua SonicMidiRouter (bridge); bridge // down → SonicSF (WASM). CC7 = VOLUME của track (không chỉ 100/0). -if(isMidiSoundTrack(t)){try{sendMidiVolumeCC7(t,effective,audible);}catch(e){}}if(audible&&!wasAudible)becameAudible=true;trackAudibleRef.current[t.id]=audible;});// A track that just became audible may have had its audio sources dropped +if(isMidiSoundTrack(t)){try{sendMidiVolumeCC7(t,effective,audible||prPlayingForTrack);}catch(e){}}// D18: track vừa MẤT tiếng (mute/solo) — dừng note đang giữ ngay. CC7=0 +// (sendMidiVolumeCC7) không ăn với VST như Qin (bỏ qua channel volume) +// → note kêu tiếp (tremolo) khi solo track khác. NOTE_OFF từng pitch đang +// giữ + CC123/CC120 all-notes-off trên channel track đó. +if(!audible&&wasAudible&&!prPlayingForTrack&&isMidiSoundTrack(t)){try{const _isPerc=!!(t.synth_engine&&t.synth_engine.soundfont_bank===128)||!!t.is_percussion;try{if(window.__dbgLog)window.__dbgLog('solo_kill','track='+t.id+' tset='+((timelinePitchesRef.current||{})[t.id]||new Set()).size+' held='+(activeMidiPitchesRef.current||new Set()).size);}catch(e){}const _held=activeMidiPitchesRef.current||new Set();// D18c: timeline MIDI notes (per-track) dang keu - NOTE_OFF tung pitch; +// truoc day chi kill preview keyboard (activeMidiPitchesRef) -> note +// timeline (Qin VST2) cu ngan/loop (tremolo) khi solo track khac. +const _tSet=(timelinePitchesRef.current||{})[t.id];if(_tSet&&_tSet.size){_tSet.forEach(function(_p){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:t.id,pitch:_p,velocity:0,percussion:_isPerc,live:true});});}_held.forEach(function(_p){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:t.id,pitch:_p,velocity:0,percussion:_isPerc,live:true});});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:t.id,pitch:123,velocity:0,data2:0,percussion:_isPerc});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:t.id,pitch:120,velocity:0,data2:0,percussion:_isPerc});if(heldMidiNotesRef.current)delete heldMidiNotesRef.current[t.id];if(timelinePitchesRef.current)delete timelinePitchesRef.current[t.id];}catch(e){}}if(audible&&!wasAudible)becameAudible=true;trackAudibleRef.current[t.id]=prPlayingForTrack?true:audible;});// A track that just became audible may have had its audio sources dropped // by a loop restart while it was inaudible (startTrackPlayback skips // muted/soloed tracks, so unmute alone cannot resurrect them). Re-schedule // from the current playhead — the same approach toggleTrackSoloEvaluate @@ -1324,7 +1390,8 @@ stopAllPlayback();const ctx=getAudioContext();const resumeAt=currentTimeRef.curr // startTrackPlayback (isPlayable) → câm + CC11 không chạy. Dùng // `effective` (đã patch mute/solo) làm nguồn restart; ref tự phục hồi // ở render kế tiếp (activeTracksRef.current = activeTracks). -const _prevActiveTracks=activeTracksRef.current;activeTracksRef.current=effective;try{startTrackPlayback(resumeAt);}finally{activeTracksRef.current=_prevActiveTracks;}}catch(e){console.warn('mute/solo resume error:',e);}}updateSfRouting();};window.__applyTrackMuteSolo=applyAllTrackMuteSolo;window.__toggleMediaExplorerRef=function(){setShowMediaExplorer(function(p){const next=!p;if(next)setShowMixerRef.current(false);return next;});};const[mixerHeight,setMixerHeight]=useState(function(){var saved=localStorage.getItem('studio_mixer_height');return saved?parseInt(saved):200;}());const[mediaExplorerPanelHeight,setMediaExplorerPanelHeight]=useState(function(){var saved=localStorage.getItem('studio_media_explorer_height');return saved?parseInt(saved):240;}());const[masterVolume,setMasterVolume]=useState(0);// dB (legacy single fader) +// V45.3: ref da = effective (dau ham) -> restart thay trang thai moi ngay. +startTrackPlayback(resumeAt);}catch(e){console.warn('mute/solo resume error:',e);}}updateSfRouting();};window.__applyTrackMuteSolo=applyAllTrackMuteSolo;window.__toggleMediaExplorerRef=function(){setShowMediaExplorer(function(p){const next=!p;if(next)setShowMixerRef.current(false);return next;});};const[mixerHeight,setMixerHeight]=useState(function(){var saved=localStorage.getItem('studio_mixer_height');return saved?parseInt(saved):200;}());const[mediaExplorerPanelHeight,setMediaExplorerPanelHeight]=useState(function(){var saved=localStorage.getItem('studio_media_explorer_height');return saved?parseInt(saved):240;}());const[masterVolume,setMasterVolume]=useState(0);// dB (legacy single fader) const[masterVolL,setMasterVolL]=useState(0);// dB - master L fader const[masterVolR,setMasterVolR]=useState(0);// dB - master R fader const[masterLink,setMasterLink]=useState(true);// LINK L/R @@ -1339,14 +1406,21 @@ useEffect(()=>{try{if(exportSettings.rememberExportDir&&exportSettings.exportDir const[stretchedClip,setStretchedClip]=useState(null);// { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } const[draggedSectionItem,setDraggedSectionItem]=useState(null);const[resizedSectionItem,setResizedSectionItem]=useState(null);const[editingTrackName,setEditingTrackName]=useState(null);// trackId being edited const[editingClipName,setEditingClipName]=useState(null);// { trackId, clipId } -const[editNameInput,setEditNameInput]=useState('');// ── Context Menu & Clipboard ── +const[editNameInput,setEditNameInput]=useState('');// D16: per-track loading ruler — ticker buộc re-render khi có track đang +// loading; poller queryStatus chuyển loading→done khi channel sẵn sàng +// (đúng cho cả C++ restore từ spawn lẫn JS load — không phụ thuộc thứ tự). +const[loadTick,setLoadTick]=useState(0);useEffect(()=>{window.__onTrackLoadState=()=>setLoadTick(t=>t+1);const iv=setInterval(async()=>{try{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return;const m=window.__trackLoadState||{};const loadingKeys=Object.keys(m).filter(k=>m[k].state==='loading');if(!loadingKeys.length)return;const st=await window.NativeBridgeService.queryStatus();const lc=st&&Array.isArray(st.loaded_channels)?st.loaded_channels:null;let changed=false;loadingKeys.forEach(k=>{const s=m[k];if(lc&&s.channel!=null&&lc.some(x=>x.channel===s.channel)){m[k]=Object.assign({},s,{state:'done',doneTs:Date.now()});changed=true;}else if(!s.startedTs||Date.now()-s.startedTs>90000){m[k]=Object.assign({},s,{state:'failed'});changed=true;}});if(changed)setLoadTick(t=>t+1);}catch(e){}},400);return()=>{delete window.__onTrackLoadState;clearInterval(iv);};},[]);// ── Context Menu & Clipboard ── const[contextMenu,setContextMenu]=useState(null);// { x, y, trackId } // Gợi ý động PIANO ROLL trên status bar (user 09:40) — piano roll set qua // window.__setPrHint (mouse in/out + Shift/Ctrl state) const[prHint,setPrHint]=useState(null);React.useEffect(()=>{window.__setPrHint=h=>setPrHint(h||null);return()=>{delete window.__setPrHint;};},[]);const clipboardRef=useRef(null);// { buffer, name, volume, color } for copy/paste // ── Undo/Redo Engine (LOOP_EDITOR.md §4 + Global Extension) ── -const[undoStack,setUndoStack]=useState([]);const[redoStack,setRedoStack]=useState([]);const MAX_UNDO=30;const pushAction=(actionType,trackId,beforeState,afterState)=>{const node={action_type:actionType,track_id:trackId,timestamp:Date.now(),before_state:beforeState,after_state:afterState};setUndoStack(prev=>{const next=[...prev,node];if(next.length>MAX_UNDO)next.shift();return next;});setRedoStack([]);};const handleUndo=()=>{if(window.UndoRedoEngine&&window.UndoRedoEngine.canUndo()){const entry=window.UndoRedoEngine.undo();if(entry){if(entry.undo&&typeof entry.undo==='function')entry.undo(entry);showToast(`Undo: ${entry.label||entry.type}`,'info');return;}}if(undoStack.length===0)return;const last=undoStack[undoStack.length-1];setUndoStack(prev=>prev.slice(0,-1));setRedoStack(prev=>[...prev,last]);applyTrackState(last.track_id,last.before_state);showToast(`Undo: ${last.action_type}`,'info');};const handleRedo=()=>{if(window.UndoRedoEngine&&window.UndoRedoEngine.canRedo()){const entry=window.UndoRedoEngine.redo();if(entry){if(entry.redo&&typeof entry.redo==='function')entry.redo(entry);showToast(`Redo: ${entry.label||entry.type}`,'info');return;}}if(redoStack.length===0)return;const last=redoStack[redoStack.length-1];setRedoStack(prev=>prev.slice(0,-1));setUndoStack(prev=>[...prev,last]);applyTrackState(last.track_id,last.after_state);showToast(`Redo: ${last.action_type}`,'info');};const applyTrackState=(trackId,state)=>{if(trackId==='ALL_TRACKS'){state.forEach(entry=>{applyTrackState(entry.trackId,entry.state);});return;}updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;var updated={...t,...state};if(state.sections!==undefined){updated.sections=state.sections;}if(state.midiItems!==undefined){updated.midiItems=state.midiItems;}if(state.clips!==undefined){updated.clips=state.clips;}return updated;}));};const getSelectedMidiItemInfo=()=>{if(!selectedItemIds||selectedItemIds.size!==1)return null;const selId=selectedItemIds.values().next().value;const tlist=activeTracks||tracks||[];for(const t of tlist){const found=(t.midiItems||[]).find(m=>m.id===selId);if(found)return{itemName:found.name,trackName:t.name,trackId:t.id,itemId:selId,notes:found.notes||[],startTime:found.startTime||0,duration:found.duration||4};}return null;};const captureTrackSnapshot=trackId=>{const track=activeTracks.find(t=>t.id===trackId);if(!track)return null;return{volumeDb:track.volumeDb,pan:track.pan,muted:track.muted,name:track.name,markers:JSON.parse(JSON.stringify(track.markers||[])),buffer:track.buffer,startTime:track.startTime||0,clips:track.clips?track.clips.map(c=>({id:c.id,buffer:c.buffer,startTime:c.startTime,name:c.name})):null,sections:track.sections?track.sections.map(function(s){return{id:s.id,start:s.start,duration:s.duration,name:s.name,color:s.color,notes:s.notes?s.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null,tracks:s.tracks?s.tracks.map(function(st){return{id:st.id,name:st.name,color:st.color,clips:st.clips?st.clips.map(function(c){return{id:c.id,startTime:c.startTime,name:c.name,speed:c.speed,buffer:c.buffer};}):null,midiItems:st.midiItems?st.midiItems.map(function(m){return{id:m.id,startTime:m.startTime,duration:m.duration,name:m.name,notes:m.notes?m.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null};}):null};}):null};}):null,midiItems:track.midiItems?track.midiItems.map(function(m){return{id:m.id,startTime:m.startTime,duration:m.duration,name:m.name,notes:m.notes?m.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null};}):null};};const captureAllTracksSnapshot=()=>{const curTracks=activeTracksRef.current||activeTracks||[];return curTracks.map(t=>({trackId:t.id,state:captureTrackSnapshot(t.id)}));};const setBpmWithUndo=newBpm=>{const oldBpm=bpmRef.current;if(String(oldBpm)===String(newBpm))return;const entry={type:'SET_BPM',scope:'global',label:`BPM ${oldBpm} → ${newBpm}`,before:oldBpm,after:newBpm,undo:e=>{setBpm(e.before);showToast(`Undo: BPM → ${e.before}`,'info');},redo:e=>{setBpm(e.after);showToast(`Redo: BPM → ${e.after}`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setBpm(String(newBpm));};const setPlayheadWithUndo=newTime=>{const oldTime=currentTime;if(Math.abs(oldTime-newTime)<0.001)return;const entry={type:'SET_PLAYHEAD',scope:'global',label:`Playhead ${formatTimeSimple(oldTime)} → ${formatTimeSimple(newTime)}`,before:oldTime,after:newTime,undo:e=>{applyPlayheadDirect(e.before);showToast(`Undo: Playhead`,'info');},redo:e=>{applyPlayheadDirect(e.after);showToast(`Redo: Playhead`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);applyPlayheadDirect(newTime);};const applyPlayheadDirect=time=>{localSelectionAnchorRef.current=time;if(isPlaying){setCurrentTime(time);fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();setTimeout(()=>{startOffsetTimeRef.current=time;startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(time);setIsPlaying(true);},50);}else{setCurrentTime(time);}};const setSelectionWithUndo=(newStart,newEnd,mode)=>{const oldStart=selectionRef.current.start;const oldEnd=selectionRef.current.end;const oldMode=selectionMode;if(oldStart===newStart&&oldEnd===newEnd&&oldMode===mode)return;const entry={type:'SET_SELECTION',scope:'global',label:`Selection`,before:{start:oldStart,end:oldEnd,mode:oldMode},after:{start:newStart,end:newEnd,mode:mode},undo:e=>{setSelectionStart(e.before.start);setSelectionEnd(e.before.end);setSelectionMode(e.before.mode);showToast(`Undo: Selection`,'info');},redo:e=>{setSelectionStart(e.after.start);setSelectionEnd(e.after.end);setSelectionMode(e.after.mode);showToast(`Redo: Selection`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setSelectionStart(newStart);setSelectionEnd(newEnd);if(mode!==undefined)setSelectionMode(mode);};const setSelectedItemsWithUndo=newSet=>{const oldSet=selectedItemIdsRef.current;if(oldSet&&newSet&&oldSet.size===newSet.size&&[...oldSet].every(x=>newSet.has(x)))return;const entry={type:'SELECT_ITEMS',scope:'global',label:`Selection`,before:[...oldSet],after:[...newSet],undo:e=>{setSelectedItemIds(new Set(e.before));showToast(`Undo: Selection`,'info');},redo:e=>{setSelectedItemIds(new Set(e.after));showToast(`Redo: Selection`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setSelectedItemIds(newSet);};const setAiPromptWithUndo=newText=>{const oldText=aiPrompt;if(oldText===newText)return;const entry={type:'SET_AI_PROMPT',scope:'global',label:`AI Prompt`,before:oldText,after:newText,undo:e=>{setAiPrompt(e.before);showToast(`Undo: AI Prompt`,'info');},redo:e=>{setAiPrompt(e.after);showToast(`Redo: AI Prompt`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setAiPrompt(newText);};// Gán preset VST3 (.vstpreset từ thư viện storage/presets) vào track: +const[undoStack,setUndoStack]=useState([]);const[redoStack,setRedoStack]=useState([]);const MAX_UNDO=30;const pushAction=(actionType,trackId,beforeState,afterState)=>{const node={action_type:actionType,track_id:trackId,timestamp:Date.now(),before_state:beforeState,after_state:afterState};setUndoStack(prev=>{const next=[...prev,node];if(next.length>MAX_UNDO)next.shift();return next;});setRedoStack([]);};const handleUndo=()=>{if(window.UndoRedoEngine&&window.UndoRedoEngine.canUndo()){const entry=window.UndoRedoEngine.undo();if(entry){if(entry.undo&&typeof entry.undo==='function')entry.undo(entry);showToast(`Undo: ${entry.label||entry.type}`,'info');return;}}if(undoStack.length===0)return;const last=undoStack[undoStack.length-1];setUndoStack(prev=>prev.slice(0,-1));setRedoStack(prev=>[...prev,last]);applyTrackState(last.track_id,last.before_state);showToast(`Undo: ${last.action_type}`,'info');};const handleRedo=()=>{if(window.UndoRedoEngine&&window.UndoRedoEngine.canRedo()){const entry=window.UndoRedoEngine.redo();if(entry){if(entry.redo&&typeof entry.redo==='function')entry.redo(entry);showToast(`Redo: ${entry.label||entry.type}`,'info');return;}}if(redoStack.length===0)return;const last=redoStack[redoStack.length-1];setRedoStack(prev=>prev.slice(0,-1));setUndoStack(prev=>[...prev,last]);applyTrackState(last.track_id,last.after_state);showToast(`Redo: ${last.action_type}`,'info');};const applyTrackState=(trackId,state)=>{if(trackId==='ALL_TRACKS'){state.forEach(entry=>{applyTrackState(entry.trackId,entry.state);});return;}updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;var updated={...t,...state};if(state.sections!==undefined){updated.sections=state.sections;}if(state.midiItems!==undefined){updated.midiItems=state.midiItems;}if(state.clips!==undefined){updated.clips=state.clips;}return updated;}));};const getSelectedMidiItemInfo=()=>{if(!selectedItemIds||selectedItemIds.size!==1)return null;const selId=selectedItemIds.values().next().value;const tlist=activeTracks||tracks||[];for(const t of tlist){const found=(t.midiItems||[]).find(m=>m.id===selId);if(found)return{itemName:found.name,trackName:t.name,trackId:t.id,itemId:selId,notes:found.notes||[],startTime:found.startTime||0,duration:found.duration||4};}return null;};const captureTrackSnapshot=trackId=>{const track=activeTracks.find(t=>t.id===trackId);if(!track)return null;return{volumeDb:track.volumeDb,pan:track.pan,muted:track.muted,name:track.name,markers:JSON.parse(JSON.stringify(track.markers||[])),buffer:track.buffer,startTime:track.startTime||0,clips:track.clips?track.clips.map(c=>({id:c.id,buffer:c.buffer,startTime:c.startTime,name:c.name})):null,sections:track.sections?track.sections.map(function(s){return{id:s.id,start:s.start,duration:s.duration,name:s.name,color:s.color,notes:s.notes?s.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null,tracks:s.tracks?s.tracks.map(function(st){return{id:st.id,name:st.name,color:st.color,clips:st.clips?st.clips.map(function(c){return{id:c.id,startTime:c.startTime,name:c.name,speed:c.speed,buffer:c.buffer};}):null,midiItems:st.midiItems?st.midiItems.map(function(m){return{id:m.id,startTime:m.startTime,duration:m.duration,name:m.name,notes:m.notes?m.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null};}):null};}):null};}):null,midiItems:track.midiItems?track.midiItems.map(function(m){return{id:m.id,startTime:m.startTime,duration:m.duration,name:m.name,notes:m.notes?m.notes.map(function(n){return{id:n.id,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:n.velocity};}):null};}):null};};const captureAllTracksSnapshot=()=>{const curTracks=activeTracksRef.current||activeTracks||[];return curTracks.map(t=>({trackId:t.id,state:captureTrackSnapshot(t.id)}));};const setBpmWithUndo=newBpm=>{const oldBpm=bpmRef.current;if(String(oldBpm)===String(newBpm))return;const entry={type:'SET_BPM',scope:'global',label:`BPM ${oldBpm} → ${newBpm}`,before:oldBpm,after:newBpm,undo:e=>{setBpm(e.before);showToast(`Undo: BPM → ${e.before}`,'info');},redo:e=>{setBpm(e.after);showToast(`Redo: BPM → ${e.after}`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setBpm(String(newBpm));};const setPlayheadWithUndo=newTime=>{const oldTime=currentTime;if(Math.abs(oldTime-newTime)<0.001)return;const entry={type:'SET_PLAYHEAD',scope:'global',label:`Playhead ${formatTimeSimple(oldTime)} → ${formatTimeSimple(newTime)}`,before:oldTime,after:newTime,undo:e=>{applyPlayheadDirect(e.before);showToast(`Undo: Playhead`,'info');},redo:e=>{applyPlayheadDirect(e.after);showToast(`Redo: Playhead`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);applyPlayheadDirect(newTime);};const applyPlayheadDirect=time=>{localSelectionAnchorRef.current=time;if(isPlaying){// V45.17: seek khi đang play — startOffset + freeze NGAY: playhead nhảy +// tới click + đứng tại đó (không chạy theo processed anchor cũ 50ms). +startOffsetTimeRef.current=time;setCurrentTime(time);fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime +fxRtSeekRestartFlag=true;// V45.18: seek khi play — startTrackPlayback clear_queue mode 'seek' (wipe queue cu + resume nhanh) +stopAllPlayback(false,true);// V45.21: skipRouting nhu seekPlaybackTo (V45.10) +try{if(window.__fxRtPlayheadFreeze)window.__fxRtPlayheadFreeze(true);}catch(e){}setTimeout(()=>{startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(time);setIsPlaying(true);},50);}else{setCurrentTime(time);}};const setSelectionWithUndo=(newStart,newEnd,mode)=>{const oldStart=selectionRef.current.start;const oldEnd=selectionRef.current.end;const oldMode=selectionMode;if(oldStart===newStart&&oldEnd===newEnd&&oldMode===mode)return;const entry={type:'SET_SELECTION',scope:'global',label:`Selection`,before:{start:oldStart,end:oldEnd,mode:oldMode},after:{start:newStart,end:newEnd,mode:mode},undo:e=>{setSelectionStart(e.before.start);setSelectionEnd(e.before.end);setSelectionMode(e.before.mode);showToast(`Undo: Selection`,'info');},redo:e=>{setSelectionStart(e.after.start);setSelectionEnd(e.after.end);setSelectionMode(e.after.mode);showToast(`Redo: Selection`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setSelectionStart(newStart);setSelectionEnd(newEnd);if(mode!==undefined)setSelectionMode(mode);};const setSelectedItemsWithUndo=newSet=>{const oldSet=selectedItemIdsRef.current;if(oldSet&&newSet&&oldSet.size===newSet.size&&[...oldSet].every(x=>newSet.has(x)))return;const entry={type:'SELECT_ITEMS',scope:'global',label:`Selection`,before:[...oldSet],after:[...newSet],undo:e=>{setSelectedItemIds(new Set(e.before));showToast(`Undo: Selection`,'info');},redo:e=>{setSelectedItemIds(new Set(e.after));showToast(`Redo: Selection`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setSelectedItemIds(newSet);};const setAiPromptWithUndo=newText=>{const oldText=aiPrompt;if(oldText===newText)return;const entry={type:'SET_AI_PROMPT',scope:'global',label:`AI Prompt`,before:oldText,after:newText,undo:e=>{setAiPrompt(e.before);showToast(`Undo: AI Prompt`,'info');},redo:e=>{setAiPrompt(e.after);showToast(`Redo: AI Prompt`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setAiPrompt(newText);};// Gán preset VST3 (.vstpreset từ thư viện storage/presets) vào track: // preset_id nằm trong synth_engine → render engine tải qua load_preset khi // render → âm render = âm đã chỉnh trong preset. const setTrackPreset=(trackId,presetId)=>{if(!trackId||!presetId)return;var mt=activeTracksRef.current||tracks;var cur=null;for(var ci=0;ciprev.map(t=>t.id===trackId?{...t,synth_engine:{...(t.synth_engine||{}),preset_id:presetId}}:t));setInstrumentDropdownTrackId(null);setInstrumentDropdownBtnRect(null);var p=(window.SonicRuntime&&window.SonicRuntime.presets||[]).find(function(x){return x.id===presetId;});showToast('Đã gán preset: '+(p&&p.name||presetId),'success');};const setTrackInstrumentWithUndo=(trackId,instrumentId,displayName,bankNumber,programNumber)=>{let track=activeTracks.find(t=>t.id===trackId);if(!track&&sessionTabsRef&&sessionTabsRef.current){for(let _ti=0;_tit.id===trackId);}}if(!track)return;const oldInstrumentId=track.instrumentId;const oldInstrumentName=track.instrumentName;if(oldInstrumentId===instrumentId&&oldInstrumentName===displayName)return;const entry={type:'SET_INSTRUMENT',scope:'track:'+trackId,label:`Instrument ${track.name}`,before:{instrumentId:oldInstrumentId,instrumentName:oldInstrumentName,bankNumber:track.soundfont_bank,programNumber:track.instrumentProgram},after:{instrumentId,instrumentName:displayName,bankNumber,programNumber},undo:e=>{setTrackInstrumentWithProgram(trackId,e.before.instrumentId,e.before.programNumber,e.before.instrumentName,e.before.bankNumber);showToast(`Undo: Instrument`,'info');},redo:e=>{setTrackInstrumentWithProgram(trackId,e.after.instrumentId,e.after.programNumber,e.after.instrumentName,e.after.bankNumber);showToast(`Redo: Instrument`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);setTrackInstrumentWithProgram(trackId,instrumentId,programNumber,displayName,bankNumber);};const createSectionWithUndo=(trackId,section)=>{const entry={type:'CREATE_SECTION',scope:'track:'+trackId,label:`Create Section`,before:null,after:section,undo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,sections:(t.sections||[]).filter(s=>s.id!==e.after.id)}:t));showToast(`Undo: Create Section`,'info');},redo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,sections:[...(t.sections||[]),e.after]}:t));showToast(`Redo: Create Section`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);};const createMidiWithUndo=(trackId,midiItem)=>{const entry={type:'CREATE_MIDI',scope:'track:'+trackId,label:`Create MIDI Item`,before:null,after:midiItem,undo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,midiItems:(t.midiItems||[]).filter(m=>m.id!==e.after.id)}:t));showToast(`Undo: Create MIDI`,'info');},redo:e=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,midiItems:[...(t.midiItems||[]),e.after]}:t));showToast(`Redo: Create MIDI`,'info');}};if(window.UndoRedoEngine)window.UndoRedoEngine.execute(entry);};// Export MIDI notes → audio bằng VSTi: midi-render (pedalboard) → chèn clip @@ -1358,7 +1432,8 @@ const[activeTab,setActiveTab]=useState('main');const[subTabSelectedNodeTime,setS const[pianoRollClipboard,setPianoRollClipboard]=useState(null);// notes copy giữa các PIANO ROLL TAB const[sessionTabs,setSessionTabs]=useState([]);// [{id, name, tracks}, ...] const[tabContextMenu,setTabContextMenu]=useState(null);// { x, y, tabId, tabType } -const handleSetTabColor=(tabId,tabType,color)=>{if(tabType==='session'){setSessionTabs(prev=>prev.map(s=>s.id===tabId?{...s,color:color}:s));const tab=sessionTabs.find(s=>s.id===tabId);if(tab){setTracks(prev=>prev.map(t=>{if(!t.sections||t.sections.length===0)return t;return{...t,sections:t.sections.map(s=>{if(s.sectionId===tab.sectionId){return{...s,color:color};}return s;})};}));}}else{setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,color:color}:s));}};const activeTracks=useMemo(()=>{var st=sessionTabs.find(s=>s.id===activeTab);if(st)return st.tracks;var pr=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(pr&&pr.parent_tab_id&&pr.parent_tab_id.startsWith('session_')){var parentSt=sessionTabs.find(s=>s.id===pr.parent_tab_id);if(parentSt)return parentSt.tracks;}return tracks;},[activeTab,sessionTabs,subTabs,tracks]);const activeTracksRef=useRef([]);activeTracksRef.current=activeTracks;const sessionTabsRef=useRef(sessionTabs);sessionTabsRef.current=sessionTabs;// Vị trí MIDI item (trong context của piano roll) — dùng để đồng bộ playhead +const handleSetTabColor=(tabId,tabType,color)=>{if(tabType==='session'){setSessionTabs(prev=>prev.map(s=>s.id===tabId?{...s,color:color}:s));const tab=sessionTabs.find(s=>s.id===tabId);if(tab){setTracks(prev=>prev.map(t=>{if(!t.sections||t.sections.length===0)return t;return{...t,sections:t.sections.map(s=>{if(s.sectionId===tab.sectionId){return{...s,color:color};}return s;})};}));}}else{setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,color:color}:s));}};const activeTracks=useMemo(()=>{var st=sessionTabs.find(s=>s.id===activeTab);if(st)return st.tracks;var pr=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(pr&&pr.parent_tab_id&&pr.parent_tab_id.startsWith('session_')){var parentSt=sessionTabs.find(s=>s.id===pr.parent_tab_id);if(parentSt)return parentSt.tracks;}return tracks;},[activeTab,sessionTabs,subTabs,tracks]);const activeTracksRef=useRef([]);activeTracksRef.current=activeTracks;tracksRef.current=tracks;// D18c: sync tracks state moi nhat (guard fire-time) +const sessionTabsRef=useRef(sessionTabs);sessionTabsRef.current=sessionTabs;// Vị trí MIDI item (trong context của piano roll) — dùng để đồng bộ playhead // 2 chiều SECTION-TAB ↔ PIANO ROLL: item.startTime (local section / main) + // st.currentTime (vị trí TRONG item). Dùng REFS (activeTracksRef/ // sessionTabsRef) — an toàn với closure cũ (updatePlayhead/rAF loop). @@ -1379,10 +1454,12 @@ const all=[...(sessionTabs||[]).reduce((acc,s)=>acc.concat(s.tracks||[]),[]),... // KHÔNG được trigger re-sync/re-route (tránh mất âm khi play sau đó — // updateSfRouting chạy thừa có thể đặt SF destination sai thời điểm). const audioSig=list.map(t=>(t.muted?'1':'0')+(t.solo?'1':'0')+':'+(t.volumeDb??0)+':'+(t.audioBypass?'1':'0')+(t.midiBypass?'1':'0')+':'+(t.midiItems||[]).length+':'+(t.clips||[]).length+':'+(t.sections||[]).length).join('|');if(trackAudioSyncSigRef.current===audioSig)return;trackAudioSyncSigRef.current=audioSig;list.forEach(t=>{// ♪ state → SF mastering route (sfRouteGain/sfDryGain), live on existing nodes -const sn=activeTrackNodesRef.current[t.id];if(sn&&sn.sfRouteGain&&sn.sfDryGain&&sn.sfRouteGain.gain.value>0!==!effMidiBypass(t)){const _b=effMidiBypass(t);sn.sfRouteGain.gain.value=_b?0:1;sn.sfDryGain.gain.value=_b?1:0;}const sig=(t.muted?'1':'0')+(t.solo?'1':'0')+':'+(t.volumeDb??0);if(trackMuteSoloSigRef.current[t.id]===sig)return;trackMuteSoloSigRef.current[t.id]=sig;const audible=computeTrackAudibleGain(list,t)>0;const node=activeTrackNodesRef.current[t.id];const audibleGain=computeTrackAudibleGain(list,t);if(node)setTrackNodeGain(node,audibleGain);if(node&&node.sfEntry){try{const g=typeof audibleGain==='number'&&isFinite(audibleGain)&&!isNaN(audibleGain)?audibleGain:1.0;node.sfEntry.gain.setTargetAtTime(g,getAudioContext().currentTime,0.02);}catch(e){}}// MIDI/bridge tracks: mirror the gain decision onto CC7 (channel volume) +const sn=activeTrackNodesRef.current[t.id];if(sn&&sn.sfRouteGain&&sn.sfDryGain&&sn.sfRouteGain.gain.value>0!==!effMidiBypass(t)){const _b=effMidiBypass(t);sn.sfRouteGain.gain.value=_b?0:1;sn.sfDryGain.gain.value=_b?1:0;}const sig=(t.muted?'1':'0')+(t.solo?'1':'0')+':'+(t.volumeDb??0);if(trackMuteSoloSigRef.current[t.id]===sig)return;trackMuteSoloSigRef.current[t.id]=sig;const audible=computeTrackAudibleGain(list,t)>0;const node=activeTrackNodesRef.current[t.id];const audibleGain=computeTrackAudibleGain(list,t);// V45.24: PR tab doc lap — track co sub-tab PR PLAY thi khong ap +// mute/solo realtime (gain=0/CC7=0) trong sync effect. +const prPlayingForTrack=!!(subTabsRef.current||[]).some(function(s){return s.type==='PIANO_ROLL'&&s.trackId===t.id&&s.isPlaying;});const _volDb=Number(t.volumeDb);const _volLin=isNaN(_volDb)||!isFinite(_volDb)?1.0:_volDb<=-50?0:Math.pow(10,_volDb/20);const effGain=prPlayingForTrack?_volLin:audibleGain;if(node)setTrackNodeGain(node,effGain);if(node&&node.sfEntry){try{const g=typeof effGain==='number'&&isFinite(effGain)&&!isNaN(effGain)?effGain:1.0;node.sfEntry.gain.setTargetAtTime(g,getAudioContext().currentTime,0.02);}catch(e){}}// MIDI/bridge tracks: mirror the gain decision onto CC7 (channel volume) // so FluidSynth/bridge-rendered notes respect mute/solo too. Bridge // active → SonicMidiRouter; down → SonicSF. CC7 = VOLUME track. -if(isMidiSoundTrack(t)){try{sendMidiVolumeCC7(t,list,audible);}catch(e){}}trackAudibleRef.current[t.id]=audible;});updateSfRouting();},[tracks,sessionTabs]);const prevActiveTabRef=useRef(activeTab);useEffect(()=>{const prevTab=prevActiveTabRef.current;prevActiveTabRef.current=activeTab;updateSfRouting();// Log chẩn đoán zoom khi đổi tab (user: zoom reset khi chuyển tab) +if(isMidiSoundTrack(t)){try{sendMidiVolumeCC7(t,list,audible||prPlayingForTrack);}catch(e){}}trackAudibleRef.current[t.id]=prPlayingForTrack?true:audible;});updateSfRouting();},[tracks,sessionTabs]);const prevActiveTabRef=useRef(activeTab);useEffect(()=>{const prevTab=prevActiveTabRef.current;prevActiveTabRef.current=activeTab;updateSfRouting();// Log chẩn đoán zoom khi đổi tab (user: zoom reset khi chuyển tab) if(prevTab!==activeTab){console.log('[Zoom] đổi tab',prevTab,'→',activeTab,'| zoom=',zoom,'| minZoom=',(minZoom||0).toFixed(2));}// Tab DEACTIVE → stop âm của tab đó (user requirement): rời khỏi sub-tab // (PIANO_ROLL/section/audio tab) đang play → stop playback của tab đó. // Tab mới hoạt động bình thường; MAIN giữ hành vi cũ (mở piano-roll lúc @@ -1524,14 +1601,19 @@ const startOffsetTimeRef=useRef(0);const startBufferOffsetRef=useRef(0);const st // session không active trong 1500ms → hủy freeze (đừng đóng băng playhead vô hạn). window.__fxRtPlayheadFreeze=function(allowCold){try{if(_fxRtFreezeTimer){clearTimeout(_fxRtFreezeTimer);_fxRtFreezeTimer=null;}}catch(e3){}// G3: FX active -> playhead engine-derived (processed) — KHONG freeze/snap. // Anchor processed tai day (moi play/resume path deu goi ham nay). -if(fxRt&&fxRt.active){_fxRtPlayheadSnapPending=false;fxRtAnchorProcessed();return;}// V41: gate theo CHAIN (không chỉ active) — session FX warm (stop giữ warm, +if(fxRt&&fxRt.active){_fxRtPlayheadSnapPending=false;fxRtAnchorProcessed();// V45.16: seek/restart FX active — giữ playhead tại startOffset cho tới khi +// worklet phát block đầu (fx_play_first). Trước đây playhead chạy theo +// processedSamples trong lúc re-buffer FILL 24 (~128ms) → playhead trước +// âm (Bug 1b). Fail-safe 1500ms: engine không phát (session fail) → thả. +_fxRtSeekFreeze=true;if(_fxRtSeekFreezeTimer){clearTimeout(_fxRtSeekFreezeTimer);}_fxRtSeekFreezeTimer=setTimeout(function(){_fxRtSeekFreeze=false;_fxRtSeekFreezeTimer=null;fxRtAnchorProcessed();},1500);return;}// V41: gate theo CHAIN (không chỉ active) — session FX warm (stop giữ warm, // bypass toggle gọi rebuildMasteringGraph KHÔNG gọi fxRtStop) → fxRt.active vẫn // true khi bypass → trước đây freeze nhầm → playhead chạy trước → wrap sớm → vấp. const _fxWillRun=!!(fxRt&&fxRtChain().length>0&&(fxRt.active||allowCold));if(!_fxWillRun)return;const _estMs=fxRt.pdcCalibMs||FX_RT_START_EST_MS;const _wasActiveAtFreeze=!!(fxRt&&fxRt.active);startAudioTimeRef.current=getAudioContext().currentTime+_estMs/1000;_fxRtPlayheadSnapPending=true;_fxRtFreezeTries=0;// V41: snap xác định bằng timer tại est — loop wrap warm không post fx_play_first // cho pass mới (old-tail tiêu thụ dbgPlayFirst) nên không có event để snap. Cold // start (fxRt.active=false) KHÔNG snap sớm: chờ fx_play_first thật (VST load 4s). _fxRtFreezeTimer=setTimeout(function freezeSnapRetry(){_fxRtFreezeTimer=null;if(_wasActiveAtFreeze&&_fxRtPlayheadSnapPending){_fxRtPlayheadSnapPending=false;startAudioTimeRef.current=getAudioContext().currentTime;return;}if(fxRt&&fxRt.starting){if(_fxRtFreezeTries<20){_fxRtFreezeTries++;_fxRtFreezeTimer=setTimeout(freezeSnapRetry,500);}return;}if(!fxRt||!fxRt.active){startAudioTimeRef.current=getAudioContext().currentTime;}// session fail → bỏ freeze -},_estMs);};window.__fxRtSnapPlayhead=function(){if(fxRt&&fxRt.active&&_fxRtPlayheadSnapPending){_fxRtPlayheadSnapPending=false;startAudioTimeRef.current=getAudioContext().currentTime;}};const animationFrameIdRef=useRef(null);const toastTimeoutRef=useRef(null);const rulerDragStartRef=useRef(null);const rulerAnchorRef=useRef(null);const isDraggingRulerRef=useRef(false);const subTabDragStartRef=useRef(null);const isDraggingSubTabRef=useRef(false);const handlePlayPauseRef=useRef(null);const currentTimeRef=useRef(currentTime);// D9: playhead sample counter cho native bridge (A11/A13 dùng để đồng bộ +},_estMs);};window.__fxRtSnapPlayhead=function(){// V45.16: hết seek-freeze — playhead bắt đầu từ startOffset tại mẫu nghe được. +if(_fxRtSeekFreeze){_fxRtSeekFreeze=false;if(_fxRtSeekFreezeTimer){clearTimeout(_fxRtSeekFreezeTimer);_fxRtSeekFreezeTimer=null;}fxRtAnchorProcessed();return;}if(fxRt&&fxRt.active&&_fxRtPlayheadSnapPending){_fxRtPlayheadSnapPending=false;startAudioTimeRef.current=getAudioContext().currentTime;}};const animationFrameIdRef=useRef(null);const toastTimeoutRef=useRef(null);const rulerDragStartRef=useRef(null);const rulerAnchorRef=useRef(null);const isDraggingRulerRef=useRef(false);const subTabDragStartRef=useRef(null);const isDraggingSubTabRef=useRef(false);const handlePlayPauseRef=useRef(null);const currentTimeRef=useRef(currentTime);// D9: playhead sample counter cho native bridge (A11/A13 dùng để đồng bộ // timeline sample-accurate); cập nhật trong updatePlayhead khi bridge active. const bridgePlayheadSampleRef=useRef(0);// V10: timer của transport STOP#2 (150ms sau STOP#1, V8 bug 3 guard) — loop // restart gọi stopAllPlayback() + startTrackPlayback() cùng lúc, STOP#2 trễ @@ -1722,7 +1804,17 @@ const showToast=(text,type='info',actionText=null,onActionClick=null)=>{if(toast // NOTE_ON/NOTE_OFF theo pattern attachMidiHandler (hardware MIDI): bridge → // per-track channel alloc; native SF/VST; SonicSF fallback; VU + giữ // counter (heldMidiNotesRef); forward recorder (live record vào midiItems). -const vkbNoteOn=(pitch,vel)=>{try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}const allTracks=activeTracksRef.current||[];const armed=allTracks.filter(function(t){return t.isArmed;});const targets=armed.length?armed:allTracks;if(!armed.length&&!window.__vkbNoArmToast){window.__vkbNoArmToast=true;showToast('Không có track nào ARM — chơi vào tất cả track.','info');}activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));vkbHeldRef.current.add(pitch);targets.forEach(function(t){const ctx=resolveTrackInstrumentCtx(t,allTracks);ensureSonicInstrument(ctx);if(window.triggerMidiVuActivity)window.triggerMidiVuActivity(t.id,Math.round(vel*127));try{heldMidiNotesRef.current[t.id]=(heldMidiNotesRef.current[t.id]||0)+1;}catch(err){}const percussion=!!(ctx&&ctx.synthEngine&&ctx.synthEngine.soundfont_bank===128);if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(t.id,ctx.program,ctx.synthEngine);}catch(er){}try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:t.id,pitch:pitch,velocity:vel,percussion:percussion,live:true});}catch(e){}}else if(isStandaloneSf()&&(isSfTrackEngine(ctx.synthEngine)||isVstTrackEngine(ctx.synthEngine))){if(isSfTrackEngine(ctx.synthEngine))playNativeSfNote(t,pitch,vel,60000,undefined,'vkb_'+t.id+'_'+pitch);else playNativeVstNote(t,pitch,vel,60000,undefined,'vkb_'+t.id+'_'+pitch);}else if(window.SonicSF){const dest=activeTrackNodesRef.current[t.id]&&activeTrackNodesRef.current[t.id].gainNode||null;window.SonicSF.playNote(pitch,Math.max(1,Math.round(vel*127)),60000,undefined,ctx.program,dest,ctx.ch,ctx.synthEngine);}});// Live record: forward tới mọi recorder đang chạy (timeline take / piano roll) +const vkbNoteOn=(pitch,vel)=>{try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(er){}const allTracks=activeTracksRef.current||[];// V45.19 Bug 2: ARM piano roll chi tac dung khi DANG o PIANO_ROLL tab. O +// MAIN/SECTION chi tinh track isArmed that su; ARM tu piano roll KHONG truyen +// sang. Khong track nao ARM o MAIN/SECTION -> targets rong -> preview cam. +const _prTabNow=(subTabsRef.current||[]).find(function(s){return s.id===activeTabRef.current;});const _isPrTabNow=!!(_prTabNow&&_prTabNow.type==='PIANO_ROLL');const prArmedIds={};// V45.27: PR tab preview chi track dang edit (sub-tab active) VA chi khi sub-tab do ARM (isArmed). +// ARM tu sub-tab PR khac / track MAIN KHONG leak sang PR tab nay. +try{if(_isPrTabNow){if(_prTabNow.isArmed&&_prTabNow.trackId)prArmedIds[_prTabNow.trackId]=true;}}catch(e){}const armed=allTracks.filter(function(t){return t.isArmed;});const targets=_isPrTabNow?allTracks.filter(function(t){return prArmedIds[t.id];}):armed.length?armed:[];if(!_isPrTabNow&&!armed.length&&!window.__vkbNoArmToast){window.__vkbNoArmToast=true;showToast('Không có track nào ARM — preview MIDI câm (MAIN/SECTION).','info');}// DIAG Bug 3/4 (preview khong am): trang thai routing tai note-on. +try{let _pdDest='none';if(window.AudioRoutingEngine&&window.AudioRoutingEngine._connected){_pdDest=window.AudioRoutingEngine._dest&&window.AudioRoutingEngine._dest.constructor?window.AudioRoutingEngine._dest.constructor.name:'null';_pdDest+='/'+window.AudioRoutingEngine._trackId;}else{_pdDest='NOT_CONNECTED';}const _pdD=window.BridgeAudioNode?window.BridgeAudioNode.getDiag():null;const _pdTab=activeTabRef.current;const _pdSub=subTabsRef.current?(subTabsRef.current.find(s=>s.id===_pdTab)||{}).type:null;_dbgLog('preview_diag','armed='+armed.length+' trg='+targets.length+' prTab='+(_isPrTabNow?1:0)+' tab='+_pdTab+' sub='+(_pdSub||'none')+' play='+(isPlayingRef.current?1:0)+' br='+(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()?1:0)+' fxRt='+(fxRt.active?1:0)+' dest='+_pdDest+' bd='+(_pdD?'i='+_pdD.initialized+' wl='+_pdD.useWorklet+' nd='+_pdD.hasWorkletNode+' pre='+_pdD.preBufferLen+' ch='+_pdD.chunksLen+' g='+_pdD.gain+' ws='+(_pdD.ws?_pdD.ws.overruns+'/'+_pdD.ws.underruns:'-'):'MISSING')+' mb='+!!masterBus);}catch(e){}// DIAG Bug 3/4: arm bo dem audio frames (500ms sau note-on) de biet +// engine co gui audio ve JS khong (tach engine vs JS routing). +try{window.__dbgPreviewAudioArmed={count:0,nz:0,t0:performance.now()};}catch(e){}activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));vkbHeldRef.current.add(pitch);targets.forEach(function(t){// V45.24: PR preview doc lap voi mute/solo MAIN/SECTION — khoi phuc +// volume track khi o PR tab. +if(_isPrTabNow){try{ensureTrackPrAudible(t,allTracks,activeTrackNodesRef.current[t.id]);}catch(e){}}const ctx=resolveTrackInstrumentCtx(t,allTracks);ensureSonicInstrument(ctx);if(window.triggerMidiVuActivity)window.triggerMidiVuActivity(t.id,Math.round(vel*127));try{heldMidiNotesRef.current[t.id]=(heldMidiNotesRef.current[t.id]||0)+1;}catch(err){}const percussion=!!(ctx&&ctx.synthEngine&&ctx.synthEngine.soundfont_bank===128);if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(t.id,ctx.program,ctx.synthEngine);}catch(er){}try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:t.id,pitch:pitch,velocity:vel,percussion:percussion,live:true});}catch(e){}}else if(isStandaloneSf()&&(isSfTrackEngine(ctx.synthEngine)||isVstTrackEngine(ctx.synthEngine))){if(isSfTrackEngine(ctx.synthEngine))playNativeSfNote(t,pitch,vel,60000,undefined,'vkb_'+t.id+'_'+pitch);else playNativeVstNote(t,pitch,vel,60000,undefined,'vkb_'+t.id+'_'+pitch);}else if(window.SonicSF){const dest=activeTrackNodesRef.current[t.id]&&activeTrackNodesRef.current[t.id].gainNode||null;window.SonicSF.playNote(pitch,Math.max(1,Math.round(vel*127)),60000,undefined,ctx.program,dest,ctx.ch,ctx.synthEngine);}});// Live record: forward tới mọi recorder đang chạy (timeline take / piano roll) if(activeMIDIRecordersRef.current){const msg={data:[0x90,pitch,Math.max(1,Math.round(vel*127))]};for(const trackId in activeMIDIRecordersRef.current){const rec=activeMIDIRecordersRef.current[trackId];if(rec){try{rec.handleMIDIMessage(msg,null);}catch(e){}}}}};const vkbNoteOff=pitch=>{vkbHeldRef.current.delete(pitch);activeMidiPitchesRef.current.delete(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));const allTracks=activeTracksRef.current||[];allTracks.forEach(function(t){const ctx=resolveTrackInstrumentCtx(t,allTracks);const percussion=!!(ctx&&ctx.synthEngine&&ctx.synthEngine.soundfont_bank===128);if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:t.id,pitch:pitch,velocity:0,percussion:percussion,live:true});}catch(e){}}else if(window.SonicSF&&window.SonicSF.stopNote){if(isStandaloneSf()){stopNativeSfNote('vkb_'+t.id+'_'+pitch);stopNativeVstNote('vkb_'+t.id+'_'+pitch);}window.SonicSF.stopNote(ctx.ch,pitch);}try{if(heldMidiNotesRef.current[t.id]!==undefined){heldMidiNotesRef.current[t.id]=Math.max(0,heldMidiNotesRef.current[t.id]-1);if(heldMidiNotesRef.current[t.id]===0)delete heldMidiNotesRef.current[t.id];}}catch(err){}});if(activeMIDIRecordersRef.current){const msg={data:[0x80,pitch,0]};for(const trackId in activeMIDIRecordersRef.current){const rec=activeMIDIRecordersRef.current[trackId];if(rec){try{rec.handleMIDIMessage(msg,null);}catch(e){}}}}};vkbNoteOnRef.current=vkbNoteOn;vkbNoteOffRef.current=vkbNoteOff;window.__toggleVkb=function(){setVkbOpen(function(p){const next=!p;if(!next&&vkbHeldRef.current.size){Array.from(vkbHeldRef.current).forEach(function(pitch){try{vkbNoteOffRef.current(pitch);}catch(e){}});}return next;});};window.__toggleChordsPanel=function(){setChordsPanelOpen(function(p){return!p;});};// ── Server-side upload ── // WAV encoder tối thiểu — upload buffer-only clip khi LƯU (nếu chưa có // serverFileId — upload sớm thất bại/race → clip mất sau reload). @@ -1739,11 +1831,12 @@ const startSubTabPlayback=(st,offsetWallTime)=>{const context=getAudioContext(); // V21: section play không gọi fxRtStart — hủy idle timer thủ công (không // để master FX bị teardown giữa lúc section đang phát). if(fxRtWarmIdleTimer){clearTimeout(fxRtWarmIdleTimer);fxRtWarmIdleTimer=null;}restorePendingFadeSession();// V17: huy fade dang treo — replay khong bi dip gain -// V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc -// khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu). -try{if(window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong +// D18: loop WRAP (fxRtWrapRestart) — KHONG flush ring bridge: ring dang chua +// tail pass cu + block moi (bridge render lien tuc); flush tai seam = gap +// trong → giat beat 0:0. Play that van flush (V30) de bo block idle cu. +const _wrapRestart=fxRtWrapRestart;const _wrapMode=_wrapRestart?'loop':'play';fxRtWrapRestart=false;// V38 +try{if(!_wrapRestart&&window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong // cho phia sau; flush ring bridge (V30) khong chaym queue nay. -const _wrapMode=fxRtWrapRestart?'loop':'play';fxRtWrapRestart=false;// V38 try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:_wrapMode});}catch(e){}if(!st.buffer){st.buffer=context.createBuffer(1,128,context.sampleRate);}const speed=st.speed||1.0;const offsetBuffer=offsetWallTime*speed;const eff=st.buffer.getChannelData(0);const edBuffer=context.createBuffer(1,eff.length,st.buffer.sampleRate);const edData=edBuffer.getChannelData(0);edData.set(eff);const fx=st.effects||{};if(fx.reverse){const reversed=new Float32Array(edData);for(let i=0;i0){const sr=edBuffer.sampleRate;const fadeSamples=Math.min(edData.length,Math.floor(fx.fadeInMs/1000*sr));for(let i=0;i0){const sr=edBuffer.sampleRate;const fadeSamples=Math.min(edData.length,Math.floor(fx.fadeOutMs/1000*sr));for(let i=edData.length-fadeSamples;i=end){fxRtWrapRestart=true;// V38: loop wrap restart -fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime +try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime stopAllPlayback();setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,buffer:s.buffer||getAudioContext().createBuffer(1,128,getAudioContext().sampleRate),currentTime:start,isPlaying:true}:s));schedulePianoRollMidi(st,start);startSubTabPlayback(st,start);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}}const effectiveDuration=st.type==='PIANO_ROLL'?(()=>{const notes=st.notes||[];const bpmVal=parseInt(bpmRef?.current||bpm)||120;const beatSec=60.0/bpmVal;if(recordingStateRef.current==='RECORDING')return 600.0;// 10 min during recording let maxEnd=0;notes.forEach(n=>{const end=(n.start_beat||0)+(n.duration_beats||1);if(end>maxEnd)maxEnd=end;});return Math.max(maxEnd*beatSec,16*beatSec*4)+1.0;})():st.buffer.duration;if(bufferPos>=effectiveDuration){fxRtWrapRestart=true;// V38: loop wrap restart -fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime +try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime stopAllPlayback();if(st.isLooping){setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,buffer:s.buffer||getAudioContext().createBuffer(1,128,getAudioContext().sampleRate),currentTime:0,isPlaying:true}:s));// Loop lại: phải schedule notes MỚI (âm piano roll mất ở lần loop 2 // nếu chỉ chạy silent buffer). if(st.type==='PIANO_ROLL')schedulePianoRollMidi(st,0);startSubTabPlayback(st,0);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:0,isPlaying:false}:s));}return;}setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:wallTime}:s));animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}if(!isPlaying)return;const context=getAudioContext();const elapsed=context.currentTime-startAudioTimeRef.current;const updatedTime=startOffsetTimeRef.current+elapsed;// G3: playhead display engine-derived khi FX active (wrap/loop trigger giu // wall-clock — transport truth). -const _playheadSec=fxRt&&fxRt.active&&typeof fxRt.processedSamples==='number'&&fxRt.processedAnchor!=null?Math.max(startOffsetTimeRef.current,startOffsetTimeRef.current+(fxRt.processedSamples-fxRt.processedAnchor-(fxRt.latencyTotal||0))/(context.sampleRate||44100)):updatedTime;// Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 +const _playheadSec=_fxRtSeekFreeze&&fxRt&&fxRt.active?startOffsetTimeRef.current:fxRt&&fxRt.active&&typeof fxRt.processedSamples==='number'&&fxRt.processedAnchor!=null?Math.max(startOffsetTimeRef.current,startOffsetTimeRef.current+(fxRt.processedSamples-fxRt.processedAnchor-(fxRt.latencyTotal||0))/(context.sampleRate||44100)):updatedTime;// Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 // If selection cleared by user, play linearly (don't loop) if(!selectionCleared&&isLoopingSelection&&selLeft!==null&&selRight!==null){if(selRight>selLeft&&updatedTime>=selRight){if(hasAnySolo||selectionMode==='local'){fxRtWrapRestart=true;// V38: loop wrap restart -fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=context.currentTime;if(hasAnySolo){const soloed=tracks.filter(t=>t.solo);soloed.forEach(t=>startLocalTrackPlayback(t.id,selLeft));}else{startLocalTrackPlayback(localSelectionTrackId,selLeft);}setCurrentTime(selLeft);setIsPlaying(true);}else{fxRtWrapRestart=true;// V38: loop wrap restart -fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=context.currentTime;startTrackPlayback(selLeft);setCurrentTime(selLeft);setIsPlaying(true);}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}}// Hết bài: dừng/loop lại tại ENDTIME THẬT của session (projectEnd — không +try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime +stopAllPlayback(false,true);// V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble) +startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=context.currentTime;if(hasAnySolo){const soloed=tracks.filter(t=>t.solo);soloed.forEach(t=>startLocalTrackPlayback(t.id,selLeft));}else{startLocalTrackPlayback(localSelectionTrackId,selLeft);}setCurrentTime(selLeft);setIsPlaying(true);}else{fxRtWrapRestart=true;// V38: loop wrap restart +try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime +stopAllPlayback(false,true);// V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble) +startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=context.currentTime;startTrackPlayback(selLeft);setCurrentTime(selLeft);setIsPlaying(true);}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}}// Hết bài: dừng/loop lại tại ENDTIME THẬT của session (projectEnd — không // buffer 12 bars như maxDuration dùng cho scroll/zoom). if(updatedTime>=projectEndRef.current){console.log('[Stop] projectEnd reached — updatedTime=',updatedTime.toFixed(2),'projectEnd=',projectEndRef.current.toFixed(2),'isLoopingSelection=',isLoopingSelection);if(recordingStateRef.current==='RECORDING'){setCurrentTime(updatedTime);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}if(isLoopingSelection){fxRtWrapRestart=true;// V38: loop wrap restart -fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();startOffsetTimeRef.current=0;startAudioTimeRef.current=context.currentTime;startTrackPlayback(0);setCurrentTime(0);setIsPlaying(true);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}stopAllPlayback();setCurrentTime(0);return;}setCurrentTime(_playheadSec);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);};useEffect(()=>{if(isPlaying||subTabs.some(s=>s.isPlaying)){animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{cancelAnimationFrame(animationFrameIdRef.current);}return()=>cancelAnimationFrame(animationFrameIdRef.current);},[isPlaying,subTabs,isLoopingSelection,selLeft,selRight,selectionMode,localSelectionTrackId,selectionCleared,activeTab]);// ── FX Nodes ── +try{_dbgLog('wrap_info','solo='+(hasAnySolo?1:0)+' local='+(selectionMode==='local'?1:0)+' tracks='+(tracks?tracks.length:-1)+' nl='+nativeLoopSrcsRef.current.size);}catch(e){}fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime +const _wrapT0=performance.now();stopAllPlayback(false,true);// V45.22: wrap restart — skipRouting nhu seekPlaybackTo (het stall updateSfRouting tai seam -> het wobble) +const _wrapT1=performance.now();startOffsetTimeRef.current=0;startAudioTimeRef.current=context.currentTime;startTrackPlayback(0);const _wrapT2=performance.now();setCurrentTime(0);setIsPlaying(true);try{_dbgLog('wrap_timing','stop='+Math.round(_wrapT1-_wrapT0)+' start='+Math.round(_wrapT2-_wrapT1));}catch(e){}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}stopAllPlayback();setCurrentTime(0);return;}setCurrentTime(_playheadSec);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);};useEffect(()=>{if(isPlaying||subTabs.some(s=>s.isPlaying)){animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{cancelAnimationFrame(animationFrameIdRef.current);}return()=>cancelAnimationFrame(animationFrameIdRef.current);},[isPlaying,subTabs,isLoopingSelection,selLeft,selRight,selectionMode,localSelectionTrackId,selectionCleared,activeTab]);// ── FX Nodes ── const createChorusNode=(context,inputNode,outputNode)=>{const dryGain=context.createGain();dryGain.gain.value=0.6;const wetGain=context.createGain();wetGain.gain.value=0.5;const delayNode=context.createDelay();delayNode.delayTime.value=0.02;const lfo=context.createOscillator();lfo.type='sine';lfo.frequency.value=1.5;const lfoGain=context.createGain();lfoGain.gain.value=0.002;lfo.connect(lfoGain);lfoGain.connect(delayNode.delayTime);lfo.start();inputNode.connect(dryGain);inputNode.connect(delayNode);delayNode.connect(wetGain);dryGain.connect(outputNode);wetGain.connect(outputNode);return{stop:()=>{try{lfo.stop();}catch(e){}}};};const createReverbNode=(context,inputNode,outputNode)=>{const dryGain=context.createGain();dryGain.gain.value=0.6;const wetGain=context.createGain();wetGain.gain.value=0.4;const convolver=context.createConvolver();const rate=context.sampleRate;const len=rate*2.0;const impulse=context.createBuffer(2,len,rate);const left=impulse.getChannelData(0);const right=impulse.getChannelData(1);for(let i=0;i{if(!track)return null;let node=activeTrackNodesRef.current[track.id];if(!node){const gainNode=context.createGain();const volDb=track.volumeDb??0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);gainNode.gain.setValueAtTime(volLinear,context.currentTime);const pannerNode=context.createStereoPanner();pannerNode.pan.setValueAtTime((track.pan??0)/100,context.currentTime);// Ensure master bus is initialized for MAIN OUT routing if(!masterBus)initMasterBus(context);const analyserNode=context.createAnalyser();analyserNode.fftSize=2048;pannerNode.connect(analyserNode);// Wave Observer scope analysers (unified_fx_rack_panel_update.md §III.3): @@ -1904,7 +2000,7 @@ fxRtTrackReconcile(trackId,node,track);}catch(e){console.warn('rebuildTrackFxGra // Cần activeTrackNodesRef/isPlayingRef. Session giống master (fxRtStart): // fetch start → AudioWorkletNode 'sf-fx-realtime' → WS; WS open → splice // worklet vào chain hiện tại (đọc mods MỚI NHẤT — an toàn với rebuild). -const fxRtTrackStart=async(trackId,track,path)=>{const key=trackId+':'+path;const chain=fxRtTrackChain(track);if(!chain.length)return;if(fxRtTracks[key]){if(!fxRtTracks[key].node||fxRtTracks[key].node.context!==getAudioContext())fxRtTrackStop(trackId,path,true);else return;}const ctx=getAudioContext();if(!ctx||!ctx.audioWorklet)return;const chainPayload=chain.map(s=>s.type==='vst3'?{type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:!!s.bypass||!!(track&&track.vstFxBypass)}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:!!s.bypass});let st;try{st=await window.SonicAPI.fxRealtimeStart({fx_chain:chainPayload,sample_rate:ctx.sampleRate||44100});}catch(e){console.warn('[FX RT track] start fail',trackId,path,e);return;}const entry={sessionId:st.session_id,ws:null,node:null,ready:false,path,spliced:null,chainKey:fxRtTrackChainKey(chain)+'|vb:'+(track&&track.vstFxBypass?'1':'0'),paramsBySlot:chain.map(s=>fxRtEntryParams(s,null)),inQueue:[],pumpTimer:null,lastInSent:0,flushing:false,flushTimer:null};fxRtTracks[key]=entry;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608281631`);const node=new AudioWorkletNode(ctx,'sf-fx-realtime',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2]});entry.node=node;const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';// WS to 'localhost' bi tre ~300ms (Chromium thu ::1 truoc roi fallback) - dung 127.0.0.1 +const fxRtTrackStart=async(trackId,track,path)=>{const key=trackId+':'+path;const chain=fxRtTrackChain(track);if(!chain.length)return;if(fxRtTracks[key]){if(!fxRtTracks[key].node||fxRtTracks[key].node.context!==getAudioContext())fxRtTrackStop(trackId,path,true);else return;}const ctx=getAudioContext();if(!ctx||!ctx.audioWorklet)return;const chainPayload=chain.map(s=>s.type==='vst3'?{type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:!!s.bypass||!!(track&&track.vstFxBypass)}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},bypass:!!s.bypass});let st;try{st=await window.SonicAPI.fxRealtimeStart({fx_chain:chainPayload,sample_rate:ctx.sampleRate||44100});}catch(e){console.warn('[FX RT track] start fail',trackId,path,e);return;}const entry={sessionId:st.session_id,ws:null,node:null,ready:false,path,spliced:null,chainKey:fxRtTrackChainKey(chain)+'|vb:'+(track&&track.vstFxBypass?'1':'0'),paramsBySlot:chain.map(s=>fxRtEntryParams(s,null)),inQueue:[],pumpTimer:null,lastInSent:0,flushing:false,flushTimer:null};fxRtTracks[key]=entry;fxRtGuiPushShm();try{await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608311858`);const node=new AudioWorkletNode(ctx,'sf-fx-realtime',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2]});entry.node=node;const u=new URL(API_BASE_URL);const wsProto=u.protocol==='https:'?'wss://':'ws://';// WS to 'localhost' bi tre ~300ms (Chromium thu ::1 truoc roi fallback) - dung 127.0.0.1 const wsHost=u.hostname==='localhost'||u.hostname==='::1'?'127.0.0.1'+(u.port?':'+u.port:''):u.host;const tok=localStorage.getItem('sonic_token')||'';const ws=new WebSocket(`${wsProto}${wsHost}${st.ws_url}${tok?'?token='+encodeURIComponent(tok):''}`);entry.ws=ws;ws.binaryType='arraybuffer';ws.onopen=()=>{entry.ready=true;entry.inQueue=[];// bỏ audio stale trước khi WS open — hết lag khởi đầu fxRtTrackSplice(trackId,path);try{(entry.paramsBySlot||[]).forEach((pl,slot)=>pl.forEach(p=>ws.send(JSON.stringify({cmd:'set_param',slot,key:p.key,value:p.value}))));}catch(e){}};ws.onmessage=ev=>{if(typeof ev.data==='string'){try{const d=JSON.parse(ev.data);if(d&&d.cmd==='flush_done'){entry.flushing=false;if(entry.flushTimer){clearTimeout(entry.flushTimer);entry.flushTimer=null;}if(entry.node)entry.node.port.postMessage({type:'flush_done'});}}catch(e){}return;}if(!(ev.data instanceof ArrayBuffer)||!entry.node)return;if(entry.flushing)return;// V24: output stale trong luc flush — drop const f=new Float32Array(ev.data);const half=f.length/2;const l=new Float32Array(half);const r=new Float32Array(half);for(let i=0;ifxRtTrackStop(trackId,path,true);ws.onerror=()=>{try{ws.close();}catch(e){}};entry.node.port.onmessage=ev=>{const d=ev.data;if(!d||!entry.ws)return;if(d.type==='flush'){// V24: nhu master — worklet underrun-resume → flush engine ring @@ -1934,7 +2030,11 @@ const _activeTabId=activeTabRef.current;const _activeSub=subTabsRef.current?subT // phát gây: (a) swap _gainNode → LAG ~1s (bug 06:20); (b) route vào node // MAIN trùng id (midiAudible nhánh) → sfEntry/sfRouteGain theo ♪ main → // âm dry NHỎ/không rõ (bug 06:25 — fix 0620). Giữ nguyên route hiện tại. -if(isPlayingRef.current&&_activeTabId&&_activeTabId!=='main'&&!_activeTabId.startsWith('session_')){return;}// MAIN/SECTION đang play (isPlayingRef) → BỎ QUA nhánh PIANO_ROLL: +if(isPlayingRef.current&&_activeTabId&&_activeTabId!=='main'&&!_activeTabId.startsWith('session_')){return;}// MAIN/SESSION dang play -> KHONG doi bridge sink khi mute/solo giua play: +// disconnect/reconnect _gainNode FluidSynth giua play -> glitch delay/wobble/crackle +// (bug V45.9: un-solo VSTi -> soundfont con lai delay ngay). Mute/solo da xu ly +// bang CC7 + track gain; routing se set lai o lan updateSfRouting sau (STOP). +if(isPlayingRef.current){console.log('[SFRoute] skip sink re-point while playing tab='+_activeTabId);return;}// MAIN/SECTION đang play (isPlayingRef) → BỎ QUA nhánh PIANO_ROLL: // đổi SF destination (setOutputDestination — disconnect/reconnect // _gainNode FluidSynth) khi âm đang phát → âm MIDI bị NGẮT ~1s (user // bug 06:20 — mở lại piano roll tab restored lúc main play → lag 1s @@ -1947,13 +2047,13 @@ if(_activeSub&&_activeSub.type==='PIANO_ROLL'&&!isPlayingRef.current){// PIANO R // không có sub-node → null (masterBus.input — qua mastering). let _prNode=activeTrackNodesRef.current[_activeSub.trackId];if(_activeSub.parent_tab_id&&_activeSub.parent_tab_id.startsWith('session_')){const _prSubKeys=Object.keys(activeTrackNodesRef.current).filter(k=>k.endsWith('_sub_'+_activeSub.trackId));_prNode=_prSubKeys.length?activeTrackNodesRef.current[_prSubKeys[0]]:null;}// D5: bridge active → nối BridgeAudioNode vào node track (sfEntry → // FX chain → mastering) thay vì SonicSF.setOutputDestination. -if(_prNode&&window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){window.AudioRoutingEngine.connect(window.BridgeAudioNode,_prNode,_activeSub.trackId);return;}if(_prNode&&window.SonicSF&&window.SonicSF.setOutputDestination){if(_prNode.sfEntry){window.SonicSF.setOutputDestination(_prNode.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing (node có +if(_prNode&&window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){_dbgLog('sf_route','PR_CONNECT track='+_activeSub.trackId+' dest='+(_prNode.sfEntry?'sfEntry':_prNode.gainNode?'gainNode':'NULL'));window.AudioRoutingEngine.connect(window.BridgeAudioNode,_prNode,_activeSub.trackId);return;}if(_prNode&&window.SonicSF&&window.SonicSF.setOutputDestination){if(_prNode.sfEntry){window.SonicSF.setOutputDestination(_prNode.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing (node có // thể tạo khi mastering OFF → route dry — sửa ngay nếu chain ON). if(_prNode.sfRouteGain&&_prNode.sfDryGain){const _prTrk=list.find(t=>t.id===_activeSub.trackId);const _b=effMidiBypass(_prTrk||{id:_activeSub.trackId});_prNode.sfRouteGain.gain.value=_b?0:1;_prNode.sfDryGain.gain.value=_b?1:0;}return;}if(_prNode.gainNode){window.SonicSF.setOutputDestination(_prNode.gainNode);// ⚠️ FIX: sync route gains NGAY tại thời điểm routing — trước đây // bỏ qua → node giữ bypass cũ (tạo khi mastering OFF / audioBypass // true) → preview âm soundfont KHÔNG qua mastering cho tới khi bật/ // tắt power (effect re-sync). MIDI preview route theo ♪ (midiBypass). -if(_prNode.route&&_prNode.route.routeGain&&_prNode.route.dryGain){const _prTrk2=list.find(t=>t.id===_activeSub.trackId);const _b2=effMidiBypass(_prTrk2||{id:_activeSub.trackId});_prNode.route.routeGain.gain.value=_b2?0:1;_prNode.route.dryGain.gain.value=_b2?1:0;}return;}}}const midiAudible=list.filter(t=>(t.midiItems&&t.midiItems.length>0||t.isArmed||t.type==='MIDI'||!!t.instrumentId)&&computeTrackAudibleGain(list,t)>0);console.log('[SFRoute] activeTab='+_activeTabId,'midiAudible='+midiAudible.map(t=>t.id+'(midi'+(t.midiItems||[]).length+')').join(',')||'none');if(midiAudible.length===1){const t=midiAudible[0];// SECTION-TAB: track id của section clone TRÙNG main track id → node +if(_prNode.route&&_prNode.route.routeGain&&_prNode.route.dryGain){const _prTrk2=list.find(t=>t.id===_activeSub.trackId);const _b2=effMidiBypass(_prTrk2||{id:_activeSub.trackId});_prNode.route.routeGain.gain.value=_b2?0:1;_prNode.route.dryGain.gain.value=_b2?1:0;}return;}}}const midiAudible=list.filter(t=>(t.midiItems&&t.midiItems.length>0||t.isArmed||t.type==='MIDI'||!!t.instrumentId)&&computeTrackAudibleGain(list,t)>0);_dbgLog('sf_route','tab='+_activeTabId+' sub='+(_activeSub?_activeSub.type:'none')+' play='+(isPlayingRef.current?1:0)+' audible=['+midiAudible.map(t=>t.id).join(',')+'] eng='+(window.AudioRoutingEngine&&window.AudioRoutingEngine._connected?'conn:'+(window.AudioRoutingEngine._dest?window.AudioRoutingEngine._dest.constructor.name:'null'):'disconn')+' br='+(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()?1:0));console.log('[SFRoute] activeTab='+_activeTabId,'midiAudible='+midiAudible.map(t=>t.id+'(midi'+(t.midiItems||[]).length+')').join(',')||'none');if(midiAudible.length===1){const t=midiAudible[0];// SECTION-TAB: track id của section clone TRÙNG main track id → node // chính (key = id) là node MAIN (route theo ♪ main — SAI context, MIDI // section bị kéo vào dry). ƯU TIÊN SUB-NODE (key _sub_ — // route theo ♪ section + thẳng masterBus.input) — không có sub-node → @@ -1962,7 +2062,7 @@ let node=activeTrackNodesRef.current[t.id];if(_activeTabId&&_activeTabId.startsW // PWR ON). The ♪ button only switches the post-FX route (sfRouteGain / // sfDryGain) to skip or include the Mastering FX Chain. // D5: bridge active → nối vào node track (sfEntry → FX chain). -if(node&&window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){window.AudioRoutingEngine.connect(window.BridgeAudioNode,node,t.id);return;}if(node&&node.sfEntry&&window.SonicSF&&window.SonicSF.setOutputDestination){console.log('[SFRoute] dest=sfEntry node',t.id,'sfRouteGain='+(node.sfRouteGain?node.sfRouteGain.gain.value:'MISSING'),'sfDryGain='+(node.sfDryGain?node.sfDryGain.gain.value:'MISSING'));window.SonicSF.setOutputDestination(node.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing. +if(node&&window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){_dbgLog('sf_route','SINGLE_CONNECT track='+t.id+' dest='+(node.sfEntry?'sfEntry':node.gainNode?'gainNode':'NULL'));window.AudioRoutingEngine.connect(window.BridgeAudioNode,node,t.id);return;}if(node&&node.sfEntry&&window.SonicSF&&window.SonicSF.setOutputDestination){console.log('[SFRoute] dest=sfEntry node',t.id,'sfRouteGain='+(node.sfRouteGain?node.sfRouteGain.gain.value:'MISSING'),'sfDryGain='+(node.sfDryGain?node.sfDryGain.gain.value:'MISSING'));window.SonicSF.setOutputDestination(node.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing. if(node.sfRouteGain&&node.sfDryGain){// ⚠️ FIX: truyền track OBJECT (t) — trước đây truyền t.id (string) // → effMidiBypass đọc track.id=undefined → map lookup sai → _b luôn // false → sfRouteGain bị ép 1 (không bypass) — nút ♪ vô hiệu qua @@ -1971,7 +2071,7 @@ const _b=effMidiBypass(t);node.sfRouteGain.gain.value=_b?0:1;node.sfDryGain.gain // mastering cho tới khi bật/tắt power. MIDI preview theo ♪. if(node.route&&node.route.routeGain&&node.route.dryGain){const _b3=effMidiBypass(t);node.route.routeGain.gain.value=_b3?0:1;node.route.dryGain.gain.value=_b3?1:0;}return;}}// D5: bridge active + không track nào audible → ngắt khỏi graph (về // masterBus qua updateSfRouting lần sau khi có track). -if(window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){window.AudioRoutingEngine.connect(window.BridgeAudioNode,null,null);}else if(window.SonicSF&&window.SonicSF.setOutputDestination){window.SonicSF.setOutputDestination(null);}}catch(e){console.warn('updateSfRouting error:',e);}};const updateSfRoutingRef=useRef(null);// ── Đồng bộ mastering + routing NGAY TRƯỚC KHI PREVIEW (keybed/draw/MIDI file) ── +if(window.AudioRoutingEngine&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.BridgeAudioNode){_dbgLog('sf_route','MASTER_FALLBACK');window.AudioRoutingEngine.connect(window.BridgeAudioNode,null,null);}else if(window.SonicSF&&window.SonicSF.setOutputDestination){window.SonicSF.setOutputDestination(null);}}catch(e){console.warn('updateSfRouting error:',e);}};const updateSfRoutingRef=useRef(null);// ── Đồng bộ mastering + routing NGAY TRƯỚC KHI PREVIEW (keybed/draw/MIDI file) ── // Bug: preview instrument soundfont với MIDI keyboard KHÔNG tự động đi qua // mastering FX của main out — phải bật/tắt nút power mới có tác dụng. Nguyên // nhân: routing SF (setOutputDestination + sfRouteGain/sfDryGain) và graph @@ -2020,26 +2120,37 @@ if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){const isPerc // SonicSF (FluidSynth WASM). Giữ setTimeout scheduling (như SonicSF cũ); // bridge tự render note-off sau duration. A11 (sample-accurate) sẽ thay // setTimeout bằng sampleOffset đẩy thẳng vào SHM. -const scheduleMidiNoteDispatch=(track,pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine)=>{if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){const ctx=getAudioContext();const startAt=startTime||ctx.currentTime;// undefined/null = phát ngay +const scheduleMidiNoteDispatch=(track,pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine,prNote)=>{if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){const ctx=getAudioContext();const startAt=startTime||ctx.currentTime;// undefined/null = phát ngay const delayMs=Math.max(0,(startAt-ctx.currentTime)*1000);// Guard chống note-on trễ sau Stop: CHỈ khi là timeline (startTime thật). const guardPlay=startTime!=null?function(){return isPlayingRef.current||subTabsRef.current&&subTabsRef.current.some(s=>s.isPlaying)||isExportingRef.current;}:function(){return true;};const trkId=track?track.id:0;// Percussion (bank 128) → router allocateChannel ch 9; melodic → round-robin. const isPerc=!!(synthEngine&&synthEngine.soundfont_bank===128);// D8: track đổi instrument → gửi CC0/CC32 (bank THẬT từ synthEngine) + // program change qua bridge (A12) — helper dedupe theo track+bank+program. if(program!==undefined&&program!==null){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(trkId,program,synthEngine);}catch(e){}}// V11: epoch guard — hủy timer cũ khi stop/loop restart (xem ref). -const epoch=bridgeNoteEpochRef.current;setTimeout(function(){if(epoch!==bridgeNoteEpochRef.current)return;if(!guardPlay())return;var _rt=window.SonicMidiRouter;try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:trkId,pitch:pitch||60,velocity:velocity||0.8,percussion:isPerc});}catch(e){console.warn('[Midi] pushEvent error:',e&&e.message);}},delayMs);scheduleNoteOff('B:'+trkId+':'+(pitch||60),function(){if(epoch!==bridgeNoteEpochRef.current)return;if(!guardPlay())return;try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:trkId,pitch:pitch||60,velocity:0,percussion:isPerc});}catch(e){}},delayMs,durMs||1000);return;}// Native VSTi (standalone browser, bridge inactive) → midi-render thay vì +const epoch=bridgeNoteEpochRef.current;setTimeout(function(){if(epoch!==bridgeNoteEpochRef.current)return;if(!guardPlay())return;// D18b: mute/solo guard tai thoi diem fire — note-on dang cho cua track +// vua tro nen inaudible (solo track khac / mute) khong duoc phat ra. +// V45.24: note PR tab (prNote=true) BO QUA guard + khoi phuc volume — +// mute/solo chi cuc bo MAIN/SECTION, PR khong bao gio bi cam. +const _list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracksRef.current&&tracksRef.current.length?tracksRef.current:activeTracks;const _trk=_list.find(t=>t.id===trkId)||track;if(prNote){try{ensureTrackPrAudible(_trk,_list,destNode);}catch(e){}}else if(!_trk||computeTrackAudibleGain(_list,_trk)<=0){try{if(window.__dbgLog)window.__dbgLog('solo_guard','block ch='+trkId+' p='+(pitch||60));}catch(e){}return;}// D18c: track pitch dang keu (timeline) - de applyAllTrackMuteSolo kill +// dung note khi track thanh inaudible giua chung (Qin VST2 tremolo). +try{var _tps=timelinePitchesRef.current;(_tps[trkId]||(_tps[trkId]=new Set())).add(pitch||60);}catch(e){}var _rt=window.SonicMidiRouter;try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:trkId,pitch:pitch||60,velocity:velocity||0.8,percussion:isPerc});}catch(e){console.warn('[Midi] pushEvent error:',e&&e.message);}},delayMs);scheduleNoteOff('B:'+trkId+':'+(pitch||60),function(){if(epoch!==bridgeNoteEpochRef.current)return;if(!guardPlay())return;try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:trkId,pitch:pitch||60,velocity:0,percussion:isPerc});}catch(e){}// D18c: xoa khoi per-track set (note da tat - khong con can kill). +try{var _tps2=timelinePitchesRef.current;if(_tps2[trkId])_tps2[trkId].delete(pitch||60);}catch(e){}},delayMs,durMs||1000);return;}// Native VSTi (standalone browser, bridge inactive) → midi-render thay vì // FluidSynth GM sai âm — safety net cho mọi caller (ghost notes, per-note). if(isStandaloneSf()&&isVstTrackEngine(synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(track,pitch,velocity,durMs,startTime,track?track.id:'global');return;}window.SonicSF.playNote(pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine);};const startTrackPlayback=offsetTime=>{const context=getAudioContext();_nativePlayEpoch++;// new play — vô hiệu doHardStop đang fade (play trong cửa sổ 80ms) restorePendingFadeSession();// V17: huy fade dang treo — replay khong bi dip gain -// V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc -// khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu). -try{if(window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong +// D18: loop WRAP (fxRtWrapRestart) — KHONG flush ring bridge: ring dang chua +// tail pass cu + block moi (bridge render lien tuc); flush tai seam = gap +// trong → giat beat 0:0. Play that van flush (V30) de bo block idle cu. +const _wrapRestart=fxRtWrapRestart;const _wrapMode=_wrapRestart?'loop':fxRtSeekRestartFlag?'seek':'play';fxRtWrapRestart=false;fxRtSeekRestartFlag=false;// V38/V45.18 +try{if(!_wrapRestart&&window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong // cho phia sau; flush ring bridge (V30) khong chaym queue nay. -const _wrapMode=fxRtWrapRestart?'loop':'play';fxRtWrapRestart=false;// V38 try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:_wrapMode});}catch(e){}_dbgPlayStartMs=performance.now();_dbgFirstAudioPending=true;_dbgFirstAudiblePending=true;_dbgLog('play_start','offset='+offsetTime+' epoch='+_nativePlayEpoch+' '+_dbgBridgeGain()+' '+_dbgWorkletState()+' fxrt='+(fxRt.active?1:0)+' node='+(fxRt.node?1:0)+' q='+(fxRt.stats?fxRt.stats.queueLen:'n/a'));// Realtime VST FX (master chain qua SHM bridge): bật khi play + chain active. try{fxRtStart();}catch(e){console.warn('[FX RT] start error:',e);}// V40: real PDC — freeze playhead theo latency FX path; allowCold=true che // cold-start VST pre-roll (playhead không chạy trước khi có tiếng). Bỏ V39 // freeze giả định 0.31s (under-estimate → playhead chạy trước ~0.66s). -try{if(window.__fxRtPlayheadFreeze)window.__fxRtPlayheadFreeze(true);}catch(e){}// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng +// V45.19 Bug 1b: loop WRAP khong re-buffer (audio lien tuc qua KEEP tail) — chi +// re-anchor processed (playhead theo anchor), KHONG seek-freeze 1500ms (truoc day +// freeze -> playhead dung ~1.5s sau wrap = 'delay kha lau'). Seek/play that freeze. +try{if(_wrapRestart&&fxRt&&fxRt.active){fxRtAnchorProcessed();}else if(window.__fxRtPlayheadFreeze){window.__fxRtPlayheadFreeze(true);}}catch(e){}// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng // bộ timeline; A13 C++ xử lý arg1=playheadSamples sau này). if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected){// V10: loop restart — stopAllPlayback vừa schedule STOP#2 (150ms); hủy // ngay khi PLAY, nếu không STOP#2 đến sau PLAY → bridge drop mọi note-on @@ -2051,7 +2162,7 @@ if(bridgeStopRetryTimerRef.current){clearTimeout(bridgeStopRetryTimerRef.current isPlayingRef.current=true;// Gap 7: master volume automation — schedule envelope tu playhead. try{applyMasterAutomationSchedule(offsetTime||0);}catch(e){console.warn('[Auto] schedule error:',e);}// ⚠️ FIX: đồng bộ mastering NGAY khi play — MIDI item phải qua mastering // FX (khi bật). -try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e){}const allPlayTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:activeTracks;// Capture items signature at schedule time — updatePlayhead so sánh với +try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e){}const allPlayTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:activeTracks;try{if(window.__dbgLog)window.__dbgLog('play_tracks','n='+allPlayTracks.length+' solo='+(allPlayTracks.some(function(t){return t.solo;})?1:0)+' ids='+allPlayTracks.map(function(t){return t.id+':'+(t.solo?'S':t.muted?'M':'A');}).join(','));}catch(e){}// Capture items signature at schedule time — updatePlayhead so sánh với // signature này để phát hiện items đổi vị trí giữa lúc play (re-schedule). // V28: defer bookkeeping signature — tính đồng bộ chặn main thread trước // first-audio (góp vào delay onset); signature chỉ so sánh ~10fps trong @@ -2123,11 +2234,11 @@ const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip. const midiItems=track.midiItems||[];const nativeSf=isStandaloneSf()&&isSfTrackEngine(track.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());const nativeVst=isStandaloneSf()&&isVstTrackEngine(track.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if((track.type==='MIDI'||midiItems.length>0)&&(window.SonicSF||nativeSf||nativeVst)){if(nativeSf||nativeVst){scheduleNativeTrack(track,offsetTime,context,gainNode,bpm,{playheadNow:()=>startOffsetTimeRef.current+(getAudioContext().currentTime-startAudioTimeRef.current),sources:activeSourcesRef.current,isActive:()=>!!isPlayingRef.current});return;// native render → audio buffer (không qua WASM per-note) }var lcCh=assignTrackMidiChannel(track,tracks);const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;midiItems.forEach(item=>{const notes=item.notes||[];notes.forEach(note=>{const noteStartSec=item.startTime+(note.start_beat||0)*secondsPerBeat;const noteDurSec=(note.duration_beats||1)*secondsPerBeat;const noteEndSec=noteStartSec+noteDurSec;if(offsetTime{if(st.type!=='PIANO_ROLL')return;const context=getAudioContext();_nativePlayEpoch++;// new play — vô hiệu doHardStop đang fade restorePendingFadeSession();// V17: huy fade dang treo — replay khong bi dip gain -// V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc -// khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu). -try{if(window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong -// cho phia sau; flush ring bridge (V30) khong chaym queue nay. -try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:'play'});}catch(e){}// V10: piano roll loop restart cũng gọi stopAllPlayback() trước (STOP#1 → +// D18: piano-roll wrap (fxRtWrapRestart set o loop restart) — KHONG flush +// ring + clear_queue mode 'loop' (giu queue + wrapFade) nhu main path; truoc +// day luon mode 'play' → wipe + re-buffer moi pass = giat. KHONG reset flag +// (startSubTabPlayback goi sau van can doc de clear dung mode). +try{if(!fxRtWrapRestart&&window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){}try{if(fxRt&&fxRt.node&&fxRt.node.port)fxRt.node.port.postMessage({type:'clear_queue',mode:fxRtWrapRestart?'loop':'play'});}catch(e){}// V10: piano roll loop restart cũng gọi stopAllPlayback() trước (STOP#1 → // bridge transportStopped=true) → mọi note-on bị drop nếu không PLAY lại. // Hủy STOP#2 (150ms) + báo PLAY để bridge nhận note mới (loop liên tục). if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected){if(bridgeStopRetryTimerRef.current){clearTimeout(bridgeStopRetryTimerRef.current);bridgeStopRetryTimerRef.current=null;}try{window.NativeBridgeService.transport('play');}catch(e){}}// V12: guardPlay race tương tự startTrackPlayback — set NGAY để note-on @@ -2146,8 +2257,8 @@ try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(st.trackId,inst // future (silence when pressing play). Ghost notes are already relative to // the item window, so no absolute-session offset is applied anywhere here. if(isStandaloneSf()&&(isSfTrackEngine(synthEngine)||isVstTrackEngine(synthEngine))&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){scheduleNativeTrack(track,offsetSeconds,context,destNode,bpm,{notesOverride:midiNotes,playheadNow:()=>startOffsetTimeRef.current+(getAudioContext().currentTime-startAudioTimeRef.current),sources:activeSourcesRef.current,isActive:()=>!!isPlayingRef.current});return;// native render cả tab → audio buffer (không per-note WASM) -}midiNotes.forEach(note=>{const noteOnBeat=note.start_beat||0;const noteDurBeat=note.duration_beats||1;const noteStartSec=noteOnBeat*secondsPerBeat;const noteDurSec=noteDurBeat*secondsPerBeat;if(noteStartSec+noteDurSec>offsetSeconds){const effectiveStart=Math.max(0,noteStartSec-offsetSeconds);const effectiveDur=noteDurSec-Math.max(0,offsetSeconds-noteStartSec);const scheduledTime=startWallTime+effectiveStart;const durMs=effectiveDur*1000;if(window.SonicSF){scheduleMidiNoteDispatch(track,note.pitch||60,note.velocity||0.8,durMs,scheduledTime,instrumentProgram,destNode,mainCh,synthEngine);}scheduleNoteExpressionCC11(track,note,mainCh,startWallTime,effectiveStart,effectiveDur);}});// Play ghost notes from active tracks -var ghostLayers=st.ghostPlayLayers||[];ghostLayers.forEach(function(layer){var ghostTrack=allTracks.find(function(t){return t.id===layer.trackId;});var ghostProg=layer.instrumentProgram!==undefined?layer.instrumentProgram:ghostTrack?ghostTrack.instrumentProgram:undefined;var ghostSynth=layer.synthEngine||(ghostTrack?ghostTrack.synth_engine:undefined);if(ghostProg===undefined&&!ghostSynth)return;var ghostDest=getOrCreateTrackNode(ghostTrack,context);var ghostCh=ghostTrack?assignTrackMidiChannel(ghostTrack,allTracks):0;layer.notes.forEach(function(note){var beat=note.start_beat||0;var dur=note.duration_beats||1;var startSec=beat*secondsPerBeat;var durSec=dur*secondsPerBeat;if(startSec+durSec>offsetSeconds){var effStart=Math.max(0,startSec-offsetSeconds);var effDur=durSec-Math.max(0,offsetSeconds-startSec);var schedTime=startWallTime+effStart;var durMs=effDur*1000;if(window.SonicSF){scheduleMidiNoteDispatch(ghostTrack,note.pitch||60,note.velocity||0.8,durMs,schedTime,ghostProg,ghostDest,ghostCh,ghostSynth);}}});});};const handlePlayPause=()=>{console.log('[Play] click activeTab=',activeTab,'isPlaying=',isPlaying,'subPlaying=',subTabs.filter(s=>s.isPlaying).length);if(activeTab!=='main'&&!activeTab.startsWith('session_')){// Sub-tab playback transport +}midiNotes.forEach(note=>{const noteOnBeat=note.start_beat||0;const noteDurBeat=note.duration_beats||1;const noteStartSec=noteOnBeat*secondsPerBeat;const noteDurSec=noteDurBeat*secondsPerBeat;if(noteStartSec+noteDurSec>offsetSeconds){const effectiveStart=Math.max(0,noteStartSec-offsetSeconds);const effectiveDur=noteDurSec-Math.max(0,offsetSeconds-noteStartSec);const scheduledTime=startWallTime+effectiveStart;const durMs=effectiveDur*1000;if(window.SonicSF){scheduleMidiNoteDispatch(track,note.pitch||60,note.velocity||0.8,durMs,scheduledTime,instrumentProgram,destNode,mainCh,synthEngine,true);}scheduleNoteExpressionCC11(track,note,mainCh,startWallTime,effectiveStart,effectiveDur);}});// Play ghost notes from active tracks +var ghostLayers=st.ghostPlayLayers||[];ghostLayers.forEach(function(layer){var ghostTrack=allTracks.find(function(t){return t.id===layer.trackId;});var ghostProg=layer.instrumentProgram!==undefined?layer.instrumentProgram:ghostTrack?ghostTrack.instrumentProgram:undefined;var ghostSynth=layer.synthEngine||(ghostTrack?ghostTrack.synth_engine:undefined);if(ghostProg===undefined&&!ghostSynth)return;var ghostDest=getOrCreateTrackNode(ghostTrack,context);var ghostCh=ghostTrack?assignTrackMidiChannel(ghostTrack,allTracks):0;layer.notes.forEach(function(note){var beat=note.start_beat||0;var dur=note.duration_beats||1;var startSec=beat*secondsPerBeat;var durSec=dur*secondsPerBeat;if(startSec+durSec>offsetSeconds){var effStart=Math.max(0,startSec-offsetSeconds);var effDur=durSec-Math.max(0,offsetSeconds-startSec);var schedTime=startWallTime+effStart;var durMs=effDur*1000;if(window.SonicSF){scheduleMidiNoteDispatch(ghostTrack,note.pitch||60,note.velocity||0.8,durMs,schedTime,ghostProg,ghostDest,ghostCh,ghostSynth,true);}}});});};const handlePlayPause=()=>{console.log('[Play] click activeTab=',activeTab,'isPlaying=',isPlaying,'subPlaying=',subTabs.filter(s=>s.isPlaying).length);if(activeTab!=='main'&&!activeTab.startsWith('session_')){// Sub-tab playback transport const st=subTabs.find(s=>s.id===activeTab);if(!st||!st.buffer&&st.type!=='PIANO_ROLL')return;// MAIN/SECTION đang play (ngầm — MIDI section/main còn kêu sau khi mở // tab): Space phải STOP trước — không được play sub-tab (user: trong // PIANO ROLL không phát hiện MIDI đang play → space 2 lần mới stop). @@ -2159,13 +2270,11 @@ stopAllPlayback();const startOffset=st.currentTime||0;if(st.type==='PIANO_ROLL') animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}catch(e){console.error('[Play] Piano Roll play error:',e);showToast('Lỗi phát Piano Roll: '+e.message,'error');}}return;}const context=getAudioContext();if(isPlaying){stopAllPlayback(true);// user stop — fade out }else{startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(currentTime);setIsPlaying(true);// V28: rAF kế tiếp sau setState → đo jank main thread (chênh với play_start). requestAnimationFrame(function(){_dbgLog('play_rAF','delta_ms='+Math.round(performance.now()-_dbgPlayStartMs));});}};handlePlayPauseRef.current=handlePlayPause;const handlePianoRollRealtimePlay=trackIds=>{const prSt=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(!prSt)return;var prLayers=[];var ghostData=window.SonicGhost&&window.SonicGhost.extractGhostLayers?window.SonicGhost.extractGhostLayers(activeTracks,prSt.trackId,prSt.target_id,parseInt(bpm)||120):[];(ghostData||[]).forEach(function(layer){if(!trackIds||trackIds.indexOf(layer.track_id)===-1)return;var ltrk=(activeTracks||[]).find(function(t){return t.id===layer.track_id;});prLayers.push({trackId:layer.track_id,notes:layer.notes.map(function(n){return{pitch:n.pitch,start_beat:n.relative_start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8};}),instrumentProgram:ltrk?ltrk.instrumentProgram:undefined,instrumentName:ltrk?ltrk.instrumentName:undefined,synthEngine:ltrk?ltrk.synth_engine:undefined});});fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();const prCtx=getAudioContext();const prTNode=activeTrackNodesRef.current[prSt.trackId];if(prTNode&&prTNode.gainNode){prTNode.gainNode.gain.setValueAtTime(prTNode.gainNode.gain.value||1,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(0.001,prCtx.currentTime+0.04);}setTimeout(()=>{window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();if(prTNode&&prTNode.gainNode){const prTrackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===prSt.trackId):null;const prVolDb=prTrackData?prTrackData.volumeDb??0:0;const prVolLinear=prVolDb<=-50?0:Math.pow(10,prVolDb/20);prTNode.gainNode.gain.setValueAtTime(0.001,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(prVolLinear||0.8,prCtx.currentTime+0.015);}const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=prCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);const playTab=Object.assign({},prSt,{ghostPlayLayers:prLayers});schedulePianoRollMidi(playTab,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{ghostPlayLayers:prLayers,isPlaying:true,currentTime:prOffset}):s));},60);};const handlePause=()=>{if(isPlaying||subTabs.some(s=>s.isPlaying))stopAllPlayback(true);};const stopAllPlayback=fade=>{try{if(fxRtWrapRestart&&fxRtChain().length===0&&nativeLoopSrcsRef.current.size===0&&masterBus&&masterBus.muteGain&&masterBus.muteGain.gain){// V41: dip CHỈ khi bypass (chain rỗng). FX active → seam thật xảy ra sau -// latency (~80ms) tại _now+latency; dip tại _now chỉ tạo dropout 14ms giữa -// đuôi pass cũ (audible) mà không che được seam click. -// V42: rút ngắn dip 14ms → 5ms (2ms xuống + 3ms lên) — đủ che click seam -// nhưng dropout quá ngắn để nghe thấy (V39 14ms tạo dropout rõ). -const _actx=getAudioContext();const _now=_actx.currentTime;masterBus.muteGain.gain.cancelScheduledValues(_now);masterBus.muteGain.gain.setValueAtTime(masterBus.muteGain.gain.value||1,_now);masterBus.muteGain.gain.linearRampToValueAtTime(0.0001,_now+0.002);masterBus.muteGain.gain.linearRampToValueAtTime(1,_now+0.005);}// V39: dip input truoc hard-stop -_dbgLog('stop_start','fade='+!!fade+' '+_dbgBridgeGain());isPlayingRef.current=false;// Gap 7: xoa automation schedule — tra gain master ve 1.0 (fader rieng). +stopAllPlayback();const prCtx=getAudioContext();const prTNode=activeTrackNodesRef.current[prSt.trackId];if(prTNode&&prTNode.gainNode){prTNode.gainNode.gain.setValueAtTime(prTNode.gainNode.gain.value||1,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(0.001,prCtx.currentTime+0.04);}setTimeout(()=>{window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();if(prTNode&&prTNode.gainNode){const prTrackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===prSt.trackId):null;const prVolDb=prTrackData?prTrackData.volumeDb??0:0;const prVolLinear=prVolDb<=-50?0:Math.pow(10,prVolDb/20);prTNode.gainNode.gain.setValueAtTime(0.001,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(prVolLinear||0.8,prCtx.currentTime+0.015);}const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=prCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);const playTab=Object.assign({},prSt,{ghostPlayLayers:prLayers});schedulePianoRollMidi(playTab,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{ghostPlayLayers:prLayers,isPlaying:true,currentTime:prOffset}):s));},60);};const handlePause=()=>{if(isPlaying||subTabs.some(s=>s.isPlaying))stopAllPlayback(true);};const stopAllPlayback=(fade,skipRouting)=>{try{// D14: bỏ dip muteGain tại wrap (loop) — 5ms master→0.0001 mỗi vòng = +// âm beat 0:0 nhỏ hơn hẳn âm cuối pass (bug seam volume). Click seam giờ +// xử bằng release mượt (CC123, không CC120 hard-cut — D15) + worklet +// WRAP_FADE 256 (FX path) → không cần che bằng dropout master. +_dbgLog('stop_start','fade='+!!fade+' '+_dbgBridgeGain()+' call='+((new Error().stack||'').split('\n').slice(2,4).join('|')||'').trim());isPlayingRef.current=false;// Gap 7: xoa automation schedule — tra gain master ve 1.0 (fader rieng). try{if(masterBus&&masterBus.output&&masterBus.output.gain){const _actx=getAudioContext();masterBus.output.gain.cancelScheduledValues(_actx.currentTime);masterBus.output.gain.setValueAtTime(1,_actx.currentTime);}}catch(e){}// Realtime VST FX: dừng session + khôi phục output→analyser. Nhánh restart // (loop/seek/reschedule/solo...) set fxRtKeepForRestart=true → GIỮ session // (fxRtStart early-return khi active + chain khớp) → hết bypass+reload ~4s @@ -2178,7 +2287,9 @@ try{fxRtTrackStopAll();}catch(e){console.warn('[FX RT track] stop error:',e);}}e try{fxRtTrackStopAll();}catch(e){console.warn('[FX RT track] stop error:',e);}clearTimeout(fxRtWarmIdleTimer);fxRtWarmIdleTimer=setTimeout(()=>{fxRtWarmIdleTimer=null;try{fxRtStop();}catch(e){console.warn('[FX RT] idle stop error:',e);}},FX_RT_WARM_IDLE_MS);}_nativePlayEpoch++;// hủy mọi render/decode pending của lần play cũ // V11: hủy mọi note timer của lần play cũ (stop/seek/loop restart) — timer // cũ bắn vào vòng mới = NOTE_OFF cắt note đang kêu → crack (xem ref). -bridgeNoteEpochRef.current++;try{clearAllPendingNoteOffs();}catch(e){}_cc11Epoch++;// CC11 timers lan play cu vo hieu (da clear o tren) +bridgeNoteEpochRef.current++;try{clearAllPendingNoteOffs();}catch(e){}// V45.6: GIU timelinePitchesRef qua stop - kill branch (solo/mute) can +// per-pitch set de NOTE_OFF tung note dang keu (Qin VST2 sandbox). +_cc11Epoch++;// CC11 timers lan play cu vo hieu (da clear o tren) try{clearAllCC11Timers();}catch(e){}try{if(subTabsRef.current)subTabsRef.current=subTabsRef.current.map(s=>({...s,isPlaying:false}));}catch(e){}// Fade-out khi user nhấn Stop/Pause (fade=true): ramp gain track + bridge // xuống 0 trong FADE_MS rồi mới hard-stop — hết click, âm ngân mượt. // Loop restart/seek (fade=false) giữ stop tức thì như cũ (không fade). @@ -2205,10 +2316,26 @@ if(stale){if(_pendingFadeSession&&_pendingFadeSession.stopEpoch===stopEpoch){res // session giữ warm (play kế tiếp FX ngay, không reload VST). try{if(fade&&fxRt&&fxRt.node&&fxRt.active){fxRt.node.port.postMessage({type:'flush_for_stop'});}}catch(e){console.warn('[Stop] fx flush error:',e);}stopMidiCapture();stopAllNativeSfNotes();stopAllNativeVstNotes();// D2/D6: bridge active -> transport STOP (bridge flush toàn pitch, hết // âm ngân tức thì; A13 C++ flush note-off). -if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected){try{window.NativeBridgeService.transport('stop');}catch(e){}_dbgLog('stop_transport','epoch='+stopEpoch);// V39: MIDI panic CC123/CC120 tai wrap — VSTi note treo (khong nhan note-off) phai tat ngay. -try{if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive&&window.SonicMidiRouter.isBridgeActive()){Object.keys(trackMidiChannelsRef.current||{}).forEach(function(_tid){var _ch=trackMidiChannelsRef.current[_tid];window.NativeBridgeService.dispatchMidiEvent('CC',_ch,123,0,0,0,0,false);window.NativeBridgeService.dispatchMidiEvent('CC',_ch,120,0,0,0,0,false);});}}catch(e){console.warn('[Stop] midi panic error:',e);}// V27: xóa audio còn buffer trong pipeline (preBuffer/chunks + worklet +// D17: wrap (loop restart) — BỎ transport STOP. C++ STOP làm allNotesOff +// + CC64=0 trên cả 16 channel → cắt mọi note (kể cả note ngân qua seam) +// + có thể stall audio loop ~20ms (dbg.log: wrap play_start→first_audio +// δ17-32ms vs restart thường δ3-5ms) = "reload" tại beat 0:0. CC123 (D15) +// đã lo release JS-side; PLAY vẫn reset playhead (playheadSamples chỉ đổi +// qua transport); transportStopped giữ false → note-on pass mới không bị +// drop (V8 guard). Stop/seek thật vẫn gửi STOP. +const _wrapNoStop=!!(fxRtWrapRestart&&!stale);if(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected){if(!_wrapNoStop){try{window.NativeBridgeService.transport('stop');}catch(e){}}_dbgLog('stop_transport','epoch='+stopEpoch+(_wrapNoStop?' wrap-skip':''));// V39: MIDI panic CC123/CC120 tai wrap — VSTi note treo (khong nhan note-off) phai tat ngay. +// D15: wrap (loop) → chỉ CC123 (all-notes-off, release mượt); bỏ CC120 +// (all-sound-off, hard cut → click + âm beat 0:0 tụt). Release chồng +// lên re-noteon pass mới → seam như cùng khuôn nhạc. Stop thật giữ CC120. +try{const _wrapRelease=!!(fxRtWrapRestart&&!stale);if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive&&window.SonicMidiRouter.isBridgeActive()){Object.keys(trackMidiChannelsRef.current||{}).forEach(function(_tid){var _ch=trackMidiChannelsRef.current[_tid];window.NativeBridgeService.dispatchMidiEvent('CC',_ch,123,0,0,0,0,false);if(!_wrapRelease)window.NativeBridgeService.dispatchMidiEvent('CC',_ch,120,0,0,0,0,false);// D18d: wrap - NOTE_OFF tung pitch dang keu (timelinePitchesRef). +// CC123 khong an voi VST2 (Qin bo qua all-notes-off CC) -> note cu +// ring qua seam -> re-trigger cung pitch chong nhau = crackle. +// Per-note OFF da chung minh an (panic 128 noteOff o STOP; D18c). +if(_wrapRelease){var _tps=(timelinePitchesRef.current||{})[_tid];if(_tps&&_tps.size){_tps.forEach(function(_p){window.NativeBridgeService.dispatchMidiEvent('NOTE_OFF',_ch,_p,0,0,0,0,false);});}if(timelinePitchesRef.current)delete timelinePitchesRef.current[_tid];}});}}catch(e){console.warn('[Stop] midi panic error:',e);}// V27: xóa audio còn buffer trong pipeline (preBuffer/chunks + worklet // ring) — nếu không, âm trước stop còn trong ring phát tiếp sau fade. -try{if(window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){console.warn('[Stop] bridge flush error:',e);}// V8 bug 3: transport stop thu 2 sau 150ms — note-on timer guard sync +// D18: wrap (_wrapNoStop) — KHÔNG flush (giữ dòng liên tục qua seam); +// flush ở đây + ở startTrackPlayback = ring rỗng → gap giật beat 0:0. +if(!_wrapNoStop){try{if(window.BridgeAudioNode&&window.BridgeAudioNode.flush)window.BridgeAudioNode.flush();}catch(e){console.warn('[Stop] bridge flush error:',e);}}// V8 bug 3: transport stop thu 2 sau 150ms — note-on timer guard sync // qua React effect (cham) van bay toi bridge sau STOP → retrigger am // treo. V10: timer lưu ref — startTrackPlayback (loop restart) hủy // khi PLAY, tránh STOP#2 giết loop tiếp theo (VSTi chỉ play 1 vòng). @@ -2223,9 +2350,9 @@ _dbgLog('stop_hard','epoch='+stopEpoch+' '+_dbgBridgeGain());}catch(e){console.w // items phải tắt theo, không "diễn" tiếp). try{midiVuActivityRef.current={};}catch(e){}// Clear luôn counter note đang giữ — nếu không, sau stop (âm đã dừng) // tick vẫn thấy heldCnt > 0 → giữ peak → VU dính mãi (user bug 08:08). -try{heldMidiNotesRef.current={};}catch(e){}}catch(e){console.warn('[Stop] stopAllPlayback error:',e);}setIsPlaying(false);setSubTabs(prev=>prev.map(s=>({...s,isPlaying:false})));updateSfRouting();};const seekPlaybackTo=time=>{const isSubTab=subTabs.some(sub=>sub.id===activeTab);if(isSubTab){const st=subTabs.find(s=>s.id===activeTab);if(!st)return;if(st.isPlaying){fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:time,isPlaying:true}:s));const ctx=getAudioContext();startOffsetTimeRef.current=time;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=time*(st.speed||1.0);if(st.type==='PIANO_ROLL'){schedulePianoRollMidi(st,time);}startSubTabPlayback(st,time);}else{setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:time}:s));}}else{if(isPlaying){fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();setCurrentTime(time);startOffsetTimeRef.current=time;startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(time);setIsPlaying(true);}else{setCurrentTime(time);}}};const handleStop=()=>{if(recordingStateRef.current==='RECORDING'||recordingStateRef.current==='COUNT_IN'){stopRecordingTake();return;}stopAllPlayback(true);// user stop transport — fade out +try{heldMidiNotesRef.current={};}catch(e){}}catch(e){console.warn('[Stop] stopAllPlayback error:',e);}setIsPlaying(false);setSubTabs(prev=>prev.map(s=>({...s,isPlaying:false})));if(!skipRouting)updateSfRouting();};const seekPlaybackTo=time=>{const isSubTab=subTabs.some(sub=>sub.id===activeTab);if(isSubTab){const st=subTabs.find(s=>s.id===activeTab);if(!st)return;if(st.isPlaying){fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime +stopAllPlayback(false,true);window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:time,isPlaying:true}:s));const ctx=getAudioContext();startOffsetTimeRef.current=time;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=time*(st.speed||1.0);if(st.type==='PIANO_ROLL'){schedulePianoRollMidi(st,time);}startSubTabPlayback(st,time);}else{setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:time}:s));}}else{if(isPlaying){fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime +stopAllPlayback(false,true);setCurrentTime(time);startOffsetTimeRef.current=time;startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(time);setIsPlaying(true);}else{setCurrentTime(time);}}};const handleStop=()=>{if(recordingStateRef.current==='RECORDING'||recordingStateRef.current==='COUNT_IN'){stopRecordingTake();return;}stopAllPlayback(true);// user stop transport — fade out if(activeTab!=='main'&&!activeTab.startsWith('session_')){setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:0}:s));}else{setCurrentTime(0);}};const drawVuMeter=(canvas,db)=>{if(!canvas)return;const ctx=canvas.getContext('2d');if(!ctx)return;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);const minDb=-60;const maxDb=0;const frac=Math.max(0,Math.min(1,(db-minDb)/(maxDb-minDb)));ctx.fillStyle='#18181b';ctx.fillRect(0,0,w,h);const grad=ctx.createLinearGradient(0,0,w,0);grad.addColorStop(0,'#10b981');grad.addColorStop(0.7,'#eab308');grad.addColorStop(0.95,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(0,0,w*frac,h);if(db>=-0.5){ctx.fillStyle='#ff0000';ctx.fillRect(w-6,0,6,h);}};const handleRecordClick=async()=>{if(recordingState==='RECORDING'||recordingState==='COUNT_IN'){await stopRecordingTake();return;}// Check if piano roll tab is active and armed const activePianoRoll=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL'&&s.isArmed);if(activePianoRoll&&selectedMidiInputId){setRecordingState('COUNT_IN');const secondsPerBeat=60.0/(parseInt(bpm)||120);const countInDuration=secondsPerBeat*4;const audioCtx=getAudioContext();const now=audioCtx.currentTime;showToast('Metronome Count-in: 4... 3... 2... 1... (MIDI Piano Roll)','info');for(let i=0;i<4;i++){playMetronomeClick(now+i*secondsPerBeat,i===0);}setTimeout(()=>{startPianoRollRecording(activePianoRoll);},countInDuration*1000);return;}const armed=activeTracks.filter(t=>t.isArmed&&t.inputSource?.deviceType&&t.inputSource.deviceType!=='NONE');if(armed.length===0){showToast('Vui lòng Arm (R) ít nhất một track và chọn cổng Input để ghi âm.','warning');return;}setRecordingState('COUNT_IN');const secondsPerBeat=60.0/(parseInt(bpm)||120);const countInDuration=secondsPerBeat*4;const audioCtx=getAudioContext();const now=audioCtx.currentTime;showToast('Metronome Count-in: 4... 3... 2... 1...','info');for(let i=0;i<4;i++){playMetronomeClick(now+i*secondsPerBeat,i===0);}setTimeout(()=>{startRecordingTake(armed);},countInDuration*1000);};const startPianoRollRecording=tab=>{try{const context=getAudioContext();const secondsPerBeat=60.0/(parseInt(bpm)||120);const startTime=currentTime;const startBeat=startTime/secondsPerBeat;nextMetronomeBeatRef.current=Math.ceil(startBeat);const midiRec=new ClientMIDIRecorder(context,parseInt(bpm)||120,4);midiRec.tempTabId=tab.id;midiRec.selectedMidiInputId=selectedMidiInputId||'ALL';midiRec.start(startTime/secondsPerBeat,midiRec.selectedMidiInputId);pianoRollRecorderRef.current=midiRec;activeMIDIRecordersRef.current['piano_roll']=midiRec;setRecStartTimelineTime(startTime);recordingStartTimeRef.current=startTime;setRecordingState('RECORDING');setRecTempMidiNotes([]);const ctx=getAudioContext();const silentBuf=ctx.createBuffer(1,128,ctx.sampleRate);setSubTabs(prev=>prev.map(s=>s.id===tab.id?{...s,buffer:silentBuf,isPlaying:true}:s));startSubTabPlayback({...tab,buffer:silentBuf},startTime);startOffsetTimeRef.current=startTime;startAudioTimeRef.current=context.currentTime;setIsPlaying(true);midiRec.onNoteOn=(pitch,currentBeat)=>{const elapsedBeats=Math.max(0,currentBeat);const sec=elapsedBeats*(60.0/(parseInt(bpm)||120));const activeNotes=Array.from(midiRec.activeNotes.values()).map(n=>({id:'rec_'+n.pitch+'_'+currentBeat,pitch:n.pitch,start_beat:n.start_beat,duration_beats:Math.max(0.125,currentBeat-n.start_beat),velocity:Math.min(1,n.velocity||0.8),pan:0.0}));const rec=midiRec.recordedNotes.map((n,i)=>({id:'rec_'+Date.now()+'_'+i,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:Math.min(1,n.velocity||0.8),pan:0.0}));const allNotes=[...rec,...activeNotes];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);setSubTabs(prev=>prev.map(s=>s.id===tab.id?{...s,currentTime:startTime+sec,isDirty:true}:s));};if(!tab._recordingStarted){tab._recordingStarted=true;}showToast('Recording MIDI to Piano Roll...','info');}catch(err){console.error('startPianoRollRecording error:',err);showToast('Lỗi khi bắt đầu ghi âm Piano Roll: '+err.message,'warning');setRecordingState('IDLE');}};handleRecordClickRef.current=handleRecordClick;const startRecordingTake=async armedTracks=>{const context=getAudioContext();if(context.state==='suspended'){await context.resume();}setRecordingState('RECORDING');setRecTempMidiNotes([]);setRecTempAudioBuffer(null);const startTimelineTime=currentTime;setRecStartTimelineTime(startTimelineTime);recordingStartTimeRef.current=startTimelineTime;const secondsPerBeat=60.0/(parseInt(bpm)||120);const startBeat=startTimelineTime/secondsPerBeat;nextMetronomeBeatRef.current=Math.ceil(startBeat);activeMIDIRecordersRef.current={};activeAudioRecordersRef.current={};recordingPCMDataRef.current={};startOffsetTimeRef.current=startTimelineTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(startTimelineTime);setIsPlaying(true);let midiRecList=[];for(let track of armedTracks){if(track.inputSource.deviceType==='MIDI_KEYBOARD'){const midiRec=new ClientMIDIRecorder(context,parseInt(bpm)||120,4);const tempMidiItemId='midi_rec_'+Date.now()+'_'+track.id;midiRec.tempMidiItemId=tempMidiItemId;updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:[...(t.midiItems||[]),{id:tempMidiItemId,name:'Recording...',startTime:startTimelineTime,duration:4*(secondsPerBeat*4),notes:[]}]};}));midiRec.onNoteOn=(pitch,currentBeat)=>{const canvas=trackVuRefs.current[track.id];if(canvas){drawVuMeter(canvas,0);setTimeout(()=>drawVuMeter(canvas,-60),100);}const elapsedBeats=Math.max(0,currentBeat);const elapsedSec=elapsedBeats*secondsPerBeat;const durationSec=Math.max(4*(secondsPerBeat*4),elapsedSec);const allNotes=[...midiRec.recordedNotes,...Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}))];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);if(midiRec.tempMidiItemId){updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}};midiRec.onNoteOff=()=>{const currentBeat=(context.currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec)/(60.0/midiRec.bpm);const elapsedBeats=Math.max(0,currentBeat);const elapsedSec=elapsedBeats*secondsPerBeat;const durationSec=Math.max(4*(secondsPerBeat*4),elapsedSec);const activeNotesArray=Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}));const allNotes=[...midiRec.recordedNotes,...activeNotesArray];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);if(midiRec.tempMidiItemId){updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}};midiRec.start(startTimelineTime/(secondsPerBeat*4),track.inputSource.deviceId);activeMIDIRecordersRef.current[track.id]=midiRec;midiRecList.push({trackId:track.id,midiRec});}else if(track.inputSource.deviceType==='MICROPHONE'){const audioRec=new ClientAudioRecorder(context);try{await audioRec.initializeInput(track.inputSource.deviceId);recordingPCMDataRef.current[track.id]=[];audioRec.onLevelUpdate=db=>{const canvas=trackVuRefs.current[track.id];if(canvas){drawVuMeter(canvas,db);}};audioRec.onPCMChunk=chunk=>{if(recordingPCMDataRef.current[track.id]){const currentData=recordingPCMDataRef.current[track.id];const newData=new Float32Array(currentData.length+chunk.length);newData.set(currentData);newData.set(chunk,currentData.length);recordingPCMDataRef.current[track.id]=newData;}};const monitorGain=track.monitoringEnabled?masterBus?masterBus.input:context.destination:null;await audioRec.start(monitorGain,track.monitoringEnabled);activeAudioRecordersRef.current[track.id]=audioRec;}catch(err){console.error('Failed to initialize microphone:',err);showToast('Không khởi động được micro: '+err.message,'warning');}}}recordingSyncRef.current=setInterval(()=>{const secondsPerBeatInt=60.0/(parseInt(bpm)||120);for(let{trackId,midiRec}of midiRecList){if(!midiRec.isRecording||!midiRec.tempMidiItemId)continue;const currentTimeSec=Math.max(0,getAudioContext().currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec);const currentBeat=currentTimeSec/secondsPerBeatInt;const allNotes=[...midiRec.recordedNotes,...Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}))];const durationSec=Math.max(4*(secondsPerBeatInt*4),currentTimeSec);if(Math.random()<0.2){// Throttle log to prevent flooding (approx 2 logs/sec) console.log(`[DevLog] [MIDI Rec Sync] Temp Item ID: ${midiRec.tempMidiItemId}, Duration: ${durationSec.toFixed(2)}s, ActiveNotes: ${midiRec.activeNotes.size}, RecordedNotes: ${midiRec.recordedNotes.length}`);}setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}},100);showToast('Đang ghi âm...','info');};const stopRecordingTake=async()=>{setRecordingState('IDLE');stopAllPlayback();const context=getAudioContext();const secondsPerBeat=60.0/(parseInt(bpm)||120);const secondsPerBar=secondsPerBeat*4;const midiRecorders=activeMIDIRecordersRef.current;const audioRecorders=activeAudioRecordersRef.current;Object.keys(trackVuRefs.current).forEach(tid=>{const canvas=trackVuRefs.current[tid];if(canvas)drawVuMeter(canvas,-60);});let hasRecordedAnything=false;for(let trackId in midiRecorders){const midiRec=midiRecorders[trackId];const recordedNotes=midiRec.stop();if(midiRec.tempTabId){if(recordedNotes.length>0){const newNotes=recordedNotes.map((n,i)=>({id:'rec_'+Date.now()+'_'+i,pitch:n.pitch,start_beat:Math.max(0,n.start_beat||0),duration_beats:Math.max(0.125,n.duration_beats||0.25),velocity:Math.min(1,n.velocity||0.8),pan:0.0}));setSubTabs(prev=>prev.map(s=>s.id===midiRec.tempTabId?{...s,notes:[...(s.notes||[]),...newNotes],isDirty:true}:s));setCanvasRedrawCount(n=>n+1);showToast(`Đã ghi ${recordedNotes.length} notes vào Piano Roll.`,'success');}hasRecordedAnything=true;}else if(midiRec.tempMidiItemId){const recCurrentTimeSec=Math.max(0,context.currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec);const recElapsedBeats=recCurrentTimeSec/(60.0/midiRec.bpm);const totalDurationBeats=Math.max(4.0,recordedNotes.length>0?Math.max(recElapsedBeats,...recordedNotes.map(n=>n.start_beat+n.duration_beats)):recElapsedBeats);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const itemIndex=(t.midiItems||[]).findIndex(m=>m.id===midiRec.tempMidiItemId);if(itemIndex>=0){const updatedItems=[...t.midiItems];updatedItems[itemIndex]={...updatedItems[itemIndex],name:recordedNotes.length>0?'Recorded MIDI':'Empty MIDI',notes:recordedNotes,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar};return{...t,midiItems:updatedItems};}const newMidiItem={id:'midi_rec_'+Date.now(),name:'Recorded MIDI',parent_track_id:t.id,startTime:recordingStartTimeRef.current,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar,length_bars:Math.ceil(totalDurationBeats/4),notes:recordedNotes};return{...t,midiItems:[...(t.midiItems||[]),newMidiItem]};}));if(recordedNotes.length>0){hasRecordedAnything=true;}}else if(recordedNotes.length>0){hasRecordedAnything=true;const totalDurationBeats=Math.max(4.0,...recordedNotes.map(n=>n.start_beat+n.duration_beats));const newMidiItem={id:'midi_rec_'+Date.now(),name:'Recorded MIDI',parent_track_id:trackId,startTime:recordingStartTimeRef.current,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar,length_bars:Math.ceil(totalDurationBeats/4),notes:recordedNotes};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:[...(t.midiItems||[]),newMidiItem]};}));}}for(let trackId in audioRecorders){const audioRec=audioRecorders[trackId];const audioBuffer=await audioRec.stop();if(audioBuffer&&audioBuffer.duration>0.05){hasRecordedAnything=true;const newClip={id:'clip_rec_'+Date.now(),name:'Recorded Audio.wav',buffer:audioBuffer,startTime:recordingStartTimeRef.current,speed:1.0};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const updatedClips=[...(t.clips||[]),newClip];return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));}}if(recordingSyncRef.current){clearInterval(recordingSyncRef.current);recordingSyncRef.current=null;}activeMIDIRecordersRef.current={};activeAudioRecordersRef.current={};recordingPCMDataRef.current={};setRecTempMidiNotes([]);setRecTempAudioBuffer(null);if(hasRecordedAnything){showToast('Đã thu và lưu bản ghi vào timeline.','success');}else{showToast('Đã dừng ghi âm (không phát hiện tín hiệu đầu vào).','info');}};const handleSubTabResizeMouseDown=e=>{e.preventDefault();e.stopPropagation();const startY=e.clientY;const startHeight=subTabHeight;const handleMouseMove=moveEvent=>{const deltaY=moveEvent.clientY-startY;const newHeight=Math.max(48,Math.min(400,startHeight+deltaY));setSubTabHeight(newHeight);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};// ── Playhead set with seek+play ── @@ -2276,8 +2403,15 @@ useEffect(()=>{const handleMouseMove=e=>{const resize=resizedSectionItemRef.curr if(activeTabRef.current&&activeTabRef.current.startsWith('session_')){const _tabId=activeTabRef.current;setTimeout(()=>{try{syncSectionTabToMain(_tabId);}catch(e){}},0);}};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom,activeTab,sessionTabs]);// ── Sweep Select mousemove/mouseup ── useEffect(()=>{const handleMouseMove=e=>{if(!isSweepingRef.current)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=Math.max(0,mouseX/zoom-leadInMarginRef.current);setSweepSelect(prev=>{var updated=prev?{...prev,endTime:time}:null;sweepSelectRef.current=updated;return updated;});};const handleMouseUp=()=>{if(!isSweepingRef.current)return;isSweepingRef.current=false;sweepTrackIdRef.current=null;const sweep=sweepSelectRef.current;sweepSelectRef.current=null;captureSelectionUndo();if(sweep){const start=Math.min(sweep.startTime,sweep.endTime);const end=Math.max(sweep.startTime,sweep.endTime);if(Math.abs(end-start)<0.02){setSelectedItemIds(new Set());setSweepSelect(null);pushSelectionUndo();return;}const curTracks=activeTracksRef.current||[];const found=new Set();curTracks.forEach(t=>{(t.midiItems||[]).forEach(m=>{if(m.startTimestart){found.add(m.id);}});(t.sections||[]).forEach(s=>{if(s.startstart){found.add(s.id);}});(t.clips||[]).forEach(c=>{const dur=c.buffer?c.buffer.duration/(c.speed||1.0):4;if(c.startTimestart){const cid=c.id==='default'?'default_'+t.id:c.id;found.add(cid);}});});setSelectedItemIds(prev=>{const next=new Set(prev);found.forEach(id=>{if(next.has(id))next.delete(id);else next.add(id);});return next;});setSweepSelect(null);sweepTrackIdRef.current=null;pushSelectionUndo();}};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom]);const handleSelectRange=(start,end,reset)=>{captureSelectionUndo();const maxLen=maxDuration;const cleanStart=Math.max(0,Math.min(maxLen,start));const cleanEnd=Math.max(0,Math.min(maxLen,end));if(reset){setSelectionStart(cleanStart);setSelectionEnd(cleanEnd);}else{setSelectionEnd(cleanEnd);}setSelectionCleared(false);pushSelectionUndo();};const handleSelectionInputChange=(field,val)=>{captureSelectionUndo();const numericVal=Math.max(0,parseFloat(val)||0);if(selectionMode==='local'){if(field==='start'){setLocalSelectionStart(numericVal);}else{setLocalSelectionEnd(numericVal);}}else{if(field==='start'){setSelectionStart(numericVal);}else{setSelectionEnd(numericVal);}}pushSelectionUndo();};const selectionStats=useMemo(()=>{if(selLeft===null||selRight===null){return{start:0,end:0,length:0};}const s=Math.min(selLeft,selRight);const e=Math.max(selLeft,selRight);return{start:parseFloat(s.toFixed(3)),end:parseFloat(e.toFixed(3)),length:parseFloat((e-s).toFixed(3))};},[selLeft,selRight]);// ── Handle Drag (selection resize) ── const handleHandleDragStart=(e,side)=>{e.preventDefault();e.stopPropagation();const startX=e.clientX;const useLocal=selectionMode==='local';const currentStart=useLocal?localSelectionStart:selectionStart;const currentEnd=useLocal?localSelectionEnd:selectionEnd;const initialLeft=Math.min(currentStart,currentEnd);const initialRight=Math.max(currentStart,currentEnd);const setStart=useLocal?setLocalSelectionStart:setSelectionStart;const setEnd=useLocal?setLocalSelectionEnd:setSelectionEnd;const handleMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const deltaSec=deltaX/zoom;if(side==='left'){const newLeft=Math.max(0,Math.min(initialRight-0.05,initialLeft+deltaSec));setStart(newLeft);setEnd(initialRight);}else{const newRight=Math.max(initialLeft+0.05,Math.min(maxDuration,initialRight+deltaSec));setStart(initialLeft);setEnd(newRight);}};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleSelectionBodyDragStart=e=>{e.preventDefault();e.stopPropagation();const startX=e.clientX;const useLocal=selectionMode==='local';const currentStart=useLocal?localSelectionStart:selectionStart;const currentEnd=useLocal?localSelectionEnd:selectionEnd;const initialLeft=Math.min(currentStart,currentEnd);const initialRight=Math.max(currentStart,currentEnd);const widthSec=initialRight-initialLeft;const setStart=useLocal?setLocalSelectionStart:setSelectionStart;const setEnd=useLocal?setLocalSelectionEnd:setSelectionEnd;const handleMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const deltaSec=deltaX/zoom;let newLeft=initialLeft+deltaSec;let newRight=initialRight+deltaSec;if(newLeft<0){newLeft=0;newRight=widthSec;}if(newRight>maxDuration){newRight=maxDuration;newLeft=maxDuration-widthSec;}setStart(newLeft);setEnd(newRight);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};// ── Track Controls ── -const toggleTrackSoloEvaluate=trackId=>{const wasPlaying=isPlaying;const playingSubTab=subTabs.find(s=>s.isPlaying&&s.type==='PIANO_ROLL');fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,solo:!t.solo,muted:false}:t));setTimeout(()=>{const soloCtx=getAudioContext();if(wasPlaying){startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=soloCtx.currentTime;startBufferOffsetRef.current=currentTime;setIsPlaying(true);startTrackPlayback(currentTime);}if(playingSubTab){const prSt=subTabs.find(s=>s.id===playingSubTab.id);if(prSt){const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=soloCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);schedulePianoRollMidi(prSt,prOffset);startSubTabPlayback(prSt,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{isPlaying:true,currentTime:prOffset}):s));}}},60);setTimeout(()=>lucide.createIcons(),50);};const toggleTrackMute=trackId=>{const beforeSnap=captureTrackSnapshot(trackId);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,muted:!t.muted}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'MUTE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});setTimeout(()=>lucide.createIcons(),50);};const updateTrackProp=(trackId,props)=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,...props}:t));// Mixer fader dùng updateTrackProp({volumeDb}) — áp gain realtime cho node +const toggleTrackSoloEvaluate=trackId=>{const wasPlaying=isPlaying;try{if(window.__dbgLog)window.__dbgLog('solo_toggle','track='+trackId+' wasPlaying='+(wasPlaying?1:0)+' refSolo='+((activeTracksRef.current||[]).find(function(t){return t.id===trackId;})||{}).solo+' n='+(activeTracksRef.current||[]).length);}catch(e){}const playingSubTab=subTabs.find(s=>s.isPlaying&&s.type==='PIANO_ROLL');const wasSolo=!!((activeTracksRef.current||[]).find(function(t){return t.id===trackId;})||{}).solo;fxRtKeepForRestart=true;// restart/resume - giu session FX realtime +updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,solo:!t.solo,muted:false}:t));if(playingSubTab){// PIANO_ROLL sub-tab dang play: giu stop+restart cu (scheduler sub-tab +// can restart de ap solo moi; main session khong play song song). +stopAllPlayback();setTimeout(()=>{const soloCtx=getAudioContext();if(wasPlaying){startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=soloCtx.currentTime;startBufferOffsetRef.current=currentTime;setIsPlaying(true);startTrackPlayback(currentTime);}if(playingSubTab){const prSt=subTabs.find(s=>s.id===playingSubTab.id);if(prSt){const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=soloCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);schedulePianoRollMidi(prSt,prOffset);startSubTabPlayback(prSt,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{isPlaying:true,currentTime:prOffset}):s));}}},60);setTimeout(()=>lucide.createIcons(),50);return;}// V45.6: main-session - KHONG stopAllPlayback nua (track solo dang keu +// giu nguyen, het wobble re-attack 60ms). applyAllTrackMuteSolo tu kill +// track im (NOTE_OFF tung pitch - timelinePitchesRef giu set tu V45.5) +// + restart khi co track became audible. IPC toggleSolo chi goi ham nay +// -> tu apply luon (truoc day khong apply -> ref khong patch -> Qin khong im). +try{applyAllTrackMuteSolo(trackId,{solo:!wasSolo,muted:false});}catch(e){}setTimeout(()=>lucide.createIcons(),50);};const toggleTrackMute=trackId=>{const beforeSnap=captureTrackSnapshot(trackId);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,muted:!t.muted}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'MUTE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});setTimeout(()=>lucide.createIcons(),50);};const updateTrackProp=(trackId,props)=>{updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,...props}:t));// Mixer fader dùng updateTrackProp({volumeDb}) — áp gain realtime cho node // thường + sub-node SECTION (key _sub_) if(props&&props.volumeDb!==undefined){const volLinear=props.volumeDb<=-50?0:Math.pow(10,props.volumeDb/20);const ctx=getAudioContext();const nodes=activeTrackNodesRef.current[trackId];if(nodes)nodes.gainNode.gain.setValueAtTime(volLinear,ctx.currentTime);Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k.endsWith('_sub_'+trackId)){const sn=activeTrackNodesRef.current[k];if(sn&&sn.gainNode)sn.gainNode.gain.setValueAtTime(volLinear,ctx.currentTime);}});}setTimeout(()=>lucide.createIcons(),50);};const toggleTrackDrum=trackId=>{var mt=tracks||[];var tidx=mt.findIndex(function(tr){return tr.id===trackId;});if(tidx<0)tidx=0;setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const becomingPercussion=!t.is_percussion;let newBank=becomingPercussion?128:t._saved_sf_bank!==undefined?t._saved_sf_bank:0;let newProgram=becomingPercussion?0:t._saved_sf_program!==undefined?t._saved_sf_program:0;let newCh=becomingPercussion?9:t._saved_sf_channel!==undefined?t._saved_sf_channel:tidx%16;return{...t,is_percussion:becomingPercussion,soundfont_bank:newBank,instrumentProgram:newProgram,midiChannel:newCh,_saved_sf_bank:becomingPercussion?t.soundfont_bank:t._saved_sf_bank,_saved_sf_program:becomingPercussion?t.instrumentProgram:t._saved_sf_program,_saved_sf_channel:becomingPercussion?t.midiChannel:t._saved_sf_channel,synth_engine:t.synth_engine?{...t.synth_engine,soundfont_bank:newBank,soundfont_program:newProgram}:t.synth_engine};}));setTimeout(()=>lucide.createIcons(),50);};const updateTrackVolumeDb=(trackId,val)=>{const beforeSnap=captureTrackSnapshot(trackId);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,volumeDb:val}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'VOLUME_CHANGE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});// Real-time update during playback const volLinear=val<=-50?0:Math.pow(10,val/20);const ctx=getAudioContext();const nodes=activeTrackNodesRef.current[trackId];if(nodes){nodes.gainNode.gain.setValueAtTime(volLinear,ctx.currentTime);}// Section sub-nodes (key: _sub_) — volume slider của @@ -2595,13 +2729,13 @@ if(aiPromptMgrRef.current&&window.__aiPresetVersion!==aiPresetVersion){window.__ // (quét 8 bars hiển thị "0 đến 8"): nhập 8 → selection tới vạch 8. setSelectionEnd(t);setNumberBar(Math.max(0,b-beginBar));},className:"w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col justify-center"},/*#__PURE__*/React.createElement("span",{className:"text-[7px] text-zinc-500 font-bold uppercase"},"# Bars"),/*#__PURE__*/React.createElement("span",{className:"text-zinc-200 font-mono text-xs font-semibold mt-0.5"},numberBar))));if(panelId==='fx_rack')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('fx_rack',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-rose-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3.5 h-3.5 text-rose-400"}))," Plugin FX Rack"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('fx_rack'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 text-xs text-zinc-500 italic flex items-center justify-center"},"No FX plugins loaded"));if(panelId==='midi_events')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('midi_events',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-sky-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-3.5 h-3.5 text-sky-400"}))," MIDI Event List"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('midi_events'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 text-xs text-zinc-500 italic flex items-center justify-center"},"No MIDI events selected"));return null;};const renderDock=(pos,title)=>{const panels=dockPanels[pos];if(panels.length===0)return null;const isSide=pos==='left'||pos==='right';const borderClass=pos==='left'?'border-r':pos==='right'?'border-l':pos==='top'?'border-b':'border-t';const bgClass='bg-[#1e1e1e]';const highlight=panelDragRef.current&&panelDropZone===pos;if(pos==='right')return/*#__PURE__*/React.createElement("div",{id:"right-sidebar",className:`${borderClass} ${bgClass} flex flex-col overflow-hidden select-none h-full`,style:{width:`${rightSidebarWidth}px`,minWidth:'200px',maxWidth:'600px',flexShrink:0}},/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col gap-2 p-2 overflow-hidden h-full"},panels.map((p,idx)=>/*#__PURE__*/React.createElement(React.Fragment,{key:p},/*#__PURE__*/React.createElement("div",{className:'flex flex-col flex-1 min-h-0 border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-3'},renderPanelContent(p)),idx/*#__PURE__*/React.createElement("div",{key:p,className:`${isSide?'w-full':'flex-none w-[320px]'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2`},renderPanelContent(p))));};return/*#__PURE__*/React.createElement("div",{ref:workspaceRef,className:"flex-1 flex flex-col overflow-hidden select-none daw-bg relative"},panelDragRef.current&&panelDropZone&&/*#__PURE__*/React.createElement("div",{className:"absolute inset-0 z-50 pointer-events-none"},panelDropZone==='top'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='bottom'&&/*#__PURE__*/React.createElement("div",{className:"absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='left'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='right'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"})),dragGhostPanel&&dragGhostPos&&/*#__PURE__*/React.createElement("div",{className:"fixed z-[100] pointer-events-none opacity-80 border border-cyan-500 rounded-lg bg-[#262626] shadow-2xl p-3 w-56",style:{left:dragGhostPos.x,top:dragGhostPos.y}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-xs text-zinc-200 font-bold"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"move",className:"w-3.5 h-3.5 text-cyan-400"})),dragGhostPanel==='export'?'Export Panel':dragGhostPanel==='ai'?'AI Panel':dragGhostPanel==='python_tools'?'Audio Processing Panel':'Selection Panel'),/*#__PURE__*/React.createElement("div",{className:"text-xs text-zinc-500 mt-1"},"Drop at edge to dock")),renderDock('top','Top'),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden"},renderDock('left','Left'),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col overflow-hidden min-w-0"},/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden"},activeTab==='main'||sessionTabs.some(s=>s.id===activeTab)?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{ref:tcpContainerRef,onScroll:handleTCPScroll,className:"shrink-0 z-20 bg-[#262626] overflow-y-auto flex flex-col border-r border-zinc-900 no-scrollbar",style:{width:tcpWidth+'px',scrollbarWidth:'none',msOverflowStyle:'none'}},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-300 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3.5 h-3.5 text-cyan-400"})),"TRACKS (",activeTracks.length,")"),/*#__PURE__*/React.createElement("button",{onClick:addNewTrack,className:"px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3 h-3"}))," Add Track")),/*#__PURE__*/React.createElement("div",{className:"sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between w-full"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-purple-400 font-mono"},"TM"),/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300"},"Tempo")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("input",{type:"number",value:bpm,onChange:e=>{setBpm(e.target.value);},onBlur:e=>{const v=e.target.value;if(v&&String(bpm)!==v)setBpmWithUndo(v);localStorage.setItem('studio_bpm',bpm);},onKeyDown:e=>{if(e.key==='Enter'){e.target.blur();}},className:"w-12 bg-zinc-800 border border-zinc-700 rounded text-xs text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500",min:"40",max:"300"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500"},"BPM")))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"},activeTracks.length===0?/*#__PURE__*/React.createElement("div",{className:"p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus-circle",className:"w-8 h-8 text-cyan-400 opacity-80"})),/*#__PURE__*/React.createElement("p",{className:"text-xs font-medium"},"Chưa có Track nào trong dự án."),/*#__PURE__*/React.createElement("button",{onClick:addNewTrack,className:"px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 shadow"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus-circle",className:"w-3.5 h-3.5"}))," Thêm Track Mới")):activeTracks.map((track,idx)=>{const isSelected=selectedTrackId===track.id;const autoHeight=track.height||(track.isArmed?164:140);return/*#__PURE__*/React.createElement("div",{key:track.id,style:{height:`${autoHeight}px`},className:`shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected?'border-cyan-500 bg-[#252525]':'border-transparent hover:bg-zinc-800/20'}`,onClick:()=>{// Chỉ CHỌN track — KHÔNG tự dừng main play + chuyển sang piano roll // tab (trước đây gây "mất âm thanh khi click track number"). -setSelectedTrackId(track.id);}},/*#__PURE__*/React.createElement("div",{className:"flex items-start justify-between"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-500 font-mono"},(idx+1).toString().padStart(2,'0')),/*#__PURE__*/React.createElement("label",{className:"cursor-pointer relative block"},/*#__PURE__*/React.createElement("input",{type:"color",value:track.color||'#0f766e',onClick:e=>e.stopPropagation(),onMouseDown:e=>e.stopPropagation(),onChange:e=>{e.stopPropagation();updateTrackColor(track.id,e.target.value);},className:"absolute inset-0 w-full h-full opacity-0 cursor-pointer"}),/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",style:{backgroundColor:track.color}})),editingTrackName===track.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{updateTrackName(track.id,editNameInput||track.name);setEditingTrackName(null);},onKeyDown:e=>{if(e.key==='Enter'){updateTrackName(track.id,editNameInput||track.name);setEditingTrackName(null);}if(e.key==='Escape')setEditingTrackName(null);},onClick:e=>e.stopPropagation(),className:"text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none"}):/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors",title:"Click to rename",onClick:e=>{e.stopPropagation();setEditingTrackName(track.id);setEditNameInput(track.name);}},track.name)),/*#__PURE__*/React.createElement("div",{className:"flex flex-wrap gap-0.5 max-w-[100px] mb-0.5"},(track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',name:track.name,startTime:track.startTime}]:[]).slice(0,3).map(c=>/*#__PURE__*/React.createElement("span",{key:c.id,className:"text-[9px] font-mono text-zinc-500 bg-zinc-800/60 rounded px-0.5 truncate max-w-[90px] cursor-pointer hover:text-cyan-400 hover:bg-zinc-700",title:c.name||track.name,onClick:e=>{e.stopPropagation();setSelectedTrackId(track.id);clearLocalSelection();setSelectionMode('global');const start=c.startTime||0;const end=start+(c.buffer?c.buffer.duration:2);setSelectionStart(start);setSelectionEnd(end);showToast(`Selected: ${c.name||track.name}`,'info');}},c.name||track.name),editingClipName&&editingClipName.trackId===track.id&&editingClipName.clipId===c.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{if(editNameInput.trim())updateClipName(track.id,c.id,editNameInput.trim());setEditingClipName(null);},onKeyDown:e=>{if(e.key==='Enter'){if(editNameInput.trim())updateClipName(track.id,c.id,editNameInput.trim());setEditingClipName(null);}if(e.key==='Escape')setEditingClipName(null);},onClick:e=>e.stopPropagation(),className:"w-20 text-[9px] font-mono bg-black text-cyan-300 border border-cyan-500 rounded px-0.5 py-0 outline-none"}):/*#__PURE__*/React.createElement("button",{className:"text-[9px] text-zinc-600 hover:text-cyan-400 ml-0.5 shrink-0",title:"Sửa tên clip",onClick:e=>{e.stopPropagation();setEditingClipName({trackId:track.id,clipId:c.id});setEditNameInput(c.name||track.name);}},/*#__PURE__*/React.createElement("i",{"data-lucide":"pencil",className:"w-2.5 h-2.5"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMute(track.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:!track.muted});},title:"Mute",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.muted?'bg-red-950 text-red-400 border-red-700':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.muted?"volume-x":"volume-2",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackSoloEvaluate(track.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:!track.solo});},title:"Solo",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.solo?'bg-amber-950 text-amber-400 border-amber-600':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.solo?"headphones":"headphone-off",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackDrum(track.id);},title:track.is_percussion?"Drum Channel (CH 10) - Click to disable":"Toggle Drum Channel (CH 10)",className:`px-1.5 py-0.5 text-[9px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.is_percussion?'bg-rose-900 text-rose-300 border-rose-700':'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`},/*#__PURE__*/React.createElement("span",{className:"text-[11px]"},"🥁"),track.is_percussion?/*#__PURE__*/React.createElement("span",{className:"text-[9px]"},"D"):null),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackArm(track.id);},title:"ARM (Record)",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.isArmed?'bg-red-600 text-white border-red-500 hover:bg-red-500':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":"circle",className:`w-2.5 h-2.5 ${track.isArmed?'fill-white':''}`})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMonitor(track.id);},title:"Input Monitor",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.monitoringEnabled?'bg-amber-600 text-white border-amber-500 hover:bg-amber-500':'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-305'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.monitoringEnabled?"mic":"mic-off",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();deleteTrack(track.id);},className:"p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3 h-3"}))))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-0.5 text-xs",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('vol:'+track.id,e.deltaY,e.deltaMode,100);if(!steps)return;const nv=Math.max(-50,Math.min(7,(track.volumeDb??0)+steps*0.5));updateTrackVolumeDb(track.id,nv);},onDoubleClick:e=>{e.stopPropagation();updateTrackVolumeDb(track.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-xs"},"Vol:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-50",max:"7",step:"0.5",value:track.volumeDb??0,onChange:e=>updateTrackVolumeDb(track.id,parseFloat(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-12 text-right font-mono text-zinc-300 text-xs"},track.volumeDb??0,"dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('pan:'+track.id,e.deltaY,e.deltaMode,100);if(!steps)return;const np=Math.max(-100,Math.min(100,(track.pan??0)+steps*5));updateTrackPan(track.id,np);},onDoubleClick:e=>{e.stopPropagation();updateTrackPan(track.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-xs"},"Pan:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",step:"1",value:track.pan??0,onChange:e=>updateTrackPan(track.id,parseInt(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-12 text-right font-mono text-zinc-300 text-xs"},track.pan>0?'R'+track.pan:track.pan<0?'L'+Math.abs(track.pan):'C')),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 mt-0.5"},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-[10px]"},"In:"),/*#__PURE__*/React.createElement("select",{value:`${track.inputSource?.deviceType||'NONE'}:${track.inputSource?.deviceId||''}`,onChange:e=>{const val=e.target.value;const parts=val.split(':');const type=parts[0];const id=parts.slice(1).join(':');updateTrackInputSource(track.id,type,id);},className:"flex-1 bg-[#18181b] text-zinc-300 text-[10px] rounded border border-zinc-700 focus:outline-none py-0.5 px-1 truncate max-w-[120px]"},/*#__PURE__*/React.createElement("option",{value:"NONE:"},"No Input"),/*#__PURE__*/React.createElement("optgroup",{label:"Microphones"},audioDevices.map(d=>/*#__PURE__*/React.createElement("option",{key:d.deviceId,value:`MICROPHONE:${d.deviceId}`},d.label||`Microphone ${d.deviceId.slice(0,5)}`))),/*#__PURE__*/React.createElement("optgroup",{label:"MIDI Keyboards"},/*#__PURE__*/React.createElement("option",{value:"MIDI_KEYBOARD:ALL"},"Any MIDI Keyboard"),midiDevices.map(d=>/*#__PURE__*/React.createElement("option",{key:d.id,value:`MIDI_KEYBOARD:${d.id}`},d.name||`MIDI Input ${d.id.slice(0,5)}`)))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-0.5 w-16 shrink-0 ml-1"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 text-[9px]"},"VU"),/*#__PURE__*/React.createElement("canvas",{ref:el=>{// TÁCH VU MAIN vs SECTION-TAB (user 07:20): section clone id TRÙNG +setSelectedTrackId(track.id);}},/*#__PURE__*/React.createElement("div",{className:"flex items-start justify-between"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-500 font-mono"},(idx+1).toString().padStart(2,'0')),/*#__PURE__*/React.createElement("label",{className:"cursor-pointer relative block"},/*#__PURE__*/React.createElement("input",{type:"color",value:track.color||'#0f766e',onClick:e=>e.stopPropagation(),onMouseDown:e=>e.stopPropagation(),onChange:e=>{e.stopPropagation();updateTrackColor(track.id,e.target.value);},className:"absolute inset-0 w-full h-full opacity-0 cursor-pointer"}),/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",style:{backgroundColor:track.color}})),editingTrackName===track.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{updateTrackName(track.id,editNameInput||track.name);setEditingTrackName(null);},onKeyDown:e=>{if(e.key==='Enter'){updateTrackName(track.id,editNameInput||track.name);setEditingTrackName(null);}if(e.key==='Escape')setEditingTrackName(null);},onClick:e=>e.stopPropagation(),className:"text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none"}):/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors",title:"Click to rename",onClick:e=>{e.stopPropagation();setEditingTrackName(track.id);setEditNameInput(track.name);}},track.name)),/*#__PURE__*/React.createElement("div",{className:"flex flex-wrap gap-0.5 max-w-[100px] mb-0.5"},(track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',name:track.name,startTime:track.startTime}]:[]).slice(0,3).map(c=>/*#__PURE__*/React.createElement("span",{key:c.id,className:"text-[9px] font-mono text-zinc-500 bg-zinc-800/60 rounded px-0.5 truncate max-w-[90px] cursor-pointer hover:text-cyan-400 hover:bg-zinc-700",title:c.name||track.name,onClick:e=>{e.stopPropagation();setSelectedTrackId(track.id);clearLocalSelection();setSelectionMode('global');const start=c.startTime||0;const end=start+(c.buffer?c.buffer.duration:2);setSelectionStart(start);setSelectionEnd(end);showToast(`Selected: ${c.name||track.name}`,'info');}},c.name||track.name),editingClipName&&editingClipName.trackId===track.id&&editingClipName.clipId===c.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{if(editNameInput.trim())updateClipName(track.id,c.id,editNameInput.trim());setEditingClipName(null);},onKeyDown:e=>{if(e.key==='Enter'){if(editNameInput.trim())updateClipName(track.id,c.id,editNameInput.trim());setEditingClipName(null);}if(e.key==='Escape')setEditingClipName(null);},onClick:e=>e.stopPropagation(),className:"w-20 text-[9px] font-mono bg-black text-cyan-300 border border-cyan-500 rounded px-0.5 py-0 outline-none"}):/*#__PURE__*/React.createElement("button",{className:"text-[9px] text-zinc-600 hover:text-cyan-400 ml-0.5 shrink-0",title:"Sửa tên clip",onClick:e=>{e.stopPropagation();setEditingClipName({trackId:track.id,clipId:c.id});setEditNameInput(c.name||track.name);}},/*#__PURE__*/React.createElement("i",{"data-lucide":"pencil",className:"w-2.5 h-2.5"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMute(track.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:!track.muted});},title:"Mute",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.muted?'bg-red-950 text-red-400 border-red-700':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.muted?"volume-x":"volume-2",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackSoloEvaluate(track.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:!track.solo});},title:"Solo",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.solo?'bg-amber-950 text-amber-400 border-amber-600':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.solo?"headphones":"headphone-off",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackDrum(track.id);},title:track.is_percussion?"Drum Channel (CH 10) - Click to disable":"Toggle Drum Channel (CH 10)",className:`px-1.5 py-0.5 text-[9px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.is_percussion?'bg-rose-900 text-rose-300 border-rose-700':'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`},/*#__PURE__*/React.createElement("span",{className:"text-[11px]"},"🥁"),track.is_percussion?/*#__PURE__*/React.createElement("span",{className:"text-[9px]"},"D"):null),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackArm(track.id);},title:"ARM (Record)",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.isArmed?'bg-red-600 text-white border-red-500 hover:bg-red-500':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":"circle",className:`w-2.5 h-2.5 ${track.isArmed?'fill-white':''}`})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMonitor(track.id);},title:"Input Monitor",className:`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.monitoringEnabled?'bg-amber-600 text-white border-amber-500 hover:bg-amber-500':'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-305'}`},/*#__PURE__*/React.createElement("i",{"data-lucide":track.monitoringEnabled?"mic":"mic-off",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();deleteTrack(track.id);},className:"p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3 h-3"}))))),function(){const _ls=window.__trackLoadState&&window.__trackLoadState[track.id];if(_ls&&_ls.state==='loading'){return/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 mt-0.5",title:"Đang nạp instrument vào bridge — track sẽ phát sau khi load xong"},/*#__PURE__*/React.createElement("div",{className:"flex-1 h-1 rounded bg-zinc-800 overflow-hidden"},/*#__PURE__*/React.createElement("div",{className:"h-full rounded sf-ruler-bar",style:{background:'#06b6d4',width:'45%'}})),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-cyan-400 font-mono shrink-0"},"Loading…"));}if(_ls&&_ls.state==='failed'){return/*#__PURE__*/React.createElement("div",{className:"mt-0.5 text-[9px] text-red-500 font-mono"},"Load failed");}if(_ls&&_ls.state==='done'&&_ls.doneTs&&Date.now()-_ls.doneTs<2500){return/*#__PURE__*/React.createElement("div",{className:"mt-0.5 h-1 rounded bg-green-600/30 overflow-hidden"},/*#__PURE__*/React.createElement("div",{className:"h-full bg-green-500 sf-ruler-done"}));}return null;}(),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-0.5 text-xs",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('vol:'+track.id,e.deltaY,e.deltaMode,100);if(!steps)return;const nv=Math.max(-50,Math.min(7,(track.volumeDb??0)+steps*0.5));updateTrackVolumeDb(track.id,nv);},onDoubleClick:e=>{e.stopPropagation();updateTrackVolumeDb(track.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-xs"},"Vol:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-50",max:"7",step:"0.5",value:track.volumeDb??0,onChange:e=>updateTrackVolumeDb(track.id,parseFloat(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-12 text-right font-mono text-zinc-300 text-xs"},track.volumeDb??0,"dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('pan:'+track.id,e.deltaY,e.deltaMode,100);if(!steps)return;const np=Math.max(-100,Math.min(100,(track.pan??0)+steps*5));updateTrackPan(track.id,np);},onDoubleClick:e=>{e.stopPropagation();updateTrackPan(track.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-xs"},"Pan:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",step:"1",value:track.pan??0,onChange:e=>updateTrackPan(track.id,parseInt(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-12 text-right font-mono text-zinc-300 text-xs"},track.pan>0?'R'+track.pan:track.pan<0?'L'+Math.abs(track.pan):'C')),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 mt-0.5"},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-[10px]"},"In:"),/*#__PURE__*/React.createElement("select",{value:`${track.inputSource?.deviceType||'NONE'}:${track.inputSource?.deviceId||''}`,onChange:e=>{const val=e.target.value;const parts=val.split(':');const type=parts[0];const id=parts.slice(1).join(':');updateTrackInputSource(track.id,type,id);},className:"flex-1 bg-[#18181b] text-zinc-300 text-[10px] rounded border border-zinc-700 focus:outline-none py-0.5 px-1 truncate max-w-[120px]"},/*#__PURE__*/React.createElement("option",{value:"NONE:"},"No Input"),/*#__PURE__*/React.createElement("optgroup",{label:"Microphones"},audioDevices.map(d=>/*#__PURE__*/React.createElement("option",{key:d.deviceId,value:`MICROPHONE:${d.deviceId}`},d.label||`Microphone ${d.deviceId.slice(0,5)}`))),/*#__PURE__*/React.createElement("optgroup",{label:"MIDI Keyboards"},/*#__PURE__*/React.createElement("option",{value:"MIDI_KEYBOARD:ALL"},"Any MIDI Keyboard"),midiDevices.map(d=>/*#__PURE__*/React.createElement("option",{key:d.id,value:`MIDI_KEYBOARD:${d.id}`},d.name||`MIDI Input ${d.id.slice(0,5)}`)))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-0.5 w-16 shrink-0 ml-1"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 text-[9px]"},"VU"),/*#__PURE__*/React.createElement("canvas",{ref:el=>{// TÁCH VU MAIN vs SECTION-TAB (user 07:20): section clone id TRÙNG // main id → key canvas trùng → VU main/section lẫn nhau. Section // strip dùng key '_sess_' — VU tick parse theo key. const _vuK=track._isSectionClone?'_sess_'+track.id:track.id;if(el)trackVuRefs.current[_vuK]=el;else delete trackVuRefs.current[_vuK];},width:100,height:4,className:"flex-1 bg-[#18181b] rounded h-1"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 mt-1",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("input",{type:"file",id:`upload-${track.id}`,accept:"audio/*",className:"hidden",onChange:e=>loadFileOnTrack(track.id,e.target.files[0])}),/*#__PURE__*/React.createElement("label",{htmlFor:`upload-${track.id}`,className:"px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-xs flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"upload",className:"w-3 h-3"}))," File"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();window.__openFxRack(track.id,track.name);},title:"Mở Track FX Chain",className:"px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-xs font-bold flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"wand-2",className:"w-3 h-3"}))," FX: ",/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 font-normal"},track.fxType||"None"),/*#__PURE__*/React.createElement("span",{title:"FX nhanh (cũ)",className:"ml-0.5 text-zinc-500 hover:text-purple-300 cursor-pointer",onClick:e=>{e.stopPropagation();setFxSelectorTrackId(track.id);}},"▾")),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();openInstrumentSelector(track.id);},className:"px-1.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-xs font-bold flex items-center gap-1 max-w-[90px] shrink-0"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-2.5 h-2.5 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"truncate text-xs min-w-0"},track.instrumentName||track.instrumentId||"Synth"),/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-down",className:"w-3 h-3 shrink-0"})),track.instrumentId&&typeof track.instrumentId==='string'&&!track.instrumentId.startsWith('sf_')?/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();ensureAndOpenVstGui(track);},title:"Mở lại GUI VSTi",className:"px-1.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-violet-300 border border-violet-800 rounded text-xs font-bold flex items-center gap-1 shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders-horizontal",className:"w-3 h-3 shrink-0"}),/*#__PURE__*/React.createElement("span",{className:"truncate text-xs min-w-0"},"GUI")):null),/*#__PURE__*/React.createElement("div",{onMouseDown:e=>handleTrackResizeMouseDown(e,track.id),className:"absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors",onClick:e=>e.stopPropagation()}));})),/*#__PURE__*/React.createElement("div",{className:"h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0"})),/*#__PURE__*/React.createElement("div",{className:"w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",onMouseDown:startTcpResize}),/*#__PURE__*/React.createElement("div",{ref:handleTimelineWrapperRef,onScroll:handleTimelineScroll,className:"flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${timelineWidth}px`},className:"relative flex flex-col min-h-full"},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-30 bg-[#1a1a1a]"},/*#__PURE__*/React.createElement(TimelineRuler,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:timelineWidth,viewportWidth:viewportWidth,onPlayheadSet:handlePlayheadSet,snapValue:snapValue,onRulerMouseDown:e=>{setSelectionFollowsTempo(false);handleRulerMouseDown(e);},scrollLeft:scrollLeft,canvasRedrawCount:canvasRedrawCount}),/*#__PURE__*/React.createElement(TempoTrackLane,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:timelineWidth,viewportWidth:viewportWidth,onPlayheadSet:handlePlayheadSet,snapValue:snapValue,onRulerMouseDown:e=>{setSelectionFollowsTempo(true);handleRulerMouseDown(e);},scrollLeft:scrollLeft,canvasRedrawCount:canvasRedrawCount})),selectionMode==='global'&&selLeft!==null&&selRight!==null&&selRight>selLeft&&/*#__PURE__*/React.createElement("div",{className:"absolute inset-0 pointer-events-none z-20",style:{left:`${selLeft*zoom}px`,width:`${(selRight-selLeft)*zoom}px`,top:'80px'}},/*#__PURE__*/React.createElement("div",{className:"w-full h-full bg-amber-500/10",style:{borderLeft:'1px solid #f59e0b',borderRight:'1px solid #f59e0b'}})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full",onDragOver:e=>e.preventDefault(),onDrop:async e=>{e.preventDefault();// Media Explorer drag (client FS handle / server file_id / local path) if(window.__mediaExplorerDragFile){const f=await resolveMediaExplorerDropFile();window.__mediaExplorerDragFile=null;if(!f)return;if(/\.mid$|\.midi$/i.test(f.name||'')){handleDropMidiToNewTracks(f);}else if(selectedTrackId){loadFileOnTrack(selectedTrackId,f);}return;}const f=e.dataTransfer.files&&e.dataTransfer.files[0];if(!f)return;if(/\.mid$|\.midi$/i.test(f.name||'')){handleDropMidiToNewTracks(f);}else if(selectedTrackId){loadFileOnTrack(selectedTrackId,f);}},onMouseDown:e=>{if(e.ctrlKey&&!e.shiftKey&&!e.altKey&&e.button===0){const wrapper=timelineWrapperRef.current;if(wrapper){const rect=wrapper.getBoundingClientRect();const sl=wrapper.scrollLeft;const raw=Math.max(0,(e.clientX-rect.left+sl)/zoom-leadInMargin);const time=snapValue!=='free'?snapTime(raw,snapValue,bpm):raw;handleSweepSelectStart(null,time);}}}},activeTracks.map((track,idx)=>{const isSelected=selectedTrackId===track.id;const autoHeight=track.height||(track.isArmed?164:140);return/*#__PURE__*/React.createElement("div",{key:track.id,style:{height:`${autoHeight}px`},className:`shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected?'bg-zinc-800/10':''}`,onDragOver:e=>e.preventDefault(),onDrop:async e=>{e.preventDefault();e.stopPropagation();// Media Explorer drag (client FS handle / server file_id / local path) if(window.__mediaExplorerDragFile){const f=await resolveMediaExplorerDropFile();window.__mediaExplorerDragFile=null;if(f)loadFileOnTrack(track.id,f);return;}const f=e.dataTransfer.files&&e.dataTransfer.files[0];if(!f)return;loadFileOnTrack(track.id,f);},onMouseEnter:()=>{setHoveredTrackId(track.id);hoveredTrackIdRef.current=track.id;}},/*#__PURE__*/React.createElement(WaveformLane,{track:track,zoom:zoom,timelineWidth:timelineWidth,viewportWidth:viewportWidth,scrollLeft:scrollLeft,onSelectRange:handleSelectRange,onPlayheadSet:handlePlayheadSet,isSelected:isSelected,selectedItemIds:selectedItemIds,onSelectTrack:setSelectedTrackId,markers:track.markers,onTrackLaneMouseDown:handleTrackLaneMouseDown,onClearSelection:()=>{captureSelectionUndo();setSelectedItemIds(new Set());},onSweepSelectStart:handleSweepSelectStart,onDeselectItem:handleDeselectItem,onAddToSelection:handleAddToSelection,onSetPendingDrag:handleSetPendingDrag,onSetPendingDragMove:handleSetPendingDragMove,onContextMenu:handleContextMenu,onClipDragStart:handleClipDragStart,onClipStretchStart:handleClipStretchStart,onSectionItemDragStart:handleSectionItemDragStart,onSectionItemResizeStart:handleSectionItemResizeStart,onSelectionEdgeDragStart:handleSelectionEdgeDragStart,setSelectedClipId:setSelectedClipId,selectedClipId:selectedClipId,activeTool:activeTool,onSplitTrackAtTime:handleSplitTrackAtTime,onEditClipInSubTab:handleEditClipInSubTab,onEditSectionInTab:handleEditSectionInTab,onEditMidiInTab:handleEditMidiInTab,snapValue:snapValue,bpm:bpm,selectionMode:selectionMode,localSelectionTrackId:localSelectionTrackId,localSelectionStart:localSelectionStart,currentTime:currentTime,getLocalAnchor:()=>localSelectionAnchorRef.current,onClearLocalSelection:()=>{captureSelectionUndo();clearLocalSelection();},onSetSelectionMode:mode=>{captureSelectionUndo();setSelectionMode(mode);},onSetSelectionStart:val=>{captureSelectionUndo();setSelectionStart(val);},onSetSelectionEnd:val=>{captureSelectionUndo();setSelectionEnd(val);},onSetCurrentTime:setCurrentTime,onSetLocalSelectionTrackId:setLocalSelectionTrackId,onSetLocalSelectionStart:setLocalSelectionStart,onSetLocalSelectionEnd:setLocalSelectionEnd,localSelLeft:localSelectionStart!==null&&localSelectionEnd!==null?Math.min(localSelectionStart,localSelectionEnd):null,localSelRight:localSelectionStart!==null&&localSelectionEnd!==null?Math.max(localSelectionStart,localSelectionEnd):null,scrollLeft:scrollLeft,recordingState:recordingState,recTempMidiNotes:recTempMidiNotes,recTempAudioBuffer:recTempAudioBuffer,recStartTimelineTime:recStartTimelineTime,canvasRedrawCount:canvasRedrawCount}),selectionMode==='local'&&localSelectionTrackId===track.id&&localSelectionStart!==null&&localSelectionEnd!==null&&Math.abs(localSelectionEnd-localSelectionStart)>0&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",style:{left:`${Math.min(localSelectionStart,localSelectionEnd)*zoom}px`,width:`${Math.abs(localSelectionEnd-localSelectionStart)*zoom}px`}},/*#__PURE__*/React.createElement("div",{className:"absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",onMouseDown:e=>handleHandleDragStart(e,'left')}),/*#__PURE__*/React.createElement("div",{className:"absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",onMouseDown:e=>handleHandleDragStart(e,'right')})),sweepSelect&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/10 z-20 pointer-events-none",style:{left:`${Math.min(sweepSelect.startTime,sweepSelect.endTime)*zoom}px`,width:`${Math.abs(sweepSelect.endTime-sweepSelect.startTime)*zoom}px`}}),track.buffer&&/*#__PURE__*/React.createElement("div",{className:"absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100"},/*#__PURE__*/React.createElement("button",{onClick:()=>handleSplitTrack(track.id),className:"px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-xs flex items-center gap-1 border border-zinc-700/50"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-2.5 h-2.5 text-cyan-400"}))," Cắt")),/*#__PURE__*/React.createElement("div",{onMouseDown:e=>handleTrackResizeMouseDown(e,track.id),className:"absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors",onClick:e=>e.stopPropagation()}));}),/*#__PURE__*/React.createElement("div",{className:"h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800",onMouseEnter:()=>{if(draggedClipRef.current||draggedSectionItemRef.current){setHoveredTrackId(addNewTrack());}},onClick:addNewTrack},/*#__PURE__*/React.createElement("span",{className:"flex items-center gap-1 text-zinc-400"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3.5 h-3.5"}))," Kéo clip xuống hoặc Click tạo Track")),selectionMode==='global'&&selLeft!==null&&selRight!==null&&selRight>selLeft&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing",style:{left:`${selLeft*zoom}px`,width:`${(selRight-selLeft)*zoom}px`},onMouseDown:handleSelectionBodyDragStart},/*#__PURE__*/React.createElement("div",{className:"absolute -left-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30",onMouseDown:e=>handleHandleDragStart(e,'left')}),/*#__PURE__*/React.createElement("div",{className:"absolute -right-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30",onMouseDown:e=>handleHandleDragStart(e,'right')})),/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none",style:{left:`${playheadLeftPos}px`}},/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"})))))):(()=>{const st=subTabs.find(s=>s.id===activeTab);if(!st)return null;if(st.type==='PIANO_ROLL'){return/*#__PURE__*/React.createElement(PianoRollTabEditor,{st:st,zoom:zoom,bpm:bpm,viewportWidth:viewportWidth,activeTracks:activeTracks,onClose:()=>closeSubTab(st.id),onUpdateNotes:handleUpdateMidiNotes,onSaveNotes:handleSaveMidiNotes,setSubTabs:setSubTabs,onPlayPause:handlePlayPause,onStop:()=>stopAllPlayback(true),isPlaying:isPlaying,playPreviewNote:playMidiPreviewNote,showToast:showToast,midiDevices:midiDevices,recordingState:recordingState,recTempMidiNotes:recTempMidiNotes,onRecord:handleRecordClick,selectedMidiInputId:selectedMidiInputId,onMidiInputSelect:handleMidiInputSelect,activeMidiPitches:activeMidiPitches,onInstrumentSelect:trackId=>{openInstrumentSelector(trackId);},onRealtimePlay:handlePianoRollRealtimePlay,onCopyNotes:n=>setPianoRollClipboard(JSON.parse(JSON.stringify(n||[]))),clipboardNotes:pianoRollClipboard,onExportMidiAudio:handleExportMidiAudio,onRescheduleMidi:updatedNotes=>{const playingSub=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL'&&s.isPlaying);if(playingSub){const offset=playingSub.currentTime||0;const ctx=getAudioContext();const tNode=activeTrackNodesRef.current[playingSub.trackId];if(tNode&&tNode.gainNode){tNode.gainNode.gain.setValueAtTime(tNode.gainNode.gain.value||1,ctx.currentTime);tNode.gainNode.gain.linearRampToValueAtTime(0.001,ctx.currentTime+0.04);}setTimeout(()=>{window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();if(tNode&&tNode.gainNode){const trackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===playingSub.trackId):null;const volDb=trackData?trackData.volumeDb??0:0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);tNode.gainNode.gain.setValueAtTime(0.001,ctx.currentTime);tNode.gainNode.gain.linearRampToValueAtTime(volLinear||0.8,ctx.currentTime+0.015);}startOffsetTimeRef.current=offset;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=offset*(playingSub.speed||1.0);schedulePianoRollMidi(playingSub,offset,updatedNotes);},50);}},onSeekPlayhead:clickTime=>{const seekSt=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(!seekSt)return;if(seekSt.isPlaying){fxRtKeepForRestart=true;// restart/resume — giữ session FX realtime -stopAllPlayback();window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();setSubTabs(prev=>prev.map(s=>s.id===seekSt.id?{...s,currentTime:clickTime,isPlaying:true}:s));const ctx=getAudioContext();startOffsetTimeRef.current=clickTime;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=clickTime*(seekSt.speed||1.0);schedulePianoRollMidi(seekSt,clickTime);startSubTabPlayback(seekSt,clickTime);}else{setSubTabs(prev=>prev.map(s=>s.id===seekSt.id?{...s,currentTime:clickTime}:s));}}});}const subTrack=tracks.find(t=>t.id===st.trackId);const vTrack=subTrack?{...subTrack,buffer:st.buffer,isSubTab:true}:null;const subTabDuration=st.type==='PIANO_ROLL'?(()=>{const notes=st.notes||[];const beatSec=60.0/(parseFloat(bpm)||120);let maxEnd=0;notes.forEach(n=>{const end=(n.start_beat||0)+(n.duration_beats||1);if(end>maxEnd)maxEnd=end;});return maxEnd*beatSec+1.0;})():st.buffer&&'duration'in st.buffer?st.buffer.duration:4.0;const subTabTimelineWidth=Math.max(zoom*subTabDuration,viewportWidth);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",style:{width:tcpWidth+'px',scrollbarWidth:'none',msOverflowStyle:'none'}},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-500 uppercase"},"Sub-Tab"),/*#__PURE__*/React.createElement("button",{onClick:()=>closeSubTab(st.id),className:"px-1.5 py-0.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))," Close")),vTrack?/*#__PURE__*/React.createElement("div",{key:vTrack.id,className:"flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between mb-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("label",{className:"cursor-pointer relative block"},/*#__PURE__*/React.createElement("input",{type:"color",value:vTrack.color||'#0f766e',onClick:e=>e.stopPropagation(),onMouseDown:e=>e.stopPropagation(),onChange:e=>{e.stopPropagation();updateTrackColor(vTrack.id,e.target.value);},className:"absolute inset-0 w-full h-full opacity-0 cursor-pointer"}),/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",style:{backgroundColor:vTrack.color}})),editingTrackName===vTrack.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{updateTrackName(vTrack.id,editNameInput||vTrack.name);setEditingTrackName(null);},onKeyDown:e=>{if(e.key==='Enter'){updateTrackName(vTrack.id,editNameInput||vTrack.name);setEditingTrackName(null);}if(e.key==='Escape')setEditingTrackName(null);},onClick:e=>e.stopPropagation(),className:"text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none"}):/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors",title:"Click to rename",onClick:e=>{e.stopPropagation();setEditingTrackName(vTrack.id);setEditNameInput(vTrack.name);}},vTrack.name)),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMute(vTrack.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(vTrack.id,{muted:!vTrack.muted});},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.muted?'bg-red-950 text-red-400 border-red-700':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"M"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackSoloEvaluate(vTrack.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(vTrack.id,{solo:!vTrack.solo});},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.solo?'bg-amber-950 text-amber-400 border-amber-600':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"S"))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-2.5 text-[14px] mb-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('vol:'+vTrack.id,e.deltaY,e.deltaMode,100);if(!steps)return;const nv=Math.max(-50,Math.min(7,(vTrack.volumeDb??0)+steps*0.5));updateTrackVolumeDb(vTrack.id,nv);},onDoubleClick:e=>{e.stopPropagation();updateTrackVolumeDb(vTrack.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Vol:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-50",max:"7",step:"0.5",id:`tcp-vol-${st.id}`,value:vTrack.volumeDb??0,onChange:e=>updateTrackVolumeDb(vTrack.id,parseFloat(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-16 text-right font-mono text-zinc-300 text-[14px]",id:`tcp-vol-label-${st.id}`},vTrack.volumeDb??0,"dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('pan:'+vTrack.id,e.deltaY,e.deltaMode,100);if(!steps)return;const np=Math.max(-100,Math.min(100,(vTrack.pan??0)+steps*5));updateTrackPan(vTrack.id,np);},onDoubleClick:e=>{e.stopPropagation();updateTrackPan(vTrack.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Pan:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",step:"1",id:`tcp-pan-${st.id}`,value:vTrack.pan??0,onChange:e=>updateTrackPan(vTrack.id,parseInt(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-16 text-right font-mono text-zinc-300 text-[14px]",id:`tcp-pan-label-detailed-${st.id}`},vTrack.pan>0?'R'+vTrack.pan:vTrack.pan<0?'L'+Math.abs(vTrack.pan):'C')),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 text-[14px]"},/*#__PURE__*/React.createElement("button",{onClick:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,isLooping:!s.isLooping}:s)),className:`px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping?'bg-amber-700 text-white border-amber-500':'bg-zinc-800 text-zinc-400 border-zinc-700'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>updateSubTabEffects(st.id,{reverse:!(st.effects||{}).reverse}),className:`px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects||{}).reverse?'bg-zinc-600 text-white border-zinc-500':'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`,title:"Reverse"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-left-right",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Loop:"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",max:"999",value:st.loopCount||0,onChange:e=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,loopCount:Math.max(0,parseInt(e.target.value)||0)}:s)),className:"w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono",title:"Loop count"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase mt-2"},/*#__PURE__*/React.createElement("span",null,"DSP"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-600 text-[11px] normal-case"},"áp dụng vùng chọn / cả clip")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-1 mb-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'invert_phase',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-down-up",className:"w-3 h-3"})),"Phase Inv"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'swap_channels',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"shuffle",className:"w-3 h-3"})),"Swap L/R"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'reverse',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-left-right",className:"w-3 h-3"})),"Reverse")),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 justify-between my-2.5"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Normalize"},"Norm"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",step:"0.1",value:subTabNormVal,onChange:e=>setSubTabNormVal(parseFloat(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabNormVal,"dB"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'normalize',subTabNormVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply")),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Pitch Shift"},"Pitch"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"12",step:"0.5",value:subTabPitchVal,onChange:e=>setSubTabPitchVal(parseFloat(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabPitchVal>0?'+':'',subTabPitchVal,"st"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'pitch',subTabPitchVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply")),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Gain Multiplier"},"Gain"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0",max:"150",step:"1",value:subTabGainVal,onChange:e=>setSubTabGainVal(parseInt(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabGainVal,"%"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'gain',subTabGainVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply"))),/*#__PURE__*/React.createElement("div",{className:"flex-1"}),/*#__PURE__*/React.createElement("div",{className:"mt-auto pt-2.5 border-t border-zinc-800"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase"},/*#__PURE__*/React.createElement("span",null,"Duration:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-300"},st.buffer?formatTime(subTabDuration):'0s')),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase"},/*#__PURE__*/React.createElement("span",null,"SR:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-300"},st.buffer?st.buffer.sampleRate:0," Hz")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1 mb-2"},/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;handleAIScan();},disabled:analysisState.isRunning,className:"py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"map-pin",className:"w-3 h-3"}))," Scan"),/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;handleAICutToNewTrack();},disabled:analysisState.isRunning,className:"py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3 h-3"}))," Cut"),/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;if(st.buffer){setSelectionRangeOnBuffer(st.buffer,st.selectionStart||0,st.selectionEnd||st.buffer.duration);}handleAIAnalysicLoop();},disabled:analysisState.isRunning,className:"py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1 col-span-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sparkles",className:"w-3 h-3"}))," AI Analysic Loop")),/*#__PURE__*/React.createElement("button",{onClick:()=>exportSubTabBuffer(st.id),className:"w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"download",className:"w-4 h-4"}))," Export"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTab(st.id),className:"w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-4 h-4"}))," Save"))):/*#__PURE__*/React.createElement("div",{className:"flex-1 flex items-center justify-center text-xs text-zinc-500"},"Track not found")),/*#__PURE__*/React.createElement("div",{className:"w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",onMouseDown:startTcpResize}),/*#__PURE__*/React.createElement("div",{ref:handleTimelineWrapperRef,className:"flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${subTabTimelineWidth}px`},className:"relative flex flex-col min-h-full"},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-30 bg-[#1a1a1a]"},/*#__PURE__*/React.createElement(TimelineRuler,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:subTabTimelineWidth,viewportWidth:viewportWidth,onPlayheadSet:setCurrentTime,snapValue:snapValue,onRulerMouseDown:e=>{setSelectionFollowsTempo(false);const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const sl=wrapper.scrollLeft;const raw=Math.max(0,(e.clientX-rect.left+sl)/zoom);const t=snapValue!=='free'?snapTime(raw,snapValue,bpm):raw;if(e.ctrlKey){e.preventDefault();e.stopPropagation();setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,selectionStart:null,selectionEnd:null}:s));return;}if(e.shiftKey){e.preventDefault();e.stopPropagation();}handlePlayheadSet(t);subTabDragStartRef.current=t;isDraggingSubTabRef.current=true;},scrollLeft:scrollLeft,canvasRedrawCount:canvasRedrawCount})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col relative bg-[#111111] min-h-full"},vTrack&&/*#__PURE__*/React.createElement("div",{style:{height:`${subTabHeight}px`},className:"relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0"},/*#__PURE__*/React.createElement(SubTabWaveform,{buffer:st.buffer,subTabId:st.id,activeTab:activeTab,activeTool:activeTool,currentTime:st.currentTime,selectionStart:st.selectionStart,selectionEnd:st.selectionEnd,onSelectRange:(start,end)=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionStart:start,selectionEnd:end}:s)),onPlayheadSet:time=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:time}:s)),onContextMenu:(e,clickTime)=>setContextMenu({x:e.clientX,y:e.clientY,isSubTab:true,subTabId:st.id,time:clickTime}),zoom:zoom,timelineWidth:subTabTimelineWidth,color:vTrack.color,name:vTrack.name,speed:st.speed||1.0,volumeNodes:st.volumeNodes||[],panningNodes:st.panningNodes||[],fadeInLen:st.fadeInLen||0,fadeOutLen:st.fadeOutLen||0,graphMode:st.graphMode,channelInfo:st.channelInfo,selectedNodeTime:subTabSelectedNodeTime,setSelectedNodeTime:setSubTabSelectedNodeTime,onUpdateNodes:nodes=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,[s.graphMode==='pan'?'panningNodes':'volumeNodes']:nodes}:s)),onUpdateFade:fade=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,fadeInLen:fade.fadeInLen??s.fadeInLen,fadeOutLen:fade.fadeOutLen??s.fadeOutLen}:s)),onModeToggle:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,graphMode:s.graphMode==='pan'?null:'pan'}:s)),onSpeedChange:newSpeed=>{setSubTabs(prev=>prev.map(s=>{if(s.id!==st.id)return s;const oldSpeed=s.speed||1.0;const ratio=oldSpeed/newSpeed;const newVolumeNodes=(s.volumeNodes||[]).map(n=>({...n,time:n.time*ratio}));const newPanningNodes=(s.panningNodes||[]).map(n=>({...n,time:n.time*ratio}));return{...s,speed:newSpeed,volumeNodes:newVolumeNodes,panningNodes:newPanningNodes,fadeInLen:(s.fadeInLen||0)*ratio,fadeOutLen:(s.fadeOutLen||0)*ratio,currentTime:(s.currentTime||0)*ratio,label:s.label.replace(/\s\(\d+%\)$/,'')+` (${Math.round(newSpeed*100)}%)`};}));const n=activeTrackNodesRef.current[st.trackId];if(n&&n.source)n.source.playbackRate.value=newSpeed;// Reset time refs to prevent playhead jump when speed changes mid-playback +stopAllPlayback(false,true);window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();setSubTabs(prev=>prev.map(s=>s.id===seekSt.id?{...s,currentTime:clickTime,isPlaying:true}:s));const ctx=getAudioContext();startOffsetTimeRef.current=clickTime;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=clickTime*(seekSt.speed||1.0);schedulePianoRollMidi(seekSt,clickTime);startSubTabPlayback(seekSt,clickTime);}else{setSubTabs(prev=>prev.map(s=>s.id===seekSt.id?{...s,currentTime:clickTime}:s));}}});}const subTrack=tracks.find(t=>t.id===st.trackId);const vTrack=subTrack?{...subTrack,buffer:st.buffer,isSubTab:true}:null;const subTabDuration=st.type==='PIANO_ROLL'?(()=>{const notes=st.notes||[];const beatSec=60.0/(parseFloat(bpm)||120);let maxEnd=0;notes.forEach(n=>{const end=(n.start_beat||0)+(n.duration_beats||1);if(end>maxEnd)maxEnd=end;});return maxEnd*beatSec+1.0;})():st.buffer&&'duration'in st.buffer?st.buffer.duration:4.0;const subTabTimelineWidth=Math.max(zoom*subTabDuration,viewportWidth);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",style:{width:tcpWidth+'px',scrollbarWidth:'none',msOverflowStyle:'none'}},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-500 uppercase"},"Sub-Tab"),/*#__PURE__*/React.createElement("button",{onClick:()=>closeSubTab(st.id),className:"px-1.5 py-0.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))," Close")),vTrack?/*#__PURE__*/React.createElement("div",{key:vTrack.id,className:"flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between mb-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("label",{className:"cursor-pointer relative block"},/*#__PURE__*/React.createElement("input",{type:"color",value:vTrack.color||'#0f766e',onClick:e=>e.stopPropagation(),onMouseDown:e=>e.stopPropagation(),onChange:e=>{e.stopPropagation();updateTrackColor(vTrack.id,e.target.value);},className:"absolute inset-0 w-full h-full opacity-0 cursor-pointer"}),/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",style:{backgroundColor:vTrack.color}})),editingTrackName===vTrack.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{updateTrackName(vTrack.id,editNameInput||vTrack.name);setEditingTrackName(null);},onKeyDown:e=>{if(e.key==='Enter'){updateTrackName(vTrack.id,editNameInput||vTrack.name);setEditingTrackName(null);}if(e.key==='Escape')setEditingTrackName(null);},onClick:e=>e.stopPropagation(),className:"text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none"}):/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors",title:"Click to rename",onClick:e=>{e.stopPropagation();setEditingTrackName(vTrack.id);setEditNameInput(vTrack.name);}},vTrack.name)),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMute(vTrack.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(vTrack.id,{muted:!vTrack.muted});},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.muted?'bg-red-950 text-red-400 border-red-700':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"M"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackSoloEvaluate(vTrack.id);if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(vTrack.id,{solo:!vTrack.solo});},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.solo?'bg-amber-950 text-amber-400 border-amber-600':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"S"))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-2.5 text-[14px] mb-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('vol:'+vTrack.id,e.deltaY,e.deltaMode,100);if(!steps)return;const nv=Math.max(-50,Math.min(7,(vTrack.volumeDb??0)+steps*0.5));updateTrackVolumeDb(vTrack.id,nv);},onDoubleClick:e=>{e.stopPropagation();updateTrackVolumeDb(vTrack.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Vol:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-50",max:"7",step:"0.5",id:`tcp-vol-${st.id}`,value:vTrack.volumeDb??0,onChange:e=>updateTrackVolumeDb(vTrack.id,parseFloat(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-16 text-right font-mono text-zinc-300 text-[14px]",id:`tcp-vol-label-${st.id}`},vTrack.volumeDb??0,"dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1","data-sf-wheel":"1",onWheel:e=>{const steps=wheelSteps('pan:'+vTrack.id,e.deltaY,e.deltaMode,100);if(!steps)return;const np=Math.max(-100,Math.min(100,(vTrack.pan??0)+steps*5));updateTrackPan(vTrack.id,np);},onDoubleClick:e=>{e.stopPropagation();updateTrackPan(vTrack.id,0);}},/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Pan:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",step:"1",id:`tcp-pan-${st.id}`,value:vTrack.pan??0,onChange:e=>updateTrackPan(vTrack.id,parseInt(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-16 text-right font-mono text-zinc-300 text-[14px]",id:`tcp-pan-label-detailed-${st.id}`},vTrack.pan>0?'R'+vTrack.pan:vTrack.pan<0?'L'+Math.abs(vTrack.pan):'C')),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 text-[14px]"},/*#__PURE__*/React.createElement("button",{onClick:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,isLooping:!s.isLooping}:s)),className:`px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping?'bg-amber-700 text-white border-amber-500':'bg-zinc-800 text-zinc-400 border-zinc-700'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>updateSubTabEffects(st.id,{reverse:!(st.effects||{}).reverse}),className:`px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects||{}).reverse?'bg-zinc-600 text-white border-zinc-500':'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`,title:"Reverse"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-left-right",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Loop:"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",max:"999",value:st.loopCount||0,onChange:e=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,loopCount:Math.max(0,parseInt(e.target.value)||0)}:s)),className:"w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono",title:"Loop count"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase mt-2"},/*#__PURE__*/React.createElement("span",null,"DSP"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-600 text-[11px] normal-case"},"áp dụng vùng chọn / cả clip")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-1 mb-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'invert_phase',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-down-up",className:"w-3 h-3"})),"Phase Inv"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'swap_channels',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"shuffle",className:"w-3 h-3"})),"Swap L/R"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'reverse',0),className:"py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-left-right",className:"w-3 h-3"})),"Reverse")),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 justify-between my-2.5"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Normalize"},"Norm"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",step:"0.1",value:subTabNormVal,onChange:e=>setSubTabNormVal(parseFloat(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabNormVal,"dB"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'normalize',subTabNormVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply")),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Pitch Shift"},"Pitch"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"12",step:"0.5",value:subTabPitchVal,onChange:e=>setSubTabPitchVal(parseFloat(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabPitchVal>0?'+':'',subTabPitchVal,"st"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'pitch',subTabPitchVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply")),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Gain Multiplier"},"Gain"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0",max:"150",step:"1",value:subTabGainVal,onChange:e=>setSubTabGainVal(parseInt(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabGainVal,"%"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'gain',subTabGainVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply"))),/*#__PURE__*/React.createElement("div",{className:"flex-1"}),/*#__PURE__*/React.createElement("div",{className:"mt-auto pt-2.5 border-t border-zinc-800"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase"},/*#__PURE__*/React.createElement("span",null,"Duration:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-300"},st.buffer?formatTime(subTabDuration):'0s')),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase"},/*#__PURE__*/React.createElement("span",null,"SR:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-300"},st.buffer?st.buffer.sampleRate:0," Hz")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1 mb-2"},/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;handleAIScan();},disabled:analysisState.isRunning,className:"py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"map-pin",className:"w-3 h-3"}))," Scan"),/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;handleAICutToNewTrack();},disabled:analysisState.isRunning,className:"py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3 h-3"}))," Cut"),/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;if(st.buffer){setSelectionRangeOnBuffer(st.buffer,st.selectionStart||0,st.selectionEnd||st.buffer.duration);}handleAIAnalysicLoop();},disabled:analysisState.isRunning,className:"py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1 col-span-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sparkles",className:"w-3 h-3"}))," AI Analysic Loop")),/*#__PURE__*/React.createElement("button",{onClick:()=>exportSubTabBuffer(st.id),className:"w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"download",className:"w-4 h-4"}))," Export"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTab(st.id),className:"w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-4 h-4"}))," Save"))):/*#__PURE__*/React.createElement("div",{className:"flex-1 flex items-center justify-center text-xs text-zinc-500"},"Track not found")),/*#__PURE__*/React.createElement("div",{className:"w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",onMouseDown:startTcpResize}),/*#__PURE__*/React.createElement("div",{ref:handleTimelineWrapperRef,className:"flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${subTabTimelineWidth}px`},className:"relative flex flex-col min-h-full"},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-30 bg-[#1a1a1a]"},/*#__PURE__*/React.createElement(TimelineRuler,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:subTabTimelineWidth,viewportWidth:viewportWidth,onPlayheadSet:setCurrentTime,snapValue:snapValue,onRulerMouseDown:e=>{setSelectionFollowsTempo(false);const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const sl=wrapper.scrollLeft;const raw=Math.max(0,(e.clientX-rect.left+sl)/zoom);const t=snapValue!=='free'?snapTime(raw,snapValue,bpm):raw;if(e.ctrlKey){e.preventDefault();e.stopPropagation();setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,selectionStart:null,selectionEnd:null}:s));return;}if(e.shiftKey){e.preventDefault();e.stopPropagation();}handlePlayheadSet(t);subTabDragStartRef.current=t;isDraggingSubTabRef.current=true;},scrollLeft:scrollLeft,canvasRedrawCount:canvasRedrawCount})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col relative bg-[#111111] min-h-full"},vTrack&&/*#__PURE__*/React.createElement("div",{style:{height:`${subTabHeight}px`},className:"relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0"},/*#__PURE__*/React.createElement(SubTabWaveform,{buffer:st.buffer,subTabId:st.id,activeTab:activeTab,activeTool:activeTool,currentTime:st.currentTime,selectionStart:st.selectionStart,selectionEnd:st.selectionEnd,onSelectRange:(start,end)=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionStart:start,selectionEnd:end}:s)),onPlayheadSet:time=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:time}:s)),onContextMenu:(e,clickTime)=>setContextMenu({x:e.clientX,y:e.clientY,isSubTab:true,subTabId:st.id,time:clickTime}),zoom:zoom,timelineWidth:subTabTimelineWidth,color:vTrack.color,name:vTrack.name,speed:st.speed||1.0,volumeNodes:st.volumeNodes||[],panningNodes:st.panningNodes||[],fadeInLen:st.fadeInLen||0,fadeOutLen:st.fadeOutLen||0,graphMode:st.graphMode,channelInfo:st.channelInfo,selectedNodeTime:subTabSelectedNodeTime,setSelectedNodeTime:setSubTabSelectedNodeTime,onUpdateNodes:nodes=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,[s.graphMode==='pan'?'panningNodes':'volumeNodes']:nodes}:s)),onUpdateFade:fade=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,fadeInLen:fade.fadeInLen??s.fadeInLen,fadeOutLen:fade.fadeOutLen??s.fadeOutLen}:s)),onModeToggle:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,graphMode:s.graphMode==='pan'?null:'pan'}:s)),onSpeedChange:newSpeed=>{setSubTabs(prev=>prev.map(s=>{if(s.id!==st.id)return s;const oldSpeed=s.speed||1.0;const ratio=oldSpeed/newSpeed;const newVolumeNodes=(s.volumeNodes||[]).map(n=>({...n,time:n.time*ratio}));const newPanningNodes=(s.panningNodes||[]).map(n=>({...n,time:n.time*ratio}));return{...s,speed:newSpeed,volumeNodes:newVolumeNodes,panningNodes:newPanningNodes,fadeInLen:(s.fadeInLen||0)*ratio,fadeOutLen:(s.fadeOutLen||0)*ratio,currentTime:(s.currentTime||0)*ratio,label:s.label.replace(/\s\(\d+%\)$/,'')+` (${Math.round(newSpeed*100)}%)`};}));const n=activeTrackNodesRef.current[st.trackId];if(n&&n.source)n.source.playbackRate.value=newSpeed;// Reset time refs to prevent playhead jump when speed changes mid-playback const ctx=getAudioContext();const elapsed=ctx.currentTime-startAudioTimeRef.current;const oldSpeed=activePlaybackSpeedRef.current;const ratio=oldSpeed/newSpeed;startBufferOffsetRef.current=startBufferOffsetRef.current+elapsed*oldSpeed;startOffsetTimeRef.current=(startOffsetTimeRef.current+elapsed)*ratio;startAudioTimeRef.current=ctx.currentTime;activePlaybackSpeedRef.current=newSpeed;}}),/*#__PURE__*/React.createElement("div",{onMouseDown:handleSubTabResizeMouseDown,className:"absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors"})),st.selectionStart!==null&&st.selectionEnd!==null&&st.selectionEnd>st.selectionStart&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 pointer-events-none",style:{left:`${Math.min(st.selectionStart,st.selectionEnd)*zoom}px`,width:`${Math.abs(st.selectionEnd-st.selectionStart)*zoom}px`}})))));})())),/*#__PURE__*/React.createElement("div",{className:"w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",onMouseDown:startColResize}),renderDock('right','Right')),renderDock('bottom','Bottom'),showMixer&&/*#__PURE__*/React.createElement("div",{className:"flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",style:{height:mixerHeight+'px'}},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize",onMouseDown:e=>{e.preventDefault();var startY=e.clientY;var startH=mixerHeight;var onMove=function(ev){var newH=Math.max(80,Math.min(400,startH-(ev.clientY-startY)));setMixerHeight(newH);localStorage.setItem('studio_mixer_height',newH.toString());};var onUp=function(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-horizontal",className:"w-3 h-3 text-zinc-600"})),/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-zinc-400 uppercase tracking-wider"},"MIXER")),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowMixer(false),className:"p-0.5 rounded text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-x-auto p-1.5 gap-1.5 items-stretch"},/*#__PURE__*/React.createElement(MasterStripConsole,{masterVolume:masterVolume,setMasterVolume:setMasterVolume,masterVolL:masterVolL,setMasterVolL:setMasterVolL,masterVolR:masterVolR,setMasterVolR:setMasterVolR,masterLink:masterLink,setMasterLink:setMasterLink,masterMuted:masterMuted,setMasterMuted:setMasterMuted,masterAutoVol:masterAutoVol,setMasterAutoVol:setMasterAutoVol,masterAutoOn:masterAutoOn,setMasterAutoOn:setMasterAutoOn,showMasteringModal:showMasteringModal,setShowMasteringModal:setShowMasteringModal,masteringSettings:masteringSettings,setMasteringSettings:setMasteringSettings,isPlaying:isPlaying}),activeTracks.length>0&&/*#__PURE__*/React.createElement("div",{className:"w-px bg-zinc-700 shrink-0 self-stretch mx-0.5"}),activeTracks.map(function(track,idx){return/*#__PURE__*/React.createElement(TrackStripConsole,{key:track.id,track:track,index:idx,onUpdateTrack:updateTrackProp,trackVuRefs:trackVuRefs});}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",style:{height:mediaExplorerPanelHeight+'px',display:showMediaExplorer?'flex':'none'}},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize",onMouseDown:e=>{e.preventDefault();var startY=e.clientY;var startH=mediaExplorerPanelHeight;var onMove=function(ev){var newH=Math.max(120,Math.min(520,startH-(ev.clientY-startY)));setMediaExplorerPanelHeight(newH);localStorage.setItem('studio_media_explorer_height',newH.toString());};var onUp=function(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-horizontal",className:"w-3 h-3 text-zinc-600"})),/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-emerald-400 uppercase tracking-wider"},"Media Explorer (F6)")),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowMediaExplorer(false),className:"p-0.5 rounded text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 min-h-0 overflow-hidden bg-[#262626]"},/*#__PURE__*/React.createElement(MediaExplorerPanel,{height:mediaExplorerPanelHeight,clipboardRef:clipboardRef,active:showMediaExplorer}))));})(),/*#__PURE__*/React.createElement("div",{className:"h-9 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[18px] text-zinc-500 select-none shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-4"},/*#__PURE__*/React.createElement("span",null,"Status: ",isPlaying?'Playing':'Stopped'),activeTab!=='main'&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-semibold uppercase"},"Sub-Tab"),activeTab==='main'&&/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-semibold uppercase"},"Track: ID ",selectedTrackId),selectionMode==='local'&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-semibold uppercase text-[18px]"},"Local Sel"),selectionMode==='global'&&/*#__PURE__*/React.createElement("span",{className:"text-purple-400 font-semibold uppercase text-[18px]"},"Global Sel"),hasAnySolo&&/*#__PURE__*/React.createElement("span",{className:"text-amber-500 font-semibold"},"Solo: ",tracks.filter(t=>t.solo).length," track(s)"),isLoopingSelection&&selectionMode==='local'&&/*#__PURE__*/React.createElement("span",{className:"text-emerald-400 font-semibold uppercase text-[18px]"},"Solo Loop"),isLoopingSelection&&selectionMode!=='local'&&/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-semibold uppercase text-[18px]"},"Master Loop")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 ml-3"},/*#__PURE__*/React.createElement("span",{className:"text-[18px] font-bold text-zinc-400 uppercase"},"Bars:"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",value:beginBar,onChange:e=>{const b=parseInt(e.target.value)||0;setBeginBar(b);const beatDuration=60/parseInt(bpm||120);const t=b*beatDuration*4;clearLocalSelection();setSelectionMode('global');setSelectionStart(t);setSelectionEnd(t+beatDuration*4);},className:"w-[60px] bg-black text-white text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"-"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",value:endBar,onChange:e=>{const b=parseInt(e.target.value)||0;setEndBar(b);const beatDuration=60/parseInt(bpm||120);const t=b*beatDuration*4;// selectionEnd = vạch bar b (không +1 bar) — khớp hiển thị endBar // (quét 8 bars hiển thị "0 đến 8"): nhập 8 → selection tới vạch 8. setSelectionEnd(t);setNumberBar(Math.max(0,b-beginBar));},className:"w-[60px] bg-black text-white text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"#"),/*#__PURE__*/React.createElement("input",{type:"number",min:"1",value:numberBar,readOnly:true,className:"w-[60px] bg-black text-zinc-400 text-[18px] px-1 py-0 rounded border border-zinc-800 text-center font-mono"}),/*#__PURE__*/React.createElement("button",{onClick:()=>setSelectionFollowsTempo(prev=>!prev),className:`px-1.5 py-0 text-[18px] rounded border font-bold ${selectionFollowsTempo?'bg-amber-700 text-white border-amber-500':'bg-zinc-800 text-zinc-400 border-zinc-700'}`,title:selectionFollowsTempo?"Selection theo tempo (đổi BPM → selection thay đổi)":"Selection theo thời gian (cố định)"},selectionFollowsTempo?"♪T":"⏱T"),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-6 bg-zinc-800 mx-1"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"Start:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selLeft!==null?formatTime(selLeft):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));clearLocalSelection();setSelectionMode('global');setSelectionStart(secs);}},className:"w-24 bg-black text-zinc-200 text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"End:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selRight!==null?formatTime(selRight):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));setSelectionEnd(secs);}},className:"w-24 bg-black text-zinc-200 text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[18px] text-zinc-500"},"Len:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selLeft!==null&&selRight!==null?formatTime(Math.abs(selRight-selLeft)):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3&&selLeft!==null){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));setSelectionEnd(selLeft+secs);}},className:"w-24 bg-black text-amber-300 text-[18px] px-1 py-0 rounded border border-zinc-700 text-center font-mono"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setShowExportPanel(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showExportPanel?'bg-cyan-900 text-cyan-300':'text-zinc-500 hover:text-zinc-300'}`,title:`Export (floating modal)`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showExportPanel?panelPositions.export[0].toUpperCase():'')),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowAIPanel(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showAIPanel?'bg-purple-900 text-purple-300':'text-zinc-500 hover:text-zinc-300'}`,title:`AI Panel (${panelPositions.ai})`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showAIPanel?panelPositions.ai[0].toUpperCase():'')),/*#__PURE__*/React.createElement("button",{onClick:()=>window.__toggleMixerRef(),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMixer?'bg-indigo-900 text-indigo-300':'text-zinc-500 hover:text-zinc-300'}`,title:"Mixer Panel (F7)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders-horizontal",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>window.__toggleMediaExplorerRef(),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMediaExplorer?'bg-emerald-900 text-emerald-300':'text-zinc-500 hover:text-zinc-300'}`,title:"Media Explorer Panel (F6)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"folder-open",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("span",{className:"w-[1px] h-3 bg-zinc-800 mx-1"}),/*#__PURE__*/React.createElement("span",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"info",className:"w-3 h-3 text-zinc-600"})),prHint?/*#__PURE__*/React.createElement("span",{className:"text-cyan-400"},prHint):/*#__PURE__*/React.createElement("span",{className:"text-zinc-600 italic"},"Adaptive tips: hover vào vùng làm việc để xem hướng dẫn")))),contextMenu&&(contextMenu.isSubTab?/*#__PURE__*/React.createElement("div",{className:"fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto",style:{left:Math.min(contextMenu.x,window.innerWidth-260),top:contextMenu.y+200>window.innerHeight?undefined:contextMenu.y,bottom:contextMenu.y+200>window.innerHeight?window.innerHeight-contextMenu.y:undefined,maxHeight:'60vh'},onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"px-3 py-1 text-xs text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1"},"Selection: ",formatTime(subTabs.find(s=>s.id===contextMenu.subTabId)?.selectionStart||0)," - ",formatTime(subTabs.find(s=>s.id===contextMenu.subTabId)?.selectionEnd||0)),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabCut(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-rose-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Cut"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+X")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabCopy(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5 text-zinc-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Copy"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+C")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabPaste(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5 text-emerald-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Paste"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+V")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabDelete(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3.5 h-3.5 text-red-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Delete Selected Segment"),/*#__PURE__*/React.createElement("span",{className:"text-amber-400 text-xs font-semibold font-mono ml-auto"},"Del")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabLoop(contextMenu.subTabId,4);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5 text-cyan-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Loop Selection 4 times"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+L"))):/*#__PURE__*/React.createElement("div",{className:"fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto",style:{left:Math.min(contextMenu.x,window.innerWidth-260),top:contextMenu.y+260>window.innerHeight?undefined:contextMenu.y,bottom:contextMenu.y+260>window.innerHeight?window.innerHeight-contextMenu.y:undefined,maxHeight:'60vh'},onClick:e=>e.stopPropagation()},contextMenu.sectionId?/*#__PURE__*/React.createElement("button",{onClick:contextMenuEditSection,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-edit",className:"w-3.5 h-3.5 text-amber-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Edit Section"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+E")):/*#__PURE__*/React.createElement("button",{onClick:contextMenuEdit,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-edit",className:"w-3.5 h-3.5 text-amber-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Edit"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+E")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuSplit,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-cyan-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Split"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"S")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuMerge,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"combine",className:"w-3.5 h-3.5 text-purple-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Merge"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+M")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("div",{className:"px-3 py-0.5 text-[10px] text-zinc-500 font-bold uppercase tracking-wider"},"Insert"),!sessionTabs.some(s=>s.id===activeTab)&&/*#__PURE__*/React.createElement("button",{onClick:()=>{insertSectionAtPlayhead();closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"folder-plus",className:"w-3.5 h-3.5 text-amber-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Insert Section")),/*#__PURE__*/React.createElement("button",{onClick:()=>{insertMidiItemAtPlayhead();closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-3.5 h-3.5 text-purple-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Insert MIDI Item")),/*#__PURE__*/React.createElement("button",{onClick:()=>{insertSoundClipAtCursor();closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-audio",className:"w-3.5 h-3.5 text-emerald-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Insert Sound Clip")),/*#__PURE__*/React.createElement("button",{onClick:()=>{insertTrackBelow();closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus-square",className:"w-3.5 h-3.5 text-cyan-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Insert Track")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:contextMenuCopy,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5 text-zinc-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Copy"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+C")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuCut,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-rose-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Cut"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+X")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuPaste,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5 text-emerald-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Paste"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+V")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:contextMenuDelete,className:"w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3.5 h-3.5 text-red-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Delete"),/*#__PURE__*/React.createElement("span",{className:"text-amber-400 text-xs font-semibold font-mono ml-auto pl-8"},"Del")))),toastMessage&&/*#__PURE__*/React.createElement("div",{className:"absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"bell",className:`w-4 h-4 ${toastMessage.type==='success'?'text-emerald-400':toastMessage.type==='error'?'text-rose-400':toastMessage.type==='warning'?'text-amber-400':'text-cyan-400'}`})),toastMessage.text,toastMessage.onActionClick&&/*#__PURE__*/React.createElement("button",{onClick:()=>{toastMessage.onActionClick();setToastMessage(null);},className:"ml-2 px-2 py-0.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-[10px] uppercase font-bold"},toastMessage.actionText||'Tải về'))),appWarningModal&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#222222] border border-zinc-800 rounded-lg shadow-2xl p-6 max-w-md w-full text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 mb-3 text-cyan-400 font-bold"},/*#__PURE__*/React.createElement("h3",{className:"text-base font-bold"},appWarningModal.title)),/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-300 mb-5 leading-relaxed whitespace-pre-line"},appWarningModal.message),/*#__PURE__*/React.createElement("div",{className:"flex justify-end gap-2"},appWarningModal.isAlert?/*#__PURE__*/React.createElement("button",{onClick:()=>setAppWarningModal(null),className:"px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition"},"Đóng"):/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("button",{onClick:()=>setAppWarningModal(null),className:"px-4 py-2 bg-zinc-700 hover:bg-zinc-600 active:bg-zinc-800 text-slate-300 rounded text-xs font-semibold transition"},"Hủy"),/*#__PURE__*/React.createElement("button",{onClick:()=>{const fn=appWarningModal.onConfirm;setAppWarningModal(null);if(fn)fn();},className:"px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition"},"Xác nhận"))))),tabContextMenu&&/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[190]",onClick:()=>setTabContextMenu(null),onContextMenu:e=>{e.preventDefault();setTabContextMenu(null);}}),/*#__PURE__*/React.createElement("div",{className:"fixed bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl p-2 z-[200] flex gap-1.5",style:{top:tabContextMenu.y,left:tabContextMenu.x},onClick:e=>e.stopPropagation()},['#f43f5e','#f59e0b','#10b981','#06b6d4','#8b5cf6','#64748b'].map(color=>/*#__PURE__*/React.createElement("button",{key:color,className:"w-5 h-5 rounded-full border border-zinc-700 hover:scale-110 active:scale-95 transition",style:{backgroundColor:color},onClick:()=>{handleSetTabColor(tabContextMenu.tabId,tabContextMenu.tabType,color);setTabContextMenu(null);}})))),/*#__PURE__*/React.createElement(AuthModal,{isOpen:authModalOpen,mode:authMode,forceMandatory:isMandatoryLogin,onClose:()=>setAuthModalOpen(false),onSuccess:handleAuthSuccess}),/*#__PURE__*/React.createElement(ProfileModal,{isOpen:profileModalOpen,onClose:()=>setProfileModalOpen(false),tracks:tracks,setTracks:setTracks,setSelectedTrackId:setSelectedTrackId,projectName:projectName,setProjectName:setProjectName,currentProjectId:currentProjectId,setCurrentProjectId:setCurrentProjectId,showToast:showToast,loadAudioBuffersForTracks:loadAudioBuffersForTracks,setAppWarningModal:setAppWarningModal,bpm:bpm,setBpm:setBpm,setMasteringSettings:setMasteringSettings,setSessionTabs:setSessionTabs,setSubTabs:setSubTabs,trackMidiChannelsRef:trackMidiChannelsRef}),/*#__PURE__*/React.createElement(SaveProjectModal,{isOpen:saveProjectModalOpen,onClose:()=>setSaveProjectModalOpen(false),projectName:projectName,onSaveCloud:(newName,existingId)=>{handleSaveCloudProject(newName,existingId);},onSaveLocal:newName=>{handleSaveLocalProject(newName);}}),/*#__PURE__*/React.createElement(SaveAsModal,{isOpen:saveAsModalOpen,onClose:()=>setSaveAsModalOpen(false),projectName:projectName,onSaveCloud:newName=>{handleSaveAsCloud(newName);},onSaveLocal:newName=>{handleExportSFS(newName);setProjectName(newName);localStorage.setItem('sonic_project_name',newName);}}),/*#__PURE__*/React.createElement(OpenProjectModal,{isOpen:openProjectModalOpen,onClose:()=>setOpenProjectModalOpen(false),onOpenCloud:(projId,projName)=>{setOpenProjectModalOpen(false);handleOpenProject(projId,projName);},onOpenLocal:()=>{setOpenProjectModalOpen(false);handleImportSFS();}}),/*#__PURE__*/React.createElement(AIConfigModal,{isOpen:aiConfigModalOpen,onClose:()=>setAiConfigModalOpen(false),onConfigSaved:providers=>{setAiProviders(providers);const active=providers.find(p=>p.is_active)||providers[0];if(active)setSelectedProviderId(active.id);}}),/*#__PURE__*/React.createElement(AboutModal,{isOpen:aboutModalOpen,onClose:()=>setAboutModalOpen(false)}),/*#__PURE__*/React.createElement(HelpModal,{isOpen:helpModalOpen,onClose:()=>setHelpModalOpen(false),lang:prefs.language}),/*#__PURE__*/React.createElement(PreferencesModal,{isOpen:preferencesModalOpen,onClose:()=>setPreferencesModalOpen(false),prefs:prefs,onPrefsChange:handlePrefsChange}),/*#__PURE__*/React.createElement(SystemManagerModal,{isOpen:systemManagerModalOpen,onClose:()=>setSystemManagerModalOpen(false)}),/*#__PURE__*/React.createElement(PluginManagerModal,{isOpen:pluginManagerModalOpen,onClose:()=>setPluginManagerModalOpen(false),pluginsData:pluginsData,onInsertInstrument:inst=>{// Chèn instrument (soundfont bank/program) vào track đang chọn — nút Synth diff --git a/app/static/js/services/bridgeAudioNode.js b/app/static/js/services/bridgeAudioNode.js index 7cd01f2..ba614f1 100644 --- a/app/static/js/services/bridgeAudioNode.js +++ b/app/static/js/services/bridgeAudioNode.js @@ -16,6 +16,7 @@ var _ctx = null; var _initialized = false; var _useWorklet = false; + var _workletStats = null; // stats tu worklet (overrun/underrun) — getDiag var _dest = null; // destination gain node da noi (chong connect trung) function _getCtx() { @@ -31,7 +32,7 @@ console.warn('[BridgeAudioNode] AudioWorklet unavailable — ScriptProcessor fallback'); return false; } - var url = '/static/js/services/bridgeAudioWorklet.js?v=202608272301'; + var url = '/static/js/services/bridgeAudioWorklet.js?v=202608311858'; _ctx.audioWorklet.addModule(url).then(function () { if (!_initialized) return; // disconnect() happened while loading if (_useWorklet) return; // already set up (init retry) @@ -42,6 +43,9 @@ outputChannelCount: [2] }); _workletNode.connect(_gainNode); + _workletNode.port.onmessage = function (ev) { + if (ev.data && ev.data.type === 'stats') _workletStats = ev.data; + }; _useWorklet = true; // drain pre-buffer into worklet (order preserved) while (_preBuffer.length) { @@ -168,7 +172,8 @@ hasWorkletNode: !!_workletNode, preBufferLen: _preBuffer.length, chunksLen: _chunks.length, - gain: _gainNode ? _gainNode.gain.value : -1 + gain: _gainNode ? _gainNode.gain.value : -1, + ws: _workletStats }; } }; diff --git a/app/static/js/services/bridgeAudioWorklet.js b/app/static/js/services/bridgeAudioWorklet.js index 8934de4..a3ab216 100644 --- a/app/static/js/services/bridgeAudioWorklet.js +++ b/app/static/js/services/bridgeAudioWorklet.js @@ -1,22 +1,30 @@ // app/static/js/services/bridgeAudioWorklet.js // VSTi crackle fix: audio sink chay tren audio thread (khong jank main thread). -// - Ring block 256 mau, toi da 8 block (~42ms @48k) — hap thu jitter pump -// nhung gioi han backlog: 64 block (~371ms) tao tre ~0.3-0.4s khi play/ -// keyboard preview (audio moi phai cho ring cu chay het). +// - Ring block 256 mau, toi da 32 block (~170ms @48k) — hap thu jitter pump +// (V45.27: 16->32 de dung crackle VST2 loop/tremolo — render loop C++ bi +// stall khi instrument xu li loop/tremolo nang CPU; 8->16 truoc do da dung +// tremolo crackle, 16 van chua du cho stall >85ms). Flush o play start +// (app.jsx) xoa ring nen do tre khong tang: ring thuong can o preview. // - Underrun: fade ve 0 thay vi click (hard silence). // - Resume sau silence: fade vao tu 0. // - Overrun: drop block cu nhat + crossfade 64 mau tai diem drop (het click). +// - Stats: dem overrun/underrun, gui ve main thread moi ~0.5s — kiem tra +// con crackle nao la do ring can (underrun) hay ring tran (overrun). class BridgeAudioSinkProcessor extends AudioWorkletProcessor { constructor() { super(); this._ring = []; - this._MAX = 8; + this._MAX = 32; this._gain = 1.0; this._lastOutL = 0.0; this._lastOutR = 0.0; - this._skipFade = 0; // >0: crossfade dang chay sau overrun drop + this._skipFade = 0; // >0: con lai cua crossfade dang chay sau overrun drop + this._fadeLen = 0; // tong chieu dai crossfade hien tai (cap 1024) this._skipFromL = 0.0; this._skipFromR = 0.0; + this._wasUnderrun = false; + this._statAccum = 0; + this._stats = { overruns: 0, underruns: 0 }; this.port.onmessage = (e) => { const m = e.data; if (!m) return; @@ -24,13 +32,19 @@ class BridgeAudioSinkProcessor extends AudioWorkletProcessor { this._ring.push({ l: m.l, r: m.r }); while (this._ring.length > this._MAX) { // overrun: bo block cu nhat; lan consume toi se crossfade ranh gioi + // V45.32: crossfade tich luy — moi block drop cong 256 mau fade, cap + // 1024 (~21ms) thay vi co dinh 64 mau (1.3ms qua ngan -> blip/wow khi + // drop loat block o chord nhieu voice = crackle VST2 tremolo/loop) this._ring.shift(); - this._skipFade = 64; + this._skipFade = Math.min(this._skipFade + 256, 1024); + this._fadeLen = this._skipFade; + this._stats.overruns++; } } else if (m.type === 'flush') { this._ring.length = 0; this._gain = 1.0; this._skipFade = 0; + this._fadeLen = 0; } }; } @@ -43,12 +57,14 @@ class BridgeAudioSinkProcessor extends AudioWorkletProcessor { let w = 0; while (w < n) { if (this._ring.length) { + if (this._wasUnderrun) this._wasUnderrun = false; const blk = this._ring[0]; const avail = blk.l.length; const take = Math.min(avail, n - w); if (this._skipFade > 0) { + const done = this._fadeLen - this._skipFade; for (let i = 0; i < take; i++) { - const t = i / 64; + const t = (done + i) / this._fadeLen; L[w + i] = (this._lastOutL * (1 - t) + blk.l[i] * t) * this._gain; R[w + i] = (this._lastOutR * (1 - t) + blk.r[i] * t) * this._gain; } @@ -70,7 +86,8 @@ class BridgeAudioSinkProcessor extends AudioWorkletProcessor { blk.r = blk.r.subarray(take); } } else { - // underrun: fade ve 0 (khong click) + // underrun: fade ve 0 (khong click) — dem 1 lan moi lan roi vao trang thai trong + if (!this._wasUnderrun) { this._wasUnderrun = true; this._stats.underruns++; } for (let i = w; i < n; i++) { this._gain = Math.max(0, this._gain - 1.0 / 256.0); L[i] = this._lastOutL * this._gain; @@ -81,6 +98,20 @@ class BridgeAudioSinkProcessor extends AudioWorkletProcessor { w = n; } } + // Stats: gui ve main thread moi ~0.5s (24000 mau @48k) — log overrun/underrun + this._statAccum += n; + if (this._statAccum >= 24000) { + this._statAccum = 0; + try { + this.port.postMessage({ + type: 'stats', + overruns: this._stats.overruns, + underruns: this._stats.underruns, + ring: this._ring.length, + max: this._MAX + }); + } catch (e) {} + } return true; } } diff --git a/app/static/js/services/nativeBridgeService.js b/app/static/js/services/nativeBridgeService.js index 968ff05..474fa7c 100644 --- a/app/static/js/services/nativeBridgeService.js +++ b/app/static/js/services/nativeBridgeService.js @@ -17,8 +17,13 @@ /** D10: UI subscribes to bridge connection changes. cb({connected}) or null to clear. */ onStatusChange: function (cb) { this._statusCb = cb; }, _notifyStatus: function (connected) { - this.isBridgeConnected = !!connected; - if (this._statusCb) this._statusCb({ connected: !!connected }); + connected = !!connected; + // Loop guard (V45.7): poller queryStatus moi 400ms tung goi cb du connected + // khong doi -> App reset map + reload toan bo track -> mark loading lai -> + // poller lai thay loading -> LOAD skip vo han. Chi fire khi TRANG THAI DOI. + if (this.isBridgeConnected === connected) return; + this.isBridgeConnected = connected; + if (this._statusCb) this._statusCb({ connected: connected }); }, /** Fix C: re-establish bridge routing once audio frames arrive again diff --git a/app/static/js/services/unifiedMidiRouter.js b/app/static/js/services/unifiedMidiRouter.js index b954710..92a280e 100644 --- a/app/static/js/services/unifiedMidiRouter.js +++ b/app/static/js/services/unifiedMidiRouter.js @@ -6,6 +6,10 @@ var MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14]; // skip 9 (percussion); 15 reserved for Media Explorer preview var _lastTimelineDbgTs = 0; + // V45.26: dem note live dang giu + lan live event gan nhat — quyet dinh co + // clear master FX outQueue khong (chi idle->active; KHONG khi dang phat lien tuc). + var _liveHeldCount = 0; + var _lastLiveEventAt = 0; var router = { bridgeConnected: false, @@ -59,7 +63,23 @@ // V31: live note (ARM keyboard preview) — clear master FX outQueue backlog // (idle block ~341ms) truoc khi note moi render; timeline note (live=false) // KHONG clear — tranh drop audio dang phat. - if (opts.live && opts.cmd === 'NOTE_ON') { try { if (window.__clearMasterFxQueue) window.__clearMasterFxQueue(); } catch (e) {} } + // V45.26: chi clear khi THAT SU idle -> active (khong con note dang giu + // VA im lang >500ms). Truoc day clear MOI note-on live -> rapid 1/8 lien + // tuc: moi note wipe outQueue (dang chua tail note truoc + resume fade-in) + // -> wobble/crackle (bug user). Note dang ngan / chuoi note nhanh -> giu + // queue -> tail note truoc khong bi cat. + if (opts.live) { + try { + var _nowEv = (window.performance && performance.now) ? performance.now() : Date.now(); + if (opts.cmd === 'NOTE_ON') { + if (_liveHeldCount === 0 && (_nowEv - _lastLiveEventAt) > 500 && window.__clearMasterFxQueue) window.__clearMasterFxQueue(); + _liveHeldCount++; + } else if (opts.cmd === 'NOTE_OFF') { + if (_liveHeldCount > 0) _liveHeldCount--; + } + _lastLiveEventAt = _nowEv; + } catch (e) {} + } var cmd = opts.cmd, ch = opts.channel, pitch = opts.pitch; var vel = (opts.velocity === undefined ? 1.0 : opts.velocity); var sampleOffset = opts.sampleOffset || 0; @@ -73,7 +93,7 @@ var _nowMs = (window.performance && performance.now) ? performance.now() : Date.now(); if (window.__dbgLog && (opts.live || _nowMs - _lastTimelineDbgTs > 200)) { if (!opts.live) _lastTimelineDbgTs = _nowMs; - window.__dbgLog('midi_push', 'cmd=' + cmd + ' ch=' + ch + ' live=' + (opts.live ? 1 : 0) + ' vel=' + Math.round((vel || 0) * 100) + (window.__dbgBridgeGain ? ' ' + window.__dbgBridgeGain() : '') + ' q=' + (window.__fxRtQueueLen ? window.__fxRtQueueLen() : 'n/a')); + window.__dbgLog('midi_push', 'cmd=' + cmd + ' ch=' + ch + ' p=' + (pitch === undefined ? 'n/a' : pitch) + ' live=' + (opts.live ? 1 : 0) + ' vel=' + Math.round((vel || 0) * 100) + (window.__dbgBridgeGain ? ' ' + window.__dbgBridgeGain() : '') + ' q=' + (window.__fxRtQueueLen ? window.__fxRtQueueLen() : 'n/a')); } } catch (e) {} if (this.isBridgeActive()) { @@ -85,6 +105,7 @@ /** Stop/panic -> bridge transport panic (flush all notes) + fallback local stopAll. */ panic: function () { + _liveHeldCount = 0; if (this.isBridgeActive() && window.NativeBridgeService.transport) { window.NativeBridgeService.transport('panic'); } diff --git a/app/static/js/sf-fx-realtime.js b/app/static/js/sf-fx-realtime.js index e9df348..328c2ba 100644 --- a/app/static/js/sf-fx-realtime.js +++ b/app/static/js/sf-fx-realtime.js @@ -57,6 +57,7 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { this.lastOutR = 0.0; this.wrapFadeBlocks = -1; // V42: số block tail pass cũ còn lại sau clear_queue('loop') — đếm ngược để crossfade đúng seam tail→head this.latencySamples = 0; // G3: latency chain thật (engine qua WS) — align wrap fade theo processed tail + this.wrapMode = false; // V45.3: dang trong wrap (clear_queue loop) — resume underrun bo flush + re-buffer nho this.WRAP_FADE = 256; // V42: crossfade 256 mẫu (~5.3ms @48k) tại seam wrap — che click jump nội dung end→start (FX path) this.fadeFromL = 0.0; // mẫu cố định khi fade-out underrun (fade tuyến tính tới 0) this.fadeFromR = 0.0; @@ -79,6 +80,7 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { this.resumeFadeIn = true; this.resumeFill = this.RESUME_FILL; this.silenced = false; + this.wrapMode = false; // V30: fade-out tu mau output CUOI (lastOut) — truoc day dung fadeFrom // cu (mac dinh 0) -> jump mau -> click/crackle ngay khi stop. this.fadeFromL = this.lastOutL; @@ -88,6 +90,7 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { return; } if (d && d.type === 'clear_queue') { + this.clearMode = d.mode; // V45.19: nho mode de tag fx_seek_first tai dbgPlayFirst // V34: backlog guard o DAU handler (V33 dat sau mutation -> outQueue.length=0 // chay truoc -> guard luon return, clear van drop queue + fade + re-buffer moi // lan = V31). Queue nho (< FILL 8 block) -> bo qua hoan toan (khong mutation, @@ -110,9 +113,51 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { // G3: align processed-tail — tail pass cũ còn in-flight trong bridge // (FX latency thật từ engine) chưa về queue; đếm đủ block tới seam // (block đầu pass mới lên front) → crossfade đúng chỗ jump end→start. + // V45.2 (option B): cắt bớt tail cũ giữ KEEP block thay vì giữ nguyên + // FILL 24 (~128ms) → downbeat pass mới không trễ ~110ms. skipFade + // crossfade từ lastOut che jump cắt front; wrapFadeBlocks đếm lại từ + // KEEP để seam crossfade vẫn đúng lúc. + // V45.3: KEEP 4 -> 16 (~85ms): KEEP 4 (~21ms) engine (stall bridge + // 68-138ms sau transport play) khong refill kip -> queue can -> underrun + // -> flush + re-buffer FILL 24 = gap ~110-235ms moi wrap (WORSE hon + // truoc V45.2: downbeat tre 106-128ms nhung khong gap). KEEP 16 che + // stall dien hinh 68-82ms lien tuc (khong gap, downbeat ~85ms); stall + // dai hon roi vao nhanh resume wrap (bo flush + resumeFill 8). + const KEEP = 16; + this.wrapMode = true; + if (this.outQueue.length > KEEP) { + const head = this.outQueue.length - KEEP; + this.outQueue.splice(0, head); + this.outPos = 0; + if (this.playing) { + this.skipFade = this.WRAP_FADE; + this.skipFadeTotal = this.WRAP_FADE; + this.skipFromL = this.lastOutL; + this.skipFromR = this.lastOutR; + } + } this.wrapFadeBlocks = this.outQueue.length + Math.ceil((this.latencySamples || 0) / this.BLOCK); return; } + if (d.mode === 'seek') { + // V45.18: seek khi play master FX active — wipe queue cu (audio cu + // choi tiep 0-50ms sau click) + resume nhanh RESUME_FILL 8 (~43ms) + // thay vi FILL 24 (~128ms) — het "am tre 1 beat" (hit dau roi vao + // vung cam 128ms). Giong resume wrap V45.4 (proven khong gap). + this.outQueue.length = 0; + this.outPos = 0; + this.playing = false; + this.wrapFadeBlocks = -1; + this.wrapMode = false; + this.resumeFadeIn = true; + this.resumeFill = 8; + this.silenced = false; + this.fadeFromL = this.lastOutL; + this.fadeFromR = this.lastOutR; + this.holdFadeLeft = this.HOLD_FADE * this.BLOCK; + this.port.postMessage({ type: 'dbg', tag: 'fx_seek_clear', t: Math.round(currentTime * 1000), q: 0 }); + return; + } if (d.mode !== 'play' && this.outQueue.length < this.FILL) return; // V31: play/ARM live note — main thread bao xoa backlog block idle // (engine render lien tuc khi STOP -> outQueue day ~32 block ~170ms @@ -123,6 +168,7 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { this.outPos = 0; this.playing = false; this.wrapFadeBlocks = -1; // V42: wipe — hủy wrap crossfade chờ (không có tail cũ) + this.wrapMode = false; // V45.3: play/seek — het context wrap this.resumeFadeIn = true; // V34: mode phan biet — play re-buffer FILL 8 (~43ms, cushion du hap thu // engine ramp/jitter dau play); ARM live note giu RESUME_FILL 8 (~43ms) — preview @@ -300,21 +346,31 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { // tại (giữa fade) — hết jump. const wasInterrupted = !this.resumeFadeIn && (this.holdFadeLeft > 0 || this.silenced) && this.hasStarted; if (wasInterrupted) { + // V45.3: wrap (loop restart) — engine da transport('play') tu main + // (D17): block moi = audio pass moi, KHONG phai backlog stale (boi + // worklet flush gui 'flush' xuong engine ring = lam mat tac dung D17 + // skip-flush o stopAllPlayback -> gap giua seam). Bo flush (round-trip) + // + re-buffer nho hon FILL 24 (~128ms) -> resume sau stall som hon + // (~43ms) -> gap wrap giam tu ~110-235ms xuong con vai chuc ms. + const _wrap = this.wrapMode; this.outQueue.length = 0; this.outPos = 0; - this.flushPending = true; this.resumeFadeIn = true; - this.resumeFill = this.FILL; // underrun-stall: giữ cushion FILL đầy đủ (V25 wobble) - this.port.postMessage({ type: 'flush' }); + this.resumeFill = _wrap ? 8 : this.FILL; + this.flushPending = _wrap ? false : true; + if (!_wrap) this.port.postMessage({ type: 'flush' }); i--; continue; } this.playing = true; this.outPos = 0; this.holdFadeLeft = 0; + this.wrapMode = false; // V45.3: resume xong — ve che do thuong (underrun sau nay flush nhu cu) if (this.dbgPlayFirst) { this.dbgPlayFirst = false; - this.port.postMessage({ type: 'dbg', tag: 'fx_play_first', t: Math.round(currentTime * 1000), q: this.outQueue.length }); + // V45.19 Bug 1b: seek -> tag fx_seek_first (main thread snap khong can + // guard 50ms — seek resume nhanh ~43ms < 50ms). Wrap/play -> fx_play_first. + this.port.postMessage({ type: 'dbg', tag: (this.clearMode === 'seek' ? 'fx_seek_first' : 'fx_play_first'), t: Math.round(currentTime * 1000), q: this.outQueue.length, m: this.clearMode || '' }); } if (this.resumeFadeIn) { this.resumeFadeIn = false; diff --git a/app/templates/index.html b/app/templates/index.html index 6369095..0960e6a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -32,27 +32,27 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + +