diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index 9decd2c..396c49d 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -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": ""} 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") diff --git a/app/core/vst_engine.py b/app/core/vst_engine.py index 2bb0420..3d86d57 100644 --- a/app/core/vst_engine.py +++ b/app/core/vst_engine.py @@ -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,23 +182,34 @@ 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 file in files: - if file.endswith(".vst3") or file.endswith(".so"): - plugin_path = os.path.join(root, file) - plugin_name = os.path.splitext(file)[0] - plugins[plugin_name] = plugin_path + 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: + 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 def _scan_soundfonts(self) -> list: diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 839d596..67ca8c2 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -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 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) { - if (!pmDirs.includes(sel)) setPmDirs(prev => [...prev, sel]); - } - return; + if (typeof sel === 'string' && sel) { addDir(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) + // 3) Trình duyệt thư mục in-app (backend) — fallback mọi OS await openPluginPicker(); return; } catch (e) { - // Fallback cuoi: prompt nhap tay (browser thuan, khong phai WebView2) + // 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()) { - 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 của track này (key // _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) { + // ⚠️ 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]; diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index df3a0c3..39e0ca5 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -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;iaudioPeak)audioPeak=v;}}// Section item trên MAIN track: VU theo sub-nodes của track này (key // _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;iaudioPeak)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;iaudioPeak)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). diff --git a/app/static/js/services/api.js b/app/static/js/services/api.js index 5a31a4b..e7aa8b8 100644 --- a/app/static/js/services/api.js +++ b/app/static/js/services/api.js @@ -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' }), diff --git a/app/templates/index.html b/app/templates/index.html index e4ca882..054bbfd 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -31,7 +31,7 @@ - + @@ -43,7 +43,7 @@ - +