From 1c5b728e8bba1226338deb71e1b87f37359b7ab2 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 8 Jun 2026 00:57:26 -0400 Subject: [PATCH] Build: Web UI + mDNS (pedal.local) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- etc/avahi/pi-multifx-pedal.service | 22 + main.py | 30 +- requirements.txt | 9 +- scripts/setup-mdns.sh | 81 ++++ src/web/__init__.py | 9 + src/web/server.py | 723 +++++++++++++++++++++++++++++ src/web/static/app.js | 182 ++++++++ src/web/static/irs.js | 95 ++++ src/web/static/models.js | 95 ++++ src/web/static/presets.js | 167 +++++++ src/web/static/style.css | 654 ++++++++++++++++++++++++++ src/web/static/websocket.js | 117 +++++ src/web/templates/base.html | 37 ++ src/web/templates/dashboard.html | 89 ++++ src/web/templates/irs.html | 49 ++ src/web/templates/models.html | 49 ++ src/web/templates/presets.html | 34 ++ src/web/templates/settings.html | 68 +++ tests/test_web.py | 376 +++++++++++++++ 19 files changed, 2884 insertions(+), 2 deletions(-) create mode 100644 etc/avahi/pi-multifx-pedal.service create mode 100755 scripts/setup-mdns.sh create mode 100644 src/web/__init__.py create mode 100644 src/web/server.py create mode 100644 src/web/static/app.js create mode 100644 src/web/static/irs.js create mode 100644 src/web/static/models.js create mode 100644 src/web/static/presets.js create mode 100644 src/web/static/style.css create mode 100644 src/web/static/websocket.js create mode 100644 src/web/templates/base.html create mode 100644 src/web/templates/dashboard.html create mode 100644 src/web/templates/irs.html create mode 100644 src/web/templates/models.html create mode 100644 src/web/templates/presets.html create mode 100644 src/web/templates/settings.html create mode 100644 tests/test_web.py diff --git a/etc/avahi/pi-multifx-pedal.service b/etc/avahi/pi-multifx-pedal.service new file mode 100644 index 0000000..cc08d58 --- /dev/null +++ b/etc/avahi/pi-multifx-pedal.service @@ -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 + + + + + Pi Multi-FX Pedal + + _http._tcp + 8080 + path=/ + product=Pi-Multi-FX-Pedal + version=0.1.0 + + + _pedal._tcp + 8080 + api=/api + ws=/ws + + \ No newline at end of file diff --git a/main.py b/main.py index 73e7e14..d9cfad7 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 61ad01f..5922903 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/scripts/setup-mdns.sh b/scripts/setup-mdns.sh new file mode 100755 index 0000000..842a1ee --- /dev/null +++ b/scripts/setup-mdns.sh @@ -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" \ No newline at end of file diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000..869a1ec --- /dev/null +++ b/src/web/__init__.py @@ -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 \ No newline at end of file diff --git a/src/web/server.py b/src/web/server.py new file mode 100644 index 0000000..617737e --- /dev/null +++ b/src/web/server.py @@ -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(" 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() \ No newline at end of file diff --git a/src/web/static/app.js b/src/web/static/app.js new file mode 100644 index 0000000..eb6da0a --- /dev/null +++ b/src/web/static/app.js @@ -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) {} + } +}); \ No newline at end of file diff --git a/src/web/static/irs.js b/src/web/static/irs.js new file mode 100644 index 0000000..fe8aa7f --- /dev/null +++ b/src/web/static/irs.js @@ -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 = '

Loading...

'; + + try { + const data = await apiGet('/irs'); + renderIRList(listEl, currentEl, data); + } catch (e) { + listEl.innerHTML = `

Error: ${e.message}

