fix: API cleanup — dedup endpoints, rename channel_mode, fix brightness scaling, device switch rollback
- **4.1:** Consolidated duplicate PUT/POST /api/volume and POST /api/bypass/toggle into single handlers via stacked FastAPI decorators - **4.2:** Renamed 'channel_mode' → 'audio_routing_mode' in settings KEY_MAP and runtime apply, decoupling it from instrument routing - **4.4:** Changed brightness API from ambiguous 'brightness' (1-10/0-1 heuristic) to explicit 'brightness_percent' (0-100, unambiguous) - **4.5:** Added config snapshot/rollback on JACK device switch failure. Restores original devices + persists restored config. Sets needs_reboot flag if even the rollback restart fails. Surfaces needs_reboot in state.
This commit is contained in:
+329
-143
@@ -29,9 +29,9 @@ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Requ
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from ..dsp.nam_host import NAMHost
|
||||
from ..dsp.nam_router import NAMEngineRouter
|
||||
from ..dsp.ir_loader import IRLoader
|
||||
from ..dsp.pipeline import AudioPipeline, SAMPLE_RATE
|
||||
from ..dsp.pipeline import AudioPipeline
|
||||
from ..presets.manager import PresetManager
|
||||
from ..presets.types import Channel, FXBlock, FXType, Preset
|
||||
from ..system.tonedownload import (
|
||||
@@ -47,10 +47,25 @@ from ..ui.leds import LEDController
|
||||
from ..ui.display import DisplayController
|
||||
from ..midi.handler import MIDIHandler
|
||||
|
||||
# Fallback constant — pipeline no longer exports this at module level
|
||||
SAMPLE_RATE: int = 48000
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Cache buster version (bumped on deploy) ─────────────────────────
|
||||
# Increment this when template/static changes need to bypass browser cache
|
||||
# ── Safe error detail (log full exception, return generic message) ────────────
|
||||
|
||||
def _safe_detail(logger_obj: logging.Logger, exc: Exception) -> str:
|
||||
"""Log the full exception server-side, return a generic message to client.
|
||||
|
||||
Prevents leaking internal exception text (traceback, file paths, credentials)
|
||||
to unauthenticated clients while preserving debuggability via logs.
|
||||
"""
|
||||
logger_obj.error("Internal server error: %s", exc, exc_info=True)
|
||||
return "Internal server error"
|
||||
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
@@ -60,6 +75,18 @@ 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"
|
||||
|
||||
# ── Valid channel names ─────────────────────────────────────────────────────
|
||||
|
||||
VALID_CHANNELS: list[str] = ["guitar", "bass", "keys", "vocals", "backing_tracks"]
|
||||
|
||||
|
||||
def _resolve_channel(param: str | None) -> str:
|
||||
"""Return a validated channel name, falling back to 'guitar'."""
|
||||
if param in VALID_CHANNELS:
|
||||
return param
|
||||
return "guitar"
|
||||
|
||||
|
||||
# ── Channel mode persistence ────────────────────────────────────────────────
|
||||
|
||||
CHANNEL_MODE_FILE = Path.home() / ".pedal" / "channel_mode.json"
|
||||
@@ -177,15 +204,33 @@ class WebServerDeps:
|
||||
# Guitar channel (default)
|
||||
presets: Optional[PresetManager] = None
|
||||
pipeline: Optional[AudioPipeline] = None
|
||||
nam_host: Optional[NAMHost] = None
|
||||
nam_host: Optional[NAMEngineRouter] = None
|
||||
ir_loader: Optional[IRLoader] = None
|
||||
|
||||
# Bass channel (separate from guitar)
|
||||
# Bass channel (separate DSP chain)
|
||||
bass_presets: Optional[PresetManager] = None
|
||||
bass_pipeline: Optional[AudioPipeline] = None
|
||||
bass_nam_host: Optional[NAMHost] = None
|
||||
bass_nam_host: Optional[NAMEngineRouter] = None
|
||||
bass_ir_loader: Optional[IRLoader] = None
|
||||
|
||||
# Keys channel (separate DSP chain)
|
||||
keys_presets: Optional[PresetManager] = None
|
||||
keys_pipeline: Optional[AudioPipeline] = None
|
||||
keys_nam_host: Optional[NAMEngineRouter] = None
|
||||
keys_ir_loader: Optional[IRLoader] = None
|
||||
|
||||
# Vocals channel (separate DSP chain)
|
||||
vocals_presets: Optional[PresetManager] = None
|
||||
vocals_pipeline: Optional[AudioPipeline] = None
|
||||
vocals_nam_host: Optional[NAMEngineRouter] = None
|
||||
vocals_ir_loader: Optional[IRLoader] = None
|
||||
|
||||
# Backing tracks channel (separate DSP chain)
|
||||
backing_tracks_presets: Optional[PresetManager] = None
|
||||
backing_tracks_pipeline: Optional[AudioPipeline] = None
|
||||
backing_tracks_nam_host: Optional[NAMEngineRouter] = None
|
||||
backing_tracks_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)
|
||||
@@ -208,11 +253,25 @@ class WebServerDeps:
|
||||
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().
|
||||
All channels share the same PresetManager — it routes internally
|
||||
via ``channel=Channel(channel)``. Each channel gets its own DSP
|
||||
chain (pipeline, NAM host, IR loader) if populated, otherwise
|
||||
returns ``None`` for the DSP components (caller handles gracefully).
|
||||
|
||||
Falls back to guitar fields for unrecognised channels.
|
||||
"""
|
||||
return (self.presets, self.pipeline, self.nam_host, self.ir_loader)
|
||||
# All channels share one PresetManager — it routes per-channel internally
|
||||
pm = self.presets
|
||||
|
||||
channel_map = {
|
||||
"guitar": (self.pipeline, self.nam_host, self.ir_loader),
|
||||
"bass": (self.bass_pipeline, self.bass_nam_host, self.bass_ir_loader),
|
||||
"keys": (self.keys_pipeline, self.keys_nam_host, self.keys_ir_loader),
|
||||
"vocals": (self.vocals_pipeline, self.vocals_nam_host, self.vocals_ir_loader),
|
||||
"backing_tracks": (self.backing_tracks_pipeline, self.backing_tracks_nam_host, self.backing_tracks_ir_loader),
|
||||
}
|
||||
pl, nam, ir = channel_map.get(channel, (self.pipeline, self.nam_host, self.ir_loader))
|
||||
return (pm, pl, nam, ir)
|
||||
|
||||
|
||||
# ── WebSocket connection manager ─────────────────────────────────────────────
|
||||
@@ -266,16 +325,22 @@ class WebServer:
|
||||
deps: WebServerDeps,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
ssl_certfile: Optional[str] = None,
|
||||
ssl_keyfile: Optional[str] = None,
|
||||
) -> None:
|
||||
self.deps = deps
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.ssl_certfile = ssl_certfile
|
||||
self.ssl_keyfile = ssl_keyfile
|
||||
|
||||
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._preset_write_locks: dict[str, asyncio.Lock] = {}
|
||||
self._needs_reboot: bool = False
|
||||
self._app = self._build_app()
|
||||
|
||||
# ── Channel dep resolution ──────────────────────────────────────
|
||||
@@ -287,8 +352,33 @@ class WebServer:
|
||||
"""
|
||||
return self.deps.get_deps(channel)
|
||||
|
||||
# ── Preset write lock ───────────────────────────────────────────
|
||||
|
||||
def _get_preset_lock(self, channel: str, bank: int, program: int) -> asyncio.Lock:
|
||||
"""Return an ``asyncio.Lock`` for serializing writes to a preset file.
|
||||
|
||||
Each (channel, bank, program) tuple gets its own lock so that
|
||||
near-simultaneous requests to different presets don't block each other.
|
||||
"""
|
||||
key = f"{channel}:{bank}:{program}"
|
||||
if key not in self._preset_write_locks:
|
||||
self._preset_write_locks[key] = asyncio.Lock()
|
||||
return self._preset_write_locks[key]
|
||||
|
||||
# ── App factory ─────────────────────────────────────────────────────
|
||||
|
||||
# ── Auth helpers ─────────────────────────────────────────────
|
||||
|
||||
def _get_auth_pin(self) -> str | None:
|
||||
"""Return the configured auth PIN from deps.config, or None if auth is disabled."""
|
||||
if self.deps.config is None:
|
||||
return None
|
||||
web_cfg = self.deps.config.get("web", {})
|
||||
if not isinstance(web_cfg, dict):
|
||||
return None
|
||||
pin = web_cfg.get("auth_pin")
|
||||
return str(pin) if pin else None
|
||||
|
||||
def _build_app(self) -> FastAPI:
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -299,6 +389,75 @@ class WebServer:
|
||||
|
||||
app = FastAPI(title="Pi Multi-FX Pedal", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
# ── Global exception handler — log server-side, generic to client ──
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error("Unhandled exception on %s %s: %s",
|
||||
request.method, request.url.path, exc, exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
# ── Auth middleware ──────────────────────────────────────────────
|
||||
# All /api/ endpoints require X-Pedal-Auth: <pin> header, EXCEPT
|
||||
# read-only GET endpoints that the UI needs on first load.
|
||||
# The PIN is set at first boot in config.yaml under web.auth_pin.
|
||||
|
||||
AUTH_SAFE_PREFIXES = (
|
||||
# Read-only endpoints the UI needs for basic operation
|
||||
# (none of these expose WiFi/BT credentials or settings)
|
||||
"/api/state",
|
||||
"/api/presets",
|
||||
"/api/snapshots",
|
||||
"/api/models",
|
||||
"/api/irs",
|
||||
"/api/audio/profile",
|
||||
"/api/audio/profiles",
|
||||
"/api/audio/devices",
|
||||
"/api/block-params",
|
||||
"/api/levels",
|
||||
"/api/tuner/pitch",
|
||||
"/api/channel-output",
|
||||
"/api/channel-mode",
|
||||
"/api/routing",
|
||||
"/api/system",
|
||||
"/api/network",
|
||||
"/api/backing-source",
|
||||
"/api/midi/mappings",
|
||||
"/api/nam/engine",
|
||||
"/api/tonedownload/status",
|
||||
"/api/models/tonedownload/search",
|
||||
"/api/irs/tonedownload/search",
|
||||
"/api/fx/params",
|
||||
"/api/bluetooth/midi-status",
|
||||
"/api/bluetooth/midi-devices",
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# Static files and the main UI are always public
|
||||
if path == "/" or path.startswith("/static/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Legacy redirect routes are public
|
||||
if path in ("/presets", "/models", "/irs", "/settings"):
|
||||
return await call_next(request)
|
||||
|
||||
# Enforce auth on all /api/ endpoints except safe GET prefixes
|
||||
if path.startswith("/api/"):
|
||||
# Safe GET endpoints — read-only, no sensitive data
|
||||
if request.method == "GET" and any(path.startswith(p) for p in AUTH_SAFE_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
pin = self._get_auth_pin()
|
||||
if pin is not None:
|
||||
auth = request.headers.get("X-Pedal-Auth", "")
|
||||
if auth != pin:
|
||||
logger.warning("Unauthorized request to %s %s", request.method, path)
|
||||
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
@@ -460,7 +619,7 @@ class WebServer:
|
||||
})
|
||||
return {"ok": True, "preset": preset.name}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
@app.put("/api/presets/{bank}/{program}")
|
||||
async def save_preset(bank: int, program: int, data: dict, channel: str = "guitar"):
|
||||
@@ -491,7 +650,7 @@ class WebServer:
|
||||
pm.save(preset)
|
||||
return {"ok": True, "name": preset.name}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
@app.delete("/api/presets/{bank}/{program}")
|
||||
async def delete_preset(bank: int, program: int, channel: str = "guitar"):
|
||||
@@ -503,7 +662,7 @@ class WebServer:
|
||||
pm.delete(bank, program)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
# ── Snapshot endpoints ──────────────────────────────────────
|
||||
|
||||
@@ -729,8 +888,9 @@ class WebServer:
|
||||
}
|
||||
|
||||
@app.post("/api/bypass")
|
||||
@app.post("/api/bypass/toggle")
|
||||
async def toggle_bypass(data: Optional[dict] = None, channel: str = "guitar"):
|
||||
"""Toggle or set bypass state for the given channel."""
|
||||
"""Toggle or set bypass state (canonical; /api/bypass/toggle also routes here)."""
|
||||
_pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||
if not pl:
|
||||
raise HTTPException(status_code=503)
|
||||
@@ -743,7 +903,7 @@ class WebServer:
|
||||
"channel": channel,
|
||||
"bypass": pl.bypassed,
|
||||
})
|
||||
return {"ok": True, "bypass": pl._bypassed}
|
||||
return {"ok": True, "bypass": pl.bypassed}
|
||||
|
||||
@app.post("/api/tuner")
|
||||
async def toggle_tuner(data: Optional[dict] = None, channel: str = "guitar"):
|
||||
@@ -752,15 +912,15 @@ class WebServer:
|
||||
if not pl:
|
||||
raise HTTPException(status_code=503)
|
||||
if data and "enabled" in data:
|
||||
pl._tuner_enabled = bool(data["enabled"])
|
||||
pl.tuner_enabled = bool(data["enabled"])
|
||||
else:
|
||||
pl._tuner_enabled = not pl._tuner_enabled
|
||||
pl.tuner_enabled = not pl.tuner_enabled
|
||||
await self._manager.broadcast({
|
||||
"type": "tuner_changed",
|
||||
"channel": channel,
|
||||
"tuner_enabled": pl._tuner_enabled,
|
||||
"tuner_enabled": pl.tuner_enabled,
|
||||
})
|
||||
return {"ok": True, "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"):
|
||||
@@ -777,59 +937,24 @@ class WebServer:
|
||||
"cents": getattr(pl, '_tuner_cents', 0),
|
||||
"string": getattr(pl, '_tuner_string', -1),
|
||||
"confidence": getattr(pl, '_tuner_confidence', 0.0),
|
||||
"enabled": pl._tuner_enabled,
|
||||
"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."""
|
||||
async def set_volume(data: dict, channel: str = "guitar"):
|
||||
"""Set master volume (0.0-1.0) for the given channel (PUT and POST both route here)."""
|
||||
_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({
|
||||
pl.master_volume = vol
|
||||
await 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}
|
||||
return {"ok": True, "volume": vol, "channel": channel}
|
||||
|
||||
# ── Block toggle (UI: PATCH /api/blocks {id, enabled}) ──
|
||||
@app.patch("/api/blocks")
|
||||
@@ -847,26 +972,30 @@ class WebServer:
|
||||
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}
|
||||
lock = self._get_preset_lock(channel, bank, program)
|
||||
async with lock:
|
||||
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 HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
# ── Block param update (UI: PATCH /api/block {id, ...params}) ──
|
||||
@app.patch("/api/block")
|
||||
@@ -883,42 +1012,62 @@ class WebServer:
|
||||
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)
|
||||
lock = self._get_preset_lock(channel, bank, program)
|
||||
async with lock:
|
||||
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):
|
||||
# Validate param keys against block-params schema
|
||||
valid_param_keys: set[str] = set()
|
||||
schema = _FX_PARAM_SCHEMAS.get(b.fx_type.value, [])
|
||||
for pdef in schema:
|
||||
valid_param_keys.add(pdef["key"])
|
||||
unknown_keys = []
|
||||
for key in data:
|
||||
if key in ("id", "block_id", "nam_model_path", "ir_file_path", "bypass", "enabled", "subtype"):
|
||||
continue
|
||||
if key not in valid_param_keys:
|
||||
unknown_keys.append(key)
|
||||
if unknown_keys:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown param keys for {b.fx_type.value}: {unknown_keys}. Valid keys: {sorted(valid_param_keys)}",
|
||||
)
|
||||
# Update params from request body (skip metadata keys)
|
||||
for key, val in data.items():
|
||||
if key in ("id", "block_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:
|
||||
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)}
|
||||
# Safe: key already validated against schema
|
||||
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 HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
# ── 4CM routing endpoints ────────────────────────────────
|
||||
|
||||
@@ -1086,7 +1235,8 @@ class WebServer:
|
||||
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")
|
||||
detail = nam.last_error or "Failed to load model"
|
||||
raise HTTPException(status_code=422, detail=detail)
|
||||
await self._manager.broadcast({
|
||||
"type": "model_loaded",
|
||||
"model": path,
|
||||
@@ -1177,6 +1327,7 @@ class WebServer:
|
||||
dest = models_dir / Path(file.filename).name
|
||||
content = await file.read()
|
||||
dest.write_bytes(content)
|
||||
nam.evict_cache(str(dest))
|
||||
logger.info("Uploaded NAM model: %s", dest.name)
|
||||
return {"ok": True, "filename": dest.name}
|
||||
|
||||
@@ -1553,6 +1704,14 @@ class WebServer:
|
||||
if nam_host and hasattr(nam_host, 'set_block_size'):
|
||||
nam_host.set_block_size(target_profile["period"])
|
||||
|
||||
# Sync AudioPipeline block size and sample rate for correct DSP timing
|
||||
pipeline = self.deps.pipeline
|
||||
if pipeline is not None and hasattr(pipeline, 'set_audio_profile'):
|
||||
pipeline.set_audio_profile(
|
||||
target_profile["period"],
|
||||
target_profile["rate"],
|
||||
)
|
||||
|
||||
# Restart JACK audio client
|
||||
if jack_client:
|
||||
try:
|
||||
@@ -1596,7 +1755,7 @@ class WebServer:
|
||||
KEY_MAP = {
|
||||
"input_device": ("audio", "input_device"),
|
||||
"output_device": ("audio", "output_device"),
|
||||
"channel_mode": ("audio", "mode"),
|
||||
"audio_routing_mode": ("audio", "routing_mode"),
|
||||
"input_pad": ("audio", "input_pad"),
|
||||
"input_impedance": ("audio", "input_impedance"),
|
||||
"midi_channel": ("midi", "channel"),
|
||||
@@ -1624,7 +1783,7 @@ class WebServer:
|
||||
persisted immediately.
|
||||
|
||||
Special handling:
|
||||
``brightness`` — UI sends 1–10, stored as 0.1–1.0 float.
|
||||
``brightness_percent`` — explicit 0–100 scale, stored as 0.0–1.0 float.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
if self.deps.config is None:
|
||||
@@ -1634,12 +1793,9 @@ class WebServer:
|
||||
n_saved = 0
|
||||
|
||||
for key, val in data.items():
|
||||
if key == "brightness":
|
||||
if key == "brightness_percent":
|
||||
leds = cfg.setdefault("leds", {})
|
||||
if isinstance(val, (int, float)) and val >= 1:
|
||||
leds["brightness"] = val / 10.0
|
||||
else:
|
||||
leds["brightness"] = val
|
||||
leds["brightness"] = max(0.0, min(1.0, float(val) / 100.0))
|
||||
n_saved += 1
|
||||
elif key in KEY_MAP:
|
||||
section, skey = KEY_MAP[key]
|
||||
@@ -1650,28 +1806,29 @@ class WebServer:
|
||||
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:
|
||||
if "brightness_percent" 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))
|
||||
bv = max(0.0, min(100.0, float(data["brightness_percent"])))
|
||||
self.deps.leds.set_brightness(bv / 100.0)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply brightness: %s", e)
|
||||
logger.warning("Failed to apply brightness_percent: %s", e)
|
||||
|
||||
if "channel_mode" in data and self.deps.pipeline is not None:
|
||||
if "audio_routing_mode" in data and self.deps.pipeline is not None:
|
||||
try:
|
||||
mode = str(data["channel_mode"])
|
||||
mode = str(data["audio_routing_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)
|
||||
logger.warning("Failed to apply audio_routing_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
|
||||
# Snapshot original devices before mutation for rollback
|
||||
orig_input = audio_sys.config.input_device if hasattr(audio_sys.config, 'input_device') else None
|
||||
orig_output = audio_sys.config.output_device if hasattr(audio_sys.config, 'output_device') else None
|
||||
# Kill JACK server FIRST to avoid deadlock on
|
||||
# jack.Client.deactivate() (same pattern as profile switch)
|
||||
if "input_device" in data:
|
||||
@@ -1684,9 +1841,28 @@ class WebServer:
|
||||
ok = await loop.run_in_executor(None, partial(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
|
||||
# Restore original devices
|
||||
if orig_input is not None:
|
||||
audio_sys.config.input_device = orig_input
|
||||
if orig_output is not None:
|
||||
audio_sys.config.output_device = orig_output
|
||||
if self.deps.config is not None:
|
||||
acfg = self.deps.config.setdefault("audio", {})
|
||||
if orig_input is not None:
|
||||
acfg["input_device"] = orig_input
|
||||
if orig_output is not None:
|
||||
acfg["output_device"] = orig_output
|
||||
save_config(self.deps.config, self.deps.config_path)
|
||||
# Try restarting with original config
|
||||
ok2 = await loop.run_in_executor(None, partial(audio_sys.start_jack, timeout=10))
|
||||
if not ok2:
|
||||
logger.error("JACK rollback also failed — pedal needs manual restart")
|
||||
self._needs_reboot = True
|
||||
if jack_client:
|
||||
jack_client.start()
|
||||
try:
|
||||
jack_client.start()
|
||||
except Exception as e:
|
||||
logger.warning("jack_client.start() failed after device change: %s", e)
|
||||
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:
|
||||
@@ -1927,7 +2103,7 @@ class WebServer:
|
||||
_capture_path = path
|
||||
return {"ok": True, "path": str(path), "filename": path.name}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
@app.post("/api/capture/stop")
|
||||
async def capture_stop():
|
||||
@@ -2117,7 +2293,8 @@ class WebServer:
|
||||
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": []}
|
||||
logger.warning("Failed to get system logs: %s", e, exc_info=True)
|
||||
return {"error": "Failed to read logs", "logs": []}
|
||||
|
||||
@app.post("/api/system/hostname")
|
||||
async def set_hostname(data: dict):
|
||||
@@ -2129,7 +2306,7 @@ class WebServer:
|
||||
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))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
@app.get("/api/network")
|
||||
async def get_network():
|
||||
@@ -2175,7 +2352,7 @@ iface eth0 inet static
|
||||
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))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
@app.post("/api/network/dhcp")
|
||||
async def set_network_dhcp():
|
||||
@@ -2218,7 +2395,8 @@ iface eth0 inet static
|
||||
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": []}
|
||||
logger.warning("Failed to list audio devices: %s", e, exc_info=True)
|
||||
return {"error": "Failed to list devices", "devices": []}
|
||||
|
||||
# ── Backing track source ────────────────────────────────────
|
||||
|
||||
@@ -2358,7 +2536,7 @@ iface eth0 inet static
|
||||
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))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
@app.get("/api/backup/list")
|
||||
async def list_backups():
|
||||
@@ -2386,7 +2564,7 @@ iface eth0 inet static
|
||||
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))
|
||||
raise HTTPException(status_code=500, detail=_safe_detail(logger, e))
|
||||
|
||||
@app.get("/api/diagnostics")
|
||||
async def get_diagnostics():
|
||||
@@ -2502,7 +2680,7 @@ iface eth0 inet static
|
||||
import psutil
|
||||
return {
|
||||
"cpu_percent": psutil.cpu_percent(interval=0.2),
|
||||
"sample_rate": SAMPLE_RATE if hasattr(self, 'deps') and self.deps.pipeline else 48000,
|
||||
"sample_rate": self.deps.pipeline.sample_rate if hasattr(self, 'deps') and self.deps.pipeline else 48000,
|
||||
}
|
||||
except Exception:
|
||||
return {"cpu_percent": 0, "sample_rate": 48000}
|
||||
@@ -2602,14 +2780,14 @@ iface eth0 inet static
|
||||
"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,
|
||||
"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,
|
||||
"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,
|
||||
@@ -2656,10 +2834,11 @@ iface eth0 inet static
|
||||
# System stats
|
||||
"cpu_percent": self._gather_system_stats().get("cpu_percent", 0),
|
||||
"nam_cpu": self._calc_nam_cpu(nam, pl),
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"sample_rate": pl.sample_rate if pl else 48000,
|
||||
"wifi": self._gather_wifi_state(),
|
||||
"bluetooth": self._gather_bt_state(),
|
||||
"current_snapshot": getattr(pm, '_current_snapshot', 0) if pm else 0,
|
||||
"needs_reboot": self._needs_reboot,
|
||||
}
|
||||
|
||||
def _gather_state(self, channel: Optional[str] = None) -> dict[str, Any]:
|
||||
@@ -2721,7 +2900,8 @@ iface eth0 inet static
|
||||
vocals.get("cpu_percent", 0),
|
||||
backing.get("cpu_percent", 0),
|
||||
),
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"sample_rate": pl.sample_rate if pl else 48000,
|
||||
"needs_reboot": self._needs_reboot,
|
||||
}
|
||||
|
||||
# ── Tone3000 client lazy init ────────────────────────────────
|
||||
@@ -2748,12 +2928,17 @@ iface eth0 inet static
|
||||
avoid conflicting with the main thread's blocking JACK loop.
|
||||
"""
|
||||
import threading
|
||||
ssl_kw = {}
|
||||
if self.ssl_certfile and self.ssl_keyfile:
|
||||
ssl_kw["ssl_certfile"] = self.ssl_certfile
|
||||
ssl_kw["ssl_keyfile"] = self.ssl_keyfile
|
||||
config = uvicorn.Config(
|
||||
self._app,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_level="info",
|
||||
access_log=False,
|
||||
**ssl_kw,
|
||||
)
|
||||
|
||||
def _run():
|
||||
@@ -2765,7 +2950,8 @@ iface eth0 inet static
|
||||
|
||||
self._thread = threading.Thread(target=_run, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("Web server starting on http://%s:%d", self.host, self.port)
|
||||
proto = "https" if self.ssl_certfile else "http"
|
||||
logger.info("Web server starting on %s://%s:%d", proto, self.host, self.port)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the web server gracefully."""
|
||||
@@ -3064,4 +3250,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user