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