231 lines
8.9 KiB
Python
231 lines
8.9 KiB
Python
import os
|
|
import json
|
|
import pytest
|
|
from app.core.render_engine import PythonRenderEngine
|
|
import numpy as np
|
|
|
|
def test_render_engine_init():
|
|
engine = PythonRenderEngine()
|
|
assert engine.sample_rate == 44100
|
|
|
|
def test_bars_to_samples():
|
|
engine = PythonRenderEngine()
|
|
samples = engine.bars_to_samples(4.0, 120.0, 4)
|
|
assert samples == 352800
|
|
|
|
def test_render_session_container_empty():
|
|
engine = PythonRenderEngine()
|
|
session = {"tracks": []}
|
|
buf = engine.render_session_container(session, {}, 120.0, 4, 1000)
|
|
assert buf.shape == (2, 1000)
|
|
assert (buf == 0.0).all()
|
|
|
|
def _midi_track(instrument_id, fx_type=None):
|
|
return {
|
|
"id": "t1",
|
|
"type": "MIDI",
|
|
"instrument_id": instrument_id,
|
|
"instrument_source": "vsti",
|
|
"fx_type": fx_type,
|
|
"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 test_render_session_vsti_bridge_unknown_instrument_falls_back(monkeypatch):
|
|
"""SF_RENDER_ENGINE=bridge: track VSTi đi vào native_render; instrument lạ
|
|
(404) → fallback oscillator, không crash, không cần pedalboard."""
|
|
from app.api.v1 import plugins as pl
|
|
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
|
engine = PythonRenderEngine()
|
|
session = {"tracks": [_midi_track("__no_such_plugin__")]}
|
|
buf = engine.render_session_container(session, {}, 120.0, 4, 44100)
|
|
assert buf.shape == (2, 44100)
|
|
# oscillator sinh âm thật (non-silent)
|
|
assert (buf != 0.0).any()
|
|
|
|
|
|
def test_render_session_vsti_non_bridge_engine_falls_back(monkeypatch):
|
|
"""Engine không phải bridge (vd 'pedalboard' cũ): VSTi → fallback oscillator (không crash)."""
|
|
from app.api.v1 import plugins as pl
|
|
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "pedalboard")
|
|
engine = PythonRenderEngine()
|
|
session = {"tracks": [_midi_track("__no_such_plugin__")]}
|
|
buf = engine.render_session_container(session, {}, 120.0, 4, 44100)
|
|
assert buf.shape == (2, 44100)
|
|
|
|
|
|
def test_render_session_fx_chorus_scipy_fallback():
|
|
"""FX chorus: fallback scipy (không pedalboard) không crash, thay đổi âm."""
|
|
from app.api.v1 import plugins as pl
|
|
pl.settings.RENDER_ENGINE = "bridge"
|
|
engine = PythonRenderEngine()
|
|
session = {"tracks": [_midi_track("__no_such_plugin__", fx_type="chorus")]}
|
|
buf = engine.render_session_container(session, {}, 120.0, 4, 44100)
|
|
assert buf.shape == (2, 44100)
|
|
assert (buf != 0.0).any()
|
|
|
|
|
|
def test_render_session_fx_reverb_scipy_fallback():
|
|
"""FX reverb: fallback scipy (không pedalboard) không crash, thay đổi âm."""
|
|
from app.api.v1 import plugins as pl
|
|
pl.settings.RENDER_ENGINE = "bridge"
|
|
engine = PythonRenderEngine()
|
|
session = {"tracks": [_midi_track("__no_such_plugin__", fx_type="reverb")]}
|
|
buf = engine.render_session_container(session, {}, 120.0, 4, 44100)
|
|
assert buf.shape == (2, 44100)
|
|
assert (buf != 0.0).any()
|
|
|
|
|
|
_BRIDGE_TEST_PATH = r"C:/Users/locpham/SonicForgeStudio/native_bridge/build/Release/daw_vst_bridge.exe"
|
|
|
|
|
|
def _peak(buf):
|
|
return float(np.max(np.abs(buf)))
|
|
|
|
|
|
def test_render_track_fx_chain_gain_via_bridge(monkeypatch):
|
|
"""Track fx_chain (builtin gain +6dB) qua native_bridge → biên độ x2."""
|
|
import numpy as np
|
|
if not os.path.isfile(_BRIDGE_TEST_PATH):
|
|
pytest.skip("bridge chưa build")
|
|
monkeypatch.setenv("SF_BRIDGE_PATH", _BRIDGE_TEST_PATH)
|
|
from app.api.v1 import plugins as pl
|
|
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
|
engine = PythonRenderEngine()
|
|
base = _midi_track("__no_such_plugin__")
|
|
fx = _midi_track("__no_such_plugin__")
|
|
fx["vst_fx_chain"] = [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}]
|
|
b0 = engine.render_session_container({"tracks": [base]}, {}, 120.0, 4, 44100)
|
|
b1 = engine.render_session_container({"tracks": [fx]}, {}, 120.0, 4, 44100)
|
|
assert _peak(b1) > 1.5 * _peak(b0)
|
|
|
|
|
|
def test_render_project_master_fx_chain_gain_via_bridge(monkeypatch, tmp_path):
|
|
"""Masterbus master.fx_chain (gain +6dB) qua native_bridge → peak x2."""
|
|
import numpy as np
|
|
if not os.path.isfile(_BRIDGE_TEST_PATH):
|
|
pytest.skip("bridge chưa build")
|
|
monkeypatch.setenv("SF_BRIDGE_PATH", _BRIDGE_TEST_PATH)
|
|
from app.api.v1 import plugins as pl
|
|
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
|
engine = PythonRenderEngine()
|
|
|
|
def project(with_master):
|
|
track = _midi_track("__no_such_plugin__")
|
|
track["volume_db"] = -20.0 # tránh normalize (>1.0) làm hỏng tỉ lệ 2x
|
|
proj = {
|
|
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
|
|
"main_session": {"length_bars": 1.0, "tracks": [track]},
|
|
"section_store": {},
|
|
}
|
|
if with_master:
|
|
proj["main_session"]["master"] = {
|
|
"fx_chain": [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}],
|
|
"bypass": False,
|
|
}
|
|
return proj
|
|
|
|
out0 = str(tmp_path / "no_master.wav")
|
|
out1 = str(tmp_path / "with_master.wav")
|
|
engine.render_project(project(False), out0)
|
|
engine.render_project(project(True), out1)
|
|
_, d0 = sf_read_peaks(out0)
|
|
_, d1 = sf_read_peaks(out1)
|
|
assert d1 > 1.5 * d0
|
|
|
|
import soundfile as _sf
|
|
|
|
def sf_read_peaks(path):
|
|
import numpy as np
|
|
data, _ = _sf.read(path, dtype="float32", always_2d=True)
|
|
return data, float(np.max(np.abs(data)))
|
|
_FX_BRIDGE_TEST_PATH = r"C:/Users/locpham/SonicForgeStudio/native_bridge/build/Release/fx_vst_bridge.exe"
|
|
|
|
|
|
def _master_project(chain, pan=0.6, **flat):
|
|
"""Project với mastering_settings.chain + flat mastering knobs (giống UI)."""
|
|
track = _midi_track("__no_such_plugin__")
|
|
track["volume_db"] = -20.0 # tránh normalize (>1.0) làm hỏng tỉ lệ
|
|
track["pan"] = pan # constant-power pan → L≠R: master có side content
|
|
proj = {
|
|
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
|
|
"main_session": {"length_bars": 1.0, "tracks": [track]},
|
|
"section_store": {},
|
|
}
|
|
if chain is not None:
|
|
ms = {"chain": chain}
|
|
ms.update(flat)
|
|
proj["mastering_settings"] = ms
|
|
return proj
|
|
|
|
|
|
def _side_rms(path):
|
|
data, _ = _sf.read(path, dtype="float32", always_2d=True)
|
|
side = data[:, 0] - data[:, 1]
|
|
return float(np.sqrt(np.mean(side ** 2)))
|
|
|
|
|
|
def _render_rms(path):
|
|
data, _ = _sf.read(path, dtype="float32", always_2d=True)
|
|
return float(np.sqrt(np.mean(data ** 2)))
|
|
|
|
|
|
def test_render_master_chain_imager_maximizer_via_bridge(monkeypatch, tmp_path):
|
|
"""Phase 3.2: master dùng mastering_settings.chain (hết bất đối xứng) —
|
|
imager w=0 giết side, w=200 tăng side, maximizer boost RMS — qua bridge."""
|
|
if not os.path.isfile(_FX_BRIDGE_TEST_PATH):
|
|
pytest.skip("fx bridge chưa build")
|
|
monkeypatch.setenv("SF_FX_BRIDGE_PATH", _FX_BRIDGE_TEST_PATH)
|
|
monkeypatch.setenv("SF_BRIDGE_PATH", _BRIDGE_TEST_PATH)
|
|
from app.api.v1 import plugins as pl
|
|
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
|
engine = PythonRenderEngine()
|
|
|
|
def render(proj, name):
|
|
out = str(tmp_path / name)
|
|
engine.render_project(proj, out)
|
|
return out
|
|
|
|
base = _side_rms(render(_master_project(None), "base.wav"))
|
|
s0 = _side_rms(render(_master_project(
|
|
[{"id": "imager", "type": "imager", "name": "Imager", "active": True}],
|
|
w1=0, w2=0, w3=0, w4=0), "imager0.wav"))
|
|
s2 = _side_rms(render(_master_project(
|
|
[{"id": "imager", "type": "imager", "name": "Imager", "active": True}],
|
|
w1=200, w2=200, w3=200, w4=200), "imager200.wav"))
|
|
assert base > 0.001 # pan tạo side content
|
|
assert s0 < 0.05 * base # w=0 → side≈0 (imager đã được áp qua bridge)
|
|
assert s2 > 1.5 * base # w=200 → side tăng
|
|
|
|
rm0 = _render_rms(render(_master_project(
|
|
[{"id": "maximizer", "type": "maximizer", "name": "Max", "active": True}],
|
|
maxGain=0.0), "max0.wav"))
|
|
rm6 = _render_rms(render(_master_project(
|
|
[{"id": "maximizer", "type": "maximizer", "name": "Max", "active": True}],
|
|
maxGain=6.0), "max6.wav"))
|
|
assert rm6 > 1.3 * rm0 # boost 6dB → RMS tăng
|
|
|
|
|
|
def test_probe_track_latency_no_vst3_returns_zero(monkeypatch):
|
|
"""Phase 3.3: probe latency chỉ spawn bridge khi chain có vst3 —
|
|
chain builtin-only → 0, không spawn exe."""
|
|
from app.api.v1 import plugins as pl
|
|
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
|
track = _midi_track("__no_such_plugin__")
|
|
track["fx_chain"] = [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}]
|
|
from app.core.render_engine import _probe_track_latency
|
|
assert _probe_track_latency(track, 44100) == 0
|