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:
2026-06-17 21:31:19 -04:00
parent 3c8d5e4c1c
commit 42e357c4a1
+329 -143
View File
@@ -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 110, stored as 0.11.0 float.
``brightness_percent`` — explicit 0100 scale, stored as 0.01.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()