fix: move NAM block size sync before JACK client restart

The profile change handler was calling nam_host.set_block_size() AFTER
jack_client.start(), which means the JACK process callback started firing
with the new buffer size while the old NAM subprocess was still running
at the old block size. The pipe read/write byte counts desynchronized,
causing NAMEngineProcess.process() to return audio_block as passthrough
(direct input / DI signal) — every subsequent callback showed dry audio.

Fix: reload the NAM engine with the correct block size BEFORE restarting
the JACK client. Now when the process callback fires, the NAM subprocess
is already ready with the matching block size.
This commit is contained in:
2026-06-17 21:07:48 +00:00
parent 1b2747abb1
commit 5dc769ad30
8 changed files with 1648 additions and 66 deletions
+1168
View File
File diff suppressed because it is too large Load Diff
+31 -3
View File
@@ -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(
+195
View File
@@ -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
+3 -3
View File
@@ -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)
+12 -4
View File
@@ -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:
+26 -7
View File
@@ -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 []
+44 -5
View File
@@ -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)
+169 -44
View File
@@ -172,7 +172,7 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
<!-- Main Content -->
<div class="main-content">
<div class="nam-hero empty" id="namHero" onclick="switchTab('FX')">
<div class="nam-hero empty" id="namHero" onclick="document.querySelector('.tab-btn[data-view=&quot;downloads&quot;]').click();setTimeout(()=>{var b=document.getElementById('dlTabLocal');if(b)b.click();},100)">
<div class="nam-badge" id="namBadge">NAM</div>
<div class="nam-name" id="namName">No Model Loaded</div>
<div class="nam-detail" id="namDetail"></div>
@@ -228,20 +228,39 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
<div class="overlay-hdr"><span class="overlay-title">⬇ Downloads</span><button class="overlay-close" id="closeDownloads"></button></div>
<!-- Tab + filter nav -->
<div class="settings-nav">
<button class="sn-btn active" id="dlTabNam" onclick="switchDlTab('nam')">🎛 NAM</button>
<button class="sn-btn" id="dlTabIr" onclick="switchDlTab('ir')">📦 IR</button>
<button class="sn-btn active" id="dlTabLocal">📂 Local</button>
<button class="sn-btn" id="dlTabNam">🎛 NAM</button>
<button class="sn-btn" id="dlTabIr">📦 IR</button>
</div>
<div class="settings-nav" style="border-bottom:1px solid var(--border);padding-top:0">
<button class="sn-btn active" data-arch="" onclick="setArch(this,'')">All</button>
<button class="sn-btn" data-arch="nano" onclick="setArch(this,'nano')">Nano</button>
<button class="sn-btn" data-arch="feather" onclick="setArch(this,'feather')">Feather</button>
<button class="sn-btn" data-arch="standard" onclick="setArch(this,'standard')">Standard</button>
<button class="sn-btn active" data-arch="">All</button>
<button class="sn-btn" data-arch="nano">Nano</button>
<button class="sn-btn" data-arch="feather">Feather</button>
<button class="sn-btn" data-arch="standard">Standard</button>
</div>
<!-- Type filter row (NAM only) -->
<div class="settings-nav" id="dlGearRow" style="border-bottom:1px solid var(--border);padding-top:0;padding-bottom:0">
<button class="sn-btn active" data-gear="">All Types</button>
<button class="sn-btn" data-gear="amp">Amp</button>
<button class="sn-btn" data-gear="pedal">Pedal</button>
<button class="sn-btn" data-gear="full-rig">Full Rig</button>
<button class="sn-btn" data-gear="outboard">Outboard</button>
<button class="sn-btn" data-gear="ir">IR</button>
</div>
<div class="overlay-body" style="padding-top:4px">
<div style="display:flex;gap:6px;margin-bottom:6px">
<div style="display:flex;gap:6px;margin-bottom:4px" id="dlSearchRow">
<input id="dlSearch" placeholder="Search Tone3000..." style="flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:11px;outline:none;min-height:36px">
<button class="sr-action primary" id="dlSearchBtn">🔍</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:6px;align-items:center" id="dlSortRow">
<span style="font-size:10px;color:var(--text-dim);flex-shrink:0">Sort:</span>
<select id="dlSortSelect" style="flex:1;font-size:10px;padding:4px 6px;min-height:30px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font)">
<option value="created_at.desc">Newest</option>
<option value="created_at.asc">Oldest</option>
<option value="name.asc">Name A-Z</option>
<option value="name.desc">Name Z-A</option>
</select>
</div>
<div id="dlResults"></div>
</div>
</div>
@@ -371,6 +390,7 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
<div class="setting-row"><span class="sr-label">💾 Disk</span><span class="sr-value" id="sysDisk"></span></div>
<div class="setting-row"><span class="sr-label">🧠 RAM</span><span class="sr-value" id="sysMemory"></span></div>
<div class="setting-row"><span class="sr-label">↺ Reset</span><button class="sr-action danger" id="btnResetDefaults">Reset to Defaults</button></div>
<div class="setting-row"><span class="sr-label">🎛 NAM Engine</span><span class="sr-value" id="namEngineLabel">cpp</span><button class="sr-action" id="btnToggleNamEngine">Switch</button></div>
<div class="setting-row"><span class="sr-label">🔄 Service</span><button class="sr-action danger" id="btnRestartSvc">Restart</button></div>
<div class="setting-row"><span class="sr-label">⏻ Reboot</span><button class="sr-action danger" id="btnReboot">Reboot</button></div>
</div>
@@ -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='<div style="text-align:center;padding:20px;color:var(--text-dim);font-size:12px">No presets</div>';
}catch(e){toast('Failed to load presets');}
}
// ═══ Snapshots ═══
async function loadSnapshots(){
try{
@@ -751,7 +774,7 @@ async function loadSnapshots(){
else{div.innerHTML='<div class="ss-num">'+i+'</div><div class="ss-name" style="color:var(--text-dim)">Empty</div>';}
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='<div style="text-align:center;padding:16px"><span class="spinner"></span></div>';
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='<div style="text-align:center;padding:16px;color:var(--text-dim);font-size:11px">No models installed — download from the NAM tab</div>';
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='<div class="pc-name">'+(item.name||'Unknown')+'</div><div class="pc-num">'+(isLoaded?'✅ Loaded':'')+'</div><div style="font-size:8px;color:var(--text-dim);margin-top:2px">'+meta.join(' · ')+'</div>';
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='<div style="text-align:center;padding:16px;color:var(--text-dim);font-size:11px">No results'+(q?'':' \u2014 tap Search or type a query')+'</div>';return;}
if(items.length===0){res.innerHTML='<div style="text-align:center;padding:16px;color:var(--text-dim);font-size:11px">No results'+(q?'':' — no models found')+'</div>';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='<div class="pc-name">'+(item.name||'Unknown')+'</div><div class="pc-num">'+(item.author||'')+(item.pack_name?' \u00b7 '+item.pack_name:'')+'</div><div style="font-size:8px;color:var(--text-dim);margin-top:2px">'+meta.join(' \u00b7 ')+'</div>';
if(item.downloads>0)meta.push('⬇'+item.downloads);
d.innerHTML='<div class="pc-name">'+(item.name||'Unknown')+'</div><div class="pc-num">'+(item.author||'')+(item.pack_name?' · '+item.pack_name:'')+'</div><div style="font-size:8px;color:var(--text-dim);margin-top:2px">'+meta.join(' · ')+'</div>';
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='<button class="sr-action" id="dlPrevBtn"'+(dlPage<1?' disabled':'')+'>← Prev</button>'+
'<span style="font-size:11px;color:var(--text-dim)">Page '+(dlPage+1)+'</span>'+
'<button class="sr-action" id="dlNextBtn"'+(items.length<20?' disabled':'')+'>Next →</button>';
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='<div style="text-align:center;padding:16px;color:var(--text-dim)">Error</div>';}
}
function showDlDetail(item){
@@ -1078,6 +1201,8 @@ function init(){
if(item.pack_name)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Pack: '+item.pack_name+'</div>';
if(item.architecture)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Arch: '+item.architecture+'</div>';
if(item.size_display)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Size: '+item.size_display+'</div>';
if(item.downloads>0)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">⬇ Downloads: '+item.downloads+'</div>';
if(item.likes>0)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">❤ Likes: '+item.likes+'</div>';
if(item.tone_gear)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Gear: '+item.tone_gear+'</div>';
if(item.tone_description)html+='<div style="font-size:10px;color:var(--text2);margin-top:6px;padding:6px 8px;background:var(--surface);border-radius:6px;border:1px solid var(--border);line-height:1.4">'+(item.tone_description||'')+'</div>';
html+='</div>';
@@ -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';