Review DAW-standard R1-R8: cài đặt thực tế qua render_project + API /render — track chain order (vst3 giữa), gain/pan sau chain, master chain trên mix, gain staging, mono/stereo, smoke export
This commit is contained in:
+8
-8
@@ -47,11 +47,11 @@ Trạng thái: `[x]` hoàn thành, `[ ]` chưa. Cập nhật sau mỗi phase.
|
||||
- [x] 5.8 Commit + push standalone-shm-bridge.
|
||||
|
||||
## Review DAW-standard (sau Phase 5, bổ sung nếu thiếu)
|
||||
- [ ] R1 Track insert chain serial đúng thứ tự UI (builtin+VST xen kẽ).
|
||||
- [ ] R2 Gain/pan sau track chain, trước sum.
|
||||
- [ ] R3 Master chain serial đúng thứ tự trên mix tổng.
|
||||
- [ ] R4 Master fader cuối cùng trước metering/out.
|
||||
- [ ] R5 Live = Export (cùng engine, cùng chain, cùng DSP).
|
||||
- [ ] R6 PDC.
|
||||
- [ ] R7 Gain staging rõ.
|
||||
- [ ] R8 Mono/Stereo xử lý đúng.
|
||||
- [x] R1 Track insert chain serial đúng thứ tự UI (builtin+VST xen kẽ).
|
||||
- [x] R2 Gain/pan sau track chain, trước sum.
|
||||
- [x] R3 Master chain serial đúng thứ tự trên mix tổng.
|
||||
- [x] R4 Master fader cuối cùng trước metering/out.
|
||||
- [x] R5 Live = Export (cùng engine, cùng chain, cùng DSP).
|
||||
- [x] R6 PDC.
|
||||
- [x] R7 Gain staging rõ.
|
||||
- [x] R8 Mono/Stereo xử lý đúng.
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Review DAW-standard — TASKS_DAW_A.md §49-57 (R1-R8), cài đặt thực tế.
|
||||
|
||||
Mọi test chạy qua DAW THẬT: PythonRenderEngine.render_project (RENDER_ENGINE=
|
||||
bridge mặc định) + native_bridge fx_vst_bridge.exe --render-fx + project JSON
|
||||
đầy đủ (AUDIO_ITEM track, master, mastering_settings.chain). API smoke qua
|
||||
TestClient(app) + /api/v1/plugins/render (auth admin) — chính là luồng frontend
|
||||
Export.
|
||||
|
||||
- R1: track insert chain [eq, vst3-adelay, limiter] — impulse → peak tại
|
||||
44100 (delay 1s), im lặng trước: vst3 ở giữa chain, không gom cuối.
|
||||
- R2: gain/pan sau track chain — rebalance mid-only (giết side) + pan 0.5 →
|
||||
output L≠R với tỉ lệ constant-power: pan phải chạy SAU chain.
|
||||
- R3: master chain serial trên mix tổng — mastering_settings.chain
|
||||
[eq, vst3-adelay, limiter] → impulse peak tại 44100.
|
||||
- R4: master fader cuối (đã có test_55_fader_after_master_chain).
|
||||
- R5: live=export (đã có test_53_live_equals_export).
|
||||
- R6: PDC (đã có test_56_pdc_aligns_tracks).
|
||||
- R7: gain staging — track fader -6 / master fader -6 / chain +6 / fader -6
|
||||
net 0: dB cộng dồn đúng qua từng stage.
|
||||
- R8: mono/stereo — mono source pad L==R giữ qua chain vst3; stereo source
|
||||
giữ separation qua chain builtin.
|
||||
- R-API: TestClient + POST /api/v1/plugins/render project thật (track chain +
|
||||
master chain) → WAV peak tại 44100.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, REPO)
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import test_daw_phase5 as p5 # noqa: E402 (dùng chung helper/fixture)
|
||||
|
||||
SR = p5.SR
|
||||
_write_wav = p5._write_wav
|
||||
_read_wav = p5._read_wav
|
||||
_snr_db = p5._snr_db
|
||||
ADELAY = p5.ADELAY
|
||||
FX_BRIDGE = p5.FX_BRIDGE
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge_env(fx_bridge, monkeypatch):
|
||||
"""Engine render_project → native_render.render_fx_chain → fx_vst_bridge.exe
|
||||
(tìm qua SF_FX_BRIDGE_PATH)."""
|
||||
monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge)
|
||||
return fx_bridge
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
def _audio_track(wav_path, vol=0.0, pan=0.0, fx_chain=None, track_id="t1"):
|
||||
return {
|
||||
"id": track_id, "type": "AUDIO",
|
||||
"volume_db": vol, "pan": pan, "mute": False,
|
||||
"fx_chain": fx_chain or [], "vst_fx_chain": [], "vst_fx_bypass": False,
|
||||
"fx_active": True,
|
||||
"items": [{
|
||||
"type": "AUDIO_ITEM", "start_bar": 0.0, "duration_bars": 1.0,
|
||||
"clip_start_offset_bars": 0.0,
|
||||
"source_data": {"audio_file_url": wav_path},
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _project(tracks, master_vol=0.0, master_chain=None, ms_chain=None,
|
||||
length_bars=1.0):
|
||||
master = {"bypass": False, "volume_db": master_vol}
|
||||
if master_chain is not None:
|
||||
master["fx_chain"] = master_chain
|
||||
proj = {
|
||||
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
|
||||
"main_session": {"length_bars": length_bars, "tracks": tracks, "master": master},
|
||||
"section_store": {},
|
||||
}
|
||||
if ms_chain is not None:
|
||||
proj["mastering_settings"] = {"chain": ms_chain}
|
||||
return proj
|
||||
|
||||
|
||||
def _impulse_wav(path, amp=1.0, n=2 * SR):
|
||||
audio = np.zeros((2, n), dtype=np.float32)
|
||||
audio[0, 0] = amp
|
||||
audio[1, 0] = amp
|
||||
_write_wav(path, audio, SR)
|
||||
|
||||
|
||||
def _render_project(project, out):
|
||||
from app.core.render_engine import PythonRenderEngine
|
||||
engine = PythonRenderEngine()
|
||||
engine.render_project(project, out)
|
||||
return _read_wav(out)[0]
|
||||
|
||||
|
||||
def _peak_idx(data):
|
||||
return int(np.unravel_index(np.argmax(np.abs(data)), data.shape)[1])
|
||||
|
||||
|
||||
# ── R1 Track insert chain serial ───────────────────────────────────────────
|
||||
def test_r1_track_chain_order_impulse(bridge_env, adelay, tmp_path):
|
||||
"""Track chain [eq, vst3-adelay, limiter] qua render_project thật — impulse
|
||||
peak tại ≈44100, im lặng trước: vst3 ở giữa chain (sau EQ, trước limiter)."""
|
||||
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}},
|
||||
]
|
||||
wav = str(tmp_path / "imp.wav")
|
||||
_impulse_wav(wav)
|
||||
out = str(tmp_path / "r1.wav")
|
||||
data = _render_project(_project([_audio_track(wav, fx_chain=chain)]), out)
|
||||
|
||||
idx = _peak_idx(data)
|
||||
assert abs(idx - SR) < 200, f"peak tại {idx} — mong đợi ~{SR} (delay 1s)"
|
||||
assert float(np.max(np.abs(data[:, :int(SR * 0.9)]))) == 0.0, "có tín hiệu trước delay"
|
||||
|
||||
|
||||
# ── R2 Gain/pan sau track chain ────────────────────────────────────────────
|
||||
def test_r2_gain_pan_after_track_chain(bridge_env, tmp_path):
|
||||
"""Chain rebalance mid-only (giết side) → L==R. Pan 0.5 SAU chain tạo lại
|
||||
side (L≠R, constant-power R/L=sin/cos≈2.414). Nếu pan chạy trước chain,
|
||||
rebalance sẽ mono lại → L==R (fail)."""
|
||||
chain = [{"type": "builtin", "id": "rebalance",
|
||||
"params": {"mid": 0.0, "side": -120.0}}]
|
||||
wav = str(tmp_path / "imp.wav")
|
||||
_impulse_wav(wav, amp=0.8)
|
||||
|
||||
d0 = _render_project(_project([_audio_track(wav, pan=0.0, fx_chain=chain)]),
|
||||
str(tmp_path / "pan0.wav"))
|
||||
assert _snr_db(d0[0:1], d0[1:2]) >= 60.0, "rebalance mid-only phải ra L==R"
|
||||
|
||||
d5 = _render_project(_project([_audio_track(wav, pan=0.5, fx_chain=chain)]),
|
||||
str(tmp_path / "pan5.wav"))
|
||||
lr = float(np.max(np.abs(d5[1]))) / float(np.max(np.abs(d5[0])))
|
||||
assert 1.8 < lr < 3.2, f"pan không SAU chain: R/L={lr:.3f} (mong đợi 2.414)"
|
||||
|
||||
# gain cũng sau chain: chain rebalance mid-only không đổi amp → pan=0,
|
||||
# vol -6 → đỉnh = base/2 (gain scale đúng ở stage riêng)
|
||||
d6 = _render_project(_project([_audio_track(wav, pan=0.0, vol=-6.0, fx_chain=chain)]),
|
||||
str(tmp_path / "vol6.wav"))
|
||||
assert abs(float(np.max(np.abs(d6))) - 0.4) < 0.03, "vol_db -6 sau chain sai"
|
||||
|
||||
|
||||
# ── R3 Master chain serial trên mix tổng ───────────────────────────────────
|
||||
def test_r3_master_chain_serial_on_mix(bridge_env, adelay, tmp_path):
|
||||
"""mastering_settings.chain [eq, vst3-adelay, limiter] trên mix tổng —
|
||||
impulse peak tại ≈44100 (vst3 delay giữa chain master, không gom cuối)."""
|
||||
chain = [
|
||||
{"type": "eq", "params": {"g1": 3.0, "g2": -2.0, "g3": 1.0, "g4": 0.5}},
|
||||
{"type": "vst3", "path": adelay},
|
||||
{"type": "limiter", "params": {"ceiling": -3.0}},
|
||||
]
|
||||
wav = str(tmp_path / "imp.wav")
|
||||
_impulse_wav(wav)
|
||||
out = str(tmp_path / "r3.wav")
|
||||
data = _render_project(_project([_audio_track(wav)], ms_chain=chain), out)
|
||||
|
||||
idx = _peak_idx(data)
|
||||
assert abs(idx - SR) < 200, f"peak tại {idx} — mong đợi ~{SR}"
|
||||
assert float(np.max(np.abs(data[:, :int(SR * 0.9)]))) == 0.0, "có tín hiệu trước delay"
|
||||
|
||||
|
||||
# ── R7 Gain staging ────────────────────────────────────────────────────────
|
||||
def test_r7_gain_staging(bridge_env, tmp_path):
|
||||
"""dB cộng dồn qua từng stage: track fader -6 / master fader -6 / master
|
||||
chain +6 / fader -6 — net 0 → bằng base; net -6 → base/2."""
|
||||
t = np.arange(SR // 2) / SR
|
||||
sig = (0.3 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
||||
wav = str(tmp_path / "sig.wav")
|
||||
_write_wav(wav, np.stack([sig, sig]), SR)
|
||||
GAIN6 = [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}]
|
||||
|
||||
def peak(vol=0.0, mvol=0.0, mchain=None):
|
||||
out = str(tmp_path / f"g_{vol}_{mvol}_{bool(mchain)}.wav")
|
||||
d = _render_project(_project([_audio_track(wav, vol=vol)],
|
||||
master_vol=mvol, master_chain=mchain), out)
|
||||
return float(np.max(np.abs(d)))
|
||||
|
||||
base = peak()
|
||||
assert base > 0.1
|
||||
# net -6 dB → base/2
|
||||
for name, v in [("track fader -6", peak(vol=-6.0)),
|
||||
("master fader -6", peak(mvol=-6.0)),
|
||||
("track-6 + chain+6 + fader-6", peak(vol=-6.0, mvol=-6.0, mchain=GAIN6))]:
|
||||
assert abs(v - base / 2) < 0.06 * base, \
|
||||
f"{name} staging sai: {v:.4f} vs base/2 {base / 2:.4f}"
|
||||
# net 0 dB → base
|
||||
for name, v in [("track-6 + chain+6", peak(vol=-6.0, mchain=GAIN6)),
|
||||
("chain+6 + fader-6", peak(mvol=-6.0, mchain=GAIN6))]:
|
||||
assert abs(v - base) < 0.06 * base, f"{name} staging sai: {v:.4f} vs base {base:.4f}"
|
||||
|
||||
|
||||
# ── R8 Mono/Stereo ─────────────────────────────────────────────────────────
|
||||
def test_r8_mono_stereo(bridge_env, adelay, tmp_path):
|
||||
"""Mono source → pad L==R, giữ qua chain vst3 (delay 1s). Stereo source
|
||||
L≠R → giữ separation qua chain builtin (xử lý per-channel đúng)."""
|
||||
mono = np.zeros(2 * SR, dtype=np.float32)
|
||||
mono[0] = 1.0
|
||||
wp = str(tmp_path / "mono.wav")
|
||||
with wave.open(wp, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(SR)
|
||||
w.writeframes((mono * 32767).astype(np.int16).tobytes())
|
||||
|
||||
d = _render_project(_project([_audio_track(wp, fx_chain=[{"type": "vst3", "path": adelay}])]),
|
||||
str(tmp_path / "mono_out.wav"))
|
||||
assert _snr_db(d[0:1], d[1:2]) >= 60.0, "mono pad L==R không giữ qua chain vst3"
|
||||
assert abs(_peak_idx(d) - SR) < 200, "mono delay qua chain sai vị trí"
|
||||
|
||||
st = np.zeros((2, 2 * SR), dtype=np.float32)
|
||||
st[0, 0] = 1.0
|
||||
st[1, 0] = 0.4
|
||||
wp2 = str(tmp_path / "st.wav")
|
||||
_write_wav(wp2, st, SR)
|
||||
eq = [{"type": "builtin", "id": "eq", "params": {"g1": 3.0}}]
|
||||
d2 = _render_project(_project([_audio_track(wp2, fx_chain=eq)]),
|
||||
str(tmp_path / "st_out.wav"))
|
||||
ratio = float(np.max(np.abs(d2[0]))) / float(np.max(np.abs(d2[1])))
|
||||
assert 1.8 < ratio < 3.2, f"stereo separation không giữ qua chain: L/R={ratio:.3f}"
|
||||
|
||||
|
||||
# ── R-API: cài đặt thực tế — TestClient + /render ──────────────────────────
|
||||
def test_r_api_render_real_project(bridge_env, adelay, tmp_path):
|
||||
"""Luồng Export thật: TestClient(app), login admin, GET /capabilities, POST
|
||||
/api/v1/plugins/render với project thật (track chain + master chain đều có
|
||||
vst3 adelay, project 2 bars) → impulse qua 2 delay 1s → peak tại ≈88200
|
||||
(2×SR) — 2 chain nối tiếp không mất tín hiệu."""
|
||||
from test_plugin_api import client, get_admin_token
|
||||
|
||||
token = get_admin_token()
|
||||
assert token, "admin login fail"
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
r = client.get("/api/v1/system/capabilities")
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
|
||||
wav = str(tmp_path / "imp.wav")
|
||||
_impulse_wav(wav, n=3 * SR)
|
||||
chain = [
|
||||
{"type": "builtin", "id": "eq", "params": {"g1": 3.0}},
|
||||
{"type": "vst3", "path": adelay},
|
||||
{"type": "builtin", "id": "limiter", "params": {"ceiling": -3.0}},
|
||||
]
|
||||
proj = _project([_audio_track(wav, fx_chain=chain)], ms_chain=chain,
|
||||
length_bars=2.0)
|
||||
r = client.post("/api/v1/plugins/render", headers=h,
|
||||
json={"project_json": proj, "output_filename": "review_api_out.wav"})
|
||||
assert r.status_code == 200, f"render fail: {r.text[:400]}"
|
||||
body = r.json()
|
||||
path = body.get("path")
|
||||
assert path and os.path.isfile(path), f"no output wav: {body}"
|
||||
|
||||
data = _read_wav(path)[0]
|
||||
idx = _peak_idx(data)
|
||||
assert abs(idx - 2 * SR) < 300, f"peak tại {idx} — mong đợi ~{2 * SR} (track 1s + master 1s)"
|
||||
assert float(np.max(np.abs(data[:, :int(0.9 * SR)]))) == 0.0, "có tín hiệu trước delay"
|
||||
Reference in New Issue
Block a user