FIX: 6 lỗi âm thanh/Carla/temp-save/Ctrl-S/FX Chain Carla bridge

- Soundfont preview: hủy note đang chờ load soundfont (stopNote/stopAll/panic) — hết âm loop không dừng với MIDI Keyboard; preview dùng channel riêng (applyAITrackInstrument) — hết sai instrument
- Carla bridge: endpoint /carla-stop (all-notes-off OSC + terminate process) + stopBridge() khi track chuyển VSTi -> soundfont — hết âm play qua Carla cũ
- MIDI items play qua Carla khi VSTi loaded: scheduleCarlaNote route vào startTrackPlayback + startLocalTrackPlayback + ghost notes
- Tự động lưu temp khi tắt app: beforeunload/pagehide sendBeacon + autosave 30s + ghi storage/temp/autosave.json + khôi phục khi load lại
- Ctrl-S: desktop -> save-to-disk (Documents/SonicForgeDAW/Projects); docker -> Cloud/local
- FX Chain (Mastering + FX Rack): module Carla Bridge (VST FX) — load/openInCarla + stop/unload, pass-through trong graph
This commit is contained in:
2026-08-10 05:34:32 +00:00
parent d20932a68b
commit c08233a163
10 changed files with 622 additions and 45 deletions
+90 -2
View File
@@ -1,7 +1,7 @@
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 import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional, Any
@@ -631,7 +631,8 @@ async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(ge
cmd.append(carxs_path)
try:
cwd = os.path.dirname(carla) or None
subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt")
proc = subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt")
_register_carla_process(proc)
return {
"success": True,
"started": True,
@@ -644,6 +645,93 @@ async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(ge
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.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).
+81 -6
View File
@@ -162,6 +162,18 @@ async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[
conn.commit()
conn.close()
# ── Ghi file autosave vào thư mục temp CỦA ỨNG DỤNG (storage/temp) ──
# Yêu cầu: "Khi tắt ứng dụng → tự động lưu temp trên thư mục temp của ứng
# dụng để khi load lại thì tải lại dự án đang làm dở." Ngoài row trong DB,
# ghi thẳng file JSON để luôn có bản sao thật trên ổ đĩa OS.
try:
from app.config import settings as _st
temp_dir = os.path.join(_st.STORAGE_DIR, "temp")
os.makedirs(temp_dir, exist_ok=True)
with open(os.path.join(temp_dir, "autosave.json"), "w", encoding="utf-8") as f:
json.dump({"user_id": user_id, "updated_at": now, "data_json": validated_data_json}, f, ensure_ascii=False)
except Exception:
pass
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
@router.get("/temp")
@@ -175,15 +187,78 @@ async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_u
row = cursor.fetchone()
conn.close()
if not row:
return {"has_temp": False}
if row:
return {
"has_temp": True,
"data_json": row["data_json"],
"updated_at": row["updated_at"]
}
# Fallback: file autosave.json trong thư mục temp của ứng dụng (khi lưu lúc
# đóng app qua sendBeacon — user anonymous) — tải lại dự án đang làm dở.
try:
from app.config import settings as _st
autosave_path = os.path.join(_st.STORAGE_DIR, "temp", "autosave.json")
if os.path.isfile(autosave_path):
with open(autosave_path, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("data_json"):
return {
"has_temp": True,
"data_json": data["data_json"],
"updated_at": data.get("updated_at", 0),
"source": "file",
}
except Exception:
pass
return {"has_temp": False}
def _os_projects_dir() -> str:
"""Thư mục dự án trên hệ điều hành (Ctrl-S desktop):
Windows → Documents/SonicForgeDAW/Projects (fallback USERPROFILE);
Linux/macOS → ~/SonicForgeDAW/Projects. Luôn tồn tại (tự tạo)."""
try:
if os.name == "nt":
docs = os.path.join(os.environ.get("USERPROFILE") or os.path.expanduser("~"), "Documents")
base = docs if os.path.isdir(docs) else (os.environ.get("USERPROFILE") or os.path.expanduser("~"))
else:
base = os.path.expanduser("~")
d = os.path.join(base, "SonicForgeDAW", "Projects")
os.makedirs(d, exist_ok=True)
return d
except Exception:
return os.path.join(os.path.expanduser("~"), "SonicForgeDAW", "Projects")
@router.post("/save-to-disk")
async def save_project_to_disk(req: SaveProjectRequest, authorization: Optional[str] = Header(None)):
"""Ctrl-S trên desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
(Documents/SonicForgeDAW/Projects — bản desktop). Docker/headless KHÔNG
dùng endpoint này (frontend lưu Cloud). Auth optional — desktop có thể
chưa login."""
try:
if authorization and authorization.startswith("Bearer "):
decode_token(authorization.split(" ")[1])
except Exception:
pass
validated_data_json = validate_project_data(req.data_json)
safe_name = "".join(c for c in (req.name or "Dự án mới") if c.isalnum() or c in " _-.").strip() or "Du-an-moi"
if len(safe_name) > 80:
safe_name = safe_name[:80].strip()
safe_name = safe_name.replace(".", "_") if safe_name.endswith(".") else safe_name
fname = safe_name + ".sonicforge.json"
out_dir = _os_projects_dir()
out_path = os.path.join(out_dir, fname)
# Không ghi đè file đang mở ở nơi khác? Ghi đè OK (Ctrl-S = save).
with open(out_path, "w", encoding="utf-8") as f:
f.write(validated_data_json)
return {
"has_temp": True,
"data_json": row["data_json"],
"updated_at": row["updated_at"]
"success": True,
"name": req.name or "Dự án mới",
"path": out_path,
"filename": fname,
}
@router.post("/cloud")
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
validated_data_json = validate_project_data(req.data_json)