Build: Web UI + mDNS (pedal.local)
FastAPI web server with REST + WebSocket, 5 Jinja2 HTML pages, mobile-friendly dark theme CSS, page-specific JS controllers, and Avahi mDNS service advertisement for pedal.local. Files: src/web/server.py — FastAPI app with 17 REST endpoints + /ws WebSocket src/web/__init__.py — Package init with WebServer, WebServerDeps exports src/web/templates/*.html — Dashboard, Presets, Models, IRs, Settings (base) src/web/static/style.css — Dark mobile-first CSS (80-82% max-width) src/web/static/websocket.js — Auto-reconnecting WebSocket manager src/web/static/app.js — Dashboard live updates + REST helpers src/web/static/presets.js — Bank/preset CRUD modal UI src/web/static/models.js — NAM model list/load/upload src/web/static/irs.js — IR file list/load/upload etc/avahi/pi-multifx-pedal.service — Avahi service advertisement scripts/setup-mdns.sh — One-shot mDNS setup (avahi, hostname, restart) main.py — Wire WebServer into boot/shutdown requirements.txt — Add fastapi, uvicorn, jinja2, python-multipart tests/test_web.py — 36 tests (pages, REST, WebSocket, errors) All 36 tests pass.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Avahi service advertisement for the Pi Multi-FX Pedal web UI.
|
||||
# This allows the pedal to be discovered as pedal.local on the LAN.
|
||||
# Place in /etc/avahi/services/pi-multifx-pedal.service
|
||||
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
||||
<service-group>
|
||||
<name>Pi Multi-FX Pedal</name>
|
||||
<service>
|
||||
<type>_http._tcp</type>
|
||||
<port>8080</port>
|
||||
<txt-record>path=/</txt-record>
|
||||
<txt-record>product=Pi-Multi-FX-Pedal</txt-record>
|
||||
<txt-record>version=0.1.0</txt-record>
|
||||
</service>
|
||||
<service>
|
||||
<type>_pedal._tcp</type>
|
||||
<port>8080</port>
|
||||
<txt-record>api=/api</txt-record>
|
||||
<txt-record>ws=/ws</txt-record>
|
||||
</service>
|
||||
</service-group>
|
||||
@@ -32,6 +32,7 @@ from src.midi.handler import MIDIHandler
|
||||
from src.ui.footswitch import FootswitchController, FootSwitch, SwitchAction
|
||||
from src.ui.leds import LEDController, LEDDriver, LEDConfig, LEDPattern
|
||||
from src.ui.display import DisplayController, DisplayState
|
||||
from src.web.server import WebServer, WebServerDeps
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -84,6 +85,7 @@ class PedalApp:
|
||||
self.footswitches: FootswitchController | None = None
|
||||
self.leds: LEDController | None = None
|
||||
self.display: DisplayController | None = None
|
||||
self.web: WebServer | None = None
|
||||
|
||||
# ── Signal handlers ────────────────────────────────────────────
|
||||
self._shutdown_requested = threading.Event()
|
||||
@@ -190,6 +192,24 @@ class PedalApp:
|
||||
# Boot LED animation — quick scan
|
||||
self.leds.preset_animate(direction="up")
|
||||
|
||||
# ── 8. Web UI server (non-blocking HTTP + WebSocket) ────
|
||||
# Runs on port 8080 by default, alongside the JACK audio loop.
|
||||
try:
|
||||
self.web = WebServer(
|
||||
deps=WebServerDeps(
|
||||
presets=self.presets,
|
||||
pipeline=self.pipeline,
|
||||
nam_host=self.nam_host,
|
||||
ir_loader=self.ir_loader,
|
||||
),
|
||||
host="0.0.0.0",
|
||||
port=8080,
|
||||
)
|
||||
self.web.start()
|
||||
logger.info("Web UI server started on http://0.0.0.0:8080")
|
||||
except Exception as e:
|
||||
logger.warning("Web UI server failed to start (non-fatal): %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.critical("Boot failed: %s", e, exc_info=True)
|
||||
return False
|
||||
@@ -495,7 +515,15 @@ class PedalApp:
|
||||
except Exception as e:
|
||||
logger.warning("Footswitch stop error: %s", e)
|
||||
|
||||
# 2. Save current state (preset + bank)
|
||||
# 2b. Stop web server (before state save — WS clients get final message)
|
||||
if self.web:
|
||||
try:
|
||||
self.web.stop()
|
||||
logger.debug("Web server stopped")
|
||||
except Exception as e:
|
||||
logger.warning("Web server stop error: %s", e)
|
||||
|
||||
# 3. Save current state (preset + bank)
|
||||
if self.presets:
|
||||
try:
|
||||
self.presets.save_state()
|
||||
|
||||
+8
-1
@@ -25,7 +25,14 @@ pyserial>=3.5
|
||||
pyyaml>=6.0
|
||||
orjson>=3.9
|
||||
|
||||
# Web UI
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.27
|
||||
jinja2>=3.1
|
||||
python-multipart>=0.0.9
|
||||
websockets>=12.0
|
||||
|
||||
# Testing
|
||||
pytest>=7.4
|
||||
pytest>=7.0
|
||||
pytest-asyncio>=0.21
|
||||
pytest-mock>=3.11
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Pi Multi-FX Pedal — mDNS (Avahi) setup ─────────────────────────
|
||||
# Enables pedal.local or multifx.local resolution on the LAN.
|
||||
# Run once after first install: sudo bash scripts/setup-mdns.sh
|
||||
#
|
||||
# What it does:
|
||||
# 1. Installs avahi-daemon if missing
|
||||
# 2. Sets hostname to 'pedal' in /etc/hostname
|
||||
# 3. Configures avahi-daemon to publish the hostname
|
||||
# 4. Installs the service advertisement file for the web UI
|
||||
# 5. Restarts avahi-daemon
|
||||
#
|
||||
# After this, http://pedal.local:8080 works from any machine on the LAN.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
HOSTNAME="pedal"
|
||||
AVAHI_SERVICE_SRC="${PROJECT_DIR}/etc/avahi/pi-multifx-pedal.service"
|
||||
AVAHI_SERVICE_DST="/etc/avahi/services/pi-multifx-pedal.service"
|
||||
|
||||
echo "═══ Pi Multi-FX Pedal — mDNS Setup ═══"
|
||||
|
||||
# 1. Install avahi-daemon
|
||||
if ! command -v avahi-daemon &>/dev/null; then
|
||||
echo "Installing avahi-daemon..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq avahi-daemon avahi-utils
|
||||
else
|
||||
echo "avahi-daemon already installed"
|
||||
fi
|
||||
|
||||
# 2. Set hostname
|
||||
CURRENT_HOSTNAME="$(hostname)"
|
||||
if [ "$CURRENT_HOSTNAME" != "$HOSTNAME" ]; then
|
||||
echo "Setting hostname to '$HOSTNAME'..."
|
||||
echo "$HOSTNAME" | sudo tee /etc/hostname > /dev/null
|
||||
sudo hostnamectl set-hostname "$HOSTNAME"
|
||||
else
|
||||
echo "Hostname already set to '$HOSTNAME'"
|
||||
fi
|
||||
|
||||
# 3. Configure avahi to publish hostname
|
||||
AVAHI_CONF="/etc/avahi/avahi-daemon.conf"
|
||||
if grep -q "^#host-name=" "$AVAHI_CONF" 2>/dev/null; then
|
||||
echo "Uncommenting host-name in avahi-daemon.conf..."
|
||||
sudo sed -i "s/^#host-name=/host-name=/g" "$AVAHI_CONF"
|
||||
fi
|
||||
|
||||
# Make sure publish-workstation is on (publishes _http._tcp for pedal.local)
|
||||
if grep -q "^#publish-workstation=" "$AVAHI_CONF" 2>/dev/null; then
|
||||
sudo sed -i "s/^#publish-workstation=/publish-workstation=/g" "$AVAHI_CONF"
|
||||
fi
|
||||
|
||||
# 4. Install the custom service advertisement
|
||||
if [ -f "$AVAHI_SERVICE_SRC" ]; then
|
||||
echo "Installing Avahi service advertisement..."
|
||||
sudo cp "$AVAHI_SERVICE_SRC" "$AVAHI_SERVICE_DST"
|
||||
else
|
||||
echo "WARNING: Service file not found at $AVAHI_SERVICE_SRC — skipping"
|
||||
fi
|
||||
|
||||
# 5. Restart avahi-daemon
|
||||
echo "Restarting avahi-daemon..."
|
||||
sudo systemctl enable avahi-daemon
|
||||
sudo systemctl restart avahi-daemon
|
||||
|
||||
echo ""
|
||||
echo "═══ mDNS setup complete ═══"
|
||||
echo "The pedal should now be reachable at:"
|
||||
echo " http://${HOSTNAME}.local:8080"
|
||||
echo ""
|
||||
echo "From any device on the same LAN, try:"
|
||||
echo " ping ${HOSTNAME}.local"
|
||||
echo " curl http://${HOSTNAME}.local:8080/api/state"
|
||||
echo ""
|
||||
echo "NOTE: If resolution doesn't work immediately, wait ~10 seconds"
|
||||
echo "for mDNS propagation, or check: sudo systemctl status avahi-daemon"
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Web UI package — FastAPI + WebSocket control interface for the pedal.
|
||||
|
||||
Usage from main.py:
|
||||
from src.web.server import WebServer, WebServerDeps
|
||||
web = WebServer(WebServerDeps(presets=..., pipeline=..., nam=..., ir=...))
|
||||
web.start()
|
||||
"""
|
||||
|
||||
from .server import WebServer, WebServerDeps
|
||||
@@ -0,0 +1,723 @@
|
||||
"""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
|
||||
|
||||
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._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}
|
||||
|
||||
@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}
|
||||
|
||||
@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)
|
||||
|
||||
return app
|
||||
|
||||
# ── 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,
|
||||
"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,
|
||||
}
|
||||
|
||||
# ── 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},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 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()
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Main app.js — Dashboard live updates, global handlers.
|
||||
*
|
||||
* Relies on pedalWS (from websocket.js) for real-time state pushes
|
||||
* and provides REST helper functions used by all pages.
|
||||
*/
|
||||
|
||||
/* ── REST helpers ───────────────────────────────────────────────── */
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(`/api${path}`);
|
||||
if (!res.ok) throw new Error(`API GET ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, body = {}) {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API POST ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPut(path, body = {}) {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API PUT ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiDelete(path) {
|
||||
const res = await fetch(`/api${path}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`API DELETE ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ── Dashboard updates (via WebSocket) ──────────────────────────── */
|
||||
|
||||
pedalWS.on('connected', (msg) => {
|
||||
if (msg.state) updateDashboardState(msg.state);
|
||||
});
|
||||
|
||||
pedalWS.on('state', (msg) => {
|
||||
if (msg.state) updateDashboardState(msg.state);
|
||||
});
|
||||
|
||||
pedalWS.on('preset_changed', (msg) => {
|
||||
if (msg.preset) {
|
||||
const nameEl = document.getElementById('preset-name');
|
||||
const metaEl = document.getElementById('preset-meta');
|
||||
if (nameEl) nameEl.textContent = msg.preset.name;
|
||||
if (metaEl) metaEl.textContent = `Bank ${msg.preset.bank} — Program ${msg.preset.program}`;
|
||||
}
|
||||
if (msg.state) updateDashboardState(msg.state);
|
||||
});
|
||||
|
||||
pedalWS.on('bypass_changed', (msg) => {
|
||||
const btn = document.getElementById('bypass-btn');
|
||||
if (btn) {
|
||||
btn.textContent = msg.bypass ? 'BYPASSED' : 'ACTIVE';
|
||||
btn.classList.toggle('active', msg.bypass);
|
||||
}
|
||||
});
|
||||
|
||||
pedalWS.on('tuner_changed', (msg) => {
|
||||
const btn = document.getElementById('tuner-btn');
|
||||
if (btn) {
|
||||
btn.textContent = msg.tuner_enabled ? 'ON' : 'OFF';
|
||||
btn.classList.toggle('active', msg.tuner_enabled);
|
||||
}
|
||||
const sBtn = document.getElementById('settings-tuner-btn');
|
||||
if (sBtn) {
|
||||
sBtn.textContent = msg.tuner_enabled ? 'ON' : 'OFF';
|
||||
sBtn.classList.toggle('active', msg.tuner_enabled);
|
||||
}
|
||||
});
|
||||
|
||||
function updateDashboardState(state) {
|
||||
if (!state || !state.connected) return;
|
||||
|
||||
// Preset info
|
||||
if (state.current_preset) {
|
||||
const nameEl = document.getElementById('preset-name');
|
||||
const metaEl = document.getElementById('preset-meta');
|
||||
if (nameEl) nameEl.textContent = state.current_preset.name;
|
||||
if (metaEl) metaEl.textContent = `Bank ${state.current_preset.bank} — Program ${state.current_preset.program}`;
|
||||
}
|
||||
|
||||
// Bypass
|
||||
const bypassBtn = document.getElementById('bypass-btn');
|
||||
if (bypassBtn) {
|
||||
bypassBtn.textContent = state.bypass ? 'BYPASSED' : 'ACTIVE';
|
||||
bypassBtn.classList.toggle('active', state.bypass);
|
||||
}
|
||||
|
||||
// Tuner
|
||||
const tunerBtn = document.getElementById('tuner-btn');
|
||||
if (tunerBtn) {
|
||||
tunerBtn.textContent = state.tuner_enabled ? 'ON' : 'OFF';
|
||||
tunerBtn.classList.toggle('active', state.tuner_enabled);
|
||||
}
|
||||
const sTunerBtn = document.getElementById('settings-tuner-btn');
|
||||
if (sTunerBtn) {
|
||||
sTunerBtn.textContent = state.tuner_enabled ? 'ON' : 'OFF';
|
||||
sTunerBtn.classList.toggle('active', state.tuner_enabled);
|
||||
}
|
||||
|
||||
// Volume
|
||||
const volSlider = document.getElementById('volume-slider');
|
||||
const volText = document.getElementById('volume-text');
|
||||
const sVolSlider = document.getElementById('settings-volume');
|
||||
const sVolText = document.getElementById('settings-volume-text');
|
||||
const vol = Math.round((state.master_volume || 0.8) * 100);
|
||||
if (volSlider) volSlider.value = vol;
|
||||
if (volText) volText.textContent = vol + '%';
|
||||
if (sVolSlider) sVolSlider.value = vol;
|
||||
if (sVolText) sVolText.textContent = vol + '%';
|
||||
|
||||
// NAM model
|
||||
const modelNameEl = document.getElementById('model-name');
|
||||
if (modelNameEl) modelNameEl.textContent = state.nam_model || 'None loaded';
|
||||
|
||||
// IR
|
||||
const irNameEl = document.getElementById('ir-name');
|
||||
if (irNameEl) irNameEl.textContent = state.ir_name || 'None loaded';
|
||||
}
|
||||
|
||||
/* ── Dashboard action handlers ─────────────────────────────────── */
|
||||
|
||||
async function toggleBypass() {
|
||||
const data = await apiPost('/bypass');
|
||||
// WS will update UI
|
||||
}
|
||||
|
||||
async function toggleTuner() {
|
||||
const enabled = !document.getElementById('tuner-btn')?.classList.contains('active');
|
||||
const data = await apiPost('/tuner', { enabled });
|
||||
}
|
||||
|
||||
async function setVolume(val) {
|
||||
const vol = parseInt(val) / 100;
|
||||
await apiPut('/volume', { volume: vol });
|
||||
}
|
||||
|
||||
async function activatePreset(direction, delta) {
|
||||
const state = await apiGet('/state');
|
||||
if (!state.connected) return;
|
||||
const bank = state.current_preset?.bank || 0;
|
||||
const program = (state.current_preset?.program || 0) + delta;
|
||||
// Clamp 0-3 within the bank
|
||||
const clamped = Math.max(0, Math.min(3, program));
|
||||
if (clamped !== program) return; // at edge
|
||||
await apiPost(`/presets/${bank}/${clamped}/activate`);
|
||||
}
|
||||
|
||||
/* ── Initial page load: fetch state ─────────────────────────────── */
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// If we're on the dashboard, fetch initial state
|
||||
if (document.getElementById('preset-name')) {
|
||||
try {
|
||||
const state = await apiGet('/state');
|
||||
updateDashboardState(state);
|
||||
} catch (e) {
|
||||
console.warn('Could not fetch initial state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update WebSocket client count on settings page
|
||||
const wsCount = document.getElementById('ws-count');
|
||||
if (wsCount) {
|
||||
try {
|
||||
const state = await apiGet('/state');
|
||||
wsCount.textContent = pedalWS._handlers ? '1' : '0';
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* irs.js — IR Manager page.
|
||||
*/
|
||||
|
||||
async function loadIRs() {
|
||||
const listEl = document.getElementById('ir-list');
|
||||
const currentEl = document.getElementById('current-ir');
|
||||
|
||||
listEl.innerHTML = '<p class="loading">Loading...</p>';
|
||||
|
||||
try {
|
||||
const data = await apiGet('/irs');
|
||||
renderIRList(listEl, currentEl, data);
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<p class="text-muted">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderIRList(listEl, currentEl, data) {
|
||||
const currentName = data.current;
|
||||
|
||||
// Current IR display
|
||||
if (currentEl) {
|
||||
if (currentName) {
|
||||
currentEl.innerHTML = `<span class="ir-name">${escapeHtml(currentName)}</span>`;
|
||||
document.getElementById('unload-ir-btn').style.display = 'inline-flex';
|
||||
} else {
|
||||
currentEl.innerHTML = `<span class="ir-name">None loaded</span>`;
|
||||
document.getElementById('unload-ir-btn').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Available IRs
|
||||
if (!data.irs || data.irs.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-muted">No .wav IR files found in the pedal directory.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const ir of data.irs) {
|
||||
const isLoaded = ir.name === currentName;
|
||||
html += `<div class="ir-item ${isLoaded ? 'loaded' : ''}">`;
|
||||
html += `<div>
|
||||
<div class="ir-name">${escapeHtml(ir.name)}</div>
|
||||
<div class="ir-meta">
|
||||
${ir.num_taps} taps · ${ir.length_ms}ms @ ${ir.sample_rate}Hz
|
||||
${isLoaded ? '· <strong style="color:var(--success)">CURRENT</strong>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
if (!isLoaded) {
|
||||
html += `<button class="btn btn-primary" onclick='loadIR(${JSON.stringify(ir.path)})'
|
||||
style="padding:4px 10px; font-size:0.78rem;">Load</button>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadIR(path) {
|
||||
try {
|
||||
await apiPost('/irs/load', { path });
|
||||
loadIRs();
|
||||
} catch (e) {
|
||||
alert('Failed to load IR: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function unloadIR() {
|
||||
try {
|
||||
await apiPost('/irs/unload');
|
||||
loadIRs();
|
||||
} catch (e) {
|
||||
alert('Failed to unload: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadIR() {
|
||||
const input = document.getElementById('ir-upload-input');
|
||||
const file = input.files[0];
|
||||
if (!file) return alert('Select a .wav file first.');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/irs/upload', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
input.value = '';
|
||||
loadIRs();
|
||||
} catch (e) {
|
||||
alert('Upload error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadIRs);
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* models.js — NAM Model Manager page.
|
||||
*/
|
||||
|
||||
async function loadModels() {
|
||||
const listEl = document.getElementById('model-list');
|
||||
const currentEl = document.getElementById('current-model');
|
||||
|
||||
listEl.innerHTML = '<p class="loading">Loading...</p>';
|
||||
|
||||
try {
|
||||
const data = await apiGet('/models');
|
||||
renderModelList(listEl, currentEl, data);
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<p class="text-muted">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelList(listEl, currentEl, data) {
|
||||
const currentName = data.current;
|
||||
|
||||
// Current model display
|
||||
if (currentEl) {
|
||||
if (currentName) {
|
||||
currentEl.innerHTML = `<span class="model-name">${escapeHtml(currentName)}</span>`;
|
||||
document.getElementById('unload-model-btn').style.display = 'inline-flex';
|
||||
} else {
|
||||
currentEl.innerHTML = `<span class="model-name">None loaded</span>`;
|
||||
document.getElementById('unload-model-btn').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Available models
|
||||
if (!data.models || data.models.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-muted">No .nam models found in the pedal directory.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const m of data.models) {
|
||||
const isLoaded = m.name === currentName;
|
||||
html += `<div class="model-item ${isLoaded ? 'loaded' : ''}">`;
|
||||
html += `<div>
|
||||
<div class="model-name">${escapeHtml(m.name)}</div>
|
||||
<div class="model-meta">
|
||||
${m.architecture} · ${m.size_mb} MB
|
||||
${isLoaded ? '· <strong style="color:var(--success)">CURRENT</strong>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
if (!isLoaded) {
|
||||
html += `<button class="btn btn-primary" onclick='loadModel(${JSON.stringify(m.path)})'
|
||||
style="padding:4px 10px; font-size:0.78rem;">Load</button>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadModel(path) {
|
||||
try {
|
||||
await apiPost('/models/load', { path });
|
||||
loadModels();
|
||||
} catch (e) {
|
||||
alert('Failed to load model: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function unloadModel() {
|
||||
try {
|
||||
await apiPost('/models/unload');
|
||||
loadModels();
|
||||
} catch (e) {
|
||||
alert('Failed to unload: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadModel() {
|
||||
const input = document.getElementById('model-upload-input');
|
||||
const file = input.files[0];
|
||||
if (!file) return alert('Select a .nam file first.');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/models/upload', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
input.value = '';
|
||||
loadModels();
|
||||
} catch (e) {
|
||||
alert('Upload error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadModels);
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* presets.js — Preset Manager page: browse banks, load/save/edit.
|
||||
*/
|
||||
|
||||
let currentBank = 0;
|
||||
let currentProgram = 0;
|
||||
|
||||
async function loadPresets() {
|
||||
const container = document.getElementById('bank-list');
|
||||
container.innerHTML = '<p class="loading">Loading...</p>';
|
||||
|
||||
try {
|
||||
const data = await apiGet('/presets');
|
||||
renderBanks(container, data.banks);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="text-muted">Error loading presets: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanks(container, banks) {
|
||||
if (!banks || banks.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">No presets found. Create one from the Dashboard.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const bank of banks) {
|
||||
html += `<div class="bank-section" data-bank="${bank.number}">`;
|
||||
html += `<div class="bank-header">${bank.name}</div>`;
|
||||
html += '<div class="preset-grid">';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = bank.presets && bank.presets[i];
|
||||
if (p) {
|
||||
const active = (p.bank === currentBank && p.program === currentProgram);
|
||||
html += `<div class="preset-card ${active ? 'active' : ''}"
|
||||
onclick="openPreset(${bank.number}, ${i})">`;
|
||||
html += `<div class="preset-card-name">${escapeHtml(p.name)}</div>`;
|
||||
html += `<div class="preset-card-slot">Bank ${bank.number} · ${i}</div>`;
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += `<div class="preset-card" style="opacity:0.4; cursor:default">
|
||||
<div class="preset-card-name">— Empty —</div>
|
||||
<div class="preset-card-slot">Slot ${i}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function openPreset(bank, program) {
|
||||
currentBank = bank;
|
||||
currentProgram = program;
|
||||
|
||||
try {
|
||||
const preset = await apiGet(`/presets/${bank}/${program}`);
|
||||
const modal = document.getElementById('preset-modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const body = document.getElementById('modal-body');
|
||||
|
||||
title.textContent = `Edit: ${preset.name}`;
|
||||
body.innerHTML = renderPresetForm(preset);
|
||||
|
||||
modal.style.display = 'flex';
|
||||
} catch (e) {
|
||||
console.error('Error loading preset:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPresetForm(preset) {
|
||||
let html = `
|
||||
<div class="param-row">
|
||||
<span class="param-label">Name</span>
|
||||
<input type="text" id="edit-preset-name" value="${escapeHtml(preset.name)}"
|
||||
style="flex:1; background:var(--bg-secondary); border:1px solid var(--border);
|
||||
color:var(--text-primary); padding:4px 8px; border-radius:4px;">
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<span class="param-label">Volume</span>
|
||||
<input type="range" class="slider" id="edit-volume" min="0" max="100"
|
||||
value="${Math.round((preset.master_volume || 0.8) * 100)}"
|
||||
oninput="document.getElementById('edit-volume-text').textContent=this.value+'%'">
|
||||
<span class="param-value" id="edit-volume-text">${Math.round((preset.master_volume || 0.8) * 100)}%</span>
|
||||
</div>
|
||||
<hr style="border-color:var(--border); margin:10px 0;">
|
||||
<div style="font-size:0.85rem; color:var(--text-secondary); margin-bottom:8px;">FX Chain:</div>
|
||||
`;
|
||||
|
||||
if (preset.chain && preset.chain.length > 0) {
|
||||
for (const block of preset.chain) {
|
||||
const label = block.fx_type.replace(/_/g, ' ');
|
||||
const badged = block.enabled && !block.bypass
|
||||
? `<span class="fx-badge" style="background:var(--success)">ON</span>`
|
||||
: `<span class="fx-badge" style="background:var(--text-muted)">OFF</span>`;
|
||||
html += `<div class="param-row" style="justify-content:space-between">
|
||||
<span style="text-transform:capitalize; font-size:0.8rem;">${label} ${badged}</span>
|
||||
</div>`;
|
||||
}
|
||||
} else {
|
||||
html += `<p class="text-muted" style="font-size:0.8rem;">No FX blocks in chain</p>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async function savePreset() {
|
||||
const modal = document.getElementById('preset-modal');
|
||||
if (!modal || modal.style.display === 'none') return;
|
||||
|
||||
const nameInput = document.getElementById('edit-preset-name');
|
||||
const volSlider = document.getElementById('edit-volume');
|
||||
|
||||
const name = nameInput ? nameInput.value : 'Preset';
|
||||
const volume = volSlider ? parseInt(volSlider.value) / 100 : 0.8;
|
||||
|
||||
try {
|
||||
// Fetch current preset data, update name/volume, re-save
|
||||
const preset = await apiGet(`/presets/${currentBank}/${currentProgram}`);
|
||||
preset.name = name;
|
||||
preset.master_volume = volume;
|
||||
|
||||
await apiPut(`/presets/${currentBank}/${currentProgram}`, preset);
|
||||
closeModal();
|
||||
loadPresets();
|
||||
} catch (e) {
|
||||
console.error('Error saving preset:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePreset() {
|
||||
if (!confirm('Delete this preset?')) return;
|
||||
try {
|
||||
await apiDelete(`/presets/${currentBank}/${currentProgram}`);
|
||||
closeModal();
|
||||
loadPresets();
|
||||
} catch (e) {
|
||||
console.error('Error deleting preset:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function activateFromPresetPage(bank, program) {
|
||||
try {
|
||||
await apiPost(`/presets/${bank}/${program}/activate`);
|
||||
currentBank = bank;
|
||||
currentProgram = program;
|
||||
loadPresets();
|
||||
} catch (e) {
|
||||
console.error('Error activating:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
const modal = document.getElementById('preset-modal');
|
||||
if (event && event.target !== modal) return; // only close on overlay click
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load on page ready
|
||||
document.addEventListener('DOMContentLoaded', loadPresets);
|
||||
@@ -0,0 +1,654 @@
|
||||
/* ── Pi Multi-FX Pedal — Web UI Styles ──────────────────────────────
|
||||
Mobile-first, dark theme, 80-82% max-width for chat-bubble comfort.
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Reset & Base ─────────────────────────────────────────────────── */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0d0d0d;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-card: #222;
|
||||
--bg-card-hover: #2a2a2a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #999;
|
||||
--text-muted: #666;
|
||||
--accent: #4fc3f7;
|
||||
--accent-dark: #0288d1;
|
||||
--success: #66bb6a;
|
||||
--danger: #ef5350;
|
||||
--warning: #ffa726;
|
||||
--border: #333;
|
||||
--border-light: #444;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--max-width: 82%;
|
||||
--header-height: 48px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── App Container ────────────────────────────────────────────────── */
|
||||
|
||||
.app-container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────── */
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--header-height);
|
||||
padding: 0 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-left .logo {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.header-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connection-indicator {
|
||||
font-size: 0.72rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-indicator.connected {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
/* ── Main Content ─────────────────────────────────────────────────── */
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ── Cards ────────────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* ── Dashboard ────────────────────────────────────────────────────── */
|
||||
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.preset-name-display {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.preset-meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.preset-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Status Row ───────────────────────────────────────────────────── */
|
||||
|
||||
.status-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-dark);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c62828;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
min-width: 90px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* ── Sliders ──────────────────────────────────────────────────────── */
|
||||
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--bg-card);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--bg-card);
|
||||
}
|
||||
|
||||
.volume-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── FX Chain ────────────────────────────────────────────────────── */
|
||||
|
||||
.fx-chain {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fx-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.fx-block.bypassed {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.fx-block .fx-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fx-block .fx-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fx-block-chain-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Page layout ──────────────────────────────────────────────────── */
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Bank / Preset List ───────────────────────────────────────────── */
|
||||
|
||||
.bank-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bank-header {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preset-card {
|
||||
padding: 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.preset-card:hover {
|
||||
border-color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.preset-card.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
}
|
||||
|
||||
.preset-card .preset-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.preset-card .preset-card-slot {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Model / IR List ──────────────────────────────────────────────── */
|
||||
|
||||
.model-list, .ir-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.model-item, .ir-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.model-item.loaded, .ir-item.loaded {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.model-item .model-meta, .ir-item .ir-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Settings ─────────────────────────────────────────────────────── */
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-value code {
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.about-info p {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.about-info a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Modal ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Param sliders in modal ────────────────────────────────────────── */
|
||||
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.param-row .param-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.param-row .param-value {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.param-row .slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--max-width: 82%;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 6px 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--max-width: 720px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Utilities ─────────────────────────────────────────────────────── */
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
.flex { display: flex; }
|
||||
.flex-1 { flex: 1; }
|
||||
.gap-8 { gap: 8px; }
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* WebSocket connection manager for Pi Multi-FX Pedal.
|
||||
*
|
||||
* Establishes a persistent WebSocket connection to the pedal server,
|
||||
* auto-reconnects on disconnect, and dispatches typed events to
|
||||
* registered handlers.
|
||||
*
|
||||
* Usage:
|
||||
* const ws = new PedalWebSocket();
|
||||
* ws.on('state', (data) => { ... });
|
||||
* ws.on('preset_changed', (data) => { ... });
|
||||
*/
|
||||
class PedalWebSocket {
|
||||
constructor(url = null) {
|
||||
this.url = url || (() => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.host}/ws`;
|
||||
})();
|
||||
this.ws = null;
|
||||
this._handlers = {};
|
||||
this._reconnectTimer = null;
|
||||
this._reconnectDelay = 1000; // start at 1s, back off
|
||||
this._closed = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this._closed) return;
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
} catch (e) {
|
||||
console.error('[WS] Connection error:', e);
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[WS] Connected');
|
||||
this._reconnectDelay = 1000;
|
||||
this._updateConnectionStatus(true);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
this._dispatch(msg.type, msg);
|
||||
} catch (e) {
|
||||
console.warn('[WS] Parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('[WS] Disconnected');
|
||||
this._updateConnectionStatus(false);
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (e) => {
|
||||
console.error('[WS] Error:', e);
|
||||
};
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (this._closed) return;
|
||||
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
console.log(`[WS] Reconnecting (delay=${this._reconnectDelay}ms)...`);
|
||||
this._reconnectDelay = Math.min(this._reconnectDelay * 1.5, 15000);
|
||||
this.connect();
|
||||
}, this._reconnectDelay);
|
||||
}
|
||||
|
||||
_updateConnectionStatus(connected) {
|
||||
const el = document.getElementById('connection-status');
|
||||
if (el) {
|
||||
el.textContent = connected ? 'Connected' : 'Disconnected';
|
||||
el.classList.toggle('connected', connected);
|
||||
}
|
||||
}
|
||||
|
||||
on(type, handler) {
|
||||
if (!this._handlers[type]) this._handlers[type] = [];
|
||||
this._handlers[type].push(handler);
|
||||
}
|
||||
|
||||
off(type, handler) {
|
||||
if (!this._handlers[type]) return;
|
||||
this._handlers[type] = this._handlers[type].filter(h => h !== handler);
|
||||
}
|
||||
|
||||
_dispatch(type, msg) {
|
||||
const handlers = this._handlers[type];
|
||||
if (handlers) {
|
||||
for (const h of handlers) h(msg);
|
||||
}
|
||||
// Also fire wildcard handlers
|
||||
const wildcard = this._handlers['*'];
|
||||
if (wildcard) {
|
||||
for (const h of wildcard) h(msg);
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this._closed = true;
|
||||
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
||||
if (this.ws) this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance — shared across all pages
|
||||
const pedalWS = new PedalWebSocket();
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>{% block title %}Pi Multi-FX Pedal{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>🎸</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<a href="/" class="logo">🎸 Pi Pedal</a>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/" class="nav-link {% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
||||
<a href="/presets" class="nav-link {% if request.url.path == '/presets' %}active{% endif %}">Presets</a>
|
||||
<a href="/models" class="nav-link {% if request.url.path == '/models' %}active{% endif %}">Models</a>
|
||||
<a href="/irs" class="nav-link {% if request.url.path == '/irs' %}active{% endif %}">IRs</a>
|
||||
<a href="/settings" class="nav-link {% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<span class="connection-indicator" id="connection-status">Disconnected</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/websocket.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<!-- Current Preset Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Current Preset</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preset-name-display" id="preset-name">{{ current_preset.name if current_preset else "—" }}</div>
|
||||
<div class="preset-meta" id="preset-meta">
|
||||
Bank {{ current_preset.bank if current_preset else 0 }} — Program {{ current_preset.program if current_preset else 0 }}
|
||||
</div>
|
||||
<div class="preset-controls">
|
||||
<button class="btn btn-primary" onclick="activatePreset('prev', -1)">◀ Prev</button>
|
||||
<button class="btn btn-primary" onclick="activatePreset('next', 1)">Next ▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Row -->
|
||||
<div class="status-row">
|
||||
<div class="card status-card">
|
||||
<div class="status-label">Bypass</div>
|
||||
<div class="status-value">
|
||||
<button class="btn toggle-btn {% if bypass %}active{% endif %}" id="bypass-btn" onclick="toggleBypass()">
|
||||
{% if bypass %}BYPASSED{% else %}ACTIVE{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card status-card">
|
||||
<div class="status-label">Tuner</div>
|
||||
<div class="status-value">
|
||||
<button class="btn toggle-btn {% if tuner_enabled %}active{% endif %}" id="tuner-btn" onclick="toggleTuner()">
|
||||
{% if tuner_enabled %}ON{% else %}OFF{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card status-card">
|
||||
<div class="status-label">Volume</div>
|
||||
<div class="status-value">
|
||||
<input type="range" class="slider" id="volume-slider"
|
||||
min="0" max="100" value="{{ (master_volume * 100)|int }}"
|
||||
oninput="setVolume(this.value)">
|
||||
<span class="volume-text" id="volume-text">{{ (master_volume * 100)|int }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FX Chain -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>FX Chain</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="fx-chain" id="fx-chain">
|
||||
<div class="fx-block-chain-placeholder" id="fx-chain-placeholder">
|
||||
<p>Connect to pedal to see active FX chain.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAM Model Status -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>NAM Model</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="model-status" id="model-status">
|
||||
<span class="model-name" id="model-name">{{ nam_model if nam_model else "None loaded" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IR Status -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>IR Cabinet</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="ir-status" id="ir-status">
|
||||
<span class="ir-name" id="ir-name">{{ ir_name if ir_name else "None loaded" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}IR Cabinet — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page irs-page">
|
||||
<div class="page-header">
|
||||
<h1>IR Manager</h1>
|
||||
<button class="btn btn-secondary" onclick="loadIRs()">⟳ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Current IR</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="current-ir" id="current-ir">
|
||||
<span class="ir-name">None loaded</span>
|
||||
</div>
|
||||
<div class="ir-controls">
|
||||
<button class="btn btn-danger" onclick="unloadIR()" id="unload-ir-btn" style="display:none">Unload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Available IR Files</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="ir-list" id="ir-list">
|
||||
<p class="loading">Loading IR files...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Upload IR</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="hint">Upload .wav IR files to the pedal's IR directory.</p>
|
||||
<input type="file" id="ir-upload-input" accept=".wav" class="file-input">
|
||||
<button class="btn btn-primary" onclick="uploadIR()">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/irs.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}NAM Models — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page models-page">
|
||||
<div class="page-header">
|
||||
<h1>NAM Model Manager</h1>
|
||||
<button class="btn btn-secondary" onclick="loadModels()">⟳ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Current Model</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="current-model" id="current-model">
|
||||
<span class="model-name">None loaded</span>
|
||||
</div>
|
||||
<div class="model-controls">
|
||||
<button class="btn btn-danger" onclick="unloadModel()" id="unload-model-btn" style="display:none">Unload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Available Models</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="model-list" id="model-list">
|
||||
<p class="loading">Loading models...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Upload Model</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="hint">Upload .nam model files to the pedal's model directory.</p>
|
||||
<input type="file" id="model-upload-input" accept=".nam" class="file-input">
|
||||
<button class="btn btn-primary" onclick="uploadModel()">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/models.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Presets — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page presets-page">
|
||||
<div class="page-header">
|
||||
<h1>Preset Manager</h1>
|
||||
<button class="btn btn-secondary" onclick="loadPresets()">⟳ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="bank-list" id="bank-list">
|
||||
<p class="loading">Loading presets...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="preset-modal" style="display:none" onclick="closeModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Edit Preset</h2>
|
||||
<button class="btn-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body">
|
||||
<!-- Dynamic content loaded by JS -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" id="save-preset-btn" onclick="savePreset()">Save</button>
|
||||
<button class="btn btn-danger" onclick="deletePreset()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/presets.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page settings-page">
|
||||
<div class="page-header">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Connection</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Hostname</div>
|
||||
<div class="setting-value"><code id="hostname">pedal.local</code></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Web Server</div>
|
||||
<div class="setting-value"><code id="web-url">http://pedal.local:8080</code></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Connected Clients</div>
|
||||
<div class="setting-value"><span id="ws-count">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Audio</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Master Volume</div>
|
||||
<div class="setting-value">
|
||||
<input type="range" class="slider" id="settings-volume"
|
||||
min="0" max="100" value="{{ (master_volume * 100)|int }}"
|
||||
oninput="setVolume(this.value)">
|
||||
<span id="settings-volume-text">{{ (master_volume * 100)|int }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Tuner</div>
|
||||
<div class="setting-value">
|
||||
<button class="btn toggle-btn {% if tuner_enabled %}active{% endif %}"
|
||||
id="settings-tuner-btn" onclick="toggleTuner()">
|
||||
{% if tuner_enabled %}ON{% else %}OFF{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>About</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="about-info">
|
||||
<p><strong>Pi Multi-FX Pedal</strong> v0.1.0</p>
|
||||
<p>Raspberry Pi 4B — Python 3.11 — JACK Audio</p>
|
||||
<p><a href="https://gitea.ourpad.casa/shawn/pi-multifx-pedal" target="_blank">Gitea Repository</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Tests for the web UI server — REST API, WebSocket, and HTML pages.
|
||||
|
||||
Run with:
|
||||
pytest tests/test_web.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Ensure src is on sys.path
|
||||
SRC = Path(__file__).resolve().parent.parent / "src"
|
||||
if str(SRC) not in sys.path:
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
from src.web.server import WebServer, WebServerDeps
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_presets():
|
||||
"""A mock PresetManager with a real Preset returned on load."""
|
||||
from src.presets.types import Preset
|
||||
pm = MagicMock()
|
||||
pm.current_bank = 0
|
||||
pm.current_program = 0
|
||||
pm.load.return_value = Preset(name="Default", bank=0, program=0)
|
||||
return pm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pipeline():
|
||||
pl = MagicMock()
|
||||
pl._bypassed = False
|
||||
pl._tuner_enabled = False
|
||||
pl._master_volume = 0.8
|
||||
return pl
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nam():
|
||||
nam = MagicMock()
|
||||
nam.is_loaded = False
|
||||
nam.current_model = None
|
||||
nam._models_dir = Path("/tmp/pedal-test/nam")
|
||||
return nam
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ir():
|
||||
ir = MagicMock()
|
||||
ir.is_loaded = False
|
||||
ir.current_ir = None
|
||||
ir._ir_dir = Path("/tmp/pedal-test/irs")
|
||||
return ir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deps(mock_presets, mock_pipeline, mock_nam, mock_ir):
|
||||
return WebServerDeps(
|
||||
presets=mock_presets,
|
||||
pipeline=mock_pipeline,
|
||||
nam_host=mock_nam,
|
||||
ir_loader=mock_ir,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(deps):
|
||||
"""TestClient with a fresh server per test."""
|
||||
server = WebServer(deps)
|
||||
# Use TestClient from starlette which FastAPI wraps
|
||||
from fastapi.testclient import TestClient
|
||||
test_client = TestClient(server._app)
|
||||
return test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_no_deps():
|
||||
"""Server with no pedal dependencies connected (disconnected state)."""
|
||||
server = WebServer(WebServerDeps())
|
||||
test_client = TestClient(server._app)
|
||||
return test_client
|
||||
|
||||
|
||||
# ── HTML page tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPages:
|
||||
"""Verify all HTML pages are served with correct status and content type."""
|
||||
|
||||
def test_dashboard(self, client):
|
||||
res = client.get("/")
|
||||
assert res.status_code == 200
|
||||
assert "text/html" in res.headers["content-type"]
|
||||
|
||||
def test_presets_page(self, client):
|
||||
res = client.get("/presets")
|
||||
assert res.status_code == 200
|
||||
assert "text/html" in res.headers["content-type"]
|
||||
|
||||
def test_models_page(self, client):
|
||||
res = client.get("/models")
|
||||
assert res.status_code == 200
|
||||
assert "text/html" in res.headers["content-type"]
|
||||
|
||||
def test_irs_page(self, client):
|
||||
res = client.get("/irs")
|
||||
assert res.status_code == 200
|
||||
assert "text/html" in res.headers["content-type"]
|
||||
|
||||
def test_settings_page(self, client):
|
||||
res = client.get("/settings")
|
||||
assert res.status_code == 200
|
||||
assert "text/html" in res.headers["content-type"]
|
||||
|
||||
def test_404_page(self, client):
|
||||
res = client.get("/nonexistent")
|
||||
assert res.status_code == 404
|
||||
|
||||
def test_static_css(self, client):
|
||||
res = client.get("/static/style.css")
|
||||
assert res.status_code == 200
|
||||
assert "text/css" in res.headers["content-type"]
|
||||
|
||||
|
||||
# ── REST API tests ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAPIState:
|
||||
"""GET /api/state — pedal state snapshot."""
|
||||
|
||||
def test_state_connected(self, client, mock_presets, mock_pipeline, mock_nam, mock_ir):
|
||||
res = client.get("/api/state")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["connected"] is True
|
||||
assert "current_preset" in data
|
||||
assert "bypass" in data
|
||||
assert "tuner_enabled" in data
|
||||
assert "master_volume" in data
|
||||
assert data["master_volume"] == 0.8
|
||||
|
||||
def test_state_disconnected(self, client_no_deps):
|
||||
res = client_no_deps.get("/api/state")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["connected"] is False
|
||||
|
||||
|
||||
class TestAPIPresets:
|
||||
"""Preset CRUD endpoints."""
|
||||
|
||||
def test_list_presets(self, client):
|
||||
res = client.get("/api/presets")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "banks" in data
|
||||
|
||||
def test_list_presets_no_deps(self, client_no_deps):
|
||||
res = client_no_deps.get("/api/presets")
|
||||
assert res.status_code == 503
|
||||
|
||||
def test_get_preset_not_found(self, client, mock_presets):
|
||||
mock_presets.load.side_effect = FileNotFoundError("No preset")
|
||||
res = client.get("/api/presets/99/0")
|
||||
assert res.status_code == 404
|
||||
|
||||
def test_activate_preset(self, client, mock_presets):
|
||||
from src.presets.types import Preset
|
||||
fake_preset = Preset(name="Test", bank=0, program=0)
|
||||
mock_presets.select.return_value = fake_preset
|
||||
res = client.post("/api/presets/0/0/activate")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["ok"] is True
|
||||
mock_presets.select.assert_called_with(0, 0)
|
||||
|
||||
def test_save_preset(self, client, mock_presets):
|
||||
payload = {
|
||||
"name": "My Tone",
|
||||
"master_volume": 0.7,
|
||||
"chain": [
|
||||
{"fx_type": "overdrive", "enabled": True, "bypass": False, "params": {"drive": 0.5}}
|
||||
],
|
||||
}
|
||||
res = client.put("/api/presets/1/2", json=payload)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["name"] == "My Tone"
|
||||
mock_presets.save.assert_called_once()
|
||||
|
||||
def test_delete_preset(self, client, mock_presets):
|
||||
res = client.delete("/api/presets/0/0")
|
||||
assert res.status_code == 200
|
||||
mock_presets.delete.assert_called_with(0, 0)
|
||||
|
||||
|
||||
class TestAPIBypass:
|
||||
"""Bypass toggle endpoint."""
|
||||
|
||||
def test_bypass_toggle_on(self, client):
|
||||
res = client.post("/api/bypass", json={"bypass": True})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["bypass"] is True
|
||||
|
||||
def test_bypass_toggle_off(self, client):
|
||||
res = client.post("/api/bypass", json={"bypass": False})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["bypass"] is False
|
||||
|
||||
def test_bypass_no_deps(self, client_no_deps):
|
||||
res = client_no_deps.post("/api/bypass")
|
||||
assert res.status_code == 503
|
||||
|
||||
|
||||
class TestAPIVolume:
|
||||
"""Volume control endpoint."""
|
||||
|
||||
def test_set_volume(self, client, mock_pipeline):
|
||||
res = client.put("/api/volume", json={"volume": 0.5})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["volume"] == 0.5
|
||||
assert mock_pipeline._master_volume == 0.5
|
||||
|
||||
def test_set_volume_clamp(self, client, mock_pipeline):
|
||||
res = client.put("/api/volume", json={"volume": 2.5})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["volume"] == 1.0 # clamped
|
||||
|
||||
|
||||
class TestAPIModels:
|
||||
"""NAM model listing and loading."""
|
||||
|
||||
def test_list_models(self, client, mock_nam):
|
||||
mock_nam.list_available_models.return_value = []
|
||||
res = client.get("/api/models")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "models" in data
|
||||
assert "current" in data
|
||||
|
||||
def test_load_model(self, client, mock_nam):
|
||||
mock_nam.load_model.return_value = True
|
||||
res = client.post("/api/models/load", json={"path": "/tmp/test.nam"})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ok"] is True
|
||||
|
||||
def test_load_model_fail(self, client, mock_nam):
|
||||
mock_nam.load_model.return_value = False
|
||||
res = client.post("/api/models/load", json={"path": "/tmp/bad.nam"})
|
||||
assert res.status_code == 500
|
||||
|
||||
def test_unload_model(self, client, mock_nam):
|
||||
res = client.post("/api/models/unload")
|
||||
assert res.status_code == 200
|
||||
mock_nam.unload.assert_called_once()
|
||||
|
||||
|
||||
class TestAPIIRs:
|
||||
"""IR listing and loading."""
|
||||
|
||||
def test_list_irs(self, client, mock_ir):
|
||||
mock_ir.get_irs.return_value = []
|
||||
res = client.get("/api/irs")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "irs" in data
|
||||
|
||||
def test_load_ir(self, client, mock_ir):
|
||||
mock_ir.load_ir.return_value = True
|
||||
res = client.post("/api/irs/load", json={"path": "/tmp/test.wav"})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ok"] is True
|
||||
|
||||
def test_unload_ir(self, client, mock_ir):
|
||||
res = client.post("/api/irs/unload")
|
||||
assert res.status_code == 200
|
||||
mock_ir.unload.assert_called_once()
|
||||
|
||||
|
||||
class TestAPIBlockParams:
|
||||
"""FX block parameter schemas."""
|
||||
|
||||
def test_overdrive_params(self, client):
|
||||
res = client.get("/api/block-params/overdrive")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["fx_type"] == "overdrive"
|
||||
assert len(data["params"]) > 0
|
||||
keys = [p["key"] for p in data["params"]]
|
||||
assert "drive" in keys
|
||||
|
||||
def test_unknown_fx_type(self, client):
|
||||
res = client.get("/api/block-params/unknown_fx")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["params"] == []
|
||||
|
||||
|
||||
class TestAPITuner:
|
||||
"""Tuner toggle."""
|
||||
|
||||
def test_tuner_toggle_on(self, client):
|
||||
res = client.post("/api/tuner", json={"enabled": True})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["tuner_enabled"] is True
|
||||
|
||||
def test_tuner_toggle_off(self, client):
|
||||
res = client.post("/api/tuner", json={"enabled": False})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["tuner_enabled"] is False
|
||||
|
||||
|
||||
# ── WebSocket tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebSocket:
|
||||
"""WebSocket connection and message handling."""
|
||||
|
||||
def test_ws_connect_receives_state(self, client, mock_presets):
|
||||
"""On connect, server sends 'connected' message with state."""
|
||||
from src.presets.types import Preset
|
||||
mock_presets.load.return_value = Preset(name="Default", bank=0, program=0)
|
||||
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
data = ws.receive_text()
|
||||
msg = json.loads(data)
|
||||
assert msg["type"] == "connected"
|
||||
assert "state" in msg
|
||||
|
||||
def test_ws_ping_pong(self, client):
|
||||
"""Ping message gets pong response."""
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
# Consume the initial connected message
|
||||
ws.receive_text()
|
||||
ws.send_text(json.dumps({"type": "ping"}))
|
||||
data = ws.receive_text()
|
||||
msg = json.loads(data)
|
||||
assert msg["type"] == "pong"
|
||||
|
||||
def test_ws_get_state(self, client, mock_presets):
|
||||
"""Requesting state returns full state object."""
|
||||
from src.presets.types import Preset
|
||||
mock_presets.load.return_value = Preset(name="Test", bank=1, program=2)
|
||||
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
ws.receive_text() # initial connected
|
||||
ws.send_text(json.dumps({"type": "get_state"}))
|
||||
data = ws.receive_text()
|
||||
msg = json.loads(data)
|
||||
assert msg["type"] == "state"
|
||||
assert "state" in msg
|
||||
|
||||
|
||||
# ── Error handling ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestErrors:
|
||||
"""Edge cases and error responses."""
|
||||
|
||||
def test_missing_volume_key(self, client):
|
||||
"""PUT /api/volume with no 'volume' key defaults."""
|
||||
res = client.put("/api/volume", json={})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["volume"] == 0.8 # default
|
||||
|
||||
def test_empty_model_load_path(self, client):
|
||||
res = client.post("/api/models/load", json={"path": ""})
|
||||
assert res.status_code == 400
|
||||
Reference in New Issue
Block a user