Remove Carla native VSTi bridge entirely

Drop Carla integration across frontend and backend:
- plugins.py: remove /carla-midi, /carla-status, /carla-stop, /open-in-carla,
  /carla-play-notes endpoints and Carla process management
- runtime.py: remove find_carla / _carla_* helpers, detect and capabilities
- app.jsx: remove Carla bridge UI, SonicCarlaMidi routing, shouldRouteCarla
  guards, VST row Carla button and preset fallback
- system.py: remove /carla-path
- Keep audio path unchanged (tracks -> masterBus -> output -> analyser),
  directOut only for offline render

Build frontend, tests pass (124 passed).
This commit is contained in:
2026-08-22 18:39:49 +07:00
parent 14265515aa
commit 5ad2c681e7
16 changed files with 163 additions and 1295 deletions
+7 -437
View File
@@ -1,7 +1,7 @@
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio, logging
import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional, Any
@@ -609,13 +609,6 @@ class RenderRequest(BaseModel):
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 FxGuiRequest(BaseModel):
"""Mở native GUI của VST FX qua bridge (Windows desktop)."""
path: str
@@ -649,170 +642,6 @@ class PreviewRequest(BaseModel):
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 _carla_osc_ready() -> bool:
"""Carla OSC engine đang lắng nghe cổng UDP chưa — thử bind socket vào đúng
cổng. Bind THÀNH CÔNG → cổng trống (Carla chưa mở / OSC chưa sẵn sàng);
EADDRINUSE → Carla đang giữ cổng (kể cả mở thủ công ngoài app, hoặc app
spawn đang boot tới giai đoạn OSC). KHÔNG set SO_REUSEADDR (Linux/Win cho
phép 2 UDP socket cùng port → mất tác dụng phát hiện)."""
try:
import socket
port = _carla_osc_port()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("127.0.0.1", port))
s.close()
return False
except OSError:
return True
except Exception:
return False
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 <Plugin><Info><Type>VST3
</Type><Binary>...</Binary></Info> → 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)
# ⚠️ FIX (Bug 2): Carla ĐÃ chạy (mở thủ công / app spawn từ trước, hoặc
# đang boot) → KHÔNG spawn bản 2 (trùng OSC port 22752 + audio → bridge
# hỏng; Carla là single-instance). Trả already_running — frontend poll
# carla-status chờ OSC ready rồi mới gửi note.
if _carla_osc_ready() or len(_CARLA_PROCESSES) > 0:
return {
"success": True,
"started": False,
"already_running": True,
"carla_path": carla,
"plugin_path": plugin_path,
"project_file": carxs_path,
"cmd": [],
}
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",
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
_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}")
@router.post("/fx-gui")
async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_current_user)):
"""Mở native GUI của VST FX (mastering chain / FX rack) qua bridge.
@@ -941,199 +770,6 @@ def _prune_fx_gui_processes():
# ── 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 (OSC engine đang nghe cổng UDP —
phát hiện CẢ Carla mở thủ công ngoài app, không chỉ app-spawn; chính xác
hơn _CARLA_PROCESSES vì OSC bind khi Carla boot xong engine).
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. running=OSC ready → gửi note là tới được Carla."""
_prune_carla_processes()
return {
"success": True,
"running": _carla_osc_ready(),
"app_spawned": 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 <CARLA-PROJECT VERSION='2.5'>,
<Plugin><Info><Type>VST3</Type><Binary>path</Binary><Label>..</Label>
</Info><Data><Active>Yes</Active><ControlChannel>1</ControlChannel>
<Options>0x0</Options></Data></Plugin>."""
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 = (
" <Patchbay>\n"
# Audio out plugin → loa mặc định (system:playback_1/2)
f" <Connection><Source>{pb_name}:audio_out1</Source><Target>system:playback_1</Target></Connection>\n"
f" <Connection><Source>{pb_name}:audio_out2</Source><Target>system:playback_2</Target></Connection>\n"
# MIDI input → plugin midi_in (biến thể tên client: Carla / carla / system)
f" <Connection><Source>Carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
f" <Connection><Source>carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
f" <Connection><Source>system:midi_capture_1</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
" </Patchbay>\n"
)
xml = (
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<!DOCTYPE CARLA-PROJECT>\n"
f"<CARLA-PROJECT VERSION='2.5'>\n"
" <EngineSettings>\n"
" <ForceStereo>false</ForceStereo>\n"
" <PreferPluginBridges>false</PreferPluginBridges>\n"
" <PreferUiBridges>false</PreferUiBridges>\n"
" <UIsAlwaysOnTop>false</UIsAlwaysOnTop>\n"
" <MaxParameters>100</MaxParameters>\n"
" <UIBridgesTimeout>10000</UIBridgesTimeout>\n"
" </EngineSettings>\n"
" <Plugin>\n"
" <Info>\n"
f" <Type>VST3</Type>\n"
f" <Name>{name}</Name>\n"
f" <Binary>{binary}</Binary>\n"
f" <Label>{name}</Label>\n"
" </Info>\n"
" <Data>\n"
" <Active>Yes</Active>\n"
" <ControlChannel>1</ControlChannel>\n"
" <Options>0x0</Options>\n"
" </Data>\n"
" </Plugin>\n"
+ patchbay +
"</CARLA-PROJECT>\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)."""
@@ -1171,8 +807,8 @@ 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."""
PluginManager). Đây là cầu nối preset → pedalboard: preset chỉnh trong
native GUI (.vstpreset) được áp vào pedalboard trước khi render."""
instrument_id: str
notes: list = []
bpm: float = 120.0
@@ -1187,7 +823,7 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
"""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)
- preview realtime chỉ phát qua loa hệ thống (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ư
@@ -1233,9 +869,9 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
raise HTTPException(
status_code=500,
detail=f"Render MIDI thất bại: {e}. Nếu plugin VSTi không load được "
"qua native_bridge, 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.",
"qua native_bridge, hãy chỉnh âm trong native GUI (Save preset "
".vstpreset) rồi Upload preset vào track — render sẽ dùng "
"đúng âm đã chỉnh.",
)
@@ -1517,72 +1153,6 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
raise HTTPException(status_code=500, detail=f"Render soundfont thất bại: {e}")
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()
# OSC ready (mở thủ công / app spawn đã boot) HOẶC tiến trình app-spawn
# còn sống (đang boot) — preview chấp nhận gửi sớm một chút.
return _carla_osc_ready() or len(_CARLA_PROCESSES) > 0
except Exception:
return False
@router.post("/render")
def render_project(
req: RenderRequest,