Files
SonicForgeStudio/tests/test_native_matrix.py
T

153 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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