"""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} # ── 4CM routing endpoints ──────────────────────────────── @app.get("/api/routing") async def get_routing(): """Get current routing mode and breakpoint.""" pl = self.deps.pipeline if not pl: raise HTTPException(status_code=503) return { "routing_mode": pl.routing_mode, "routing_breakpoint": pl.routing_breakpoint, } @app.post("/api/routing") async def set_routing(data: dict): """Set routing mode and/or breakpoint. Body: routing_mode (optional): ``\"mono\"`` or ``\"4cm\"``. routing_breakpoint (optional): chain index for split. """ pl = self.deps.pipeline pm = self.deps.presets if not pl: raise HTTPException(status_code=503) mode = data.get("routing_mode", pl.routing_mode) bp = int(data.get("routing_breakpoint", pl.routing_breakpoint)) pl.set_routing(mode, bp) # Persist to current preset if preset manager is available if pm: try: bank = pm.current_bank program = pm.current_program preset = pm.load(bank, program) pm.set_4cm_config(preset, mode=mode, breakpoint=bp) except Exception: pass await self._manager.broadcast({ "type": "routing_changed", "routing_mode": pl.routing_mode, "routing_breakpoint": pl.routing_breakpoint, }) return {"ok": True, "routing_mode": pl.routing_mode, "routing_breakpoint": pl.routing_breakpoint} @app.get("/api/models") async def list_models(): """List available NAM models.""" nam = self.deps.nam_host if not nam: raise HTTPException(status_code=503) models = nam.list_available_models() return { "models": [ { "name": m.name, "path": m.path, "size_mb": round(m.size_mb, 2), "architecture": m.architecture, "loaded": nam.current_model is not None and nam.current_model.name == m.name, } for m in models ], "current": nam.current_model.name if nam.current_model else None, } @app.post("/api/models/load") async def load_model(data: dict): """Load a NAM model by path.""" nam = self.deps.nam_host if not nam: raise HTTPException(status_code=503) path = data.get("path", "") if not path: raise HTTPException(status_code=400, detail="path required") ok = nam.load_model(path) if not ok: raise HTTPException(status_code=500, detail="Failed to load model") await self._manager.broadcast({ "type": "model_loaded", "model": path, }) return {"ok": True} @app.post("/api/models/unload") async def unload_model(): """Unload the current NAM model.""" nam = self.deps.nam_host if not nam: raise HTTPException(status_code=503) nam.unload() return {"ok": True} @app.get("/api/irs") async def list_irs(): """List available IR files.""" ir = self.deps.ir_loader if not ir: raise HTTPException(status_code=503) irs = ir.get_irs() return { "irs": [ { "name": irf.name, "path": irf.path, "sample_rate": irf.sample_rate, "num_taps": irf.num_taps, "length_ms": round(irf.length_ms, 1), "loaded": ir.current_ir is not None and ir.current_ir.name == irf.name, } for irf in irs ], "current": ir.current_ir.name if ir.current_ir else None, } @app.post("/api/irs/load") async def load_ir(data: dict): """Load an IR file by path.""" ir = self.deps.ir_loader if not ir: raise HTTPException(status_code=503) path = data.get("path", "") if not path: raise HTTPException(status_code=400, detail="path required") ok = ir.load_ir(path) if not ok: raise HTTPException(status_code=500, detail="Failed to load IR") await self._manager.broadcast({ "type": "ir_loaded", "ir": path, }) return {"ok": True} @app.post("/api/irs/unload") async def unload_ir(): """Unload the current IR.""" ir = self.deps.ir_loader if not ir: raise HTTPException(status_code=503) ir.unload() return {"ok": True} # ── File upload endpoints ───────────────────────────────── @app.post("/api/models/upload") async def upload_model(request: Request): """Upload a .nam model file to the pedal's models directory.""" nam = self.deps.nam_host if not nam: raise HTTPException(status_code=503) form = await request.form() file = form.get("file") if not file or not file.filename: raise HTTPException(status_code=400, detail="No file provided") if not file.filename.endswith(".nam"): raise HTTPException(status_code=400, detail="Only .nam files accepted") models_dir = nam._models_dir dest = models_dir / Path(file.filename).name content = await file.read() dest.write_bytes(content) logger.info("Uploaded NAM model: %s", dest.name) return {"ok": True, "filename": dest.name} @app.post("/api/irs/upload") async def upload_ir(request: Request): """Upload a .wav IR file to the pedal's IR directory.""" ir = self.deps.ir_loader if not ir: raise HTTPException(status_code=503) form = await request.form() file = form.get("file") if not file or not file.filename: raise HTTPException(status_code=400, detail="No file provided") if not file.filename.endswith(".wav"): raise HTTPException(status_code=400, detail="Only .wav files accepted") ir_dir = ir._ir_dir dest = ir_dir / Path(file.filename).name content = await file.read() dest.write_bytes(content) logger.info("Uploaded IR file: %s", dest.name) return {"ok": True, "filename": dest.name} # ── WiFi endpoints ────────────────────────────────────── @app.get("/api/wifi/status") async def wifi_get_status(): """Get current WiFi client connection status.""" from ..system.network import wifi_status return wifi_status() @app.get("/api/wifi/scan") async def wifi_scan(): """Scan for available WiFi networks.""" from ..system.network import wifi_scan return {"networks": wifi_scan()} @app.get("/api/wifi/saved") async def wifi_saved(): """List saved/known WiFi networks.""" from ..system.network import wifi_saved_networks return {"networks": wifi_saved_networks()} @app.post("/api/wifi/connect") async def wifi_connect(data: dict): """Connect to a WiFi network. Body: {ssid: str, password: str} """ from ..system.network import wifi_connect return wifi_connect( ssid=data.get("ssid", ""), password=data.get("password", ""), ) @app.post("/api/wifi/disconnect") async def wifi_disconnect(): """Disconnect from the current WiFi network.""" from ..system.network import wifi_disconnect return wifi_disconnect() @app.post("/api/wifi/forget") async def wifi_forget(data: dict): """Forget a saved WiFi network. Body: {ssid: str} """ from ..system.network import wifi_forget return wifi_forget(ssid=data.get("ssid", "")) @app.get("/api/wifi/hotspot") async def wifi_hotspot_status(): """Get hotspot status.""" from ..system.network import hotspot_status return hotspot_status() @app.post("/api/wifi/hotspot/enable") async def wifi_hotspot_enable(data: dict): """Enable WiFi hotspot. Body: {ssid: str (optional), password: str (optional)} """ from ..system.network import hotspot_enable return hotspot_enable( ssid=data.get("ssid", "Pi-Pedal"), password=data.get("password", "pedal1234"), ) @app.post("/api/wifi/hotspot/disable") async def wifi_hotspot_disable(): """Disable WiFi hotspot.""" from ..system.network import hotspot_disable return hotspot_disable() # ── Bluetooth endpoints ────────────────────────────────── @app.get("/api/bluetooth/status") async def bluetooth_status(): """Get Bluetooth adapter status.""" from ..system.bluetooth import bt_status return bt_status() @app.post("/api/bluetooth/power") async def bluetooth_power(data: dict): """Power Bluetooth on or off. Body: {on: bool} """ from ..system.bluetooth import bt_power return bt_power(on=data.get("on", True)) @app.post("/api/bluetooth/discoverable") async def bluetooth_discoverable(data: dict): """Set Bluetooth discoverable. Body: {on: bool} """ from ..system.bluetooth import bt_discoverable return bt_discoverable(on=data.get("on", True)) @app.post("/api/bluetooth/scan") async def bluetooth_scan(): """Scan for discoverable Bluetooth devices.""" from ..system.bluetooth import bt_scan return bt_scan(timeout=12) @app.get("/api/bluetooth/paired") async def bluetooth_paired(): """List paired Bluetooth devices.""" from ..system.bluetooth import bt_paired_devices return {"devices": bt_paired_devices()} @app.post("/api/bluetooth/pair") async def bluetooth_pair(data: dict): """Pair with a Bluetooth device. Body: {address: str} """ from ..system.bluetooth import bt_pair return bt_pair(address=data.get("address", "")) @app.post("/api/bluetooth/unpair") async def bluetooth_unpair(data: dict): """Unpair a Bluetooth device. Body: {address: str} """ from ..system.bluetooth import bt_unpair return bt_unpair(address=data.get("address", "")) @app.post("/api/bluetooth/connect") async def bluetooth_connect(data: dict): """Connect to a paired Bluetooth device. Body: {address: str} """ from ..system.bluetooth import bt_connect return bt_connect(address=data.get("address", "")) @app.post("/api/bluetooth/disconnect") async def bluetooth_disconnect(data: dict): """Disconnect a Bluetooth device. Body: {address: str} """ from ..system.bluetooth import bt_disconnect return bt_disconnect(address=data.get("address", "")) @app.get("/api/bluetooth/midi-status") async def bluetooth_midi_status(): """Get Bluetooth MIDI service status.""" from ..system.bluetooth import bt_midi_status return bt_midi_status() @app.post("/api/bluetooth/midi-enable") async def bluetooth_midi_enable(): """Enable and start the Bluetooth MIDI service.""" from ..system.bluetooth import bt_midi_enable return bt_midi_enable() @app.post("/api/bluetooth/midi-disable") async def bluetooth_midi_disable(): """Stop and disable the Bluetooth MIDI service.""" from ..system.bluetooth import bt_midi_disable return bt_midi_disable() @app.get("/api/bluetooth/midi-devices") async def bluetooth_midi_devices(): """List connected Bluetooth MIDI devices.""" from ..system.bluetooth import bt_midi_connected_devices return {"devices": bt_midi_connected_devices()} # ── FX block param schemas ─────────────────────────────── @app.get("/api/block-params/{fx_type}") async def get_block_params(fx_type: str): """Return editable parameters for a given FX block type. Returns a list of param definitions with name, type, min, max, default. The web UI uses this to render appropriate sliders/knobs. """ param_schema = _FX_PARAM_SCHEMAS.get(fx_type, []) return {"fx_type": fx_type, "params": param_schema} # ── WebSocket ────────────────────────────────────────────── @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket): await self._manager.connect(ws) logger.info("WebSocket client connected (%d active)", self._manager.active_count) try: # Send initial state snapshot on connect state = self._gather_state() await ws.send_text(json.dumps({"type": "connected", "state": state})) while True: raw = await ws.receive_text() msg = json.loads(raw) msg_type = msg.get("type", "") if msg_type == "ping": await ws.send_text(json.dumps({"type": "pong"})) elif msg_type == "get_state": state = self._gather_state() await ws.send_text(json.dumps({"type": "state", "state": state})) except WebSocketDisconnect: pass except Exception as e: logger.warning("WebSocket error: %s", e) finally: self._manager.disconnect(ws) logger.info("WebSocket client disconnected (%d active)", self._manager.active_count) return app # ── Network/BT state gathering ────────────────────────────────── def _gather_wifi_state(self) -> dict: """Try to gather WiFi status without hard-failing.""" try: from ..system.network import wifi_status, hotspot_status ws = wifi_status() hs = hotspot_status() return { "connected": ws.get("connected", False), "ssid": ws.get("ssid"), "ip": ws.get("ip"), "signal": ws.get("signal", 0), "hotspot_active": hs.get("active", False), "hotspot_ssid": hs.get("ssid"), "hotspot_clients": hs.get("clients", 0), } except Exception: return {"connected": False, "ssid": None, "ip": None, "signal": 0, "hotspot_active": False, "hotspot_ssid": None, "hotspot_clients": 0} def _gather_bt_state(self) -> dict: """Try to gather Bluetooth status without hard-failing.""" try: from ..system.bluetooth import bt_status, bt_midi_status bs = bt_status() ms = bt_midi_status() return { "available": bs.get("available", False), "powered": bs.get("powered", False), "name": bs.get("name"), "address": bs.get("address"), "discoverable": bs.get("discoverable", False), "midi_enabled": ms.get("enabled", False), "midi_running": ms.get("running", False), } except Exception: return {"available": False, "powered": False, "name": None, "address": None, "discoverable": False, "midi_enabled": False, "midi_running": False} # ── State gathering ────────────────────────────────────────────── def _gather_state(self) -> dict[str, Any]: """Collect a snapshot of the current pedal state.""" pm = self.deps.presets pl = self.deps.pipeline nam = self.deps.nam_host ir = self.deps.ir_loader if not pm: return {"connected": False} try: bank = pm.current_bank program = pm.current_program preset = pm.load(bank, program) if pm else None except Exception: preset = None def _safe_str(val) -> str | None: """Get a string representation, or None if the value is falsy/mock.""" if val is None: return None try: s = str(val) return s if s and not s.startswith(" None: """Start the web server in a background thread. Uses a dedicated thread with its own asyncio event loop to avoid conflicting with the main thread's blocking JACK loop. """ import threading config = uvicorn.Config( self._app, host=self.host, port=self.port, log_level="info", access_log=False, ) def _run(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) server = uvicorn.Server(config) self._server = server loop.run_until_complete(server.serve()) self._thread = threading.Thread(target=_run, daemon=True) self._thread.start() logger.info("Web server starting on http://%s:%d", self.host, self.port) def stop(self) -> None: """Stop the web server gracefully.""" if self._server: self._server.should_exit = True logger.info("Web server stopped") # ── FX parameter schemas ───────────────────────────────────────────────────── # Used by the web UI to render appropriate controls per FX block type. _FX_PARAM_SCHEMAS: dict[str, list[dict]] = { "noise_gate": [ {"key": "threshold", "name": "Threshold", "type": "float", "min": 0.0, "max": 0.1, "step": 0.001, "default": 0.01}, {"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0}, ], "compressor": [ {"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -20.0}, {"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 3.0}, {"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 5.0}, {"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0}, {"key": "gain", "name": "Makeup Gain", "type": "float", "min": 0.0, "max": 5.0, "step": 0.1, "default": 1.0}, ], "boost": [ {"key": "gain_db", "name": "Gain (dB)", "type": "float", "min": 0.0, "max": 30.0, "step": 0.5, "default": 6.0}, ], "overdrive": [ {"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0}, ], "distortion": [ {"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7}, {"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0}, ], "fuzz": [ {"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.8}, {"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0}, ], "eq": [ {"key": "low_gain", "name": "Low (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0}, {"key": "mid_gain", "name": "Mid (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0}, {"key": "high_gain", "name": "High (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0}, ], "chorus": [ {"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.1, "max": 5.0, "step": 0.1, "default": 1.0}, {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "flanger": [ {"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 3.0, "step": 0.05, "default": 0.5}, {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "phaser": [ {"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.5}, {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, ], "tremolo": [ {"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 3.0}, {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "vibrato": [ {"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 3.0}, {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "delay": [ {"key": "time", "name": "Time (ms)", "type": "float", "min": 10.0, "max": 2000.0, "step": 5.0, "default": 400.0}, {"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "reverb": [ {"key": "size", "name": "Room Size", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, {"key": "damping", "name": "Damping", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, ], "volume": [ {"key": "level", "name": "Level", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.8}, ], # ── Pitch & Frequency ────────────────────────────────────────── "octaver": [ {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "pitch_shifter": [ {"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "harmonizer": [ {"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 3.0}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "whammy": [ {"key": "bend", "name": "Bend", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.0}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7}, ], "detune": [ {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], # ── Modulation ───────────────────────────────────────────────── "ring_modulator": [ {"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 10.0, "max": 2000.0, "step": 10.0, "default": 100.0}, {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "auto_wah": [ {"key": "sensitivity", "name": "Sensitivity", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "q", "name": "Q", "type": "float", "min": 0.5, "max": 10.0, "step": 0.1, "default": 2.0}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "envelope_filter": [ {"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "range", "name": "Range", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "rotary_speaker": [ {"key": "speed", "name": "Speed", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "uni_vibe": [ {"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.8}, {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "auto_pan": [ {"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.3}, {"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7}, {"key": "waveform", "name": "Waveform", "type": "select", "options": ["sine", "triangle", "square"], "default": "sine"}, ], "stereo_widener": [ {"key": "width", "name": "Width", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], # ── Drive & Saturation ───────────────────────────────────────── "bitcrusher": [ {"key": "bits", "name": "Bits", "type": "int", "min": 1, "max": 16, "step": 1, "default": 8}, {"key": "rate", "name": "Rate Reduction", "type": "float", "min": 1.0, "max": 10.0, "step": 1.0, "default": 1.0}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "wavefolder": [ {"key": "gain", "name": "Gain", "type": "float", "min": 0.5, "max": 10.0, "step": 0.5, "default": 2.0}, {"key": "fold", "name": "Fold Stages", "type": "int", "min": 1, "max": 10, "step": 1, "default": 3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "rectifier": [ {"key": "mode", "name": "Mode", "type": "select", "options": ["full", "half"], "default": "full"}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], # ── Dynamics ──────────────────────────────────────────────────── "expander": [ {"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -30.0}, {"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 3.0}, {"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 5.0}, {"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0}, ], "de_esser": [ {"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 2000.0, "max": 10000.0, "step": 100.0, "default": 6000.0}, {"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -50.0, "max": 0.0, "step": 1.0, "default": -30.0}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "transient_shaper": [ {"key": "attack", "name": "Attack Boost", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "sustain", "name": "Sustain", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], "sidechain_compressor": [ {"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -20.0}, {"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 4.0}, {"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 2.0}, {"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 50.0}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], # ── Filters & EQ ──────────────────────────────────────────────── "parametric_eq": [ {"key": "freq_0", "name": "Band 1 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 100.0}, {"key": "gain_0", "name": "Band 1 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0}, {"key": "q_0", "name": "Band 1 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707}, {"key": "freq_1", "name": "Band 2 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 500.0}, {"key": "gain_1", "name": "Band 2 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0}, {"key": "q_1", "name": "Band 2 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707}, {"key": "freq_2", "name": "Band 3 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 2000.0}, {"key": "gain_2", "name": "Band 3 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0}, {"key": "q_2", "name": "Band 3 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707}, {"key": "freq_3", "name": "Band 4 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 5000.0}, {"key": "gain_3", "name": "Band 4 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0}, {"key": "q_3", "name": "Band 4 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707}, ], "high_pass_filter": [ {"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 200.0}, {"key": "slope", "name": "Slope (dB/oct)", "type": "select", "options": [6, 12], "default": 12}, ], "low_pass_filter": [ {"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 5000.0}, {"key": "slope", "name": "Slope (dB/oct)", "type": "select", "options": [6, 12], "default": 12}, ], "band_pass_filter": [ {"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 1000.0}, {"key": "q", "name": "Q", "type": "float", "min": 0.1, "max": 20.0, "step": 0.1, "default": 0.707}, ], "notch_filter": [ {"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 60.0}, {"key": "q", "name": "Q", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 10.0}, ], "formant_filter": [ {"key": "vowel", "name": "Vowel", "type": "select", "options": ["a", "e", "i", "o", "u"], "default": "a"}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, ], # ── Time-Based ────────────────────────────────────────────────── "ping_pong_delay": [ {"key": "time", "name": "Time (ms)", "type": "float", "min": 10.0, "max": 2000.0, "step": 5.0, "default": 400.0}, {"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, ], "multi_tap_delay": [ {"key": "pattern", "name": "Pattern", "type": "select", "options": ["quarter", "dotted", "triplet"], "default": "quarter"}, {"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, ], "reverse_delay": [ {"key": "time", "name": "Time (ms)", "type": "float", "min": 50.0, "max": 2000.0, "step": 5.0, "default": 400.0}, {"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, ], "tape_echo": [ {"key": "time", "name": "Time (ms)", "type": "float", "min": 50.0, "max": 2000.0, "step": 5.0, "default": 300.0}, {"key": "wow", "name": "Wow/Flutter", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "high_cut", "name": "High Cut", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, ], "shimmer_reverb": [ {"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0}, {"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, ], "looper": [ {"key": "record", "name": "Record", "type": "bool", "default": 0}, {"key": "overdub", "name": "Overdub", "type": "bool", "default": 0}, {"key": "play", "name": "Play", "type": "bool", "default": 0}, {"key": "stop", "name": "Stop", "type": "bool", "default": 0}, ], # ── Ambience ──────────────────────────────────────────────────── "early_reflections": [ {"key": "size", "name": "Room Size", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5}, {"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, {"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4}, ], } # ── Standalone runner for development ───────────────────────────────────────── def main(): """Run the web server standalone (for dev/testing without the full pedal).""" logging.basicConfig(level=logging.INFO) import uvicorn deps = WebServerDeps() ws = WebServer(deps) ws.start() logger.info("Dev server running on http://0.0.0.0:8080") try: loop = asyncio.get_event_loop() loop.run_forever() except KeyboardInterrupt: pass finally: ws.stop() if __name__ == "__main__": main()