Files
SonicForgeStudio/tests/test_builtin_fx_golden.py
T
admin 1390ad545b Phase 2: BuiltinFxChain C++ (8 DSP) + control ring SET_PARAM/REPORT_LATENCY
- native_bridge/src/BuiltinFxChain.{h,cpp}: 8 builtin DSP port 1:1 từ
  Python _apply_builtin_fx_chain (eq/eqpro/compressor/limiter/exciter/rebalance)
  và JS MASTER_MODULE_IO (imager/maximizer). Biquad RBJ DF2T, block-wise
  stateful. createBuiltinFx trả nullptr cho gain/normalize (legacy).
- RenderFxJob: FxSlot thêm bfx, parse builtin 8 loại, runBuiltinSlotSafe SEH,
  realtimeRunChain nhánh builtin, chainGen()/entryLatencies(), setParam
  áp live lên bfx; Vst3Fx latencySamples từ getLatencySamples().
- RealtimeFxLoop: drain ctrl ring (SET_PARAM -> chain.setParam, drop cũ khi
  đầy) + REPORT_LATENCY khi gen đổi (ipc->lat[] ring, lastGen).
- FxRealtimeIPC.h: header 18 u32 (72B), ctrl/lat slots 8, FxCtrlCmd 24B,
  FxLatReport 8B, static_asserts.
- app/core/fx_realtime.py: mirror header 72B (ctrl_write/read, lat_write/read,
  slots), set_param ghi ctrl ring qua _ctrl_enqueue_locked (guard shm),
  get_latencies drain lat ring qua _lat_drain.
- tests/test_builtin_fx_golden.py: golden SNR C++ vs Python (8 cases, ngưỡng
  30dB) + order test eq/compressor xen kẽ.
- TASKS_DAW_A.md: tick 2.1-2.9.

Test: 136 passed (4 fx_realtime_chain + 8 builtin golden + regression).
2026-08-22 19:55:08 +07:00

140 lines
4.9 KiB
Python

# -*- coding: utf-8 -*-
"""Golden test PLAN_DAW_A.md 2.9: C++ BuiltinFxChain vs Python
_apply_builtin_fx_chain (render_engine.py) — SNR per-chain, kể cả order test.
Skip nếu thiếu fx_vst_bridge.exe (build/Release) hoặc scipy.
"""
import io, json, math, os, subprocess, sys, tempfile, wave
import numpy as np
import pytest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO)
from app.core.render_engine import _apply_builtin_fx_chain # noqa: E402
BRIDGE_CANDIDATES = [
os.environ.get("SF_FX_BRIDGE_PATH", ""),
os.path.join(REPO, "native_bridge", "build", "Release", "fx_vst_bridge.exe"),
os.path.join(REPO, "install", "fx_vst_bridge.exe"),
]
def _find_bridge():
for c in BRIDGE_CANDIDATES:
if c and os.path.isfile(c):
return c
return None
@pytest.fixture(scope="module")
def bridge():
b = _find_bridge()
if not b:
pytest.skip("fx_vst_bridge.exe not found (native_bridge/build/Release)")
return b
def _write_wav(path, data, sr):
data = np.clip(data, -1.0, 1.0)
# data shape (2, n) → interleave (n, 2) trước khi ghi
pcm = (data.T * 32767).astype(np.int16)
with wave.open(path, "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(pcm.tobytes())
def _read_wav(path):
import soundfile as sf
data, sr = sf.read(path, dtype="float32", always_2d=True)
return data.T, sr
def _render_bridge(bridge, wav_in, sr, chain):
job = {"sample_rate": sr, "block_size": 512, "fx_chain": chain}
with tempfile.TemporaryDirectory() as td:
job_path = os.path.join(td, "job.json")
out_path = os.path.join(td, "out.wav")
with io.open(job_path, "w", encoding="utf-8") as f:
json.dump(job, f)
r = subprocess.run([bridge, "--render-fx", job_path, "--in", wav_in,
"--out", out_path],
capture_output=True, text=True, timeout=120)
assert r.returncode == 0, f"bridge rc={r.returncode}: {r.stdout} {r.stderr}"
assert os.path.isfile(out_path), f"no out wav: {r.stdout} {r.stderr}"
return _read_wav(out_path)[0]
def _snr_db(ref, got):
ref = ref.astype(np.float64)
got = got.astype(np.float64)
if ref.shape != got.shape:
raise AssertionError(f"shape mismatch {ref.shape} vs {got.shape}")
n = min(ref.shape[1], got.shape[1])
if n == 0:
return math.inf
ref, got = ref[:, :n], got[:, :n]
denom = float(np.sum((ref - got) ** 2))
if denom < 1e-12:
return math.inf
return 10.0 * math.log10(float(np.sum(ref ** 2)) / denom)
def _run_case(bridge, sr, seed, chain):
rng = np.random.default_rng(seed)
n = sr // 4
t = np.arange(n) / sr
sig = (0.25 * np.sin(2 * np.pi * 220 * t)
+ 0.12 * np.sin(2 * np.pi * 1100 * t)
+ 0.06 * np.sin(2 * np.pi * 5000 * t))
noise = 0.01 * rng.standard_normal(n)
audio = np.stack([sig + noise, 0.9 * sig + 0.02 * rng.standard_normal(n)])
audio = audio.astype(np.float32)
with tempfile.TemporaryDirectory() as td:
wav_in = os.path.join(td, "in.wav")
_write_wav(wav_in, audio, sr)
got = _render_bridge(bridge, wav_in, sr, chain)
# Python reference dùng schema cũ {type: eq} — convert từ job schema mới
# {type: builtin, id: eq} (bridge nhận schema mới, spec Phase 2).
py_chain = [{"type": s.get("id", s.get("type")), "params": s.get("params") or {}}
for s in chain]
ref = _apply_builtin_fx_chain(audio.copy(), py_chain, sr)
snr = _snr_db(ref, got)
return snr
def _b(id, params):
return {"type": "builtin", "id": id, "params": params or {}}
CHAINS = {
"eq": [_b("eq", {"g1": 3.0, "g2": -2.0, "g3": 1.5, "g4": 2.0})],
"eqpro": [_b("eqpro", {"amount": 80, "bands": [
{"active": True, "type": "lowshelf", "freq": 120, "gain": 2.5, "q": 0.8},
{"active": True, "type": "peaking", "freq": 900, "gain": -3.0, "q": 1.2},
{"active": True, "type": "highpass", "freq": 60, "gain": 0.0, "q": 0.7},
{"active": True, "type": "highshelf", "freq": 8000, "gain": 1.8, "q": 0.7},
]})],
"compressor": [_b("compressor", {"threshold": -20.0, "ratio": 4.0, "makeup": 2.0})],
"limiter": [_b("limiter", {"ceiling": -3.0})],
"exciter": [_b("exciter", {"drive": 60.0})],
"rebalance": [_b("rebalance", {"mid": 2.0, "side": -1.5})],
"order_ec": [_b("eq", {"g1": 2.0, "g3": -1.0}),
_b("compressor", {"threshold": -18.0, "ratio": 3.0})],
"order_ce": [_b("compressor", {"threshold": -18.0, "ratio": 3.0}),
_b("eq", {"g1": 2.0, "g3": -1.0})],
}
MIN_SNR = 30.0
@pytest.mark.parametrize("name", sorted(CHAINS))
def test_builtin_golden(bridge, name):
snr = _run_case(bridge, 44100, seed=1234, chain=CHAINS[name])
assert snr >= MIN_SNR, f"{name}: SNR {snr:.1f} dB < {MIN_SNR}"