# app/core/native_render.py """Render MIDI notes → WAV qua native_bridge CLI (daw_vst_bridge --render). Thay thế pedalboard (GPL-3.0) bằng bridge (MIT) cho preview/export offline: cùng chữ ký (out_path, duration_sec) nên plugins.py/render_engine thay trực tiếp. KHÔNG import pedalboard. Kích hoạt bằng SF_RENDER_ENGINE=bridge (settings.RENDER_ENGINE); mặc định bridge từ phase 5. """ import base64 import json import os import subprocess import sys import uuid import soundfile as sf from fastapi import HTTPException from app.config import settings BRIDGE_EXE = "daw_vst_bridge.exe" if os.name == "nt" else "daw_vst_bridge" FX_BRIDGE_EXE = "fx_vst_bridge.exe" if os.name == "nt" else "fx_vst_bridge" def find_bridge_exe(exe_name=BRIDGE_EXE, env_key="SF_BRIDGE_PATH") -> str: """Đường dẫn bridge exe: env → bundle (frozen) → src-tauri/binaries → install/ (dev).""" env = os.getenv(env_key, "").strip() if env and os.path.isfile(env): return env # Tauri externalBin renames sidecars → -x86_64-pc-windows-msvc.exe. sidecar = exe_name[:-4] + "-x86_64-pc-windows-msvc.exe" if exe_name.lower().endswith(".exe") else exe_name cands = [] if getattr(sys, "frozen", False): root = getattr(sys, "_MEIPASS", "") if root: cands += [os.path.join(root, "binaries", exe_name), os.path.join(root, "binaries", sidecar), os.path.join(root, exe_name), os.path.join(root, sidecar)] # PyInstaller onedir: daw_engine.exe nam /daw_engine/, bridge # sidecar nam canh app root (khong trong _MEIPASS) - tim ca exe_dir. exe_dir = os.path.dirname(os.path.abspath(sys.executable)) cands += [os.path.join(exe_dir, "binaries", exe_name), os.path.join(exe_dir, "binaries", sidecar), os.path.join(exe_dir, exe_name), os.path.join(exe_dir, sidecar), os.path.join(os.path.dirname(exe_dir), "binaries", exe_name), os.path.join(os.path.dirname(exe_dir), "binaries", sidecar), os.path.join(os.path.dirname(exe_dir), exe_name), os.path.join(os.path.dirname(exe_dir), sidecar)] # Canonical build output (build_native_bridge.ps1) + legacy install/ (dev). cands.append(os.path.join(settings.BASE_DIR, "src-tauri", "binaries", sidecar)) cands.append(os.path.join(settings.BASE_DIR, "install", exe_name)) for p in cands: if p and os.path.isfile(p): return p return "" def find_fx_bridge_exe() -> str: """Đường dẫn FX bridge (fx_vst_bridge.exe): SF_FX_BRIDGE_PATH → bundle → install/.""" return find_bridge_exe(FX_BRIDGE_EXE, "SF_FX_BRIDGE_PATH") def normalize_audio_peak(audio, target_db=0.0): """Scale audio (numpy array, peak) về target_db nếu vượt; giữ nguyên nếu dưới. Chống clip do note MIDI chồng (polyphony) — spec midi_polyphony_loudness §II: render offline nhiều note đồng thời → tổng biên độ vượt 0dBFS → WAV vỡ đỉnh (hard clip, méo harmonic) trước khi tới client; limiter master không cứu được. """ try: import numpy as np peak = float(np.max(np.abs(audio))) if audio.size else 0.0 limit = 10 ** (target_db / 20) if peak > limit and peak > 1e-9: return audio * (limit / peak) except Exception: pass return audio def normalize_peak_file(path, target_db=0.0): """Post-render WAV: scan peak, vượt target_db → scale toàn file (giữ format).""" try: info = sf.info(path) data, sr = sf.read(path, dtype="float32", always_2d=True) data = normalize_audio_peak(data, target_db) sf.write(path, data, sr, subtype=info.subtype) except Exception: pass def classify_instrument(path: str) -> int: """InstrumentType enum của bridge: 0=VST3, 1=VST2, 2=SF2/SF3, 3=SFZ.""" low = path.lower() if low.endswith(".vst3"): return 0 if low.endswith((".dll", ".so")): return 1 if low.endswith((".sf2", ".sf3")): return 2 if low.endswith(".sfz"): return 3 return 0 def _resolve_instrument(instrument_id: str): from app.core.vst_engine import PluginManager path = PluginManager()._scan_plugins().get(instrument_id) if not path: return None, None return classify_instrument(path), path def _preset_b64(preset_id, preset_path, preset_data_b64) -> str: """Base64 .vstpreset cho job: bytes nhúng ưu tiên, sau đó id/path file.""" if preset_data_b64: return preset_data_b64 from app.core.vst_engine import resolve_preset_path p = resolve_preset_path(preset_id or "") or resolve_preset_path(preset_path or "") if p: try: with open(p, "rb") as fh: return base64.b64encode(fh.read()).decode("ascii") except Exception: return "" return "" 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: """Render MIDI notes qua native_bridge --render → (out_path, duration_sec). Giữ response shape của _render_midi_notes_pedalboard. Lỗi → HTTPException (404 instrument lạ, 501 thiếu bridge, 500 lỗi render — file rác bị xóa). """ itype, path = _resolve_instrument(instrument_id) if not path: raise HTTPException( status_code=404, 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, "sample_rate": int(sample_rate), "bpm": float(bpm), "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], } if itype == 0: # preset chỉ áp dụng cho VST3 (loadSerializedState) preset = _preset_b64(preset_id, preset_path, preset_data_b64) if preset: job["preset"] = preset # bank/program (giữ parity với midi_events_to_messages cũ: key có mặt # kể cả giá trị 0 — bridge xử lý theo presence, không theo truthiness) if soundfont_bank is not 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 def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None): """Render WAV qua native_bridge --render-fx (VST3 FX + builtin gain/normalize). fx_chain: list dict {type: "vst3"|"builtin", path|id, preset_b64?, bypass?, params?}. path là plugin id/name → resolve_plugin_path (".vst3"). Trả (out_path, latencies) — latencies: per-slot samples (bridge FX_LATENCIES, Phase 3 PDC; [] nếu exe cũ không báo). Lỗi → HTTPException (501 thiếu bridge, 404 plugin lạ, 500 render). """ from app.core.vst_engine import resolve_plugin_path exe = find_fx_bridge_exe() if not exe: raise HTTPException( status_code=501, detail="native_bridge không khả dụng — đặt SF_FX_BRIDGE_PATH hoặc cài fx_vst_bridge", ) slots = [] for fx in fx_chain or []: if not isinstance(fx, dict) or fx.get("active") is False: continue typ = fx.get("type") if typ == "vst3": p = (fx.get("path") or "").strip() resolved = p if os.path.exists(p) else resolve_plugin_path(p, ".vst3") if not resolved: raise HTTPException( status_code=404, detail=f"Không tìm thấy VST3 FX: {p} — bấm Plugins Manager → Scan trước.", ) slots.append({"type": "vst3", "path": resolved, "preset_b64": fx.get("preset_b64", "") or "", "bypass": bool(fx.get("bypass"))}) elif typ in ("eq", "eqpro", "imager", "maximizer", "compressor", "limiter", "exciter", "rebalance", "multiband", "deesser"): # Builtin DSP — chain hợp nhất (spec PLAN_DAW_A.md Phase 1). C++ # RenderFxJob xử lý theo id/params (Phase 2); giữ NGUYÊN thứ tự. slots.append({"type": "builtin", "id": typ, "params": dict(fx.get("params") or {}), "bypass": bool(fx.get("bypass"))}) elif typ == "builtin": # Legacy: {type: builtin, id: gain} — giữ cho project cũ. slots.append({"type": "builtin", "id": fx.get("id", "gain"), "params": dict(fx.get("params") or {}), "bypass": bool(fx.get("bypass"))}) else: raise HTTPException(status_code=400, detail=f"fx_chain slot không hợp lệ: {typ}") if not slots: raise HTTPException(status_code=400, detail="fx_chain trống — không có FX để render") job = {"sample_rate": int(sample_rate), "block_size": 512, "fx_chain": slots} os.makedirs(settings.PROCESSED_DIR, exist_ok=True) out = out_path or os.path.join(settings.PROCESSED_DIR, f"fx_{uuid.uuid4().hex[:10]}.wav") job_path = out + ".job.json" try: with open(job_path, "w", encoding="utf-8") as fh: json.dump(job, fh) r = subprocess.run([exe, "--render-fx", job_path, "--in", input_wav, "--out", out], 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): if os.path.exists(out): try: os.remove(out) except Exception: pass if r.returncode == 2: raise HTTPException(status_code=500, detail=f"Bridge không load được plugin. {log[-500:]}") raise HTTPException(status_code=500, detail=f"FX render thất bại (rc={r.returncode}). {log[-500:]}") latencies = [] for _line in (r.stdout or "").splitlines(): if "FX_LATENCIES" in _line: try: latencies = [int(x) for x in _line.split("FX_LATENCIES", 1)[1].split()] except Exception: latencies = [] break return out, latencies except HTTPException: raise except subprocess.TimeoutExpired: if os.path.exists(out): try: os.remove(out) except Exception: pass raise HTTPException(status_code=500, detail="FX render quá lâu (timeout 120s)") except Exception as e: if os.path.exists(out): try: os.remove(out) except Exception: pass raise HTTPException(status_code=500, detail=f"FX render thất bại: {e}") finally: if os.path.exists(job_path): try: os.remove(job_path) except Exception: pass