fix: code review batch 2 — multi-channel, thread safety, DSP reset, MIDI, hotspot, NAM, docs/CI, code quality
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 | ✅ |
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
@@ -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 ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user