diff --git a/index.html b/index.html new file mode 100644 index 0000000..9b37c45 --- /dev/null +++ b/index.html @@ -0,0 +1,1168 @@ + + + + + +Pedal v2 + + + + + +
+
+
Guitar
+
โ€”
+
80%
+ + +
+ + +
+
+
NAM
+
No Model Loaded
+
โ€”
+
+
+
+
+
+
+
+
+
+ Vol +
+
+
+
+ 80% +
+
+ + +
+ + + + + + +
+ + +
+ +
+ + +
+
๐ŸŽธ Presets
+
+
+ + +
+
๐Ÿ“ธ Snapshots
+
+
+
+
+
+ + +
+
โฌ‡ Downloads
+ +
+ + +
+
+ + + + +
+ +
+ + + + + + +
+
+
+ + +
+
+
+
+ + +
+
Model
+
+
+
+
+ + +
+
โš™ Settings
+ +
+ + + + + +
+
+
Network
+
๐ŸŒ Eth IPโ€”
+
๐Ÿ”— MACโ€”
+
๐Ÿšช Gatewayโ€”
+
๐ŸŒ DNSโ€”
+
๐ŸŒ IP ModeDHCP
+
๐Ÿ”— Hostnamepedal
+
๐Ÿ“ถ WiFiโ€”
+
+ +
๐Ÿ”ฅ HotspotOff
+
+
Audio
+
๐ŸŽ› Sample Rate + +
+
๐Ÿ“ฆ Buffer Size + +
+
๐ŸŽธ InstrumentGuitar
+
๐Ÿ”„ Channel ModeDual Mono
+
๐Ÿ”— Routing4CM
+
๐ŸŽต Backing Track + +
+
+
๐ŸŽš Audio Interface
+
๐Ÿ”Œ Input Device
+
๐Ÿ”Š Output Device
+
+
+ ๐Ÿ”ด CH 1 โ€” Input 1/L + +
+
+ Instrument: + +
+
+ Output: + +
+
+
+
+ ๐ŸŸข CH 2 โ€” Input 2/R + +
+
+ Instrument: + +
+
+ Output: + +
+
+
Dual Mono: each channel has independent presets. Same instrument = shared presets.
+
+
Bluetooth
+
๐Ÿ”ต Statusโ€”
+
๐ŸŽ› MIDIOff
+
๐Ÿ”“ DiscoverableOn
+
+
System
+
๐Ÿ”ง CPU0%
+
๐ŸŒก Tempโ€”
+
โฑ Uptimeโ€”
+
๐Ÿ’พ Diskโ€”
+
๐Ÿง  RAMโ€”
+
โ†บ Reset
+
๐ŸŽ› NAM Enginecpp
+
๐Ÿ”„ Service
+
โป Reboot
+
+
+
+ + +
+
โ€”
+
โ™ญ
โ™ฏ
+
0.0 Hz
+ +
+ + +
+ + + + diff --git a/main.py b/main.py index e30c582..f118ef0 100644 --- a/main.py +++ b/main.py @@ -25,7 +25,7 @@ import yaml from src.system.audio import AudioConfig, AudioSystem, JackAudioClient, _jack_is_running from src.system.defaults import ensure_defaults_exist from src.dsp.pipeline import AudioPipeline -from src.dsp.nam_engine_host import FastNAMHost +from src.dsp.nam_router import NAMEngineRouter from src.dsp.ir_loader import IRLoader from src.presets.manager import PresetManager from src.presets.types import Channel, MIDIMapping, Preset @@ -69,6 +69,7 @@ class PedalApp: def __init__(self, config_path: Path = DEFAULT_CONFIG_PATH) -> None: self._config = load_config(config_path) + self._config_path = config_path # โ”€โ”€ Runtime state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ self._running = False @@ -79,7 +80,7 @@ class PedalApp: self.audio_config: AudioConfig | None = None self.audio_system: AudioSystem | None = None self.jack_audio: JackAudioClient | None = None - self.nam_host: FastNAMHost | None = None + self.nam_host: NAMEngineRouter | None = None self.ir_loader: IRLoader | None = None self.pipeline: AudioPipeline | None = None self.presets: PresetManager | None = None @@ -124,6 +125,8 @@ class PedalApp: output_device=acfg.get("output_device", "hw:0,0"), jack_enabled=acfg.get("jack_enabled", True), auto_connect=acfg.get("auto_connect", True), + period=acfg.get("period"), + rate=acfg.get("rate"), ) self.audio_system = AudioSystem(self.audio_config) self.audio_system.setup_i2s(reboot_hint=True) @@ -134,7 +137,7 @@ class PedalApp: # โ”€โ”€ 2. DSP pipeline (NAM + IR + FX chain) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ block_size = self.audio_config.latency_profile["period"] - self.nam_host = FastNAMHost(block_size=block_size) + self.nam_host = NAMEngineRouter(block_size=block_size) self.ir_loader = IRLoader() self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader) self.nam_host.warm_up() @@ -251,6 +254,8 @@ class PedalApp: ir_loader=self.ir_loader, audio_system=self.audio_system, jack_audio=self.jack_audio, + config=self._config, + config_path=self._config_path, ), host="0.0.0.0", port=80, @@ -681,6 +686,29 @@ def main() -> int: Returns exit code 0 on clean shutdown, 1 on boot failure. """ + # Lock process memory early to prevent page faults in RT callback + try: + import ctypes + libc = ctypes.CDLL('libc.so.6') + # MCL_CURRENT | MCL_FUTURE = 1 | 2 = 3 + if libc.mlockall(3) == 0: + logger.info("mlockall() OK โ€” process memory locked") + else: + logger.warning("mlockall() failed โ€” check LimitMEMLOCK in systemd unit") + except Exception as exc: + logger.warning("mlockall() not available: %s", exc) + + # Set CPU governor to performance for stable RT audio + try: + for c in range(4): # RPi 4B has 4 cores + gov_path = f"/sys/devices/system/cpu/cpu{c}/cpufreq/scaling_governor" + if os.path.exists(gov_path): + with open(gov_path, "w") as f: + f.write("performance") + logger.info("CPU%d governor set to performance", c) + except Exception as exc: + logger.warning("Could not set CPU governor (non-root?): %s", exc) + import argparse parser = argparse.ArgumentParser( diff --git a/src/dsp/nam_router.py b/src/dsp/nam_router.py new file mode 100644 index 0000000..c13e4be --- /dev/null +++ b/src/dsp/nam_router.py @@ -0,0 +1,195 @@ +"""NAM engine router โ€” switch between C++ subprocess and PyTorch in-process. + +Provides a unified interface that wraps either FastNAMHost (C++ subprocess) +or NAMHost (PyTorch in-process) and allows runtime switching. + +Usage: + router = NAMEngineRouter(engine_mode='cpp') # or 'pytorch' + router.load_model('/path/to/model.nam') + out = router.process(audio_block) + router.set_engine('pytorch') # switches at runtime, reloads current model +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional + +import numpy as np + +logger = logging.getLogger(__name__) + + +class NAMEngineRouter: + """Wraps either FastNAMHost or NAMHost with a common interface. + + Parameters + ---------- + engine_mode : str + ``'cpp'`` for C++ subprocess (FastNAMHost), ``'pytorch'`` for + in-process PyTorch (NAMHost). + models_dir : str | Path + Directory scanned for available .nam models. + block_size : int + Audio block size in samples. + """ + + ENGINE_MODES = ("cpp", "pytorch") + + def __init__( + self, + engine_mode: str = "cpp", + models_dir: str | Path | None = None, + block_size: int = 256, + ): + if engine_mode not in self.ENGINE_MODES: + raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {engine_mode!r}") + + self._models_dir = Path(models_dir) if models_dir else ( + Path(__file__).parent.parent / "models" / "nam" + ) + self._block_size = block_size + self._engine_mode = engine_mode + self._engine: object = None # FastNAMHost or NAMHost instance + self._loaded_path: Optional[str] = None + + self._create_engine() + + # โ”€โ”€ Engine lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _create_engine(self) -> None: + """Create the engine instance for the current mode.""" + if self._engine_mode == "cpp": + from .nam_engine_host import FastNAMHost + self._engine = FastNAMHost( + models_dir=str(self._models_dir), + block_size=self._block_size, + ) + logger.info("NAM engine: C++ subprocess (FastNAMHost)") + else: + from .nam_host import NAMHost, ModelSwitchMode + self._engine = NAMHost( + models_dir=str(self._models_dir), + device="cpu", + switch_mode=ModelSwitchMode.CROSSFADE, + ) + logger.info("NAM engine: PyTorch in-process (NAMHost)") + + def set_engine(self, mode: str) -> bool: + """Switch engine type at runtime. + + Unloads the current model and reloads it in the new engine. + Returns True on success, False if the model couldn't be reloaded. + """ + if mode == self._engine_mode: + return True + if mode not in self.ENGINE_MODES: + raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {mode!r}") + + # Save currently loaded model path + old_path = self._loaded_path + self.unload() + + self._engine_mode = mode + self._create_engine() + + # Reload current model if one was loaded + if old_path: + logger.info("Reloading model %s in new engine %s", old_path, mode) + return self.load_model(old_path) + return True + + @property + def engine_mode(self) -> str: + """Current engine mode: 'cpp' or 'pytorch'.""" + return self._engine_mode + + # โ”€โ”€ Properties delegated to engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @property + def is_loaded(self) -> bool: + return self._engine is not None and self._engine.is_loaded + + @property + def current_model(self): + return getattr(self._engine, 'current_model', None) if self._engine else None + + @property + def avg_inference_ms(self) -> float: + if self._engine is None: + return 0.0 + return getattr(self._engine, 'avg_inference_ms', 0.0) + + @property + def block_size(self) -> int: + return self._block_size + + # โ”€โ”€ Crossfade (compatible with both engines) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @property + def _crossfade_buf(self): + """For pipeline crossfade compatibility. + PyTorch NAMHost has this natively; FastNAMHost has None.""" + if hasattr(self._engine, '_crossfade_buf'): + return self._engine._crossfade_buf + return None + + def apply_crossfade(self, buf: np.ndarray) -> np.ndarray: + if hasattr(self._engine, 'apply_crossfade'): + return self._engine.apply_crossfade(buf) + return buf + + # โ”€โ”€ Model loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def load_model(self, model_path: str) -> bool: + if self._engine is None: + return False + ok = self._engine.load_model(model_path) + if ok: + self._loaded_path = model_path + return ok + + def unload(self) -> None: + if self._engine is not None: + self._engine.unload() + self._loaded_path = None + + def set_block_size(self, block_size: int) -> None: + self._block_size = block_size + if self._engine is not None and hasattr(self._engine, 'set_block_size'): + self._engine.set_block_size(block_size) + + def warm_up(self) -> None: + if self._engine is not None and hasattr(self._engine, 'warm_up'): + self._engine.warm_up(block_size=self._block_size) + + def process(self, audio_block: np.ndarray) -> np.ndarray: + if self._engine is None: + return audio_block + return self._engine.process(audio_block) + + # โ”€โ”€ Model discovery โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def list_available_models(self): + """List available .nam files from models_dir and ~/.pedal/models/.""" + models = [] + if self._engine is not None: + models = self._engine.list_available_models() + # Also scan the Tone3000 download directory for models not in + # the primary models_dir + from pathlib import Path + extra = Path.home() / ".pedal" / "models" + if extra != self._models_dir and extra.is_dir(): + from .nam_engine_host import NAMFastModel + seen = set(m.path for m in models) + for f in sorted(extra.glob("*.nam")): + if str(f) not in seen: + size_mb = f.stat().st_size / (1024 * 1024) + models.append(NAMFastModel( + name=f.stem, + path=str(f), + size_mb=size_mb, + architecture="LSTM", + )) + return models diff --git a/src/dsp/pipeline.py b/src/dsp/pipeline.py index f4cc61e..f44c5b1 100644 --- a/src/dsp/pipeline.py +++ b/src/dsp/pipeline.py @@ -21,7 +21,7 @@ from typing import Optional import numpy as np from scipy.signal import lfilter -from .nam_engine_host import FastNAMHost +from .nam_router import NAMEngineRouter from .ir_loader import IRLoader, IRFile from ..presets.types import FXBlock, FXType, Preset @@ -302,10 +302,10 @@ class AudioPipeline: def __init__( self, - nam_host: Optional[FastNAMHost] = None, + nam_host: Optional[NAMEngineRouter] = None, ir_loader: Optional[IRLoader] = None, ): - self.nam = nam_host or FastNAMHost() + self.nam = nam_host or NAMEngineRouter() self.ir = ir_loader or IRLoader() # Signal chain โ€” list of (FXType, enabled, bypass, params) diff --git a/src/system/audio.py b/src/system/audio.py index d38f682..128d2bb 100644 --- a/src/system/audio.py +++ b/src/system/audio.py @@ -133,6 +133,10 @@ class AudioConfig: jack_enabled: bool = True auto_connect: bool = True xrun_warn_only: bool = True + # Custom period/rate override โ€” saved to config when user changes + # these via the UI. When set, they override the profile defaults. + period: Optional[int] = None + rate: Optional[int] = None @property def latency_profile(self) -> dict: @@ -140,7 +144,14 @@ class AudioConfig: p = LATENCY_PROFILES.get(self.profile) if p is None: logger.warning("Unknown profile %r, falling back to standard", self.profile) - return LATENCY_PROFILES["standard"] + p = dict(LATENCY_PROFILES["standard"]) + else: + p = dict(p) + # Apply stored period/rate overrides if set + if self.period is not None: + p["period"] = self.period + if self.rate is not None: + p["rate"] = self.rate return p @property @@ -1007,9 +1018,6 @@ class JackAudioClient: for ch in range(self._input_channels): port_buf = self._inports[ch].get_array() self._in_buf[ch, :frames] = port_buf[:frames] - # DEBUG: always log - import os - os.system('echo "CB frames=' + str(frames) + '" >> /tmp/debug_audio.log') # โ”€โ”€ Run through pipeline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if self._input_channels == 1: diff --git a/src/system/tonedownload.py b/src/system/tonedownload.py index 14b3b9a..5b20585 100644 --- a/src/system/tonedownload.py +++ b/src/system/tonedownload.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio import json import logging +import os import re import time from dataclasses import dataclass, field @@ -294,27 +295,37 @@ class Tone3000Client: page_size: int = 20, force_refresh: bool = False, arch: Optional[str] = None, + gear: Optional[str] = None, + sort: str = "created_at.desc", ) -> list[ModelResult]: """Search Tone3000 for NAM models matching the query. - Searches model names. Results are cached for 5 minutes. + Args: + query: Search term. + page: Page number (0-based). + page_size: Results per page. + force_refresh: Bypass cache. + arch: Architecture filter (nano, feather, standard). + gear: Gear type filter (amp, pedal, full-rig, outboard, ir). + sort: Sort order โ€” "created_at.desc" (default), "created_at.asc", + "name.asc", "name.desc". + + Returns list of ModelResult. Results cached for 5 minutes. Returns empty list on any error (logged as warning). """ try: - cache_key = f"models:{query}:{page}:{arch}" + cache_key = f"models:{query}:{page}:{arch}:{gear}:{sort}" if not force_refresh: cached = self._get_cached(cache_key) if cached is not None: return [ModelResult(**r) for r in cached] # Search models table by name similarity - # Replace spaces with wildcards so multi-word queries work - # (e.g. "vox ac30" โ†’ ilike.*vox*ac30* โ†’ ILIKE '%vox%ac30%') ilike_query = query.replace(" ", "*") params: dict[str, str] = { "select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at", "name": f"ilike.*{ilike_query}*", - "order": "created_at.desc", + "order": sort, "limit": str(page_size), "offset": str(page * page_size), } @@ -323,6 +334,11 @@ class Tone3000Client: raw_models = await self._supabase_get("models", params) results = await self._enrich_models(raw_models) + + # Filter by gear type after enrichment (gear is on the tone, not model) + if gear: + results = [r for r in results if r.tone_gear == gear] + self._set_cache(cache_key, [r.__dict__ for r in results]) return results except Exception as e: @@ -403,7 +419,7 @@ class Tone3000Client: # โ”€โ”€ Trending / Browse โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - async def get_trending_models(self, limit: int = 20, arch: Optional[str] = None) -> list[ModelResult]: + async def get_trending_models(self, limit: int = 20, arch: Optional[str] = None, gear: Optional[str] = None) -> list[ModelResult]: """Get trending NAM models. Returns empty list on error.""" try: params: dict[str, str] = { @@ -414,7 +430,10 @@ class Tone3000Client: if arch: params["architecture_version"] = f"eq.{arch}" raw = await self._supabase_get("models", params) - return await self._enrich_models(raw) + results = await self._enrich_models(raw) + if gear: + results = [r for r in results if r.tone_gear == gear] + return results except Exception as e: logger.warning("get_trending_models failed: %s", e) return [] diff --git a/src/web/server.py b/src/web/server.py index 720fc6a..cce60bc 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -1051,6 +1051,29 @@ class WebServer: "current": nam.current_model.name if nam.current_model else None, } + @app.get("/api/nam/engine") + async def get_nam_engine(channel: str = "guitar"): + """Get current NAM engine mode.""" + _pm, _pl, nam, _ir = self._channel_deps(channel) + if not nam: + raise HTTPException(status_code=503) + mode = getattr(nam, 'engine_mode', 'cpp') + return {"engine_mode": mode} + + @app.post("/api/nam/engine") + async def set_nam_engine(data: dict, channel: str = "guitar"): + """Switch NAM engine between 'cpp' and 'pytorch'.""" + _pm, _pl, nam, _ir = self._channel_deps(channel) + if not nam: + raise HTTPException(status_code=503) + mode = data.get("engine_mode", "cpp") + if mode not in ("cpp", "pytorch"): + raise HTTPException(status_code=400, detail="engine_mode must be 'cpp' or 'pytorch'") + ok = nam.set_engine(mode) + if not ok: + raise HTTPException(status_code=500, detail=f"Failed to switch to {mode} engine") + return {"ok": True, "engine_mode": mode} + @app.post("/api/models/load") async def load_model(data: dict, channel: str = "guitar"): """Load a NAM model by path for the given channel.""" @@ -1486,12 +1509,17 @@ class WebServer: logger.warning("jack_client.stop() failed (expected if JACK was mid-callback)") # Start JACK with new parameters - ok = audio_sys.start_jack(timeout=10) + # Disable auto_connect temporarily โ€” fx_in/fx_out ports don't + # exist until jack_client.start() recreates the JACK client below + saved_auto_connect = audio_sys.config.auto_connect + audio_sys.config.auto_connect = False + ok = audio_sys.start_jack(timeout=15) + audio_sys.config.auto_connect = saved_auto_connect if not ok: # Rollback on failure audio_sys.config.profile = old_key logger.warning("JACK restart failed โ€” attempting rollback to %s", old_key) - audio_sys.start_jack(timeout=10) + audio_sys.start_jack(timeout=15) if jack_client: try: jack_client.start() @@ -1509,6 +1537,13 @@ class WebServer: except Exception as e: logger.warning("jack_client.start() failed after profile switch: %s", e) + # Reconnect FX ports now that pipeline's JACK client is alive + if saved_auto_connect: + try: + audio_sys.connect_fx_ports() + except Exception as e: + logger.warning("connect_fx_ports() failed after profile switch: %s", e) + # Sync NAM engine block size nam_host = self.deps.nam_host if nam_host and hasattr(nam_host, 'set_block_size'): @@ -1751,7 +1786,7 @@ class WebServer: return {"online": ok} @app.get("/api/models/tonedownload/search") - async def tonedownload_search_models(q: str = "", page: int = 0, arch: str = ""): + async def tonedownload_search_models(q: str = "", page: int = 0, arch: str = "", gear: str = "", sort: str = "created_at.desc"): """Search Tone3000 for NAM models.""" try: client = await self._get_tonedownload() @@ -1765,10 +1800,11 @@ class WebServer: }, ) arch_val = arch.strip() or None + gear_val = gear.strip() or None if not q.strip(): - results = await client.get_trending_models(arch=arch_val) + results = await client.get_trending_models(arch=arch_val, gear=gear_val) else: - results = await client.search_models(q.strip(), page=page, arch=arch_val) + results = await client.search_models(q.strip(), page=page, arch=arch_val, gear=gear_val, sort=sort) return { "results": [ { @@ -1786,9 +1822,12 @@ class WebServer: "pack_name": r.pack_name, "architecture": r.architecture, "created_at": r.created_at, + "downloads": r.downloads, + "likes": r.likes, } for r in results ], + "page": page, } except Exception as e: logger.warning("Tone3000 model search failed: %s", e) diff --git a/src/web/ui-v2-dist/index.html b/src/web/ui-v2-dist/index.html index 3d131d0..23131f9 100644 --- a/src/web/ui-v2-dist/index.html +++ b/src/web/ui-v2-dist/index.html @@ -172,7 +172,7 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
-
+
NAM
No Model Loaded
โ€”
@@ -228,20 +228,39 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
โฌ‡ Downloads
- - + + +
- - - - + + + + +
+ +
+ + + + + +
-
+
+
+ Sort: + +
@@ -371,6 +390,7 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
๐Ÿ’พ Diskโ€”
๐Ÿง  RAMโ€”
โ†บ Reset
+
๐ŸŽ› NAM Enginecpp
๐Ÿ”„ Service
โป Reboot
@@ -422,9 +442,9 @@ const API = { btStatus:()=>API._fetch('/api/bluetooth/status'), btPaired:()=>API._fetch('/api/bluetooth/paired'), btMidiStatus:()=>API._fetch('/api/bluetooth/midi-status'), - btMidiEnable:()=>API._fetch('/api/bluetooth/midi-enable',{method:'POST'}), - btMidiDisable:()=>API._fetch('/api/bluetooth/midi-disable',{method:'POST'}), - btDiscoverableSet:(on)=>API._fetch('/api/bluetooth/discoverable',{method:'POST',body:JSON.stringify({on})}), + btMidiEnable:()=>API._fetch('/api/bluetooth/midi-enable',{method:'POST',timeout:15000}), + btMidiDisable:()=>API._fetch('/api/bluetooth/midi-disable',{method:'POST',timeout:15000}), + btDiscoverableSet:(on)=>API._fetch('/api/bluetooth/discoverable',{method:'POST',body:JSON.stringify({on}),timeout:15000}), listAudioProfiles:()=>API._fetch('/api/audio/profiles'), getAudioProfile:()=>API._fetch('/api/audio/profile'), setAudioProfile:(p)=>{ @@ -439,7 +459,7 @@ const API = { getSettings:()=>API._fetch('/api/settings'), getSystem:()=>API._fetch('/api/system'), getSystemLogs:(n)=>API._fetch('/api/system/logs?lines='+(n||50)), - setHostname:(h)=>API._fetch('/api/system/hostname',{method:'POST',body:JSON.stringify({hostname:h})}), + setHostname:(h)=>API._fetch('/api/system/hostname',{method:'POST',body:JSON.stringify({hostname:h}),timeout:15000}), getNetwork:()=>API._fetch('/api/network'), setNetworkStatic:(d)=>API._fetch('/api/network/static',{method:'POST',body:JSON.stringify(d),timeout:25000}), setNetworkDhcp:()=>API._fetch('/api/network/dhcp',{method:'POST',timeout:25000}), @@ -518,16 +538,19 @@ async function loadAudioDevices(){ async function setAudioInputDevice(id){ if(!id)return; showLoading('Switching input...'); - await API.saveSettings({input_device:id}).catch(()=>{}); + try{ + await API.saveSettings({input_device:id}); + toast('Input: '+id); + }catch(e){toast('Input switch failed');} hideLoading(); - toast('Input: '+id); } async function setAudioOutputDevice(id){ if(!id)return; showLoading('Switching output...'); - await API.saveSettings({output_device:id}).catch(()=>{}); - hideLoading(); - toast('Output: '+id); + try{ + await API.saveSettings({output_device:id}); + toast('Output: '+id); + }catch(e){toast('Output switch failed');} } async function setChOutput(ch,port){ try{ @@ -567,7 +590,7 @@ function setChInstrument(idx,val){ const arr=JSON.parse(localStorage.getItem('chInstruments')||'["guitar","bass"]'); arr[idx]=val; localStorage.setItem('chInstruments',JSON.stringify(arr)); - API.saveSettings({['ch'+(idx+1)+'_instrument']:val}).catch(()=>{}); + API.saveSettings({['ch'+(idx+1)+'_instrument']:val}).catch(e=>toast('Instrument failed')); } function toggleChPower(idx){ const btn=document.getElementById('ch'+(idx+1)+'Power'); @@ -576,7 +599,7 @@ function toggleChPower(idx){ btn.style.background=on?'rgba(160,160,188,.1)':'rgba(58,184,122,.2)'; btn.style.borderColor=on?'rgba(160,160,188,.2)':'rgba(58,184,122,.4)'; btn.style.color=on?'var(--text3)':'var(--green)'; - API.bypassToggle(channels[idx]||'guitar').catch(()=>{}); + API.bypassToggle(channels[idx]||'guitar').catch(e=>toast('Channel toggle failed')); } function loadChInstrumentState(){ @@ -604,8 +627,8 @@ function initVolSlider(){ track.addEventListener('touchstart',(e)=>{volDragging=true;upd(e);},{passive:true}); document.addEventListener('mousemove',(e)=>{if(volDragging){e.preventDefault();upd(e);}}); document.addEventListener('touchmove',(e)=>{if(volDragging)upd(e);},{passive:true}); - document.addEventListener('mouseup',()=>{if(!volDragging)return;volDragging=false;API.setVolume(currentChannel,parseFloat($('#volFill').style.width)/100).catch(()=>{});}); - document.addEventListener('touchend',()=>{if(!volDragging)return;volDragging=false;API.setVolume(currentChannel,parseFloat($('#volFill').style.width)/100).catch(()=>{});}); + document.addEventListener('mouseup',()=>{if(!volDragging)return;volDragging=false;API.setVolume(currentChannel,parseFloat($('#volFill').style.width)/100).catch(e=>toast('Volume failed'));}); + document.addEventListener('touchend',()=>{if(!volDragging)return;volDragging=false;API.setVolume(currentChannel,parseFloat($('#volFill').style.width)/100).catch(e=>toast('Volume failed'));}); } // โ•โ•โ• State Loading โ•โ•โ• @@ -704,10 +727,10 @@ async function toggleBlock(idx){ const b=blocks[idx]; if(!b)return; const newEn=!(b.enabled!==false); - await API.toggleBlock(currentChannel,b.block_id||idx,newEn).catch(()=>{}); + await API.toggleBlock(currentChannel,b.block_id||idx,newEn).catch(e=>toast('Block failed')); loadState(); } -function showAddBlock(){openOverlay('Downloads');} +function showAddBlock(){document.querySelector('.tab-btn[data-view="downloads"]').click();} // โ•โ•โ• Presets โ•โ•โ• async function loadPresets(){ @@ -731,9 +754,9 @@ async function loadPresets(){ } grid.appendChild(div); } - }catch(e){} + if(!bank||slots.length===0)grid.innerHTML='
No presets
'; + }catch(e){toast('Failed to load presets');} } - // โ•โ•โ• Snapshots โ•โ•โ• async function loadSnapshots(){ try{ @@ -751,7 +774,7 @@ async function loadSnapshots(){ else{div.innerHTML='
'+i+'
Empty
';} grid.appendChild(div); } - }catch(e){} + }catch(e){toast('Failed to load snapshots');} } // โ•โ•โ• Settings Loaders โ•โ•โ• @@ -764,7 +787,7 @@ async function loadNetworkInfo(){ document.getElementById('netGateway').textContent=n.ethernet.gateway||'โ€”'; } if(n.dns)document.getElementById('netDns').textContent=n.dns.join(', '); - }catch(e){} + }catch(e){toast('Network info failed');} try{ const w=await API.wifiStatus(); if(w.ssid)document.getElementById('wifiStatus').textContent=w.ssid+(w.signal?' ('+w.signal+'dB)':''); @@ -804,7 +827,7 @@ async function setAudioParams(){ const btn=document.querySelector('.sn-btn[data-section="audio"]'); if(btn)btn.textContent='โณ Applying...'; try{ - await API._fetch('/api/audio/profile',{method:'POST',body:JSON.stringify({period,rate}),timeout:15000}); + await API._fetch('/api/audio/profile',{method:'POST',body:JSON.stringify({period,rate}),timeout:30000}); toast('Audio: '+rate+'Hz / '+period+'fr'); loadAudioParams(); }catch(e){toast('Failed: '+e.message);} @@ -914,17 +937,17 @@ function init(){ initVolSlider(); // Footswitch - document.getElementById('footswitch').onclick=()=>API.bypassToggle(currentChannel).catch(()=>{}); + document.getElementById('footswitch').onclick=()=>API.bypassToggle(currentChannel).catch(e=>toast('Bypass failed')); // Tab bar navigation document.querySelectorAll('.tab-btn[data-view]').forEach(btn=>{ btn.onclick=()=>{ const view=btn.dataset.view; if(view==='main'){document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');} - else if(view==='settings'){openOverlay('Settings');loadNetworkInfo();loadSystemInfo();loadAudioParams();loadAudioDevices();loadChOutput();loadBackingSource();loadHotspotStatus();loadBtStatus();loadBtMidi();return;} + else if(view==='settings'){openOverlay('Settings');loadNetworkInfo();loadSystemInfo();loadAudioParams();loadAudioDevices();loadChOutput();loadBackingSource();loadHotspotStatus();loadBtStatus();loadBtMidi();loadNamEngine();return;} else if(view==='presets'){openOverlay('Presets');loadPresets();} else if(view==='snapshots'){openOverlay('Snapshots');loadSnapshots();} - else if(view==='downloads')openOverlay('Downloads'); + else if(view==='downloads'){openOverlay('Downloads');if(dlTab==='local')doDlSearch();} }; }); @@ -940,6 +963,7 @@ function init(){ loadHotspotStatus(); loadBtStatus(); loadBtMidi(); + loadNamEngine(); }; // Close all overlays @@ -1004,18 +1028,38 @@ function init(){ document.getElementById('btnRestartSvc').onclick=()=>{toast('Restarting service...');fetch('/api/settings',{method:'POST',body:JSON.stringify({_restart:true})}).catch(()=>{});}; document.getElementById('btnReboot').onclick=()=>{if(confirm('Reboot pedal?')){toast('Rebooting...');fetch('/api/settings',{method:'POST',body:JSON.stringify({_reboot:true})}).catch(()=>{});}}; + // NAM engine toggle + document.getElementById('btnToggleNamEngine').onclick=async()=>{ + const label=document.getElementById('namEngineLabel'); + const cur=label.textContent; + const next=cur==='cpp'?'pytorch':'cpp'; + label.textContent='...'; + try{ + await API._fetch('/api/nam/engine',{method:'POST',body:JSON.stringify({engine_mode:next}),timeout:30000}); + label.textContent=next; + toast('NAM engine: '+next); + }catch(e){label.textContent=cur;toast('Switch failed: '+e.message);} + }; + // Load current engine on settings open + async function loadNamEngine(){ + try{ + const r=await API._fetch('/api/nam/engine'); + document.getElementById('namEngineLabel').textContent=r.engine_mode||'cpp'; + }catch(e){} + } + // Tuner document.getElementById('tabTuner').onclick=async()=>{ state.tuner_enabled=!state.tuner_enabled; if(state.tuner_enabled){document.getElementById('tunerView').classList.add('open');startTunerPolling();} else{document.getElementById('tunerView').classList.remove('open');stopTunerPolling();} - API.tunerToggle(currentChannel,state.tuner_enabled).catch(()=>{}); + API.tunerToggle(currentChannel,state.tuner_enabled).catch(e=>toast('Tuner failed')); }; document.getElementById('tunerExit').onclick=()=>{ state.tuner_enabled=false; document.getElementById('tunerView').classList.remove('open'); stopTunerPolling(); - API.tunerToggle(currentChannel,false).catch(()=>{}); + API.tunerToggle(currentChannel,false).catch(e=>toast('Tuner failed')); }; // Snapshots save @@ -1026,34 +1070,103 @@ function init(){ try{await API.saveSnapshot(currentChannel,slot);toast('Saved to slot '+slot);loadSnapshots();}catch(e){toast(e.message);} }; - // Downloads - state - let dlTab='nam', dlArch=''; + // Downloads state & wiring + // Downloads state & wiring + let dlTab='local', dlArch='', dlGear='', dlPage=0, dlSort='created_at.desc'; function switchDlTab(tab){ - dlTab=tab; + dlTab=tab; dlPage=0; + document.getElementById('dlTabLocal').className='sn-btn'+(tab==='local'?' active':''); document.getElementById('dlTabNam').className='sn-btn'+(tab==='nam'?' active':''); document.getElementById('dlTabIr').className='sn-btn'+(tab==='ir'?' active':''); + // Show/hide filter rows per tab const archRow=document.querySelectorAll('#overlayDownloads > .settings-nav')[1]; - if(archRow)archRow.style.display=tab==='nam'?'flex':'none'; + const gearRow=document.getElementById('dlGearRow'); + const sortRow=document.getElementById('dlSortRow'); + const searchRow=document.getElementById('dlSearchRow'); + const showNam=tab==='nam'; + if(archRow)archRow.style.display=showNam?'flex':'none'; + if(gearRow)gearRow.style.display=showNam?'flex':'none'; + if(sortRow)sortRow.style.display=showNam?'flex':'none'; + if(searchRow)searchRow.style.display=(tab==='nam'||tab==='ir')?'flex':'none'; doDlSearch(); } + document.getElementById('dlTabLocal').onclick=()=>switchDlTab('local'); + document.getElementById('dlTabNam').onclick=()=>switchDlTab('nam'); + document.getElementById('dlTabIr').onclick=()=>switchDlTab('ir'); + function setArch(btn,arch){ - document.querySelectorAll('#overlayDownloads .settings-nav')[1].querySelectorAll('.sn-btn').forEach(b=>b.classList.remove('active')); + document.querySelectorAll('#overlayDownloads > .settings-nav')[1].querySelectorAll('.sn-btn').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); - dlArch=arch; + dlArch=arch; dlPage=0; doDlSearch(); } + document.querySelectorAll('#overlayDownloads > .settings-nav')[1].querySelectorAll('.sn-btn[data-arch]').forEach(btn=>{ + btn.onclick=()=>setArch(btn,btn.dataset.arch); + }); + + function setGear(btn,gear){ + document.getElementById('dlGearRow').querySelectorAll('.sn-btn').forEach(b=>b.classList.remove('active')); + btn.classList.add('active'); + dlGear=gear; dlPage=0; + doDlSearch(); + } + document.getElementById('dlGearRow').querySelectorAll('.sn-btn[data-gear]').forEach(btn=>{ + btn.onclick=()=>setGear(btn,btn.dataset.gear); + }); + + // Sort dropdown + document.getElementById('dlSortSelect').onchange=function(){ + dlSort=this.value; dlPage=0; doDlSearch(); + }; + async function doDlSearch(){ - const q=document.getElementById('dlSearch').value.trim(); const res=document.getElementById('dlResults'); res.innerHTML='
'; try{ + // Local tab: list installed models from /api/models + if(dlTab==='local'){ + const data=await API._fetch('/api/models'); + const items=data.models||[]; + const current=data.current||null; + res.innerHTML=''; + if(items.length===0){ + res.innerHTML='
No models installed โ€” download from the NAM tab
'; + return; + } + items.forEach(item=>{ + const d=document.createElement('div'); + d.className='preset-card'; + d.style.cursor='pointer'; + const isLoaded=item.path===current; + if(isLoaded)d.style.borderColor='var(--green)'; + const meta=[item.architecture||'?']; + if(item.size_mb)meta.push(item.size_mb+'MB'); + d.innerHTML='
'+(item.name||'Unknown')+'
'+(isLoaded?'โœ… Loaded':'')+'
'+meta.join(' ยท ')+'
'; + d.onclick=async()=>{ + toast('Loading...'); + try{ + await API._fetch('/api/models/load',{method:'POST',body:JSON.stringify({path:item.path}),timeout:10000}); + toast('Loaded: '+item.name); + doDlSearch(); + }catch(e){toast('Failed: '+e.message);} + }; + res.appendChild(d); + }); + return; + } + + // NAM / IR tab: Tone3000 search + const q=document.getElementById('dlSearch').value.trim(); const endpoint=dlTab==='nam'?'/api/models/tonedownload/search':'/api/irs/tonedownload/search'; - const params='?q='+encodeURIComponent(q)+(dlArch?'&arch='+dlArch:'')+'&page=0'; + let params='?q='+encodeURIComponent(q)+'&page='+dlPage; + if(dlArch)params+='&arch='+dlArch; + if(dlTab==='nam'&&dlGear)params+='&gear='+dlGear; + if(dlTab==='nam')params+='&sort='+dlSort; const data=await API._fetch(endpoint+params); const items=data.results||[]; res.innerHTML=''; - if(items.length===0){res.innerHTML='
No results'+(q?'':' \u2014 tap Search or type a query')+'
';return;} + if(items.length===0){res.innerHTML='
No results'+(q?'':' โ€” no models found')+'
';return;} items.forEach(item=>{ const d=document.createElement('div'); d.className='preset-card'; @@ -1062,10 +1175,20 @@ function init(){ if(item.size_display)meta.push(item.size_display); if(item.architecture)meta.push(item.architecture); if(item.tone_gear&&item.tone_gear!=='nam')meta.push(item.tone_gear); - d.innerHTML='
'+(item.name||'Unknown')+'
'+(item.author||'')+(item.pack_name?' \u00b7 '+item.pack_name:'')+'
'+meta.join(' \u00b7 ')+'
'; + if(item.downloads>0)meta.push('โฌ‡'+item.downloads); + d.innerHTML='
'+(item.name||'Unknown')+'
'+(item.author||'')+(item.pack_name?' ยท '+item.pack_name:'')+'
'+meta.join(' ยท ')+'
'; d.onclick=()=>showDlDetail(item); res.appendChild(d); }); + // Pagination bar + const pbar=document.createElement('div'); + pbar.style.cssText='display:flex;align-items:center;justify-content:center;gap:8px;padding:8px 0;margin-top:4px;border-top:1px solid var(--border)'; + pbar.innerHTML=''+ + 'Page '+(dlPage+1)+''+ + ''; + res.appendChild(pbar); + document.getElementById('dlPrevBtn').onclick=()=>{if(dlPage>0){dlPage--;doDlSearch();}}; + document.getElementById('dlNextBtn').onclick=()=>{if(items.length>=20){dlPage++;doDlSearch();}}; }catch(e){res.innerHTML='
Error
';} } function showDlDetail(item){ @@ -1078,6 +1201,8 @@ function init(){ if(item.pack_name)html+='
Pack: '+item.pack_name+'
'; if(item.architecture)html+='
Arch: '+item.architecture+'
'; if(item.size_display)html+='
Size: '+item.size_display+'
'; + if(item.downloads>0)html+='
โฌ‡ Downloads: '+item.downloads+'
'; + if(item.likes>0)html+='
โค Likes: '+item.likes+'
'; if(item.tone_gear)html+='
Gear: '+item.tone_gear+'
'; if(item.tone_description)html+='
'+(item.tone_description||'')+'
'; html+='
'; @@ -1090,7 +1215,7 @@ function init(){ btn.textContent='...'; try{ const endpoint=isIr?'/api/irs/tonedownload/install':'/api/models/tonedownload/install'; - await API._fetch(endpoint,{method:'POST',body:JSON.stringify({id:item.id,download_url:item.download_url,name:item.name,filename:item.filename,size:item.size,author:item.author,author_avatar:item.author_avatar,thumbnail:item.thumbnail,tone_description:item.tone_description,pack_name:item.pack_name,architecture:item.architecture,created_at:item.created_at}),timeout:60000}); + await API._fetch(endpoint,{method:'POST',body:JSON.stringify({id:item.id,download_url:item.download_url,name:item.name,filename:item.filename,size:item.size,author:item.author,author_avatar:item.author_avatar,thumbnail:item.thumbnail,tone_description:item.tone_description,pack_name:item.pack_name,architecture:item.architecture,created_at:item.created_at,downloads:item.downloads,likes:item.likes}),timeout:60000}); toast('Downloaded!'); btn.textContent='Downloaded'; btn.className='sr-action';