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:
@@ -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 (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
|
||||
Reference in New Issue
Block a user