`; + } +} + +function renderIRList(listEl, currentEl, data) { + const currentName = data.current; + + // Current IR display + if (currentEl) { + if (currentName) { + currentEl.innerHTML = `${escapeHtml(currentName)}`; + document.getElementById('unload-ir-btn').style.display = 'inline-flex'; + } else { + currentEl.innerHTML = `None loaded`; + document.getElementById('unload-ir-btn').style.display = 'none'; + } + } + + // Available IRs + if (!data.irs || data.irs.length === 0) { + listEl.innerHTML = '

No .wav IR files found in the pedal directory.

'; + return; + } + + let html = ''; + for (const ir of data.irs) { + const isLoaded = ir.name === currentName; + html += `
`; + html += `
+
${escapeHtml(ir.name)}
+
+ ${ir.num_taps} taps · ${ir.length_ms}ms @ ${ir.sample_rate}Hz + ${isLoaded ? '· CURRENT' : ''} +
+
`; + if (!isLoaded) { + html += ``; + } + html += '
'; + } + 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); \ No newline at end of file diff --git a/src/web/static/models.js b/src/web/static/models.js new file mode 100644 index 0000000..6b43732 --- /dev/null +++ b/src/web/static/models.js @@ -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 = '

Loading...

'; + + try { + const data = await apiGet('/models'); + renderModelList(listEl, currentEl, data); + } catch (e) { + listEl.innerHTML = `

Error: ${e.message}

`; + } +} + +function renderModelList(listEl, currentEl, data) { + const currentName = data.current; + + // Current model display + if (currentEl) { + if (currentName) { + currentEl.innerHTML = `${escapeHtml(currentName)}`; + document.getElementById('unload-model-btn').style.display = 'inline-flex'; + } else { + currentEl.innerHTML = `None loaded`; + document.getElementById('unload-model-btn').style.display = 'none'; + } + } + + // Available models + if (!data.models || data.models.length === 0) { + listEl.innerHTML = '

No .nam models found in the pedal directory.

'; + return; + } + + let html = ''; + for (const m of data.models) { + const isLoaded = m.name === currentName; + html += `
`; + html += `
+
${escapeHtml(m.name)}
+
+ ${m.architecture} · ${m.size_mb} MB + ${isLoaded ? '· CURRENT' : ''} +
+
`; + if (!isLoaded) { + html += ``; + } + html += '
'; + } + 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); \ No newline at end of file diff --git a/src/web/static/presets.js b/src/web/static/presets.js new file mode 100644 index 0000000..da4a0ee --- /dev/null +++ b/src/web/static/presets.js @@ -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 = '

Loading...

'; + + try { + const data = await apiGet('/presets'); + renderBanks(container, data.banks); + } catch (e) { + container.innerHTML = `

Error loading presets: ${e.message}

`; + } +} + +function renderBanks(container, banks) { + if (!banks || banks.length === 0) { + container.innerHTML = '

No presets found. Create one from the Dashboard.

'; + return; + } + + let html = ''; + for (const bank of banks) { + html += `
`; + html += `
${bank.name}
`; + html += '
'; + 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 += `
`; + html += `
${escapeHtml(p.name)}
`; + html += `
Bank ${bank.number} · ${i}
`; + html += '
'; + } else { + html += `
+
— Empty —
+
Slot ${i}
+
`; + } + } + html += '
'; + } + 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 = ` +
+ Name + +
+
+ Volume + + ${Math.round((preset.master_volume || 0.8) * 100)}% +
+
+
FX Chain:
+ `; + + 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 + ? `ON` + : `OFF`; + html += `
+ ${label} ${badged} +
`; + } + } else { + html += `

No FX blocks in chain

