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
+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';