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:
+90
-2
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user