feat: native folder picker (Explorer) + Synth liet ke VSTi da scan + fix VU leak (ban 1.1.4)

1) Folder picker dung WINDOW EXPLORER (khong prompt nhap tay):
   - src-tauri/src/lib.rs: IPC bridge — thread watcher ipc/pick_dir.request
     -> run_on_main_thread -> dialog().file().blocking_pick_folder()
     (tauri-plugin-dialog = IFileDialog/Explorer) -> ghi pick_dir.response;
     ghi marker tauri_bridge_ready luc setup.
   - app/api/v1/plugins.py: POST /pick-dir (def sync -> threadpool, khong
     auth) — uu tien Tauri bridge, fallback PowerShell FolderBrowserDialog
     (Win) / osascript (macOS) / zenity-kdialog (Linux).
   - app.jsx pickPluginFolder: 1) pickPluginDir (native) -> 2) __TAURI__
     invoke -> 3) in-app browser -> 4) prompt (cuoi cung).

2) Nut Synth liet ke VSTi da scan (truoc day rong):
   - Root: list_available() chi quet settings.VST_DIR (mac dinh
     /opt/daw_engine/vst3) trong khi Plugin Manager scan plugin_dirs user.
   - plugins.py list_plugins: gop _scan_vst_in_dirs(plugin_dirs) (file
     .vst3/.dll/.so + folder X.vst3 Windows).
   - vst_engine.py: PluginManager.extra_vst_dirs + _scan_plugins quet them
     (ca folder .vst3) + get_plugin_manager doc plugin_dirs.json -> load_vst
     tim thay plugin user scan khi render.

3) VU meter leak: section-tab play -> sang MAIN SESSION -> track MAIN van
   animate theo am section.
   - Root: VU tick fallback _sub_ (sub-node section co analyser) chay cho
     ca canvas MAIN; startSubTabPlayback ghi node o key track MAIN.
   - Fix: fallback _sub_ chi ap dung cho canvas SECTION (isSessVu); 2 trigger
     piano-roll them prefix _sess_ theo st.parent_tab_id.

