Compare commits
18 Commits
main
..
standalone
| Author | SHA1 | Date | |
|---|---|---|---|
| cd2547ef6a | |||
| c538cab745 | |||
| 90493c73f3 | |||
| a3359aa0ed | |||
| a7489f41e6 | |||
| 194c9b52b2 | |||
| 77505fbc86 | |||
| a61f9abd6a | |||
| a7163efaf1 | |||
| 2462bbc1a8 | |||
| 1f1017d78a | |||
| f17bed4e5f | |||
| bc6bc71d0e | |||
| bc8431fe81 | |||
| 466bf25a0b | |||
| 52e1dc6eda | |||
| 30a40b2bca | |||
| 8dd00cc2ea |
@@ -15,3 +15,11 @@ DEFAULT_ADMIN_PASSWORD=thay-mat-khau-admin
|
||||
|
||||
# Storage (đường dẫn trong container)
|
||||
STORAGE_DIR=/app/app/storage
|
||||
|
||||
# ── Plugin directories ──
|
||||
# Đường dẫn HOST tới thư mục chứa VST / SoundFont / Pianobook — dùng trong
|
||||
# docker-compose.yml để mount vào container (đổi theo máy chạy Docker).
|
||||
# Mặc định: /home/locpham/daw_assets/...
|
||||
VST_DIR=/home/locpham/daw_assets/vst3
|
||||
SOUNDFONT_DIR=/home/locpham/daw_assets/soundfonts
|
||||
PIANOBK_DIR=/home/locpham/daw_assets/pianobook
|
||||
|
||||
@@ -31,4 +31,5 @@ celerybeat-schedule
|
||||
node_modules
|
||||
src-tauri/target/
|
||||
src-tauri/binaries/
|
||||
src-tauri/resources/daw_engine/
|
||||
src-tauri/vc_redist.x64.exe
|
||||
@@ -16,7 +16,121 @@ router = APIRouter()
|
||||
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
||||
os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
|
||||
|
||||
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"
|
||||
SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
|
||||
SYSTEM_VST_DIR = settings.VST_DIR
|
||||
|
||||
# User dirs (Windows/macOS — người dùng chọn qua folder picker trong
|
||||
# Plugins Manager). File global (không per-user): desktop app 1 user.
|
||||
PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json")
|
||||
|
||||
def _load_plugin_dirs() -> dict:
|
||||
if os.path.exists(PLUGIN_DIRS_FILE):
|
||||
try:
|
||||
with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_plugin_dirs(dirs: dict):
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(dirs, f, indent=2)
|
||||
|
||||
def _effective_dirs() -> dict:
|
||||
"""Env/.env (Docker) là base; user dirs (file) override nếu khai báo.
|
||||
|
||||
plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục
|
||||
có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa
|
||||
khai báo → fallback env VST_DIR + SOUNDFONT_DIR.
|
||||
"""
|
||||
user = _load_plugin_dirs()
|
||||
plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d]
|
||||
vst_dir = settings.VST_DIR
|
||||
soundfont_dir = settings.SOUNDFONT_DIR
|
||||
# Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list.
|
||||
if not plugin_dirs:
|
||||
if user.get("vst_dir"):
|
||||
plugin_dirs.append(user["vst_dir"])
|
||||
if user.get("soundfont_dir"):
|
||||
plugin_dirs.append(user["soundfont_dir"])
|
||||
if not plugin_dirs:
|
||||
plugin_dirs = [vst_dir, soundfont_dir]
|
||||
return {
|
||||
"plugin_dirs": plugin_dirs,
|
||||
"vst_dir": vst_dir,
|
||||
"soundfont_dir": soundfont_dir,
|
||||
"plugin_dirs_user_set": bool(user.get("plugin_dirs")),
|
||||
}
|
||||
|
||||
class DirsRequest(BaseModel):
|
||||
vst_dir: Optional[str] = None
|
||||
soundfont_dir: Optional[str] = None
|
||||
plugin_dirs: Optional[list] = None
|
||||
|
||||
@router.get("/dirs")
|
||||
async def get_plugin_dirs():
|
||||
return {"success": True, **(_effective_dirs())}
|
||||
|
||||
@router.post("/dirs")
|
||||
async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)):
|
||||
enforce_password_changed(current_user)
|
||||
user = _load_plugin_dirs()
|
||||
if req.plugin_dirs is not None:
|
||||
user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()]
|
||||
# Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn
|
||||
user.pop("vst_dir", None)
|
||||
user.pop("soundfont_dir", None)
|
||||
else:
|
||||
if req.vst_dir is not None:
|
||||
user["vst_dir"] = req.vst_dir.strip()
|
||||
if req.soundfont_dir is not None:
|
||||
user["soundfont_dir"] = req.soundfont_dir.strip()
|
||||
_save_plugin_dirs(user)
|
||||
return {"success": True, **(_effective_dirs())}
|
||||
|
||||
@router.post("/scan")
|
||||
async def scan_plugin_dirs(background_tasks: BackgroundTasks = None,
|
||||
current_user: dict = Depends(get_current_user)):
|
||||
"""Scan các dir hiệu lực (env + user override): cập nhật catalog
|
||||
soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ
|
||||
VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo."""
|
||||
enforce_password_changed(current_user)
|
||||
dirs = _effective_dirs()
|
||||
plugin_dirs = dirs["plugin_dirs"]
|
||||
# SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực)
|
||||
scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR)
|
||||
if background_tasks:
|
||||
background_tasks.add_task(scanner.scan_once)
|
||||
else:
|
||||
scanner.scan_once()
|
||||
catalog = scanner.get_catalog()
|
||||
# VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension
|
||||
vst_found = []
|
||||
sf_found = []
|
||||
for d in plugin_dirs:
|
||||
if 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"):
|
||||
vst_found.append({"name": os.path.splitext(f)[0],
|
||||
"path": os.path.join(root, f),
|
||||
"dir": d,
|
||||
"type": "VST3" if low.endswith(".vst3") else "VST2"})
|
||||
elif low.endswith(".sf2") or low.endswith(".sf3"):
|
||||
sf_found.append({"name": os.path.splitext(f)[0],
|
||||
"path": os.path.join(root, f),
|
||||
"dir": d})
|
||||
return {
|
||||
"success": True,
|
||||
"plugin_dirs": plugin_dirs,
|
||||
"vst_found": vst_found,
|
||||
"vst_count": len(vst_found),
|
||||
"soundfonts": sf_found,
|
||||
"soundfont_count": len(sf_found),
|
||||
}
|
||||
|
||||
_inspector = None
|
||||
_scanner = None
|
||||
@@ -24,20 +138,24 @@ _scanner = None
|
||||
def get_inspector():
|
||||
global _inspector
|
||||
if _inspector is None:
|
||||
_inspector = SoundFontInspector(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR)
|
||||
d = _effective_dirs()
|
||||
_inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR,
|
||||
upload_sf_dir=UPLOAD_SF_DIR)
|
||||
return _inspector
|
||||
|
||||
def get_scanner():
|
||||
global _scanner
|
||||
if _scanner is None:
|
||||
_scanner = SoundFontAutoScanner(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR)
|
||||
d = _effective_dirs()
|
||||
_scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR)
|
||||
_scanner.scan_once()
|
||||
return _scanner
|
||||
|
||||
|
||||
@router.get("/available")
|
||||
async def list_plugins(current_user: dict = Depends(get_current_user)):
|
||||
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR)
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -29,4 +29,9 @@ class Settings:
|
||||
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
|
||||
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
|
||||
|
||||
# Plugin dirs — người dùng khai báo qua .env / docker-compose (Docker)
|
||||
# hoặc qua Plugins Manager (Windows/macOS, lưu theo user).
|
||||
VST_DIR: str = os.getenv("VST_DIR", "/opt/daw_engine/vst3")
|
||||
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", "/opt/daw_engine/soundfonts")
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRACK_FILE = os.path.join(settings.STORAGE_DIR, "sf_scan_state.json")
|
||||
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"
|
||||
SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
|
||||
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
||||
|
||||
|
||||
@@ -19,8 +19,11 @@ def _file_sig(path: str) -> tuple:
|
||||
|
||||
|
||||
class SoundFontAutoScanner:
|
||||
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR):
|
||||
self.system_sf_dir = system_sf_dir
|
||||
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR,
|
||||
system_sf_dirs=None):
|
||||
# system_sf_dirs (list) — nhiều thư mục user khai báo trong Plugins
|
||||
# Manager. Fallback system_sf_dir (env/.env) nếu list rỗng.
|
||||
self.system_sf_dirs = [d for d in (system_sf_dirs or []) if d] or [system_sf_dir]
|
||||
self.upload_sf_dir = upload_sf_dir
|
||||
self._catalog = {}
|
||||
self._lock = threading.Lock()
|
||||
@@ -50,6 +53,15 @@ class SoundFontAutoScanner:
|
||||
out.append((fname, os.path.join(directory, fname)))
|
||||
return out
|
||||
|
||||
def _all_sf_files(self) -> list:
|
||||
"""Gộp file .sf2/.sf3 từ TẤT CẢ thư mục hiệu lực (user dirs + upload)."""
|
||||
out = []
|
||||
for d in self.system_sf_dirs:
|
||||
out.extend(self._sf_files(d))
|
||||
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
||||
out.extend(self._sf_files(self.upload_sf_dir))
|
||||
return out
|
||||
|
||||
def _inspect_single(self, fname: str, full: str, inspector) -> dict:
|
||||
if fname.lower().endswith(".sf2"):
|
||||
sf_info = inspector.inspect_sf2_file(full) or {}
|
||||
@@ -68,9 +80,9 @@ class SoundFontAutoScanner:
|
||||
|
||||
def scan_once(self) -> bool:
|
||||
from app.core.soundfont_inspector import SoundFontInspector
|
||||
inspector = SoundFontInspector(self.system_sf_dir, self.upload_sf_dir)
|
||||
inspector = SoundFontInspector(self.system_sf_dirs[0], self.upload_sf_dir)
|
||||
found_new = False
|
||||
dirs = [(self.system_sf_dir, "system")]
|
||||
dirs = [(d, "system") for d in self.system_sf_dirs]
|
||||
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
||||
dirs.append((self.upload_sf_dir, "upload"))
|
||||
|
||||
|
||||
@@ -99,8 +99,13 @@ _PLUGIN_MANAGER_INSTANCE = None
|
||||
_PLUGIN_MANAGER_ARGS = None
|
||||
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
|
||||
|
||||
def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager":
|
||||
"""Singleton: reuse PluginManager when args match, else create new."""
|
||||
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)."""
|
||||
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"
|
||||
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
|
||||
args = (vst_dir, sf_dir, upload_sf_dir)
|
||||
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
|
||||
@@ -155,9 +160,10 @@ def release_soundfont(path: str):
|
||||
_FLUID_CACHE[path] = (fl, ref - 1)
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None):
|
||||
self.vst_dir = vst_dir
|
||||
self.sf_dir = sf_dir
|
||||
def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=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
|
||||
self._sf_scan_cache = None # cache for _scan_soundfonts()
|
||||
|
||||
|
||||
@@ -5284,14 +5284,71 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
|
||||
const [localData, setLocalData] = React.useState(pluginsData);
|
||||
const [sfUploadStatus, setSfUploadStatus] = React.useState('');
|
||||
const [sfToDelete, setSfToDelete] = React.useState(null);
|
||||
const [pmDirs, setPmDirs] = React.useState([]);
|
||||
const [pmScanning, setPmScanning] = React.useState(false);
|
||||
const [pmScanResult, setPmScanResult] = React.useState('');
|
||||
const [pmScanData, setPmScanData] = React.useState(null); // { vst_found, soundfonts }
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
window.SonicAPI.listPlugins()
|
||||
.then(data => setLocalData(data))
|
||||
.catch(() => setLocalData({ vst_instruments: [], soundfonts: [] }));
|
||||
window.SonicAPI.getPluginDirs()
|
||||
.then(d => setPmDirs(d.plugin_dirs || []))
|
||||
.catch(() => {});
|
||||
setTimeout(() => { try { window.lucide.createIcons(); } catch(e) {} }, 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
// Folder picker: Tauri v2 dialog qua invoke (window.__TAURI__.dialog KHONG
|
||||
// ton tai trong v2 — plugin dialog chi co JS binding qua npm; goi truc tiep
|
||||
// command 'plugin:dialog|open'). Fallback: prompt nhap path (browser).
|
||||
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;
|
||||
}
|
||||
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 (e) {
|
||||
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;
|
||||
@@ -5403,6 +5460,79 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
|
||||
)
|
||||
))
|
||||
),
|
||||
// Plugin directories section (folder picker + save + scan)
|
||||
React.createElement('div', {
|
||||
className: 'pt-4 mt-4 border-t border-[#383838]'
|
||||
},
|
||||
React.createElement('div', { className: 'flex items-center justify-between mb-2' },
|
||||
React.createElement('h4', { className: 'text-xs font-bold text-zinc-400 uppercase' },
|
||||
'Plugin Directories'),
|
||||
React.createElement('button', {
|
||||
className: 'px-3 py-1.5 bg-violet-800 hover:bg-violet-700 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0',
|
||||
title: 'Add plugin directory (VST / SoundFont)',
|
||||
onClick: pickPluginFolder
|
||||
},
|
||||
React.createElement('i', { 'data-lucide': 'plus', className: 'w-3 h-3' }), 'Add Directory')
|
||||
),
|
||||
pmDirs.length === 0 &&
|
||||
React.createElement('p', { className: 'text-[10px] text-zinc-600 mb-2 italic' },
|
||||
'Chưa có thư mục nào. Nhấn Add Directory để chọn thư mục chứa VST / SoundFont.'),
|
||||
pmDirs.map((dir, idx) =>
|
||||
React.createElement('div', {
|
||||
key: 'pdir_' + idx,
|
||||
className: 'flex items-center gap-2 mb-1.5 bg-zinc-800/70 border border-zinc-700 rounded px-2 py-1.5'
|
||||
},
|
||||
React.createElement('button', {
|
||||
className: 'text-zinc-500 hover:text-red-400 transition shrink-0',
|
||||
title: 'Remove directory',
|
||||
onClick: () => removePluginDir(dir)
|
||||
},
|
||||
React.createElement('i', { 'data-lucide': 'x', className: 'w-3.5 h-3.5' })),
|
||||
React.createElement('span', {
|
||||
className: 'flex-1 text-[11px] text-zinc-300 font-mono truncate',
|
||||
title: dir
|
||||
}, dir),
|
||||
React.createElement('i', {
|
||||
'data-lucide': 'folder',
|
||||
className: 'w-3 h-3 text-zinc-600 shrink-0'
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'flex gap-2 items-center mt-2' },
|
||||
React.createElement('button', {
|
||||
className: 'px-4 py-2 bg-emerald-800 hover:bg-emerald-700 text-white text-xs font-semibold rounded transition flex items-center gap-1',
|
||||
onClick: saveAndScanDirs
|
||||
},
|
||||
React.createElement('i', { 'data-lucide': 'search', className: 'w-3 h-3' }), 'Scan'),
|
||||
pmScanning && React.createElement('span', { className: 'text-[10px] text-emerald-400' }, 'Scanning...'),
|
||||
pmScanResult && React.createElement('span', { className: 'text-[10px] text-zinc-400' }, pmScanResult)
|
||||
),
|
||||
pmScanData && React.createElement('div', { className: 'mt-3 space-y-2 max-h-40 overflow-y-auto' },
|
||||
React.createElement('div', { className: 'text-[10px] font-bold text-violet-300 uppercase flex items-center gap-1' },
|
||||
React.createElement('i', { 'data-lucide': 'cpu', className: 'w-3 h-3' }),
|
||||
'VST Instruments (' + pmScanData.vst_found.length + ')'),
|
||||
pmScanData.vst_found.length === 0 ?
|
||||
React.createElement('p', { className: 'text-[10px] text-zinc-600 italic' }, 'Không tìm thấy VST.') :
|
||||
pmScanData.vst_found.map((v, i) =>
|
||||
React.createElement('div', { key: 'sv_' + i, className: 'flex items-center gap-2 text-[11px] text-zinc-300' },
|
||||
React.createElement('span', { className: 'w-16 shrink-0 text-zinc-500 font-mono text-[9px] truncate' }, v.type || 'VST'),
|
||||
React.createElement('span', { className: 'truncate' }, v.name),
|
||||
React.createElement('span', { className: 'text-[9px] text-zinc-600 font-mono truncate ml-auto' }, v.dir)
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'text-[10px] font-bold text-amber-300 uppercase flex items-center gap-1 mt-2' },
|
||||
React.createElement('i', { 'data-lucide': 'music', className: 'w-3 h-3' }),
|
||||
'SoundFonts (' + pmScanData.soundfonts.length + ')'),
|
||||
pmScanData.soundfonts.length === 0 ?
|
||||
React.createElement('p', { className: 'text-[10px] text-zinc-600 italic' }, 'Không tìm thấy SoundFont.') :
|
||||
pmScanData.soundfonts.map((s, i) =>
|
||||
React.createElement('div', { key: 'ss_' + i, className: 'flex items-center gap-2 text-[11px] text-zinc-300' },
|
||||
React.createElement('span', { className: 'truncate' }, s.name),
|
||||
React.createElement('span', { className: 'text-[9px] text-zinc-600 font-mono truncate ml-auto' }, s.dir)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
// Upload section (bottom of right panel)
|
||||
React.createElement('div', {
|
||||
className: 'pt-4 mt-4 border-t border-[#383838]'
|
||||
@@ -18000,6 +18130,33 @@ const App = () => {
|
||||
for (let i = 0; i < endSample - startSample; i++) {
|
||||
resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0;
|
||||
}
|
||||
} else if (effectType === 'invert_phase') {
|
||||
// DSP: đảo pha — nhân -1 toàn bộ vùng chọn/clip
|
||||
for (let i = startSample; i < endSample; i++) {
|
||||
resultData[i] = -resultData[i];
|
||||
}
|
||||
} else if (effectType === 'swap_channels') {
|
||||
// DSP: đảo kênh L/R — buffer 2 kênh (nếu có), hoán đổi dữ liệu
|
||||
const srcBuffer = subTab.buffer;
|
||||
if (srcBuffer.numberOfChannels >= 2) {
|
||||
const l = srcBuffer.getChannelData(0).slice();
|
||||
const r = srcBuffer.getChannelData(1).slice();
|
||||
const out = ctx.createBuffer(2, eff.length, sr);
|
||||
out.getChannelData(0).set(r);
|
||||
out.getChannelData(1).set(l);
|
||||
resultBuffer = out;
|
||||
resultData = resultBuffer.getChannelData(0);
|
||||
} else {
|
||||
// Mono → không đổi kênh được, giữ nguyên
|
||||
showToast('Buffer mono — không có kênh L/R để hoán đổi.', 'info');
|
||||
return;
|
||||
}
|
||||
} else if (effectType === 'reverse') {
|
||||
// DSP: đảo ngược thời gian vùng chọn/clip
|
||||
const seg = resultData.slice(startSample, endSample);
|
||||
for (let i = 0; i < seg.length; i++) {
|
||||
resultData[startSample + i] = seg[seg.length - 1 - i];
|
||||
}
|
||||
}
|
||||
|
||||
// Update subTab buffer state
|
||||
@@ -18012,7 +18169,11 @@ const App = () => {
|
||||
selectionEnd: null
|
||||
};
|
||||
}));
|
||||
showToast(`Đã áp dụng ${effectType === 'normalize' ? 'Normalize' : effectType === 'gain' ? 'Gain' : 'Pitch'} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success');
|
||||
const effectLabels = {
|
||||
normalize: 'Normalize', gain: 'Gain', pitch: 'Pitch',
|
||||
invert_phase: 'Phase Invert', swap_channels: 'Swap L/R', reverse: 'Reverse'
|
||||
};
|
||||
showToast(`Đã áp dụng ${effectLabels[effectType] || effectType} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success');
|
||||
};
|
||||
const exportSubTabBuffer = async tabId => {
|
||||
const subTab = subTabs.find(s => s.id === tabId);
|
||||
@@ -26466,7 +26627,17 @@ STRICT CONSTRAINTS:
|
||||
}, {
|
||||
label: 'DSP Tools Panel',
|
||||
icon: 'wrench',
|
||||
action: () => openPanel('python_tools')
|
||||
action: () => {
|
||||
// DSP Tool đã được move vào SUB-TAB editor (audioclip). Mở sub-tab
|
||||
// edit cho track/clip đang chọn — nếu chưa có clip → mở panel cũ.
|
||||
const t = activeTracks.find(x => x.id === selectedTrackId);
|
||||
if (t && (t.buffer || (t.clips && t.clips.length))) {
|
||||
const clipId = (t.clips && t.clips[0]) ? t.clips[0].id : 'default';
|
||||
handleEditClipInSubTab(t.id, clipId);
|
||||
} else {
|
||||
openPanel('python_tools');
|
||||
}
|
||||
}
|
||||
}, {
|
||||
sep: true
|
||||
}, {
|
||||
@@ -28540,6 +28711,36 @@ STRICT CONSTRAINTS:
|
||||
className: "w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono",
|
||||
title: "Loop count"
|
||||
}))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase mt-2"
|
||||
}, /*#__PURE__*/React.createElement("span", null, "DSP"), /*#__PURE__*/React.createElement("span", {
|
||||
className: "font-mono text-zinc-600 text-[11px] normal-case"
|
||||
}, "áp dụng vùng chọn / cả clip")), /*#__PURE__*/React.createElement("div", {
|
||||
className: "grid grid-cols-3 gap-1 mb-2"
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => applySubTabEffect(st.id, 'invert_phase', 0),
|
||||
className: "py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "arrow-down-up",
|
||||
className: "w-3 h-3"
|
||||
})), "Phase Inv"), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => applySubTabEffect(st.id, 'swap_channels', 0),
|
||||
className: "py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "shuffle",
|
||||
className: "w-3 h-3"
|
||||
})), "Swap L/R"), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => applySubTabEffect(st.id, 'reverse', 0),
|
||||
className: "py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "arrow-left-right",
|
||||
className: "w-3 h-3"
|
||||
})), "Reverse")), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex gap-1 justify-between my-2.5"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"
|
||||
@@ -29099,7 +29300,9 @@ STRICT CONSTRAINTS:
|
||||
className: "w-3 h-3 text-zinc-600"
|
||||
})), prHint ? /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-cyan-400"
|
||||
}, prHint) : " Scroll: Zoom"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", {
|
||||
}, prHint) : /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-zinc-600 italic"
|
||||
}, "Adaptive tips: hover vào vùng làm việc để xem hướng dẫn")))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", {
|
||||
className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto",
|
||||
style: {
|
||||
left: Math.min(contextMenu.x, window.innerWidth - 260),
|
||||
|
||||
@@ -64,6 +64,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }),
|
||||
|
||||
listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }),
|
||||
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' }),
|
||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
# Kill moi daw_engine con song tu build/truoc (windowed, khong console ->
|
||||
# de quen -> file exe dang chay bi khoa -> Tauri build loi PermissionDenied
|
||||
# khi doc externalBin). Stop-Process im lang neu khong co tien trinh nao.
|
||||
Write-Host "== [0/6] Kill daw_engine.exe cu (neu dang chay) =="
|
||||
Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
Write-Host "== [1/6] Python dependencies =="
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements.txt pyinstaller pywin32
|
||||
@@ -43,9 +50,19 @@ if ($LASTEXITCODE -ne 0) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "== [4/6] Sidecar binary -> src-tauri/binaries (tauri triple naming) =="
|
||||
New-Item -ItemType Directory -Force src-tauri\binaries | Out-Null
|
||||
Copy-Item dist\daw_engine.exe src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe -Force
|
||||
Write-Host "== [4/6] ONEDIR engine -> src-tauri/resources/daw_engine (Tauri resources) =="
|
||||
# ONEDIR: copy ca thu muc dist\daw_engine\ (exe + _internal) vao resources.
|
||||
# Tauri bundle resources -> resource_dir()/daw_engine/daw_engine.exe luc runtime.
|
||||
if (-not (Test-Path "dist\daw_engine\daw_engine.exe")) {
|
||||
Write-Host "ERROR: dist\daw_engine\daw_engine.exe khong ton tai (onedir build loi?)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path "src-tauri\resources\daw_engine") {
|
||||
Remove-Item -Recurse -Force "src-tauri\resources\daw_engine"
|
||||
}
|
||||
New-Item -ItemType Directory -Force "src-tauri\resources\daw_engine" | Out-Null
|
||||
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
|
||||
Write-Host "Copied onedir engine -> src-tauri\resources\daw_engine"
|
||||
|
||||
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
|
||||
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
||||
@@ -53,6 +70,16 @@ if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
||||
}
|
||||
|
||||
Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) =="
|
||||
# Guard: resources/daw_engine PHAI co exe + _internal truoc khi tauri build
|
||||
# (glob trong tauri.conf.json fail ngay "path not found" neu thieu).
|
||||
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) {
|
||||
Write-Host "ERROR: src-tauri\resources\daw_engine\daw_engine.exe khong co - buoc [4/6] that bai?" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) {
|
||||
Write-Host "ERROR: thieu src-tauri\resources\daw_engine\_internal (onedir khong day du)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
npm install -D @tauri-apps/cli
|
||||
npx tauri build
|
||||
|
||||
|
||||
@@ -10,13 +10,15 @@ services:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
||||
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
- VST_DIR=/opt/daw_engine/vst3
|
||||
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
@@ -25,13 +27,15 @@ services:
|
||||
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
|
||||
volumes:
|
||||
- .:/app
|
||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
||||
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
- VST_DIR=/opt/daw_engine/vst3
|
||||
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
@@ -40,12 +44,14 @@ services:
|
||||
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
|
||||
volumes:
|
||||
- .:/app
|
||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
||||
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
- VST_DIR=/opt/daw_engine/vst3
|
||||
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
@@ -112,10 +112,8 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='daw_engine',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
@@ -124,5 +122,18 @@ exe = EXE(
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False, # True khi debug (xem log truc tiep), False cho production
|
||||
icon='src-tauri/icons/icon.ico',
|
||||
# Icon exe = favicon cua app (app/templates/favicon.svg -> render PNG -> ICO,
|
||||
# sinh boi tools/gen_favicon_ico.py). Cung nguon voi icon hien thi trong app.
|
||||
icon='src-tauri/icons/favicon.ico',
|
||||
)
|
||||
|
||||
# ONEDIR (khong phai onefile): exe 700MB+ onefile phai giai nen toan bo vao
|
||||
# %TEMP% moi lan chay -> load RAT CHAM tren Windows. Onedir chay truc tiep tu
|
||||
# thu muc (bundle qua Tauri resources), khoi dong gan nhu tuc thi.
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
name='daw_engine',
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"identifier": "default",
|
||||
"description": "Default capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": ["core:default", "dialog:default"]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1009 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 459 B After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1 @@
|
||||
PLACEHOLDER - duoc thay boi build_windows.ps1 buoc [4/6] (copy dist\daw_engine)
|
||||
@@ -10,16 +10,27 @@ struct EngineProcess(Mutex<Option<CommandChild>>);
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
// 1. Spawn sidecar daw_engine.exe (PyInstaller bundle)
|
||||
let sidecar_command = app
|
||||
// 1. Spawn daw_engine (PyInstaller ONEDIR bundle — khong giai nen
|
||||
// moi lan chay nhu onefile, khoi dong nhanh). Bundle qua Tauri
|
||||
// resources: resource_dir()/daw_engine/daw_engine(.exe)
|
||||
let res_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.expect("resource dir not found");
|
||||
let engine_dir = res_dir.join("daw_engine");
|
||||
let engine_exe = if cfg!(windows) {
|
||||
engine_dir.join("daw_engine.exe")
|
||||
} else {
|
||||
engine_dir.join("daw_engine")
|
||||
};
|
||||
let (_rx, child) = app
|
||||
.shell()
|
||||
.sidecar("daw_engine")
|
||||
.expect("sidecar daw_engine not found — run build_windows.ps1 first");
|
||||
let (_rx, child) = sidecar_command
|
||||
.command(engine_exe)
|
||||
.env("SF_PARENT_PID", std::process::id().to_string())
|
||||
.spawn()
|
||||
.expect("Failed to spawn daw_engine sidecar");
|
||||
.expect("Failed to spawn daw_engine (onedir resource) — run build_windows.ps1 first");
|
||||
|
||||
app.manage(EngineProcess(Mutex::new(Some(child))));
|
||||
println!("Python Background Engine started (localhost:8000, auto-fallback 8000-8010)");
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"devUrl": "http://localhost:8000"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Sonic Forge DAW - Professional Desktop Studio",
|
||||
@@ -29,11 +30,12 @@
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
"icons/favicon.ico"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/daw_engine"
|
||||
"resources": [
|
||||
"resources/daw_engine"
|
||||
],
|
||||
"externalBin": [],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "hooks.nsh"
|
||||
|
||||
@@ -93,3 +93,55 @@ class TestPluginAPI:
|
||||
assert data["size_bytes"] == len(valid_content)
|
||||
elif resp.status_code == 403:
|
||||
pytest.skip("Permission denied for admin user")
|
||||
|
||||
def test_plugin_dirs_save_list(self):
|
||||
"""plugin_dirs (list) — save/get/effective + scan phân loại riêng rẽ."""
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 2 dir giả: 1 chứa VST, 1 chứa SoundFont
|
||||
vst_dir = os.path.join(td, "vsts")
|
||||
sf_dir = os.path.join(td, "sfs")
|
||||
os.makedirs(vst_dir)
|
||||
os.makedirs(sf_dir)
|
||||
open(os.path.join(vst_dir, "Synth1.vst3"), "w").write("x")
|
||||
open(os.path.join(sf_dir, "piano.sf2"), "w").write("x")
|
||||
# Save list
|
||||
r = client.post("/api/v1/plugins/dirs", headers=h,
|
||||
json={"plugin_dirs": [vst_dir, sf_dir]})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["plugin_dirs"] == [vst_dir, sf_dir]
|
||||
# Get lại
|
||||
g = client.get("/api/v1/plugins/dirs", headers=h)
|
||||
assert g.json()["plugin_dirs"] == [vst_dir, sf_dir]
|
||||
# Scan → phân loại riêng rẽ
|
||||
s = client.post("/api/v1/plugins/scan", headers=h)
|
||||
assert s.status_code == 200
|
||||
data = s.json()
|
||||
assert any(v["name"] == "Synth1" for v in data["vst_found"])
|
||||
assert any(x["name"] == "piano" for x in data["soundfonts"])
|
||||
assert data["vst_count"] == 1
|
||||
assert data["soundfont_count"] == 1
|
||||
# Mỗi entry có dir gốc
|
||||
assert data["vst_found"][0]["dir"] == vst_dir
|
||||
assert data["soundfonts"][0]["dir"] == sf_dir
|
||||
|
||||
def test_plugin_dirs_remove(self):
|
||||
"""Xóa 1 dir khỏi list → save lại → không còn trong effective."""
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
d1 = os.path.join(td, "d1")
|
||||
d2 = os.path.join(td, "d2")
|
||||
os.makedirs(d1)
|
||||
os.makedirs(d2)
|
||||
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1, d2]})
|
||||
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1]})
|
||||
g = client.get("/api/v1/plugins/dirs", headers=h)
|
||||
assert g.json()["plugin_dirs"] == [d1]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Sinh src-tauri/icons/favicon.ico tu app/templates/favicon.svg (icon exe).
|
||||
// Dung: node tools/gen_favicon_ico.js
|
||||
// (Can @resvg/resvg-js — npm install @resvg/resvg-js trong thu muc lam viec,
|
||||
// hoac chay trong thu muc da cai. Output: src-tauri/icons/favicon.ico.)
|
||||
// ICO chua cac size 16/24/32/48/64/128 — Windows dung cho exe icon.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SVG = path.join(ROOT, 'app', 'templates', 'favicon.svg');
|
||||
const OUT = path.join(ROOT, 'src-tauri', 'icons', 'favicon.ico');
|
||||
|
||||
let Resvg;
|
||||
try {
|
||||
({ Resvg } = require('@resvg/resvg-js'));
|
||||
} catch (e) {
|
||||
console.error('Thieu @resvg/resvg-js. Chay: npm install @resvg/resvg-js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const svg = fs.readFileSync(SVG, 'utf8');
|
||||
const SIZES = [16, 24, 32, 48, 64, 128];
|
||||
|
||||
function buildIco(images) {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0);
|
||||
header.writeUInt16LE(1, 2);
|
||||
header.writeUInt16LE(images.length, 4);
|
||||
const entries = [];
|
||||
const datas = [];
|
||||
let offset = 6 + 16 * images.length;
|
||||
for (const { size, data } of images) {
|
||||
const entry = Buffer.alloc(16);
|
||||
const dim = size >= 256 ? 0 : size;
|
||||
entry.writeUInt8(dim, 0);
|
||||
entry.writeUInt8(dim, 1);
|
||||
entry.writeUInt8(0, 2);
|
||||
entry.writeUInt8(0, 3);
|
||||
entry.writeUInt16LE(1, 4);
|
||||
entry.writeUInt16LE(32, 6);
|
||||
entry.writeUInt32LE(data.length, 8);
|
||||
entry.writeUInt32LE(offset, 12);
|
||||
entries.push(entry);
|
||||
datas.push(data);
|
||||
offset += data.length;
|
||||
}
|
||||
return Buffer.concat([header, ...entries, ...datas]);
|
||||
}
|
||||
|
||||
const pngs = SIZES.map(s => ({
|
||||
size: s,
|
||||
data: new Resvg(svg, { fitTo: { mode: 'width', value: s }, background: 'rgba(0,0,0,0)' }).render().asPng(),
|
||||
}));
|
||||
fs.writeFileSync(OUT, buildIco(pngs));
|
||||
console.log('favicon.ico ->', OUT, fs.statSync(OUT).size, 'bytes');
|
||||