import os, sys, uuid, json, tempfile, subprocess, time as _time import numpy as np import soundfile as sf from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks from fastapi.responses import FileResponse from pydantic import BaseModel from typing import Optional, Any from app.config import settings from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH, apply_preset_to_plugin from app.core.render_engine import PythonRenderEngine from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_scanner import SoundFontAutoScanner from app.api.v1.auth import get_current_user, enforce_password_changed router = APIRouter() UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts") os.makedirs(UPLOAD_SF_DIR, exist_ok=True) SYSTEM_SF_DIR = settings.SOUNDFONT_DIR SYSTEM_VST_DIR = settings.VST_DIR # User dirs (Windows/macOS — người dùng chọn qua folder picker trong # Plugins Manager). File global (không per-user): desktop app 1 user. PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json") def _load_plugin_dirs() -> dict: if os.path.exists(PLUGIN_DIRS_FILE): try: with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {} def _save_plugin_dirs(dirs: dict): os.makedirs(settings.STORAGE_DIR, exist_ok=True) with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f: json.dump(dirs, f, indent=2) def _effective_dirs() -> dict: """Env/.env (Docker) là base; user dirs (file) override nếu khai báo. plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa khai báo → fallback env VST_DIR + SOUNDFONT_DIR. """ user = _load_plugin_dirs() plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d] vst_dir = settings.VST_DIR soundfont_dir = settings.SOUNDFONT_DIR # Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list. if not plugin_dirs: if user.get("vst_dir"): plugin_dirs.append(user["vst_dir"]) if user.get("soundfont_dir"): plugin_dirs.append(user["soundfont_dir"]) if not plugin_dirs: plugin_dirs = [vst_dir, soundfont_dir] return { "plugin_dirs": plugin_dirs, "vst_dir": vst_dir, "soundfont_dir": soundfont_dir, "plugin_dirs_user_set": bool(user.get("plugin_dirs")), } class DirsRequest(BaseModel): vst_dir: Optional[str] = None soundfont_dir: Optional[str] = None plugin_dirs: Optional[list] = None @router.get("/dirs") async def get_plugin_dirs(): return {"success": True, **(_effective_dirs())} @router.post("/dirs") async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)): enforce_password_changed(current_user) user = _load_plugin_dirs() if req.plugin_dirs is not None: user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()] # Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn user.pop("vst_dir", None) user.pop("soundfont_dir", None) else: if req.vst_dir is not None: user["vst_dir"] = req.vst_dir.strip() if req.soundfont_dir is not None: user["soundfont_dir"] = req.soundfont_dir.strip() _save_plugin_dirs(user) return {"success": True, **(_effective_dirs())} @router.post("/scan") async def scan_plugin_dirs(background_tasks: BackgroundTasks = None, current_user: dict = Depends(get_current_user)): """Scan các dir hiệu lực (env + user override): cập nhật catalog soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo.""" enforce_password_changed(current_user) dirs = _effective_dirs() plugin_dirs = dirs["plugin_dirs"] # SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực) scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR) if background_tasks: background_tasks.add_task(scanner.scan_once) else: scanner.scan_once() catalog = scanner.get_catalog() # VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension vst_found = [] sf_found = [] for d in plugin_dirs: if not os.path.isdir(d): continue for root, dirs, files in os.walk(d): # Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong) for sub in list(dirs): if sub.lower().endswith(".vst3"): vst_found.append({"name": os.path.splitext(sub)[0], "path": os.path.join(root, sub), "dir": d, "type": "VST3"}) for f in files: low = f.lower() if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"): vst_found.append({"name": os.path.splitext(f)[0], "path": os.path.join(root, f), "dir": d, "type": "VST3" if low.endswith(".vst3") else "VST2"}) elif low.endswith(".sf2") or low.endswith(".sf3"): sf_found.append({"name": os.path.splitext(f)[0], "path": os.path.join(root, f), "dir": d}) return { "success": True, "plugin_dirs": plugin_dirs, "vst_found": vst_found, "vst_count": len(vst_found), "soundfonts": sf_found, "soundfont_count": len(sf_found), } _inspector = None _scanner = None def get_inspector(): global _inspector if _inspector is None: d = _effective_dirs() _inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR, upload_sf_dir=UPLOAD_SF_DIR) return _inspector def get_scanner(): global _scanner if _scanner is None: d = _effective_dirs() _scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR) _scanner.scan_once() return _scanner @router.get("/available") async def list_plugins(current_user: dict = Depends(get_current_user)): d = _effective_dirs() pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR) avail = pm.list_available() # Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir # env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà # Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng). extra = _scan_vst_in_dirs(d["plugin_dirs"]) by_id = {v["id"]: v for v in avail["vst_instruments"]} for v in extra: by_id.setdefault(v["id"], v) avail["vst_instruments"] = list(by_id.values()) return avail def _scan_vst_in_dirs(dirs: list) -> list: """Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan: file .vst3/.dll/.so + FOLDER tên X.vst3 (Windows VST3 = folder chứa X.vst3.dll bên trong).""" found = {} for d in dirs: if not d or not os.path.isdir(d): continue for root, dirs, files in os.walk(d): # Windows: VST3 là FOLDER tên X.vst3 for sub in list(dirs): if sub.lower().endswith(".vst3"): name = os.path.splitext(sub)[0] if name not in found: found[name] = { "id": name, "name": name, "type": "VST3", "path": os.path.join(root, sub), } for f in files: low = f.lower() if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"): name = os.path.splitext(f)[0] if name not in found: found[name] = { "id": name, "name": name, "type": "VST3" if low.endswith(".vst3") else "VST2", "path": os.path.join(root, f), } return list(found.values()) # ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải # nhập tay) ───────────────────────────────────────────────────────────── PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu def _pick_dir_via_tauri_bridge() -> Optional[str]: """Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog / Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog → ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone).""" root = os.environ.get("APPDATA") or os.path.expanduser("~") ipc = os.path.join(root, "SonicForgeDAW", "ipc") if not os.path.isdir(ipc): return None # Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")): return None req = os.path.join(ipc, "pick_dir.request") resp = os.path.join(ipc, "pick_dir.response") try: for f in (req, resp): if os.path.exists(f): os.remove(f) with open(req, "w", encoding="utf-8") as fh: fh.write("1") deadline = _time.time() + PICK_DIR_TIMEOUT while _time.time() < deadline: if os.path.exists(resp): try: with open(resp, "r", encoding="utf-8") as fh: val = fh.read().strip() finally: os.remove(resp) return val or None _time.sleep(0.1) except Exception: pass return None def _pick_dir_native_engine() -> Optional[str]: """Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog (Windows), osascript (macOS), zenity/kdialog (Linux).""" if os.name == "nt": ps = ( "Add-Type -AssemblyName System.Windows.Forms; " "$f = New-Object System.Windows.Forms.FolderBrowserDialog; " "$f.Description = 'Chọn thư mục chứa VST / SoundFont'; " "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }" ) try: r = subprocess.run( ["powershell", "-NoProfile", "-STA", "-Command", ps], capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT, ) return r.stdout.strip() or None except Exception: return None if sys.platform == "darwin": try: r = subprocess.run( ["osascript", "-e", 'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'], capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT, ) return r.stdout.strip() or None except Exception: return None for cmd in (["zenity", "--file-selection", "--directory"], ["kdialog", "--getexistingdirectory", os.path.expanduser("~")]): try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT) if r.returncode == 0: p = r.stdout.strip() if p: return p except Exception: continue return None @router.post("/pick-dir") def pick_plugin_directory(): """Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog). Trả {"path": ""} hoặc {"path": None} (hủy/không có dialog). Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop trong lúc user chọn thư mục (có thể mất phút).""" path = _pick_dir_via_tauri_bridge() if path is None: path = _pick_dir_native_engine() return {"path": path} @router.get("/default-soundfonts") async def list_default_soundfonts(): static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts") result = [] if os.path.isdir(static_sf_dir): for f in os.listdir(static_sf_dir): if f.endswith(".sf2") or f.endswith(".sf3"): result.append({ "id": os.path.splitext(f)[0], "name": f, "file": f, "url": f"/soundfonts/{f}" }) return result @router.get("/soundfonts/catalog") async def soundfont_catalog(current_user: dict = Depends(get_current_user)): scanner = get_scanner() full_catalog = scanner.get_catalog() inspector = get_inspector() condensed_catalog = inspector.get_condensed_catalog_summary(full_catalog) return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog} @router.get("/soundfont-instruments/{sf_id}") async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)): pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR) presets = pm.list_soundfont_instruments(sf_id) return {"presets": presets, "count": len(presets)} @router.post("/upload-soundfont") async def upload_soundfont( file: UploadFile = File(...), background_tasks: BackgroundTasks = None, current_user: dict = Depends(get_current_user) ): enforce_password_changed(current_user) if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))): raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed") # Stream upload in chunks with a hard size cap (SGM-class fonts can exceed # 500MB; reading the whole body into RAM would OOM the server). MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB contents = bytearray() while True: chunk = await file.read(1024 * 1024) if not chunk: break contents.extend(chunk) if len(contents) > MAX_SF_UPLOAD_BYTES: raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)") if not PluginManager.validate_sf2_header(bytes(contents[:4096])): raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header") file_ext = os.path.splitext(file.filename)[1] file_uuid = str(uuid.uuid4()) # Store original name in a sidecar file base_name = os.path.splitext(file.filename)[0].replace('/', '_').replace('\\', '_') file_id = file_uuid + file_ext dest_path = os.path.join(UPLOAD_SF_DIR, file_id) with open(dest_path, "wb") as f: f.write(contents) # Save metadata with original name meta_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".meta") with open(meta_path, "w", encoding="utf-8") as f: import json json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f) scanner = get_scanner() if background_tasks: background_tasks.add_task(scanner.scan_once) else: scanner.scan_once() return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)} @router.delete("/soundfont/{sf_id}") async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_user)): base_id = sf_id.replace("sf_", "") deleted = False for d in [UPLOAD_SF_DIR, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts"), SYSTEM_SF_DIR]: if not os.path.isdir(d): continue for f in os.listdir(d): if os.path.splitext(f)[0] == base_id: # Skip system dir — only allow deleting uploads if d == SYSTEM_SF_DIR: raise HTTPException(status_code=403, detail="System soundfonts cannot be deleted via this endpoint") path = os.path.join(d, f) os.remove(path) meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta") if os.path.isfile(meta_path): os.remove(meta_path) deleted = True break if deleted: break if not deleted: raise HTTPException(status_code=404, detail="SoundFont not found") get_scanner().scan_once() return {"deleted": True, "sf_id": sf_id} @router.get("/soundfonts/download/{sf_id}") async def download_soundfont_asset(sf_id: str): clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id # Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước # đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng) # không tải được font → instrument CÂM (browser thường dùng cache nên OK). static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts") for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]: if not os.path.isdir(base_dir): continue # Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis) # samples, so any SF3 would play silence in the browser. for fname in os.listdir(base_dir): fbase, fext = os.path.splitext(fname) if fext.lower() == ".sf2" and fbase.lower() == clean_id.lower(): full = os.path.join(base_dir, fname) return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf2") # Only an SF3 exists -> decompress it to a playable SF2 on demand (cached) for fname in os.listdir(base_dir): fbase, fext = os.path.splitext(fname) if fext.lower() == ".sf3" and fbase.lower() == clean_id.lower(): full = os.path.join(base_dir, fname) try: from app.core.soundfont_converter import SoundFontConverter sf2_path = os.path.join(UPLOAD_SF_DIR, clean_id + ".sf2") if os.path.exists(sf2_path) and os.path.getmtime(sf2_path) >= os.path.getmtime(full): return FileResponse(sf2_path, media_type="application/octet-stream", filename="soundfont.sf2") result = SoundFontConverter().sf3_to_sf2(full, sf2_path) if result != full and os.path.exists(result): return FileResponse(result, media_type="application/octet-stream", filename="soundfont.sf2") except Exception as e: print(f"[soundfont-download] SF3->SF2 conversion failed for {full}: {e}") return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf3") raise HTTPException(status_code=404, detail="SoundFont asset not found") class RenderRequest(BaseModel): project_json: dict output_filename: Optional[str] = "render_output.wav" class OpenInCarlaRequest(BaseModel): """Mở native GUI của VSTi trong Carla (chỉ khả dụng khi runtime=desktop và Carla được cài trên cùng máy — tự phát hiện qua runtime profile).""" plugin_name: Optional[str] = None plugin_path: Optional[str] = None class PreviewRequest(BaseModel): """Quick-render preview: pedalboard render clip ngắn bằng ĐÚNG plugin + preset (cùng code path với export) → trả wav để browser phát. Âm preview = âm export (khác Preview Synth WASM hiện tại).""" instrument_id: str notes: list = [] bpm: float = 120.0 sample_rate: int = 44100 soundfont_bank: Optional[int] = 0 soundfont_program: Optional[int] = 0 preset_id: Optional[str] = None preset_path: Optional[str] = None preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng) @router.post("/open-in-carla") async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(get_current_user)): """Mở Carla với VSTi đã chọn — TỰ ĐỘNG load plugin (native GUI + keyboard). Cơ chế: sinh file project .carxs (định dạng XML chính thức của Carla — `carla.exe [FILE]` nhận project file) chứa node VST3 ... → Carla mở lên là plugin đã load sẵn, kèm on-screen MIDI keyboard (PixmapKeyboard) để preview realtime. Carla là app ngoài do user tự giải nén (GPL-2.0+ → không bundle/nhúng); app chỉ spawn tiến trình + trao đổi file preset.""" enforce_password_changed(current_user) from app.core.runtime import find_carla carla = find_carla() if not carla: raise HTTPException( status_code=409, detail="Không tìm thấy Carla trên máy này. Hãy giải nén bản Carla " "portable (zip, miễn phí) từ https://github.com/falkTX/Carla/releases, " "rồi vào Plugin Manager → Carla Bridge → Định vị Carla... để chọn " "thư mục chứa carla.exe (bản portable không dùng PATH).", ) plugin_path = req.plugin_path or "" if not plugin_path and req.plugin_name: try: pm = PluginManager() plugins = pm._scan_plugins() if req.plugin_name in plugins: plugin_path = plugins[req.plugin_name] except Exception: plugin_path = "" carxs_path = "" if plugin_path: carxs_path = _write_carla_project(req.plugin_name or os.path.basename(plugin_path), plugin_path) cmd = [carla] if carxs_path: cmd.append(carxs_path) try: cwd = os.path.dirname(carla) or None subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt") return { "success": True, "started": True, "carla_path": carla, "plugin_path": plugin_path, "project_file": carxs_path, "cmd": cmd, } except Exception as e: raise HTTPException(status_code=500, detail=f"Không mở được Carla: {e}") def _write_carla_project(plugin_name: str, plugin_path: str) -> str: """Sinh file project .carxs cho Carla load sẵn VSTi (chỉ VST3 — VST2 cần uniqueID không đoán được → mở Carla trống để user tự Add Plugin). Định dạng theo source Carla (CarlaEngine::saveProjectInternal + CarlaStateSave::dumpToMemoryStream): root , VST3path Yes1 0x0.""" if not plugin_path or not os.path.exists(plugin_path): return "" low = plugin_path.lower() is_vst3 = low.endswith(".vst3") or low.endswith(".vst3/") or "\\" in plugin_path and plugin_path.rstrip("\\/").lower().endswith(".vst3") if not is_vst3: # VST2 (.dll/.so ngoài .vst3): không auto-load được tin cậy → Carla trống return "" from xml.sax.saxutils import escape name = escape(plugin_name or os.path.splitext(os.path.basename(plugin_path))[0]) binary = escape(plugin_path) xml = ( "\n" "\n" f"\n" " \n" " false\n" " false\n" " false\n" " false\n" " 100\n" " 10000\n" " \n" " \n" " \n" f" VST3\n" f" {name}\n" f" {binary}\n" f" \n" " \n" " \n" " Yes\n" " 1\n" " 0x0\n" " \n" " \n" "\n" ) try: proj_dir = os.path.join(settings.STORAGE_DIR, "carla_projects") os.makedirs(proj_dir, exist_ok=True) # Dọn project cũ (quá 1 ngày) — tránh rác try: now = _time.time() for f in os.listdir(proj_dir): fp = os.path.join(proj_dir, f) try: if os.path.isfile(fp) and now - os.path.getmtime(fp) > 86400: os.remove(fp) except Exception: pass except Exception: pass safe = "".join(c for c in plugin_name if c.isalnum() or c in " _-")[:40].strip() or "plugin" carxs = os.path.join(proj_dir, f"{safe}_{uuid.uuid4().hex[:8]}.carxs") with open(carxs, "w", encoding="utf-8") as fh: fh.write(xml) return carxs except Exception: return "" @router.post("/preview") async def preview_instrument(req: PreviewRequest): """Quick-render preview VSTi (âm thật, cùng code path với export).""" if not HAS_PEDALBOARD: raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này") 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, preset_id=req.preset_id, preset_path=req.preset_path, preset_data_b64=req.preset_data, ) 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) return { "success": True, "url": f"/static/audio/processed/{fname}", "path": out_path, "duration_sec": round(buf.shape[1] / float(req.sample_rate), 3), } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}") @router.post("/render") async def render_project( req: RenderRequest, current_user: dict = Depends(get_current_user) ): enforce_password_changed(current_user) engine = PythonRenderEngine() # Prevent path traversal: strip any directory components and force .wav. safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/")) if not safe_name.lower().endswith(".wav"): safe_name += ".wav" output_path = os.path.join(settings.PROCESSED_DIR, safe_name) try: result_path = engine.render_project(req.project_json, output_path) return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path} except Exception as e: raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")