# -*- 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.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}"