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)
|
||||
|
||||
+307
-7
@@ -84,6 +84,23 @@ const ensureSonicInstrument = (ctx) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Carla bridge MIDI scheduling helper ────────────────────────────────────
|
||||
// Schedule note_on/note_off tới Carla (OSC qua backend) cho MIDI items khi
|
||||
// track dùng VSTi + có Carla local. Dùng chung cho mọi đường playback
|
||||
// (main timeline, local loop, piano roll, ghost notes) để MIDI item PHẢI 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)
|
||||
(function handleSfsDeepLink() {
|
||||
@@ -992,6 +1009,11 @@ function createTrackFxModule(type, ctx, params) {
|
||||
nodes = { gLL, gRL, gLR, gRR };
|
||||
} else if (type === 'eqpro') {
|
||||
return createEqProModule(ctx, params);
|
||||
} else if (type === 'carla') {
|
||||
// Carla Bridge = VST FX chạy NGOÀI (ứng dụng ngoài, user tự cài) — trong
|
||||
// WebAudio graph chỉ là pass-through (không thêm DSP): âm track đi thẳng.
|
||||
input.connect(output);
|
||||
nodes = { passthrough: input };
|
||||
} else {
|
||||
// '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);
|
||||
@@ -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 => delete eqProStore[k]);
|
||||
activeMods.forEach(mod => {
|
||||
if (mod.type === 'carla') {
|
||||
// Carla Bridge = VST FX chạy NGOÀI (Carla standalone) — không có node
|
||||
// WebAudio trong master chain: pass-through, prev giữ nguyên.
|
||||
return;
|
||||
}
|
||||
if (mod.type === 'eqpro') {
|
||||
const m = createEqProModule(getAudioContext(), mod.params || {});
|
||||
eqProStore[mod.id] = m;
|
||||
@@ -10514,7 +10541,8 @@ const TRACK_FX_META = {
|
||||
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' },
|
||||
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 = {
|
||||
eq: { g1: 0, g2: 0, g3: 0, g4: 0 },
|
||||
@@ -10522,7 +10550,8 @@ const TRACK_FX_DEFAULTS = {
|
||||
compressor: { threshold: -16, ratio: 3, makeup: 0 },
|
||||
limiter: { ceiling: -1.0 },
|
||||
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,
|
||||
@@ -10992,6 +11021,13 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
const scopeMeterRRef = 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 });
|
||||
// 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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
{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 và 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>
|
||||
|
||||
{/* 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).
|
||||
const dragChainIndexRef = React.useRef(null);
|
||||
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;
|
||||
|
||||
@@ -11922,9 +12015,10 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
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' },
|
||||
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 toggleChainModule = (modId) => {
|
||||
@@ -11963,6 +12057,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
const id = 'mod_' + type + '_' + Date.now();
|
||||
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 === 'carla') entry.params = { plugin: '', plugin_path: '' };
|
||||
setOzState(prev => ({
|
||||
...prev,
|
||||
chain: [...(prev.chain || []), entry],
|
||||
@@ -12416,6 +12511,62 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
</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 là pass-through trong master chain (không thêm DSP WebAudio).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WAVE OBSERVER INTEGRATION */}
|
||||
<div className="border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0">
|
||||
{/* Header */}
|
||||
@@ -13663,8 +13814,15 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
||||
const program = curInst ? curInst.program : undefined;
|
||||
const sfId = curInst ? curInst.sfId : undefined;
|
||||
const bank = curInst ? curInst.bank : 0;
|
||||
if (curInst && window.SonicSF.selectInstrument) {
|
||||
try { await window.SonicSF.selectInstrument(0, bank, program, sfId); } catch (e2) {}
|
||||
// Preview dùng channel RIÊNG (qua _engineChMap) — KHÔNG đè channel 0 mà
|
||||
// track đang dùng → không làm sai instrument của track/soundfont khác
|
||||
// (trước đây cứng 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)
|
||||
// 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 startSec = shiftedStartBeat * secondsPerBeat + (note.trackOffset || 0);
|
||||
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);
|
||||
@@ -14835,6 +14993,19 @@ const App = () => {
|
||||
var mt = activeTracksRef.current || tracks;
|
||||
var curTrk = null;
|
||||
for (var ci = 0; ci < mt.length; ci++) { if (mt[ci].id === trackId) { curTrk = mt[ci]; break; } }
|
||||
// ── Unload Carla bridge khi chuyển từ VSTi sang instrument KHÔNG phải VST ──
|
||||
// (soundfont/GM/default). Nếu không, Carla vẫn chạy với VSTi cũ → MIDI vẫn
|
||||
// play qua Carla bridge (âm sai instrument + âm kẹt không dừng đượ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);
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
if (t.id !== trackId) return t;
|
||||
@@ -16565,6 +16736,7 @@ const App = () => {
|
||||
limActive: false, limThreshold: -1.0,
|
||||
excActive: false, excDrive: 40,
|
||||
rebalActive: false, rebalMid: 0, rebalSide: 0,
|
||||
carlaBridgeActive: false, carlaBridge: { plugin: '', plugin_path: '', connected: false },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -16655,6 +16827,35 @@ const App = () => {
|
||||
setInstrumentSelectorData(data);
|
||||
}).catch(() => {});
|
||||
} catch (e) { }
|
||||
// ── Khôi phục dự án đang làm dở (temp autosave) khi load lại app ──
|
||||
// Yêu cầu: "Khi tắt ứng dụng → tự động lưu temp; khi load lại → tải lại
|
||||
// dự án đang làm dở." Chỉ restore khi app khởi động với project TRỐNG
|
||||
// (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]);
|
||||
|
||||
// ── Temp autosave SERVER khi tắt ứng dụng (Bug: đóng app mất dự án làm dở) ──
|
||||
// Lưu temp vào thư mục temp của ứng dụng (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 lại app → khôi phục 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 thuần
|
||||
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ơ hội gửi 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) ──
|
||||
useEffect(() => {
|
||||
const BACKUP_MAX_KEY = 'sonic_backup_max_count';
|
||||
@@ -20843,6 +21089,9 @@ const App = () => {
|
||||
trkCh,
|
||||
track.synth_engine
|
||||
);
|
||||
// MIDI items → Carla bridge (track VSTi + Carla local): phát VSTi
|
||||
// realtime — yêu cầu: MIDI item PHẢI 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
|
||||
setTimeout(() => {
|
||||
// ⚠️ Guard: stop → setTimeout sót không được fire (VU nhảy
|
||||
@@ -20866,6 +21115,8 @@ const App = () => {
|
||||
trkCh,
|
||||
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
|
||||
if (window.triggerMidiVuActivity) {
|
||||
window.triggerMidiVuActivity((activeTab && activeTab.startsWith('session_') ? '_sess_' : '') + track.id, note.velocity || 0.8);
|
||||
@@ -21092,6 +21343,8 @@ const App = () => {
|
||||
lcCh,
|
||||
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 {
|
||||
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
|
||||
window.SonicSF.playNote(
|
||||
@@ -21104,6 +21357,8 @@ const App = () => {
|
||||
lcCh,
|
||||
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) {
|
||||
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);
|
||||
handleSaveProjectRef.current = handleSaveProject;
|
||||
|
||||
// ── Ctrl-S (Save) toàn cục ──
|
||||
// Desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH (Documents/SonicForgeDAW/
|
||||
// Projects). Docker/headless: lưu lên Cloud (đã login) hoặc 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ư mục của hệ điều 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 nhập), ngược lại 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 projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
|
||||
const dataJson = JSON.stringify(projectSchemaObj);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 }) }),
|
||||
// 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 }) }),
|
||||
// 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
|
||||
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)
|
||||
@@ -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 }) }),
|
||||
|
||||
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' }),
|
||||
uploadSoundFont: async (file) => {
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -98,5 +98,25 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
this.noteOn(channel, note, velocity);
|
||||
var self = this;
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
let _scheduledNotes = [];
|
||||
let _loadPromises = {};
|
||||
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 () {
|
||||
if (_audioCtx) {
|
||||
@@ -468,8 +475,11 @@
|
||||
|
||||
stopNote: function (channel, pitch) {
|
||||
if (channel < 0 || channel > 15) return;
|
||||
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 key = channel + ':' + pitch;
|
||||
var mappedChs = _activeNotes[key];
|
||||
if (mappedChs === undefined) mappedChs = [channel];
|
||||
for (var i = 0; i < mappedChs.length; i++) {
|
||||
@@ -564,7 +574,24 @@
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
// 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);
|
||||
if (ok) {
|
||||
doNote();
|
||||
@@ -666,6 +693,9 @@
|
||||
panic: function () {
|
||||
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
|
||||
_scheduledNotes = [];
|
||||
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành no-op
|
||||
_noteGeneration++;
|
||||
_pendingNoteOns = {};
|
||||
if (_initialized && _fluidModule) {
|
||||
// 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ó
|
||||
@@ -743,6 +773,10 @@
|
||||
},
|
||||
|
||||
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) {
|
||||
// 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.
|
||||
|
||||
@@ -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-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/runtime.js?v=202608091200"></script>
|
||||
<script src="/static/js/services/api.js?v=202608091200"></script>
|
||||
<script src="/static/js/services/runtime.js?v=202608101300"></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/storage.js?v=202608038200"></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/dawCommandDispatcher.js?v=202607271016"></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/promptTemplateManager.js?v=202607281039"></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">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
Reference in New Issue
Block a user