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:
+199
-34
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user