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, Header 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, extra_vst_dirs=d["plugin_dirs"]) 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()) # Tương tự cho SOUNDFONT: gộp .sf2/.sf3 từ plugin_dirs user — nếu không, # nút Synth không liệt kê instrument của soundfont trong thư mục user thêm. sf_by_id = {s["id"]: s for s in avail["soundfonts"]} for s in _scan_soundfonts_in_dirs(d["plugin_dirs"]): sf_by_id.setdefault(s["id"], s) avail["soundfonts"] = list(sf_by_id.values()) return avail def _scan_soundfonts_in_dirs(dirs: list) -> list: """Walk các thư mục (plugin_dirs user) → danh sách soundfont .sf2/.sf3 (id = tên file; name từ .meta nếu có — cùng format PluginManager).""" found = {} for d in dirs: if not d or not os.path.isdir(d): continue for root, dirs2, files in os.walk(d): for f in files: low = f.lower() if not (low.endswith(".sf2") or low.endswith(".sf3")): continue base_id = os.path.splitext(f)[0] if base_id in found: continue meta = {} mp = os.path.join(root, os.path.splitext(f)[0] + ".meta") if os.path.isfile(mp): try: with open(mp, "r", encoding="utf-8") as mf: meta = json.load(mf) except Exception: meta = {} name = meta.get("original_name", f) if meta else f found[base_id] = { "id": base_id, "name": name, "file": f, "display": os.path.splitext(name)[0][:40], "source": "user", } # Không walk sâu thêm (soundfont thường đặt ngay trong thư mục khai báo) dirs2[:] = [] return list(found.values()) 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)): d = _effective_dirs() # extra_vst_dirs = plugin_dirs user (chứa cả VST lẫn SoundFont) → tìm sf2 ở đó pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"]) 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) class CarlaMidiRequest(BaseModel): """Gửi MIDI note từ track ARM → Carla (OSC /Carla/0/note_on|note_off). Carla standalone bật OSC UDP mặc định cổng 22752 (source: CarlaEngineOsc, CarlaEngineData oscPortUDP=22752; override: env CARLA_OSC_UDP_PORT của Carla, hoặc SF_CARLA_OSC_PORT / osc_port trong carla_path.json của app). Plugin đầu tiên trong project .carxs do app sinh có pluginId = 0.""" event: str # "note_on" | "note_off" note: int velocity: Optional[int] = 100 channel: Optional[int] = 0 @router.post("/carla-midi") async def carla_midi(req: CarlaMidiRequest): """MIDI keyboard (piano roll / keybed) → Carla để preview VSTi realtime.""" if req.event not in ("note_on", "note_off"): raise HTTPException(status_code=400, detail="event phải là note_on hoặc note_off") ok = _send_carla_osc(req.event, req.note, req.velocity if req.event == "note_on" else 0, req.channel) if not ok: raise HTTPException( status_code=502, detail="Không gửi được OSC tới Carla — Carla đã mở chưa? (cổng OSC UDP mặc định 22752; " "nếu đổi cổng trong Carla, đặt SF_CARLA_OSC_PORT hoặc osc_port trong carla_path.json)", ) return {"success": True, "event": req.event, "note": req.note, "channel": req.channel} def _carla_osc_port() -> int: """Cổng OSC UDP của Carla: SF_CARLA_OSC_PORT env → osc_port trong storage/carla_path.json → mặc định 22752 (CarlaEngineData).""" try: p = os.environ.get("SF_CARLA_OSC_PORT") if p: return int(p) except Exception: pass try: from app.core.runtime import _carla_config_path with open(_carla_config_path(), "r", encoding="utf-8") as f: data = json.load(f) p = data.get("osc_port") if p: return int(p) except Exception: pass return 22752 def _send_carla_osc(event: str, note: int, velocity: int, channel: int) -> bool: """Gửi OSC UDP tới `/Carla/0/{event}` (plugin đầu tiên = plugin auto-load). OSC message: path + typetag + int args, mỗi phần pad '\0' tới bội số 4. Đã xác minh từ source Carla: handleMsgNoteOn/NoteOff nhận `iii`/`ii` và tên client mặc định của app standalone là "Carla" (carla_host.py fClientName = CARLA_CLIENT_NAME or "Carla").""" try: import socket import struct port = _carla_osc_port() path = f"/Carla/0/{event}".encode("utf-8") typetag = b",iii" if event == "note_on" else b",ii" vals = [int(channel), int(note), int(velocity)] if event == "note_on" else [int(channel), int(note)] def _pad(b: bytes) -> bytes: rem = len(b) % 4 return b + b"\x00" * (4 - rem) if rem else b msg = _pad(path) + _pad(typetag) + b"".join(struct.pack(">i", v) for v in vals) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(0.5) s.sendto(msg, ("127.0.0.1", port)) s.close() return True except Exception: return False @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 proc = subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt") _register_carla_process(proc) 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}") # ── Carla bridge lifecycle ───────────────────────────────────────────────── # App spawn Carla (open_in_carla) và PHẢI có khả năng dừng nó khi track chuyển # sang instrument KHÔNG phải VST (soundfont/GM) — nếu không, Carla vẫn chạy và # MIDI vẫn vào VSTi cũ → âm sai instrument + âm kẹt không dừng được. _CARLA_PROCESSES = [] # list[subprocess.Popen] def _register_carla_process(proc): """Lưu handle tiến trình Carla do app spawn (để carla-stop terminate được).""" global _CARLA_PROCESSES _prune_carla_processes() _CARLA_PROCESSES.append(proc) def _prune_carla_processes(): """Bỏ các handle đã thoát (poll() trả code) — tránh list rác.""" global _CARLA_PROCESSES _CARLA_PROCESSES = [p for p in _CARLA_PROCESSES if p is not None and p.poll() is None] def _send_carla_all_notes_off() -> bool: """Gửi note_off TẤT CẢ pitch (0-127) trên mọi channel (0-15) tới Carla. Dừng mọi âm đang ngân trong Carla (kể cả sustain) — dùng ngay trước khi terminate để không còn tiếng kẹt khi bridge bị unload.""" ok = False try: for ch in range(16): for note in range(128): if _send_carla_osc("note_off", note, 0, ch): ok = True except Exception: pass return ok @router.get("/carla-status") async def carla_status(): """Kiểm tra Carla bridge còn sống không (do app spawn) + cổng OSC đang dùng. Frontend dùng để quyết định: route MIDI item EXCLUSIVE qua Carla (chỉ khi Carla đang chạy) hay fallback FluidSynth (luôn có âm) — tránh câm toàn phần khi Carla bị đóng.""" _prune_carla_processes() return { "success": True, "running": len(_CARLA_PROCESSES) > 0, "osc_port": _carla_osc_port(), } @router.post("/carla-stop") async def carla_stop(authorization: Optional[str] = Header(None)): """Unload Carla bridge: tắt mọi note đang ngân + terminate tiến trình Carla do app spawn. Gọi khi track chuyển từ VSTi sang instrument khác (soundfont) để âm KHÔNG còn play qua Carla bridge.""" # Auth là optional (desktop app có thể chưa login) — chỉ cần decode nếu có try: if authorization and authorization.startswith("Bearer "): from app.core.auth import decode_token decode_token(authorization.split(" ")[1]) except Exception: pass # 1. Tắt hết âm đang ngân trong Carla (trước khi giết tiến trình) try: _send_carla_all_notes_off() except Exception: pass # 2. Terminate tiến trình Carla đã spawn killed = 0 _prune_carla_processes() for proc in list(_CARLA_PROCESSES): try: proc.terminate() except Exception: pass # Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống try: import time as _t deadline = _t.time() + 2.0 for proc in list(_CARLA_PROCESSES): while proc.poll() is None and _t.time() < deadline: _t.sleep(0.05) if proc.poll() is None: try: proc.kill() except Exception: pass if proc.poll() is not None: killed += 1 except Exception: pass _CARLA_PROCESSES.clear() return { "success": True, "stopped": True, "killed": killed, "all_notes_off": True, } 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) # Patchbay: kết nối TỰ ĐỘNG MIDI input + audio output → default speaker. # Mặc định Carla KHÔNG connect khi load project (chỉ restore connection có # trong file) → lần đầu mở không có âm, không nhận MIDI (phải unload/load # lại mới auto-connect như thêm plugin thủ công). Connection nào trỏ tới # port không tồn tại sẽ bị Carla bỏ qua im lặng → thêm nhiều biến thể tên. pb_name = name patchbay = ( " \n" # Audio out plugin → loa mặc định (system:playback_1/2) f" {pb_name}:audio_out1system:playback_1\n" f" {pb_name}:audio_out2system:playback_2\n" # MIDI input → plugin midi_in (biến thể tên client: Carla / carla / system) f" Carla:midi_in{pb_name}:midi_in\n" f" carla:midi_in{pb_name}:midi_in\n" f" system:midi_capture_1{pb_name}:midi_in\n" " \n" ) 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" + patchbay + "\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)}")