From 42e357c4a1ab338ed88bdd98126a517ec3c05221 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 17 Jun 2026 21:31:19 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20API=20cleanup=20=E2=80=94=20dedup=20endp?= =?UTF-8?q?oints,=20rename=20channel=5Fmode,=20fix=20brightness=20scaling,?= =?UTF-8?q?=20device=20switch=20rollback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **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. --- src/web/server.py | 472 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 329 insertions(+), 143 deletions(-) diff --git a/src/web/server.py b/src/web/server.py index c9a8843..337ce19 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -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: 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() \ No newline at end of file + main()