FIX: 11 bugs bảo mật/ổn định (static mount chặn dotfile, delete traversal, cleanup giữ clips serverFileId, upload whitelist, password strength, auth audio endpoints, pedalboard==0.9.19, vendor CDN local) + FEATURE: Carla bridge preview/export MIDI notes âm VSTi (POST /midi-render, /carla-play-notes, nút Preview VSTi/Export MIDI->Audio; pedalboard 0.9.19 raw MIDI bytes; SONICFORGE_STORAGE_DIR cô lập test storage)

This commit is contained in:
2026-08-10 18:46:54 +07:00
parent 67ad2b8e4b
commit 61cb4b846f
33 changed files with 1097 additions and 103 deletions
+199 -34
View File
@@ -1,4 +1,4 @@
import os, sys, uuid, json, tempfile, subprocess, time as _time
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
@@ -843,47 +843,23 @@ async def preview_instrument(req: PreviewRequest):
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để preview")
try:
pm = PluginManager()
vst = pm.load_vst(req.instrument_id)
if vst is None:
raise HTTPException(status_code=404, detail=f"Không tìm thấy VSTi: {req.instrument_id}")
apply_preset_to_plugin(
vst,
out_path, duration_sec = _render_midi_notes_pedalboard(
instrument_id=req.instrument_id,
notes=req.notes,
bpm=req.bpm,
sample_rate=req.sample_rate,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
soundfont_bank=req.soundfont_bank,
soundfont_program=req.soundfont_program,
)
from pedalboard import Pedalboard
midi_events = []
for n in req.notes:
midi_events.append({
"note": int(n.get("pitch", 60)),
"start_beat": float(n.get("start_beat", 0)),
"duration_beats": float(n.get("duration_beats", 1)),
"velocity": int(float(n.get("velocity", 0.8)) * 127),
})
midi_messages = PluginManager.midi_events_to_messages(
midi_events, req.bpm, req.sample_rate,
bank=req.soundfont_bank, program=req.soundfont_program,
)
total_needed = 0
beat_sec = 60.0 / max(30.0, req.bpm)
for ev in midi_events:
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
if int(end_sec * req.sample_rate) > total_needed:
total_needed = int(end_sec * req.sample_rate)
total_needed = max(total_needed, 1024)
silent = np.zeros((2, total_needed), dtype=np.float32)
board = Pedalboard([vst])
buf = board(silent, sample_rate=req.sample_rate, midi_messages=midi_messages)
fname = f"preview_{uuid.uuid4().hex[:10]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, fname)
sf.write(out_path, buf.T, req.sample_rate)
fname = os.path.basename(out_path)
return {
"success": True,
"url": f"/static/audio/processed/{fname}",
"path": out_path,
"duration_sec": round(buf.shape[1] / float(req.sample_rate), 3),
"duration_sec": round(duration_sec, 3),
}
except HTTPException:
raise
@@ -891,6 +867,195 @@ async def preview_instrument(req: PreviewRequest):
raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}")
class MidiRenderRequest(BaseModel):
"""Render MIDI notes → audio qua VSTi (âm thật, cùng code path với export).
instrument_id = plugin_id của track synth_engine (khớp key scan của
PluginManager). Đây là cầu nối Carla → pedalboard: preset chỉnh trong
Carla (.vstpreset) được áp vào pedalboard trước khi render."""
instrument_id: str
notes: list = []
bpm: float = 120.0
sample_rate: int = 44100
preset_id: Optional[str] = None
preset_path: Optional[str] = None
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
@router.post("/midi-render")
async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_current_user)):
"""Export MIDI notes → WAV với âm của VSTi instrument (lưu vào processed).
Preview/export MIDI notes với âm VSTi trước đây KHÔNG thực hiện được:
- preview realtime chỉ phát qua loa Carla (không vào audio graph DAW)
- clientSideExport chỉ render soundfont (midiCache = FluidSynth WASM)
- /plugins/preview có sẵn nhưng frontend không gọi
Endpoint này render offline bằng pedalboard (đúng plugin + preset như
export) → trả file_id để UI preview / gán clip vào project / download."""
enforce_password_changed(current_user)
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để render")
user_id = current_user["user_id"]
out_name = f"user_{user_id}_midi_{uuid.uuid4().hex[:10]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, out_name)
try:
out_path, duration_sec = _render_midi_notes_pedalboard(
instrument_id=req.instrument_id,
notes=req.notes,
bpm=req.bpm,
sample_rate=req.sample_rate,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
)
return {
"success": True,
"file_id": os.path.basename(out_path),
"url": f"/static/audio/processed/{os.path.basename(out_path)}",
"path": out_path,
"duration_sec": round(duration_sec, 3),
"render_mode": "pedalboard",
}
except HTTPException:
# Không để lại file rác nếu thất bại giữa chừng
try:
if os.path.exists(out_path):
os.remove(out_path)
except Exception:
pass
raise
except Exception as e:
try:
if os.path.exists(out_path):
os.remove(out_path)
except Exception:
pass
raise HTTPException(
status_code=500,
detail=f"Render MIDI thất bại: {e}. Nếu plugin là VST2 hoặc pedalboard "
"không load được, hãy mở Carla Bridge (chọn VSTi, chỉnh âm, "
"Save preset .vstpreset) rồi Upload preset vào track — render "
"sẽ dùng đúng âm đã chỉnh.",
)
def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
sample_rate: int, preset_id=None, preset_path=None,
preset_data_b64=None, soundfont_bank=None,
soundfont_program=None) -> tuple:
"""Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR.
Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
if not HAS_PEDALBOARD:
raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
pm = PluginManager()
vst = pm.load_vst(instrument_id)
if vst is None:
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, hoặc mở Carla để load VST2 "
"(pedalboard chỉ render được VST3).",
)
apply_preset_to_plugin(
vst,
preset_id=preset_id,
preset_path=preset_path,
preset_data_b64=preset_data_b64,
)
from pedalboard import Pedalboard
midi_events = []
for n in notes:
midi_events.append({
"note": int(n.get("pitch", 60)),
"start_beat": float(n.get("start_beat", 0)),
"duration_beats": float(n.get("duration_beats", 1)),
"velocity": int(float(n.get("velocity", 0.8)) * 127),
})
midi_messages = PluginManager.midi_events_to_messages(
midi_events, bpm, sample_rate,
bank=soundfont_bank, program=soundfont_program,
)
total_needed = 0
beat_sec = 60.0 / max(30.0, bpm)
for ev in midi_events:
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
if int(end_sec * sample_rate) > total_needed:
total_needed = int(end_sec * sample_rate)
total_needed = max(total_needed, 1024)
# pedalboard >= 0.9: Pedalboard container KHÔNG chứa instrument — gọi
# thẳng overload MIDI của plugin (overload 2: midi_messages + duration).
buf = vst(midi_messages, sample_rate=sample_rate,
duration=total_needed / float(sample_rate), num_channels=2)
out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav")
sf.write(out_path, buf.T, sample_rate)
return out_path, buf.shape[1] / float(sample_rate)
class CarlaPlayNotesRequest(BaseModel):
"""Phát dãy MIDI notes qua Carla bridge (OSC, realtime) — preview khi
pedalboard không render được plugin (VD VST2). Cần Carla đang chạy với
plugin đã load (open-in-carla)."""
notes: list = []
bpm: float = 120.0
channel: Optional[int] = 0
@router.post("/carla-play-notes")
async def carla_play_notes(req: CarlaPlayNotesRequest, current_user: dict = Depends(get_current_user)):
"""Phát toàn bộ dãy MIDI notes vào Carla (note_on/note_off đúng thời điểm).
Preview realtime qua đúng VSTi đang mở trong Carla — dùng khi pedalboard
không load được plugin (VST2, plugin cần state GUI). Âm phát ra loa hệ
thống (Carla), KHÔNG thu vào file — muốn file audio dùng /midi-render."""
enforce_password_changed(current_user)
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để phát")
if not find_carla_local():
raise HTTPException(status_code=409, detail="Carla chưa được định vị (Plugin Manager → Carla Bridge → Định vị Carla...)")
if not _carla_bridge_running():
raise HTTPException(status_code=409, detail="Carla chưa mở. Hãy mở Carla Bridge (nút Synth → VSTi) trước khi preview qua Carla.")
beat_sec = 60.0 / max(30.0, req.bpm)
channel = int(req.channel or 0)
events = []
for n in req.notes:
pitch = int(n.get("pitch", 60))
vel = int(float(n.get("velocity", 0.8)) * 127)
start = float(n.get("start_beat", 0)) * beat_sec
dur = float(n.get("duration_beats", 1)) * beat_sec
events.append((start, "note_on", pitch, max(1, min(127, vel))))
events.append((start + dur, "note_off", pitch, 0))
events.sort(key=lambda e: (e[0], 0 if e[1] == "note_off" else 1))
duration_sec = max((e[0] for e in events), default=0) + 0.3
# Phát trong thread nền — endpoint trả ngay, không block tới hết bản nhạc
def _player():
import time as _t
t0 = _t.monotonic()
for ev_time, ev_type, pitch, vel in events:
wait = (t0 + ev_time) - _t.monotonic()
if wait > 0:
_t.sleep(wait)
_send_carla_osc(ev_type, pitch, vel, channel)
threading.Thread(target=_player, daemon=True).start()
return {"success": True, "mode": "carla_realtime", "duration_sec": round(duration_sec, 3), "events": len(events)}
def find_carla_local() -> str:
from app.core.runtime import find_carla as _fc
try:
return _fc() or ""
except Exception:
return ""
def _carla_bridge_running() -> bool:
try:
_prune_carla_processes()
return len(_CARLA_PROCESSES) > 0
except Exception:
return False
@router.post("/render")
async def render_project(
req: RenderRequest,