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:
+404
-99
@@ -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 ────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user