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(), creating a race where the JACK callback fired with the new buffer size while the old NAM subprocess was still running at the old block_size. Pipe byte counts desynced => NAM returned passthrough. + also: auto_connect disable during restart, connect_fx_ports reorder, Tone3000 gear/sort/pagination params, NAM engine switch API endpoints
This commit is contained in:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
+50
-9
@@ -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()
|
||||
@@ -1502,6 +1530,13 @@ class WebServer:
|
||||
detail=f"Failed to start JACK with period={target_profile['period']}, rate={target_profile['rate']} — rolled back",
|
||||
)
|
||||
|
||||
# Sync NAM engine block size BEFORE restarting JACK client
|
||||
# so the NAM subprocess is already sized for the new period
|
||||
# when the process callback starts firing.
|
||||
nam_host = self.deps.nam_host
|
||||
if nam_host and hasattr(nam_host, 'set_block_size'):
|
||||
nam_host.set_block_size(target_profile["period"])
|
||||
|
||||
# Restart JACK audio client
|
||||
if jack_client:
|
||||
try:
|
||||
@@ -1509,10 +1544,12 @@ class WebServer:
|
||||
except Exception as e:
|
||||
logger.warning("jack_client.start() 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'):
|
||||
nam_host.set_block_size(target_profile["period"])
|
||||
# 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)
|
||||
|
||||
# ── Persist to config.yaml ──────────────────────────────
|
||||
if self.deps.config is not None:
|
||||
@@ -1751,7 +1788,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 +1802,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 +1824,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
@@ -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="downloads"]').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';
|
||||
|
||||
Reference in New Issue
Block a user