feat: auto-scan soundfont for Synth instrument list

Background daemon (30s interval) tracks scanned files via
sf_scan_state.json (size+mtime). Only inspects new/changed SFs.
Catalog served from scanner cache, no full re-scan per request.
Upload/delete trigger immediate re-scan.
This commit is contained in:
2026-07-28 07:06:37 +07:00
parent e68fe27d7f
commit d0def4bf1e
6 changed files with 150 additions and 11 deletions
+2
View File
@@ -17,6 +17,8 @@ app/storage/uploads/*
app/storage/processed/* app/storage/processed/*
!app/storage/uploads/.gitkeep !app/storage/uploads/.gitkeep
!app/storage/processed/.gitkeep !app/storage/processed/.gitkeep
app/storage/*.db
app/storage/sf_scan_state.json
.DS_Store .DS_Store
# VST3 and sample library directories (proprietary binaries) # VST3 and sample library directories (proprietary binaries)
+17 -8
View File
@@ -8,6 +8,7 @@ from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
from app.core.render_engine import PythonRenderEngine from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner
from app.api.v1.auth import get_current_user from app.api.v1.auth import get_current_user
router = APIRouter() router = APIRouter()
@@ -18,6 +19,7 @@ os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts" SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"
_inspector = None _inspector = None
_scanner = None
def get_inspector(): def get_inspector():
global _inspector global _inspector
@@ -25,6 +27,13 @@ def get_inspector():
_inspector = SoundFontInspector(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR) _inspector = SoundFontInspector(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR)
return _inspector 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)
_scanner.scan_once()
return _scanner
@router.get("/available") @router.get("/available")
async def list_plugins(current_user: dict = Depends(get_current_user)): async def list_plugins(current_user: dict = Depends(get_current_user)):
@@ -50,9 +59,10 @@ async def list_default_soundfonts():
@router.get("/soundfonts/catalog") @router.get("/soundfonts/catalog")
async def soundfont_catalog(current_user: dict = Depends(get_current_user)): async def soundfont_catalog(current_user: dict = Depends(get_current_user)):
scanner = get_scanner()
full_catalog = scanner.get_catalog()
inspector = get_inspector() inspector = get_inspector()
full_catalog = inspector.get_catalog() condensed_catalog = inspector.get_condensed_catalog_summary(full_catalog)
condensed_catalog = inspector.get_condensed_catalog_summary()
return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog} return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog}
@@ -91,11 +101,11 @@ async def upload_soundfont(
import json import json
json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f) json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f)
inspector = get_inspector() scanner = get_scanner()
inspector.invalidate_catalog_cache()
if background_tasks: if background_tasks:
background_tasks.add_task(inspector.generate_full_catalog) background_tasks.add_task(scanner.scan_once)
else:
scanner.scan_once()
return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)} return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)}
@@ -122,8 +132,7 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
break break
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="SoundFont not found") raise HTTPException(status_code=404, detail="SoundFont not found")
inspector = get_inspector() get_scanner().scan_once()
inspector.invalidate_catalog_cache()
return {"deleted": True, "sf_id": sf_id} return {"deleted": True, "sf_id": sf_id}
+2 -2
View File
@@ -104,8 +104,8 @@ class SoundFontInspector:
return self._catalog_cache return self._catalog_cache
return self.generate_full_catalog() return self.generate_full_catalog()
def get_condensed_catalog_summary(self) -> dict: def get_condensed_catalog_summary(self, catalog: dict = None) -> dict:
catalog = self.get_catalog() catalog = catalog if catalog is not None else self.get_catalog()
condensed = {} condensed = {}
for sf_id, sf_info in catalog.items(): for sf_id, sf_info in catalog.items():
instruments = sf_info.get("instruments", []) instruments = sf_info.get("instruments", [])
+117
View File
@@ -0,0 +1,117 @@
import os
import json
import time
import logging
import threading
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"
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
def _file_sig(path: str) -> tuple:
s = os.path.getsize(path)
m = os.path.getmtime(path)
return (s, m)
class SoundFontAutoScanner:
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR):
self.system_sf_dir = system_sf_dir
self.upload_sf_dir = upload_sf_dir
self._catalog = {}
self._lock = threading.Lock()
self._state = self._load_state()
def _load_state(self) -> dict:
if not os.path.exists(TRACK_FILE):
return {}
try:
with open(TRACK_FILE) as f:
return json.load(f)
except Exception as e:
logger.warning("scan_state load failed: %s", e)
return {}
def _save_state(self):
os.makedirs(os.path.dirname(TRACK_FILE), exist_ok=True)
with open(TRACK_FILE, "w") as f:
json.dump(self._state, f, indent=2)
def _sf_files(self, directory: str) -> list:
if not os.path.isdir(directory):
return []
out = []
for fname in os.listdir(directory):
if fname.lower().endswith((".sf2", ".sf3")):
out.append((fname, os.path.join(directory, fname)))
return out
def scan_once(self) -> bool:
from app.core.soundfont_inspector import SoundFontInspector
inspector = SoundFontInspector(self.system_sf_dir, self.upload_sf_dir)
found_new = False
dirs = [(self.system_sf_dir, "system")]
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
dirs.append((self.upload_sf_dir, "upload"))
for directory, source in dirs:
for fname, full in self._sf_files(directory):
sf_id = os.path.splitext(fname)[0].lower()
key = f"{source}:{sf_id}"
sig = _file_sig(full)
prev = self._state.get(key)
if prev and prev["size"] == sig[0] and prev["mtime"] == sig[1]:
if sf_id in self._catalog:
continue
found_new = True
logger.info("New/changed SF detected: %s", fname)
if fname.lower().endswith(".sf2"):
sf_info = inspector.inspect_sf2_file(full) or {}
else:
sf_info = {}
if not sf_info.get("soundfont_id"):
sf_info = {
"soundfont_id": sf_id,
"filename": fname,
"total_instruments": 0,
"instruments": [],
"_sf3": True
}
with self._lock:
self._catalog[sf_id] = sf_info
self._state[key] = {"size": sig[0], "mtime": sig[1], "file": fname}
if found_new:
self._save_state()
inspector.invalidate_catalog_cache()
return found_new
def scan_loop(self, interval: int = 30, stop_event: threading.Event = None):
logger.info("SF auto-scanner started (interval=%ds)", interval)
self.scan_once()
while not (stop_event and stop_event.is_set()):
time.sleep(interval)
try:
self.scan_once()
except Exception as e:
logger.error("scan cycle error: %s", e)
def start_background(self, interval: int = 30) -> threading.Event:
ev = threading.Event()
t = threading.Thread(target=self.scan_loop, args=(interval, ev), daemon=True)
t.start()
return ev
def get_catalog(self) -> dict:
with self._lock:
return dict(self._catalog)
def get_instruments(self, sf_id: str) -> list:
entry = self._catalog.get(sf_id)
if entry:
return entry.get("instruments", [])
return []
+6
View File
@@ -15,6 +15,7 @@ from app.api.v1.ai_proxy import router as ai_proxy_router
from app.api.v1.plugins import router as plugins_router from app.api.v1.plugins import router as plugins_router
from app.core.auth import seed_admin from app.core.auth import seed_admin
from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner
# Ensure storage directories exist # Ensure storage directories exist
os.makedirs(settings.UPLOADS_DIR, exist_ok=True) os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
@@ -65,6 +66,11 @@ async def startup_convert_soundfonts():
print(f"[Startup] SoundFont conversion error: {e}") print(f"[Startup] SoundFont conversion error: {e}")
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()
@app.on_event("startup")
async def startup_sf_scanner():
scanner = SoundFontAutoScanner()
scanner.start_background(interval=30)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def get_index(): async def get_index():
index_path = os.path.join(settings.TEMPLATES_DIR, "index.html") index_path = os.path.join(settings.TEMPLATES_DIR, "index.html")
+6 -1
View File
@@ -468,7 +468,12 @@
- **Các file ảnh hưởng:** `app/static/js/services/ghostNoteExtractor.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html` - **Các file ảnh hưởng:** `app/static/js/services/ghostNoteExtractor.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Kiểm tra dropdown list đúng tất cả MIDI items. Switch item → ghost notes của item cũ hiện ra. Nút 🌐 Session/📋 Isolated chuyển chế độ. 👻 Ghost toggle chỉ hoạt động ở Session mode. - **Ghi chú/Test (nếu có):** Kiểm tra dropdown list đúng tất cả MIDI items. Switch item → ghost notes của item cũ hiện ra. Nút 🌐 Session/📋 Isolated chuyển chế độ. 👻 Ghost toggle chỉ hoạt động ở Session mode.
### [2026-07-27 20:28] Task: Verify instrument assignment rules for MAIN SESSION ### [2026-07-28 07:03] Task: Auto-scan soundfont cho Synth button
- **Tóm tắt thay đổi:** Thêm `SoundFontAutoScanner` — background daemon thread quét thư mục `/opt/daw_engine/soundfonts``app/storage/soundfonts` mỗi 30s. Dùng `sf_scan_state.json` để track file đã quét (theo size+mtime), chỉ inspect file mới/thay đổi. Catalog dùng scanner cache thay vì regenerate từ đầu mỗi request. `/soundfonts/catalog` API dùng scanner catalog. Upload/delete trigger scan tức thì. Thêm `.gitignore` cho `*.db``sf_scan_state.json`.
- **Các file ảnh hưởng:** `app/core/soundfont_scanner.py` (NEW), `app/api/v1/plugins.py`, `app/core/soundfont_inspector.py`, `app/main.py`, `.gitignore`
- **Ghi chú/Test (nếu có):** `python3 -c "from app.core.soundfont_scanner import SoundFontAutoScanner; s=SoundFontAutoScanner(); print(s.scan_once())"` — scan hoạt động. Condensed catalog dùng scanner's full_catalog tránh duplicate scan.
---
- **Tóm tắt thay đổi:** Verify 4 rules: (1) MIDI item nhận instrument từ track cha qua `handleSwitchMidiItem` copy `instrumentProgram` từ track → sub-tab; (2) Section item KHÔNG nhận instrument (reset về null khi clone tracks trong `handleEditSectionInTab`); (3) Track khác không bị ảnh hưởng do `setTrackInstrumentWithProgram` filter by `trackId`; (4) Đổi instrument trong PianoRoll tab → áp dụng ngược lại cho track qua `openInstrumentSelector``setTrackInstrumentWithProgram`. Tất cả đều OK, không bug. - **Tóm tắt thay đổi:** Verify 4 rules: (1) MIDI item nhận instrument từ track cha qua `handleSwitchMidiItem` copy `instrumentProgram` từ track → sub-tab; (2) Section item KHÔNG nhận instrument (reset về null khi clone tracks trong `handleEditSectionInTab`); (3) Track khác không bị ảnh hưởng do `setTrackInstrumentWithProgram` filter by `trackId`; (4) Đổi instrument trong PianoRoll tab → áp dụng ngược lại cho track qua `openInstrumentSelector``setTrackInstrumentWithProgram`. Tất cả đều OK, không bug.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (verify lines 4642-4667, 5905, 6763-6807, 8422-8424), `app/core/render_engine.py` (verify lines 95-115) - **Các file ảnh hưởng:** `app/static/js/app.jsx` (verify lines 4642-4667, 5905, 6763-6807, 8422-8424), `app/core/render_engine.py` (verify lines 95-115)
- **Ghi chú/Test (nếu có):** No code changes needed. All 4 rules confirmed working. - **Ghi chú/Test (nếu có):** No code changes needed. All 4 rules confirmed working.