`; + } + + 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); \ No newline at end of file diff --git a/src/web/static/style.css b/src/web/static/style.css new file mode 100644 index 0000000..6985a1a --- /dev/null +++ b/src/web/static/style.css @@ -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; } \ No newline at end of file diff --git a/src/web/static/websocket.js b/src/web/static/websocket.js new file mode 100644 index 0000000..5d8d4dc --- /dev/null +++ b/src/web/static/websocket.js @@ -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(); \ No newline at end of file diff --git a/src/web/templates/base.html b/src/web/templates/base.html new file mode 100644 index 0000000..0a27419 --- /dev/null +++ b/src/web/templates/base.html @@ -0,0 +1,37 @@ + + + + + + + + {% block title %}Pi Multi-FX Pedal{% endblock %} + + + + +
+
+ + +
+ Disconnected +
+
+
+ {% block content %}{% endblock %} +
+
+ + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/src/web/templates/dashboard.html b/src/web/templates/dashboard.html new file mode 100644 index 0000000..53984c5 --- /dev/null +++ b/src/web/templates/dashboard.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Pi Multi-FX Pedal{% endblock %} +{% block content %} +
+ +
+
+

Current Preset

+
+
+
{{ current_preset.name if current_preset else "—" }}
+
+ Bank {{ current_preset.bank if current_preset else 0 }} — Program {{ current_preset.program if current_preset else 0 }} +
+
+ + +
+
+
+ + +
+
+
Bypass
+
+ +
+
+
+
Tuner
+
+ +
+
+
+
Volume
+
+ + {{ (master_volume * 100)|int }}% +
+
+
+ + +
+
+

FX Chain

+
+
+
+
+

Connect to pedal to see active FX chain.

+
+
+
+
+ + +
+
+

NAM Model

+
+
+
+ {{ nam_model if nam_model else "None loaded" }} +
+
+
+ + +
+
+

IR Cabinet

+
+
+
+ {{ ir_name if ir_name else "None loaded" }} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/src/web/templates/irs.html b/src/web/templates/irs.html new file mode 100644 index 0000000..ca7c59a --- /dev/null +++ b/src/web/templates/irs.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}IR Cabinet — Pi Multi-FX Pedal{% endblock %} +{% block content %} +
+ + +
+
+

Current IR

+
+
+
+ None loaded +
+
+ +
+
+
+ +
+
+

Available IR Files

+
+
+
+

Loading IR files...

+
+
+
+ +
+
+

Upload IR

+
+
+

Upload .wav IR files to the pedal's IR directory.

+ + +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/src/web/templates/models.html b/src/web/templates/models.html new file mode 100644 index 0000000..5d9fdf6 --- /dev/null +++ b/src/web/templates/models.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}NAM Models — Pi Multi-FX Pedal{% endblock %} +{% block content %} +
+ + +
+
+

Current Model

+
+
+
+ None loaded +
+
+ +
+
+
+ +
+
+

Available Models

+
+
+
+

Loading models...

+
+
+
+ +
+
+

Upload Model

+
+
+

Upload .nam model files to the pedal's model directory.

+ + +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/src/web/templates/presets.html b/src/web/templates/presets.html new file mode 100644 index 0000000..439ec6c --- /dev/null +++ b/src/web/templates/presets.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Presets — Pi Multi-FX Pedal{% endblock %} +{% block content %} +
+ + +
+

Loading presets...

+
+
+ + +{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/src/web/templates/settings.html b/src/web/templates/settings.html new file mode 100644 index 0000000..5859406 --- /dev/null +++ b/src/web/templates/settings.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}Settings — Pi Multi-FX Pedal{% endblock %} +{% block content %} +
+ + +
+
+

Connection

+
+
+
+
Hostname
+
pedal.local
+
+
+
Web Server
+
http://pedal.local:8080
+
+
+
Connected Clients
+
0
+
+
+
+ +
+
+

Audio

+
+
+
+
Master Volume
+
+ + {{ (master_volume * 100)|int }}% +
+
+
+
Tuner
+
+ +
+
+
+
+ +
+
+

About

+
+
+
+

Pi Multi-FX Pedal v0.1.0

+

Raspberry Pi 4B — Python 3.11 — JACK Audio

+

Gitea Repository

+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..7505984 --- /dev/null +++ b/tests/test_web.py @@ -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 \ No newline at end of file