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

This commit is contained in:
2026-08-11 17:33:12 +07:00
parent 500384a226
commit 0af834a8fa
14 changed files with 1131 additions and 8 deletions
+194
View File
@@ -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()))}
+610
View File
@@ -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 (T9T14)")
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
+2
View File
@@ -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.media import router as media_router
from app.api.v1.system import router as system_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.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.auth import seed_admin
from app.core.soundfont_scanner import SoundFontAutoScanner 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(media_router, prefix="/api/v1/media", tags=["media"])
app.include_router(system_router, prefix="/api/v1/system", tags=["system"]) 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(presets_router, prefix="/api/v1/presets", tags=["presets"])
app.include_router(native_router, prefix="/api/v1/native", tags=["native"])
@app.get("/health") @app.get("/health")
+14
View File
@@ -1980,6 +1980,8 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
const handleFaderChange = (val) => { const handleFaderChange = (val) => {
setMasterVolume(val); setMasterVolume(val);
// T15: master fader track instrument native engine (NativeMixer)
if (window.SonicNativeAudio) window.SonicNativeAudio.setMasterGain(val);
ensureAudio(); ensureAudio();
if (masterBus && masterBus.output) { if (masterBus && masterBus.output) {
const linear = val <= -50 ? 0 : Math.pow(10, val / 20); 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); 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 updateTrackPan = (trackId, val) => {
const beforeSnap = captureTrackSnapshot(trackId); const beforeSnap = captureTrackSnapshot(trackId);
@@ -24264,6 +24272,12 @@ const App = () => {
if (nodes) { if (nodes) {
nodes.pannerNode.pan.setValueAtTime(val / 100, getAudioContext().currentTime); 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 updateTrackName = (trackId, name) => {
const beforeSnap = captureTrackSnapshot(trackId); const beforeSnap = captureTrackSnapshot(trackId);
File diff suppressed because one or more lines are too long
@@ -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 () {});
}
};
})();
+32 -3
View File
@@ -1,7 +1,9 @@
// SonicForge TrackInstrument Service // 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 // (ch/program/synthEngine/sfId/bank/prog/dest) — dùng cho preview mọi nguồn
// (keybed, piano roll, draw, hardware MIDI, click) qua UnifiedMidiRouter. // (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 // 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. // tracker nên note-off luôn stop đúng channel.
window.TrackInstrument = window.TrackInstrument || {}; window.TrackInstrument = window.TrackInstrument || {};
@@ -18,6 +20,11 @@ window.TrackInstrument = window.TrackInstrument || {};
this.dest = ctx ? (ctx.dest || null) : null; 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: // Đả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). // playNote tự load + retry nếu SF chưa load xong — dedup trong loadSoundFont).
TrackInstrument.prototype._ensure = function () { 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) { 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 { try {
if (!window.SonicSF || !window.SonicSF.playNote) return; if (!window.SonicSF || !window.SonicSF.playNote) return;
this._ensure(); this._ensure();
var dur = (durationMs != null ? durationMs : 500) || 500; var durF = (durationMs != null ? durationMs : 500) || 500;
window.SonicSF.playNote(pitch, velocity != null ? velocity : 0.8, dur, startTime, this.program, this.dest, this.ch, this.synthEngine); window.SonicSF.playNote(pitch, velocity != null ? velocity : 0.8, durF, startTime, this.program, this.dest, this.ch, this.synthEngine);
} catch (e) { } catch (e) {
console.warn('[TrackInstrument] playNote error:', e); console.warn('[TrackInstrument] playNote error:', e);
} }
}; };
TrackInstrument.prototype.noteOff = function (pitch) { TrackInstrument.prototype.noteOff = function (pitch) {
if (this._nativeReady()) {
try { window.SonicNativeAudio.noteOff('sf', this.trackId, this.ch, pitch); return; } catch (e) {}
}
try { try {
if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(this.ch, pitch); if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(this.ch, pitch);
} catch (e) {} } catch (e) {}
+1
View File
@@ -37,6 +37,7 @@
<script src="/static/js/services/api.js?v=202608111230"></script> <script src="/static/js/services/api.js?v=202608111230"></script>
<script src="/static/js/services/vstiAutosample.js?v=202608111230"></script> <script src="/static/js/services/vstiAutosample.js?v=202608111230"></script>
<script src="/static/js/services/unifiedMidiRouter.js?v=202608111230"></script> <script src="/static/js/services/unifiedMidiRouter.js?v=202608111230"></script>
<script src="/static/js/services/nativeAudioClient.js?v=202608111230"></script>
<script src="/static/js/services/trackInstrument.js?v=202608111230"></script> <script src="/static/js/services/trackInstrument.js?v=202608111230"></script>
<script src="/static/js/services/audioEngine.js?v=202607271016"></script> <script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202608038200"></script> <script src="/static/js/services/storage.js?v=202608038200"></script>
+4 -2
View File
@@ -82,8 +82,10 @@ void NativeMixer::processInPlace (float* outL, float* outR, int32 frames) const
else if (r < -1.0f) else if (r < -1.0f)
r = -1.0f; r = -1.0f;
} }
outL[i] = l; // Master fader (T15): sau limiter+clip, linear, khong re-clip — mirror
outR[i] = r; // WebAudio masterBus.output.gain (gain node cuoi cung sau mastering).
outL[i] = l * m_masterGain;
outR[i] = r * m_masterGain;
} }
} }
+5
View File
@@ -38,6 +38,10 @@ public:
// thresholdDb clamp -24..0, NaN -> -1 (mirror _clamp trong mastering_engine). // thresholdDb clamp -24..0, NaN -> -1 (mirror _clamp trong mastering_engine).
void setLimiter (bool active, float thresholdDb); 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] // Ap track gain/pan vao buffer roi (neu active) limiter + hard clip [-1,1]
// ngay tai buffer (in-place). frames > 0. // ngay tai buffer (in-place). frames > 0.
void processInPlace (float* outL, float* outR, int32 frames) const; void processInPlace (float* outL, float* outR, int32 frames) const;
@@ -49,6 +53,7 @@ private:
bool m_limActive = false; bool m_limActive = false;
float m_limK = 1.0f; // 1/max(0.02, t_lin) float m_limK = 1.0f; // 1/max(0.02, t_lin)
float m_limTk = 1.0f; // tanh(k) float m_limTk = 1.0f; // tanh(k)
float m_masterGain = 1.0f; // T15: master fader (sau limiter+clip, khong re-clip)
}; };
} // namespace sonicforge } // namespace sonicforge
+12
View File
@@ -409,6 +409,18 @@ __declspec (dllexport) int32 SF_FS_SetMasterLimiter (int32 handle, int32 active,
return 0; 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<std::mutex> 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. // Đó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) __declspec (dllexport) int32 SF_FS_Close (int32 handle, char* err, int32 err_cap)
{ {
+11
View File
@@ -627,4 +627,15 @@ __declspec (dllexport) int32 SF_VST2_SetMasterLimiter (int32 handle, int32 activ
return 0; 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<std::mutex> 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" } // extern "C"
+11
View File
@@ -573,4 +573,15 @@ __declspec (dllexport) int32 SF_VST3_SetMasterLimiter (int32 handle, int32 activ
return 0; 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<std::mutex> 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" } // extern "C"
+152
View File
@@ -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 (T9T14)")
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