From d0def4bf1ec2ac06f9d4e385f8285bd3943606f6 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 28 Jul 2026 07:06:37 +0700 Subject: [PATCH] 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. --- .gitignore | 2 + app/api/v1/plugins.py | 25 ++++--- app/core/soundfont_inspector.py | 4 +- app/core/soundfont_scanner.py | 117 ++++++++++++++++++++++++++++++++ app/main.py | 6 ++ wiki.md | 7 +- 6 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 app/core/soundfont_scanner.py diff --git a/.gitignore b/.gitignore index 1d5cf58..7f20662 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ app/storage/uploads/* app/storage/processed/* !app/storage/uploads/.gitkeep !app/storage/processed/.gitkeep +app/storage/*.db +app/storage/sf_scan_state.json .DS_Store # VST3 and sample library directories (proprietary binaries) diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index ea49b39..e65f74a 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -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.soundfont_inspector import SoundFontInspector from app.core.soundfont_converter import SoundFontConverter +from app.core.soundfont_scanner import SoundFontAutoScanner from app.api.v1.auth import get_current_user router = APIRouter() @@ -18,6 +19,7 @@ os.makedirs(UPLOAD_SF_DIR, exist_ok=True) SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts" _inspector = None +_scanner = None def get_inspector(): global _inspector @@ -25,6 +27,13 @@ def get_inspector(): _inspector = SoundFontInspector(system_sf_dir=SYSTEM_SF_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) + _scanner.scan_once() + return _scanner + @router.get("/available") async def list_plugins(current_user: dict = Depends(get_current_user)): @@ -50,9 +59,10 @@ async def list_default_soundfonts(): @router.get("/soundfonts/catalog") async def soundfont_catalog(current_user: dict = Depends(get_current_user)): + scanner = get_scanner() + full_catalog = scanner.get_catalog() inspector = get_inspector() - full_catalog = inspector.get_catalog() - condensed_catalog = inspector.get_condensed_catalog_summary() + condensed_catalog = inspector.get_condensed_catalog_summary(full_catalog) return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog} @@ -91,11 +101,11 @@ async def upload_soundfont( import json json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f) - inspector = get_inspector() - inspector.invalidate_catalog_cache() + scanner = get_scanner() 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)} @@ -122,8 +132,7 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_ break if not deleted: raise HTTPException(status_code=404, detail="SoundFont not found") - inspector = get_inspector() - inspector.invalidate_catalog_cache() + get_scanner().scan_once() return {"deleted": True, "sf_id": sf_id} diff --git a/app/core/soundfont_inspector.py b/app/core/soundfont_inspector.py index f0d18b5..cd8a9dd 100644 --- a/app/core/soundfont_inspector.py +++ b/app/core/soundfont_inspector.py @@ -104,8 +104,8 @@ class SoundFontInspector: return self._catalog_cache return self.generate_full_catalog() - def get_condensed_catalog_summary(self) -> dict: - catalog = self.get_catalog() + def get_condensed_catalog_summary(self, catalog: dict = None) -> dict: + catalog = catalog if catalog is not None else self.get_catalog() condensed = {} for sf_id, sf_info in catalog.items(): instruments = sf_info.get("instruments", []) diff --git a/app/core/soundfont_scanner.py b/app/core/soundfont_scanner.py new file mode 100644 index 0000000..036ce03 --- /dev/null +++ b/app/core/soundfont_scanner.py @@ -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 [] diff --git a/app/main.py b/app/main.py index 11dc1d3..4a61687 100644 --- a/app/main.py +++ b/app/main.py @@ -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.core.auth import seed_admin from app.core.soundfont_converter import SoundFontConverter +from app.core.soundfont_scanner import SoundFontAutoScanner # Ensure storage directories exist os.makedirs(settings.UPLOADS_DIR, exist_ok=True) @@ -65,6 +66,11 @@ async def startup_convert_soundfonts(): print(f"[Startup] SoundFont conversion error: {e}") 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) async def get_index(): index_path = os.path.join(settings.TEMPLATES_DIR, "index.html") diff --git a/wiki.md b/wiki.md index 2d71b97..664be4c 100644 --- a/wiki.md +++ b/wiki.md @@ -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` - **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` và `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` và `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. - **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.