Files
pi-multifx-pedal/src/web/server.py
T
shawn 6008776e3c fix: move NAM block size sync before JACK client restart
The profile change handler was calling nam_host.set_block_size() AFTER
jack_client.start(), creating a race where the JACK callback fired with
the new buffer size while the old NAM subprocess was still running at
the old block_size. Pipe byte counts desynced => NAM returned passthrough.

+ also: auto_connect disable during restart, connect_fx_ports reorder,
  Tone3000 gear/sort/pagination params, NAM engine switch API endpoints
2026-06-17 17:09:38 -04:00

3021 lines
141 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 os
import logging
import math
import mimetypes
import datetime
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, RedirectResponse
from fastapi.staticfiles import StaticFiles
from ..dsp.nam_host import NAMHost
from ..dsp.ir_loader import IRLoader
from ..dsp.pipeline import AudioPipeline, SAMPLE_RATE
from ..presets.manager import PresetManager
from ..presets.types import Channel, FXBlock, FXType, Preset
from ..system.tonedownload import (
Tone3000Client,
ModelResult,
IRResult,
discover_anon_key,
format_size,
)
from ..system.audio import AudioSystem, JackAudioClient, LATENCY_PROFILES
from ..system.config import save_config
from ..ui.leds import LEDController
from ..ui.display import DisplayController
from ..midi.handler import MIDIHandler
logger = logging.getLogger(__name__)
# ── Cache buster version (bumped on deploy) ─────────────────────────
# Increment this when template/static changes need to bypass browser cache
# ── Defaults ─────────────────────────────────────────────────────────────────
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 80
STATIC_DIR = Path(__file__).resolve().parent / "static"
# v2 UI dist (the only UI we serve)
V2_DIST_DIR = Path(__file__).resolve().parent / "ui-v2-dist"
# ── Channel mode persistence ────────────────────────────────────────────────
CHANNEL_MODE_FILE = Path.home() / ".pedal" / "channel_mode.json"
def _load_channel_mode() -> str:
"""Load channel mode from disk; default to 'dual-mono'."""
try:
if CHANNEL_MODE_FILE.exists():
data = json.loads(CHANNEL_MODE_FILE.read_text())
mode = data.get("mode", "dual-mono")
if mode in ("dual-mono", "stereo"):
return mode
except Exception:
pass
return "dual-mono"
def _save_channel_mode(mode: str) -> None:
"""Persist channel mode to disk."""
CHANNEL_MODE_FILE.parent.mkdir(parents=True, exist_ok=True)
CHANNEL_MODE_FILE.write_text(json.dumps({"mode": mode}))
@dataclass
class ChannelState:
"""Per-channel runtime state (volume, bypass, tuner)."""
volume: float = 0.8
bypass: bool = False
tuner_enabled: bool = False
current_snapshot: int = 0
class ChannelManager:
"""Manages multi-channel vs stereo routing mode and per-channel state.
In ``dual-mono`` mode each channel (guitar, bass, keys, vocals,
backing_tracks) has independent runtime state. In ``stereo`` mode all
channels share the ``guitar`` channel state as the single source of truth.
"""
def __init__(self) -> None:
self.mode: str = _load_channel_mode()
self._channels: dict[str, ChannelState] = {
"guitar": ChannelState(),
"bass": ChannelState(),
"keys": ChannelState(),
"vocals": ChannelState(),
"backing_tracks": ChannelState(),
}
# ── Mode management ────────────────────────────────────────────────
def set_mode(self, mode: str) -> None:
if mode not in ("dual-mono", "stereo"):
raise ValueError(f"Unknown channel mode: {mode!r}")
self.mode = mode
_save_channel_mode(mode)
# ── Per-channel helpers ────────────────────────────────────────────
def state_for(self, channel: str) -> ChannelState:
"""Return the ``ChannelState`` for a given channel.
In stereo mode both channels read from the ``guitar`` slot.
Falls back to ``guitar`` for unknown channel names.
"""
if self.mode == "stereo":
return self._channels["guitar"]
return self._channels.get(channel, self._channels["guitar"])
def sync_to_stereo(self) -> None:
"""When switching *to* stereo, copy bass -> guitar so guitar has
the most recently changed values from either channel."""
if self.mode == "stereo":
g = self._channels["guitar"]
b = self._channels["bass"]
# Pick the "latest" non-default values
for attr in ("volume", "bypass", "tuner_enabled", "current_snapshot"):
bv = getattr(b, attr)
if bv != getattr(ChannelState(), attr):
setattr(g, attr, bv)
def resolve_channel(self, param: str | None) -> str:
"""Return the active channel name.
In stereo mode always returns ``"guitar"`` (the canonical slot).
In dual-mono returns *param* if it's a valid channel name,
otherwise falls back to ``"guitar"``.
"""
if self.mode == "stereo":
return "guitar"
if param in VALID_CHANNELS:
return param
return "guitar"
# ── Dependency container ─────────────────────────────────────────────────────
@dataclass
class WebServerDeps:
"""Dependencies injected into the web server from the host pedal app.
Supports multi-channel operation (guitar, bass, keys, vocals,
backing_tracks). Each channel can have its own preset manager, audio
pipeline, NAM host, and IR loader. Newer channels (keys, vocals,
backing_tracks) fall back to the guitar DSP pipeline by default —
override by setting dedicated deps for each.
The guitar fields are the defaults — callers that don't need multi-channel
can just set the guitar fields as before and everything works.
"""
# Guitar channel (default)
presets: Optional[PresetManager] = None
pipeline: Optional[AudioPipeline] = None
nam_host: Optional[NAMHost] = None
ir_loader: Optional[IRLoader] = None
# Bass channel (separate from guitar)
bass_presets: Optional[PresetManager] = None
bass_pipeline: Optional[AudioPipeline] = None
bass_nam_host: Optional[NAMHost] = None
bass_ir_loader: Optional[IRLoader] = None
# Audio system (for JACK restart on profile change)
audio_system: Optional[AudioSystem] = None
# JACK audio client (needs restart when JACK restarts)
jack_audio: Optional[JackAudioClient] = None
# Full config dict (for saving profile/rate/period changes to disk)
config: Optional[dict] = None
config_path: Optional[Path] = None
# Runtime subsystems (for live apply of settings changes)
leds: Optional[LEDController] = None
midi: Optional[MIDIHandler] = None
display: Optional[DisplayController] = None
@property
def is_connected(self) -> bool:
"""True if we're wired to a live pedal instance (any channel)."""
return self.presets is not None or self.bass_presets is not None
def get_deps(self, channel: str) -> tuple:
"""Return (presets, pipeline, nam_host, ir_loader) for the given channel.
Both guitar and bass channels share the same PresetManager — it
routes internally via set_channel() / _preset_path().
Falls back to guitar fields for unrecognised channels.
"""
return (self.presets, self.pipeline, self.nam_host, self.ir_loader)
# ── 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._channel_mgr = ChannelManager()
self._server: Optional[uvicorn.Server] = None
self._task: Optional[asyncio.Task] = None
self._tonedownload: Optional[Tone3000Client] = None
self._app = self._build_app()
# ── Channel dep resolution ──────────────────────────────────────
def _channel_deps(self, channel: str = "guitar") -> tuple:
"""Shorthand for self.deps.get_deps(channel).
Returns (presets, pipeline, nam_host, ir_loader).
"""
return self.deps.get_deps(channel)
# ── App factory ─────────────────────────────────────────────────────
def _build_app(self) -> FastAPI:
@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 root(request: Request):
"""Serve the v2 UI."""
v2_index = V2_DIST_DIR / "index.html"
if v2_index.exists():
content = v2_index.read_text()
return HTMLResponse(content)
return HTMLResponse("<h1>Pi Multi-FX Pedal</h1><p>UI build not found.</p>")
# Legacy routes — redirect to the v2 SPA
@app.get("/presets")
async def presets_redirect():
return RedirectResponse(url="/")
@app.get("/models")
async def models_redirect():
return RedirectResponse(url="/")
@app.get("/irs")
async def irs_redirect():
return RedirectResponse(url="/")
@app.get("/settings")
async def settings_redirect():
return RedirectResponse(url="/")
# ── REST API ────────────────────────────────────────────────
@app.get("/api/state")
async def get_state(channel: Optional[str] = None, combined: bool = False):
"""Full pedal state snapshot.
Without parameters returns the active (guitar) channel's flat state.
``?channel=guitar|bass`` returns per-channel state.
``?combined=true`` returns both channel states in a combined view.
"""
if combined:
state = self._gather_state_combined()
else:
state = self._gather_state(channel=channel)
if not state:
raise HTTPException(status_code=503, detail="Pedal not connected")
return state
@app.get("/api/presets")
async def list_presets(channel: str = "guitar"):
"""List all banks and their presets for the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
if not pm:
raise HTTPException(status_code=503, detail="Preset manager not available")
banks = pm.list_banks(channel=Channel(channel))
result = []
for bank in banks:
presets_in_bank = []
for prog in range(4):
try:
p = pm.load(bank.number, prog, channel=Channel(channel))
presets_in_bank.append({
"name": p.name,
"bank": p.bank,
"program": p.program,
"chain": [
{
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
"nam_model_path": b.nam_model_path,
"nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "",
"ir_file_path": b.ir_file_path,
"ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "",
"name": (
Path(b.nam_model_path).stem.replace("_", " ")
if b.nam_model_path
else Path(b.ir_file_path).stem.replace("_", " ")
if b.ir_file_path
else b.fx_type.value.replace("_", " ").title()
),
}
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, channel: str = "guitar"):
"""Get a specific preset for the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
if not pm:
raise HTTPException(status_code=503)
try:
p = pm.load(bank, program, channel=Channel(channel))
return {
"name": p.name,
"bank": p.bank,
"program": p.program,
"chain": [
{
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
"nam_model_path": b.nam_model_path,
"nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "",
"ir_file_path": b.ir_file_path,
"ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "",
"name": (
Path(b.nam_model_path).stem.replace("_", " ")
if b.nam_model_path
else Path(b.ir_file_path).stem.replace("_", " ")
if b.ir_file_path
else b.fx_type.value.replace("_", " ").title()
),
}
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, channel: str = "guitar"):
"""Activate a preset (load into pipeline) for the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
if not pm:
raise HTTPException(status_code=503)
try:
preset = pm.select(bank, program)
pm.save_state()
state = self._gather_state(channel)
await self._manager.broadcast({
"type": "preset_changed",
"channel": channel,
"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, channel: str = "guitar"):
"""Save/update a preset for the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
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}"),
channel=Channel(channel),
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, channel: str = "guitar"):
"""Delete a preset for the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
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))
# ── Snapshot endpoints ──────────────────────────────────────
SNAPSHOT_NAMES = {1: "Clean", 2: "Crunch", 3: "Lead", 4: "Rhythm",
5: "Ambient", 6: "Swell", 7: "Mellow", 8: "Edge"}
def _get_current_preset(pm) -> Preset | None:
"""Load the currently active preset."""
from ..presets.types import Preset
if not pm:
return None
try:
return pm.load(pm.current_bank, pm.current_program)
except Exception:
return None
def _snapshot_from_pipeline(pm, pl, slot: int, name: str = "") -> dict:
"""Capture current block states + params into a snapshot dict."""
from ..presets.types import FXBlock, FXType
preset = _get_current_preset(pm)
if not preset:
return {}
chain_data = []
for b in preset.chain:
chain_data.append({
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
"nam_model_path": b.nam_model_path,
"ir_file_path": b.ir_file_path,
})
return {
"name": name or SNAPSHOT_NAMES.get(slot, f"Snapshot {slot}"),
"chain": chain_data,
"master_volume": preset.master_volume,
}
@app.get("/api/snapshots")
async def list_snapshots(channel: str = "guitar"):
"""List snapshot slots for the current preset on the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
preset = _get_current_preset(pm)
if not preset:
return {"snapshots": {}}
data = {}
for slot in range(1, 9):
snap = preset.snapshots.get(slot)
if snap:
chain = []
for b in snap.chain:
chain.append({
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
"nam_model_path": b.nam_model_path,
"ir_file_path": b.ir_file_path,
})
data[str(slot)] = {
"name": snap.name,
"chain": chain,
"master_volume": snap.master_volume,
}
else:
data[str(slot)] = None
return {"snapshots": data, "current_snapshot": getattr(pm, '_current_snapshot', 0)}
@app.post("/api/snapshots/{slot}/save")
async def save_snapshot(slot: int, data: Optional[dict] = None, channel: str = "guitar"):
"""Save current state to a snapshot slot (1-8) for the given channel."""
pm, pl, _nam, _ir = self._channel_deps(channel)
if not pm or not (1 <= slot <= 8):
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
preset = _get_current_preset(pm)
if not preset:
raise HTTPException(status_code=404, detail="No active preset")
name = (data or {}).get("name", "")
snap_data = _snapshot_from_pipeline(pm, pl, slot, name)
from ..presets.types import FXBlock, FXType, Snapshot as SnapModel
snap_chain = []
for bd in snap_data["chain"]:
snap_chain.append(
FXBlock(
fx_type=FXType(bd["fx_type"]),
enabled=bd.get("enabled", True),
bypass=bd.get("bypass", False),
params=dict(bd.get("params", {})),
nam_model_path=bd.get("nam_model_path", ""),
ir_file_path=bd.get("ir_file_path", ""),
)
)
preset.snapshots[slot] = SnapModel(
name=snap_data["name"],
chain=snap_chain,
master_volume=snap_data["master_volume"],
)
pm.save(preset)
# Mark current snapshot
pm._current_snapshot = slot
# Broadcast snapshot update
await self._manager.broadcast({
"type": "snapshot_saved",
"slot": slot,
"name": snap_data["name"],
})
return {"ok": True, "slot": slot, "name": snap_data["name"]}
@app.post("/api/snapshots/{slot}/recall")
async def recall_snapshot(slot: int, channel: str = "guitar"):
"""Recall a snapshot: restore block states + params from slot for the given channel."""
pm, pl, _nam, _ir = self._channel_deps(channel)
if not pm or not (1 <= slot <= 8):
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
preset = _get_current_preset(pm)
if not preset:
raise HTTPException(status_code=404, detail="No active preset")
snap = preset.snapshots.get(slot)
if not snap:
raise HTTPException(status_code=404, detail=f"No snapshot in slot {slot}")
# Apply snapshot state to the pipeline
if pl:
# Restore bypass states and params for each block
for i, snap_block in enumerate(snap.chain):
if i < len(preset.chain):
preset.chain[i].bypass = snap_block.bypass
preset.chain[i].enabled = snap_block.enabled
preset.chain[i].params = dict(snap_block.params)
preset.chain[i].nam_model_path = snap_block.nam_model_path
preset.chain[i].ir_file_path = snap_block.ir_file_path
# Restore master volume
preset.master_volume = snap.master_volume
# Push to pipeline
try:
pl.load_preset(preset)
except Exception:
pass
pm.save(preset)
pm._current_snapshot = slot
state = self._gather_state(channel)
await self._manager.broadcast({
"type": "snapshot_recalled",
"channel": channel,
"slot": slot,
"name": snap.name,
"state": state,
})
return {"ok": True, "slot": slot, "name": snap.name}
@app.put("/api/snapshots/{slot}")
async def update_snapshot(slot: int, data: dict, channel: str = "guitar"):
"""Rename or update a snapshot slot for the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
if not pm or not (1 <= slot <= 8):
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
preset = _get_current_preset(pm)
if not preset:
raise HTTPException(status_code=404, detail="No active preset")
snap = preset.snapshots.get(slot)
if not snap:
raise HTTPException(status_code=404, detail=f"No snapshot in slot {slot}")
if "name" in data:
snap.name = data["name"]
pm.save(preset)
await self._manager.broadcast({
"type": "snapshot_updated",
"channel": channel,
"slot": slot,
"name": snap.name,
})
return {"ok": True, "slot": slot, "name": snap.name}
@app.delete("/api/snapshots/{slot}")
async def delete_snapshot(slot: int, channel: str = "guitar"):
"""Delete a snapshot slot for the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
if not pm or not (1 <= slot <= 8):
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
preset = _get_current_preset(pm)
if not preset:
raise HTTPException(status_code=404, detail="No active preset")
preset.snapshots.pop(slot, None)
if getattr(pm, '_current_snapshot', 0) == slot:
pm._current_snapshot = 0
pm.save(preset)
await self._manager.broadcast({
"type": "snapshot_deleted",
"channel": channel,
"slot": slot,
})
return {"ok": True, "slot": slot}
@app.get("/api/snapshots/{slot}")
async def get_snapshot(slot: int, channel: str = "guitar"):
"""Get a single snapshot slot for the given channel."""
pm, _pl, _nam, _ir = self._channel_deps(channel)
preset = _get_current_preset(pm)
if not preset:
raise HTTPException(status_code=404, detail="No active preset")
snap = preset.snapshots.get(slot)
if not snap:
raise HTTPException(status_code=404, detail=f"No snapshot in slot {slot}")
return {
"name": snap.name,
"slot": slot,
"channel": channel,
"chain": [
{
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
}
for b in snap.chain
],
"master_volume": snap.master_volume,
}
@app.post("/api/bypass")
async def toggle_bypass(data: Optional[dict] = None, channel: str = "guitar"):
"""Toggle or set bypass state for the given channel."""
_pm, pl, _nam, _ir = self._channel_deps(channel)
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",
"channel": channel,
"bypass": pl.bypassed,
})
return {"ok": True, "bypass": pl._bypassed}
@app.post("/api/tuner")
async def toggle_tuner(data: Optional[dict] = None, channel: str = "guitar"):
"""Toggle tuner mode for the given channel."""
_pm, pl, _nam, _ir = self._channel_deps(channel)
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",
"channel": channel,
"tuner_enabled": pl._tuner_enabled,
})
return {"ok": True, "tuner_enabled": pl._tuner_enabled}
@app.get("/api/tuner/pitch")
async def get_tuner_pitch(channel: str = "guitar"):
"""Get current tuner pitch detection data for the given channel (fast poll endpoint)."""
_pm, pl, _nam, _ir = self._channel_deps(channel)
if not pl:
return {
"frequency": 0.0, "note": "--", "cents": 0,
"string": -1, "confidence": 0.0, "enabled": False,
}
return {
"frequency": getattr(pl, '_tuner_frequency', 0.0),
"note": getattr(pl, '_tuner_note', '--'),
"cents": getattr(pl, '_tuner_cents', 0),
"string": getattr(pl, '_tuner_string', -1),
"confidence": getattr(pl, '_tuner_confidence', 0.0),
"enabled": pl._tuner_enabled,
}
@app.put("/api/volume")
async def set_volume(data: dict, channel: str = "guitar"):
"""Set master volume (0.0-1.0) for the given channel."""
_pm, pl, _nam, _ir = self._channel_deps(channel)
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
import asyncio
asyncio.ensure_future(self._manager.broadcast({
"type": "volume_changed",
"channel": channel,
"volume": vol,
}))
return {"ok": True, "volume": vol, "channel": channel}
# ── POST alias for volume (UI uses POST, server had PUT) ──
@app.post("/api/volume")
async def set_volume_post(data: dict, channel: str = "guitar"):
"""Set master volume — POST alias for PUT endpoint."""
_pm, pl, _nam, _ir = self._channel_deps(channel)
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
import asyncio
asyncio.ensure_future(self._manager.broadcast({
"type": "volume_changed",
"channel": channel,
"volume": vol,
}))
return {"ok": True, "volume": vol, "channel": channel}
# ── Toggle bypass alias (UI calls /api/bypass/toggle) ─────
@app.post("/api/bypass/toggle")
async def toggle_bypass_alias(data: Optional[dict] = None, channel: str = "guitar"):
"""Toggle or set bypass state — alias for /api/bypass."""
_pm, pl, _nam, _ir = self._channel_deps(channel)
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",
"channel": channel,
"bypass": pl.bypassed,
})
return {"ok": True, "bypass": pl._bypassed}
# ── Block toggle (UI: PATCH /api/blocks {id, enabled}) ──
@app.patch("/api/blocks")
async def toggle_block(data: dict, channel: str = "guitar"):
"""Enable/disable a block by block_id or fx_type id — sets both enabled and bypass."""
from ..presets.types import Channel
pm, pl, nam, ir = self._channel_deps(channel)
if not pm:
raise HTTPException(status_code=503)
# Support both block_id (preferred) and id (fx_type, backward compat)
block_id = data.get("block_id") or data.get("id")
enabled = data.get("enabled")
if not block_id or enabled is None:
raise HTTPException(status_code=400, detail="Missing 'block_id'/'id' or 'enabled'")
try:
bank = pm.current_bank
program = pm.current_program
preset = pm.load(bank, program, channel=Channel(channel))
for b in preset.chain:
if b.block_id == block_id or (not data.get("block_id") and b.fx_type.value == block_id):
b.enabled = bool(enabled)
b.bypass = not bool(enabled) # sync bypass with enabled
pm.save(preset, channel=Channel(channel))
# Reload pipeline if needed
if pl:
pl.load_preset(preset)
await self._manager.broadcast({
"type": "block_toggled",
"channel": channel,
"id": block_id,
"enabled": b.enabled,
"bypass": b.bypass,
})
return {"ok": True, "id": block_id, "enabled": b.enabled, "bypass": b.bypass}
raise HTTPException(status_code=404, detail=f"Block '{block_id}' not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Block param update (UI: PATCH /api/block {id, ...params}) ──
@app.patch("/api/block")
async def update_block_params(data: dict, channel: str = "guitar"):
"""Update parameters for a block by block_id or fx_type id."""
from ..presets.types import Channel, FXBlock
pm, pl, nam, ir = self._channel_deps(channel)
if not pm:
raise HTTPException(status_code=503)
# Support both block_id (preferred) and id (fx_type, backward compat)
block_id = data.get("block_id") or data.get("id")
if not block_id:
raise HTTPException(status_code=400, detail="Missing 'id'")
try:
bank = pm.current_bank
program = pm.current_program
preset = pm.load(bank, program, channel=Channel(channel))
for b in preset.chain:
if b.block_id == block_id or (not data.get("block_id") and b.fx_type.value == block_id):
# Update params from request body (skip 'id' key)
for key, val in data.items():
if key == "id":
continue
if key in ("nam_model_path", "ir_file_path", "bypass", "enabled", "subtype"):
if key == "bypass":
b.bypass = bool(val)
b.enabled = not b.bypass # sync: bypass=true → enabled=false
elif key == "enabled":
b.enabled = bool(val)
b.bypass = not b.enabled # sync: enabled=false → bypass=true
elif key == "subtype":
b.subtype = str(val)
else:
setattr(b, key, str(val))
else:
# Try setting as param even if not in defaults
b.params[key] = float(val)
pm.save(preset, channel=Channel(channel))
if pl:
pl.load_preset(preset)
await self._manager.broadcast({
"type": "block_params_changed",
"channel": channel,
"id": block_id,
"params": dict(b.params),
"bypass": b.bypass,
"enabled": b.enabled,
})
return {"ok": True, "id": block_id, "params": dict(b.params)}
raise HTTPException(status_code=404, detail=f"Block '{block_id}' not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── 4CM routing endpoints ────────────────────────────────
@app.get("/api/routing")
async def get_routing(channel: str = "guitar"):
"""Get current routing mode and breakpoint for the given channel."""
_pm, pl, _nam, _ir = self._channel_deps(channel)
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, channel: str = "guitar"):
"""Set routing mode and/or breakpoint for the given channel.
Body:
routing_mode (optional): ``\"mono\"`` or ``\"4cm\"``.
routing_breakpoint (optional): chain index for split.
"""
_pm, pl, _nam, _ir = self._channel_deps(channel)
pm, _pl2, _nam2, _ir2 = self._channel_deps(channel)
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",
"channel": channel,
"routing_mode": pl.routing_mode,
"routing_breakpoint": pl.routing_breakpoint,
})
return {"ok": True, "routing_mode": pl.routing_mode,
"routing_breakpoint": pl.routing_breakpoint}
# ── Channel mode endpoints ───────────────────────────────
@app.get("/api/channel-mode")
async def get_channel_mode():
"""Get the current channel routing mode."""
return {"channel_mode": self._channel_mgr.mode}
@app.post("/api/channel-mode")
async def set_channel_mode(data: dict):
"""Set channel routing mode.
Body: { "channel_mode": "dual-mono" | "stereo" }
"""
mode = data.get("channel_mode", "")
if mode not in ("dual-mono", "stereo"):
raise HTTPException(
status_code=400,
detail=f"Invalid channel_mode: {mode!r}. Must be 'dual-mono' or 'stereo'.",
)
was = self._channel_mgr.mode
self._channel_mgr.set_mode(mode)
if mode == "stereo" and was == "dual-mono":
self._channel_mgr.sync_to_stereo()
logger.info("Channel mode switched: %s%s", was, mode)
await self._manager.broadcast({
"type": "channel_mode_changed",
"channel_mode": mode,
})
return {"ok": True, "channel_mode": mode}
# ── Per-channel output routing ────────────────────────────
@app.get("/api/channel-output")
async def get_channel_output():
"""Get output routing for each channel."""
audio = self.deps.audio_system
if not audio or not audio.config:
return {"ch1": "both", "ch2": "both"}
cfg = audio.config.__dict__ if hasattr(audio.config, '__dict__') else {}
return {
"ch1": cfg.get("ch1_output", "both"),
"ch2": cfg.get("ch2_output", "both"),
}
@app.post("/api/channel-output")
async def set_channel_output(data: dict):
"""Set output routing for a channel.
Body: { "ch": 1, "port": "playback_1"|"playback_2"|"both" }
"""
ch = int(data.get("ch", 1))
port = data.get("port", "both")
if port not in ("playback_1", "playback_2", "both"):
raise HTTPException(status_code=400, detail=f"Invalid port: {port}")
audio = self.deps.audio_system
if audio and audio.config:
key = f"ch{ch}_output"
if hasattr(audio.config, 'input_device'):
setattr(audio.config, key, port)
# Reconnect JACK ports
audio.connect_fx_ports()
return {"ok": True, f"ch{ch}": port}
@app.get("/api/models")
async def list_models(channel: str = "guitar"):
"""List available NAM models for the given channel."""
_pm, _pl, nam, _ir = self._channel_deps(channel)
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.get("/api/nam/engine")
async def get_nam_engine(channel: str = "guitar"):
"""Get current NAM engine mode."""
_pm, _pl, nam, _ir = self._channel_deps(channel)
if not nam:
raise HTTPException(status_code=503)
mode = getattr(nam, 'engine_mode', 'cpp')
return {"engine_mode": mode}
@app.post("/api/nam/engine")
async def set_nam_engine(data: dict, channel: str = "guitar"):
"""Switch NAM engine between 'cpp' and 'pytorch'."""
_pm, _pl, nam, _ir = self._channel_deps(channel)
if not nam:
raise HTTPException(status_code=503)
mode = data.get("engine_mode", "cpp")
if mode not in ("cpp", "pytorch"):
raise HTTPException(status_code=400, detail="engine_mode must be 'cpp' or 'pytorch'")
ok = nam.set_engine(mode)
if not ok:
raise HTTPException(status_code=500, detail=f"Failed to switch to {mode} engine")
return {"ok": True, "engine_mode": mode}
@app.post("/api/models/load")
async def load_model(data: dict, channel: str = "guitar"):
"""Load a NAM model by path for the given channel."""
_pm, _pl, nam, _ir = self._channel_deps(channel)
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(channel: str = "guitar"):
"""Unload the current NAM model for the given channel."""
_pm, _pl, nam, _ir = self._channel_deps(channel)
if not nam:
raise HTTPException(status_code=503)
nam.unload()
await self._manager.broadcast({
"type": "model_unloaded",
"channel": channel,
})
return {"ok": True}
@app.get("/api/irs")
async def list_irs(channel: str = "guitar"):
"""List available IR files for the given channel."""
_pm, _pl, _nam, ir = self._channel_deps(channel)
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, channel: str = "guitar"):
"""Load an IR file by path for the given channel."""
_pm, _pl, _nam, ir = self._channel_deps(channel)
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(channel: str = "guitar"):
"""Unload the current IR for the given channel."""
_pm, _pl, _nam, ir = self._channel_deps(channel)
if not ir:
raise HTTPException(status_code=503)
ir.unload()
await self._manager.broadcast({
"type": "ir_unloaded",
"channel": channel,
})
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()}
# ── Audio latency profile endpoints ───────────────────────
@app.get("/api/audio/profiles")
async def list_audio_profiles():
"""List all available JACK latency profiles."""
return {
"profiles": [
{
"key": k,
**v,
}
for k, v in LATENCY_PROFILES.items()
],
"current": (
self.deps.audio_system.config.profile
if self.deps.audio_system
else "standard"
),
}
@app.get("/api/audio/profile")
async def get_audio_profile():
"""Get the current JACK latency profile and audio stats."""
if not self.deps.audio_system:
return {
"profile": "standard",
"period": 256,
"nperiods": 2,
"rate": 48000,
"jack_running": False,
"xrun_count": None,
}
profile_key = self.deps.audio_system.config.profile
profile = self.deps.audio_system.config.latency_profile
xruns = self.deps.audio_system.read_xrun_count()
return {
"profile": profile_key,
"period": profile["period"],
"nperiods": profile["nperiods"],
"rate": profile["rate"],
"jack_running": True,
"xrun_count": xruns,
"input_device": self.deps.audio_system.config.input_device,
"output_device": self.deps.audio_system.config.output_device,
}
@app.post("/api/audio/profile")
async def set_audio_profile(data: dict):
"""Switch JACK latency profile and/or set custom period/rate.
Body — at least one of:
{ "profile": "stable" } → switch to a named profile
{ "period": 256, "rate": 48000 } → custom settings
{ "profile": "stable", → named profile with rate override
"rate": 44100 }
Changes are saved to ~/.pedal/config.yaml so they survive reboot.
The switch restarts JACK (brief audio dropout ~1s).
"""
audio_sys = self.deps.audio_system
if not audio_sys:
raise HTTPException(status_code=503, detail="Audio system not available")
profile_key = data.get("profile")
custom_period = data.get("period")
custom_rate = data.get("rate")
# ── Resolve target profile ───────────────────────────────
if profile_key and profile_key not in LATENCY_PROFILES and profile_key != "custom":
raise HTTPException(
status_code=400,
detail=f"Unknown profile: {profile_key!r}. Options: {list(LATENCY_PROFILES.keys())}",
)
if profile_key and profile_key in LATENCY_PROFILES:
# Named profile — start from profile defaults
target_profile = dict(LATENCY_PROFILES[profile_key])
else:
# Custom — use current as base, or fall back to stable
current = audio_sys.config.latency_profile
target_profile = dict(current)
# Apply overrides
if custom_period is not None:
if custom_period not in (16, 32, 64, 128, 256, 512, 1024, 2048):
raise HTTPException(status_code=400, detail=f"Invalid period: {custom_period}. Must be power of 2 (16-2048)")
target_profile["period"] = custom_period
if custom_rate is not None:
if custom_rate not in (44100, 48000, 96000, 192000):
raise HTTPException(status_code=400, detail=f"Invalid rate: {custom_rate}. Must be 44100, 48000, 96000, or 192000")
target_profile["rate"] = custom_rate
# Build effective profile name
if profile_key and profile_key in LATENCY_PROFILES:
if custom_period is not None or custom_rate is not None:
effective_key = f"{profile_key}_custom"
else:
effective_key = profile_key
else:
effective_key = "custom"
old_key = audio_sys.config.profile
# Check if anything actually changed
cur_profile = audio_sys.config.latency_profile
if (audio_sys.config.profile == effective_key
and cur_profile["period"] == target_profile["period"]
and cur_profile["rate"] == target_profile["rate"]):
return {"ok": True, "profile": effective_key, "restarted": False}
# ── Apply new profile on the fly ─────────────────────────
# Update AudioConfig — store named overrides inline in profile key
audio_sys.config.profile = effective_key
logger.info(
"Switching audio: %s%s (period=%d, rate=%d)",
old_key, effective_key,
target_profile["period"], target_profile["rate"],
)
jack_client = self.deps.jack_audio
# Update LATENCY_PROFILES with custom entry so latency_profile resolves it
LATENCY_PROFILES[effective_key] = target_profile
# ── Kill JACK server FIRST (breaks any client deadlock) ──
# jack.Client.deactivate() is a synchronous JACK protocol call
# that blocks until the RT callback thread acknowledges. If the
# RT thread is stuck (ALSA reconfiguring, xruns), deactivate()
# hangs permanently. Kill the server first to force-kill the
# RT thread, then clean up the client.
# See also: JackAudioClient.stop() which calls deactivate().
try:
audio_sys.stop_jack()
except Exception:
logger.warning("stop_jack() failed during profile switch")
if jack_client:
try:
jack_client.stop()
except Exception:
logger.warning("jack_client.stop() failed (expected if JACK was mid-callback)")
# Start JACK with new parameters
# Disable auto_connect temporarily — fx_in/fx_out ports don't
# exist until jack_client.start() recreates the JACK client below
saved_auto_connect = audio_sys.config.auto_connect
audio_sys.config.auto_connect = False
ok = audio_sys.start_jack(timeout=15)
audio_sys.config.auto_connect = saved_auto_connect
if not ok:
# Rollback on failure
audio_sys.config.profile = old_key
logger.warning("JACK restart failed — attempting rollback to %s", old_key)
audio_sys.start_jack(timeout=15)
if jack_client:
try:
jack_client.start()
except Exception:
pass
raise HTTPException(
status_code=500,
detail=f"Failed to start JACK with period={target_profile['period']}, rate={target_profile['rate']} — rolled back",
)
# Sync NAM engine block size BEFORE restarting JACK client
# so the NAM subprocess is already sized for the new period
# when the process callback starts firing.
nam_host = self.deps.nam_host
if nam_host and hasattr(nam_host, 'set_block_size'):
nam_host.set_block_size(target_profile["period"])
# Restart JACK audio client
if jack_client:
try:
jack_client.start()
except Exception as e:
logger.warning("jack_client.start() failed after profile switch: %s", e)
# Reconnect FX ports now that pipeline's JACK client is alive
if saved_auto_connect:
try:
audio_sys.connect_fx_ports()
except Exception as e:
logger.warning("connect_fx_ports() failed after profile switch: %s", e)
# ── Persist to config.yaml ──────────────────────────────
if self.deps.config is not None:
audio_cfg = self.deps.config.setdefault("audio", {})
audio_cfg["profile"] = effective_key
# Store custom period/rate in config too
audio_cfg["period"] = target_profile["period"]
audio_cfg["rate"] = target_profile["rate"]
save_config(self.deps.config, self.deps.config_path)
await self._manager.broadcast({
"type": "audio_profile_changed",
"profile": effective_key,
"period": target_profile["period"],
"rate": target_profile["rate"],
})
return {
"ok": True,
"profile": effective_key,
"period": target_profile["period"],
"rate": target_profile["rate"],
"restarted": True,
}
# ── Generic settings persistence ──────────────────────────
KEY_MAP = {
"input_device": ("audio", "input_device"),
"output_device": ("audio", "output_device"),
"channel_mode": ("audio", "mode"),
"input_pad": ("audio", "input_pad"),
"input_impedance": ("audio", "input_impedance"),
"midi_channel": ("midi", "channel"),
"midi_clock": ("midi", "clock"),
"midi_thru": ("midi", "thru"),
"auto_dim": ("display", "auto_dim"),
"screen_saver": ("display", "screen_saver"),
"tap_tempo": ("tempo", "tap_tempo"),
"tempo_division": ("tempo", "division"),
"tap_tempo_mute": ("tempo", "mute_on_tap"),
}
@app.get("/api/settings")
async def get_settings():
"""Return the full config dict (read-only snapshot)."""
if self.deps.config is None:
raise HTTPException(status_code=503, detail="Config not available")
return self.deps.config
@app.post("/api/settings")
async def save_settings(data: dict):
"""Save arbitrary settings to config.yaml.
Accepts a dict of key-value pairs. Each key is mapped to
the correct ``section.key`` in ``~/.pedal/config.yaml`` and
persisted immediately.
Special handling:
``brightness`` — UI sends 110, stored as 0.11.0 float.
"""
if self.deps.config is None:
raise HTTPException(status_code=503, detail="Config not available")
cfg = self.deps.config
n_saved = 0
for key, val in data.items():
if key == "brightness":
leds = cfg.setdefault("leds", {})
if isinstance(val, (int, float)) and val >= 1:
leds["brightness"] = val / 10.0
else:
leds["brightness"] = val
n_saved += 1
elif key in KEY_MAP:
section, skey = KEY_MAP[key]
cfg.setdefault(section, {})[skey] = val
n_saved += 1
save_config(cfg, self.deps.config_path)
logger.info("Settings saved: %d keys from %s", n_saved, data)
# ── Runtime apply for live-updatable settings ──────────
if "brightness" in data and self.deps.leds is not None:
try:
bv = data["brightness"]
if isinstance(bv, (int, float)) and bv >= 1:
bv = bv / 10.0
self.deps.leds.set_brightness(float(bv))
except Exception as e:
logger.warning("Failed to apply brightness: %s", e)
if "channel_mode" in data and self.deps.pipeline is not None:
try:
mode = str(data["channel_mode"])
self.deps.pipeline.routing_mode = "4cm" if mode == "stereo_4cm" else "mono"
logger.info("Pipeline routing_mode set to %s", self.deps.pipeline.routing_mode)
except Exception as e:
logger.warning("Failed to apply channel_mode: %s", e)
# ── JACK device hot-swap for input_device / output_device ──
if "input_device" in data or "output_device" in data:
try:
audio_sys = self.deps.audio_system
jack_client = self.deps.jack_audio
# Kill JACK server FIRST to avoid deadlock on
# jack.Client.deactivate() (same pattern as profile switch)
if "input_device" in data:
audio_sys.config.input_device = str(data["input_device"])
if "output_device" in data:
audio_sys.config.output_device = str(data["output_device"])
audio_sys.stop_jack()
if jack_client:
jack_client.stop()
ok = audio_sys.start_jack(timeout=10)
if not ok:
logger.warning("JACK restart failed after device change — rolling back")
# Restore old devices is complex, just log the failure
if jack_client:
jack_client.start()
logger.info("JACK restarted for device change: input=%s output=%s",
audio_sys.config.input_device, audio_sys.config.output_device)
except Exception as e:
logger.warning("Failed to apply device change: %s", e)
# ── MIDI channel filter ──
if "midi_channel" in data and self.deps.midi is not None:
try:
val = data["midi_channel"]
if val is None or str(val).lower() == "omni":
self.deps.midi.set_channel_filter(None)
logger.info("MIDI channel filter set to omni")
else:
ch = int(val) - 1 # UI sends "1"-"16", store as 0-15
self.deps.midi.set_channel_filter(ch)
logger.info("MIDI channel filter set to %d", ch)
except Exception as e:
logger.warning("Failed to apply midi_channel: %s", e)
# ── Display settings ──
if "auto_dim" in data and self.deps.display is not None:
try:
self.deps.display.set_auto_dim(bool(data["auto_dim"]))
except Exception as e:
logger.warning("Failed to apply auto_dim: %s", e)
if "screen_saver" in data and self.deps.display is not None:
try:
self.deps.display.set_screen_saver(str(data["screen_saver"]))
except Exception as e:
logger.warning("Failed to apply screen_saver: %s", e)
# ── Tempo settings ──
if "tap_tempo" in data and self.deps.pipeline is not None:
try:
self.deps.pipeline.set_tempo(float(data["tap_tempo"]))
except Exception as e:
logger.warning("Failed to apply tap_tempo: %s", e)
if "tempo_division" in data:
logger.info("tempo_division change noted (boot-only): %s", data["tempo_division"])
if "tap_tempo_mute" in data:
logger.info("tap_tempo_mute change noted (boot-only): %s", data["tap_tempo_mute"])
return {"ok": True, "saved": n_saved}
# ── 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)
# Track per-connection channel preference
_ws_channel: str = "guitar"
try:
# Send initial state snapshot on connect (combined view)
state = self._gather_state_combined()
await ws.send_text(json.dumps({
"type": "connected",
"state": state,
"channels": list(VALID_CHANNELS),
}))
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":
ch = msg.get("channel", _ws_channel)
_ws_channel = _resolve_channel(ch)
state = self._gather_state(channel=_ws_channel)
await ws.send_text(json.dumps({
"type": "state",
"channel": _ws_channel,
"state": state,
}))
elif msg_type == "set_channel":
_ws_channel = _resolve_channel(msg.get("channel", "guitar"))
await ws.send_text(json.dumps({
"type": "channel_set",
"channel": _ws_channel,
}))
except WebSocketDisconnect:
pass
except Exception as e:
logger.warning("WebSocket error: %s", e)
finally:
self._manager.disconnect(ws)
logger.info("WebSocket client disconnected (%d active)", self._manager.active_count)
# ── Tone3000 downloader endpoints ───────────────────────
@app.get("/api/tonedownload/status")
async def tonedownload_status():
"""Check if the Tone3000 API is reachable."""
client = await self._get_tonedownload()
if not client:
return {"online": False, "error": "Client not initialized"}
ok = await client.ping()
return {"online": ok}
@app.get("/api/models/tonedownload/search")
async def tonedownload_search_models(q: str = "", page: int = 0, arch: str = "", gear: str = "", sort: str = "created_at.desc"):
"""Search Tone3000 for NAM models."""
try:
client = await self._get_tonedownload()
if not client:
return JSONResponse(
status_code=200,
content={
"results": [],
"status": "offline",
"message": "Tone3000 unavailable - using local defaults",
},
)
arch_val = arch.strip() or None
gear_val = gear.strip() or None
if not q.strip():
results = await client.get_trending_models(arch=arch_val, gear=gear_val)
else:
results = await client.search_models(q.strip(), page=page, arch=arch_val, gear=gear_val, sort=sort)
return {
"results": [
{
"id": r.id,
"name": r.display_name,
"filename": r.filename,
"size": r.size_bytes,
"size_display": format_size(r.size_bytes),
"author": r.author_username or "Unknown",
"author_avatar": r.author_avatar_url,
"thumbnail": r.thumbnail_url,
"download_url": r.download_url,
"tone_description": r.tone_description,
"tone_gear": r.tone_gear,
"pack_name": r.pack_name,
"architecture": r.architecture,
"created_at": r.created_at,
"downloads": r.downloads,
"likes": r.likes,
}
for r in results
],
"page": page,
}
except Exception as e:
logger.warning("Tone3000 model search failed: %s", e)
return JSONResponse(
status_code=200,
content={
"results": [],
"status": "offline",
"message": "Tone3000 unavailable - using local defaults",
},
)
@app.get("/api/irs/tonedownload/search")
async def tonedownload_search_irs(q: str = "", page: int = 0):
"""Search Tone3000 for IRs."""
try:
client = await self._get_tonedownload()
if not client:
return JSONResponse(
status_code=200,
content={
"results": [],
"status": "offline",
"message": "Tone3000 unavailable - using local defaults",
},
)
if not q.strip():
results = await client.get_trending_irs()
else:
results = await client.search_irs(q.strip(), page=page)
return {
"results": [
{
"id": r.id,
"name": r.display_name,
"filename": r.filename,
"size": r.size_bytes,
"size_display": format_size(r.size_bytes),
"author": r.author_username or "Unknown",
"author_avatar": r.author_avatar_url,
"thumbnail": r.thumbnail_url,
"download_url": r.download_url,
"tone_description": r.tone_description,
"created_at": r.created_at,
}
for r in results
],
}
except Exception as e:
logger.warning("Tone3000 IR search failed: %s", e)
return JSONResponse(
status_code=200,
content={
"results": [],
"status": "offline",
"message": "Tone3000 unavailable - using local defaults",
},
)
# ── Audio capture (looper/recorder) ──────────────────────
_capture_process = None
_capture_path = None
@app.post("/api/capture/start")
async def capture_start():
"""Start recording audio to a WAV file via arecord."""
nonlocal _capture_process, _capture_path
import subprocess, datetime
captures_dir = Path.home() / ".pedal" / "captures"
captures_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
path = captures_dir / f"capture_{ts}.wav"
try:
_capture_process = subprocess.Popen(
["arecord", "-f", "S16_LE", "-r", "48000", "-c", "2", str(path)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
_capture_path = path
return {"ok": True, "path": str(path), "filename": path.name}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/capture/stop")
async def capture_stop():
"""Stop the current recording."""
nonlocal _capture_process, _capture_path
if _capture_process is None:
raise HTTPException(status_code=400, detail="No active recording")
try:
_capture_process.terminate()
_capture_process.wait(timeout=5)
except Exception:
_capture_process.kill()
name = _capture_path.name if _capture_path else "unknown"
_capture_process = None
_capture_path = None
return {"ok": True, "filename": name}
@app.get("/api/captures")
async def list_captures():
"""List saved captures."""
captures_dir = Path.home() / ".pedal" / "captures"
if not captures_dir.is_dir():
return {"captures": []}
files = sorted(captures_dir.glob("*.wav"), key=lambda f: f.stat().st_mtime, reverse=True)
return {
"captures": [
{
"name": f.name,
"path": str(f),
"size": f.stat().st_size,
"size_display": f"{f.stat().st_size / 1024:.0f} KB" if f.stat().st_size < 1024*1024 else f"{f.stat().st_size / (1024*1024):.1f} MB",
"created": datetime.datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
}
for f in files
]
}
@app.get("/api/captures/{filename}")
async def serve_capture(filename: str):
"""Serve a captured WAV file for download/playback."""
from fastapi.responses import FileResponse
captures_dir = Path.home() / ".pedal" / "captures"
file_path = captures_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="Capture not found")
return FileResponse(str(file_path), media_type="audio/wav", filename=filename)
# ── Tonehub API alias routes ─────────────────────────────
# These are direct aliases for Tone3000 search, providing a
# simpler /api/tonehub/search path for the web UI.
@app.get("/api/tonehub/search")
async def tonehub_search_models(q: str = "", page: int = 0):
"""Alias for /api/models/tonedownload/search — search NAM models."""
return await tonedownload_search_models(q=q, page=page)
@app.get("/api/tonehub/search/irs")
async def tonehub_search_irs(q: str = "", page: int = 0):
"""Alias for /api/irs/tonedownload/search — search IRs."""
return await tonedownload_search_irs(q=q, page=page)
@app.post("/api/models/tonedownload/install")
async def tonedownload_install_model(data: dict):
"""Download a .nam file to the pedal's models directory."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
url = data.get("download_url", "")
name = data.get("name", "model.nam")
if not url:
raise HTTPException(status_code=400, detail="download_url required")
model = ModelResult(
id=data.get("id", 0),
name=name,
download_url=url,
size_bytes=data.get("size"),
pack_name=data.get("pack_name"),
tone_title=data.get("display_name", name),
)
path = await client.download_model(model)
if not path:
raise HTTPException(status_code=500, detail="Download failed")
# Save metadata alongside the model file
import json
meta = {
"id": data.get("id"),
"name": data.get("name"),
"filename": data.get("filename"),
"size_bytes": data.get("size"),
"author": data.get("author"),
"author_avatar": data.get("author_avatar"),
"thumbnail": data.get("thumbnail"),
"tone_description": data.get("tone_description"),
"pack_name": data.get("pack_name"),
"architecture": data.get("architecture"),
"created_at": data.get("created_at"),
"download_date": datetime.datetime.now().isoformat(),
"source": "tone3000",
}
meta_path = path.with_suffix(".meta.json")
meta_path.write_text(json.dumps(meta, indent=2))
# Refresh the model list via NAM host
nam = self.deps.nam_host
if nam:
nam.list_available_models()
return {"ok": True, "path": str(path), "filename": path.name}
@app.post("/api/irs/tonedownload/install")
async def tonedownload_install_ir(data: dict):
"""Download a .wav IR to the pedal's IRs directory."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
url = data.get("download_url", "")
name = data.get("name", "ir.wav")
if not url:
raise HTTPException(status_code=400, detail="download_url required")
ir = IRResult(
id=0,
name=name,
download_url=url,
size_bytes=data.get("size"),
tone_title=data.get("display_name", name),
)
path = await client.download_ir(ir)
if not path:
raise HTTPException(status_code=500, detail="Download failed")
# Save IR metadata
import json
meta = {
"id": data.get("id"),
"name": data.get("name"),
"filename": data.get("filename"),
"size_bytes": data.get("size"),
"author": data.get("author"),
"author_avatar": data.get("author_avatar"),
"thumbnail": data.get("thumbnail"),
"tone_description": data.get("tone_description"),
"created_at": data.get("created_at"),
"download_date": datetime.datetime.now().isoformat(),
"source": "tone3000",
}
meta_path = path.with_suffix(".meta.json")
meta_path.write_text(json.dumps(meta, indent=2))
ir_loader = self.deps.ir_loader
if ir_loader:
ir_loader.get_irs()
return {"ok": True, "path": str(path), "filename": path.name}
@app.get("/api/system")
async def get_system():
import os, subprocess
info = {"hostname": os.uname().nodename, "kernel": os.uname().release}
try:
with open("/proc/uptime") as f:
secs = float(f.read().split()[0])
info["uptime_seconds"] = round(secs)
d = int(secs // 86400); h = int((secs % 86400) // 3600); m = int((secs % 3600) // 60)
info["uptime"] = f"{d}d {h}h {m}m"
except: info["uptime"] = "?"
try:
r = subprocess.run(["cat","/sys/class/thermal/thermal_zone0/temp"], capture_output=True, text=True, timeout=3)
if r.returncode == 0: info["cpu_temp_c"] = round(int(r.stdout.strip()) / 1000, 1)
except: pass
try:
r = subprocess.run(["free","-m"], capture_output=True, text=True, timeout=3)
for line in r.stdout.splitlines():
p = line.split()
if p and p[0] == "Mem:":
info["memory_mb"] = {"total": int(p[1]), "used": int(p[2]), "free": int(p[3])}
except: pass
try:
r = subprocess.run(["df","-h","/"], capture_output=True, text=True, timeout=3)
parts = r.stdout.splitlines()[1].split()
info["disk"] = {"total": parts[1], "used": parts[2], "avail": parts[3], "pct": parts[4]}
except: pass
try:
with open("/proc/loadavg") as f:
info["load"] = f.read().split()[:3]
except: pass
return info
@app.get("/api/system/logs")
async def get_system_logs(lines: int = 50):
import subprocess
try:
r = subprocess.run(["journalctl","-u","pedal","--no-pager","-n",str(lines)], capture_output=True, text=True, timeout=10)
return {"logs": r.stdout.splitlines(), "exit_code": r.returncode}
except Exception as e:
return {"error": str(e), "logs": []}
@app.post("/api/system/hostname")
async def set_hostname(data: dict):
import subprocess
hostname = data.get("hostname", "").strip()
if not hostname or not hostname.replace("-","").replace("_","").isalnum():
raise HTTPException(status_code=400, detail="Invalid hostname")
try:
subprocess.run(["hostnamectl","set-hostname",hostname], check=True, timeout=10)
return {"ok": True, "hostname": hostname}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/network")
async def get_network():
import subprocess, re
info = {"ethernet": {}, "wifi": {}}
try:
r = subprocess.run(["ip","-4","addr","show","eth0"], capture_output=True, text=True, timeout=3)
m = re.search(r"inet (\S+)", r.stdout)
if m: info["ethernet"]["ip"] = m.group(1)
r2 = subprocess.run(["ip","link","show","eth0"], capture_output=True, text=True, timeout=3)
m2 = re.search(r"link/ether (\S+)", r2.stdout)
if m2: info["ethernet"]["mac"] = m2.group(1)
r3 = subprocess.run(["ip","route","show","default"], capture_output=True, text=True, timeout=3)
m3 = re.search(r"default via (\S+)", r3.stdout)
if m3: info["ethernet"]["gateway"] = m3.group(1)
except: pass
try:
with open("/etc/resolv.conf") as f:
dns = [line.split()[1] for line in f if line.startswith("nameserver")]
if dns: info["dns"] = dns
except: pass
return info
@app.post("/api/network/static")
async def set_network_static(data: dict):
import subprocess, os
ip = data.get("ip", "").strip()
gw = data.get("gateway", "").strip()
mask = data.get("netmask", "24").strip()
dns_servers = data.get("dns", "9.9.9.9 149.112.112.112")
if not ip:
raise HTTPException(status_code=400, detail="ip required")
config = f"""auto eth0
iface eth0 inet static
address {ip}/{mask}
gateway {gw}
dns-nameservers {dns_servers}
"""
try:
with open("/etc/network/interfaces.d/eth0", "w") as f:
f.write(config)
subprocess.run(["ifdown","eth0"], capture_output=True, timeout=10)
subprocess.run(["ifup","eth0"], capture_output=True, timeout=30)
return {"ok": True, "ip": ip, "mode": "static"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/network/dhcp")
async def set_network_dhcp():
import subprocess, os
try:
os.remove("/etc/network/interfaces.d/eth0")
except FileNotFoundError: pass
try:
subprocess.run(["ifdown","eth0"], capture_output=True, timeout=10)
subprocess.run(["ifup","eth0"], capture_output=True, timeout=30)
except: pass
return {"ok": True, "mode": "dhcp"}
@app.get("/api/audio/devices")
async def list_audio_devices():
import subprocess, re
try:
r = subprocess.run(["aplay","-l"], capture_output=True, text=True, timeout=5)
cards = []
for line in r.stdout.splitlines():
m = re.match(r"card (\d+):.*?\[(.*?)\].*device (\d+):.*?\[(.*?)\]", line)
if m:
cards.append({"card": int(m.group(1)), "name": m.group(2).strip(),
"device": int(m.group(3)), "desc": m.group(4).strip()})
r2 = subprocess.run(["cat","/proc/asound/cards"], capture_output=True, text=True, timeout=3)
usb_id = None
for line in r2.stdout.splitlines():
if "USB" in line or "usb" in line.lower():
m = re.match(r"\s*(\d+)", line)
if m: usb_id = int(m.group(1))
# Add software PCM devices (bluealsa, etc.) as virtual cards
try:
r3 = subprocess.run(["aplay","-L"], capture_output=True, text=True, timeout=3)
for line in r3.stdout.splitlines():
name = line.strip()
if name.startswith("bluealsa") or name.startswith("bluealsa:"):
if not any(c.get("name","") == "Bluetooth Audio" for c in cards):
cards.append({"card": -1, "name": "Bluetooth Audio",
"device": 0, "desc": "BlueALSA (BT playback)"})
except: pass
return {"devices": cards, "usb_card": usb_id, "suggested": f"hw:{usb_id or 0},0"}
except Exception as e:
return {"error": str(e), "devices": []}
# ── Backing track source ────────────────────────────────────
@app.get("/api/backing-source")
async def get_backing_source():
"""Get current backing track source and available sources."""
import subprocess
audio = self.deps.audio_system
cfg = audio.config.__dict__ if audio and hasattr(audio.config, '__dict__') else {}
current = cfg.get("backing_source", "none")
# Check available JACK ports for possible sources
sources = [{"id": "none", "name": "None"}]
try:
r = subprocess.run(["jack_lsp"], capture_output=True, text=True, timeout=3)
for line in r.stdout.splitlines():
line = line.strip()
if line.endswith(":capture_1") or line.endswith(":capture_2"):
name = line.split(":")[0]
if name != "system" and name != "pi-multifx" and name != "bt_audio":
sources.append({"id": name, "name": f"{name} (capture)"})
# Always offer bluetooth if JACK has bt_audio ports
r2 = subprocess.run(["jack_lsp","bt_audio:capture_1"], capture_output=True, text=True, timeout=2)
if r2.returncode == 0:
sources.append({"id": "bt_audio", "name": "Bluetooth Audio"})
except: pass
return {"current": current, "sources": sources}
@app.post("/api/backing-source")
async def set_backing_source(data: dict):
"""Set backing track source and route it into the FX chain.
Body: { "source": "none" | "bt_audio" | "<jack_source>" }
"""
import subprocess
source = data.get("source", "none")
audio = self.deps.audio_system
# Save to config
if audio and hasattr(audio.config, '__dict__'):
audio.config.__dict__["backing_source"] = source
# Disconnect old backing connections
try:
r = subprocess.run(["jack_lsp","fx_in:input_2"], capture_output=True, text=True, timeout=2)
if r.returncode == 0:
subprocess.run(["jack_disconnect","bt_audio:capture_1","fx_in:input_2"],
capture_output=True, timeout=2)
subprocess.run(["jack_disconnect","bt_audio:capture_2","fx_in:input_2"],
capture_output=True, timeout=2)
except: pass
# Connect new source
if source != "none":
try:
if source == "bt_audio":
subprocess.run(["jack_connect","bt_audio:capture_1","fx_in:input_2"],
capture_output=True, timeout=3)
else:
subprocess.run(["jack_connect",f"{source}:capture_1","fx_in:input_2"],
capture_output=True, timeout=3)
except: pass
return {"ok": True, "source": source}
@app.get("/api/levels")
async def get_signal_levels():
levels = {"channels": {}}
ja = self.deps.jack_audio
if ja and hasattr(ja, 'get_levels'):
try: levels = ja.get_levels()
except: pass
if not levels.get("channels"):
for ch in ["guitar", "bass", "keys", "vocals", "backing_tracks"]:
levels["channels"][ch] = {"input_rms": 0, "input_peak": 0, "output_rms": 0, "output_peak": 0}
return levels
@app.delete("/api/models/{name:path}")
async def delete_model(name: str):
import os
from pathlib import Path
models_dir = Path(self.deps.config.get("models", {}).get("dir", "/home/oplabs/projects/pi-multifx-pedal/src/models/nam"))
target = models_dir / name
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="Model not found")
if target.suffix not in (".nam", ".wav", ".onnx"):
raise HTTPException(status_code=400, detail="Not a model file")
os.remove(str(target))
nam = self.deps.nam_host
if nam: nam.list_available_models()
return {"ok": True, "deleted": name}
@app.delete("/api/irs/{name:path}")
async def delete_ir(name: str):
import os
from pathlib import Path
irs_dir = Path(self.deps.config.get("irs", {}).get("dir", "/home/oplabs/projects/pi-multifx-pedal/src/models/ir"))
target = irs_dir / name
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="IR not found")
if target.suffix != ".wav":
raise HTTPException(status_code=400, detail="Not an IR file")
os.remove(str(target))
ir_loader = self.deps.ir_loader
if ir_loader: ir_loader.get_irs()
return {"ok": True, "deleted": name}
@app.get("/api/midi/mappings")
async def get_midi_mappings():
mappings = {}
if self.deps.midi and hasattr(self.deps.midi, 'get_mappings'):
try: mappings = self.deps.midi.get_mappings()
except: pass
return {"mappings": mappings}
@app.post("/api/midi/learn")
async def midi_learn(data: dict = None):
if data and "enable" in data:
enable = bool(data["enable"])
if self.deps.midi and hasattr(self.deps.midi, 'set_learn_mode'):
try: self.deps.midi.set_learn_mode(enable)
except: pass
return {"learn_active": enable}
if data and "param" in data:
if self.deps.midi and hasattr(self.deps.midi, 'set_mapping'):
try: self.deps.midi.set_mapping(data["param"], data.get("cc"))
except: pass
return {"mapped": data["param"]}
return {"learn_active": False, "mappings": {}}
@app.post("/api/backup")
async def create_backup():
import subprocess, os, datetime
backup_dir = "/tmp/pedal-backups"
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"pedal-backup-{ts}.tar.gz"
outpath = os.path.join(backup_dir, filename)
cfg_path = os.path.expanduser("~/.pedal")
try:
subprocess.run(["tar","-czf",outpath,"-C",os.path.dirname(cfg_path),os.path.basename(cfg_path)], check=True, timeout=30)
size = os.path.getsize(outpath)
return {"ok": True, "filename": filename, "path": outpath, "size": size}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/backup/list")
async def list_backups():
import os, datetime
backup_dir = "/tmp/pedal-backups"
os.makedirs(backup_dir, exist_ok=True)
bks = []
for f in sorted(os.listdir(backup_dir), reverse=True):
fp = os.path.join(backup_dir, f)
if os.path.isfile(fp):
bks.append({"name": f, "size": os.path.getsize(fp),
"modified": datetime.datetime.fromtimestamp(os.path.getmtime(fp)).isoformat()})
return {"backups": bks}
@app.post("/api/restore")
async def restore_backup(data: dict):
import subprocess, os
filename = data.get("filename") if data else None
if not filename:
return {"error": "filename required"}
backup_path = os.path.join("/tmp/pedal-backups", filename)
if not os.path.exists(backup_path):
raise HTTPException(status_code=404, detail="Backup not found")
try:
subprocess.run(["tar","-xzf",backup_path,"-C",os.path.expanduser("~")], check=True, timeout=30)
return {"ok": True, "restored": filename}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/diagnostics")
async def get_diagnostics():
import subprocess
diag = {}
ja = self.deps.jack_audio
diag["jack"] = {"running": ja is not None and hasattr(ja, 'is_running') and ja.is_running()}
nam = self.deps.nam_host
diag["nam"] = {"loaded": nam is not None and nam.current_model is not None}
if diag["nam"]["loaded"]: diag["nam"]["model"] = str(nam.current_model)
midi = self.deps.midi
diag["midi"] = {"available": midi is not None}
try:
r = subprocess.run(["iwgetid"], capture_output=True, text=True, timeout=3)
diag["wifi"] = {"connected": r.returncode == 0, "ssid": r.stdout.strip() if r.returncode == 0 else None}
except: diag["wifi"] = {"connected": False}
bt = getattr(self.deps, 'bt_controller', None)
diag["bluetooth"] = {"available": bt is not None}
if self.deps.audio_system:
try:
ap = self.deps.audio_system.config
diag["audio"] = {"profile": getattr(ap, 'profile', '?'), "rate": getattr(ap, 'rate', '?')}
except: diag["audio"] = {"profile": "?"}
diag["api"] = {"status": "ok"}
return diag
# ── Channel-prefixed route aliases ──────────────────────
# Every state/control endpoint that accepts ?channel= is also
# available at /api/channel/{channel}/... for explicit routing.
_ch_base = "/api/channel/{channel}"
# State
app.add_api_route(f"{_ch_base}/state", get_state, methods=["GET"])
# Presets
app.add_api_route(f"{_ch_base}/presets", list_presets, methods=["GET"])
app.add_api_route(f"{_ch_base}/presets/{{bank}}/{{program}}", get_preset, methods=["GET"]) # noqa: F821
app.add_api_route(f"{_ch_base}/presets/{{bank}}/{{program}}/activate", activate_preset, methods=["POST"]) # noqa: F821
app.add_api_route(f"{_ch_base}/presets/{{bank}}/{{program}}", save_preset, methods=["PUT"]) # noqa: F821
app.add_api_route(f"{_ch_base}/presets/{{bank}}/{{program}}", delete_preset, methods=["DELETE"]) # noqa: F821
# Snapshots
app.add_api_route(f"{_ch_base}/snapshots", list_snapshots, methods=["GET"])
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}", get_snapshot, methods=["GET"])
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}/save", save_snapshot, methods=["POST"])
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}/recall", recall_snapshot, methods=["POST"])
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}", update_snapshot, methods=["PUT"])
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}", delete_snapshot, methods=["DELETE"])
# Controller
app.add_api_route(f"{_ch_base}/bypass", toggle_bypass, methods=["POST"])
app.add_api_route(f"{_ch_base}/tuner", toggle_tuner, methods=["POST"])
app.add_api_route(f"{_ch_base}/tuner/pitch", get_tuner_pitch, methods=["GET"])
app.add_api_route(f"{_ch_base}/volume", set_volume, methods=["PUT"])
app.add_api_route(f"{_ch_base}/routing", get_routing, methods=["GET"])
app.add_api_route(f"{_ch_base}/routing", set_routing, methods=["POST"])
# Models & IRs
app.add_api_route(f"{_ch_base}/models", list_models, methods=["GET"])
app.add_api_route(f"{_ch_base}/models/load", load_model, methods=["POST"])
app.add_api_route(f"{_ch_base}/models/unload", unload_model, methods=["POST"])
app.add_api_route(f"{_ch_base}/irs", list_irs, methods=["GET"])
app.add_api_route(f"{_ch_base}/irs/load", load_ir, methods=["POST"])
app.add_api_route(f"{_ch_base}/irs/unload", unload_ir, methods=["POST"])
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}
# ── System state gathering ────────────────────────────────────
def _gather_system_stats(self) -> dict:
"""Gather system stats (CPU, audio config) without hard-failing."""
try:
import psutil
return {
"cpu_percent": psutil.cpu_percent(interval=None),
"sample_rate": SAMPLE_RATE if hasattr(self, 'deps') and self.deps.pipeline else 48000,
}
except Exception:
return {"cpu_percent": 0, "sample_rate": 48000}
# ── State gathering ──────────────────────────────────────────────
def _gather_channel_state(self, channel: str) -> dict[str, Any]:
"""Collect a snapshot for a single channel.
Returns the full state dict for the requested channel. When the
channel's preset manager is not connected the dict has a flat
``connected: False`` marker.
"""
pm, pl, nam, ir = self._channel_deps(channel)
if not pm:
return {
"connected": False,
"current_preset": None,
"bypass": False,
"tuner_enabled": False,
"tuner_frequency": 0.0,
"tuner_note": "--",
"tuner_cents": 0,
"tuner_string": -1,
"tuner_confidence": 0.0,
"master_volume": 0.8,
"routing_mode": "mono",
"routing_breakpoint": 7,
"nam_loaded": False,
"nam_model": None,
"ir_loaded": False,
"ir_name": None,
"input_level": 0,
"output_level": 0,
"cpu_percent": 0,
"sample_rate": 48000,
"wifi": {},
"bluetooth": {},
}
try:
bank = pm.current_bank
program = pm.current_program
preset = pm.load(bank, program) if pm else None
except Exception:
preset = None
def _safe_str(val) -> str | None:
"""Get a string representation, or None if the value is falsy/mock."""
if val is None:
return None
try:
s = str(val)
return s if s and not s.startswith("<MagicMock") else None
except Exception:
return None
current_model_obj = nam.current_model if nam else None
current_ir_obj = ir.current_ir if ir else None
current_model_name = _safe_str(current_model_obj.name if current_model_obj else None)
current_ir_name = _safe_str(current_ir_obj.name if current_ir_obj else None)
return {
"channel": channel,
"connected": True,
"current_preset": {
"name": preset.name if preset else "",
"bank": preset.bank if preset else 0,
"program": preset.program if preset else 0,
} if preset else None,
"bypass": pl._bypassed if pl else False,
"tuner_enabled": pl._tuner_enabled if pl else False,
"tuner_frequency": getattr(pl, '_tuner_frequency', 0.0),
"tuner_note": getattr(pl, '_tuner_note', '--'),
"tuner_cents": getattr(pl, '_tuner_cents', 0),
"tuner_string": getattr(pl, '_tuner_string', -1),
"tuner_confidence": getattr(pl, '_tuner_confidence', 0.0),
"master_volume": pl._master_volume if pl else 0.8,
"routing_mode": pl.routing_mode if pl else "mono",
"routing_breakpoint": pl.routing_breakpoint if pl else 7,
"nam_loaded": bool(nam and nam.is_loaded) if nam else False,
"nam_model": current_model_name,
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
"ir_name": current_ir_name,
"blocks": (
[
{
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
"nam_model_path": b.nam_model_path,
"nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "",
"ir_file_path": b.ir_file_path,
"ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "",
"name": (
Path(b.nam_model_path).stem.replace("_", " ")
if b.nam_model_path
else Path(b.ir_file_path).stem.replace("_", " ")
if b.ir_file_path
else b.fx_type.value.replace("_", " ").title()
),
}
for b in (preset.chain if preset else [])
]
if preset
else []
),
"channel_mode": self._channel_mgr.mode,
# Audio levels from pipeline (RMS float32 normalized 0.0-1.0, scaled to 0-100)
"input_level": (
0
if pl is None or getattr(pl, '_input_level', None) is None
else max(0, min(100, round(0 if (v := float(getattr(pl, '_input_level', 0.0))) != v else v * 150)))
),
"output_level": (
0
if pl is None or getattr(pl, '_output_level', None) is None
else max(0, min(100, round(0 if (v := float(getattr(pl, '_output_level', 0.0))) != v else v * 150)))
),
# System stats
"cpu_percent": self._gather_system_stats().get("cpu_percent", 0),
"sample_rate": SAMPLE_RATE,
"wifi": self._gather_wifi_state(),
"bluetooth": self._gather_bt_state(),
"current_snapshot": getattr(pm, '_current_snapshot', 0) if pm else 0,
}
def _gather_state(self, channel: Optional[str] = None) -> dict[str, Any]:
"""Collect pedal state snapshot.
By default (``channel=None``) returns the guitar channel's flat
state — this preserves backward compatibility for existing clients
that expect ``connected``, ``current_preset``, etc. at the top
level.
Pass ``channel=\"guitar\"`` or ``channel=\"bass\"`` for explicit
per-channel state.
"""
ch = channel if channel is not None else "guitar"
return self._gather_channel_state(ch)
def _gather_state_combined(self) -> dict[str, Any]:
"""Return a combined view with all channel states.
Shape::
{
"channels": {
"guitar": { … },
"bass": { … },
"keys": { … },
"vocals": { … },
"backing_tracks": { … },
},
"active_channel": "guitar",
"channel_mode": "dual-mono",
"wifi": { … },
"bluetooth": { … },
"cpu_percent": …,
}
"""
guitar = self._gather_channel_state("guitar")
bass = self._gather_channel_state("bass")
keys = self._gather_channel_state("keys")
vocals = self._gather_channel_state("vocals")
backing = self._gather_channel_state("backing_tracks")
return {
"channels": {
"guitar": guitar,
"bass": bass,
"keys": keys,
"vocals": vocals,
"backing_tracks": backing,
},
"active_channel": "guitar",
"channel_mode": self._channel_mgr.mode,
"wifi": guitar.get("wifi", {}),
"bluetooth": guitar.get("bluetooth", {}),
"cpu_percent": max(
guitar.get("cpu_percent", 0),
bass.get("cpu_percent", 0),
keys.get("cpu_percent", 0),
vocals.get("cpu_percent", 0),
backing.get("cpu_percent", 0),
),
"sample_rate": SAMPLE_RATE,
}
# ── Tone3000 client lazy init ────────────────────────────────
async def _get_tonedownload(self) -> Optional[Tone3000Client]:
"""Lazily initialize the Tone3000 download client."""
if self._tonedownload is None:
from aiohttp import ClientSession
async with ClientSession() as session:
key = await discover_anon_key(session)
if not key:
# Fall back to hardcoded key
from ..system.tonedownload import SUPABASE_ANON_KEY
key = SUPABASE_ANON_KEY
self._tonedownload = Tone3000Client(anon_key=key)
return self._tonedownload
# ── Lifecycle ────────────────────────────────────────────────────
def start(self) -> None:
"""Start the web server in a background thread.
Uses a dedicated thread with its own asyncio event loop to
avoid conflicting with the main thread's blocking JACK loop.
"""
import threading
config = uvicorn.Config(
self._app,
host=self.host,
port=self.port,
log_level="info",
access_log=False,
)
def _run():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server = uvicorn.Server(config)
self._server = server
loop.run_until_complete(server.serve())
self._thread = threading.Thread(target=_run, daemon=True)
self._thread.start()
logger.info("Web server starting on http://%s:%d", self.host, self.port)
def stop(self) -> None:
"""Stop the web server gracefully."""
if self._server:
self._server.should_exit = True
logger.info("Web server stopped")
# ── FX parameter schemas ─────────────────────────────────────────────────────
# Used by the web UI to render appropriate controls per FX block type.
_FX_PARAM_SCHEMAS: dict[str, list[dict]] = {
"noise_gate": [
{"key": "threshold", "name": "Threshold", "type": "float", "min": 0.0, "max": 0.1, "step": 0.001, "default": 0.01},
{"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0},
],
"compressor": [
{"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -20.0},
{"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 3.0},
{"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 5.0},
{"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0},
{"key": "gain", "name": "Makeup Gain", "type": "float", "min": 0.0, "max": 5.0, "step": 0.1, "default": 1.0},
],
"boost": [
{"key": "gain_db", "name": "Gain (dB)", "type": "float", "min": 0.0, "max": 30.0, "step": 0.5, "default": 6.0},
],
"overdrive": [
{"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
{"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
{"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0},
],
"distortion": [
{"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7},
{"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
{"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0},
],
"fuzz": [
{"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.8},
{"key": "tone", "name": "Tone", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
{"key": "gain", "name": "Gain", "type": "float", "min": 0.0, "max": 3.0, "step": 0.1, "default": 1.0},
],
"eq": [
{"key": "bass", "name": "Bass (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
{"key": "mid", "name": "Mid (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
{"key": "treble", "name": "Treble (dB)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
{"key": "bass_freq", "name": "Bass Freq (Hz)", "type": "float", "min": 40.0, "max": 500.0, "step": 10.0, "default": 200.0},
{"key": "mid_freq", "name": "Mid Freq (Hz)", "type": "float", "min": 200.0, "max": 5000.0, "step": 10.0, "default": 1000.0},
{"key": "treble_freq", "name": "Treble Freq (Hz)", "type": "float", "min": 1000.0, "max": 10000.0, "step": 10.0, "default": 3500.0},
{"key": "q", "name": "Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
],
"chorus": [
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.1, "max": 5.0, "step": 0.1, "default": 0.5},
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
{"key": "delay", "name": "Delay (ms)", "type": "float", "min": 5.0, "max": 60.0, "step": 1.0, "default": 20.0},
{"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.25},
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
{"key": "delay", "name": "Delay (ms)", "type": "float", "min": 0.5, "max": 15.0, "step": 0.5, "default": 5.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.5},
],
"phaser": [
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.4},
{"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": "stages", "name": "Stages", "type": "int", "min": 2, "max": 12, "step": 1, "default": 4},
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
],
"tremolo": [
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 4.0},
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
{"key": "shape", "name": "Waveform", "type": "select", "options": ["sine", "triangle", "square"], "default": "sine"},
],
"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},
{"key": "tap_tempo", "name": "Tap Tempo (ms)", "type": "float", "min": 20.0, "max": 2000.0, "step": 5.0, "default": 0.0},
],
"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.5},
{"key": "damping", "name": "Damping", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
{"key": "predelay", "name": "Pre-delay (ms)", "type": "float", "min": 0.0, "max": 100.0, "step": 5.0, "default": 30.0},
{"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},
],
# ── Amp / Cabinet ──────────────────────────────────────────────
"nam_amp": [
{"key": "model_path", "name": "Model Path", "type": "string", "default": ""},
],
"ir_cab": [
{"key": "wet", "name": "Wet Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 1.0},
{"key": "dry", "name": "Dry Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.0},
],
# ── 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://%s:%d", ws.host, ws.port)
try:
loop = asyncio.get_event_loop()
loop.run_forever()
except KeyboardInterrupt:
pass
finally:
ws.stop()
if __name__ == "__main__":
main()