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:
2026-06-08 00:57:26 -04:00
parent b7f5488efd
commit 1c5b728e8b
19 changed files with 2884 additions and 2 deletions
+22
View File
@@ -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>
+29 -1
View File
@@ -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
View File
@@ -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
+81
View File
@@ -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"
+9
View File
@@ -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
+723
View File
@@ -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()
+182
View File
@@ -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) {}
}
});
+95
View File
@@ -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);
+95
View File
@@ -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);
+167
View File
@@ -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);
+654
View File
@@ -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; }
+117
View File
@@ -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();
+37
View File
@@ -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>
+89
View File
@@ -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 %}
+49
View File
@@ -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 %}
+49
View File
@@ -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 %}
+34
View File
@@ -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 %}
+68
View File
@@ -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 %}
+376
View File
@@ -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