531 lines
22 KiB
Python
531 lines
22 KiB
Python
# -*- 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"
|
||
|