fix: 3 bug standalone — soundfont preview/play, Carla bridge play item, realtime sync LAN/Tauri
Bug1: dispatcher CREATE_MIDI_ITEM track_id number vs state string -> String(); play+preview native soundfont verified. Bug2: Carla play item — chờ OSC ready (poll carla-status, queue nốt khi cold start), chống spawn trùng (carla-status dò port bind, open-in-carla skip khi đã chạy). Bug3: realtime sync — GET /temp/revision (updated_at+client_id), poll 3s, apply qua deserializeProjectFromSchema; hash-skip autosave + client_id chống ping-pong.
This commit is contained in:
+42
-4
@@ -563,6 +563,25 @@ def _carla_osc_port() -> int:
|
||||
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).
|
||||
|
||||
@@ -626,6 +645,20 @@ async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(ge
|
||||
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)
|
||||
@@ -683,15 +716,18 @@ def _send_carla_all_notes_off() -> bool:
|
||||
|
||||
@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.
|
||||
"""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."""
|
||||
phần khi Carla bị đóng. running=OSC ready → gửi note là tới được Carla."""
|
||||
_prune_carla_processes()
|
||||
return {
|
||||
"success": True,
|
||||
"running": len(_CARLA_PROCESSES) > 0,
|
||||
"running": _carla_osc_ready(),
|
||||
"app_spawned": len(_CARLA_PROCESSES) > 0,
|
||||
"osc_port": _carla_osc_port(),
|
||||
}
|
||||
|
||||
@@ -1118,7 +1154,9 @@ def find_carla_local() -> str:
|
||||
def _carla_bridge_running() -> bool:
|
||||
try:
|
||||
_prune_carla_processes()
|
||||
return len(_CARLA_PROCESSES) > 0
|
||||
# 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
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ class SaveProjectRequest(BaseModel):
|
||||
|
||||
class SaveTempProjectRequest(BaseModel):
|
||||
data_json: str
|
||||
client_id: Optional[str] = None # client ghi bản này (LAN browser / Tauri UI) — chống ping-pong sync
|
||||
|
||||
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
@@ -172,6 +173,10 @@ async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[
|
||||
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)
|
||||
# Revision sidecar (nhỏ) — realtime sync LAN/Tauri đọc updated_at +
|
||||
# client_id KHÔNG cần parse data_json (có thể MB).
|
||||
with open(os.path.join(temp_dir, f"revision_{user_id}.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"client_id": req.client_id or "", "updated_at": now}, f, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
|
||||
@@ -212,6 +217,37 @@ async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_u
|
||||
pass
|
||||
return {"has_temp": False}
|
||||
|
||||
|
||||
@router.get("/temp/revision")
|
||||
async def temp_project_revision(current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
"""Revision nhẹ của bản temp: updated_at + client_id — frontend poll 2-3s
|
||||
để realtime sync giữa LAN browser và Tauri standalone (không tải full
|
||||
data_json mỗi lần). client_id = client ghi bản cuối → client khác bỏ qua
|
||||
bản do CHÍNH NÓ ghi (chống ping-pong)."""
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
client_id = ""
|
||||
updated_at = 0.0
|
||||
try:
|
||||
from app.config import settings as _st
|
||||
rev_path = os.path.join(_st.STORAGE_DIR, "temp", f"revision_{user_id}.json")
|
||||
if os.path.isfile(rev_path):
|
||||
with open(rev_path, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
client_id = meta.get("client_id", "") or ""
|
||||
updated_at = float(meta.get("updated_at", 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
if not updated_at:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT updated_at FROM projects WHERE id = ? AND is_temp = 1", (f"temp_{user_id}",))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
updated_at = float(row["updated_at"] or 0)
|
||||
return {"updated_at": updated_at, "client_id": client_id}
|
||||
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user