Verify: 86 tests pass; engine frozen STARTUP 1.35s, 1 engine, 0 spawn '-c';
pick-dir IPC mock tra dung path; scan VST user dirs OK.
This commit is contained in:
2026-08-09 13:05:53 +00:00
parent 5ae4fd6149
commit cc8b286f6c
7 changed files with 251 additions and 36 deletions
+124 -2
View File
@@ -1,4 +1,4 @@
import os, uuid, json, tempfile
import os, sys, uuid, json, tempfile, subprocess, time as _time
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
@@ -156,7 +156,129 @@ def get_scanner():
async def list_plugins(current_user: dict = Depends(get_current_user)):
d = _effective_dirs()
pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR)
return pm.list_available()
avail = pm.list_available()
# Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir
# env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà
# Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng).
extra = _scan_vst_in_dirs(d["plugin_dirs"])
by_id = {v["id"]: v for v in avail["vst_instruments"]}
for v in extra:
by_id.setdefault(v["id"], v)
avail["vst_instruments"] = list(by_id.values())
return avail
def _scan_vst_in_dirs(dirs: list) -> list:
"""Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan:
file .vst3/.dll/.so (VST3 folder Windows = .vst3.dll bên trong)."""
found = {}
for d in dirs:
if not d or not os.path.isdir(d):
continue
for root, _dirs, files in os.walk(d):
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
name = os.path.splitext(f)[0]
if name not in found:
found[name] = {
"id": name, "name": name,
"type": "VST3" if low.endswith(".vst3") else "VST2",
"path": os.path.join(root, f),
}
return list(found.values())
# ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải
# nhập tay) ─────────────────────────────────────────────────────────────
PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu
def _pick_dir_via_tauri_bridge() -> Optional[str]:
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog /
Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog →
ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone)."""
root = os.environ.get("APPDATA") or os.path.expanduser("~")
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
if not os.path.isdir(ipc):
return None
# Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
return None
req = os.path.join(ipc, "pick_dir.request")
resp = os.path.join(ipc, "pick_dir.response")
try:
for f in (req, resp):
if os.path.exists(f):
os.remove(f)
with open(req, "w", encoding="utf-8") as fh:
fh.write("1")
deadline = _time.time() + PICK_DIR_TIMEOUT
while _time.time() < deadline:
if os.path.exists(resp):
try:
with open(resp, "r", encoding="utf-8") as fh:
val = fh.read().strip()
finally:
os.remove(resp)
return val or None
_time.sleep(0.1)
except Exception:
pass
return None
def _pick_dir_native_engine() -> Optional[str]:
"""Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog
(Windows), osascript (macOS), zenity/kdialog (Linux)."""
if os.name == "nt":
ps = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$f.Description = 'Chọn thư mục chứa VST / SoundFont'; "
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
)
try:
r = subprocess.run(
["powershell", "-NoProfile", "-STA", "-Command", ps],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
if sys.platform == "darwin":
try:
r = subprocess.run(
["osascript", "-e",
'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
for cmd in (["zenity", "--file-selection", "--directory"],
["kdialog", "--getexistingdirectory", os.path.expanduser("~")]):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT)
if r.returncode == 0:
p = r.stdout.strip()
if p:
return p
except Exception:
continue
return None
@router.post("/pick-dir")
def pick_plugin_directory():
"""Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog).
Trả {"path": "<thư mục>"} hoặc {"path": None} (hủy/không có dialog).
Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop
trong lúc user chọn thư mục (có thể mất phút)."""
path = _pick_dir_via_tauri_bridge()
if path is None:
path = _pick_dir_native_engine()
return {"path": path}
@router.get("/default-soundfonts")
+39 -8
View File
@@ -1,5 +1,6 @@
# SonicForge Studio VST / VSTi Engine Service
import os
import json
import numpy as np
import functools
from ctypes import c_char_p
@@ -101,19 +102,38 @@ _PLUGIN_MANAGER_INSTANCE = None
_PLUGIN_MANAGER_ARGS = None
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
def _load_user_plugin_dirs() -> list:
"""Đọc plugin_dirs.json (Plugin Manager user chọn) — cùng file với
plugins.py (STORAGE_DIR/plugin_dirs.json). Không import plugins.py để
tránh vòng import (plugins.py import vst_engine)."""
try:
from app.config import settings as _st
path = os.path.join(_st.STORAGE_DIR, "plugin_dirs.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return [d for d in (data.get("plugin_dirs") or []) if d]
except Exception:
pass
return []
def get_plugin_manager(vst_dir=None, sf_dir=None, upload_sf_dir=None) -> "PluginManager":
"""Singleton: reuse PluginManager when args match, else create new.
Default dirs từ settings (env/.env/docker-compose hoặc user override)."""
Default dirs từ settings (env/.env/docker-compose hoặc user override).
VST scan gộp thêm plugin_dirs user (Plugin Manager) — nút Synth phải liệt
kê được VSTi đã scan và load_vst phải tìm thấy chúng khi render."""
from app.config import settings as _st
vst_dir = vst_dir or _st.VST_DIR
sf_dir = sf_dir or _st.SOUNDFONT_DIR
upload_sf_dir = upload_sf_dir or _st.STORAGE_DIR + "/soundfonts"
extra = _load_user_plugin_dirs()
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
args = (vst_dir, sf_dir, upload_sf_dir)
args = (vst_dir, sf_dir, upload_sf_dir, tuple(extra))
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
return _PLUGIN_MANAGER_INSTANCE
_PLUGIN_MANAGER_ARGS = args
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir)
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir, extra_vst_dirs=extra)
return _PLUGIN_MANAGER_INSTANCE
def load_soundfont_cached(path: str):
@@ -162,22 +182,33 @@ def release_soundfont(path: str):
_FLUID_CACHE[path] = (fl, ref - 1)
class PluginManager:
def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=None):
def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=None, extra_vst_dirs=None):
from app.config import settings as _st
self.vst_dir = vst_dir or _st.VST_DIR
self.sf_dir = sf_dir or _st.SOUNDFONT_DIR
self.upload_sf_dir = upload_sf_dir
# Thư mục VST thêm (plugin_dirs user scan trong Plugin Manager) —
# list_available()/load_vst phải thấy VSTi user đã scan (bug: nút
# Synth chỉ quét vst_dir env mặc định /opt/daw_engine/vst3).
self.extra_vst_dirs = [d for d in (extra_vst_dirs or []) if d]
self._sf_scan_cache = None # cache for _scan_soundfonts()
def _scan_plugins(self) -> dict:
plugins = {}
if not os.path.isdir(self.vst_dir):
return plugins
for root, dirs, files in os.walk(self.vst_dir):
for scan_dir in [self.vst_dir] + self.extra_vst_dirs:
if not scan_dir or not os.path.isdir(scan_dir):
continue
for root, dirs, files in os.walk(scan_dir):
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
for d in list(dirs):
if d.lower().endswith(".vst3"):
plugins[os.path.splitext(d)[0]] = os.path.join(root, d)
for file in files:
if file.endswith(".vst3") or file.endswith(".so"):
low = file.lower()
if low.endswith(".vst3") or low.endswith(".so") or low.endswith(".dll"):
plugin_path = os.path.join(root, file)
plugin_name = os.path.splitext(file)[0]
if plugin_name not in plugins:
plugins[plugin_name] = plugin_path
return plugins
+19 -14
View File
@@ -5333,28 +5333,30 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
setPmPicker(null);
};
const pickPluginFolder = async () => {
const addDir = (p) => { if (p && !pmDirs.includes(p)) setPmDirs(prev => [...prev, p]); };
try {
// 1) NATIVE dialog qua engine (Tauri bridge IFileDialog/Explorer;
// fallback PowerShell/zenity/osascript) UI chy localhost:8000 nên
// __TAURI__ không có, window.prompt vô hiu trong WebView2.
try {
const d = await window.SonicAPI.pickPluginDir();
if (d && typeof d.path === 'string' && d.path) { addDir(d.path); return; }
} catch (e) { /* fallthrough */ }
// 2) Tauri dialog trc tiếp (ch khi page đưc Tauri serve)
if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) {
const sel = await window.__TAURI__.core.invoke('plugin:dialog|open', {
options: { directory: true, multiple: false }
});
if (typeof sel === 'string' && sel) {
if (!pmDirs.includes(sel)) setPmDirs(prev => [...prev, sel]);
if (typeof sel === 'string' && sel) { addDir(sel); return; }
}
return;
}
// Trinh duyet thu muc in-app (backend) hoat dong khi UI chay tren
// localhost:8000 (khong co __TAURI__, window.prompt vo hieu trong WebView2)
// 3) Trình duyt thư mc in-app (backend) fallback mi OS
await openPluginPicker();
return;
} catch (e) {
// Fallback cuoi: prompt nhap tay (browser thuan, khong phai WebView2)
// 4) Cui cùng: prompt nhp tay (browser thun, không phi WebView2)
try {
const manual = window.prompt('Nhập đường dẫn thư mục plugin (VST / SoundFont):');
if (manual && manual.trim()) {
const p = manual.trim();
if (!pmDirs.includes(p)) setPmDirs(prev => [...prev, p]);
}
if (manual && manual.trim()) addDir(manual.trim());
} catch (e2) {
showToast('Browse failed: ' + (e.message || e), 'error');
}
@@ -8902,7 +8904,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
keybedMouseDownRef.current = true;
try {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(st.trackId, 100);
window.triggerMidiVuActivity((st.parent_tab_id && st.parent_tab_id.startsWith('session_') ? '_sess_' : '') + st.trackId, 100);
}
if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 500, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
@@ -8915,7 +8917,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (keybedMouseDownRef.current) {
try {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(st.trackId, 100);
window.triggerMidiVuActivity((st.parent_tab_id && st.parent_tab_id.startsWith('session_') ? '_sess_' : '') + st.trackId, 100);
}
if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 200, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
@@ -26322,7 +26324,10 @@ STRICT CONSTRAINTS:
// Section item trên MAIN track: VU theo sub-nodes ca track này (key
// <trackId>_sub_*) âm section đi thng masterBus.input (không qua
// node chính) node chính không có tín hiu VU track đng yên.
if (audioPeak <= 0.001) {
// CH fallback cho canvas SECTION (_sess_) KHÔNG cho canvas MAIN:
// section-tab đang play chuyn sang MAIN SESSION VU track MAIN
// không đưc animate theo âm section (user bug).
if (audioPeak <= 0.001 && isSessVu) {
for (var sk in trackNodes) {
if (sk.indexOf(trackId + '_sub_') === 0) {
const sn = trackNodes[sk];
+12 -6
View File
@@ -371,10 +371,13 @@ React.useEffect(()=>{if(isOpen){window.SonicAPI.listPlugins().then(data=>setLoca
// (backend /media/computer + /media/browse) — hoat dong moi OS.
// 3) Cuoi cung: prompt nhap path (browser thuan).
const[pmPicker,setPmPicker]=React.useState(null);// {path, dirs, parent, roots, loading}
const openPluginPicker=async()=>{setPmPicker({path:null,dirs:null,parent:null,roots:null,loading:true});try{const data=await window.SonicAPI.browseComputer();setPmPicker({path:null,dirs:null,parent:null,roots:data.roots||[],loading:false});}catch(e){setPmPicker(null);showToast('Không mở được trình duyệt thư mục: '+(e.message||e),'error');}};const browsePluginDir=async path=>{setPmPicker(prev=>({...prev,loading:true}));try{const data=await window.SonicAPI.browseDir(path);setPmPicker({path:data.path,parent:data.parent,dirs:data.dirs||[],roots:null,loading:false});}catch(e){setPmPicker(prev=>({...prev,loading:false}));showToast('Không đọc được thư mục: '+(e.message||e),'error');}};const confirmPluginDir=()=>{const p=pmPicker&&pmPicker.path;if(p&&!pmDirs.includes(p))setPmDirs(prev=>[...prev,p]);setPmPicker(null);};const pickPluginFolder=async()=>{try{if(window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke){const sel=await window.__TAURI__.core.invoke('plugin:dialog|open',{options:{directory:true,multiple:false}});if(typeof sel==='string'&&sel){if(!pmDirs.includes(sel))setPmDirs(prev=>[...prev,sel]);}return;}// Trinh duyet thu muc in-app (backend) — hoat dong khi UI chay tren
// localhost:8000 (khong co __TAURI__, window.prompt vo hieu trong WebView2)
await openPluginPicker();return;}catch(e){// Fallback cuoi: prompt nhap tay (browser thuan, khong phai WebView2)
try{const manual=window.prompt('Nhập đường dẫn thư mục plugin (VST / SoundFont):');if(manual&&manual.trim()){const p=manual.trim();if(!pmDirs.includes(p))setPmDirs(prev=>[...prev,p]);}}catch(e2){showToast('Browse failed: '+(e.message||e),'error');}}};const removePluginDir=dir=>{setPmDirs(prev=>prev.filter(d=>d!==dir));};// Lưu dirs (user override) + scan cả 2 thư mục → refresh list + catalog.
const openPluginPicker=async()=>{setPmPicker({path:null,dirs:null,parent:null,roots:null,loading:true});try{const data=await window.SonicAPI.browseComputer();setPmPicker({path:null,dirs:null,parent:null,roots:data.roots||[],loading:false});}catch(e){setPmPicker(null);showToast('Không mở được trình duyệt thư mục: '+(e.message||e),'error');}};const browsePluginDir=async path=>{setPmPicker(prev=>({...prev,loading:true}));try{const data=await window.SonicAPI.browseDir(path);setPmPicker({path:data.path,parent:data.parent,dirs:data.dirs||[],roots:null,loading:false});}catch(e){setPmPicker(prev=>({...prev,loading:false}));showToast('Không đọc được thư mục: '+(e.message||e),'error');}};const confirmPluginDir=()=>{const p=pmPicker&&pmPicker.path;if(p&&!pmDirs.includes(p))setPmDirs(prev=>[...prev,p]);setPmPicker(null);};const pickPluginFolder=async()=>{const addDir=p=>{if(p&&!pmDirs.includes(p))setPmDirs(prev=>[...prev,p]);};try{// 1) NATIVE dialog qua engine (Tauri bridge → IFileDialog/Explorer;
// fallback PowerShell/zenity/osascript) — UI chạy localhost:8000 nên
// __TAURI__ không có, window.prompt vô hiệu trong WebView2.
try{const d=await window.SonicAPI.pickPluginDir();if(d&&typeof d.path==='string'&&d.path){addDir(d.path);return;}}catch(e){/* fallthrough */}// 2) Tauri dialog trực tiếp (chỉ khi page được Tauri serve)
if(window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke){const sel=await window.__TAURI__.core.invoke('plugin:dialog|open',{options:{directory:true,multiple:false}});if(typeof sel==='string'&&sel){addDir(sel);return;}}// 3) Trình duyệt thư mục in-app (backend) — fallback mọi OS
await openPluginPicker();return;}catch(e){// 4) Cuối cùng: prompt nhập tay (browser thuần, không phải WebView2)
try{const manual=window.prompt('Nhập đường dẫn thư mục plugin (VST / SoundFont):');if(manual&&manual.trim())addDir(manual.trim());}catch(e2){showToast('Browse failed: '+(e.message||e),'error');}}};const removePluginDir=dir=>{setPmDirs(prev=>prev.filter(d=>d!==dir));};// Lưu dirs (user override) + scan cả 2 thư mục → refresh list + catalog.
const saveAndScanDirs=async()=>{setPmScanning(true);setPmScanResult('');setPmScanData(null);try{await window.SonicAPI.savePluginDirs({plugin_dirs:pmDirs});const scan=await window.SonicAPI.scanPluginDirs();setPmScanData({vst_found:scan.vst_found||[],soundfonts:scan.soundfonts||[]});const data=await window.SonicAPI.listPlugins();setLocalData(data);try{const cat=await window.SonicAPI.getSoundfontCatalog();window.__soundfontCatalog=cat;}catch(_){}setPmScanResult(`VST: ${scan.vst_count||0} | SoundFonts: ${scan.soundfont_count||0}`);showToast(`Scan xong: ${scan.vst_count||0} VST, ${scan.soundfont_count||0} SoundFonts.`,'success');}catch(err){setPmScanResult('Scan failed: '+(err.message||err));showToast('Scan failed: '+(err.message||err),'error');}finally{setPmScanning(false);}};const handleUploadSF=async e=>{const file=e.target.files?.[0];if(!file)return;setSfUploadStatus('Uploading...');try{const result=await window.SonicAPI.uploadSoundFont(file);setSfUploadStatus('Uploaded: '+result.name);// Refresh plugin list and catalog
const data=await window.SonicAPI.listPlugins();setLocalData(data);try{const cat=await window.SonicAPI.getSoundfontCatalog();window.__soundfontCatalog=cat;}catch(_){}}catch(err){setSfUploadStatus('Error: '+err.message);}};const[pmTab,setPmTab]=React.useState('soundfont');return React.createElement('div',{className:'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',onClick:onClose},React.createElement('div',{className:'bg-[#1e1e1e] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-0 text-slate-200 overflow-hidden flex flex-col relative',style:{maxHeight:'80vh'},onClick:e=>e.stopPropagation()},// ── In-app folder picker (Plugin Directories) ─────────────────────
pmPicker&&React.createElement('div',{className:'absolute inset-0 z-10 bg-[#171717]/97 flex flex-col',style:{padding:16}},React.createElement('div',{className:'flex items-center justify-between mb-2'},React.createElement('div',{className:'text-xs font-bold text-violet-300 uppercase'},'Chọn thư mục plugin'),React.createElement('button',{onClick:()=>setPmPicker(null),className:'text-zinc-500 hover:text-zinc-200 transition'},React.createElement('i',{'data-lucide':'x',className:'w-4 h-4'}))),React.createElement('div',{className:'flex items-center gap-2 mb-2'},React.createElement('button',{onClick:()=>{if(pmPicker.parent)browsePluginDir(pmPicker.parent);},disabled:!pmPicker.parent,className:'px-2 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-[11px] text-zinc-300 disabled:opacity-30 shrink-0'},'Lên'),React.createElement('div',{className:'flex-1 text-[11px] text-zinc-400 font-mono truncate',title:pmPicker.path||''},pmPicker.path||(pmPicker.loading?'Đang tải...':'My Computer'))),pmPicker.loading?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-500 text-xs'},'Đang tải...'):pmPicker.dirs?pmPicker.dirs.length===0?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-600 text-xs italic'},'Thư mục trống'):React.createElement('div',{className:'flex-1 overflow-y-auto space-y-1'},pmPicker.dirs.map((d,i)=>React.createElement('div',{key:'pd_'+i,className:'flex items-center gap-2 px-2 py-1.5 rounded hover:bg-violet-900/30 cursor-pointer group',onClick:()=>browsePluginDir(d.path)},React.createElement('i',{'data-lucide':'folder',className:'w-3.5 h-3.5 text-amber-500/80 shrink-0'}),React.createElement('span',{className:'flex-1 text-[11px] text-zinc-300 truncate'},d.name),React.createElement('i',{'data-lucide':'chevron-right',className:'w-3 h-3 text-zinc-600 group-hover:text-violet-400 shrink-0'})))):pmPicker.roots?pmPicker.roots.length===0?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-600 text-xs'},'Không tìm thấy ổ đĩa'):React.createElement('div',{className:'flex-1 overflow-y-auto space-y-1'},pmPicker.roots.map((r,i)=>React.createElement('div',{key:'pr_'+i,className:'flex items-center gap-2 px-2 py-1.5 rounded hover:bg-violet-900/30 cursor-pointer',onClick:()=>browsePluginDir(r.path)},React.createElement('i',{'data-lucide':'hard-drive',className:'w-3.5 h-3.5 text-cyan-500/80 shrink-0'}),React.createElement('span',{className:'flex-1 text-[11px] text-zinc-300'},r.name||r.path)))):null,React.createElement('div',{className:'flex items-center justify-end gap-2 mt-2 pt-2 border-t border-[#383838]'},React.createElement('button',{onClick:()=>setPmPicker(null),className:'px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-white text-xs rounded'},'Hủy'),React.createElement('button',{onClick:confirmPluginDir,disabled:!pmPicker.path,className:'px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white text-xs font-semibold rounded disabled:opacity-30'},'Chọn thư mục này'))),// Header
@@ -490,7 +493,7 @@ const prKeyStateRef=React.useRef({shift:false,ctrl:false});const prMouseInRef=Re
// (trước đây findCCNoteIndex chỉ trả 1 note → chord khó draw).
const findCCNoteIndicesAtBeat=b=>{const snapped=getSnapBeat(b,snapValue);const out=[];notes.forEach((n,idx)=>{if(Math.abs(n.start_beat-snapped)<0.01)out.push(idx);});return out;};const handleCCMouseDown=e=>{const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const noteIdx=findCCNoteIndex(beat,y,h);const val=Math.max(0,Math.min(1,(h-y)/h));if(e.ctrlKey){if(selectedNoteIds.length>0){const cursorNoteIdx=findCCNoteIndex(beat,y,h);if(cursorNoteIdx!==-1&&selectedNoteIds.includes(notes[cursorNoteIdx]?notes[cursorNoteIdx].id:-1)){const currentNote=notes[cursorNoteIdx];const currentVal=ccMode==='pan'?(currentNote.pan||0)/2.0+0.5:currentNote.velocity!==undefined?currentNote.velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==cursorNoteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}ccDragRef.current={active:true,lastBeat:beat,selectedMode:true,lastPainted:[cursorNoteIdx]};}else{ccDragRef.current={active:true,lastBeat:beat,selectedMode:true,lastPainted:[]};}}else{// Không chọn notes: vẽ TẤT CẢ notes cùng beat (chord — user 08:25)
const idxs=findCCNoteIndicesAtBeat(beat);ccDragRef.current={active:true,lastBeat:beat,lastPainted:idxs};}return;}if(noteIdx!==-1){const currentVal=ccMode==='pan'?(notes[noteIdx].pan||0)/2.0+0.5:notes[noteIdx].velocity!==undefined?notes[noteIdx].velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==noteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}}};const handleCCMouseMove=e=>{if(!ccDragRef.current||!ccDragRef.current.active)return;const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const val=Math.max(0,Math.min(1,(h-y)/h));const drag=ccDragRef.current;const painted=drag.lastPainted||[];if(drag.selectedMode&&selectedNoteIds.length>0){const cursorNoteIdx=findCCNoteIndex(beat,y,h);if(cursorNoteIdx!==-1){const cursorNote=notes[cursorNoteIdx];if(cursorNote&&selectedNoteIds.includes(cursorNote.id)&&!painted.includes(cursorNoteIdx)){const currentVal=ccMode==='pan'?(cursorNote.pan||0)/2.0+0.5:cursorNote.velocity!==undefined?cursorNote.velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==cursorNoteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}drag.lastPainted=[...painted,cursorNoteIdx];}}return;}// Vẽ TẤT CẢ notes cùng beat (chord — user 08:25) — trước đây chỉ 1 note
const candidateIdxs=findCCNoteIndicesAtBeat(beat);const unpainted=candidateIdxs.filter(ci=>!painted.includes(ci));if(unpainted.length>0){const updatedNotes=notes.map((n,i)=>{if(!unpainted.includes(i))return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);drag.lastPainted=[...painted,...unpainted];}};const renderKeybed=()=>{var kbTrk=activeTracks.find(function(t){return t.id===st.trackId;});var kbCtx=resolveTrackInstrumentCtx(kbTrk,activeTracks);ensureSonicInstrument(kbCtx);const keys=[];for(let pitch=127;pitch>=PITCH_START;pitch--){const isBlack=[1,3,6,8,10].includes(pitch%12);const notesArray=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;const label=`${notesArray[pitch%12]}${octave}`;const showLabel=pitch%12===0;keys.push(/*#__PURE__*/React.createElement("div",{key:pitch,style:{height:`${NoteHeight}px`},className:`w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${activeMidiPitches&&activeMidiPitches.has(pitch)?'bg-emerald-500 text-white border-emerald-400':isBlack?'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800':'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,onMouseDown:e=>{e.stopPropagation();keybedMouseDownRef.current=true;try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(st.trackId,100);}if(window.SonicSF){window.SonicSF.playNote(pitch,100,500,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}},onMouseEnter:e=>{if(keybedMouseDownRef.current){try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(st.trackId,100);}if(window.SonicSF){window.SonicSF.playNote(pitch,100,200,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}}},onMouseUp:()=>{keybedMouseDownRef.current=false;}},showLabel&&label));}return keys;};const handleScroll=e=>{if(keybedRef.current){keybedRef.current.scrollTop=e.currentTarget.scrollTop;}if(rulerScrollRef.current){rulerScrollRef.current.scrollLeft=e.currentTarget.scrollLeft;}if(ccWrapperRef.current){ccWrapperRef.current.scrollLeft=e.currentTarget.scrollLeft;}const el=e.currentTarget;const THRESHOLD=200;if(el.scrollLeft+el.clientWidth>=el.scrollWidth-THRESHOLD){const newBeats=rollBeatsRef.current+16;setRollBeats(newBeats);}};const handleKeybedScroll=e=>{if(gridScrollRef.current){gridScrollRef.current.scrollTop=e.currentTarget.scrollTop;}};const SCALES={"None":null,"Diatonic":{"Major":[0,2,4,5,7,9,11],"Minor":[0,2,3,5,7,8,10],"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10],"Locrian":[0,1,3,5,6,8,10]},"Pentatonic":{"Major":[0,2,4,7,9],"Minor":[0,3,5,7,10],"Blues":[0,3,5,6,7,10],"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"India":[0,2,5,7,9],"Japan":[0,2,5,7,9],"Africa":[0,3,5,7,10]},"Church":{"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10]},"Jazz":{"Blues":[0,3,5,6,7,10],"Bebop":[0,2,4,5,7,9,10,11],"Diminished":[0,2,3,5,6,8,9,11]},"Asian Traditional":{"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"Japan":[0,2,5,7,9],"India":[0,2,5,7,9],"Gamelan":[0,2,4,7,9],"Korea":[0,2,4,5,7,9]},"Middle Eastern":{"Hijaz":[0,1,4,5,7,8,11],"Nikriz":[0,1,4,5,7,8,10],"Rast":[0,2,4,5,7,8,10],"Saba":[0,1,3,4,7,8,10],"Bayati":[0,2,3,4,7,8,10]}};const[selectedScale,setSelectedScale]=React.useState(null);const selectedScaleRef=React.useRef(null);selectedScaleRef.current=selectedScale;// Root note (0-11 — C..B) — transpose scale highlight + snap (spec 20:12)
const candidateIdxs=findCCNoteIndicesAtBeat(beat);const unpainted=candidateIdxs.filter(ci=>!painted.includes(ci));if(unpainted.length>0){const updatedNotes=notes.map((n,i)=>{if(!unpainted.includes(i))return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);drag.lastPainted=[...painted,...unpainted];}};const renderKeybed=()=>{var kbTrk=activeTracks.find(function(t){return t.id===st.trackId;});var kbCtx=resolveTrackInstrumentCtx(kbTrk,activeTracks);ensureSonicInstrument(kbCtx);const keys=[];for(let pitch=127;pitch>=PITCH_START;pitch--){const isBlack=[1,3,6,8,10].includes(pitch%12);const notesArray=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;const label=`${notesArray[pitch%12]}${octave}`;const showLabel=pitch%12===0;keys.push(/*#__PURE__*/React.createElement("div",{key:pitch,style:{height:`${NoteHeight}px`},className:`w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${activeMidiPitches&&activeMidiPitches.has(pitch)?'bg-emerald-500 text-white border-emerald-400':isBlack?'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800':'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,onMouseDown:e=>{e.stopPropagation();keybedMouseDownRef.current=true;try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(window.SonicSF){window.SonicSF.playNote(pitch,100,500,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}},onMouseEnter:e=>{if(keybedMouseDownRef.current){try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(window.SonicSF){window.SonicSF.playNote(pitch,100,200,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}}},onMouseUp:()=>{keybedMouseDownRef.current=false;}},showLabel&&label));}return keys;};const handleScroll=e=>{if(keybedRef.current){keybedRef.current.scrollTop=e.currentTarget.scrollTop;}if(rulerScrollRef.current){rulerScrollRef.current.scrollLeft=e.currentTarget.scrollLeft;}if(ccWrapperRef.current){ccWrapperRef.current.scrollLeft=e.currentTarget.scrollLeft;}const el=e.currentTarget;const THRESHOLD=200;if(el.scrollLeft+el.clientWidth>=el.scrollWidth-THRESHOLD){const newBeats=rollBeatsRef.current+16;setRollBeats(newBeats);}};const handleKeybedScroll=e=>{if(gridScrollRef.current){gridScrollRef.current.scrollTop=e.currentTarget.scrollTop;}};const SCALES={"None":null,"Diatonic":{"Major":[0,2,4,5,7,9,11],"Minor":[0,2,3,5,7,8,10],"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10],"Locrian":[0,1,3,5,6,8,10]},"Pentatonic":{"Major":[0,2,4,7,9],"Minor":[0,3,5,7,10],"Blues":[0,3,5,6,7,10],"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"India":[0,2,5,7,9],"Japan":[0,2,5,7,9],"Africa":[0,3,5,7,10]},"Church":{"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10]},"Jazz":{"Blues":[0,3,5,6,7,10],"Bebop":[0,2,4,5,7,9,10,11],"Diminished":[0,2,3,5,6,8,9,11]},"Asian Traditional":{"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"Japan":[0,2,5,7,9],"India":[0,2,5,7,9],"Gamelan":[0,2,4,7,9],"Korea":[0,2,4,5,7,9]},"Middle Eastern":{"Hijaz":[0,1,4,5,7,8,11],"Nikriz":[0,1,4,5,7,8,10],"Rast":[0,2,4,5,7,8,10],"Saba":[0,1,3,4,7,8,10],"Bayati":[0,2,3,4,7,8,10]}};const[selectedScale,setSelectedScale]=React.useState(null);const selectedScaleRef=React.useRef(null);selectedScaleRef.current=selectedScale;// Root note (0-11 — C..B) — transpose scale highlight + snap (spec 20:12)
const[scaleRoot,setScaleRoot]=React.useState(0);const scaleRootRef=React.useRef(0);scaleRootRef.current=scaleRoot;// Modal states: Arpeggiator / Strummer / Humanize (spec 20:12)
const[arpModal,setArpModal]=React.useState(null);// { pattern, rate, octaves, gate }
const[strumModal,setStrumModal]=React.useState(null);// { ms, direction }
@@ -1553,7 +1556,10 @@ const vNode=node||function(){for(var k in trackNodes){if(k.endsWith('_sub_'+trac
var vuTracks=activeTracksRef.current||[];var anySoloVU=vuTracks.some(function(t){return t.solo;});var vuTrack=vuTracks.find(function(t){return t.id===trackId;});var isAudible=vuTrack?anySoloVU?!!vuTrack.solo:!vuTrack.muted:true;let audioPeak=0;if(isAudible&&vNode&&vNode.analyserNode){const analyser=vNode.analyserNode;const data=new Uint8Array(128);analyser.getByteTimeDomainData(data);for(let i=0;i<data.length;i++){const v=Math.abs(data[i]-128)/128;if(v>audioPeak)audioPeak=v;}}// Section item trên MAIN track: VU theo sub-nodes của track này (key
// <trackId>_sub_*) — âm section đi thẳng masterBus.input (không qua
// node chính) → node chính không có tín hiệu → VU track đứng yên.
if(audioPeak<=0.001){for(var sk in trackNodes){if(sk.indexOf(trackId+'_sub_')===0){const sn=trackNodes[sk];if(sn&&sn.analyserNode){const d2=new Uint8Array(128);sn.analyserNode.getByteTimeDomainData(d2);for(let i=0;i<d2.length;i++){const v=Math.abs(d2[i]-128)/128;if(v>audioPeak)audioPeak=v;}}}}}// midiVuActivityRef được set bởi triggerMidiVuActivity — fire ĐÚNG NHỊP
// ⚠️ CHỈ fallback cho canvas SECTION (_sess_) — KHÔNG cho canvas MAIN:
// section-tab đang play → chuyển sang MAIN SESSION → VU track MAIN
// không được animate theo âm section (user bug).
if(audioPeak<=0.001&&isSessVu){for(var sk in trackNodes){if(sk.indexOf(trackId+'_sub_')===0){const sn=trackNodes[sk];if(sn&&sn.analyserNode){const d2=new Uint8Array(128);sn.analyserNode.getByteTimeDomainData(d2);for(let i=0;i<d2.length;i++){const v=Math.abs(d2[i]-128)/128;if(v>audioPeak)audioPeak=v;}}}}}// midiVuActivityRef được set bởi triggerMidiVuActivity — fire ĐÚNG NHỊP
// note (nhánh delayed: setTimeout khớp thời điểm phát; nhánh instant:
// note đang kêu). KHÔNG gate theo amplitude SF — gate chặn note đơn /
// velocity thấp (âm SF nhỏ < 0.03) → VU không nhảy (user bug 07:10).
+3
View File
@@ -67,6 +67,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
getPluginDirs: () => apiRequest('/api/v1/plugins/dirs', { method: 'GET' }),
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
scanPluginDirs: () => apiRequest('/api/v1/plugins/scan', { method: 'POST' }),
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
// user yêu cầu dùng window explorer, không nhập tay
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
// Folder picker cho Plugin Manager: duyet thu muc qua backend (media)
// — hoat dong moi OS, khong can window.__TAURI__ (UI chay tren localhost:8000)
browseComputer: () => apiRequest('/api/v1/media/computer', { method: 'GET' }),
+2 -2
View File
@@ -31,7 +31,7 @@
<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/api.js?v=202608091200"></script>
<script src="/static/js/services/api.js?v=202608091300"></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>
@@ -43,7 +43,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=202608091300" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+48
View File
@@ -11,6 +11,7 @@
// Ngoài ra lib.rs còn dò THÊM các vị trí fallback (legacy/portable/dev)
// và ghi đầy đủ diagnostic vào %APPDATA%/SonicForgeDAW/logs/spawn.log.
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt;
use std::path::{Path, PathBuf};
@@ -155,6 +156,53 @@ pub fn run() {
app.manage(EngineProcess(Mutex::new(None)));
}
}
// ── Native folder picker bridge (folder picker cho Plugin Manager) ──
// UI chay tren http://127.0.0.1:8000 (engine) — KHONG co __TAURI__
// (WebView2 cung khong ho tro window.prompt) → engine goi qua file
// IPC: engine ghi ipc/pick_dir.request → thread nay mo NATIVE dialog
// (IFileDialog/Explorer — tauri-plugin-dialog) → ghi ket qua vao
// ipc/pick_dir.response → engine tra ve cho frontend.
let data_root = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let ipc_dir = std::path::Path::new(&data_root)
.join("SonicForgeDAW")
.join("ipc");
if let Ok(()) = std::fs::create_dir_all(&ipc_dir) {
// Marker: engine biet bridge ton tai (khong phai chay standalone)
let _ = std::fs::write(ipc_dir.join("tauri_bridge_ready"), "1");
}
let ipc_dir_for_thread = ipc_dir.clone();
let app_handle = app.handle().clone();
std::thread::spawn(move || {
loop {
let req = ipc_dir_for_thread.join("pick_dir.request");
if req.exists() {
let _ = std::fs::remove_file(&req);
let resp = ipc_dir_for_thread.join("pick_dir.response");
let _ = std::fs::remove_file(&resp);
// Dialog phai chay tren main thread (GTK/Windows message loop)
let handle = app_handle.clone();
let resp_for_main = resp.clone();
let _ = handle.run_on_main_thread(move || {
let picked: Option<PathBuf> = handle
.dialog()
.file()
.blocking_pick_folder();
let val = picked
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let _ = std::fs::write(&resp_for_main, val);
});
// Cho den khi co response (user co the de dialog mo lau)
let mut waited_ms = 0u32;
while !resp.exists() && waited_ms < 300_000 {
std::thread::sleep(std::time::Duration::from_millis(100));
waited_ms += 100;
}
}
std::thread::sleep(std::time::Duration::from_millis(150));
}
});
Ok(())
})
.on_window_event(|window, event| {