diff --git a/src/web/server.py b/src/web/server.py index 77b1544..43b9355 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -42,6 +42,10 @@ from ..system.tonedownload import ( format_size, ) from ..system.audio import AudioSystem, JackAudioClient, LATENCY_PROFILES +from ..system.config import save_config +from ..ui.leds import LEDController +from ..ui.display import DisplayController +from ..midi.handler import MIDIHandler logger = logging.getLogger(__name__) @@ -207,6 +211,15 @@ class WebServerDeps: # JACK audio client (needs restart when JACK restarts) jack_audio: Optional[JackAudioClient] = None + # Full config dict (for saving profile/rate/period changes to disk) + config: Optional[dict] = None + config_path: Optional[Path] = None + + # Runtime subsystems (for live apply of settings changes) + leds: Optional[LEDController] = None + midi: Optional[MIDIHandler] = None + display: Optional[DisplayController] = None + @property def is_connected(self) -> bool: """True if we're wired to a live pedal instance (any channel).""" @@ -316,12 +329,25 @@ class WebServer: logger.info("Mounting React UI from %s at /ui", UI_DIST_DIR) app.mount("/ui", StaticFiles(directory=str(UI_DIST_DIR), html=True), name="ui") + # Mount V2 React UI (if dist exists) + V2_DIST_DIR = Path(__file__).resolve().parent / "ui-v2-dist" + if V2_DIST_DIR.is_dir(): + logger.info("Mounting V2 React UI from %s at /v2", V2_DIST_DIR) + app.mount("/v2", StaticFiles(directory=str(V2_DIST_DIR), html=True), name="ui-v2") + # ── HTML pages ────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def root(request: Request): - """Serve the React SPA (or legacy dashboard as fallback).""" - # If we have a React build, serve it at / + """Serve the v2 UI (or React SPA / legacy dashboard as fallback).""" + # Prefer the v2 UI if available + v2_dir = Path(__file__).resolve().parent / "ui-v2-dist" + if v2_dir.is_dir(): + v2_index = v2_dir / "index.html" + if v2_index.exists(): + content = v2_index.read_text() + return HTMLResponse(content) + # Fallback: React build if UI_DIST_DIR and UI_DIST_DIR.is_dir(): index_html = UI_DIST_DIR / "index.html" if index_html.exists(): @@ -394,11 +420,21 @@ class WebServer: "chain": [ { "fx_type": b.fx_type.value, + "block_id": b.block_id, "enabled": b.enabled, "bypass": b.bypass, "params": dict(b.params), "nam_model_path": b.nam_model_path, + "nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "", "ir_file_path": b.ir_file_path, + "ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "", + "name": ( + Path(b.nam_model_path).stem.replace("_", " ") + if b.nam_model_path + else Path(b.ir_file_path).stem.replace("_", " ") + if b.ir_file_path + else b.fx_type.value.replace("_", " ").title() + ), } for b in p.chain ], @@ -429,11 +465,21 @@ class WebServer: "chain": [ { "fx_type": b.fx_type.value, + "block_id": b.block_id, "enabled": b.enabled, "bypass": b.bypass, "params": dict(b.params), "nam_model_path": b.nam_model_path, + "nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "", "ir_file_path": b.ir_file_path, + "ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "", + "name": ( + Path(b.nam_model_path).stem.replace("_", " ") + if b.nam_model_path + else Path(b.ir_file_path).stem.replace("_", " ") + if b.ir_file_path + else b.fx_type.value.replace("_", " ").title() + ), } for b in p.chain ], @@ -535,6 +581,7 @@ class WebServer: for b in preset.chain: chain_data.append({ "fx_type": b.fx_type.value, + "block_id": b.block_id, "enabled": b.enabled, "bypass": b.bypass, "params": dict(b.params), @@ -562,6 +609,7 @@ class WebServer: for b in snap.chain: chain.append({ "fx_type": b.fx_type.value, + "block_id": b.block_id, "enabled": b.enabled, "bypass": b.bypass, "params": dict(b.params), @@ -721,6 +769,7 @@ class WebServer: "chain": [ { "fx_type": b.fx_type.value, + "block_id": b.block_id, "enabled": b.enabled, "bypass": b.bypass, "params": dict(b.params), @@ -737,13 +786,13 @@ class WebServer: if not pl: raise HTTPException(status_code=503) if data and "bypass" in data: - pl._bypassed = bool(data["bypass"]) + pl.bypassed = bool(data["bypass"]) else: - pl._bypassed = not pl._bypassed + pl.bypassed = not pl.bypassed await self._manager.broadcast({ "type": "bypass_changed", "channel": channel, - "bypass": pl._bypassed, + "bypass": pl.bypassed, }) return {"ok": True, "bypass": pl._bypassed} @@ -790,6 +839,12 @@ class WebServer: 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) ── @@ -801,6 +856,12 @@ class WebServer: 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} # ── Toggle bypass alias (UI calls /api/bypass/toggle) ───── @@ -811,40 +872,48 @@ class WebServer: if not pl: raise HTTPException(status_code=503) if data and "bypass" in data: - pl._bypassed = bool(data["bypass"]) + pl.bypassed = bool(data["bypass"]) else: - pl._bypassed = not pl._bypassed + pl.bypassed = not pl.bypassed await self._manager.broadcast({ "type": "bypass_changed", "channel": channel, - "bypass": pl._bypassed, + "bypass": pl.bypassed, }) return {"ok": True, "bypass": pl._bypassed} # ── Block toggle (UI: PATCH /api/blocks {id, enabled}) ── @app.patch("/api/blocks") async def toggle_block(data: dict, channel: str = "guitar"): - """Enable/disable a block by its fx_type id — sets both enabled and bypass.""" + """Enable/disable a block by block_id or fx_type id — sets both enabled and bypass.""" from ..presets.types import Channel pm, pl, nam, ir = self._channel_deps(channel) if not pm: raise HTTPException(status_code=503) - block_id = data.get("id") + # Support both block_id (preferred) and id (fx_type, backward compat) + block_id = data.get("block_id") or data.get("id") enabled = data.get("enabled") if not block_id or enabled is None: - raise HTTPException(status_code=400, detail="Missing 'id' or 'enabled'") + raise HTTPException(status_code=400, detail="Missing 'block_id'/'id' or 'enabled'") try: bank = pm.current_bank program = pm.current_program preset = pm.load(bank, program, channel=Channel(channel)) for b in preset.chain: - if b.fx_type.value == block_id: + 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) # <-- added: sync bypass with 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 Exception as e: @@ -853,12 +922,13 @@ class WebServer: # ── Block param update (UI: PATCH /api/block {id, ...params}) ── @app.patch("/api/block") async def update_block_params(data: dict, channel: str = "guitar"): - """Update parameters for a block by its fx_type id.""" + """Update parameters for a block by block_id or fx_type id.""" from ..presets.types import Channel, FXBlock pm, pl, nam, ir = self._channel_deps(channel) if not pm: raise HTTPException(status_code=503) - block_id = data.get("id") + # Support both block_id (preferred) and id (fx_type, backward compat) + block_id = data.get("block_id") or data.get("id") if not block_id: raise HTTPException(status_code=400, detail="Missing 'id'") try: @@ -866,7 +936,7 @@ class WebServer: program = pm.current_program preset = pm.load(bank, program, channel=Channel(channel)) for b in preset.chain: - if b.fx_type.value == block_id: + 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": @@ -874,8 +944,10 @@ class WebServer: 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: @@ -886,6 +958,14 @@ class WebServer: 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 Exception as e: @@ -1015,6 +1095,10 @@ class WebServer: if not nam: raise HTTPException(status_code=503) nam.unload() + await self._manager.broadcast({ + "type": "model_unloaded", + "channel": channel, + }) return {"ok": True} @app.get("/api/irs") @@ -1064,6 +1148,10 @@ class WebServer: if not ir: raise HTTPException(status_code=503) ir.unload() + await self._manager.broadcast({ + "type": "ir_unloaded", + "channel": channel, + }) return {"ok": True} # ── File upload endpoints ───────────────────────────────── @@ -1321,64 +1409,291 @@ class WebServer: @app.post("/api/audio/profile") async def set_audio_profile(data: dict): - """Switch JACK latency profile (restarts JACK server). + """Switch JACK latency profile and/or set custom period/rate. - Body: { "profile": "standard" | "low" } + Body — at least one of: + { "profile": "stable" } → switch to a named profile + { "period": 256, "rate": 48000 } → custom settings + { "profile": "stable", → named profile with rate override + "rate": 44100 } - The switch takes effect immediately by restarting JACK with the - new period / nperiods / priority settings. + Changes are saved to ~/.pedal/config.yaml so they survive reboot. + The switch restarts JACK (brief audio dropout ~1s). """ - profile_key = data.get("profile", "") - if profile_key not in LATENCY_PROFILES: + audio_sys = self.deps.audio_system + if not audio_sys: + raise HTTPException(status_code=503, detail="Audio system not available") + + profile_key = data.get("profile") + custom_period = data.get("period") + custom_rate = data.get("rate") + + # ── Resolve target profile ─────────────────────────────── + if profile_key and profile_key not in LATENCY_PROFILES and profile_key != "custom": raise HTTPException( status_code=400, detail=f"Unknown profile: {profile_key!r}. Options: {list(LATENCY_PROFILES.keys())}", ) - audio_sys = self.deps.audio_system - if not audio_sys: - raise HTTPException(status_code=503, detail="Audio system not available") + + if profile_key and profile_key in LATENCY_PROFILES: + # Named profile — start from profile defaults + target_profile = dict(LATENCY_PROFILES[profile_key]) + else: + # Custom — use current as base, or fall back to stable + current = audio_sys.config.latency_profile + target_profile = dict(current) + + # Apply overrides + if custom_period is not None: + if custom_period not in (16, 32, 64, 128, 256, 512, 1024, 2048): + raise HTTPException(status_code=400, detail=f"Invalid period: {custom_period}. Must be power of 2 (16-2048)") + target_profile["period"] = custom_period + if custom_rate is not None: + if custom_rate not in (44100, 48000, 96000, 192000): + raise HTTPException(status_code=400, detail=f"Invalid rate: {custom_rate}. Must be 44100, 48000, 96000, or 192000") + target_profile["rate"] = custom_rate + + # Build effective profile name + if profile_key and profile_key in LATENCY_PROFILES: + if custom_period is not None or custom_rate is not None: + effective_key = f"{profile_key}_custom" + else: + effective_key = profile_key + else: + effective_key = "custom" + old_key = audio_sys.config.profile - if old_key == profile_key: - return {"ok": True, "profile": profile_key, "restarted": False} - # Apply new profile - audio_sys.config.profile = profile_key - profile = audio_sys.config.latency_profile - logger.info("Switching audio profile: %s → %s (period=%d, nperiods=%d)", - old_key, profile_key, profile["period"], profile["nperiods"]) - # Stop JACK audio client first (holds a live JACK C client) + # Check if anything actually changed + cur_profile = audio_sys.config.latency_profile + if (audio_sys.config.profile == effective_key + and cur_profile["period"] == target_profile["period"] + and cur_profile["rate"] == target_profile["rate"]): + return {"ok": True, "profile": effective_key, "restarted": False} + + # ── Apply new profile on the fly ───────────────────────── + # Update AudioConfig — store named overrides inline in profile key + audio_sys.config.profile = effective_key + + logger.info( + "Switching audio: %s → %s (period=%d, rate=%d)", + old_key, effective_key, + target_profile["period"], target_profile["rate"], + ) + + # Stop JACK audio client first jack_client = self.deps.jack_audio if jack_client: jack_client.stop() - # Restart JACK server with new parameters - ok = audio_sys.restart_jack(timeout=10) + + # Update LATENCY_PROFILES with custom entry so latency_profile resolves it + LATENCY_PROFILES[effective_key] = target_profile + + # ── Kill JACK server FIRST (breaks any client deadlock) ── + # This interrupts the audio callback thread, releasing any lock + # that jack_client.stop() would need. + try: + audio_sys.stop_jack() + except Exception: + logger.warning("stop_jack() failed during profile switch") + if jack_client: + try: + jack_client.stop() + except Exception: + logger.warning("jack_client.stop() failed (expected if JACK was mid-callback)") + + # Start JACK with new parameters + ok = audio_sys.start_jack(timeout=10) if not ok: # Rollback on failure audio_sys.config.profile = old_key + logger.warning("JACK restart failed — attempting rollback to %s", old_key) audio_sys.start_jack(timeout=10) if jack_client: - jack_client.start() - raise HTTPException(status_code=500, detail=f"Failed to start JACK with profile {profile_key!r} — rolled back to {old_key!r}") + try: + jack_client.start() + except Exception: + pass + raise HTTPException( + status_code=500, + detail=f"Failed to start JACK with period={target_profile['period']}, rate={target_profile['rate']} — rolled back", + ) + # Restart JACK audio client if jack_client: - jack_client.start() - # Sync NAM engine block size with new period + try: + jack_client.start() + except Exception as e: + logger.warning("jack_client.start() failed after profile switch: %s", e) + + # Sync NAM engine block size nam_host = self.deps.nam_host if nam_host and hasattr(nam_host, 'set_block_size'): - nam_host.set_block_size(profile["period"]) + nam_host.set_block_size(target_profile["period"]) + + # ── Persist to config.yaml ────────────────────────────── + if self.deps.config is not None: + audio_cfg = self.deps.config.setdefault("audio", {}) + audio_cfg["profile"] = effective_key + # Store custom period/rate in config too + audio_cfg["period"] = target_profile["period"] + audio_cfg["rate"] = target_profile["rate"] + save_config(self.deps.config, self.deps.config_path) + await self._manager.broadcast({ "type": "audio_profile_changed", - "profile": profile_key, - "period": profile["period"], - "nperiods": profile["nperiods"], + "profile": effective_key, + "period": target_profile["period"], + "rate": target_profile["rate"], }) + return { "ok": True, - "profile": profile_key, - "period": profile["period"], - "nperiods": profile["nperiods"], + "profile": effective_key, + "period": target_profile["period"], + "rate": target_profile["rate"], "restarted": True, } + # ── Generic settings persistence ────────────────────────── + + KEY_MAP = { + "input_device": ("audio", "input_device"), + "output_device": ("audio", "output_device"), + "channel_mode": ("audio", "mode"), + "input_pad": ("audio", "input_pad"), + "input_impedance": ("audio", "input_impedance"), + "midi_channel": ("midi", "channel"), + "midi_clock": ("midi", "clock"), + "midi_thru": ("midi", "thru"), + "auto_dim": ("display", "auto_dim"), + "screen_saver": ("display", "screen_saver"), + "tap_tempo": ("tempo", "tap_tempo"), + "tempo_division": ("tempo", "division"), + "tap_tempo_mute": ("tempo", "mute_on_tap"), + } + + @app.get("/api/settings") + async def get_settings(): + """Return the full config dict (read-only snapshot).""" + if self.deps.config is None: + raise HTTPException(status_code=503, detail="Config not available") + return self.deps.config + @app.post("/api/settings") + async def save_settings(data: dict): + """Save arbitrary settings to config.yaml. + + Accepts a dict of key-value pairs. Each key is mapped to + the correct ``section.key`` in ``~/.pedal/config.yaml`` and + persisted immediately. + + Special handling: + ``brightness`` — UI sends 1–10, stored as 0.1–1.0 float. + """ + if self.deps.config is None: + raise HTTPException(status_code=503, detail="Config not available") + + cfg = self.deps.config + n_saved = 0 + + for key, val in data.items(): + if key == "brightness": + leds = cfg.setdefault("leds", {}) + if isinstance(val, (int, float)) and val >= 1: + leds["brightness"] = val / 10.0 + else: + leds["brightness"] = val + n_saved += 1 + elif key in KEY_MAP: + section, skey = KEY_MAP[key] + cfg.setdefault(section, {})[skey] = val + n_saved += 1 + + save_config(cfg, self.deps.config_path) + 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: + try: + bv = data["brightness"] + if isinstance(bv, (int, float)) and bv >= 1: + bv = bv / 10.0 + self.deps.leds.set_brightness(float(bv)) + except Exception as e: + logger.warning("Failed to apply brightness: %s", e) + + if "channel_mode" in data and self.deps.pipeline is not None: + try: + mode = str(data["channel_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) + + # ── 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 + if jack_client: + jack_client.stop() + if "input_device" in data: + audio_sys.config.input_device = str(data["input_device"]) + if "output_device" in data: + audio_sys.config.output_device = str(data["output_device"]) + audio_sys.stop_jack() + ok = 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 + if jack_client: + jack_client.start() + 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: + logger.warning("Failed to apply device change: %s", e) + + # ── MIDI channel filter ── + if "midi_channel" in data and self.deps.midi is not None: + try: + val = data["midi_channel"] + if val is None or str(val).lower() == "omni": + self.deps.midi.set_channel_filter(None) + logger.info("MIDI channel filter set to omni") + else: + ch = int(val) - 1 # UI sends "1"-"16", store as 0-15 + self.deps.midi.set_channel_filter(ch) + logger.info("MIDI channel filter set to %d", ch) + except Exception as e: + logger.warning("Failed to apply midi_channel: %s", e) + + # ── Display settings ── + if "auto_dim" in data and self.deps.display is not None: + try: + self.deps.display.set_auto_dim(bool(data["auto_dim"])) + except Exception as e: + logger.warning("Failed to apply auto_dim: %s", e) + + if "screen_saver" in data and self.deps.display is not None: + try: + self.deps.display.set_screen_saver(str(data["screen_saver"])) + except Exception as e: + logger.warning("Failed to apply screen_saver: %s", e) + + # ── Tempo settings ── + if "tap_tempo" in data and self.deps.pipeline is not None: + try: + self.deps.pipeline.set_tempo(float(data["tap_tempo"])) + except Exception as e: + logger.warning("Failed to apply tap_tempo: %s", e) + + if "tempo_division" in data: + logger.info("tempo_division change noted (boot-only): %s", data["tempo_division"]) + + if "tap_tempo_mute" in data: + logger.info("tap_tempo_mute change noted (boot-only): %s", data["tap_tempo_mute"]) + + return {"ok": True, "saved": n_saved} + # ── FX block param schemas ─────────────────────────────── @app.get("/api/block-params/{fx_type}") @@ -1680,6 +1995,275 @@ class WebServer: ir_loader.get_irs() return {"ok": True, "path": str(path), "filename": path.name} + @app.get("/api/system") + async def get_system(): + import os, subprocess + info = {"hostname": os.uname().nodename, "kernel": os.uname().release} + try: + with open("/proc/uptime") as f: + secs = float(f.read().split()[0]) + info["uptime_seconds"] = round(secs) + d = int(secs // 86400); h = int((secs % 86400) // 3600); m = int((secs % 3600) // 60) + info["uptime"] = f"{d}d {h}h {m}m" + except: info["uptime"] = "?" + try: + r = subprocess.run(["cat","/sys/class/thermal/thermal_zone0/temp"], capture_output=True, text=True, timeout=3) + if r.returncode == 0: info["cpu_temp_c"] = round(int(r.stdout.strip()) / 1000, 1) + except: pass + try: + r = subprocess.run(["free","-m"], capture_output=True, text=True, timeout=3) + for line in r.stdout.splitlines(): + p = line.split() + if p and p[0] == "Mem:": + info["memory_mb"] = {"total": int(p[1]), "used": int(p[2]), "free": int(p[3])} + except: pass + try: + r = subprocess.run(["df","-h","/"], capture_output=True, text=True, timeout=3) + parts = r.stdout.splitlines()[1].split() + info["disk"] = {"total": parts[1], "used": parts[2], "avail": parts[3], "pct": parts[4]} + except: pass + try: + with open("/proc/loadavg") as f: + info["load"] = f.read().split()[:3] + except: pass + return info + + @app.get("/api/system/logs") + async def get_system_logs(lines: int = 50): + import subprocess + try: + 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": []} + + @app.post("/api/system/hostname") + async def set_hostname(data: dict): + import subprocess + hostname = data.get("hostname", "").strip() + if not hostname or not hostname.replace("-","").replace("_","").isalnum(): + raise HTTPException(status_code=400, detail="Invalid hostname") + try: + 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)) + + @app.get("/api/network") + async def get_network(): + import subprocess, re + info = {"ethernet": {}, "wifi": {}} + try: + r = subprocess.run(["ip","-4","addr","show","eth0"], capture_output=True, text=True, timeout=3) + m = re.search(r"inet (\S+)", r.stdout) + if m: info["ethernet"]["ip"] = m.group(1) + r2 = subprocess.run(["ip","link","show","eth0"], capture_output=True, text=True, timeout=3) + m2 = re.search(r"link/ether (\S+)", r2.stdout) + if m2: info["ethernet"]["mac"] = m2.group(1) + r3 = subprocess.run(["ip","route","show","default"], capture_output=True, text=True, timeout=3) + m3 = re.search(r"default via (\S+)", r3.stdout) + if m3: info["ethernet"]["gateway"] = m3.group(1) + except: pass + try: + with open("/etc/resolv.conf") as f: + dns = [line.split()[1] for line in f if line.startswith("nameserver")] + if dns: info["dns"] = dns + except: pass + return info + + @app.post("/api/network/static") + async def set_network_static(data: dict): + import subprocess, os + ip = data.get("ip", "").strip() + gw = data.get("gateway", "").strip() + mask = data.get("netmask", "24").strip() + dns_servers = data.get("dns", "9.9.9.9 149.112.112.112") + if not ip: + raise HTTPException(status_code=400, detail="ip required") + config = f"""auto eth0 +iface eth0 inet static + address {ip}/{mask} + gateway {gw} + dns-nameservers {dns_servers} +""" + try: + with open("/etc/network/interfaces.d/eth0", "w") as f: + f.write(config) + subprocess.run(["ifdown","eth0"], capture_output=True, timeout=10) + 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)) + + @app.post("/api/network/dhcp") + async def set_network_dhcp(): + import subprocess, os + try: + os.remove("/etc/network/interfaces.d/eth0") + except FileNotFoundError: pass + try: + subprocess.run(["ifdown","eth0"], capture_output=True, timeout=10) + subprocess.run(["ifup","eth0"], capture_output=True, timeout=30) + except: pass + return {"ok": True, "mode": "dhcp"} + + @app.get("/api/audio/devices") + async def list_audio_devices(): + import subprocess, re + try: + r = subprocess.run(["aplay","-l"], capture_output=True, text=True, timeout=5) + cards = [] + for line in r.stdout.splitlines(): + m = re.match(r"card (\d+):.*?\[(.*?)\].*device (\d+):.*?\[(.*?)\]", line) + if m: + cards.append({"card": int(m.group(1)), "name": m.group(2).strip(), + "device": int(m.group(3)), "desc": m.group(4).strip()}) + r2 = subprocess.run(["cat","/proc/asound/cards"], capture_output=True, text=True, timeout=3) + usb_id = None + for line in r2.stdout.splitlines(): + if "USB" in line or "usb" in line.lower(): + m = re.match(r"\s*(\d+)", line) + if m: usb_id = int(m.group(1)) + return {"devices": cards, "usb_card": usb_id, "suggested": f"hw:{usb_id or 0},0"} + except Exception as e: + return {"error": str(e), "devices": []} + + @app.get("/api/levels") + async def get_signal_levels(): + levels = {"channels": {}} + ja = self.deps.jack_audio + if ja and hasattr(ja, 'get_levels'): + try: levels = ja.get_levels() + except: pass + if not levels.get("channels"): + for ch in ["guitar", "bass", "keys", "vocals", "backing_tracks"]: + levels["channels"][ch] = {"input_rms": 0, "input_peak": 0, "output_rms": 0, "output_peak": 0} + return levels + + @app.delete("/api/models/{name:path}") + async def delete_model(name: str): + import os + from pathlib import Path + models_dir = Path(self.deps.config.get("models", {}).get("dir", "/home/oplabs/projects/pi-multifx-pedal/src/models/nam")) + target = models_dir / name + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="Model not found") + if target.suffix not in (".nam", ".wav", ".onnx"): + raise HTTPException(status_code=400, detail="Not a model file") + os.remove(str(target)) + nam = self.deps.nam_host + if nam: nam.list_available_models() + return {"ok": True, "deleted": name} + + @app.delete("/api/irs/{name:path}") + async def delete_ir(name: str): + import os + from pathlib import Path + irs_dir = Path(self.deps.config.get("irs", {}).get("dir", "/home/oplabs/projects/pi-multifx-pedal/src/models/ir")) + target = irs_dir / name + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="IR not found") + if target.suffix != ".wav": + raise HTTPException(status_code=400, detail="Not an IR file") + os.remove(str(target)) + ir_loader = self.deps.ir_loader + if ir_loader: ir_loader.get_irs() + return {"ok": True, "deleted": name} + + @app.get("/api/midi/mappings") + async def get_midi_mappings(): + mappings = {} + if self.deps.midi and hasattr(self.deps.midi, 'get_mappings'): + try: mappings = self.deps.midi.get_mappings() + except: pass + return {"mappings": mappings} + + @app.post("/api/midi/learn") + async def midi_learn(data: dict = None): + if data and "enable" in data: + enable = bool(data["enable"]) + if self.deps.midi and hasattr(self.deps.midi, 'set_learn_mode'): + try: self.deps.midi.set_learn_mode(enable) + except: pass + return {"learn_active": enable} + if data and "param" in data: + if self.deps.midi and hasattr(self.deps.midi, 'set_mapping'): + try: self.deps.midi.set_mapping(data["param"], data.get("cc")) + except: pass + return {"mapped": data["param"]} + return {"learn_active": False, "mappings": {}} + + @app.post("/api/backup") + async def create_backup(): + import subprocess, os, datetime + backup_dir = "/tmp/pedal-backups" + os.makedirs(backup_dir, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"pedal-backup-{ts}.tar.gz" + outpath = os.path.join(backup_dir, filename) + cfg_path = os.path.expanduser("~/.pedal") + try: + subprocess.run(["tar","-czf",outpath,"-C",os.path.dirname(cfg_path),os.path.basename(cfg_path)], check=True, timeout=30) + 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)) + + @app.get("/api/backup/list") + async def list_backups(): + import os, datetime + backup_dir = "/tmp/pedal-backups" + os.makedirs(backup_dir, exist_ok=True) + bks = [] + for f in sorted(os.listdir(backup_dir), reverse=True): + fp = os.path.join(backup_dir, f) + if os.path.isfile(fp): + bks.append({"name": f, "size": os.path.getsize(fp), + "modified": datetime.datetime.fromtimestamp(os.path.getmtime(fp)).isoformat()}) + return {"backups": bks} + + @app.post("/api/restore") + async def restore_backup(data: dict): + import subprocess, os + filename = data.get("filename") if data else None + if not filename: + return {"error": "filename required"} + backup_path = os.path.join("/tmp/pedal-backups", filename) + if not os.path.exists(backup_path): + raise HTTPException(status_code=404, detail="Backup not found") + try: + 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)) + + @app.get("/api/diagnostics") + async def get_diagnostics(): + import subprocess + diag = {} + ja = self.deps.jack_audio + diag["jack"] = {"running": ja is not None and hasattr(ja, 'is_running') and ja.is_running()} + nam = self.deps.nam_host + diag["nam"] = {"loaded": nam is not None and nam.current_model is not None} + if diag["nam"]["loaded"]: diag["nam"]["model"] = str(nam.current_model) + midi = self.deps.midi + diag["midi"] = {"available": midi is not None} + try: + r = subprocess.run(["iwgetid"], capture_output=True, text=True, timeout=3) + diag["wifi"] = {"connected": r.returncode == 0, "ssid": r.stdout.strip() if r.returncode == 0 else None} + except: diag["wifi"] = {"connected": False} + bt = getattr(self.deps, 'bt_controller', None) + diag["bluetooth"] = {"available": bt is not None} + if self.deps.audio_system: + try: + ap = self.deps.audio_system.config + diag["audio"] = {"profile": getattr(ap, 'profile', '?'), "rate": getattr(ap, 'rate', '?')} + except: diag["audio"] = {"profile": "?"} + diag["api"] = {"status": "ok"} + return diag + + + # ── Channel-prefixed route aliases ────────────────────── # Every state/control endpoint that accepts ?channel= is also # available at /api/channel/{channel}/... for explicit routing. @@ -1853,6 +2437,31 @@ class WebServer: "nam_model": current_model_name, "ir_loaded": bool(ir and ir.is_loaded) if ir else False, "ir_name": current_ir_name, + "blocks": ( + [ + { + "fx_type": b.fx_type.value, + "block_id": b.block_id, + "enabled": b.enabled, + "bypass": b.bypass, + "params": dict(b.params), + "nam_model_path": b.nam_model_path, + "nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "", + "ir_file_path": b.ir_file_path, + "ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "", + "name": ( + Path(b.nam_model_path).stem.replace("_", " ") + if b.nam_model_path + else Path(b.ir_file_path).stem.replace("_", " ") + if b.ir_file_path + else b.fx_type.value.replace("_", " ").title() + ), + } + for b in (preset.chain if preset else []) + ] + if preset + else [] + ), "channel_mode": self._channel_mgr.mode, # Audio levels from pipeline (RMS float32 normalized 0.0-1.0, scaled to 0-100) "input_level": ( diff --git a/src/web/ui-v2-dist/index.html b/src/web/ui-v2-dist/index.html new file mode 100644 index 0000000..07f2c20 --- /dev/null +++ b/src/web/ui-v2-dist/index.html @@ -0,0 +1,730 @@ + + +
+ + +