diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..747a78f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + PYTHON_VERSION: "3.11" + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install system deps + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libsndfile1 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff + + - name: Lint with ruff + run: | + ruff check src/ tests/ + + - name: Test with pytest + run: | + python -m pytest tests/ -v --tb=short -x diff --git a/.gitignore b/.gitignore index fbe19dc..fe838ff 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,9 @@ config.json .env *.swp .scone/ -systemd/ \ No newline at end of file +systemd/ +.pytest_cache/ +test_write.txt +test-drc.rpt +test_empty-drc.rptpatch_*.py +tests/test_issue_*.py diff --git a/README.md b/README.md index 7915abc..e2c41b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pi Multi-FX Pedal 🎸 -A real-time guitar multi-FX pedal running on **Raspberry Pi 4B** with **32 DSP effects**, **NAM A2 neural amp modeling**, **IR cab simulation**, **4-Cable Method** routing, and a **web UI** on `pedal.local:8080`. +A real-time guitar multi-FX pedal running on **Raspberry Pi 4B** with **32 DSP effects**, **NAM A2 neural amp modeling**, **IR cab simulation**, **4-Cable Method** routing, and a **web UI** on `pedal.local`. ## Quick Install (Focusrite 2i2 Test) @@ -85,8 +85,8 @@ python3 main.py ### 7. Open the Web UI From any device on the same network: -- **Browser:** http://pedal.local:8080 -- Or use the IP: http://192.168.x.x:8080 +- **Browser:** http://pedal.local +- Or use the IP: http://192.168.x.x ### WiFi Hotspot (No Network? No Problem) @@ -99,7 +99,7 @@ sudo bash scripts/setup-wifi-ap.sh # Or with custom SSID/password: sudo bash scripts/setup-wifi-ap.sh --ssid "Pi-Pedal" --psk "yourpassword" -# Phone connects to "Pi-Pedal" → open http://pedal.local:8080 +# Phone connects to "Pi-Pedal" → open http://pedal.local # To turn it off and go back to client mode: sudo bash scripts/setup-wifi-ap.sh --disable @@ -130,6 +130,58 @@ cp etc/config-focusrite-4cm.yaml ~/.pedal/config.yaml Toggle 4CM on/off from the Settings page in the web UI. +## Port & Privileges + +The web UI runs on **port 80** (standard HTTP), so URLs are clean — just `http://pedal.local` with no port number. + +Binding to port 80 requires elevated privileges. Either: + +1. **Run as root** (simplest): + ```bash + sudo python3 main.py + ``` + +2. **Grant the `cap_net_bind_service` capability** (safer — no root shell): + ```bash + sudo setcap 'cap_net_bind_service=+ep' $(readlink -f $(which python3)) + python3 main.py # no sudo needed + ``` + +3. **Override the port** via code change (development only): + Change `DEFAULT_PORT` in `src/web/server.py` and `port=80` in `main.py`. + +For development, option 2 is recommended. + +## WiFi / Bluetooth Permissions + +The WiFi hotspot and Bluetooth management features call `sudo` internally for system operations (hostapd, dnsmasq, iwlist, systemctl). If your user does not have passwordless `sudo`, these features will fail silently. + +Add a sudoers entry to grant passwordless access for the pedal user (`pi` or your username): + +```bash +sudo visudo -f /etc/sudoers.d/pi-pedal +``` + +Add one of the following, replacing `pi` with your actual username: + +**Minimal** (only the WiFi setup script): +``` +pi ALL=(ALL) NOPASSWD: /home/pi/projects/pi-multifx-pedal/scripts/setup-wifi-ap.sh * +pi ALL=(ALL) NOPASSWD: /usr/sbin/iwlist * +pi ALL=(ALL) NOPASSWD: /usr/sbin/iwconfig * +pi ALL=(ALL) NOPASSWD: /usr/bin/wpa_cli * +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl * hostapd +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl * dnsmasq +pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl * pi-bt-midi +``` + +**Permissive** (full passwordless sudo — convenient on a dedicated device): +``` +pi ALL=(ALL) NOPASSWD: ALL +``` + +The web UI surfaces permission errors with an `"insufficient_permissions"` key when `sudo` calls fail due to missing sudoers configuration. + ## Hardware | Component | Spec | diff --git a/docs/test-plan-focusrite.md b/docs/test-plan-focusrite.md index f6926fb..743515d 100644 --- a/docs/test-plan-focusrite.md +++ b/docs/test-plan-focusrite.md @@ -69,7 +69,7 @@ presets: | 2 | Wire guitar → Focusrite Input 1, Output 1 → amp | Physical | ✅ | | 3 | Start pedal: `python3 main.py --config ~/.pedal/config-mono.yaml` | Boots, JACK starts | ✅ | | 4 | Play guitar | Sound through amp, clean bypass | ✅ | -| 5 | Open browser to `http://pedal.local:8080` | Web UI shows dashboard | ✅ | +| 5 | Open browser to `http://pedal.local` | Web UI shows dashboard | ✅ | | 6 | Click bypass toggle on web UI | Pedal mutes → unmutes | ✅ | | 7 | Check preset name shows on dashboard | "Default" or first preset | ✅ | | 8 | Press preset up/down on footswitch or web UI | Changes preset, different sound | ✅ | diff --git a/docs/test-plan-ux-apis.md b/docs/test-plan-ux-apis.md index 13b5942..62c98ba 100644 --- a/docs/test-plan-ux-apis.md +++ b/docs/test-plan-ux-apis.md @@ -2,7 +2,7 @@ ## Scope -Test every block-related API endpoint and the UI's interaction with it. Covers both the React SPA at `pedal.local:8080` and the REST/WebSocket APIs. +Test every block-related API endpoint and the UI's interaction with it. Covers both the React SPA at `pedal.local` and the REST/WebSocket APIs. --- @@ -99,7 +99,7 @@ Core bug: previously `enabled` changed but `bypass` stayed true, so blocks remai | # | Test | Action | Expected | Result | |---|------|--------|----------|--------| -| 7.1 | Connect WS | `wscat -c ws://pedal.local:8080/ws` | Receives `{"type": "connected", "state": {...}}` | +| 7.1 | Connect WS | `wscat -c ws://pedal.local/ws` | Receives `{"type": "connected", "state": {...}}` | | 7.2 | Ping/pong | Send `{"type": "ping"}` | Response `{"type": "pong"}` | | 7.3 | Block toggle broadcast | Toggle block via PATCH /api/blocks | WS message `{"type": "block_toggled", ...}` | | 7.4 | Preset change broadcast | Activate preset via API | WS message `{"type": "preset_changed", ...}` | @@ -113,7 +113,7 @@ Core bug: previously `enabled` changed but `bypass` stayed true, so blocks remai | # | Test | Action | Expected | Result | |---|------|--------|----------|--------| -| 8.1 | Page loads | Navigate to `http://pedal.local:8080/` | React SPA renders, block chain visible | +| 8.1 | Page loads | Navigate to `http://pedal.local/` | React SPA renders, block chain visible | | 8.2 | NAM block visible | Check the block chain | "nam_amp" tile shown with correct icon | | 8.3 | Block label | Read tile text | Shows "nam_amp" or friendly name | | 8.4 | Toggle block ON/OFF | Click the block's power icon | Block appears enabled/disabled visually | @@ -162,12 +162,12 @@ Run in order. Mark each cell: For curl tests, use this template: ```bash -curl -sv http://pedal.local:8080/api/presets | python3 -m json.tool +curl -sv http://pedal.local/api/presets | python3 -m json.tool ``` For WS tests: ```bash # Connect, wait, then send toggle commands in another terminal -wscat -c ws://pedal.local:8080/ws +wscat -c ws://pedal.local/ws ``` diff --git a/etc/avahi/pi-multifx-pedal.service b/etc/avahi/pi-multifx-pedal.service index cc08d58..0d93d1e 100644 --- a/etc/avahi/pi-multifx-pedal.service +++ b/etc/avahi/pi-multifx-pedal.service @@ -8,14 +8,14 @@ Pi Multi-FX Pedal _http._tcp - 8080 + 80 path=/ product=Pi-Multi-FX-Pedal version=0.1.0 _pedal._tcp - 8080 + 80 api=/api ws=/ws diff --git a/frontend-react/src/App.jsx b/frontend-react/src/App.jsx index 4e1a7a9..46f9cf4 100644 --- a/frontend-react/src/App.jsx +++ b/frontend-react/src/App.jsx @@ -1444,7 +1444,7 @@ function SettingsScreen({ state, connected, refresh }) { const active = state?.wifi?.hotspot_active; try { if (active) await apiPost('/api/wifi/hotspot/disable'); - else await apiPost('/api/wifi/hotspot/enable', { ssid: 'Pi-Pedal', password: 'pedal1234' }); + else await apiPost('/api/wifi/hotspot/enable', { ssid: 'Pi-Pedal' }); setTimeout(refresh, 2000); } catch (e) { /* ignore */ } }; diff --git a/main.py b/main.py index a00f500..1736d88 100644 --- a/main.py +++ b/main.py @@ -607,6 +607,7 @@ class PedalApp: bypassed=self._bypassed, fx_active=fx_active, fx_bypass_states=fx_bypass_states, + hotspot_password=self._get_hotspot_password(), ) self.display.update(state) except Exception: @@ -616,6 +617,7 @@ class PedalApp: preset_name="Ready", bank_name="", bypassed=self._bypassed, + hotspot_password=self._get_hotspot_password(), ) self.display.update(state) @@ -623,6 +625,14 @@ class PedalApp: # Helpers # ═══════════════════════════════════════════════════════════════ + def _get_hotspot_password(self) -> str: + """Return hotspot password from config, or empty if not configured.""" + try: + from src.system.network import get_hotspot_password + return get_hotspot_password() + except Exception: + return "" + def _build_footswitch_layout(self) -> list[FootSwitch]: """Build FootSwitch list from config.""" layout_cfg = self._config.get("footswitch", {}).get("layout", DEFAULT_CONFIG["footswitch"]["layout"]) diff --git a/pyproject.toml b/pyproject.toml index 0d0e694..665c840 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,4 +13,11 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["src"] \ No newline at end of file +pythonpath = ["src"] + +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] \ No newline at end of file diff --git a/scripts/benchmark_fx.py b/scripts/benchmark_fx.py index 71b608e..3ce23a9 100644 --- a/scripts/benchmark_fx.py +++ b/scripts/benchmark_fx.py @@ -20,7 +20,7 @@ import time import numpy as np -from src.dsp.pipeline import AudioPipeline, BLOCK_SIZE, SAMPLE_RATE +from src.dsp.pipeline import AudioPipeline from src.presets.types import FXBlock, FXType, Preset # ── Test tone parameters ─────────────────────────────────────────── diff --git a/scripts/setup-mdns.sh b/scripts/setup-mdns.sh index 842a1ee..3355782 100755 --- a/scripts/setup-mdns.sh +++ b/scripts/setup-mdns.sh @@ -10,7 +10,7 @@ # 4. Installs the service advertisement file for the web UI # 5. Restarts avahi-daemon # -# After this, http://pedal.local:8080 works from any machine on the LAN. +# After this, http://pedal.local works from any machine on the LAN. # ─────────────────────────────────────────────────────────────────────── set -euo pipefail @@ -71,11 +71,11 @@ sudo systemctl restart avahi-daemon echo "" echo "═══ mDNS setup complete ═══" echo "The pedal should now be reachable at:" -echo " http://${HOSTNAME}.local:8080" +echo " http://${HOSTNAME}.local" echo "" echo "From any device on the same LAN, try:" echo " ping ${HOSTNAME}.local" -echo " curl http://${HOSTNAME}.local:8080/api/state" +echo " curl http://${HOSTNAME}.local/api/state" echo "" echo "NOTE: If resolution doesn't work immediately, wait ~10 seconds" echo "for mDNS propagation, or check: sudo systemctl status avahi-daemon" \ No newline at end of file diff --git a/scripts/setup-wifi-ap.sh b/scripts/setup-wifi-ap.sh index 67bcb07..fae9d9e 100755 --- a/scripts/setup-wifi-ap.sh +++ b/scripts/setup-wifi-ap.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ── Pi Multi-FX Pedal — WiFi Access Point Setup ──────────────────── # Enables the Pi to act as its own WiFi hotspot. -# Phone connects to "Pi-Pedal" → web UI at http://pedal.local:8080 +# Phone connects to "Pi-Pedal" → web UI at http://pedal.local # No venue WiFi or internet required. # # Usage: @@ -11,7 +11,7 @@ # # After setup: # Phone WiFi → join "Pi-Pedal" (password: changeme or your custom psk) -# Browser → http://pedal.local:8080 +# Browser → http://pedal.local # # Dual WiFi option (for internet while hotspot is active): # Plug a USB WiFi dongle. The built-in WiFi becomes the hotspot, @@ -25,7 +25,7 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # ── Defaults ────────────────────────────────────────────────────────── SSID="Pi-Pedal" -PSK="pedal1234" +PSK="$(head -c 12 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 12)" AP_IP="192.168.4.1" DISABLE=false @@ -206,8 +206,8 @@ echo "╚═══════════════════════ echo "" echo " 📶 SSID: $SSID" echo " 🔑 Password: $PSK" -echo " 🔗 Web UI: http://pedal.local:8080" -echo " 🌐 IP: http://$AP_IP:8080" +echo " 🔗 Web UI: http://pedal.local" +echo " 🌐 IP: http://$AP_IP" echo "" echo "Your phone should see '$SSID' in available WiFi networks." echo "Join it, then open the browser to the URL above." diff --git a/src/dsp/nam_host.py b/src/dsp/nam_host.py index 5efad77..e3eba71 100644 --- a/src/dsp/nam_host.py +++ b/src/dsp/nam_host.py @@ -17,8 +17,9 @@ from __future__ import annotations import json import logging +import os +import threading import time -import warnings from dataclasses import dataclass, field from enum import Enum from pathlib import Path @@ -101,6 +102,48 @@ def _build_linear(config: dict, weights: list) -> "torch.nn.Module": return LinearNAM(gain, bias) +def _validate_wavenet_arch(config: dict) -> None: + """Validate WaveNet config is standard A2 architecture. + + Raises ValueError with a clear message if unsupported features + (FiLM, head1x1, multi-array) are detected — prevents silent garbage + output from a corrupted model load. + + Checks performed: + 1. condition_size > 1 — indicates FiLM conditioning (extra weight tensors) + 2. head1x1 key in layer array — extra 1x1 head conv not in A2 format + 3. Multiple entries in layers array — multi-array not supported + """ + layers_cfg = config.get("layers", []) + if not layers_cfg: + raise ValueError("WaveNet config has no layers") + + la = layers_cfg[0] + + # FiLM detection: condition_size > 1 means conditioning inputs + # that require FiLM-specific weight tensors the A2 builder doesn't create + condition_size = la.get("condition_size", 1) + if condition_size > 1: + raise ValueError( + f"FiLM architecture (condition_size={condition_size}) is not supported. " + "Only standard A2 WaveNet without FiLM conditioning is supported." + ) + + # head1x1 detection: some models export an extra 1x1 conv in the head + if "head1x1" in la: + raise ValueError( + "head1x1 architecture detected — not supported. " + "Only standard A2 WaveNet head is supported." + ) + + # Multiple layer arrays are not supported + if len(layers_cfg) > 1: + raise ValueError( + f"Multi-array WaveNet ({len(layers_cfg)} layer arrays) is not supported. " + "Only single-array A2 WaveNet is supported." + ) + + def _build_wavenet(config: dict, weights: list) -> "torch.nn.Module": """Build a WaveNet NAM model from config and flat weight array. @@ -130,6 +173,9 @@ def _build_wavenet(config: dict, weights: list) -> "torch.nn.Module": if not layers_cfg: raise ValueError("WaveNet config has no layers") + # ── Validate architecture is A2-compatible before building ───────── + _validate_wavenet_arch(config) + # ── Extract layer array config (we use first/only layer array) ───── la = layers_cfg[0] # A2 models have single layer array input_size = la.get("input_size", 1) @@ -346,12 +392,14 @@ def _import_wavenet_weights(model: "torch.nn.Module", weights: list) -> None: model.head_scale = float(w[i]) i += 1 - # Verify we consumed all weights + # Verify we consumed all weights — mismatch means unsupported arch if i != len(w): - warnings.warn( - f"NAM weight import consumed {i}/{len(w)} weights. " + raise ValueError( + f"NAM weight import consumed {i}/{len(w)} weights " + f"(expected {len(w)}, got {i}). " f"Model has {sum(p.numel() for p in model.parameters())} params. " - f"Check weight order matches architecture." + f"Weight order does not match architecture — likely FiLM, head1x1, " + f"or other non-A2 format." ) @@ -454,6 +502,10 @@ class NAMHost: self._torch = None # lazy import self._torch_device = None # resolved device object + # Thread-safety lock — protects _inference_model from concurrent + # access by the audio thread (process) and control thread (load/unload) + self._lock = threading.Lock() + # Timing stats self._timing_samples: list[float] = [] @@ -462,8 +514,13 @@ class NAMHost: self._crossfade_buf: Optional[np.ndarray] = None self._crossfade_pos: int = 0 - # Simple model cache (path -> model instance) - self._model_cache: dict[str, object] = {} + # Model cache keyed by (path, mtime_ns) for automatic + # invalidation on re-upload (same filename, different content) + self._model_cache: dict[tuple[str, int], object] = {} + self._lock = threading.Lock() + + # Last error message (for UI surfacing on failed load) + self._last_error: str = "" self._models_dir.mkdir(parents=True, exist_ok=True) @@ -497,16 +554,44 @@ class NAMHost: return 0.0 return float(np.mean(self._timing_samples[-50:])) + @property + def last_error(self) -> str: + """Last model-load error message (empty string if last load succeeded).""" + return self._last_error + + # ── Cache management ─────────────────────────────────────────── + + def evict_cache(self, model_path: str) -> None: + """Remove a model from cache so it's reloaded on next use. + + Called automatically by the upload endpoint to invalidate the + cached representation when a file is replaced. + """ + path = str(Path(model_path)) + with self._lock: + keys_to_remove = [ + k for k in self._model_cache + if isinstance(k, tuple) and k[0] == path + ] + for k in keys_to_remove: + del self._model_cache[k] + if keys_to_remove: + logger.debug("Evicted %d cache entries for %s", len(keys_to_remove), path) + # ── Model loading ────────────────────────────────────────────── def load_model(self, model_path: str) -> bool: """Load a ``.nam`` model file and build its inference model. - Returns ``True`` on success, ``False`` on error. + Returns ``True`` on success, ``False`` on error. On failure, + ``self.last_error`` contains the error message. """ + self._last_error = "" path = Path(model_path) if not path.exists() or path.suffix.lower() not in (".nam",): - logger.error("Model not found or invalid: %s", model_path) + msg = f"Model not found or invalid: {model_path}" + logger.error(msg) + self._last_error = msg return False # Read file @@ -514,56 +599,68 @@ class NAMHost: with open(path, "r") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: - logger.error("Failed to parse .nam file: %s", e) + msg = f"Failed to parse .nam file: {e}" + logger.error(msg) + self._last_error = msg return False - architecture = data.get("architecture", "Linear") - config = data.get("config", {}) - size_mb = path.stat().st_size / (1024 * 1024) - - # Build metadata - self._loaded_model = NAMModel( - name=path.stem, - path=str(path), - size_mb=size_mb, - architecture=architecture, - receptive_field=config.get("receptive_field", 0), - ) - - # Build PyTorch inference model - model_ok = self._build_inference(data) + # Build PyTorch inference model (includes cache check and architecture validation) + model_ok = self._build_inference(data, model_path=model_path) if model_ok: + architecture = data.get("architecture", "Linear") + config = data.get("config", {}) + size_mb = path.stat().st_size / (1024 * 1024) + + self._loaded_model = NAMModel( + name=path.stem, + path=str(path), + size_mb=size_mb, + architecture=architecture, + receptive_field=config.get("receptive_field", 0), + ) # Store param count in metadata params = sum(p.numel() for p in self._inference_model.parameters()) self._loaded_model.params_k = round(params / 1000, 1) - logger.info( - "Loaded NAM model: %s (%.1f MB, %s, %s family, rf=%d, params=%.1fK)", - self._loaded_model.name, - size_mb, - architecture, - self._loaded_model.family, - self._loaded_model.receptive_field, - self._loaded_model.params_k, - ) - return True + logger.info( + "Loaded NAM model: %s (%.1f MB, %s, %s family, rf=%d, params=%.1fK)", + self._loaded_model.name, size_mb, architecture, + self._loaded_model.family, self._loaded_model.receptive_field, + self._loaded_model.params_k, + ) + else: + self._loaded_model = None + logger.warning("Failed to load model: %s", self._last_error) - def _build_inference(self, data: dict) -> bool: + return model_ok + + def _build_inference(self, data: dict, model_path: str = "") -> bool: """Instantiate a PyTorch model from a NAM config dict. Uses our lightweight in-process builder that handles SlimmableContainer (A2), WaveNet, and Linear architectures. + + The cache is keyed by (model_path, mtime_ns) so re-uploading + with the same filename but different content naturally invalidates + the cache entry. """ self._import_torch() - path = data.get("path", "") - cache_key = str(path) + # Build cache key from path + mtime for invalidation on re-upload + cache_key: tuple[str, int] | None = None + if model_path: + try: + mtime_ns = os.path.getmtime(model_path) + cache_key = (model_path, int(mtime_ns)) + except OSError: + cache_key = None # Check cache first - if cache_key and cache_key in self._model_cache: - self._inference_model = self._model_cache[cache_key] - self._inference_model.eval() + if cache_key is not None and cache_key in self._model_cache: + with self._lock: + self._inference_model = self._model_cache[cache_key] + self._inference_model.eval() return True try: @@ -575,28 +672,38 @@ class NAMHost: model = model.to(self._torch_device) # Cache it - if cache_key: - self._model_cache[cache_key] = model + if cache_key is not None: + with self._lock: + self._model_cache[cache_key] = model - self._inference_model = model + # Swap reference under lock so process() sees a consistent model + with self._lock: + self._inference_model = model return True except ImportError as exc: - logger.warning("Required package not installed; inference unavailable: %s", exc) - self._inference_model = None + msg = f"Required package not installed; inference unavailable: {exc}" + logger.warning(msg) + self._last_error = str(exc) + with self._lock: + self._inference_model = None return False except Exception as exc: - logger.warning("Failed to build inference model: %s", exc) - self._inference_model = None + msg = f"Failed to build inference model: {exc}" + logger.warning(msg) + self._last_error = str(exc) + with self._lock: + self._inference_model = None return False def unload(self) -> None: """Unload the current model and free resources.""" - self._loaded_model = None - if self._inference_model is not None: - self._inference_model = self._inference_model.to("cpu") - del self._inference_model - self._inference_model = None + with self._lock: + self._loaded_model = None + if self._inference_model is not None: + self._inference_model = self._inference_model.to("cpu") + del self._inference_model + self._inference_model = None self._torch = None self._torch_device = None self._timing_samples.clear() @@ -627,6 +734,10 @@ class NAMHost: def process(self, audio_block: np.ndarray) -> np.ndarray: """Run a block of audio through the loaded NAM model. + Thread-safe: captures a local reference to ``_inference_model`` + under ``_lock`` so the control thread can safely swap models + without racing with the audio callback. + Handles 1-D (``(N,)``) and 2-D (``(1, N)``) float32 input. If no model is loaded, passes audio through unchanged. @@ -640,7 +751,11 @@ class NAMHost: np.ndarray Processed audio, same shape. """ - if self._inference_model is None: + # Capture model reference atomically — the model instance stays alive + # through this local ref even if the control thread swaps it out. + with self._lock: + model = self._inference_model + if model is None: # Pass-through when no model is loaded return audio_block @@ -659,7 +774,7 @@ class NAMHost: if str(self._torch_device) != "cpu": x = x.to(self._torch_device) - y = self._inference_model(x) + y = model(x) # Squeeze back if was_1d: diff --git a/src/dsp/nam_router.py b/src/dsp/nam_router.py index c13e4be..ff6d903 100644 --- a/src/dsp/nam_router.py +++ b/src/dsp/nam_router.py @@ -13,6 +13,7 @@ Usage: from __future__ import annotations import logging +import threading from pathlib import Path from typing import Optional @@ -56,6 +57,10 @@ class NAMEngineRouter: self._create_engine() + # Thread-safety lock — protects _engine and _loaded_path from + # concurrent access between audio callback and set_engine() + self._lock = threading.Lock() + # ── Engine lifecycle ──────────────────────────────────────────── def _create_engine(self) -> None: @@ -79,6 +84,9 @@ class NAMEngineRouter: def set_engine(self, mode: str) -> bool: """Switch engine type at runtime. + Thread-safe: holds ``_lock`` during the engine swap to prevent + the audio callback from seeing a half-destroyed engine. + Unloads the current model and reloads it in the new engine. Returns True on success, False if the model couldn't be reloaded. """ @@ -87,14 +95,18 @@ class NAMEngineRouter: if mode not in self.ENGINE_MODES: raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {mode!r}") - # Save currently loaded model path - old_path = self._loaded_path - self.unload() + with self._lock: + # Save currently loaded model path + old_path = self._loaded_path + if self._engine is not None: + self._engine.unload() + self._engine = None + self._loaded_path = None - self._engine_mode = mode - self._create_engine() + self._engine_mode = mode + self._create_engine() - # Reload current model if one was loaded + # Reload current model if one was loaded (outside lock — may do I/O) if old_path: logger.info("Reloading model %s in new engine %s", old_path, mode) return self.load_model(old_path) @@ -109,11 +121,13 @@ class NAMEngineRouter: @property def is_loaded(self) -> bool: - return self._engine is not None and self._engine.is_loaded + with self._lock: + return self._engine is not None and self._engine.is_loaded @property def current_model(self): - return getattr(self._engine, 'current_model', None) if self._engine else None + with self._lock: + return getattr(self._engine, 'current_model', None) if self._engine else None @property def avg_inference_ms(self) -> float: @@ -131,43 +145,70 @@ class NAMEngineRouter: def _crossfade_buf(self): """For pipeline crossfade compatibility. PyTorch NAMHost has this natively; FastNAMHost has None.""" - if hasattr(self._engine, '_crossfade_buf'): - return self._engine._crossfade_buf + with self._lock: + if hasattr(self._engine, '_crossfade_buf'): + return self._engine._crossfade_buf return None def apply_crossfade(self, buf: np.ndarray) -> np.ndarray: - if hasattr(self._engine, 'apply_crossfade'): - return self._engine.apply_crossfade(buf) + with self._lock: + engine = self._engine + if engine is not None and hasattr(engine, 'apply_crossfade'): + return engine.apply_crossfade(buf) return buf # ── Model loading ─────────────────────────────────────────────── def load_model(self, model_path: str) -> bool: - if self._engine is None: - return False - ok = self._engine.load_model(model_path) - if ok: - self._loaded_path = model_path - return ok + with self._lock: + if self._engine is None: + return False + ok = self._engine.load_model(model_path) + if ok: + self._loaded_path = model_path + return ok def unload(self) -> None: - if self._engine is not None: - self._engine.unload() - self._loaded_path = None + with self._lock: + if self._engine is not None: + self._engine.unload() + self._loaded_path = None def set_block_size(self, block_size: int) -> None: self._block_size = block_size if self._engine is not None and hasattr(self._engine, 'set_block_size'): self._engine.set_block_size(block_size) + @property + def last_error(self) -> str: + """Last model-load error from the active engine.""" + with self._lock: + if self._engine is not None and hasattr(self._engine, 'last_error'): + return self._engine.last_error + return "" + + def evict_cache(self, model_path: str) -> None: + """Evict model from engine's model cache.""" + with self._lock: + if self._engine is not None and hasattr(self._engine, 'evict_cache'): + self._engine.evict_cache(model_path) + def warm_up(self) -> None: if self._engine is not None and hasattr(self._engine, 'warm_up'): self._engine.warm_up(block_size=self._block_size) def process(self, audio_block: np.ndarray) -> np.ndarray: - if self._engine is None: + """Run inference through the current engine. + + Thread-safe: captures the engine reference under ``_lock`` so + ``set_engine()`` can swap engines without racing with the audio + callback. + """ + with self._lock: + engine = self._engine + if engine is None: return audio_block - return self._engine.process(audio_block) + return engine.process(audio_block) # ── Model discovery ───────────────────────────────────────────── diff --git a/src/dsp/pipeline.py b/src/dsp/pipeline.py index ba9bdff..e48b1fc 100644 --- a/src/dsp/pipeline.py +++ b/src/dsp/pipeline.py @@ -28,8 +28,9 @@ from ..presets.types import FXBlock, FXType, Preset logger = logging.getLogger(__name__) -BLOCK_SIZE = 256 # Samples per JACK callback -SAMPLE_RATE = 48000 # Standard guitar audio rate +# BLOCK_SIZE and SAMPLE_RATE are now instance attributes on AudioPipeline +# (self._block_size, self._sample_rate) — set at construction time. +# Do not re-introduce module-level constants; use the instance properties. # ── Biquad coefficient helpers ───────────────────────────────────── @@ -379,6 +380,7 @@ class AudioPipeline: for block in preset.chain: entry = { + "block_id": block.block_id, "fx_type": block.fx_type, "enabled": block.enabled, "bypass": block.bypass, @@ -410,6 +412,42 @@ class AudioPipeline: preset.name, len(self._chain), self._routing_mode, self._routing_breakpoint) + def set_block_in_place(self, block_id: str, *, + params: dict | None = None, + enabled: bool | None = None, + bypass: bool | None = None) -> bool: + """Update a block's params / enabled / bypass in-place without clearing DSP state. + + Unlike :meth:`load_preset`, this mutates ``self._chain[idx]`` directly + while preserving ``self._state`` and ``self._coeffs``. Reverb tails, + delay buffers, and filter states continue uninterrupted. + + Must be called after the corresponding ``FXBlock`` on the preset object + has already been updated. This method only needs to push the change + into the real-time chain. + + Args: + block_id: The ``block_id`` of the target block. + params: Subset of params to update (or ``None`` to skip). + enabled: New enabled state (or ``None`` to skip). + bypass: New bypass state (or ``None`` to skip). + + Returns: + ``True`` if a matching block was found and updated, ``False`` otherwise. + """ + with self._lock: + for entry in self._chain: + if entry.get("block_id") == block_id: + if params is not None: + entry["params"].update(params) + if enabled is not None: + entry["enabled"] = bool(enabled) + if bypass is not None: + entry["bypass"] = bool(bypass) + return True + logger.warning("set_block_in_place: block_id=%r not found in chain", block_id) + return False + def process(self, audio_in: np.ndarray) -> np.ndarray: """Process a block of audio through the entire FX chain. diff --git a/src/presets/types.py b/src/presets/types.py index cec7384..74a0658 100644 --- a/src/presets/types.py +++ b/src/presets/types.py @@ -141,9 +141,35 @@ class Preset: """Snapshot slots 1-8. Empty dict means no snapshots saved.""" +def resolve_block_by_key(preset: Preset, param_key: str) -> Optional[tuple[FXBlock, str]]: + """Resolve a ``param_key`` like ``"delay.feedback"`` or ``".feedback"`` + to a specific ``(FXBlock, param_name)`` in the preset's chain. + + Tries ``block_id`` match first (for multiple blocks of the same type), + then falls back to ``fx_type.value`` match. Returns ``None`` if no + block matches the key prefix or the key format is invalid. + + This is the shared resolver used by both MIDI CC mapping + (``PedalApp._on_midi_cc``) and the REST API + (``WebServer.update_block_params``). + """ + parts = param_key.rsplit('.', 1) + if len(parts) != 2: + return None + prefix, param_name = parts + + for b in preset.chain: + if b.block_id == prefix: + return (b, param_name) + for b in preset.chain: + if b.fx_type.value == prefix: + return (b, param_name) + return None + + @dataclass class Bank: """A bank of presets (typically 4 per bank).""" name: str number: int - presets: list[Preset] = field(default_factory=list) \ No newline at end of file + presets: list[Preset] = field(default_factory=list) diff --git a/src/system/network.py b/src/system/network.py index 435c268..d99267e 100644 --- a/src/system/network.py +++ b/src/system/network.py @@ -13,6 +13,8 @@ import json import logging import os import re +import secrets +import string import subprocess import tempfile import time @@ -25,6 +27,7 @@ logger = logging.getLogger(__name__) HOTSPOT_SCRIPT = Path(__file__).resolve().parent.parent.parent / "scripts" / "setup-wifi-ap.sh" WPA_SUPPLICANT_CONF = Path("/etc/wpa_supplicant/wpa_supplicant.conf") +CONFIG_PATH = Path.home() / ".pedal" / "config.yaml" # ── Capability detection ────────────────────────────────────────────────────── @@ -356,6 +359,43 @@ def _wpa_connect(ssid: str, password: str) -> bool: # ── Hotspot control ─────────────────────────────────────────────────────────── +def get_hotspot_password() -> str: + """Get the persisted hotspot password, generating a random one on first boot. + + Looks up ``hotspot.password`` in ``~/.pedal/config.yaml``. If unset, + generates a cryptographically random 12-character alphanumeric password, + persists it, and returns it. This ensures each device gets a unique, + non-guessable password. + + Returns: + The hotspot password (always at least 8 characters). + """ + try: + from src.system.config import load_config, save_config + cfg = load_config(CONFIG_PATH) + hotspot_cfg = cfg.setdefault("hotspot", {}) + password = hotspot_cfg.get("password") + if password and len(password) >= 8: + return password + except Exception: + pass # fall through to generate a fresh one + + # Generate a random 12-char alphanumeric password + alphabet = string.ascii_letters + string.digits + password = "".join(secrets.choice(alphabet) for _ in range(12)) + + # Try to persist + try: + from src.system.config import load_config, save_config + cfg = load_config(CONFIG_PATH) + cfg.setdefault("hotspot", {})["password"] = password + save_config(cfg, CONFIG_PATH) + except Exception: + logger.warning("Could not persist hotspot password to config") + + return password + + def _hotspot_status() -> dict[str, Any]: """Check if the WiFi hotspot is currently active.""" # Check if hostapd is running @@ -366,7 +406,7 @@ def _hotspot_status() -> dict[str, Any]: active = result.stdout.strip() == "active" if not active: - return {"active": False, "ssid": None, "clients": 0, "ip": None} + return {"active": False, "ssid": None, "password": None, "clients": 0, "ip": None} # Read SSID from config ssid = None @@ -386,16 +426,28 @@ def _hotspot_status() -> dict[str, Any]: if iw_result.returncode == 0: clients = len([l for l in iw_result.stdout.split("\n") if "Station" in l]) + # Read password from config + password = None + try: + from src.system.config import load_config + cfg = load_config(CONFIG_PATH) + password = cfg.get("hotspot", {}).get("password") + except Exception: + pass + return { "active": True, "ssid": ssid or "Pi-Pedal", + "password": password, "clients": clients, "ip": "192.168.4.1", } -def _hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> bool: +def _hotspot_enable(ssid: str = "Pi-Pedal", password: str | None = None) -> bool: """Enable WiFi hotspot mode by calling the setup script.""" + if not password: + raise ValueError("hotspot password is required") result = _run( ["sudo", "bash", str(HOTSPOT_SCRIPT), "--ssid", ssid, "--psk", password], timeout=60, check=False, @@ -578,16 +630,17 @@ def wifi_forget(ssid: str) -> dict[str, Any]: def hotspot_status() -> dict[str, Any]: """Get the current hotspot status. - Returns dict with active (bool), ssid (str|None), clients (int), ip (str|None). + Returns dict with active (bool), ssid (str|None), password (str|None), + clients (int), ip (str|None). """ try: return _hotspot_status() except Exception as e: logger.error("Hotspot status failed: %s", str(e)) - return {"active": False, "ssid": None, "clients": 0, "ip": None} + return {"active": False, "ssid": None, "password": None, "clients": 0, "ip": None} -def hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> dict[str, Any]: +def hotspot_enable(ssid: str = "Pi-Pedal", password: str | None = None) -> dict[str, Any]: """Enable the WiFi hotspot. Args: @@ -597,6 +650,8 @@ def hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> dict[ Returns: dict with ok (bool) and error (str|None) keys. """ + if not password: + return {"ok": False, "error": "Password is required"} if len(password) < 8: return {"ok": False, "error": "Password must be at least 8 characters"} try: diff --git a/src/ui/display.py b/src/ui/display.py index bae5aa9..0044909 100644 --- a/src/ui/display.py +++ b/src/ui/display.py @@ -52,6 +52,7 @@ class DisplayState: tuner_cents: int = 0 param_name: str = "" param_value: float = 0.0 + hotspot_password: str = "" # Shown in footer when hotspot active def _import_display_driver(): @@ -208,7 +209,10 @@ class DisplayController: draw.text((MARGIN, fx_y), fx_line, fill=255, font=font) # Footer — preset number or info - draw.text((MARGIN, FOOTER_Y), s.mode.upper(), fill=255, font=font) + footer_text = s.mode.upper() + if s.hotspot_password: + footer_text = f"AP:{s.hotspot_password}" + draw.text((MARGIN, FOOTER_Y), footer_text, fill=255, font=font) def _render_tuner(self, draw) -> None: """Render tuner mode — note name + cents indicator.""" diff --git a/src/web/server.py b/src/web/server.py index 337ce19..6849ded 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -18,6 +18,7 @@ import logging import math import mimetypes import datetime +import threading from contextlib import asynccontextmanager from dataclasses import dataclass, field from pathlib import Path @@ -340,6 +341,8 @@ class WebServer: self._task: Optional[asyncio.Task] = None self._tonedownload: Optional[Tone3000Client] = None self._preset_write_locks: dict[str, asyncio.Lock] = {} + self._debounce_timers: dict[str, threading.Timer] = {} + self._pending_presets: dict[str, Any] = {} self._needs_reboot: bool = False self._app = self._build_app() @@ -365,6 +368,51 @@ class WebServer: self._preset_write_locks[key] = asyncio.Lock() return self._preset_write_locks[key] + # ── Debounced preset save (write-behind) ───────────────────────── + + def _debounced_save_preset(self, channel: str, bank: int, program: int, + preset: Any) -> None: + """Save preset to disk with a 500 ms write-behind timer. + + Each call cancels any pending save for the same ``(channel, bank, + program)``, then schedules a new one. This prevents a storm of + disk writes when the user drags a slider in the UI — only the + final value is persisted, 500 ms after the last tweak. + + Args: + channel: Channel name (e.g. ``\"guitar\"``). + bank: Bank number. + program: Program number. + preset: The :class:`~src.presets.types.Preset` to persist. + """ + key = f"{channel}:{bank}:{program}" + + # Cancel any pending timer + old = self._debounce_timers.pop(key, None) + if old is not None: + old.cancel() + + # Hold a reference so GC doesn't collect it before the timer fires + self._pending_presets[key] = preset + + def _flush() -> None: + try: + pm, _pl, _nam, _ir = self._channel_deps(channel) + if pm is not None: + from ..presets.types import Channel + pm.save(preset, channel=Channel(channel)) + logger.debug("Debounced save: ch=%s b=%d p=%d", channel, bank, program) + except Exception as exc: + logger.warning("Debounced preset save failed: %s", exc) + finally: + # Clean up the held reference on completion + self._pending_presets.pop(key, None) + + t = threading.Timer(0.5, _flush) + t.daemon = True + t.start() + self._debounce_timers[key] = t + # ── App factory ───────────────────────────────────────────────────── # ── Auth helpers ───────────────────────────────────────────── @@ -979,10 +1027,15 @@ class WebServer: 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) + # In-place pipeline update — preserves DSP state + if pl is not None: + pl.set_block_in_place( + b.block_id, + enabled=b.enabled, + bypass=b.bypass, + ) + # Deferred disk write — 500 ms debounce + self._debounced_save_preset(channel, bank, program, preset) await self._manager.broadcast({ "type": "block_toggled", "channel": channel, @@ -1051,9 +1104,27 @@ class WebServer: else: # Safe: key already validated against schema b.params[key] = float(val) - pm.save(preset, channel=Channel(channel)) - if pl: - pl.load_preset(preset) + # In-place pipeline update — preserves DSP state + if pl is not None: + changed_params = { + k: float(v) for k, v in data.items() + if k not in ("id", "block_id", "nam_model_path", "ir_file_path", "bypass", "enabled", "subtype") + and k in valid_param_keys + } + changed_enabled = None + changed_bypass = None + if "enabled" in data: + changed_enabled = bool(data["enabled"]) + if "bypass" in data: + changed_bypass = bool(data["bypass"]) + pl.set_block_in_place( + b.block_id, + params=changed_params or None, + enabled=changed_enabled, + bypass=changed_bypass, + ) + # Deferred disk write — 500 ms debounce + self._debounced_save_preset(channel, bank, program, preset) await self._manager.broadcast({ "type": "block_params_changed", "channel": channel, @@ -1411,12 +1482,12 @@ class WebServer: Body: {ssid: str (optional), password: str (optional)} """ - from ..system.network import hotspot_enable + from ..system.network import hotspot_enable, get_hotspot_password loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, partial(hotspot_enable, ssid=data.get("ssid", "Pi-Pedal"), - password=data.get("password", "pedal1234"), + password=data.get("password", get_hotspot_password()), ), ) return result diff --git a/tests/test_network.py b/tests/test_network.py index 78c2ce7..edbe35a 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -29,6 +29,7 @@ from src.system.network import ( hotspot_status, hotspot_enable, hotspot_disable, + get_hotspot_password, _has_nmcli, ) @@ -320,4 +321,42 @@ def test_hotspot_disable_failure(mock_subprocess): """hotspot_disable returns error on script failure.""" mock_subprocess.return_value = MagicMock(returncode=1, stderr="Script failed") result = hotspot_disable() - assert result["ok"] is False \ No newline at end of file + assert result["ok"] is False + + +# ── get_hotspot_password() tests ────────────────────────────────────────────── + + +def test_get_hotspot_password_generates_on_first_call(tmp_path): + """get_hotspot_password generates and persists a random password when none set.""" + with patch("src.system.network.CONFIG_PATH", tmp_path / "config.yaml"): + pwd = get_hotspot_password() + assert len(pwd) >= 8 + assert pwd.isalnum() # alphanumeric only + + # Second call returns the same password + pwd2 = get_hotspot_password() + assert pwd == pwd2 + + +def test_get_hotspot_password_persists(tmp_path): + """get_hotspot_password saves the generated password to config.yaml.""" + config_path = tmp_path / "config.yaml" + with patch("src.system.network.CONFIG_PATH", config_path): + pwd = get_hotspot_password() + # Verify it was written to disk + assert config_path.exists() + import yaml + cfg = yaml.safe_load(config_path.read_text()) + assert cfg["hotspot"]["password"] == pwd + + +def test_get_hotspot_password_returns_loaded(tmp_path): + """get_hotspot_password returns a pre-set password from config.""" + config_path = tmp_path / "config.yaml" + config_path.parent.mkdir(parents=True, exist_ok=True) + import yaml + config_path.write_text(yaml.dump({"hotspot": {"password": "MyCust0mP@ss"}})) + with patch("src.system.network.CONFIG_PATH", config_path): + pwd = get_hotspot_password() + assert pwd == "MyCust0mP@ss" \ No newline at end of file diff --git a/tests/test_stability.py b/tests/test_stability.py index e8dc855..21330fd 100644 --- a/tests/test_stability.py +++ b/tests/test_stability.py @@ -21,7 +21,7 @@ for p in [SRC, PROJECT_ROOT]: if str(p) not in sys.path: sys.path.insert(0, str(p)) -from src.dsp.pipeline import AudioPipeline, BLOCK_SIZE, SAMPLE_RATE +from src.dsp.pipeline import AudioPipeline from src.presets.types import FXBlock, FXType, Preset diff --git a/tests/test_web.py b/tests/test_web.py index ac6141e..646e0c8 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -92,7 +92,8 @@ def client(deps, monkeypatch): # Mock hardware-dependent gathers so tests don't hang on bluetoothctl/nmcli monkeypatch.setattr(WebServer, "_gather_wifi_state", lambda self: { "connected": False, "ssid": None, "ip": None, "signal": 0, - "hotspot_active": False, "hotspot_ssid": None, "hotspot_clients": 0, + "hotspot_active": False, "hotspot_ssid": None, + "hotspot_password": None, "hotspot_clients": 0, }) monkeypatch.setattr(WebServer, "_gather_bt_state", lambda self: { "available": False, "powered": False, "name": None, diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 8be321e..46e302b 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -13,9 +13,9 @@ export default defineConfig({ }, server: { proxy: { - '/api': 'http://pedal.local:8080', + '/api': 'http://pedal.local', '/ws': { - target: 'ws://pedal.local:8080', + target: 'ws://pedal.local', ws: true, }, },