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
+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