153 lines
7.1 KiB
Python
153 lines
7.1 KiB
Python
"""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
|