feat(tonedownload): Tone3000 NAM + IR browser, real anon key, tests

- Tonedownload.py: Tone3000 API client with search/download/cache/enrich
- Server.py: 4 endpoints (search models, search IRs, install model, install IR)
- Models.html + irs.html: Browse Tone3000 tab with search, trending, offline
- Models.js + irs.js: Tone3000 search UI with install tracking via localStorage
- Style.css: Card layout for search results with thumbnail, meta, install btn
- Tests: 47 tests covering data classes, cache, API, search, download, enrichment
- Anon key: Updated from placeholder to real Supabase anon key (verified: API works)
This commit is contained in:
2026-06-09 01:39:12 -04:00
parent b4237f2f1d
commit a31342b478
8 changed files with 1789 additions and 2 deletions
+146
View File
@@ -31,6 +31,13 @@ from ..dsp.ir_loader import IRLoader
from ..dsp.pipeline import AudioPipeline
from ..presets.manager import PresetManager
from ..presets.types import FXBlock, FXType, Preset
from ..system.tonedownload import (
Tone3000Client,
ModelResult,
IRResult,
discover_anon_key,
format_size,
)
logger = logging.getLogger(__name__)
@@ -118,6 +125,7 @@ class WebServer:
self._manager = ConnectionManager()
self._server: Optional[uvicorn.Server] = None
self._task: Optional[asyncio.Task] = None
self._tonedownload: Optional[Tone3000Client] = None
self._app = self._build_app()
# ── App factory ─────────────────────────────────────────────────────
@@ -750,6 +758,129 @@ class WebServer:
self._manager.disconnect(ws)
logger.info("WebSocket client disconnected (%d active)", self._manager.active_count)
# ── Tone3000 downloader endpoints ───────────────────────
@app.get("/api/tonedownload/status")
async def tonedownload_status():
"""Check if the Tone3000 API is reachable."""
client = await self._get_tonedownload()
if not client:
return {"online": False, "error": "Client not initialized"}
ok = await client.ping()
return {"online": ok}
@app.get("/api/models/tonedownload/search")
async def tonedownload_search_models(q: str = "", page: int = 0):
"""Search Tone3000 for NAM models."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
if not q.strip():
results = await client.get_trending_models()
else:
results = await client.search_models(q.strip(), page=page)
return {
"results": [
{
"id": r.id,
"name": r.display_name,
"filename": r.filename,
"size": r.size_bytes,
"size_display": format_size(r.size_bytes),
"author": r.author_username or "Unknown",
"author_avatar": r.author_avatar_url,
"thumbnail": r.thumbnail_url,
"download_url": r.download_url,
"tone_description": r.tone_description,
"pack_name": r.pack_name,
"architecture": r.architecture,
"created_at": r.created_at,
}
for r in results
],
}
@app.get("/api/irs/tonedownload/search")
async def tonedownload_search_irs(q: str = "", page: int = 0):
"""Search Tone3000 for IRs."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
if not q.strip():
results = await client.get_trending_irs()
else:
results = await client.search_irs(q.strip(), page=page)
return {
"results": [
{
"id": r.id,
"name": r.display_name,
"filename": r.filename,
"size": r.size_bytes,
"size_display": format_size(r.size_bytes),
"author": r.author_username or "Unknown",
"author_avatar": r.author_avatar_url,
"thumbnail": r.thumbnail_url,
"download_url": r.download_url,
"tone_description": r.tone_description,
"created_at": r.created_at,
}
for r in results
],
}
@app.post("/api/models/tonedownload/install")
async def tonedownload_install_model(data: dict):
"""Download a .nam file to the pedal's models directory."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
url = data.get("download_url", "")
name = data.get("name", "model.nam")
if not url:
raise HTTPException(status_code=400, detail="download_url required")
model = ModelResult(
id=data.get("id", 0),
name=name,
download_url=url,
size_bytes=data.get("size"),
pack_name=data.get("pack_name"),
tone_title=data.get("display_name", name),
)
path = await client.download_model(model)
if not path:
raise HTTPException(status_code=500, detail="Download failed")
# Refresh the model list via NAM host
nam = self.deps.nam_host
if nam:
nam.list_available_models()
return {"ok": True, "path": str(path), "filename": path.name}
@app.post("/api/irs/tonedownload/install")
async def tonedownload_install_ir(data: dict):
"""Download a .wav IR to the pedal's IRs directory."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
url = data.get("download_url", "")
name = data.get("name", "ir.wav")
if not url:
raise HTTPException(status_code=400, detail="download_url required")
ir = IRResult(
id=0,
name=name,
download_url=url,
size_bytes=data.get("size"),
tone_title=data.get("display_name", name),
)
path = await client.download_ir(ir)
if not path:
raise HTTPException(status_code=500, detail="Download failed")
ir_loader = self.deps.ir_loader
if ir_loader:
ir_loader.get_irs()
return {"ok": True, "path": str(path), "filename": path.name}
return app
# ── Network/BT state gathering ──────────────────────────────────
@@ -847,6 +978,21 @@ class WebServer:
"bluetooth": self._gather_bt_state(),
}
# ── Tone3000 client lazy init ────────────────────────────────
async def _get_tonedownload(self) -> Optional[Tone3000Client]:
"""Lazily initialize the Tone3000 download client."""
if self._tonedownload is None:
from aiohttp import ClientSession
async with ClientSession() as session:
key = await discover_anon_key(session)
if not key:
# Fall back to hardcoded key
from ..system.tonedownload import SUPABASE_ANON_KEY
key = SUPABASE_ANON_KEY
self._tonedownload = Tone3000Client(anon_key=key)
return self._tonedownload
# ── Lifecycle ────────────────────────────────────────────────────
def start(self) -> None: