Gap 12: soundfont render qua native bridge — render_soundfont_offline (FluidSynth C API) thay pyfluidsynth khi thiếu lib; render_engine soundfont branch fallback bridge (test_64, 176 passed)
This commit is contained in:
+84
-51
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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?"
|
||||
|
||||
Reference in New Issue
Block a user