1390ad545b
- 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).
140 lines
4.9 KiB
Python
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}"
|