feat: separate preset banks per channel (guitar/bass)

- Channel enum (GUITAR, BASS) with channel field on Preset dataclass
- Channel-prefixed storage: {root}/{channel}/bank_{bank}/preset_{program}.json
- PresetManager channel awareness: set_channel(), current_channel property
- Per-channel state persistence (channel_state.json per channel dir)
- Legacy migration: flat bank_* dirs auto-migrate to guitar/ on first boot
- All CRUD methods accept optional channel parameter
- API endpoints accept channel param on GET/PUT/DELETE /api/presets
- /api/channel GET/POST for channel switching
- 10 new channel tests (independence, switching, migration, scoping)
- Factory presets install to specified channel
This commit is contained in:
2026-06-12 23:22:47 -04:00
parent 0a21297f77
commit bfe1434f75
10 changed files with 1100 additions and 352 deletions
+404 -99
View File
@@ -32,7 +32,7 @@ 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 FXBlock, FXType, Preset
from ..presets.types import Channel, FXBlock, FXType, Preset
from ..system.tonedownload import (
Tone3000Client,
ModelResult,
@@ -64,22 +64,156 @@ else:
]
UI_DIST_DIR = next((d for d in _candidates if d.is_dir()), None)
# ── Channel support ──────────────────────────────────────────────────────────────
VALID_CHANNELS: frozenset = frozenset({"guitar", "bass"})
def _resolve_channel(channel: str = "guitar") -> str:
"""Normalize a channel string; fall back to 'guitar' for invalid values."""
if isinstance(channel, str) and channel.lower() in VALID_CHANNELS:
return channel.lower()
return "guitar"
# ── 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 dual-channel vs stereo routing mode and per-channel state.
In ``dual-mono`` mode each channel (guitar / bass) has independent
runtime state. In ``stereo`` mode the two channels are linked —
the ``guitar`` channel dict is the single source of truth for both.
"""
def __init__(self) -> None:
self.mode: str = _load_channel_mode()
self._channels: dict[str, ChannelState] = {
"guitar": ChannelState(),
"bass": 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 ``"guitar"`` or ``"bass"``,
otherwise falls back to ``"guitar"``.
"""
if self.mode == "stereo":
return "guitar"
if param in ("guitar", "bass"):
return param
return "guitar"
# ── Dependency container ─────────────────────────────────────────────────────
@dataclass
class WebServerDeps:
"""Dependencies injected into the web server from the host pedal app."""
"""Dependencies injected into the web server from the host pedal app.
Supports dual-channel operation (guitar + bass). Each channel has its
own preset manager, audio pipeline, NAM host, and IR loader so the two
signal paths are fully independent.
The guitar fields are the defaults — callers that don't need dual-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
@property
def is_connected(self) -> bool:
"""True if we're wired to a live pedal instance."""
return self.presets is not None
"""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.
Falls back to guitar fields for unrecognised channels.
"""
if _resolve_channel(channel) == "bass":
return (
self.bass_presets,
self.bass_pipeline,
self.bass_nam_host,
self.bass_ir_loader,
)
return (self.presets, self.pipeline, self.nam_host, self.ir_loader)
# ── WebSocket connection manager ─────────────────────────────────────────────
@@ -139,11 +273,21 @@ class WebServer:
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:
@@ -177,8 +321,8 @@ class WebServer:
if index_html.exists():
content = index_html.read_text()
return HTMLResponse(content)
# Fallback: legacy dashboard
state = self._gather_state()
# Fallback: legacy dashboard (guitar channel state)
state = self._gather_state(channel="guitar")
ctx = {"request": request}
ctx.update(state)
return templates.TemplateResponse(request, "dashboard.html", ctx)
@@ -201,7 +345,7 @@ class WebServer:
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
"""Settings page."""
state = self._gather_state()
state = self._gather_state(channel="guitar")
ctx = {"request": request}
ctx.update(state)
return templates.TemplateResponse(request, "settings.html", ctx)
@@ -209,17 +353,25 @@ class WebServer:
# ── REST API ────────────────────────────────────────────────
@app.get("/api/state")
async def get_state():
"""Full current pedal state snapshot."""
state = self._gather_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():
"""List all banks and their presets."""
pm = self.deps.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()
@@ -257,9 +409,9 @@ class WebServer:
return {"banks": result}
@app.get("/api/presets/{bank}/{program}")
async def get_preset(bank: int, program: int):
"""Get a specific preset."""
pm = self.deps.presets
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:
@@ -286,17 +438,18 @@ class WebServer:
raise HTTPException(status_code=404, detail="Preset not found")
@app.post("/api/presets/{bank}/{program}/activate")
async def activate_preset(bank: int, program: int):
"""Activate a preset (load into pipeline)."""
pm = self.deps.presets
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()
state = self._gather_state(channel)
await self._manager.broadcast({
"type": "preset_changed",
"channel": channel,
"preset": {
"name": preset.name,
"bank": preset.bank,
@@ -309,9 +462,9 @@ class WebServer:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/presets/{bank}/{program}")
async def save_preset(bank: int, program: int, data: dict):
"""Save/update a preset."""
pm = self.deps.presets
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:
@@ -327,6 +480,7 @@ class WebServer:
))
preset = Preset(
name=data.get("name", f"Preset {bank}-{program}"),
channel=Channel(channel),
bank=bank,
program=program,
chain=chain,
@@ -339,9 +493,9 @@ class WebServer:
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/presets/{bank}/{program}")
async def delete_preset(bank: int, program: int):
"""Delete a preset."""
pm = self.deps.presets
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:
@@ -388,9 +542,9 @@ class WebServer:
}
@app.get("/api/snapshots")
async def list_snapshots():
"""List snapshot slots for the current preset."""
pm = self.deps.presets
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": {}}
@@ -418,10 +572,9 @@ class WebServer:
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):
"""Save current state to a snapshot slot (1-8)."""
pm = self.deps.presets
pl = self.deps.pipeline
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)
@@ -459,10 +612,9 @@ class WebServer:
return {"ok": True, "slot": slot, "name": snap_data["name"]}
@app.post("/api/snapshots/{slot}/recall")
async def recall_snapshot(slot: int):
"""Recall a snapshot: restore block states + params from slot."""
pm = self.deps.presets
pl = self.deps.pipeline
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)
@@ -493,9 +645,10 @@ class WebServer:
pm.save(preset)
pm._current_snapshot = slot
state = self._gather_state()
state = self._gather_state(channel)
await self._manager.broadcast({
"type": "snapshot_recalled",
"channel": channel,
"slot": slot,
"name": snap.name,
"state": state,
@@ -503,9 +656,9 @@ class WebServer:
return {"ok": True, "slot": slot, "name": snap.name}
@app.put("/api/snapshots/{slot}")
async def update_snapshot(slot: int, data: dict):
"""Rename or update a snapshot slot."""
pm = self.deps.presets
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)
@@ -519,15 +672,16 @@ class WebServer:
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):
"""Delete a snapshot slot."""
pm = self.deps.presets
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)
@@ -539,14 +693,15 @@ class WebServer:
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):
"""Get a single snapshot slot."""
pm = self.deps.presets
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")
@@ -556,6 +711,7 @@ class WebServer:
return {
"name": snap.name,
"slot": slot,
"channel": channel,
"chain": [
{
"fx_type": b.fx_type.value,
@@ -569,9 +725,9 @@ class WebServer:
}
@app.post("/api/bypass")
async def toggle_bypass(data: Optional[dict] = None):
"""Toggle or set bypass state."""
pl = self.deps.pipeline
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:
@@ -580,14 +736,15 @@ class WebServer:
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):
"""Toggle tuner mode."""
pl = self.deps.pipeline
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:
@@ -596,14 +753,15 @@ class WebServer:
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():
"""Get current tuner pitch detection data (fast poll endpoint)."""
pl = self.deps.pipeline
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,
@@ -619,21 +777,21 @@ class WebServer:
}
@app.put("/api/volume")
async def set_volume(data: dict):
"""Set master volume (0.0-1.0)."""
pl = self.deps.pipeline
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
return {"ok": True, "volume": vol}
return {"ok": True, "volume": vol, "channel": channel}
# ── 4CM routing endpoints ────────────────────────────────
@app.get("/api/routing")
async def get_routing():
"""Get current routing mode and breakpoint."""
pl = self.deps.pipeline
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 {
@@ -642,15 +800,15 @@ class WebServer:
}
@app.post("/api/routing")
async def set_routing(data: dict):
"""Set routing mode and/or breakpoint.
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.
"""
pl = self.deps.pipeline
pm = self.deps.presets
_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)
@@ -669,16 +827,47 @@ class WebServer:
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}
@app.get("/api/models")
async def list_models():
"""List available NAM models."""
nam = self.deps.nam_host
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()
@@ -697,9 +886,9 @@ class WebServer:
}
@app.post("/api/models/load")
async def load_model(data: dict):
"""Load a NAM model by path."""
nam = self.deps.nam_host
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", "")
@@ -715,18 +904,18 @@ class WebServer:
return {"ok": True}
@app.post("/api/models/unload")
async def unload_model():
"""Unload the current NAM model."""
nam = self.deps.nam_host
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()
return {"ok": True}
@app.get("/api/irs")
async def list_irs():
"""List available IR files."""
ir = self.deps.ir_loader
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()
@@ -746,9 +935,9 @@ class WebServer:
}
@app.post("/api/irs/load")
async def load_ir(data: dict):
"""Load an IR file by path."""
ir = self.deps.ir_loader
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", "")
@@ -764,9 +953,9 @@ class WebServer:
return {"ok": True}
@app.post("/api/irs/unload")
async def unload_ir():
"""Unload the current IR."""
ir = self.deps.ir_loader
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()
@@ -999,10 +1188,16 @@ class WebServer:
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
state = self._gather_state()
await ws.send_text(json.dumps({"type": "connected", "state": state}))
# 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()
@@ -1012,8 +1207,20 @@ class WebServer:
if msg_type == "ping":
await ws.send_text(json.dumps({"type": "pong"}))
elif msg_type == "get_state":
state = self._gather_state()
await ws.send_text(json.dumps({"type": "state", "state": state}))
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:
@@ -1264,6 +1471,42 @@ class WebServer:
ir_loader.get_irs()
return {"ok": True, "path": str(path), "filename": path.name}
# ── 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 ──────────────────────────────────
@@ -1322,12 +1565,14 @@ class WebServer:
# ── State gathering ──────────────────────────────────────────────
def _gather_state(self) -> dict[str, Any]:
"""Collect a snapshot of the current pedal state."""
pm = self.deps.presets
pl = self.deps.pipeline
nam = self.deps.nam_host
ir = self.deps.ir_loader
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 {
@@ -1378,6 +1623,7 @@ class WebServer:
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 "",
@@ -1398,15 +1644,74 @@ class WebServer:
"nam_model": current_model_name,
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
"ir_name": current_ir_name,
"channel_mode": self._channel_mgr.mode,
# Audio levels from pipeline (RMS float32 normalized 0.0-1.0, scaled to 0-100)
"input_level": min(100, round((pl._input_level or 0.0) * 150)) if pl else 0,
"output_level": min(100, round((pl._output_level or 0.0) * 150)) if pl else 0,
"input_level": (
min(100, round((getattr(pl, '_input_level', 0.0) or 0.0) * 150))
if pl and isinstance(getattr(pl, '_input_level', None), (int, float))
else 0
),
"output_level": (
min(100, round((getattr(pl, '_output_level', 0.0) or 0.0) * 150))
if pl and isinstance(getattr(pl, '_output_level', None), (int, float))
else 0
),
# 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(self.deps.presets, '_current_snapshot', 0) if self.deps.presets else 0,
"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 both channel states.
Shape::
{
\"channels\": {
\"guitar\": { … },
\"bass\": { … },
},
\"active_channel\": \"guitar\",
\"channel_mode\": \"dual-mono\",
\"wifi\": { … },
\"bluetooth\": { … },
\"cpu_percent\": …,
}
"""
guitar = self._gather_channel_state("guitar")
bass = self._gather_channel_state("bass")
return {
"channels": {
"guitar": guitar,
"bass": bass,
},
"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),
),
"sample_rate": SAMPLE_RATE,
}
# ── Tone3000 client lazy init ────────────────────────────────