Files
SonicForgeStudio/tests/test_daw_phase5.py
T

531 lines
22 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.
# -*- coding: utf-8 -*-
"""Phase 5 verify — TASKS_DAW_A.md §39-47 (chuẩn DAW checklist).
5.3 Golden live=export: realtime SHM loop vs offline --render-fx cùng chain.
5.4 Order: chain [eq, vst3-delay, limiter] giữ thứ tự (contract) + impulse
qua adelay (VST delay áp giữa chain, không gom cuối, không bỏ).
5.5 Fader: master chain active + fader -6dB → đỉnh giảm 2x, chain vẫn áp.
5.6 PDC: slot latency_samples=1024 + audio bị delay 1024 → bù track → xuyên
pha hết (sum = 2×B); control không PDC → lệch pha.
5.7 Smoke: /capabilities + render master imager+maximizer+vst3.
Skip nếu thiếu fx_vst_bridge.exe (native_bridge/build/Release) — ngoại trừ
các test contract thuần (không spawn bridge).
"""
import io
import json
import math
import os
import subprocess
import sys
import tempfile
import time
import wave
import numpy as np
import pytest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO)
FX_BRIDGE = os.path.join(REPO, "native_bridge", "build", "Release", "fx_vst_bridge.exe")
DAW_BRIDGE = os.path.join(REPO, "native_bridge", "build", "Release", "daw_vst_bridge.exe")
ADELAY = os.path.join(REPO, "native_bridge", "build", "VST3", "Release", "adelay.vst3")
SR = 44100
@pytest.fixture(scope="module")
def fx_bridge():
if not os.path.isfile(FX_BRIDGE):
pytest.skip("fx_vst_bridge.exe chưa build (native_bridge/build/Release)")
return FX_BRIDGE
@pytest.fixture(scope="module")
def adelay():
if not os.path.isfile(os.path.join(ADELAY, "Contents", "x86_64-win", "adelay.vst3")):
pytest.skip("adelay.vst3 sample chưa build (native_bridge/build/VST3/Release)")
return ADELAY
def _write_wav(path, data, sr):
pcm = (np.clip(data, -1.0, 1.0).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, block=256):
job = {"sample_rate": sr, "block_size": block, "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=180)
assert r.returncode == 0, f"bridge rc={r.returncode}: {r.stdout[-500:]} {r.stderr[-500:]}"
assert os.path.isfile(out_path), f"no out wav: {r.stdout[-500:]} {r.stderr[-500:]}"
return _read_wav(out_path)[0]
def _snr_db(ref, got):
ref = ref.astype(np.float64)
got = got.astype(np.float64)
n = min(ref.shape[1], got.shape[1])
if n == 0:
return -float("inf")
ref, got = ref[:, :n], got[:, :n]
denom = float(np.sum((ref - got) ** 2))
if denom < 1e-12:
return float("inf")
return 10.0 * math.log10(float(np.sum(ref ** 2)) / denom)
def _find_delay(live, ref, max_lag=2048):
a = live[0].astype(np.float64)
b = ref[0].astype(np.float64)
n = min(a.shape[0], b.shape[0])
a, b = a[:n], b[:n]
corr = np.correlate(a, b, mode="same")
lag = int(np.argmax(np.abs(corr))) - n // 2
if abs(lag) > max_lag:
return 0
return lag
def _midi_track():
return {
"id": "t1",
"type": "MIDI",
"instrument_id": "__no_such_plugin__",
"instrument_source": "vsti",
"fx_type": None,
"volume_db": 0.0,
"pan": 0.0,
"mute": False,
"items": [{
"type": "MIDI_ITEM",
"start_bar": 0.0,
"duration_bars": 1.0,
"clip_start_offset_bars": 0.0,
"source_data": {"notes": [
{"pitch": 60, "velocity": 0.8, "start_beat": 0.0, "duration_beats": 1.0}
]},
}],
}
def _side_rms(path):
import soundfile as sf
data, _ = sf.read(path, dtype="float32", always_2d=True)
side = data[:, 0] - data[:, 1]
return float(np.sqrt(np.mean(side ** 2)))
# ── 5.3 Golden live=export ────────────────────────────────────────────────
def test_53_live_equals_export(fx_bridge, monkeypatch):
"""Realtime SHM loop vs offline --render-fx, cùng chain builtin, block 256
→ SNR ≥ 30 dB (cùng engine C++, live = export)."""
monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge)
from app.core import fx_realtime
chain = [
{"type": "builtin", "id": "eq",
"params": {"g1": 3.0, "g2": -2.0, "g3": 1.0, "g4": 0.5}},
{"type": "builtin", "id": "compressor",
"params": {"threshold": -18.0, "ratio": 3.0, "makeup": 2.0}},
{"type": "builtin", "id": "limiter", "params": {"ceiling": -3.0}},
]
n = 172 * 256 # bội của block 256
t = np.arange(n) / SR
rng = np.random.default_rng(7)
sig = (0.2 * np.sin(2 * np.pi * 220 * t) + 0.1 * np.sin(2 * np.pi * 1100 * t)
+ 0.02 * rng.standard_normal(n))
audio = np.stack([sig, 0.9 * sig]).astype(np.float32)
with tempfile.TemporaryDirectory() as td:
wav_in = os.path.join(td, "in.wav")
_write_wav(wav_in, audio, SR)
offline = _render_bridge(fx_bridge, wav_in, SR, chain, block=256)
sess = fx_realtime.start_session(chain, SR)
try:
out = bytearray()
nb = n // 256
for i in range(nb):
blk = audio[:, i * 256:(i + 1) * 256]
inter = np.empty(512, dtype=np.float32)
inter[0::2] = blk[0]
inter[1::2] = blk[1]
sess.write_input(inter.tobytes())
time.sleep(0.006)
for b in sess.read_output():
out += b
time.sleep(0.05)
for b in sess.read_output():
out += b
finally:
sess.close()
arr = np.frombuffer(bytes(out), dtype=np.float32)
assert arr.size > 0, "realtime loop không ra output"
live = arr.reshape(-1, 2).T
assert live.shape[1] >= n // 2, f"live quá ngắn: {live.shape}"
lag = _find_delay(live, offline)
if lag >= 0:
got = live[:, lag:lag + offline.shape[1]]
else:
got = live[:, :offline.shape[1] + lag]
n_cmp = min(offline.shape[1], got.shape[1]) - 8
assert n_cmp > 1000, "đoạn so sánh quá ngắn"
snr = _snr_db(offline[:, :n_cmp], got[:, :n_cmp])
assert snr >= 30.0, f"live≠export SNR {snr:.1f} dB (lag {lag})"
# ── 5.4 Order ─────────────────────────────────────────────────────────────
def test_54_chain_order_contract():
"""Chain [eq, vst3-delay, limiter] giữ NGUYÊN thứ tự qua merge offline +
live JSON — không gom VST cuối (regression cho "không gom cuối")."""
from app.core import fx_realtime
from app.core.render_engine import _merge_track_fx_chain
track = {
"id": "t1",
"fx_chain": [
{"type": "builtin", "id": "eq", "params": {"g1": 3.0}},
{"type": "vst3", "path": ADELAY, "active": True},
{"type": "builtin", "id": "limiter", "params": {"ceiling": -3.0}},
],
"vst_fx_chain": [],
"vst_fx_bypass": False,
}
merged = _merge_track_fx_chain(track)
assert [s.get("id") or s.get("type") for s in merged] == ["eq", "vst3", "limiter"]
s = object.__new__(fx_realtime.FxRealtimeSession)
s.chain = list(merged)
s._lock = None
out = s._chain_json_for_bridge()
assert [o["type"] for o in out] == ["builtin", "vst3", "builtin"]
assert out[1]["path"] == ADELAY
def test_54_vst3_delay_in_middle_audio(fx_bridge, adelay):
"""Impulse qua [eq, adelay(default 1s), limiter] → peak tại ≈44100, im lặng
trước đó; SNR vs reference Python (eq → shift 44100 → limiter) ≥ 18 dB —
(EQ Python vs C++ impulse response lệch ~22 dB; check order thật là vị trí
peak + pre-silence) — VST delay áp giữa chain (sau EQ, trước limiter)."""
from app.core.render_engine import _apply_builtin_fx_chain
chain = [
{"type": "builtin", "id": "eq",
"params": {"g1": 3.0, "g2": -2.0, "g3": 1.0, "g4": 0.5}},
{"type": "vst3", "path": adelay},
{"type": "builtin", "id": "limiter", "params": {"ceiling": -3.0}},
]
n = int(SR * 1.5)
audio = np.zeros((2, n), dtype=np.float32)
audio[0, 0] = 1.0
audio[1, 0] = 1.0
with tempfile.TemporaryDirectory() as td:
wav_in = os.path.join(td, "in.wav")
_write_wav(wav_in, audio, SR)
got = _render_bridge(fx_bridge, wav_in, SR, chain, block=256)
idx = int(np.unravel_index(np.argmax(np.abs(got)), got.shape)[1])
assert abs(idx - SR) < 200, f"peak tại {idx} — mong đợi ~{SR} (delay 1s)"
assert float(np.max(np.abs(got[:, :int(SR * 0.9)]))) == 0.0, "có tín hiệu trước delay"
ref = _apply_builtin_fx_chain(
audio.copy(),
[{"type": "eq", "params": {"g1": 3.0, "g2": -2.0, "g3": 1.0, "g4": 0.5}}], SR)
ref = np.roll(ref, SR, axis=1)
ref = _apply_builtin_fx_chain(ref, [{"type": "limiter", "params": {"ceiling": -3.0}}], SR)
n_cmp = min(ref.shape[1], got.shape[1]) - 8
snr = _snr_db(ref[:, :n_cmp], got[:, :n_cmp])
assert snr >= 18.0, f"SNR {snr:.1f} dB"
# ── 5.5 Fader sau master chain ────────────────────────────────────────────
def test_55_fader_after_master_chain(monkeypatch, tmp_path, fx_bridge):
"""Master chain active (gain +6dB) + fader -6dB → đỉnh giảm 2x, chain vẫn
áp (đỉnh fader-6 ≈ base, đỉnh fader0 ≈ 2×base) — fader SAU chain."""
monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge)
if not os.path.isfile(DAW_BRIDGE):
pytest.skip("daw_vst_bridge.exe chưa build")
monkeypatch.setenv("SF_BRIDGE_PATH", DAW_BRIDGE)
from app.api.v1 import plugins as pl
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
from app.core.render_engine import PythonRenderEngine
engine = PythonRenderEngine()
def project(master_vol, chain):
track = _midi_track()
track["volume_db"] = -20.0 # tránh normalize (>1.0)
return {
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
"main_session": {
"length_bars": 1.0,
"tracks": [track],
"master": {
"fx_chain": chain,
"bypass": False,
"volume_db": master_vol,
},
},
"section_store": {},
}
GAIN = [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}]
def peak(name):
chain = {"base.wav": [], "0.wav": GAIN, "6.wav": GAIN}[name]
vol = {"base.wav": 0.0, "0.wav": 0.0, "6.wav": -6.0}[name]
out = str(tmp_path / name)
engine.render_project(project(vol, chain), out)
data, _ = _read_wav(out)
return float(np.max(np.abs(data)))
base = peak("base.wav")
p0 = peak("0.wav")
p6 = peak("6.wav")
assert p0 > 1.7 * base, f"chain +6dB không áp: {p0:.4f} vs base {base:.4f}"
assert 0.4 < p6 / p0 < 0.6, f"fader -6dB phải ~1/2 đỉnh: {p6:.4f}/{p0:.4f}"
assert 0.8 < p6 / base < 1.2, f"fader-6 phải ≈ base: {p6:.4f} vs {base:.4f}"
# ── 5.8 Master L/R volume + LINK + MUTE ─────────────────────────────────
def test_58_master_lr_link_mute(monkeypatch, tmp_path, fx_bridge):
"""Master strip L/R: volumeL_db/volumeR_db tách kênh, link_lr → R theo L,
muted → zero output. Fallback volume_db (project cũ) vẫn chạy."""
monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge)
if not os.path.isfile(DAW_BRIDGE):
pytest.skip("daw_vst_bridge.exe chưa build")
monkeypatch.setenv("SF_BRIDGE_PATH", DAW_BRIDGE)
from app.api.v1 import plugins as pl
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
from app.core.render_engine import PythonRenderEngine
engine = PythonRenderEngine()
def project(master):
track = _midi_track()
track["volume_db"] = -20.0 # tránh normalize (>1.0)
return {
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
"main_session": {
"length_bars": 1.0,
"tracks": [track],
"master": dict({"fx_chain": [], "bypass": False}, **master),
},
"section_store": {},
}
def render(name, master):
out = str(tmp_path / name)
engine.render_project(project(master), out)
data, _ = _read_wav(out)
return data
base = render("base.wav", {"volumeL_db": 0.0, "volumeR_db": 0.0, "link_lr": True})
baseL = float(np.max(np.abs(base[0])))
baseR = float(np.max(np.abs(base[1])))
assert baseL > 0
lr = render("lr.wav", {"volumeL_db": -6.0, "volumeR_db": 0.0, "link_lr": False})
assert 0.4 < float(np.max(np.abs(lr[0]))) / baseL < 0.6, f"L -6dB phải ~1/2 đỉnh: {float(np.max(np.abs(lr[0]))):.4f}/{baseL:.4f}"
assert abs(float(np.max(np.abs(lr[1]))) - baseR) / baseR < 0.15, "R phải giữ nguyên khi link_lr=false"
link = render("link.wav", {"volumeL_db": -6.0, "volumeR_db": -3.0, "link_lr": True})
ratio = float(np.max(np.abs(link[1]))) / float(np.max(np.abs(link[0])))
assert 0.9 < ratio < 1.1, f"link_lr: R phải theo L ({ratio:.3f})"
muted = render("muted.wav", {"volumeL_db": 0.0, "volumeR_db": 0.0,
"link_lr": True, "muted": True})
assert float(np.max(np.abs(muted))) < 1e-6, "MUTE master phải zero output"
legacy = render("legacy.wav", {"volume_db": -6.0})
assert 0.4 < float(np.max(np.abs(legacy[0]))) / baseL < 0.6 and 0.4 < float(np.max(np.abs(legacy[1]))) / baseR < 0.6, "fallback volume_db (project cũ) phải áp cả 2 kênh"
# ── 5.6 PDC ───────────────────────────────────────────────────────────────
def test_56_pdc_aligns_tracks(monkeypatch, tmp_path, fx_bridge):
"""Plugin latency 1024 (slot latency_samples) + audio thật bị delay 1024 →
PDC bù track ngắn → sum = 2×B (xuyên pha hết). Control latency=0 → sum
lệch pha (SNR thấp)."""
monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge)
from app.api.v1 import plugins as pl
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
from app.core.render_engine import PythonRenderEngine
engine = PythonRenderEngine()
n = SR * 2 # 1 bar @120bpm 4/4 = 88200
t = np.arange(n) / SR
w = (0.2 * np.sin(2 * np.pi * 220 * t) + 0.1 * np.sin(2 * np.pi * 1100 * t)).astype(np.float32)
ws = np.zeros_like(w)
ws[1024:] = w[:n - 1024]
fa = str(tmp_path / "a.wav")
fb = str(tmp_path / "b.wav")
_write_wav(fa, np.stack([w, w]), SR)
_write_wav(fb, np.stack([ws, ws]), SR)
def project(lat):
def track(path, chain):
return {
"id": path,
"type": "AUDIO",
"volume_db": 0.0,
"pan": 0.0,
"fx_chain": chain,
"vst_fx_chain": [],
"vst_fx_bypass": False,
"items": [{
"type": "AUDIO_ITEM",
"start_bar": 0.0,
"duration_bars": 1.0,
"clip_start_offset_bars": 0.0,
"source_data": {"audio_file_url": path},
}],
}
chain_b = []
if lat:
# bypass passthrough — chỉ khai latency cho PDC (spec §3 field)
chain_b = [{"type": "vst3", "path": "", "bypass": True,
"active": True, "latency_samples": lat}]
return {
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
"main_session": {
"length_bars": 1.0,
"tracks": [track(fa, []), track(fb, chain_b)],
},
"section_store": {},
}
out_pdc = str(tmp_path / "pdc.wav")
out_nopdc = str(tmp_path / "nopdc.wav")
engine.render_project(project(1024), out_pdc)
engine.render_project(project(0), out_nopdc)
d_pdc, _ = _read_wav(out_pdc)
d_nopdc, _ = _read_wav(out_nopdc)
expected = np.stack([ws, ws]) * 2.0 # PDC delay track A → cùng pha B
n_cmp = n - 16
snr_pdc = _snr_db(expected[:, :n_cmp], d_pdc[:, :n_cmp])
snr_nopdc = _snr_db(expected[:, :n_cmp], d_nopdc[:, :n_cmp])
assert snr_pdc >= 30.0, f"PDC sum lệch: SNR {snr_pdc:.1f} dB"
assert snr_nopdc < 15.0, f"control không lệch pha: SNR {snr_nopdc:.1f} dB"
# ── 5.7 Smoke ─────────────────────────────────────────────────────────────
def test_57_capabilities_smoke():
"""Server /capabilities phản hồi OK (smoke API)."""
from fastapi.testclient import TestClient
from app.main import app
r = TestClient(app).get("/api/v1/system/capabilities")
assert r.status_code == 200, r.text[:300]
assert isinstance(r.json(), dict)
def test_57_master_chain_imager_maximizer_vst3(monkeypatch, tmp_path, fx_bridge, adelay):
"""Render master imager+maximizer+vst3 (adelay) qua bridge — không crash,
non-silent; imager vẫn tác dụng khi chain có vst3."""
monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge)
if not os.path.isfile(DAW_BRIDGE):
pytest.skip("daw_vst_bridge.exe chưa build")
monkeypatch.setenv("SF_BRIDGE_PATH", DAW_BRIDGE)
from app.api.v1 import plugins as pl
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
from app.core.render_engine import PythonRenderEngine
engine = PythonRenderEngine()
def render(chain, name):
track = _midi_track()
track["volume_db"] = -20.0
track["pan"] = 0.6
proj = {
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
"main_session": {"length_bars": 1.0, "tracks": [track]},
"section_store": {},
}
if chain is not None:
proj["mastering_settings"] = {
"chain": chain,
"w1": 0, "w2": 0, "w3": 0, "w4": 0,
"maxGain": 0.0, "maxSoftClip": False, "maxUpward": False,
"ceiling": -1.0,
}
out = str(tmp_path / name)
engine.render_project(proj, out)
return out
base = _side_rms(render(None, "base.wav"))
s0 = _side_rms(render([
{"id": "imager", "type": "imager", "name": "Imager", "active": True},
{"type": "vst3", "path": adelay},
{"id": "maximizer", "type": "maximizer", "name": "Max", "active": True},
], "im0.wav"))
s2 = _side_rms(render([
{"id": "imager", "type": "imager", "name": "Imager", "active": True,
"params": {"w1": 200, "w2": 200, "w3": 200, "w4": 200}},
{"type": "vst3", "path": adelay},
{"id": "maximizer", "type": "maximizer", "name": "Max", "active": True},
], "im200.wav"))
assert base > 0.001, "pan tạo side content"
assert s0 < 0.05 * base, f"imager w=0 không giết side: {s0:.5f} vs {base:.5f}"
assert s2 > 1.5 * base, f"imager w=200 không tăng side: {s2:.5f} vs {base:.5f}"
def test_59_limiter_brickwall_oversample(fx_bridge):
"""D2: LimiterFx mode brickwall — lookahead + oversample 4x + hard clip.
Impulse 0dB qua ceiling -1dB: peak <= ceiling + 0.005, length giu nguyen;
soft mode giu tanh (peak ~1.0); low-level qua brickwall ~ unity."""
n = 4096
imp = np.zeros(n, dtype=np.float32)
imp[512] = 1.0
audio = np.stack([imp, imp * 0.8]).astype(np.float32)
ceil_lin = 10.0 ** (-1.0 / 20.0)
brick = [{"type": "builtin", "id": "limiter",
"params": {"ceiling": -1.0, "mode": 1, "lookahead_ms": 2.0}}]
brick_str = [{"type": "builtin", "id": "limiter",
"params": {"ceiling": -1.0, "mode": "brickwall", "lookahead_ms": 3.0}}]
soft = [{"type": "builtin", "id": "limiter",
"params": {"ceiling": -1.0}}]
with tempfile.TemporaryDirectory() as td:
wav_in = os.path.join(td, "in.wav")
_write_wav(wav_in, audio, SR)
out_brick = _render_bridge(fx_bridge, wav_in, SR, brick, block=512)
out_brick_str = _render_bridge(fx_bridge, wav_in, SR, brick_str, block=512)
out_soft = _render_bridge(fx_bridge, wav_in, SR, soft, block=512)
assert out_brick.shape == audio.shape, "brickwall length giu nguyen"
pk = float(np.max(np.abs(out_brick)))
assert pk <= ceil_lin + 0.005, f"brickwall peak {pk:.4f} > ceiling {ceil_lin:.4f}"
assert pk >= 0.70, f"brickwall khong giam dinh? peak {pk:.4f}"
pk_str = float(np.max(np.abs(out_brick_str)))
assert pk_str <= ceil_lin + 0.005, f"mode string peak {pk_str:.4f}"
pk_soft = float(np.max(np.abs(out_soft)))
assert pk_soft > 0.95, f"soft peak {pk_soft:.4f} (tanh normalized ~1.0)"
assert pk_soft <= 1.0005, f"soft peak {pk_soft:.4f} vuot 1.0"
imp2 = np.zeros(n, dtype=np.float32)
imp2[2048] = 0.1
audio2 = np.stack([imp2, imp2]).astype(np.float32)
with tempfile.TemporaryDirectory() as td:
wav_in2 = os.path.join(td, "in2.wav")
_write_wav(wav_in2, audio2, SR)
out_low = _render_bridge(fx_bridge, wav_in2, SR, brick, block=512)
pk_low = float(np.max(np.abs(out_low)))
assert 0.05 <= pk_low <= 0.15, f"low-level peak {pk_low:.4f} lech unity"