fix: code review batch 2 — multi-channel, thread safety, DSP reset, MIDI, hotspot, NAM, docs/CI, code quality
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-17 22:38:24 -04:00
parent c65e4816c4
commit 2558306e78
23 changed files with 631 additions and 125 deletions
+42
View File
@@ -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
+6 -1
View File
@@ -14,4 +14,9 @@ config.json
.env .env
*.swp *.swp
.scone/ .scone/
systemd/ systemd/
.pytest_cache/
test_write.txt
test-drc.rpt
test_empty-drc.rptpatch_*.py
tests/test_issue_*.py
+56 -4
View File
@@ -1,6 +1,6 @@
# Pi Multi-FX Pedal 🎸 # 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) ## Quick Install (Focusrite 2i2 Test)
@@ -85,8 +85,8 @@ python3 main.py
### 7. Open the Web UI ### 7. Open the Web UI
From any device on the same network: From any device on the same network:
- **Browser:** http://pedal.local:8080 - **Browser:** http://pedal.local
- Or use the IP: http://192.168.x.x:8080 - Or use the IP: http://192.168.x.x
### WiFi Hotspot (No Network? No Problem) ### WiFi Hotspot (No Network? No Problem)
@@ -99,7 +99,7 @@ sudo bash scripts/setup-wifi-ap.sh
# Or with custom SSID/password: # Or with custom SSID/password:
sudo bash scripts/setup-wifi-ap.sh --ssid "Pi-Pedal" --psk "yourpassword" 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: # To turn it off and go back to client mode:
sudo bash scripts/setup-wifi-ap.sh --disable 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. 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 ## Hardware
| Component | Spec | | Component | Spec |
+1 -1
View File
@@ -69,7 +69,7 @@ presets:
| 2 | Wire guitar → Focusrite Input 1, Output 1 → amp | Physical | ✅ | | 2 | Wire guitar → Focusrite Input 1, Output 1 → amp | Physical | ✅ |
| 3 | Start pedal: `python3 main.py --config ~/.pedal/config-mono.yaml` | Boots, JACK starts | ✅ | | 3 | Start pedal: `python3 main.py --config ~/.pedal/config-mono.yaml` | Boots, JACK starts | ✅ |
| 4 | Play guitar | Sound through amp, clean bypass | ✅ | | 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 | ✅ | | 6 | Click bypass toggle on web UI | Pedal mutes → unmutes | ✅ |
| 7 | Check preset name shows on dashboard | "Default" or first preset | ✅ | | 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 | ✅ | | 8 | Press preset up/down on footswitch or web UI | Changes preset, different sound | ✅ |
+5 -5
View File
@@ -2,7 +2,7 @@
## Scope ## 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 | | # | 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.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.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", ...}` | | 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 | | # | 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.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.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 | | 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: For curl tests, use this template:
```bash ```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: For WS tests:
```bash ```bash
# Connect, wait, then send toggle commands in another terminal # Connect, wait, then send toggle commands in another terminal
wscat -c ws://pedal.local:8080/ws wscat -c ws://pedal.local/ws
``` ```
+2 -2
View File
@@ -8,14 +8,14 @@
<name>Pi Multi-FX Pedal</name> <name>Pi Multi-FX Pedal</name>
<service> <service>
<type>_http._tcp</type> <type>_http._tcp</type>
<port>8080</port> <port>80</port>
<txt-record>path=/</txt-record> <txt-record>path=/</txt-record>
<txt-record>product=Pi-Multi-FX-Pedal</txt-record> <txt-record>product=Pi-Multi-FX-Pedal</txt-record>
<txt-record>version=0.1.0</txt-record> <txt-record>version=0.1.0</txt-record>
</service> </service>
<service> <service>
<type>_pedal._tcp</type> <type>_pedal._tcp</type>
<port>8080</port> <port>80</port>
<txt-record>api=/api</txt-record> <txt-record>api=/api</txt-record>
<txt-record>ws=/ws</txt-record> <txt-record>ws=/ws</txt-record>
</service> </service>
+1 -1
View File
@@ -1444,7 +1444,7 @@ function SettingsScreen({ state, connected, refresh }) {
const active = state?.wifi?.hotspot_active; const active = state?.wifi?.hotspot_active;
try { try {
if (active) await apiPost('/api/wifi/hotspot/disable'); 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); setTimeout(refresh, 2000);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}; };
+10
View File
@@ -607,6 +607,7 @@ class PedalApp:
bypassed=self._bypassed, bypassed=self._bypassed,
fx_active=fx_active, fx_active=fx_active,
fx_bypass_states=fx_bypass_states, fx_bypass_states=fx_bypass_states,
hotspot_password=self._get_hotspot_password(),
) )
self.display.update(state) self.display.update(state)
except Exception: except Exception:
@@ -616,6 +617,7 @@ class PedalApp:
preset_name="Ready", preset_name="Ready",
bank_name="", bank_name="",
bypassed=self._bypassed, bypassed=self._bypassed,
hotspot_password=self._get_hotspot_password(),
) )
self.display.update(state) self.display.update(state)
@@ -623,6 +625,14 @@ class PedalApp:
# Helpers # 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]: def _build_footswitch_layout(self) -> list[FootSwitch]:
"""Build FootSwitch list from config.""" """Build FootSwitch list from config."""
layout_cfg = self._config.get("footswitch", {}).get("layout", DEFAULT_CONFIG["footswitch"]["layout"]) layout_cfg = self._config.get("footswitch", {}).get("layout", DEFAULT_CONFIG["footswitch"]["layout"])
+8 -1
View File
@@ -13,4 +13,11 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["src"] pythonpath = ["src"]
[tool.ruff]
target-version = "py311"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
+1 -1
View File
@@ -20,7 +20,7 @@ import time
import numpy as np 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 from src.presets.types import FXBlock, FXType, Preset
# ── Test tone parameters ─────────────────────────────────────────── # ── Test tone parameters ───────────────────────────────────────────
+3 -3
View File
@@ -10,7 +10,7 @@
# 4. Installs the service advertisement file for the web UI # 4. Installs the service advertisement file for the web UI
# 5. Restarts avahi-daemon # 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 set -euo pipefail
@@ -71,11 +71,11 @@ sudo systemctl restart avahi-daemon
echo "" echo ""
echo "═══ mDNS setup complete ═══" echo "═══ mDNS setup complete ═══"
echo "The pedal should now be reachable at:" echo "The pedal should now be reachable at:"
echo " http://${HOSTNAME}.local:8080" echo " http://${HOSTNAME}.local"
echo "" echo ""
echo "From any device on the same LAN, try:" echo "From any device on the same LAN, try:"
echo " ping ${HOSTNAME}.local" echo " ping ${HOSTNAME}.local"
echo " curl http://${HOSTNAME}.local:8080/api/state" echo " curl http://${HOSTNAME}.local/api/state"
echo "" echo ""
echo "NOTE: If resolution doesn't work immediately, wait ~10 seconds" echo "NOTE: If resolution doesn't work immediately, wait ~10 seconds"
echo "for mDNS propagation, or check: sudo systemctl status avahi-daemon" echo "for mDNS propagation, or check: sudo systemctl status avahi-daemon"
+5 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ── Pi Multi-FX Pedal — WiFi Access Point Setup ──────────────────── # ── Pi Multi-FX Pedal — WiFi Access Point Setup ────────────────────
# Enables the Pi to act as its own WiFi hotspot. # 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. # No venue WiFi or internet required.
# #
# Usage: # Usage:
@@ -11,7 +11,7 @@
# #
# After setup: # After setup:
# Phone WiFi → join "Pi-Pedal" (password: changeme or your custom psk) # 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): # Dual WiFi option (for internet while hotspot is active):
# Plug a USB WiFi dongle. The built-in WiFi becomes the hotspot, # Plug a USB WiFi dongle. The built-in WiFi becomes the hotspot,
@@ -25,7 +25,7 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# ── Defaults ────────────────────────────────────────────────────────── # ── Defaults ──────────────────────────────────────────────────────────
SSID="Pi-Pedal" 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" AP_IP="192.168.4.1"
DISABLE=false DISABLE=false
@@ -206,8 +206,8 @@ echo "╚═══════════════════════
echo "" echo ""
echo " 📶 SSID: $SSID" echo " 📶 SSID: $SSID"
echo " 🔑 Password: $PSK" echo " 🔑 Password: $PSK"
echo " 🔗 Web UI: http://pedal.local:8080" echo " 🔗 Web UI: http://pedal.local"
echo " 🌐 IP: http://$AP_IP:8080" echo " 🌐 IP: http://$AP_IP"
echo "" echo ""
echo "Your phone should see '$SSID' in available WiFi networks." echo "Your phone should see '$SSID' in available WiFi networks."
echo "Join it, then open the browser to the URL above." echo "Join it, then open the browser to the URL above."
+170 -55
View File
@@ -17,8 +17,9 @@ from __future__ import annotations
import json import json
import logging import logging
import os
import threading
import time import time
import warnings
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -101,6 +102,48 @@ def _build_linear(config: dict, weights: list) -> "torch.nn.Module":
return LinearNAM(gain, bias) 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": def _build_wavenet(config: dict, weights: list) -> "torch.nn.Module":
"""Build a WaveNet NAM model from config and flat weight array. """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: if not layers_cfg:
raise ValueError("WaveNet config has no layers") 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) ───── # ── Extract layer array config (we use first/only layer array) ─────
la = layers_cfg[0] # A2 models have single layer array la = layers_cfg[0] # A2 models have single layer array
input_size = la.get("input_size", 1) 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]) model.head_scale = float(w[i])
i += 1 i += 1
# Verify we consumed all weights # Verify we consumed all weights — mismatch means unsupported arch
if i != len(w): if i != len(w):
warnings.warn( raise ValueError(
f"NAM weight import consumed {i}/{len(w)} weights. " 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"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 = None # lazy import
self._torch_device = None # resolved device object 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 # Timing stats
self._timing_samples: list[float] = [] self._timing_samples: list[float] = []
@@ -462,8 +514,13 @@ class NAMHost:
self._crossfade_buf: Optional[np.ndarray] = None self._crossfade_buf: Optional[np.ndarray] = None
self._crossfade_pos: int = 0 self._crossfade_pos: int = 0
# Simple model cache (path -> model instance) # Model cache keyed by (path, mtime_ns) for automatic
self._model_cache: dict[str, object] = {} # 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) self._models_dir.mkdir(parents=True, exist_ok=True)
@@ -497,16 +554,44 @@ class NAMHost:
return 0.0 return 0.0
return float(np.mean(self._timing_samples[-50:])) 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 ────────────────────────────────────────────── # ── Model loading ──────────────────────────────────────────────
def load_model(self, model_path: str) -> bool: def load_model(self, model_path: str) -> bool:
"""Load a ``.nam`` model file and build its inference model. """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) path = Path(model_path)
if not path.exists() or path.suffix.lower() not in (".nam",): 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 return False
# Read file # Read file
@@ -514,56 +599,68 @@ class NAMHost:
with open(path, "r") as f: with open(path, "r") as f:
data = json.load(f) data = json.load(f)
except (json.JSONDecodeError, OSError) as e: 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 return False
architecture = data.get("architecture", "Linear") # Build PyTorch inference model (includes cache check and architecture validation)
config = data.get("config", {}) model_ok = self._build_inference(data, model_path=model_path)
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)
if model_ok: 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 # Store param count in metadata
params = sum(p.numel() for p in self._inference_model.parameters()) params = sum(p.numel() for p in self._inference_model.parameters())
self._loaded_model.params_k = round(params / 1000, 1) self._loaded_model.params_k = round(params / 1000, 1)
logger.info( logger.info(
"Loaded NAM model: %s (%.1f MB, %s, %s family, rf=%d, params=%.1fK)", "Loaded NAM model: %s (%.1f MB, %s, %s family, rf=%d, params=%.1fK)",
self._loaded_model.name, self._loaded_model.name, size_mb, architecture,
size_mb, self._loaded_model.family, self._loaded_model.receptive_field,
architecture, self._loaded_model.params_k,
self._loaded_model.family, )
self._loaded_model.receptive_field, else:
self._loaded_model.params_k, self._loaded_model = None
) logger.warning("Failed to load model: %s", self._last_error)
return True
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. """Instantiate a PyTorch model from a NAM config dict.
Uses our lightweight in-process builder that handles Uses our lightweight in-process builder that handles
SlimmableContainer (A2), WaveNet, and Linear architectures. 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() self._import_torch()
path = data.get("path", "") # Build cache key from path + mtime for invalidation on re-upload
cache_key = str(path) 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 # Check cache first
if cache_key and cache_key in self._model_cache: if cache_key is not None and cache_key in self._model_cache:
self._inference_model = self._model_cache[cache_key] with self._lock:
self._inference_model.eval() self._inference_model = self._model_cache[cache_key]
self._inference_model.eval()
return True return True
try: try:
@@ -575,28 +672,38 @@ class NAMHost:
model = model.to(self._torch_device) model = model.to(self._torch_device)
# Cache it # Cache it
if cache_key: if cache_key is not None:
self._model_cache[cache_key] = model 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 return True
except ImportError as exc: except ImportError as exc:
logger.warning("Required package not installed; inference unavailable: %s", exc) msg = f"Required package not installed; inference unavailable: {exc}"
self._inference_model = None logger.warning(msg)
self._last_error = str(exc)
with self._lock:
self._inference_model = None
return False return False
except Exception as exc: except Exception as exc:
logger.warning("Failed to build inference model: %s", exc) msg = f"Failed to build inference model: {exc}"
self._inference_model = None logger.warning(msg)
self._last_error = str(exc)
with self._lock:
self._inference_model = None
return False return False
def unload(self) -> None: def unload(self) -> None:
"""Unload the current model and free resources.""" """Unload the current model and free resources."""
self._loaded_model = None with self._lock:
if self._inference_model is not None: self._loaded_model = None
self._inference_model = self._inference_model.to("cpu") if self._inference_model is not None:
del self._inference_model self._inference_model = self._inference_model.to("cpu")
self._inference_model = None del self._inference_model
self._inference_model = None
self._torch = None self._torch = None
self._torch_device = None self._torch_device = None
self._timing_samples.clear() self._timing_samples.clear()
@@ -627,6 +734,10 @@ class NAMHost:
def process(self, audio_block: np.ndarray) -> np.ndarray: def process(self, audio_block: np.ndarray) -> np.ndarray:
"""Run a block of audio through the loaded NAM model. """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. Handles 1-D (``(N,)``) and 2-D (``(1, N)``) float32 input.
If no model is loaded, passes audio through unchanged. If no model is loaded, passes audio through unchanged.
@@ -640,7 +751,11 @@ class NAMHost:
np.ndarray np.ndarray
Processed audio, same shape. 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 # Pass-through when no model is loaded
return audio_block return audio_block
@@ -659,7 +774,7 @@ class NAMHost:
if str(self._torch_device) != "cpu": if str(self._torch_device) != "cpu":
x = x.to(self._torch_device) x = x.to(self._torch_device)
y = self._inference_model(x) y = model(x)
# Squeeze back # Squeeze back
if was_1d: if was_1d:
+64 -23
View File
@@ -13,6 +13,7 @@ Usage:
from __future__ import annotations from __future__ import annotations
import logging import logging
import threading
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -56,6 +57,10 @@ class NAMEngineRouter:
self._create_engine() 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 ──────────────────────────────────────────── # ── Engine lifecycle ────────────────────────────────────────────
def _create_engine(self) -> None: def _create_engine(self) -> None:
@@ -79,6 +84,9 @@ class NAMEngineRouter:
def set_engine(self, mode: str) -> bool: def set_engine(self, mode: str) -> bool:
"""Switch engine type at runtime. """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. Unloads the current model and reloads it in the new engine.
Returns True on success, False if the model couldn't be reloaded. 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: if mode not in self.ENGINE_MODES:
raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {mode!r}") raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {mode!r}")
# Save currently loaded model path with self._lock:
old_path = self._loaded_path # Save currently loaded model path
self.unload() 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._engine_mode = mode
self._create_engine() 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: if old_path:
logger.info("Reloading model %s in new engine %s", old_path, mode) logger.info("Reloading model %s in new engine %s", old_path, mode)
return self.load_model(old_path) return self.load_model(old_path)
@@ -109,11 +121,13 @@ class NAMEngineRouter:
@property @property
def is_loaded(self) -> bool: 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 @property
def current_model(self): 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 @property
def avg_inference_ms(self) -> float: def avg_inference_ms(self) -> float:
@@ -131,43 +145,70 @@ class NAMEngineRouter:
def _crossfade_buf(self): def _crossfade_buf(self):
"""For pipeline crossfade compatibility. """For pipeline crossfade compatibility.
PyTorch NAMHost has this natively; FastNAMHost has None.""" PyTorch NAMHost has this natively; FastNAMHost has None."""
if hasattr(self._engine, '_crossfade_buf'): with self._lock:
return self._engine._crossfade_buf if hasattr(self._engine, '_crossfade_buf'):
return self._engine._crossfade_buf
return None return None
def apply_crossfade(self, buf: np.ndarray) -> np.ndarray: def apply_crossfade(self, buf: np.ndarray) -> np.ndarray:
if hasattr(self._engine, 'apply_crossfade'): with self._lock:
return self._engine.apply_crossfade(buf) engine = self._engine
if engine is not None and hasattr(engine, 'apply_crossfade'):
return engine.apply_crossfade(buf)
return buf return buf
# ── Model loading ─────────────────────────────────────────────── # ── Model loading ───────────────────────────────────────────────
def load_model(self, model_path: str) -> bool: def load_model(self, model_path: str) -> bool:
if self._engine is None: with self._lock:
return False if self._engine is None:
ok = self._engine.load_model(model_path) return False
if ok: ok = self._engine.load_model(model_path)
self._loaded_path = model_path if ok:
return ok self._loaded_path = model_path
return ok
def unload(self) -> None: def unload(self) -> None:
if self._engine is not None: with self._lock:
self._engine.unload() if self._engine is not None:
self._loaded_path = None self._engine.unload()
self._loaded_path = None
def set_block_size(self, block_size: int) -> None: def set_block_size(self, block_size: int) -> None:
self._block_size = block_size self._block_size = block_size
if self._engine is not None and hasattr(self._engine, 'set_block_size'): if self._engine is not None and hasattr(self._engine, 'set_block_size'):
self._engine.set_block_size(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: def warm_up(self) -> None:
if self._engine is not None and hasattr(self._engine, 'warm_up'): if self._engine is not None and hasattr(self._engine, 'warm_up'):
self._engine.warm_up(block_size=self._block_size) self._engine.warm_up(block_size=self._block_size)
def process(self, audio_block: np.ndarray) -> np.ndarray: 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 audio_block
return self._engine.process(audio_block) return engine.process(audio_block)
# ── Model discovery ───────────────────────────────────────────── # ── Model discovery ─────────────────────────────────────────────
+40 -2
View File
@@ -28,8 +28,9 @@ from ..presets.types import FXBlock, FXType, Preset
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BLOCK_SIZE = 256 # Samples per JACK callback # BLOCK_SIZE and SAMPLE_RATE are now instance attributes on AudioPipeline
SAMPLE_RATE = 48000 # Standard guitar audio rate # (self._block_size, self._sample_rate) — set at construction time.
# Do not re-introduce module-level constants; use the instance properties.
# ── Biquad coefficient helpers ───────────────────────────────────── # ── Biquad coefficient helpers ─────────────────────────────────────
@@ -379,6 +380,7 @@ class AudioPipeline:
for block in preset.chain: for block in preset.chain:
entry = { entry = {
"block_id": block.block_id,
"fx_type": block.fx_type, "fx_type": block.fx_type,
"enabled": block.enabled, "enabled": block.enabled,
"bypass": block.bypass, "bypass": block.bypass,
@@ -410,6 +412,42 @@ class AudioPipeline:
preset.name, len(self._chain), preset.name, len(self._chain),
self._routing_mode, self._routing_breakpoint) 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: def process(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a block of audio through the entire FX chain. """Process a block of audio through the entire FX chain.
+27 -1
View File
@@ -141,9 +141,35 @@ class Preset:
"""Snapshot slots 1-8. Empty dict means no snapshots saved.""" """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 ``"<block_id>.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 @dataclass
class Bank: class Bank:
"""A bank of presets (typically 4 per bank).""" """A bank of presets (typically 4 per bank)."""
name: str name: str
number: int number: int
presets: list[Preset] = field(default_factory=list) presets: list[Preset] = field(default_factory=list)
+60 -5
View File
@@ -13,6 +13,8 @@ import json
import logging import logging
import os import os
import re import re
import secrets
import string
import subprocess import subprocess
import tempfile import tempfile
import time import time
@@ -25,6 +27,7 @@ logger = logging.getLogger(__name__)
HOTSPOT_SCRIPT = Path(__file__).resolve().parent.parent.parent / "scripts" / "setup-wifi-ap.sh" HOTSPOT_SCRIPT = Path(__file__).resolve().parent.parent.parent / "scripts" / "setup-wifi-ap.sh"
WPA_SUPPLICANT_CONF = Path("/etc/wpa_supplicant/wpa_supplicant.conf") WPA_SUPPLICANT_CONF = Path("/etc/wpa_supplicant/wpa_supplicant.conf")
CONFIG_PATH = Path.home() / ".pedal" / "config.yaml"
# ── Capability detection ────────────────────────────────────────────────────── # ── Capability detection ──────────────────────────────────────────────────────
@@ -356,6 +359,43 @@ def _wpa_connect(ssid: str, password: str) -> bool:
# ── Hotspot control ─────────────────────────────────────────────────────────── # ── 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]: def _hotspot_status() -> dict[str, Any]:
"""Check if the WiFi hotspot is currently active.""" """Check if the WiFi hotspot is currently active."""
# Check if hostapd is running # Check if hostapd is running
@@ -366,7 +406,7 @@ def _hotspot_status() -> dict[str, Any]:
active = result.stdout.strip() == "active" active = result.stdout.strip() == "active"
if not 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 # Read SSID from config
ssid = None ssid = None
@@ -386,16 +426,28 @@ def _hotspot_status() -> dict[str, Any]:
if iw_result.returncode == 0: if iw_result.returncode == 0:
clients = len([l for l in iw_result.stdout.split("\n") if "Station" in l]) 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 { return {
"active": True, "active": True,
"ssid": ssid or "Pi-Pedal", "ssid": ssid or "Pi-Pedal",
"password": password,
"clients": clients, "clients": clients,
"ip": "192.168.4.1", "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.""" """Enable WiFi hotspot mode by calling the setup script."""
if not password:
raise ValueError("hotspot password is required")
result = _run( result = _run(
["sudo", "bash", str(HOTSPOT_SCRIPT), "--ssid", ssid, "--psk", password], ["sudo", "bash", str(HOTSPOT_SCRIPT), "--ssid", ssid, "--psk", password],
timeout=60, check=False, timeout=60, check=False,
@@ -578,16 +630,17 @@ def wifi_forget(ssid: str) -> dict[str, Any]:
def hotspot_status() -> dict[str, Any]: def hotspot_status() -> dict[str, Any]:
"""Get the current hotspot status. """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: try:
return _hotspot_status() return _hotspot_status()
except Exception as e: except Exception as e:
logger.error("Hotspot status failed: %s", str(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. """Enable the WiFi hotspot.
Args: Args:
@@ -597,6 +650,8 @@ def hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> dict[
Returns: Returns:
dict with ok (bool) and error (str|None) keys. dict with ok (bool) and error (str|None) keys.
""" """
if not password:
return {"ok": False, "error": "Password is required"}
if len(password) < 8: if len(password) < 8:
return {"ok": False, "error": "Password must be at least 8 characters"} return {"ok": False, "error": "Password must be at least 8 characters"}
try: try:
+5 -1
View File
@@ -52,6 +52,7 @@ class DisplayState:
tuner_cents: int = 0 tuner_cents: int = 0
param_name: str = "" param_name: str = ""
param_value: float = 0.0 param_value: float = 0.0
hotspot_password: str = "" # Shown in footer when hotspot active
def _import_display_driver(): def _import_display_driver():
@@ -208,7 +209,10 @@ class DisplayController:
draw.text((MARGIN, fx_y), fx_line, fill=255, font=font) draw.text((MARGIN, fx_y), fx_line, fill=255, font=font)
# Footer — preset number or info # 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: def _render_tuner(self, draw) -> None:
"""Render tuner mode — note name + cents indicator.""" """Render tuner mode — note name + cents indicator."""
+80 -9
View File
@@ -18,6 +18,7 @@ import logging
import math import math
import mimetypes import mimetypes
import datetime import datetime
import threading
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -340,6 +341,8 @@ class WebServer:
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._tonedownload: Optional[Tone3000Client] = None self._tonedownload: Optional[Tone3000Client] = None
self._preset_write_locks: dict[str, asyncio.Lock] = {} 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._needs_reboot: bool = False
self._app = self._build_app() self._app = self._build_app()
@@ -365,6 +368,51 @@ class WebServer:
self._preset_write_locks[key] = asyncio.Lock() self._preset_write_locks[key] = asyncio.Lock()
return self._preset_write_locks[key] 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 ───────────────────────────────────────────────────── # ── App factory ─────────────────────────────────────────────────────
# ── Auth helpers ───────────────────────────────────────────── # ── 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): if b.block_id == block_id or (not data.get("block_id") and b.fx_type.value == block_id):
b.enabled = bool(enabled) b.enabled = bool(enabled)
b.bypass = not bool(enabled) # sync bypass with enabled b.bypass = not bool(enabled) # sync bypass with enabled
pm.save(preset, channel=Channel(channel)) # In-place pipeline update — preserves DSP state
# Reload pipeline if needed if pl is not None:
if pl: pl.set_block_in_place(
pl.load_preset(preset) 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({ await self._manager.broadcast({
"type": "block_toggled", "type": "block_toggled",
"channel": channel, "channel": channel,
@@ -1051,9 +1104,27 @@ class WebServer:
else: else:
# Safe: key already validated against schema # Safe: key already validated against schema
b.params[key] = float(val) b.params[key] = float(val)
pm.save(preset, channel=Channel(channel)) # In-place pipeline update — preserves DSP state
if pl: if pl is not None:
pl.load_preset(preset) 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({ await self._manager.broadcast({
"type": "block_params_changed", "type": "block_params_changed",
"channel": channel, "channel": channel,
@@ -1411,12 +1482,12 @@ class WebServer:
Body: {ssid: str (optional), password: str (optional)} 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() loop = asyncio.get_event_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, partial(hotspot_enable, None, partial(hotspot_enable,
ssid=data.get("ssid", "Pi-Pedal"), ssid=data.get("ssid", "Pi-Pedal"),
password=data.get("password", "pedal1234"), password=data.get("password", get_hotspot_password()),
), ),
) )
return result return result
+40 -1
View File
@@ -29,6 +29,7 @@ from src.system.network import (
hotspot_status, hotspot_status,
hotspot_enable, hotspot_enable,
hotspot_disable, hotspot_disable,
get_hotspot_password,
_has_nmcli, _has_nmcli,
) )
@@ -320,4 +321,42 @@ def test_hotspot_disable_failure(mock_subprocess):
"""hotspot_disable returns error on script failure.""" """hotspot_disable returns error on script failure."""
mock_subprocess.return_value = MagicMock(returncode=1, stderr="Script failed") mock_subprocess.return_value = MagicMock(returncode=1, stderr="Script failed")
result = hotspot_disable() result = hotspot_disable()
assert result["ok"] is False 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"
+1 -1
View File
@@ -21,7 +21,7 @@ for p in [SRC, PROJECT_ROOT]:
if str(p) not in sys.path: if str(p) not in sys.path:
sys.path.insert(0, str(p)) 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 from src.presets.types import FXBlock, FXType, Preset
+2 -1
View File
@@ -92,7 +92,8 @@ def client(deps, monkeypatch):
# Mock hardware-dependent gathers so tests don't hang on bluetoothctl/nmcli # Mock hardware-dependent gathers so tests don't hang on bluetoothctl/nmcli
monkeypatch.setattr(WebServer, "_gather_wifi_state", lambda self: { monkeypatch.setattr(WebServer, "_gather_wifi_state", lambda self: {
"connected": False, "ssid": None, "ip": None, "signal": 0, "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: { monkeypatch.setattr(WebServer, "_gather_bt_state", lambda self: {
"available": False, "powered": False, "name": None, "available": False, "powered": False, "name": None,
+2 -2
View File
@@ -13,9 +13,9 @@ export default defineConfig({
}, },
server: { server: {
proxy: { proxy: {
'/api': 'http://pedal.local:8080', '/api': 'http://pedal.local',
'/ws': { '/ws': {
target: 'ws://pedal.local:8080', target: 'ws://pedal.local',
ws: true, ws: true,
}, },
}, },