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
+1
View File
@@ -19,6 +19,7 @@ app/storage/processed/*
!app/storage/processed/.gitkeep !app/storage/processed/.gitkeep
app/storage/*.db app/storage/*.db
app/storage/sf_scan_state.json app/storage/sf_scan_state.json
app/storage/temp/
.DS_Store .DS_Store
# VST3 and sample library directories (proprietary binaries) # VST3 and sample library directories (proprietary binaries)
+90 -2
View File
@@ -1,7 +1,7 @@
import os, sys, uuid, json, tempfile, subprocess, time as _time import os, sys, uuid, json, tempfile, subprocess, time as _time
import numpy as np import numpy as np
import soundfile as sf 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 fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Any 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) cmd.append(carxs_path)
try: try:
cwd = os.path.dirname(carla) or None 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 { return {
"success": True, "success": True,
"started": 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}") 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: 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 """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). uniqueID không đoán được → mở Carla trống để user tự Add Plugin).
+78 -3
View File
@@ -162,6 +162,18 @@ async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[
conn.commit() conn.commit()
conn.close() 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} return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
@router.get("/temp") @router.get("/temp")
@@ -175,14 +187,77 @@ async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_u
row = cursor.fetchone() row = cursor.fetchone()
conn.close() conn.close()
if not row: if row:
return {"has_temp": False}
return { return {
"has_temp": True, "has_temp": True,
"data_json": row["data_json"], "data_json": row["data_json"],
"updated_at": row["updated_at"] "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 {
"success": True,
"name": req.name or "Dự án mới",
"path": out_path,
"filename": fname,
}
@router.post("/cloud") @router.post("/cloud")
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)): async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
+307 -7
View File
@@ -84,6 +84,23 @@ const ensureSonicInstrument = (ctx) => {
} }
}; };
// Carla bridge MIDI scheduling helper
// Schedule note_on/note_off ti Carla (OSC qua backend) cho MIDI items khi
// track dùng VSTi + có Carla local. Dùng chung cho mi đưng playback
// (main timeline, local loop, piano roll, ghost notes) đ MIDI item PHI play
// qua Carla bridge khi VSTi đưc loaded.
const scheduleCarlaNote = (synthEngine, channel, pitch, velocity, startWallTime, durMs) => {
try {
if (!window.SonicCarlaMidi || !window.SonicCarlaMidi.shouldRoutePlayback(synthEngine)) return;
const ctx = getAudioContext();
const delay = Math.max(0, (startWallTime - ctx.currentTime) * 1000);
const carlaVel = Math.round((velocity || 0.8) * 127);
const carlaCh = (synthEngine && synthEngine.midi_channel !== undefined) ? synthEngine.midi_channel : (channel || 0);
setTimeout(function () { window.SonicCarlaMidi.noteOn(carlaCh, pitch, carlaVel); }, delay);
setTimeout(function () { window.SonicCarlaMidi.noteOff(carlaCh, pitch); }, delay + (durMs || 300) + 30);
} catch (e) { /* routing thất bại im lặng — soundfont vẫn chơi qua FluidSynth */ }
};
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project) // Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
(function handleSfsDeepLink() { (function handleSfsDeepLink() {
@@ -992,6 +1009,11 @@ function createTrackFxModule(type, ctx, params) {
nodes = { gLL, gRL, gLR, gRR }; nodes = { gLL, gRL, gLR, gRR };
} else if (type === 'eqpro') { } else if (type === 'eqpro') {
return createEqProModule(ctx, params); return createEqProModule(ctx, params);
} else if (type === 'carla') {
// Carla Bridge = VST FX chy NGOÀI (ng dng ngoài, user t cài) trong
// WebAudio graph ch là pass-through (không thêm DSP): âm track đi thng.
input.connect(output);
nodes = { passthrough: input };
} else { } else {
// 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB) // 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB)
const f1 = ctx.createBiquadFilter(); f1.type = 'lowshelf'; f1.frequency.value = clampF(100); const f1 = ctx.createBiquadFilter(); f1.type = 'lowshelf'; f1.frequency.value = clampF(100);
@@ -1134,6 +1156,11 @@ function rebuildMasteringGraph(activate, chainArray) {
Object.keys(eqProStore).forEach(k => { try { eqProStore[k].destroy && eqProStore[k].destroy(); } catch (e) { } }); Object.keys(eqProStore).forEach(k => { try { eqProStore[k].destroy && eqProStore[k].destroy(); } catch (e) { } });
Object.keys(eqProStore).forEach(k => delete eqProStore[k]); Object.keys(eqProStore).forEach(k => delete eqProStore[k]);
activeMods.forEach(mod => { activeMods.forEach(mod => {
if (mod.type === 'carla') {
// Carla Bridge = VST FX chy NGOÀI (Carla standalone) không có node
// WebAudio trong master chain: pass-through, prev gi nguyên.
return;
}
if (mod.type === 'eqpro') { if (mod.type === 'eqpro') {
const m = createEqProModule(getAudioContext(), mod.params || {}); const m = createEqProModule(getAudioContext(), mod.params || {});
eqProStore[mod.id] = m; eqProStore[mod.id] = m;
@@ -10514,7 +10541,8 @@ const TRACK_FX_META = {
compressor: { name: 'Bus Compressor', icon: 'compress', color: '#fbbf24', sub: 'Glue & Punch' }, compressor: { name: 'Bus Compressor', icon: 'compress', color: '#fbbf24', sub: 'Glue & Punch' },
limiter: { name: 'Brickwall Limiter', icon: 'shield-half', color: '#f43f5e', sub: 'True-Peak 20:1' }, limiter: { name: 'Brickwall Limiter', icon: 'shield-half', color: '#f43f5e', sub: 'True-Peak 20:1' },
exciter: { name: 'Harmonic Exciter', icon: 'wand-2', color: '#c084fc', sub: 'Saturation & Air' }, exciter: { name: 'Harmonic Exciter', icon: 'wand-2', color: '#c084fc', sub: 'Saturation & Air' },
rebalance: { name: 'Master Rebalance', icon: 'sliders-horizontal', color: '#38bdf8', sub: 'M/S Balance' } rebalance: { name: 'Master Rebalance', icon: 'sliders-horizontal', color: '#38bdf8', sub: 'M/S Balance' },
carla: { name: 'Carla Bridge (VST FX)', icon: 'sliders', color: '#14b8a6', sub: 'Native VST audio processing' }
}; };
const TRACK_FX_DEFAULTS = { const TRACK_FX_DEFAULTS = {
eq: { g1: 0, g2: 0, g3: 0, g4: 0 }, eq: { g1: 0, g2: 0, g3: 0, g4: 0 },
@@ -10522,7 +10550,8 @@ const TRACK_FX_DEFAULTS = {
compressor: { threshold: -16, ratio: 3, makeup: 0 }, compressor: { threshold: -16, ratio: 3, makeup: 0 },
limiter: { ceiling: -1.0 }, limiter: { ceiling: -1.0 },
exciter: { drive: 40 }, exciter: { drive: 40 },
rebalance: { mid: 0, side: 0 } rebalance: { mid: 0, side: 0 },
carla: { plugin: '', plugin_path: '' }
}; };
// EQ Pro canvas frame renderer (graphic_EQ_interactive_module.md §I-II): grid, // EQ Pro canvas frame renderer (graphic_EQ_interactive_module.md §I-II): grid,
@@ -10992,6 +11021,13 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
const scopeMeterRRef = React.useRef(null); const scopeMeterRRef = React.useRef(null);
const eqCurveRef = React.useRef(null); const eqCurveRef = React.useRef(null);
const scopeStateRef = React.useRef({ L: null, R: null, head: 0, len: 0, tmpL: null, tmpR: null, freq: null }); const scopeStateRef = React.useRef({ L: null, R: null, head: 0, len: 0, tmpL: null, tmpR: null, freq: null });
// Carla Bridge (VST FX) danh sách VST đ load vào Carla
const [fxCarlaVsts, setFxCarlaVsts] = React.useState(null); // null = chưa load
React.useEffect(() => {
if (track && window.SonicAPI && window.SonicAPI.listPlugins) {
window.SonicAPI.listPlugins().then(d => setFxCarlaVsts((d && d.vst_instruments) || [])).catch(() => setFxCarlaVsts([]));
}
}, [track && track.id]);
if (!track) return null; if (!track) return null;
@@ -11350,6 +11386,56 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('SIDE GAIN', ap.side, -12, 12, 0.1, '#22d3ee', v => setParams(activeIdx, { side: v }), v => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`)}</div> <div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('SIDE GAIN', ap.side, -12, 12, 0.1, '#22d3ee', v => setParams(activeIdx, { side: v }), v => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`)}</div>
</div> </div>
)} )}
{activeMod && activeMod.type === 'carla' && activeIdx >= 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[9px] text-teal-400 font-mono uppercase tracking-widest">CARLA BRIDGE VST FX CHỈNH SỬA ÂM THANH</span>
<span className="text-[9px] text-slate-500 font-mono">Carla chạy ngoài (user tự cài) · native GUI</span>
</div>
<div className="bg-slate-900/80 border border-teal-900/60 p-3 rounded-lg space-y-3">
<div className="flex items-end gap-2 flex-wrap">
<div className="flex-1 min-w-[220px]">
<div className="text-[9px] text-slate-500 font-mono mb-1">CHỌN VST FX (đã scan trên máy)</div>
<select
value={ap.plugin || ''}
onChange={e => setParams(activeIdx, { plugin: e.target.value, plugin_path: '' })}
className="w-full bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1.5 text-xs outline-none focus:border-teal-500"
>
<option value=""> Chọn VST FX </option>
{(fxCarlaVsts || []).map(v => <option key={v.id || v.name} value={v.id || v.name}>{v.name || v.id}</option>)}
</select>
</div>
<button
onClick={() => {
if (!ap.plugin) { window.showToast && window.showToast('Chọn VST FX trước khi Load Carla', 'warning'); return; }
window.SonicAPI.openInCarla(ap.plugin, ap.plugin_path).then(r => {
if (r && r.success) { window.showToast && window.showToast('Đã mở Carla với ' + ap.plugin + ' (VST FX) — chỉnh âm thanh trong Carla', 'success'); }
else { window.showToast && window.showToast('Không mở được Carla', 'error'); }
}).catch(err => window.showToast && window.showToast('Lỗi mở Carla: ' + (err.message || err), 'error'));
}}
className="px-4 py-1.5 bg-teal-700 hover:bg-teal-600 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
>
<i data-lucide="play" className="w-3 h-3"></i> Load Carla Bridge
</button>
<button
onClick={() => {
if (window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) window.SonicCarlaMidi.stopBridge();
window.showToast && window.showToast('Đã ngắt kết nối Carla Bridge', 'info');
}}
className="px-4 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-red-300 text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
>
<i data-lucide="square" className="w-3 h-3"></i> Stop / Unload
</button>
</div>
<div className="text-[10px] text-slate-500 font-mono leading-relaxed">
{ap.plugin
? <>Đã chọn: <span className="text-teal-300">{ap.plugin}</span> bấm <b>Load Carla Bridge</b> để mở native GUI VST chỉnh sửa âm thanh.</>
: 'Chọn VST FX từ danh sách đã scan, rồi bấm Load Carla Bridge để mở Carla (native GUI).'}
</div>
</div>
</div>
)}
</div> </div>
{/* WAVE OBSERVER — REAL-TIME OSCILLOSCOPE (unified_fx_rack_panel_update.md §III.3) */} {/* WAVE OBSERVER — REAL-TIME OSCILLOSCOPE (unified_fx_rack_panel_update.md §III.3) */}
@@ -11908,6 +11994,13 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
// stable hook count across renders (error #310 otherwise). // stable hook count across renders (error #310 otherwise).
const dragChainIndexRef = React.useRef(null); const dragChainIndexRef = React.useRef(null);
const [addModuleOpen, setAddModuleOpen] = React.useState(false); const [addModuleOpen, setAddModuleOpen] = React.useState(false);
// Carla Bridge (VST FX) danh sách VST đ load vào Carla (master chain)
const [masterCarlaVsts, setMasterCarlaVsts] = React.useState(null); // null = chưa load
React.useEffect(() => {
if (isOpen && window.SonicAPI && window.SonicAPI.listPlugins) {
window.SonicAPI.listPlugins().then(d => setMasterCarlaVsts((d && d.vst_instruments) || [])).catch(() => setMasterCarlaVsts([]));
}
}, [isOpen]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -11922,9 +12015,10 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
compressor: { name: 'Bus Compressor', sub: 'Glue & Punch', icon: 'compress', color: '#fbbf24' }, compressor: { name: 'Bus Compressor', sub: 'Glue & Punch', icon: 'compress', color: '#fbbf24' },
limiter: { name: 'Brickwall Limiter', sub: 'True-Peak 20:1', icon: 'shield-half', color: '#f43f5e' }, limiter: { name: 'Brickwall Limiter', sub: 'True-Peak 20:1', icon: 'shield-half', color: '#f43f5e' },
exciter: { name: 'Harmonic Exciter', sub: 'Saturation & Air', icon: 'wand-2', color: '#c084fc' }, exciter: { name: 'Harmonic Exciter', sub: 'Saturation & Air', icon: 'wand-2', color: '#c084fc' },
rebalance: { name: 'Master Rebalance', sub: 'M/S Balance', icon: 'sliders-horizontal', color: '#38bdf8' } rebalance: { name: 'Master Rebalance', sub: 'M/S Balance', icon: 'sliders-horizontal', color: '#38bdf8' },
carla: { name: 'Carla Bridge (VST FX)', sub: 'Native VST audio processing', icon: 'sliders', color: '#14b8a6' }
}; };
const chainFlag = (type) => type === 'eq' ? 'eqActive' : type === 'eqpro' ? 'eqproActive' : type === 'imager' ? 'imagerActive' : type === 'maximizer' ? 'maximizerActive' : type === 'compressor' ? 'compActive' : type === 'limiter' ? 'limActive' : type === 'exciter' ? 'excActive' : 'rebalActive'; const chainFlag = (type) => type === 'eq' ? 'eqActive' : type === 'eqpro' ? 'eqproActive' : type === 'imager' ? 'imagerActive' : type === 'maximizer' ? 'maximizerActive' : type === 'compressor' ? 'compActive' : type === 'limiter' ? 'limActive' : type === 'exciter' ? 'excActive' : type === 'carla' ? 'carlaBridgeActive' : 'rebalActive';
const chainActive = (type) => !!ozState[chainFlag(type)]; const chainActive = (type) => !!ozState[chainFlag(type)];
const toggleChainModule = (modId) => { const toggleChainModule = (modId) => {
@@ -11963,6 +12057,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
const id = 'mod_' + type + '_' + Date.now(); const id = 'mod_' + type + '_' + Date.now();
const entry = { id, type, name: meta.name, active: true }; const entry = { id, type, name: meta.name, active: true };
if (type === 'eqpro') entry.params = { amount: 100, bands: JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS)) }; if (type === 'eqpro') entry.params = { amount: 100, bands: JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS)) };
if (type === 'carla') entry.params = { plugin: '', plugin_path: '' };
setOzState(prev => ({ setOzState(prev => ({
...prev, ...prev,
chain: [...(prev.chain || []), entry], chain: [...(prev.chain || []), entry],
@@ -12416,6 +12511,62 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
</div> </div>
</div> </div>
{/* VIEW: CARLA BRIDGE (VST FX) — load carla bridge để dùng VST chỉnh sửa âm thanh */}
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'carla' ? '' : 'hidden'}`}>
<div className="oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center">
<div className="col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4">
<span className="text-xs font-bold text-teal-400 uppercase oz-font-mono mb-3">Carla Bridge (VST FX)</span>
<button onClick={() => toggleChainModule((ozState.chain || []).find(m => m.type === 'carla')?.id)}
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.carlaBridgeActive ? 'bg-teal-700 border-teal-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}`}>
{ozState.carlaBridgeActive ? 'ON' : 'OFF'}
</button>
</div>
<div className="col-span-8 space-y-3">
<div className="flex items-end gap-2 flex-wrap">
<div className="flex-1 min-w-[220px]">
<div className="text-[9px] text-slate-500 oz-font-mono mb-1">CHỌN VST FX (đã scan trên máy)</div>
<select
value={(ozState.carlaBridge && ozState.carlaBridge.plugin) || ''}
onChange={e => setOzState(prev => ({ ...prev, carlaBridge: { ...(prev.carlaBridge || {}), plugin: e.target.value, plugin_path: '' } }))}
className="w-full bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1.5 text-xs outline-none focus:border-teal-500"
>
<option value=""> Chọn VST FX </option>
{(masterCarlaVsts || []).map(v => <option key={v.id || v.name} value={v.id || v.name}>{v.name || v.id}</option>)}
</select>
</div>
<button
onClick={() => {
const plugin = (ozState.carlaBridge && ozState.carlaBridge.plugin) || '';
if (!plugin) { window.showToast && window.showToast('Chọn VST FX trước khi Load Carla', 'warning'); return; }
window.SonicAPI.openInCarla(plugin, ozState.carlaBridge.plugin_path).then(r => {
if (r && r.success) {
setOzState(prev => ({ ...prev, carlaBridge: { ...(prev.carlaBridge || {}), connected: true } }));
window.showToast && window.showToast('Đã mở Carla với ' + plugin + ' (VST FX) — chỉnh âm thanh trong Carla', 'success');
} else { window.showToast && window.showToast('Không mở được Carla', 'error'); }
}).catch(err => window.showToast && window.showToast('Lỗi mở Carla: ' + (err.message || err), 'error'));
}}
className="px-4 py-1.5 bg-teal-700 hover:bg-teal-600 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
>
<i data-lucide="play" className="w-3 h-3"></i> Load Carla Bridge
</button>
<button
onClick={() => {
if (window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) window.SonicCarlaMidi.stopBridge();
setOzState(prev => ({ ...prev, carlaBridge: { ...(prev.carlaBridge || {}), connected: false } }));
window.showToast && window.showToast('Đã ngắt kết nối Carla Bridge', 'info');
}}
className="px-4 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-red-300 text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
>
<i data-lucide="square" className="w-3 h-3"></i> Stop / Unload
</button>
</div>
<div className="text-[10px] text-slate-500 oz-font-mono leading-relaxed">
Mở Carla với VST FX để chỉnh sửa âm thanh master (native GUI). Module này pass-through trong master chain (không thêm DSP WebAudio).
</div>
</div>
</div>
</div>
{/* WAVE OBSERVER INTEGRATION */} {/* WAVE OBSERVER INTEGRATION */}
<div className="border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0"> <div className="border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0">
{/* Header */} {/* Header */}
@@ -13663,8 +13814,15 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
const program = curInst ? curInst.program : undefined; const program = curInst ? curInst.program : undefined;
const sfId = curInst ? curInst.sfId : undefined; const sfId = curInst ? curInst.sfId : undefined;
const bank = curInst ? curInst.bank : 0; const bank = curInst ? curInst.bank : 0;
if (curInst && window.SonicSF.selectInstrument) { // Preview dùng channel RIÊNG (qua _engineChMap) KHÔNG đè channel 0 mà
try { await window.SonicSF.selectInstrument(0, bank, program, sfId); } catch (e2) {} // track đang dùng không làm sai instrument ca track/soundfont khác
// (trưc đây cng channel 0: preview MIDI Keyboard có th chơi sai
// instrument khi track khác đang dùng chung channel).
let pvCh = 0;
if (curInst && window.SonicSF.applyAITrackInstrument) {
try { pvCh = window.SonicSF.applyAITrackInstrument(bank, program, { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: program !== undefined ? program : 0 }) || 0; } catch (e2) { pvCh = 0; }
} else if (curInst && window.SonicSF.selectInstrument) {
try { await window.SonicSF.selectInstrument(pvCh, bank, program, sfId); } catch (e2) {}
} }
// Re-check token after the async await stale playMidiPreview (older file) // Re-check token after the async await stale playMidiPreview (older file)
// must not schedule notes over the newly selected file. // must not schedule notes over the newly selected file.
@@ -13695,7 +13853,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
const shiftedStartBeat = hasSelection ? (noteStartBeat - loopStartBeats) : noteStartBeat; const shiftedStartBeat = hasSelection ? (noteStartBeat - loopStartBeats) : noteStartBeat;
const startSec = shiftedStartBeat * secondsPerBeat + (note.trackOffset || 0); const startSec = shiftedStartBeat * secondsPerBeat + (note.trackOffset || 0);
const durMs = Math.max(80, (note.duration_beats || 1) * secondsPerBeat * 1000); const durMs = Math.max(80, (note.duration_beats || 1) * secondsPerBeat * 1000);
window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, passStartTime + startSec, prog, null, 0, eng); window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, passStartTime + startSec, prog, null, pvCh, eng);
}); });
}; };
schedulePass(startWallTime); schedulePass(startWallTime);
@@ -14835,6 +14993,19 @@ const App = () => {
var mt = activeTracksRef.current || tracks; var mt = activeTracksRef.current || tracks;
var curTrk = null; var curTrk = null;
for (var ci = 0; ci < mt.length; ci++) { if (mt[ci].id === trackId) { curTrk = mt[ci]; break; } } for (var ci = 0; ci < mt.length; ci++) { if (mt[ci].id === trackId) { curTrk = mt[ci]; break; } }
// Unload Carla bridge khi chuyn t VSTi sang instrument KHÔNG phi VST
// (soundfont/GM/default). Nếu không, Carla vn chy vi VSTi cũ MIDI vn
// play qua Carla bridge (âm sai instrument + âm kt không dng đưc).
try {
const _hasInst = !!instrumentId;
const _wasVst = curTrk && curTrk.synth_engine && String(curTrk.synth_engine.type || '').indexOf('vst') !== -1;
const _nowVst = _hasInst && !isSfInstrument;
if (_wasVst && !_nowVst && window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) {
window.SonicCarlaMidi.stopBridge();
try { if (window.SonicSF && window.SonicSF.stopAll) window.SonicSF.stopAll(); } catch (e) {}
console.log('[Instrument] Carla bridge unloaded — track', trackId, 'switched from VSTi to non-VST');
}
} catch (e) { console.warn('[Instrument] carla-stop on instrument switch error:', e); }
var mch = curTrk ? assignTrackMidiChannel(curTrk, mt) : (sfBank === 128 ? 9 : 0); var mch = curTrk ? assignTrackMidiChannel(curTrk, mt) : (sfBank === 128 ? 9 : 0);
updateActiveTracks(prev => prev.map(t => { updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t; if (t.id !== trackId) return t;
@@ -16565,6 +16736,7 @@ const App = () => {
limActive: false, limThreshold: -1.0, limActive: false, limThreshold: -1.0,
excActive: false, excDrive: 40, excActive: false, excDrive: 40,
rebalActive: false, rebalMid: 0, rebalSide: 0, rebalActive: false, rebalMid: 0, rebalSide: 0,
carlaBridgeActive: false, carlaBridge: { plugin: '', plugin_path: '', connected: false },
}); });
} }
} else { } else {
@@ -16655,6 +16827,35 @@ const App = () => {
setInstrumentSelectorData(data); setInstrumentSelectorData(data);
}).catch(() => {}); }).catch(() => {});
} catch (e) { } } catch (e) { }
// Khôi phc d án đang làm d (temp autosave) khi load li app
// Yêu cu: "Khi tt ng dng t đng lưu temp; khi load li ti li
// d án đang làm d." Ch restore khi app khi đng vi project TRNG
// (chưa có clip/midi/audio nào) không đè lên d án user đang m.
try {
const tmp = await window.SonicAPI.getTempProject();
if (tmp && tmp.has_temp && tmp.data_json) {
let proj = tmp.data_json;
if (typeof proj === 'string') { try { proj = JSON.parse(proj); } catch (e) { proj = null; } }
if (proj && proj.main_session) {
const freshStart = !(tracks && tracks.some(t => (t.clips && t.clips.length > 0) || (t.midiItems && t.midiItems.length > 0) || t.buffer));
if (freshStart) {
const result = deserializeProjectFromSchema(proj);
if (result && result.tracks && result.tracks.length > 0) {
setTracks(result.tracks);
loadAudioBuffersForTracks(result.tracks);
setBpm((result.bpm || 120).toString());
if (result.sessionTabs && result.sessionTabs.length > 0) setSessionTabs(result.sessionTabs);
if (result.subTabs && result.subTabs.length > 0) setSubTabs(result.subTabs);
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
const tmpName = (proj.metadata && proj.metadata.title) || 'Dự án tạm';
setProjectName(tmpName);
localStorage.setItem('sonic_project_name', tmpName);
showToast('Đã khôi phục dự án đang làm dở từ bản lưu tạm', 'success');
}
}
}
}
} catch (e) { console.warn('[TempRestore] error:', e); }
})(); })();
} }
}; };
@@ -16692,6 +16893,51 @@ const App = () => {
}); });
}, [tracks, subTabs, sessionTabs, masteringSettings]); }, [tracks, subTabs, sessionTabs, masteringSettings]);
// Temp autosave SERVER khi tt ng dng (Bug: đóng app mt d án làm d)
// Lưu temp vào thư mc temp ca ng dng (storage/temp + DB) khi:
// 1) đóng tab/window (beforeunload/pagehide sendBeacon keepalive)
// 2) đnh k 30s khi có thay đi (phòng Tauri kill engine ngay khi close)
// Load li app khôi phc d án đang làm d (getTempProject mount).
useEffect(() => {
const buildTempPayload = () => {
try {
const schemaObj = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings);
return JSON.stringify({ data_json: JSON.stringify(schemaObj) });
} catch (e) { return null; }
};
const saveTempServer = () => {
const payload = buildTempPayload();
if (!payload) return;
try {
if (navigator.sendBeacon) {
// sendBeacon không đt header JSON đưc backend đc body JSON thun
const blob = new Blob([payload], { type: 'application/json' });
navigator.sendBeacon(window.API_BASE_URL + '/api/v1/projects/temp', blob);
} else {
fetch(window.API_BASE_URL + '/api/v1/projects/temp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true
}).catch(() => {});
}
} catch (e) { /* fire-and-forget */ }
};
const onUnload = (e) => {
saveTempServer();
// Đ sendBeacon có cơ hi gi trưc khi WebView b destroy
try { navigator.sendBeacon && navigator.sendBeacon(window.API_BASE_URL + '/health', new Blob(['ping'], { type: 'text/plain' })); } catch (e2) {}
};
window.addEventListener('beforeunload', onUnload);
window.addEventListener('pagehide', onUnload);
const interval = setInterval(saveTempServer, 30 * 1000);
return () => {
window.removeEventListener('beforeunload', onUnload);
window.removeEventListener('pagehide', onUnload);
clearInterval(interval);
};
}, [currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings]);
// Timer-based auto-save (5 min) + backup (30 min) // Timer-based auto-save (5 min) + backup (30 min)
useEffect(() => { useEffect(() => {
const BACKUP_MAX_KEY = 'sonic_backup_max_count'; const BACKUP_MAX_KEY = 'sonic_backup_max_count';
@@ -20843,6 +21089,9 @@ const App = () => {
trkCh, trkCh,
track.synth_engine track.synth_engine
); );
// MIDI items Carla bridge (track VSTi + Carla local): phát VSTi
// realtime yêu cu: MIDI item PHI play qua Carla khi VSTi loaded.
scheduleCarlaNote(track.synth_engine, trkCh, note.pitch || 60, note.velocity || 0.8, startTime, durationMs);
// Trigger VU meter flash when the note starts playing // Trigger VU meter flash when the note starts playing
setTimeout(() => { setTimeout(() => {
// Guard: stop setTimeout sót không đưc fire (VU nhy // Guard: stop setTimeout sót không đưc fire (VU nhy
@@ -20866,6 +21115,8 @@ const App = () => {
trkCh, trkCh,
track.synth_engine track.synth_engine
); );
// MIDI items Carla bridge (track VSTi + Carla local)
scheduleCarlaNote(track.synth_engine, trkCh, note.pitch || 60, note.velocity || 0.8, context.currentTime, remainingDurMs);
// Trigger VU meter flash instantly // Trigger VU meter flash instantly
if (window.triggerMidiVuActivity) { if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity((activeTab && activeTab.startsWith('session_') ? '_sess_' : '') + track.id, note.velocity || 0.8); window.triggerMidiVuActivity((activeTab && activeTab.startsWith('session_') ? '_sess_' : '') + track.id, note.velocity || 0.8);
@@ -21092,6 +21343,8 @@ const App = () => {
lcCh, lcCh,
track.synth_engine track.synth_engine
); );
// MIDI items Carla bridge (track VSTi + Carla local)
scheduleCarlaNote(track.synth_engine, lcCh, note.pitch || 60, note.velocity || 0.8, startTime, durationMs);
} else { } else {
const remainingDurMs = (noteEndSec - offsetTime) * 1000; const remainingDurMs = (noteEndSec - offsetTime) * 1000;
window.SonicSF.playNote( window.SonicSF.playNote(
@@ -21104,6 +21357,8 @@ const App = () => {
lcCh, lcCh,
track.synth_engine track.synth_engine
); );
// MIDI items Carla bridge (track VSTi + Carla local)
scheduleCarlaNote(track.synth_engine, lcCh, note.pitch || 60, note.velocity || 0.8, context.currentTime, remainingDurMs);
} }
} }
}); });
@@ -21183,6 +21438,8 @@ const App = () => {
if (window.SonicSF) { if (window.SonicSF) {
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth); window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth);
} }
// Ghost notes Carla bridge (ghost track VSTi + Carla local)
scheduleCarlaNote(ghostSynth, ghostCh, note.pitch || 60, note.velocity || 0.8, schedTime, durMs);
} }
}); });
}); });
@@ -24131,6 +24388,49 @@ const App = () => {
const handleSaveProjectRef = useRef(handleSaveProject); const handleSaveProjectRef = useRef(handleSaveProject);
handleSaveProjectRef.current = handleSaveProject; handleSaveProjectRef.current = handleSaveProject;
// Ctrl-S (Save) toàn cc
// Desktop: lưu project ra THƯ MC CA H ĐIU HÀNH (Documents/SonicForgeDAW/
// Projects). Docker/headless: lưu lên Cloud (đã login) hoc local (.sfs).
const handleGlobalSave = async () => {
try {
const finalName = projectName || 'Dự án mới';
const schemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const dataJson = JSON.stringify(schemaObj);
const runtime = (window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.runtime) || '';
if (runtime === 'desktop' && window.SonicAPI && window.SonicAPI.saveProjectToDisk) {
// Desktop thư mc ca h điu hành
const res = await window.SonicAPI.saveProjectToDisk(finalName, dataJson);
showToast('Đã lưu dự án: ' + ((res && res.path) || finalName), 'success');
} else if (window.SonicAPI) {
// Docker / headless Cloud (nếu đã đăng nhp), ngưc li local
if (currentProjectId && !currentProjectId.startsWith('local_') && currentUser) {
await window.SonicAPI.updateCloudProject(currentProjectId, finalName, dataJson);
showToast('Đã lưu dự án lên Cloud', 'success');
} else if (currentUser) {
await handleSaveAsCloud(finalName);
} else {
handleSaveLocalProject(finalName);
}
}
} catch (err) {
showToast('Lỗi lưu dự án: ' + (err.message || err), 'error');
}
};
const handleGlobalSaveRef = useRef(handleGlobalSave);
handleGlobalSaveRef.current = handleGlobalSave;
useEffect(() => {
const handler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
e.preventDefault();
handleGlobalSaveRef.current();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const handleSaveAsCloud = async (newName) => { const handleSaveAsCloud = async (newName) => {
const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings); const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const dataJson = JSON.stringify(projectSchemaObj); const dataJson = JSON.stringify(projectSchemaObj);
File diff suppressed because one or more lines are too long
+6
View File
@@ -76,6 +76,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
setCarlaPath: (path) => apiRequest('/api/v1/system/carla-path', { method: 'POST', body: JSON.stringify({ carla_path: path }) }), setCarlaPath: (path) => apiRequest('/api/v1/system/carla-path', { method: 'POST', body: JSON.stringify({ carla_path: path }) }),
// Mở native GUI VSTi trong Carla (chỉ khi runtime=desktop + có Carla local) // Mở native GUI VSTi trong Carla (chỉ khi runtime=desktop + có Carla local)
openInCarla: (pluginName, pluginPath) => apiRequest('/api/v1/plugins/open-in-carla', { method: 'POST', body: JSON.stringify({ plugin_name: pluginName, plugin_path: pluginPath }) }), openInCarla: (pluginName, pluginPath) => apiRequest('/api/v1/plugins/open-in-carla', { method: 'POST', body: JSON.stringify({ plugin_name: pluginName, plugin_path: pluginPath }) }),
// 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 soundfont)
stopCarla: () => apiRequest('/api/v1/plugins/carla-stop', { method: 'POST', body: JSON.stringify({}) }),
// Gửi MIDI note (track ARM → Carla OSC) để preview VSTi realtime // Gửi MIDI note (track ARM → Carla OSC) để preview VSTi realtime
carlaMidi: (payload) => apiRequest('/api/v1/plugins/carla-midi', { method: 'POST', body: JSON.stringify(payload) }), carlaMidi: (payload) => apiRequest('/api/v1/plugins/carla-midi', { method: 'POST', body: JSON.stringify(payload) }),
// Quick-render preview VSTi (âm thật = âm export, cùng code path) // Quick-render preview VSTi (âm thật = âm export, cùng code path)
@@ -109,6 +112,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
cleanupBackups: (keep) => apiRequest('/api/v1/projects/cloud/backups/cleanup', { method: 'POST', body: JSON.stringify({ keep }) }), cleanupBackups: (keep) => apiRequest('/api/v1/projects/cloud/backups/cleanup', { method: 'POST', body: JSON.stringify({ keep }) }),
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }), renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
// Ctrl-S desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
// (Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux)
saveProjectToDisk: (name, dataJson) => apiRequest('/api/v1/projects/save-to-disk', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }), deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
uploadSoundFont: async (file) => { uploadSoundFont: async (file) => {
const formData = new FormData(); const formData = new FormData();
+20
View File
@@ -98,5 +98,25 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
this.noteOn(channel, note, velocity); this.noteOn(channel, note, velocity);
var self = this; var self = this;
setTimeout(function () { self.noteOff(channel, note); }, (durMs || 300) + 50); setTimeout(function () { self.noteOff(channel, note); }, (durMs || 300) + 50);
},
// Tắt mọi note đang ngân trong Carla (note_off toàn pitch — dùng khi stop)
allNotesOff: function () {
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
for (var ch = 0; ch < 16; ch++) {
for (var n = 0; n < 128; n++) {
window.SonicAPI.carlaMidi({ event: 'note_off', note: n, channel: ch }).catch(function () {});
}
}
},
// Unload Carla bridge: tắt âm + terminate tiến trình Carla (app spawn).
// Gọi khi track chuyển từ VSTi → instrument soundfont để âm KHÔNG còn
// play qua Carla bridge nữa.
stopBridge: function () {
var self = this;
try { self.allNotesOff(); } catch (e) {}
if (window.SonicAPI && window.SonicAPI.stopCarla) {
return window.SonicAPI.stopCarla().catch(function () {});
}
return Promise.resolve();
} }
}; };
+35 -1
View File
@@ -29,6 +29,13 @@
let _scheduledNotes = []; let _scheduledNotes = [];
let _loadPromises = {}; let _loadPromises = {};
let _sfloadSeq = 0; let _sfloadSeq = 0;
// Note đang CHỜ load soundfont (chưa noteon) — 'ch:pitch' → số lần chờ.
// Đăng ký TRƯỚC khi load để stopNote/stopAll/panic hủy được note này;
// trước đây note deferred bắn TRỄ sau khi thả phím / sau Stop → âm loop
// không dừng (keybed preview âm soundfont).
let _pendingNoteOns = {};
// Tăng mỗi lần stopAll/panic — deferred doNote của generation cũ thành no-op.
let _noteGeneration = 0;
const getCtx = function () { const getCtx = function () {
if (_audioCtx) { if (_audioCtx) {
@@ -468,8 +475,11 @@
stopNote: function (channel, pitch) { stopNote: function (channel, pitch) {
if (channel < 0 || channel > 15) return; if (channel < 0 || channel > 15) return;
if (_initialized && _fluidModule) {
var key = channel + ':' + pitch; var key = channel + ':' + pitch;
// Hủy note đang CHỜ LOAD soundfont (chưa noteon) — trước đây note
// này vẫn bắn TRỄ sau khi thả phím → âm loop không dừng được.
if (_pendingNoteOns[key]) delete _pendingNoteOns[key];
if (_initialized && _fluidModule) {
var mappedChs = _activeNotes[key]; var mappedChs = _activeNotes[key];
if (mappedChs === undefined) mappedChs = [channel]; if (mappedChs === undefined) mappedChs = [channel];
for (var i = 0; i < mappedChs.length; i++) { for (var i = 0; i < mappedChs.length; i++) {
@@ -564,7 +574,24 @@
return; return;
} }
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId); console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
// Đăng ký note CHỜ LOAD trước khi load — để stopNote (thả
// phím) / stopAll / panic hủy được note này. Trước đây
// note deferred vẫn bắn TRỄ sau khi thả phím hoặc sau
// Stop → âm loop không dừng với preview MIDI Keyboard.
var _pendKey = ch + ':' + midiPitch;
var _gen = _noteGeneration;
_pendingNoteOns[_pendKey] = (_pendingNoteOns[_pendKey] || 0) + 1;
self.loadSoundFont(finalSfId).then(function (ok) { self.loadSoundFont(finalSfId).then(function (ok) {
// stopAll/panic chạy trong lúc load → generation đổi → bỏ
if (_gen !== _noteGeneration) return;
// stopNote (thả phím) đã hủy → KHÔNG bắn note trễ nữa
var _pend = _pendingNoteOns[_pendKey];
if (_pend) {
_pendingNoteOns[_pendKey] = _pend - 1;
if (_pendingNoteOns[_pendKey] <= 0) delete _pendingNoteOns[_pendKey];
} else {
return;
}
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId); console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
if (ok) { if (ok) {
doNote(); doNote();
@@ -666,6 +693,9 @@
panic: function () { panic: function () {
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } }); _scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
_scheduledNotes = []; _scheduledNotes = [];
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành no-op
_noteGeneration++;
_pendingNoteOns = {};
if (_initialized && _fluidModule) { if (_initialized && _fluidModule) {
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc // noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có // chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
@@ -743,6 +773,10 @@
}, },
stopAll: function () { stopAll: function () {
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành
// no-op sau Stop (âm loop không dừng khi preview MIDI Keyboard).
_noteGeneration++;
_pendingNoteOns = {};
if (_initialized && _fluidModule) { if (_initialized && _fluidModule) {
// noteoff từng note đang ngân (binding chắc chắn tồn tại) — // noteoff từng note đang ngân (binding chắc chắn tồn tại) —
// phòng all_notes_off không có trong WASM exports. // phòng all_notes_off không có trong WASM exports.
+4 -4
View File
@@ -31,12 +31,12 @@
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script> <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script> <script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/runtime.js?v=202608091200"></script> <script src="/static/js/services/runtime.js?v=202608101300"></script>
<script src="/static/js/services/api.js?v=202608091200"></script> <script src="/static/js/services/api.js?v=202608101300"></script>
<script src="/static/js/services/audioEngine.js?v=202607271016"></script> <script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202608038200"></script> <script src="/static/js/services/storage.js?v=202608038200"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script> <script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202608070635"></script> <script src="/static/js/services/soundfontPlayer.js?v=202608101300"></script>
<script src="/static/js/services/aiGateway.js?v=202608037200"></script> <script src="/static/js/services/aiGateway.js?v=202608037200"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script> <script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script> <script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
@@ -44,7 +44,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script> <script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608091200" defer></script> <script src="/static/js/app.precompiled.js?v=202608101300" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+11
View File
@@ -1,3 +1,14 @@
### [2026-08-10] Task: Fix 6 lỗi âm thanh/Carla/temp-save/Ctrl-S/FX Chain
- **Tóm tắt thay đổi:**
(1) **Soundfont preview âm loop không dừng**: `soundfontPlayer.js` — note đang CHỜ load soundfont giờ được đăng ký trong `_pendingNoteOns` + `_noteGeneration` trước khi load → `stopNote` (thả phím MIDI Keyboard) / `stopAll` / `panic` hủy được note deferred (trước đây note bắn TRỄ sau khi thả phím/Stop → âm loop không dừng).
(2) **Unload Carla bridge khi chọn lại instrument soundfont**: backend `plugins.py` thêm registry `_CARLA_PROCESSES` (open_in_carla lưu Popen) + endpoint `POST /api/v1/plugins/carla-stop` (OSC note_off toàn pitch mọi channel → terminate/kill tiến trình Carla). Frontend `setTrackInstrumentWithProgram` gọi `SonicCarlaMidi.stopBridge()` khi track chuyển từ VSTi → non-VST; `runtime.js` thêm `allNotesOff()` + `stopBridge()`; `api.js` thêm `stopCarla()`.
(3) **MIDI item play qua Carla bridge khi VSTi loaded**: `app.jsx` thêm helper `scheduleCarlaNote()` và route vào CẢ 3 đường playback: `startTrackPlayback` (main timeline), `startLocalTrackPlayback` (local loop), ghost notes trong `schedulePianoRollMidi` (trước đây chỉ piano roll chính route).
(4) **Tự động lưu temp khi tắt app**: `projects.py` POST /temp giờ ghi file `storage/temp/autosave.json` (thư mục temp của ứng dụng) + DB; GET /temp fallback đọc file. `app.jsx` thêm beforeunload/pagehide → sendBeacon POST /projects/temp + autosave 30s + khôi phục dự án temp khi mount (chỉ khi project trống).
(5) **Ctrl-S (Save)**: `app.jsx` thêm global Ctrl/Cmd+S handler — desktop → `POST /api/v1/projects/save-to-disk` (ghi vào Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux); docker/headless → Cloud (đã login) hoặc local .sfs.
(6) **FX Chain (Mastering + FX Rack) load Carla bridge**: thêm module type `carla` vào `TRACK_FX_META`/`TRACK_FX_DEFAULTS` + `MODULE_META` (mastering) — UI dropdown VST đã scan → Load Carla Bridge (openInCarla) / Stop-Unload (carla-stop); `createTrackFxModule` + `rebuildMasteringGraph` xử lý carla là pass-through (không thêm DSP WebAudio).
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `app/api/v1/projects.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/static/js/services/soundfontPlayer.js`, `app/static/js/services/runtime.js`, `app/static/js/services/api.js`, `app/templates/index.html` (?v= bump 202608101200), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Smoke test jsdom: bundle eval OK, các symbol mới (scheduleCarlaNote, TRACK_FX_META.carla, stopBridge, stopCarla, saveProjectToDisk) đều tồn tại. Test curl: `/carla-stop` 200, `/save-to-disk` ghi file OS, `/temp` ghi storage/temp/autosave.json + GET trả has_temp. ⚠️ Backend .py nằm trong PyInstaller PYZ → CẦN rebuild exe trên Windows để nhận fix backend (static/templates đã sync dist).
### [2026-08-08] FIX: verify_bundle.py vẫn false-positive trên Windows — TOC là Python literal nên backslash bị DOUBLE-escape ('app\\\\static') ### [2026-08-08] FIX: verify_bundle.py vẫn false-positive trên Windows — TOC là Python literal nên backslash bị DOUBLE-escape ('app\\\\static')
- **Tóm tắt thay đổi:** Fix LAN 2 normalize `replace('\\','/')` 1 lần KHÔNG đủ: TOC file là Python literal (repr) → path Windows ghi thành `app\\\\static` (2 backslash trên disk) → replace 1 lần ra `app//static` → regex `app/static(?=[/'"])` không match → vẫn báo "Bundle thieu" DÙ bundle đủ. Fix: normalize 2 bước — `replace("\\\\","/")` (bắt double-escape) rồi `replace("\\","/")` (backslash đơn). Verify bằng TOC giả lập Windows repr: cả 2 entry match True. - **Tóm tắt thay đổi:** Fix LAN 2 normalize `replace('\\','/')` 1 lần KHÔNG đủ: TOC file là Python literal (repr) → path Windows ghi thành `app\\\\static` (2 backslash trên disk) → replace 1 lần ra `app//static` → regex `app/static(?=[/'"])` không match → vẫn báo "Bundle thieu" DÙ bundle đủ. Fix: normalize 2 bước — `replace("\\\\","/")` (bắt double-escape) rồi `replace("\\","/")` (backslash đơn). Verify bằng TOC giả lập Windows repr: cả 2 entry match True.
- **Các file ảnh hưởng:** `tools/verify_bundle.py` (dòng normalize) - **Các file ảnh hưởng:** `tools/verify_bundle.py` (dòng normalize)