diff --git a/PLAN_MASTERBUS_FX_RACK_VST.md b/PLAN_MASTERBUS_FX_RACK_VST.md index 2558be0..c227b9c 100644 --- a/PLAN_MASTERBUS_FX_RACK_VST.md +++ b/PLAN_MASTERBUS_FX_RACK_VST.md @@ -1,7 +1,7 @@ # PLAN: Masterbus + FX Rack hỗ trợ chèn VST FX (Ozone, Scaler) — xử lý âm thanh Ngày: 2026-08-15 -Trạng thái: ĐỀ XUẤT — chưa code. +Trạng thái: HOÀN THÀNH Phase 0-4 (2026-08-18) — code + test pass. Kế thừa nền: `PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md` HOÀN THÀNH (Phase 0-5, commit `dc331f3`) — pedalboard GPL-3.0 đã gỡ, `RENDER_ENGINE` mặc định `"bridge"`, `native_bridge` (MIT) render VSTi offline qua @@ -156,13 +156,13 @@ như audio-FX bọc ngoài (unlikely, cần confirm). ## 4. Phases -| Phase | Nội dung | Deliverable | -|---|---|---| -| 0 | Bridge `--render-fx`: WAV reader, NativeFxEngine VST3 effect (audio-in/out), builtin normalize/gain, SEH guard | CLI render FX demo: WAV in → WAV out, test với Ozone | -| 1 | Bridge `--scan`: phân loại instrument/effect; Python `native_render.render_fx_chain` + `vst_engine.list_fx` | `/api/v1/plugins/fx` + `/api/v1/plugins/fx-render` | -| 2 | Masterbus: session key `master`, render_project chạy chain, backward compatible | Export session có mastering VST | -| 3 | FX Rack per-track: track `fx_chain`, render_engine integrate; UI panel (slot/bypass/order/preset upload) | Track VST FX + UI | -| 4 | Realtime preview quick_render cho slot VST; gỡ/làm rõ tham chiếu doc thiếu (`mastering_expand.md`) | Preview thật; doc sạch | +| Phase | Nội dung | Deliverable | Trạng thái | +|---|---|---|---| +| 0 | Bridge `--render-fx`: WAV reader, NativeFxEngine VST3 effect (audio-in/out), builtin normalize/gain, SEH guard | CLI render FX demo: WAV in → WAV out, test với Ozone | ✅ test `/c/tmp/test_fx_phase0.py` 7/7 (gain0 bit-exact, -6dB RMS/2, normalize, Ozone 11 Maximizer khác input 88172 samples, scan 86 plugins); banner stdout→stderr `45a7bde` | +| 1 | Bridge `--scan`: phân loại instrument/effect; Python `native_render.render_fx_chain` + `vst_engine.list_fx` | `/api/v1/plugins/fx` + `/api/v1/plugins/fx-render` | ✅ code sẵn + tests mới `test_render_fx_chain_builtin_gain_e2e`, `test_fx_render_builtin_gain` | +| 2 | Masterbus: session key `master`, render_project chạy chain, backward compatible | Export session có mastering VST | ✅ `render_project` (render_engine.py) + `test_render_project_master_fx_chain_gain_via_bridge` | +| 3 | FX Rack per-track: track `fx_chain`, render_engine integrate; UI panel (slot/bypass/order/preset upload) | Track VST FX + UI | ✅ backend `vst_fx_chain` + UI FX Rack (`__openFxRack`, add/change/bypass/preset upload) + `test_render_track_fx_chain_gain_via_bridge` | +| 4 | Realtime preview quick_render cho slot VST; gỡ/làm rõ tham chiếu doc thiếu (`mastering_expand.md`) | Preview thật; doc sạch | ✅ "Nghe thử" = `__renderProjectPreview` → renderProject offline (âm thật = âm export); `md/mastering_expand.md` + `md/unified_fx_rack_panel.md` TỒN TẠI (không cần gỡ) | Mỗi phase kết thúc = commit + test pass. Phase 0-1 có thể merge (cùng chạm bridge). diff --git a/tests/test_native_render.py b/tests/test_native_render.py index 88e3574..8da2bf5 100644 --- a/tests/test_native_render.py +++ b/tests/test_native_render.py @@ -66,3 +66,30 @@ def test_plugins_bridge_branch_dispatch(monkeypatch): with pytest.raises(HTTPException) as ei: pl._render_midi_notes_pedalboard("__no_such__", [], bpm=120.0, sample_rate=44100) assert ei.value.status_code == 404 +def test_render_fx_chain_builtin_gain_e2e(): + """render_fx_chain (PLAN_MASTERBUS_FX_RACK_VST Phase 1): builtin gain + +6dB qua bridge --render-fx, không cần VST. Output tồn tại, đúng sr, + RMS gấp đôi input.""" + import math + import numpy as np + import soundfile as sf + exe = native_render.find_bridge_exe() + if not exe: + pytest.skip("bridge exe missing") + sr = 44100 + t = np.linspace(0, 1.0, sr, endpoint=False) + data = (0.1 * np.sin(2 * np.pi * 440 * t)).astype(np.float32) + with tempfile.TemporaryDirectory() as td: + inp = os.path.join(td, "in.wav") + sf.write(inp, data, sr, subtype="FLOAT") + out = native_render.render_fx_chain( + input_wav=inp, + fx_chain=[{"type": "builtin", "id": "gain", "params": {"db": 6.0}}], + sample_rate=sr, + ) + assert os.path.isfile(out) + d, _ = sf.read(out, dtype="float32", always_2d=True) + rms = float(np.sqrt(np.mean(d ** 2))) + assert rms > 0.09 # ~2x 0.05 + info = sf.info(out) + assert info.samplerate == sr diff --git a/tests/test_plugin_api.py b/tests/test_plugin_api.py index 9a32697..dd300e4 100644 --- a/tests/test_plugin_api.py +++ b/tests/test_plugin_api.py @@ -30,6 +30,47 @@ def get_admin_token(): class TestPluginAPI: + def test_fx_render_builtin_gain(self): + """POST /api/v1/plugins/fx-render với builtin gain (bridge --render-fx), + không cần VST — output WAV mới trong PROCESSED_DIR.""" + import numpy as np + import soundfile as sf + from app.core import native_render + from app.config import settings + exe = native_render.find_bridge_exe() + if not exe: + pytest.skip("bridge exe missing") + token = get_admin_token() + if not token: + pytest.skip("no admin token") + h = {"Authorization": f"Bearer {token}"} + sr = 44100 + t = np.linspace(0, 0.5, int(sr * 0.5), endpoint=False) + data = (0.05 * np.sin(2 * np.pi * 440 * t)).astype(np.float32) + os.makedirs(settings.PROCESSED_DIR, exist_ok=True) + inp = os.path.join(settings.PROCESSED_DIR, "fx_api_in_test.wav") + sf.write(inp, data, sr, subtype="FLOAT") + try: + resp = client.post("/api/v1/plugins/fx-render", headers=h, json={ + "input_file_id": "fx_api_in_test.wav", + "fx_chain": [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}], + "sample_rate": sr, + }) + assert resp.status_code == 200, resp.text[-500:] + body = resp.json() + assert body["success"] is True + assert os.path.isfile(body["path"]) + d, _ = sf.read(body["path"], dtype="float32", always_2d=True) + assert float(np.max(np.abs(d))) > 0.07 # ~2x 0.05 + finally: + client.cookies.clear() # không làm hỏng test requires_auth kế tiếp + for f in (inp, os.path.join(settings.PROCESSED_DIR, "fx_api_out_test.wav")): + if os.path.exists(f): + try: + os.remove(f) + except Exception: + pass + def test_list_plugins_requires_auth(self): resp = client.get("/api/v1/plugins/available") assert resp.status_code in (401, 403)