From 0af834a8fa0b1671f95fe6afa35f429007e632ac Mon Sep 17 00:00:00 2001 From: locphamtran Date: Tue, 11 Aug 2026 17:33:12 +0700 Subject: [PATCH] T15: drop WebAudio master for tracks - native master gain + track gain/pan IPC (SF/VST2/VST3 bridges), NativeAudioService + /api/v1/native router, native-first TrackInstrument, test matrix 8 passed --- app/api/v1/native.py | 194 +++++++ app/core/native_audio_service.py | 610 ++++++++++++++++++++ app/main.py | 2 + app/static/js/app.jsx | 14 + app/static/js/app.precompiled.js | 9 +- app/static/js/services/nativeAudioClient.js | 77 +++ app/static/js/services/trackInstrument.js | 35 +- app/templates/index.html | 1 + native_host/NativeMixer.cpp | 6 +- native_host/NativeMixer.h | 5 + native_host/SFHost.cpp | 12 + native_host/VST2AudioEngine.cpp | 11 + native_host/vst3_host_bridge.cpp | 11 + tests/test_native_matrix.py | 152 +++++ 14 files changed, 1131 insertions(+), 8 deletions(-) create mode 100644 app/api/v1/native.py create mode 100644 app/core/native_audio_service.py create mode 100644 app/static/js/services/nativeAudioClient.js create mode 100644 tests/test_native_matrix.py diff --git a/app/api/v1/native.py b/app/api/v1/native.py new file mode 100644 index 0000000..4e94b98 --- /dev/null +++ b/app/api/v1/native.py @@ -0,0 +1,194 @@ +# SonicForge Native Audio API (T15) — track instrument đi thẳng vào engine +# native (SF host / VST2 / VST3 bridge DLL), KHÔNG qua masterBus WebAudio. +# Master fader + track gain/pan áp native qua NativeMixer — một engine duy nhất. +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional +import os + +from app.core.native_audio_service import get_service +from app.core.render_engine import _find_sf2_path + +router = APIRouter() + + +class MasterGainRequest(BaseModel): + gain_db: float = 0.0 + + +class TrackGainPanRequest(BaseModel): + track_id: str + gain_db: float = 0.0 + pan: float = 0.0 + + +class SfEnsureRequest(BaseModel): + track_id: str + sf_path: Optional[str] = None + sf_id: Optional[str] = None # resolve path server-side (JS chỉ có UUID) + bank: int = 0 + program: int = 0 + channel: int = 0 + live: bool = True + + +class SfNoteRequest(BaseModel): + track_id: str + channel: int = 0 + pitch: int + velocity: int = 100 + + +class SfNoteOffRequest(BaseModel): + track_id: str + channel: int = 0 + pitch: int + + +class Vst2EnsureRequest(BaseModel): + track_id: str + plugin_path: str + live: bool = True + + +class Vst2NoteRequest(BaseModel): + track_id: str + channel: int = 0 + pitch: int + velocity: int = 100 + + +class Vst2NoteOffRequest(BaseModel): + track_id: str + channel: int = 0 + pitch: int + + +class RenderNote(BaseModel): + note: int = 60 + velocity: int = 100 + start_beat: float = 0.0 + duration_beats: float = 1.0 + + +class RenderRequest(BaseModel): + kind: str # "sf" | "vst2" + path: str + bank: int = 0 + program: int = 0 + notes: List[RenderNote] = [] + bpm: float = 120.0 + gain_db: float = 0.0 + pan: float = 0.0 + lim_active: bool = False + threshold_db: float = -1.0 + master_gain_db: float = 0.0 + + +def _svc(): + return get_service() + + +@router.get("/status") +async def status(): + return _svc().status() + + +@router.post("/set_master_gain") +async def set_master_gain(req: MasterGainRequest): + return _svc().set_master_gain(req.gain_db) + + +@router.post("/track_gain_pan") +async def track_gain_pan(req: TrackGainPanRequest): + return _svc().set_track_gain_pan(req.track_id, req.gain_db, req.pan) + + +@router.post("/sf/ensure") +async def sf_ensure(req: SfEnsureRequest): + path = (req.sf_path or "").strip() + if not path and req.sf_id: + path = _find_sf2_path(req.sf_id) + if not path or not os.path.exists(path): + raise HTTPException(status_code=400, detail=f"không tìm thấy SF2/SF3 " + f"(sf_path={req.sf_path!r} sf_id={req.sf_id!r})") + try: + return _svc().ensure_sf(req.track_id, path, req.bank, + req.program, req.channel, req.live) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/sf/note_on") +async def sf_note_on(req: SfNoteRequest): + try: + return _svc().sf_note_on(req.track_id, req.channel, req.pitch, req.velocity) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/sf/note_off") +async def sf_note_off(req: SfNoteOffRequest): + try: + return _svc().sf_note_off(req.track_id, req.channel, req.pitch) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/sf/audio_stop") +async def sf_audio_stop(req: SfNoteOffRequest): + return _svc().sf_audio_stop(req.track_id) + + +@router.post("/vst2/ensure") +async def vst2_ensure(req: Vst2EnsureRequest): + try: + return _svc().ensure_vst2(req.track_id, req.plugin_path, req.live) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/vst2/note_on") +async def vst2_note_on(req: Vst2NoteRequest): + try: + return _svc().vst2_note_on(req.track_id, req.channel, req.pitch, req.velocity) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/vst2/note_off") +async def vst2_note_off(req: Vst2NoteOffRequest): + try: + return _svc().vst2_note_off(req.track_id, req.channel, req.pitch) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/render") +async def render(req: RenderRequest): + """Offline render cho test matrix spec V. Trả về số liệu (không trả audio + bytes) — peak/rms đủ để so sánh âm lượng/mastering.""" + svc = _svc() + notes = [n.model_dump() for n in req.notes] + try: + if req.kind == "sf": + y = svc.render_sf_offline(req.path, req.bank, req.program, notes, + req.bpm, gain_db=req.gain_db, pan=req.pan, + lim_active=req.lim_active, + threshold_db=req.threshold_db, + master_gain_db=req.master_gain_db) + elif req.kind == "vst2": + y = svc.render_vst2_offline(req.path, notes, req.bpm, + gain_db=req.gain_db, pan=req.pan, + lim_active=req.lim_active, + threshold_db=req.threshold_db, + master_gain_db=req.master_gain_db) + else: + raise HTTPException(status_code=400, + detail="kind phải là 'sf' hoặc 'vst2'") + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + import numpy as np + return {"ok": True, "kind": req.kind, "samples": int(y.shape[1]), + "peak": float(np.abs(y).max()), + "rms": float(np.sqrt((y ** 2).mean()))} diff --git a/app/core/native_audio_service.py b/app/core/native_audio_service.py new file mode 100644 index 0000000..de3c2e2 --- /dev/null +++ b/app/core/native_audio_service.py @@ -0,0 +1,610 @@ +"""Native audio service (T15) — một engine duy nhất cho track instrument. + +Load các bridge native_host DLL qua ctypes (SF host / VST2 / VST3) và giữ +instance theo track_id. Mọi track instrument (SF + VSTi) đi qua CÙNG mixer +native (track gain/pan + master limiter + master fader) — hết drift Bug 2: +JS không còn route instrument qua masterBus WebAudio. + +- Live: AudioStart (WASAPI) → note_on/note_off qua SPSC trên audio thread. +- Offline SF: SF_FS_RenderBlock (write_float + mixer native trong C++). +- Offline VST2: SF_VST2_Process (raw plugin) + mixer_math (mirror NativeMixer/ + mastering_engine.py) trong Python — bridge Process không áp mixer. +- VST3 offline: bridge chưa có export Process — matrix ghi limitation (T15); + VST3 live dùng AudioStart + SendNoteOn như VST2 (AudioEngine chung). + +master_gain (dB) áp cho MỌI instance — mirror masterBus.output.gain WebAudio +(sau limiter + clip, linear, không re-clip). +""" +import ctypes +import os +import threading + +import numpy as np + +from app.config import settings + +_NATIVE_DIR = os.getenv("SONICFORGE_NATIVE_DIR") or os.path.join( + settings.BASE_DIR, "native_host", "build", "Release") + +_ERR = None +_LOCK = threading.Lock() + + +def native_dir() -> str: + return _NATIVE_DIR + + +def _dll(name: str) -> str: + return os.path.join(_NATIVE_DIR, name) + + +def _errbuf(): + return ctypes.create_string_buffer(256) + + +def _errval(buf) -> str: + return buf.value.decode(errors="replace") if buf and buf.value else "" + + +def is_available() -> bool: + return os.path.isfile(_dll("sf_host_bridge.dll")) + + +# ── mixer math (offline VST) — MIRROR NativeMixer.cpp + mastering_engine.py ── +def _clamp_db(v, lo, hi, default): + if v != v: # NaN + return default + return max(lo, min(hi, v)) + + +def apply_track_mixer(y, gain_db=0.0, pan=0.0, lim_active=False, + threshold_db=-1.0, master_lin=1.0): + """y: (2, N) float32. Áp track gain/pan + limiter + master fader.""" + y = y * (10.0 ** (gain_db / 20.0)) + if pan != 0.0: + theta = ((max(-1.0, min(1.0, pan)) + 1.0) / 2.0) * (np.pi / 2.0) + y = y.copy() + y[0, :] *= np.cos(theta) + y[1, :] *= np.sin(theta) + if lim_active: + t = _clamp_db(threshold_db, -24.0, 0.0, -1.0) + t_lin = 10.0 ** (t / 20.0) + k = 1.0 / max(0.02, t_lin) + tk = np.tanh(k) + y = np.tanh(y * k) / tk + np.clip(y, -1.0, 1.0, out=y) + y = y * master_lin + return y + + +class _SfCtx: + """ctypes wrapper một instance sf_host_bridge (handle theo track).""" + + def __init__(self, dll: ctypes.WinDLL): + self.dll = dll + self.handle = 0 + + def create(self, sr, block): + self.handle = self.dll.SF_FS_Create(sr, block, _ERR, 256) + return self.handle + + def close(self): + if self.handle: + try: + self.dll.SF_FS_Close(self.handle, _ERR, 256) + finally: + self.handle = 0 + + def __getattr__(self, name): + return getattr(self.dll, "SF_FS_" + name) + + +class _Vst2Ctx: + def __init__(self, dll: ctypes.WinDLL): + self.dll = dll + self.handle = 0 + + def create(self, plugin_path): + w = ctypes.c_int32(0) + h = ctypes.c_int32(0) + self.handle = self.dll.SF_VST2_Load(plugin_path.encode("utf-8"), 0, + ctypes.byref(w), ctypes.byref(h), + _ERR, 256) + return self.handle + + def close(self): + if self.handle: + try: + self.dll.SF_VST2_Close(self.handle, _ERR, 256) + finally: + self.handle = 0 + + def __getattr__(self, name): + return getattr(self.dll, "SF_VST2_" + name) + + +class _Vst3Ctx: + def __init__(self, dll: ctypes.WinDLL): + self.dll = dll + self.handle = 0 + + def create(self, plugin_path): + w = ctypes.c_int32(0) + h = ctypes.c_int32(0) + self.handle = self.dll.SF_VST3_Load(plugin_path.encode("utf-8"), 0, + ctypes.byref(w), ctypes.byref(h), + _ERR, 256) + return self.handle + + def close(self): + if self.handle: + try: + self.dll.SF_VST3_Close(self.handle, _ERR, 256) + finally: + self.handle = 0 + + def __getattr__(self, name): + return getattr(self.dll, "SF_VST3_" + name) + + +class NativeAudioService: + """Singleton: registry track_id → instance; master_gain toàn cục.""" + + def __init__(self): + self._sf = {} + self._vst2 = {} + self._vst3 = {} + self._sf_dll = None + self._vst2_dll = None + self._vst3_dll = None + self.master_gain_db = 0.0 + self._master_lin = 1.0 + self.sample_rate = 44100 + self.block_size = 256 + + # ── master fader (IPC set_master_gain) ── + def set_master_gain(self, gain_db): + with _LOCK: + self.master_gain_db = float(gain_db) + self._master_lin = 0.0 if self.master_gain_db <= -50 else \ + 10.0 ** (self.master_gain_db / 20.0) + for ctx in list(self._sf.values()) + list(self._vst2.values()) + \ + list(self._vst3.values()): + if ctx and ctx.handle: + try: + ctx.SetMasterGain(ctx.handle, self._master_lin) + except Exception: + pass + return {"ok": True, "master_gain_db": self.master_gain_db, + "linear": self._master_lin} + + def _load_sf_dll(self): + if self._sf_dll is None: + if not os.path.isfile(_dll("sf_host_bridge.dll")): + raise RuntimeError(f"thiếu {_dll('sf_host_bridge.dll')} — " + f"build native_host Release (T9–T14)") + dll = ctypes.WinDLL(_dll("sf_host_bridge.dll")) + i32 = ctypes.c_int32 + dll.SF_FS_Create.argtypes = [i32, i32, ctypes.c_char_p, i32] + dll.SF_FS_Create.restype = i32 + dll.SF_FS_LoadSF2.argtypes = [i32, ctypes.c_char_p, ctypes.c_char_p, i32] + dll.SF_FS_LoadSF2.restype = i32 + dll.SF_FS_SelectInstrument.argtypes = [i32, i32, i32, i32, i32] + dll.SF_FS_SelectInstrument.restype = i32 + dll.SF_FS_NoteOn.argtypes = [i32, i32, i32, i32] + dll.SF_FS_NoteOn.restype = i32 + dll.SF_FS_NoteOff.argtypes = [i32, i32, i32] + dll.SF_FS_NoteOff.restype = i32 + dll.SF_FS_RenderBlock.argtypes = [i32, i32, + np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"), + np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS")] + dll.SF_FS_RenderBlock.restype = i32 + dll.SF_FS_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32] + dll.SF_FS_AudioStart.restype = i32 + dll.SF_FS_AudioStop.argtypes = [i32] + dll.SF_FS_AudioStop.restype = i32 + dll.SF_FS_AudioUnderruns.argtypes = [i32] + dll.SF_FS_AudioUnderruns.restype = i32 + dll.SF_FS_AudioBlocks.argtypes = [i32] + dll.SF_FS_AudioBlocks.restype = i32 + for fn in ("SetTrackGainPan", "SetMasterLimiter"): + getattr(dll, "SF_FS_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float] + getattr(dll, "SF_FS_" + fn).restype = i32 + dll.SF_FS_SetMasterGain.argtypes = [i32, ctypes.c_float] + dll.SF_FS_SetMasterGain.restype = i32 + dll.SF_FS_Close.argtypes = [i32, ctypes.c_char_p, i32] + dll.SF_FS_Close.restype = i32 + self._sf_dll = dll + return self._sf_dll + + def ensure_sf(self, track_id, sf_path, bank=0, program=0, channel=0, + live=True, gain_db=None, pan=None): + """Tạo (hoặc tái dùng) instance SF cho track. sf_path: đường dẫn SF2/SF3.""" + track_id = str(track_id) + with _LOCK: + dll = self._load_sf_dll() + ctx = self._sf.get(track_id) + if ctx is None: + ctx = _SfCtx(dll) + h = ctx.create(self.sample_rate, self.block_size) + if h <= 0: + raise RuntimeError(f"SF_FS_Create fail: {_errval(_ERR)}") + rc = ctx.LoadSF2(h, os.fspath(sf_path).encode("utf-8"), _ERR, 256) + if rc != 0: + ctx.close() + raise RuntimeError(f"SF_FS_LoadSF2 fail rc={rc}: {_errval(_ERR)}") + # sfontId<0 → dùng sfid của font vừa load (T14) + rc = ctx.SelectInstrument(h, channel, -1, int(bank), int(program)) + if rc != 0: + ctx.close() + raise RuntimeError(f"SF_FS_SelectInstrument fail rc={rc}: " + f"bank={bank} prog={program}") + ctx.SetMasterGain(h, self._master_lin) + if live: + rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256) + if rc != 0: + ctx.close() + raise RuntimeError(f"SF_FS_AudioStart fail rc={rc}: {_errval(_ERR)}") + self._sf[track_id] = ctx + else: + h = ctx.handle + ctx.SelectInstrument(h, channel, -1, int(bank), int(program)) + if gain_db is not None and pan is not None: + ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan)) + return {"ok": True, "track_id": track_id, "handle": ctx.handle} + + def sf_note_on(self, track_id, channel, pitch, velocity=100): + track_id = str(track_id) + with _LOCK: + ctx = self._sf.get(track_id) + if not ctx or not ctx.handle: + raise RuntimeError("track SF chưa ensure") + return {"ok": ctx.NoteOn(ctx.handle, int(channel), int(pitch), + int(max(1, min(127, velocity)))) == 0} + + def sf_note_off(self, track_id, channel, pitch): + track_id = str(track_id) + with _LOCK: + ctx = self._sf.get(track_id) + if not ctx or not ctx.handle: + raise RuntimeError("track SF chưa ensure") + return {"ok": ctx.NoteOff(ctx.handle, int(channel), int(pitch)) == 0} + + def set_track_gain_pan(self, track_id, gain_db, pan): + """Áp gain/pan cho instance track (SF/VST2/VST3).""" + track_id = str(track_id) + with _LOCK: + ctx = (self._sf.get(track_id) or self._vst2.get(track_id) or + self._vst3.get(track_id)) + if not ctx or not ctx.handle: + raise RuntimeError("track chưa ensure") + rc = ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan)) + return {"ok": rc == 0, "track_id": track_id, + "gain_db": gain_db, "pan": pan} + + def sf_stats(self, track_id): + track_id = str(track_id) + with _LOCK: + ctx = self._sf.get(track_id) + if not ctx or not ctx.handle: + return None + return {"blocks": ctx.AudioBlocks(ctx.handle), + "underruns": ctx.AudioUnderruns(ctx.handle)} + + def sf_audio_stop(self, track_id): + track_id = str(track_id) + with _LOCK: + ctx = self._sf.get(track_id) + if not ctx or not ctx.handle: + return {"ok": False} + return {"ok": ctx.AudioStop(ctx.handle) == 0} + + def render_sf_offline(self, sf_path, bank, program, notes, bpm=120.0, + sr=44100, gain_db=0.0, pan=0.0, lim_active=False, + threshold_db=-1.0, master_gain_db=0.0): + """Render offline 1 đoạn MIDI bằng instance TẠM (không AudioStart) — + RenderBlock áp mixer native (gain/pan/limiter/master) trong C++.""" + with _LOCK: + dll = self._load_sf_dll() + ctx = _SfCtx(dll) + h = ctx.create(sr, 256) + if h <= 0: + raise RuntimeError(f"SF_FS_Create fail: {_errval(_ERR)}") + try: + rc = ctx.LoadSF2(h, os.fspath(sf_path).encode("utf-8"), _ERR, 256) + if rc != 0: + raise RuntimeError(f"SF_FS_LoadSF2 fail rc={rc}") + rc = ctx.SelectInstrument(h, 0, -1, int(bank), int(program)) + if rc != 0: + raise RuntimeError(f"SF_FS_SelectInstrument fail rc={rc}") + ctx.SetTrackGainPan(h, float(gain_db), float(pan)) + ctx.SetMasterLimiter(h, int(lim_active), float(threshold_db)) + master_lin = 0.0 if master_gain_db <= -50 else \ + 10.0 ** (float(master_gain_db) / 20.0) + ctx.SetMasterGain(h, master_lin) + + beat_sec = 60.0 / max(30.0, bpm) + events = [] + total = 0.0 + for ev in notes: + start_s = float(ev.get("start_beat", 0)) * beat_sec + dur_s = float(ev.get("duration_beats", 1)) * beat_sec + pitch = int(ev.get("note", 60)) + vel = int(max(1, min(127, float(ev.get("velocity", 100))))) + events.append((int(start_s * sr), "on", pitch, vel)) + events.append((int((start_s + dur_s) * sr), "off", pitch, 0)) + total = max(total, start_s + dur_s) + events.sort(key=lambda e: e[0]) + n = int((max(total, 0.25) + 0.5) * sr) + out = np.zeros((2, n), dtype=np.float32) + block = 256 + pos = 0 + ev_idx = 0 + while pos < n: + while ev_idx < len(events) and events[ev_idx][0] <= pos: + _, kind, pitch, vel = events[ev_idx] + if kind == "on": + ctx.NoteOn(h, 0, pitch, vel) + else: + ctx.NoteOff(h, 0, pitch) + ev_idx += 1 + take = min(block, n - pos) + l = np.zeros(take, np.float32) + r = np.zeros(take, np.float32) + ctx.RenderBlock(h, take, l, r) + out[0, pos:pos + take] = l + out[1, pos:pos + take] = r + pos += take + return out + finally: + ctx.close() + + # ── VST2 (live + offline qua SF_VST2_Process) ── + def _load_vst2_dll(self): + if self._vst2_dll is None: + if not os.path.isfile(_dll("vst2_host_bridge.dll")): + raise RuntimeError(f"thiếu {_dll('vst2_host_bridge.dll')}") + dll = ctypes.WinDLL(_dll("vst2_host_bridge.dll")) + i32 = ctypes.c_int32 + dll.SF_VST2_Load.argtypes = [ctypes.c_char_p, ctypes.c_void_p, + ctypes.POINTER(i32), ctypes.POINTER(i32), + ctypes.c_char_p, i32] + dll.SF_VST2_Load.restype = i32 + dll.SF_VST2_SendNoteOn.argtypes = [i32, i32, i32, i32] + dll.SF_VST2_SendNoteOn.restype = i32 + dll.SF_VST2_SendNoteOff.argtypes = [i32, i32, i32] + dll.SF_VST2_SendNoteOff.restype = i32 + dll.SF_VST2_Process.argtypes = [i32, + np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"), + i32, i32] + dll.SF_VST2_Process.restype = i32 + dll.SF_VST2_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32] + dll.SF_VST2_AudioStart.restype = i32 + dll.SF_VST2_AudioStop.argtypes = [i32] + dll.SF_VST2_AudioStop.restype = i32 + dll.SF_VST2_AudioUnderruns.argtypes = [i32] + dll.SF_VST2_AudioUnderruns.restype = i32 + dll.SF_VST2_AudioBlocks.argtypes = [i32] + dll.SF_VST2_AudioBlocks.restype = i32 + dll.SF_VST2_Close.argtypes = [i32, ctypes.c_char_p, i32] + dll.SF_VST2_Close.restype = i32 + for fn in ("SetTrackGainPan", "SetMasterLimiter"): + getattr(dll, "SF_VST2_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float] + getattr(dll, "SF_VST2_" + fn).restype = i32 + dll.SF_VST2_SetMasterGain.argtypes = [i32, ctypes.c_float] + dll.SF_VST2_SetMasterGain.restype = i32 + self._vst2_dll = dll + return self._vst2_dll + + def ensure_vst2(self, track_id, plugin_path, live=True, gain_db=None, pan=None): + track_id = str(track_id) + with _LOCK: + dll = self._load_vst2_dll() + ctx = self._vst2.get(track_id) + if ctx is None: + ctx = _Vst2Ctx(dll) + h = ctx.create(plugin_path) + if h <= 0: + raise RuntimeError(f"SF_VST2_Load fail: {_errval(_ERR)}") + ctx.SetMasterGain(h, self._master_lin) + if live: + rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256) + if rc != 0: + ctx.close() + raise RuntimeError(f"SF_VST2_AudioStart fail rc={rc}: {_errval(_ERR)}") + self._vst2[track_id] = ctx + if gain_db is not None and pan is not None: + ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan)) + return {"ok": True, "track_id": track_id, "handle": ctx.handle} + + def vst2_note_on(self, track_id, channel, pitch, velocity=100): + track_id = str(track_id) + with _LOCK: + ctx = self._vst2.get(track_id) + if not ctx or not ctx.handle: + raise RuntimeError("track VST2 chưa ensure") + return {"ok": ctx.SendNoteOn(ctx.handle, int(channel), int(pitch), + int(max(1, min(127, velocity)))) == 0} + + def vst2_note_off(self, track_id, channel, pitch): + track_id = str(track_id) + with _LOCK: + ctx = self._vst2.get(track_id) + if not ctx or not ctx.handle: + raise RuntimeError("track VST2 chưa ensure") + return {"ok": ctx.SendNoteOff(ctx.handle, int(channel), int(pitch)) == 0} + + def vst2_stats(self, track_id): + track_id = str(track_id) + with _LOCK: + ctx = self._vst2.get(track_id) + if not ctx or not ctx.handle: + return None + return {"blocks": ctx.AudioBlocks(ctx.handle), + "underruns": ctx.AudioUnderruns(ctx.handle)} + + def render_vst2_offline(self, plugin_path, notes, bpm=120.0, sr=44100, + gain_db=0.0, pan=0.0, lim_active=False, + threshold_db=-1.0, master_gain_db=0.0): + """Offline VST2: SF_VST2_Process (raw) + mixer_math (Python mirror).""" + with _LOCK: + dll = self._load_vst2_dll() + ctx = _Vst2Ctx(dll) + h = ctx.create(plugin_path) + if h <= 0: + raise RuntimeError(f"SF_VST2_Load fail: {_errval(_ERR)}") + try: + beat_sec = 60.0 / max(30.0, bpm) + events = [] + total = 0.0 + for ev in notes: + start_s = float(ev.get("start_beat", 0)) * beat_sec + dur_s = float(ev.get("duration_beats", 1)) * beat_sec + pitch = int(ev.get("note", 60)) + vel = int(max(1, min(127, float(ev.get("velocity", 100))))) + events.append((int(start_s * sr), "on", pitch, vel)) + events.append((int((start_s + dur_s) * sr), "off", pitch, 0)) + total = max(total, start_s + dur_s) + events.sort(key=lambda e: e[0]) + n = int((max(total, 0.25) + 0.5) * sr) + out = np.zeros((2, n), dtype=np.float32) + block = 256 + pos = 0 + ev_idx = 0 + buf = np.zeros((4, block), np.float32) # inputs+outputs (fake: 2+2) + while pos < n: + while ev_idx < len(events) and events[ev_idx][0] <= pos: + _, kind, pitch, vel = events[ev_idx] + if kind == "on": + ctx.SendNoteOn(h, 0, pitch, vel) + else: + ctx.SendNoteOff(h, 0, pitch) + ev_idx += 1 + take = min(block, n - pos) + buf.fill(0) + ctx.Process(h, buf, 4, take) + # ponytail: giả định plugin synth 0-input → output ở buf[0:2] + # (bridge xếp outputs tại offset numInputs; khi cần plugin + # có input → thêm export getNumIO rồi đọc offset đó) + out[0, pos:pos + take] = buf[0, :take] + out[1, pos:pos + take] = buf[1, :take] + pos += take + master_lin = 0.0 if master_gain_db <= -50 else \ + 10.0 ** (float(master_gain_db) / 20.0) + return apply_track_mixer(out, gain_db, pan, lim_active, + threshold_db, master_lin) + finally: + ctx.close() + + # ── VST3: live qua bridge (offline: limitation — chưa có Process export) ── + def _load_vst3_dll(self): + if self._vst3_dll is None: + if not os.path.isfile(_dll("vst3_host_bridge.dll")): + raise RuntimeError(f"thiếu {_dll('vst3_host_bridge.dll')}") + dll = ctypes.WinDLL(_dll("vst3_host_bridge.dll")) + i32 = ctypes.c_int32 + dll.SF_VST3_Load.argtypes = [ctypes.c_char_p, ctypes.c_void_p, + ctypes.POINTER(i32), ctypes.POINTER(i32), + ctypes.c_char_p, i32] + dll.SF_VST3_Load.restype = i32 + dll.SF_VST3_SendNoteOn.argtypes = [i32, i32, i32, i32] + dll.SF_VST3_SendNoteOn.restype = i32 + dll.SF_VST3_SendNoteOff.argtypes = [i32, i32, i32] + dll.SF_VST3_SendNoteOff.restype = i32 + dll.SF_VST3_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32] + dll.SF_VST3_AudioStart.restype = i32 + dll.SF_VST3_AudioStop.argtypes = [i32] + dll.SF_VST3_AudioStop.restype = i32 + dll.SF_VST3_AudioUnderruns.argtypes = [i32] + dll.SF_VST3_AudioUnderruns.restype = i32 + dll.SF_VST3_AudioBlocks.argtypes = [i32] + dll.SF_VST3_Close.argtypes = [i32, ctypes.c_char_p, i32] + dll.SF_VST3_Close.restype = i32 + for fn in ("SetTrackGainPan", "SetMasterLimiter"): + getattr(dll, "SF_VST3_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float] + getattr(dll, "SF_VST3_" + fn).restype = i32 + dll.SF_VST3_SetMasterGain.argtypes = [i32, ctypes.c_float] + dll.SF_VST3_SetMasterGain.restype = i32 + self._vst3_dll = dll + return self._vst3_dll + + def ensure_vst3(self, track_id, plugin_path, live=True, gain_db=None, pan=None): + track_id = str(track_id) + with _LOCK: + dll = self._load_vst3_dll() + ctx = self._vst3.get(track_id) + if ctx is None: + ctx = _Vst3Ctx(dll) + h = ctx.create(plugin_path) + if h <= 0: + raise RuntimeError(f"SF_VST3_Load fail: {_errval(_ERR)}") + ctx.SetMasterGain(h, self._master_lin) + if live: + rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256) + if rc != 0: + ctx.close() + raise RuntimeError(f"SF_VST3_AudioStart fail rc={rc}: {_errval(_ERR)}") + self._vst3[track_id] = ctx + if gain_db is not None and pan is not None: + ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan)) + return {"ok": True, "track_id": track_id, "handle": ctx.handle} + + def vst3_note_on(self, track_id, channel, pitch, velocity=100): + track_id = str(track_id) + with _LOCK: + ctx = self._vst3.get(track_id) + if not ctx or not ctx.handle: + raise RuntimeError("track VST3 chưa ensure") + return {"ok": ctx.SendNoteOn(ctx.handle, int(channel), int(pitch), + int(max(1, min(127, velocity)))) == 0} + + def vst3_note_off(self, track_id, channel, pitch): + track_id = str(track_id) + with _LOCK: + ctx = self._vst3.get(track_id) + if not ctx or not ctx.handle: + raise RuntimeError("track VST3 chưa ensure") + return {"ok": ctx.SendNoteOff(ctx.handle, int(channel), int(pitch)) == 0} + + def close_track(self, track_id): + track_id = str(track_id) + with _LOCK: + for reg in (self._sf, self._vst2, self._vst3): + ctx = reg.pop(track_id, None) + if ctx: + try: + ctx.close() + except Exception: + pass + return {"ok": True} + + def close_all(self): + with _LOCK: + for reg in (self._sf, self._vst2, self._vst3): + for ctx in list(reg.values()): + try: + ctx.close() + except Exception: + pass + reg.clear() + return {"ok": True} + + def status(self): + return { + "available": is_available(), + "dll_dir": _NATIVE_DIR, + "master_gain_db": self.master_gain_db, + "sf_tracks": sorted(self._sf.keys()), + "vst2_tracks": sorted(self._vst2.keys()), + "vst3_tracks": sorted(self._vst3.keys()), + } + + +_service = None + + +def get_service() -> NativeAudioService: + global _service + if _service is None: + _service = NativeAudioService() + return _service diff --git a/app/main.py b/app/main.py index ef4f56e..5e6393b 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ from app.api.v1.plugins import router as plugins_router from app.api.v1.media import router as media_router from app.api.v1.system import router as system_router from app.api.v1.presets import router as presets_router +from app.api.v1.native import router as native_router from app.core.auth import seed_admin from app.core.soundfont_scanner import SoundFontAutoScanner @@ -112,6 +113,7 @@ app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"]) app.include_router(media_router, prefix="/api/v1/media", tags=["media"]) app.include_router(system_router, prefix="/api/v1/system", tags=["system"]) app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"]) +app.include_router(native_router, prefix="/api/v1/native", tags=["native"]) @app.get("/health") diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 1d86710..2bda6b6 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -1980,6 +1980,8 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal, const handleFaderChange = (val) => { setMasterVolume(val); + // T15: master fader track instrument → native engine (NativeMixer) + if (window.SonicNativeAudio) window.SonicNativeAudio.setMasterGain(val); ensureAudio(); if (masterBus && masterBus.output) { const linear = val <= -50 ? 0 : Math.pow(10, val / 20); @@ -24241,6 +24243,12 @@ const App = () => { if (sn && sn.gainNode) sn.gainNode.gain.setValueAtTime(volLinear, ctx.currentTime); } }); + // T15: track gain → native engine (NativeMixer) + if (window.SonicNativeAudio) { + const trk = (tracks || []).find(t => t.id === trackId); + const curPan = trk && trk.pan != null ? trk.pan : 0; + window.SonicNativeAudio.setTrackGainPan(trackId, val, curPan); + } }; const updateTrackPan = (trackId, val) => { const beforeSnap = captureTrackSnapshot(trackId); @@ -24264,6 +24272,12 @@ const App = () => { if (nodes) { nodes.pannerNode.pan.setValueAtTime(val / 100, getAudioContext().currentTime); } + // T15: track pan → native engine (NativeMixer; pan -100..100 → -1..1) + if (window.SonicNativeAudio) { + const trk = (tracks || []).find(t => t.id === trackId); + const curVol = trk && trk.volumeDb != null ? trk.volumeDb : 0; + window.SonicNativeAudio.setTrackGainPan(trackId, curVol, val / 100); + } }; const updateTrackName = (trackId, name) => { const beforeSnap = captureTrackSnapshot(trackId); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 756adfc..53f0b3c 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -275,7 +275,8 @@ if(enableMonitoring&&destinationTrackGainNode){this.sourceNode.connect(destinati const totalSamples=this.pcmChunks.reduce((sum,chunk)=>sum+chunk.length,0);if(totalSamples===0)return null;const audioBuffer=this.audioCtx.createBuffer(1,totalSamples,this.audioCtx.sampleRate);const channelData=audioBuffer.getChannelData(0);let offset=0;for(const chunk of this.pcmChunks){channelData.set(chunk,offset);offset+=chunk.length;}return audioBuffer;// Return compiled AudioBuffer for timeline insertion }}const VolumeKnob=({value,onChange,min=0,max=1})=>{const[isDragging,setIsDragging]=useState(false);const startY=useRef(0);const startValue=useRef(0);const rotation=useMemo(()=>{const percent=(value-min)/(max-min);return-135+percent*270;},[value,min,max]);const handleMouseDown=e=>{setIsDragging(true);startY.current=e.clientY;startValue.current=value;document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleMouseMove=e=>{const deltaY=startY.current-e.clientY;const sensitivity=0.005;const newValue=Math.max(min,Math.min(max,startValue.current+deltaY*sensitivity));onChange(parseFloat(newValue.toFixed(2)));};const handleMouseUp=()=>{setIsDragging(false);document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};return/*#__PURE__*/React.createElement("div",{className:"knob-container cursor-ns-resize flex flex-col items-center",onMouseDown:handleMouseDown,title:`Volume: ${Math.round(value*100)}%`},/*#__PURE__*/React.createElement("svg",{className:"w-7 h-7",viewBox:"0 0 40 40"},/*#__PURE__*/React.createElement("circle",{cx:"20",cy:"20",r:"16",fill:"#141414",stroke:"#444",strokeWidth:"2"}),/*#__PURE__*/React.createElement("g",{transform:`rotate(${rotation} 20 20)`,className:"knob-dial"},/*#__PURE__*/React.createElement("line",{x1:"20",y1:"20",x2:"20",y2:"6",stroke:"#ef4444",strokeWidth:"3",strokeLinecap:"round"}))));};const MixerStrip=({track,index,onUpdateTrack,trackVuRefs})=>{const dbLabel=track.volumeDb==null||track.volumeDb<=-50?'-inf':(track.volumeDb>0?'+':'')+(track.volumeDb||0).toFixed(1)+'dB';const isMuted=track.muted;const isSoloed=track.solo;const isAudioBypassed=!!track.audioBypass;const isMidiBypassed=!!track.midiBypass;const vol=track.volumeDb!=null?track.volumeDb:0;var pct=Math.max(0,Math.min(100,(vol+60)/72*100));var vuColor=pct>=80?'#ef4444':pct>=50?'#eab308':'#22c55e';var trackColor=track.color||'#06b6d4';return React.createElement("div",{className:"flex flex-col items-stretch w-[84px] shrink-0 bg-[#2b2b2b] border border-black/70 overflow-hidden rounded-sm"},React.createElement("div",{className:"flex items-center justify-between px-1 py-0.5 bg-[#222] border-b border-black/60 shrink-0"},React.createElement("span",{className:"text-[9px] font-mono font-bold text-zinc-400"},index+1)),React.createElement("div",{className:"flex items-center justify-center gap-1 py-0.5 shrink-0"},React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!track.muted;if(onUpdateTrack)onUpdateTrack(track.id,{muted:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:next});},title:"Mute",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isMuted?'bg-orange-500 text-black border-orange-400':'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')},"M"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!track.solo;if(onUpdateTrack)onUpdateTrack(track.id,{solo:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:next});},title:"Solo",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isSoloed?'bg-yellow-400 text-black border-yellow-300':'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')},"S"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!(track.audioBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{audioBypass:next});// Live audio re-route (applies immediately to playing tracks). if(window.__setTrackBypass)window.__setTrackBypass(track.id,'audio',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},title:"A = Mastering FX Chain cho audio items (clips + sections): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG XANH = qua mastering",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isAudioBypassed?'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100':'bg-sky-400 text-black border-sky-300')},"A"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!(track.midiBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{midiBypass:next});if(window.__setTrackBypass)window.__setTrackBypass(track.id,'midi',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},title:"♪ = Mastering FX Chain cho MIDI (soundfont): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG TÍM = qua mastering",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isMidiBypassed?'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100':'bg-fuchsia-400 text-black border-fuchsia-300')},"\u266A")),React.createElement("div",{className:"flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"},React.createElement("div",{className:"w-[30px] rounded-sm relative overflow-hidden bg-[#0d0d0d] border border-black/60 flex flex-col items-center cursor-pointer",onMouseDown:function(e){e.preventDefault();var rect=e.currentTarget.getBoundingClientRect();var tid=track.id;function onMove(ev){var pct=1-Math.max(0,Math.min(1,(ev.clientY-rect.top)/rect.height));var val=Math.round((pct*72-60)*2)/2;if(onUpdateTrack)onUpdateTrack(tid,{volumeDb:val});}function onUp(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);onMove(e);}},/* 0dB reference line */React.createElement("div",{className:"absolute w-full h-px bg-amber-400/60 z-10 pointer-events-none",style:{bottom:'83.333%'}}),/* Background gradient */React.createElement("div",{className:"absolute inset-0",style:{background:'linear-gradient(to top, #22c55e, #eab308, #ef4444)'}}),/* Level overlay - dark at TOP, gradient visible at bottom */React.createElement("div",{className:"absolute top-0 w-full transition-all duration-75 bg-[#0d0d0d]",style:{height:100-pct+'%'}})),React.createElement("canvas",{ref:el=>{if(el)trackVuRefs.current[track.id+'_mixer']=el;else delete trackVuRefs.current[track.id+'_mixer'];},width:15,height:120,className:"w-[15px] rounded-sm bg-[#0d0d0d] border border-black/60 block h-full"})),React.createElement("div",{className:"text-center text-[9px] font-mono font-bold py-0.5 "+(vol>0?'text-orange-400':'text-zinc-300')+" bg-[#1c1c1c] border-t border-black/50 shrink-0"},dbLabel),React.createElement("div",{className:"text-[8px] font-mono truncate w-full text-center px-1 py-0.5 bg-[#222] border-t border-black/60 shrink-0",style:{color:trackColor}},track.name));};// ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ── -const MasterStripConsole=({masterVolume,setMasterVolume,showMasteringModal,setShowMasteringModal,masteringSettings,setMasteringSettings,isPlaying})=>{const[isFxActive,setIsFxActive]=React.useState(true);const[isTestPlaying,setIsTestPlaying]=React.useState(false);const[isMuted,setIsMuted]=React.useState(false);const[isMono,setIsMono]=React.useState(false);const[pan,setPan]=React.useState(0.0);const[panText,setPanText]=React.useState('center');const vuCanvasRef=React.useRef(null);const animFrameRef=React.useRef(null);const rmsValRef=React.useRef(null);const peakLRef=React.useRef(null);const peakRRef=React.useRef(null);const panPointerRef=React.useRef(null);const isMasterActive=masteringSettings?masteringSettings.masterConnected:false;const panStartYRef=React.useRef(0);const startPanValRef=React.useRef(0);const ensureAudio=()=>{getAudioContext();};const handleFaderChange=val=>{setMasterVolume(val);ensureAudio();if(masterBus&&masterBus.output){const linear=val<=-50?0:Math.pow(10,val/20);masterBus.output.gain.setTargetAtTime(linear,audioCtx.currentTime,0.01);}};const handlePanPointerDown=e=>{isPanDraggingRef.current=true;panStartYRef.current=e.clientY;startPanValRef.current=pan;e.currentTarget.setPointerCapture(e.pointerId);};const handlePanPointerMove=e=>{if(!isPanDraggingRef.current)return;const deltaY=panStartYRef.current-e.clientY;let newPan=startPanValRef.current+deltaY/80;newPan=Math.min(1.0,Math.max(-1.0,newPan));setPan(newPan);const angle=newPan*120;if(panPointerRef.current)panPointerRef.current.style.transform=`rotate(${angle}deg)`;if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));};const handlePanPointerUp=e=>{isPanDraggingRef.current=false;try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(err){}};React.useEffect(()=>{const canvas=vuCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');function render(){animFrameRef.current=requestAnimationFrame(render);canvas.width=canvas.clientWidth;canvas.height=canvas.clientHeight;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);let levelL=0;let levelR=0;if(masterBus&&masterBus.leftAnalyser&&masterBus.rightAnalyser){const leftData=new Uint8Array(256);const rightData=new Uint8Array(256);masterBus.leftAnalyser.getByteTimeDomainData(leftData);masterBus.rightAnalyser.getByteTimeDomainData(rightData);let peakL=0;let peakR=0;for(let i=0;ipeakL)peakL=v;}for(let i=0;ipeakR)peakR=v;}levelL=peakL;levelR=peakR;}const padding=4;const gap=4;const barW=Math.max(4,(w-padding*2-gap)/2);const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#10b981');grad.addColorStop(0.7,'#f59e0b');grad.addColorStop(0.95,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(padding,h-levelL*h,barW,levelL*h);ctx.fillRect(padding+barW+gap,h-levelR*h,barW,levelR*h);const maxLevel=Math.max(levelL,levelR);if(rmsValRef.current){rmsValRef.current.innerText=maxLevel>0?(20*Math.log10(maxLevel)-3.2).toFixed(1)+' dB':'-inf';}if(peakLRef.current){peakLRef.current.innerText=levelL>0?(20*Math.log10(levelL)).toFixed(1)+'dB':'-inf';}if(peakRRef.current){peakRRef.current.innerText=levelR>0?(20*Math.log10(levelR)).toFixed(1)+'dB':'-inf';}}render();return()=>{if(animFrameRef.current)cancelAnimationFrame(animFrameRef.current);};},[masterVolume,isMuted,isMono]);return React.createElement("div",{className:"flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden"},React.createElement("div",{className:"space-y-1.5 mb-2"},React.createElement("button",{onClick:()=>setShowMasteringModal(true),className:"w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"},"MASTERING PANEL"),React.createElement("div",{className:"flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"},React.createElement("span",{className:"text-slate-400 truncate"},"Output 1 / Output 2"),React.createElement("i",{className:"fa-solid fa-circle-notch text-[9px] text-slate-500"}))),React.createElement("div",{className:"flex flex-col items-center my-0.5"},React.createElement("span",{className:"text-[9px] text-slate-400 font-mono"},panText||'center'),React.createElement("div",{className:"flex items-center gap-1"},React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-right"},pan<0?'L'+Math.abs(Math.round(pan*100)):''),React.createElement("div",{id:"panDial",className:"w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer",title:"Kéo chuột để chỉnh Pan (Left/Right)",onPointerDown:handlePanPointerDown,onPointerMove:handlePanPointerMove,onPointerUp:handlePanPointerUp,onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.05:0.05;var newPan=Math.max(-1,Math.min(1,pan+step));newPan=Math.round(newPan*100)/100;setPan(newPan);if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+newPan*120+'deg)';if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));},onDoubleClick:function(){setPan(0);setPanText('center');if(panPointerRef.current)panPointerRef.current.style.transform='rotate(0deg)';}},React.createElement("div",{ref:panPointerRef,id:"panPointer",className:"w-0.5 h-2 bg-slate-200 rounded absolute top-0.5 transition-transform",style:{transform:'rotate('+pan*120+'deg)'}})),React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-left"},pan>0?'R'+Math.round(pan*100):''),React.createElement("span",{ref:React.createRef?null:null,className:"text-[10px] font-bold font-mono text-slate-200 ml-8",onDoubleClick:function(){handleFaderChange(0);}},masterVolume<=-50?'-inf':(masterVolume>0?'+':'')+masterVolume.toFixed(1)))),React.createElement("div",{className:"flex gap-1 my-1 justify-between items-stretch min-h-0",style:{flex:'1 1 0%'}},React.createElement("div",{className:"flex-1 flex flex-col bg-black/80 p-1.5 rounded border border-slate-800/90 relative shadow-inner"},React.createElement("div",{className:"flex-1 flex items-stretch justify-between min-h-0"},React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pr-1 text-right border-r border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-54")),React.createElement("div",{className:"flex-1 relative bg-slate-950 rounded overflow-hidden mx-1.5 border border-slate-900"},React.createElement("canvas",{ref:vuCanvasRef,className:"w-[100px] h-full block"}),React.createElement("div",{className:"absolute bottom-0.5 inset-x-0 flex justify-around text-[7px] font-mono text-slate-500 font-bold pointer-events-none bg-black/60 py-0.5"},React.createElement("span",null,"L"),React.createElement("span",null,"R"))),React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pl-1 text-left border-l border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-54"))),React.createElement("div",{className:"flex justify-between text-[9px] font-mono text-slate-400 mt-0.5"},React.createElement("span",{ref:peakLRef},"-inf"),React.createElement("span",{ref:peakRRef},"-inf"))),React.createElement("div",{className:"w-16 flex items-stretch gap-1 bg-slate-900/60 p-1 rounded border border-slate-800"},React.createElement("div",{className:"flex-1 flex flex-col items-center justify-center relative fader-track rounded",onWheel:function(e){e.preventDefault();var delta=e.deltaY>0?-0.5:0.5;handleFaderChange(Math.max(-60,Math.min(12,masterVolume+delta)));}},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute left-1/2 -translate-x-1/2 top-0"}),React.createElement("input",{id:"masterFader",type:"range",min:"-60",max:"12",step:"0.5",value:masterVolume,className:"fader-slider w-full z-10",orient:"vertical",onChange:function(e){handleFaderChange(parseFloat(e.target.value));},onDoubleClick:function(){handleFaderChange(0);}})),React.createElement("div",{className:"relative w-5 text-[7px] font-mono text-slate-500 select-none overflow-hidden"},React.createElement("span",{className:"absolute",style:{top:'0%',right:'2px'}},"+12"),React.createElement("span",{className:"absolute",style:{top:'8.3%',right:'2px'}},"+6"),React.createElement("span",{className:"absolute",style:{top:'16.7%',right:'2px'}},"0"),React.createElement("span",{className:"absolute",style:{top:'25%',right:'2px'}},"-6"),React.createElement("span",{className:"absolute",style:{top:'33.3%',right:'2px'}},"-12"),React.createElement("span",{className:"absolute",style:{top:'50%',right:'2px'}},"-24"),React.createElement("span",{className:"absolute",style:{top:'66.7%',right:'2px'}},"-36"),React.createElement("span",{className:"absolute",style:{top:'91.7%',right:'2px'}},"-54"))),React.createElement("div",{className:"w-8 flex flex-col justify-between text-[10px] font-bold"},React.createElement("button",{id:"monoBtn",onClick:function(){setIsMono(function(p){return!p;});},className:"btn-daw h-[18px] rounded flex flex-col items-center justify-center text-[8px]"+(isMono?" btn-mono-active":""),title:"Mono Switch"},React.createElement("i",{className:"fa-solid fa-circle-half-stroke text-[9px]"}),React.createElement("span",null,"MONO")),React.createElement("button",{id:"muteBtn",onClick:function(){setIsMuted(function(p){return!p;});},className:"btn-daw h-[18px] rounded text-amber-500 font-bold hover:text-amber-400"+(isMuted?" btn-mute-active":""),title:"Mute Master Output"},"M"),React.createElement("button",{id:"soloBtn",className:"btn-daw h-[18px] rounded text-yellow-400 font-bold hover:text-yellow-300",title:"Solo Master"},"S"),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 hover:text-slate-200",title:"Route Matrix"},React.createElement("i",{className:"fa-solid fa-diagram-project text-[9px]"})),React.createElement("button",{id:"fxBtn",className:"btn-daw h-[18px] rounded font-extrabold text-[10px] transition-all",onClick:function(){setShowMasteringModal(true);},title:"Mở MASTERING PANEL để chỉnh sửa"},"FX"),React.createElement("button",{id:"powerBtn",className:"btn-daw h-[18px] rounded text-xs transition-all"+(isMasterActive?" btn-teal-active":""),onClick:function(){if(setMasteringSettings){setMasteringSettings(function(prev){return Object.assign({},prev,{masterConnected:!prev.masterConnected,isBypassed:false});});}},title:"Bật/Tắt MASTERING PANEL Bypass"},[React.createElement("i",{className:"fa-solid fa-power-off"+(isFxActive?" text-emerald-400":" text-slate-500"),key:"ico"}),React.createElement("span",{key:"lbl",className:"text-[7px] font-bold"+(isFxActive?" text-emerald-300":" text-slate-400")},"PWR")]),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 text-[8px]",title:"Trim Envelope"},"TRIM"),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 text-[9px]",title:"Session Info"},React.createElement("i",{className:"fa-solid fa-info"})))),React.createElement("div",{className:"text-center text-[10px] font-bold font-mono text-slate-200 shrink-0"},masterVolume<=-50?'-inf':(masterVolume>0?'+':'')+masterVolume.toFixed(1)),React.createElement("div",{className:"shrink-0 border-t border-slate-800 flex flex-col items-center"},React.createElement("div",{className:"flex justify-between w-full text-[9px] font-mono py-0.5"},React.createElement("span",{className:"text-emerald-400"},"RMS"),React.createElement("span",{ref:rmsValRef,className:"text-emerald-400 font-bold"},"-inf")),React.createElement("div",{className:"w-full text-center bg-black/80 py-0.5 rounded border border-slate-800 text-[10px] font-extrabold tracking-widest text-slate-100 uppercase"},React.createElement("span",null,"MAIN OUT"))));};// ── Track Strip Console Component (from md/48_TRACK_STRIP_COMPONENT.md) ── +const MasterStripConsole=({masterVolume,setMasterVolume,showMasteringModal,setShowMasteringModal,masteringSettings,setMasteringSettings,isPlaying})=>{const[isFxActive,setIsFxActive]=React.useState(true);const[isTestPlaying,setIsTestPlaying]=React.useState(false);const[isMuted,setIsMuted]=React.useState(false);const[isMono,setIsMono]=React.useState(false);const[pan,setPan]=React.useState(0.0);const[panText,setPanText]=React.useState('center');const vuCanvasRef=React.useRef(null);const animFrameRef=React.useRef(null);const rmsValRef=React.useRef(null);const peakLRef=React.useRef(null);const peakRRef=React.useRef(null);const panPointerRef=React.useRef(null);const isMasterActive=masteringSettings?masteringSettings.masterConnected:false;const panStartYRef=React.useRef(0);const startPanValRef=React.useRef(0);const ensureAudio=()=>{getAudioContext();};const handleFaderChange=val=>{setMasterVolume(val);// T15: master fader track instrument → native engine (NativeMixer) +if(window.SonicNativeAudio)window.SonicNativeAudio.setMasterGain(val);ensureAudio();if(masterBus&&masterBus.output){const linear=val<=-50?0:Math.pow(10,val/20);masterBus.output.gain.setTargetAtTime(linear,audioCtx.currentTime,0.01);}};const handlePanPointerDown=e=>{isPanDraggingRef.current=true;panStartYRef.current=e.clientY;startPanValRef.current=pan;e.currentTarget.setPointerCapture(e.pointerId);};const handlePanPointerMove=e=>{if(!isPanDraggingRef.current)return;const deltaY=panStartYRef.current-e.clientY;let newPan=startPanValRef.current+deltaY/80;newPan=Math.min(1.0,Math.max(-1.0,newPan));setPan(newPan);const angle=newPan*120;if(panPointerRef.current)panPointerRef.current.style.transform=`rotate(${angle}deg)`;if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));};const handlePanPointerUp=e=>{isPanDraggingRef.current=false;try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(err){}};React.useEffect(()=>{const canvas=vuCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');function render(){animFrameRef.current=requestAnimationFrame(render);canvas.width=canvas.clientWidth;canvas.height=canvas.clientHeight;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);let levelL=0;let levelR=0;if(masterBus&&masterBus.leftAnalyser&&masterBus.rightAnalyser){const leftData=new Uint8Array(256);const rightData=new Uint8Array(256);masterBus.leftAnalyser.getByteTimeDomainData(leftData);masterBus.rightAnalyser.getByteTimeDomainData(rightData);let peakL=0;let peakR=0;for(let i=0;ipeakL)peakL=v;}for(let i=0;ipeakR)peakR=v;}levelL=peakL;levelR=peakR;}const padding=4;const gap=4;const barW=Math.max(4,(w-padding*2-gap)/2);const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#10b981');grad.addColorStop(0.7,'#f59e0b');grad.addColorStop(0.95,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(padding,h-levelL*h,barW,levelL*h);ctx.fillRect(padding+barW+gap,h-levelR*h,barW,levelR*h);const maxLevel=Math.max(levelL,levelR);if(rmsValRef.current){rmsValRef.current.innerText=maxLevel>0?(20*Math.log10(maxLevel)-3.2).toFixed(1)+' dB':'-inf';}if(peakLRef.current){peakLRef.current.innerText=levelL>0?(20*Math.log10(levelL)).toFixed(1)+'dB':'-inf';}if(peakRRef.current){peakRRef.current.innerText=levelR>0?(20*Math.log10(levelR)).toFixed(1)+'dB':'-inf';}}render();return()=>{if(animFrameRef.current)cancelAnimationFrame(animFrameRef.current);};},[masterVolume,isMuted,isMono]);return React.createElement("div",{className:"flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden"},React.createElement("div",{className:"space-y-1.5 mb-2"},React.createElement("button",{onClick:()=>setShowMasteringModal(true),className:"w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"},"MASTERING PANEL"),React.createElement("div",{className:"flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"},React.createElement("span",{className:"text-slate-400 truncate"},"Output 1 / Output 2"),React.createElement("i",{className:"fa-solid fa-circle-notch text-[9px] text-slate-500"}))),React.createElement("div",{className:"flex flex-col items-center my-0.5"},React.createElement("span",{className:"text-[9px] text-slate-400 font-mono"},panText||'center'),React.createElement("div",{className:"flex items-center gap-1"},React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-right"},pan<0?'L'+Math.abs(Math.round(pan*100)):''),React.createElement("div",{id:"panDial",className:"w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer",title:"Kéo chuột để chỉnh Pan (Left/Right)",onPointerDown:handlePanPointerDown,onPointerMove:handlePanPointerMove,onPointerUp:handlePanPointerUp,onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.05:0.05;var newPan=Math.max(-1,Math.min(1,pan+step));newPan=Math.round(newPan*100)/100;setPan(newPan);if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+newPan*120+'deg)';if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));},onDoubleClick:function(){setPan(0);setPanText('center');if(panPointerRef.current)panPointerRef.current.style.transform='rotate(0deg)';}},React.createElement("div",{ref:panPointerRef,id:"panPointer",className:"w-0.5 h-2 bg-slate-200 rounded absolute top-0.5 transition-transform",style:{transform:'rotate('+pan*120+'deg)'}})),React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-left"},pan>0?'R'+Math.round(pan*100):''),React.createElement("span",{ref:React.createRef?null:null,className:"text-[10px] font-bold font-mono text-slate-200 ml-8",onDoubleClick:function(){handleFaderChange(0);}},masterVolume<=-50?'-inf':(masterVolume>0?'+':'')+masterVolume.toFixed(1)))),React.createElement("div",{className:"flex gap-1 my-1 justify-between items-stretch min-h-0",style:{flex:'1 1 0%'}},React.createElement("div",{className:"flex-1 flex flex-col bg-black/80 p-1.5 rounded border border-slate-800/90 relative shadow-inner"},React.createElement("div",{className:"flex-1 flex items-stretch justify-between min-h-0"},React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pr-1 text-right border-r border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-54")),React.createElement("div",{className:"flex-1 relative bg-slate-950 rounded overflow-hidden mx-1.5 border border-slate-900"},React.createElement("canvas",{ref:vuCanvasRef,className:"w-[100px] h-full block"}),React.createElement("div",{className:"absolute bottom-0.5 inset-x-0 flex justify-around text-[7px] font-mono text-slate-500 font-bold pointer-events-none bg-black/60 py-0.5"},React.createElement("span",null,"L"),React.createElement("span",null,"R"))),React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pl-1 text-left border-l border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-54"))),React.createElement("div",{className:"flex justify-between text-[9px] font-mono text-slate-400 mt-0.5"},React.createElement("span",{ref:peakLRef},"-inf"),React.createElement("span",{ref:peakRRef},"-inf"))),React.createElement("div",{className:"w-16 flex items-stretch gap-1 bg-slate-900/60 p-1 rounded border border-slate-800"},React.createElement("div",{className:"flex-1 flex flex-col items-center justify-center relative fader-track rounded",onWheel:function(e){e.preventDefault();var delta=e.deltaY>0?-0.5:0.5;handleFaderChange(Math.max(-60,Math.min(12,masterVolume+delta)));}},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute left-1/2 -translate-x-1/2 top-0"}),React.createElement("input",{id:"masterFader",type:"range",min:"-60",max:"12",step:"0.5",value:masterVolume,className:"fader-slider w-full z-10",orient:"vertical",onChange:function(e){handleFaderChange(parseFloat(e.target.value));},onDoubleClick:function(){handleFaderChange(0);}})),React.createElement("div",{className:"relative w-5 text-[7px] font-mono text-slate-500 select-none overflow-hidden"},React.createElement("span",{className:"absolute",style:{top:'0%',right:'2px'}},"+12"),React.createElement("span",{className:"absolute",style:{top:'8.3%',right:'2px'}},"+6"),React.createElement("span",{className:"absolute",style:{top:'16.7%',right:'2px'}},"0"),React.createElement("span",{className:"absolute",style:{top:'25%',right:'2px'}},"-6"),React.createElement("span",{className:"absolute",style:{top:'33.3%',right:'2px'}},"-12"),React.createElement("span",{className:"absolute",style:{top:'50%',right:'2px'}},"-24"),React.createElement("span",{className:"absolute",style:{top:'66.7%',right:'2px'}},"-36"),React.createElement("span",{className:"absolute",style:{top:'91.7%',right:'2px'}},"-54"))),React.createElement("div",{className:"w-8 flex flex-col justify-between text-[10px] font-bold"},React.createElement("button",{id:"monoBtn",onClick:function(){setIsMono(function(p){return!p;});},className:"btn-daw h-[18px] rounded flex flex-col items-center justify-center text-[8px]"+(isMono?" btn-mono-active":""),title:"Mono Switch"},React.createElement("i",{className:"fa-solid fa-circle-half-stroke text-[9px]"}),React.createElement("span",null,"MONO")),React.createElement("button",{id:"muteBtn",onClick:function(){setIsMuted(function(p){return!p;});},className:"btn-daw h-[18px] rounded text-amber-500 font-bold hover:text-amber-400"+(isMuted?" btn-mute-active":""),title:"Mute Master Output"},"M"),React.createElement("button",{id:"soloBtn",className:"btn-daw h-[18px] rounded text-yellow-400 font-bold hover:text-yellow-300",title:"Solo Master"},"S"),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 hover:text-slate-200",title:"Route Matrix"},React.createElement("i",{className:"fa-solid fa-diagram-project text-[9px]"})),React.createElement("button",{id:"fxBtn",className:"btn-daw h-[18px] rounded font-extrabold text-[10px] transition-all",onClick:function(){setShowMasteringModal(true);},title:"Mở MASTERING PANEL để chỉnh sửa"},"FX"),React.createElement("button",{id:"powerBtn",className:"btn-daw h-[18px] rounded text-xs transition-all"+(isMasterActive?" btn-teal-active":""),onClick:function(){if(setMasteringSettings){setMasteringSettings(function(prev){return Object.assign({},prev,{masterConnected:!prev.masterConnected,isBypassed:false});});}},title:"Bật/Tắt MASTERING PANEL Bypass"},[React.createElement("i",{className:"fa-solid fa-power-off"+(isFxActive?" text-emerald-400":" text-slate-500"),key:"ico"}),React.createElement("span",{key:"lbl",className:"text-[7px] font-bold"+(isFxActive?" text-emerald-300":" text-slate-400")},"PWR")]),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 text-[8px]",title:"Trim Envelope"},"TRIM"),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 text-[9px]",title:"Session Info"},React.createElement("i",{className:"fa-solid fa-info"})))),React.createElement("div",{className:"text-center text-[10px] font-bold font-mono text-slate-200 shrink-0"},masterVolume<=-50?'-inf':(masterVolume>0?'+':'')+masterVolume.toFixed(1)),React.createElement("div",{className:"shrink-0 border-t border-slate-800 flex flex-col items-center"},React.createElement("div",{className:"flex justify-between w-full text-[9px] font-mono py-0.5"},React.createElement("span",{className:"text-emerald-400"},"RMS"),React.createElement("span",{ref:rmsValRef,className:"text-emerald-400 font-bold"},"-inf")),React.createElement("div",{className:"w-full text-center bg-black/80 py-0.5 rounded border border-slate-800 text-[10px] font-extrabold tracking-widest text-slate-100 uppercase"},React.createElement("span",null,"MAIN OUT"))));};// ── Track Strip Console Component (from md/48_TRACK_STRIP_COMPONENT.md) ── const TrackStripConsole=({track,index,onUpdateTrack,trackVuRefs,style})=>{var vol=track.volumeDb!=null?track.volumeDb:0;var trackColor=track.color||'#06b6d4';var isMuted=track.muted;var isSoloed=track.solo;var isBypassed=!!(track.audioBypass??track.masteringBypass);var isArmed=track.isArmed;var trackName=track.name||'Track '+(index+1);var isMicActive=track.inputSource?.deviceType==='MICROPHONE';const[pan,setPan]=React.useState(0.0);const[panLabel,setPanLabel]=React.useState('center');const[isPhaseInverted,setIsPhaseInverted]=React.useState(false);const panPointerRef=React.useRef(null);const setVuCanvas=React.useCallback(function(el){if(el)trackVuRefs.current[track.id+'_mixer']=el;else delete trackVuRefs.current[track.id+'_mixer'];},[track.id,trackVuRefs]);// Track FX chain editor (mastering_expand.md §II.4): reuse the same module // types as the mastering suite inside this track's FX chain. var FX_MODULE_TYPES=['compressor','limiter','exciter','rebalance','eq'];const updateFxChain=function(nextChain){if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:nextChain});};const handlePanPointerDown=e=>{e.currentTarget._panStartY=e.clientY;e.currentTarget._startPan=pan;e.currentTarget.setPointerCapture(e.pointerId);function onMove(ev){if(!e.currentTarget)return;var deltaY=e.currentTarget._panStartY-ev.clientY;var newPan=Math.min(1.0,Math.max(-1.0,e.currentTarget._startPan+deltaY/80));setPan(newPan);var angle=newPan*120;if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+angle+'deg)';if(newPan===0)setPanLabel('center');else if(newPan<0)setPanLabel('L'+Math.abs(Math.round(newPan*100)));else setPanLabel('R'+Math.round(newPan*100));}function onUp(){document.removeEventListener('pointermove',onMove);document.removeEventListener('pointerup',onUp);}document.addEventListener('pointermove',onMove);document.addEventListener('pointerup',onUp);};return React.createElement("div",{style:style||undefined,className:"flex flex-col items-stretch w-[145px] shrink-0 strip-bg-track rounded-md text-slate-300 select-none shadow-2xl border border-slate-900 overflow-y-auto"},/* 1. Top Track Color Accent Bar */React.createElement("div",{className:"h-1.5 w-full shrink-0 transition-colors",style:{backgroundColor:trackColor}}),/* 2. Pan Rotary Dial Area */React.createElement("div",{className:"h-[46px] shrink-0 py-1 px-2 flex flex-col items-center justify-center border-b border-slate-700/40",style:{backgroundColor:trackColor+'15'}},React.createElement("div",{className:"w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-400 relative flex items-center justify-center cursor-pointer shadow-md",title:"Kéo chuột lên/xuống để chỉnh Pan",onPointerDown:handlePanPointerDown,onDoubleClick:function(){setPan(0);setPanLabel('center');if(panPointerRef.current)panPointerRef.current.style.transform='rotate(0deg)';},onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.05:0.05;var newPan=Math.max(-1,Math.min(1,pan+step));newPan=Math.round(newPan*100)/100;setPan(newPan);if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+newPan*120+'deg)';if(newPan===0)setPanLabel('center');else if(newPan<0)setPanLabel('L'+Math.abs(Math.round(newPan*100)));else setPanLabel('R'+Math.round(newPan*100));}},React.createElement("div",{ref:panPointerRef,className:"w-0.5 h-2 rounded absolute top-0.5 transition-transform",style:{backgroundColor:trackColor,transform:'rotate(0deg)'}})),React.createElement("span",{className:"text-[8px] font-mono mt-0.5 font-semibold",style:{color:trackColor}},panLabel)),/* 3. Center Area: Peak dB + Fader + VU + Button Stack */React.createElement("div",{className:"flex-1 p-1 flex gap-1 justify-between items-stretch min-h-[170px]"},/* Left Fader & VU Column */React.createElement("div",{className:"flex-1 flex flex-col justify-between items-center bg-black/60 p-1 rounded border border-slate-800/80"},React.createElement("div",{className:"w-full flex justify-center text-[8px] font-mono text-slate-400 h-4 items-center"},React.createElement("span",null,vol<=-50?'-inf':(vol>0?'+':'')+vol.toFixed(1)+'dB')),React.createElement("div",{className:"flex items-stretch justify-around w-full flex-1 relative py-1"},/* Fader Rail */React.createElement("div",{className:"relative fader-track-bg w-3 flex-1 rounded flex items-center justify-center overflow-hidden"},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute"}),React.createElement("input",{type:"range",min:"-60",max:"12",step:"0.5",value:vol,className:"fader-slider w-full z-10",onChange:function(e){var val=parseFloat(e.target.value);if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:val});},onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.5:0.5;var newVol=Math.max(-60,Math.min(12,vol+step));if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:newVol});},onDoubleClick:function(){if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:0});}})),/* VU Meter */React.createElement("div",{className:"w-2.5 flex-1 bg-slate-950 rounded border border-slate-900 overflow-hidden relative",title:"Peak VU Meter"},React.createElement("canvas",{ref:setVuCanvas,className:"w-full h-full block"})))),/* Right Button Stack */React.createElement("div",{className:"w-7 flex flex-col justify-between text-[8px] font-bold shrink-0"},React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!track.muted;if(onUpdateTrack)onUpdateTrack(track.id,{muted:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:next});},className:"btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center"+(isMuted?" btn-mute-active":""),title:"Mute Track"},"M"),React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!track.solo;if(onUpdateTrack)onUpdateTrack(track.id,{solo:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:next});},className:"btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center"+(isSoloed?" btn-solo-active":""),title:"Solo Track"},"S"),React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!(track.audioBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{audioBypass:next});// Live re-route: bypassed channel skips FX + mastering chain at Main out. @@ -1566,8 +1567,10 @@ const toggleTrackSoloEvaluate=trackId=>{const wasPlaying=isPlaying;const playing 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 // SECTION-TAB phải đổi âm section ngay lập tức -Object.keys(activeTrackNodesRef.current).forEach(function(k){if(k.endsWith('_sub_'+trackId)){const sn=activeTrackNodesRef.current[k];if(sn&&sn.gainNode)sn.gainNode.gain.setValueAtTime(volLinear,ctx.currentTime);}});};const updateTrackPan=(trackId,val)=>{const beforeSnap=captureTrackSnapshot(trackId);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,pan:val}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'PAN_CHANGE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});// Real-time update during playback -const nodes=activeTrackNodesRef.current[trackId];if(nodes){nodes.pannerNode.pan.setValueAtTime(val/100,getAudioContext().currentTime);}};const updateTrackName=(trackId,name)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,name}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'RENAME',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});};const updateTrackColor=(trackId,color)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,color}:t));// Lưu màu NGAY (flush — không chờ debounce 2s — tránh mất màu khi reload nhanh) +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);}});// T15: track gain → native engine (NativeMixer) +if(window.SonicNativeAudio){const trk=(tracks||[]).find(t=>t.id===trackId);const curPan=trk&&trk.pan!=null?trk.pan:0;window.SonicNativeAudio.setTrackGainPan(trackId,val,curPan);}};const updateTrackPan=(trackId,val)=>{const beforeSnap=captureTrackSnapshot(trackId);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,pan:val}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'PAN_CHANGE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});// Real-time update during playback +const nodes=activeTrackNodesRef.current[trackId];if(nodes){nodes.pannerNode.pan.setValueAtTime(val/100,getAudioContext().currentTime);}// T15: track pan → native engine (NativeMixer; pan -100..100 → -1..1) +if(window.SonicNativeAudio){const trk=(tracks||[]).find(t=>t.id===trackId);const curVol=trk&&trk.volumeDb!=null?trk.volumeDb:0;window.SonicNativeAudio.setTrackGainPan(trackId,curVol,val/100);}};const updateTrackName=(trackId,name)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,name}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'RENAME',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});};const updateTrackColor=(trackId,color)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,color}:t));// Lưu màu NGAY (flush — không chờ debounce 2s — tránh mất màu khi reload nhanh) try{if(window.SonicStorage){const nextTracks=(tracks||[]).map(t=>t.id===trackId?{...t,color}:t);window.SonicStorage.scheduleTempAutoSave(()=>serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,nextTracks,subTabs,sessionTabs,masteringSettings));if(window.SonicStorage.flushTempAutoSave)window.SonicStorage.flushTempAutoSave();}}catch(e){}setUndoStack(prev=>{const next=[...prev,{action_type:'RECOLOR',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});};const updateClipName=(trackId,clipId,newName)=>{setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const clips=t.clips&&t.clips.length>0?t.clips:[];return{...t,clips:clips.map(c=>c.id===clipId?{...c,name:newName}:c)};}));};// ── MIDI .mid file parser ── const parseMidiFile=arrayBuffer=>{const data=new Uint8Array(arrayBuffer);if(data.length<14)return null;var pos=0;var read32=function(){var v=data[pos]<<24|data[pos+1]<<16|data[pos+2]<<8|data[pos+3];pos+=4;return v;};var read16=function(){var v=data[pos]<<8|data[pos+1];pos+=2;return v;};var readVLQ=function(){var v=0,b;do{b=data[pos++];v=v<<7|b&0x7f;}while(b&0x80);return v;};var header=String.fromCharCode(data[0],data[1],data[2],data[3]);if(header!=='MThd')return null;pos=8;var fmt=read16();var numTracks=read16();var division=read16();var ticksPerBeat=division&0x8000?480:division||480;var bpm=120;var result=[];for(var t=0;tdata.length)break;var trkId=String.fromCharCode(data[pos],data[pos+1],data[pos+2],data[pos+3]);pos+=4;var trkLen=read32();var endPos=Math.min(pos+trkLen,data.length);if(trkId!=='MTrk'){pos=endPos;continue;}var absTicks=0;var runningStatus=0;var trackName='MIDI Track '+(t+1);var midiNotes=[];var pendingNotes={};var maxAbsTick=0;while(pos=0x80){if(status<0xf0){runningStatus=status;}pos++;}else{status=runningStatus;}var cmd=status>>4;if(cmd===0x9||cmd===0x8){var chan=status&0x0F;var pitch=data[pos++];var vel=pos0){pendingNotes[noteKey]={tick:absTicks,vel:vel};if(absTicks>maxAbsTick)maxAbsTick=absTicks;}else{var pn=pendingNotes[noteKey];if(pn){var durTicks=absTicks-pn.tick;if(durTicks<=0)durTicks=240;midiNotes.push({id:'mn_'+t+'_'+pitch+'_'+pn.tick,pitch:pitch,start_beat:pn.tick/ticksPerBeat,duration_beats:durTicks/ticksPerBeat,velocity:Math.min(1,pn.vel/127)});delete pendingNotes[noteKey];}}}else if(status>=0xc0&&status<0xe0){if(pos=0xe0&&status<0xf0){if(pos+2<=endPos)pos+=2;}else if(status>=0xa0&&status<0xc0){if(pos+2<=endPos)pos+=2;}else if(status>=0xf0&&status<0xf8){if(pos=endPos)break;var metaType=data[pos++];var metaLen=readVLQ();if(metaType===0x03){try{trackName=String.fromCharCode.apply(null,Array.from(data.subarray(pos,pos+metaLen)));}catch(e){}}else if(metaType===0x51&&pos+3<=endPos){bpm=Math.round(60000000/(data[pos]<<16|data[pos+1]<<8|data[pos+2]));}pos+=Math.min(metaLen,endPos-pos);}else{if(pos+2<=endPos)pos+=2;}}catch(e){pos=endPos;}}Object.keys(pendingNotes).forEach(function(k){var pn=pendingNotes[k];var parts=k.split('_');var p=parseInt(parts[1]);var dur=Math.max(240,maxAbsTick-pn.tick);midiNotes.push({id:'mn_'+t+'_'+p+'_'+pn.tick,pitch:p,start_beat:pn.tick/ticksPerBeat,duration_beats:dur/ticksPerBeat,velocity:Math.min(1,pn.vel/127)});});if(midiNotes.length>0){var lastEnd=0;var maxEndBeat=0;midiNotes.forEach(function(n){var e=(n.start_beat+n.duration_beats)*60/bpm;if(e>lastEnd)lastEnd=e;var eb=n.start_beat+n.duration_beats;if(eb>maxEndBeat)maxEndBeat=eb;});result.push({name:trackName,notes:midiNotes,duration:lastEnd||4,startTime:0,id:'midi_'+t+'_'+Date.now(),totalBeats:maxEndBeat||16,bars:Math.max(1,Math.ceil((maxEndBeat||16)/4)),bpm:bpm,ticksPerBeat:ticksPerBeat});}pos=endPos;}return result.length>0?result:null;};window.parseMidiFile=parseMidiFile;// ── Load File on Track (with server upload) ── // ── Resolve Media Explorer drag file → real File (for timeline drop) ── diff --git a/app/static/js/services/nativeAudioClient.js b/app/static/js/services/nativeAudioClient.js new file mode 100644 index 0000000..39d3ccb --- /dev/null +++ b/app/static/js/services/nativeAudioClient.js @@ -0,0 +1,77 @@ +// SonicForge Native Audio Client (T15) — IPC JS → native engine. +// Track instrument KHÔNG còn đi masterBus WebAudio: master fader + track +// gain/pan áp native qua NativeMixer (SF host / VST2 / VST3 bridge DLL). +// WebAudio chỉ giữ cho UI/aux. Mọi call fire-and-forget — native engine +// tự âm thanh; không block UI. +window.SonicNativeAudio = window.SonicNativeAudio || {}; + +(function () { + var BASE = window.API_BASE_URL || window.location.origin; + + function headers() { + var h = { 'Content-Type': 'application/json' }; + var token = localStorage.getItem('sonic_token') || ''; + if (token) h['Authorization'] = 'Bearer ' + token; + return h; + } + + async function post(path, body) { + var resp = await fetch(BASE + '/api/v1/native' + path, { + method: 'POST', + headers: headers(), + body: JSON.stringify(body || {}) + }); + var data = await resp.json().catch(function () { return {}; }); + if (!resp.ok) throw new Error(data.detail || 'native API lỗi ' + resp.status); + return data; + } + + async function get(path) { + var resp = await fetch(BASE + '/api/v1/native' + path, { headers: headers() }); + var data = await resp.json().catch(function () { return {}; }); + if (!resp.ok) throw new Error(data.detail || 'native API lỗi ' + resp.status); + return data; + } + + var _masterTimer = null; + + window.SonicNativeAudio = { + status: function () { + return get('/status'); + }, + // Master fader: debounce 40ms — fader kéo liên tục, chỉ gửi giá trị cuối. + setMasterGain: function (gainDb) { + if (_masterTimer) clearTimeout(_masterTimer); + _masterTimer = setTimeout(function () { + post('/set_master_gain', { gain_db: gainDb }).catch(function (e) { + console.warn('[NativeAudio] set_master_gain:', e.message); + }); + }, 40); + }, + setTrackGainPan: function (trackId, gainDb, pan) { + return post('/track_gain_pan', { track_id: trackId, gain_db: gainDb, pan: pan }).catch(function (e) { + console.warn('[NativeAudio] track_gain_pan:', e.message); + }); + }, + ensureSf: function (trackId, sfId, bank, program) { + return post('/sf/ensure', { track_id: trackId, sf_id: sfId || null, bank: bank || 0, program: program || 0, live: true }).catch(function (e) { + console.warn('[NativeAudio] sf/ensure:', e.message); + }); + }, + noteOn: function (kind, trackId, channel, pitch, velocity) { + var path = kind === 'vst2' ? '/vst2/note_on' : '/sf/note_on'; + return post(path, { track_id: trackId, channel: channel || 0, pitch: pitch, velocity: velocity != null ? velocity : 100 }).catch(function (e) { + console.warn('[NativeAudio] note_on:', e.message); + }); + }, + noteOff: function (kind, trackId, channel, pitch) { + var path = kind === 'vst2' ? '/vst2/note_off' : '/sf/note_off'; + return post(path, { track_id: trackId, channel: channel || 0, pitch: pitch }).catch(function (e) { + console.warn('[NativeAudio] note_off:', e.message); + }); + }, + sfAudioStop: function (trackId) { + return post('/sf/audio_stop', { track_id: trackId, pitch: 0 }).catch(function () {}); + } + }; +})(); diff --git a/app/static/js/services/trackInstrument.js b/app/static/js/services/trackInstrument.js index 87a91ae..c0e85f9 100644 --- a/app/static/js/services/trackInstrument.js +++ b/app/static/js/services/trackInstrument.js @@ -1,7 +1,9 @@ // SonicForge TrackInstrument Service -// Bọc FluidSynth channel per-track: playNote/noteOff theo TrackInstrumentCtx +// Bọc engine per-track: playNote/noteOff theo TrackInstrumentCtx // (ch/program/synthEngine/sfId/bank/prog/dest) — dùng cho preview mọi nguồn // (keybed, piano roll, draw, hardware MIDI, click) qua UnifiedMidiRouter. +// T15: native-first — SF track đi thẳng vào engine native (NativeAudioService +// + SF host bridge), SonicSF WASM chỉ là fallback khi native không sẵn sàng. // Tạo mới mỗi note-on (overwrite registry) — voice cũ giữ engine cũ trong // tracker nên note-off luôn stop đúng channel. window.TrackInstrument = window.TrackInstrument || {}; @@ -18,6 +20,11 @@ window.TrackInstrument = window.TrackInstrument || {}; this.dest = ctx ? (ctx.dest || null) : null; } + // Native engine sẵn sàng cho track SF? (chỉ khi có sfId — đường native) + TrackInstrument.prototype._nativeReady = function () { + try { return !!(window.SonicNativeAudio && this.sfId); } catch (e) { return false; } + }; + // Đảm bảo channel đã select đúng instrument trước khi play (fire-and-forget: // playNote tự load + retry nếu SF chưa load xong — dedup trong loadSoundFont). TrackInstrument.prototype._ensure = function () { @@ -33,18 +40,40 @@ window.TrackInstrument = window.TrackInstrument || {}; } }; + // velocity: router đã normalize int 1-127 (unifiedMidiRouter.normalizeVelocity). TrackInstrument.prototype.playNote = function (pitch, velocity, durationMs, startTime) { + var vel = velocity != null ? velocity : 100; + if (this._nativeReady()) { + try { + var self = this; + var dur = (durationMs != null ? durationMs : 500) || 500; + // ensure native SF engine cho track (server dedup theo track_id) + window.SonicNativeAudio.ensureSf(this.trackId, this.sfId, this.bank, this.prog) + .catch(function () {}); + window.SonicNativeAudio.noteOn('sf', this.trackId, this.ch, pitch, vel); + // ponytail: TrackInstrument không biết audioCtx → bỏ startTime + // offset (delay = duration); thêm scheduling chính xác khi cần + setTimeout(function () { self.noteOff(pitch); }, dur + 40); + return; + } catch (e) { + console.warn('[TrackInstrument] native playNote error:', e); + } + } + // Fallback SonicSF (WASM) try { if (!window.SonicSF || !window.SonicSF.playNote) return; this._ensure(); - var dur = (durationMs != null ? durationMs : 500) || 500; - window.SonicSF.playNote(pitch, velocity != null ? velocity : 0.8, dur, startTime, this.program, this.dest, this.ch, this.synthEngine); + var durF = (durationMs != null ? durationMs : 500) || 500; + window.SonicSF.playNote(pitch, velocity != null ? velocity : 0.8, durF, startTime, this.program, this.dest, this.ch, this.synthEngine); } catch (e) { console.warn('[TrackInstrument] playNote error:', e); } }; TrackInstrument.prototype.noteOff = function (pitch) { + if (this._nativeReady()) { + try { window.SonicNativeAudio.noteOff('sf', this.trackId, this.ch, pitch); return; } catch (e) {} + } try { if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(this.ch, pitch); } catch (e) {} diff --git a/app/templates/index.html b/app/templates/index.html index d2f66f8..3157241 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -37,6 +37,7 @@ + diff --git a/native_host/NativeMixer.cpp b/native_host/NativeMixer.cpp index 4170cf0..417c1f4 100644 --- a/native_host/NativeMixer.cpp +++ b/native_host/NativeMixer.cpp @@ -82,8 +82,10 @@ void NativeMixer::processInPlace (float* outL, float* outR, int32 frames) const else if (r < -1.0f) r = -1.0f; } - outL[i] = l; - outR[i] = r; + // Master fader (T15): sau limiter+clip, linear, khong re-clip — mirror + // WebAudio masterBus.output.gain (gain node cuoi cung sau mastering). + outL[i] = l * m_masterGain; + outR[i] = r * m_masterGain; } } diff --git a/native_host/NativeMixer.h b/native_host/NativeMixer.h index 1d3efc2..3744b47 100644 --- a/native_host/NativeMixer.h +++ b/native_host/NativeMixer.h @@ -38,6 +38,10 @@ public: // thresholdDb clamp -24..0, NaN -> -1 (mirror _clamp trong mastering_engine). void setLimiter (bool active, float thresholdDb); + // Master fader (T15) — mirror WebAudio masterBus.output.gain (sau mastering + // chain + clip; linear, khong re-clip). Mac dinh 1.0. + void setMasterGain (float gainLinear) { m_masterGain = gainLinear; } + // Ap track gain/pan vao buffer roi (neu active) limiter + hard clip [-1,1] // ngay tai buffer (in-place). frames > 0. void processInPlace (float* outL, float* outR, int32 frames) const; @@ -49,6 +53,7 @@ private: bool m_limActive = false; float m_limK = 1.0f; // 1/max(0.02, t_lin) float m_limTk = 1.0f; // tanh(k) + float m_masterGain = 1.0f; // T15: master fader (sau limiter+clip, khong re-clip) }; } // namespace sonicforge diff --git a/native_host/SFHost.cpp b/native_host/SFHost.cpp index cf12bb2..dc16e08 100644 --- a/native_host/SFHost.cpp +++ b/native_host/SFHost.cpp @@ -409,6 +409,18 @@ __declspec (dllexport) int32 SF_FS_SetMasterLimiter (int32 handle, int32 active, return 0; } +// T15: master fader — linear, ap sau limiter+clip trong mixer (mirror +// masterBus.output.gain WebAudio). +__declspec (dllexport) int32 SF_FS_SetMasterGain (int32 handle, float gainLinear) +{ + std::lock_guard lock (g_mutex); + auto it = g_instances.find (handle); + if (it == g_instances.end ()) + return -1; + it->second->mixer.setMasterGain (gainLinear); + return 0; +} + // Đóng: stop audio loop, xóa synth, giải phóng DLL. __declspec (dllexport) int32 SF_FS_Close (int32 handle, char* err, int32 err_cap) { diff --git a/native_host/VST2AudioEngine.cpp b/native_host/VST2AudioEngine.cpp index d05e793..e39a21f 100644 --- a/native_host/VST2AudioEngine.cpp +++ b/native_host/VST2AudioEngine.cpp @@ -627,4 +627,15 @@ __declspec (dllexport) int32 SF_VST2_SetMasterLimiter (int32 handle, int32 activ return 0; } +// T15: master fader — mirror masterBus.output.gain WebAudio (sau limiter+clip). +__declspec (dllexport) int32 SF_VST2_SetMasterGain (int32 handle, float gainLinear) +{ + std::lock_guard lock (g_mutex); + auto it = g_instances.find (handle); + if (it == g_instances.end ()) + return -1; + it->second->mixer.setMasterGain (gainLinear); + return 0; +} + } // extern "C" diff --git a/native_host/vst3_host_bridge.cpp b/native_host/vst3_host_bridge.cpp index 97690c8..878f7fd 100644 --- a/native_host/vst3_host_bridge.cpp +++ b/native_host/vst3_host_bridge.cpp @@ -573,4 +573,15 @@ __declspec (dllexport) int32 SF_VST3_SetMasterLimiter (int32 handle, int32 activ return 0; } +// T15: master fader — mirror masterBus.output.gain WebAudio (sau limiter+clip). +__declspec (dllexport) int32 SF_VST3_SetMasterGain (int32 handle, float gainLinear) +{ + std::lock_guard lock (g_mutex); + auto it = g_instances.find (handle); + if (it == g_instances.end ()) + return -1; + it->second->mixer.setMasterGain (gainLinear); + return 0; +} + } // extern "C" diff --git a/tests/test_native_matrix.py b/tests/test_native_matrix.py new file mode 100644 index 0000000..1af6f96 --- /dev/null +++ b/tests/test_native_matrix.py @@ -0,0 +1,152 @@ +"""Test matrix T15 — một engine duy nhất cho track instrument (spec V). + +SF × VST2 × (VST3 limitation) qua live (WASAPI) + offline (RenderBlock / +SF_VST2_Process), master fader + track gain/pan áp native (NativeMixer). +Chạy được trên Windows dev (cần build/Release DLL + fluidsynth_runtime). +Chạy riêng: python -m pytest tests/test_native_matrix.py -v +""" +import ctypes +import os +import time + +import numpy as np +import pytest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SF2 = os.path.join(ROOT, "app", "storage", "soundfonts", + "518e850f-a5d3-4790-b1f9-0c90c203c524.sf2") +FAKE_VST2 = os.path.join(ROOT, "native_host", "tests", "fake_vst2.dll") + +pytestmark = pytest.mark.skipif( + os.name != "nt" or not os.path.isfile(os.path.join( + ROOT, "native_host", "build", "Release", "sf_host_bridge.dll")), + reason="cần Windows + native_host/build/Release (T9–T14)") + +from app.core.native_audio_service import get_service # noqa: E402 + + +@pytest.fixture() +def svc(): + s = get_service() + s.close_all() + s.set_master_gain(0.0) + yield s + s.close_all() + s.set_master_gain(0.0) + + +def _ratio_db(a, b): + """Sai lệch dB giữa 2 mức RMS (bảo vệ 0).""" + ra = float(np.sqrt((a ** 2).mean())) if a.size else 0.0 + rb = float(np.sqrt((b ** 2).mean())) if b.size else 0.0 + if ra <= 0 and rb <= 0: + return 0.0 + if ra <= 0 or rb <= 0: + return float("inf") + return abs(20.0 * np.log10(ra / rb)) + + +# ── SF live ──────────────────────────────────────────────────────────────── +def test_sf_live_blocks_underruns(svc): + svc.ensure_sf("sf_live", SF2, bank=128, program=0, live=True) + svc.sf_note_on("sf_live", 0, 60, 100) + time.sleep(0.35) + st = svc.sf_stats("sf_live") + assert st and st["blocks"] > 0, f"audio thread không render: {st}" + assert st["underruns"] == 0, f"underrun live: {st}" + svc.sf_note_off("sf_live", 0, 60) + time.sleep(0.15) + st2 = svc.sf_stats("sf_live") + assert st2["blocks"] > st["blocks"], "note_off không đẩy thêm block" + svc.sf_audio_stop("sf_live") + assert svc.sf_stats("sf_live") is not None + + +# ── SF offline: master gain ──────────────────────────────────────────────── +def test_sf_offline_master_gain(svc): + notes = [{"note": 60, "velocity": 100, "start_beat": 0, "duration_beats": 1}] + y0 = svc.render_sf_offline(SF2, 128, 0, notes, master_gain_db=0.0) + y6 = svc.render_sf_offline(SF2, 128, 0, notes, master_gain_db=-6.0) + assert y0.size and float(np.abs(y0).max()) > 1e-6 + # master -6dB → peak ratio 10^(-6/20) ≈ 0.5012 + r = float(np.abs(y6).max() / np.abs(y0).max()) + assert abs(r - 10 ** (-6 / 20)) < 0.01, f"master gain ratio={r}" + assert _ratio_db(y6, y0 * 10 ** (-6 / 20)) < 0.1 + + +# ── SF offline: track gain + pan ─────────────────────────────────────────── +def test_sf_offline_track_gain_pan(svc): + notes = [{"note": 62, "velocity": 110, "start_beat": 0, "duration_beats": 1}] + y_id = svc.render_sf_offline(SF2, 128, 0, notes, gain_db=0.0, pan=0.0) + # pan -1 → chỉ kênh trái (constant-power: cos(0)=1, sin(0)=0) + y_l = svc.render_sf_offline(SF2, 128, 0, notes, gain_db=0.0, pan=-1.0) + assert float(np.abs(y_l[0]).max()) > 1e-6 + assert float(np.abs(y_l[1]).max()) < 1e-6, "pan=-1 phải tắt kênh phải" + assert _ratio_db(y_l[0], y_id[0]) < 0.1, "pan=-1 kênh trái giữ mức" + + +# ── VST2 live ────────────────────────────────────────────────────────────── +def test_vst2_live_blocks_underruns(svc): + svc.ensure_vst2("v2_live", FAKE_VST2, live=True) + svc.vst2_note_on("v2_live", 0, 60, 100) + time.sleep(0.35) + st = svc.vst2_stats("v2_live") + assert st and st["blocks"] > 0, f"audio thread không render: {st}" + assert st["underruns"] == 0, f"underrun live: {st}" + svc.vst2_note_off("v2_live", 0, 60) + time.sleep(0.15) + st2 = svc.vst2_stats("v2_live") + assert st2["blocks"] > st["blocks"] + svc.close_track("v2_live") + + +# ── VST2 offline: fake sine + mixer ──────────────────────────────────────── +def test_vst2_offline_sine_and_mixer(svc): + notes = [{"note": 60, "velocity": 100, "start_beat": 0, "duration_beats": 1}] + y0 = svc.render_vst2_offline(FAKE_VST2, notes) + assert float(np.abs(y0).max()) > 0.2, "fake sine 0.25 amplitude" + y6 = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=-6.0, + master_gain_db=-6.0) + r = float(np.abs(y6).max() / np.abs(y0).max()) + # track -6 * master -6 → 10^(-12/20) ≈ 0.2512 + assert abs(r - 10 ** (-12 / 20)) < 0.01, f"vst2 mixer ratio={r}" + + +# ── VST2 offline: limiter ────────────────────────────────────────────────── +def test_vst2_offline_limiter(svc): + notes = [{"note": 60, "velocity": 127, "start_beat": 0, "duration_beats": 1}] + y_hot = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=+18.0) + y_lim = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=+18.0, + lim_active=True, threshold_db=0.0) + assert float(np.abs(y_hot).max()) > 1.0, "hot cần clip (fake sine 0.25 × +18dB)" + assert float(np.abs(y_lim).max()) <= 1.0 + 1e-6, "limiter phải chặn clip" + + +# ── VST3: limitation (bridge export) ─────────────────────────────────────── +def test_vst3_bridge_exports_master_gain(): + dll_path = os.path.join(ROOT, "native_host", "build", "Release", + "vst3_host_bridge.dll") + if not os.path.isfile(dll_path): + pytest.skip("chưa build vst3_host_bridge.dll") + dll = ctypes.WinDLL(dll_path) + dll.SF_VST3_SetMasterGain.argtypes = [ctypes.c_int32, ctypes.c_float] + dll.SF_VST3_SetMasterGain.restype = ctypes.c_int32 + # handle 0 → -1 (chưa có instance) — export gọi được, không crash + assert dll.SF_VST3_SetMasterGain(0, 1.0) == -1 + + +# ── API endpoints ────────────────────────────────────────────────────────── +def test_api_status_and_render(svc): + from fastapi.testclient import TestClient + from app.main import app + c = TestClient(app) + st = c.get("/api/v1/native/status").json() + assert st["available"] is True + r = c.post("/api/v1/native/render", json={ + "kind": "sf", "path": SF2, "bank": 128, "program": 0, + "notes": [{"note": 60, "velocity": 100, "start_beat": 0, + "duration_beats": 1}], + "master_gain_db": -6.0}) + assert r.status_code == 200 + d = r.json() + assert d["ok"] and d["samples"] > 0 and d["peak"] > 0