diff --git a/app/core/native_render.py b/app/core/native_render.py index d0a413d..0fdf17e 100644 --- a/app/core/native_render.py +++ b/app/core/native_render.py @@ -126,6 +126,65 @@ def _preset_b64(preset_id, preset_path, preset_data_b64) -> str: return "" +def _run_notes_job(job: dict, label: str) -> tuple: + """Chạy job MIDI render qua native_bridge --render → (out_path, duration_sec). + + Dùng chung cho VSTi (render_offline) + SoundFont (render_soundfont_offline). + Lỗi → HTTPException (501 thiếu bridge, 500 lỗi render — file rác bị xóa). + """ + exe = find_bridge_exe() + if not exe: + raise HTTPException( + status_code=501, + detail="native_bridge không khả dụng — đặt SF_BRIDGE_PATH hoặc cài daw_vst_bridge", + ) + os.makedirs(settings.PROCESSED_DIR, exist_ok=True) + out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav") + job_path = out_path + ".job.json" + try: + with open(job_path, "w", encoding="utf-8") as fh: + json.dump(job, fh) + r = subprocess.run([exe, "--render", job_path, "--out", out_path], + capture_output=True, text=True, timeout=120, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) + log = ((r.stdout or "") + (r.stderr or "")).strip() + if r.returncode != 0 or not os.path.isfile(out_path): + if os.path.exists(out_path): + try: + os.remove(out_path) + except Exception: + pass + if r.returncode == 2: + raise HTTPException(status_code=500, + detail=f"Bridge không load được plugin {label}. {log[-500:]}") + raise HTTPException(status_code=500, + detail=f"Render thất bại (rc={r.returncode}). {log[-500:]}") + normalize_peak_file(out_path) + return out_path, sf.info(out_path).frames / float(job.get("sample_rate", 44100)) + except HTTPException: + raise + except subprocess.TimeoutExpired: + if os.path.exists(out_path): + try: + os.remove(out_path) + except Exception: + pass + raise HTTPException(status_code=500, detail="Render quá lâu (timeout 120s)") + except Exception as e: + if os.path.exists(out_path): + try: + os.remove(out_path) + except Exception: + pass + raise HTTPException(status_code=500, detail=f"Render thất bại: {e}") + finally: + if os.path.exists(job_path): + try: + os.remove(job_path) + except Exception: + pass + + def render_offline(instrument_id, notes, bpm, sample_rate, preset_id=None, preset_path=None, preset_data_b64=None, soundfont_bank=None, soundfont_program=None) -> tuple: @@ -141,12 +200,6 @@ def render_offline(instrument_id, notes, bpm, sample_rate, preset_id=None, detail=f"Không tìm thấy VSTi: {instrument_id} — chưa scan thấy plugin này. " "Kiểm tra Plugins Manager → Scan.", ) - exe = find_bridge_exe() - if not exe: - raise HTTPException( - status_code=501, - detail="native_bridge không khả dụng — đặt SF_BRIDGE_PATH hoặc cài daw_vst_bridge", - ) job = { "instrument_type": itype, "plugin_path": path, @@ -169,51 +222,31 @@ def render_offline(instrument_id, notes, bpm, sample_rate, preset_id=None, job["soundfont_bank"] = int(soundfont_bank) & 0x7F if soundfont_program is not None: job["soundfont_program"] = int(soundfont_program) & 0x7F - os.makedirs(settings.PROCESSED_DIR, exist_ok=True) - out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav") - job_path = out_path + ".job.json" - try: - with open(job_path, "w", encoding="utf-8") as fh: - json.dump(job, fh) - r = subprocess.run([exe, "--render", job_path, "--out", out_path], - capture_output=True, text=True, timeout=120, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) - log = ((r.stdout or "") + (r.stderr or "")).strip() - if r.returncode != 0 or not os.path.isfile(out_path): - if os.path.exists(out_path): - try: - os.remove(out_path) - except Exception: - pass - if r.returncode == 2: - raise HTTPException(status_code=500, - detail=f"Bridge không load được plugin {instrument_id}. {log[-500:]}") - raise HTTPException(status_code=500, - detail=f"Render thất bại (rc={r.returncode}). {log[-500:]}") - normalize_peak_file(out_path) - return out_path, sf.info(out_path).frames / float(sample_rate) - except HTTPException: - raise - except subprocess.TimeoutExpired: - if os.path.exists(out_path): - try: - os.remove(out_path) - except Exception: - pass - raise HTTPException(status_code=500, detail="Render quá lâu (timeout 120s)") - except Exception as e: - if os.path.exists(out_path): - try: - os.remove(out_path) - except Exception: - pass - raise HTTPException(status_code=500, detail=f"Render thất bại: {e}") - finally: - if os.path.exists(job_path): - try: - os.remove(job_path) - except Exception: - pass + return _run_notes_job(job, instrument_id) + + +def render_soundfont_offline(sf_path, notes, bpm, sample_rate, bank=0, program=0) -> tuple: + """Render MIDI notes bằng SoundFont (.sf2/.sf3) qua native_bridge --render. + + Thay pyfluidsynth khi thiếu FluidSynth lib: bridge nhúng FluidSynth C API + (NativeInstrumentEngine.cpp, InstrumentType::SOUNDFONT_SF2_SF3) — cùng job + shape với render_offline nhưng instrument_type=2 + plugin_path là file SF2. + Lỗi → HTTPException (501 thiếu bridge, 500 lỗi render).""" + job = { + "instrument_type": 2, + "plugin_path": os.fspath(sf_path), + "sample_rate": int(sample_rate), + "bpm": float(bpm), + "soundfont_bank": int(bank) & 0x7F, + "soundfont_program": int(program) & 0x7F, + "notes": [{ + "pitch": int(n.get("pitch", 60)), + "velocity": float(n.get("velocity", 0.8)), # bridge job: float 0..1 (RenderJob.cpp *127) + "start_beat": float(n.get("start_beat", 0)), + "duration_beats": float(n.get("duration_beats", 1)), + } for n in notes], + } + return _run_notes_job(job, os.fspath(sf_path)) def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None): diff --git a/app/core/render_engine.py b/app/core/render_engine.py index 8521e4b..26f6717 100644 --- a/app/core/render_engine.py +++ b/app/core/render_engine.py @@ -603,11 +603,11 @@ class PythonRenderEngine: ) elif soundfont_id or (instrument_id and instrument_id.startswith("sf_")): sf_path = _find_sf2_path(soundfont_id or instrument_id) - # 3-level fallback: selected SF → default SF → oscillator synth - if not sf_path or not os.path.exists(sf_path) or not HAS_PYFLUIDSYNTH: - if not sf_path or not os.path.exists(sf_path): - logger.warning(f"[RenderEngine] SoundFont not found for {soundfont_id or instrument_id}, trying default") - sf_path = _find_default_sf2() if HAS_PYFLUIDSYNTH else "" + # 4-level fallback: selected SF → default SF → native bridge → oscillator synth. + # Gap 12: pyfluidsynth thiếu FluidSynth lib → bridge render SF2 thật thay vì oscillator. + if not sf_path or not os.path.exists(sf_path): + logger.warning(f"[RenderEngine] SoundFont not found for {soundfont_id or instrument_id}, trying default") + sf_path = _find_default_sf2() if sf_path and os.path.exists(sf_path) and HAS_PYFLUIDSYNTH: import fluidsynth as _fs @@ -651,9 +651,39 @@ class PythonRenderEngine: _cursor = end_s synth_buffer = midi_data _fs.delete_fluid_synth(_fl) + elif sf_path and os.path.exists(sf_path) and settings.RENDER_ENGINE == "bridge": + # Gap 12: native bridge nhúng FluidSynth C API — render SF2/SF3 thật + # khi pyfluidsynth thiếu (RENDER_ENGINE=bridge mặc định). + try: + from app.core import native_render + out_path, _dur = native_render.render_soundfont_offline( + sf_path=sf_path, + notes=[{ + "pitch": ev["note"], + "velocity": float(ev["velocity"]) / 127.0, + "start_beat": ev["start_beat"], + "duration_beats": ev["duration_beats"], + } for ev in midi_events], + bpm=bpm, + sample_rate=self.sample_rate, + bank=soundfont_bank, + program=soundfont_program, + ) + synth_buffer, _ = sf.read(out_path, dtype="float32") + if synth_buffer.ndim == 1: + synth_buffer = np.vstack([synth_buffer, synth_buffer]) + else: + synth_buffer = synth_buffer.T + try: + os.remove(out_path) + except Exception: + pass + except Exception as e: + logger.warning("[RenderEngine] native soundfont render failed: %s", e) + synth_buffer = render_midi_events_to_audio( + midi_events=midi_events, sr=self.sample_rate, bpm=bpm, instrument='synth' + ) else: - if not HAS_PYFLUIDSYNTH: - logger.warning("[RenderEngine] pyfluidsynth not available, falling back to oscillator synth") synth_buffer = render_midi_events_to_audio( midi_events=midi_events, sr=self.sample_rate, bpm=bpm, instrument='synth' ) diff --git a/tests/test_daw_phase5.py b/tests/test_daw_phase5.py index e2a1ec6..6327061 100644 --- a/tests/test_daw_phase5.py +++ b/tests/test_daw_phase5.py @@ -32,6 +32,8 @@ FX_BRIDGE = os.path.join(REPO, "native_bridge", "build", "Release", "fx_vst_brid 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 +REAL_SF2 = os.path.join(REPO, "app", "storage", "soundfonts", "518e850f-a5d3-4790-b1f9-0c90c203c524.sf2") + @pytest.fixture(scope="module") @@ -741,3 +743,66 @@ def test_63_fxrt_ws_latency_push(monkeypatch): assert d.get("cmd") == "latency", d assert d.get("total") == 1024, d assert d.get("latencies", {}).get("0") == 1024, d + +# ── 5.10 Gap 12: SoundFont thật qua native bridge (thay pyfluidsynth) ───── +def test_64_soundfont_render_via_bridge(monkeypatch, tmp_path): + """Gap 12: pyfluidsynth thiếu FluidSynth lib → native bridge render SF2 thật + (bridge nhúng FluidSynth C API, NativeInstrumentEngine.cpp). + + (1) render_soundfont_offline trực tiếp: non-silent, đúng duration, program + khác → audio khác (program_select áp). + (2) render_project với HAS_PYFLUIDSYNTH=False: track MIDI soundfont ra âm + thật — không fallback oscillator synth.""" + if not os.path.isfile(DAW_BRIDGE): + pytest.skip("daw_vst_bridge.exe chưa build (native_bridge/build/Release)") + if not os.path.isfile(REAL_SF2): + pytest.skip("không có SF2 mẫu trong app/storage/soundfonts") + monkeypatch.setenv("SF_BRIDGE_PATH", DAW_BRIDGE) + + from app.core import native_render + notes = [{"pitch": 60, "velocity": 0.8, "start_beat": 0.0, "duration_beats": 2.0}] + out0, dur0 = native_render.render_soundfont_offline(REAL_SF2, notes, 120.0, SR, bank=0, program=0) + try: + d0, sr0 = _read_wav(out0) + assert sr0 == SR, f"sr {sr0}" + assert d0.shape[1] >= 44000, f"duration ngắn: {d0.shape[1]} (2 beats @120 = 44100)" + assert float(np.max(np.abs(d0))) > 0.01, "SF2 render im lặng" + finally: + os.remove(out0) + out40, _ = native_render.render_soundfont_offline(REAL_SF2, notes, 120.0, SR, bank=0, program=40) + try: + d40, _ = _read_wav(out40) + n = min(d0.shape[1], d40.shape[1]) + assert float(np.max(np.abs(d40[:, :n] - d0[:, :n]))) > 1e-3, "program_select không áp — program 0 == 40" + finally: + os.remove(out40) + + # (2) engine-level: bắt buộc branch bridge (pyfluidsynth vắng mặt) + import app.core.vst_engine as ve + import app.core.render_engine as re_mod + monkeypatch.setattr(ve, "HAS_PYFLUIDSYNTH", False) + monkeypatch.setattr(re_mod, "HAS_PYFLUIDSYNTH", False) + monkeypatch.setattr(re_mod, "_find_sf2_path", lambda sf_id: REAL_SF2) + from app.api.v1 import plugins as pl + monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge") + from app.core.render_engine import PythonRenderEngine + engine = PythonRenderEngine() + + track = _midi_track() + track["instrument_id"] = "" + track["instrument_source"] = "soundfont" + track["soundfont_id"] = "518e850f-a5d3-4790-b1f9-0c90c203c524" + track["volume_db"] = -12.0 + project = { + "metadata": {"bpm": 120.0, "time_signature_numerator": 4}, + "main_session": { + "length_bars": 1.0, + "tracks": [track], + "master": {"fx_chain": [], "bypass": False, "volume_db": 0.0}, + }, + "section_store": {}, + } + out = str(tmp_path / "sf2_engine.wav") + engine.render_project(project, out) + data, _ = _read_wav(out) + assert float(np.max(np.abs(data))) > 0.01, "engine render soundfont im lặng — fallback oscillator?"