a31342b478
- 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)
1302 lines
60 KiB
Python
1302 lines
60 KiB
Python
"""FastAPI web server for Pi Multi-FX Pedal control interface.
|
|
|
|
Provides REST endpoints and WebSocket for real-time bidirectional
|
|
control of the pedal from any device on the LAN.
|
|
|
|
Run standalone for development:
|
|
python -m src.web.server
|
|
|
|
Or embed in main.py via WebServerDepss.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from ..dsp.nam_host import NAMHost
|
|
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__)
|
|
|
|
# ── Defaults ─────────────────────────────────────────────────────────────────
|
|
|
|
DEFAULT_HOST = "0.0.0.0"
|
|
DEFAULT_PORT = 8080
|
|
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
|
|
# ── Dependency container ─────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class WebServerDeps:
|
|
"""Dependencies injected into the web server from the host pedal app."""
|
|
|
|
presets: Optional[PresetManager] = None
|
|
pipeline: Optional[AudioPipeline] = None
|
|
nam_host: Optional[NAMHost] = None
|
|
ir_loader: Optional[IRLoader] = None
|
|
|
|
@property
|
|
def is_connected(self) -> bool:
|
|
"""True if we're wired to a live pedal instance."""
|
|
return self.presets is not None
|
|
|
|
|
|
# ── WebSocket connection manager ─────────────────────────────────────────────
|
|
|
|
|
|
class ConnectionManager:
|
|
"""Manages active WebSocket connections for real-time push."""
|
|
|
|
def __init__(self) -> None:
|
|
self._connections: set[WebSocket] = set()
|
|
|
|
async def connect(self, ws: WebSocket) -> None:
|
|
await ws.accept()
|
|
self._connections.add(ws)
|
|
|
|
def disconnect(self, ws: WebSocket) -> None:
|
|
self._connections.discard(ws)
|
|
|
|
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
"""Send a JSON message to every connected client."""
|
|
payload = json.dumps(message)
|
|
stale: list[WebSocket] = []
|
|
for ws in self._connections:
|
|
try:
|
|
await ws.send_text(payload)
|
|
except Exception:
|
|
stale.append(ws)
|
|
for ws in stale:
|
|
self._connections.discard(ws)
|
|
|
|
@property
|
|
def active_count(self) -> int:
|
|
return len(self._connections)
|
|
|
|
|
|
# ── The WebServer ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class WebServer:
|
|
"""FastAPI web app wrapping REST + WebSocket endpoints for pedal control.
|
|
|
|
Typical usage from PedalApp.boot():
|
|
self.web = WebServer(WebServerDeps(presets=..., pipeline=..., ...))
|
|
self.web.start()
|
|
|
|
The server runs in a background thread to avoid blocking the JACK audio loop.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
deps: WebServerDeps,
|
|
host: str = DEFAULT_HOST,
|
|
port: int = DEFAULT_PORT,
|
|
) -> None:
|
|
self.deps = deps
|
|
self.host = host
|
|
self.port = port
|
|
|
|
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 ─────────────────────────────────────────────────────
|
|
|
|
def _build_app(self) -> FastAPI:
|
|
|
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logger.info("Web server lifespan started")
|
|
yield
|
|
logger.info("Web server lifespan ended")
|
|
|
|
app = FastAPI(title="Pi Multi-FX Pedal", version="0.1.0", lifespan=lifespan)
|
|
|
|
# Mount static files
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
# ── HTML pages ──────────────────────────────────────────────
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def dashboard(request: Request):
|
|
"""Main dashboard — current preset, bypass, tuner, volume."""
|
|
state = self._gather_state()
|
|
ctx = {"request": request}
|
|
ctx.update(state)
|
|
return templates.TemplateResponse(request, "dashboard.html", ctx)
|
|
|
|
@app.get("/presets", response_class=HTMLResponse)
|
|
async def preset_manager(request: Request):
|
|
"""Preset manager page — browse banks, load/save/edit."""
|
|
return templates.TemplateResponse(request, "presets.html", {"request": request})
|
|
|
|
@app.get("/models", response_class=HTMLResponse)
|
|
async def model_manager(request: Request):
|
|
"""NAM model manager page."""
|
|
return templates.TemplateResponse(request, "models.html", {"request": request})
|
|
|
|
@app.get("/irs", response_class=HTMLResponse)
|
|
async def ir_manager(request: Request):
|
|
"""IR manager page."""
|
|
return templates.TemplateResponse(request, "irs.html", {"request": request})
|
|
|
|
@app.get("/settings", response_class=HTMLResponse)
|
|
async def settings_page(request: Request):
|
|
"""Settings page."""
|
|
state = self._gather_state()
|
|
ctx = {"request": request}
|
|
ctx.update(state)
|
|
return templates.TemplateResponse(request, "settings.html", ctx)
|
|
|
|
# ── REST API ────────────────────────────────────────────────
|
|
|
|
@app.get("/api/state")
|
|
async def get_state():
|
|
"""Full current pedal state snapshot."""
|
|
state = self._gather_state()
|
|
if not state:
|
|
raise HTTPException(status_code=503, detail="Pedal not connected")
|
|
return state
|
|
|
|
@app.get("/api/presets")
|
|
async def list_presets():
|
|
"""List all banks and their presets."""
|
|
pm = self.deps.presets
|
|
if not pm:
|
|
raise HTTPException(status_code=503, detail="Preset manager not available")
|
|
banks = pm.list_banks()
|
|
result = []
|
|
for bank in banks:
|
|
presets_in_bank = []
|
|
for prog in range(4):
|
|
try:
|
|
p = pm.load(bank.number, prog)
|
|
presets_in_bank.append({
|
|
"name": p.name,
|
|
"bank": p.bank,
|
|
"program": p.program,
|
|
"chain": [
|
|
{
|
|
"fx_type": b.fx_type.value,
|
|
"enabled": b.enabled,
|
|
"bypass": b.bypass,
|
|
"params": dict(b.params),
|
|
"nam_model_path": b.nam_model_path,
|
|
"ir_file_path": b.ir_file_path,
|
|
}
|
|
for b in p.chain
|
|
],
|
|
"master_volume": p.master_volume,
|
|
"tuner_enabled": p.tuner_enabled,
|
|
})
|
|
except FileNotFoundError:
|
|
presets_in_bank.append(None)
|
|
result.append({
|
|
"name": bank.name,
|
|
"number": bank.number,
|
|
"presets": presets_in_bank,
|
|
})
|
|
return {"banks": result}
|
|
|
|
@app.get("/api/presets/{bank}/{program}")
|
|
async def get_preset(bank: int, program: int):
|
|
"""Get a specific preset."""
|
|
pm = self.deps.presets
|
|
if not pm:
|
|
raise HTTPException(status_code=503)
|
|
try:
|
|
p = pm.load(bank, program)
|
|
return {
|
|
"name": p.name,
|
|
"bank": p.bank,
|
|
"program": p.program,
|
|
"chain": [
|
|
{
|
|
"fx_type": b.fx_type.value,
|
|
"enabled": b.enabled,
|
|
"bypass": b.bypass,
|
|
"params": dict(b.params),
|
|
"nam_model_path": b.nam_model_path,
|
|
"ir_file_path": b.ir_file_path,
|
|
}
|
|
for b in p.chain
|
|
],
|
|
"master_volume": p.master_volume,
|
|
"tuner_enabled": p.tuner_enabled,
|
|
}
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Preset not found")
|
|
|
|
@app.post("/api/presets/{bank}/{program}/activate")
|
|
async def activate_preset(bank: int, program: int):
|
|
"""Activate a preset (load into pipeline)."""
|
|
pm = self.deps.presets
|
|
if not pm:
|
|
raise HTTPException(status_code=503)
|
|
try:
|
|
preset = pm.select(bank, program)
|
|
pm.save_state()
|
|
state = self._gather_state()
|
|
await self._manager.broadcast({
|
|
"type": "preset_changed",
|
|
"preset": {
|
|
"name": preset.name,
|
|
"bank": preset.bank,
|
|
"program": preset.program,
|
|
},
|
|
"state": state,
|
|
})
|
|
return {"ok": True, "preset": preset.name}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.put("/api/presets/{bank}/{program}")
|
|
async def save_preset(bank: int, program: int, data: dict):
|
|
"""Save/update a preset."""
|
|
pm = self.deps.presets
|
|
if not pm:
|
|
raise HTTPException(status_code=503)
|
|
try:
|
|
chain = []
|
|
for b in data.get("chain", []):
|
|
chain.append(FXBlock(
|
|
fx_type=FXType(b["fx_type"]),
|
|
enabled=b.get("enabled", True),
|
|
bypass=b.get("bypass", False),
|
|
params=b.get("params", {}),
|
|
nam_model_path=b.get("nam_model_path", ""),
|
|
ir_file_path=b.get("ir_file_path", ""),
|
|
))
|
|
preset = Preset(
|
|
name=data.get("name", f"Preset {bank}-{program}"),
|
|
bank=bank,
|
|
program=program,
|
|
chain=chain,
|
|
master_volume=data.get("master_volume", 0.8),
|
|
tuner_enabled=data.get("tuner_enabled", False),
|
|
)
|
|
pm.save(preset)
|
|
return {"ok": True, "name": preset.name}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.delete("/api/presets/{bank}/{program}")
|
|
async def delete_preset(bank: int, program: int):
|
|
"""Delete a preset."""
|
|
pm = self.deps.presets
|
|
if not pm:
|
|
raise HTTPException(status_code=503)
|
|
try:
|
|
pm.delete(bank, program)
|
|
return {"ok": True}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.post("/api/bypass")
|
|
async def toggle_bypass(data: Optional[dict] = None):
|
|
"""Toggle or set bypass state."""
|
|
pl = self.deps.pipeline
|
|
if not pl:
|
|
raise HTTPException(status_code=503)
|
|
if data and "bypass" in data:
|
|
pl._bypassed = bool(data["bypass"])
|
|
else:
|
|
pl._bypassed = not pl._bypassed
|
|
await self._manager.broadcast({
|
|
"type": "bypass_changed",
|
|
"bypass": pl._bypassed,
|
|
})
|
|
return {"ok": True, "bypass": pl._bypassed}
|
|
|
|
@app.post("/api/tuner")
|
|
async def toggle_tuner(data: Optional[dict] = None):
|
|
"""Toggle tuner mode."""
|
|
pl = self.deps.pipeline
|
|
if not pl:
|
|
raise HTTPException(status_code=503)
|
|
if data and "enabled" in data:
|
|
pl._tuner_enabled = bool(data["enabled"])
|
|
else:
|
|
pl._tuner_enabled = not pl._tuner_enabled
|
|
await self._manager.broadcast({
|
|
"type": "tuner_changed",
|
|
"tuner_enabled": pl._tuner_enabled,
|
|
})
|
|
return {"ok": True, "tuner_enabled": pl._tuner_enabled}
|
|
|
|
@app.put("/api/volume")
|
|
async def set_volume(data: dict):
|
|
"""Set master volume (0.0-1.0)."""
|
|
pl = self.deps.pipeline
|
|
if not pl:
|
|
raise HTTPException(status_code=503)
|
|
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
|
|
pl._master_volume = vol
|
|
return {"ok": True, "volume": vol}
|
|
|
|
# ── 4CM routing endpoints ────────────────────────────────
|
|
|
|
@app.get("/api/routing")
|
|
async def get_routing():
|
|
"""Get current routing mode and breakpoint."""
|
|
pl = self.deps.pipeline
|
|
if not pl:
|
|
raise HTTPException(status_code=503)
|
|
return {
|
|
"routing_mode": pl.routing_mode,
|
|
"routing_breakpoint": pl.routing_breakpoint,
|
|
}
|
|
|
|
@app.post("/api/routing")
|
|
async def set_routing(data: dict):
|
|
"""Set routing mode and/or breakpoint.
|
|
|
|
Body:
|
|
routing_mode (optional): ``\"mono\"`` or ``\"4cm\"``.
|
|
routing_breakpoint (optional): chain index for split.
|
|
"""
|
|
pl = self.deps.pipeline
|
|
pm = self.deps.presets
|
|
if not pl:
|
|
raise HTTPException(status_code=503)
|
|
mode = data.get("routing_mode", pl.routing_mode)
|
|
bp = int(data.get("routing_breakpoint", pl.routing_breakpoint))
|
|
pl.set_routing(mode, bp)
|
|
|
|
# Persist to current preset if preset manager is available
|
|
if pm:
|
|
try:
|
|
bank = pm.current_bank
|
|
program = pm.current_program
|
|
preset = pm.load(bank, program)
|
|
pm.set_4cm_config(preset, mode=mode, breakpoint=bp)
|
|
except Exception:
|
|
pass
|
|
|
|
await self._manager.broadcast({
|
|
"type": "routing_changed",
|
|
"routing_mode": pl.routing_mode,
|
|
"routing_breakpoint": pl.routing_breakpoint,
|
|
})
|
|
return {"ok": True, "routing_mode": pl.routing_mode,
|
|
"routing_breakpoint": pl.routing_breakpoint}
|
|
|
|
@app.get("/api/models")
|
|
async def list_models():
|
|
"""List available NAM models."""
|
|
nam = self.deps.nam_host
|
|
if not nam:
|
|
raise HTTPException(status_code=503)
|
|
models = nam.list_available_models()
|
|
return {
|
|
"models": [
|
|
{
|
|
"name": m.name,
|
|
"path": m.path,
|
|
"size_mb": round(m.size_mb, 2),
|
|
"architecture": m.architecture,
|
|
"loaded": nam.current_model is not None and nam.current_model.name == m.name,
|
|
}
|
|
for m in models
|
|
],
|
|
"current": nam.current_model.name if nam.current_model else None,
|
|
}
|
|
|
|
@app.post("/api/models/load")
|
|
async def load_model(data: dict):
|
|
"""Load a NAM model by path."""
|
|
nam = self.deps.nam_host
|
|
if not nam:
|
|
raise HTTPException(status_code=503)
|
|
path = data.get("path", "")
|
|
if not path:
|
|
raise HTTPException(status_code=400, detail="path required")
|
|
ok = nam.load_model(path)
|
|
if not ok:
|
|
raise HTTPException(status_code=500, detail="Failed to load model")
|
|
await self._manager.broadcast({
|
|
"type": "model_loaded",
|
|
"model": path,
|
|
})
|
|
return {"ok": True}
|
|
|
|
@app.post("/api/models/unload")
|
|
async def unload_model():
|
|
"""Unload the current NAM model."""
|
|
nam = self.deps.nam_host
|
|
if not nam:
|
|
raise HTTPException(status_code=503)
|
|
nam.unload()
|
|
return {"ok": True}
|
|
|
|
@app.get("/api/irs")
|
|
async def list_irs():
|
|
"""List available IR files."""
|
|
ir = self.deps.ir_loader
|
|
if not ir:
|
|
raise HTTPException(status_code=503)
|
|
irs = ir.get_irs()
|
|
return {
|
|
"irs": [
|
|
{
|
|
"name": irf.name,
|
|
"path": irf.path,
|
|
"sample_rate": irf.sample_rate,
|
|
"num_taps": irf.num_taps,
|
|
"length_ms": round(irf.length_ms, 1),
|
|
"loaded": ir.current_ir is not None and ir.current_ir.name == irf.name,
|
|
}
|
|
for irf in irs
|
|
],
|
|
"current": ir.current_ir.name if ir.current_ir else None,
|
|
}
|
|
|
|
@app.post("/api/irs/load")
|
|
async def load_ir(data: dict):
|
|
"""Load an IR file by path."""
|
|
ir = self.deps.ir_loader
|
|
if not ir:
|
|
raise HTTPException(status_code=503)
|
|
path = data.get("path", "")
|
|
if not path:
|
|
raise HTTPException(status_code=400, detail="path required")
|
|
ok = ir.load_ir(path)
|
|
if not ok:
|
|
raise HTTPException(status_code=500, detail="Failed to load IR")
|
|
await self._manager.broadcast({
|
|
"type": "ir_loaded",
|
|
"ir": path,
|
|
})
|
|
return {"ok": True}
|
|
|
|
@app.post("/api/irs/unload")
|
|
async def unload_ir():
|
|
"""Unload the current IR."""
|
|
ir = self.deps.ir_loader
|
|
if not ir:
|
|
raise HTTPException(status_code=503)
|
|
ir.unload()
|
|
return {"ok": True}
|
|
|
|
# ── File upload endpoints ─────────────────────────────────
|
|
|
|
@app.post("/api/models/upload")
|
|
async def upload_model(request: Request):
|
|
"""Upload a .nam model file to the pedal's models directory."""
|
|
nam = self.deps.nam_host
|
|
if not nam:
|
|
raise HTTPException(status_code=503)
|
|
form = await request.form()
|
|
file = form.get("file")
|
|
if not file or not file.filename:
|
|
raise HTTPException(status_code=400, detail="No file provided")
|
|
if not file.filename.endswith(".nam"):
|
|
raise HTTPException(status_code=400, detail="Only .nam files accepted")
|
|
models_dir = nam._models_dir
|
|
dest = models_dir / Path(file.filename).name
|
|
content = await file.read()
|
|
dest.write_bytes(content)
|
|
logger.info("Uploaded NAM model: %s", dest.name)
|
|
return {"ok": True, "filename": dest.name}
|
|
|
|
@app.post("/api/irs/upload")
|
|
async def upload_ir(request: Request):
|
|
"""Upload a .wav IR file to the pedal's IR directory."""
|
|
ir = self.deps.ir_loader
|
|
if not ir:
|
|
raise HTTPException(status_code=503)
|
|
form = await request.form()
|
|
file = form.get("file")
|
|
if not file or not file.filename:
|
|
raise HTTPException(status_code=400, detail="No file provided")
|
|
if not file.filename.endswith(".wav"):
|
|
raise HTTPException(status_code=400, detail="Only .wav files accepted")
|
|
ir_dir = ir._ir_dir
|
|
dest = ir_dir / Path(file.filename).name
|
|
content = await file.read()
|
|
dest.write_bytes(content)
|
|
logger.info("Uploaded IR file: %s", dest.name)
|
|
return {"ok": True, "filename": dest.name}
|
|
|
|
# ── WiFi endpoints ──────────────────────────────────────
|
|
|
|
@app.get("/api/wifi/status")
|
|
async def wifi_get_status():
|
|
"""Get current WiFi client connection status."""
|
|
from ..system.network import wifi_status
|
|
return wifi_status()
|
|
|
|
@app.get("/api/wifi/scan")
|
|
async def wifi_scan():
|
|
"""Scan for available WiFi networks."""
|
|
from ..system.network import wifi_scan
|
|
return {"networks": wifi_scan()}
|
|
|
|
@app.get("/api/wifi/saved")
|
|
async def wifi_saved():
|
|
"""List saved/known WiFi networks."""
|
|
from ..system.network import wifi_saved_networks
|
|
return {"networks": wifi_saved_networks()}
|
|
|
|
@app.post("/api/wifi/connect")
|
|
async def wifi_connect(data: dict):
|
|
"""Connect to a WiFi network.
|
|
|
|
Body: {ssid: str, password: str}
|
|
"""
|
|
from ..system.network import wifi_connect
|
|
return wifi_connect(
|
|
ssid=data.get("ssid", ""),
|
|
password=data.get("password", ""),
|
|
)
|
|
|
|
@app.post("/api/wifi/disconnect")
|
|
async def wifi_disconnect():
|
|
"""Disconnect from the current WiFi network."""
|
|
from ..system.network import wifi_disconnect
|
|
return wifi_disconnect()
|
|
|
|
@app.post("/api/wifi/forget")
|
|
async def wifi_forget(data: dict):
|
|
"""Forget a saved WiFi network.
|
|
|
|
Body: {ssid: str}
|
|
"""
|
|
from ..system.network import wifi_forget
|
|
return wifi_forget(ssid=data.get("ssid", ""))
|
|
|
|
@app.get("/api/wifi/hotspot")
|
|
async def wifi_hotspot_status():
|
|
"""Get hotspot status."""
|
|
from ..system.network import hotspot_status
|
|
return hotspot_status()
|
|
|
|
@app.post("/api/wifi/hotspot/enable")
|
|
async def wifi_hotspot_enable(data: dict):
|
|
"""Enable WiFi hotspot.
|
|
|
|
Body: {ssid: str (optional), password: str (optional)}
|
|
"""
|
|
from ..system.network import hotspot_enable
|
|
return hotspot_enable(
|
|
ssid=data.get("ssid", "Pi-Pedal"),
|
|
password=data.get("password", "pedal1234"),
|
|
)
|
|
|
|
@app.post("/api/wifi/hotspot/disable")
|
|
async def wifi_hotspot_disable():
|
|
"""Disable WiFi hotspot."""
|
|
from ..system.network import hotspot_disable
|
|
return hotspot_disable()
|
|
|
|
# ── Bluetooth endpoints ──────────────────────────────────
|
|
|
|
@app.get("/api/bluetooth/status")
|
|
async def bluetooth_status():
|
|
"""Get Bluetooth adapter status."""
|
|
from ..system.bluetooth import bt_status
|
|
return bt_status()
|
|
|
|
@app.post("/api/bluetooth/power")
|
|
async def bluetooth_power(data: dict):
|
|
"""Power Bluetooth on or off.
|
|
|
|
Body: {on: bool}
|
|
"""
|
|
from ..system.bluetooth import bt_power
|
|
return bt_power(on=data.get("on", True))
|
|
|
|
@app.post("/api/bluetooth/discoverable")
|
|
async def bluetooth_discoverable(data: dict):
|
|
"""Set Bluetooth discoverable.
|
|
|
|
Body: {on: bool}
|
|
"""
|
|
from ..system.bluetooth import bt_discoverable
|
|
return bt_discoverable(on=data.get("on", True))
|
|
|
|
@app.post("/api/bluetooth/scan")
|
|
async def bluetooth_scan():
|
|
"""Scan for discoverable Bluetooth devices."""
|
|
from ..system.bluetooth import bt_scan
|
|
return bt_scan(timeout=12)
|
|
|
|
@app.get("/api/bluetooth/paired")
|
|
async def bluetooth_paired():
|
|
"""List paired Bluetooth devices."""
|
|
from ..system.bluetooth import bt_paired_devices
|
|
return {"devices": bt_paired_devices()}
|
|
|
|
@app.post("/api/bluetooth/pair")
|
|
async def bluetooth_pair(data: dict):
|
|
"""Pair with a Bluetooth device.
|
|
|
|
Body: {address: str}
|
|
"""
|
|
from ..system.bluetooth import bt_pair
|
|
return bt_pair(address=data.get("address", ""))
|
|
|
|
@app.post("/api/bluetooth/unpair")
|
|
async def bluetooth_unpair(data: dict):
|
|
"""Unpair a Bluetooth device.
|
|
|
|
Body: {address: str}
|
|
"""
|
|
from ..system.bluetooth import bt_unpair
|
|
return bt_unpair(address=data.get("address", ""))
|
|
|
|
@app.post("/api/bluetooth/connect")
|
|
async def bluetooth_connect(data: dict):
|
|
"""Connect to a paired Bluetooth device.
|
|
|
|
Body: {address: str}
|
|
"""
|
|
from ..system.bluetooth import bt_connect
|
|
return bt_connect(address=data.get("address", ""))
|
|
|
|
@app.post("/api/bluetooth/disconnect")
|
|
async def bluetooth_disconnect(data: dict):
|
|
"""Disconnect a Bluetooth device.
|
|
|
|
Body: {address: str}
|
|
"""
|
|
from ..system.bluetooth import bt_disconnect
|
|
return bt_disconnect(address=data.get("address", ""))
|
|
|
|
@app.get("/api/bluetooth/midi-status")
|
|
async def bluetooth_midi_status():
|
|
"""Get Bluetooth MIDI service status."""
|
|
from ..system.bluetooth import bt_midi_status
|
|
return bt_midi_status()
|
|
|
|
@app.post("/api/bluetooth/midi-enable")
|
|
async def bluetooth_midi_enable():
|
|
"""Enable and start the Bluetooth MIDI service."""
|
|
from ..system.bluetooth import bt_midi_enable
|
|
return bt_midi_enable()
|
|
|
|
@app.post("/api/bluetooth/midi-disable")
|
|
async def bluetooth_midi_disable():
|
|
"""Stop and disable the Bluetooth MIDI service."""
|
|
from ..system.bluetooth import bt_midi_disable
|
|
return bt_midi_disable()
|
|
|
|
@app.get("/api/bluetooth/midi-devices")
|
|
async def bluetooth_midi_devices():
|
|
"""List connected Bluetooth MIDI devices."""
|
|
from ..system.bluetooth import bt_midi_connected_devices
|
|
return {"devices": bt_midi_connected_devices()}
|
|
|
|
# ── FX block param schemas ───────────────────────────────
|
|
|
|
@app.get("/api/block-params/{fx_type}")
|
|
async def get_block_params(fx_type: str):
|
|
"""Return editable parameters for a given FX block type.
|
|
|
|
Returns a list of param definitions with name, type, min, max, default.
|
|
The web UI uses this to render appropriate sliders/knobs.
|
|
"""
|
|
param_schema = _FX_PARAM_SCHEMAS.get(fx_type, [])
|
|
return {"fx_type": fx_type, "params": param_schema}
|
|
|
|
# ── WebSocket ──────────────────────────────────────────────
|
|
|
|
@app.websocket("/ws")
|
|
async def websocket_endpoint(ws: WebSocket):
|
|
await self._manager.connect(ws)
|
|
logger.info("WebSocket client connected (%d active)", self._manager.active_count)
|
|
try:
|
|
# Send initial state snapshot on connect
|
|
state = self._gather_state()
|
|
await ws.send_text(json.dumps({"type": "connected", "state": state}))
|
|
|
|
while True:
|
|
raw = await ws.receive_text()
|
|
msg = json.loads(raw)
|
|
msg_type = msg.get("type", "")
|
|
|
|
if msg_type == "ping":
|
|
await ws.send_text(json.dumps({"type": "pong"}))
|
|
elif msg_type == "get_state":
|
|
state = self._gather_state()
|
|
await ws.send_text(json.dumps({"type": "state", "state": state}))
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning("WebSocket error: %s", e)
|
|
finally:
|
|
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 ──────────────────────────────────
|
|
|
|
def _gather_wifi_state(self) -> dict:
|
|
"""Try to gather WiFi status without hard-failing."""
|
|
try:
|
|
from ..system.network import wifi_status, hotspot_status
|
|
ws = wifi_status()
|
|
hs = hotspot_status()
|
|
return {
|
|
"connected": ws.get("connected", False),
|
|
"ssid": ws.get("ssid"),
|
|
"ip": ws.get("ip"),
|
|
"signal": ws.get("signal", 0),
|
|
"hotspot_active": hs.get("active", False),
|
|
"hotspot_ssid": hs.get("ssid"),
|
|
"hotspot_clients": hs.get("clients", 0),
|
|
}
|
|
except Exception:
|
|
return {"connected": False, "ssid": None, "ip": None, "signal": 0,
|
|
"hotspot_active": False, "hotspot_ssid": None, "hotspot_clients": 0}
|
|
|
|
def _gather_bt_state(self) -> dict:
|
|
"""Try to gather Bluetooth status without hard-failing."""
|
|
try:
|
|
from ..system.bluetooth import bt_status, bt_midi_status
|
|
bs = bt_status()
|
|
ms = bt_midi_status()
|
|
return {
|
|
"available": bs.get("available", False),
|
|
"powered": bs.get("powered", False),
|
|
"name": bs.get("name"),
|
|
"address": bs.get("address"),
|
|
"discoverable": bs.get("discoverable", False),
|
|
"midi_enabled": ms.get("enabled", False),
|
|
"midi_running": ms.get("running", False),
|
|
}
|
|
except Exception:
|
|
return {"available": False, "powered": False, "name": None,
|
|
"address": None, "discoverable": False,
|
|
"midi_enabled": False, "midi_running": False}
|
|
|
|
# ── State gathering ──────────────────────────────────────────────
|
|
|
|
def _gather_state(self) -> dict[str, Any]:
|
|
"""Collect a snapshot of the current pedal state."""
|
|
pm = self.deps.presets
|
|
pl = self.deps.pipeline
|
|
nam = self.deps.nam_host
|
|
ir = self.deps.ir_loader
|
|
|
|
if not pm:
|
|
return {"connected": False}
|
|
|
|
try:
|
|
bank = pm.current_bank
|
|
program = pm.current_program
|
|
preset = pm.load(bank, program) if pm else None
|
|
except Exception:
|
|
preset = None
|
|
|
|
def _safe_str(val) -> str | None:
|
|
"""Get a string representation, or None if the value is falsy/mock."""
|
|
if val is None:
|
|
return None
|
|
try:
|
|
s = str(val)
|
|
return s if s and not s.startswith("<MagicMock") else None
|
|
except Exception:
|
|
return None
|
|
|
|
current_model_obj = nam.current_model if nam else None
|
|
current_ir_obj = ir.current_ir if ir else None
|
|
current_model_name = _safe_str(current_model_obj.name if current_model_obj else None)
|
|
current_ir_name = _safe_str(current_ir_obj.name if current_ir_obj else None)
|
|
|
|
return {
|
|
"connected": True,
|
|
"current_preset": {
|
|
"name": preset.name if preset else "—",
|
|
"bank": preset.bank if preset else 0,
|
|
"program": preset.program if preset else 0,
|
|
} if preset else None,
|
|
"bypass": pl._bypassed if pl else False,
|
|
"tuner_enabled": pl._tuner_enabled if pl else False,
|
|
"master_volume": pl._master_volume if pl else 0.8,
|
|
"routing_mode": pl.routing_mode if pl else "mono",
|
|
"routing_breakpoint": pl.routing_breakpoint if pl else 7,
|
|
"nam_loaded": bool(nam and nam.is_loaded) if nam else False,
|
|
"nam_model": current_model_name,
|
|
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
|
|
"ir_name": current_ir_name,
|
|
"wifi": self._gather_wifi_state(),
|
|
"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:
|
|
"""Start the web server in a background thread.
|
|
|
|
Uses a dedicated thread with its own asyncio event loop to
|
|
avoid conflicting with the main thread's blocking JACK loop.
|
|
"""
|
|
import threading
|
|
config = uvicorn.Config(
|
|
self._app,
|
|
host=self.host,
|
|
port=self.port,
|
|
log_level="info",
|
|
access_log=False,
|
|
)
|
|
|
|
def _run():
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
server = uvicorn.Server(config)
|
|
self._server = server
|
|
loop.run_until_complete(server.serve())
|
|
|
|
self._thread = threading.Thread(target=_run, daemon=True)
|
|
self._thread.start()
|
|
logger.info("Web server starting on http://%s:%d", self.host, self.port)
|
|
|
|
def stop(self) -> None:
|
|
"""Stop the web server gracefully."""
|
|
if self._server:
|
|
self._server.should_exit = True
|
|
logger.info("Web server stopped")
|
|
|
|
|
|
# ── FX parameter schemas ─────────────────────────────────────────────────────
|
|
# Used by the web UI to render appropriate controls per FX block type.
|
|
|
|
_FX_PARAM_SCHEMAS: dict[str, list[dict]] = {
|
|
"noise_gate": [
|
|
{"key": "threshold", "name": "Threshold", "type": "float", "min": 0.0, "max": 0.1, "step": 0.001, "default": 0.01},
|
|
{"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0},
|
|
],
|
|
"compressor": [
|
|
{"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -20.0},
|
|
{"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 3.0},
|
|
{"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 5.0},
|
|
{"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0},
|
|
{"key": "gain", "name": "Makeup Gain", "type": "float", "min": 0.0, "max": 5.0, "step": 0.1, "default": 1.0},
|
|
],
|
|
"boost": [
|
|
{"key": "gain_db", "name": "Gain (dB)", "type": "float", "min": 0.0, "max": 30.0, "step": 0.5, "default": 6.0},
|
|
],
|
|
"overdrive": [
|
|
{"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0},
|
|
],
|
|
"distortion": [
|
|
{"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7},
|
|
{"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0},
|
|
],
|
|
"fuzz": [
|
|
{"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.8},
|
|
{"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0},
|
|
],
|
|
"eq": [
|
|
{"key": "low_gain", "name": "Low (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
|
|
{"key": "mid_gain", "name": "Mid (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
|
|
{"key": "high_gain", "name": "High (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
|
|
],
|
|
"chorus": [
|
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.1, "max": 5.0, "step": 0.1, "default": 1.0},
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"flanger": [
|
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 3.0, "step": 0.05, "default": 0.5},
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"phaser": [
|
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.5},
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
],
|
|
"tremolo": [
|
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 3.0},
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"vibrato": [
|
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 3.0},
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"delay": [
|
|
{"key": "time", "name": "Time (ms)", "type": "float", "min": 10.0, "max": 2000.0, "step": 5.0, "default": 400.0},
|
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"reverb": [
|
|
{"key": "size", "name": "Room Size", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
{"key": "damping", "name": "Damping", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
],
|
|
"volume": [
|
|
{"key": "level", "name": "Level", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.8},
|
|
],
|
|
# ── Pitch & Frequency ──────────────────────────────────────────
|
|
"octaver": [
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"pitch_shifter": [
|
|
{"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"harmonizer": [
|
|
{"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 3.0},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"whammy": [
|
|
{"key": "bend", "name": "Bend", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.0},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7},
|
|
],
|
|
"detune": [
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
# ── Modulation ─────────────────────────────────────────────────
|
|
"ring_modulator": [
|
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 10.0, "max": 2000.0, "step": 10.0, "default": 100.0},
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"auto_wah": [
|
|
{"key": "sensitivity", "name": "Sensitivity", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "q", "name": "Q", "type": "float", "min": 0.5, "max": 10.0, "step": 0.1, "default": 2.0},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"envelope_filter": [
|
|
{"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "range", "name": "Range", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"rotary_speaker": [
|
|
{"key": "speed", "name": "Speed", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"uni_vibe": [
|
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.8},
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"auto_pan": [
|
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.3},
|
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7},
|
|
{"key": "waveform", "name": "Waveform", "type": "select", "options": ["sine", "triangle", "square"], "default": "sine"},
|
|
],
|
|
"stereo_widener": [
|
|
{"key": "width", "name": "Width", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
# ── Drive & Saturation ─────────────────────────────────────────
|
|
"bitcrusher": [
|
|
{"key": "bits", "name": "Bits", "type": "int", "min": 1, "max": 16, "step": 1, "default": 8},
|
|
{"key": "rate", "name": "Rate Reduction", "type": "float", "min": 1.0, "max": 10.0, "step": 1.0, "default": 1.0},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"wavefolder": [
|
|
{"key": "gain", "name": "Gain", "type": "float", "min": 0.5, "max": 10.0, "step": 0.5, "default": 2.0},
|
|
{"key": "fold", "name": "Fold Stages", "type": "int", "min": 1, "max": 10, "step": 1, "default": 3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"rectifier": [
|
|
{"key": "mode", "name": "Mode", "type": "select", "options": ["full", "half"], "default": "full"},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
# ── Dynamics ────────────────────────────────────────────────────
|
|
"expander": [
|
|
{"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -30.0},
|
|
{"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 3.0},
|
|
{"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 5.0},
|
|
{"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0},
|
|
],
|
|
"de_esser": [
|
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 2000.0, "max": 10000.0, "step": 100.0, "default": 6000.0},
|
|
{"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -50.0, "max": 0.0, "step": 1.0, "default": -30.0},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"transient_shaper": [
|
|
{"key": "attack", "name": "Attack Boost", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "sustain", "name": "Sustain", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
"sidechain_compressor": [
|
|
{"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -20.0},
|
|
{"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 4.0},
|
|
{"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 2.0},
|
|
{"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 50.0},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
# ── Filters & EQ ────────────────────────────────────────────────
|
|
"parametric_eq": [
|
|
{"key": "freq_0", "name": "Band 1 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 100.0},
|
|
{"key": "gain_0", "name": "Band 1 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0},
|
|
{"key": "q_0", "name": "Band 1 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
|
|
{"key": "freq_1", "name": "Band 2 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 500.0},
|
|
{"key": "gain_1", "name": "Band 2 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0},
|
|
{"key": "q_1", "name": "Band 2 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
|
|
{"key": "freq_2", "name": "Band 3 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 2000.0},
|
|
{"key": "gain_2", "name": "Band 3 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0},
|
|
{"key": "q_2", "name": "Band 3 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
|
|
{"key": "freq_3", "name": "Band 4 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 5000.0},
|
|
{"key": "gain_3", "name": "Band 4 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0},
|
|
{"key": "q_3", "name": "Band 4 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
|
|
],
|
|
"high_pass_filter": [
|
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 200.0},
|
|
{"key": "slope", "name": "Slope (dB/oct)", "type": "select", "options": [6, 12], "default": 12},
|
|
],
|
|
"low_pass_filter": [
|
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 5000.0},
|
|
{"key": "slope", "name": "Slope (dB/oct)", "type": "select", "options": [6, 12], "default": 12},
|
|
],
|
|
"band_pass_filter": [
|
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 1000.0},
|
|
{"key": "q", "name": "Q", "type": "float", "min": 0.1, "max": 20.0, "step": 0.1, "default": 0.707},
|
|
],
|
|
"notch_filter": [
|
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 60.0},
|
|
{"key": "q", "name": "Q", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 10.0},
|
|
],
|
|
"formant_filter": [
|
|
{"key": "vowel", "name": "Vowel", "type": "select", "options": ["a", "e", "i", "o", "u"], "default": "a"},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
],
|
|
# ── Time-Based ──────────────────────────────────────────────────
|
|
"ping_pong_delay": [
|
|
{"key": "time", "name": "Time (ms)", "type": "float", "min": 10.0, "max": 2000.0, "step": 5.0, "default": 400.0},
|
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
],
|
|
"multi_tap_delay": [
|
|
{"key": "pattern", "name": "Pattern", "type": "select", "options": ["quarter", "dotted", "triplet"], "default": "quarter"},
|
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
],
|
|
"reverse_delay": [
|
|
{"key": "time", "name": "Time (ms)", "type": "float", "min": 50.0, "max": 2000.0, "step": 5.0, "default": 400.0},
|
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
],
|
|
"tape_echo": [
|
|
{"key": "time", "name": "Time (ms)", "type": "float", "min": 50.0, "max": 2000.0, "step": 5.0, "default": 300.0},
|
|
{"key": "wow", "name": "Wow/Flutter", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "high_cut", "name": "High Cut", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
],
|
|
"shimmer_reverb": [
|
|
{"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
|
|
{"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
],
|
|
"looper": [
|
|
{"key": "record", "name": "Record", "type": "bool", "default": 0},
|
|
{"key": "overdub", "name": "Overdub", "type": "bool", "default": 0},
|
|
{"key": "play", "name": "Play", "type": "bool", "default": 0},
|
|
{"key": "stop", "name": "Stop", "type": "bool", "default": 0},
|
|
],
|
|
# ── Ambience ────────────────────────────────────────────────────
|
|
"early_reflections": [
|
|
{"key": "size", "name": "Room Size", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
|
{"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
|
],
|
|
}
|
|
|
|
|
|
# ── Standalone runner for development ─────────────────────────────────────────
|
|
|
|
def main():
|
|
"""Run the web server standalone (for dev/testing without the full pedal)."""
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
import uvicorn
|
|
deps = WebServerDeps()
|
|
ws = WebServer(deps)
|
|
ws.start()
|
|
|
|
logger.info("Dev server running on http://0.0.0.0:8080")
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
loop.run_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
ws.stop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |