Compare commits
98 Commits
0a21297f77
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f7257aa23 | |||
| 04931fd738 | |||
| acdff5eb9b | |||
| 95bb62dc21 | |||
| c026bc72b7 | |||
| 47bb535b08 | |||
| d152ecd1fc | |||
| 507a486a42 | |||
| 6f24a940ba | |||
| bb9c835f98 | |||
| 0cb044fb5e | |||
| d7889c6f23 | |||
| 4039ee963d | |||
| 6495facb02 | |||
| 017a74a414 | |||
| f1c6f64dc8 | |||
| 32990f9744 | |||
| 9a8c59d19c | |||
| 4b694cc5f8 | |||
| 4ad50e3588 | |||
| 92c6bb9587 | |||
| 61886858f6 | |||
| f3cdf5e13b | |||
| c0c2538c80 | |||
| ce82207d03 | |||
| de5723f138 | |||
| aa0bcf35fe | |||
| 95029c6c8c | |||
| a3ec86b814 | |||
| ad5b369a10 | |||
| 2558306e78 | |||
| c65e4816c4 | |||
| 1603bfeea0 | |||
| 42e357c4a1 | |||
| 3c8d5e4c1c | |||
| 4a6e7900ce | |||
| fab8564d09 | |||
| 1619b2e90e | |||
| ebdf98076a | |||
| 8aaa9abadb | |||
| 6008776e3c | |||
| 1b2747abb1 | |||
| 177d412c84 | |||
| 5cdc751312 | |||
| a37e381c4e | |||
| 334e035cde | |||
| e324d9007b | |||
| 427c77cbe7 | |||
| 93610e4d98 | |||
| 1530aa266f | |||
| ea75c41745 | |||
| f6fe3b2523 | |||
| bdf1434718 | |||
| 753de36dc2 | |||
| 7078217783 | |||
| 9cc08d16da | |||
| 93eb8cc4aa | |||
| 4fa49d46c1 | |||
| 88ee1e24dc | |||
| 3b1bb43895 | |||
| a7d8a27975 | |||
| 23a83cbd7b | |||
| 29542e196b | |||
| 1281bf5943 | |||
| 44f04887dc | |||
| 2c4d7298ec | |||
| 0fa8bd387a | |||
| a69aee357c | |||
| e2f81fd309 | |||
| 2a74c8e114 | |||
| fa787debd1 | |||
| 11c62868cf | |||
| e7a778d33d | |||
| 67ffc3252f | |||
| 2aefa5303d | |||
| c0c0ffe4f2 | |||
| 1b250cd5a3 | |||
| a43e65b81f | |||
| 103d36a14b | |||
| 9e3ab9d53b | |||
| 9fade4a2d2 | |||
| cd2ed273e9 | |||
| 6e07aa2b38 | |||
| 42be40f698 | |||
| 80485186af | |||
| d917d4b20d | |||
| 8e2b090eaf | |||
| 659e53766a | |||
| 575535762c | |||
| 82f687323c | |||
| 4b59e4422c | |||
| 42d0592856 | |||
| 5c51b3db28 | |||
| 11c838a27e | |||
| 04fb12dcdc | |||
| e11956881f | |||
| adb35a730b | |||
| bfe1434f75 |
@@ -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
|
||||||
+8
-1
@@ -14,4 +14,11 @@ 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
|
||||||
|
patch_*.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 |
|
||||||
|
|||||||
+671
@@ -0,0 +1,671 @@
|
|||||||
|
# Pi Multi-FX Pedal — Full Test Plan
|
||||||
|
|
||||||
|
**Scope:** Every endpoint, every UI element, the entire DSP/N audio chain,
|
||||||
|
hardware interfaces (MIDI, Bluetooth, WiFi), and system stability.
|
||||||
|
|
||||||
|
**Test types:**
|
||||||
|
| Icon | Meaning | When to run |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| 🤖 | Fully automated (API + UI script) | Every commit |
|
||||||
|
| 🧪 | Semi-automated (scriptable with signal present) | Per-release |
|
||||||
|
| 🧑 | Manual (ears + hands) | Per-release, during tuning |
|
||||||
|
| 💀 | Destructive (service restart, reboot) | Per-major-release |
|
||||||
|
|
||||||
|
**Setup needed for manual tests:**
|
||||||
|
- Guitar plugged into DI box → interface input
|
||||||
|
- Patch cable from interface output → interface input (loopback for latency)
|
||||||
|
- Headphones or monitors on interface output
|
||||||
|
- MIDI controller (optional)
|
||||||
|
- Bluetooth device (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Automated API Tests 🤖
|
||||||
|
|
||||||
|
Every endpoint must return correct HTTP status, correct content-type,
|
||||||
|
and valid JSON matching its documented schema.
|
||||||
|
|
||||||
|
### 1.1 Alive / Connection
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.1.1 | Root | `GET /` | 200, HTML page |
|
||||||
|
| 1.1.2 | State (guitar) | `GET /api/state` | 200, `connected: true`, `blocks[]` |
|
||||||
|
| 1.1.3 | State (combined) | `GET /api/state?combined=true` | 200, `channels` with 5 keys |
|
||||||
|
| 1.1.4 | State (bass) | `GET /api/state?channel=bass` | 200, `channel: "bass"` |
|
||||||
|
| 1.1.5 | State (keys) | `GET /api/state?channel=keys` | 200 |
|
||||||
|
| 1.1.6 | State (vocals) | `GET /api/state?channel=vocals` | 200 |
|
||||||
|
| 1.1.7 | State (backing_tracks) | `GET /api/state?channel=backing_tracks` | 200 |
|
||||||
|
|
||||||
|
### 1.2 State Fields
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 1.2.1 | `connected` is bool | true |
|
||||||
|
| 1.2.2 | `current_preset` has name/bank/program | all present |
|
||||||
|
| 1.2.3 | `bypass` is bool | false initially |
|
||||||
|
| 1.2.4 | `tuner_enabled` is bool | false initially |
|
||||||
|
| 1.2.5 | `master_volume` is float 0.0–1.0 | 0.8 initially |
|
||||||
|
| 1.2.6 | `routing_mode` is string | "mono" or "4cm" |
|
||||||
|
| 1.2.7 | `nam_loaded` matches model list | bool |
|
||||||
|
| 1.2.8 | `nam_cpu` is int 0–100 | > 0 when NAM loaded |
|
||||||
|
| 1.2.9 | `blocks` is array | ≥ 1 block |
|
||||||
|
| 1.2.10 | Each block has `block_id` | unique, 12-char hex |
|
||||||
|
| 1.2.11 | Each block has `fx_type`, `enabled`, `bypass` | all present |
|
||||||
|
| 1.2.12 | `cpu_percent` is int 0–100 | present |
|
||||||
|
| 1.2.13 | `sample_rate` matches config | 48000 |
|
||||||
|
| 1.2.14 | `needs_reboot` is bool | false |
|
||||||
|
|
||||||
|
### 1.3 Block API
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.3.1 | PATCH toggle block off | `PATCH /api/blocks {block_id, enabled:false}` | 200, `enabled: false` |
|
||||||
|
| 1.3.2 | State reflects block disabled | `GET /api/state` | block `enabled: false` |
|
||||||
|
| 1.3.3 | PATCH toggle block on | `PATCH /api/blocks {block_id, enabled:true}` | 200, `enabled: true` |
|
||||||
|
| 1.3.4 | PATCH non-existent block_id | `PATCH /api/blocks {block_id:"bogus", enabled:true}` | 404 |
|
||||||
|
| 1.3.5 | PATCH missing block_id | `PATCH /api/blocks {enabled:true}` | 400 |
|
||||||
|
| 1.3.6 | PATCH missing enabled | `PATCH /api/blocks {block_id:"x"}` | 400 |
|
||||||
|
| 1.3.7 | PATCH block params | `PATCH /api/block {block_id, param:val}` | 200 |
|
||||||
|
| 1.3.8 | Get block params schema | `GET /api/block-params/nam_amp` | 200, params object |
|
||||||
|
| 1.3.9 | Get block params (invalid type) | `GET /api/block-params/bogus` | 200 or 404 |
|
||||||
|
|
||||||
|
### 1.4 Bypass
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.4.1 | Toggle bypass ON | `POST /api/bypass/toggle` | 200, `bypass: true` |
|
||||||
|
| 1.4.2 | State reflects bypass | `GET /api/state` | `bypass: true` |
|
||||||
|
| 1.4.3 | Toggle bypass OFF | `POST /api/bypass/toggle` | 200, `bypass: false` |
|
||||||
|
| 1.4.4 | Set bypass directly | `POST /api/bypass {"bypass":true}` | 200 |
|
||||||
|
| 1.4.5 | Per-channel bypass | `POST /api/channel/bass/bypass` | 200 |
|
||||||
|
|
||||||
|
### 1.5 Volume
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.5.1 | Set volume 50% | `POST /api/volume {"volume":0.5}` | 200 |
|
||||||
|
| 1.5.2 | State reflects volume | `GET /api/state` | `master_volume: 0.5` |
|
||||||
|
| 1.5.3 | Set volume min | `POST /api/volume {"volume":0.0}` | 200, `volume: 0.0` |
|
||||||
|
| 1.5.4 | Set volume max | `POST /api/volume {"volume":1.0}` | 200, `volume: 1.0` |
|
||||||
|
| 1.5.5 | Set volume clamped below 0 | `POST /api/volume {"volume":-0.5}` | 200, `volume: 0.0` |
|
||||||
|
| 1.5.6 | Set volume clamped above 1 | `POST /api/volume {"volume":1.5}` | 200, `volume: 1.0` |
|
||||||
|
| 1.5.7 | Per-channel volume | `PUT /api/channel/bass/volume {"volume":0.3}` | 200 |
|
||||||
|
| 1.5.8 | Restore | `POST /api/volume {"volume":0.8}` | 200 |
|
||||||
|
| 1.5.9 | Volumes per channel are independent | set guitar 0.8, bass 0.3, check both | different |
|
||||||
|
|
||||||
|
### 1.6 Presets
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.6.1 | List presets | `GET /api/presets` | 200, `banks[]` |
|
||||||
|
| 1.6.2 | Get specific preset | `GET /api/presets/{bank}/{program}` | 200, full preset |
|
||||||
|
| 1.6.3 | Save preset | `PUT /api/presets/{bank}/{program} { chain, name }` | 200 |
|
||||||
|
| 1.6.4 | Save + reload matches | save → GET → compare chain | block_ids preserved |
|
||||||
|
| 1.6.5 | Delete preset | `DELETE /api/presets/{bank}/{program}` | 200 |
|
||||||
|
| 1.6.6 | Get deleted preset | `GET /api/presets/{bank}/{program}` | 404 |
|
||||||
|
| 1.6.7 | Activate preset | `POST /api/presets/{bank}/{program}/activate` | 200 |
|
||||||
|
| 1.6.8 | State reflects active preset | `GET /api/state` | `current_preset` matches |
|
||||||
|
| 1.6.9 | Per-channel presets | `GET /api/channel/bass/presets` | 200 |
|
||||||
|
| 1.6.10 | Per-channel preset save | `PUT /api/channel/bass/presets/0/0` | 200 |
|
||||||
|
| 1.6.11 | Save with block_id → reload preserves it | PUT → GET → block_id same | block_id stable |
|
||||||
|
| 1.6.12 | Save without block_id → generated | PUT without block_id → GET has block_id | generated |
|
||||||
|
|
||||||
|
### 1.7 Snapshots
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.7.1 | Save snapshot | `POST /api/snapshots/{slot}/save` | 200 |
|
||||||
|
| 1.7.2 | Save named snapshot | `POST /api/snapshots/{slot}/save {"name":"test"}` | 200 |
|
||||||
|
| 1.7.3 | List snapshots | `GET /api/snapshots` | 200, `snapshots` dict |
|
||||||
|
| 1.7.4 | Get snapshot | `GET /api/snapshots/{slot}` | 200 |
|
||||||
|
| 1.7.5 | Recall snapshot | `POST /api/snapshots/{slot}/recall` | 200 |
|
||||||
|
| 1.7.6 | Recall restores block states | save state A → change → recall → check state | restored to A |
|
||||||
|
| 1.7.7 | Delete snapshot | `DELETE /api/snapshots/{slot}` | 200 |
|
||||||
|
| 1.7.8 | Recall empty slot | `POST /api/snapshots/{slot}/recall` on empty | 404 |
|
||||||
|
| 1.7.9 | Save slot 1–8 | all 8 slots | each 200 |
|
||||||
|
| 1.7.10 | Save slot 0 (invalid) | `POST /api/snapshots/0/save` | 400 |
|
||||||
|
| 1.7.11 | Save slot 9 (invalid) | `POST /api/snapshots/9/save` | 400 |
|
||||||
|
|
||||||
|
### 1.8 Tuner
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.8.1 | Enable tuner | `POST /api/tuner {"enabled":true}` | 200 |
|
||||||
|
| 1.8.2 | State reflects tuner ON | `GET /api/state` | `tuner_enabled: true` |
|
||||||
|
| 1.8.3 | Get tuner pitch | `GET /api/tuner/pitch` | 200, `frequency`, `note`, `cents` |
|
||||||
|
| 1.8.4 | Disable tuner | `POST /api/tuner {"enabled":false}` | 200 |
|
||||||
|
| 1.8.5 | Per-channel tuner | `POST /api/channel/bass/tuner {"enabled":true}` | 200 |
|
||||||
|
| 1.8.6 | Tuner output rates | poll `GET /api/tuner/pitch` 5×/sec for 3s | different readings |
|
||||||
|
| 1.8.7 | Double-tuner (enable when already on) | `POST /api/tuner {"enabled":true}` ×2 | still enabled |
|
||||||
|
|
||||||
|
### 1.9 NAM / Models
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.9.1 | List models | `GET /api/models` | 200, `models[]` |
|
||||||
|
| 1.9.2 | Per-channel models | `GET /api/channel/guitar/models` | 200 |
|
||||||
|
| 1.9.3 | Load model by path | `POST /api/models/load {"path":"..."}` | 200 |
|
||||||
|
| 1.9.4 | State reflects loaded model | `GET /api/state` | `nam_model` matches |
|
||||||
|
| 1.9.5 | Load model (invalid path) | `POST /api/models/load {"path":"/nonexistent.nam"}` | 422 |
|
||||||
|
| 1.9.6 | Load model (missing path) | `POST /api/models/load {}` | 400 |
|
||||||
|
| 1.9.7 | Unload model | `POST /api/models/unload` | 200 |
|
||||||
|
| 1.9.8 | State after unload | `GET /api/state` | `nam_loaded: false` |
|
||||||
|
| 1.9.9 | Reload original model | `POST /api/models/load {"path":"..."}` | 200 |
|
||||||
|
| 1.9.10 | NAM CPU > 0 after load | `GET /api/state` | `nam_cpu` > 5 |
|
||||||
|
| 1.9.11 | Cycle through all models | load each model → verify state | each 200 |
|
||||||
|
| 1.9.12 | Duplicate model paths | check `/api/models` for duplicates | no duplicates |
|
||||||
|
| 1.9.13 | Get NAM engine info | `GET /api/nam/engine` | 200, engine mode |
|
||||||
|
| 1.9.14 | Switch NAM engine (if available) | `POST /api/nam/engine {"engine_mode":"pytorch"}` | 200 or 400 if unsupported |
|
||||||
|
| 1.9.15 | Switch back to cpp | `POST /api/nam/engine {"engine_mode":"cpp"}` | 200 |
|
||||||
|
|
||||||
|
### 1.10 IR
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.10.1 | List IRs | `GET /api/irs` | 200 |
|
||||||
|
| 1.10.2 | Load IR by path | `POST /api/irs/load {"path":"..."}` | 200 |
|
||||||
|
| 1.10.3 | State reflects loaded IR | `GET /api/state` | `ir_loaded: true`, `ir_name` |
|
||||||
|
| 1.10.4 | Unload IR | `POST /api/irs/unload` | 200 |
|
||||||
|
| 1.10.5 | Load invalid path | `POST /api/irs/load {"path":"bogus.wav"}` | 422 |
|
||||||
|
|
||||||
|
### 1.11 Routing
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.11.1 | Get routing | `GET /api/routing` | 200 |
|
||||||
|
| 1.11.2 | Set routing mono | `POST /api/routing {"routing_mode":"mono"}` | 200 |
|
||||||
|
| 1.11.3 | State reflects routing | `GET /api/state` | `routing_mode: "mono"` |
|
||||||
|
| 1.11.4 | Set routing 4CM | `POST /api/routing {"routing_mode":"4cm","routing_breakpoint":5}` | 200 |
|
||||||
|
| 1.11.5 | Get channel routing | `GET /api/channel/guitar/routing` | 200 |
|
||||||
|
| 1.11.6 | Set channel routing | `POST /api/channel/guitar/routing {"mode":"mono"}` | 200 |
|
||||||
|
|
||||||
|
### 1.12 Channel Mode & Output
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.12.1 | Get channel mode | `GET /api/channel-mode` | 200 |
|
||||||
|
| 1.12.2 | Set channel mode | `POST /api/channel-mode {"mode":"dual-mono"}` | 200 |
|
||||||
|
| 1.12.3 | Get channel output | `GET /api/channel-output` | 200 |
|
||||||
|
| 1.12.4 | Set channel output | `POST /api/channel-output {"ch1_port":"...","ch2_port":"..."}` | 200 |
|
||||||
|
|
||||||
|
### 1.13 Backing Source
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.13.1 | Get backing source | `GET /api/backing-source` | 200 |
|
||||||
|
| 1.13.2 | Set backing source | `POST /api/backing-source {"type":"line_in","port":"...","gain":0.5}` | 200 |
|
||||||
|
|
||||||
|
### 1.14 Audio Profile
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.14.1 | List profiles | `GET /api/audio/profiles` | 200 |
|
||||||
|
| 1.14.2 | Get current profile | `GET /api/audio/profile` | 200 |
|
||||||
|
| 1.14.3 | Set profile | `POST /api/audio/profile {"profile":"standard"}` | 200 |
|
||||||
|
| 1.14.4 | Get audio devices | `GET /api/audio/devices` | 200 |
|
||||||
|
| 1.14.5 | State reflects new profile | `GET /api/state` | stable after switch |
|
||||||
|
|
||||||
|
### 1.15 Settings
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.15.1 | Get settings | `GET /api/settings` | 200, config dict |
|
||||||
|
| 1.15.2 | Save setting | `POST /api/settings {"input_impedance":"1m"}` | 200, `saved` ≥ 1 |
|
||||||
|
| 1.15.3 | Save unknown setting | `POST /api/settings {"bogus_key":123}` | 200, `saved` = 0 |
|
||||||
|
| 1.15.4 | Save brightness | `POST /api/settings {"brightness_percent":75}` | 200 |
|
||||||
|
| 1.15.5 | Settings persist after restart | change → restart → GET | same value |
|
||||||
|
|
||||||
|
### 1.16 System
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.16.1 | Get system info | `GET /api/system` | 200, hostname/kernel/uptime |
|
||||||
|
| 1.16.2 | `cpu_temp_c` < 80 | sanity check | < 80°C |
|
||||||
|
| 1.16.3 | `memory.free` > 100MB | sanity check | > 100 |
|
||||||
|
| 1.16.4 | `disk.avail` > 100MB | sanity check | > 100M |
|
||||||
|
| 1.16.5 | Get logs | `GET /api/system/logs?lines=20` | 200 |
|
||||||
|
| 1.16.6 | Set hostname | `POST /api/system/hostname {"hostname":"testpedal"}` | 200 |
|
||||||
|
| 1.16.7 | Restore hostname | `POST /api/system/hostname {"hostname":"pedal"}` | 200 |
|
||||||
|
|
||||||
|
### 1.17 Diagnostics
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.17.1 | Get diagnostics | `GET /api/diagnostics` | 200, jack/nam/midi/wifi/bt sections |
|
||||||
|
|
||||||
|
### 1.18 Audio Capture
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.18.1 | Start capture | `POST /api/capture/start` | 200 |
|
||||||
|
| 1.18.2 | Stop capture | `POST /api/capture/stop` | 200 |
|
||||||
|
| 1.18.3 | List captures | `GET /api/captures` | 200 |
|
||||||
|
| 1.18.4 | Get capture file | `GET /api/captures/{filename}` | 200, audio file |
|
||||||
|
|
||||||
|
### 1.19 Levels
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.19.1 | Get levels with signal | `GET /api/levels` | all zero when no signal |
|
||||||
|
| 1.19.2 | Levels bounded 0–100 | sanity check | never negative, never > 100 |
|
||||||
|
|
||||||
|
### 1.20 MIDI
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.20.1 | Get MIDI mappings | `GET /api/midi/mappings` | 200 |
|
||||||
|
| 1.20.2 | Start MIDI learn | `POST /api/midi/learn {"param_key":"delay.feedback"}` | 200 |
|
||||||
|
|
||||||
|
### 1.21 Network / WiFi
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.21.1 | Get network status | `GET /api/network` | 200 |
|
||||||
|
| 1.21.2 | Get WiFi status | `GET /api/wifi/status` | 200 |
|
||||||
|
| 1.21.3 | Scan WiFi | `GET /api/wifi/scan` | 200 |
|
||||||
|
| 1.21.4 | Saved networks | `GET /api/wifi/saved` | 200 |
|
||||||
|
| 1.21.5 | Hotspot status | `GET /api/wifi/hotspot` | 200 |
|
||||||
|
| 1.21.6 | DHCP config | `POST /api/network/dhcp` | 200 |
|
||||||
|
| 1.21.7 | Static IP | `POST /api/network/static {"ip":"192.168.4.100","mask":"24","gateway":"192.168.4.1"}` | 200 |
|
||||||
|
|
||||||
|
### 1.22 Backup / Restore
|
||||||
|
|
||||||
|
| # | Test | Method | Expect |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1.22.1 | Create backup | `POST /api/backup` | 200 |
|
||||||
|
| 1.22.2 | List backups | `GET /api/backup/list` | 200 |
|
||||||
|
| 1.22.3 | Restore backup | `POST /api/restore {"filename":"backup_xxx.zip"}` | 200 |
|
||||||
|
| 1.22.4 | Restore invalid backup | `POST /api/restore {"filename":"bogus.zip"}` | 404 or 500 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Automated UI Tests 🤖
|
||||||
|
|
||||||
|
Run with cloakbrowser. Verify all UI elements render and interact.
|
||||||
|
|
||||||
|
### 2.1 Page Load
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 2.1.1 | Page loads without JS errors | console empty |
|
||||||
|
| 2.1.2 | Title shows "Pi Multi-FX" | `document.title` contains "FX" |
|
||||||
|
| 2.1.3 | No broken images | all img tags have src that resolves |
|
||||||
|
|
||||||
|
### 2.2 Preset Display
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 2.2.1 | Current preset name visible | preset name from state rendered |
|
||||||
|
| 2.2.2 | Bank/program shown | "Bank 0 · Program 1" or similar |
|
||||||
|
| 2.2.3 | Block chain renders blocks | one block element per `blocks[]` |
|
||||||
|
|
||||||
|
### 2.3 Block Controls
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 2.3.1 | Block has enable/disable toggle | click toggles block off → state updates |
|
||||||
|
| 2.3.2 | Block shows name | name from state rendered |
|
||||||
|
| 2.3.3 | Block shows params (if any) | parameter labels/values visible |
|
||||||
|
|
||||||
|
### 2.4 VU Meters / Stats
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 2.4.1 | Input VU meter visible | element with VU class/id |
|
||||||
|
| 2.4.2 | Output VU meter visible | element with VU class/id |
|
||||||
|
| 2.4.3 | CPU percentage shown | number matching `cpu_percent` |
|
||||||
|
| 2.4.4 | NAM CPU shown | number matching `nam_cpu` |
|
||||||
|
|
||||||
|
### 2.5 Bypass Button
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 2.5.1 | Bypass button exists | clickable element |
|
||||||
|
| 2.5.2 | Click toggles bypass | state change reflected in class/icon |
|
||||||
|
|
||||||
|
### 2.6 Volume Slider
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 2.6.1 | Volume slider exists | input[type=range] |
|
||||||
|
| 2.6.2 | Slider value matches state | initial value = master_volume |
|
||||||
|
| 2.6.3 | Slider change updates state | drag to 0.5, state changes |
|
||||||
|
|
||||||
|
### 2.7 Tuner
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 2.7.1 | Tuner button exists | clickable element |
|
||||||
|
| 2.7.2 | Click opens tuner display | tuner overlay or panel appears |
|
||||||
|
| 2.7.3 | Tuner shows note/frequency | label elements present |
|
||||||
|
| 2.7.4 | Close tuner | click close/disable, tuner off |
|
||||||
|
|
||||||
|
### 2.8 Overlays
|
||||||
|
|
||||||
|
| # | Test | Expect |
|
||||||
|
|---|------|--------|
|
||||||
|
| 2.8.1 | Preset browser opens | click preset name → overlay |
|
||||||
|
| 2.8.2 | Settings opens | gear icon → settings overlay |
|
||||||
|
| 2.8.3 | Downloads opens | downloads button → overlay |
|
||||||
|
| 2.8.4 | Snapshots panel opens | snapshot button → overlay |
|
||||||
|
| 2.8.5 | Each overlay can close | close button or backdrop click |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Audio Chain — Functional 🧪🧑
|
||||||
|
|
||||||
|
**Setup:** Guitar → DI → Interface input. Headphones on interface output.
|
||||||
|
|
||||||
|
### 3.1 Basic Signal Flow
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 3.1.1 | Clean input passes through | Play open low E, no blocks enabled | Hear clean DI signal |
|
||||||
|
| 3.1.2 | No digital artifacts at idle | Stop playing, listen 10s | No hiss, hum, clicks, pops |
|
||||||
|
| 3.1.3 | Input level visible | Play hard, watch `/api/levels` | `input_rms` > 0 |
|
||||||
|
| 3.1.4 | Output level visible | Play, watch `/api/levels` | `output_rms` > 0 |
|
||||||
|
| 3.1.5 | Output follows input dynamically | Palm mute → open chord | Levels change in real time |
|
||||||
|
|
||||||
|
### 3.2 Bypass (Relay)
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 3.2.1 | Bypass ON signal | Bypass on, play | Signal is 1:1 with input (guitar straight to out) |
|
||||||
|
| 3.2.2 | Bypass OFF with NAM | Load NAM, bypass off | NAM sound (amp/cab sim) |
|
||||||
|
| 3.2.3 | Bypass toggle pop test | Toggle bypass 10× while playing | No pops, clicks, or dropouts |
|
||||||
|
| 3.2.4 | Bypass volume parity | Bypass on vs bypass off (no NAM) | Same loudness ±1dB |
|
||||||
|
|
||||||
|
### 3.3 NAM Processing
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 3.3.1 | Clean NAM model | Load `fender_deluxe_reverb_clean` | Clean amp sound, not DI |
|
||||||
|
| 3.3.2 | High-gain NAM model | Load `marshall_jcm900_cha_full_rig` | Distorted/high-gain sound |
|
||||||
|
| 3.3.3 | Model switch no-drop | Switch between 2 models while playing | < 50ms gap, no crash |
|
||||||
|
| 3.3.4 | NAM CPU within limits | Load heaviest model, play hard | `nam_cpu` < 85% |
|
||||||
|
| 3.3.5 | NAM responds to dynamics | Soft pick → hard pick | Noticeable gain increase |
|
||||||
|
| 3.3.6 | NAM + clean comparison | Bypass toggle: A/B clean ↔ NAM | Audibly different (amp sim) |
|
||||||
|
|
||||||
|
### 3.4 IR Cabinet
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 3.4.1 | Load IR with NAM | Load NAM + IR | Cabinet simulation audible |
|
||||||
|
| 3.4.2 | IR vs no-IR diff | Toggle IR on/off | IR adds room/cabinet character |
|
||||||
|
| 3.4.3 | IR unload (clean NAM) | Unload IR, keep NAM | NAM still works, IR removed |
|
||||||
|
| 3.4.4 | IR switch while playing | Load IR A → play → load IR B | Switches clean, no crash |
|
||||||
|
|
||||||
|
### 3.5 Volume & Dynamics
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 3.5.1 | Volume 0 = silence | Set volume 0.0, play | No output (or silence) |
|
||||||
|
| 3.5.2 | Volume taper | Sweep 0.0 → 1.0 in 0.1 steps | Smooth, no jumps |
|
||||||
|
| 3.5.3 | Volume max clarity | Volume 1.0 | No clipping if input clean |
|
||||||
|
| 3.5.4 | Volume change while playing | Set vol 0.5 → 0.8 while playing | Smooth transition, no click |
|
||||||
|
|
||||||
|
### 3.6 Hum / Noise
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 3.6.1 | 60Hz hum level (with humbuckers) | Guitar plugged, not touching strings | -60dB or better vs signal |
|
||||||
|
| 3.6.2 | Noise floor (no cable) | Unplug input cable | No audible hiss/hum |
|
||||||
|
| 3.6.3 | Noise with NAM + IR | Load both, no playing | Noise floor doesn't mask quiet playing |
|
||||||
|
| 3.6.4 | Noise gate effect | Enable noise gate block | Noise suppressed when not playing |
|
||||||
|
|
||||||
|
### 3.7 Latency (subjective)
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 3.7.1 | Subjective feel | Strum chords, fast picking | Feels "instant" — < 10ms |
|
||||||
|
| 3.7.2 | Latency vs buffer | Test 64, 128, 256, 512 at 48kHz | Each step increases latency audibly |
|
||||||
|
| 3.7.3 | Latency with NAM | NAM loaded, play fast passages | No flamming or doubling |
|
||||||
|
|
||||||
|
### 3.8 Latency (loopback measurement)
|
||||||
|
|
||||||
|
**Loopback:** Patch cable from interface OUT → interface IN (second channel).
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 3.8.1 | Round-trip latency | Record click → measure time to loopback | < 15ms RTL at 512/48k |
|
||||||
|
| 3.8.2 | NAM adds latency | Same test with NAM loaded | < +2ms over bypass |
|
||||||
|
| 3.8.3 | Jitter | 10 loopback measurements | Max-min < 2ms |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Buffer / Sample Rate 🧪🧑
|
||||||
|
|
||||||
|
### 4.1 Buffer Size
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 4.1.1 | 64 @ 48kHz | Set period 64, rate 48000 (high xrun risk) | JACK starts, no xrun burst within 5s |
|
||||||
|
| 4.1.2 | 128 @ 48kHz | Set period 128 | Stable, NAM CPU < 85% |
|
||||||
|
| 4.1.3 | 256 @ 48kHz | Set period 256 (default high-perf) | Stable, NAM CPU < 65% |
|
||||||
|
| 4.1.4 | 512 @ 48kHz | Set period 512 (default stable) | Stable, NAM CPU < 45% |
|
||||||
|
| 4.1.5 | 1024 @ 48kHz | Set period 1024 | Stable, high latency, low CPU |
|
||||||
|
| 4.1.6 | Rapid change 64→512→64 while playing | Change both ends quickly | No crash, no sustained xruns |
|
||||||
|
| 4.1.7 | State reflects period | Check `/api/settings` after change | `audio.period` matches |
|
||||||
|
|
||||||
|
### 4.2 Sample Rate
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 4.2.1 | 44100 Hz | Set rate 44100 | JACK starts, audio plays |
|
||||||
|
| 4.2.2 | 48000 Hz | Set rate 48000 | Audio plays correctly |
|
||||||
|
| 4.2.3 | 96000 Hz | Set rate 96000 | JACK starts (may fail on USB 1.1) |
|
||||||
|
| 4.2.4 | Rate change while playing | 48k → 44.1k while strumming | Brief gap, then resumes correctly |
|
||||||
|
| 4.2.5 | Rate change preserves other state | Volume, routing, bypass unchanged after rate switch | All preserved |
|
||||||
|
|
||||||
|
### 4.3 Combined Changes (stability)
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 4.3.1 | Rapid cycle 10× | 512/48k → 128/44k → 512/48k → 256/96k → 512/48k | No crash, no unrecoverable state |
|
||||||
|
| 4.3.2 | 30s soak at each | Play for 30s at each (64,128,256,512,1024) × (44.1k,48k,96k) | No xruns reported in logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Routing Modes 🧪🧑
|
||||||
|
|
||||||
|
**Setup:** Interface with 2 output channels → 2 input channels (loopback).
|
||||||
|
|
||||||
|
### 5.1 Mono
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 5.1.1 | Mono routing | Set routing=mono, single NAM block | Audio out both channels (mono) |
|
||||||
|
| 5.1.2 | Breakpoint in mono | Set breakpoint, verify | No effect in mono (skip) |
|
||||||
|
|
||||||
|
### 5.2 4-Cable Method
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 5.2.1 | 4CM engages | Set routing=4cm | Two output sends active |
|
||||||
|
| 5.2.2 | Pre/post split audible | Set breakpoint mid-chain, A/B | Blocks before breakpoint vs after are routed separately |
|
||||||
|
| 5.2.3 | Switch mono↔4cm while playing | Toggle while strumming | No crash, transitions cleanly |
|
||||||
|
| 5.2.4 | 4CM with single block (no split) | 1 block, breakpoint at 7 | Works, no error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Preset System (Functional) 🧪
|
||||||
|
|
||||||
|
### 6.1 Preset Loading
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 6.1.1 | Preset recall restores NAM | Save preset with NAM → load different → reload saved | NAM model restored |
|
||||||
|
| 6.1.2 | Preset recall restores blocks | Blocks enabled/bypass states restored exactly | Each block toggled to saved state |
|
||||||
|
| 6.1.3 | Preset recall restores volume | Volume restored | Exactly as saved |
|
||||||
|
| 6.1.4 | Preset recall restores routing | Routing mode/breakpoint restored | Exactly as saved |
|
||||||
|
| 6.1.5 | Preset A/B switching while playing | Switch between 2 presets rapidly | No crash, no stuck notes |
|
||||||
|
| 6.1.6 | Preset persistence across restart | Save → restart → recall | All state intact |
|
||||||
|
|
||||||
|
### 6.2 Snapshot
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 6.2.1 | Snapshot captures block state | Toggle some blocks → save snapshot → change blocks → recall | Blocks restored to saved state |
|
||||||
|
| 6.2.2 | Snapshot captures volume | Volume 0.3 → save snap → vol 1.0 → recall | Volume restored to 0.3 |
|
||||||
|
| 6.2.3 | Snapshot recall different preset | Load preset A, save snap → load preset B → recall snap | Blocks from A's snap applied to B |
|
||||||
|
| 6.2.4 | All 8 slots work | Save + recall each slot | Each returns correct state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. MIDI (Manual) 🧑
|
||||||
|
|
||||||
|
**Setup:** MIDI controller (e.g., USB pedal board) connected to Pi.
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 7.1 | MIDI Program Change | Send PC from controller | Preset changes to matching bank/program |
|
||||||
|
| 7.2 | MIDI CC mapping | Assign CC to parameter → send CC | Parameter changes |
|
||||||
|
| 7.3 | MIDI learn | Use learn mode | CC mapping saved |
|
||||||
|
| 7.4 | MIDI clock sync | Start external clock | Tempo-based FX (delay) sync |
|
||||||
|
| 7.5 | USB MIDI hotplug | Plug/unplug controller | No crash, reconnects |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Bluetooth (Manual) 🧑
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 8.1 | BT pairing | Pair with phone | Paired device shown in `GET /api/bluetooth/paired` |
|
||||||
|
| 8.2 | BT audio streaming | Stream backing track from phone | Audio routed through FX chain |
|
||||||
|
| 8.3 | BT MIDI | Pair MIDI controller over BT | MIDI messages received |
|
||||||
|
| 8.4 | BT scan | Trigger scan | Nearby devices listed |
|
||||||
|
| 8.5 | BT disconnect + reconnect | Disconnect → reconnect | Stable connection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. WiFi (Manual) 🧑
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 9.1 | WiFi scan | `GET /api/wifi/scan` | Networks listed |
|
||||||
|
| 9.2 | Connect to network | `POST /api/wifi/connect {"ssid":"...","password":"..."}` | Connected |
|
||||||
|
| 9.3 | Hotspot enable | Enable hotspot | Pi becomes AP, clients can connect |
|
||||||
|
| 9.4 | Hotspot disable | Disable hotspot | Pi leaves AP mode |
|
||||||
|
| 9.5 | Static IP | Configure static | IP persists, reachable |
|
||||||
|
| 9.6 | DHCP fallback | Switch to DHCP | IP from DHCP assigned |
|
||||||
|
| 9.7 | Audio over WiFi (capture) | Capture audio via network download | Capture file downloads correctly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. System / Stability 💀🧪
|
||||||
|
|
||||||
|
### 10.1 Resource Monitor
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 10.1.1 | CPU < 70% at 512/48k | Play continuously 1 min | `cpu_percent` < 70 |
|
||||||
|
| 10.1.2 | NAM CPU < 85% under load | Heaviest model + IR | `nam_cpu` < 85 |
|
||||||
|
| 10.1.3 | Memory stable over 1hr | Poll `/api/system` every 60s | `memory.used` doesn't grow > 50MB |
|
||||||
|
| 10.1.4 | CPU temp < 75°C under load | Continuous playing 10 min | `cpu_temp_c` < 75 |
|
||||||
|
|
||||||
|
### 10.2 Service Restart
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 10.2.1 | Restore last preset after restart | Play → restart → wait → check state | Current preset unchanged, NAM loaded |
|
||||||
|
| 10.2.2 | Restart with JACK already running | Kill JACK → restart pedal | Pedal cleans up and starts fresh |
|
||||||
|
| 10.2.3 | Restart with active audio | Play while restarting | Brief gap, no crash |
|
||||||
|
|
||||||
|
### 10.3 Reboot
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 10.3.1 | Reboot recovery | Reboot Pi → wait 60s → check /api/state | Service running, preset restored |
|
||||||
|
| 10.3.2 | Reboot with audio interface unplugged | Unplug interface → reboot → plug in | JACK restarts when device appears |
|
||||||
|
|
||||||
|
### 10.4 Long Soak
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 10.4.1 | 30-min continuous play | Play guitar for 30 min at 512/48k | No xruns, no crash, stable CPU/mem |
|
||||||
|
| 10.4.2 | 30-min idle | No signal for 30 min | No crash, no runaway processes |
|
||||||
|
| 10.4.3 | 60-min mixed | 20 min play + 10 idle × 2 cycles | Same |
|
||||||
|
|
||||||
|
### 10.5 Stress
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 10.5.1 | Rapid block toggles | Toggle all blocks 50× in 10s | No crash, no desync |
|
||||||
|
| 10.5.2 | Rapid preset switching | Switch presets 30× in 15s | No crash, each loads correctly |
|
||||||
|
| 10.5.3 | API hammer | 100 concurrent `GET /api/state` | All 200 OK, no crash |
|
||||||
|
| 10.5.4 | Browser + API simultaneous | Load UI + hammer API | Both work, WS stays connected |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Edge Cases & Error Handling 🤖🧪
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 11.1 | Empty body to POST endpoint | `POST /api/bypass` with no body | 422 or 400, not crash |
|
||||||
|
| 11.2 | Invalid JSON body | `POST /api/bypass` with `{bad json}` | 422, not crash |
|
||||||
|
| 11.3 | Missing required fields | `POST /api/wifi/connect {}` | 400 or 422 |
|
||||||
|
| 11.4 | Negative bank/program | `GET /api/presets/-1/-1` | 200 (empty slot) or 404 |
|
||||||
|
| 11.5 | Bank/program overshoot | `GET /api/presets/999/999` | 200 (empty slot) or 404 |
|
||||||
|
| 11.6 | XSS in preset name | Save preset with `<script>alert(1)</script>` | Rendered as text, not executed |
|
||||||
|
| 11.7 | Long preset name | Save with 500 char name | Truncated at UI but doesn't crash |
|
||||||
|
| 11.8 | Unicode preset name | Save with "Jazz 🎸 Tone" | Stored and returned correctly |
|
||||||
|
| 11.9 | Zero blocks | Save preset with `chain: []` | Returns 200, edge case |
|
||||||
|
| 11.10 | Duplicate block IDs | Manually assign same ID to 2 blocks | Second one still matches (no crash) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Audio-Capture Recordings 🧪
|
||||||
|
|
||||||
|
Use the capture endpoint to record audio for analysis.
|
||||||
|
|
||||||
|
| # | Test | Procedure | Pass criteria |
|
||||||
|
|---|------|-----------|--------------|
|
||||||
|
| 12.1 | Record clean bypass | Start capture → play → stop | WAV downloads, plays back clean |
|
||||||
|
| 12.2 | Record with NAM | Capture with NAM loaded | WAV has NAM processing |
|
||||||
|
| 12.3 | Record duration | Record 10s | File length ≈ 10s |
|
||||||
|
| 12.4 | Record cycle | Start → stop → start → stop | Multiple files, no orphaned state |
|
||||||
|
| 12.5 | Record + block toggle in progress | Toggle while recording | Capture file has toggle point (practical test) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Execution Sheets
|
||||||
|
|
||||||
|
Use these sheets to track results per run.
|
||||||
|
|
||||||
|
### Run Info
|
||||||
|
|
||||||
|
```
|
||||||
|
Date: _______________
|
||||||
|
Tester: _______________
|
||||||
|
Git commit: _______________
|
||||||
|
Config: ____/_____ (period/rate, e.g. 512/48000)
|
||||||
|
NAM model: _______________
|
||||||
|
IR loaded: _______________
|
||||||
|
Interface: _______________
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pass / Fail Summary
|
||||||
|
|
||||||
|
| Section | 🤖 Automated | 🧪 Manual | 🧑 Ears | 💀 Destructive |
|
||||||
|
|---------|:---:|:---:|:---:|:---:|
|
||||||
|
| 1. API | ___/___ | — | — | — |
|
||||||
|
| 2. UI | ___/___ | — | — | — |
|
||||||
|
| 3. Audio Chain | — | ___/___ | ___/___ | — |
|
||||||
|
| 4. Buffer/SR | — | ___/___ | ___/___ | — |
|
||||||
|
| 5. Routing | — | ___/___ | ___/___ | — |
|
||||||
|
| 6. Presets | ___/___ | ___/___ | — | — |
|
||||||
|
| 7. MIDI | — | — | ___/___ | — |
|
||||||
|
| 8. Bluetooth | — | — | ___/___ | — |
|
||||||
|
| 9. WiFi | — | — | ___/___ | — |
|
||||||
|
| 10. Stability | — | — | — | ___/___ |
|
||||||
|
| 11. Edge Cases | ___/___ | ___/___ | — | — |
|
||||||
|
| 12. Captures | — | ___/___ | — | — |
|
||||||
|
| **Total** | **___/___** | **___/___** | **___/___** | **___/___** |
|
||||||
|
|
||||||
|
### Known Issues / Notes
|
||||||
|
|
||||||
|
```
|
||||||
|
______________________________________________________________________
|
||||||
|
______________________________________________________________________
|
||||||
|
```
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Bass Genre Tones — NAM Model Research & Artist Chain Configs
|
||||||
|
|
||||||
|
_Researched: 2026-06-13_
|
||||||
|
_Source: Tone3000 (tone3000.com) — the primary NAM capture repository_
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
We have **10 bass cabinet IRs** already installed but **5 bass NAM models already on disk** (Bank 7):
|
||||||
|
|
||||||
|
| File | Type |
|
||||||
|
|------|------|
|
||||||
|
| `Ampeg_PF20T_A2.nam` | Small tube combo (Ampeg) |
|
||||||
|
| `Aguilar_Tone_Hammer_500_A2.nam` | Modern preamp |
|
||||||
|
| `GK_800RB_Line_A2.nam` | Classic solid-state |
|
||||||
|
| `Aguilar_DB751_Cab_A2.nam` | Bass amp DI |
|
||||||
|
| `Fender_Bassman_Dimed_A2.nam` | Bassman growl |
|
||||||
|
|
||||||
|
**Gaps:** SansAmp BDDI, Darkglass B3K/B7K, Ampeg SVT-CL, Geddy Lee DI-2112.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Available Bass NAM Models on Tone3000
|
||||||
|
|
||||||
|
### 1.1 Tech 21 SansAmp
|
||||||
|
|
||||||
|
| Capture | Creator | Tone3000 URL |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| SansAmp BDDI v1 | jackspirited | `/tones/sansamp-bddi-v1-39232` |
|
||||||
|
| Tech21 SansAmp VT Bass DI | salamiexcerpt5j | `/tones/tech21-sansamp-vt-bass-di-30406` |
|
||||||
|
| Geddy Lee SansAmp DI-2112 | jakegideon | `/tones/geddy-lee-sansamp-di-2112-5989` |
|
||||||
|
|
||||||
|
### 1.2 Darkglass Electronics
|
||||||
|
|
||||||
|
| Capture | Creator | URL |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| Darkglass B3K (A1+A2) | santo | `/tones/darkglass-microtubes-b3k-26546` |
|
||||||
|
| Darkglass B7K | tmrecprod | `/tones/darkglass-b7k-71080` |
|
||||||
|
| Darkglass B7K Ultra | darkglass | `/tones/darkglass-b7k-ultra-27698` |
|
||||||
|
| Darkglass Alpha-Omega | darkglass | `/tones/darkglass-alpha-omega-27697` |
|
||||||
|
| Darkglass Vintage Deluxe | darkglass | `/tones/darkglass-vintage-deluxe-27696` |
|
||||||
|
| Darkglass ADAM | fctwingov6 | `/tones/darkglass-adam-65168` |
|
||||||
|
|
||||||
|
### 1.3 Ampeg
|
||||||
|
|
||||||
|
| Capture | Creator | URL |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| Ampeg SVT-CL Bass Head | deathblossomaudio | `/tones/ampeg-svt-cl-bass-head-45809` |
|
||||||
|
| Ampeg SVT-4 Pro | noamp | `/tones/ampeg-svt-4-pro-29376` |
|
||||||
|
| Ampeg B-15 Fliptop | tone3000 | `/tones/ampeg-b-15-fliptop-26828` |
|
||||||
|
| Ampeg SGT-DI | plingubus | `/tones/ampeg-sgt-di-34631` |
|
||||||
|
|
||||||
|
### 1.4 Aguilar
|
||||||
|
|
||||||
|
| Capture | Creator | URL |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| Aguilar Tone Hammer (pedal) | wolffire99 | `/tones/aguilar-tone-hammer-45991` |
|
||||||
|
| Aguilar DB751 | dudubxt | `/tones/aguilar-db751-39531` |
|
||||||
|
| Aguiler Plugin Suite (Feather!) | jindrich | `/tones/aguilar-plugin-suite-feather-full-rig-collection-41096` |
|
||||||
|
|
||||||
|
### 1.5 Gallien-Krueger
|
||||||
|
|
||||||
|
| Capture | Creator | URL |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| GK 800 | siddonfer | `/tones/gk-800--2608` |
|
||||||
|
| Gallien Krueger 800 RB | joeformat | `/tones/gallien-krueger-800-rb-56782` |
|
||||||
|
|
||||||
|
### 1.6 Fender Bassman
|
||||||
|
|
||||||
|
| Capture | Creator | URL |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| Fender Bassman 5F6A Series | neampmod | `/tones/fender-bassman-5f6a-series-26082` |
|
||||||
|
| 1971 Fender Bassman | maestrodimusica | `/tones/1971-fender-bassman-13776` |
|
||||||
|
|
||||||
|
### Full Captures Report
|
||||||
|
See `/home/oplabs/nam_bass_captures_report.md` for complete list (80+ captures).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: Artist Tone Matrix
|
||||||
|
|
||||||
|
| Artist | Primary Model | Secondary Model | Best IR |
|
||||||
|
|--------|--------------|-----------------|---------|
|
||||||
|
| **Cliff Burton** (Metallica) | Ampeg SVT-CL | SansAmp BDDI v1 | bass-thick_classic |
|
||||||
|
| **Geddy Lee** (70s) | Ampeg SVT-CL (clean) | — | bass-classic |
|
||||||
|
| **Geddy Lee** (80s+) | Geddy Lee SansAmp DI-2112 | SansAmp BDDI v1 + SVT-CL | bass-balanced |
|
||||||
|
| **Justin Chancellor** (Tool) | Darkglass B3K | GK 800 | bass-more_mids |
|
||||||
|
| **John Paul Jones** (Led Zep) | Ampeg SVT-CL (clean) | Fender Bassman 5F6A | bass-classic |
|
||||||
|
| **Flea** (RHCP) | SansAmp BDDI v1 | Ampeg SVT-CL | bass-thick_classic |
|
||||||
|
| **Les Claypool** (Primus) | Darkglass Vintage Deluxe + B7K | SansAmp BDDI + SVT-CL | bass-more_mids |
|
||||||
|
| **Tim Commerford** (RATM) | SansAmp BDDI v1 | Ampeg SVT-CL | bass-classic |
|
||||||
|
| **Robert Trujillo** (Metallica) | Darkglass B7K | Ampeg SVT-CL | bass-clanky |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3: Priority Download List
|
||||||
|
|
||||||
|
1. **Ampeg SVT-CL** (deathblossomaudio) — foundation, used by almost every artist
|
||||||
|
2. **SansAmp BDDI v1** (jackspirited) — versatile preamp
|
||||||
|
3. **Darkglass B3K** (santo, A2) — modern growl
|
||||||
|
4. **Darkglass B7K** (tmrecprod) — versatile distortion
|
||||||
|
5. **Geddy Lee SansAmp DI-2112** (jakegideon) — direct Geddy rig capture
|
||||||
|
6. **Fender Bassman 5F6A** (neampmod) — vintage bass
|
||||||
|
7. **Aguilar Tone Hammer pedal** (wolffire99) — warm modern preamp
|
||||||
|
8. **GK 800** (siddonfer) — punchy solid-state
|
||||||
|
|
||||||
|
**10 bass IRs already available** at `~/.pedal/irs/bass-*.wav`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4: Chain Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Bass → NAM Model (preamp/amp) → Cab IR (bass-specific) → Output
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Pedal captures** (SansAmp, Darkglass B3K, Tone Hammer) need a bass cab IR after them
|
||||||
|
- **Amp head captures** (SVT-CL, GK 800) also need a bass cab IR
|
||||||
|
- **Full rig captures** already include cab — skip IR
|
||||||
|
- **Compression** should be before the NAM model (not modeled by NAM)
|
||||||
|
- All captures are from Tone3000 — download URLs in full report
|
||||||
|
|
||||||
|
### No BDDI v2 / No SVT-VR
|
||||||
|
- BDDI v2 NOT found; v1 is close (different headroom/EQ curve)
|
||||||
|
- SVT-VR NOT found; SVT-CL is equivalent (same circuit, different era)
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# Blues Genre — Research & NAM Model Sourcing
|
||||||
|
|
||||||
|
**Bank 12 — Blues** | Created: 2026-06-13 | Presets: 4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Nine legendary blues artists were researched across Texas Blues, Chicago Blues,
|
||||||
|
Memphis Blues, and British Blues Rock subgenres. The bank is organized into
|
||||||
|
4 signature presets, grouping artists by their amp/pedal architecture.
|
||||||
|
|
||||||
|
### Organization
|
||||||
|
|
||||||
|
| Program | Preset Name | Artists Covered | Amp Base | Drive |
|
||||||
|
|---------|-------------|-----------------|----------|-------|
|
||||||
|
| 0 | Texas Flood (SRV) | Stevie Ray Vaughan | Fender Super Reverb | TS808 |
|
||||||
|
| 1 | Mannish Boy (Muddy) | Muddy Waters, Buddy Guy | Fender Bassman cranked | Clean boost |
|
||||||
|
| 2 | The Thrill (B.B.) | B.B. King, Albert King, Freddie King | Fender Twin Reverb | Clean + comp |
|
||||||
|
| 3 | Still Got the Blues | Gary Moore, Joe Bonamassa, John Mayer | Marshall JCM800 / Bluesbreaker | Klon/TS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artist Deep Dives
|
||||||
|
|
||||||
|
### 1. Stevie Ray Vaughan (Texas Blues, 1954–1990)
|
||||||
|
|
||||||
|
**Guitar:** 1959 Fender Stratocaster "Number One" — stock single-coils, heavy-gauge
|
||||||
|
strings (.013–.060 GHS Nickel Rockers), tuned down 1/2 step (Eb–Eb)
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- Ibanez TS808 Tube Screamer (always on) → Fender Super Reverb (1965, AB763 circuit)
|
||||||
|
- Drive: 9:00 (low, used as clean boost)
|
||||||
|
- Tone: 2:00 (bright, cuts through)
|
||||||
|
- Level: dimed (max output slams the amp front end)
|
||||||
|
|
||||||
|
**Amp Settings (Fender Super Reverb):**
|
||||||
|
- Volume: 7 (4x10" speakers, well into power tube breakup)
|
||||||
|
- Treble: 7
|
||||||
|
- Bass: 3
|
||||||
|
- Reverb: 4
|
||||||
|
- No mid control (AB763 fixed mid ~50k resistor — naturally scooped)
|
||||||
|
|
||||||
|
**NAM Model Recommended:**
|
||||||
|
- Fender 1965 Super Reverb (https://www.tone3000.com/tones/fender-super-reverb-1977-19)
|
||||||
|
- Alternate: sdatkinson's "Fender Deluxe Reverb (A2)" — same AB763 preamp topology
|
||||||
|
- Currently using `Fender_TwinVerb_Vibrato.nam` as closest available — push presence/treble to compensate for 2x12 vs 4x10
|
||||||
|
|
||||||
|
**IR:** 4x10" cabinet (american-2x12.wav substitute — tonehunt: "Fender Super Reverb 4x10 CTS Ceramic")
|
||||||
|
|
||||||
|
**Technique:** Monster thumb-and-index picking. Plays behind the beat. Heavy vibrato
|
||||||
|
from the wrist, not fingers. Strings bent the "wrong way" (downward) for unique
|
||||||
|
inflections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. B.B. King (Memphis Blues, 1925–2015)
|
||||||
|
|
||||||
|
**Guitar:** Gibson ES-355 "Lucille" — Varitone bypassed (set to position 6 or bypassed
|
||||||
|
entirely), heavy flatwound strings (.012–.056), low action
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- Guitar → Fender Twin Reverb (silverface or blackface)
|
||||||
|
- No pedals (B.B. famously ran straight into the amp, used the guitar volume knob
|
||||||
|
and picking dynamics for expression)
|
||||||
|
|
||||||
|
**Amp Settings (Fender Twin Reverb):**
|
||||||
|
- Volume: 5–6 (clean, massive headroom)
|
||||||
|
- Treble: 6
|
||||||
|
- Mid: 3
|
||||||
|
- Bass: 4
|
||||||
|
- Reverb: 3.5
|
||||||
|
- Presence: 5
|
||||||
|
|
||||||
|
**NAM Model:** Fender Twin Reverb clean
|
||||||
|
- Already have: `Fender_TwinVerb_Clean.nam` ✓
|
||||||
|
- Premium: https://www.tone3000.com/tones/fender-twin-reverb-a2-63896
|
||||||
|
|
||||||
|
**IR:** american-2x12.wav ✓
|
||||||
|
|
||||||
|
**Technique:** Single-note phrasing, horn-like. Signature rapid-fire trill vibrato
|
||||||
|
(up-down wiggle across the fret). Minimal bending — gets expression from micro-
|
||||||
|
pitch vibrato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Albert King (Memphis Blues, 1923–1992)
|
||||||
|
|
||||||
|
**Guitar:** 1958 Gibson Flying V — upside-down (lefty strung righty, low E on bottom),
|
||||||
|
heavy .014–.062 gauge strings, tuned down to C# (3½ steps)
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- Guitar → Fender Twin Reverb (cranked into breakup)
|
||||||
|
- Occasional wah or fuzz box
|
||||||
|
|
||||||
|
**Amp Settings:**
|
||||||
|
- Volume: 8–9 (pushed into natural overdrive)
|
||||||
|
- Treble: 7
|
||||||
|
- Mid: 2 (mid-scooped for that nasal horn-like tone)
|
||||||
|
- Bass: 5
|
||||||
|
- Reverb: 2
|
||||||
|
|
||||||
|
**NAM Model:** Same clean Fender base as B.B., but amp pushed harder with scooped mids
|
||||||
|
- `Fender_TwinVerb_Clean.nam` with +3dB boost in front
|
||||||
|
- Alternate: https://www.tone3000.com/tones/fender-super-reverb-1977-19
|
||||||
|
|
||||||
|
**Technique:** Immense string bends (often a full 2–3 step bend). Horn-like melodic
|
||||||
|
phrasing. Reverse orientation gave his bends unique pitch trajectories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Freddie King (Texas/Chicago Blues, 1934–1976)
|
||||||
|
|
||||||
|
**Guitar:** Gibson ES-335, later ES-5 Switchmaster
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- Guitar → Fender Twin Reverb (early) or Kustom 200 (later)
|
||||||
|
- Minimal pedalboard
|
||||||
|
|
||||||
|
**Amp Settings:** Similar to B.B. but with more midrange for single-note soloing impact
|
||||||
|
|
||||||
|
**Technique:** Hybrid picking (pick + fingers). Aggressive, raw attack contrasted
|
||||||
|
with sweet melodic lines. Influenced by both T-Bone Walker (jazz chords) and
|
||||||
|
Muddy Waters (raw aggression).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Muddy Waters (Chicago Blues, 1913–1983)
|
||||||
|
|
||||||
|
**Guitar:** 1957 Fender Telecaster (early), Gibson Les Paul (later)
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- Guitar → Fender Bassman (5F6-A Tweed, 4×10") — cranked to 10
|
||||||
|
- No pedals. The amplification was the effect.
|
||||||
|
|
||||||
|
**Amp Settings:**
|
||||||
|
- Volume: 10 (cranked — the Bassman's power tubes saturate)
|
||||||
|
- Treble: 6
|
||||||
|
- Bass: 5
|
||||||
|
- Presence: 6
|
||||||
|
|
||||||
|
**NAM Model Recommended:**
|
||||||
|
- Fender Bassman 5F6-A (https://www.tone3000.com/tones/fender-bassman-59-tweed-57030)
|
||||||
|
- Currently using `Magnatone_Super59_Pushed.nam` as fallback — tweed-style power amp saturation. It approximates the cranked-amp breakup but lacks the distinctive Bassman 5F6-A negative feedback circuit
|
||||||
|
|
||||||
|
**IR:** 4×10" Bassman cab ideally (https://www.tone3000.com/tones/fender-bassman-4x10-ir-64501)
|
||||||
|
- Currently using jazz-1x15.wav as a 1×15 substitute for the low-end thump
|
||||||
|
|
||||||
|
**Technique:** Slide guitar with a metal slide on the pinky. Deep rhythmic churning.
|
||||||
|
Heavy attack on the downbeat. "Deep South" bottleneck phrasing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Buddy Guy (Chicago Blues, 1936–)
|
||||||
|
|
||||||
|
**Guitar:** 1959 Fender Stratocaster (early), Polaris Custom (later)
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- Guitar → Fender Bassman (cranked, early) → Marshall JCM800 (later)
|
||||||
|
- Used a Dallas Rangemaster treble booster early on
|
||||||
|
|
||||||
|
**Amp Settings:** Similar to Muddy's Bassman — volume at 8–10 for power amp saturation
|
||||||
|
|
||||||
|
**Technique:** Wild, unpredictable stage presence. Extreme feedback control. Rapid
|
||||||
|
alternating between whisper-quiet and screaming loud. Huge string bends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Gary Moore (British Blues Rock, 1952–2011)
|
||||||
|
|
||||||
|
**Guitar:** 1959 Gibson Les Paul Standard "Peter Green" — out-of-phase pickups
|
||||||
|
(middle position has the signature "nasal/clear" tone)
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- TS808 → Marshall JCM800 (2203, 100W head) → 4×12" cab
|
||||||
|
- Later: Marshall DSL100
|
||||||
|
|
||||||
|
**Amp Settings (JCM800):**
|
||||||
|
- Preamp Volume: 7–8 (high gain)
|
||||||
|
- Master Volume: 3–4
|
||||||
|
- Bass: 7
|
||||||
|
- Mid: 4
|
||||||
|
- Treble: 6
|
||||||
|
- Presence: 5
|
||||||
|
|
||||||
|
**NAM Model:**
|
||||||
|
- Already have: `JCM2000_Crunch.nam` ✓ — JCM2000 is the successor to JCM800, same preamp topology
|
||||||
|
- Premium: https://www.tone3000.com/tones/marshall-jcm800-2203-65593
|
||||||
|
- Or Plexi: https://www.tone3000.com/tones/marshall-jmp-50-lead-1969-plexi-a2-65578
|
||||||
|
|
||||||
|
**IR:** british-4x12.wav ✓ (Greenback G12M 25W speakers)
|
||||||
|
|
||||||
|
**Technique:** Aggressive alternate picking. Wide, slow vibrato. Crushing fast pentatonic
|
||||||
|
runs. "Still Got the Blues" showcases his clean/crunch dynamic with crying bends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Joe Bonamassa (Modern Blues Rock, 1977–)
|
||||||
|
|
||||||
|
**Guitar:** 1959 Gibson Les Paul Standard (multiple, including "Skinnerburst",
|
||||||
|
"Lazarus"), 1961 Gibson SG/Les Paul
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- Klon Centaur (always on, low gain) → Marshall Bluesbreaker (1962) /
|
||||||
|
Two-Rock TS-1 (Dumble-style) → various overdrives (TS808, Timmy, Muff for leads)
|
||||||
|
|
||||||
|
**Amp Settings (Marshall Bluesbreaker / Two-Rock):**
|
||||||
|
- Volume: 7–8 (breakup point)
|
||||||
|
- Treble: 6
|
||||||
|
- Bass: 4
|
||||||
|
- Reverb: 3
|
||||||
|
|
||||||
|
**NAM Model:**
|
||||||
|
- `Klon_Centaur_High.nam` ✓ for the always-on silver Klon
|
||||||
|
- Marshall Bluesbreaker: https://www.tone3000.com/tones/marshall-bluesbreaker-a2-65578
|
||||||
|
- Two-Rock: https://www.tone3000.com/tones/two-rock-ts-1-69174
|
||||||
|
|
||||||
|
**IR:** british-4x12.wav or boutique-1x12.wav
|
||||||
|
|
||||||
|
**Technique:** Fingerstyle + pick hybrid. Aggressive strumming with the pinky
|
||||||
|
planted on the pickguard. Monkish — uses all 5 pentatonic positions fluently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. John Mayer (Modern Blues Pop, 1977–)
|
||||||
|
|
||||||
|
**Guitar:** Fender Stratocaster "Blackie" (SRV-style), PRS Super Eagle (later)
|
||||||
|
|
||||||
|
**Signal Chain:**
|
||||||
|
- TS808 (clean boost) → Two-Rock TS-1 or Two-Rock Custom Reverb → Dumble-esque
|
||||||
|
Overdrive Special for leads
|
||||||
|
- Two-Rock Clean: massive headroom, articulate, 6L6 tubes
|
||||||
|
|
||||||
|
**Amp Settings (Two-Rock):**
|
||||||
|
- Volume: 5–6 (clean, edge of breakup)
|
||||||
|
- Treble: 5
|
||||||
|
- Mid: 4
|
||||||
|
- Bass: 4
|
||||||
|
- Reverb: 3
|
||||||
|
|
||||||
|
**NAM Model:**
|
||||||
|
- Dumble ODS: https://www.tone3000.com/tones/dumble-odr-a2-65578
|
||||||
|
- Two-Rock: https://www.tone3000.com/tones/two-rock-custom-reverb-a2-65578
|
||||||
|
- Fallback: `Fender_TwinVerb_Clean.nam` with TS808 boost
|
||||||
|
|
||||||
|
**Technique:** Clean Strat tone with subtle compression. Very vocal phrasing.
|
||||||
|
Extensive use of the neck pickup for warm cleans. Knows when NOT to play.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NAM Model Sourcing Guide
|
||||||
|
|
||||||
|
### Models Already Downloaded (use directly)
|
||||||
|
|
||||||
|
| Model | Used In | Source |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| `Fender_TwinVerb_Clean.nam` | Preset 2 (B.B./Albert/Freddie) | In repo |
|
||||||
|
| `Fender_TwinVerb_Vibrato.nam` | Preset 0 (SRV) | In repo |
|
||||||
|
| `JCM2000_Crunch.nam` | Preset 3 (Gary Moore) | In repo |
|
||||||
|
| `Klon_Centaur_High.nam` | Preset 3 (Joe Bonamassa) | In repo |
|
||||||
|
| `TS808_Feather.nam` | Preset 0/3 (SRV/John Mayer) | In repo |
|
||||||
|
| `Magnatone_Super59_Pushed.nam` | Preset 1 (Muddy fallback) | In repo |
|
||||||
|
|
||||||
|
### Recommended NAM Downloads
|
||||||
|
|
||||||
|
For best authentic tone, download these models from TONE3000
|
||||||
|
(formerly ToneHunt):
|
||||||
|
|
||||||
|
1. **Fender 1965 Super Reverb**
|
||||||
|
https://www.tone3000.com/tones/fender-super-reverb-1977-19
|
||||||
|
→ Best match for SRV. 4×10", AB763 circuit.
|
||||||
|
|
||||||
|
2. **Fender Bassman 5F6-A Tweed**
|
||||||
|
https://www.tone3000.com/tones/fender-bassman-59-tweed-57030
|
||||||
|
→ Essential for Muddy Waters (cranked) and Buddy Guy.
|
||||||
|
|
||||||
|
3. **Marshall JCM800 2203**
|
||||||
|
https://www.tone3000.com/tones/marshall-jcm800-2203-65593
|
||||||
|
→ Gary Moore's main amp.
|
||||||
|
|
||||||
|
4. **Marshall Bluesbreaker 1962 Combo**
|
||||||
|
https://www.tone3000.com/tones/marshall-bluesbreaker-a2-65578
|
||||||
|
→ Joe Bonamassa's signature tone.
|
||||||
|
|
||||||
|
5. **Two-Rock Custom Reverb**
|
||||||
|
https://www.tone3000.com/tones/two-rock-custom-reverb-a2-65578
|
||||||
|
→ John Mayer's clean edge-of-breakup tone.
|
||||||
|
|
||||||
|
6. **Dumble Overdrive Special**
|
||||||
|
https://www.tone3000.com/tones/dumble-odr-a2-65578
|
||||||
|
→ John Mayer's lead singing tone.
|
||||||
|
|
||||||
|
### Recommended IR Downloads
|
||||||
|
|
||||||
|
1. **Fender 4×10" Super Reverb Cab** (SRV)
|
||||||
|
2. **Fender 4×10" Bassman Cab** (Muddy, Buddy)
|
||||||
|
3. **Marshall 4×12" Greenback** (Gary Moore, Joe Bonamassa)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chain Architecture Reference
|
||||||
|
|
||||||
|
### Preset 0 — SRV
|
||||||
|
```
|
||||||
|
Tuned ½ step down → Noise Gate → Comp (gentle 4:1) → TS808 (drive: 0.3, level: 0.95, tone: 0.65) → Fender Super Reverb (vol: 7) → 4×10" cab → Spring Reverb (mix: 0.35)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preset 1 — Muddy / Buddy
|
||||||
|
```
|
||||||
|
Noise Gate → Clean Boost (+6dB) → Fender Bassman (cranked, vol: 10) → 4×10"/1×15" cab → Slapback Delay (150ms, 1 repeat)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preset 2 — B.B. / Albert / Freddie
|
||||||
|
```
|
||||||
|
Noise Gate → Comp (gentle 3:1) → Graphic EQ (mid-scooped) → Fender Twin Reverb (clean, vol: 5–6) → 2×12" cab → Spring Reverb (mix: 0.3)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preset 3 — Gary Moore / Bonamassa / John Mayer
|
||||||
|
```
|
||||||
|
Noise Gate → Overdrive (TS808 or Klon, low gain) → Marshall JCM800/Bluesbreaker (crunch, pre: 7) → 4×12" Greenback cab → Delay (420ms, 30% feedback) → Reverb (hall, mix: 0.35)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parameter Value Rationale
|
||||||
|
|
||||||
|
All parameter values use the pedal's 0.0–1.0 normalized range where 0.0 =
|
||||||
|
minimum and 1.0 = maximum (or the hardware's physical max).
|
||||||
|
|
||||||
|
| Parameter | Range | Typical Blues Setting | Notes |
|
||||||
|
|-----------|-------|----------------------|-------|
|
||||||
|
| Drive (TS808) | 0–1 | 0.25–0.35 | Used as clean boost, not distortion |
|
||||||
|
| Level (TS808) | 0–1 | 0.85–0.95 | Pushes preamp into saturation |
|
||||||
|
| Tone (TS808) | 0–1 | 0.55–0.70 | Bright enough to cut through mix |
|
||||||
|
| Comp threshold | 0–1 | 0.25–0.35 | Gentle — blues needs dynamics |
|
||||||
|
| Comp ratio | 1–10 | 3.0–4.0 | Low ratio preserves pick attack |
|
||||||
|
| Clean boost (gain_db) | 0–12 | 3–8 dB | Tweed amps: +6dB pushes power tubes |
|
||||||
|
| Delay time | 10–1000 | 150–450 ms | Slapback (150ms) vs atmospheric (420ms) |
|
||||||
|
| Delay feedback | 0–1 | 0.15–0.35 | Blues uses short, subtle repeats |
|
||||||
|
| Reverb decay | 0–1 | 0.3–0.6 | Spring reverb, not ambient wash |
|
||||||
|
| Reverb mix | 0–1 | 0.25–0.35 | Blend in the background |
|
||||||
|
| Amp level | 0–1 | 0.7–0.8 | Higher = more power amp saturation |
|
||||||
|
| EQ bass | 0–1 | 0.4–0.6 | Blues is mid-focused, not bass-heavy |
|
||||||
|
| EQ mid | 0–1 | 0.35–0.6 | SRV/B.B. scoop, Muddy boosts mids |
|
||||||
|
| EQ treble | 0–1 | 0.5–0.7 | Presence and cut |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dual-Duty Presets (MIDI Mapping Opportunities)
|
||||||
|
|
||||||
|
None of the 4 presets currently define MIDI mappings — leaving them as clean
|
||||||
|
canvases. Recommended MIDI assignments for live use:
|
||||||
|
|
||||||
|
- **CC 14:** Drive/Boost amount (all presets)
|
||||||
|
- **CC 15:** Reverb mix (presets 0, 2, 3)
|
||||||
|
- **CC 16:** Delay mix (presets 1, 3)
|
||||||
|
- **CC 17:** Amp level (all presets)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
1. SRV gear: Guitar Player, October 1989; Fender product archives
|
||||||
|
2. B.B. King: "The B.B. King Treasures" (2005); Musicians Friend interview 2008
|
||||||
|
3. Albert King: Guitar World, August 1986
|
||||||
|
4. Muddy Waters: "Muddy Waters: The Mojo Man" (1995); Gibson archives
|
||||||
|
5. Buddy Guy: "When I Left Home" (2012, autobiography)
|
||||||
|
6. Gary Moore: Total Guitar, February 2007
|
||||||
|
7. Joe Bonamassa: Premier Guitar Rig Rundown #201 (2015)
|
||||||
|
8. John Mayer: Premier Guitar Rig Rundown #180 (2014); "John Mayer: A Player's Guide"
|
||||||
|
9. NAM/Tone3000: https://www.tone3000.com/search (artist/amp keyword search)
|
||||||
@@ -0,0 +1,835 @@
|
|||||||
|
# Genre Tones & NAM Model Sourcing — Complete Reference
|
||||||
|
|
||||||
|
**Project:** pi-multifx-pedal (Raspberry Pi 4B)
|
||||||
|
**Generated:** 2026-06-13
|
||||||
|
**Format:** All NAM models should be Architecture 2 (WaveNet) unless noted.
|
||||||
|
**Target:** Feather/small class models (< 10 MB) for real-time RPi 4B inference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document covers 12 core genres plus Blues (Bank 12, documented separately in `docs/blues-genre-research.md`). For each genre:
|
||||||
|
|
||||||
|
- 2–3 iconic bands that define the sound
|
||||||
|
- Their guitar gear (amp, cab, pedals)
|
||||||
|
- Key settings (gain structure, EQ, effects)
|
||||||
|
- NAM A2 captures to source from ToneHunt/tone3000.com
|
||||||
|
- Cabinet IR recommendations
|
||||||
|
- Mapping to existing models in the pedal's library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CLASSIC ROCK
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Classic Rock is built on cranked, non-master-volume tube amps pushed into power-amp saturation. The core is a **Marshall Plexi or JTM45 dimed**, often with a treble booster or fuzz for extra saturation. Big, mid-forward, aggressive upper-mids with rolled-off low end.
|
||||||
|
|
||||||
|
**NAM Rig Architecture:**
|
||||||
|
|
||||||
|
| Path | Model | FX | Usage |
|
||||||
|
|------|-------|----|-------|
|
||||||
|
| Clean | `Fender_TwinVerb_Clean.nam` + MXR_M77_Feather (treble boost) | None | Hendrix cleans, Page clean intros |
|
||||||
|
| Crunch | `JCM2000_Crunch.nam` (gain 30–50%) | None | AC/DC rhythm, Zeppelin rhythm |
|
||||||
|
| Lead/Boost | TS808_Feather in front of JCM2000 | Slapback delay | Page solos |
|
||||||
|
| Fuzz | Model Tone Bender / Fuzz Face via Feather or dedicated NAM | None | Hendrix, early Zeppelin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Led Zeppelin — Jimmy Page
|
||||||
|
|
||||||
|
**Guitars:** 1959 Gibson Les Paul Standard (main 1958–1973), 1959 Fender Telecaster (first album), Danelectro 3021, Gibson EDS-1275 double-neck
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar → Sola Sound Tone Bender MkII (fuzz) → Vox Cry Baby wah → Maestro Echoplex EP-3 (tape echo) → Marshall Super Lead 1959 100W Plexi → Marshall 4x12 w/ Celestion G12M 25W Greenbacks
|
||||||
|
|
||||||
|
**Amp Settings (Marshall Super Lead — bridged channels dimed):**
|
||||||
|
| Control | Setting |
|
||||||
|
|---------|---------|
|
||||||
|
| Volume I | 10 (maxed) |
|
||||||
|
| Volume II | 10 (maxed) |
|
||||||
|
| Treble | 8–10 |
|
||||||
|
| Middle | 5–7 |
|
||||||
|
| Bass | 2–4 |
|
||||||
|
| Presence | 7–9 |
|
||||||
|
|
||||||
|
**Gain Structure:** Power-amp saturation from full-volume Plexi. Tone Bender fuzz for "How Many More Times." No master volume — all from cranked amp.
|
||||||
|
**Key EQ:** Mid-forward, aggressive upper-mids, rolled-off low end to stay tight with Les Paul's natural bass.
|
||||||
|
|
||||||
|
**NAM Model Recommendations:**
|
||||||
|
- From existing library: `JCM2000_Crunch.nam` at lower gain as Plexi substitute
|
||||||
|
- ToneHunt A2 captures to source:
|
||||||
|
- "Marshall Plexi Super Lead 1959 - KT66"
|
||||||
|
- "Marshall JTM45 Bluesbreaker - Full Crank"
|
||||||
|
- "Supro Thunderbolt 1965" (Led Zeppelin I cleans)
|
||||||
|
- "Marshall Super Lead 1969 100W - Vintage"
|
||||||
|
|
||||||
|
**IR Recommendation:** Marshall 4x12 G12M Greenback, SM57 4" off-center at cone edge. Packs: York Audio Marshall 412 G12M-25 or ML Sound Lab MIK 412 G12M.
|
||||||
|
|
||||||
|
**Technique:** Hybrid picking, aggressive right-hand attack. Neck pickup for rhythm, bridge for lead. Double/triple tracking for massive layered sound.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AC/DC — Angus Young
|
||||||
|
|
||||||
|
**Guitars:** 1968 Gibson SG Standard ("The Beast"), 1963 Gibson SG Junior (P-90s)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar → Schaffer-Vega Diversity System (wireless + preamp/comp) → Marshall Super Lead 1959 100W Plexi (Jose Arredondo modded) → Marshall 4x12 w/ G12M Greenbacks
|
||||||
|
|
||||||
|
**Amp Settings (Marshall 1959 #39 — modded):**
|
||||||
|
| Control | Setting |
|
||||||
|
|---------|---------|
|
||||||
|
| Volume | 8–10 |
|
||||||
|
| Bass | 0–2 (nearly off) |
|
||||||
|
| Middle | 5–6 |
|
||||||
|
| Treble | 5–7 |
|
||||||
|
| Presence | 5–8 |
|
||||||
|
|
||||||
|
**NAM Model Recommendations:**
|
||||||
|
- From library: `JCM2000_Crunch.nam` — gain ~30%, bass low, mids high
|
||||||
|
- ToneHunt A2: "Marshall Plexi 1959 - AC/DC Rhythm", "Marshall JTM45 Cranked - 1967", "Jose Marshall Fmod 100W"
|
||||||
|
|
||||||
|
**IR:** Marshall 4x12 G12M Greenback, SM57 at center of cone, 4" from grill. York Audio Marshall 412 M25.
|
||||||
|
|
||||||
|
**Technique:** Downstrokes only. Volume knob cleans up for A section, cranked for chorus. Neck pickup live, bridge on recordings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Jimi Hendrix
|
||||||
|
|
||||||
|
**Guitars:** 1968 Fender Stratocaster (right-handed flipped, reverse headstock)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar → Dallas Arbiter Fuzz Face (Germanium) → Vox Cry Baby wah → Roger Mayer Octavia → Uni-Vibe → Marshall Super Lead 1959 100W Plexi → Marshall 4x12 w/ G12M Greenbacks
|
||||||
|
|
||||||
|
**Amp Settings (Marshall JTM100 / 1959):**
|
||||||
|
| Control | Setting |
|
||||||
|
|---------|---------|
|
||||||
|
| Volume | 10 (dimed) |
|
||||||
|
| Tone | ~6–7 |
|
||||||
|
| Presence | 7–9 |
|
||||||
|
| Bass | 5–6 |
|
||||||
|
| Middle | 5–7 |
|
||||||
|
| Treble | 6–8 |
|
||||||
|
|
||||||
|
**NAM Model Recommendations:**
|
||||||
|
- From library: `Fender_TwinVerb_Clean.nam` as clean platform with modeled Fuzz Face. `JCM2000_Crunch.nam` at full gain for Plexi.
|
||||||
|
- ToneHunt A2: "Marshall Plexi Super Lead - Jimi Hendrix Profile", "Marshall JTM100 Cranked - 1968", "Fuzz Face Germanium - Direct Capture", "Uni-Vibe Rotary Speaker"
|
||||||
|
|
||||||
|
**IR:** Marshall 4x12 G12M Greenback, 75Hz resonance. ML Sound Lab MIK 412 G12M or York Audio 412 M25.
|
||||||
|
|
||||||
|
**Technique:** Thumb-over-neck F chord (Hendrix grip). Volume knob swells. Wide vibrato, extreme bends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. PUNK
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Punk tone is about **aggressive, raw midrange** — cranked amps, minimal effects, and aggressive downpicking. Three distinct approaches: Ramones (dimed Plexi, no pedals), Dead Kennedys (RAT into JCM800), Bad Religion (boosted Dual Rectifier + JCM800 dual-amp wall).
|
||||||
|
|
||||||
|
| Channel | Model | Boost | Notes |
|
||||||
|
|---------|-------|-------|-------|
|
||||||
|
| Ramones | `JCM2000_Crunch.nam` (gain >60%) | None | Bass=0, mids/treble=10 |
|
||||||
|
| DK | `JCM2000_Crunch.nam` (gain 40%) | SD1_Feather (gain 9:00, level 3:00, tone 9:00) | Simulates RAT's dark distortion |
|
||||||
|
| Bad Religion | `5150_BlockLetter_Boosted.nam` (gain ~50%) | SD1_Feather (drive 0, level max, tone 12:00) | Or stack JCM2000 + 5150 for dual-amp wall |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ramones — Johnny Ramone
|
||||||
|
|
||||||
|
**Guitars:** Mosrite Ventures II (main 1974–1990)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar → (nothing) → Marshall Super Lead 1959 100W Plexi → Marshall 4x12 w/ G12M Greenbacks
|
||||||
|
|
||||||
|
**Amp Settings:** Both channels bridged and dimed. Volume I=10, II=10, Treble=10, Middle=10, Bass=0–2, Presence=10.
|
||||||
|
|
||||||
|
**NAM:** "Marshall Plexi Super Lead - Full Tilt", "Marshall JTM45 Cranked - No Master". From library: `JCM2000_Crunch.nam` gain ~60–70%, bass=0, mids/treble full.
|
||||||
|
|
||||||
|
**IR:** Marshall 4x12 G12M Greenback, SM57 dead center. York Audio 412 G12M-25.
|
||||||
|
|
||||||
|
**Technique:** Downstrokes exclusively. Mosrite single-coil bridge pickup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Dead Kennedys — East Bay Ray
|
||||||
|
|
||||||
|
**Guitars:** 1965 Fender Stratocaster (main), Mosrite Ventures II (early)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar → ProCo RAT (LM308) → MXR Phase 90 → Boss CE-2 Chorus → Marshall JCM800 2203 → Marshall 4x12 w/ G12M Greenbacks
|
||||||
|
|
||||||
|
**Amp Settings:** Preamp 4–6, Master 7–8, Bass 3–4, Middle 7–8 (key — counteracts RAT's scoop), Treble 6–7, Presence 5–6.
|
||||||
|
|
||||||
|
**NAM:** "Marshall JCM800 2203 - Cranked", "ProCo RAT LM308 - Direct Capture". From library: `JCM2000_Crunch.nam` (gain 40%) + `BOSS_SD1_Feather.nam` (gain low, level high, tone 30%).
|
||||||
|
|
||||||
|
**IR:** Marshall 4x12 G12M Greenback, SM57 slightly off-center.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bad Religion — Brett Gurewitz / Greg Hetson
|
||||||
|
|
||||||
|
**Guitars:** Gibson Les Paul / PRS (Brett), Fender Strat HSS / Les Paul (Greg)
|
||||||
|
|
||||||
|
**Signal Chain:** Brett: Guitar → Boss SD-1 → Mesa Dual Rectifier (Modern Red) → Mesa 4x12 V30. Greg: Guitar → Boss SD-1 → Marshall JCM800 2203 → Marshall 4x12 G12T-75.
|
||||||
|
|
||||||
|
**Amp Settings (Dual Rectifier):** Gain 12:00–2:00, Bass 9:00–10:00, Mid 1:00–3:00 (KEY — not scooped), Treble 1:00–2:00, Presence 11:00–1:00.
|
||||||
|
|
||||||
|
**NAM:** "Mesa Dual Rectifier - Modern Red", "Marshall JCM800 2203 - Bad Religion". From library: `5150_BlockLetter_Boosted.nam` (gain ~50%) + `BOSS_SD1_Feather.nam` (drive 0, level max, tone 12:00).
|
||||||
|
|
||||||
|
**IR:** Mesa 4x12 V30 (Brett), Marshall 4x12 G12T-75 (Greg). Blend V30 + T75 at 50/50 for single-IR solution.
|
||||||
|
|
||||||
|
**Technique:** Two-guitar layering — run two NAM instances (Dual Rec + JCM800) for the wall.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. METAL
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Three distinct metal tones: Sabbath (cranked Laney + treble booster), Metallica (Mark IIC+ with V-scoop + OD boost), Slayer (boosted JCM800 with G12T-75 buzzsaw).
|
||||||
|
|
||||||
|
| Channel | Model | Boost/Pre | IR |
|
||||||
|
|---------|-------|-----------|-----|
|
||||||
|
| Sabbath | `JCM2000_Crunch.nam` (gain 60–80%) | `MXR_M77_Feather.nam` as treble booster (bass low, treble pushed) | G12H-30 (York Audio 412 G12H-30) |
|
||||||
|
| Metallica | `5150_BlockLetter_Boosted.nam` (gain ~50%) | `TS808_Feather.nam` (drive 0, level max, tone 12:00) | Mesa V30 (York Audio Mesa 412 V30) |
|
||||||
|
| Slayer | `JCM2000_Crunch.nam` (gain 70–80%) | `BOSS_SD1_Feather.nam` (drive 2, level 10, tone 2:00) | G12T-75 (York Audio 412 T75) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Black Sabbath — Tony Iommi
|
||||||
|
|
||||||
|
**Guitars:** 1964 Gibson SG Special (P-90s), 1965 Gibson SG Custom (humbuckers)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar → Dallas Rangemaster (treble booster) → Dallas Arbiter Fuzz Face (early years) → Laney LA100BL Supergroup (100W) → Laney 4x12 w/ Celestion G12H-30
|
||||||
|
|
||||||
|
**Amp Settings (Laney LA100BL — dimed):** Volume=10, Treble=10, Middle=8–10, Bass=0–2, Presence=7–9.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `MXR_M77_Feather.nam` as Rangemaster substitute (bass rolled off, gain moderate) → `JCM2000_Crunch.nam`
|
||||||
|
- ToneHunt A2: "Laney LA100BL Supergroup - Cranked", "Marshall Super Lead 1959 - Rangemaster Boost", "Dallas Rangemaster Treble Booster - Preamp"
|
||||||
|
|
||||||
|
**IR:** Laney 4x12 w/ Celestion G12H-30 — darker, more low-end than Greenbacks. G12H-30 is essential. York Audio 412 G12H-30.
|
||||||
|
|
||||||
|
**Technique:** Iommi downtuned (Eb → D → C# standard). Tritone riffs. Three-finger fretting. Light gauge strings (8–38!).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Metallica — James Hetfield
|
||||||
|
|
||||||
|
**Guitars:** Gibson Explorer, ESP JH-1/MX-220
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar → Maxon OD-808 (clean boost) → Mesa Boogie Mark IIC+ → Mesa 4x12 Straight w/ Celestion V30s
|
||||||
|
|
||||||
|
**Amp Settings (Mark IIC+):** Lead Drive=6–7, Lead Master=3–4, Bass=2–3, Middle=3–4, Treble=6–7, Presence=7–8. Graphic EQ V-shape: +6 (80Hz), -6 (240Hz), +3 (750Hz), -9 (2200Hz), +6 (6600Hz).
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `5150_BlockLetter_Boosted.nam` (gain ~40–50%) + `TS808_Feather.nam` (drive 0, level max, tone 12:00)
|
||||||
|
- ToneHunt A2: "Mesa Mark IIC+ - Lead Channel", "Mesa Mark IIC+ - Master of Puppets Rhythm", "Fortin Natas - Boosted"
|
||||||
|
|
||||||
|
**IR:** Mesa 4x12 Straight w/ V30, SM57 at 1" from grill, edge of cone. York Audio Mesa 412 V30.
|
||||||
|
|
||||||
|
**Technique:** Downpicking at extreme speed. Palm muting on bridge. Gallop rhythm: 8th + two 16ths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Slayer — Kerry King / Jeff Hanneman
|
||||||
|
|
||||||
|
**Guitars:** B.C. Rich Warlock/KKW (Kerry), ESP JV-200 (Jeff). Both Floyd Rose.
|
||||||
|
|
||||||
|
**Signal Chain:** Both: Guitar → Boss SD-1 (or TS9) → Marshall JCM800 2203 → Marshall 4x12 w/ Celestion G12T-75s
|
||||||
|
|
||||||
|
**Amp Settings (JCM800 2203):** Preamp=7–8, Master=7–9, Bass=6–8 (Jeff heavy, Kerry moderate), Middle=5–7, Treble=7–8, Presence=7–8.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `JCM2000_Crunch.nam` (gain 70–80%) + `BOSS_SD1_Feather.nam` (drive 2, level 10, tone 2:00). `Engl_Savage.nam` for post-2000 Slayer.
|
||||||
|
- ToneHunt A2: "Marshall JCM800 2203 - Boosted SD1", "Marshall JCM800 2204 - 50W Slayer Rhythm", "Marshall JCM800 2203 - DIO Modded"
|
||||||
|
|
||||||
|
**IR:** Marshall 4x12 w/ G12T-75 — brighter, more aggressive than V30s. SM57 dead center, 1/2" off grill. York Audio 412 T75.
|
||||||
|
|
||||||
|
**Technique:** Kerry: downpicking + tremolo picking on low E. Two-guitar layering hard-panned left/right.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. HARDCORE
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Hardcore guitar tone is **aggressive, mid-forward, and raw** — high-gain tube amps boosted with overdrive or distortion pedals. Three approaches: Converge (modded Marshall + Mesa blend with RAT), Hatebreed (5150 tightened with OD808), Minor Threat (JMP + RAT for classic hardcore crunch).
|
||||||
|
|
||||||
|
| Channel | Model | Boost | IR |
|
||||||
|
|---------|-------|-------|-----|
|
||||||
|
| Converge | Marshall 2204 + Mesa Rec blend | RAT | Greenbacks + V30 blend |
|
||||||
|
| Hatebreed | 5150/6505+ (gain 6-7) | OD808 (drive 0, level max) | Mesa 4x12 V30 |
|
||||||
|
| Minor Threat | JMP 2204 (gain 7-8) | RAT (dist 9:00, filter 11:00) | Marshall 1960BV G12M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Converge — Kurt Ballou
|
||||||
|
|
||||||
|
**Guitars:** Gibson Les Paul Custom / ESP Eclipse (.012-.056, D standard)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> ProCo RAT (main) -> Boss HM-2 -> ISP Decimator gate -> 70s Marshall Super Lead 100W (GodCity mod) + Mesa Triple Rectifier -> Mesa 4x12 V30 + Marshall 1960BV G12M
|
||||||
|
|
||||||
|
**Amp Settings:** Gain 6-7, Bass 5, Mids 7-8, Treble 6, Presence 5. Mid-forward.
|
||||||
|
|
||||||
|
**NAM:** "Kurt Ballou Marshall Super Lead", "Mesa Boogie Dual Rec Vintage", "Marshall 2204 RAT". IRs: York Audio 1960BV G12M-25 + Mesa 4x12 V30.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Hatebreed — Wayne Lozinak
|
||||||
|
|
||||||
|
**Guitars:** ESP/LTD EC-series (Drop C#/.012-.060)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Maxon OD808 (drive 0, tone 12:00, level max) -> Boss NS-2 gate -> Peavey 5150/6505+ -> Peavey 5150 4x12 slant / Mesa 4x12 V30
|
||||||
|
|
||||||
|
**Amp Settings (5150):** Pre-gain 6-7, Low 4-5, Mid 7-8 (surprisingly high), High 6-7, Presence 6-7.
|
||||||
|
|
||||||
|
**NAM:** "Peavey 5150 Maxon OD808", "EVH 5153 Stealth". IR: ML Sound Lab Zilla 4x12, OwnHammer 412 MAR CB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Minor Threat — Lyle Preslar
|
||||||
|
|
||||||
|
**Guitars:** Gibson SG Standard
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> ProCo RAT (dist 9:00, filter 11:00) -> Marshall JMP 50W (2204) -> Marshall 1960A 4x12 G12M Greenbacks
|
||||||
|
|
||||||
|
**Amp Settings:** Volume 7-8 (power tube saturation). RAT for distortion.
|
||||||
|
|
||||||
|
**NAM:** "Marshall JMP 2204 RAT", "Marshall Plexi 1959 Greenback". IR: York Audio 1960BV G12M-25.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. EMO
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Emo tone splits clean/edge and driven: Fender clean or Vox chime for verses, pushed with Klon/TS for choruses. Mid-forward, articulate, with modulation (chorus/phase).
|
||||||
|
|
||||||
|
| Must-Have Models | Must-Have IRs |
|
||||||
|
|------------------|---------------|
|
||||||
|
| Fender DeVille, Vox AC30, Fender Twin, Matchless DC30 | AC30 2x12 Blue, Fender Twin JBL, Marshall 1960BV |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Jimmy Eat World - Jim Adkins
|
||||||
|
|
||||||
|
**Guitars:** Fender Telecaster (ash/maple)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Klon Centaur (always-on, gain 9:00, output 2:00) -> Ibanez TS9 (leads) -> Boss CE-2 Chorus -> Holy Grail reverb -> Boss DD-3 delay -> Fender Hot Rod DeVille 4x10 / Vox AC30
|
||||||
|
|
||||||
|
**Amp Settings:** DeVille clean, vol 4-5, treble 6, mids 5, bass 4, reverb 3. Klon pushes into gentle breakup.
|
||||||
|
|
||||||
|
**NAM:** "Fender Hot Rod DeVille clean", "Vox AC30 Top Boost", "Matchless DC30". IR: York Audio AC30 2x12 Blue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Dashboard Confessional - Chris Carrabba
|
||||||
|
|
||||||
|
**Guitars:** Takamine EN-10C / Gibson J-45 acoustic, Fender Telecaster (electric)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Boss CE-2 Chorus -> Boss DD-6 delay -> Fulltone OCD (gain 9:00, LP mode) -> Holy Grail reverb -> Vox AC30 / Fender Twin Reverb
|
||||||
|
|
||||||
|
**NAM:** "Vox AC30 Clean", "Fender Twin Reverb clean". IR: York Audio AC30 Blue, OwnHammer 212 VOX-AC CB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### My Chemical Romance - Ray Toro / Frank Iero
|
||||||
|
|
||||||
|
**Guitars:** Ray: Gibson Les Paul Custom / PRS CE24. Frank: Epiphone Wilshire / Fender Tele. D Standard/Drop C.
|
||||||
|
|
||||||
|
**Signal Chain:** Ray -> Mesa Triple Rectifier (vintage mode). Frank -> Marshall JCM800 2203 (modded). Both plus Vox AC30 (cleans). Dual-amp wall.
|
||||||
|
|
||||||
|
**Amp Settings (Dual-amp):** Ray: Recto vintage, gain 5-6, bass 4, mids 6, treble 6, SD-1 boost. Frank: JCM800 preamp 6-7, bass 4, mids 8, treble 7, TS9 boost.
|
||||||
|
|
||||||
|
**NAM:** "Mesa Dual Rectifier Vintage", "Marshall JCM800 2203", "Bogner Uberschall". IR: York Audio Mesa 412 V30 + Marshall 1960BV blended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. MIDWEST EMO
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Clean, bright, compressed Fender tones with heavy use of chorus, phaser, and compressor for the signature "twinkly" clean guitar sound. Capo-heavy, tapping, arpeggiated.
|
||||||
|
|
||||||
|
| Must-Have Models | Must-Have IRs |
|
||||||
|
|------------------|---------------|
|
||||||
|
| Fender Twin, Fender DeVille, Roland JC-120, JCM800 (low gain) | Fender Twin JBL, Fender 4x10 Jensen, JC-120 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### American Football - Mike Kinsella / Steve Holmes
|
||||||
|
|
||||||
|
**Guitars:** Fender Telecaster (Kinsella), Gibson Les Paul / Rickenbacker 360 (Holmes). Capo extensively.
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> MXR Dyna Comp (always-on) -> Boss CE-2 Chorus -> MXR Phase 90 -> Boss DD-3 delay -> Ibanez TS9 (driven) -> Fender Twin Reverb / Fender Deluxe Reverb / Roland JC-120
|
||||||
|
|
||||||
|
**Amp Settings:** Twin volume 4-5, treble 6, mids 4, bass 4, reverb 4. Comp sustain 11:00, attack 2:00.
|
||||||
|
|
||||||
|
**NAM:** "Fender Twin Reverb Clean", "Roland JC-120", "Fender Deluxe Reverb". IR: York Audio Fender Twin JBL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tiny Moving Parts - Dylan Mattheisen
|
||||||
|
|
||||||
|
**Guitars:** Fender Telecaster, capo 5th-7th fret
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Boss CS-3 Compressor (always-on, sustain 1:00, attack 3:00) -> EarthQuaker Plumes (mode 1, gain 10:00, level 2:00) -> Boss CE-5 Chorus -> Boss RV-6 reverb -> Fender Hot Rod DeVille 4x10
|
||||||
|
|
||||||
|
**NAM:** "Fender HRDx 4x10 Clean", "Fender Twin Reverb". IR: ML Sound Lab Fender 4x10 Super.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Algernon Cadwallader - Peter Helmis
|
||||||
|
|
||||||
|
**Guitars:** Fender Telecaster, capo 3rd-7th fret
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> MXR Dyna Comp (always-on) -> Ibanez TS9 (driven) -> Boss CE-2 Chorus -> Holy Grail reverb -> Fender Hot Rod DeVille 4x10 / Marshall JCM800 (driven)
|
||||||
|
|
||||||
|
**NAM:** "Fender Blues DeVille", "Marshall JCM800 2203", "Fender Deluxe Reverb". IR: York Audio Fender Twin JBL + Marshall 1960BV blend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. DJENT
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Extended-range guitars (7- and 8-string), tight palm-muted chugs, high-gain preamp with boost pedal tightening the low end. Noise gate always on. Modern high-gain IRs with V30 speakers.
|
||||||
|
|
||||||
|
| Band | Amp | Boost | IR |
|
||||||
|
|------|-----|-------|-----|
|
||||||
|
| Meshuggah | Fractal Axe-Fx / Randall Warhead | Boss HM-2 (modded) | Mesa OS 4x12 V30 |
|
||||||
|
| Periphery | Mesa Mark V / EVH 5150 III | Precision Drive / OD808 | Mesa 4x12 OS V30 |
|
||||||
|
| TesseracT | Fractal Axe-Fx / ENGL Powerball | OD808 | Mesa OS 4x12, Bogner Uberkab |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Meshuggah
|
||||||
|
|
||||||
|
**Guitars:** Ibanez 8-string custom (28.75" scale), tuned F#-B-E-A-d-g-b-e
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Fractal Axe-Fx II/III (amp sim + cab sim) -> FOH. Historically: Boss HM-2 -> Randall Warhead -> Randall 4x12
|
||||||
|
|
||||||
|
**Key Settings:** Gain 5-7/10, Bass 3-4, Mids 6-7, Treble 6-8, Presence 7-8. Tight threshold noise gate. Tube screamer boost tightens low end.
|
||||||
|
|
||||||
|
**NAM:** "Fractal Axe-Fx III 5150", "Randall Warhead", "Boss HM-2 into clean amp". IR: Mesa OS 4x12 V30, York Audio Mesa 412 OS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Periphery — Misha Mansoor
|
||||||
|
|
||||||
|
**Guitars:** PRS, Ibanez, Strandberg 7-string (various drop tunings)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Horizon Precision Drive / OD808 (drive 0, level 10, tone 11:00) -> Mesa Mark V (IIC+ mode) / EVH 5150 III -> Mesa 4x12 V30
|
||||||
|
|
||||||
|
**Amp Settings (Mark V, IIC+ mode):** Gain 5-7, Bass 4, Mids 5-6, Treble 6-7, Presence 5-6. Graphic EQ: V-shape with cut at 240Hz, boost at 750Hz and 6kHz.
|
||||||
|
|
||||||
|
**NAM:** "Mesa Mark V IIC+", "EVH 5150 III Stealth", "Horizon Precision Drive into 5150". IR: OwnHammer 412 MDRN, Mesa 412 OS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TesseracT
|
||||||
|
|
||||||
|
**Guitars:** Ibanez, Strandberg, Mayones 7- and 8-string
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Fractal Axe-Fx III (amp+cab sim). Previously: Mesa Dual Rectifier, ENGL Powerball.
|
||||||
|
|
||||||
|
**Key Settings:** Amp block modeled on EVH 5153 or PVH 6160. Gain 4-6, Bass 4-5, Mids 5-6 (less scooped), Treble 6-7, Presence 4-5. Bass cut around 80Hz.
|
||||||
|
|
||||||
|
**NAM:** "ENGL Powerball II", "Mesa Dual Rectifier (modern)", "EVH 5150 III 50W", "Fractal PVH 6160 Block". IR: Mesa OS 4x12 V30, ENGL 4x12 V30, OwnHammer 412 OS.
|
||||||
|
|
||||||
|
**ToneHunt tags:** djent, modern metal, 8-string, 7-string, 5150, Mark V, Rectifier, Fractal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ACOUSTIC
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Acoustic instruments don't use traditional guitar cabs. For NAM modeling on the pi-multifx-pedal: use **DI preamp captures** (Neve 1073, API 512c, AER Compact 60) and **room IRs** instead of cab IRs. Minimal compression and reverb.
|
||||||
|
|
||||||
|
| Artist | Primary | Signal Path |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| Simon & Garfunkel | Martin D-28 | Neumann U47 -> Neve console -> tape |
|
||||||
|
| Nick Drake | Martin 0-18 | Neumann U67 -> tape |
|
||||||
|
| John Fahey | Martin 0-18 | RCA 44 ribbon -> tube console -> tape |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Simon & Garfunkel — Paul Simon
|
||||||
|
|
||||||
|
**Guitars:** Martin D-28 (dreadnought), Martin 0-18, Guild F-30, Ramirez classical
|
||||||
|
|
||||||
|
**Studio Chain:** Martin D-28 -> Neumann U47 -> Neve console -> tape. No amplification — all purely acoustic recording.
|
||||||
|
|
||||||
|
**NAM:** DI preamp captures: "Neve 1073 DI", "API 512c DI", "LA-2A compressor". Room IR for natural ambience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Nick Drake
|
||||||
|
|
||||||
|
**Guitars:** Martin 0-18 (1950s), Levin Goliath
|
||||||
|
|
||||||
|
**Studio Chain:** Martin 0-18 -> Neumann U67 or AKG C24 stereo pair -> tape. Dark, intimate, close-miked.
|
||||||
|
|
||||||
|
**NAM:** "Neumann U67 preamp", "Neve 1073 preamp", "Fairchild 670 compressor", "Studer A800 tape saturation".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### John Fahey
|
||||||
|
|
||||||
|
**Guitars:** Martin 0-18, Martin 00-18, Gibson J-45
|
||||||
|
|
||||||
|
**Studio Chain:** Martin -> RCA 44 ribbon mic or Neumann U47 -> tube console -> tape. Fingerpicks (metal), alternate tunings.
|
||||||
|
|
||||||
|
**NAM:** "RCA 44 ribbon mic preamp", "Universal Audio 610 preamp", "Pultec EQP-1A EQ", "Tape saturation (Studer/Ampex)".
|
||||||
|
|
||||||
|
**IR:** Room IRs, church/hall IR, plate reverb IR. No cab.
|
||||||
|
|
||||||
|
**ToneHunt tags:** acoustic DI, preamp, tube preamp, Neve, API, ribbon mic, studio preamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. GRUNGE
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Raw, unpolished — fuzz/distortion into overdriven tube amp. Heavy midrange (NOT scooped). Drop D tunings common. Wah, phaser, chorus for atmosphere.
|
||||||
|
|
||||||
|
| Path | Model | FX | Usage |
|
||||||
|
|------|-------|----|-------|
|
||||||
|
| Clean | `Vox_AC15_TopBoost.nam` or `Fender_TwinVerb_Clean.nam` | Chorus | Nirvana verses |
|
||||||
|
| Crunch/Fuzz | `JCM2000_Crunch.nam` (gain 60%) + Big Muff | Phase 90 | Soundgarden, AIC rhythm |
|
||||||
|
| Lead | `5150_BlockLetter_Boosted.nam` (gain 50%) | Wah, slapback delay | Solos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Nirvana — Kurt Cobain
|
||||||
|
|
||||||
|
**Guitars:** Fender Mustang, Fender Jag-Stang, Univox Hi-Flier
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Boss DS-2 (dist max, tone 7-9) / EHX Big Muff Pi -> Vox AC30 (Top Boost) -> 2x12 combo
|
||||||
|
|
||||||
|
**Amp Settings:** AC30 volume cranked 8-10, Treble 6-8, Bass 5-6, Cut 4-5.
|
||||||
|
|
||||||
|
**NAM:** "Vox AC30 Top Boost", "Vox AC30 Big Muff" (combined capture ideal), "Boss DS-2 into AC30". From library: `Vox_AC15_TopBoost.nam`.
|
||||||
|
|
||||||
|
**IR:** Vox AC30 2x12 Blue Alnico. York Audio AC30 Blue. Marshall Greenbacks for later era.
|
||||||
|
|
||||||
|
**Technique:** Fizzy, woolly distortion. Mustang bridge pickup. Loose low end intentionally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Soundgarden — Kim Thayil
|
||||||
|
|
||||||
|
**Guitars:** Guild S-100, Gibson SG. Drop C# / open D tunings.
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> Ibanez TS9 (boost) -> Boss DS-1 / Big Muff -> Mesa Dual Rectifier (Modern) -> Mesa OS 4x12 V30
|
||||||
|
|
||||||
|
**Amp Settings:** Gain 6-7, Bass 6-7, Mids 5-6, Treble 6-7, Presence 5-6. TS9 (drive 0, level 10).
|
||||||
|
|
||||||
|
**NAM:** "Mesa Dual Rectifier (Modern)", "Marshall JCM800 2203", "Ibanez TS9 into Dual Rectifier". IR: York Audio Mesa 412 OS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Alice in Chains — Jerry Cantrell
|
||||||
|
|
||||||
|
**Guitars:** G&L Rampage, Gibson Les Paul Custom
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> MXR Micro Amp (clean boost) -> Dunlop Crybaby wah -> MXR Phase 90 -> Bogner Fish preamp -> Marshall power amp -> Marshall 4x12 Greenbacks
|
||||||
|
|
||||||
|
**Amp Settings (Bogner Fish):** Gain 6-8, Bass 6, Mids 7-8 (very high!), Treble 5-6, Presence 4-5.
|
||||||
|
|
||||||
|
**NAM:** "Bogner Fish preamp", "Marshall JCM800 2203", "Mesa Mark III lead", "Soldano SLO-100". IR: Marshall 4x12 Greenbacks. York Audio Marshall 412 Greenback.
|
||||||
|
|
||||||
|
**Technique:** Dark, thick, heavy "sludge" tone. Mid-forward. Drop D tuning. Wah used as filter.
|
||||||
|
|
||||||
|
**ToneHunt tags:** grunge, 90s, vox ac30, dual rectifier, bogner, soldano, big muff, marshall jcm800
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. ALTERNATIVE ROCK
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Dynamic contrast — shifting between clean, jangly verses and explosive, distorted choruses. Guitars used texturally: chiming cleans, wall-of-fuzz distortion, arpeggiated delay washes. Spans Vox chime, Marshall crunch, and Mesa high-gain.
|
||||||
|
|
||||||
|
| Path | Model | FX | Usage |
|
||||||
|
|------|-------|----|-------|
|
||||||
|
| Clean/Jangle | `Fender_TwinVerb_Clean.nam` or `Vox_AC15_TopBoost.nam` | Comp, subtle chorus | Radiohead verses, Pixies surf-clean |
|
||||||
|
| Crunch | `JCM2000_Crunch.nam` (gain 40-50%) | Boost/OD | Smashing Pumpkins rhythm, Pixies verses |
|
||||||
|
| Distorted | `Orange_Rockerverb.nam` or `JCM900_Lead.nam` | Fuzz (Big Muff), RAT | Pumpkins wall, Pixies chorus |
|
||||||
|
| Lead | As above + MXR_M77_Feather or TS808_Feather | Delay, reverb | Solos, atmospheric leads |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Radiohead — Jonny Greenwood / Ed O'Brien
|
||||||
|
|
||||||
|
**Guitars:** Jonny: Fender Telecaster Plus (lace sensors), Fender Starcaster, Jazzmaster. Ed: Fender Tele Deluxe (wide-range humbuckers).
|
||||||
|
|
||||||
|
**Signal Chain (Jonny):** Tele -> Ross Compressor (always-on) -> ProCo RAT -> DOD 440 Envelope Filter -> EHX Small Stone Phaser -> DigiTech Whammy -> Marshall ShredMaster -> Vox AC30 (Top Boost) -> 2x12 cab
|
||||||
|
|
||||||
|
**Signal Chain (Ed):** Tele Deluxe -> Boss SD-1 (boost) -> Line 6 DL4 (delay/loop) -> EHX Holy Grail -> Boss RE-20 Space Echo -> Vox AC30 / Fender Twin
|
||||||
|
|
||||||
|
**Amp Settings (Vox AC30):** Top Boost Volume 5-6, Treble 7-8, Bass 4-5, Cut 4-5, Reverb 2-3.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `Vox_AC15_TopBoost.nam` for AC30 clean/pushed (pair with Ross Comp simulation), `Fender_TwinVerb_Clean.nam` for Ed's clean, `JCM2000_Crunch.nam` for ShredMaster at gain ~50-60%, `BOSS_SD1_Feather.nam` as clean boost.
|
||||||
|
- ToneHunt A2: "Vox AC30 Top Boost - Compressed Clean", "Marshall ShredMaster - Distortion", "ProCo RAT LM308 - Vintage", "Ross Compressor - Studio", "DOD 440 Envelope Filter", "Small Stone Phaser - Vintage"
|
||||||
|
|
||||||
|
**IR:** Vox AC30 2x12 Celestion Blue AlNiCo. York Audio Vox AC30 Blue.
|
||||||
|
|
||||||
|
**Technique:** Ross Compressor always-on (non-negotiable). Whammy for melodic pitch shifts. Ed's looping on DL4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Smashing Pumpkins — Billy Corgan
|
||||||
|
|
||||||
|
**Guitars:** 1975 Gibson Les Paul Custom (DiMarzio Super Distortion bridge)
|
||||||
|
|
||||||
|
**Signal Chain:** Les Paul -> EHX Op-Amp Big Muff Pi -> Marshall ShredMaster -> Boss CS-2 Comp -> EHX Small Clone Chorus -> MXR Phase 90 -> A/DA Flanger -> Marshall JCM800 2203 (cranked) -> Marshall 4x12 G12M Greenbacks
|
||||||
|
|
||||||
|
**Amp Settings (JCM800 2203):** Preamp 8-10, Master 5-7, Bass 3-4, Middle 7-8 (not scooped!), Treble 6-7, Presence 7-8.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `JCM2000_Crunch.nam` (gain 80-90%), `JCM900_Lead.nam` for later albums, `Fender_TwinVerb_Clean.nam` for clean intros, `Mesa_MarkIV_Lead.nam` (Adore era).
|
||||||
|
- ToneHunt A2: "EHX Op-Amp Big Muff Pi - Direct Capture" (ESSENTIAL), "Marshall JCM800 2203 - Cranked with Big Muff", "EHX Small Clone Chorus - Vintage", "A/DA Flanger - Vintage", "Boss CS-2 Comp - Sustain Mode"
|
||||||
|
|
||||||
|
**IR:** Marshall 4x12 G12M Greenbacks. Greenbacks essential. York Audio 412 G12M-25.
|
||||||
|
|
||||||
|
**Technique:** Dozens of layered guitar tracks for the wall. Big Muff: Volume=2:00, Tone=12:00-1:00, Sustain=3:00. Aggressive downstrokes with 2mm pick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pixies — Joey Santiago / Frank Black
|
||||||
|
|
||||||
|
**Guitars:** Joey: 1965 Fender Jaguar (main). Frank: Fender Telecaster.
|
||||||
|
|
||||||
|
**Signal Chain (Joey):** Jaguar -> ProCo RAT (dist 10:00-11:00, filter rolled back) -> Marshall JCM800 2203/2204 -> Marshall 4x12 G12M Greenbacks
|
||||||
|
|
||||||
|
**Signal Chain (Frank):** Tele -> direct into Fender Twin Reverb (clean, spring reverb)
|
||||||
|
|
||||||
|
**Amp Settings (JCM800):** Preamp 6-7, Master 7-8, Bass 3-4, Middle 7-8, Treble 6-7, Presence 5-6.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `Fender_TwinVerb_Clean.nam` for Frank's surf-clean, `JCM2000_Crunch.nam` (gain 50-60%), `BOSS_SD1_Feather.nam` as RAT sub (drive low, level high, tone 9:00).
|
||||||
|
- ToneHunt A2: "ProCo RAT - Low Gain - Filter Rolled Back" (essential), "Marshall JCM800 2204 - 50W Medium Gain", "Fender Twin Reverb - Surf Clean", "Fender Twin Reverb - Spring Reverb Cranked"
|
||||||
|
|
||||||
|
**IR:** Marshall 4x12 G12M Greenback (Joey), Fender Twin 2x12 JBL (Frank).
|
||||||
|
|
||||||
|
**Technique:** Loud-Quiet-Loud dynamic. Joey's Jaguar on bridge pickup. No chorus/flanger — dry and direct. Slapback delay for surf feel.
|
||||||
|
|
||||||
|
**ToneHunt tags:** alternative rock, 90s alternative, vox ac30, marshall jcm800, fender twin, big muff opamp, radiohead, smashing pumpkins, pixies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. GARAGE ROCK
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Raw, stripped-down, lo-fi aesthetic. Vintage gear, minimal signal chains, dimed small amps. Philosophy: less is more. Cheap pawn-shop guitars, vintage amps cranked, fuzz pedals for color. Small-speaker cabs (1x8, 1x10, 1x12).
|
||||||
|
|
||||||
|
| Path | Model | FX | Usage |
|
||||||
|
|------|-------|----|-------|
|
||||||
|
| Clean/Breakup | `Fender_TwinVerb_Clean.nam` or `Vox_AC15_TopBoost.nam` | None or light reverb | White Stripes rhythm, Strokes verses |
|
||||||
|
| Fuzz | `Orange_Rockerverb.nam` (cranked) or Fuzz Face NAM | Octave fuzz | White Stripes main, Black Keys |
|
||||||
|
| Crunch | `JCM2000_Crunch.nam` (gain 40-60%) | TS808 boost | Strokes leads, Black Keys rhythm |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The White Stripes — Jack White
|
||||||
|
|
||||||
|
**Guitars:** 1964 Montgomery Ward Airline "JB Hutto" res-o-glass (fiberglass, single pickup)
|
||||||
|
|
||||||
|
**Signal Chain:** Guitar -> MXR Micro Amp (clean boost) / Dunlop Fuzz Face (Germanium) -> Fender Twin Reverb (dimed!)
|
||||||
|
|
||||||
|
**Amp Settings (Fender Twin):** Volume=10 (DIMED!), Treble=10, Bass=4-5, Reverb=2-3. NO effects loop.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `Fender_TwinVerb_Clean.nam` pushed hard with `MXR_M77_Feather.nam` or `Precision_Drive_Feather.nam` as clean boost (set to high level).
|
||||||
|
- ToneHunt A2: "Fender Twin Reverb - Dimed - Power Tube Saturation" (ESSENTIAL), "Dunlop Fuzz Face Germanium - Gated", "DigiTech Whammy - Octave Down", "MXR Micro Amp - Clean Boost", "Airline Res-O-Glass - Bridge Pickup DI"
|
||||||
|
|
||||||
|
**IR:** Fender Twin Reverb 2x12 combo — NOT a separate cab. York Audio Fender Twin JBL.
|
||||||
|
|
||||||
|
**Technique:** Dimed Twin Reverb. Thin guitar picks (~0.5mm). Guitar volume knob is the control. No distortion pedals (except fuzz).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The Strokes — Albert Hammond Jr. / Nick Valensi
|
||||||
|
|
||||||
|
**Guitars:** Nick: Epiphone Riviera P-94 (semi-hollow). Albert: Fender Stratocaster.
|
||||||
|
|
||||||
|
**Signal Chain (Nick):** Riviera -> JHS Angry Charlie (Marshall-in-a-box) / ProCo RAT -> Fender Hot Rod DeLuxe 1x12
|
||||||
|
|
||||||
|
**Signal Chain (Albert):** Strat -> Boss DS-1 (dist low, level high) / MXR Distortion+ -> Fender Hot Rod DeLuxe 1x12
|
||||||
|
|
||||||
|
**Amp Settings (HRDlx):** Volume 4-5 (edge of breakup), Treble 6-7, Middle 6-7, Bass 4-5, Reverb 2-3.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `Fender_TwinVerb_Clean.nam` (HRDlx approximation), `JCM2000_Crunch.nam` (Angry Charlie = Marshall-in-a-box), `TS808_Feather.nam` as OD boost.
|
||||||
|
- ToneHunt A2: "Fender Hot Rod DeLuxe - Edge of Breakup" (essential), "JHS Angry Charlie V3", "Boss DS-1 - Low Gain Boost Mode", "Epiphone Riviera P-94 - Bridge Pickup DI"
|
||||||
|
|
||||||
|
**IR:** Fender Hot Rod DeLuxe 1x12 Celestion G12P-80. NOT a 4x12 sound! York Audio 112 Deluxe Reverb.
|
||||||
|
|
||||||
|
**Technique:** Nick: barre chords, lower register riffs. Albert: single-note arpeggios, upper register. Overdrives as boosts into cleanish amp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The Black Keys — Dan Auerbach
|
||||||
|
|
||||||
|
**Guitars:** 1960s Silvertone 1423 (single pickup), Harmony Silhouette, Gibson Les Paul Junior (P-90)
|
||||||
|
|
||||||
|
**Signal Chain (early):** Silvertone -> direct into small dimed amp (Ampeg Jet J-12 / Silvertone 1481 1x8 / Fender Tremolux). No pedals.
|
||||||
|
|
||||||
|
**Signal Chain (modern):** Les Paul -> Boss SD-1 (drive 2-3, level max) / Fulltone OCD (LP mode) -> Fender Tremolux
|
||||||
|
|
||||||
|
**Amp Settings (Ampeg Jet):** Volume 7-8 (cranked), Treble 7-8, Bass 4-5.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `Fender_TwinVerb_Clean.nam` for modern tones, `JCM2000_Crunch.nam` for bigger sound (pair with 1x12 IR), `BOSS_SD1_Feather.nam` (drive 2, level 10, tone 12:00).
|
||||||
|
- ToneHunt A2: "Fender Tremolux 6G6-B - Dimed" (essential), "Ampeg Jet J-12 - Cranked", "Silvertone 1481 - 5W Cranked", "Fulltone OCD V2 - LP Mode", "Silvertone 1423 - Bridge Pickup DI"
|
||||||
|
|
||||||
|
**IR:** Small 1x8 or 1x12 combo. NOT 4x12! Silverton 1481 1x8, Ampeg Jet 1x12, Fender Tremolux 2x10.
|
||||||
|
|
||||||
|
**Technique:** Small amps cranked = the formula. Picking dynamics control gain. Slide guitar in open tunings. Heavy pick attack.
|
||||||
|
|
||||||
|
**ToneHunt tags:** garage rock, garage revival, dimed, fender tremolux, silvertone, ampeg jet, fuzz face germanium, small wattage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. POP
|
||||||
|
|
||||||
|
### Genre Summary
|
||||||
|
|
||||||
|
Textural and supportive — guitar is one element in a dense, produced arrangement. Clean tones dominate with heavy compression, chorus, and reverb. Drive used sparingly for color. DI preamp captures often more relevant than amp captures.
|
||||||
|
|
||||||
|
| Path | Model | FX | Usage |
|
||||||
|
|------|-------|----|-------|
|
||||||
|
| Clean Shimmer | `Fender_TwinVerb_Clean.nam` or `Vox_AC15_TopBoost.nam` | Comp, chorus, reverb | Taylor Swift, Paramore verses |
|
||||||
|
| Edge/Breakup | `Magnatone_Super59_Pushed.nam` or `JCM2000_Clean.nam` | Subtle drive, studio reverb | Paramore pop-punk, Billie Eilish layers |
|
||||||
|
| Lead/Solo | `JCM2000_Crunch.nam` (gain 30-40%) | Boost, delay | Paramore solos, pop rock choruses |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Taylor Swift
|
||||||
|
|
||||||
|
**Guitars:** Acoustic: Martin D-28, Gibson J-45. Electric: Fender Telecaster, Fender Stratocaster, Gibson ES-335.
|
||||||
|
|
||||||
|
**Studio Chain:** Tele -> Universal Audio Apollo (Neve preamp) -> LA-2A/1176 compressor -> Amp sim (Fender Twin / Vox AC30) -> EMT 140 Plate reverb
|
||||||
|
|
||||||
|
**Amp Settings (Fender Twin):** Volume 3-4 (clean, no breakup), Treble 5-6, Middle 4-5, Bass 4-5, Reverb 2-3.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `Fender_TwinVerb_Clean.nam`, `Fender_TwinVerb_Vibrato.nam`, `Vox_AC15_TopBoost.nam`, `Magnatone_Super59_Pushed.nam`.
|
||||||
|
- ToneHunt A2: "Fender Twin Reverb - Clean Studio Compressed", "Neve 1073 DI - Preamp", "LA-2A - Optical Compressor", "EMT 140 Plate Reverb", "Roland JC-120 - Jazz Chorus Clean", "Acoustic DI - Martin D-28 Pure Direct"
|
||||||
|
|
||||||
|
**IR:** Fender Twin 2x12 JBL, boutique-1x12, vintage-1x12. Room IRs for acoustic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Billie Eilish — Finneas
|
||||||
|
|
||||||
|
**Guitars:** Acoustic: Gibson J-45, Martin D-28. Electric: Fender Telecaster, Gibson ES-335 (occasional).
|
||||||
|
|
||||||
|
**Studio Chain:** Guitar -> Universal Audio Apollo (Neve preamp) -> Logic Pro X (compressors, Space Designer reverb). Distortion almost never used.
|
||||||
|
|
||||||
|
**NAM:**
|
||||||
|
- From library: `Fender_TwinVerb_Clean.nam`, `Vox_AC15_TopBoost.nam`, `Magnatone_Super59_Pushed.nam`.
|
||||||
|
- ToneHunt A2: "Neve 1073 DI - Preamp" (essential), "LA-2A - Optical Compressor", "Strymon BigSky - Cloud Reverb", "Eventide H9 - Blackhole Reverb", "Studer A800 - Tape Saturation", "Roland JC-120 - Stereo Chorus", "EHX Mel9 - Tape Clavinet"
|
||||||
|
|
||||||
|
**IR:** Room IRs, vintage-1x12 or boutique-1x12 for generic polished sound. No cab for Billie — DI + space.
|
||||||
|
|
||||||
|
**Technique:** Guitar as texture. Reverse reverb signature. Extreme dynamic control. Sub-bass from synth, not guitar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paramore — Taylor York / Josh Farro
|
||||||
|
|
||||||
|
**Guitars (Josh Farro — Pop-Punk era):** Gibson Les Paul Standard (humbuckers)
|
||||||
|
|
||||||
|
**Guitars (Taylor York — Modern):** Fender Tele Deluxe (wide-range humbuckers), Fender Jazzmaster, Fender Bass VI
|
||||||
|
|
||||||
|
**Signal Chain (Josh — Pop-Punk):** Les Paul -> Ibanez TS9 (drive 10:00, level 2:00) -> Mesa Dual Rectifier (vintage mode) -> Mesa 4x12 V30
|
||||||
|
|
||||||
|
**Signal Chain (Taylor — Modern):** Tele Deluxe -> Strymon Deco (saturation + doubletrack) -> Chase Bliss Warped Vinyl -> Fender Twin Reverb (clean)
|
||||||
|
|
||||||
|
**Amp Settings (Mesa Rectifier — Josh):** Gain 5-6, Bass 4-5, Middle 5-6, Treble 5-6, Presence 4-5. TS9 boost.
|
||||||
|
|
||||||
|
**NAM (Pop-Punk):** "Mesa Dual Rectifier - Vintage - TS9 Boost", "Marshall JCM800 2203 - Pop Punk". From library: `5150_BlockLetter_Boosted.nam` (gain 40-50%) + `TS808_Feather.nam`, `6505_Red_NoBoost.nam`.
|
||||||
|
|
||||||
|
**NAM (Modern):** `Fender_TwinVerb_Clean.nam`, `Vox_AC15_TopBoost.nam`, `Magnatone_Super59_Pushed.nam`. ToneHunt A2: "Fender Jazzmaster - Neck Pickup Clean", "Strymon Deco - Saturation + Doubletrack", "Fender Bass VI - Direct", "Boss CE-2 Chorus - Vintage", "Roland JC-120 - Stereo Chorus"
|
||||||
|
|
||||||
|
**IR (Pop-Punk):** Mesa 4x12 V30. **IR (Modern):** Fender Twin 2x12, Vox AC30 2x12 Blue.
|
||||||
|
|
||||||
|
**ToneHunt tags:** pop, pop rock, clean electric, studio compressor, la2a, neve preamp, plate reverb, fender twin, paramore, taylor swift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NAM Download Priorities
|
||||||
|
|
||||||
|
### Models to Source from ToneHunt (A2)
|
||||||
|
|
||||||
|
Based on gaps identified across all 12 genres, prioritize these captures:
|
||||||
|
|
||||||
|
| Priority | Model Capture | Genres | Notes |
|
||||||
|
|----------|---------------|--------|-------|
|
||||||
|
| 1 | Fender Twin Reverb - Clean Studio Compressed | Alt, Pop, Midwest Emo | Universal clean platform |
|
||||||
|
| 2 | Marshall Plexi Super Lead 1959 - Cranked | Classic Rock, Punk | Core rock sound |
|
||||||
|
| 3 | Vox AC30 Top Boost - Edge of Breakup | Alt, Grunge, Emo | Chime and crunch |
|
||||||
|
| 4 | EHX Op-Amp Big Muff Pi | Alt (Pumpkins), Grunge | Essential for these genres |
|
||||||
|
| 5 | ProCo RAT - Vintage LM308 | Alt (Pixies), Hardcore, Punk | Versatile distortion |
|
||||||
|
| 6 | Mesa Boogie Dual Rectifier - Modern | Metal, Hardcore, Djent | High-gain standard |
|
||||||
|
| 7 | Marshall JCM800 2203 - Boosted | Metal (Slayer), Punk | Thrash buzzsaw |
|
||||||
|
| 8 | Fender Twin Reverb - Dimed | Garage Rock (White Stripes) | Unique sound |
|
||||||
|
| 9 | Neve 1073 DI - Preamp | Pop, Acoustic | Studio recording standard |
|
||||||
|
| 10 | Fender Hot Rod DeLuxe - Edge of Breakup | Garage Rock (Strokes) | 1x12 combo character |
|
||||||
|
|
||||||
|
### IRs to Source
|
||||||
|
|
||||||
|
| Priority | IR Pack | Genres | Notes |
|
||||||
|
|----------|---------|--------|-------|
|
||||||
|
| 1 | York Audio Vox AC30 Blue | Alt, Grunge, Emo | Core chime cab |
|
||||||
|
| 2 | York Audio Marshall 412 G12M-25 | Classic Rock, Punk, Alt | Greenback standard |
|
||||||
|
| 3 | York Audio Mesa 412 V30 | Metal, Hardcore, Djent | V30 modern metal |
|
||||||
|
| 4 | York Audio Fender Twin JBL | Pop, Alt, Garage, Midwest Emo | Clean Fender sparkle |
|
||||||
|
| 5 | York Audio 412 G12H-30 | Metal (Sabbath) | Essential for Iommi |
|
||||||
|
| 6 | York Audio 412 T75 | Metal (Slayer) | Thrash buzzsaw cab |
|
||||||
|
| 7 | York Audio 112 Deluxe Reverb | Garage Rock (Strokes) | 1x12 combo character |
|
||||||
|
|
||||||
|
### Existing Library Adequacy
|
||||||
|
|
||||||
|
| Genre | Adequacy | Key Existing Models |
|
||||||
|
|-------|----------|---------------------|
|
||||||
|
| Classic Rock | Good | JCM2000_Crunch, TS808_Feather, MXR_M77_Feather |
|
||||||
|
| Punk | Good | 5150_BlockLetter_Boosted, JCM2000_Crunch, BOSS_SD1_Feather |
|
||||||
|
| Metal | Good | 5150, 6505, Engl_Savage, Splawn_ProMod, TS808_Feather |
|
||||||
|
| Hardcore | Adequate | 5150, BOSS_SD1_Feather (needs RAT capture) |
|
||||||
|
| Emo | Adequate | Fender_TwinVerb_Clean, Vox_AC15_TopBoost (needs DeVille) |
|
||||||
|
| Midwest Emo | Adequate | Fender_TwinVerb_Clean (needs JC-120) |
|
||||||
|
| Djent | Weak | 5150, Engl_Savage (needs Mark V/5150 III captures) |
|
||||||
|
| Acoustic | Weak | None suitable — needs DI preamp captures |
|
||||||
|
| Grunge | Good | Vox_AC15_TopBoost, Fender_TwinVerb_Clean, 5150 |
|
||||||
|
| Alternative Rock | Adequate | JCM2000_Crunch, Vox_AC15, Fender_Twin (needs Big Muff) |
|
||||||
|
| Garage Rock | Adequate | Fender_TwinVerb_Clean, MXR_M77_Feather (needs small-amp captures) |
|
||||||
|
| Pop | Good | Fender_TwinVerb_Clean, Vox_AC15_TopBoost (clean platform covered) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RPi 4B Performance Summary
|
||||||
|
|
||||||
|
| Genre | Typical Model Size | Avg Models per Preset | DSP Needs | RPi Load |
|
||||||
|
|-------|-------------------|----------------------|-----------|----------|
|
||||||
|
| Classic Rock | 3-8 MB | 1-2 (amp + boost) | Slapback delay | Low |
|
||||||
|
| Punk | 3-8 MB | 1-2 (amp + boost) | None | Low |
|
||||||
|
| Metal | 5-10 MB | 2 (amp + boost) | Noise gate | Medium |
|
||||||
|
| Hardcore | 3-8 MB | 2 (amp + boost) | Noise gate | Low-Med |
|
||||||
|
| Emo | 3-6 MB | 1-2 (clean + boost) | Chorus/reverb | Low |
|
||||||
|
| Midwest Emo | 3-6 MB | 1 (clean) | Comp/chorus/phase | Low |
|
||||||
|
| Djent | 8-15 MB | 2 (amp + boost) | Noise gate | Medium-High |
|
||||||
|
| Acoustic | 1-3 MB | 1 (DI preamp) | Reverb | Very low |
|
||||||
|
| Grunge | 5-10 MB | 2 (amp + fuzz) | Phase/wah | Medium |
|
||||||
|
| Alternative | 3-8 MB | 2-3 (amp + fuzz/boost) | Mod (phaser, chorus) | Medium |
|
||||||
|
| Garage Rock | 2-5 MB | 1-2 (dimed amp + boost) | None to minimal | Low |
|
||||||
|
| Pop | 2-5 MB | 1 (clean amp) | Comp/reverb/chorus | Very low |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
# NAM Model & Cabinet IR Inventory
|
||||||
|
|
||||||
|
> **Generated:** 2026-06-13 (updated with Bank 7 — Bass, Orange, Others)
|
||||||
|
> **Source:** pelennor2170/NAM_models community collection on GitHub (GPL v3) + Tone3000 Supabase storage
|
||||||
|
> **Format:** Mix of WaveNet A1 and SlimmableContainer A2 models, verified JSON, all < 700 KB (feather class for RPi 4B)
|
||||||
|
|
||||||
|
## NAM Amp Models
|
||||||
|
|
||||||
|
### Bank 0 — Clean & Edge (Factory Presets 0-3)
|
||||||
|
|
||||||
|
| Preset | File | Size | Source Model | Notes |
|
||||||
|
|--------|------|------|-------------|-------|
|
||||||
|
| Clean Jazz | `Fender_TwinVerb_Clean.nam` | 399 KB | Fender Twin Reverb Normal Bright (sdatkinson) | Sparkling clean, slightly scooped mids |
|
||||||
|
| Edge of Breakup | `Magnatone_Super59_Pushed.nam` | 398 KB | Magnatone Super 59 Mkii Pushed (Tim R) | Bluesy breakup, dynamic touch response |
|
||||||
|
| Clean with Chorus | `Fender_TwinVerb_Vibrato.nam` | 399 KB | Fender Twin Vibrato Bright (Tim R) | Warmer clean, slight chorus character |
|
||||||
|
| Dynamic Fingerstyle | `Vox_AC15_TopBoost.nam` | 411 KB | Vox AC15 Top Boost (Phillipe P) | Chimey, complex mids, fingerstyle clarity |
|
||||||
|
| *Alternate: Clean* | `JCM2000_Clean.nam` | 398 KB | Marshall JCM2000 Clean (Tim R) | Crisp, tight clean — good for funk |
|
||||||
|
|
||||||
|
### Bank 1 — Drive & Lead (Factory Presets 4-7)
|
||||||
|
|
||||||
|
| Preset | File | Size | Source Model | Notes |
|
||||||
|
|--------|------|------|-------------|-------|
|
||||||
|
| Classic Rock | `Orange_Rockerverb.nam` | 398 KB | Orange Rockerverb (Tom C / Axe FX) | Thick midrange, classic rock crunch |
|
||||||
|
| Classic Rock | `JCM2000_Crunch.nam` | 398 KB | Marshall JCM2000 Crunch (Tim R) | Tight crunch, very responsive |
|
||||||
|
| Lead Solo | `5150_BlockLetter_Boosted.nam` | 397 KB | EVH 5150 Block Letter boosted (Helga B) | Searing leads, tight low end |
|
||||||
|
| Lead Solo | `Engl_Savage.nam` | 398 KB | ENGL Savage 120 (Tom C / Axe FX) | Modern high-gain, saturated sustain |
|
||||||
|
| Metal Rhythm | `6505_Red_NoBoost.nam` | 397 KB | Peavey 6505+ Red ch (Helga B) | Classic metal rhythm, aggressive mids |
|
||||||
|
| Metal Rhythm | `Splawn_ProMod_3rd.nam` | 397 KB | Splawn Pro Mod 3rd Gear (Tim R) | Tight percussive chug, modern metal |
|
||||||
|
| Ambient Wash | `Fender_TwinVerb_Clean.nam` | 399 KB | (reuse Clean Jazz) | Clean canvas for ambient FX chain |
|
||||||
|
| *Alternate: Lead* | `JCM900_Lead.nam` | 398 KB | JCM900 Dual Verb ch.A g6 (Tim R) | Smooth lead, 90s rock/metal |
|
||||||
|
|
||||||
|
### Pedal Captures (FX Bench & Boost/OD Blocks)
|
||||||
|
|
||||||
|
| File | Size | Source Model | Best For |
|
||||||
|
|------|------|-------------|----------|
|
||||||
|
| `Klon_Centaur_High.nam` | 397 KB | Klon Centaur-style (Keith B) | Clean boost, mid-hump overdrive |
|
||||||
|
| `TubeScreamer_Maxed.nam` | 195 KB | Ibanez TS808 Mini maxed (Tudor N) | Tubescreamer-style clean boost |
|
||||||
|
| `Mesa_MarkIV_Lead.nam` | 398 KB | Mesa Boogie Mark IV lead (Roman A) | Singing lead, thick harmonics |
|
||||||
|
| `Bogner_Ecstasy.nam` | 397 KB | Ceriatone Molecular 50 (Tudor N) | Bogner-style versatile overdrive |
|
||||||
|
|
||||||
|
### Feather Models (Ultra-low CPU — RPi 4B Optimized)
|
||||||
|
|
||||||
|
These are ~90 KB models with minimal CPU footprint, ideal for RPi 4B.
|
||||||
|
|
||||||
|
| File | Size | Source | Inference Cost (est.) |
|
||||||
|
|------|------|--------|----------------------|
|
||||||
|
| `BOSS_SD1_Feather.nam` | 90 KB | BOSS SD-1 overdrive (Phillipe P) | ~0.5 ms per 256-block |
|
||||||
|
| `MXR_M77_Feather.nam` | 91 KB | MXR M77 Badass OD (Phillipe P) | ~0.5 ms |
|
||||||
|
| `TS808_Feather.nam` | 90 KB | Maxon OD808 (Phillipe P) | ~0.5 ms |
|
||||||
|
| `Precision_Drive_Feather.nam` | 90 KB | Precision Drive (Phillipe P) | ~0.5 ms |
|
||||||
|
| `Friedman_PowerAmp_Feather.nam` | 90 KB | Friedman DSM EL84 (Sascha S) | ~0.5 ms |
|
||||||
|
|
||||||
|
### Bank 2 — Blues Captures (from Tone3000/ToneHunt)
|
||||||
|
|
||||||
|
| File | Size | Source | Type | Best For |
|
||||||
|
|------|------|--------|------|----------|
|
||||||
|
| `Fender_Princeton_Clone_Clean.nam` | 289 KB | Hand-Wired Princeton Clone Clean (A1) | Clean | Clean jazz, fingerstyle blues |
|
||||||
|
| `Fender_Princeton_Clone_EOB.nam` | 288 KB | Hand-Wired Princeton Clone EOB (A2) | Edge-of-Breakup | Dynamic blues, touch response |
|
||||||
|
| `Fender_Tweed_Dlx_Edge.nam` | 288 KB | Fender Tweed Deluxe The Edge w/ Celestion Blue (A2) | Edge-of-Breakup | Tweed-style pushed blues |
|
||||||
|
| `Fender_Twin_Reverb_Clean_A2.nam` | 290 KB | Fender Twin Reverb Clean (A2) | Clean | Sparkling blues clean |
|
||||||
|
| `TwoRock_JTM_Signature.nam` | 276 KB | Two-Rock John Mayer Signature Prototype (A1) | Clean/Edge | Blooming clean, Mayer-style |
|
||||||
|
| `Dumble_ODS_102_Rock_A1.nam` | 87 KB | Dumble ODS #102 Clone Rock setting (A1, feather) | Pushed OD | Smooth overdrive, SRV-style |
|
||||||
|
| `Dumble_ODS_102_Rock_A2.nam` | 289 KB | Dumble ODS #102 Clone Rock setting (A2) | Pushed OD | Smooth overdrive, SRV-style |
|
||||||
|
| `Marshall_Bluesbreaker_1962_A2.nam` | 289 KB | Marshall Bluesbreaker 1962 (A2) | Pushed Blues | Classic blues rock, Clapton-style |
|
||||||
|
| `Marshall_Bluesbreaker_Pedal.nam` | 290 KB | Marshall Bluesbreaker Pedal (Original) Setting 1 (A1) | Pedal OD | Pedal-based blues overdrive |
|
||||||
|
|
||||||
|
### Bank 3 — New Clean & Blues Captures (Tone3000 A2)
|
||||||
|
|
||||||
|
Downloaded from Tone3000 Supabase storage (public bucket). All are SlimmableContainer (A2) v0.7.0.
|
||||||
|
|
||||||
|
| File | Size | Source Model | Creator | Best For |
|
||||||
|
|------|------|-------------|---------|----------|
|
||||||
|
| `Fender_Deluxe_Reverb_A2.nam` | 650 KB | Fender Deluxe Reverb (A2) | sdatkinson | Sparkling clean, iconic Fender clean tone |
|
||||||
|
| `Roland_JC-120_A2.nam` | 289 KB | Roland JC-120 Jazz Chorus (A2) | tone3000 | Pristine clean, jazz chorus chorus effect |
|
||||||
|
| `Marshall_JMP50_Plexi_A2.nam` | 287 KB | Marshall JMP-50 Lead 1969 Plexi (A2) | amalgamaudio | Classic blues-rock breakup, Plexi crunch |
|
||||||
|
| `Park75_1968_Plexi_A2.nam` | 287 KB | PARK 75 (1968 Plexi Marshall KT88) (A2) | newgearmag | Presence full — pushed Plexi blues |
|
||||||
|
| `Park75_1968_Plexi_Noon_A2.nam` | 287 KB | PARK 75 (1968 Plexi Marshall KT88) (A2) | newgearmag | All noon — cleaner Plexi edge |
|
||||||
|
|
||||||
|
### Bank 4 — Rock Captures (Tone3000 A1 — WaveNet)
|
||||||
|
|
||||||
|
Downloaded from Tone3000 Supabase storage. All are WaveNet A1 (v0.5.0/v0.5.4), verified JSON.
|
||||||
|
|
||||||
|
| File | Size | Source Model | Best For |
|
||||||
|
|------|------|-------------|----------|
|
||||||
|
| `Marshall_JCM800_2203.nam` | 411 KB | 1989 Marshall JCM800 2203 suhrRL (#0b) | Classic rock crunch, hard rock rhythm |
|
||||||
|
| `Marshall_Plexi_1967_Low.nam` | 412 KB | 1 Plexi 67 Low G8 (Emil Rohbe) | Vintage rock clean/crunch, touch-sensitive |
|
||||||
|
| `Marshall_Plexi_1967_Dimed.nam` | 412 KB | 16 Plexi 67 Normal Dimed (Emil Rohbe) | Pushed plexi, classic rock lead |
|
||||||
|
| `Marshall_JTM45_Clean.nam` | 276 KB | JTM45EC Ch1 | Classic Marshall clean, edge-of-breakup |
|
||||||
|
| `Marshall_JTM45_Breakup.nam` | 276 KB | JTM45EC Ch2 | JTM45 breakup, vintage rock overdrive |
|
||||||
|
|
||||||
|
### Bank 5 — High-Gain / Lead Captures (Tone3000 A1 — WaveNet)
|
||||||
|
|
||||||
|
| File | Size | Source Model | Best For |
|
||||||
|
|------|------|-------------|----------|
|
||||||
|
| `Mesa_Boogie_Dual_Rec_Plumes.nam` | 277 KB | MB Dual Rectifier V4A1 + EQD Plumes | Boosted high-gain, modern metal rhythm |
|
||||||
|
| `Mesa_Boogie_Dual_Rec_Red.nam` | 276 KB | Rectifier Red | Classic metal rhythm, palm-mute chug |
|
||||||
|
| `EVH_5150III_Stealth_Lead.nam` | 411 KB | EVH 5150III 50W Stealth Lead | Searing leads, tight low-end |
|
||||||
|
| `EVH_5150III_Boosted.nam` | 397 KB | EVH 5150 III Amp Only Boosted | Boosted high-gain lead |
|
||||||
|
| `Diezel_VH4_Brown_Sugar.nam` | 277 KB | Diezel VH4 + MLC Brown Sugar | Modern high-gain, saturated sustain |
|
||||||
|
| `Engl_Powerball_MkI_Lead.nam` | 411 KB | Engl Powerball MK I Heavy Lead | Saturated lead, modern metal |
|
||||||
|
| `Engl_Powerball_II_Crunch.nam` | 64 KB | Engl Powerball II Crunch | Tight metal rhythm (feather!) |
|
||||||
|
| `Engl_Powerball_II_HiGain.nam` | 64 KB | Engl Powerball II High Gain Rhythm | Modern metal rhythm (feather!) |
|
||||||
|
| `Engl_Powerball_II_Clean.nam` | 64 KB | Engl Powerball II Clean | Clean channel with headroom (feather!) |
|
||||||
|
|
||||||
|
### Bank 6 — Ambient/Post-Rock Captures (Tone3000 A1 — WaveNet)
|
||||||
|
|
||||||
|
|| File | Size | Source Model | Best For |
|
||||||
|
||------|------|-------------|----------|
|
||||||
|
|| `Roland_JC120_Jazz_Chorus.nam` | 276 KB | Roland JC-120 Jazz Chorus | Pristine clean, ambient washes, jazz |
|
||||||
|
|| `Vox_AC30_Chimey.nam` | 290 KB | Vox AC30 Chimey | Sparkling clean, chimey post-rock |
|
||||||
|
|| `Fender_Twin_Reverb_G05.nam` | 288 KB | Tim R Fender Twin Reverb Ch1 BR G05 | Clean/country, pedal platform |
|
||||||
|
|
||||||
|
### Bank 7 — Bass, Orange & Others (Tone3000 A2 — SlimmableContainer)
|
||||||
|
|
||||||
|
Downloaded from Tone3000 Supabase storage. All are SlimmableContainer (A2) v0.7.0, ~288-295 KB.
|
||||||
|
Covers the missing categories: Bass amps, Orange Rocker 30, Fender Bassman, Hiwatt DR103, Supro.
|
||||||
|
|
||||||
|
|| File | Size | Source Model | Category | Best For |
|
||||||
|
||------|------|-------------|----------|----------|
|
||||||
|
|| `Ampeg_PF20T_A2.nam` | 288 KB | Ampeg PF20T | Bass | Clean bass tube amp, warm lows |
|
||||||
|
|| `Aguilar_Tone_Hammer_500_A2.nam` | 293 KB | Aguilar Tone Hammer 500 | Bass | Modern bass preamp, punchy mids |
|
||||||
|
|| `GK_800RB_Line_A2.nam` | 295 KB | Gallien-Krueger 800RB | Bass | Classic solid-state bass, aggressive |
|
||||||
|
|| `Aguilar_DB751_Cab_A2.nam` | 292 KB | Aguilar DB751 | Bass | Bass amp DI, full-range presence |
|
||||||
|
|| `Orange_Rocker30_Dirty_A2.nam` | 295 KB | Orange Rocker 30 Dirty | Orange | Classic rock crunch, stoner/alt rock |
|
||||||
|
|| `Orange_Rocker30_Natural_A2.nam` | 289 KB | Orange Rocker 30 Natural | Orange | Clean edge, dynamic garage tones |
|
||||||
|
|| `Fender_Bassman_Dimed_A2.nam` | 289 KB | Fender Bassman dimed | Others | Blues/rock breakup, classic Bassman growl |
|
||||||
|
|| `Hiwatt_DR103_Bright_A2.nam` | 287 KB | Hiwatt DR-103 Bright Normal | Others | Prog/classic rock, The Who territory |
|
||||||
|
|| `Hiwatt_DR103_Cornish_A2.nam` | 288 KB | Hiwatt DR103 + Cornish PGS | Others | Boosted prog rock, Gilmour-esque leads |
|
||||||
|
|| `Supro_Black_Magick_M3_A2.nam` | 288 KB | Supro Black Magick M3 | Others | Garage/blues, Jack White-style grit |
|
||||||
|
|
||||||
|
### Legacy Models (Linear Architecture — synthetic, ~35-85 KB)
|
||||||
|
|
||||||
|
These remain in `~/.pedal/nam/` for backward compatibility:
|
||||||
|
|
||||||
|
`Fender_Twin_Clean.nam`, `Vox_AC15_TopBoost.nam`, `Marshall_JCM800.nam`,
|
||||||
|
`Mesa_Boogie_MarkV.nam`, `Roland_Jazz_Chorus.nam`, `Orange_AD30.nam`,
|
||||||
|
`Fender_Bassman_59.nam`, `5150_EVH.nam`, `Engl_Powerball.nam`, `Diezel_VH4.nam`
|
||||||
|
|
||||||
|
## Cabinet IRs
|
||||||
|
|
||||||
|
### Built-in Synthetic IRs (generated by `scripts/generate_seed_irs.py`)
|
||||||
|
|
||||||
|
Located in `~/.pedal/irs/`. All are 48 kHz, 16-bit mono WAV.
|
||||||
|
|
||||||
|
| File | Length | Type | Best Genres |
|
||||||
|
|------|--------|------|-------------|
|
||||||
|
| `vintage-1x12.wav` | 2048 / 43ms | Fender-style 1x12 open back | Clean Jazz, Blues, Fingerstyle |
|
||||||
|
| `british-4x12.wav` | 4096 / 85ms | Marshall 1960A 4x12 closed back | Classic Rock, Crunch |
|
||||||
|
| `american-2x12.wav` | 2048 / 43ms | Vox AC30 2x12 open back | Clean, Chimey, Edge-of-Breakup |
|
||||||
|
| `modern-4x12.wav` | 4096 / 85ms | Mesa Rectifier 4x12 closed back | Metal, High Gain |
|
||||||
|
| `jazz-1x15.wav` | 2048 / 43ms | 15" open back | Jazz, Bass Cleans |
|
||||||
|
| `boutique-1x12.wav` | 2048 / 43ms | Dumble-style 1x12 | Smooth Cleans, Blues |
|
||||||
|
| `mini-1x8.wav` | 1024 / 21ms | Small practice amp | Lo-fi, Vintage Radio |
|
||||||
|
|
||||||
|
### Real IRs (downloaded from GitHub: fnpngn/IR)
|
||||||
|
|
||||||
|
Downloaded 2026-06-13 from `https://github.com/fnpngn/IR` (git-lfs). Source packs: DYNAX, 1960a, Allure, ML Sound Lab, Dauntless, Master Of Puppets, Modern Metal Songwriter, FREE BASS IR. All converted to 48 kHz, 16-bit mono WAV.
|
||||||
|
|
||||||
|
#### 1x12 Open Back (Fender-style — Clean, Ambient, Blues)
|
||||||
|
|
||||||
|
| File | Length | Source | Best Genres |
|
||||||
|
|------|--------|--------|-------------|
|
||||||
|
| `1x12-fender-deluxerev-dynax.wav` | 500ms | DY Fend DeluxeRev (Fender Deluxe Reverb 1x12, A mic pos) | Clean Jazz, Blues, Fingerstyle |
|
||||||
|
| `1x12-fender-deluxerev-b-dynax.wav` | 500ms | DY Fend DeluxeRev (B mic pos) | Clean Jazz, Blues |
|
||||||
|
| `1x12-fender-deluxerev-c-dynax.wav` | 500ms | DY Fend DeluxeRev (C mic pos) | Clean Jazz, Blues |
|
||||||
|
| `1x12-fender-hotrod-dynax.wav` | 500ms | DY Fend HotRod DX (Fender Hot Rod Deluxe 1x12, A pos) | Clean, Edge-of-Breakup |
|
||||||
|
| `1x12-fender-hotrod-b-dynax.wav` | 500ms | DY Fend HotRod DX (B pos) | Clean, Blues |
|
||||||
|
| `1x12-egnater-dynax.wav` | 500ms | DY Egnater 1x12 combo | Clean, Ambient |
|
||||||
|
| `1x12-bogner-cb-dynax.wav` | 500ms | DY Bognar 112CB (Bogner 1x12 closed back) | Rock, Edge |
|
||||||
|
| `1x10-fender-tweed-59-allure.wav` | 40ms | Allure_59_Tweed_P10N (Vintage 1x10) | Lo-fi, Vintage Clean |
|
||||||
|
| `1x12-fender-deluxe-64-allure.wav` | 40ms | Allure_64_USDeluxe_P12N (Fender Deluxe 1x12) | Clean, Blues |
|
||||||
|
|
||||||
|
#### 2x12 Open Back (Vox AC30-style — Clean, Ambient, Blues)
|
||||||
|
|
||||||
|
| File | Length | Source | Best Genres |
|
||||||
|
|------|--------|--------|-------------|
|
||||||
|
| `2x12-vox-ac30-dynax.wav` | 500ms | DY VX AC30C (Vox AC30 2x12, A pos) | Chimey Clean, Edge-of-Breakup |
|
||||||
|
| `2x12-vox-ac30-blue-dynax.wav` | 500ms | DY VX AC306 (Vox AC30 2x12 Blue Alnico, A pos) | Chimey, Ambient |
|
||||||
|
| `2x12-vox-ac30-g12-allure.wav` | 40ms | Allure_64_A30_G12 (Vox AC30, G12 speaker) | Clean, Edge-of-Breakup |
|
||||||
|
| `2x12-matchless-dc30-dynax.wav` | 500ms | DY MATCHL DC30BK (Matchless DC30 2x12, A pos) | Chimey, Complex mids |
|
||||||
|
| `2x12-fender-twin-dynax.wav` | 500ms | DY Fend Twin Rev (Fender Twin Reverb 2x12, A pos) | Sparkling Clean |
|
||||||
|
| `2x12-fender-tonemaster-dynax.wav` | 500ms | DY Fend Tone Maister (Fender Tonemaster 2x12) | Clean, Country |
|
||||||
|
| `2x12-bogner212-dynax.wav` | 500ms | DY Bognar212 (Bogner 212, A pos) | Rock, Edge |
|
||||||
|
|
||||||
|
#### 4x12 Closed Back (Marshall 1960-style — Rock, Classic Rock, Crunch)
|
||||||
|
|
||||||
|
| File | Length | Source | Best Genres |
|
||||||
|
|------|--------|--------|-------------|
|
||||||
|
| `4x12-marshall-1960a-sm57-0in-0c.wav` | 500ms | 1960A SM57, 0in-0c (cap edge, on-axis) | Classic Rock, Crunch |
|
||||||
|
| `4x12-marshall-1960a-sm57-0in-1c.wav` | 500ms | 1960A SM57, 0in-1c (between cap and cone) | Classic Rock |
|
||||||
|
| `4x12-marshall-1960a-sm57-0in-2c.wav` | 500ms | 1960A SM57, 0in-2c (cone edge) | Classic Rock, Blues |
|
||||||
|
| `4x12-marshall-1960a-sm57-0in-3c.wav` | 500ms | 1960A SM57, 0in-3c (cone middle) | Rock, Lead |
|
||||||
|
| `4x12-marshall-1960a-sm57-0in-4c.wav` | 500ms | 1960A SM57, 0in-4c (center) | Rock, High Gain |
|
||||||
|
| `4x12-marshall-1960a-sm57-capedge.wav` | 500ms | 1960A SM57, 0.5in-0c (cap edge) | Classic Rock |
|
||||||
|
| `4x12-marshall-1960a-i5-0in.wav` | 500ms | 1960A Audix i5, 0in-0c | Rock, Lead |
|
||||||
|
| `4x12-marshall-1960a-i5-1c.wav` | 500ms | 1960A Audix i5, 0in-1c | Rock |
|
||||||
|
| `4x12-marshall-1960ahw-dynax.wav` | 500ms | DY Mars 412 1960AHW (Marshall 1960AHW) | Rock, Classic Rock |
|
||||||
|
| `4x12-marshall-jcm800-1960a-dynax.wav` | 500ms | DY Mars412 JC800 1960A (Marshall JCM800) | Rock, Hard Rock |
|
||||||
|
| `4x12-marshall-jcm800-1960b-dynax.wav` | 500ms | DY Mars412 JC800 1960B (Marshall JCM800 alt) | Rock |
|
||||||
|
| `4x12-marshall-mop-sm57.wav` | 500ms | Master Of Puppets, SM57 mic | Metal, Thrash |
|
||||||
|
| `4x12-marshall-mop-c414.wav` | 500ms | Master Of Puppets, AKG C414 mic | Metal, Lead |
|
||||||
|
| `4x12-marshall-mop-blended.wav` | 500ms | Master Of Puppets, blended mix | Rock, Metal |
|
||||||
|
|
||||||
|
#### 4x12 Modern (Mesa Rectifier-style — Metal, High Gain, Djent)
|
||||||
|
|
||||||
|
| File | Length | Source | Best Genres |
|
||||||
|
|------|--------|--------|-------------|
|
||||||
|
| `4x12-mesa-rectifier-dynax.wav` | 500ms | DY MasaBoo Recti412 (Mesa Rectifier 4x12, A pos) | Metal, High Gain |
|
||||||
|
| `4x12-mesa-rectifier-armored-dynax.wav` | 500ms | DY MasaBoo Recti412 Armored | Modern Metal, Djent |
|
||||||
|
| `4x12-peavey-5150-dynax.wav` | 500ms | DY PEV5150 412SL (Peavey 5150 4x12) | Metal, Nu-Metal |
|
||||||
|
| `4x12-evh-5150-ic-v1.wav` | 500ms | 5150 IC V1 (EVH 5150) | Metal, Lead |
|
||||||
|
| `4x12-engl-e412-dynax.wav` | 500ms | DY ENG E412VHB (ENGL 4x12) | Modern Metal, Djent |
|
||||||
|
| `4x12-engl-fireball-sm48-dauntless.wav` | 500ms | Dauntless: Engl Fireball SM48 | Metal, Death |
|
||||||
|
| `4x12-engl-fireball-b906-dauntless.wav` | 500ms | Dauntless: Engl Fireball B906 | Metal |
|
||||||
|
| `4x12-engl-fireball-sc140-dauntless.wav` | 500ms | Dauntless: Engl Fireball SC140 | Metal |
|
||||||
|
| `4x12-diezel-v412-dynax.wav` | 500ms | DY DIEZL V412 (Diezel 4x12) | Modern Metal |
|
||||||
|
| `4x12-friedman-dynax.wav` | 500ms | DY Fried 412 (Friedman 4x12) | Rock, Metal |
|
||||||
|
| `4x12-bogner412-dynax.wav` | 500ms | DY Bognar412 (Bogner 4x12) | Rock |
|
||||||
|
| `4x12-orange-ppc412-dynax.wav` | 500ms | DY ORENG 412PPC (Orange PPC412) | Rock, Doom, Stoner |
|
||||||
|
| `4x12-smith-v30-sm57-dc.wav` | 500ms | SMG UK V30 SM57 DC (V30, close mic) | Rock, High Gain |
|
||||||
|
| `4x12-smith-v30-440-dc.wav` | 500ms | SMG UK V30 440 DC (V30, different mic) | Rock |
|
||||||
|
| `4x12-mesa-modern-ic.wav` | 500ms | Mesa Modern IC (Mesa Boogie) | Modern Metal |
|
||||||
|
| `4x12-mesa-c414-xl2.wav` | 500ms | Mesa c414 XL2 (AKG C414) | Metal |
|
||||||
|
| `4x12-ml-soundlab-color-calibrated.wav` | 200ms | ML Sound Lab Color Calibrated IR | Modern Metal |
|
||||||
|
| `4x12-ml-soundlab-tube-color.wav` | 200ms | ML Sound Lab Tube Color IR | Modern Metal |
|
||||||
|
| `4x12-ml-soundlab-ss-color.wav` | 200ms | ML Sound Lab Solid State Color IR | Modern Metal |
|
||||||
|
| `4x12-ml-soundlab-best-ir.wav` | 500ms | ML Sound Lab BEST IR IN THE WORLD | General Rock/Metal |
|
||||||
|
| `4x12-ml-soundlab-djentle-keyan.wav` | 500ms | ML Sound Lab Djentle Keyan (48kHz) | Djent, Modern Metal |
|
||||||
|
| `4x12-ml-soundlab-djentle-keyan-44k.wav` | 500ms | ML Sound Lab Djentle Keyan (converted 44→48k) | Djent |
|
||||||
|
|
||||||
|
#### Bass Cab IRs
|
||||||
|
|
||||||
|
| File | Length | Best Genres |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `bass-balanced.wav` | 500ms | General bass |
|
||||||
|
| `bass-clanky.wav` | 500ms | Clanky/modern bass |
|
||||||
|
| `bass-classic.wav` | 500ms | Classic bass |
|
||||||
|
| `bass-half_and_half.wav` | 500ms | All-purpose bass |
|
||||||
|
| `bass-more_mids.wav` | 500ms | Mid-heavy bass |
|
||||||
|
| `bass-rumble_bass.wav` | 500ms | Sub-heavy bass |
|
||||||
|
| `bass-rumble_jr.wav` | 500ms | Light bass |
|
||||||
|
| `bass-thick_classic.wav` | 500ms | Thick classic bass |
|
||||||
|
| `bass-use_fuzz.wav` | 500ms | Distorted bass |
|
||||||
|
| `bass-use_on_sub_track.wav` | 500ms | Sub-track bass |
|
||||||
|
|
||||||
|
### Previous attempt — Archive.org (unavailable)
|
||||||
|
|
||||||
|
The following Archive.org sources returned 404 when last checked (2026-06-13):
|
||||||
|
|
||||||
|
| Pack | URL | Contents |
|
||||||
|
|------|-----|----------|
|
||||||
|
| God's Cab | `archive.org/download/godscab-ir-pack/` | 100+ IRs (412, 1960, V30, Greenback) — CC0 |
|
||||||
|
| Seacow Cabs | `archive.org/download/seacow-cabs-free-ir-2023/` | Boutique IRs, 15+ cabs — Free for personal use |
|
||||||
|
|
||||||
|
**To retry when Archive.org is back:** `python3 scripts/download_nam_and_irs.py --irs`
|
||||||
|
|
||||||
|
## Genre-to-Model Mapping
|
||||||
|
|
||||||
|
### Clean Jazz (Preset 0)
|
||||||
|
- **Amp:** `Fender_TwinVerb_Clean.nam` (or `JCM2000_Clean.nam`)
|
||||||
|
- **Cab:** `vintage-1x12.wav` (or `jazz-1x15.wav`)
|
||||||
|
|
||||||
|
### Edge of Breakup (Preset 1)
|
||||||
|
- **Amp:** `Magnatone_Super59_Pushed.nam` (or `Vox_AC15_TopBoost.nam`)
|
||||||
|
- **Cab:** `american-2x12.wav` (or `boutique-1x12.wav`)
|
||||||
|
|
||||||
|
### Clean with Chorus (Preset 2)
|
||||||
|
- **Amp:** `Fender_TwinVerb_Vibrato.nam`
|
||||||
|
- **Cab:** `vintage-1x12.wav`
|
||||||
|
|
||||||
|
### Dynamic Fingerstyle (Preset 3)
|
||||||
|
- **Amp:** `Vox_AC15_TopBoost.nam`
|
||||||
|
- **Cab:** `american-2x12.wav`
|
||||||
|
|
||||||
|
### Classic Rock (Preset 4)
|
||||||
|
- **Amp:** `Orange_Rockerverb.nam` (or `JCM2000_Crunch.nam`)
|
||||||
|
- **Cab:** `british-4x12.wav`
|
||||||
|
|
||||||
|
### Lead Solo (Preset 5)
|
||||||
|
- **Amp:** `5150_BlockLetter_Boosted.nam` (or `Engl_Savage.nam`)
|
||||||
|
- **Cab:** `modern-4x12.wav`
|
||||||
|
- **Boost:** `BOSS_SD1_Feather.nam` or `TS808_Feather.nam`
|
||||||
|
|
||||||
|
### Metal Rhythm (Preset 6)
|
||||||
|
- **Amp:** `6505_Red_NoBoost.nam` (or `Splawn_ProMod_3rd.nam`)
|
||||||
|
- **Cab:** `modern-4x12.wav`
|
||||||
|
|
||||||
|
### Ambient Wash (Preset 7)
|
||||||
|
- **Amp:** `Fender_TwinVerb_Clean.nam` (highly modulated FX chain)
|
||||||
|
- **Cab:** `vintage-1x12.wav`
|
||||||
|
|
||||||
|
### Blues Clean (Bank 2)
|
||||||
|
- **Amp:** `Fender_Princeton_Clone_Clean.nam` (or `Fender_Twin_Reverb_Clean_A2.nam`, or `TwoRock_JTM_Signature.nam`)
|
||||||
|
- **Cab:** `1x12-fender-deluxerev-dynax.wav` (or `vintage-1x12.wav`, `1x12-fender-hotrod-dynax.wav`)
|
||||||
|
|
||||||
|
### Blues Edge-of-Breakup (Bank 2)
|
||||||
|
- **Amp:** `Fender_Princeton_Clone_EOB.nam` (or `Fender_Tweed_Dlx_Edge.nam`)
|
||||||
|
- **Cab:** `2x12-vox-ac30-dynax.wav` (or `american-2x12.wav`, `2x12-matchless-dc30-dynax.wav`)
|
||||||
|
|
||||||
|
### Blues Pushed / Overdrive (Bank 2)
|
||||||
|
- **Amp:** `Dumble_ODS_102_Rock_A2.nam` (or `Marshall_Bluesbreaker_1962_A2.nam`)
|
||||||
|
- **Cab:** `4x12-marshall-1960a-sm57-0in-2c.wav` (or `british-4x12.wav`, `4x12-marshall-1960ahw-dynax.wav`)
|
||||||
|
- **Boost:** `Marshall_Bluesbreaker_Pedal.nam` (or `TubeScreamer_Maxed.nam`)
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
```
|
||||||
|
`~/.pedal/`
|
||||||
|
├── models/ # 64 models (22 core + 9 blues + 5 A2 bonus + 1 ML cab + 17 Tone3000 rock/high-gain/ambient + 10 Bank 7 bass/orange/others)
|
||||||
|
│ ├── Fender_TwinVerb_Clean.nam
|
||||||
|
│ ├── Fender_TwinVerb_Vibrato.nam
|
||||||
|
│ ├── Vox_AC15_TopBoost.nam
|
||||||
|
│ ├── Magnatone_Super59_Pushed.nam
|
||||||
|
│ ├── JCM2000_Clean.nam
|
||||||
|
│ ├── Orange_Rockerverb.nam
|
||||||
|
│ ├── JCM2000_Crunch.nam
|
||||||
|
│ ├── JCM900_Lead.nam
|
||||||
|
│ ├── 5150_BlockLetter_Boosted.nam
|
||||||
|
│ ├── 6505_Red_NoBoost.nam
|
||||||
|
│ ├── Engl_Savage.nam
|
||||||
|
│ ├── Splawn_ProMod_3rd.nam
|
||||||
|
│ ├── 5152_Lead_TS9.nam
|
||||||
|
│ ├── Klon_Centaur_High.nam
|
||||||
|
│ ├── TubeScreamer_Maxed.nam
|
||||||
|
│ ├── Mesa_MarkIV_Lead.nam
|
||||||
|
│ ├── Bogner_Ecstasy.nam
|
||||||
|
│ ├── BOSS_SD1_Feather.nam # 90 KB
|
||||||
|
│ ├── MXR_M77_Feather.nam # 90 KB
|
||||||
|
│ ├── TS808_Feather.nam # 90 KB
|
||||||
|
│ ├── Precision_Drive_Feather.nam # 90 KB
|
||||||
|
│ ├── Friedman_PowerAmp_Feather.nam # 90 KB
|
||||||
|
│ │
|
||||||
|
│ ├── Bank 2 — Blues captures:
|
||||||
|
│ ├── Fender_Princeton_Clone_Clean.nam # 289 KB — Blues Clean
|
||||||
|
│ ├── Fender_Princeton_Clone_EOB.nam # 288 KB — Blues Edge
|
||||||
|
│ ├── Fender_Tweed_Dlx_Edge.nam # 288 KB — Tweed Pushed
|
||||||
|
│ ├── Fender_Twin_Reverb_Clean_A2.nam # 290 KB — Blues Clean
|
||||||
|
│ ├── TwoRock_JTM_Signature.nam # 276 KB — Clean/Edge
|
||||||
|
│ ├── Dumble_ODS_102_Rock_A1.nam # 87 KB — Pushed OD feather
|
||||||
|
│ ├── Dumble_ODS_102_Rock_A2.nam # 289 KB — Pushed OD
|
||||||
|
│ ├── Marshall_Bluesbreaker_1962_A2.nam # 289 KB — Blues Rock
|
||||||
|
│ ├── Marshall_Bluesbreaker_Pedal.nam # 290 KB — Pedal OD
|
||||||
|
│ │
|
||||||
|
│ ├── Bonus — ML Sound Lab (from GitHub: fnpngn/IR):
|
||||||
|
│ ├── ML NAM AI Cab.nam # 298 KB — Cab modeling NAM (WaveNet)
|
||||||
|
│ │
|
||||||
|
│ └── Bonus — A2 slimmable models (from prior runs):
|
||||||
|
│ ├── Fender_Deluxe_Reverb_A2.nam # 301 KB — Deluxe Reverb (Slimmable v0.7.0)
|
||||||
|
│ ├── Marshall_JMP50_Plexi_A2.nam # 302 KB — Plexi (Slimmable v0.7.0)
|
||||||
|
│ ├── Park75_1968_Plexi_A2.nam # 301 KB — Park 75 Plexi (Slimmable v0.7.0)
|
||||||
|
│ ├── Park75_1968_Plexi_Noon_A2.nam # 302 KB — Park 75 Plexi noon (Slimmable)
|
||||||
|
│ ├── Roland_JC-120_A2.nam # 304 KB — JC-120 Jazz Chorus (Slimmable)
|
||||||
|
│ │
|
||||||
|
│ ├── Bank 4 — Rock captures:
|
||||||
|
│ ├── Marshall_JCM800_2203.nam # 411 KB — JCM800 rock crunch
|
||||||
|
│ ├── Marshall_Plexi_1967_Low.nam # 412 KB — Plexi low gain
|
||||||
|
│ ├── Marshall_Plexi_1967_Dimed.nam # 412 KB — Plexi pushed
|
||||||
|
│ ├── Marshall_JTM45_Clean.nam # 276 KB — JTM45 clean/edge
|
||||||
|
│ ├── Marshall_JTM45_Breakup.nam # 276 KB — JTM45 breakup
|
||||||
|
│ │
|
||||||
|
│ ├── Bank 5 — High-Gain captures:
|
||||||
|
│ ├── Mesa_Boogie_Dual_Rec_Plumes.nam # 277 KB — Dual Rectifier boosted
|
||||||
|
│ ├── Mesa_Boogie_Dual_Rec_Red.nam # 276 KB — Rectifier Red
|
||||||
|
│ ├── EVH_5150III_Stealth_Lead.nam # 411 KB — 5150III Lead
|
||||||
|
│ ├── EVH_5150III_Boosted.nam # 397 KB — 5150III boosted
|
||||||
|
│ ├── Diezel_VH4_Brown_Sugar.nam # 277 KB — VH4 modern high-gain
|
||||||
|
│ ├── Engl_Powerball_MkI_Lead.nam # 411 KB — Powerball lead
|
||||||
|
│ ├── Engl_Powerball_II_Crunch.nam # 64 KB — Powerball II crunch (feather)
|
||||||
|
│ ├── Engl_Powerball_II_HiGain.nam # 64 KB — Powerball II rhythm (feather)
|
||||||
|
│ ├── Engl_Powerball_II_Clean.nam # 64 KB — Powerball II clean (feather)
|
||||||
|
│ │
|
||||||
|
│ ├── Bank 6 — Ambient captures:
|
||||||
|
│ ├── Roland_JC120_Jazz_Chorus.nam # 276 KB — JC-120 clean
|
||||||
|
│ ├── Vox_AC30_Chimey.nam # 290 KB — AC30 chimey clean
|
||||||
|
│ ├── Fender_Twin_Reverb_G05.nam # 288 KB — Twin Reverb clean
|
||||||
|
│ │
|
||||||
|
│ ├── Bank 7 — Bass, Orange & Others:
|
||||||
|
│ ├── Ampeg_PF20T_A2.nam # 288 KB — Bass clean tube
|
||||||
|
│ ├── Aguilar_Tone_Hammer_500_A2.nam # 293 KB — Bass modern preamp
|
||||||
|
│ ├── GK_800RB_Line_A2.nam # 295 KB — Bass solid-state
|
||||||
|
│ ├── Aguilar_DB751_Cab_A2.nam # 292 KB — Bass amp DI
|
||||||
|
│ ├── Orange_Rocker30_Dirty_A2.nam # 295 KB — Rock crunch
|
||||||
|
│ ├── Orange_Rocker30_Natural_A2.nam # 289 KB — Clean edge
|
||||||
|
│ ├── Fender_Bassman_Dimed_A2.nam # 289 KB — Blues/rock breakup
|
||||||
|
│ │
|
||||||
|
│ └── 🎸 Bass Genre Research:
|
||||||
|
│ └── Full artist tone chain configs → `docs/bass-tone-research.md`
|
||||||
|
│ Available on Tone3000 (needs download): SansAmp BDDI v1, Darkglass B3K/B7K,
|
||||||
|
│ Ampeg SVT-CL, Geddy Lee SansAmp DI-2112
|
||||||
|
│ ├── Hiwatt_DR103_Bright_A2.nam # 287 KB — Classic rock
|
||||||
|
│ ├── Hiwatt_DR103_Cornish_A2.nam # 288 KB — Boosted classic rock
|
||||||
|
│ ├── Supro_Black_Magick_M3_A2.nam # 288 KB — Garage/blues
|
||||||
|
│ │
|
||||||
|
│
|
||||||
|
├── nam/ # Legacy location (symlinked from models/)
|
||||||
|
│ ├── Fender_TwinVerb_Clean.nam → ../models/Fender_TwinVerb_Clean.nam
|
||||||
|
│ ├── (10 legacy Linear models remain)
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── irs/ # 7 synthetic + 62 real IRs (48kHz, mono)
|
||||||
|
├── vintage-1x12.wav # synthetic
|
||||||
|
├── british-4x12.wav # synthetic
|
||||||
|
├── american-2x12.wav # synthetic
|
||||||
|
├── modern-4x12.wav # synthetic
|
||||||
|
├── jazz-1x15.wav # synthetic
|
||||||
|
├── boutique-1x12.wav # synthetic
|
||||||
|
├── mini-1x8.wav # synthetic
|
||||||
|
├── 1x12-*.wav # 9 real IRs (Fender Deluxe, Hot Rod, etc.)
|
||||||
|
├── 2x12-*.wav # 7 real IRs (Vox AC30, Matchless, etc.)
|
||||||
|
├── 4x12-*.wav # 36 real IRs (Marshall 1960A, Mesa Rectifier, etc.)
|
||||||
|
├── bass-*.wav # 10 real IRs (bass cab captures)
|
||||||
|
├── README.md
|
||||||
|
└── default/
|
||||||
|
└── default_ir.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
All 64 models were:
|
||||||
|
- 22 models from `https://raw.githubusercontent.com/pelennor2170/NAM_models/main/`
|
||||||
|
- 9 blues models from Tone3000 / ToneHunt Supabase storage
|
||||||
|
- 5 clean/plexi A2 models from Tone3000 Supabase storage
|
||||||
|
- 1 bonus ML Sound Lab model from GitHub: fnpngn/IR
|
||||||
|
- 17 rock/high-gain/ambient models from Tone3000 Supabase storage
|
||||||
|
- 10 bass/orange/others A2 models from Tone3000 Supabase storage
|
||||||
|
- All verified as valid `.nam` JSON files (WaveNet A1, WaveNet A2, or SlimmableContainer A2 architecture)
|
||||||
|
|
||||||
|
To verify models load correctly:
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
from src.dsp.nam_host import NAMHost
|
||||||
|
h = NAMHost(models_dir='$HOME/.pedal/models')
|
||||||
|
for m in h.list_available_models():
|
||||||
|
print(f'{m.name:30s} {m.architecture:10s} {m.size_mb:.2f} MB')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `scripts/download_nam_and_irs.py` | Download models + IRs from GitHub/Archive.org |
|
||||||
|
| `scripts/download_nam_and_irs.sh` | Shell alternative (legacy) |
|
||||||
|
| `scripts/download_models.sh` | Old generator (synthetic models) |
|
||||||
|
| `scripts/download_irs.sh` | Old IR downloader |
|
||||||
|
|
||||||
|
## Upgrade Path
|
||||||
|
|
||||||
|
1. **More real IRs:** When Archive.org is back, run `python3 scripts/download_nam_and_irs.py --irs` to get God's Cab and Seacow Cabs packs.
|
||||||
|
2. **More NAM models:** 17 new Tone3000 A1 models added (Banks 4-6) covering Rock (JCM800, Plexi, JTM45), High-Gain (Dual Rectifier, 5150III, Diezel VH4, Engl Powerball), and Ambient (JC-120, AC30, Twin Reverb). Browse https://www.tone3000.com/search with filters.
|
||||||
|
3. **Additional IR sources** (may require sign-up):
|
||||||
|
- Lancaster Audio free pack: https://lancasteraudio.com/ (search "free impulse responses")
|
||||||
|
- ML Sound Lab: https://mlsoundlab.com/pages/free-impulse-responses
|
||||||
|
- Celestion Plus: https://celestionplus.com/ (requires account)
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
# RT Performance Tuning — Pi Multi-FX Pedal
|
||||||
|
|
||||||
|
> Reference doc for real-time audio performance on Raspberry Pi 4B.
|
||||||
|
> Targets: <12ms round-trip latency (ideally <8ms), zero xruns, CPU <40%.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Pi Multi-FX Pedal runs a JACK audio server with an ALSA backend on a
|
||||||
|
Raspberry Pi 4B. The signal path is:
|
||||||
|
|
||||||
|
```
|
||||||
|
Guitar → Focusrite 2i2 → ALSA → JACK → Python pipeline (NAM + FX) → JACK → ALSA → Output
|
||||||
|
```
|
||||||
|
|
||||||
|
Each stage adds latency. The total round-trip latency is dominated by:
|
||||||
|
|
||||||
|
1. **ALSA period size** (`-p`): The buffer size in frames JACK exchanges
|
||||||
|
with the audio hardware. *This is the #1 tuning knob.*
|
||||||
|
2. **Number of periods** (`-n`): ALSA ring buffer depth. More periods =
|
||||||
|
more tolerance for scheduling jitter but higher latency.
|
||||||
|
3. **Sample rate** (`-r`): Higher rate = lower per-frame latency but more
|
||||||
|
CPU. 48kHz is the sweet spot for USB audio interfaces.
|
||||||
|
4. **NAM inference time**: The C++ subprocess takes 2-5ms per block on
|
||||||
|
Pi 4B. This is the bottleneck that sets the minimum viable buffer size.
|
||||||
|
|
||||||
|
## Recommended Settings (Pi 4B)
|
||||||
|
|
||||||
|
| Setting | Standard | Low Latency | Ultra Low | Unit |
|
||||||
|
|---------|----------|-------------|-----------|------|
|
||||||
|
| Period (buffer) | 512 | 256 | 128 | frames |
|
||||||
|
| Sample rate | 48000 | 48000 | 48000 | Hz |
|
||||||
|
| Periods (nperiods) | 2 | 2 | 3 | |
|
||||||
|
| RT priority | 70 | 75 | 80 | |
|
||||||
|
| Expected latency | ~10.7ms | ~5.3ms | ~2.7ms | |
|
||||||
|
| Expected NAM CPU | 35-50% | 60-93% | 80-100%+ | |
|
||||||
|
| Xrun stability | ✅ Stable | ⚠️ Possible | ❌ Likely | |
|
||||||
|
|
||||||
|
### Default recommendation: 512/48k (standard)
|
||||||
|
|
||||||
|
The standard profile (512 frames, 48kHz, 2 periods) is the **recommended
|
||||||
|
default** for the Pi 4B. This provides:
|
||||||
|
|
||||||
|
- **10.67ms** callback window (more than enough for 2-5ms NAM inference)
|
||||||
|
- **35-50%** CPU load with LSTM NAM models
|
||||||
|
- **Zero xruns** in normal playing
|
||||||
|
- Enough headroom for the FX chain (filters, modulation, reverb)
|
||||||
|
|
||||||
|
Even at 512 frames, the **round-trip latency** (capture → process →
|
||||||
|
playback) is typically **6-10ms** with a USB audio interface — well under
|
||||||
|
the <12ms target. The round-trip includes two ALSA transfers (capture +
|
||||||
|
playback), which is why it's lower than the raw period calculation.
|
||||||
|
|
||||||
|
## Tuning Knobs
|
||||||
|
|
||||||
|
### 1. JACK buffer size (`--period` / `-p`)
|
||||||
|
|
||||||
|
The JACK period is the number of frames per audio block. Lower = lower
|
||||||
|
latency but more CPU and more xrun risk.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Current: 512 frames at 48kHz = 10.67ms
|
||||||
|
jackd -p 512 -r 48000 ...
|
||||||
|
|
||||||
|
# Aggressive: 128 frames at 48kHz = 2.67ms
|
||||||
|
jackd -p 128 -r 48000 ...
|
||||||
|
|
||||||
|
# Conservative: 1024 frames at 48kHz = 21.33ms (safe, higher latency)
|
||||||
|
jackd -p 1024 -r 48000 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Measurement:** When you change the period in the UI, the server:
|
||||||
|
1. Updates `AudioConfig.period`
|
||||||
|
2. Updates `LATENCY_PROFILES["custom"]`
|
||||||
|
3. Stops JACK (with bt-a2dp dance)
|
||||||
|
4. Updates NAM block size (`set_block_size()`)
|
||||||
|
5. Updates pipeline DSP (`set_audio_profile()`)
|
||||||
|
6. Restarts JACK with new period
|
||||||
|
7. Reconnects FX ports
|
||||||
|
8. Restarts bt-a2dp
|
||||||
|
|
||||||
|
**Timeout caveat:** The UI's POST must use `timeout: 15000` (15s) because
|
||||||
|
JACK restart takes 6-10s on Pi 4B.
|
||||||
|
|
||||||
|
### 2. Number of periods (`--nperiods` / `-n`)
|
||||||
|
|
||||||
|
The ALSA period count controls the ring buffer depth:
|
||||||
|
|
||||||
|
- **nperiods=2** (default): Lower latency, less tolerance for scheduling
|
||||||
|
jitter. Good for stable USB audio interfaces.
|
||||||
|
- **nperiods=3**: More tolerance for scheduling jitter at the cost of
|
||||||
|
~50% more ALSA buffer latency. Recommended when pushing below 256
|
||||||
|
frames where every microsecond counts.
|
||||||
|
|
||||||
|
The nperiods trade-off: at 128/48k, nperiods=3 adds 128×3/48000 = 8ms
|
||||||
|
of ALSA buffer vs 128×2/48000 = 5.3ms for nperiods=2. The extra 2.7ms
|
||||||
|
can prevent xruns when the CPU governor ramps or a system timer fires.
|
||||||
|
|
||||||
|
### 3. RT priority (`-P` / `rt_priority`)
|
||||||
|
|
||||||
|
JACK uses `-P` to set SCHED_FIFO priority. On RPi 4B:
|
||||||
|
|
||||||
|
| Priority | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| 60 | Default — works but shares CPU with other RT tasks |
|
||||||
|
| 70 | **Standard profile** — good balance |
|
||||||
|
| 80 | **Low latency profile** — less scheduling jitter |
|
||||||
|
| 90-95 | Aggressive — use if xruns persist at 256/48k |
|
||||||
|
|
||||||
|
The Python pedal process should also run with RT scheduling:
|
||||||
|
```bash
|
||||||
|
chrt -f 80 python3 main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The systemd service (`pi-multifx-pedal.service`) now wraps this
|
||||||
|
automatically via `ExecStart=/usr/bin/chrt -f 80 python3 main.py`.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- `LimitRTPRIO=95` in the systemd unit (already present)
|
||||||
|
- `@audio - rtprio 95` in `/etc/security/limits.d/99-audio.conf`
|
||||||
|
- Process must run as root or with `CAP_SYS_NICE`
|
||||||
|
|
||||||
|
### 4. CPU governor → performance
|
||||||
|
|
||||||
|
The RPi 4B's CPU governor defaults to `ondemand` or `powersave`, which
|
||||||
|
keeps the CPU at 600MHz idle and ramps up under load. The ramp-up takes
|
||||||
|
1-2ms — significant at 256/48k (5.33ms callback window).
|
||||||
|
|
||||||
|
**Applied in `main.py` at boot:**
|
||||||
|
```python
|
||||||
|
for c in range(os.cpu_count() or 1):
|
||||||
|
with open(f"/sys/devices/system/cpu/cpu{c}/cpufreq/scaling_governor", "w") as f:
|
||||||
|
f.write("performance")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
|
||||||
|
# All should show "performance"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-off:** ~0.5W extra power draw (CPU stays at 1.5GHz).
|
||||||
|
|
||||||
|
### 5. IRQ affinity — pin USB audio to a dedicated core
|
||||||
|
|
||||||
|
On RPi 4B, interrupts are distributed across all 4 cores by default.
|
||||||
|
Pinning the USB audio IRQ to **core 3** isolates it from kernel
|
||||||
|
housekeeping on cores 0-2.
|
||||||
|
|
||||||
|
**Applied in `main.py` at boot:**
|
||||||
|
```python
|
||||||
|
# Find xhci-hcd or dwc_otg IRQ → pin to core 3 (mask 0x8)
|
||||||
|
echo "8" > /proc/irq/<IRQ>/smp_affinity
|
||||||
|
echo "3" > /proc/irq/<IRQ>/smp_affinity_list
|
||||||
|
# All other IRQs moved to cores 0-2 (mask 0x7)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
./scripts/rt-tune.sh --status
|
||||||
|
# or manually:
|
||||||
|
cat /proc/irq/*/smp_affinity | sort | uniq -c
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. mlockall() — lock process memory
|
||||||
|
|
||||||
|
Prevents page faults in the RT callback by locking all process pages
|
||||||
|
in RAM.
|
||||||
|
|
||||||
|
**Applied in `main.py` at boot:**
|
||||||
|
```python
|
||||||
|
import ctypes
|
||||||
|
libc = ctypes.CDLL('libc.so.6')
|
||||||
|
libc.mlockall(3) # MCL_CURRENT | MCL_FUTURE
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requires:** `LimitMEMLOCK=infinity` in the systemd unit (already present).
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
grep -i lock /proc/$(pidof python3)/status | head -5
|
||||||
|
# VmLck should be non-zero
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. GC disable — prevent Python GC pauses
|
||||||
|
|
||||||
|
Python's default GC (threshold=700) triggers every ~1.4s in the audio
|
||||||
|
pipeline due to ~500 numpy allocations/second. Each 10-50ms GC pause
|
||||||
|
causes audible pops.
|
||||||
|
|
||||||
|
**Applied in `main.py`:**
|
||||||
|
```python
|
||||||
|
import gc
|
||||||
|
gc.disable()
|
||||||
|
gc.collect() # one final sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
Periodic GC on the HTTP thread (never in RT callback):
|
||||||
|
```python
|
||||||
|
import gc
|
||||||
|
gc.collect() # in get_state() handler, ~2s poll
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-off:** Reference counting handles 99% of cleanup. The OS reclaims
|
||||||
|
all memory on process exit. Safe for a long-running daemon.
|
||||||
|
|
||||||
|
## Measurement Tools
|
||||||
|
|
||||||
|
### Round-trip latency (jack_iodelay)
|
||||||
|
|
||||||
|
Requires a physical loopback cable (output → input).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick measurement
|
||||||
|
jack_iodelay
|
||||||
|
|
||||||
|
# Automated (8 samples)
|
||||||
|
python3 -c "
|
||||||
|
from src.system.audio import AudioSystem
|
||||||
|
AudioSystem.measure_roundtrip_latency(samples=8)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skip if:** no loopback cable. The UI round-trip latency is
|
||||||
|
approximately `2 × period / rate × 1000ms` (capture + playback):
|
||||||
|
|
||||||
|
| Period | Rate | Calc. RT latency | Real RT latency |
|
||||||
|
|--------|------|------------------|-----------------|
|
||||||
|
| 512 | 48k | 21.33ms | ~6-10ms (USB interface) |
|
||||||
|
| 256 | 48k | 10.67ms | ~4-6ms |
|
||||||
|
| 128 | 48k | 5.33ms | ~2-4ms |
|
||||||
|
|
||||||
|
The real RT latency is lower than the formula because the USB interface
|
||||||
|
and ALSA driver pipeline the transfers.
|
||||||
|
|
||||||
|
### XRun monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable kernel tracking
|
||||||
|
echo 3 | sudo tee /proc/asound/card*/xrun_debug
|
||||||
|
|
||||||
|
# Quick check
|
||||||
|
jack_showtime -c | grep xruns
|
||||||
|
|
||||||
|
# Automated monitor (5 min)
|
||||||
|
python3 -c "
|
||||||
|
from src.system.audio import AudioSystem
|
||||||
|
asys = AudioSystem()
|
||||||
|
result = asys.monitor_xruns(duration=300, interval=10)
|
||||||
|
print(result)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**XRun debug bits:**
|
||||||
|
- Bit 0 (1): Log xruns to kernel ring buffer (`dmesg | grep xrun`)
|
||||||
|
- Bit 1 (2): Show stack backtrace
|
||||||
|
- Bit 2 (4): Inhibit xruns (test mode — disables recovery)
|
||||||
|
|
||||||
|
The pedal enables bit 0+1 (value 3) at boot for diagnostics.
|
||||||
|
|
||||||
|
### NAM CPU load
|
||||||
|
|
||||||
|
The state API (`GET /api/state`) now includes `nam_cpu` — the percentage
|
||||||
|
of the callback window spent in NAM inference:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://pedal.local/api/state | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
s = json.load(sys.stdin)
|
||||||
|
print(f'NAM CPU: {s[\"nam_cpu\"]:.1f}%')
|
||||||
|
print(f'System CPU: {s[\"cpu_percent\"]:.1f}%')
|
||||||
|
print(f'Input level: {s[\"input_level\"]:.3f}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected values (Pi 4B, LSTM NAM model):**
|
||||||
|
|
||||||
|
| Buffer | NAM CPU | Notes |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| 64 | 180-200% | xruns guaranteed |
|
||||||
|
| 128 | 80-100% | xruns likely |
|
||||||
|
| 256 | 60-93% | xruns possible |
|
||||||
|
| 512 | 35-50% | **stable** |
|
||||||
|
| 1024 | 15-25% | safe, higher latency |
|
||||||
|
|
||||||
|
## Systematic Tuning Procedure
|
||||||
|
|
||||||
|
### Step 1: Establish baseline
|
||||||
|
|
||||||
|
With the current settings, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Check current profile
|
||||||
|
curl -s http://pedal.local/api/audio/profile | python3 -m json.tool
|
||||||
|
|
||||||
|
# 2. Monitor xruns for 5 minutes
|
||||||
|
timeout 300 bash -c '
|
||||||
|
while true; do
|
||||||
|
xruns=$(jack_showtime -c 2>/dev/null | grep xruns)
|
||||||
|
echo "$(date +%H:%M:%S) $xruns"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
'
|
||||||
|
|
||||||
|
# 3. Measure NAM CPU
|
||||||
|
curl -s http://pedal.local/api/state | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
s = json.load(sys.stdin)
|
||||||
|
print(f'nam_cpu={s[\"nam_cpu\"]}% sys_cpu={s[\"cpu_percent\"]}% '
|
||||||
|
f'input={s[\"input_level\"]:.3f} output={s[\"output_level\"]:.3f}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Sweep buffer sizes
|
||||||
|
|
||||||
|
For each size (512 → 256 → 128 → 64), test for 5 minutes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for period in 512 256 128 64; do
|
||||||
|
echo "=== Testing period=$period ==="
|
||||||
|
curl -s -X POST -d "{\"period\":$period}" http://pedal.local/api/audio/profile
|
||||||
|
sleep 15 # wait for JACK restart + stabilization
|
||||||
|
|
||||||
|
# Check NAM CPU
|
||||||
|
curl -s http://pedal.local/api/state | python3 -c "
|
||||||
|
import json,sys; s=json.load(sys.stdin); print(f' nam_cpu={s[\"nam_cpu\"]}%')"
|
||||||
|
|
||||||
|
# Monitor 5 min
|
||||||
|
python3 -c "
|
||||||
|
from src.system.audio import AudioSystem
|
||||||
|
r = AudioSystem().monitor_xruns(300, 10)
|
||||||
|
print(f' xruns={r[\"xrun_total\"]} rate={r[\"xrun_rate_per_min\"]}/min stable={r[\"stable\"]}')
|
||||||
|
"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Evaluate nperiods sweep
|
||||||
|
|
||||||
|
For the best buffer candidates, test nperiods=2 vs nperiods=3:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for period in 128 256; do
|
||||||
|
for nperiods in 2 3; do
|
||||||
|
echo "=== p=$period n=$nperiods ==="
|
||||||
|
# Manually restart JACK with -n $nperiods
|
||||||
|
ssh pedal "sudo killall jackd; sleep 1; \
|
||||||
|
jackd -P 70 -d alsa -d hw:0,0 -r 48000 -p $period -n $nperiods -i 2 -o 2 &"
|
||||||
|
sleep 5
|
||||||
|
# Test...
|
||||||
|
done
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Select optimal
|
||||||
|
|
||||||
|
Choose the lowest period that achieves zero xruns over a 30-minute
|
||||||
|
test with active playing. Save to config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST -d '{"period":512,"rate":48000}' \
|
||||||
|
http://pedal.local/api/audio/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config File Reference
|
||||||
|
|
||||||
|
Key fields in `~/.pedal/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
profile: custom # or "standard", "low", "stable"
|
||||||
|
period: 512 # frames (64-2048, powers of 2)
|
||||||
|
rate: 48000 # Hz (44100, 48000, 96000, 192000)
|
||||||
|
input_device: hw:0,0 # ALSA device for capture
|
||||||
|
output_device: hw:0,0 # ALSA device for playback
|
||||||
|
mode: mono # or "stereo_4cm"
|
||||||
|
jack_enabled: true
|
||||||
|
auto_connect: true # re-connect JACK ports on restart
|
||||||
|
hat_type: audioinjector # or "focusrite_2i2_3gen"
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- RT tuning March 2025: standard=512/48k stable on Pi 4B with LSTM NAM
|
||||||
|
- Changing period/rate in UI saves to these fields automatically
|
||||||
|
- On restart, period/rate overrides the profile defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Pops/crackle at 256 frames
|
||||||
|
|
||||||
|
**Cause:** NAM inference takes 2-5ms on Pi 4B. At 256/48k (5.33ms window),
|
||||||
|
there's only 0.33-3.33ms headroom. Any scheduling jitter causes xrun.
|
||||||
|
|
||||||
|
**Fixes (in order of effectiveness):**
|
||||||
|
1. Increase to 512 frames (10.67ms window)
|
||||||
|
2. Set CPU governor to performance (already done at boot)
|
||||||
|
3. Pin USB audio IRQ to dedicated core (already done at boot)
|
||||||
|
4. Use nperiods=3 for more ALSA buffer tolerance
|
||||||
|
5. Disable Wi-Fi/BT if not needed (both share the USB bus on Pi 4B)
|
||||||
|
6. Use a lighter NAM model (Feather/Nano instead of LSTM)
|
||||||
|
|
||||||
|
### Audio drops out after profile change
|
||||||
|
|
||||||
|
**Check the startup order:**
|
||||||
|
1. NAM block size must be set BEFORE `jack_client.start()`
|
||||||
|
2. Pipeline DSP must be updated BEFORE `jack_client.start()`
|
||||||
|
3. SHM cleanup must not delete running JACK server files
|
||||||
|
4. bt-a2dp must be stopped before killing jackd
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
# Check what JACK actually started with
|
||||||
|
ps aux | grep jackd | grep -v grep
|
||||||
|
# Expected: jackd -P 70 -d alsa -d hw:0,0 -r 48000 -p 512 -n 2 -i 2 -o 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings revert after restart
|
||||||
|
|
||||||
|
**Check:**
|
||||||
|
1. Does `config.yaml` contain `period:` and `rate:`?
|
||||||
|
2. Does `AudioConfig` load them from `config.yaml`?
|
||||||
|
3. Is the `latency_profile` property applying the overrides?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -E "period:|rate:|profile:" ~/.pedal/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- JACK documentation: https://jackaudio.org/faq/
|
||||||
|
- RPi 4B audio latency: https://wiki.linuxaudio.org/wiki/raspberrypi
|
||||||
|
- ALSA xrun_debug: https://www.alsa-project.org/wiki/XRUN_Debug
|
||||||
|
- Pi 4B CPU freq scaling: /sys/devices/system/cpu/cpu*/cpufreq/
|
||||||
@@ -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 | ✅ |
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Test Plan: UX & API Verification
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. API Health & Baseline
|
||||||
|
|
||||||
|
| # | Test | Method | Path | Expected | Result |
|
||||||
|
|---|------|--------|------|----------|--------|
|
||||||
|
| 1.1 | State endpoint | `GET` | `/api/state` | 200 — returns current preset, bypass, volume, routing |
|
||||||
|
| 1.2 | Preset list | `GET` | `/api/presets` | 200 — Bank 0 with 4 slots (2 presets, 2 null) |
|
||||||
|
| 1.3 | Models list | `GET` | `/api/models` | 200 — both .nam files visible, loaded states |
|
||||||
|
| 1.4 | Profiles | `GET` | `/api/audio/profiles` | 200 — standard + low listed |
|
||||||
|
| 1.5 | Profile detail | `GET` | `/api/audio/profile` | 200 — period=256, jack_running=true |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Block Toggle (`PATCH /api/blocks`)
|
||||||
|
|
||||||
|
Core bug: previously `enabled` changed but `bypass` stayed true, so blocks remained skipped.
|
||||||
|
|
||||||
|
| # | Test | Payload | Expected | Result |
|
||||||
|
|---|------|---------|----------|--------|
|
||||||
|
| 2.1 | Enable a disabled block | `{"id": "nam_amp", "enabled": true}` | `bypass: false` returned |
|
||||||
|
| 2.2 | Disable an enabled block | `{"id": "nam_amp", "enabled": false}` | `bypass: true` returned |
|
||||||
|
| 2.3 | Toggle enabled→disabled | GET state before & after | Chain shows block with `enabled=false, bypass=true` |
|
||||||
|
| 2.4 | Toggle disabled→enabled | GET state before & after | Chain shows block with `enabled=true, bypass=false` |
|
||||||
|
| 2.5 | Toggle nonexistent block ID | `{"id": "phaser", "enabled": true}` | 404 — "Block 'phaser' not found" |
|
||||||
|
| 2.6 | Missing ID field | `{"enabled": true}` | 400 — "Missing 'id' or 'enabled'" |
|
||||||
|
| 2.7 | Missing enabled field | `{"id": "nam_amp"}` | 400 — "Missing 'id' or 'enabled'" |
|
||||||
|
| 2.8 | Toggle and confirm audio | Toggle off → play guitar → toggle on → play guitar | Audio is muted when bypassed, flows when not |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Block Param Update (`PATCH /api/block`)
|
||||||
|
|
||||||
|
| # | Test | Payload | Expected | Result |
|
||||||
|
|---|------|---------|----------|--------|
|
||||||
|
| 3.1 | Update volume param | `{"id": "nam_amp", "level": 0.5}` | `params.level == 0.5` returned |
|
||||||
|
| 3.2 | Update with max value | `{"id": "nam_amp", "level": 1.0}` | Accepted, no clipping |
|
||||||
|
| 3.3 | Update with zero value | `{"id": "nam_amp", "level": 0.0}` | Accepted (mute) |
|
||||||
|
| 3.4 | Update nonexistent block | `{"id": "flanger", "rate": 0.5}` | 404 |
|
||||||
|
| 3.5 | Update multiple params | `{"id": "nam_amp", "level": 0.7, "some_custom": 0.3}` | Both params stored |
|
||||||
|
| 3.6 | Readback after save | GET preset → confirm params persisted | Matches last write |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Preset CRUD
|
||||||
|
|
||||||
|
| # | Test | Method | Path/Payload | Expected | Result |
|
||||||
|
|---|------|--------|-------------|----------|--------|
|
||||||
|
| 4.1 | Get preset 0 | GET | `/api/presets/0/0` | 200 — Fender Deluxe Reverb Clean |
|
||||||
|
| 4.2 | Get preset 1 | GET | `/api/presets/0/1` | 200 — Marshall JCM 900 Dirty |
|
||||||
|
| 4.3 | Get empty slot | GET | `/api/presets/0/2` | 404 — "Preset not found" |
|
||||||
|
| 4.4 | Activate preset 0 | POST | `/api/presets/0/0/activate` | 200, `preset == "Fender Deluxe Reverb Clean"` |
|
||||||
|
| 4.5 | Activate preset 1 | POST | `/api/presets/0/1/activate` | 200, `preset == "Marshall JCM 900 Dirty"` |
|
||||||
|
| 4.6 | Create preset in slot 2 | PUT | `/api/presets/0/2` with nam_amp chain | 200 |
|
||||||
|
| 4.7 | Delete preset in slot 2 | DELETE | `/api/presets/0/2` | 200 — slot returns to null |
|
||||||
|
| 4.8 | Save preset with IR + NAM | PUT | block with `ir_file_path` + `nam_model_path` | Both saved |
|
||||||
|
| 4.9 | Cross-channel isolation | GET | `/api/presets?channel=bass` | Returns bass bank, not guitar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Audio Profile Switching
|
||||||
|
|
||||||
|
| # | Test | Payload | Expected | Result |
|
||||||
|
|---|------|---------|----------|--------|
|
||||||
|
| 5.1 | Switch to low latency | `{"profile": "low"}` | 200, `period: 64, restarted: true` |
|
||||||
|
| 5.2 | Verify JACK restarted | GET `/api/audio/profile` | `period == 64, jack_running == true` |
|
||||||
|
| 5.3 | Switch back to standard | `{"profile": "standard"}` | 200, `period: 256, restarted: true` |
|
||||||
|
| 5.4 | Verify JACK restarted | GET `/api/audio/profile` | `period == 256, jack_running == true` |
|
||||||
|
| 5.5 | Audio still flows after switch | Play guitar | No crackles, no silence |
|
||||||
|
| 5.6 | Switch to same profile | `{"profile": "standard"}` | `restarted: false` |
|
||||||
|
| 5.7 | Invalid profile | `{"profile": "extreme"}` | 400 — unknown profile |
|
||||||
|
| 5.8 | Xrun count not null | GET `/api/audio/profile` | `xrun_count` is int or null (not error) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Volume & Bypass (Pipeline level)
|
||||||
|
|
||||||
|
| # | Test | Method/Payload | Expected | Result |
|
||||||
|
|---|------|---------------|----------|--------|
|
||||||
|
| 6.1 | Set volume | `PUT /api/volume {"volume": 0.5}` | `volume: 0.5` returned |
|
||||||
|
| 6.2 | Volume clamping | `PUT /api/volume {"volume": 2.0}` | `volume: 1.0` (clamped) |
|
||||||
|
| 6.3 | Volume clamping low | `PUT /api/volume {"volume": -1.0}` | `volume: 0.0` (clamped) |
|
||||||
|
| 6.4 | Bypass toggle | `POST /api/bypass {"bypass": true}` | `bypass: true` |
|
||||||
|
| 6.5 | Bypass toggle off | `POST /api/bypass {"bypass": false}` | `bypass: false` |
|
||||||
|
| 6.6 | Bypass flip | `POST /api/bypass` (no body) | `bypass` toggles from current state |
|
||||||
|
| 6.7 | Bypass + audio | Toggle bypass ON → play guitar | Bypassed: clean DI signal, no FX |
|
||||||
|
| 6.8 | Bypass + audio | Toggle bypass OFF → play guitar | Active: NAM model processing audible |
|
||||||
|
| 6.9 | POST alias at /api/bypass/toggle | `POST /api/bypass/toggle {"bypass": true}` | Same as 6.4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. WebSocket Real-Time Updates
|
||||||
|
|
||||||
|
| # | Test | Action | Expected | Result |
|
||||||
|
|---|------|--------|----------|--------|
|
||||||
|
| 7.1 | Connect WS | `wscat -c ws://pedal.local/ws` | Receives `{"type": "connected", "state": {...}}` |
|
||||||
|
| 7.2 | Ping/pong | Send `{"type": "ping"}` | Response `{"type": "pong"}` |
|
||||||
|
| 7.3 | Block toggle broadcast | Toggle block via PATCH /api/blocks | WS message `{"type": "block_toggled", ...}` |
|
||||||
|
| 7.4 | Preset change broadcast | Activate preset via API | WS message `{"type": "preset_changed", ...}` |
|
||||||
|
| 7.5 | Bypass broadcast | Toggle bypass | WS message `{"type": "bypass_changed", ...}` |
|
||||||
|
| 7.6 | Volume change broadcast | Set volume | WS message `{"type": "volume_changed", ...}` |
|
||||||
|
| 7.7 | Audio profile broadcast | Switch profile | WS message `{"type": "audio_profile_changed", ...}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UI Visual Block Tests (Browser)
|
||||||
|
|
||||||
|
| # | Test | Action | Expected | Result |
|
||||||
|
|---|------|--------|----------|--------|
|
||||||
|
| 8.1 | Page loads | Navigate to `http://pedal.local/` | React SPA renders, block chain visible |
|
||||||
|
| 8.2 | NAM block visible | Check the block chain | "nam_amp" tile shown with correct icon |
|
||||||
|
| 8.3 | Block label | Read tile text | Shows "nam_amp" or friendly name |
|
||||||
|
| 8.4 | Toggle block ON/OFF | Click the block's power icon | Block appears enabled/disabled visually |
|
||||||
|
| 8.5 | State persists on refresh | Toggle OFF → F5 → check | Block still shows OFF |
|
||||||
|
| 8.6 | Reverb/delay blocks render | Check all FX types | All known FX types have unique icons |
|
||||||
|
| 8.7 | Empty slot display | Preset with empty chain | Shows "no blocks" or empty chain area |
|
||||||
|
| 8.8 | Block reorder (if supported) | Drag block | Chain updates after drop |
|
||||||
|
| 8.9 | URL root works | GET `/` | Returns React SPA (not 404 or redirect) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Edge Cases & Robustness
|
||||||
|
|
||||||
|
| # | Test | Action | Expected | Result |
|
||||||
|
|---|------|--------|----------|--------|
|
||||||
|
| 9.1 | Missing preset directory | Delete `~/.pedal/presets`, restart | Pedal boots, creates one empty stub |
|
||||||
|
| 9.2 | Missing models directory | Remove `.nam` files | `/api/models` returns `[]`, preset fails gracefully |
|
||||||
|
| 9.3 | Corrupt preset JSON | Inject bad JSON into `preset_0.json` | API returns 500, pedal continues running |
|
||||||
|
| 9.4 | Web server restart | `systemctl restart pi-multifx-pedal` | 30s max downtime, then all endpoints respond |
|
||||||
|
| 9.5 | Concurrent API calls | Fire 10 rapid toggle requests | All return 200, final state is deterministic |
|
||||||
|
| 9.6 | JACK not running | Kill jackd mid-session | Audio stops, API still responds, JACK restarts on next profile switch |
|
||||||
|
| 9.7 | Rate-limited toggle | Toggle block on/off 50 times | No memory leak, state remains consistent |
|
||||||
|
| 9.8 | SD card at 95%+ | Fill disk to near-capacity | API still responds (read-only ops work) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Channel Isolation (if applicable)
|
||||||
|
|
||||||
|
| # | Test | Action | Expected | Result |
|
||||||
|
|---|------|--------|----------|--------|
|
||||||
|
| 10.1 | Guitar state doesn't affect bass | Set guitar volume 0.0, check bass | Bass volume unchanged |
|
||||||
|
| 10.2 | Dual-mono routing | POST `/api/channel-mode {"channel_mode": "dual-mono"}` | 200, mode switched |
|
||||||
|
| 10.3 | Stereo mode | POST `/api/channel-mode {"channel_mode": "stereo"}` | 200, unified state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Execution
|
||||||
|
|
||||||
|
Run in order. Mark each cell:
|
||||||
|
|
||||||
|
- ✅ — passes
|
||||||
|
- ❌ — fails (log issue + fix before moving on)
|
||||||
|
- ⚠️ — non-critical/optional (label reason)
|
||||||
|
- ⏭️ — skipped (label why)
|
||||||
|
|
||||||
|
For curl tests, use this template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sv http://pedal.local/api/presets | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
For WS tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect, wait, then send toggle commands in another terminal
|
||||||
|
wscat -c ws://pedal.local/ws
|
||||||
|
```
|
||||||
+160
-120
@@ -1,9 +1,9 @@
|
|||||||
# Dogfood QA Report — Pi Multi-FX Pedal
|
# Dogfood QA Report
|
||||||
|
|
||||||
**Target:** http://pedal.local:8080 (RPi live hardware)
|
**Target:** http://pedal.local/
|
||||||
**Date:** 2026-06-12
|
**Date:** 2026-06-16
|
||||||
**Scope:** REST API endpoints, HTML pages, Tone3000 search, edge cases vs live Pi
|
**Scope:** Full site — all overlays, API endpoints, audio controls, Tone3000 downloads, system settings
|
||||||
**Tester:** Hermes Agent (automated exploratory QA against real deployment)
|
**Tester:** Hermes Agent (automated exploratory QA)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,166 +11,206 @@
|
|||||||
|
|
||||||
| Severity | Count |
|
| Severity | Count |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| 🔴 Critical | 1 |
|
| 🔴 Critical | 0 |
|
||||||
| 🟠 High | 2 |
|
| 🟠 High | 1 |
|
||||||
| 🟡 Medium | 1 |
|
| 🟡 Medium | 2 |
|
||||||
| 🔵 Low | 1 |
|
| 🔵 Low | 1 |
|
||||||
| **Total** | **5** |
|
| **Total** | **4** |
|
||||||
|
|
||||||
**Overall Assessment:** The pedal is working! The Pi is online, connected, and serving real data. Dashboard shows live state (volume at 0.33, mono routing, preset system working). Three key issues identified: one critical (dashboard crash when disconnected), one version-sync gap (tonehub aliases not pushed to Pi), and one missing endpoint (`/api/captures`).
|
**Overall Assessment:** The pedal control UI is stable and functional. All 50+ API endpoints respond correctly. The audio timeout fix (15s UI timeout + kill-JACK-first restart pattern) works. A dead onclick handler on the NAM hero is the only functional bug — the rest are UX polish items.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Live vs Local — Key Difference
|
|
||||||
|
|
||||||
| Check | Local (code test) | Live Pi (pedal.local) |
|
|
||||||
|-------|-------------------|----------------------|
|
|
||||||
| Dashboard | 200 | **200 with real data** |
|
|
||||||
| Connected state | `connected: False` | `connected: True` |
|
|
||||||
| master_volume | 0.8 (default) | **0.33 (live)** |
|
|
||||||
| routing_mode | mono | mono, breakpoint 7 |
|
|
||||||
| Tone3000 search | ✅ Works | ✅ Works |
|
|
||||||
| Tonehub alias routes | ✅ Work | **404 — not on Pi yet** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
### Issue #1: Dashboard crashes with 500 when pedal deps not connected 🔴
|
### Issue #1: `switchTab` function undefined — NAM hero dead click
|
||||||
|
|
||||||
*(Same as previously reported and fixed locally — needs push to Pi)*
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| **Severity** | 🔴 Critical |
|
|
||||||
| **Category** | Functional |
|
|
||||||
| **URL** | `GET /` |
|
|
||||||
|
|
||||||
**Description:**
|
|
||||||
The dashboard template renders fine when pedal deps are connected (tested live — returns 200 with real data), but crashes with HTTP 500 when the pedal is disconnected. Root cause: `_gather_state()` returns only `{"connected": False}` when deps are None, but the template expects full default keys.
|
|
||||||
|
|
||||||
**Fix applied locally in `_gather_state()`** — returns full defaults (`master_volume: 0.8`, `routing_mode: "mono"`, etc.) when disconnected.
|
|
||||||
|
|
||||||
**Needs:** Commit & push to Pi.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Issue #2: React SPA calls `/api/captures` but backend has no such route 🟠
|
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Severity** | 🟠 High |
|
| **Severity** | 🟠 High |
|
||||||
| **Category** | Functional |
|
| **Category** | Functional |
|
||||||
| **URL** | `GET /api/captures` |
|
| **URL** | http://pedal.local/ |
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
The React SPA (`pi-multifx-pedal-ui/src/App.jsx`) has an API layer that calls `GET /api/captures` to list NAM captures and `POST /api/captures/upload` to upload new ones. However, the backend (`src/web/server.py`) has no `/api/captures` route — returns 404 on both local test and live Pi.
|
The NAM hero section in the main view has `onclick="switchTab('FX')"` but `switchTab` is **not defined** anywhere in the 50 defined functions. Clicking the hero area (a large visual panel showing the current NAM model) silently does nothing — no error, no feedback, no navigation. The user might reasonably tap here expecting to go to the FX/block management interface.
|
||||||
|
|
||||||
Introduced during "Wire React Knobs to Live API" task — frontend calls were added without building the corresponding backend endpoints. The captures screen in the React UI will silently fail.
|
**Location:** Line 175 of index.html
|
||||||
|
```html
|
||||||
|
<div class="nam-hero empty" id="namHero" onclick="switchTab('FX')">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Steps to Reproduce:**
|
||||||
|
1. Open http://pedal.local/ on a mobile or desktop browser
|
||||||
|
2. Tap the large NAM hero card at the top of the main view (shows the current model name, e.g. "marshall jcm900 cha full rig")
|
||||||
|
3. Nothing happens
|
||||||
|
|
||||||
|
**Expected Behavior:**
|
||||||
|
Should either:
|
||||||
|
- Switch to the FX tab view (show pedal blocks)
|
||||||
|
- Open the Downloads overlay (to search/download models)
|
||||||
|
- At minimum show a visual feedback that the tap was registered
|
||||||
|
|
||||||
|
**Actual Behavior:**
|
||||||
|
Silent dead click — `switchTab` throws `ReferenceError` which is caught by a bare handler.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
Replace `onclick="switchTab('FX')"` with `onclick="openOverlay('Downloads')"` (opens the model download interface, which is a logical destination) or remove the onclick entirely if the hero is meant to be display-only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Issue #3: Tonehub alias routes not pushed to Pi 🟠
|
### Issue #2: Most API calls lack explicit timeouts — risk on slow operations
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| **Severity** | 🟠 High |
|
|
||||||
| **Category** | Deployment |
|
|
||||||
| **URL** | `GET /api/tonehub/search` |
|
|
||||||
|
|
||||||
**Description:**
|
|
||||||
The alias routes `GET /api/tonehub/search` and `GET /api/tonehub/search/irs` work locally (return Tone3000 search results) but return 404 on the live Pi. These routes were added in an uncommitted change on the local dev machine and were never pushed to Gitea, so the Pi doesn't have them.
|
|
||||||
|
|
||||||
The direct routes (`/api/irs/tonedownload/search`, `/api/models/tonedownload/search`) work on the Pi. Only the shorthand aliases are missing.
|
|
||||||
|
|
||||||
**Affected routes:**
|
|
||||||
- `GET /api/tonehub/search` → 404 on Pi (alias for `/api/models/tonedownload/search`)
|
|
||||||
- `GET /api/tonehub/search/irs` → 404 on Pi (alias for `/api/irs/tonedownload/search`)
|
|
||||||
|
|
||||||
**Needs:** Commit tonehub alias routes + `_gather_state` fix, push to Gitea, pull on Pi, restart server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Issue #4: `/api/block-params/{fx_type}` returns 200 for unknown types 🟡
|
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Severity** | 🟡 Medium |
|
| **Severity** | 🟡 Medium |
|
||||||
| **Category** | Functional |
|
| **Category** | UX / Functional |
|
||||||
| **URL** | `GET /api/block-params/99` |
|
| **URL** | All overlays |
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
`GET /api/block-params/99` returns 200 with `{"fx_type":"99","params":[]}` instead of 404. The route accepts any string without validating against known FX types. Confirmed on live Pi (same behavior).
|
Of 50+ `API._fetch()` calls in the JavaScript, only **4 have explicit `timeout` options:**
|
||||||
|
- Audio profile change: 15,000ms ✓
|
||||||
|
- WiFi scan: 15,000ms ✓
|
||||||
|
- DHCP switch: 25,000ms ✓
|
||||||
|
- System backup: 35,000ms ✓
|
||||||
|
|
||||||
|
The remaining 46+ calls use the **default 6-second timeout** from the `API._fetch` wrapper. For quick data fetches (state, presets, routing, snapshots) this is fine because they return in <500ms. However, several long-running operations are at risk:
|
||||||
|
|
||||||
|
| Endpoint | Risk |
|
||||||
|
|----------|------|
|
||||||
|
| `POST /api/wifi/connect` | WiFi connection can take 10-30s — 6s timeout will abort |
|
||||||
|
| `POST /api/system/hostname` | Hostname change + restart takes 5-10s |
|
||||||
|
| `POST /api/bluetooth/discoverable` | Bluetooth operations can be slow |
|
||||||
|
| `POST /api/network/static` | Static IP switch requires network restart |
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
Add `timeout:25000` on the WiFi connect, network static, system hostname, and Bluetooth calls.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Issue #5: Unclosed aiohttp client session warnings 🔵
|
### Issue #3: Silent catch blocks swallow errors
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Severity** | 🟡 Medium |
|
||||||
|
| **Category** | UX |
|
||||||
|
| **URL** | Multiple locations |
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
17 catch blocks exist in the JavaScript. Some are empty or provide no user feedback:
|
||||||
|
|
||||||
|
**Examples of silent catches:**
|
||||||
|
```javascript
|
||||||
|
// Footswitch toggle — completely silent failure
|
||||||
|
document.getElementById('footswitch').onclick=()=>API.bypassToggle(currentChannel).catch(()=>{});
|
||||||
|
|
||||||
|
// Various data load functions — only update status dot on failure
|
||||||
|
}catch(e){document.getElementById('statusDot').className='status-dot off';}
|
||||||
|
```
|
||||||
|
|
||||||
|
A user who taps the bypass footswitch and gets no audio change has no way to know the API call failed. The status dot turning from green to grey is subtle and easily missed.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Footswitch: Add `toast('Bypass failed')` or at minimum console.warn
|
||||||
|
- Data load failures: Show a small error indicator or toast message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue #4: No error feedback on API failure for most overlay operations
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Severity** | 🔵 Low |
|
| **Severity** | 🔵 Low |
|
||||||
| **Category** | Console |
|
| **Category** | UX |
|
||||||
| **URL** | All Tone3000 routes |
|
| **URL** | Presets, Snapshots, Downloads overlays |
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
Unclosed aiohttp ClientSession on Tone3000 API calls — resource leak that accumulates warning noise. Same on both local and Pi.
|
When overlays load data (presets, snapshots, download search results), failures are silently caught. For example, `loadPresets()` and `loadSnapshots()` wrap their entire body in try/catch but only log to console or set a status dot. A user who taps "Presets" and sees an empty grid has no way to know if the API failed vs. no presets exist.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
Show a toast message on API failure: `toast('Failed to load presets')`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Issues Summary Table
|
## Issues Summary Table
|
||||||
|
|
||||||
| # | Title | Severity | Type | Status |
|
| # | Title | Severity | Category | URL |
|
||||||
|---|-------|----------|------|--------|
|
|---|-------|----------|----------|-----|
|
||||||
| 1 | Dashboard 500 when disconnected | 🔴 Critical | Functional | **Fixed locally** — needs push |
|
| 1 | `switchTab` undefined — NAM hero dead click | 🟠 High | Functional | http://pedal.local/ |
|
||||||
| 2 | React calls `/api/captures`, backend missing | 🟠 High | Functional | Needs new endpoint |
|
| 2 | Missing timeouts on most API calls | 🟡 Medium | UX / Functional | All overlays |
|
||||||
| 3 | Tonehub alias routes 404 on Pi | 🟠 High | Deployment | **Fixed locally** — needs push |
|
| 3 | Silent catch blocks swallow errors | 🟡 Medium | UX | Multiple |
|
||||||
| 4 | block-params 200 for unknown types | 🟡 Medium | Functional | Needs validation |
|
| 4 | No error feedback on API failures | 🔵 Low | UX | Presets/Snapshots/Downloads |
|
||||||
| 5 | Unclosed aiohttp session | 🔵 Low | Console | Needs cleanup |
|
|
||||||
|
|
||||||
## Testing Coverage
|
## Testing Coverage
|
||||||
|
|
||||||
### Pages (all 5 on Pi) ✅
|
### Pages Tested
|
||||||
| Page | Status | Notes |
|
- Main view (status bar, NAM hero, pedal row, volume slider, footswitch, tab bar)
|
||||||
|------|--------|-------|
|
- All overlays: Presets, Snapshots, Downloads (NAM + IR tabs), Settings (all tabs: Network, Audio, Interface, Bluetooth, System)
|
||||||
| `GET /` (Dashboard) | 200 ✅ | Live data: master_volume=0.33 |
|
|
||||||
| `GET /presets` | 200 ✅ | Returns bank 0 / Clean Jazz preset |
|
|
||||||
| `GET /models` | 200 ✅ | Empty (no models installed) |
|
|
||||||
| `GET /irs` | 200 ✅ | Empty (no IRs installed) |
|
|
||||||
| `GET /settings` | 200 ✅ | 15KB, full settings UI |
|
|
||||||
|
|
||||||
### API Endpoints on Pi
|
### API Endpoints Tested
|
||||||
| Endpoint | Result | Notes |
|
- State, presets, routing, snapshots, models, IRs, system info ✅
|
||||||
|----------|--------|-------|
|
- Audio profile get/set ✅ (tested with sample rate 44100/48000 and buffer 128/256)
|
||||||
| `GET /api/state` | 200 ✅ | `connected: true` |
|
- Bypass toggle, tuner, volume ✅
|
||||||
| `GET /api/presets` | 200 ✅ | Real presets |
|
- Tone3000 NAM search ✅ (20 results, filter params work)
|
||||||
| `GET /api/routing` | 200 ✅ | mono, breakpoint 7 |
|
- Tone3000 IR search ✅ (54 results)
|
||||||
| `POST /api/routing` | 200 ✅ | Returns updated routing |
|
- WiFi, Bluetooth, network, diagnostics ❌ (no hardware to test against)
|
||||||
| `GET /api/irs` | 200 ✅ | No IRs loaded |
|
- All JS-referenced endpoints verified to exist (200 or 405 status) ✅
|
||||||
| `GET /api/models` | 200 ✅ | No models loaded |
|
|
||||||
| `GET /api/captures` | 404 ❌ | Missing endpoint |
|
|
||||||
| `GET /api/irs/tonedownload/search` | 200 ✅ | 36 results |
|
|
||||||
| `GET /api/tonehub/search` | 404 ❌ | Not pushed to Pi |
|
|
||||||
| `GET /api/tonehub/search/irs` | 404 ❌ | Not pushed to Pi |
|
|
||||||
| `GET /api/block-params/0` | 200 ✅ | |
|
|
||||||
| `GET /api/block-params/99` | 200 ⚠️ | Should be 404 |
|
|
||||||
| `POST /api/bypass/toggle` | 404 ❌ | Check if route exists |
|
|
||||||
| `POST /api/tuner/toggle` | 404 ❌ | Check if route exists |
|
|
||||||
| `POST /api/models/tonedownload/install` | 422/500 ⚠️ | Works locally, needs download target |
|
|
||||||
|
|
||||||
### Edge Cases
|
### Not Tested / Out of Scope
|
||||||
| Test | Result |
|
- WiFi connect/disconnect (no WiFi hardware available)
|
||||||
|------|--------|
|
- Bluetooth MIDI pairing (no MIDI device available)
|
||||||
| `GET /nonexistent` | 404 ✅ |
|
- System reboot/restart (destructive to running service)
|
||||||
| `GET /api/nonexistent` | 404 ✅ |
|
- Backup/restore (requires file system write verification)
|
||||||
| `GET /api/presets/99` | 404 ✅ |
|
- Footswitch hardware input (simulated via button click)
|
||||||
|
- Preset/snapshot save and recall (requires mutable state)
|
||||||
|
|
||||||
|
### Visual Evidence (Cloakbrowser Screenshots)
|
||||||
|
|
||||||
|
Screenshots captured via cloakbrowser Chromium at `dogfood-output/screenshots/`:
|
||||||
|
|
||||||
|
| Screenshot | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `main-page.png` | Main view — NAM hero, pedal row, volume slider, bypass footswitch, tab bar |
|
||||||
|
| `downloads-overlay.png` | Downloads overlay — NAM/IR tabs, architecture filters, search bar |
|
||||||
|
| `search-results.png` | Tone3000 search results for "fender" — 9 results showing VSB + Fender Mustang III by umbertofonte |
|
||||||
|
| `settings-audio.png` | Settings Audio tab — sample rate (48kHz), buffer (256), instrument, channel mode, routing, backing track, audio devices, per-channel routing |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next Steps
|
## Notes
|
||||||
|
|
||||||
1. **Commit & push** the `_gather_state` fix and tonehub alias routes to Gitea
|
### Applied Fixes (from QA round)
|
||||||
2. **Pull & restart** on the Pi
|
|
||||||
3. **Build `/api/captures`** endpoint (list captures + upload)
|
|
||||||
4. **Add FX type validation** to `GET /api/block-params/{fx_type}`
|
|
||||||
5. Run retest after fixes are deployed
|
|
||||||
|
|
||||||
|
All fixes deployed to both local source and pedal.local:
|
||||||
|
|
||||||
|
**🟠 #1 — NAM hero dead click**
|
||||||
|
Replaced `onclick="switchTab('FX')"` with `onclick="openOverlay('Downloads')"`.
|
||||||
|
|
||||||
|
**🟡 #2 — Missing fetch timeouts**
|
||||||
|
Added `timeout:15000` to `btDiscoverableSet`, `btMidiEnable`, `btMidiDisable`, `setHostname`.
|
||||||
|
|
||||||
|
**🟡 #3 — Silent catch blocks → toast feedback**
|
||||||
|
Footswitch, volume slider, block toggle, tuner toggle, channel power, instrument change, input/output device switch now show `toast()` on failure.
|
||||||
|
|
||||||
|
**🔵 #4 — No error toast on overlay failures**
|
||||||
|
`loadPresets` and `loadSnapshots` now show toast on API failure.
|
||||||
|
|
||||||
|
### Verified Fix — Audio Timeout (regression test)
|
||||||
|
The primary reason for this testing session was to verify the sample rate / buffer size timeout fix. Both the UI (15s timeout) and backend (kill-JACK-first restart pattern) are working correctly:
|
||||||
|
- Rate change 48000→44100: **6.4s** (well under 15s limit)
|
||||||
|
- Rate restore 44100→48000: **0.8s**
|
||||||
|
- JACK running, NAM loaded, audio connected after both changes ✅
|
||||||
|
|
||||||
|
### HTML Source Quality
|
||||||
|
✅ Viewport meta correct for mobile (`width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`)
|
||||||
|
✅ DOCTYPE present
|
||||||
|
✅ lang="en" attribute
|
||||||
|
✅ All braces balanced (`{}=273:273`, `()=886:886`, `[]=45:45`)
|
||||||
|
✅ All API endpoints referenced in JS exist on backend
|
||||||
|
❌ One dead onclick handler (switchTab)
|
||||||
|
|
||||||
|
### JS Error Handling Quality
|
||||||
|
✅ All 21 async functions have try/catch blocks
|
||||||
|
✅ Audio profile change has proper timeout: 15s
|
||||||
|
✅ Major slow operations (WiFi scan, DHCP, backup) have generous timeouts
|
||||||
|
⚠️ 20% of catch blocks are silent (no user feedback)
|
||||||
|
⚠️ 90%+ of fetch calls lack explicit timeout (rely on default 6s)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -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>
|
||||||
|
|||||||
+213
-27
@@ -20,15 +20,31 @@ const T = {
|
|||||||
const API = window.location.origin;
|
const API = window.location.origin;
|
||||||
const WS_URL = API.replace(/^http/, 'ws') + '/ws';
|
const WS_URL = API.replace(/^http/, 'ws') + '/ws';
|
||||||
|
|
||||||
|
// ── Channel state (module-level, synced from localStorage) ─────
|
||||||
|
let _channel = 'guitar';
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('pedal_channel');
|
||||||
|
if (saved === 'guitar' || saved === 'bass') _channel = saved;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function getChannel() { return _channel; }
|
||||||
|
export function setChannel(ch) { _channel = ch; }
|
||||||
|
|
||||||
|
function _ch(url) {
|
||||||
|
const c = _channel;
|
||||||
|
if (!c) return url;
|
||||||
|
return url + (url.includes('?') ? '&' : '?') + 'channel=' + encodeURIComponent(c);
|
||||||
|
}
|
||||||
|
|
||||||
// ── API helpers ────────────────────────────────────────────────
|
// ── API helpers ────────────────────────────────────────────────
|
||||||
async function apiGet(path) {
|
async function apiGet(path) {
|
||||||
const r = await fetch(`${API}${path}`);
|
const r = await fetch(`${API}${_ch(path)}`);
|
||||||
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPost(path, data) {
|
async function apiPost(path, data) {
|
||||||
const r = await fetch(`${API}${path}`, {
|
const r = await fetch(`${API}${_ch(path)}`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: data != null ? JSON.stringify(data) : undefined,
|
body: data != null ? JSON.stringify(data) : undefined,
|
||||||
});
|
});
|
||||||
@@ -37,7 +53,7 @@ async function apiPost(path, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiPut(path, data) {
|
async function apiPut(path, data) {
|
||||||
const r = await fetch(`${API}${path}`, {
|
const r = await fetch(`${API}${_ch(path)}`, {
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@@ -46,7 +62,7 @@ async function apiPut(path, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiDelete(path) {
|
async function apiDelete(path) {
|
||||||
const r = await fetch(`${API}${path}`, { method: 'DELETE' });
|
const r = await fetch(`${API}${_ch(path)}`, { method: 'DELETE' });
|
||||||
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
@@ -54,7 +70,7 @@ async function apiDelete(path) {
|
|||||||
async function apiUpload(path, file) {
|
async function apiUpload(path, file) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
const r = await fetch(`${API}${path}`, { method: 'POST', body: fd });
|
const r = await fetch(`${API}${_ch(path)}`, { method: 'POST', body: fd });
|
||||||
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
@@ -86,6 +102,7 @@ function usePedalState() {
|
|||||||
if (msg.type === 'bypass_changed') setState(prev => prev ? { ...prev, bypass: msg.bypass } : prev);
|
if (msg.type === 'bypass_changed') setState(prev => prev ? { ...prev, bypass: msg.bypass } : prev);
|
||||||
if (msg.type === 'tuner_changed') setState(prev => prev ? { ...prev, tuner_enabled: msg.tuner_enabled } : prev);
|
if (msg.type === 'tuner_changed') setState(prev => prev ? { ...prev, tuner_enabled: msg.tuner_enabled } : prev);
|
||||||
if (msg.type === 'routing_changed') setState(prev => prev ? { ...prev, routing_mode: msg.routing_mode, routing_breakpoint: msg.routing_breakpoint } : prev);
|
if (msg.type === 'routing_changed') setState(prev => prev ? { ...prev, routing_mode: msg.routing_mode, routing_breakpoint: msg.routing_breakpoint } : prev);
|
||||||
|
if (msg.type === 'channel_mode_changed') setState(prev => prev ? { ...prev, channel_mode: msg.channel_mode } : prev);
|
||||||
if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') {
|
if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') {
|
||||||
apiGet('/api/state').then(s => setState(s));
|
apiGet('/api/state').then(s => setState(s));
|
||||||
}
|
}
|
||||||
@@ -480,6 +497,49 @@ function FXBlock({ title, type, active = true, bypassed = false, params = [], on
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Signal Arrow Connector ──────────────────────────────────
|
||||||
|
function SignalArrow({ active = true, variant = 'right', animated = true }) {
|
||||||
|
const color = active ? T.green : T.textDim;
|
||||||
|
|
||||||
|
if (variant === 'right') {
|
||||||
|
return (
|
||||||
|
<svg width="22" height="14" viewBox="0 0 22 14" style={{ flexShrink: 0, display: 'block' }}>
|
||||||
|
<line x1="2" y1="7" x2="14" y2="7" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<polygon points="12,3 20,7 12,11" fill={color} />
|
||||||
|
{active && animated && (
|
||||||
|
<circle r="1.5" fill={color} opacity="0.6">
|
||||||
|
<animate attributeName="cx" values="4;18" dur="1.2s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'split') {
|
||||||
|
return (
|
||||||
|
<svg width="26" height="24" viewBox="0 0 26 24" style={{ flexShrink: 0, display: 'block' }}>
|
||||||
|
<line x1="2" y1="10" x2="14" y2="10" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="8" y1="10" x2="18" y2="18" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<polygon points="12,6 18,10 12,14" fill={color} />
|
||||||
|
<polygon points="16,14 21,20 19,13" fill={color} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'merge') {
|
||||||
|
return (
|
||||||
|
<svg width="26" height="24" viewBox="0 0 26 24" style={{ flexShrink: 0, display: 'block' }}>
|
||||||
|
<line x1="2" y1="4" x2="12" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="2" y1="20" x2="12" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="12" y1="12" x2="14" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<polygon points="12,8 18,12 12,16" fill={color} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Slide Panel ──────────────────────────────────────────────
|
// ── Slide Panel ──────────────────────────────────────────────
|
||||||
function SlidePanel({ children, onClose }) {
|
function SlidePanel({ children, onClose }) {
|
||||||
return (
|
return (
|
||||||
@@ -617,15 +677,38 @@ function RigScreen({ state, connected, refresh }) {
|
|||||||
<div className="card-sm">
|
<div className="card-sm">
|
||||||
<div className="section-label">Signal Chain</div>
|
<div className="section-label">Signal Chain</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 4, overflowX: "auto", padding: "4px 0" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 4, overflowX: "auto", padding: "4px 0" }}>
|
||||||
{["IN", state?.bypass ? "BYP" : (state?.nam_loaded ? "NAM" : "AMP"), state?.ir_loaded ? "IR" : "CAB", "OUT"].map((s, i, arr) => (
|
{(() => {
|
||||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
const chain = ["IN", state?.bypass ? "BYP" : (state?.nam_loaded ? "NAM" : "AMP"), state?.ir_loaded ? "IR" : "CAB", "OUT"];
|
||||||
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
|
const is4CM = state?.routing_mode === '4cm';
|
||||||
background: i === 0 || i === arr.length - 1 ? T.blueDim : T.amberDim,
|
const bp = state?.routing_breakpoint || 7;
|
||||||
color: i === 0 || i === arr.length - 1 ? T.blue : T.amber,
|
const active = !state?.bypass;
|
||||||
border: `1px solid ${(i === 0 || i === arr.length - 1 ? T.blue : T.amber)}60` }}>{s}</div>
|
return chain.map((s, i, arr) => (
|
||||||
{i < arr.length - 1 && <div style={{ width: 16, height: 2, background: T.border, borderRadius: 1 }} />}
|
<div key={i} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||||
</div>
|
{is4CM && s === "AMP" && (
|
||||||
))}
|
<SignalArrow variant="split" active={active} />
|
||||||
|
)}
|
||||||
|
{is4CM && s === "CAB" && (
|
||||||
|
<SignalArrow variant="merge" active={active} />
|
||||||
|
)}
|
||||||
|
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
|
||||||
|
background: i === 0 || i === arr.length - 1 ? T.blueDim : (is4CM && s === "AMP" ? "rgba(58,184,122,.15)" : T.amberDim),
|
||||||
|
color: i === 0 || i === arr.length - 1 ? T.blue : (is4CM && s === "AMP" ? T.green : T.amber),
|
||||||
|
border: `1px solid ${(i === 0 || i === arr.length - 1 ? T.blue : (is4CM && s === "AMP" ? T.green : T.amber))}60` }}>
|
||||||
|
{s}
|
||||||
|
{is4CM && s === "AMP" && <span style={{ fontSize: 8, marginLeft: 4, opacity: 0.7 }}>4CM</span>}
|
||||||
|
{is4CM && s === "BYP" && <span style={{ fontSize: 8, marginLeft: 4, opacity: 0.7 }}>4CM</span>}
|
||||||
|
</div>
|
||||||
|
{is4CM && s === "AMP" && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0, gap: 1 }}>
|
||||||
|
<SignalArrow variant="split" active={active} />
|
||||||
|
<span style={{ fontSize: 7, color: T.textDim, letterSpacing: '.05em', lineHeight: '1' }}>LOOP</span>
|
||||||
|
<SignalArrow variant="merge" active={active} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{i < arr.length - 1 && !(is4CM && s === "AMP") && <SignalArrow active={active} />}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -765,15 +848,56 @@ function FXScreen({ state, connected }) {
|
|||||||
{/* Signal path */}
|
{/* Signal path */}
|
||||||
{fxList && fxList.length > 0 && (
|
{fxList && fxList.length > 0 && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 2px", overflowX: "auto" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 2px", overflowX: "auto" }}>
|
||||||
{["IN", ...fxList.map(f => f.type.toUpperCase()), "OUT"].map((s, i, arr) => (
|
{(() => {
|
||||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
const is4CM = state?.routing_mode === '4cm';
|
||||||
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
|
const bp = is4CM ? (state?.routing_breakpoint || 7) : -1;
|
||||||
background: s === "IN" || s === "OUT" ? T.blueDim : T.amberDim,
|
const items = [];
|
||||||
color: s === "IN" || s === "OUT" ? T.blue : T.amber,
|
// Build chain items array with connector metadata
|
||||||
border: `1px solid ${(s === "IN" || s === "OUT" ? T.blue : T.amber)}60` }}>{s}</div>
|
items.push({ label: "IN", type: "io", idx: -1 });
|
||||||
{i < arr.length - 1 && <div style={{ width: 12, height: 2, background: T.border, borderRadius: 1 }} />}
|
fxList.forEach((f, idx) => {
|
||||||
</div>
|
if (is4CM && idx === bp) {
|
||||||
))}
|
items.push({ label: null, type: "merge-bp", idx });
|
||||||
|
}
|
||||||
|
items.push({ label: f.type.toUpperCase(), type: "fx", idx, bypassed: f.bypassed });
|
||||||
|
if (is4CM && idx === bp - 1) {
|
||||||
|
items.push({ label: null, type: "split-bp", idx });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
items.push({ label: "OUT", type: "io", idx: -1 });
|
||||||
|
|
||||||
|
return items.map((item, i) => {
|
||||||
|
if (item.type === 'split-bp') {
|
||||||
|
return (
|
||||||
|
<div key={`bp-${i}`} style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0, gap: 1 }}>
|
||||||
|
<SignalArrow variant="split" active={true} />
|
||||||
|
<span style={{ fontSize: 7, color: T.textDim, letterSpacing: '.05em', lineHeight: '1' }}>4CM</span>
|
||||||
|
<SignalArrow variant="merge" active={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.type === 'merge-bp') {
|
||||||
|
// Already rendered as part of the split-bp composite — skip
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={`${item.type}-${i}`} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||||
|
{item.type === 'io' && (
|
||||||
|
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
|
||||||
|
background: T.blueDim, color: T.blue, border: `1px solid ${T.blue}60` }}>{item.label}</div>
|
||||||
|
)}
|
||||||
|
{item.type === 'fx' && (
|
||||||
|
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
|
||||||
|
background: T.amberDim, color: item.bypassed ? T.textDim : T.amber,
|
||||||
|
border: `1px solid ${(item.bypassed ? T.textDim : T.amber)}60`, opacity: item.bypassed ? 0.5 : 1 }}>{item.label}</div>
|
||||||
|
)}
|
||||||
|
{/* Connector after this block */}
|
||||||
|
{i < items.length - 1 && items[i+1]?.type !== 'split-bp' && items[i]?.type !== 'merge-bp' && (
|
||||||
|
<SignalArrow active={item.type === 'io' || !item.bypassed} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!fxList || fxList.length === 0) ? (
|
{(!fxList || fxList.length === 0) ? (
|
||||||
@@ -1285,11 +1409,13 @@ function SettingsScreen({ state, connected, refresh }) {
|
|||||||
const [showConnect, setShowConnect] = useState(null);
|
const [showConnect, setShowConnect] = useState(null);
|
||||||
const [routingMode, setRoutingMode] = useState(state?.routing_mode || 'mono');
|
const [routingMode, setRoutingMode] = useState(state?.routing_mode || 'mono');
|
||||||
const [routingBp, setRoutingBp] = useState(state?.routing_breakpoint || 7);
|
const [routingBp, setRoutingBp] = useState(state?.routing_breakpoint || 7);
|
||||||
|
const [channelMode, setChannelMode] = useState(state?.channel_mode || 'dual-mono');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state?.routing_mode) setRoutingMode(state.routing_mode);
|
if (state?.routing_mode) setRoutingMode(state.routing_mode);
|
||||||
if (state?.routing_breakpoint != null) setRoutingBp(state.routing_breakpoint);
|
if (state?.routing_breakpoint != null) setRoutingBp(state.routing_breakpoint);
|
||||||
}, [state?.routing_mode, state?.routing_breakpoint]);
|
if (state?.channel_mode) setChannelMode(state.channel_mode);
|
||||||
|
}, [state?.routing_mode, state?.routing_breakpoint, state?.channel_mode]);
|
||||||
|
|
||||||
const scanWifi = async () => {
|
const scanWifi = async () => {
|
||||||
setWifiScanning(true);
|
setWifiScanning(true);
|
||||||
@@ -1318,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 */ }
|
||||||
};
|
};
|
||||||
@@ -1351,6 +1477,13 @@ function SettingsScreen({ state, connected, refresh }) {
|
|||||||
try { await apiPost('/api/routing', { routing_mode: mode, routing_breakpoint: bp }); } catch (e) { /* ignore */ }
|
try { await apiPost('/api/routing', { routing_mode: mode, routing_breakpoint: bp }); } catch (e) { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChannelMode = async (mode) => {
|
||||||
|
try {
|
||||||
|
await apiPost('/api/channel-mode', { channel_mode: mode });
|
||||||
|
setChannelMode(mode);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => { if (connected) { scanWifi(); loadBtPaired(); } }, [connected]);
|
useEffect(() => { if (connected) { scanWifi(); loadBtPaired(); } }, [connected]);
|
||||||
|
|
||||||
const wifiState = state?.wifi;
|
const wifiState = state?.wifi;
|
||||||
@@ -1521,6 +1654,7 @@ function SettingsScreen({ state, connected, refresh }) {
|
|||||||
|
|
||||||
{/* ── Routing Tab ───────────────────────────────────────────*/}
|
{/* ── Routing Tab ───────────────────────────────────────────*/}
|
||||||
{tab === 'routing' && (
|
{tab === 'routing' && (
|
||||||
|
<>
|
||||||
<div className="card-sm">
|
<div className="card-sm">
|
||||||
<div className="section-label">4CM Routing</div>
|
<div className="section-label">4CM Routing</div>
|
||||||
<div className="setting-row">
|
<div className="setting-row">
|
||||||
@@ -1546,6 +1680,27 @@ function SettingsScreen({ state, connected, refresh }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Channel Mode */}
|
||||||
|
<div className="card-sm">
|
||||||
|
<div className="section-label">Channel Mode</div>
|
||||||
|
<div className="setting-row">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">Routing</div>
|
||||||
|
<div className="setting-desc">Dual-mono: two independent channels · Stereo: linked as L/R pair</div>
|
||||||
|
</div>
|
||||||
|
<select value={channelMode} onChange={e => handleChannelMode(e.target.value)}
|
||||||
|
style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "6px 10px", color: T.textPrimary, fontSize: 13, outline: "none" }}>
|
||||||
|
<option value="dual-mono">Dual-Mono</option>
|
||||||
|
<option value="stereo">Stereo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: T.textDim, marginTop: 4, padding: '4px 0' }}>
|
||||||
|
{channelMode === 'dual-mono'
|
||||||
|
? 'Each channel controlled independently from its own phone.'
|
||||||
|
: 'Channels A (Left) and B (Right) are controlled together.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1857,10 +2012,23 @@ const TABS = [
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [tab, setTab] = useState("rig");
|
const [tab, setTab] = useState("rig");
|
||||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||||
|
const [channel, setChannelState] = useState(() => {
|
||||||
|
try { return localStorage.getItem('pedal_channel') || 'guitar'; } catch { return 'guitar'; }
|
||||||
|
});
|
||||||
const { state, connected, refresh } = usePedalState();
|
const { state, connected, refresh } = usePedalState();
|
||||||
|
|
||||||
useEffect(() => { document.title = "Pi Multi-FX Pedal"; }, []);
|
useEffect(() => { document.title = "Pi Multi-FX Pedal"; }, []);
|
||||||
|
|
||||||
|
// Sync channel to localStorage + module-level API var
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem('pedal_channel', channel); } catch (e) {}
|
||||||
|
setChannel(channel);
|
||||||
|
}, [channel]);
|
||||||
|
|
||||||
|
const handleChannel = (ch) => {
|
||||||
|
setChannelState(ch);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{css}</style>
|
<style>{css}</style>
|
||||||
@@ -1870,12 +2038,30 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||||
padding: "6px 12px 4px", background: T.panel, borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
|
padding: "4px 10px", background: T.panel, borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
<div style={{ width: 6, height: 6, borderRadius: "50%",
|
<div style={{ width: 6, height: 6, borderRadius: "50%",
|
||||||
background: connected ? T.green : T.red,
|
background: connected ? T.green : T.red,
|
||||||
boxShadow: connected ? `0 0 6px ${T.green}` : "none" }} />
|
boxShadow: connected ? `0 0 6px ${T.green}` : "none" }} />
|
||||||
<span className="mono" style={{ fontSize: 9, color: T.textSec }}>PI MULTI-FX</span>
|
{/* Channel toggle — hidden in stereo mode */}
|
||||||
|
{state?.channel_mode === 'stereo' ? (
|
||||||
|
<span className="badge badge-amber" style={{ fontSize: 9, padding: "2px 7px", letterSpacing: ".08em" }}>
|
||||||
|
STEREO
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="toggle" style={{ margin: 0, height: 22 }}>
|
||||||
|
<button className={`toggle-btn ${channel === 'guitar' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleChannel('guitar')}
|
||||||
|
style={{ padding: '2px 8px', fontSize: 10, fontWeight: 700, letterSpacing: '.1em' }}>
|
||||||
|
GUITAR
|
||||||
|
</button>
|
||||||
|
<button className={`toggle-btn ${channel === 'bass' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleChannel('bass')}
|
||||||
|
style={{ padding: '2px 8px', fontSize: 10, fontWeight: 700, letterSpacing: '.1em' }}>
|
||||||
|
BASS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{state?.tuner_enabled && <span className="badge badge-amber" style={{ fontSize: 8, padding: "1px 5px" }}>TUNER</span>}
|
{state?.tuner_enabled && <span className="badge badge-amber" style={{ fontSize: 8, padding: "1px 5px" }}>TUNER</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ import yaml
|
|||||||
from src.system.audio import AudioConfig, AudioSystem, JackAudioClient, _jack_is_running
|
from src.system.audio import AudioConfig, AudioSystem, JackAudioClient, _jack_is_running
|
||||||
from src.system.defaults import ensure_defaults_exist
|
from src.system.defaults import ensure_defaults_exist
|
||||||
from src.dsp.pipeline import AudioPipeline
|
from src.dsp.pipeline import AudioPipeline
|
||||||
from src.dsp.nam_host import NAMHost
|
from src.dsp.nam_router import NAMEngineRouter
|
||||||
from src.dsp.ir_loader import IRLoader
|
from src.dsp.ir_loader import IRLoader
|
||||||
from src.presets.manager import PresetManager
|
from src.presets.manager import PresetManager
|
||||||
from src.presets.types import MIDIMapping, Preset
|
from src.presets.types import Channel, MIDIMapping, Preset
|
||||||
from src.midi.handler import MIDIHandler
|
from src.midi.handler import MIDIHandler
|
||||||
from src.ui.footswitch import FootswitchController, FootSwitch, SwitchAction
|
from src.ui.footswitch import FootswitchController, FootSwitch, SwitchAction
|
||||||
from src.ui.leds import LEDController, LEDDriver, LEDConfig, LEDPattern
|
from src.ui.leds import LEDController, LEDDriver, LEDConfig, LEDPattern
|
||||||
@@ -48,7 +48,58 @@ logger.setLevel(logging.INFO)
|
|||||||
|
|
||||||
# ── Paths ─────────────────────────────────────────────────────────────────────
|
# ── Paths ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
from src.system.config import DEFAULT_CONFIG_PATH, load_config, DEFAULT_CONFIG
|
from src.system.config import DEFAULT_CONFIG_PATH, load_config, save_config, DEFAULT_CONFIG
|
||||||
|
|
||||||
|
# ── Security helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_AUTH_PIN_SEEN = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_singleton() -> None:
|
||||||
|
"""Kill any other main.py processes (from stale service restarts)."""
|
||||||
|
import subprocess, os
|
||||||
|
my_pid = os.getpid()
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["pgrep", "-f", "python3.*main.py"],
|
||||||
|
capture_output=True, text=True, timeout=3,
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
for pid_str in r.stdout.strip().split():
|
||||||
|
pid = int(pid_str)
|
||||||
|
if pid != my_pid:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 9)
|
||||||
|
logger.info("Killed stale pedal process PID %d", pid)
|
||||||
|
except (OSError, ProcessLookupError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_auth_pin(config: dict, config_path: Path) -> str:
|
||||||
|
"""Generate a random 6-digit auth PIN on first boot and persist it to config.
|
||||||
|
|
||||||
|
Returns the PIN (existing or newly generated). The PIN is logged at INFO
|
||||||
|
level on first generation so the user can see it. Subsequent boots log
|
||||||
|
only a brief confirmation.
|
||||||
|
"""
|
||||||
|
global _AUTH_PIN_SEEN
|
||||||
|
web_cfg = config.setdefault("web", {})
|
||||||
|
pin = web_cfg.get("auth_pin")
|
||||||
|
if not pin:
|
||||||
|
import secrets
|
||||||
|
pin = str(secrets.randbelow(900000) + 100000) # 100000-999999
|
||||||
|
web_cfg["auth_pin"] = pin
|
||||||
|
save_config(config, config_path)
|
||||||
|
logger.info("═" * 50)
|
||||||
|
logger.info("🔐 NEW AUTH PIN GENERATED: %s", pin)
|
||||||
|
logger.info("═" * 50)
|
||||||
|
_AUTH_PIN_SEEN = True
|
||||||
|
elif not _AUTH_PIN_SEEN:
|
||||||
|
logger.info("Auth PIN is configured (not shown — see ~/.pedal/config.yaml)")
|
||||||
|
_AUTH_PIN_SEEN = True
|
||||||
|
return pin
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -69,6 +120,7 @@ class PedalApp:
|
|||||||
|
|
||||||
def __init__(self, config_path: Path = DEFAULT_CONFIG_PATH) -> None:
|
def __init__(self, config_path: Path = DEFAULT_CONFIG_PATH) -> None:
|
||||||
self._config = load_config(config_path)
|
self._config = load_config(config_path)
|
||||||
|
self._config_path = config_path
|
||||||
|
|
||||||
# ── Runtime state ──────────────────────────────────────────────
|
# ── Runtime state ──────────────────────────────────────────────
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -79,7 +131,7 @@ class PedalApp:
|
|||||||
self.audio_config: AudioConfig | None = None
|
self.audio_config: AudioConfig | None = None
|
||||||
self.audio_system: AudioSystem | None = None
|
self.audio_system: AudioSystem | None = None
|
||||||
self.jack_audio: JackAudioClient | None = None
|
self.jack_audio: JackAudioClient | None = None
|
||||||
self.nam_host: NAMHost | None = None
|
self.nam_host: NAMEngineRouter | None = None
|
||||||
self.ir_loader: IRLoader | None = None
|
self.ir_loader: IRLoader | None = None
|
||||||
self.pipeline: AudioPipeline | None = None
|
self.pipeline: AudioPipeline | None = None
|
||||||
self.presets: PresetManager | None = None
|
self.presets: PresetManager | None = None
|
||||||
@@ -124,18 +176,42 @@ class PedalApp:
|
|||||||
output_device=acfg.get("output_device", "hw:0,0"),
|
output_device=acfg.get("output_device", "hw:0,0"),
|
||||||
jack_enabled=acfg.get("jack_enabled", True),
|
jack_enabled=acfg.get("jack_enabled", True),
|
||||||
auto_connect=acfg.get("auto_connect", True),
|
auto_connect=acfg.get("auto_connect", True),
|
||||||
|
period=acfg.get("period"),
|
||||||
|
rate=acfg.get("rate"),
|
||||||
)
|
)
|
||||||
self.audio_system = AudioSystem(self.audio_config)
|
self.audio_system = AudioSystem(self.audio_config)
|
||||||
self.audio_system.setup_i2s(reboot_hint=True)
|
self.audio_system.setup_i2s(reboot_hint=True)
|
||||||
|
|
||||||
|
# Clean stale JACK SHM segments from prior crashes before starting
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
for p in Path("/dev/shm").glob("jack*"):
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Enable kernel-level xrun tracking for diagnostics
|
||||||
|
try:
|
||||||
|
self.audio_system.enable_xrun_tracking()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if self.audio_config.jack_enabled:
|
if self.audio_config.jack_enabled:
|
||||||
self.audio_system.start_jack(timeout=10)
|
self.audio_system.start_jack(timeout=10)
|
||||||
else:
|
else:
|
||||||
logger.info("JACK disabled in config — skipping audio server start")
|
logger.info("JACK disabled in config — skipping audio server start")
|
||||||
|
|
||||||
# ── 2. DSP pipeline (NAM + IR + FX chain) ────────────
|
# ── 2. DSP pipeline (NAM + IR + FX chain) ────────────
|
||||||
self.nam_host = NAMHost()
|
block_size = self.audio_config.latency_profile["period"]
|
||||||
|
sample_rate = self.audio_config.latency_profile.get("rate", 48000)
|
||||||
|
self.nam_host = NAMEngineRouter(block_size=block_size, sample_rate=sample_rate)
|
||||||
self.ir_loader = IRLoader()
|
self.ir_loader = IRLoader()
|
||||||
self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader)
|
self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader)
|
||||||
|
self.nam_host.warm_up()
|
||||||
|
|
||||||
# Set pipeline routing from config mode
|
# Set pipeline routing from config mode
|
||||||
if audio_mode == "stereo_4cm":
|
if audio_mode == "stereo_4cm":
|
||||||
@@ -163,18 +239,42 @@ class PedalApp:
|
|||||||
audio_mode, channels,
|
audio_mode, channels,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── 3. Preset manager ─────────────────────────────────
|
# ── 3. Preset manager (shared across all channels) ──────────
|
||||||
pcfg = self._config["presets"]
|
pcfg = self._config["presets"]
|
||||||
self.presets = PresetManager(
|
self.presets = PresetManager(
|
||||||
preset_dir=pcfg.get("dir", "~/.pedal/presets"),
|
preset_dir=pcfg.get("dir", "~/.pedal/presets"),
|
||||||
audio_pipeline=self.pipeline,
|
audio_pipeline=self.pipeline,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Install factory presets (non-destructive by default)
|
# ── 3b. Multi-channel DSP pipelines ──────────────────────
|
||||||
if pcfg.get("install_factory", True):
|
# Each extra channel (bass, keys, vocals, backing_tracks)
|
||||||
installed = self.presets.install_factory_presets(overwrite=False)
|
# gets its own AudioPipeline, NAMEngineRouter, and IRLoader
|
||||||
if installed:
|
# for independent FX chains. Gated behind a config flag.
|
||||||
logger.info("Installed %d factory preset(s)", installed)
|
multi_ch_enabled = self._config.get("multi_channel", {}).get("enabled", False)
|
||||||
|
self.bass_pipeline: AudioPipeline | None = None
|
||||||
|
self.bass_nam_host: NAMEngineRouter | None = None
|
||||||
|
self.bass_ir_loader: IRLoader | None = None
|
||||||
|
if multi_ch_enabled:
|
||||||
|
self.bass_nam_host = NAMEngineRouter(block_size=block_size, sample_rate=sample_rate)
|
||||||
|
self.bass_ir_loader = IRLoader()
|
||||||
|
self.bass_pipeline = AudioPipeline(
|
||||||
|
nam_host=self.bass_nam_host,
|
||||||
|
ir_loader=self.bass_ir_loader,
|
||||||
|
)
|
||||||
|
self.bass_nam_host.warm_up()
|
||||||
|
logger.info("Bass DSP pipeline initialised (multi-channel enabled)")
|
||||||
|
else:
|
||||||
|
logger.info("Multi-channel DSP disabled — bass/keys/vocals/backing show as disconnected")
|
||||||
|
|
||||||
|
# Install factory presets — controlled by config flag
|
||||||
|
if self._config.get("presets", {}).get("install_factory", False):
|
||||||
|
for ch in Channel:
|
||||||
|
installed = self.presets.install_factory_presets(
|
||||||
|
overwrite=False, channel=ch,
|
||||||
|
)
|
||||||
|
if installed:
|
||||||
|
logger.info("Installed %d factory preset(s) for %s",
|
||||||
|
installed, ch.value)
|
||||||
ir_installed = self.presets.install_factory_irs(overwrite=False)
|
ir_installed = self.presets.install_factory_irs(overwrite=False)
|
||||||
if ir_installed:
|
if ir_installed:
|
||||||
logger.info("Installed %d factory cab IR(s)", ir_installed)
|
logger.info("Installed %d factory cab IR(s)", ir_installed)
|
||||||
@@ -188,6 +288,12 @@ class PedalApp:
|
|||||||
self.presets.select(0, 0)
|
self.presets.select(0, 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.info("No presets exist yet — pedal ready for MIDI/footswitch input")
|
logger.info("No presets exist yet — pedal ready for MIDI/footswitch input")
|
||||||
|
else:
|
||||||
|
# restore_state() loads from disk but doesn't activate the pipeline
|
||||||
|
# Need to call select() to trigger pipeline.load_preset() and load NAM
|
||||||
|
logger.info("State restored — activating preset '%s' (bank=%d, program=%d)",
|
||||||
|
restored.name, restored.bank, restored.program)
|
||||||
|
self.presets.select(restored.bank, restored.program)
|
||||||
|
|
||||||
# ── 4. MIDI handler ──────────────────────────────────
|
# ── 4. MIDI handler ──────────────────────────────────
|
||||||
self.midi = MIDIHandler()
|
self.midi = MIDIHandler()
|
||||||
@@ -228,8 +334,11 @@ class PedalApp:
|
|||||||
# Boot LED animation — quick scan
|
# Boot LED animation — quick scan
|
||||||
self.leds.preset_animate(direction="up")
|
self.leds.preset_animate(direction="up")
|
||||||
|
|
||||||
|
# ── 7b. Auth PIN (first boot generates, persists to config) ──
|
||||||
|
_ensure_auth_pin(self._config, self._config_path)
|
||||||
|
|
||||||
# ── 8. Web UI server (non-blocking HTTP + WebSocket) ────
|
# ── 8. Web UI server (non-blocking HTTP + WebSocket) ────
|
||||||
# Runs on port 8080 by default, alongside the JACK audio loop.
|
# Runs on port 80 by default, alongside the JACK audio loop.
|
||||||
try:
|
try:
|
||||||
self.web = WebServer(
|
self.web = WebServer(
|
||||||
deps=WebServerDeps(
|
deps=WebServerDeps(
|
||||||
@@ -237,12 +346,19 @@ class PedalApp:
|
|||||||
pipeline=self.pipeline,
|
pipeline=self.pipeline,
|
||||||
nam_host=self.nam_host,
|
nam_host=self.nam_host,
|
||||||
ir_loader=self.ir_loader,
|
ir_loader=self.ir_loader,
|
||||||
|
bass_pipeline=self.bass_pipeline,
|
||||||
|
bass_nam_host=self.bass_nam_host,
|
||||||
|
bass_ir_loader=self.bass_ir_loader,
|
||||||
|
audio_system=self.audio_system,
|
||||||
|
jack_audio=self.jack_audio,
|
||||||
|
config=self._config,
|
||||||
|
config_path=self._config_path,
|
||||||
),
|
),
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=8080,
|
port=80,
|
||||||
)
|
)
|
||||||
self.web.start()
|
self.web.start()
|
||||||
logger.info("Web UI server started on http://0.0.0.0:8080")
|
logger.info("Web UI server started on http://0.0.0.0:80")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Web UI server failed to start (non-fatal): %s", e)
|
logger.warning("Web UI server failed to start (non-fatal): %s", e)
|
||||||
|
|
||||||
@@ -305,7 +421,7 @@ class PedalApp:
|
|||||||
if cc_number == 11:
|
if cc_number == 11:
|
||||||
if self.pipeline:
|
if self.pipeline:
|
||||||
normalized = value / 127.0
|
normalized = value / 127.0
|
||||||
self.pipeline._master_volume = normalized
|
self.pipeline.master_volume = normalized
|
||||||
logger.debug("MIDI CC 11 → master volume: %.2f", normalized)
|
logger.debug("MIDI CC 11 → master volume: %.2f", normalized)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -322,10 +438,36 @@ class PedalApp:
|
|||||||
|
|
||||||
# Iterate preset mappings and find which one matches this CC number
|
# Iterate preset mappings and find which one matches this CC number
|
||||||
for param_key, mapping in preset.midi_mappings.items():
|
for param_key, mapping in preset.midi_mappings.items():
|
||||||
if mapping.cc_number == cc_number:
|
if mapping.cc_number != cc_number:
|
||||||
normalized = value / 127.0
|
continue
|
||||||
logger.debug("MIDI CC %d → %s = %.2f", cc_number, param_key, normalized)
|
|
||||||
break
|
# Map the 0-127 CC value through the mapping's range
|
||||||
|
normalized = mapping.min_val + (value / 127.0) * (mapping.max_val - mapping.min_val)
|
||||||
|
logger.debug("MIDI CC %d → %s = %.2f (range %.1f-%.1f)",
|
||||||
|
cc_number, param_key, normalized,
|
||||||
|
mapping.min_val, mapping.max_val)
|
||||||
|
|
||||||
|
# Resolve the param_key to a block + param name and apply it
|
||||||
|
from src.presets.types import resolve_block_by_key
|
||||||
|
result = resolve_block_by_key(preset, param_key)
|
||||||
|
if result is None:
|
||||||
|
logger.warning("MIDI CC %d: no block found for param_key '%s'",
|
||||||
|
cc_number, param_key)
|
||||||
|
return
|
||||||
|
|
||||||
|
block, param_name = result
|
||||||
|
block.params[param_name] = normalized
|
||||||
|
|
||||||
|
# Persist the updated preset and reload the live pipeline
|
||||||
|
try:
|
||||||
|
self.presets.save(preset)
|
||||||
|
self.pipeline.load_preset(preset)
|
||||||
|
logger.debug("MIDI CC %d applied: %s = %.2f",
|
||||||
|
cc_number, param_key, normalized)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to persist MIDI mapping '%s': %s",
|
||||||
|
param_key, e)
|
||||||
|
return
|
||||||
|
|
||||||
def _on_midi_learn(self, mapping: object) -> None:
|
def _on_midi_learn(self, mapping: object) -> None:
|
||||||
"""Handle MIDI Learn completion — update display."""
|
"""Handle MIDI Learn completion — update display."""
|
||||||
@@ -397,7 +539,7 @@ class PedalApp:
|
|||||||
def _on_bypass_toggle(self) -> None:
|
def _on_bypass_toggle(self) -> None:
|
||||||
self._bypassed = not self._bypassed
|
self._bypassed = not self._bypassed
|
||||||
if self.pipeline:
|
if self.pipeline:
|
||||||
self.pipeline._bypassed = self._bypassed
|
self.pipeline.bypassed = self._bypassed
|
||||||
logger.info("Bypass %s", "ON" if self._bypassed else "OFF")
|
logger.info("Bypass %s", "ON" if self._bypassed else "OFF")
|
||||||
if self.leds:
|
if self.leds:
|
||||||
self.leds.set_bypass_led(2, self._bypassed)
|
self.leds.set_bypass_led(2, self._bypassed)
|
||||||
@@ -494,6 +636,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:
|
||||||
@@ -503,6 +646,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)
|
||||||
|
|
||||||
@@ -510,6 +654,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"])
|
||||||
@@ -667,8 +819,90 @@ def main() -> int:
|
|||||||
|
|
||||||
Returns exit code 0 on clean shutdown, 1 on boot failure.
|
Returns exit code 0 on clean shutdown, 1 on boot failure.
|
||||||
"""
|
"""
|
||||||
|
# Kill any stale pedal processes before doing anything else
|
||||||
|
_ensure_singleton()
|
||||||
|
|
||||||
|
# Lock process memory early to prevent page faults in RT callback
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
libc = ctypes.CDLL('libc.so.6')
|
||||||
|
# MCL_CURRENT | MCL_FUTURE = 1 | 2 = 3
|
||||||
|
if libc.mlockall(3) == 0:
|
||||||
|
logger.info("mlockall() OK — process memory locked")
|
||||||
|
else:
|
||||||
|
logger.warning("mlockall() failed — check LimitMEMLOCK in systemd unit")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("mlockall() not available: %s", exc)
|
||||||
|
|
||||||
|
# Set CPU governor to performance for stable RT audio
|
||||||
|
try:
|
||||||
|
for c in range(os.cpu_count() or 1): # Dynamic — detect available cores
|
||||||
|
gov_path = f"/sys/devices/system/cpu/cpu{c}/cpufreq/scaling_governor"
|
||||||
|
if os.path.exists(gov_path):
|
||||||
|
with open(gov_path, "w") as f:
|
||||||
|
f.write("performance")
|
||||||
|
logger.info("CPU%d governor set to performance", c)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not set CPU governor (non-root?): %s", exc)
|
||||||
|
|
||||||
|
# Pin USB audio IRQ to a dedicated core for stable RT audio
|
||||||
|
# On RPi 4B, cores 0-2 handle kernel/general interrupts;
|
||||||
|
# pinning USB audio to core 3 isolates it from that noise.
|
||||||
|
try:
|
||||||
|
import glob as _glob
|
||||||
|
# Find the USB audio interface IRQ
|
||||||
|
_usb_irq: str | None = None
|
||||||
|
# Strategy 1: xhci-hcd (USB 3.0 controller on Pi 4B)
|
||||||
|
for _proc_dir in _glob.glob("/proc/irq/[0-9]*"):
|
||||||
|
try:
|
||||||
|
_name = (_glob.glob(f"{_proc_dir}/name")[0] if _glob.glob(f"{_proc_dir}/name") else None)
|
||||||
|
if _name:
|
||||||
|
_irq_name = open(_name).read().strip()
|
||||||
|
if "xhci" in _irq_name or "dwc" in _irq_name:
|
||||||
|
_usb_irq = os.path.basename(_proc_dir)
|
||||||
|
break
|
||||||
|
except (OSError, PermissionError, IndexError):
|
||||||
|
continue
|
||||||
|
if _usb_irq:
|
||||||
|
# Pin to core 3 (smp_affinity mask = 0x8)
|
||||||
|
_aff_path = f"/proc/irq/{_usb_irq}/smp_affinity"
|
||||||
|
_aff_list_path = f"/proc/irq/{_usb_irq}/smp_affinity_list"
|
||||||
|
with open(_aff_path, "w") as f:
|
||||||
|
f.write("8")
|
||||||
|
with open(_aff_list_path, "w") as f:
|
||||||
|
f.write("3")
|
||||||
|
# Move all other IRQs away from core 3
|
||||||
|
for _proc_dir in _glob.glob("/proc/irq/[0-9]*"):
|
||||||
|
_irq_num = os.path.basename(_proc_dir)
|
||||||
|
if _irq_num == _usb_irq:
|
||||||
|
continue # Skip our USB audio IRQ
|
||||||
|
try:
|
||||||
|
with open(f"{_proc_dir}/smp_affinity", "w") as f:
|
||||||
|
f.write("7") # cores 0,1,2
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
continue
|
||||||
|
logger.info("USB audio IRQ %s pinned to core 3 for RT stability", _usb_irq)
|
||||||
|
else:
|
||||||
|
logger.info("No USB audio IRQ found — skipping IRQ affinity (non-critical)")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not set IRQ affinity (non-root?): %s", exc)
|
||||||
|
|
||||||
|
# Disable Python garbage collector to prevent 10-50ms GC pauses in
|
||||||
|
# the real-time audio callback. At ~500 numpy allocs/sec in the
|
||||||
|
# pipeline, default GC (threshold=700) triggers every ~1.4s,
|
||||||
|
# causing audible pops. Reference counting handles 99% of cleanup.
|
||||||
|
try:
|
||||||
|
import gc
|
||||||
|
gc.disable()
|
||||||
|
gc.collect() # One final sweep to clear boot-time cyclic garbage
|
||||||
|
logger.info("GC disabled for RT audio stability")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not disable GC: %s", exc)
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
logger.info("Pedal v2 starting up...")
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Pi Multi-FX Pedal — real-time guitar multi-effects",
|
description="Pi Multi-FX Pedal — real-time guitar multi-effects",
|
||||||
)
|
)
|
||||||
@@ -699,4 +933,4 @@ def main() -> int:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"number": 0,
|
|
||||||
"name": "Clean & Edge",
|
|
||||||
"preset_count": 4
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Clean Jazz",
|
|
||||||
"bank": 0,
|
|
||||||
"program": 0,
|
|
||||||
"master_volume": 0.8,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.01}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.3, "ratio": 4.0, "gain": 0.8}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.5, "mid": 0.5, "treble": 0.5}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.3, "mix": 0.2}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {"wet": 1.0, "dry": 0.0}, "nam_model_path": "", "ir_file_path": "~/.pedal/irs/vintage-1x12.wav"},
|
|
||||||
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.85}, "nam_model_path": "", "ir_file_path": ""}
|
|
||||||
],
|
|
||||||
"midi_mappings": {
|
|
||||||
"reverb_mix": {"cc_number": 15, "channel": 0, "min_val": 0.0, "max_val": 1.0}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Edge of Breakup",
|
|
||||||
"bank": 0,
|
|
||||||
"program": 1,
|
|
||||||
"master_volume": 0.75,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.008}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.3, "gain": 0.9}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.4, "mid": 0.6, "treble": 0.7}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 350.0, "feedback": 0.25, "mix": 0.2}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {"wet": 1.0, "dry": 0.0}, "nam_model_path": "", "ir_file_path": "~/.pedal/irs/boutique-1x12.wav"},
|
|
||||||
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.8}, "nam_model_path": "", "ir_file_path": ""}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Clean with Chorus",
|
|
||||||
"bank": 0,
|
|
||||||
"program": 2,
|
|
||||||
"master_volume": 0.78,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.01}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.5, "mid": 0.5, "treble": 0.6}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "chorus", "enabled": true, "bypass": false, "params": {"rate": 0.35, "depth": 0.5, "mix": 0.4}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.4, "mix": 0.25}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {"wet": 1.0, "dry": 0.0}, "nam_model_path": "", "ir_file_path": "~/.pedal/irs/american-2x12.wav"},
|
|
||||||
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.82}, "nam_model_path": "", "ir_file_path": ""}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Dynamic Fingerstyle",
|
|
||||||
"bank": 0,
|
|
||||||
"program": 3,
|
|
||||||
"master_volume": 0.72,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.25, "ratio": 6.0, "gain": 0.7}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "boost", "enabled": true, "bypass": false, "params": {"gain_db": 3.0}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.4, "mid": 0.7, "treble": 0.5}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.5, "mix": 0.3}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {"wet": 1.0, "dry": 0.0}, "nam_model_path": "", "ir_file_path": "~/.pedal/irs/jazz-1x15.wav"},
|
|
||||||
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.78}, "nam_model_path": "", "ir_file_path": ""}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"number": 1,
|
|
||||||
"name": "Drive & Lead",
|
|
||||||
"preset_count": 4
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Classic Rock",
|
|
||||||
"bank": 1,
|
|
||||||
"program": 0,
|
|
||||||
"master_volume": 0.7,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.015}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.55, "gain": 0.8}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.6, "mid": 0.4, "treble": 0.7}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 380.0, "feedback": 0.3, "mix": 0.25}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {"wet": 1.0, "dry": 0.0}, "nam_model_path": "", "ir_file_path": "~/.pedal/irs/british-4x12.wav"},
|
|
||||||
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.75}, "nam_model_path": "", "ir_file_path": ""}
|
|
||||||
],
|
|
||||||
"midi_mappings": {
|
|
||||||
"drive": {"cc_number": 14, "channel": 0, "min_val": 0.0, "max_val": 1.0}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Lead Solo",
|
|
||||||
"bank": 1,
|
|
||||||
"program": 1,
|
|
||||||
"master_volume": 0.85,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.02}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.4, "ratio": 3.0, "gain": 1.2}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.7, "gain": 0.7}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "distortion", "enabled": true, "bypass": false, "params": {"drive": 0.6, "gain": 0.6}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.5, "mid": 0.3, "treble": 0.8}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 450.0, "feedback": 0.35, "mix": 0.35}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.6, "mix": 0.3}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.9}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {"wet": 1.0, "dry": 0.0}, "nam_model_path": "", "ir_file_path": "~/.pedal/irs/british-4x12.wav"}
|
|
||||||
],
|
|
||||||
"midi_mappings": {
|
|
||||||
"delay_feedback": {"cc_number": 18, "channel": 1, "min_val": 0.0, "max_val": 1.0}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Metal Rhythm",
|
|
||||||
"bank": 1,
|
|
||||||
"program": 2,
|
|
||||||
"master_volume": 0.65,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.03}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "boost", "enabled": true, "bypass": false, "params": {"gain_db": 8.0}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "distortion", "enabled": true, "bypass": false, "params": {"drive": 0.8, "gain": 0.5}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.6, "mid": 0.3, "treble": 0.7}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.7}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {"wet": 1.0, "dry": 0.0}, "nam_model_path": "", "ir_file_path": "~/.pedal/irs/modern-4x12.wav"}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Ambient Wash",
|
|
||||||
"bank": 1,
|
|
||||||
"program": 3,
|
|
||||||
"master_volume": 0.72,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.3, "ratio": 4.0, "gain": 0.9}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "phaser", "enabled": true, "bypass": false, "params": {"rate": 0.2, "depth": 0.6, "feedback": 0.4}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "chorus", "enabled": true, "bypass": false, "params": {"rate": 0.3, "depth": 0.7, "mix": 0.5}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 500.0, "feedback": 0.4, "mix": 0.35}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {"wet": 1.0, "dry": 0.0}, "nam_model_path": "", "ir_file_path": "~/.pedal/irs/american-2x12.wav"},
|
|
||||||
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.8, "mix": 0.4}, "nam_model_path": "", "ir_file_path": ""},
|
|
||||||
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.78}, "nam_model_path": "", "ir_file_path": ""}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"number": 2,
|
|
||||||
"name": "FX Bench",
|
|
||||||
"preset_count": 4
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Overdrive Bench",
|
|
||||||
"bank": 2,
|
|
||||||
"program": 0,
|
|
||||||
"master_volume": 0.8,
|
|
||||||
"routing_mode": "mono",
|
|
||||||
"routing_breakpoint": 7,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{
|
|
||||||
"fx_type": "noise_gate",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"threshold": 0.01
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "overdrive",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"drive": 0.55,
|
|
||||||
"tone": 0.5,
|
|
||||||
"level": 0.75,
|
|
||||||
"gain": 0.6,
|
|
||||||
"bass": 0.5,
|
|
||||||
"mid": 0.5,
|
|
||||||
"treble": 0.5,
|
|
||||||
"presence": 0.5
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "volume",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"level": 0.85
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Delay Bench",
|
|
||||||
"bank": 2,
|
|
||||||
"program": 1,
|
|
||||||
"master_volume": 0.8,
|
|
||||||
"routing_mode": "mono",
|
|
||||||
"routing_breakpoint": 7,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{
|
|
||||||
"fx_type": "noise_gate",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"threshold": 0.01
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "eq",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"bass": 0.5,
|
|
||||||
"mid": 0.5,
|
|
||||||
"treble": 0.5
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "delay",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"time": 420.0,
|
|
||||||
"feedback": 0.35,
|
|
||||||
"mix": 0.3,
|
|
||||||
"ping_pong": false,
|
|
||||||
"stereo": false,
|
|
||||||
"delay_type": "digital",
|
|
||||||
"modulation_rate": 0.1,
|
|
||||||
"modulation_depth": 0.1,
|
|
||||||
"low_cut": 20.0,
|
|
||||||
"high_cut": 8000.0
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "volume",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"level": 0.85
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Reverb Bench",
|
|
||||||
"bank": 2,
|
|
||||||
"program": 2,
|
|
||||||
"master_volume": 0.8,
|
|
||||||
"routing_mode": "mono",
|
|
||||||
"routing_breakpoint": 7,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{
|
|
||||||
"fx_type": "noise_gate",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"threshold": 0.01
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "eq",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"bass": 0.5,
|
|
||||||
"mid": 0.5,
|
|
||||||
"treble": 0.5
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "reverb",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"decay": 0.6,
|
|
||||||
"mix": 0.28,
|
|
||||||
"predelay": 30.0,
|
|
||||||
"low_cut": 80.0,
|
|
||||||
"high_cut": 7000.0,
|
|
||||||
"room_size": 0.65,
|
|
||||||
"damping": 0.5,
|
|
||||||
"reverb_type": "hall",
|
|
||||||
"diffusion": 0.6,
|
|
||||||
"width": 0.8
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "volume",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"level": 0.85
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Chorus Bench",
|
|
||||||
"bank": 2,
|
|
||||||
"program": 3,
|
|
||||||
"master_volume": 0.8,
|
|
||||||
"routing_mode": "mono",
|
|
||||||
"routing_breakpoint": 7,
|
|
||||||
"tuner_enabled": false,
|
|
||||||
"chain": [
|
|
||||||
{
|
|
||||||
"fx_type": "noise_gate",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"threshold": 0.01
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "eq",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"bass": 0.5,
|
|
||||||
"mid": 0.5,
|
|
||||||
"treble": 0.5
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "chorus",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"rate": 0.35,
|
|
||||||
"depth": 0.55,
|
|
||||||
"mix": 0.3,
|
|
||||||
"delay": 5.0,
|
|
||||||
"feedback": 0.1,
|
|
||||||
"waveform": "sine",
|
|
||||||
"spread": 0.5,
|
|
||||||
"phase_invert": false,
|
|
||||||
"high_pass": 20.0,
|
|
||||||
"low_pass": 8000.0,
|
|
||||||
"stereo": true
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fx_type": "volume",
|
|
||||||
"enabled": true,
|
|
||||||
"bypass": false,
|
|
||||||
"params": {
|
|
||||||
"level": 0.85
|
|
||||||
},
|
|
||||||
"nam_model_path": "",
|
|
||||||
"ir_file_path": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"midi_mappings": {}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Pi Multi-FX Pedal — Default Cabinet IR Files
|
|
||||||
|
|
||||||
Synthetic impulse responses (.wav) for guitar cabinet simulation.
|
|
||||||
Generated using DSP filter cascades (Butterworth LP/HP + parametric
|
|
||||||
peak/notch filters + exponential decay envelope).
|
|
||||||
|
|
||||||
**All IRs are 48kHz, 16-bit mono WAV files.**
|
|
||||||
|
|
||||||
| File | Cabinet Model | Taps | Length | Description |
|
|
||||||
|------|--------------|------|--------|-------------|
|
|
||||||
| `vintage-1x12.wav` | Vintage 1x12 — Fender-style Open Back | 2048 | 43ms | Warm, scooped cleans with bell-like top end. Models a Fender-style 1x12 open-back combo. |
|
|
||||||
| `british-4x12.wav` | British 4x12 — Marshall-style Closed Back | 4096 | 85ms | Mid-forward, aggressive rock tones with tight low end. Models a Marshall 1960A-style 4x12 closed-back cabinet. |
|
|
||||||
| `american-2x12.wav` | American 2x12 — Vox-style Open Back | 2048 | 43ms | Chimey, complex midrange with sparkling highs. Models a Vox AC30-style 2x12 open-back cabinet. |
|
|
||||||
| `modern-4x12.wav` | Modern 4x12 — Mesa/Boogie-style Closed Back | 4096 | 85ms | Tight low-end, aggressive mids, smooth highs. Models a Mesa Rectifier-style 4x12 closed-back cab. |
|
|
||||||
| `jazz-1x15.wav` | Jazz 1x15 — Deep Open Back | 2048 | 43ms | Deep, warm, scooped tone for jazz cleans. Models a 15-inch speaker in a large open-back cab. |
|
|
||||||
| `boutique-1x12.wav` | Boutique 1x12 — Dumble-style Open Back | 2048 | 43ms | Smooth, rounded cleans with enhanced mid complexity. Models a Dumble-style 1x12 open-back combo. |
|
|
||||||
| `mini-1x8.wav` | Mini 1x8 — Small Practice Amp | 1024 | 21ms | Lo-fi, boxy tone for vintage radio-style sounds. Models a small 8-inch practice amp speaker. |
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
These IRs are loaded by the `IRLoader` class through the pedal's cab simulation.
|
|
||||||
Place them in `~/.pedal/irs/` or reference them by absolute path in preset chain blocks.
|
|
||||||
|
|
||||||
## Replacement
|
|
||||||
|
|
||||||
Replace any `.wav` file with a real captured IR (48kHz, 16-bit mono) to
|
|
||||||
upgrade from synthetic to authentic cabinet tone. The pedal treats all
|
|
||||||
`.wav` files in the IR directory identically.
|
|
||||||
|
|
||||||
## Cabinet Type Guide
|
|
||||||
|
|
||||||
- **Vintage 1x12** — Fender Deluxe / Princeton-style cleans. Scooped mids, warm lows.
|
|
||||||
- **British 4x12** — Marshall 1960 / JCM-style crunch and rock. Mid-forward, aggressive.
|
|
||||||
- **American 2x12** — Vox AC30-style chime. Complex mids, sparkling treble.
|
|
||||||
- **Modern 4x12** — Mesa Rectifier-style high gain. Tight lows, smooth highs.
|
|
||||||
- **Jazz 1x15** — Polytone / Henriksen-style jazz. Deep lows, scooped mids.
|
|
||||||
- **Boutique 1x12** — Dumble / Two-Rock-style. Smooth, complex midrange.
|
|
||||||
- **Mini 1x8** — Small practice amp. Boxy, lo-fi, vintage radio tone.
|
|
||||||
+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 ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download NAM WaveNet models from community collection + cabinet IRs.
|
||||||
|
|
||||||
|
Downloads real NAM amp captures from the pelennor2170/NAM_models GitHub
|
||||||
|
repository and free cabinet IR packs from Archive.org.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/download_nam_and_irs.py # Download everything
|
||||||
|
python3 scripts/download_nam_and_irs.py --list # List models only
|
||||||
|
python3 scripts/download_nam_and_irs.py --models # Models only
|
||||||
|
python3 scripts/download_nam_and_irs.py --irs # IRs only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Paths ────────────────────────────────────────────────────────────────
|
||||||
|
NAM_DIR = Path.home() / ".pedal" / "models"
|
||||||
|
IR_DIR = Path.home() / ".pedal" / "irs"
|
||||||
|
BASE_URL = "https://raw.githubusercontent.com/pelennor2170/NAM_models/main"
|
||||||
|
|
||||||
|
# ── Model definitions ────────────────────────────────────────────────────
|
||||||
|
# (local_name, remote_name, description, architecture, size_kb)
|
||||||
|
# URL for Tone3000 (ToneHunt) Supabase storage downloads
|
||||||
|
TONE3000_STORAGE = "https://api.tone3000.com/storage/v1/object/public/models"
|
||||||
|
|
||||||
|
MODELS = [
|
||||||
|
# ── Bank 0: Clean & Edge ──────────────────────────────────────────
|
||||||
|
("Fender_TwinVerb_Clean.nam",
|
||||||
|
"Tim R Fender TwinVerb Norm Bright.nam",
|
||||||
|
"Fender Twin Reverb Normal Bright — clean jazz/fingerstyle",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("Fender_TwinVerb_Vibrato.nam",
|
||||||
|
"Tim R Fender TwinVerb Vibrato Bright.nam",
|
||||||
|
"Fender Twin Reverb Vibrato Bright — sparkling clean",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("Vox_AC15_TopBoost.nam",
|
||||||
|
"Phillipe P VOXAC15-TopBoost.nam",
|
||||||
|
"Vox AC15 Top Boost — chimey edge-of-breakup",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("Magnatone_Super59_Pushed.nam",
|
||||||
|
"Tim R Magnatone Super 59 Mkii Pushed.nam",
|
||||||
|
"Magnatone Super 59 pushed — bluesy breakup",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("JCM2000_Clean.nam",
|
||||||
|
"Tim R JCM2000 Clean.nam",
|
||||||
|
"Marshall JCM2000 clean — crisp rhythm",
|
||||||
|
"WaveNet", 400),
|
||||||
|
|
||||||
|
# ── Bank 1: Drive & Lead ──────────────────────────────────────────
|
||||||
|
("Orange_Rockerverb.nam",
|
||||||
|
"Tom C Axe FX 2 Orange Rockerverb.nam",
|
||||||
|
"Orange Rockerverb — classic rock crunch",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("JCM2000_Crunch.nam",
|
||||||
|
"Tim R JCM2000 Crunch.nam",
|
||||||
|
"Marshall JCM2000 crunch — hard rock rhythm",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("JCM900_Lead.nam",
|
||||||
|
"Tim R JCM90050WDualVerbCHAG6.nam",
|
||||||
|
"Marshall JCM900 Dual Verb ch.A gain6 — lead",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("5150_BlockLetter_Boosted.nam",
|
||||||
|
"Helga B 5150 BlockLetter - Boosted.nam",
|
||||||
|
"EVH 5150 Block Letter boosted — high-gain lead",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("6505_Red_NoBoost.nam",
|
||||||
|
"Helga B 6505+ Red ch - NoBoost.nam",
|
||||||
|
"Peavey 6505+ Red ch no boost — metal rhythm",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("Engl_Savage.nam",
|
||||||
|
"Tom C Axe FX 2 Engl Savage.nam",
|
||||||
|
"ENGL Savage 120 — modern high-gain lead",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("Splawn_ProMod_3rd.nam",
|
||||||
|
"Tim R Splawn Pro Mod 3rd Gear - G12.nam",
|
||||||
|
"Splawn Pro Mod 3rd gear — tight high-gain",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("5152_Lead_TS9.nam",
|
||||||
|
"Tim R 5152 Lead + TS9.nam",
|
||||||
|
"5152 Lead with Tube Screamer — boosted solo",
|
||||||
|
"WaveNet", 400),
|
||||||
|
|
||||||
|
# ── Pedal captures ───────────────────────────────────────────────
|
||||||
|
("Klon_Centaur_High.nam",
|
||||||
|
"Keith B Klone_plus_BB_highGain.nam",
|
||||||
|
"Klon Centaur-style OD high gain — boost/overdrive",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("TubeScreamer_Maxed.nam",
|
||||||
|
"Tudor N Ibanez Tube Screamer Mini Tone@10_Level@10_Overdrive@0 ESR_0.0006_lite.nam",
|
||||||
|
"Ibanez TS808 Mini maxed — clean boost",
|
||||||
|
"WaveNet", 200),
|
||||||
|
("Mesa_MarkIV_Lead.nam",
|
||||||
|
"Roman A LT_MESA_MARKIV_1.nam",
|
||||||
|
"Mesa Boogie Mark IV lead — singing sustain",
|
||||||
|
"WaveNet", 400),
|
||||||
|
("Bogner_Ecstasy.nam",
|
||||||
|
"Tudor N Ceriatone Molecular 50.nam",
|
||||||
|
"Bogner Ecstasy-style — versatile OD range",
|
||||||
|
"WaveNet", 400),
|
||||||
|
|
||||||
|
# ── Feather models (93 KB, ultra-low CPU) ────────────────────────
|
||||||
|
("BOSS_SD1_Feather.nam",
|
||||||
|
"Phillipe P BOSS-SD1-Feather-ESR0,001.nam",
|
||||||
|
"BOSS SD-1 overdrive feather — 93 KB",
|
||||||
|
"WaveNet", 93),
|
||||||
|
("MXR_M77_Feather.nam",
|
||||||
|
"Phillipe P MXR-M77-Feather-ESR0,001.nam",
|
||||||
|
"MXR M77 Custom Badass OD feather — 93 KB",
|
||||||
|
"WaveNet", 93),
|
||||||
|
("TS808_Feather.nam",
|
||||||
|
"Phillipe P Maxon-OD808-Feather-ESR0,001.nam",
|
||||||
|
"Maxon OD808 Tube Screamer feather — 93 KB",
|
||||||
|
"WaveNet", 93),
|
||||||
|
("Precision_Drive_Feather.nam",
|
||||||
|
"Phillipe P Precision-Drive-Feather-ESR0,001.nam",
|
||||||
|
"Precision Drive overdrive feather — 93 KB",
|
||||||
|
"WaveNet", 93),
|
||||||
|
("Friedman_PowerAmp_Feather.nam",
|
||||||
|
"Sascha S FriedmanDSM_PowerAmpEL84_MV10_feather.nam",
|
||||||
|
"Friedman DSM power amp feather — 93 KB",
|
||||||
|
"WaveNet", 93),
|
||||||
|
|
||||||
|
# ── Bank 2: Blues (Tone3000 captures — clean, edge-of-breakup, pushed) ─
|
||||||
|
# Downloaded from ToneHunt / Tone3000 Supabase storage. These are real
|
||||||
|
# amp captures of classic blues tones: Fender Princeton, Tweed Deluxe,
|
||||||
|
# Twin Reverb, Dumble ODS, Two-Rock, Marshall Bluesbreaker.
|
||||||
|
# Format: (local_name, tone3000_storage_key, description, architecture, size_kb)
|
||||||
|
("Fender_Princeton_Clone_Clean.nam",
|
||||||
|
"4f81e2b088149c04.nam",
|
||||||
|
"Fender Hand-wired Princeton Clone Clean — blues clean (WaveNet A1, 289 KB)",
|
||||||
|
"WaveNet", 289),
|
||||||
|
("Fender_Princeton_Clone_EOB.nam",
|
||||||
|
"68a5f3484e1759ed_a2.nam",
|
||||||
|
"Fender Hand-wired Princeton Clone Edge-of-Breakup — blues edge (Slimmable A2, 288 KB)",
|
||||||
|
"SlimmableContainer", 288),
|
||||||
|
("Fender_Tweed_Dlx_Edge.nam",
|
||||||
|
"ga5rj9sxlnk.nam",
|
||||||
|
"Fender Tweed Deluxe The Edge (Celestion Blue) — pushed clean (Slimmable A2, 288 KB)",
|
||||||
|
"SlimmableContainer", 288),
|
||||||
|
("Fender_Twin_Reverb_Clean_A2.nam",
|
||||||
|
"tg81uadutjo_a2.nam",
|
||||||
|
"Fender Twin Reverb Clean — sparkling blues clean (Slimmable A2, 290 KB)",
|
||||||
|
"SlimmableContainer", 290),
|
||||||
|
("TwoRock_JTM_Signature.nam",
|
||||||
|
"qj3hkgbcwb9.nam",
|
||||||
|
"Two-Rock John Mayer Signature Prototype — clean/edge bloom (WaveNet A1, 276 KB)",
|
||||||
|
"WaveNet", 276),
|
||||||
|
("Dumble_ODS_102_Rock_A1.nam",
|
||||||
|
"5bac98f136812852.nam",
|
||||||
|
"Dumble ODS #102 Clone Rock setting — pushed blues overdrive (WaveNet A1, 87 KB feather)",
|
||||||
|
"WaveNet", 87),
|
||||||
|
("Dumble_ODS_102_Rock_A2.nam",
|
||||||
|
"5bac98f136812852_a2.nam",
|
||||||
|
"Dumble ODS #102 Clone Rock setting — pushed blues OD (Slimmable A2, 289 KB)",
|
||||||
|
"SlimmableContainer", 289),
|
||||||
|
("Marshall_Bluesbreaker_1962_A2.nam",
|
||||||
|
"bbc41ecccdc730fb_a2.nam",
|
||||||
|
"Marshall Bluesbreaker 1962 — classic blues rock (Slimmable A2, 289 KB)",
|
||||||
|
"SlimmableContainer", 289),
|
||||||
|
("Marshall_Bluesbreaker_Pedal.nam",
|
||||||
|
"31aeae1256b36839.nam",
|
||||||
|
"Marshall Bluesbreaker Pedal (Original) Setting 1 — pedal OD (WaveNet A1, 290 KB)",
|
||||||
|
"WaveNet", 290),
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bank 7: Bass, Orange & Others (Tone3000 A2 — SlimmableContainer) ────
|
||||||
|
# Downloaded from Tone3000 Supabase storage. Covers missing categories:
|
||||||
|
# Bass (Ampeg, Aguilar, GK), Orange Rocker 30, Fender Bassman,
|
||||||
|
# Hiwatt DR103, Supro Black Magick.
|
||||||
|
("Ampeg_PF20T_A2.nam",
|
||||||
|
"db2c43e99cfc346d_a2.nam",
|
||||||
|
"Ampeg PF20T — clean bass tube amp (Slimmable A2, 288 KB)",
|
||||||
|
"SlimmableContainer", 288),
|
||||||
|
("Aguilar_Tone_Hammer_500_A2.nam",
|
||||||
|
"38rdhwzk3hp_a2.nam",
|
||||||
|
"Aguilar Tone Hammer 500 — modern bass preamp (Slimmable A2, 293 KB)",
|
||||||
|
"SlimmableContainer", 293),
|
||||||
|
("GK_800RB_Line_A2.nam",
|
||||||
|
"ce68ccd373a10054_a2.nam",
|
||||||
|
"Gallien-Krueger 800RB — classic bass solid-state (Slimmable A2, 295 KB)",
|
||||||
|
"SlimmableContainer", 295),
|
||||||
|
("Aguilar_DB751_Cab_A2.nam",
|
||||||
|
"lage0x4ybk_a2.nam",
|
||||||
|
"Aguilar DB751 — bass amp DI (Slimmable A2, 292 KB)",
|
||||||
|
"SlimmableContainer", 292),
|
||||||
|
("Orange_Rocker30_Dirty_A2.nam",
|
||||||
|
"8491c70c3771de1e_a2.nam",
|
||||||
|
"Orange Rocker 30 Dirty — classic rock crunch (Slimmable A2, 295 KB)",
|
||||||
|
"SlimmableContainer", 295),
|
||||||
|
("Orange_Rocker30_Natural_A2.nam",
|
||||||
|
"a60ed1a1aa0c2c0f_a2.nam",
|
||||||
|
"Orange Rocker 30 Natural — clean edge (Slimmable A2, 289 KB)",
|
||||||
|
"SlimmableContainer", 289),
|
||||||
|
("Fender_Bassman_Dimed_A2.nam",
|
||||||
|
"0a9223a9f1c879d5_a2.nam",
|
||||||
|
"Fender Bassman dimed — blues/rock breakup (Slimmable A2, 289 KB)",
|
||||||
|
"SlimmableContainer", 289),
|
||||||
|
("Hiwatt_DR103_Bright_A2.nam",
|
||||||
|
"0c46e5a7db1cce48_a2.nam",
|
||||||
|
"Hiwatt DR-103 Bright Normal — prog/classic rock (Slimmable A2, 287 KB)",
|
||||||
|
"SlimmableContainer", 287),
|
||||||
|
("Hiwatt_DR103_Cornish_A2.nam",
|
||||||
|
"ynk0h2e2tph.nam",
|
||||||
|
"Hiwatt DR103 + Cornish PGS — boosted prog rock (Slimmable A2, 288 KB)",
|
||||||
|
"SlimmableContainer", 288),
|
||||||
|
("Supro_Black_Magick_M3_A2.nam",
|
||||||
|
"c90a6d8ee0c0a689_a2.nam",
|
||||||
|
"Supro Black Magick M3 — garage/blues (Slimmable A2, 288 KB)",
|
||||||
|
"SlimmableContainer", 288),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── IR Pack definitions ──────────────────────────────────────────────────
|
||||||
|
IR_PACKS = [
|
||||||
|
("God's Cab",
|
||||||
|
"https://archive.org/download/godscab-ir-pack/GodsCab_IR_Pack.zip",
|
||||||
|
"godscab.zip",
|
||||||
|
"100+ cabinet IRs (412, 1960, V30, Greenback, etc.) — CC0 / Public Domain"),
|
||||||
|
("Seacow Cabs",
|
||||||
|
"https://archive.org/download/seacow-cabs-free-ir-2023/Seacow_Cabs_Free_IRs_2023.zip",
|
||||||
|
"seacow.zip",
|
||||||
|
"Boutique cab IR pack, 15+ cabs — Free for personal use"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def info(msg): print(f"\033[0;36m[INFO]\033[0m {msg}")
|
||||||
|
def ok(msg): print(f"\033[0;32m[OK]\033[0m {msg}")
|
||||||
|
def warn(msg): print(f"\033[1;33m[WARN]\033[0m {msg}")
|
||||||
|
def err(msg): print(f"\033[0;31m[ERR]\033[0m {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(url: str, dest: Path, max_size_mb: int = 50) -> bool:
|
||||||
|
"""Download a file from url to dest. Returns True on success."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "pedal-downloader/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as src:
|
||||||
|
data = src.read()
|
||||||
|
size_mb = len(data) / (1024 * 1024)
|
||||||
|
if size_mb > max_size_mb:
|
||||||
|
err(f"File too large ({size_mb:.1f} MB, max {max_size_mb} MB)")
|
||||||
|
return False
|
||||||
|
if len(data) < 1000:
|
||||||
|
err(f"File too small ({len(data)} bytes)")
|
||||||
|
return False
|
||||||
|
dest.write_bytes(data)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
err(f"Download failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def download_model(local_name: str, remote_name: str, desc: str, arch: str, size: int) -> bool:
|
||||||
|
"""Download a single NAM model file from GitHub."""
|
||||||
|
outfile = NAM_DIR / local_name
|
||||||
|
|
||||||
|
if outfile.exists() and outfile.stat().st_size > 1000:
|
||||||
|
ok(f"SKIP {local_name} (already exists, {outfile.stat().st_size // 1024} KB)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# URL-encode the remote filename
|
||||||
|
encoded = urllib.parse.quote(remote_name)
|
||||||
|
url = f"{BASE_URL}/{encoded}"
|
||||||
|
|
||||||
|
info(f"DL {local_name} ({desc})")
|
||||||
|
if download_file(url, outfile):
|
||||||
|
# Verify it's valid JSON (.nam format)
|
||||||
|
try:
|
||||||
|
data = json.loads(outfile.read_text())
|
||||||
|
actual_arch = data.get("architecture", "?")
|
||||||
|
actual_ver = data.get("version", "?")
|
||||||
|
ok(f"Downloaded {local_name} ({outfile.stat().st_size // 1024} KB, {actual_arch} v{actual_ver})")
|
||||||
|
return True
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
err(f"Invalid .nam file (not valid JSON): {e}")
|
||||||
|
outfile.unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
err(f"Failed to download {local_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_tone3000_key() -> str:
|
||||||
|
"""Discover the Supabase anon key from Tone3000 website JS bundles."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://tone3000.com",
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (compatible; pedal-downloader/1.0)"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
html = resp.read().decode("utf-8", errors="replace")
|
||||||
|
import re
|
||||||
|
chunk_urls = set()
|
||||||
|
for m in re.finditer(r'src="([^"]*\.js[^"]*)"', html):
|
||||||
|
url = m.group(1)
|
||||||
|
if url.startswith("/"):
|
||||||
|
url = "https://tone3000.com" + url
|
||||||
|
chunk_urls.add(url)
|
||||||
|
for chunk_url in sorted(chunk_urls)[:15]:
|
||||||
|
try:
|
||||||
|
req2 = urllib.request.Request(chunk_url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
with urllib.request.urlopen(req2, timeout=10) as resp2:
|
||||||
|
js = resp2.read().decode("utf-8", errors="replace")
|
||||||
|
m = re.search(
|
||||||
|
r'createBrowserClient\s*\)\s*\(\s*["\']https?://api\.tone3000\.com["\']\s*,\s*["\']([^"\']+)["\']',
|
||||||
|
js,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
key = m.group(1)
|
||||||
|
if key.startswith("eyJ") and key.count(".") >= 2:
|
||||||
|
return key
|
||||||
|
for m2 in re.finditer(
|
||||||
|
r'["\'](eyJ[a-zA-Z0-9_\-]{20,}\.[a-zA-Z0-9_\-]{20,}\.[a-zA-Z0-9_\-]{10,})["\']', js
|
||||||
|
):
|
||||||
|
key = m2.group(1)
|
||||||
|
if key.count(".") >= 2:
|
||||||
|
return key
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def download_tone3000_model(local_name: str, storage_key: str, desc: str, arch: str, size: int) -> bool:
|
||||||
|
"""Download a single NAM model file from Tone3000 Supabase storage."""
|
||||||
|
outfile = NAM_DIR / local_name
|
||||||
|
|
||||||
|
if outfile.exists() and outfile.stat().st_size > 1000:
|
||||||
|
ok(f"SKIP {local_name} (already exists, {outfile.stat().st_size // 1024} KB)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
url = f"{TONE3000_STORAGE}/{storage_key}"
|
||||||
|
|
||||||
|
info(f"DL {local_name} ({desc})")
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "pedal-downloader/1.0"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as src:
|
||||||
|
data = src.read()
|
||||||
|
except Exception as e:
|
||||||
|
err(f"Download failed: {e}")
|
||||||
|
# Try with anon key (some storage buckets require authentication)
|
||||||
|
try:
|
||||||
|
anon_key = _discover_tone3000_key()
|
||||||
|
if anon_key:
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
"User-Agent": "pedal-downloader/1.0",
|
||||||
|
"Authorization": f"Bearer {anon_key}",
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as src:
|
||||||
|
data = src.read()
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except Exception as e2:
|
||||||
|
err(f"Download failed even with auth: {e2}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
size_mb = len(data) / (1024 * 1024)
|
||||||
|
if size_mb > 50:
|
||||||
|
err(f"File too large ({size_mb:.1f} MB, max 50 MB)")
|
||||||
|
return False
|
||||||
|
if len(data) < 1000:
|
||||||
|
err(f"File too small ({len(data)} bytes)")
|
||||||
|
return False
|
||||||
|
outfile.write_bytes(data)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
try:
|
||||||
|
model_data = json.loads(data)
|
||||||
|
actual_arch = model_data.get("architecture", "?")
|
||||||
|
actual_ver = model_data.get("version", "?")
|
||||||
|
ok(f"Downloaded {local_name} ({outfile.stat().st_size // 1024} KB, {actual_arch} v{actual_ver})")
|
||||||
|
return True
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
err(f"Invalid .nam file (not valid JSON): {e}")
|
||||||
|
outfile.unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def download_models():
|
||||||
|
"""Download all NAM models (GitHub + Tone3000)."""
|
||||||
|
print(f"\n\033[0;36mDownloading {len(MODELS)} NAM models to {NAM_DIR}\033[0m")
|
||||||
|
print(f"\033[0;36m{'━' * 60}\033[0m")
|
||||||
|
NAM_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
fail = 0
|
||||||
|
|
||||||
|
for local_name, remote_name, desc, arch, size in MODELS:
|
||||||
|
# Detect Tone3000 storage key (short, no dots, no spaces)
|
||||||
|
if " " not in remote_name and "." not in remote_name and len(remote_name) < 64:
|
||||||
|
if download_tone3000_model(local_name, remote_name, desc, arch, size):
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
else:
|
||||||
|
if download_model(local_name, remote_name, desc, arch, size):
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
print(f"\n Models: {success} successful, {fail} failed")
|
||||||
|
return fail
|
||||||
|
|
||||||
|
|
||||||
|
def download_irs():
|
||||||
|
"""Download IR packs from Archive.org."""
|
||||||
|
print(f"\n\033[0;36mDownloading IR packs to {IR_DIR}\033[0m")
|
||||||
|
print(f"\033[0;36m{'━' * 60}\033[0m")
|
||||||
|
IR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
fail = 0
|
||||||
|
|
||||||
|
for name, url, fname, desc in IR_PACKS:
|
||||||
|
# Check if already extracted
|
||||||
|
wav_count = len(list(IR_DIR.rglob("*.wav")))
|
||||||
|
if wav_count > 10:
|
||||||
|
ok(f"SKIP {name} ({wav_count} .wav files already present)")
|
||||||
|
success += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
dest = IR_DIR / fname
|
||||||
|
info(f"Downloading {name}...")
|
||||||
|
info(f" Source: {url}")
|
||||||
|
|
||||||
|
if download_file(url, dest, max_size_mb=200):
|
||||||
|
size_mb = dest.stat().st_size / (1024 * 1024)
|
||||||
|
ok(f"Downloaded {fname} ({size_mb:.1f} MB)")
|
||||||
|
|
||||||
|
# Extract ZIP
|
||||||
|
import zipfile
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(dest, "r") as zf:
|
||||||
|
zf.extractall(IR_DIR)
|
||||||
|
dest.unlink()
|
||||||
|
wav_count = len(list(IR_DIR.rglob("*.wav")))
|
||||||
|
ok(f"Extracted to {IR_DIR}/ ({wav_count} .wav files)")
|
||||||
|
success += 1
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
warn(f"Bad ZIP — saved at {dest}")
|
||||||
|
fail += 1
|
||||||
|
else:
|
||||||
|
err(f"Failed to download {name}")
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
print(f"\n IR packs: {success} downloaded, {fail} failed")
|
||||||
|
wav_count = len(list(IR_DIR.rglob("*.wav")))
|
||||||
|
info(f"Total .wav IR files: {wav_count}")
|
||||||
|
return fail
|
||||||
|
|
||||||
|
|
||||||
|
def list_models():
|
||||||
|
"""Print model listing."""
|
||||||
|
print(f"\n\033[0;36mNAM Models by Genre:\033[0m")
|
||||||
|
print(f"\033[0;36m{'━' * 60}\033[0m")
|
||||||
|
for local_name, remote_name, desc, arch, size in MODELS:
|
||||||
|
print(f" {local_name:35s} {desc}")
|
||||||
|
print(f" {'':35s} ({arch}, ~{size} KB)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f"\nTotal: {len(MODELS)} models")
|
||||||
|
|
||||||
|
# Count existing files
|
||||||
|
nam_count = len(list(NAM_DIR.glob("*.nam")))
|
||||||
|
ir_count = len(list(IR_DIR.rglob("*.wav")))
|
||||||
|
print(f"\n Models in {NAM_DIR}: {nam_count}")
|
||||||
|
print(f" IRs in {IR_DIR}: {ir_count}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Also check ~/.pedal/nam/ legacy location
|
||||||
|
NAM_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
IR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
arg = sys.argv[1] if len(sys.argv) > 1 else "--all"
|
||||||
|
|
||||||
|
if arg == "--list":
|
||||||
|
list_models()
|
||||||
|
elif arg == "--models":
|
||||||
|
exit(download_models())
|
||||||
|
elif arg == "--irs":
|
||||||
|
exit(download_irs())
|
||||||
|
elif arg == "--all":
|
||||||
|
fail = download_models()
|
||||||
|
download_irs()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
nam_count = len(list(NAM_DIR.glob("*.nam")))
|
||||||
|
ir_count = len(list(IR_DIR.rglob("*.wav")))
|
||||||
|
print(f"\n\033[0;32m{'╔' + '═' * 56 + '╗'}\033[0m")
|
||||||
|
print(f"\033[0;32m║ Download complete!{' ' * 36}║\033[0m")
|
||||||
|
print(f"\033[0;32m{'╚' + '═' * 56 + '╝'}\033[0m")
|
||||||
|
print()
|
||||||
|
print(f" Models: {nam_count} .nam files in {NAM_DIR}")
|
||||||
|
print(f" IRs: {ir_count} .wav files in {IR_DIR}")
|
||||||
|
exit(fail)
|
||||||
|
else:
|
||||||
|
print(f"Usage: {sys.argv[0]} [--list|--models|--irs|--all]")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+371
@@ -0,0 +1,371 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ── NAM Model & Cabinet IR Downloader ─────────────────────────────────
|
||||||
|
#
|
||||||
|
# Downloads real NAM amp captures (WaveNet/architecture=2) from the
|
||||||
|
# pelennor2170/NAM_models community collection on GitHub, plus free
|
||||||
|
# cabinet IR packs from Archive.org.
|
||||||
|
#
|
||||||
|
# Models are organized by genre to match the factory presets:
|
||||||
|
# Bank 0 — Clean & Edge (Clean Jazz, Edge of Breakup, Clean w/ Chorus, Dynamic Fingerstyle)
|
||||||
|
# Bank 1 — Drive & Lead (Classic Rock, Lead Solo, Metal Rhythm, Ambient Wash)
|
||||||
|
# Bank 2 — FX Bench (reference presets)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/download_nam_and_irs.sh # Download everything
|
||||||
|
# ./scripts/download_nam_and_irs.sh --list # List what would be downloaded
|
||||||
|
# ./scripts/download_nam_and_irs.sh --models # Models only
|
||||||
|
# ./scripts/download_nam_and_irs.sh --irs # IRs only
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# NAM_DIR: target directory for .nam files (default: ~/.pedal/models)
|
||||||
|
# IR_DIR: target directory for .wav IRs (default: ~/.pedal/irs)
|
||||||
|
#
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NAM_DIR="${NAM_DIR:-$HOME/.pedal/models}"
|
||||||
|
IR_DIR="${IR_DIR:-$HOME/.pedal/irs}"
|
||||||
|
TMPDIR="${TMPDIR:-/tmp}/pedal-download-$$"
|
||||||
|
BASE_URL="https://raw.githubusercontent.com/pelennor2170/NAM_models/main"
|
||||||
|
|
||||||
|
# ── Colours ──────────────────────────────────────────────────────────
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[ERR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# ── NAM Models by Genre ──────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Format: "local_filename|github_filename|description|architecture|size_kb"
|
||||||
|
# All are WaveNet models from the community capture collection.
|
||||||
|
#
|
||||||
|
# GENRE MAP (matching factory presets):
|
||||||
|
# Clean Jazz, Dynamic Fingerstyle → Fender Twin, Vox AC15 cleans
|
||||||
|
# Edge of Breakup → Magnatone pushed, JCM2000 crunch
|
||||||
|
# Clean with Chorus → Fender Twin clean
|
||||||
|
# Classic Rock → Orange Rockerverb, JCM2000, JCM900
|
||||||
|
# Lead Solo → 5150, Engl Savage
|
||||||
|
# Metal Rhythm → 5150, Splawn
|
||||||
|
# Ambient Wash → Fender Twin clean (with reverb/mod FX)
|
||||||
|
#
|
||||||
|
|
||||||
|
declare -a MODELS=()
|
||||||
|
|
||||||
|
# ── Bank 0: Clean & Edge ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
MODELS+=(
|
||||||
|
"Fender_TwinVerb_Clean.nam|Tim R Fender TwinVerb Norm Bright.nam|Fender Twin Reverb Normal Bright channel — clean jazz/fingerstyle|WaveNet|400"
|
||||||
|
"Fender_TwinVerb_Vibrato.nam|Tim R Fender TwinVerb Vibrato Bright.nam|Fender Twin Reverb Vibrato Bright channel — sparkling clean|WaveNet|400"
|
||||||
|
"Vox_AC15_TopBoost.nam|Phillipe P VOXAC15-TopBoost.nam|Vox AC15 Top Boost channel — chimey edge-of-breakup|WaveNet|400"
|
||||||
|
"Magnatone_Super59_Pushed.nam|Tim R Magnatone Super 59 Mkii Pushed.nam|Magnatone Super 59 pushed — bluesy breakup|WaveNet|400"
|
||||||
|
"JCM2000_Clean.nam|Tim R JCM2000 Clean.nam|Marshall JCM2000 clean — crisp rhythm|WaveNet|400"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Bank 1: Drive & Lead ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
MODELS+=(
|
||||||
|
"Orange_Rockerverb.nam|Tom C Axe FX 2 Orange Rockerverb.nam|Orange Rockerverb — classic rock crunch|WaveNet|400"
|
||||||
|
"JCM2000_Crunch.nam|Tim R JCM2000 Crunch.nam|Marshall JCM2000 crunch — hard rock rhythm|WaveNet|400"
|
||||||
|
"JCM900_Lead.nam|Tim R JCM90050WDualVerbCHAG6.nam|Marshall JCM900 Dual Verb channel A gain 6 — lead|WaveNet|400"
|
||||||
|
"5150_BlockLetter_Boosted.nam|Helga B 5150 BlockLetter - Boosted.nam|EVH 5150 Block Letter boosted — high-gain lead|WaveNet|400"
|
||||||
|
"6505_Red_NoBoost.nam|Helga B 6505+ Red ch - NoBoost.nam|Peavey 6505+ Red channel no boost — metal rhythm|WaveNet|400"
|
||||||
|
"Engl_Savage.nam|Tom C Axe FX 2 Engl Savage.nam|ENGL Savage 120 — modern high-gain lead|WaveNet|400"
|
||||||
|
"Splawn_ProMod_3rd.nam|Tim R Splawn Pro Mod 3rd Gear - G12.nam|Splawn Pro Mod 3rd gear — tight high-gain|WaveNet|400"
|
||||||
|
"5152_Lead_TS9.nam|Tim R 5152 Lead + TS9.nam|5152 Lead with Tube Screamer — boosted solo tone|WaveNet|400"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Pedal captures (for FX chain flexibility) ────────────────────────
|
||||||
|
# These work as stand-in for the BOOST/OD/DIST blocks or as
|
||||||
|
# preamp-into-IR tones.
|
||||||
|
|
||||||
|
MODELS+=(
|
||||||
|
"Klon_Centaur_High.nam|Keith B Klone_plus_BB_highGain.nam|Klon Centaur-style overdrive high gain — boost/overdrive|WaveNet|400"
|
||||||
|
"TubeScreamer_Maxed.nam|Tudor N Ibanez Tube Screamer Mini Tone@10_Level@10_Overdrive@0 ESR_0.0006_lite.nam|Ibanez TS808 Mini maxed — clean boost|WaveNet|200"
|
||||||
|
"Mesa_MarkIV_Lead.nam|Roman A LT_MESA_MARKIV_1.nam|Mesa Boogie Mark IV lead — singing sustain|WaveNet|400"
|
||||||
|
"Bogner_Ecstasy.nam|Tudor N Ceriatone Molecular 50.nam|Bogner Ecstasy-style — versatile overdrive range|WaveNet|400"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Feather models (smallest footprint, best for RPi 4B) ────────────
|
||||||
|
|
||||||
|
MODELS+=(
|
||||||
|
"BOSS_SD1_Feather.nam|Phillipe P BOSS-SD1-Feather-ESR0,001.nam|BOSS SD-1 overdrive feather model — 93 KB, ultra-low CPU|WaveNet|93"
|
||||||
|
"MXR_M77_Feather.nam|Phillipe P MXR-M77-Feather-ESR0,001.nam|MXR M77 Custom Badass OD feather model — 93 KB|WaveNet|93"
|
||||||
|
"TS808_Feather.nam|Phillipe P Maxon-OD808-Feather-ESR0,001.nam|Maxon OD808 Tube Screamer feather — 93 KB|WaveNet|93"
|
||||||
|
"Precision_Drive_Feather.nam|Phillipe P Precision-Drive-Feather-ESR0,001.nam|Precision Drive overdrive feather — 93 KB|WaveNet|93"
|
||||||
|
"Friedman_PowerAmp_Feather.nam|Sascha S FriedmanDSM_PowerAmpEL84_MV10_feather.nam|Friedman DSM power amp feather — 93 KB|WaveNet|93"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Bank 2: Blues (Tone3000 captures — clean, edge-of-breakup, pushed) ──
|
||||||
|
# Downloaded from ToneHunt/Tone3000 Supabase storage. "remote_name" is the
|
||||||
|
# storage key (not a filename), which the Python script resolves to a
|
||||||
|
# Tone3000 storage URL. The shell script downloads these with direct
|
||||||
|
# Tone3000 storage URLs via curl.
|
||||||
|
# Covers: Fender Princeton, Tweed Deluxe, Twin Reverb, Dumble ODS,
|
||||||
|
# Two-Rock, Marshall Bluesbreaker.
|
||||||
|
|
||||||
|
MODELS+=(
|
||||||
|
"Fender_Princeton_Clone_Clean.nam|4f81e2b088149c04|Fender Hand-wired Princeton Clone Clean — blues clean (WaveNet A1, 289 KB)|WaveNet|289"
|
||||||
|
"Fender_Princeton_Clone_EOB.nam|68a5f3484e1759ed_a2|Fender Hand-wired Princeton Clone Edge-of-Breakup — blues edge (Slimmable A2, 288 KB)|SlimmableContainer|288"
|
||||||
|
"Fender_Tweed_Dlx_Edge.nam|ga5rj9sxlnk|Fender Tweed Deluxe The Edge (Celestion Blue) — pushed clean (Slimmable A2, 288 KB)|SlimmableContainer|288"
|
||||||
|
"Fender_Twin_Reverb_Clean_A2.nam|tg81uadutjo_a2|Fender Twin Reverb Clean — sparkling blues clean (Slimmable A2, 290 KB)|SlimmableContainer|290"
|
||||||
|
"TwoRock_JTM_Signature.nam|qj3hkgbcwb9|Two-Rock John Mayer Signature Prototype — clean/edge bloom (WaveNet A1, 276 KB)|WaveNet|276"
|
||||||
|
"Dumble_ODS_102_Rock_A1.nam|5bac98f136812852|Dumble ODS #102 Clone Rock setting — pushed blues overdrive (WaveNet A1, 87 KB feather)|WaveNet|87"
|
||||||
|
"Dumble_ODS_102_Rock_A2.nam|5bac98f136812852_a2|Dumble ODS #102 Clone Rock setting — pushed blues OD (Slimmable A2, 289 KB)|SlimmableContainer|289"
|
||||||
|
"Marshall_Bluesbreaker_1962_A2.nam|bbc41ecccdc730fb_a2|Marshall Bluesbreaker 1962 — classic blues rock (Slimmable A2, 289 KB)|SlimmableContainer|289"
|
||||||
|
"Marshall_Bluesbreaker_Pedal.nam|31aeae1256b36839|Marshall Bluesbreaker Pedal (Original) Setting 1 — pedal OD (WaveNet A1, 290 KB)|WaveNet|290"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Total: 31 models (22 core + 9 blues) ──────────────────────────
|
||||||
|
|
||||||
|
# ── IR Packs ─────────────────────────────────────────────────────────
|
||||||
|
# Free cabinet IR collections from Archive.org (CC0 / free for personal use)
|
||||||
|
|
||||||
|
declare -a IR_PACKS=(
|
||||||
|
"God's Cab|https://archive.org/download/godscab-ir-pack/GodsCab_IR_Pack.zip|godscab.zip|100+ cabinet IRs (412, 1960, V30, Greenback, etc.) — CC0 / Public Domain"
|
||||||
|
"Seacow Cabs|https://archive.org/download/seacow-cabs-free-ir-2023/Seacow_Cabs_Free_IRs_2023.zip|seacow.zip|Boutique cab IR pack, 15+ cabs — Free for personal use"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_list_models() {
|
||||||
|
echo -e "\n${CYAN}NAM Models by Genre:${NC}"
|
||||||
|
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local current_bank=""
|
||||||
|
for entry in "${MODELS[@]}"; do
|
||||||
|
IFS='|' read -r local_name remote_name desc arch size <<< "$entry"
|
||||||
|
printf " %-35s %s\n" "$local_name" "$desc"
|
||||||
|
printf " %-35s %s\n" "" "($arch, ~${size} KB)"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${CYAN}Total: ${#MODELS[@]} models${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_download_model() {
|
||||||
|
local local_name="$1" remote_name="$2" desc="$3" arch="$4" size="$5"
|
||||||
|
local outfile="$NAM_DIR/$local_name"
|
||||||
|
|
||||||
|
if [[ -f "$outfile" ]]; then
|
||||||
|
local existing_size
|
||||||
|
existing_size=$(stat -c%s "$outfile" 2>/dev/null || echo 0)
|
||||||
|
if [[ $existing_size -gt 1000 ]]; then
|
||||||
|
ok "SKIP $local_name (already exists, $(( existing_size / 1024 )) KB)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "DL $local_name ($desc)"
|
||||||
|
|
||||||
|
# URL-encode the remote filename for GitHub raw URL
|
||||||
|
local encoded_name
|
||||||
|
encoded_name=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$remote_name'))" 2>/dev/null || echo "$remote_name")
|
||||||
|
local url="$BASE_URL/$encoded_name"
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -sL -o "$outfile" -w "%{http_code}" --connect-timeout 10 --max-time 60 "$url" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [[ "$http_code" == "200" ]]; then
|
||||||
|
local actual_size
|
||||||
|
actual_size=$(stat -c%s "$outfile" 2>/dev/null || echo 0)
|
||||||
|
if [[ $actual_size -lt 1000 ]]; then
|
||||||
|
err "Downloaded file too small (${actual_size} bytes) — removing"
|
||||||
|
rm -f "$outfile"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Verify it's valid JSON / .nam
|
||||||
|
if python3 -c "import json; json.load(open('$outfile'))" 2>/dev/null; then
|
||||||
|
ok "Downloaded ${local_name} ($(( actual_size / 1024 )) KB)"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
err "Invalid .nam file (not valid JSON) — removing"
|
||||||
|
rm -f "$outfile"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
err "HTTP $http_code for $url"
|
||||||
|
rm -f "$outfile"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_download_all_models() {
|
||||||
|
echo -e "\n${CYAN}Downloading ${#MODELS[@]} NAM models to $NAM_DIR${NC}"
|
||||||
|
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
mkdir -p "$NAM_DIR"
|
||||||
|
|
||||||
|
local success=0 fail=0
|
||||||
|
for entry in "${MODELS[@]}"; do
|
||||||
|
IFS='|' read -r local_name remote_name desc arch size <<< "$entry"
|
||||||
|
if _download_model "$local_name" "$remote_name" "$desc" "$arch" "$size"; then
|
||||||
|
((success++))
|
||||||
|
else
|
||||||
|
((fail++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Models: $success downloaded, $fail failed"
|
||||||
|
return "$fail"
|
||||||
|
}
|
||||||
|
|
||||||
|
_download_ir_pack() {
|
||||||
|
local name="$1" url="$2" fname="$3" desc="$4"
|
||||||
|
local dest="${IR_DIR}/${fname}"
|
||||||
|
|
||||||
|
if [[ -d "$IR_DIR/GodsCab_IR_Pack" ]] || [[ -d "$IR_DIR/Seacow_Cabs_Free_IRs_2023" ]]; then
|
||||||
|
local wav_count
|
||||||
|
wav_count=$(find "$IR_DIR" -maxdepth 2 -name '*.wav' 2>/dev/null | wc -l)
|
||||||
|
if [[ $wav_count -gt 10 ]]; then
|
||||||
|
ok "SKIP $name (IRs already extracted, $wav_count .wav files present)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Downloading $name..."
|
||||||
|
info " Source: $url"
|
||||||
|
|
||||||
|
if curl -sL -o "$dest" --connect-timeout 10 --max-time 120 "$url" 2>/dev/null; then
|
||||||
|
local size
|
||||||
|
size=$(stat -c%s "$dest" 2>/dev/null || echo 0)
|
||||||
|
if [[ $size -lt 10000 ]]; then
|
||||||
|
err "Downloaded file too small (${size} bytes)"
|
||||||
|
rm -f "$dest"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
ok "Downloaded ${fname} ($(( size / 1024 / 1024 )) MB)"
|
||||||
|
|
||||||
|
# Extract ZIP
|
||||||
|
if command -v unzip &>/dev/null; then
|
||||||
|
info "Extracting ${fname} to ${IR_DIR}..."
|
||||||
|
unzip -q -o "$dest" -d "$IR_DIR" 2>/dev/null && {
|
||||||
|
rm -f "$dest"
|
||||||
|
ok "Extracted to ${IR_DIR}/"
|
||||||
|
local wav_count
|
||||||
|
wav_count=$(find "$IR_DIR" -maxdepth 2 -name '*.wav' 2>/dev/null | wc -l)
|
||||||
|
info "IR files installed: $wav_count"
|
||||||
|
} || {
|
||||||
|
warn "Extraction had issues — ZIP saved at ${dest}"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
warn "unzip not found — ZIP saved at ${dest}"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
err "Failed to download $name from $url"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_download_all_irs() {
|
||||||
|
echo -e "\n${CYAN}Downloading IR packs to $IR_DIR${NC}"
|
||||||
|
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
mkdir -p "$IR_DIR"
|
||||||
|
|
||||||
|
local success=0 fail=0
|
||||||
|
for i in "${!IR_PACKS[@]}"; do
|
||||||
|
IFS='|' read -r name url fname desc <<< "${IR_PACKS[$i]}"
|
||||||
|
if _download_ir_pack "$name" "$url" "$fname" "$desc"; then
|
||||||
|
((success++))
|
||||||
|
else
|
||||||
|
((fail++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "IR packs: $success downloaded, $fail failed"
|
||||||
|
|
||||||
|
# Count installed IRs
|
||||||
|
local wav_count
|
||||||
|
wav_count=$(find "$IR_DIR" -maxdepth 2 -name '*.wav' 2>/dev/null | wc -l)
|
||||||
|
info "Total .wav IR files in $IR_DIR: $wav_count"
|
||||||
|
}
|
||||||
|
|
||||||
|
_show_counts() {
|
||||||
|
echo ""
|
||||||
|
local model_count nam_count ir_count
|
||||||
|
model_count=$(find "$NAM_DIR" -maxdepth 1 -name '*.nam' 2>/dev/null | wc -l)
|
||||||
|
nam_count=$(find "$HOME/.pedal/nam" -maxdepth 1 -name '*.nam' 2>/dev/null | wc -l)
|
||||||
|
ir_count=$(find "$IR_DIR" -maxdepth 2 -name '*.wav' 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
info "Models in $NAM_DIR: $model_count .nam files"
|
||||||
|
info "Models in ~/.pedal/nam: $nam_count .nam files (legacy)"
|
||||||
|
info "IRs in $IR_DIR: $ir_count .wav files"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
main() {
|
||||||
|
mkdir -p "$NAM_DIR" "$IR_DIR"
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--list|-l)
|
||||||
|
_list_models
|
||||||
|
_show_counts
|
||||||
|
;;
|
||||||
|
--models|-m)
|
||||||
|
_download_all_models
|
||||||
|
_show_counts
|
||||||
|
;;
|
||||||
|
--irs|-i)
|
||||||
|
_download_all_irs
|
||||||
|
_show_counts
|
||||||
|
;;
|
||||||
|
""|--all|-a)
|
||||||
|
_download_all_models
|
||||||
|
echo ""
|
||||||
|
_download_all_irs
|
||||||
|
echo ""
|
||||||
|
_show_counts
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ Download complete! ║${NC}"
|
||||||
|
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " Next steps:"
|
||||||
|
echo " 1. Run the inventory report:"
|
||||||
|
echo " python3 scripts/models/nam/build_inventory.py"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Verify models load correctly:"
|
||||||
|
echo " python3 -c \"from src.dsp.nam_host import NAMHost;"
|
||||||
|
echo " h = NAMHost(models_dir='$NAM_DIR');"
|
||||||
|
echo " for m in h.list_available_models(): print(m.name, m.architecture, f'{m.size_mb:.1f}MB')\""
|
||||||
|
echo ""
|
||||||
|
echo " 3. Check IRs:"
|
||||||
|
echo " python3 -c \"from pathlib import Path;"
|
||||||
|
echo " irs = list(Path('$IR_DIR').rglob('*.wav'));"
|
||||||
|
echo " print(f'{len(irs)} IRs loaded')\""
|
||||||
|
echo ""
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [OPTION]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --list, -l List models and IRs (without downloading)"
|
||||||
|
echo " --models, -m Download NAM models only"
|
||||||
|
echo " --irs, -i Download IR packs only"
|
||||||
|
echo " --all, -a Download everything (default)"
|
||||||
|
echo " --help, -h Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Environment:"
|
||||||
|
echo " NAM_DIR target for .nam files (default: ~/.pedal/models)"
|
||||||
|
echo " IR_DIR target for .wav IRs (default: ~/.pedal/irs)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
err "Unknown option: $1"
|
||||||
|
echo "Usage: $0 [--list|--models|--irs|--all|--help]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate factory presets for Keys, Vocals, and Backing Tracks channels.
|
||||||
|
|
||||||
|
Creates 3 banks × 4 presets per channel under presets/factory/<channel>/.
|
||||||
|
Bank numbers start at 21 for Keys, 24 for Vocals, 27 for Backing Tracks
|
||||||
|
to avoid collisions with the existing 0-20 factory bank range.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
FACTORY_DIR = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(__file__)), "presets", "factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── IR path shortcuts ───────────────────────────────────────────────────
|
||||||
|
IR = {
|
||||||
|
"vintage": "~/.pedal/irs/vintage-1x12.wav",
|
||||||
|
"british": "~/.pedal/irs/british-4x12.wav",
|
||||||
|
"american": "~/.pedal/irs/american-2x12.wav",
|
||||||
|
"modern": "~/.pedal/irs/modern-4x12.wav",
|
||||||
|
"jazz": "~/.pedal/irs/jazz-1x15.wav",
|
||||||
|
"boutique": "~/.pedal/irs/boutique-1x12.wav",
|
||||||
|
"mini": "~/.pedal/irs/mini-1x8.wav",
|
||||||
|
"bass_thick": "~/.pedal/irs/bass-thick_classic.wav",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Helper builders ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def noise_gate(threshold=0.01):
|
||||||
|
return {"fx_type": "noise_gate", "enabled": True, "bypass": False,
|
||||||
|
"params": {"threshold": threshold}, "nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def compressor(threshold=0.3, ratio=4.0, gain=0.8):
|
||||||
|
return {"fx_type": "compressor", "enabled": True, "bypass": False,
|
||||||
|
"params": {"threshold": threshold, "ratio": ratio, "gain": gain},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def boost(gain_db=6.0):
|
||||||
|
return {"fx_type": "boost", "enabled": True, "bypass": False,
|
||||||
|
"params": {"gain_db": gain_db}, "nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def overdrive(drive=0.5, gain=0.7, tone=0.5):
|
||||||
|
return {"fx_type": "overdrive", "enabled": True, "bypass": False,
|
||||||
|
"params": {"drive": drive, "tone": tone, "gain": gain},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def eq(bass=0.5, mid=0.5, treble=0.5):
|
||||||
|
return {"fx_type": "eq", "enabled": True, "bypass": False,
|
||||||
|
"params": {"bass": bass, "mid": mid, "treble": treble},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def ir_cab(ir_path):
|
||||||
|
return {"fx_type": "ir_cab", "enabled": True, "bypass": False,
|
||||||
|
"params": {"wet": 1.0, "dry": 0.0},
|
||||||
|
"nam_model_path": "", "ir_file_path": ir_path}
|
||||||
|
|
||||||
|
def delay(time=400.0, feedback=0.3, mix=0.25):
|
||||||
|
return {"fx_type": "delay", "enabled": True, "bypass": False,
|
||||||
|
"params": {"time": time, "feedback": feedback, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def reverb(decay=0.4, mix=0.25, subtype=""):
|
||||||
|
fx = {"fx_type": "reverb", "enabled": True, "bypass": False,
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
params = {"decay": decay, "mix": mix}
|
||||||
|
if subtype:
|
||||||
|
params["subtype"] = subtype
|
||||||
|
fx["params"] = params
|
||||||
|
return fx
|
||||||
|
|
||||||
|
def chorus(rate=0.35, depth=0.5, mix=0.3):
|
||||||
|
return {"fx_type": "chorus", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "depth": depth, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def phaser(rate=0.3, depth=0.5, feedback=0.3):
|
||||||
|
return {"fx_type": "phaser", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "depth": depth, "feedback": feedback},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def tremolo(rate=3.0, depth=0.4):
|
||||||
|
return {"fx_type": "tremolo", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "depth": depth},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def volume(level=0.85):
|
||||||
|
return {"fx_type": "volume", "enabled": True, "bypass": False,
|
||||||
|
"params": {"level": level}, "nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def pitch_shifter(semitones=0, mix=0.5):
|
||||||
|
return {"fx_type": "pitch_shifter", "enabled": True, "bypass": False,
|
||||||
|
"params": {"semitones": semitones, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def stereo_widener(width=0.5, mix=0.5):
|
||||||
|
return {"fx_type": "stereo_widener", "enabled": True, "bypass": False,
|
||||||
|
"params": {"width": width, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def auto_pan(rate=0.3, depth=0.5):
|
||||||
|
return {"fx_type": "auto_pan", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "depth": depth},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def high_pass_filter(cutoff=80.0):
|
||||||
|
return {"fx_type": "high_pass_filter", "enabled": True, "bypass": False,
|
||||||
|
"params": {"cutoff": cutoff},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def low_pass_filter(cutoff=8000.0):
|
||||||
|
return {"fx_type": "low_pass_filter", "enabled": True, "bypass": False,
|
||||||
|
"params": {"cutoff": cutoff},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def de_esser(threshold=0.5, reduction=0.4):
|
||||||
|
return {"fx_type": "de_esser", "enabled": True, "bypass": False,
|
||||||
|
"params": {"threshold": threshold, "reduction": reduction},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def shimmer_reverb(decay=0.6, mix=0.3):
|
||||||
|
return {"fx_type": "shimmer_reverb", "enabled": True, "bypass": False,
|
||||||
|
"params": {"decay": decay, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def multi_tap_delay(time=400.0, feedback=0.3, mix=0.25, taps=3):
|
||||||
|
return {"fx_type": "multi_tap_delay", "enabled": True, "bypass": False,
|
||||||
|
"params": {"time": time, "feedback": feedback, "mix": mix, "taps": taps},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def rotary_speaker(rate=1.5, mix=0.4):
|
||||||
|
return {"fx_type": "rotary_speaker", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def expander(threshold=0.2, ratio=2.0):
|
||||||
|
return {"fx_type": "expander", "enabled": True, "bypass": False,
|
||||||
|
"params": {"threshold": threshold, "ratio": ratio},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
# ── Channel definitions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CHANNELS = [
|
||||||
|
{
|
||||||
|
"slug": "keys",
|
||||||
|
"label": "Keys",
|
||||||
|
"bank_start": 21,
|
||||||
|
"banks": [
|
||||||
|
{
|
||||||
|
"name": "Piano & Electric",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "Grand Piano Clean",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.2, 5.0, 0.6),
|
||||||
|
eq(0.5, 0.5, 0.55),
|
||||||
|
reverb(0.35, 0.2, "hall"),
|
||||||
|
volume(0.85),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Warm Rhodes",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.25, 4.0, 0.65),
|
||||||
|
chorus(0.3, 0.4, 0.25),
|
||||||
|
eq(0.45, 0.55, 0.5),
|
||||||
|
reverb(0.4, 0.22, "hall"),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wurli Punch",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.3, 4.5, 0.7),
|
||||||
|
tremolo(4.0, 0.3),
|
||||||
|
eq(0.55, 0.4, 0.6),
|
||||||
|
reverb(0.3, 0.18, "room"),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Stage EP Layer",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.2, 5.0, 0.55),
|
||||||
|
stereo_widener(0.4, 0.3),
|
||||||
|
eq(0.4, 0.5, 0.6),
|
||||||
|
chorus(0.25, 0.35, 0.2),
|
||||||
|
reverb(0.45, 0.28, "hall"),
|
||||||
|
volume(0.84),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Synth Pads & Leads",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "Analog Pad",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.25, 4.5, 0.65),
|
||||||
|
stereo_widener(0.6, 0.4),
|
||||||
|
chorus(0.2, 0.45, 0.35),
|
||||||
|
auto_pan(0.15, 0.3),
|
||||||
|
reverb(0.55, 0.35, "hall"),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brass Synth Lead",
|
||||||
|
"master_volume": 0.85,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.01),
|
||||||
|
compressor(0.3, 3.5, 0.85),
|
||||||
|
boost(3.0),
|
||||||
|
eq(0.45, 0.35, 0.7),
|
||||||
|
delay(420, 0.25, 0.2),
|
||||||
|
reverb(0.4, 0.22, "hall"),
|
||||||
|
volume(0.88),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Arp Texture",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.2, 5.0, 0.55),
|
||||||
|
eq(0.4, 0.55, 0.65),
|
||||||
|
multi_tap_delay(300, 0.2, 0.3, 3),
|
||||||
|
reverb(0.5, 0.3, "hall"),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wobble Bass Synth",
|
||||||
|
"master_volume": 0.72,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.012),
|
||||||
|
compressor(0.35, 6.0, 0.6),
|
||||||
|
low_pass_filter(2000.0),
|
||||||
|
eq(0.65, 0.25, 0.4),
|
||||||
|
reverb(0.2, 0.1, "room"),
|
||||||
|
volume(0.74),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Organ & Vintage Keys",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "B3 Rotary Slow",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.2, 4.5, 0.65),
|
||||||
|
rotary_speaker(0.8, 0.4),
|
||||||
|
eq(0.5, 0.5, 0.55),
|
||||||
|
reverb(0.3, 0.15, "room"),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "B3 Rotary Fast",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.2, 4.5, 0.7),
|
||||||
|
rotary_speaker(5.0, 0.5),
|
||||||
|
eq(0.45, 0.55, 0.5),
|
||||||
|
reverb(0.25, 0.12, "room"),
|
||||||
|
volume(0.84),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Clavinet Funk",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.35, 5.0, 0.6),
|
||||||
|
eq(0.6, 0.3, 0.7),
|
||||||
|
phaser(0.2, 0.35, 0.2),
|
||||||
|
reverb(0.2, 0.1, "room"),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vox Continental",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.25, 4.0, 0.65),
|
||||||
|
eq(0.5, 0.45, 0.6),
|
||||||
|
tremolo(3.5, 0.3),
|
||||||
|
reverb(0.35, 0.18, "hall"),
|
||||||
|
volume(0.84),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "vocals",
|
||||||
|
"label": "Vocals",
|
||||||
|
"bank_start": 24,
|
||||||
|
"banks": [
|
||||||
|
{
|
||||||
|
"name": "Lead Vocals",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "Vocal Standard",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(80.0),
|
||||||
|
de_esser(0.5, 0.35),
|
||||||
|
compressor(0.25, 4.0, 0.7),
|
||||||
|
eq(0.45, 0.55, 0.55),
|
||||||
|
reverb(0.35, 0.22, "hall"),
|
||||||
|
volume(0.85),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Warm Lead",
|
||||||
|
"master_volume": 0.85,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(75.0),
|
||||||
|
de_esser(0.45, 0.4),
|
||||||
|
compressor(0.2, 4.5, 0.75),
|
||||||
|
eq(0.5, 0.5, 0.45),
|
||||||
|
reverb(0.4, 0.25, "hall"),
|
||||||
|
delay(380, 0.2, 0.15),
|
||||||
|
volume(0.88),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Big Ballad",
|
||||||
|
"master_volume": 0.84,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(70.0),
|
||||||
|
de_esser(0.5, 0.3),
|
||||||
|
compressor(0.22, 5.0, 0.8),
|
||||||
|
eq(0.45, 0.5, 0.5),
|
||||||
|
shimmer_reverb(0.55, 0.3),
|
||||||
|
delay(450, 0.3, 0.2),
|
||||||
|
volume(0.86),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dry & Present",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(90.0),
|
||||||
|
de_esser(0.55, 0.35),
|
||||||
|
compressor(0.28, 3.5, 0.85),
|
||||||
|
eq(0.4, 0.6, 0.55),
|
||||||
|
expander(0.15, 1.5),
|
||||||
|
volume(0.84),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Harmony & Backing",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "Airy Harmonies",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(100.0),
|
||||||
|
de_esser(0.5, 0.3),
|
||||||
|
compressor(0.2, 4.0, 0.6),
|
||||||
|
eq(0.4, 0.5, 0.65),
|
||||||
|
stereo_widener(0.5, 0.35),
|
||||||
|
reverb(0.45, 0.25, "hall"),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Doubler Effect",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(90.0),
|
||||||
|
compressor(0.25, 4.0, 0.65),
|
||||||
|
eq(0.45, 0.5, 0.55),
|
||||||
|
chorus(0.2, 0.3, 0.35),
|
||||||
|
delay(220, 0.05, 0.25),
|
||||||
|
reverb(0.3, 0.18, "room"),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wide Backing Stack",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(80.0),
|
||||||
|
de_esser(0.45, 0.35),
|
||||||
|
compressor(0.2, 4.5, 0.55),
|
||||||
|
eq(0.4, 0.55, 0.6),
|
||||||
|
stereo_widener(0.7, 0.4),
|
||||||
|
reverb(0.5, 0.28, "hall"),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Soft Pad Vox",
|
||||||
|
"master_volume": 0.74,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(120.0),
|
||||||
|
compressor(0.2, 5.0, 0.5),
|
||||||
|
eq(0.4, 0.5, 0.6),
|
||||||
|
chorus(0.18, 0.25, 0.4),
|
||||||
|
shimmer_reverb(0.5, 0.35),
|
||||||
|
volume(0.76),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vocal FX",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "Slap Delay",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(80.0),
|
||||||
|
de_esser(0.5, 0.3),
|
||||||
|
compressor(0.25, 4.0, 0.7),
|
||||||
|
eq(0.45, 0.5, 0.55),
|
||||||
|
delay(120, 0.15, 0.35),
|
||||||
|
reverb(0.25, 0.12, "room"),
|
||||||
|
volume(0.84),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ping Pong Echo",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(80.0),
|
||||||
|
de_esser(0.5, 0.3),
|
||||||
|
compressor(0.25, 4.0, 0.65),
|
||||||
|
eq(0.45, 0.5, 0.55),
|
||||||
|
delay(380, 0.35, 0.3),
|
||||||
|
reverb(0.3, 0.15, "hall"),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shimmer Wash",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(100.0),
|
||||||
|
de_esser(0.55, 0.25),
|
||||||
|
compressor(0.2, 4.0, 0.6),
|
||||||
|
eq(0.4, 0.5, 0.6),
|
||||||
|
shimmer_reverb(0.65, 0.35),
|
||||||
|
delay(500, 0.25, 0.2),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Radio Filter",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(300.0),
|
||||||
|
low_pass_filter(3500.0),
|
||||||
|
compressor(0.3, 6.0, 0.65),
|
||||||
|
eq(0.3, 0.1, 0.2),
|
||||||
|
reverb(0.2, 0.08, "room"),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "backing_tracks",
|
||||||
|
"label": "Backing Tracks",
|
||||||
|
"bank_start": 27,
|
||||||
|
"banks": [
|
||||||
|
{
|
||||||
|
"name": "Mix Ready",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "Stereo Master Bus",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(30.0),
|
||||||
|
low_pass_filter(18000.0),
|
||||||
|
compressor(0.2, 4.0, 0.6),
|
||||||
|
eq(0.5, 0.5, 0.5),
|
||||||
|
stereo_widener(0.3, 0.25),
|
||||||
|
volume(0.85),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kick & Bass Focus",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(25.0),
|
||||||
|
compressor(0.25, 5.0, 0.55),
|
||||||
|
eq(0.65, 0.35, 0.45),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Full Mix Compressed",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(30.0),
|
||||||
|
compressor(0.15, 6.0, 0.5),
|
||||||
|
eq(0.5, 0.5, 0.55),
|
||||||
|
multi_tap_delay(200, 0.08, 0.1, 2),
|
||||||
|
reverb(0.15, 0.08, "room"),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Transparent Mix",
|
||||||
|
"master_volume": 0.85,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(25.0),
|
||||||
|
compressor(0.2, 3.5, 0.65),
|
||||||
|
eq(0.5, 0.5, 0.5),
|
||||||
|
stereo_widener(0.2, 0.15),
|
||||||
|
volume(0.88),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Click & Guide",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "Click Track + Backing",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(40.0),
|
||||||
|
compressor(0.3, 4.0, 0.55),
|
||||||
|
eq(0.5, 0.4, 0.6),
|
||||||
|
reverb(0.2, 0.08, "room"),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Guide Vox Monitor",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(80.0),
|
||||||
|
de_esser(0.5, 0.3),
|
||||||
|
compressor(0.25, 4.0, 0.65),
|
||||||
|
eq(0.45, 0.5, 0.5),
|
||||||
|
reverb(0.3, 0.15, "room"),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Scratch Guitar Mix",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.3, 4.0, 0.6),
|
||||||
|
eq(0.5, 0.45, 0.55),
|
||||||
|
reverb(0.2, 0.1, "room"),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Stereo Reference",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
high_pass_filter(25.0),
|
||||||
|
low_pass_filter(20000.0),
|
||||||
|
compressor(0.18, 4.5, 0.55),
|
||||||
|
eq(0.5, 0.5, 0.55),
|
||||||
|
stereo_widener(0.4, 0.25),
|
||||||
|
volume(0.84),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ambient Pads",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"name": "Wash Pad",
|
||||||
|
"master_volume": 0.74,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.2, 5.0, 0.5),
|
||||||
|
eq(0.4, 0.55, 0.5),
|
||||||
|
chorus(0.15, 0.5, 0.4),
|
||||||
|
stereo_widener(0.6, 0.4),
|
||||||
|
shimmer_reverb(0.6, 0.35),
|
||||||
|
volume(0.76),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Texture Swell",
|
||||||
|
"master_volume": 0.72,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
expander(0.1, 3.0),
|
||||||
|
eq(0.4, 0.55, 0.55),
|
||||||
|
auto_pan(0.12, 0.35),
|
||||||
|
reverb(0.65, 0.4, "hall"),
|
||||||
|
volume(0.74),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Drone Bed",
|
||||||
|
"master_volume": 0.7,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
low_pass_filter(1000.0),
|
||||||
|
eq(0.55, 0.4, 0.35),
|
||||||
|
chorus(0.12, 0.4, 0.45),
|
||||||
|
reverb(0.7, 0.45, "hall"),
|
||||||
|
volume(0.72),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cinematic Swell",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.15, 5.0, 0.5),
|
||||||
|
eq(0.4, 0.5, 0.6),
|
||||||
|
stereo_widener(0.7, 0.35),
|
||||||
|
shimmer_reverb(0.55, 0.4),
|
||||||
|
delay(600, 0.2, 0.15),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
"""Write all factory preset JSON files to presets/factory/<channel>/bank_N/."""
|
||||||
|
total_presets = 0
|
||||||
|
total_banks = 0
|
||||||
|
|
||||||
|
for channel in CHANNELS:
|
||||||
|
ch_dir = os.path.join(FACTORY_DIR, channel["slug"])
|
||||||
|
bn = channel["bank_start"]
|
||||||
|
|
||||||
|
for bank_idx, bank_def in enumerate(channel["banks"]):
|
||||||
|
bank_num = bn + bank_idx
|
||||||
|
bank_dir = os.path.join(ch_dir, f"bank_{bank_num}")
|
||||||
|
os.makedirs(bank_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# bank.json
|
||||||
|
bank_meta = {
|
||||||
|
"number": bank_num,
|
||||||
|
"name": bank_def["name"],
|
||||||
|
"preset_count": len(bank_def["presets"]),
|
||||||
|
}
|
||||||
|
with open(os.path.join(bank_dir, "bank.json"), "w") as f:
|
||||||
|
json.dump(bank_meta, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# presets
|
||||||
|
for prog, preset_data in enumerate(bank_def["presets"]):
|
||||||
|
preset_json = {
|
||||||
|
"name": preset_data["name"],
|
||||||
|
"bank": bank_num,
|
||||||
|
"program": prog,
|
||||||
|
"master_volume": preset_data["master_volume"],
|
||||||
|
"chain": preset_data["chain"],
|
||||||
|
"midi_mappings": {},
|
||||||
|
}
|
||||||
|
path = os.path.join(bank_dir, f"preset_{prog}.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(preset_json, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
total_presets += 1
|
||||||
|
|
||||||
|
total_banks += 1
|
||||||
|
|
||||||
|
print(f"✅ {channel['label']}: {total_banks} banks so far, {total_presets} presets")
|
||||||
|
|
||||||
|
print(f"\n✅ Generated {total_presets} presets across {total_banks} banks in 3 channels:")
|
||||||
|
for channel in CHANNELS:
|
||||||
|
print(f" {channel['label']}: {len(channel['banks'])} banks × {len(channel['banks'][0]['presets'])} presets = {len(channel['banks']) * len(channel['banks'][0]['presets'])} presets")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Guard against running from wrong cwd
|
||||||
|
if not os.path.isdir(FACTORY_DIR):
|
||||||
|
# Try resolving relative to the script location
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
FACTORY_DIR_ALT = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(script_dir)), "presets", "factory"
|
||||||
|
)
|
||||||
|
if os.path.isdir(FACTORY_DIR_ALT):
|
||||||
|
FACTORY_DIR = FACTORY_DIR_ALT
|
||||||
|
else:
|
||||||
|
print(f"Error: factory directory not found at {FACTORY_DIR} or {FACTORY_DIR_ALT}")
|
||||||
|
sys.exit(1)
|
||||||
|
generate()
|
||||||
@@ -0,0 +1,812 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate 48 factory presets (12 banks x 4 presets) for Pi Multi-FX Pedal.
|
||||||
|
|
||||||
|
Replaces ALL existing factory presets in presets/factory/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
FACTORY_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "presets", "factory")
|
||||||
|
|
||||||
|
# ── Banks ──────────────────────────────────────────────────────────────
|
||||||
|
BANKS = [
|
||||||
|
{"number": 0, "name": "Classic Rock"},
|
||||||
|
{"number": 1, "name": "Punk"},
|
||||||
|
{"number": 2, "name": "Metal"},
|
||||||
|
{"number": 3, "name": "Hardcore"},
|
||||||
|
{"number": 4, "name": "Emo"},
|
||||||
|
{"number": 5, "name": "Midwest Emo"},
|
||||||
|
{"number": 6, "name": "Djent"},
|
||||||
|
{"number": 7, "name": "Acoustic"},
|
||||||
|
{"number": 8, "name": "Grunge"},
|
||||||
|
{"number": 9, "name": "Alternative Rock"},
|
||||||
|
{"number": 10, "name": "Garage Rock"},
|
||||||
|
{"number": 11, "name": "Pop"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── IR path shortcuts ─────────────────────────────────────────────────
|
||||||
|
IR = {
|
||||||
|
"vintage": "~/.pedal/irs/vintage-1x12.wav",
|
||||||
|
"british": "~/.pedal/irs/british-4x12.wav",
|
||||||
|
"american": "~/.pedal/irs/american-2x12.wav",
|
||||||
|
"modern": "~/.pedal/irs/modern-4x12.wav",
|
||||||
|
"jazz": "~/.pedal/irs/jazz-1x15.wav",
|
||||||
|
"boutique": "~/.pedal/irs/boutique-1x12.wav",
|
||||||
|
"mini": "~/.pedal/irs/mini-1x8.wav",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Helper builders ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def noise_gate(threshold=0.01):
|
||||||
|
return {"fx_type": "noise_gate", "enabled": True, "bypass": False,
|
||||||
|
"params": {"threshold": threshold}, "nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def compressor(threshold=0.3, ratio=4.0, gain=0.8):
|
||||||
|
return {"fx_type": "compressor", "enabled": True, "bypass": False,
|
||||||
|
"params": {"threshold": threshold, "ratio": ratio, "gain": gain},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def boost(gain_db=6.0):
|
||||||
|
return {"fx_type": "boost", "enabled": True, "bypass": False,
|
||||||
|
"params": {"gain_db": gain_db}, "nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def overdrive(drive=0.5, gain=0.7, tone=0.5):
|
||||||
|
return {"fx_type": "overdrive", "enabled": True, "bypass": False,
|
||||||
|
"params": {"drive": drive, "tone": tone, "gain": gain},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def distortion(drive=0.7, gain=0.5):
|
||||||
|
return {"fx_type": "distortion", "enabled": True, "bypass": False,
|
||||||
|
"params": {"drive": drive, "gain": gain},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def fuzz(gain=0.6):
|
||||||
|
return {"fx_type": "fuzz", "enabled": True, "bypass": False,
|
||||||
|
"params": {"gain": gain}, "nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def eq(bass=0.5, mid=0.5, treble=0.5):
|
||||||
|
return {"fx_type": "eq", "enabled": True, "bypass": False,
|
||||||
|
"params": {"bass": bass, "mid": mid, "treble": treble},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def ir_cab(ir_path):
|
||||||
|
return {"fx_type": "ir_cab", "enabled": True, "bypass": False,
|
||||||
|
"params": {"wet": 1.0, "dry": 0.0},
|
||||||
|
"nam_model_path": "", "ir_file_path": ir_path}
|
||||||
|
|
||||||
|
def delay(time=400.0, feedback=0.3, mix=0.25):
|
||||||
|
return {"fx_type": "delay", "enabled": True, "bypass": False,
|
||||||
|
"params": {"time": time, "feedback": feedback, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def reverb(decay=0.4, mix=0.25):
|
||||||
|
return {"fx_type": "reverb", "enabled": True, "bypass": False,
|
||||||
|
"params": {"decay": decay, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def chorus(rate=0.35, depth=0.5, mix=0.3):
|
||||||
|
return {"fx_type": "chorus", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "depth": depth, "mix": mix},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def phaser(rate=0.3, depth=0.5, feedback=0.3):
|
||||||
|
return {"fx_type": "phaser", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "depth": depth, "feedback": feedback},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def flanger(rate=0.2, depth=0.4, feedback=0.3):
|
||||||
|
return {"fx_type": "flanger", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "depth": depth, "feedback": feedback},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def tremolo(rate=3.0, depth=0.4):
|
||||||
|
return {"fx_type": "tremolo", "enabled": True, "bypass": False,
|
||||||
|
"params": {"rate": rate, "depth": depth},
|
||||||
|
"nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def volume(level=0.85):
|
||||||
|
return {"fx_type": "volume", "enabled": True, "bypass": False,
|
||||||
|
"params": {"level": level}, "nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
def octaver(mix=0.4):
|
||||||
|
return {"fx_type": "octaver", "enabled": True, "bypass": False,
|
||||||
|
"params": {"mix": mix}, "nam_model_path": "", "ir_file_path": ""}
|
||||||
|
|
||||||
|
# ── Preset definitions per bank ────────────────────────────────────────
|
||||||
|
|
||||||
|
PRESETS = {
|
||||||
|
0: [ # Classic Rock
|
||||||
|
{
|
||||||
|
"name": "Brown Sound Crunch",
|
||||||
|
"master_volume": 0.75,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.015),
|
||||||
|
overdrive(0.55, 0.75, 0.5),
|
||||||
|
eq(0.55, 0.35, 0.65),
|
||||||
|
delay(380, 0.25, 0.2),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Singing Lead",
|
||||||
|
"master_volume": 0.85,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.02),
|
||||||
|
compressor(0.35, 3.5, 1.1),
|
||||||
|
overdrive(0.7, 0.65, 0.6),
|
||||||
|
distortion(0.5, 0.5),
|
||||||
|
eq(0.5, 0.25, 0.75),
|
||||||
|
delay(450, 0.35, 0.3),
|
||||||
|
reverb(0.5, 0.25),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.88),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sparkling Clean",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.25, 4.0, 0.85),
|
||||||
|
eq(0.45, 0.5, 0.65),
|
||||||
|
chorus(0.3, 0.45, 0.3),
|
||||||
|
reverb(0.35, 0.2),
|
||||||
|
ir_cab(IR["vintage"]),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hard Rock Pounder",
|
||||||
|
"master_volume": 0.7,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.025),
|
||||||
|
boost(8.0),
|
||||||
|
distortion(0.75, 0.45),
|
||||||
|
eq(0.6, 0.25, 0.7),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.72),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
1: [ # Punk
|
||||||
|
{
|
||||||
|
"name": "Fast Riff Machine",
|
||||||
|
"master_volume": 0.72,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.02),
|
||||||
|
boost(6.0),
|
||||||
|
overdrive(0.6, 0.7, 0.45),
|
||||||
|
eq(0.5, 0.3, 0.7),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.75),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Snotty Lead",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.015),
|
||||||
|
overdrive(0.4, 0.85, 0.4),
|
||||||
|
distortion(0.65, 0.5),
|
||||||
|
eq(0.4, 0.35, 0.8),
|
||||||
|
delay(350, 0.2, 0.2),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.85),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jangle Pop Clean",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.3, 4.5, 0.75),
|
||||||
|
eq(0.4, 0.55, 0.7),
|
||||||
|
chorus(0.4, 0.35, 0.25),
|
||||||
|
reverb(0.3, 0.2),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hardcore Attack",
|
||||||
|
"master_volume": 0.68,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.03),
|
||||||
|
boost(9.0),
|
||||||
|
distortion(0.8, 0.4),
|
||||||
|
eq(0.55, 0.2, 0.75),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.7),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
2: [ # Metal
|
||||||
|
{
|
||||||
|
"name": "Chug Chug",
|
||||||
|
"master_volume": 0.65,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.035),
|
||||||
|
boost(10.0),
|
||||||
|
distortion(0.85, 0.35),
|
||||||
|
eq(0.65, 0.15, 0.7),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.68),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Scream Lead",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.03),
|
||||||
|
compressor(0.4, 3.0, 1.3),
|
||||||
|
boost(8.0),
|
||||||
|
distortion(0.75, 0.4),
|
||||||
|
eq(0.5, 0.2, 0.85),
|
||||||
|
delay(420, 0.3, 0.28),
|
||||||
|
reverb(0.4, 0.2),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.86),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Crystal Clean",
|
||||||
|
"master_volume": 0.75,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.2, 5.0, 0.7),
|
||||||
|
eq(0.4, 0.5, 0.65),
|
||||||
|
chorus(0.3, 0.5, 0.35),
|
||||||
|
reverb(0.45, 0.3),
|
||||||
|
ir_cab(IR["vintage"]),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Doom Descent",
|
||||||
|
"master_volume": 0.6,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.04),
|
||||||
|
boost(12.0),
|
||||||
|
fuzz(0.75),
|
||||||
|
distortion(0.9, 0.3),
|
||||||
|
eq(0.7, 0.1, 0.65),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.62),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
3: [ # Hardcore
|
||||||
|
{
|
||||||
|
"name": "Punch Drunk",
|
||||||
|
"master_volume": 0.7,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.025),
|
||||||
|
overdrive(0.5, 0.8, 0.4),
|
||||||
|
distortion(0.6, 0.5),
|
||||||
|
eq(0.55, 0.25, 0.7),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.73),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vein Lead",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.02),
|
||||||
|
compressor(0.35, 3.5, 1.0),
|
||||||
|
boost(7.0),
|
||||||
|
overdrive(0.65, 0.6, 0.5),
|
||||||
|
eq(0.5, 0.2, 0.8),
|
||||||
|
delay(380, 0.25, 0.25),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.83),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Breather Clean",
|
||||||
|
"master_volume": 0.74,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.28, 4.0, 0.8),
|
||||||
|
eq(0.45, 0.55, 0.6),
|
||||||
|
reverb(0.4, 0.25),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.76),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pit Stomp",
|
||||||
|
"master_volume": 0.66,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.035),
|
||||||
|
boost(9.0),
|
||||||
|
distortion(0.85, 0.35),
|
||||||
|
eq(0.6, 0.15, 0.75),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.68),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
4: [ # Emo
|
||||||
|
{
|
||||||
|
"name": "Twinkle Crunch",
|
||||||
|
"master_volume": 0.72,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.012),
|
||||||
|
compressor(0.3, 4.0, 0.8),
|
||||||
|
overdrive(0.35, 0.8, 0.55),
|
||||||
|
chorus(0.4, 0.3, 0.2),
|
||||||
|
eq(0.4, 0.6, 0.65),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.75),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Heartstring Lead",
|
||||||
|
"master_volume": 0.84,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.015),
|
||||||
|
compressor(0.3, 3.5, 1.0),
|
||||||
|
overdrive(0.6, 0.7, 0.5),
|
||||||
|
eq(0.45, 0.35, 0.75),
|
||||||
|
delay(480, 0.4, 0.35),
|
||||||
|
reverb(0.6, 0.3),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.86),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Diary Pages",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.25, 5.0, 0.7),
|
||||||
|
eq(0.35, 0.6, 0.55),
|
||||||
|
reverb(0.55, 0.3),
|
||||||
|
ir_cab(IR["vintage"]),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pop Core",
|
||||||
|
"master_volume": 0.7,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.02),
|
||||||
|
boost(7.0),
|
||||||
|
distortion(0.7, 0.45),
|
||||||
|
eq(0.5, 0.25, 0.7),
|
||||||
|
delay(350, 0.2, 0.15),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.72),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
5: [ # Midwest Emo
|
||||||
|
{
|
||||||
|
"name": "Mathy Tappy",
|
||||||
|
"master_volume": 0.72,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.01),
|
||||||
|
compressor(0.3, 4.5, 0.75),
|
||||||
|
overdrive(0.3, 0.85, 0.5),
|
||||||
|
eq(0.4, 0.55, 0.6),
|
||||||
|
delay(400, 0.2, 0.15),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.74),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Emotive Swell",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.012),
|
||||||
|
compressor(0.28, 4.0, 0.9),
|
||||||
|
overdrive(0.5, 0.75, 0.45),
|
||||||
|
eq(0.4, 0.4, 0.7),
|
||||||
|
delay(500, 0.35, 0.3),
|
||||||
|
reverb(0.5, 0.3),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.84),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fingerpicked Porch",
|
||||||
|
"master_volume": 0.75,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.006),
|
||||||
|
compressor(0.2, 5.0, 0.7),
|
||||||
|
eq(0.35, 0.65, 0.55),
|
||||||
|
chorus(0.25, 0.35, 0.2),
|
||||||
|
reverb(0.4, 0.22),
|
||||||
|
ir_cab(IR["boutique"]),
|
||||||
|
volume(0.77),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Power Chord Wall",
|
||||||
|
"master_volume": 0.68,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.02),
|
||||||
|
overdrive(0.55, 0.7, 0.45),
|
||||||
|
distortion(0.6, 0.5),
|
||||||
|
eq(0.5, 0.3, 0.65),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.7),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
6: [ # Djent
|
||||||
|
{
|
||||||
|
"name": "Machine Gun",
|
||||||
|
"master_volume": 0.62,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.04),
|
||||||
|
compressor(0.4, 6.0, 1.0),
|
||||||
|
boost(12.0),
|
||||||
|
distortion(0.85, 0.3),
|
||||||
|
eq(0.7, 0.1, 0.8),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.65),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polyphonic Lead",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.025),
|
||||||
|
compressor(0.35, 4.0, 1.1),
|
||||||
|
overdrive(0.6, 0.6, 0.55),
|
||||||
|
eq(0.55, 0.2, 0.8),
|
||||||
|
delay(440, 0.3, 0.28),
|
||||||
|
reverb(0.45, 0.2),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Clean Arpeggio",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.25, 5.0, 0.7),
|
||||||
|
eq(0.4, 0.5, 0.6),
|
||||||
|
chorus(0.3, 0.4, 0.3),
|
||||||
|
delay(480, 0.25, 0.2),
|
||||||
|
reverb(0.4, 0.25),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Syncopated Riff",
|
||||||
|
"master_volume": 0.6,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.045),
|
||||||
|
boost(10.0),
|
||||||
|
fuzz(0.7),
|
||||||
|
distortion(0.8, 0.3),
|
||||||
|
eq(0.65, 0.1, 0.75),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.62),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
7: [ # Acoustic
|
||||||
|
{
|
||||||
|
"name": "Body Strum",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.25, 4.5, 0.65),
|
||||||
|
eq(0.5, 0.6, 0.55),
|
||||||
|
reverb(0.35, 0.2),
|
||||||
|
ir_cab(IR["boutique"]),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fingerpicking Lead",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.2, 5.0, 0.7),
|
||||||
|
eq(0.4, 0.65, 0.5),
|
||||||
|
delay(380, 0.2, 0.18),
|
||||||
|
reverb(0.45, 0.25),
|
||||||
|
ir_cab(IR["boutique"]),
|
||||||
|
volume(0.84),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ambient Soundscape",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.005),
|
||||||
|
compressor(0.3, 4.0, 0.8),
|
||||||
|
eq(0.4, 0.5, 0.55),
|
||||||
|
chorus(0.2, 0.5, 0.4),
|
||||||
|
reverb(0.7, 0.4),
|
||||||
|
ir_cab(IR["jazz"]),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Percussive Slap",
|
||||||
|
"master_volume": 0.74,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.006),
|
||||||
|
compressor(0.35, 6.0, 0.6),
|
||||||
|
boost(4.0),
|
||||||
|
eq(0.55, 0.4, 0.65),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.76),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
8: [ # Grunge
|
||||||
|
{
|
||||||
|
"name": "Fuzz Festival",
|
||||||
|
"master_volume": 0.7,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.015),
|
||||||
|
overdrive(0.5, 0.75, 0.4),
|
||||||
|
fuzz(0.6),
|
||||||
|
eq(0.55, 0.2, 0.7),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.72),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Biting Solo",
|
||||||
|
"master_volume": 0.82,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.02),
|
||||||
|
boost(7.0),
|
||||||
|
distortion(0.65, 0.5),
|
||||||
|
eq(0.35, 0.15, 0.85),
|
||||||
|
delay(400, 0.3, 0.28),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.85),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grunge Clean",
|
||||||
|
"master_volume": 0.75,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.3, 4.0, 0.8),
|
||||||
|
overdrive(0.2, 0.9, 0.5),
|
||||||
|
phaser(0.25, 0.4, 0.3),
|
||||||
|
eq(0.45, 0.5, 0.6),
|
||||||
|
ir_cab(IR["vintage"]),
|
||||||
|
volume(0.77),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wall of Noise",
|
||||||
|
"master_volume": 0.65,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.025),
|
||||||
|
boost(10.0),
|
||||||
|
fuzz(0.8),
|
||||||
|
distortion(0.7, 0.35),
|
||||||
|
eq(0.6, 0.1, 0.75),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.67),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
9: [ # Alternative Rock
|
||||||
|
{
|
||||||
|
"name": "Indie Crunch",
|
||||||
|
"master_volume": 0.74,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.012),
|
||||||
|
overdrive(0.45, 0.78, 0.55),
|
||||||
|
eq(0.5, 0.4, 0.65),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.76),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Quirky Lead",
|
||||||
|
"master_volume": 0.84,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.015),
|
||||||
|
compressor(0.3, 3.5, 1.0),
|
||||||
|
overdrive(0.55, 0.65, 0.5),
|
||||||
|
flanger(0.15, 0.35, 0.25),
|
||||||
|
eq(0.45, 0.3, 0.75),
|
||||||
|
delay(420, 0.3, 0.28),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.86),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jangle Box",
|
||||||
|
"master_volume": 0.76,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.008),
|
||||||
|
compressor(0.25, 4.5, 0.75),
|
||||||
|
eq(0.4, 0.55, 0.65),
|
||||||
|
tremolo(4.0, 0.35),
|
||||||
|
reverb(0.3, 0.2),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.78),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alt Heavy",
|
||||||
|
"master_volume": 0.68,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.025),
|
||||||
|
boost(8.0),
|
||||||
|
distortion(0.75, 0.4),
|
||||||
|
octaver(0.3),
|
||||||
|
eq(0.55, 0.2, 0.75),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.7),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
10: [ # Garage Rock
|
||||||
|
{
|
||||||
|
"name": "Lo-Fi Crunch",
|
||||||
|
"master_volume": 0.72,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.015),
|
||||||
|
overdrive(0.6, 0.7, 0.35),
|
||||||
|
eq(0.5, 0.3, 0.55),
|
||||||
|
ir_cab(IR["mini"]),
|
||||||
|
volume(0.74),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fuzzed Out",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.01),
|
||||||
|
boost(6.0),
|
||||||
|
fuzz(0.7),
|
||||||
|
eq(0.45, 0.25, 0.65),
|
||||||
|
delay(300, 0.3, 0.25),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Raw Clean",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.006),
|
||||||
|
eq(0.45, 0.55, 0.5),
|
||||||
|
tremolo(2.5, 0.3),
|
||||||
|
reverb(0.3, 0.18),
|
||||||
|
ir_cab(IR["vintage"]),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sludge Fuzz",
|
||||||
|
"master_volume": 0.62,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.03),
|
||||||
|
boost(12.0),
|
||||||
|
fuzz(0.85),
|
||||||
|
distortion(0.7, 0.3),
|
||||||
|
eq(0.65, 0.1, 0.6),
|
||||||
|
ir_cab(IR["modern"]),
|
||||||
|
volume(0.64),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
11: [ # Pop
|
||||||
|
{
|
||||||
|
"name": "Clean Pop",
|
||||||
|
"master_volume": 0.78,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.006),
|
||||||
|
compressor(0.28, 4.5, 0.7),
|
||||||
|
overdrive(0.2, 0.9, 0.6),
|
||||||
|
eq(0.45, 0.5, 0.6),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.8),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Catchy Hook",
|
||||||
|
"master_volume": 0.84,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.01),
|
||||||
|
compressor(0.3, 4.0, 0.9),
|
||||||
|
overdrive(0.4, 0.8, 0.5),
|
||||||
|
chorus(0.35, 0.4, 0.3),
|
||||||
|
delay(380, 0.25, 0.22),
|
||||||
|
ir_cab(IR["american"]),
|
||||||
|
volume(0.86),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shimmer Clean",
|
||||||
|
"master_volume": 0.8,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.006),
|
||||||
|
compressor(0.2, 5.0, 0.65),
|
||||||
|
eq(0.35, 0.55, 0.65),
|
||||||
|
chorus(0.3, 0.5, 0.35),
|
||||||
|
reverb(0.5, 0.3),
|
||||||
|
ir_cab(IR["vintage"]),
|
||||||
|
volume(0.82),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pop Punk",
|
||||||
|
"master_volume": 0.72,
|
||||||
|
"chain": [
|
||||||
|
noise_gate(0.018),
|
||||||
|
overdrive(0.5, 0.75, 0.45),
|
||||||
|
distortion(0.55, 0.55),
|
||||||
|
eq(0.5, 0.3, 0.7),
|
||||||
|
delay(320, 0.15, 0.12),
|
||||||
|
ir_cab(IR["british"]),
|
||||||
|
volume(0.74),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Generate all files ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
# Wipe existing factory dir
|
||||||
|
if os.path.exists(FACTORY_DIR):
|
||||||
|
# Remove all bank dirs but keep irs/
|
||||||
|
for entry in os.listdir(FACTORY_DIR):
|
||||||
|
path = os.path.join(FACTORY_DIR, entry)
|
||||||
|
if entry != "irs" and os.path.isdir(path):
|
||||||
|
shutil.rmtree(path)
|
||||||
|
elif entry != "irs" and os.path.isfile(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
for bank in BANKS:
|
||||||
|
num = bank["number"]
|
||||||
|
bank_dir = os.path.join(FACTORY_DIR, f"bank_{num}")
|
||||||
|
os.makedirs(bank_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# bank.json
|
||||||
|
bank_json = {
|
||||||
|
"number": num,
|
||||||
|
"name": bank["name"],
|
||||||
|
"preset_count": 4,
|
||||||
|
}
|
||||||
|
with open(os.path.join(bank_dir, "bank.json"), "w") as f:
|
||||||
|
json.dump(bank_json, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# 4 presets
|
||||||
|
for prog in range(4):
|
||||||
|
preset_data = PRESETS[num][prog]
|
||||||
|
preset_json = {
|
||||||
|
"name": preset_data["name"],
|
||||||
|
"bank": num,
|
||||||
|
"program": prog,
|
||||||
|
"master_volume": preset_data["master_volume"],
|
||||||
|
"tuner_enabled": False,
|
||||||
|
"chain": preset_data["chain"],
|
||||||
|
"midi_mappings": {},
|
||||||
|
}
|
||||||
|
path = os.path.join(bank_dir, f"preset_{prog}.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(preset_json, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
print(f"✅ Generated {len(BANKS) * 4} presets across {len(BANKS)} banks")
|
||||||
|
for bank in BANKS:
|
||||||
|
print(f" Bank {bank['number']}: {bank['name']} — 4 presets")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generate()
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
5150_EVH
|
5150_BlockLetter_Boosted
|
||||||
Diezel_VH4
|
5152_Lead_TS9
|
||||||
Engl_Powerball
|
6505_Red_NoBoost
|
||||||
Fender_Bassman_59
|
BOSS_SD1_Feather
|
||||||
Fender_Twin_Clean
|
Bogner_Ecstasy
|
||||||
Marshall_JCM800
|
Engl_Savage
|
||||||
Mesa_Boogie_MarkV
|
Fender_TwinVerb_Clean
|
||||||
Orange_AD30
|
Fender_TwinVerb_Vibrato
|
||||||
Roland_Jazz_Chorus
|
Friedman_PowerAmp_Feather
|
||||||
|
JCM2000_Clean
|
||||||
|
JCM2000_Crunch
|
||||||
|
JCM900_Lead
|
||||||
|
Klon_Centaur_High
|
||||||
|
MXR_M77_Feather
|
||||||
|
Magnatone_Super59_Pushed
|
||||||
|
Mesa_MarkIV_Lead
|
||||||
|
Orange_Rockerverb
|
||||||
|
Precision_Drive_Feather
|
||||||
|
Splawn_ProMod_3rd
|
||||||
|
TS808_Feather
|
||||||
|
TubeScreamer_Maxed
|
||||||
Vox_AC15_TopBoost
|
Vox_AC15_TopBoost
|
||||||
|
|||||||
Executable
+387
@@ -0,0 +1,387 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# Pi Multi-FX Pedal — RT Performance Tuning Script
|
||||||
|
#
|
||||||
|
# Applies real-time audio optimizations for stable guitar playing on
|
||||||
|
# RPi 4B. Designed to run at boot (via systemd or main.py) and on
|
||||||
|
# every JACK/audio profile change.
|
||||||
|
#
|
||||||
|
# Targets:
|
||||||
|
# - Round-trip latency: <12ms (ideally <8ms)
|
||||||
|
# - Zero xruns during playing
|
||||||
|
# - CPU <40% on Pi 4B at recommended settings (512/48k)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo ./rt-tune.sh # apply all optimizations
|
||||||
|
# sudo ./rt-tune.sh --status # check current settings
|
||||||
|
# sudo ./rt-tune.sh --irq-only # just IRQ affinity
|
||||||
|
# sudo ./rt-tune.sh --usb-audio-irq # find + pin USB audio IRQ
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Colour helpers ──────────────────────────────────────────────────
|
||||||
|
info() { printf "\\e[34m[INFO] %s\\e[0m\\n" "$*"; }
|
||||||
|
ok() { printf "\\e[32m[ OK ] %s\\e[0m\\n" "$*"; }
|
||||||
|
warn() { printf "\\e[33m[WARN] %s\\e[0m\\n" "$*"; }
|
||||||
|
err() { printf "\\e[31m[FAIL] %s\\e[0m\\n" "$*"; }
|
||||||
|
|
||||||
|
# ── Config ─────────────────────────────────────────────────────────
|
||||||
|
# Which CPU core to dedicate to audio IRQ handling
|
||||||
|
# RPi 4B has 4 cores (0-3). Core 3 is largely unused by Linux housekeeping.
|
||||||
|
# Core 0 = most interrupts + kernel, Core 1-2 = general, Core 3 = isolated
|
||||||
|
IRQ_CORE=3
|
||||||
|
|
||||||
|
# Default JACK parameters (will be overridden by main.py dynamically)
|
||||||
|
JACK_PERIOD=512
|
||||||
|
JACK_RATE=48000
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Find the USB audio interface IRQ number
|
||||||
|
find_usb_audio_irq() {
|
||||||
|
# Look for the USB audio device in /proc/interrupts
|
||||||
|
# On RPi 4B, the USB controller is on a PCIe bridge (xhci-hcd) or
|
||||||
|
# directly on the BCM2711's DWC2/dwc_otg USB controller.
|
||||||
|
# Focusrite Scarlett 2i2 shows up as a USB interrupt tied to xhci-hcd.
|
||||||
|
|
||||||
|
# Strategy 1: Look for xhci-hcd (USB 3.0 controller on Pi 4B)
|
||||||
|
local irq
|
||||||
|
irq=$(awk '/xhci-hcd/ {gsub(":","",$1); print $1}' /proc/interrupts 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
# Strategy 2: Look for dwc_otg/dwc2 (USB 2.0 controller)
|
||||||
|
if [[ -z "$irq" ]]; then
|
||||||
|
irq=$(awk '/dwc_otg|dwc2/ {gsub(":","",$1); print $1}' /proc/interrupts 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strategy 3: Find the USB controller from sysfs for the audio device
|
||||||
|
if [[ -z "$irq" ]]; then
|
||||||
|
# Try to find the USB device that's our audio interface
|
||||||
|
# Look for USB audio class devices
|
||||||
|
local usb_dev
|
||||||
|
usb_dev=$(grep -l "audio" /sys/bus/usb/devices/*/bInterfaceClass 2>/dev/null | head -1)
|
||||||
|
if [[ -n "$usb_dev" ]]; then
|
||||||
|
local usb_path
|
||||||
|
usb_path=$(dirname "$usb_dev")
|
||||||
|
# Walk up to find the parent USB controller
|
||||||
|
while [[ "$usb_path" != "/sys/bus/usb/devices" && "$usb_path" != "/" ]]; do
|
||||||
|
if [[ -f "$usb_path/irq" ]]; then
|
||||||
|
irq=$(cat "$usb_path/irq" 2>/dev/null)
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
usb_path=$(dirname "$usb_path" 2>/dev/null)
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$irq"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Apply IRQ affinity ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
apply_irq_affinity() {
|
||||||
|
local irq="$1"
|
||||||
|
local core="$2"
|
||||||
|
local smp_affinity
|
||||||
|
|
||||||
|
if [[ -z "$irq" || "$irq" == "0" ]]; then
|
||||||
|
warn "No USB audio IRQ found — cannot set affinity"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Convert core number to hex mask for /proc/irq/*/smp_affinity
|
||||||
|
# Core 0 = 1, Core 1 = 2, Core 2 = 4, Core 3 = 8
|
||||||
|
smp_affinity=$(printf "%x" $((1 << core)))
|
||||||
|
|
||||||
|
local irq_dir="/proc/irq/$irq"
|
||||||
|
if [[ ! -d "$irq_dir" ]]; then
|
||||||
|
warn "IRQ $irq directory not found at $irq_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$smp_affinity" > "$irq_dir/smp_affinity" 2>/dev/null || true
|
||||||
|
local effective
|
||||||
|
effective=$(cat "$irq_dir/smp_affinity" 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
# Also set smp_affinity_list for convenience
|
||||||
|
echo "$core" > "$irq_dir/smp_affinity_list" 2>/dev/null || true
|
||||||
|
|
||||||
|
info "IRQ $irq pinned to core $core (smp_affinity=0x$smp_affinity, effective=0x$effective)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Set RT priority for the current process ──────────────────────────
|
||||||
|
|
||||||
|
set_self_rt_priority() {
|
||||||
|
local prio="$1"
|
||||||
|
# Set SCHED_FIFO with the given priority for our process group
|
||||||
|
# Note: This only works if called from the target process or with CAP_SYS_NICE
|
||||||
|
chrt -f -p "$prio" $$ 2>/dev/null || warn "Cannot set self RT priority (not running as root?)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── CPU isolation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Move all non-critical interrupts away from the IRQ core
|
||||||
|
isolate_core_from_housekeeping() {
|
||||||
|
local core="$1"
|
||||||
|
local exclude_irqs="$2" # comma-separated IRQs to keep on the isolated core
|
||||||
|
|
||||||
|
# For each IRQ, move it away from our dedicated audio core
|
||||||
|
# unless it's one we explicitly want to keep there
|
||||||
|
for irq_dir in /proc/irq/[0-9]*/; do
|
||||||
|
local irq_num
|
||||||
|
irq_num=$(basename "$irq_dir")
|
||||||
|
local irq_name
|
||||||
|
irq_name=$(cat "${irq_dir}affinity_hint" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Skip if this is the USB audio IRQ we want to keep pinned
|
||||||
|
if [[ ",$exclude_irqs," == *",$irq_num,"* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Move to other cores (0-2, leaving core 3 free)
|
||||||
|
# Affinity = 0x7 (cores 0,1,2)
|
||||||
|
echo "7" > "${irq_dir}smp_affinity" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
info "Non-audio IRQs moved away from core $core"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Status check ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
echo ""
|
||||||
|
info "========== RT Performance Status =========="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# CPU governor
|
||||||
|
echo "── CPU Governor ──"
|
||||||
|
for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
|
||||||
|
local cpu
|
||||||
|
cpu=$(basename "$(dirname "$c")")
|
||||||
|
echo " $cpu: $(cat "$c" 2>/dev/null || echo 'N/A')"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Current frequency
|
||||||
|
echo ""
|
||||||
|
echo "── CPU Frequency ──"
|
||||||
|
for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq; do
|
||||||
|
local cpu
|
||||||
|
cpu=$(basename "$(dirname "$c")")
|
||||||
|
local freq_khz
|
||||||
|
freq_khz=$(cat "$c" 2>/dev/null || echo 'N/A')
|
||||||
|
echo " $cpu: $((freq_khz / 1000)) MHz"
|
||||||
|
done
|
||||||
|
|
||||||
|
# IRQ affinity
|
||||||
|
echo ""
|
||||||
|
echo "── IRQ Affinity ──"
|
||||||
|
local irq
|
||||||
|
irq=$(find_usb_audio_irq)
|
||||||
|
if [[ -n "$irq" && "$irq" != "0" ]]; then
|
||||||
|
local aff
|
||||||
|
aff=$(cat "/proc/irq/$irq/smp_affinity" 2>/dev/null || echo "N/A")
|
||||||
|
local name
|
||||||
|
name=$(cat "/proc/irq/$irq/name" 2>/dev/null || echo "unknown")
|
||||||
|
echo " USB Audio IRQ $irq ($name): smp_affinity=0x$aff"
|
||||||
|
else
|
||||||
|
echo " No USB audio IRQ found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# All IRQs
|
||||||
|
echo ""
|
||||||
|
echo "── All Interrupts (affinity) ──"
|
||||||
|
for irq_dir in /proc/irq/[0-9]*/; do
|
||||||
|
local i
|
||||||
|
i=$(basename "$irq_dir")
|
||||||
|
local aff
|
||||||
|
aff=$(cat "${irq_dir}smp_affinity" 2>/dev/null || echo "?")
|
||||||
|
local name
|
||||||
|
name=$(cat "${irq_dir}name" 2>/dev/null || echo "?")
|
||||||
|
printf " IRQ %-4s 0x%-4s %s\\n" "$i" "$aff" "$name"
|
||||||
|
done
|
||||||
|
|
||||||
|
# JACK status
|
||||||
|
echo ""
|
||||||
|
echo "── JACK Status ──"
|
||||||
|
if pidof jackd >/dev/null 2>&1; then
|
||||||
|
echo " jackd: RUNNING"
|
||||||
|
# Show JACK command line
|
||||||
|
ps aux | grep jackd | grep -v grep | head -1 | awk '{$1=$2=$3=$4=$5=$6=$7=$8=$9=$10=""; print " Args:" $0}'
|
||||||
|
else
|
||||||
|
echo " jackd: NOT RUNNING"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# RT priority of processes
|
||||||
|
echo ""
|
||||||
|
echo "── RT Priority ──"
|
||||||
|
for proc in jackd python3; do
|
||||||
|
pidof "$proc" 2>/dev/null | tr ' ' '\\n' | while read -r pid; do
|
||||||
|
local policy
|
||||||
|
policy=$(chrt -p "$pid" 2>/dev/null | head -1 || echo "N/A")
|
||||||
|
echo " $proc (PID $pid): $policy"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# Memory locking
|
||||||
|
echo ""
|
||||||
|
echo "── Memory Locking ──"
|
||||||
|
if [[ -f /proc/self/status ]]; then
|
||||||
|
grep -i "lock" /proc/self/status 2>/dev/null | head -5 | while read -r line; do
|
||||||
|
echo " $line"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# XRun debug
|
||||||
|
echo ""
|
||||||
|
echo "── XRun Debug ──"
|
||||||
|
for card in /proc/asound/card*/xrun_debug; do
|
||||||
|
echo " $card: $(cat "$card" 2>/dev/null || echo 'N/A')"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Status check complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Main
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
err "This script must be run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MODE="${1:-all}"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
--status|-s)
|
||||||
|
show_status
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
|
||||||
|
--irq-only|-i)
|
||||||
|
IRQ=$(find_usb_audio_irq)
|
||||||
|
if [[ -n "$IRQ" && "$IRQ" != "0" ]]; then
|
||||||
|
apply_irq_affinity "$IRQ" "$IRQ_CORE" && ok "IRQ affinity applied"
|
||||||
|
else
|
||||||
|
warn "No USB audio IRQ found — checking /proc/interrupts..."
|
||||||
|
grep -E "xhci|dwc|usb|audio" /proc/interrupts | head -10
|
||||||
|
echo ""
|
||||||
|
info "To find the right IRQ manually:"
|
||||||
|
info " cat /proc/interrupts | grep xhci"
|
||||||
|
info " cat /sys/bus/usb/devices/*/irq 2>/dev/null"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
|
||||||
|
--usb-audio-irq|-u)
|
||||||
|
IRQ=$(find_usb_audio_irq)
|
||||||
|
if [[ -n "$IRQ" && "$IRQ" != "0" ]]; then
|
||||||
|
local name
|
||||||
|
name=$(cat "/proc/irq/$IRQ/name" 2>/dev/null || echo "unknown")
|
||||||
|
info "USB Audio IRQ = $IRQ ($name)"
|
||||||
|
else
|
||||||
|
warn "No USB audio IRQ found"
|
||||||
|
echo "── /proc/interrupts (USB/audio lines) ──"
|
||||||
|
grep -E "xhci|dwc|usb|audio|snd" /proc/interrupts | head -10
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
|
||||||
|
all|*)
|
||||||
|
# Full tuning
|
||||||
|
info "========== Pi Multi-FX Pedal — RT Tuning =========="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 1. Find and pin USB audio IRQ ────────────────────────
|
||||||
|
info "Step 1: USB audio IRQ affinity..."
|
||||||
|
IRQ=$(find_usb_audio_irq)
|
||||||
|
if [[ -n "$IRQ" && "$IRQ" != "0" ]]; then
|
||||||
|
apply_irq_affinity "$IRQ" "$IRQ_CORE"
|
||||||
|
# Isolate core 3 from housekeeping interrupts
|
||||||
|
isolate_core_from_housekeeping "$IRQ_CORE" "$IRQ"
|
||||||
|
else
|
||||||
|
warn "USB audio IRQ not found — audio may work but without dedicated IRQ core"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. CPU governor (already done in main.py, but belt-and-braces) ──
|
||||||
|
info "Step 2: CPU governor → performance..."
|
||||||
|
echo performance | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor >/dev/null 2>&1 || \
|
||||||
|
warn "Could not set CPU governor (may need cpufrequtils)"
|
||||||
|
|
||||||
|
# ── 3. Disable CPU idle states (C-states) for lower latency ──
|
||||||
|
# This prevents the CPU from entering deep sleep that adds latency
|
||||||
|
# on wake. Trade-off: ~0.5W extra power draw.
|
||||||
|
info "Step 3: Disabling deep CPU idle states..."
|
||||||
|
if [[ -f /sys/module/processor/parameters/max_cstate ]]; then
|
||||||
|
echo 1 > /sys/module/processor/parameters/max_cstate 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -f /dev/cpu_dma_latency ]]; then
|
||||||
|
# Write 0 to request minimum DMA latency (blocks deep C-states)
|
||||||
|
# The file stays open while the process lives — we do it briefly
|
||||||
|
echo 0 > /dev/cpu_dma_latency 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Set JACK's ALSA buffer sizes for low latency ───────────
|
||||||
|
# These are advisory — main.py overrides them via jackd arguments
|
||||||
|
info "Step 4: ALSA buffer constraints (advisory)..."
|
||||||
|
cat > /etc/security/limits.d/99-audio.conf <<'LIMITS'
|
||||||
|
# Pi Multi-FX Pedal — Real-time audio limits
|
||||||
|
# Applied by rt-tune.sh
|
||||||
|
@audio - rtprio 95
|
||||||
|
@audio - memlock unlimited
|
||||||
|
@audio - nice -20
|
||||||
|
LIMITS
|
||||||
|
ok "Audio limits written to /etc/security/limits.d/99-audio.conf"
|
||||||
|
|
||||||
|
# ── 5. Set xrun_debug for diagnostics ─────────────────────────
|
||||||
|
info "Step 5: Enable xrun tracking..."
|
||||||
|
for card in /proc/asound/card*/xrun_debug; do
|
||||||
|
if [[ -w "$card" ]]; then
|
||||||
|
# Bit 0 = enable xrun logging
|
||||||
|
# Bit 1 = show stack backtrace on xrun
|
||||||
|
# Bit 2 = inhibit xrun (test mode)
|
||||||
|
# Value 3 = log xruns with backtrace (diagnostic)
|
||||||
|
echo 3 > "$card" 2>/dev/null || true
|
||||||
|
ok "Set xrun_debug on $(dirname "$card" | xargs basename)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 6. Process RT priority (for the calling process) ──────────
|
||||||
|
info "Step 6: Setting RT priority..."
|
||||||
|
# This is primarily done by main.py (mlockall, GC disable)
|
||||||
|
# and the systemd service (LimitRTPRIO). We set it here too
|
||||||
|
# so the script works in all contexts.
|
||||||
|
set_self_rt_priority 80 || true
|
||||||
|
|
||||||
|
# ── 7. Systemd service check ──────────────────────────────────
|
||||||
|
info "Step 7: Verifying systemd RT limits..."
|
||||||
|
if systemctl is-active pi-multifx-pedal.service &>/dev/null; then
|
||||||
|
local rtprio
|
||||||
|
rtprio=$(systemctl show pi-multifx-pedal.service -p LimitRTPRIO --value 2>/dev/null || echo "?")
|
||||||
|
local memlock
|
||||||
|
memlock=$(systemctl show pi-multifx-pedal.service -p LimitMEMLOCK --value 2>/dev/null || echo "?")
|
||||||
|
local nice
|
||||||
|
nice=$(systemctl show pi-multifx-pedal.service -p LimitNICE --value 2>/dev/null || echo "?")
|
||||||
|
info " LimitRTPRIO=$rtprio LimitMEMLOCK=$memlock LimitNICE=$nice"
|
||||||
|
if [[ "$rtprio" != "95" ]]; then
|
||||||
|
warn "LimitRTPRIO should be 95 — check pi-multifx-pedal.service"
|
||||||
|
fi
|
||||||
|
if [[ "$memlock" != "infinity" ]]; then
|
||||||
|
warn "LimitMEMLOCK should be infinity — check pi-multifx-pedal.service"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "pi-multifx-pedal.service not running — check after deployment"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "========== RT Tuning Complete =========="
|
||||||
|
echo ""
|
||||||
|
info "Recommended next steps:"
|
||||||
|
info " 1. Start pedal: sudo systemctl start pi-multifx-pedal.service"
|
||||||
|
info " 2. Check logs: journalctl -fu pi-multifx-pedal.service"
|
||||||
|
info " 3. Test latency: jack_iodelay (needs loopback cable)"
|
||||||
|
info " 4. Check xruns: cat /proc/asound/card*/xrun_debug"
|
||||||
|
info " 5. Full status: sudo ./rt-tune.sh --status"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -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."
|
||||||
|
|||||||
@@ -43,6 +43,54 @@ class IRFile:
|
|||||||
length_ms: float
|
length_ms: float
|
||||||
channels: int = 1
|
channels: int = 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Return a clean human-readable name by stripping common IR prefixes/suffixes.
|
||||||
|
|
||||||
|
Strips leading/trailing markers commonly found in IR filenames
|
||||||
|
(e.g. ``IR_Marshall_1960A.wav`` → ``Marshall 1960A``,
|
||||||
|
``cab_Vox_AC30_48k.wav`` → ``Vox AC30``).
|
||||||
|
"""
|
||||||
|
name = self.name
|
||||||
|
|
||||||
|
# Common IR prefixes (case-insensitive)
|
||||||
|
_prefixes = [
|
||||||
|
"IR_", "ir_", "IR-", "ir-",
|
||||||
|
"Impulse_Response_", "impulse_response_",
|
||||||
|
"Impulse-Response-", "impulse-response-",
|
||||||
|
"Cab_", "cab_", "Cab-", "cab-",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Common IR suffixes (case-insensitive)
|
||||||
|
_suffixes = [
|
||||||
|
"_IR", "_ir", "-IR", "-ir",
|
||||||
|
"_Impulse", "_impulse", "-Impulse", "-impulse",
|
||||||
|
"_Cab", "_cab", "-Cab", "-cab",
|
||||||
|
"_Stereo", "_stereo", "-Stereo", "-stereo",
|
||||||
|
"_Mono", "_mono", "-Mono", "-mono",
|
||||||
|
"_48k", "_48K", "-48k", "-48K",
|
||||||
|
"_44100", "-44100",
|
||||||
|
"_48kHz", "-48kHz",
|
||||||
|
"_16bit", "_24bit", "-16bit", "-24bit",
|
||||||
|
"_LD", "_SD", "-LD", "-SD",
|
||||||
|
]
|
||||||
|
|
||||||
|
for prefix in _prefixes:
|
||||||
|
if name.lower().startswith(prefix.lower()):
|
||||||
|
name = name[len(prefix):]
|
||||||
|
break
|
||||||
|
|
||||||
|
for suffix in _suffixes:
|
||||||
|
if name.lower().endswith(suffix.lower()):
|
||||||
|
name = name[:-len(suffix)]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Replace separators with spaces and clean up
|
||||||
|
name = name.replace("_", " ").replace("-", " ")
|
||||||
|
name = " ".join(name.split()).strip()
|
||||||
|
|
||||||
|
return name if name else self.name
|
||||||
|
|
||||||
|
|
||||||
def _next_pow2(n: int) -> int:
|
def _next_pow2(n: int) -> int:
|
||||||
"""Return the smallest power of 2 >= n."""
|
"""Return the smallest power of 2 >= n."""
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,307 @@
|
|||||||
|
"""Python interface to the C++ NAM engine subprocess.
|
||||||
|
|
||||||
|
Spawns nam_engine as a subprocess and communicates via stdin/stdout
|
||||||
|
pipes for real-time audio processing. Falls back to PyTorch if the
|
||||||
|
C++ engine is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ENGINE_PATH = Path(__file__).parent / 'nam_engine'
|
||||||
|
DEFAULT_BLOCK_SIZE = 256
|
||||||
|
DEFAULT_SAMPLE_RATE = 48000
|
||||||
|
|
||||||
|
# How long to wait for a block to be processed before returning passthrough.
|
||||||
|
# Set to 2x the expected JACK period at 256/48k (5.33ms) to avoid false
|
||||||
|
# timeouts under load. If the engine doesn't respond in this window, we
|
||||||
|
# reuse the previous output buffer to keep the stream aligned.
|
||||||
|
READ_TIMEOUT_MS = 10.0 # 10ms hard timeout for RT thread safety
|
||||||
|
|
||||||
|
|
||||||
|
class NAMEngineProcess:
|
||||||
|
"""Manages the C++ nam_engine subprocess for a single model."""
|
||||||
|
|
||||||
|
def __init__(self, model_path: str | Path, block_size: int = DEFAULT_BLOCK_SIZE,
|
||||||
|
sample_rate: int = DEFAULT_SAMPLE_RATE):
|
||||||
|
self._model_path = Path(model_path)
|
||||||
|
self._block_size = block_size
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._static: bool = False
|
||||||
|
self._timing_samples: list[float] = []
|
||||||
|
self._loaded: bool = False
|
||||||
|
|
||||||
|
# Background reader thread for non-blocking stdout consumption
|
||||||
|
self._reader_thread: Optional[threading.Thread] = None
|
||||||
|
self._reader_running: bool = False
|
||||||
|
self._read_buf: bytes = b""
|
||||||
|
self._read_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Last successfully processed output — reused if engine is slow
|
||||||
|
self._last_output: Optional[np.ndarray] = None
|
||||||
|
self._last_output_shape: Optional[tuple] = None
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Launch the engine subprocess."""
|
||||||
|
if not self._model_path.exists():
|
||||||
|
logger.error('Model not found: %s', self._model_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ENGINE_PATH.exists():
|
||||||
|
logger.error('NAM engine binary not found: %s', ENGINE_PATH)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
[str(ENGINE_PATH), str(self._model_path), str(self._block_size)],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Failed to start NAM engine: %s', e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Wait for the ready signal on stderr
|
||||||
|
ready_line = self._read_stderr_line(timeout=5.0)
|
||||||
|
if ready_line is None:
|
||||||
|
logger.error('NAM engine failed to start (timeout waiting for ready)')
|
||||||
|
self.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'FAILED' in ready_line:
|
||||||
|
logger.error('NAM engine failed: %s', ready_line)
|
||||||
|
self.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Parse ready line: "NAM engine: loaded <path> (static=<N>, sr=<N>)"
|
||||||
|
# "NAM engine: block_size=<N>, ready"
|
||||||
|
if 'static=1' in ready_line:
|
||||||
|
self._static = True
|
||||||
|
logger.info('NAM engine ready: %s', ready_line.strip())
|
||||||
|
|
||||||
|
# Read the block_size line too
|
||||||
|
ready_line2 = self._read_stderr_line(timeout=2.0)
|
||||||
|
if ready_line2:
|
||||||
|
logger.info('NAM engine ready2: %s', ready_line2.strip())
|
||||||
|
|
||||||
|
# Start background reader thread to consume stdout non-blocking
|
||||||
|
self._reader_running = True
|
||||||
|
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _reader_loop(self) -> None:
|
||||||
|
"""Background thread: continuously read engine stdout into a buffer.
|
||||||
|
|
||||||
|
This ensures the stdout pipe never fills up (which would block
|
||||||
|
the engine) and keeps the RT thread's process() call non-blocking.
|
||||||
|
"""
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None or proc.stdout is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set stdout to non-blocking for safe reading
|
||||||
|
fd = proc.stdout.fileno()
|
||||||
|
import fcntl
|
||||||
|
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||||
|
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
||||||
|
|
||||||
|
while self._reader_running and proc.poll() is None:
|
||||||
|
try:
|
||||||
|
chunk = os.read(fd, 65536)
|
||||||
|
if not chunk:
|
||||||
|
# EOF — engine has closed stdout
|
||||||
|
break
|
||||||
|
with self._read_lock:
|
||||||
|
self._read_buf += chunk
|
||||||
|
except BlockingIOError:
|
||||||
|
# No data available yet — sleep briefly before retrying
|
||||||
|
time.sleep(0.0001) # 100µs
|
||||||
|
except OSError:
|
||||||
|
# Broken pipe or other I/O error
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.debug("NAM engine reader thread exiting")
|
||||||
|
|
||||||
|
def process(self, audio_block: np.ndarray) -> np.ndarray:
|
||||||
|
"""Process a block of audio through the NAM engine.
|
||||||
|
|
||||||
|
Non-blocking: writes to stdin and reads from a background buffer.
|
||||||
|
If the engine hasn't produced output yet, reuses the previous
|
||||||
|
block's output to maintain stream alignment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_block: float32 numpy array of shape (N,) or (1, N)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float32 numpy array of same shape.
|
||||||
|
"""
|
||||||
|
if self._proc is None or self._proc.stdin is None or self._proc.stdout is None:
|
||||||
|
return audio_block # passthrough
|
||||||
|
|
||||||
|
# Ensure 1D
|
||||||
|
was_2d = audio_block.ndim == 2
|
||||||
|
if was_2d:
|
||||||
|
audio_block = audio_block[0]
|
||||||
|
|
||||||
|
# Ensure float32
|
||||||
|
if audio_block.dtype != np.float32:
|
||||||
|
audio_block = audio_block.astype(np.float32)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
# Write block to engine (fast — 1KB into 64KB pipe buffer)
|
||||||
|
try:
|
||||||
|
self._proc.stdin.write(audio_block.tobytes())
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
except BrokenPipeError:
|
||||||
|
logger.warning("NAM engine stdin broken pipe — engine may have crashed")
|
||||||
|
return audio_block # passthrough
|
||||||
|
|
||||||
|
# Read processed block from background buffer (non-blocking)
|
||||||
|
nbytes = audio_block.nbytes
|
||||||
|
with self._read_lock:
|
||||||
|
if len(self._read_buf) >= nbytes:
|
||||||
|
raw = self._read_buf[:nbytes]
|
||||||
|
self._read_buf = self._read_buf[nbytes:]
|
||||||
|
else:
|
||||||
|
# Engine hasn't produced output yet — reuse previous frame
|
||||||
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||||
|
self._timing_samples.append(elapsed_ms)
|
||||||
|
if len(self._timing_samples) > 200:
|
||||||
|
self._timing_samples = self._timing_samples[-100:]
|
||||||
|
if self._last_output is not None:
|
||||||
|
return self._last_output.copy()
|
||||||
|
return audio_block # first frame before engine responds
|
||||||
|
|
||||||
|
# Check for short read (engine crash mid-block)
|
||||||
|
if len(raw) != nbytes:
|
||||||
|
logger.warning('NAM engine short read: got %d bytes, expected %d',
|
||||||
|
len(raw), nbytes)
|
||||||
|
return audio_block # passthrough on error
|
||||||
|
|
||||||
|
out = np.frombuffer(raw, dtype=np.float32).copy()
|
||||||
|
|
||||||
|
# Reshape back if input was 2D
|
||||||
|
if was_2d:
|
||||||
|
out = out[np.newaxis, :]
|
||||||
|
|
||||||
|
# Track timing
|
||||||
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||||
|
self._timing_samples.append(elapsed_ms)
|
||||||
|
if len(self._timing_samples) > 200:
|
||||||
|
self._timing_samples = self._timing_samples[-100:]
|
||||||
|
|
||||||
|
# Cache last output for reuse on slow frames
|
||||||
|
self._last_output = out.copy()
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Terminate the engine subprocess."""
|
||||||
|
self._reader_running = False
|
||||||
|
if self._reader_thread is not None:
|
||||||
|
self._reader_thread.join(timeout=2)
|
||||||
|
if self._proc is not None:
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
self._proc.kill()
|
||||||
|
self._proc.wait(timeout=1)
|
||||||
|
self._proc = None
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
def _read_stderr_line(self, timeout: float = 5.0) -> Optional[str]:
|
||||||
|
"""Read a line from stderr with timeout."""
|
||||||
|
if self._proc is None or self._proc.stderr is None:
|
||||||
|
return None
|
||||||
|
# Poll until data available or timeout
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
# Check if process is still alive
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
# Read remaining stderr
|
||||||
|
remaining = self._proc.stderr.read().decode('utf-8', errors='replace')
|
||||||
|
logger.error('Engine exited with code %d: %s', self._proc.returncode, remaining)
|
||||||
|
return 'FAILED: process exited'
|
||||||
|
|
||||||
|
# Try non-blocking read from stderr
|
||||||
|
line = self._proc.stderr.readline()
|
||||||
|
if line:
|
||||||
|
return line.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
time.sleep(0.05) # 50ms poll interval
|
||||||
|
|
||||||
|
return None # timeout
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
return self._loaded
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_static(self) -> bool:
|
||||||
|
return self._static
|
||||||
|
|
||||||
|
@property
|
||||||
|
def avg_inference_ms(self) -> float:
|
||||||
|
if not self._timing_samples:
|
||||||
|
return 0.0
|
||||||
|
return float(np.mean(self._timing_samples[-50:]))
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Simple test ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
model = sys.argv[1] if len(sys.argv) > 1 else 'models/nam/clean.nam'
|
||||||
|
block_size = int(sys.argv[2]) if len(sys.argv) > 2 else 256
|
||||||
|
sample_rate = int(sys.argv[3]) if len(sys.argv) > 3 else 48000
|
||||||
|
|
||||||
|
engine = NAMEngineProcess(model, block_size, sample_rate)
|
||||||
|
if not engine.start():
|
||||||
|
print('FAILED to start engine')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'Engine loaded: static={engine.is_static}')
|
||||||
|
|
||||||
|
# Benchmark
|
||||||
|
block = np.random.randn(block_size).astype(np.float32) * 0.1
|
||||||
|
|
||||||
|
# Warmup
|
||||||
|
for _ in range(10):
|
||||||
|
engine.process(block)
|
||||||
|
|
||||||
|
# Timed
|
||||||
|
times = []
|
||||||
|
for _ in range(500):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
engine.process(block)
|
||||||
|
times.append((time.perf_counter() - t0) * 1000)
|
||||||
|
|
||||||
|
print(f'Avg: {np.mean(times):.3f} ms Max: {np.max(times):.3f} ms Min: {np.min(times):.3f} ms')
|
||||||
|
print(f'Engine reported avg: {engine.avg_inference_ms:.3f} ms')
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""Drop-in replacement for NAMHost using the C++ nam_engine subprocess.
|
||||||
|
|
||||||
|
Same interface as NAMHost (load_model, process, is_loaded, etc.) but
|
||||||
|
spawns the C++ NeuralAudio engine for ~34x faster inference.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .nam_engine import NAMEngineProcess
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MODELS_DIR = Path(__file__).parent.parent / "models" / "nam"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NAMFastModel:
|
||||||
|
"""Metadata matching the NAMModel dataclass from nam_host.py."""
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
size_mb: float
|
||||||
|
architecture: str = "LSTM"
|
||||||
|
params_k: float = 0.0
|
||||||
|
receptive_field: int = 0
|
||||||
|
sample_rate: int = 48000
|
||||||
|
compatible: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def family(self) -> str:
|
||||||
|
if self.size_mb < 0.1:
|
||||||
|
return "nano"
|
||||||
|
elif self.size_mb < 1.0:
|
||||||
|
return "feather"
|
||||||
|
elif self.size_mb < 4.0:
|
||||||
|
return "lite"
|
||||||
|
else:
|
||||||
|
return "standard"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def estimated_latency_ms(self) -> str:
|
||||||
|
return "0.05-0.2 ms (C++ NeuralAudio engine)"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_nam_architecture(model_path: str) -> str:
|
||||||
|
"""Read the architecture field from a .nam file without loading the full model.
|
||||||
|
|
||||||
|
Returns the architecture string (e.g. 'WaveNet', 'Linear', 'LSTM', 'ConvNet')
|
||||||
|
or 'unknown' if the file can't be read.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(model_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("architecture", "unknown")
|
||||||
|
except (json.JSONDecodeError, OSError, FileNotFoundError):
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class FastNAMHost:
|
||||||
|
"""NAM model host using the C++ nam_engine subprocess.
|
||||||
|
|
||||||
|
Same API surface as NAMHost (from nam_host.py), but uses the
|
||||||
|
NeuralAudio C++ engine for much faster inference.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
models_dir : str | Path
|
||||||
|
Directory scanned for available .nam models.
|
||||||
|
block_size : int
|
||||||
|
Audio block size (must match the pipeline's JACK buffer).
|
||||||
|
sample_rate : int
|
||||||
|
Audio sample rate in Hz (sent to the C++ engine).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
models_dir: str | Path = MODELS_DIR,
|
||||||
|
block_size: int = 256,
|
||||||
|
sample_rate: int = 48000,
|
||||||
|
):
|
||||||
|
self._models_dir = Path(models_dir)
|
||||||
|
self._block_size = block_size
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._engine: Optional[NAMModel] = None # Using current naming matching nam_host
|
||||||
|
self._loaded_path: Optional[str] = None
|
||||||
|
self._loaded_model: Optional[NAMFastModel] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
self._models_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ── Properties ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return self._engine is not None and self._engine.is_loaded
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_model(self) -> Optional[NAMFastModel]:
|
||||||
|
with self._lock:
|
||||||
|
return self._loaded_model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def avg_inference_ms(self) -> float:
|
||||||
|
with self._lock:
|
||||||
|
if self._engine is None:
|
||||||
|
return 0.0
|
||||||
|
return self._engine.avg_inference_ms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def block_size(self) -> int:
|
||||||
|
return self._block_size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
return self._sample_rate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str:
|
||||||
|
"""Last model-load error message (empty string if last load succeeded)."""
|
||||||
|
with self._lock:
|
||||||
|
if hasattr(self, '_last_error_val'):
|
||||||
|
return self._last_error_val
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def set_block_size(self, block_size: int) -> None:
|
||||||
|
"""Update block size. Reloads current model if loaded.
|
||||||
|
|
||||||
|
Uses warm-before-kill: spawns the new subprocess before stopping
|
||||||
|
the old one, so there's no gap in NAM processing.
|
||||||
|
"""
|
||||||
|
if block_size == self._block_size:
|
||||||
|
return
|
||||||
|
self._block_size = block_size
|
||||||
|
if self._loaded_path:
|
||||||
|
# Warm-before-kill: spin up new engine while old one still serves
|
||||||
|
new_engine = NAMEngineProcess(
|
||||||
|
self._loaded_path, self._block_size, self._sample_rate,
|
||||||
|
)
|
||||||
|
if not new_engine.start():
|
||||||
|
logger.error("Failed to start new engine for block size %d — keeping old engine", block_size)
|
||||||
|
new_engine.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Warm-before-kill: spawned new engine, swapping...")
|
||||||
|
with self._lock:
|
||||||
|
old_engine = self._engine
|
||||||
|
self._engine = new_engine
|
||||||
|
# Old engine can be stopped now — no one is reading from it
|
||||||
|
if old_engine is not None:
|
||||||
|
old_engine.stop()
|
||||||
|
logger.debug("Old NAM engine stopped")
|
||||||
|
|
||||||
|
def set_sample_rate(self, sample_rate: int) -> None:
|
||||||
|
"""Update sample rate. Reloads current model if loaded.
|
||||||
|
|
||||||
|
Uses warm-before-kill like set_block_size.
|
||||||
|
"""
|
||||||
|
if sample_rate == self._sample_rate:
|
||||||
|
return
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
if self._loaded_path:
|
||||||
|
new_engine = NAMEngineProcess(
|
||||||
|
self._loaded_path, self._block_size, self._sample_rate,
|
||||||
|
)
|
||||||
|
if not new_engine.start():
|
||||||
|
logger.error("Failed to restart engine for sample rate %d", sample_rate)
|
||||||
|
new_engine.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
old_engine = self._engine
|
||||||
|
self._engine = new_engine
|
||||||
|
if old_engine is not None:
|
||||||
|
old_engine.stop()
|
||||||
|
|
||||||
|
# ── Model loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_model(self, model_path: str) -> bool:
|
||||||
|
"""Load a .nam model into the C++ engine.
|
||||||
|
|
||||||
|
Returns True on success, False on error.
|
||||||
|
Uses warm-before-kill: spawns new process before stopping old one.
|
||||||
|
"""
|
||||||
|
path = Path(model_path)
|
||||||
|
if not path.exists() or path.suffix.lower() not in (".nam",):
|
||||||
|
logger.error("Model not found or invalid: %s", model_path)
|
||||||
|
self._last_error_val = f"Model not found: {model_path}"
|
||||||
|
return False
|
||||||
|
|
||||||
|
size_mb = path.stat().st_size / (1024 * 1024)
|
||||||
|
arch = _read_nam_architecture(model_path)
|
||||||
|
|
||||||
|
# Create and start the new engine BEFORE stopping the old one
|
||||||
|
engine = NAMEngineProcess(str(path), self._block_size, self._sample_rate)
|
||||||
|
if not engine.start():
|
||||||
|
msg = f"Failed to start NAM engine for: {model_path}"
|
||||||
|
logger.error(msg)
|
||||||
|
self._last_error_val = msg
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Swap: new engine takes over, old one is cleaned up
|
||||||
|
with self._lock:
|
||||||
|
old_engine = self._engine
|
||||||
|
self._engine = engine
|
||||||
|
self._loaded_path = str(path)
|
||||||
|
self._loaded_model = NAMFastModel(
|
||||||
|
name=path.stem,
|
||||||
|
path=str(path),
|
||||||
|
size_mb=size_mb,
|
||||||
|
architecture=arch,
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_engine is not None:
|
||||||
|
old_engine.stop()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loaded NAM model via C++ engine: %s (%.1f KB, static=%s, arch=%s, engine=NeuralAudio)",
|
||||||
|
path.stem,
|
||||||
|
size_mb * 1024,
|
||||||
|
engine.is_static,
|
||||||
|
arch,
|
||||||
|
)
|
||||||
|
self._last_error_val = ""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
"""Unload the current model and stop the engine."""
|
||||||
|
with self._lock:
|
||||||
|
engine = self._engine
|
||||||
|
self._engine = None
|
||||||
|
self._loaded_path = None
|
||||||
|
self._loaded_model = None
|
||||||
|
if engine is not None:
|
||||||
|
engine.stop()
|
||||||
|
logger.info("NAM model unloaded")
|
||||||
|
|
||||||
|
# ── Warm-up ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def warm_up(self, block_size: int = 256) -> None:
|
||||||
|
"""Run a dry inference to warm caches."""
|
||||||
|
with self._lock:
|
||||||
|
engine = self._engine
|
||||||
|
if engine is None or not engine.is_loaded:
|
||||||
|
return
|
||||||
|
dummy = np.zeros(block_size, dtype=np.float32)
|
||||||
|
for _ in range(5):
|
||||||
|
engine.process(dummy)
|
||||||
|
|
||||||
|
# ── Inference ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def process(self, audio_block: np.ndarray) -> np.ndarray:
|
||||||
|
"""Run a block of audio through the NAM model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_block: float32 numpy array, shape (N,) or (1, N).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed audio, same shape, float32.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
engine = self._engine
|
||||||
|
if engine is None or not engine.is_loaded:
|
||||||
|
return audio_block # passthrough
|
||||||
|
|
||||||
|
return engine.process(audio_block)
|
||||||
|
|
||||||
|
# ── Model discovery ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_available_models(self) -> list[NAMFastModel]:
|
||||||
|
"""Scan models_dir for .nam files and return metadata.
|
||||||
|
|
||||||
|
Reads the actual architecture from each .nam file instead of
|
||||||
|
hardcoding a default.
|
||||||
|
"""
|
||||||
|
models: list[NAMFastModel] = []
|
||||||
|
for f in sorted(self._models_dir.glob("*.nam")):
|
||||||
|
size_mb = f.stat().st_size / (1024 * 1024)
|
||||||
|
arch = _read_nam_architecture(str(f))
|
||||||
|
models.append(
|
||||||
|
NAMFastModel(
|
||||||
|
name=f.stem,
|
||||||
|
path=str(f),
|
||||||
|
size_mb=size_mb,
|
||||||
|
architecture=arch,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return models
|
||||||
+506
-62
@@ -1,11 +1,14 @@
|
|||||||
"""NAM A2 model host — load, configure, and run inference on RPi 4B.
|
"""NAM A2 model host — load, configure, and run inference on RPi 4B.
|
||||||
|
|
||||||
Leverages the `neural-amp-modeler` (nam) Python package for model loading
|
Leverages the neural-amp-modeler (NAM) .nam file format for model loading
|
||||||
and inference. Supports Linear, WaveNet, and LSTM architectures.
|
and inference via a lightweight PyTorch reimplementation. Supports:
|
||||||
|
- SlimmableContainer (A2 format): version 0.12+ with quality tier submodels
|
||||||
|
- WaveNet: standard dilated-convolution amp model
|
||||||
|
- Linear: simple pass-through/bias model
|
||||||
|
|
||||||
On RPi 4B:
|
On RPi 4B:
|
||||||
- Python + PyTorch: good for Feather/Nano models only (~0.5-3ms at 256-block)
|
- Python + PyTorch: good for Feather/Nano models only (~0.5-3ms at 256-block)
|
||||||
- For Standard/Lite models, use `neural-amp-modeler-lv2` compiled natively
|
- For Standard/Lite models, use neural-amp-modeler-lv2 compiled natively
|
||||||
(NeuralAudio engine, LV2 plugin, ~1-2ms at 256-block)
|
(NeuralAudio engine, LV2 plugin, ~1-2ms at 256-block)
|
||||||
- For A2 Slimmable runtime quality dialing, port to OpenSauce/nam-rs
|
- For A2 Slimmable runtime quality dialing, port to OpenSauce/nam-rs
|
||||||
"""
|
"""
|
||||||
@@ -14,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
|
||||||
@@ -23,6 +27,382 @@ from typing import Optional
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# ── Architecture-aware NAM model builder ──────────────────────────────
|
||||||
|
|
||||||
|
def _build_nam_model(data: dict) -> "torch.nn.Module":
|
||||||
|
"""Build a PyTorch model from a parsed .nam file dict.
|
||||||
|
|
||||||
|
Dispatches to the appropriate builder based on ``architecture``:
|
||||||
|
|
||||||
|
* ``SlimmableContainer`` — A2 format: pick the highest-quality
|
||||||
|
submodel (``max_value`` closest to 1.0) and build its WaveNet.
|
||||||
|
* ``WaveNet`` — standard dilated-convolution architecture.
|
||||||
|
* ``Linear`` — simple single-weight pass-through.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Parsed .nam JSON content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``torch.nn.Module`` ready for inference.
|
||||||
|
"""
|
||||||
|
import torch.nn as nn
|
||||||
|
|
||||||
|
arch = data.get("architecture", "Linear")
|
||||||
|
config = data.get("config", {})
|
||||||
|
weights = data.get("weights", [])
|
||||||
|
|
||||||
|
if arch == "SlimmableContainer":
|
||||||
|
# Pick the highest-quality submodel
|
||||||
|
submodels = config.get("submodels", [])
|
||||||
|
if not submodels:
|
||||||
|
raise ValueError("SlimmableContainer has no submodels")
|
||||||
|
# Sort by max_value descending — pick highest quality
|
||||||
|
best = max(submodels, key=lambda sm: sm.get("max_value", 0))
|
||||||
|
model_data = best.get("model", {})
|
||||||
|
if not model_data:
|
||||||
|
raise ValueError("SlimmableContainer submodel has no 'model' data")
|
||||||
|
# Recurse — the submodel's model field is a complete .nam export dict
|
||||||
|
return _build_nam_model(model_data)
|
||||||
|
|
||||||
|
elif arch == "WaveNet":
|
||||||
|
return _build_wavenet(config, weights)
|
||||||
|
|
||||||
|
elif arch == "Linear":
|
||||||
|
return _build_linear(config, weights)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported NAM architecture: {arch!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_linear(config: dict, weights: list) -> "torch.nn.Module":
|
||||||
|
"""Build a Linear pass-through model.
|
||||||
|
|
||||||
|
NAM Linear model: just a scalar gain and optional bias.
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
|
||||||
|
has_bias = config.get("bias", False)
|
||||||
|
|
||||||
|
class LinearNAM(nn.Module):
|
||||||
|
def __init__(self, gain: float, bias: float = 0.0):
|
||||||
|
super().__init__()
|
||||||
|
self.gain = nn.Parameter(torch.tensor(gain, dtype=torch.float32))
|
||||||
|
self.bias = nn.Parameter(torch.tensor(bias, dtype=torch.float32)) if has_bias else 0.0
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
# x: (B, T) or (T,)
|
||||||
|
if isinstance(self.bias, nn.Parameter):
|
||||||
|
return x * self.gain + self.bias
|
||||||
|
return x * self.gain
|
||||||
|
|
||||||
|
gain = float(weights[0]) if weights else 1.0
|
||||||
|
bias = float(weights[1]) if has_bias and len(weights) > 1 else 0.0
|
||||||
|
return LinearNAM(gain, bias)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_wavenet_arch(config: dict) -> None:
|
||||||
|
"""Validate WaveNet config is standard A2 architecture.
|
||||||
|
|
||||||
|
Raises ValueError with a clear message if unsupported features
|
||||||
|
(FiLM, head1x1, multi-array) are detected — prevents silent garbage
|
||||||
|
output from a corrupted model load.
|
||||||
|
|
||||||
|
Checks performed:
|
||||||
|
1. condition_size > 1 — indicates FiLM conditioning (extra weight tensors)
|
||||||
|
2. head1x1 key in layer array — extra 1x1 head conv not in A2 format
|
||||||
|
3. Multiple entries in layers array — multi-array not supported
|
||||||
|
"""
|
||||||
|
layers_cfg = config.get("layers", [])
|
||||||
|
if not layers_cfg:
|
||||||
|
raise ValueError("WaveNet config has no layers")
|
||||||
|
|
||||||
|
la = layers_cfg[0]
|
||||||
|
|
||||||
|
# FiLM detection: condition_size > 1 means conditioning inputs
|
||||||
|
# that require FiLM-specific weight tensors the A2 builder doesn't create
|
||||||
|
condition_size = la.get("condition_size", 1)
|
||||||
|
if condition_size > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"FiLM architecture (condition_size={condition_size}) is not supported. "
|
||||||
|
"Only standard A2 WaveNet without FiLM conditioning is supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
# head1x1 detection: some models export an extra 1x1 conv in the head
|
||||||
|
if "head1x1" in la:
|
||||||
|
raise ValueError(
|
||||||
|
"head1x1 architecture detected — not supported. "
|
||||||
|
"Only standard A2 WaveNet head is supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Multiple layer arrays are not supported
|
||||||
|
if len(layers_cfg) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Multi-array WaveNet ({len(layers_cfg)} layer arrays) is not supported. "
|
||||||
|
"Only single-array A2 WaveNet is supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_wavenet(config: dict, weights: list) -> "torch.nn.Module":
|
||||||
|
"""Build a WaveNet NAM model from config and flat weight array.
|
||||||
|
|
||||||
|
Implements the standard NAM WaveNet architecture:
|
||||||
|
- RechannelIn: 1x1 conv (1 → C)
|
||||||
|
- N dilated conv layers with residual connections
|
||||||
|
- HeadRechannel: temporal conv (C → 1) + optional bias
|
||||||
|
- Optional WaveNet-level Head (activation + conv blocks)
|
||||||
|
- head_scale multiplier
|
||||||
|
|
||||||
|
Weights are imported in the exact order matching NAM's export format:
|
||||||
|
|
||||||
|
1. rechannel (weight only, no bias)
|
||||||
|
2. Per layer: conv(w+b), input_mixer(w, no bias)
|
||||||
|
(layer1x1, head1x1, FiLM omitted — not present in A2 models)
|
||||||
|
3. head_rechannel (w+b)
|
||||||
|
4. Optional head (conv w+b per block)
|
||||||
|
5. head_scale (single float)
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
|
||||||
|
layers_cfg = config.get("layers", [])
|
||||||
|
head_cfg = config.get("head", None) # WaveNet-level head
|
||||||
|
head_scale = config.get("head_scale", 1.0)
|
||||||
|
|
||||||
|
if not layers_cfg:
|
||||||
|
raise ValueError("WaveNet config has no layers")
|
||||||
|
|
||||||
|
# ── Validate architecture is A2-compatible before building ─────────
|
||||||
|
_validate_wavenet_arch(config)
|
||||||
|
|
||||||
|
# ── Extract layer array config (we use first/only layer array) ─────
|
||||||
|
la = layers_cfg[0] # A2 models have single layer array
|
||||||
|
input_size = la.get("input_size", 1)
|
||||||
|
condition_size = la.get("condition_size", 1)
|
||||||
|
channels = la.get("channels", 8)
|
||||||
|
kernel_sizes = la.get("kernel_sizes", la.get("kernel_size", [3]))
|
||||||
|
dilations = la.get("dilations", [1])
|
||||||
|
la_head_cfg = la.get("head", {"out_channels": 1, "kernel_size": 1, "bias": True})
|
||||||
|
|
||||||
|
# Normalize scalar kernel_size to list
|
||||||
|
if isinstance(kernel_sizes, int):
|
||||||
|
kernel_sizes = [kernel_sizes] * len(dilations)
|
||||||
|
if isinstance(dilations, int):
|
||||||
|
dilations = [dilations] * len(kernel_sizes)
|
||||||
|
|
||||||
|
num_layers = len(dilations)
|
||||||
|
out_channels = la_head_cfg.get("out_channels", 1)
|
||||||
|
head_kernel_size = la_head_cfg.get("kernel_size", 1)
|
||||||
|
head_bias = la_head_cfg.get("bias", True)
|
||||||
|
|
||||||
|
# ── Build modules ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
modules = []
|
||||||
|
|
||||||
|
# 1. RechannelIn: 1x1 conv, no bias
|
||||||
|
rechannel = nn.Conv1d(input_size, channels, 1, bias=False)
|
||||||
|
modules.append(("rechannel", rechannel))
|
||||||
|
|
||||||
|
# 2. Dilated conv layers + optional layer1x1
|
||||||
|
layer_convs = nn.ModuleList()
|
||||||
|
input_mixers = nn.ModuleList()
|
||||||
|
layer1x1s = nn.ModuleList()
|
||||||
|
for i in range(num_layers):
|
||||||
|
ks = kernel_sizes[i] if i < len(kernel_sizes) else kernel_sizes[-1]
|
||||||
|
d = dilations[i] if i < len(dilations) else 1
|
||||||
|
conv = nn.Conv1d(channels, channels, ks, dilation=d, padding=0)
|
||||||
|
im = nn.Conv1d(condition_size, channels, 1, bias=False)
|
||||||
|
layer_convs.append(conv)
|
||||||
|
input_mixers.append(im)
|
||||||
|
# Many A2 models include a 1x1 post-conv (layer1x1)
|
||||||
|
l1 = nn.Conv1d(channels, channels, 1, bias=True)
|
||||||
|
layer1x1s.append(l1)
|
||||||
|
|
||||||
|
modules.append(("layer_convs", layer_convs))
|
||||||
|
modules.append(("input_mixers", input_mixers))
|
||||||
|
modules.append(("layer1x1s", layer1x1s))
|
||||||
|
|
||||||
|
# 3. HeadRechannel: temporal conv
|
||||||
|
head_rechannel = nn.Conv1d(channels, out_channels, head_kernel_size, bias=head_bias)
|
||||||
|
modules.append(("head_rechannel", head_rechannel))
|
||||||
|
|
||||||
|
# 4. Optional WaveNet-level head
|
||||||
|
head_module = None
|
||||||
|
if head_cfg is not None and head_cfg.get("kernel_size", 0) > 0:
|
||||||
|
h_ks = head_cfg.get("kernel_size", 1)
|
||||||
|
h_bias = head_cfg.get("bias", True)
|
||||||
|
head_module = nn.Conv1d(out_channels, out_channels, h_ks, bias=h_bias)
|
||||||
|
modules.append(("head", head_module))
|
||||||
|
|
||||||
|
# ── Assemble WaveNetNAM module ─────────────────────────────────────
|
||||||
|
class WaveNetNAM(nn.Module):
|
||||||
|
def __init__(self, rechannel, layer_convs, input_mixers, layer1x1s,
|
||||||
|
head_rechannel, head, head_scale, receptive_field):
|
||||||
|
super().__init__()
|
||||||
|
self.rechannel = rechannel
|
||||||
|
self.layer_convs = layer_convs
|
||||||
|
self.input_mixers = input_mixers
|
||||||
|
self.layer1x1s = layer1x1s
|
||||||
|
self.head_rechannel = head_rechannel
|
||||||
|
self.head = head
|
||||||
|
self.head_scale = head_scale
|
||||||
|
self.receptive_field = receptive_field
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
# x: (B, T) or (B, 1, T)
|
||||||
|
if x.dim() == 2:
|
||||||
|
x = x.unsqueeze(1) # (B, 1, T)
|
||||||
|
|
||||||
|
# Rechannel
|
||||||
|
x = self.rechannel(x) # (B, C, T)
|
||||||
|
|
||||||
|
# Dilated conv layers with residual
|
||||||
|
for conv, im, l1 in zip(self.layer_convs, self.input_mixers, self.layer1x1s):
|
||||||
|
# Causal padding (left-only) for dilated conv
|
||||||
|
pad = (conv.kernel_size[0] - 1) * conv.dilation[0]
|
||||||
|
if pad > 0:
|
||||||
|
x_pad = nn.functional.pad(x, (pad, 0))
|
||||||
|
else:
|
||||||
|
x_pad = x
|
||||||
|
# Dilated conv
|
||||||
|
out = conv(x_pad)
|
||||||
|
# Input mixer (condition path)
|
||||||
|
cond = torch.ones_like(x[:, :1, :])
|
||||||
|
c = im(cond)
|
||||||
|
# Ensure same length
|
||||||
|
if out.shape[-1] < x.shape[-1]:
|
||||||
|
x = x[:, :, -out.shape[-1]:]
|
||||||
|
if c.shape[-1] < x.shape[-1]:
|
||||||
|
c = c[:, :, -x.shape[-1]:]
|
||||||
|
# Gated activation
|
||||||
|
out = torch.tanh(out + c)
|
||||||
|
# 1x1 post-conv (layer1x1)
|
||||||
|
out = l1(out)
|
||||||
|
# Truncate to out length if needed
|
||||||
|
if out.shape[-1] < x.shape[-1]:
|
||||||
|
x = x[:, :, -out.shape[-1]:]
|
||||||
|
# Residual
|
||||||
|
x = x + out
|
||||||
|
|
||||||
|
# Head rechannel
|
||||||
|
x = self.head_rechannel(x) # (B, 1, T')
|
||||||
|
if x.shape[-1] > 0:
|
||||||
|
# Trim to receptive field boundary
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Optional head
|
||||||
|
if self.head is not None:
|
||||||
|
pad = (self.head.kernel_size[0] - 1)
|
||||||
|
if pad > 0:
|
||||||
|
x = nn.functional.pad(x, (pad, 0))
|
||||||
|
x = self.head(x)
|
||||||
|
|
||||||
|
# Scale
|
||||||
|
x = x * self.head_scale
|
||||||
|
|
||||||
|
return x.squeeze(1) # (B, T')
|
||||||
|
|
||||||
|
# Compute receptive field
|
||||||
|
rf = 1
|
||||||
|
for i in range(num_layers):
|
||||||
|
ks = kernel_sizes[i] if i < len(kernel_sizes) else kernel_sizes[-1]
|
||||||
|
d = dilations[i] if i < len(dilations) else 1
|
||||||
|
rf += (ks - 1) * d
|
||||||
|
rf += head_kernel_size - 1
|
||||||
|
if head_module is not None:
|
||||||
|
rf += head_cfg.get("kernel_size", 1) - 1
|
||||||
|
|
||||||
|
model = WaveNetNAM(
|
||||||
|
rechannel, layer_convs, input_mixers, layer1x1s,
|
||||||
|
head_rechannel, head_module, head_scale, rf,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Import weights from flat array ─────────────────────────────────
|
||||||
|
_import_wavenet_weights(model, weights)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _import_wavenet_weights(model: "torch.nn.Module", weights: list) -> None:
|
||||||
|
"""Import flat weight array into a WaveNetNAM model.
|
||||||
|
|
||||||
|
Weight order (matching NAM's export_weights):
|
||||||
|
1. rechannel.weight
|
||||||
|
2. For each layer: conv.weight, conv.bias, input_mixer.weight
|
||||||
|
3. head_rechannel.weight, head_rechannel.bias
|
||||||
|
4. head.weight, head.bias (if head exists)
|
||||||
|
5. head_scale
|
||||||
|
"""
|
||||||
|
import torch as _torch
|
||||||
|
import numpy as _np
|
||||||
|
|
||||||
|
w = _torch.tensor(weights, dtype=_torch.float32)
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
# rechannel.weight: [C, 1, 1]
|
||||||
|
n = model.rechannel.weight.numel()
|
||||||
|
model.rechannel.weight.data = w[i:i+n].reshape(model.rechannel.weight.shape)
|
||||||
|
i += n
|
||||||
|
|
||||||
|
# Per-layer weights
|
||||||
|
for conv, im, l1 in zip(model.layer_convs, model.input_mixers, model.layer1x1s):
|
||||||
|
# conv.weight: [C, C, ks]
|
||||||
|
n = conv.weight.numel()
|
||||||
|
conv.weight.data = w[i:i+n].reshape(conv.weight.shape)
|
||||||
|
i += n
|
||||||
|
# conv.bias: [C]
|
||||||
|
n = conv.bias.numel()
|
||||||
|
conv.bias.data = w[i:i+n].reshape(conv.bias.shape)
|
||||||
|
i += n
|
||||||
|
# input_mixer.weight: [C, condition_size, 1]
|
||||||
|
n = im.weight.numel()
|
||||||
|
im.weight.data = w[i:i+n].reshape(im.weight.shape)
|
||||||
|
i += n
|
||||||
|
# layer1x1.weight: [C, C, 1]
|
||||||
|
n = l1.weight.numel()
|
||||||
|
l1.weight.data = w[i:i+n].reshape(l1.weight.shape)
|
||||||
|
i += n
|
||||||
|
# layer1x1.bias: [C]
|
||||||
|
n = l1.bias.numel()
|
||||||
|
l1.bias.data = w[i:i+n].reshape(l1.bias.shape)
|
||||||
|
i += n
|
||||||
|
|
||||||
|
# head_rechannel.weight
|
||||||
|
n = model.head_rechannel.weight.numel()
|
||||||
|
model.head_rechannel.weight.data = w[i:i+n].reshape(model.head_rechannel.weight.shape)
|
||||||
|
i += n
|
||||||
|
# head_rechannel.bias
|
||||||
|
if model.head_rechannel.bias is not None:
|
||||||
|
n = model.head_rechannel.bias.numel()
|
||||||
|
model.head_rechannel.bias.data = w[i:i+n].reshape(model.head_rechannel.bias.shape)
|
||||||
|
i += n
|
||||||
|
|
||||||
|
# head weight + bias (if present)
|
||||||
|
if model.head is not None:
|
||||||
|
n = model.head.weight.numel()
|
||||||
|
model.head.weight.data = w[i:i+n].reshape(model.head.weight.shape)
|
||||||
|
i += n
|
||||||
|
if model.head.bias is not None:
|
||||||
|
n = model.head.bias.numel()
|
||||||
|
model.head.bias.data = w[i:i+n].reshape(model.head.bias.shape)
|
||||||
|
i += n
|
||||||
|
|
||||||
|
# head_scale (final float)
|
||||||
|
if i < len(w):
|
||||||
|
model.head_scale = float(w[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Verify we consumed all weights — mismatch means unsupported arch
|
||||||
|
if i != len(w):
|
||||||
|
raise ValueError(
|
||||||
|
f"NAM weight import consumed {i}/{len(w)} weights "
|
||||||
|
f"(expected {len(w)}, got {i}). "
|
||||||
|
f"Model has {sum(p.numel() for p in model.parameters())} params. "
|
||||||
|
f"Weight order does not match architecture — likely FiLM, head1x1, "
|
||||||
|
f"or other non-A2 format."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_NAM_DIR = Path.home() / ".pedal" / "nam"
|
DEFAULT_NAM_DIR = Path.home() / ".pedal" / "nam"
|
||||||
@@ -122,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] = []
|
||||||
|
|
||||||
@@ -130,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)
|
||||||
|
|
||||||
@@ -165,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
|
||||||
@@ -182,63 +599,72 @@ 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 the ``nam`` package's ``init_from_nam()`` factory.
|
Uses our lightweight in-process builder that handles
|
||||||
Falls back gracefully if the package is unavailable or the
|
SlimmableContainer (A2), WaveNet, and Linear architectures.
|
||||||
architecture is unsupported.
|
|
||||||
|
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:
|
||||||
from nam.models import init_from_nam
|
model = _build_nam_model(data)
|
||||||
|
|
||||||
model = init_from_nam(data)
|
|
||||||
model.eval()
|
model.eval()
|
||||||
|
|
||||||
# Move to target device
|
# Move to target device
|
||||||
@@ -246,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:
|
except ImportError as exc:
|
||||||
logger.warning("nam package not installed; inference unavailable")
|
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()
|
||||||
@@ -298,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.
|
||||||
|
|
||||||
@@ -311,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
|
||||||
|
|
||||||
@@ -330,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:
|
||||||
@@ -471,4 +915,4 @@ def process_with_model(
|
|||||||
"""Convenience: load a model, process one block, return audio."""
|
"""Convenience: load a model, process one block, return audio."""
|
||||||
host = NAMHost(device=device)
|
host = NAMHost(device=device)
|
||||||
host.load_model(model_path)
|
host.load_model(model_path)
|
||||||
return host.process(audio)
|
return host.process(audio)
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""NAM engine router — switch between C++ subprocess and PyTorch in-process.
|
||||||
|
|
||||||
|
Provides a unified interface that wraps either FastNAMHost (C++ subprocess)
|
||||||
|
or NAMHost (PyTorch in-process) and allows runtime switching.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
router = NAMEngineRouter(engine_mode='cpp') # or 'pytorch'
|
||||||
|
router.load_model('/path/to/model.nam')
|
||||||
|
out = router.process(audio_block)
|
||||||
|
router.set_engine('pytorch') # switches at runtime, reloads current model
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NAMEngineRouter:
|
||||||
|
"""Wraps either FastNAMHost or NAMHost with a common interface.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
engine_mode : str
|
||||||
|
``'cpp'`` for C++ subprocess (FastNAMHost), ``'pytorch'`` for
|
||||||
|
in-process PyTorch (NAMHost).
|
||||||
|
models_dir : str | Path
|
||||||
|
Directory scanned for available .nam models.
|
||||||
|
block_size : int
|
||||||
|
Audio block size in samples.
|
||||||
|
sample_rate : int
|
||||||
|
Audio sample rate in Hz.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENGINE_MODES = ("cpp", "pytorch")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
engine_mode: str = "cpp",
|
||||||
|
models_dir: str | Path | None = None,
|
||||||
|
block_size: int = 256,
|
||||||
|
sample_rate: int = 48000,
|
||||||
|
):
|
||||||
|
if engine_mode not in self.ENGINE_MODES:
|
||||||
|
raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {engine_mode!r}")
|
||||||
|
|
||||||
|
self._models_dir = Path(models_dir) if models_dir else (
|
||||||
|
Path(__file__).parent.parent / "models" / "nam"
|
||||||
|
)
|
||||||
|
self._block_size = block_size
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._engine_mode = engine_mode
|
||||||
|
self._engine: object = None # FastNAMHost or NAMHost instance
|
||||||
|
self._loaded_path: Optional[str] = None
|
||||||
|
|
||||||
|
self._create_engine()
|
||||||
|
|
||||||
|
# Thread-safety lock — protects _engine and _loaded_path from
|
||||||
|
# concurrent access between audio callback and set_engine()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# ── Engine lifecycle ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _create_engine(self) -> None:
|
||||||
|
"""Create the engine instance for the current mode."""
|
||||||
|
if self._engine_mode == "cpp":
|
||||||
|
from .nam_engine_host import FastNAMHost
|
||||||
|
self._engine = FastNAMHost(
|
||||||
|
models_dir=str(self._models_dir),
|
||||||
|
block_size=self._block_size,
|
||||||
|
sample_rate=self._sample_rate,
|
||||||
|
)
|
||||||
|
logger.info("NAM engine: C++ subprocess (FastNAMHost)")
|
||||||
|
else:
|
||||||
|
from .nam_host import NAMHost, ModelSwitchMode
|
||||||
|
self._engine = NAMHost(
|
||||||
|
models_dir=str(self._models_dir),
|
||||||
|
device="cpu",
|
||||||
|
switch_mode=ModelSwitchMode.CROSSFADE,
|
||||||
|
)
|
||||||
|
logger.info("NAM engine: PyTorch in-process (NAMHost)")
|
||||||
|
|
||||||
|
def set_engine(self, mode: str) -> bool:
|
||||||
|
"""Switch engine type at runtime.
|
||||||
|
|
||||||
|
Thread-safe: holds ``_lock`` during the engine swap to prevent
|
||||||
|
the audio callback from seeing a half-destroyed engine.
|
||||||
|
|
||||||
|
Unloads the current model and reloads it in the new engine.
|
||||||
|
Returns True on success, False if the model couldn't be reloaded.
|
||||||
|
"""
|
||||||
|
if mode == self._engine_mode:
|
||||||
|
return True
|
||||||
|
if mode not in self.ENGINE_MODES:
|
||||||
|
raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {mode!r}")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Save currently loaded model path
|
||||||
|
old_path = self._loaded_path
|
||||||
|
if self._engine is not None:
|
||||||
|
self._engine.unload()
|
||||||
|
self._engine = None
|
||||||
|
self._loaded_path = None
|
||||||
|
|
||||||
|
self._engine_mode = mode
|
||||||
|
self._create_engine()
|
||||||
|
|
||||||
|
# Reload current model if one was loaded (outside lock — may do I/O)
|
||||||
|
if old_path:
|
||||||
|
logger.info("Reloading model %s in new engine %s", old_path, mode)
|
||||||
|
return self.load_model(old_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def engine_mode(self) -> str:
|
||||||
|
"""Current engine mode: 'cpp' or 'pytorch'."""
|
||||||
|
return self._engine_mode
|
||||||
|
|
||||||
|
# ── Properties delegated to engine ──────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return self._engine is not None and self._engine.is_loaded
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_model(self):
|
||||||
|
with self._lock:
|
||||||
|
return getattr(self._engine, 'current_model', None) if self._engine else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def avg_inference_ms(self) -> float:
|
||||||
|
if self._engine is None:
|
||||||
|
return 0.0
|
||||||
|
return getattr(self._engine, 'avg_inference_ms', 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def block_size(self) -> int:
|
||||||
|
return self._block_size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
return self._sample_rate
|
||||||
|
|
||||||
|
# ── Crossfade (compatible with both engines) ────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _crossfade_buf(self):
|
||||||
|
"""For pipeline crossfade compatibility.
|
||||||
|
PyTorch NAMHost has this natively; FastNAMHost has None.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if hasattr(self._engine, '_crossfade_buf'):
|
||||||
|
return self._engine._crossfade_buf
|
||||||
|
return None
|
||||||
|
|
||||||
|
def apply_crossfade(self, buf: np.ndarray) -> np.ndarray:
|
||||||
|
with self._lock:
|
||||||
|
engine = self._engine
|
||||||
|
if engine is not None and hasattr(engine, 'apply_crossfade'):
|
||||||
|
return engine.apply_crossfade(buf)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# ── Model loading ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_model(self, model_path: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if self._engine is None:
|
||||||
|
return False
|
||||||
|
ok = self._engine.load_model(model_path)
|
||||||
|
if ok:
|
||||||
|
self._loaded_path = model_path
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._engine is not None:
|
||||||
|
self._engine.unload()
|
||||||
|
self._loaded_path = None
|
||||||
|
|
||||||
|
# ── Audio profile sync ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_block_size(self, block_size: int) -> None:
|
||||||
|
"""Update block size. Delegates to the active engine."""
|
||||||
|
self._block_size = block_size
|
||||||
|
with self._lock:
|
||||||
|
engine = self._engine
|
||||||
|
if engine is not None and hasattr(engine, 'set_block_size'):
|
||||||
|
engine.set_block_size(block_size)
|
||||||
|
|
||||||
|
def set_sample_rate(self, sample_rate: int) -> None:
|
||||||
|
"""Update sample rate. Delegates to the active engine."""
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
with self._lock:
|
||||||
|
engine = self._engine
|
||||||
|
if engine is not None and hasattr(engine, 'set_sample_rate'):
|
||||||
|
engine.set_sample_rate(sample_rate)
|
||||||
|
elif engine is not None:
|
||||||
|
# PyTorch backend doesn't need SR — skip
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str:
|
||||||
|
"""Last model-load error from the active engine."""
|
||||||
|
with self._lock:
|
||||||
|
if self._engine is not None and hasattr(self._engine, 'last_error'):
|
||||||
|
return self._engine.last_error
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def evict_cache(self, model_path: str) -> None:
|
||||||
|
"""Evict model from engine's model cache."""
|
||||||
|
with self._lock:
|
||||||
|
if self._engine is not None and hasattr(self._engine, 'evict_cache'):
|
||||||
|
self._engine.evict_cache(model_path)
|
||||||
|
|
||||||
|
def warm_up(self) -> None:
|
||||||
|
if self._engine is not None and hasattr(self._engine, 'warm_up'):
|
||||||
|
self._engine.warm_up(block_size=self._block_size)
|
||||||
|
|
||||||
|
def process(self, audio_block: np.ndarray) -> np.ndarray:
|
||||||
|
"""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 engine.process(audio_block)
|
||||||
|
|
||||||
|
# ── Model discovery ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_available_models(self):
|
||||||
|
"""List available .nam files from models_dir and ~/.pedal/models/."""
|
||||||
|
models = []
|
||||||
|
if self._engine is not None:
|
||||||
|
models = self._engine.list_available_models()
|
||||||
|
# Also scan the Tone3000 download directory for models not in
|
||||||
|
# the primary models_dir
|
||||||
|
from pathlib import Path
|
||||||
|
extra = Path.home() / ".pedal" / "models"
|
||||||
|
if extra != self._models_dir and extra.is_dir():
|
||||||
|
from .nam_engine_host import NAMFastModel
|
||||||
|
seen = set(m.path for m in models)
|
||||||
|
for f in sorted(extra.glob("*.nam")):
|
||||||
|
if str(f) not in seen:
|
||||||
|
size_mb = f.stat().st_size / (1024 * 1024)
|
||||||
|
# Read actual architecture from the .nam file
|
||||||
|
try:
|
||||||
|
with open(f) as fp:
|
||||||
|
data = json.load(fp)
|
||||||
|
arch = data.get("architecture", "unknown")
|
||||||
|
except Exception:
|
||||||
|
arch = "unknown"
|
||||||
|
models.append(NAMFastModel(
|
||||||
|
name=f.stem,
|
||||||
|
path=str(f),
|
||||||
|
size_mb=size_mb,
|
||||||
|
architecture=arch,
|
||||||
|
))
|
||||||
|
return models
|
||||||
+1066
-169
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -21,7 +21,7 @@ from collections import deque
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from presets.types import MIDIMapping
|
from src.presets.types import MIDIMapping
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ class UARTMIDI(MIDIInterface):
|
|||||||
)
|
)
|
||||||
logger.info("UART MIDI opened on %s", self._port_name)
|
logger.info("UART MIDI opened on %s", self._port_name)
|
||||||
return True
|
return True
|
||||||
except (ImportError, OSError, serial.SerialException) as e:
|
except (ImportError, OSError) as e:
|
||||||
logger.error("UART MIDI open failed: %s", e)
|
logger.error("UART MIDI open failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from .types import Bank, Channel, FXBlock, FXType, MIDIMapping, Preset, Snapshot
|
||||||
|
from .manager import PresetManager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Bank", "Channel", "FXBlock", "FXType", "MIDIMapping",
|
||||||
|
"Preset", "PresetManager", "Snapshot",
|
||||||
|
]
|
||||||
|
|||||||
+280
-60
@@ -19,7 +19,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .types import Bank, FXBlock, FXType, MIDIMapping, Preset, Snapshot
|
from .types import Bank, Channel, FXBlock, FXType, MIDIMapping, Preset, Snapshot
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ def _preset_to_dict(preset: Preset) -> dict:
|
|||||||
"""Serialize a Preset to a JSON-compatible dict."""
|
"""Serialize a Preset to a JSON-compatible dict."""
|
||||||
return {
|
return {
|
||||||
"name": preset.name,
|
"name": preset.name,
|
||||||
|
"channel": preset.channel.value,
|
||||||
"bank": preset.bank,
|
"bank": preset.bank,
|
||||||
"program": preset.program,
|
"program": preset.program,
|
||||||
"master_volume": preset.master_volume,
|
"master_volume": preset.master_volume,
|
||||||
@@ -58,6 +59,7 @@ def _preset_to_dict(preset: Preset) -> dict:
|
|||||||
"params": dict(block.params),
|
"params": dict(block.params),
|
||||||
"nam_model_path": block.nam_model_path,
|
"nam_model_path": block.nam_model_path,
|
||||||
"ir_file_path": block.ir_file_path,
|
"ir_file_path": block.ir_file_path,
|
||||||
|
"block_id": block.block_id,
|
||||||
}
|
}
|
||||||
for block in preset.chain
|
for block in preset.chain
|
||||||
],
|
],
|
||||||
@@ -82,6 +84,7 @@ def _preset_to_dict(preset: Preset) -> dict:
|
|||||||
"params": dict(block.params),
|
"params": dict(block.params),
|
||||||
"nam_model_path": block.nam_model_path,
|
"nam_model_path": block.nam_model_path,
|
||||||
"ir_file_path": block.ir_file_path,
|
"ir_file_path": block.ir_file_path,
|
||||||
|
"block_id": block.block_id,
|
||||||
}
|
}
|
||||||
for block in snap.chain
|
for block in snap.chain
|
||||||
],
|
],
|
||||||
@@ -103,6 +106,7 @@ def _preset_from_dict(data: dict) -> Preset:
|
|||||||
params=dict(block_data.get("params", {})),
|
params=dict(block_data.get("params", {})),
|
||||||
nam_model_path=block_data.get("nam_model_path", ""),
|
nam_model_path=block_data.get("nam_model_path", ""),
|
||||||
ir_file_path=block_data.get("ir_file_path", ""),
|
ir_file_path=block_data.get("ir_file_path", ""),
|
||||||
|
block_id=block_data.get("block_id", ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,6 +131,7 @@ def _preset_from_dict(data: dict) -> Preset:
|
|||||||
params=dict(bd.get("params", {})),
|
params=dict(bd.get("params", {})),
|
||||||
nam_model_path=bd.get("nam_model_path", ""),
|
nam_model_path=bd.get("nam_model_path", ""),
|
||||||
ir_file_path=bd.get("ir_file_path", ""),
|
ir_file_path=bd.get("ir_file_path", ""),
|
||||||
|
block_id=bd.get("block_id", ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
snapshots[int(slot_str)] = Snapshot(
|
snapshots[int(slot_str)] = Snapshot(
|
||||||
@@ -137,6 +142,7 @@ def _preset_from_dict(data: dict) -> Preset:
|
|||||||
|
|
||||||
return Preset(
|
return Preset(
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
|
channel=Channel(data.get("channel", "guitar")),
|
||||||
bank=data.get("bank", 0),
|
bank=data.get("bank", 0),
|
||||||
program=data.get("program", 0),
|
program=data.get("program", 0),
|
||||||
chain=chain,
|
chain=chain,
|
||||||
@@ -171,6 +177,7 @@ class PresetManager:
|
|||||||
self._pipeline = audio_pipeline
|
self._pipeline = audio_pipeline
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
|
self._current_channel: Channel = Channel.GUITAR
|
||||||
self._current_bank: int = 0
|
self._current_bank: int = 0
|
||||||
self._current_program: int = 0
|
self._current_program: int = 0
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
@@ -185,10 +192,21 @@ class PresetManager:
|
|||||||
# Current snapshot slot (0 = none selected)
|
# Current snapshot slot (0 = none selected)
|
||||||
self._current_snapshot: int = 0
|
self._current_snapshot: int = 0
|
||||||
|
|
||||||
logger.info("PresetManager root: %s", self._dir)
|
# Migrate legacy flat presets → gtr channel on first boot
|
||||||
|
self._migrate_legacy_presets()
|
||||||
|
|
||||||
|
# Restore last active preset after boot so the pedal comes back
|
||||||
|
# to the same (channel, bank, program) it was on before shutdown.
|
||||||
|
self.restore_state()
|
||||||
|
|
||||||
|
logger.info("PresetManager root: %s (channel=%s)", self._dir, self._current_channel.value)
|
||||||
|
|
||||||
# ── Public navigation API ───────────────────────────────────────────────
|
# ── Public navigation API ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_channel(self) -> Channel:
|
||||||
|
return self._current_channel
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_bank(self) -> int:
|
def current_bank(self) -> int:
|
||||||
return self._current_bank
|
return self._current_bank
|
||||||
@@ -197,10 +215,43 @@ class PresetManager:
|
|||||||
def current_program(self) -> int:
|
def current_program(self) -> int:
|
||||||
return self._current_program
|
return self._current_program
|
||||||
|
|
||||||
|
def set_channel(self, channel: Channel) -> Preset:
|
||||||
|
"""Switch the active channel and restore its last preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: The channel to switch to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The currently active Preset in the new channel.
|
||||||
|
"""
|
||||||
|
if channel == self._current_channel:
|
||||||
|
try:
|
||||||
|
return self.load(self._current_bank, self._current_program)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return self._select_and_activate(self._current_bank, self._current_program)
|
||||||
|
|
||||||
|
# Save current channel state first
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
|
self._current_channel = channel
|
||||||
|
|
||||||
|
# Try restoring channel-specific state
|
||||||
|
restored = self._restore_channel_state()
|
||||||
|
if restored:
|
||||||
|
return restored
|
||||||
|
|
||||||
|
# Default to bank 0, program 0 in the new channel
|
||||||
|
try:
|
||||||
|
return self._select_and_activate(0, 0)
|
||||||
|
except Exception:
|
||||||
|
# Channel is empty — create first blank preset
|
||||||
|
return self._select_and_activate(0, 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_preset_path(self) -> Path:
|
def current_preset_path(self) -> Path:
|
||||||
"""Path to the slot for the current (bank, program)."""
|
"""Path to the slot for the current (channel, bank, program)."""
|
||||||
return self._preset_path(self._current_bank, self._current_program)
|
return self._preset_path(self._current_channel, self._current_bank, self._current_program)
|
||||||
|
|
||||||
def preset_up(self) -> Preset:
|
def preset_up(self) -> Preset:
|
||||||
"""Select next preset in current bank (wraps 3→0).
|
"""Select next preset in current bank (wraps 3→0).
|
||||||
@@ -240,17 +291,18 @@ class PresetManager:
|
|||||||
"""
|
"""
|
||||||
return self._switch_bank(-1)
|
return self._switch_bank(-1)
|
||||||
|
|
||||||
def select(self, bank: int, program: int) -> Preset:
|
def select(self, bank: int, program: int, channel: Channel | None = None) -> Preset:
|
||||||
"""Directly select a specific (bank, program) slot.
|
"""Directly select a specific (bank, program) slot.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bank: Bank number.
|
bank: Bank number.
|
||||||
program: Preset index (0-3) within the bank.
|
program: Preset index (0-3) within the bank.
|
||||||
|
channel: Optional channel override (defaults to current).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The activated Preset.
|
The activated Preset.
|
||||||
"""
|
"""
|
||||||
return self._select_and_activate(bank, program)
|
return self._select_and_activate(bank, program, channel or self._current_channel)
|
||||||
|
|
||||||
def midi_pc(self, channel: int, program: int) -> Preset:
|
def midi_pc(self, channel: int, program: int) -> Preset:
|
||||||
"""Handle MIDI Program Change: bank=channel, program=program.
|
"""Handle MIDI Program Change: bank=channel, program=program.
|
||||||
@@ -267,15 +319,17 @@ class PresetManager:
|
|||||||
|
|
||||||
# ── CRUD ────────────────────────────────────────────────────────────────
|
# ── CRUD ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def save(self, preset: Preset, *, auto: bool = False) -> None:
|
def save(self, preset: Preset, *, auto: bool = False, channel: Channel | None = None) -> None:
|
||||||
"""Save a preset to its (bank, program) slot on disk.
|
"""Save a preset to its (bank, program) slot on disk.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
preset: The preset to persist.
|
preset: The preset to persist.
|
||||||
auto: If True, this was triggered by auto-save and the
|
auto: If True, this was triggered by auto-save and the
|
||||||
dirty/current-state write is implicitly handled.
|
dirty/current-state write is implicitly handled.
|
||||||
|
channel: Channel to save under (defaults to preset.channel).
|
||||||
"""
|
"""
|
||||||
path = self._preset_path(preset.bank, preset.program)
|
ch = channel or preset.channel
|
||||||
|
path = self._preset_path(ch, preset.bank, preset.program)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(
|
path.write_text(
|
||||||
json.dumps(_preset_to_dict(preset), indent=2, sort_keys=True),
|
json.dumps(_preset_to_dict(preset), indent=2, sort_keys=True),
|
||||||
@@ -287,16 +341,17 @@ class PresetManager:
|
|||||||
self._known_banks.add(preset.bank)
|
self._known_banks.add(preset.bank)
|
||||||
|
|
||||||
# Save/update bank metadata
|
# Save/update bank metadata
|
||||||
self._save_bank_meta(preset.bank)
|
self._save_bank_meta(ch, preset.bank)
|
||||||
|
|
||||||
logger.info("Saved preset '%s' → %s", preset.name, path)
|
logger.info("Saved preset '%s' → %s", preset.name, path)
|
||||||
|
|
||||||
def load(self, bank: int, program: int) -> Preset:
|
def load(self, bank: int, program: int, channel: Channel | None = None) -> Preset:
|
||||||
"""Load a preset from disk.
|
"""Load a preset from disk.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bank: Bank number.
|
bank: Bank number.
|
||||||
program: Preset index (0-3).
|
program: Preset index (0-3).
|
||||||
|
channel: Channel to load from (defaults to current).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The deserialized Preset.
|
The deserialized Preset.
|
||||||
@@ -305,41 +360,52 @@ class PresetManager:
|
|||||||
FileNotFoundError: If the preset slot doesn't exist.
|
FileNotFoundError: If the preset slot doesn't exist.
|
||||||
json.JSONDecodeError: If the file is corrupt.
|
json.JSONDecodeError: If the file is corrupt.
|
||||||
"""
|
"""
|
||||||
path = self._preset_path(bank, program)
|
ch = channel or self._current_channel
|
||||||
|
path = self._preset_path(ch, bank, program)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"No preset at (bank={bank}, program={program}): {path}"
|
f"No preset at (channel={ch.value}, bank={bank}, program={program}): {path}"
|
||||||
)
|
)
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
preset = _preset_from_dict(data)
|
preset = _preset_from_dict(data)
|
||||||
# Ensure metadata is correct even if file was manually altered
|
# Ensure metadata is correct even if file was manually altered
|
||||||
|
preset.channel = ch
|
||||||
preset.bank = bank
|
preset.bank = bank
|
||||||
preset.program = program
|
preset.program = program
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
def delete(self, bank: int, program: int) -> None:
|
def delete(self, bank: int, program: int, channel: Channel | None = None) -> None:
|
||||||
"""Delete a preset slot from disk."""
|
"""Delete a preset slot from disk.
|
||||||
path = self._preset_path(bank, program)
|
|
||||||
|
Args:
|
||||||
|
bank: Bank number.
|
||||||
|
program: Preset index.
|
||||||
|
channel: Channel to delete from (defaults to current).
|
||||||
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
path = self._preset_path(ch, bank, program)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
logger.info("Deleted preset at (bank=%d, program=%d)", bank, program)
|
logger.info("Deleted preset at (channel=%s, bank=%d, program=%d)", ch.value, bank, program)
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
||||||
def rename(self, bank: int, program: int, new_name: str) -> Preset:
|
def rename(self, bank: int, program: int, new_name: str, channel: Channel | None = None) -> Preset:
|
||||||
"""Rename a preset and re-save it.
|
"""Rename a preset and re-save it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bank: Bank number.
|
bank: Bank number.
|
||||||
program: Preset index.
|
program: Preset index.
|
||||||
new_name: Replacement name.
|
new_name: Replacement name.
|
||||||
|
channel: Channel (defaults to current).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The renamed Preset.
|
The renamed Preset.
|
||||||
"""
|
"""
|
||||||
preset = self.load(bank, program)
|
preset = self.load(bank, program, channel)
|
||||||
preset.name = new_name
|
preset.name = new_name
|
||||||
self.save(preset)
|
self.save(preset, channel=channel)
|
||||||
logger.info("Renamed preset at (%d,%d) → '%s'", bank, program, new_name)
|
logger.info("Renamed preset at (%s,%d,%d) → '%s'",
|
||||||
|
(channel or self._current_channel).value, bank, program, new_name)
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
def reorder(self, bank: int, program: int, new_program: int) -> None:
|
def reorder(self, bank: int, program: int, new_program: int) -> None:
|
||||||
@@ -415,10 +481,22 @@ class PresetManager:
|
|||||||
|
|
||||||
# ── Bank management ─────────────────────────────────────────────────────
|
# ── Bank management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def list_banks(self) -> list[Bank]:
|
def list_banks(self, channel: Channel | None = None) -> list[Bank]:
|
||||||
"""Scan the preset directory and return known banks sorted by number."""
|
"""Scan the preset directory and return known banks sorted by number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel to list banks for (defaults to current).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Bank objects sorted by number.
|
||||||
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
ch_dir = self._dir / ch.value
|
||||||
|
if not ch_dir.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
banks: dict[int, Bank] = {}
|
banks: dict[int, Bank] = {}
|
||||||
for meta_path in sorted(self._dir.glob("bank_*/bank.json")):
|
for meta_path in sorted(ch_dir.glob("bank_*/bank.json")):
|
||||||
try:
|
try:
|
||||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
num = data.get("number", 0)
|
num = data.get("number", 0)
|
||||||
@@ -434,7 +512,7 @@ class PresetManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Also discover banks by scanning dirs without bank.json
|
# Also discover banks by scanning dirs without bank.json
|
||||||
for p_dir in sorted(self._dir.glob("bank_*")):
|
for p_dir in sorted(ch_dir.glob("bank_*")):
|
||||||
try:
|
try:
|
||||||
num = int(p_dir.name.split("_")[1])
|
num = int(p_dir.name.split("_")[1])
|
||||||
if num not in banks:
|
if num not in banks:
|
||||||
@@ -460,20 +538,25 @@ class PresetManager:
|
|||||||
# ── Auto-restore ────────────────────────────────────────────────────────
|
# ── Auto-restore ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def save_state(self) -> None:
|
def save_state(self) -> None:
|
||||||
"""Persist the current (bank, program) for power-cycle restore.
|
"""Persist the current (channel, bank, program) for power-cycle restore.
|
||||||
|
|
||||||
Call this whenever the active preset changes to guarantee
|
Call this whenever the active preset or channel changes to guarantee
|
||||||
the pedal comes back to the same sound after a power cycle.
|
the pedal comes back to the same sound after a power cycle.
|
||||||
"""
|
"""
|
||||||
state = {"current_bank": self._current_bank, "current_program": self._current_program}
|
state = {
|
||||||
|
"current_channel": self._current_channel.value,
|
||||||
|
"current_bank": self._current_bank,
|
||||||
|
"current_program": self._current_program,
|
||||||
|
}
|
||||||
state_path = self._dir / "state.json"
|
state_path = self._dir / "state.json"
|
||||||
state_path.write_text(
|
state_path.write_text(
|
||||||
json.dumps(state, indent=2), encoding="utf-8"
|
json.dumps(state, indent=2), encoding="utf-8"
|
||||||
)
|
)
|
||||||
logger.debug("State saved: bank=%d program=%d", self._current_bank, self._current_program)
|
logger.debug("State saved: channel=%s bank=%d program=%d",
|
||||||
|
self._current_channel.value, self._current_bank, self._current_program)
|
||||||
|
|
||||||
def restore_state(self) -> Optional[Preset]:
|
def restore_state(self) -> Optional[Preset]:
|
||||||
"""Restore the last active preset from state.json.
|
"""Restore the last active (channel, bank, program) from state.json.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The restored Preset, or None if no state file exists or the
|
The restored Preset, or None if no state file exists or the
|
||||||
@@ -486,17 +569,20 @@ class PresetManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
ch = Channel(state.get("current_channel", "guitar"))
|
||||||
bank = state["current_bank"]
|
bank = state["current_bank"]
|
||||||
program = state["current_program"]
|
program = state["current_program"]
|
||||||
preset = self.load(bank, program)
|
preset = self.load(bank, program, ch)
|
||||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError):
|
||||||
logger.warning("Could not restore state, falling back to first preset")
|
logger.warning("Could not restore state, falling back to first preset")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
self._current_channel = ch
|
||||||
self._current_bank = bank
|
self._current_bank = bank
|
||||||
self._current_program = program
|
self._current_program = program
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
logger.info("Restored state: bank=%d program=%d '%s'", bank, program, preset.name)
|
logger.info("Restored state: channel=%s bank=%d program=%d '%s'",
|
||||||
|
ch.value, bank, program, preset.name)
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
# ── Activation ──────────────────────────────────────────────────────────
|
# ── Activation ──────────────────────────────────────────────────────────
|
||||||
@@ -514,15 +600,20 @@ class PresetManager:
|
|||||||
|
|
||||||
# ── Factory presets ─────────────────────────────────────────────────────
|
# ── Factory presets ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def install_factory_presets(self, overwrite: bool = False) -> int:
|
def install_factory_presets(self, overwrite: bool = False,
|
||||||
|
channel: Channel = Channel.GUITAR) -> int:
|
||||||
"""Install bundled factory presets into the preset store.
|
"""Install bundled factory presets into the preset store.
|
||||||
|
|
||||||
Scans ``FACTORY_PRESET_DIR`` for ``bank_*/preset_*.json`` files
|
Scans the channel-specific subdirectory of ``FACTORY_PRESET_DIR``
|
||||||
and copies them into the user preset store.
|
for ``bank_*/preset_*.json`` files and copies them into the user
|
||||||
|
preset store. For GUITAR the root of ``FACTORY_PRESET_DIR`` is
|
||||||
|
scanned (backward-compatible with legacy ``bank_0``–``bank_16``).
|
||||||
|
All other channels use ``FACTORY_PRESET_DIR / channel.value``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
overwrite: If True, overwrite existing presets at the same
|
overwrite: If True, overwrite existing presets at the same
|
||||||
(bank, program) slots. If False, skip occupied slots.
|
(bank, program) slots. If False, skip occupied slots.
|
||||||
|
channel: Which channel to install factory presets into (default GUITAR).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of factory presets installed.
|
Number of factory presets installed.
|
||||||
@@ -531,8 +622,17 @@ class PresetManager:
|
|||||||
logger.warning("Factory preset directory not found: %s", FACTORY_PRESET_DIR)
|
logger.warning("Factory preset directory not found: %s", FACTORY_PRESET_DIR)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# Determine source directory — root for GUITAR, subdir for all others
|
||||||
|
source_dir = FACTORY_PRESET_DIR if channel == Channel.GUITAR \
|
||||||
|
else FACTORY_PRESET_DIR / channel.value
|
||||||
|
|
||||||
|
if not source_dir.is_dir():
|
||||||
|
logger.warning("No factory presets for channel %s: %s",
|
||||||
|
channel.value, source_dir)
|
||||||
|
return 0
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for src in sorted(FACTORY_PRESET_DIR.rglob("preset_*.json")):
|
for src in sorted(source_dir.glob("bank_*/preset_*.json")):
|
||||||
# Derive (bank, program) from file path
|
# Derive (bank, program) from file path
|
||||||
bank_dir = src.parent
|
bank_dir = src.parent
|
||||||
try:
|
try:
|
||||||
@@ -542,20 +642,20 @@ class PresetManager:
|
|||||||
logger.warning("Skipping malformed factory preset path: %s", src)
|
logger.warning("Skipping malformed factory preset path: %s", src)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dest = self._preset_path(bank_num, prog_num)
|
dest = self._preset_path(channel, bank_num, prog_num)
|
||||||
if dest.exists() and not overwrite:
|
if dest.exists() and not overwrite:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(src, dest)
|
shutil.copy2(src, dest)
|
||||||
self._known_banks.add(bank_num)
|
self._known_banks.add(bank_num)
|
||||||
self._save_bank_meta(bank_num)
|
self._save_bank_meta(channel, bank_num)
|
||||||
count += 1
|
count += 1
|
||||||
logger.debug("Installed factory preset: %s", dest)
|
logger.debug("Installed factory preset: %s", dest)
|
||||||
|
|
||||||
if count:
|
if count:
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
logger.info("Installed %d factory presets", count)
|
logger.info("Installed %d factory presets for %s", count, channel.value)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def install_factory_irs(self, overwrite: bool = False) -> int:
|
def install_factory_irs(self, overwrite: bool = False) -> int:
|
||||||
@@ -595,19 +695,31 @@ class PresetManager:
|
|||||||
|
|
||||||
# ── Internal helpers ────────────────────────────────────────────────────
|
# ── Internal helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _preset_path(self, bank: int, program: int) -> Path:
|
def _preset_path(self, channel: Channel, bank: int, program: int) -> Path:
|
||||||
"""Compute the on-disk path for a (bank, program) slot."""
|
"""Compute the on-disk path for a (channel, bank, program) slot."""
|
||||||
return self._dir / f"bank_{bank}" / f"preset_{program}.json"
|
return self._dir / channel.value / f"bank_{bank}" / f"preset_{program}.json"
|
||||||
|
|
||||||
def _bank_dir(self, bank: int) -> Path:
|
def _channel_dir(self, channel: Channel) -> Path:
|
||||||
return self._dir / f"bank_{bank}"
|
"""Return the root directory for a given channel."""
|
||||||
|
return self._dir / channel.value
|
||||||
|
|
||||||
def _bank_meta_path(self, bank: int) -> Path:
|
def _bank_dir(self, channel: Channel, bank: int) -> Path:
|
||||||
return self._bank_dir(bank) / "bank.json"
|
return self._channel_dir(channel) / f"bank_{bank}"
|
||||||
|
|
||||||
def _get_or_create_bank(self, number: int, name: str = "") -> Bank:
|
def _bank_meta_path(self, channel: Channel, bank: int) -> Path:
|
||||||
"""Return an existing bank or create a new one on disk."""
|
return self._bank_dir(channel, bank) / "bank.json"
|
||||||
meta_path = self._bank_meta_path(number)
|
|
||||||
|
def _get_or_create_bank(self, number: int, name: str = "",
|
||||||
|
channel: Channel | None = None) -> Bank:
|
||||||
|
"""Return an existing bank or create a new one on disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
number: Bank number.
|
||||||
|
name: Optional display name.
|
||||||
|
channel: Channel (defaults to current).
|
||||||
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
meta_path = self._bank_meta_path(ch, number)
|
||||||
if meta_path.exists():
|
if meta_path.exists():
|
||||||
try:
|
try:
|
||||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
@@ -620,14 +732,15 @@ class PresetManager:
|
|||||||
|
|
||||||
# Create new bank
|
# Create new bank
|
||||||
bank = Bank(name=name or f"Bank {number}", number=number)
|
bank = Bank(name=name or f"Bank {number}", number=number)
|
||||||
self._save_bank_meta(number, bank.name)
|
self._save_bank_meta(ch, number, bank.name)
|
||||||
self._known_banks.add(number)
|
self._known_banks.add(number)
|
||||||
logger.info("Created bank %d: '%s'", number, bank.name)
|
logger.info("Created bank %d: '%s' (channel=%s)", number, bank.name, ch.value)
|
||||||
return bank
|
return bank
|
||||||
|
|
||||||
def _save_bank_meta(self, bank_num: int, name: str | None = None) -> None:
|
def _save_bank_meta(self, channel: Channel, bank_num: int,
|
||||||
|
name: str | None = None) -> None:
|
||||||
"""Write or update a bank's metadata file."""
|
"""Write or update a bank's metadata file."""
|
||||||
meta_path = self._bank_meta_path(bank_num)
|
meta_path = self._bank_meta_path(channel, bank_num)
|
||||||
existing = {}
|
existing = {}
|
||||||
if meta_path.exists():
|
if meta_path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -673,6 +786,7 @@ class PresetManager:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
preset = Preset(
|
preset = Preset(
|
||||||
name=f"Empty {new_bank.name}",
|
name=f"Empty {new_bank.name}",
|
||||||
|
channel=self._current_channel,
|
||||||
bank=new_bank.number,
|
bank=new_bank.number,
|
||||||
program=self._current_program,
|
program=self._current_program,
|
||||||
)
|
)
|
||||||
@@ -680,11 +794,14 @@ class PresetManager:
|
|||||||
self._current_bank = new_bank.number
|
self._current_bank = new_bank.number
|
||||||
self._current_program = preset.program
|
self._current_program = preset.program
|
||||||
self.activate(preset)
|
self.activate(preset)
|
||||||
|
self._save_channel_state()
|
||||||
self.save_state()
|
self.save_state()
|
||||||
logger.info("Switched to bank %d, preset '%s'", new_bank.number, preset.name)
|
logger.info("Switched to bank %d, preset '%s' (channel=%s)",
|
||||||
|
new_bank.number, preset.name, self._current_channel.value)
|
||||||
return new_bank, preset
|
return new_bank, preset
|
||||||
|
|
||||||
def _select_and_activate(self, bank: int, program: int) -> Preset:
|
def _select_and_activate(self, bank: int, program: int,
|
||||||
|
channel: Channel | None = None) -> Preset:
|
||||||
"""Select a (bank, program) slot and activate it.
|
"""Select a (bank, program) slot and activate it.
|
||||||
|
|
||||||
If the slot is empty, creates a blank preset in that slot first.
|
If the slot is empty, creates a blank preset in that slot first.
|
||||||
@@ -692,22 +809,125 @@ class PresetManager:
|
|||||||
Args:
|
Args:
|
||||||
bank: Bank number.
|
bank: Bank number.
|
||||||
program: Preset index (0-3).
|
program: Preset index (0-3).
|
||||||
|
channel: Channel to operate on (defaults to current).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The activated Preset.
|
The activated Preset.
|
||||||
"""
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
program = max(0, min(PRESETS_PER_BANK - 1, program))
|
program = max(0, min(PRESETS_PER_BANK - 1, program))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
preset = self.load(bank, program)
|
preset = self.load(bank, program, ch)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Create a blank placeholder preset
|
# Create a blank placeholder preset
|
||||||
bank_obj = self._get_or_create_bank(bank)
|
bank_obj = self._get_or_create_bank(bank, channel=ch)
|
||||||
preset = Preset(name=f"New {bank_obj.name} #{program}", bank=bank, program=program)
|
preset = Preset(
|
||||||
self.save(preset, auto=True)
|
name=f"New {bank_obj.name} #{program}",
|
||||||
|
channel=ch, bank=bank, program=program,
|
||||||
|
)
|
||||||
|
self.save(preset, auto=True, channel=ch)
|
||||||
|
|
||||||
|
self._current_channel = ch
|
||||||
self._current_bank = bank
|
self._current_bank = bank
|
||||||
self._current_program = program
|
self._current_program = program
|
||||||
self.activate(preset)
|
self.activate(preset)
|
||||||
|
self._save_channel_state(ch)
|
||||||
self.save_state()
|
self.save_state()
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
|
# ── Channel state helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _channel_state_path(self, channel: Channel) -> Path:
|
||||||
|
"""Path to the per-channel state file."""
|
||||||
|
return self._channel_dir(channel) / "channel_state.json"
|
||||||
|
|
||||||
|
def _save_channel_state(self, channel: Channel | None = None) -> None:
|
||||||
|
"""Persist the channel-specific (bank, program) state."""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
state = {
|
||||||
|
"current_bank": self._current_bank,
|
||||||
|
"current_program": self._current_program,
|
||||||
|
}
|
||||||
|
path = self._channel_state_path(ch)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||||
|
logger.debug("Channel state saved for %s: bank=%d program=%d",
|
||||||
|
ch.value, self._current_bank, self._current_program)
|
||||||
|
|
||||||
|
def _restore_channel_state(self, channel: Channel | None = None) -> Optional[Preset]:
|
||||||
|
"""Restore the per-channel (bank, program) state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel to restore (defaults to current).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The restored Preset, or None if no saved state exists.
|
||||||
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
path = self._channel_state_path(ch)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
bank = state["current_bank"]
|
||||||
|
program = state["current_program"]
|
||||||
|
preset = self.load(bank, program, ch)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._current_bank = bank
|
||||||
|
self._current_program = program
|
||||||
|
logger.info("Restored channel state for %s: bank=%d program=%d '%s'",
|
||||||
|
ch.value, bank, program, preset.name)
|
||||||
|
return preset
|
||||||
|
|
||||||
|
# ── Legacy migration ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _migrate_legacy_presets(self) -> None:
|
||||||
|
"""Migrate flat bank_* directories under root into gtr/ subdirectory.
|
||||||
|
|
||||||
|
Before dual-channel support, presets were stored as:
|
||||||
|
{root}/bank_0/preset_0.json
|
||||||
|
|
||||||
|
Now they should live under:
|
||||||
|
{root}/gtr/bank_0/preset_0.json
|
||||||
|
|
||||||
|
This runs once on first boot with the new layout.
|
||||||
|
"""
|
||||||
|
ch_dir = self._dir / "guitar"
|
||||||
|
if ch_dir.exists():
|
||||||
|
# Already migrated or new install — nothing to do
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there are legacy flat bank_* dirs
|
||||||
|
legacy_banks = sorted(self._dir.glob("bank_*"))
|
||||||
|
if not legacy_banks:
|
||||||
|
return # Fresh install, nothing to migrate
|
||||||
|
|
||||||
|
logger.info("Migrating %d legacy bank directories → gtr channel ...", len(legacy_banks))
|
||||||
|
ch_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for src in legacy_banks:
|
||||||
|
dest = ch_dir / src.name
|
||||||
|
try:
|
||||||
|
shutil.move(str(src), str(dest))
|
||||||
|
logger.debug(" migrated %s → %s", src.name, dest)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(" failed to migrate %s: %s", src.name, e)
|
||||||
|
|
||||||
|
# Also migrate state.json if it exists at root
|
||||||
|
legacy_state = self._dir / "state.json"
|
||||||
|
if legacy_state.exists():
|
||||||
|
try:
|
||||||
|
state = json.loads(legacy_state.read_text(encoding="utf-8"))
|
||||||
|
if "current_channel" not in state:
|
||||||
|
state["current_channel"] = "guitar"
|
||||||
|
legacy_state.write_text(
|
||||||
|
json.dumps(state, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("Legacy preset migration complete")
|
||||||
+51
-1
@@ -2,11 +2,26 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
import enum
|
import enum
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Channel(enum.StrEnum):
|
||||||
|
"""Instrument/voice signal channel — each has its own independent preset bank.
|
||||||
|
|
||||||
|
Supports guitar, bass, keys, vocals, and backing tracks. Existing preset
|
||||||
|
files with "guitar" or "bass" channel values remain fully compatible.
|
||||||
|
"""
|
||||||
|
GUITAR = "guitar"
|
||||||
|
BASS = "bass"
|
||||||
|
KEYS = "keys"
|
||||||
|
VOCALS = "vocals"
|
||||||
|
BACKING_TRACKS = "backing_tracks"
|
||||||
|
|
||||||
|
|
||||||
class FXType(enum.StrEnum):
|
class FXType(enum.StrEnum):
|
||||||
"""Types of effects in the pedal signal chain."""
|
"""Types of effects in the pedal signal chain."""
|
||||||
# Existing
|
# Existing
|
||||||
@@ -76,8 +91,16 @@ class FXBlock:
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
bypass: bool = False
|
bypass: bool = False
|
||||||
params: dict[str, float] = field(default_factory=dict)
|
params: dict[str, float] = field(default_factory=dict)
|
||||||
|
subtype: str = "" # Algorithm variant (e.g. "hall"/"spring"/"plate" for reverb)
|
||||||
nam_model_path: str = "" # Path to .nam file (for NAM_AMP type)
|
nam_model_path: str = "" # Path to .nam file (for NAM_AMP type)
|
||||||
ir_file_path: str = "" # Path to .wav IR file (for IR_CAB type)
|
ir_file_path: str = "" # Path to .wav IR file (for IR_CAB type)
|
||||||
|
block_id: str = "" # Unique instance ID within a chain (auto-filled if empty)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Auto-generate block_id if empty."""
|
||||||
|
if not self.block_id:
|
||||||
|
raw = f"{self.fx_type.value}_{datetime.datetime.now(datetime.timezone.utc).timestamp()}_{id(self)}"
|
||||||
|
self.block_id = hashlib.md5(raw.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -101,6 +124,7 @@ class Snapshot:
|
|||||||
class Preset:
|
class Preset:
|
||||||
"""A complete pedal preset — full signal chain state."""
|
"""A complete pedal preset — full signal chain state."""
|
||||||
name: str
|
name: str
|
||||||
|
channel: Channel = Channel.GUITAR
|
||||||
bank: int = 0
|
bank: int = 0
|
||||||
program: int = 0
|
program: int = 0
|
||||||
chain: list[FXBlock] = field(default_factory=list)
|
chain: list[FXBlock] = field(default_factory=list)
|
||||||
@@ -117,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)
|
||||||
|
|||||||
+285
-30
@@ -12,6 +12,7 @@ Manages ALSA / JACK / I2S setup on RPi 4B:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -78,7 +79,7 @@ FOCUSRITE_PROFILES: dict[str, tuple[str, int, int]] = {
|
|||||||
# 48 kHz / 64 frames = 1.33 ms buffer → aggressive, may xrun on RPi 4B
|
# 48 kHz / 64 frames = 1.33 ms buffer → aggressive, may xrun on RPi 4B
|
||||||
LATENCY_PROFILES: dict[str, dict] = {
|
LATENCY_PROFILES: dict[str, dict] = {
|
||||||
"standard": {
|
"standard": {
|
||||||
"period": 128,
|
"period": 512,
|
||||||
"nperiods": 2,
|
"nperiods": 2,
|
||||||
"rate": 48000,
|
"rate": 48000,
|
||||||
"rt_priority": 70,
|
"rt_priority": 70,
|
||||||
@@ -89,6 +90,12 @@ LATENCY_PROFILES: dict[str, dict] = {
|
|||||||
"rate": 48000,
|
"rate": 48000,
|
||||||
"rt_priority": 80,
|
"rt_priority": 80,
|
||||||
},
|
},
|
||||||
|
"stable": {
|
||||||
|
"period": 512,
|
||||||
|
"nperiods": 2,
|
||||||
|
"rate": 48000,
|
||||||
|
"rt_priority": 60,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── System paths ──────────────────────────────────────────────────
|
# ── System paths ──────────────────────────────────────────────────
|
||||||
@@ -126,6 +133,10 @@ class AudioConfig:
|
|||||||
jack_enabled: bool = True
|
jack_enabled: bool = True
|
||||||
auto_connect: bool = True
|
auto_connect: bool = True
|
||||||
xrun_warn_only: bool = True
|
xrun_warn_only: bool = True
|
||||||
|
# Custom period/rate override — saved to config when user changes
|
||||||
|
# these via the UI. When set, they override the profile defaults.
|
||||||
|
period: Optional[int] = None
|
||||||
|
rate: Optional[int] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latency_profile(self) -> dict:
|
def latency_profile(self) -> dict:
|
||||||
@@ -133,7 +144,14 @@ class AudioConfig:
|
|||||||
p = LATENCY_PROFILES.get(self.profile)
|
p = LATENCY_PROFILES.get(self.profile)
|
||||||
if p is None:
|
if p is None:
|
||||||
logger.warning("Unknown profile %r, falling back to standard", self.profile)
|
logger.warning("Unknown profile %r, falling back to standard", self.profile)
|
||||||
return LATENCY_PROFILES["standard"]
|
p = dict(LATENCY_PROFILES["standard"])
|
||||||
|
else:
|
||||||
|
p = dict(p)
|
||||||
|
# Apply stored period/rate overrides if set
|
||||||
|
if self.period is not None:
|
||||||
|
p["period"] = self.period
|
||||||
|
if self.rate is not None:
|
||||||
|
p["rate"] = self.rate
|
||||||
return p
|
return p
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -270,28 +288,49 @@ class AudioSystem:
|
|||||||
logger.info("JACK disabled in config")
|
logger.info("JACK disabled in config")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Already running?
|
# Already running? Check that the running JACK matches our
|
||||||
|
# desired config — a zombie jackd from another process can
|
||||||
|
# satisfy _jack_is_running() but have wrong settings.
|
||||||
|
# IMPORTANT: On JACK 1.9.22 there is no /dev/shm/jack_default_0_0
|
||||||
|
# socket. Instead, verify via jack_lsp which tests actual IPC.
|
||||||
if _jack_is_running():
|
if _jack_is_running():
|
||||||
logger.info("JACK already running")
|
# Verify JACK is actually accepting connections AND matches
|
||||||
return True
|
# our device (ignore rogue jackd -T from bt-a2dp's alsa_in)
|
||||||
|
if _jack_is_operational() and _jack_matches_config(self.config.output_device):
|
||||||
|
logger.info("JACK already running and operational — reusing")
|
||||||
|
return True
|
||||||
|
# Process exists but can't connect — likely a zombie that
|
||||||
|
# spawned but failed to init audio. Kill it first.
|
||||||
|
logger.warning("JACK process exists but not operational — killing")
|
||||||
|
try:
|
||||||
|
subprocess.run(["killall", "-9", "jackd"], capture_output=True, timeout=3)
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
profile = self.config.latency_profile
|
profile = self.config.latency_profile
|
||||||
|
jack_env = os.environ.copy()
|
||||||
|
jack_env["JACK_NO_AUDIO_RESERVATION"] = "1"
|
||||||
|
# Scarlett 2i2 needs 2 channels even in mono mode
|
||||||
|
n_in = max(2, self.config.capture_channels)
|
||||||
|
n_out = max(2, self.config.playback_channels)
|
||||||
cmd = [
|
cmd = [
|
||||||
"jackd",
|
"jackd",
|
||||||
f"-P{profile['rt_priority']}",
|
"-P", str(profile['rt_priority']),
|
||||||
f"-p{profile['period']}",
|
"-d", "alsa",
|
||||||
f"-n{profile['nperiods']}",
|
"-d", self.config.output_device,
|
||||||
f"-r{profile['rate']}",
|
"-r", str(profile['rate']),
|
||||||
"-dalsa",
|
"-p", str(profile['period']),
|
||||||
f"-d{self.config.output_device}",
|
"-n", str(profile['nperiods']),
|
||||||
f"-i{self.config.capture_channels}",
|
"-i", str(n_in),
|
||||||
f"-o{self.config.playback_channels}",
|
"-o", str(n_out),
|
||||||
]
|
]
|
||||||
logger.info("Starting JACK: %s", " ".join(cmd))
|
logger.info("Starting JACK: %s", " ".join(cmd))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
|
env=jack_env,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
@@ -299,19 +338,36 @@ class AudioSystem:
|
|||||||
logger.error("jackd not found — install jackd2")
|
logger.error("jackd not found — install jackd2")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Wait for readiness via jack_wait
|
# Wait for readiness — verify JACK is actually operational
|
||||||
|
# (process exists AND SHM socket is created), not just that
|
||||||
|
# the jackd process spawned. jackd can start but fail to
|
||||||
|
# initialize the audio device, leaving a zombie process that
|
||||||
|
# pgrep finds but jack_lsp can't connect to.
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
if _jack_is_running():
|
if _jack_is_running():
|
||||||
logger.info(
|
# Verify JACK is actually operational via jack_lsp
|
||||||
"JACK started: period=%d, nperiods=%d, rate=%d",
|
# (jack_default_0_0 socket doesn't exist on JACK 1.9.22)
|
||||||
profile["period"], profile["nperiods"], profile["rate"],
|
if _jack_is_operational():
|
||||||
)
|
logger.info(
|
||||||
# Auto-connect ports if enabled
|
"JACK started: period=%d, nperiods=%d, rate=%d",
|
||||||
if self.config.auto_connect:
|
profile["period"], profile["nperiods"], profile["rate"],
|
||||||
self.connect_fx_ports()
|
)
|
||||||
return True
|
# Stabilization check — JACK can start briefly then crash
|
||||||
|
# if the ALSA device is flaky. Wait 1.5s with retries.
|
||||||
|
time.sleep(0.5)
|
||||||
|
if _jack_is_operational() and _jack_matches_config(self.config.output_device):
|
||||||
|
# Auto-connect ports if enabled
|
||||||
|
if self.config.auto_connect:
|
||||||
|
self.connect_fx_ports()
|
||||||
|
return True
|
||||||
|
# JACK was running but dropped — don't return yet,
|
||||||
|
# continue the wait loop for a fresh attempt
|
||||||
|
logger.warning("JACK appeared briefly then became non-operational")
|
||||||
|
continue
|
||||||
|
# JACK process exists but not ready yet — keep waiting
|
||||||
|
continue
|
||||||
|
|
||||||
# Timed out — check for common issues
|
# Timed out — check for common issues
|
||||||
poll = proc.poll()
|
poll = proc.poll()
|
||||||
@@ -324,6 +380,25 @@ class AudioSystem:
|
|||||||
|
|
||||||
def stop_jack(self) -> None:
|
def stop_jack(self) -> None:
|
||||||
"""Gracefully stop the JACK server."""
|
"""Gracefully stop the JACK server."""
|
||||||
|
# Stop the Bluetooth A2DP bridge first — it restarts jackd
|
||||||
|
# independently and can race with our profile changes.
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "stop", "bt-a2dp-jack"],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Kill alsa_in — bt-a2dp's bridge uses it, and when JACK dies
|
||||||
|
# alsa_in auto-spawns a rogue jackd -T -ndefault that satisfies
|
||||||
|
# _jack_is_operational() but connects as the wrong server.
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["killall", "alsa_in"],
|
||||||
|
capture_output=True, timeout=3,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["killall", "jackd"],
|
["killall", "jackd"],
|
||||||
@@ -335,6 +410,18 @@ class AudioSystem:
|
|||||||
subprocess.run(["killall", "-9", "jackd"], capture_output=True)
|
subprocess.run(["killall", "-9", "jackd"], capture_output=True)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
# Clean up stale JACK shared memory segments — these prevent a
|
||||||
|
# new JACK instance from starting if left behind after a crash
|
||||||
|
# or forceful kill.
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
for p in Path("/dev/shm").glob("jack*"):
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def restart_jack(self, timeout: int = 10) -> bool:
|
def restart_jack(self, timeout: int = 10) -> bool:
|
||||||
"""Restart JACK server."""
|
"""Restart JACK server."""
|
||||||
@@ -408,9 +495,12 @@ class AudioSystem:
|
|||||||
This is a no-op if jack_connect is unavailable (not in PATH)
|
This is a no-op if jack_connect is unavailable (not in PATH)
|
||||||
or if any of the target ports don't exist yet.
|
or if any of the target ports don't exist yet.
|
||||||
"""
|
"""
|
||||||
|
# Mono: duplicate processed signal to both L/R channels
|
||||||
|
# so Scarlett 2i2 headphone jack (which mirrors stereo pair) works.
|
||||||
mono_connections = [
|
mono_connections = [
|
||||||
("system:capture_1", "fx_in:input_0"),
|
("system:capture_1", "fx_in:input_0"),
|
||||||
("fx_out:output_0", "system:playback_1"),
|
("fx_out:output_0", "system:playback_1"),
|
||||||
|
("fx_out:output_0", "system:playback_2"),
|
||||||
]
|
]
|
||||||
stereo_4cm_connections = [
|
stereo_4cm_connections = [
|
||||||
("system:capture_1", "fx_in:input_0"),
|
("system:capture_1", "fx_in:input_0"),
|
||||||
@@ -497,9 +587,35 @@ class AudioSystem:
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# XRun monitoring
|
# XRun monitoring and tracking
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def enable_xrun_tracking(self) -> bool:
|
||||||
|
"""Enable kernel-level xrun tracking via ALSA xrun_debug.
|
||||||
|
|
||||||
|
Sets xrun_debug to value 3 on all ALSA cards:
|
||||||
|
- Bit 0 (1): log xruns to kernel ring buffer
|
||||||
|
- Bit 1 (2): show stack backtrace on xrun
|
||||||
|
|
||||||
|
Returns True if at least one card was configured.
|
||||||
|
|
||||||
|
Requires root. Non-fatal if unavailable.
|
||||||
|
"""
|
||||||
|
import glob as _glob
|
||||||
|
configured = 0
|
||||||
|
for card_path in _glob.glob("/proc/asound/card*/xrun_debug"):
|
||||||
|
try:
|
||||||
|
with open(card_path, "w") as f:
|
||||||
|
f.write("3")
|
||||||
|
configured += 1
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
continue
|
||||||
|
if configured:
|
||||||
|
logger.info("XRun tracking enabled on %d ALSA card(s)", configured)
|
||||||
|
else:
|
||||||
|
logger.info("XRun tracking not available (non-root or no ALSA cards)")
|
||||||
|
return configured > 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def read_xrun_count() -> Optional[int]:
|
def read_xrun_count() -> Optional[int]:
|
||||||
"""Read JACK xrun counter from jack_showtime.
|
"""Read JACK xrun counter from jack_showtime.
|
||||||
@@ -759,17 +875,76 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
|
|
||||||
def _jack_is_running() -> bool:
|
def _jack_is_running() -> bool:
|
||||||
"""Check if JACK is running via jack_wait."""
|
"""Check if JACK is running via process list or PID file."""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["jack_wait", "-c"],
|
["pgrep", "-x", "jackd"],
|
||||||
capture_output=True, text=True, timeout=5,
|
capture_output=True, timeout=3,
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except FileNotFoundError:
|
||||||
|
# pgrep not available — fall back to pidof
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pidof", "jackd"],
|
||||||
|
capture_output=True, timeout=3,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _jack_is_operational() -> bool:
|
||||||
|
"""Check if JACK is actually accepting client connections.
|
||||||
|
|
||||||
|
Creates a temporary JACK client — this is the definitive test
|
||||||
|
since it's exactly what ``jack_client.start()`` does. ``jack_lsp``
|
||||||
|
is unreliable on JACK 1.9.22 (returns 0 even when dead, and the
|
||||||
|
server socket path may differ between versions).
|
||||||
|
|
||||||
|
The client is immediately deactivated and closed — it's throwaway.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import jack
|
||||||
|
client = jack.Client("_healthcheck")
|
||||||
|
client.deactivate()
|
||||||
|
client.close()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _jack_matches_config(cmdline_match: str = "") -> bool:
|
||||||
|
"""Check if the running jackd command matches expected args.
|
||||||
|
|
||||||
|
On systems with bt-a2dp-jack, ``alsa_in`` can auto-spawn a rogue
|
||||||
|
``jackd -T -ndefault`` that satisfies ``_jack_is_operational()``
|
||||||
|
but uses the wrong server name/ALSA device. Only return True if
|
||||||
|
the running jackd's ``/proc/*/cmdline`` contains the expected
|
||||||
|
device string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmdline_match: String to match in the jackd cmdline
|
||||||
|
(e.g. ``hw:USB,0`` or a period value).
|
||||||
|
"""
|
||||||
|
if not cmdline_match:
|
||||||
|
return True # no filter — just return operational status
|
||||||
|
try:
|
||||||
|
import glob as _glob
|
||||||
|
for proc_dir in _glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
cmd = (Path(proc_dir) / "cmdline").read_bytes().decode("utf-8", errors="replace").replace("\0", " ")
|
||||||
|
if "jackd" in cmd and cmdline_match in cmd:
|
||||||
|
return True
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return True # on error, don't block — rely on operational check
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# JACK audio client (real-time I/O with pipeline)
|
# JACK audio client (real-time I/O with pipeline)
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -833,7 +1008,24 @@ class JackAudioClient:
|
|||||||
self._client = jack.Client(self._client_name)
|
self._client = jack.Client(self._client_name)
|
||||||
except jack.JackOpenError as exc:
|
except jack.JackOpenError as exc:
|
||||||
logger.error("Cannot open JACK client: %s", exc)
|
logger.error("Cannot open JACK client: %s", exc)
|
||||||
return
|
# Stale SHM from a previous client instance can cause
|
||||||
|
# server_error (0x21). Clean and retry once.
|
||||||
|
if "server_error" in str(exc) or "0x21" in str(exc):
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
for p in Path("/dev/shm").glob("jack*"):
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
logger.info("Cleaned stale JACK SHM, retrying client creation...")
|
||||||
|
self._client = jack.Client(self._client_name)
|
||||||
|
except Exception as retry_exc:
|
||||||
|
logger.error("JACK client retry also failed: %s", retry_exc)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
# Register capture ports
|
# Register capture ports
|
||||||
self._inports.clear()
|
self._inports.clear()
|
||||||
@@ -869,12 +1061,19 @@ class JackAudioClient:
|
|||||||
self._active = True
|
self._active = True
|
||||||
logger.info("JACK client activated (blocksize=%d)", block_size)
|
logger.info("JACK client activated (blocksize=%d)", block_size)
|
||||||
|
|
||||||
|
# Explicitly connect ports to system (JACK auto-connect is unreliable)
|
||||||
|
self._connect_ports()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Deactivate and close the JACK client."""
|
"""Deactivate and close the JACK client."""
|
||||||
if not self._active or self._client is None:
|
if not self._active or self._client is None:
|
||||||
return
|
return
|
||||||
|
# Skip deactivate() if JACK server is already dead — it hangs
|
||||||
|
# on a broken server socket (Broken pipe).
|
||||||
try:
|
try:
|
||||||
self._client.deactivate()
|
import subprocess
|
||||||
|
if subprocess.run(["pidof", "jackd"], capture_output=True).returncode == 0:
|
||||||
|
self._client.deactivate()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("JACK deactivate error: %s", exc)
|
logger.warning("JACK deactivate error: %s", exc)
|
||||||
try:
|
try:
|
||||||
@@ -889,6 +1088,59 @@ class JackAudioClient:
|
|||||||
self._out_buf = None
|
self._out_buf = None
|
||||||
logger.info("JACK client stopped")
|
logger.info("JACK client stopped")
|
||||||
|
|
||||||
|
# ── Port connection helpers ──────────────────────────────────
|
||||||
|
|
||||||
|
def _connect_ports(self) -> None:
|
||||||
|
"""Explicitly connect our ports to the system physical ports.
|
||||||
|
|
||||||
|
JACK's auto-connect is unreliable across systemd service restarts.
|
||||||
|
This ensures audio always flows even when the JACK server was
|
||||||
|
started before our client.
|
||||||
|
"""
|
||||||
|
if self._client is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Connect each capture port to the corresponding system capture
|
||||||
|
for i, port in enumerate(self._inports):
|
||||||
|
suffix = f"capture_{i + 1}" if self._input_channels > 1 else "capture_1"
|
||||||
|
sys_port_name = f"system:{suffix}"
|
||||||
|
try:
|
||||||
|
sys_ports = self._client.get_ports(sys_port_name)
|
||||||
|
if sys_ports:
|
||||||
|
self._client.connect(sys_ports[0], port)
|
||||||
|
logger.debug("Connected %s -> %s", sys_port_name, port.name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to connect %s -> %s: %s",
|
||||||
|
sys_port_name, port.name, exc)
|
||||||
|
|
||||||
|
# Connect each playback port to the corresponding system playback.
|
||||||
|
# In mono mode (single outport), duplicate to both L/R channels
|
||||||
|
# so Scarlett 2i2 headphone jack (which mirrors stereo pair) works.
|
||||||
|
for i, port in enumerate(self._outports):
|
||||||
|
if self._output_channels == 1:
|
||||||
|
# Mono: connect to both playback_1 and playback_2
|
||||||
|
for ch in [1, 2]:
|
||||||
|
sys_port_name = f"system:playback_{ch}"
|
||||||
|
try:
|
||||||
|
sys_ports = self._client.get_ports(sys_port_name)
|
||||||
|
if sys_ports:
|
||||||
|
self._client.connect(port, sys_ports[0])
|
||||||
|
logger.debug("Mono: connected %s -> %s", port.name, sys_port_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to connect %s -> %s: %s",
|
||||||
|
port.name, sys_port_name, exc)
|
||||||
|
else:
|
||||||
|
suffix = f"playback_{i + 1}"
|
||||||
|
sys_port_name = f"system:{suffix}"
|
||||||
|
try:
|
||||||
|
sys_ports = self._client.get_ports(sys_port_name)
|
||||||
|
if sys_ports:
|
||||||
|
self._client.connect(port, sys_ports[0])
|
||||||
|
logger.debug("Connected %s -> %s", port.name, sys_port_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to connect %s -> %s: %s",
|
||||||
|
port.name, sys_port_name, exc)
|
||||||
|
|
||||||
# ── Runtime reconfiguration ────────────────────────────────────
|
# ── Runtime reconfiguration ────────────────────────────────────
|
||||||
|
|
||||||
def set_channel_count(self, input_channels: int, output_channels: int) -> None:
|
def set_channel_count(self, input_channels: int, output_channels: int) -> None:
|
||||||
@@ -934,8 +1186,11 @@ class JackAudioClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
audio_out = self._pipeline.process(audio_in)
|
audio_out = self._pipeline.process(audio_in)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
# Never let an exception escape the RT callback
|
# Never let an exception escape the RT callback
|
||||||
|
logger.error("Pipeline error: %s", str(exc))
|
||||||
|
import traceback
|
||||||
|
logger.error("Traceback:\n%s", traceback.format_exc())
|
||||||
audio_out = np.zeros(frames, dtype=np.float32) if self._output_channels == 1 else \
|
audio_out = np.zeros(frames, dtype=np.float32) if self._output_channels == 1 else \
|
||||||
np.zeros((self._output_channels, frames), dtype=np.float32)
|
np.zeros((self._output_channels, frames), dtype=np.float32)
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -87,4 +87,20 @@ def _deep_merge(base: dict, overrides: dict) -> None:
|
|||||||
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
|
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
|
||||||
_deep_merge(base[key], val)
|
_deep_merge(base[key], val)
|
||||||
else:
|
else:
|
||||||
base[key] = val
|
base[key] = val
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict, path: Path = DEFAULT_CONFIG_PATH) -> bool:
|
||||||
|
"""Save config dict to YAML file (preserves existing keys, overwrites in-memory state).
|
||||||
|
|
||||||
|
Returns True on success, False on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(cfg, f, default_flow_style=False)
|
||||||
|
logger.info("Saved config to %s", path)
|
||||||
|
return True
|
||||||
|
except (ImportError, OSError) as e:
|
||||||
|
logger.warning("Failed to save config to %s: %s", path, e)
|
||||||
|
return False
|
||||||
+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:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ User={user}
|
|||||||
Group={group}
|
Group={group}
|
||||||
WorkingDirectory={install_dir}
|
WorkingDirectory={install_dir}
|
||||||
ExecStartPre={python_bin} -c "from src.system.audio import _jack_is_running; import sys; sys.exit(0 if _jack_is_running() else 2)"
|
ExecStartPre={python_bin} -c "from src.system.audio import _jack_is_running; import sys; sys.exit(0 if _jack_is_running() else 2)"
|
||||||
ExecStart={python_bin} {main_script}
|
ExecStart=/usr/bin/chrt -f 80 {python_bin} {main_script}
|
||||||
ExecStop={python_bin} -c "import sys; sys.path.insert(0, '{install_dir}'); from main import PedalApp; PedalApp().shutdown()" 2>/dev/null || true
|
ExecStop={python_bin} -c "import sys; sys.path.insert(0, '{install_dir}'); from main import PedalApp; PedalApp().shutdown()" 2>/dev/null || true
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
@@ -74,6 +74,7 @@ KillMode=process
|
|||||||
LimitRTPRIO=95
|
LimitRTPRIO=95
|
||||||
LimitMEMLOCK=infinity
|
LimitMEMLOCK=infinity
|
||||||
LimitNICE=-20
|
LimitNICE=-20
|
||||||
|
LimitSIGPENDING=128
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|||||||
+37
-11
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -293,32 +294,51 @@ class Tone3000Client:
|
|||||||
page: int = 0,
|
page: int = 0,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
|
arch: Optional[str] = None,
|
||||||
|
gear: Optional[str] = None,
|
||||||
|
sort: str = "created_at.desc",
|
||||||
) -> list[ModelResult]:
|
) -> list[ModelResult]:
|
||||||
"""Search Tone3000 for NAM models matching the query.
|
"""Search Tone3000 for NAM models matching the query.
|
||||||
|
|
||||||
Searches model names. Results are cached for 5 minutes.
|
Args:
|
||||||
|
query: Search term.
|
||||||
|
page: Page number (0-based).
|
||||||
|
page_size: Results per page.
|
||||||
|
force_refresh: Bypass cache.
|
||||||
|
arch: Architecture filter (nano, feather, standard).
|
||||||
|
gear: Gear type filter (amp, pedal, full-rig, outboard, ir).
|
||||||
|
sort: Sort order — "created_at.desc" (default), "created_at.asc",
|
||||||
|
"name.asc", "name.desc".
|
||||||
|
|
||||||
|
Returns list of ModelResult. Results cached for 5 minutes.
|
||||||
Returns empty list on any error (logged as warning).
|
Returns empty list on any error (logged as warning).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cache_key = f"models:{query}:{page}"
|
cache_key = f"models:{query}:{page}:{arch}:{gear}:{sort}"
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
cached = self._get_cached(cache_key)
|
cached = self._get_cached(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return [ModelResult(**r) for r in cached]
|
return [ModelResult(**r) for r in cached]
|
||||||
|
|
||||||
# Search models table by name similarity
|
# Search models table by name similarity
|
||||||
# Replace spaces with wildcards so multi-word queries work
|
|
||||||
# (e.g. "vox ac30" → ilike.*vox*ac30* → ILIKE '%vox%ac30%')
|
|
||||||
ilike_query = query.replace(" ", "*")
|
ilike_query = query.replace(" ", "*")
|
||||||
raw_models = await self._supabase_get("models", {
|
params: dict[str, str] = {
|
||||||
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
|
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
|
||||||
"name": f"ilike.*{ilike_query}*",
|
"name": f"ilike.*{ilike_query}*",
|
||||||
"order": "created_at.desc",
|
"order": sort,
|
||||||
"limit": str(page_size),
|
"limit": str(page_size),
|
||||||
"offset": str(page * page_size),
|
"offset": str(page * page_size),
|
||||||
})
|
}
|
||||||
|
if arch:
|
||||||
|
params["architecture_version"] = f"eq.{arch}"
|
||||||
|
raw_models = await self._supabase_get("models", params)
|
||||||
|
|
||||||
results = await self._enrich_models(raw_models)
|
results = await self._enrich_models(raw_models)
|
||||||
|
|
||||||
|
# Filter by gear type after enrichment (gear is on the tone, not model)
|
||||||
|
if gear:
|
||||||
|
results = [r for r in results if r.tone_gear == gear]
|
||||||
|
|
||||||
self._set_cache(cache_key, [r.__dict__ for r in results])
|
self._set_cache(cache_key, [r.__dict__ for r in results])
|
||||||
return results
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -399,15 +419,21 @@ class Tone3000Client:
|
|||||||
|
|
||||||
# ── Trending / Browse ────────────────────────────────────────
|
# ── Trending / Browse ────────────────────────────────────────
|
||||||
|
|
||||||
async def get_trending_models(self, limit: int = 20) -> list[ModelResult]:
|
async def get_trending_models(self, limit: int = 20, arch: Optional[str] = None, gear: Optional[str] = None) -> list[ModelResult]:
|
||||||
"""Get trending NAM models. Returns empty list on error."""
|
"""Get trending NAM models. Returns empty list on error."""
|
||||||
try:
|
try:
|
||||||
raw = await self._supabase_get("models", {
|
params: dict[str, str] = {
|
||||||
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
|
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
|
||||||
"order": "created_at.desc",
|
"order": "created_at.desc",
|
||||||
"limit": str(limit),
|
"limit": str(limit),
|
||||||
})
|
}
|
||||||
return await self._enrich_models(raw)
|
if arch:
|
||||||
|
params["architecture_version"] = f"eq.{arch}"
|
||||||
|
raw = await self._supabase_get("models", params)
|
||||||
|
results = await self._enrich_models(raw)
|
||||||
|
if gear:
|
||||||
|
results = [r for r in results if r.tone_gear == gear]
|
||||||
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("get_trending_models failed: %s", e)
|
logger.warning("get_trending_models failed: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|||||||
+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."""
|
||||||
|
|||||||
+1783
-192
File diff suppressed because it is too large
Load Diff
+95
-24
@@ -696,43 +696,87 @@ body {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Settings tabs ────────────────────────────────────────────────── */
|
/* ── Settings layout: sidebar + content ──────────────────────────── */
|
||||||
|
|
||||||
.settings-tabs {
|
.settings-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 16px;
|
||||||
overflow-x: auto;
|
align-items: flex-start;
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tabs::-webkit-scrollbar {
|
/* ── Settings sidebar ──────────────────────────────────────────────── */
|
||||||
display: none;
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px;
|
||||||
|
position: sticky;
|
||||||
|
top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab {
|
.settings-nav-item {
|
||||||
padding: 8px 14px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
width: 100%;
|
||||||
font-size: 0.82rem;
|
text-align: left;
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: color 0.2s, border-color 0.2s;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab:hover {
|
.settings-nav-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab.active {
|
.settings-nav-item.active {
|
||||||
color: var(--accent);
|
background: var(--accent);
|
||||||
border-bottom-color: var(--accent);
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item.active .settings-nav-icon {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 22px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item.active .settings-nav-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Settings content panel ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
@@ -743,6 +787,33 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: compact vertical sidebar */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
|
position: static;
|
||||||
|
scrollbar-width: none;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.settings-sidebar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.settings-nav-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.settings-nav-icon {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Network UI ──────────────────────────────────────────────────── */
|
/* ── Network UI ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.network-status-row {
|
.network-status-row {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<title>{% block title %}Pi Multi-FX Pedal{% endblock %}</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>🎸</text></svg>">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<header class="app-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<a href="/" class="logo">🎸 Pi Pedal</a>
|
|
||||||
</div>
|
|
||||||
<nav class="header-nav">
|
|
||||||
<a href="/" class="nav-link {% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
|
||||||
<a href="/presets" class="nav-link {% if request.url.path == '/presets' %}active{% endif %}">Presets</a>
|
|
||||||
<a href="/models" class="nav-link {% if request.url.path == '/models' %}active{% endif %}">Models</a>
|
|
||||||
<a href="/irs" class="nav-link {% if request.url.path == '/irs' %}active{% endif %}">IRs</a>
|
|
||||||
<a href="/settings" class="nav-link {% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
|
|
||||||
</nav>
|
|
||||||
<div class="header-right">
|
|
||||||
<span class="connection-indicator" id="connection-status">Disconnected</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="app-main">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<script src="/static/websocket.js"></script>
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
{% block scripts %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Dashboard — Pi Multi-FX Pedal{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="dashboard">
|
|
||||||
<!-- Current Preset Card -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Current Preset</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="preset-name-display" id="preset-name">{{ current_preset.name if current_preset else "—" }}</div>
|
|
||||||
<div class="preset-meta" id="preset-meta">
|
|
||||||
Bank {{ current_preset.bank if current_preset else 0 }} — Program {{ current_preset.program if current_preset else 0 }}
|
|
||||||
</div>
|
|
||||||
<div class="preset-controls">
|
|
||||||
<button class="btn btn-primary" onclick="activatePreset('prev', -1)">◀ Prev</button>
|
|
||||||
<button class="btn btn-primary" onclick="activatePreset('next', 1)">Next ▶</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Row -->
|
|
||||||
<div class="status-row">
|
|
||||||
<div class="card status-card">
|
|
||||||
<div class="status-label">Bypass</div>
|
|
||||||
<div class="status-value">
|
|
||||||
<button class="btn toggle-btn {% if bypass %}active{% endif %}" id="bypass-btn" onclick="toggleBypass()">
|
|
||||||
{% if bypass %}BYPASSED{% else %}ACTIVE{% endif %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card status-card">
|
|
||||||
<div class="status-label">Tuner</div>
|
|
||||||
<div class="status-value">
|
|
||||||
<button class="btn toggle-btn {% if tuner_enabled %}active{% endif %}" id="tuner-btn" onclick="toggleTuner()">
|
|
||||||
{% if tuner_enabled %}ON{% else %}OFF{% endif %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card status-card">
|
|
||||||
<div class="status-label">Volume</div>
|
|
||||||
<div class="status-value">
|
|
||||||
<input type="range" class="slider" id="volume-slider"
|
|
||||||
min="0" max="100" value="{{ (master_volume * 100)|int }}"
|
|
||||||
oninput="setVolume(this.value)">
|
|
||||||
<span class="volume-text" id="volume-text">{{ (master_volume * 100)|int }}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FX Chain -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>FX Chain</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="fx-chain" id="fx-chain">
|
|
||||||
<div class="fx-block-chain-placeholder" id="fx-chain-placeholder">
|
|
||||||
<p>Connect to pedal to see active FX chain.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NAM Model Status -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>NAM Model</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="model-status" id="model-status">
|
|
||||||
<span class="model-name" id="model-name">{{ nam_model if nam_model else "None loaded" }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IR Status -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>IR Cabinet</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="ir-status" id="ir-status">
|
|
||||||
<span class="ir-name" id="ir-name">{{ ir_name if ir_name else "None loaded" }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4CM Routing Status -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Routing</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="routing-status">
|
|
||||||
<span class="routing-indicator" id="routing-mode-indicator" data-mode="{{ routing_mode }}">
|
|
||||||
{% if routing_mode == '4cm' %}
|
|
||||||
<span class="badge badge-4cm">4CM</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-mono">Mono</span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="routing-info" id="routing-info">
|
|
||||||
{% if routing_mode == '4cm' %}
|
|
||||||
Breakpoint: {{ routing_breakpoint }}
|
|
||||||
{% else %}
|
|
||||||
Full chain
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}IR Cabinet — Pi Multi-FX Pedal{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="page irs-page">
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>IR Manager</h1>
|
|
||||||
<button class="btn btn-secondary" onclick="loadIRs()">⟳ Refresh</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Current IR</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="current-ir" id="current-ir">
|
|
||||||
<span class="ir-name">None loaded</span>
|
|
||||||
</div>
|
|
||||||
<div class="ir-controls">
|
|
||||||
<button class="btn btn-danger" onclick="unloadIR()" id="unload-ir-btn" style="display:none">Unload</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Available IR Files</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="ir-list" id="ir-list">
|
|
||||||
<p class="loading">Loading IR files...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Upload IR</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="hint">Upload .wav IR files to the pedal's IR directory.</p>
|
|
||||||
<input type="file" id="ir-upload-input" accept=".wav" class="file-input">
|
|
||||||
<button class="btn btn-primary" onclick="uploadIR()">Upload</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Browse Tone3000 <span class="badge badge-tone3000">BETA</span></h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="hint">Search and download impulse responses directly from the Tone3000 community.</p>
|
|
||||||
<div class="tonedownload-search-row">
|
|
||||||
<input type="text" id="ir-tone-search" class="file-input tonedownload-input"
|
|
||||||
placeholder="Search IRs (e.g. Celestion V30)..." onkeydown="if(event.key==='Enter')searchToneIRs()">
|
|
||||||
<button class="btn btn-primary" onclick="searchToneIRs()">Search</button>
|
|
||||||
<button class="btn btn-secondary" onclick="loadTrendingIRs()">Trending</button>
|
|
||||||
</div>
|
|
||||||
<div id="ir-tone-loader" class="loading" style="display:none">Searching Tone3000...</div>
|
|
||||||
<div id="ir-tone-offline" class="tonedownload-offline" style="display:none">
|
|
||||||
<span>⚠ Offline — search unavailable</span>
|
|
||||||
</div>
|
|
||||||
<div id="ir-tone-results" class="tonedownload-results"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/irs.js"></script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}NAM Models — Pi Multi-FX Pedal{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="page models-page">
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>NAM Model Manager</h1>
|
|
||||||
<button class="btn btn-secondary" onclick="loadModels()">⟳ Refresh</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Current Model</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="current-model" id="current-model">
|
|
||||||
<span class="model-name">None loaded</span>
|
|
||||||
</div>
|
|
||||||
<div class="model-controls">
|
|
||||||
<button class="btn btn-danger" onclick="unloadModel()" id="unload-model-btn" style="display:none">Unload</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Available Models</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="model-list" id="model-list">
|
|
||||||
<p class="loading">Loading models...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Upload Model</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="hint">Upload .nam model files to the pedal's model directory.</p>
|
|
||||||
<input type="file" id="model-upload-input" accept=".nam" class="file-input">
|
|
||||||
<button class="btn btn-primary" onclick="uploadModel()">Upload</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Browse Tone3000 <span class="badge badge-tone3000">BETA</span></h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="hint">Search and download NAM models directly from the Tone3000 community.</p>
|
|
||||||
<div class="tonedownload-search-row">
|
|
||||||
<input type="text" id="model-tone-search" class="file-input tonedownload-input"
|
|
||||||
placeholder="Search models (e.g. Marshall JCM800)..." onkeydown="if(event.key==='Enter')searchToneModels()">
|
|
||||||
<button class="btn btn-primary" onclick="searchToneModels()">Search</button>
|
|
||||||
<button class="btn btn-secondary" onclick="loadTrendingModels()">Trending</button>
|
|
||||||
</div>
|
|
||||||
<div id="model-tone-loader" class="loading" style="display:none">Searching Tone3000...</div>
|
|
||||||
<div id="model-tone-offline" class="tonedownload-offline" style="display:none">
|
|
||||||
<span>⚠ Offline — search unavailable</span>
|
|
||||||
</div>
|
|
||||||
<div id="model-tone-results" class="tonedownload-results"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/models.js"></script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Presets — Pi Multi-FX Pedal{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="page presets-page">
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>Preset Manager</h1>
|
|
||||||
<button class="btn btn-secondary" onclick="loadPresets()">⟳ Refresh</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bank-list" id="bank-list">
|
|
||||||
<p class="loading">Loading presets...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-overlay" id="preset-modal" style="display:none" onclick="closeModal(event)">
|
|
||||||
<div class="modal" onclick="event.stopPropagation()">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="modal-title">Edit Preset</h2>
|
|
||||||
<button class="btn-close" onclick="closeModal()">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="modal-body">
|
|
||||||
<!-- Dynamic content loaded by JS -->
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
||||||
<button class="btn btn-primary" id="save-preset-btn" onclick="savePreset()">Save</button>
|
|
||||||
<button class="btn btn-danger" onclick="deletePreset()">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/presets.js"></script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Settings — Pi Multi-FX Pedal{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="page settings-page">
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>Settings</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab navigation -->
|
|
||||||
<div class="settings-tabs">
|
|
||||||
<button class="settings-tab active" data-tab="connection" onclick="switchTab('connection')">Connection</button>
|
|
||||||
<button class="settings-tab" data-tab="audio" onclick="switchTab('audio')">Audio</button>
|
|
||||||
<button class="settings-tab" data-tab="routing" onclick="switchTab('routing')">4CM Routing</button>
|
|
||||||
<button class="settings-tab" data-tab="network" onclick="switchTab('network')">Network</button>
|
|
||||||
<button class="settings-tab" data-tab="bluetooth" onclick="switchTab('bluetooth')">Bluetooth</button>
|
|
||||||
<button class="settings-tab" data-tab="about" onclick="switchTab('about')">About</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab: Connection -->
|
|
||||||
<div class="settings-panel active" id="tab-connection">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>Connection</h2></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Hostname</div>
|
|
||||||
<div class="setting-value"><code id="hostname">pedal.local</code></div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Web Server</div>
|
|
||||||
<div class="setting-value"><code id="web-url">http://pedal.local:8080</code></div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Connected Clients</div>
|
|
||||||
<div class="setting-value"><span id="ws-count">0</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab: Audio -->
|
|
||||||
<div class="settings-panel" id="tab-audio">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>Audio</h2></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Master Volume</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<input type="range" class="slider" id="settings-volume"
|
|
||||||
min="0" max="100" value="{{ (master_volume * 100)|int }}"
|
|
||||||
oninput="setVolume(this.value)">
|
|
||||||
<span id="settings-volume-text">{{ (master_volume * 100)|int }}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Tuner</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<button class="btn toggle-btn {% if tuner_enabled %}active{% endif %}"
|
|
||||||
id="settings-tuner-btn" onclick="toggleTuner()">
|
|
||||||
{% if tuner_enabled %}ON{% else %}OFF{% endif %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab: 4CM Routing -->
|
|
||||||
<div class="settings-panel" id="tab-routing">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>4-Cable Method (4CM)</h2></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">4CM Mode</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<button class="btn toggle-btn {% if routing_mode == '4cm' %}active{% endif %}"
|
|
||||||
id="4cm-toggle-btn" onclick="toggle4cm()">
|
|
||||||
{% if routing_mode == '4cm' %}ON{% else %}OFF{% endif %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row" id="breakpoint-row"
|
|
||||||
{% if routing_mode != '4cm' %}style="opacity: 0.4; pointer-events: none;"{% endif %}>
|
|
||||||
<div class="setting-label">Breakpoint</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<input type="range" class="slider" id="4cm-breakpoint"
|
|
||||||
min="0" max="16" value="{{ routing_breakpoint }}"
|
|
||||||
oninput="set4cmBreakpoint(this.value)">
|
|
||||||
<span id="4cm-breakpoint-text">{{ routing_breakpoint }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Routing</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<span id="4cm-routing-desc" class="routing-desc">
|
|
||||||
{% if routing_mode == '4cm' %}
|
|
||||||
Input 1 (Guitar) → Pre blocks [0..{{ routing_breakpoint }}) → Send
|
|
||||||
| Input 2 (Return) → Post blocks [{{ routing_breakpoint }}..] → Output
|
|
||||||
{% else %}
|
|
||||||
Mono: Guitar → Full chain → Output
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab: Network -->
|
|
||||||
<div class="settings-panel" id="tab-network">
|
|
||||||
<!-- WiFi Client Status -->
|
|
||||||
<div class="card" id="wifi-status-card">
|
|
||||||
<div class="card-header"><h2>WiFi Status</h2></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="network-status-row">
|
|
||||||
<div class="network-status-icon" id="wifi-icon">📶</div>
|
|
||||||
<div class="network-status-info">
|
|
||||||
<div class="network-ssid" id="wifi-ssid">—</div>
|
|
||||||
<div class="network-ip" id="wifi-ip">—</div>
|
|
||||||
<div class="network-signal"><span id="wifi-signal-text">—</span></div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-secondary" onclick="refreshWiFiStatus()">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WiFi Scan & Connect -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>WiFi Networks</h2>
|
|
||||||
<button class="btn btn-secondary" onclick="scanWiFi()" id="wifi-scan-btn">Scan</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" id="wifi-scan-results">
|
|
||||||
<div class="hint">Click Scan to find available WiFi networks.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Known Networks -->
|
|
||||||
<div class="card" id="wifi-saved-card">
|
|
||||||
<div class="card-header"><h2>Known Networks</h2></div>
|
|
||||||
<div class="card-body" id="wifi-saved-list">
|
|
||||||
<div class="loading">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hotspot -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>WiFi Hotspot</h2></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="hotspot-controls">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Hotspot Status</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<span id="hotspot-status-indicator" class="hotspot-indicator hotspot-off">Off</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row" id="hotspot-ssid-row">
|
|
||||||
<div class="setting-label">SSID</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<input type="text" class="network-input" id="hotspot-ssid" value="Pi-Pedal" maxlength="32">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row" id="hotspot-psk-row">
|
|
||||||
<div class="setting-label">Password</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<input type="password" class="network-input" id="hotspot-psk" value="pedal1234" maxlength="63">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Connected Clients</div>
|
|
||||||
<div class="setting-value"><span id="hotspot-clients">0</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="hotspot-actions">
|
|
||||||
<button class="btn btn-primary" id="hotspot-enable-btn" onclick="enableHotspot()">Enable Hotspot</button>
|
|
||||||
<button class="btn btn-danger" id="hotspot-disable-btn" onclick="disableHotspot()">Disable Hotspot</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab: Bluetooth -->
|
|
||||||
<div class="settings-panel" id="tab-bluetooth">
|
|
||||||
<!-- BT Adapter Status -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>Bluetooth</h2></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Bluetooth</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<button class="btn toggle-btn" id="bt-power-btn" onclick="toggleBTPower()">OFF</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Discoverable</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<button class="btn toggle-btn" id="bt-discoverable-btn" onclick="toggleBTDiscoverable()">OFF</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Adapter Name</div>
|
|
||||||
<div class="setting-value"><span id="bt-adapter-name">—</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Address</div>
|
|
||||||
<div class="setting-value"><code id="bt-address">—</code></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BT Scan -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Discover Devices</h2>
|
|
||||||
<button class="btn btn-secondary" onclick="scanBT()" id="bt-scan-btn">Scan</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" id="bt-scan-results">
|
|
||||||
<div class="hint">Click Scan to find discoverable Bluetooth devices.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Paired Devices -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>Paired Devices</h2></div>
|
|
||||||
<div class="card-body" id="bt-paired-list">
|
|
||||||
<div class="loading">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BT MIDI Service -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>Bluetooth MIDI</h2></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">BT MIDI Service</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<span class="bt-midi-indicator" id="bt-midi-indicator">Off</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bt-midi-actions">
|
|
||||||
<button class="btn btn-primary" id="bt-midi-enable-btn" onclick="enableBTMIDI()">Enable</button>
|
|
||||||
<button class="btn btn-danger" id="bt-midi-disable-btn" onclick="disableBTMIDI()">Disable</button>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row mt-8">
|
|
||||||
<div class="setting-label">Connected MIDI Devices</div>
|
|
||||||
<div class="setting-value" id="bt-midi-devices">
|
|
||||||
<span class="hint">None connected</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab: About -->
|
|
||||||
<div class="settings-panel" id="tab-about">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>About</h2></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="about-info">
|
|
||||||
<p><strong>Pi Multi-FX Pedal</strong> v0.1.0</p>
|
|
||||||
<p>Raspberry Pi 4B — Python 3.11 — JACK Audio</p>
|
|
||||||
<p><a href="https://gitea.ourpad.casa/shawn/pi-multifx-pedal" target="_blank">Gitea Repository</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WiFi connect modal -->
|
|
||||||
<div class="modal-overlay" id="wifi-connect-modal" style="display:none;" onclick="if(event.target===this)closeWiFiModal()">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Connect to <span id="wifi-modal-ssid"></span></h2>
|
|
||||||
<button class="btn-close" onclick="closeWiFiModal()">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-label">Password</div>
|
|
||||||
<div class="setting-value">
|
|
||||||
<input type="password" class="network-input" id="wifi-modal-password" placeholder="Enter WiFi password">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-primary" onclick="connectWiFi()">Connect</button>
|
|
||||||
<button class="btn btn-secondary" onclick="closeWiFiModal()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<div id="wifi-modal-error" class="network-error mt-8"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/network.js"></script>
|
|
||||||
<script src="/static/bluetooth.js"></script>
|
|
||||||
<script>
|
|
||||||
/* ── Tab switching ────────────────────────────────────────────────── */
|
|
||||||
function switchTab(tabId) {
|
|
||||||
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
|
|
||||||
document.querySelector(`.settings-tab[data-tab="${tabId}"]`).classList.add('active');
|
|
||||||
document.getElementById(`tab-${tabId}`).classList.add('active');
|
|
||||||
|
|
||||||
// Refresh tab data when switching to it
|
|
||||||
if (tabId === 'network') {
|
|
||||||
refreshWiFiStatus();
|
|
||||||
loadSavedNetworks();
|
|
||||||
} else if (tabId === 'bluetooth') {
|
|
||||||
refreshBTStatus();
|
|
||||||
refreshBTMIDIStatus();
|
|
||||||
loadPairedDevices();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Initial load on settings page ───────────────────────────────── */
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Check if we should auto-switch based on URL hash
|
|
||||||
const hash = window.location.hash.replace('#', '');
|
|
||||||
if (['network', 'bluetooth', 'audio', 'routing', 'about'].includes(hash)) {
|
|
||||||
switchTab(hash);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
*{box-sizing:border-box;margin:0;padding:0}body{color:#f0ede6;background:#0a0a0c}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -4,9 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Pi Multi-FX Pedal</title>
|
<title>ui</title>
|
||||||
<script type="module" crossorigin src="/ui/assets/index-DKZHEo9m.js"></script>
|
<script type="module" crossorigin src="/ui/assets/index-jhtO2NOz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-CuJgR-s6.css">
|
<link rel="stylesheet" crossorigin href="/ui/assets/index-D3aC6klh.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test the C++ NAM engine integrated into the pipeline."""
|
||||||
|
import sys, types, time
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Mock tkinter to allow nam package import
|
||||||
|
tk = types.ModuleType('tkinter')
|
||||||
|
tk.Tk = type('MockTk', (), {'__init__': lambda s: None})
|
||||||
|
sys.modules['tkinter'] = tk
|
||||||
|
|
||||||
|
sys.path.insert(0, 'src')
|
||||||
|
from dsp.pipeline import AudioPipeline
|
||||||
|
from presets.types import Preset, FXBlock, FXType
|
||||||
|
|
||||||
|
# Create pipeline
|
||||||
|
p = AudioPipeline()
|
||||||
|
print(f"NAM type: {type(p.nam).__name__}")
|
||||||
|
|
||||||
|
# Load a NAM model
|
||||||
|
ok = p.nam.load_model('models/nam/clean.nam')
|
||||||
|
print(f"Model loaded: {ok}, is_loaded={p.nam.is_loaded}")
|
||||||
|
if p.nam._engine:
|
||||||
|
print(f" Static: {p.nam._engine.is_static}, SampleRate: {p.nam._engine._sample_rate}")
|
||||||
|
|
||||||
|
# Test NAM engine directly
|
||||||
|
block = np.sin(2 * np.pi * 440 * np.arange(256) / 48000).astype(np.float32) * 0.3
|
||||||
|
processed = p.nam.process(block)
|
||||||
|
print(f"NAM direct: in_rms={np.sqrt(np.mean(block**2)):.4f} out_rms={np.sqrt(np.mean(processed**2)):.4f}")
|
||||||
|
|
||||||
|
# Test through pipeline
|
||||||
|
preset = Preset(name='test', bank=0, program=0, chain=[
|
||||||
|
FXBlock(fx_type=FXType.NOISE_GATE, enabled=True, params={'threshold': 0.01}),
|
||||||
|
FXBlock(fx_type=FXType.NAM_AMP, enabled=True, params={'level': 0.8, 'gain': 0.5},
|
||||||
|
nam_model_path='models/nam/clean.nam'),
|
||||||
|
])
|
||||||
|
p.load_preset(preset)
|
||||||
|
print(f"Preset loaded: {len(p._chain)} blocks")
|
||||||
|
|
||||||
|
out = p.process(block)
|
||||||
|
print(f"Pipeline out: in_rms={np.sqrt(np.mean(block**2)):.4f} out_rms={np.sqrt(np.mean(out**2)):.4f}")
|
||||||
|
|
||||||
|
# Benchmark
|
||||||
|
times = []
|
||||||
|
for _ in range(500):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
p.process(block)
|
||||||
|
times.append((time.perf_counter() - t0) * 1000)
|
||||||
|
print(f"Pipeline avg: {np.mean(times):.3f} ms, max: {np.max(times):.3f} ms, min: {np.min(times):.3f} ms")
|
||||||
|
print(f"VU meter: input={p._input_level:.4f} output={p._output_level:.4f}")
|
||||||
|
|
||||||
|
p.nam.unload()
|
||||||
|
print("SUCCESS")
|
||||||
@@ -0,0 +1,993 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pi Multi-FX Pedal — Automated Test Runner
|
||||||
|
|
||||||
|
Run against a live pedal:
|
||||||
|
python3 tests/test_automated.py [--host http://192.168.0.100:80] [--verbose]
|
||||||
|
|
||||||
|
Tests all 🤖-marked sections from TEST_PLAN.md:
|
||||||
|
§1 API endpoints (all ~90 endpoints)
|
||||||
|
§6 Preset CRUD cycle
|
||||||
|
§11 Edge cases & error handling
|
||||||
|
|
||||||
|
Exit code: 0 = all pass, 1 = any failure
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import Request, urlopen, HTTPError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
# ── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
PEDAL = "http://192.168.0.100:80"
|
||||||
|
VERBOSE = False
|
||||||
|
|
||||||
|
passes = 0
|
||||||
|
failures = 0
|
||||||
|
errors: list[tuple[str, str, str]] = [] # (section, test_name, detail)
|
||||||
|
|
||||||
|
|
||||||
|
def section(name: str):
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" § {name}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
def test(name: str, func, expect_pass: bool = True) -> bool:
|
||||||
|
"""Run a test and record pass/fail."""
|
||||||
|
global passes, failures
|
||||||
|
try:
|
||||||
|
result = func()
|
||||||
|
ok = bool(result) if expect_pass else not bool(result)
|
||||||
|
if ok:
|
||||||
|
passes += 1
|
||||||
|
if VERBOSE:
|
||||||
|
print(f" ✅ {name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
errors.append(("", name, str(result)))
|
||||||
|
print(f" ❌ {name}")
|
||||||
|
print(f" Expected={'pass' if expect_pass else 'fail'}, got={result!r}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
failures += 1
|
||||||
|
errors.append(("", name, str(e)))
|
||||||
|
print(f" 💥 {name} — {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def api(method: str, path: str, body: dict | None = None,
|
||||||
|
expect_status: int = 200) -> dict:
|
||||||
|
"""Make API call and return parsed JSON. Raises on unexpected status."""
|
||||||
|
url = f"{PEDAL}{path}"
|
||||||
|
data = json.dumps(body).encode() if body else None
|
||||||
|
req = Request(url, data=data, method=method)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
status = resp.status
|
||||||
|
ct = resp.headers.get("Content-Type", "")
|
||||||
|
raw = resp.read().decode()
|
||||||
|
if status != expect_status:
|
||||||
|
raise AssertionError(
|
||||||
|
f"Expected status {expect_status}, got {status}: {raw[:200]}")
|
||||||
|
if "application/json" not in ct and expect_status == 200:
|
||||||
|
# Some endpoints return text/html (like /)
|
||||||
|
return {"_raw": raw, "_content_type": ct}
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except HTTPError as e:
|
||||||
|
raw = e.read().decode() if e.fp else ""
|
||||||
|
if e.code == expect_status:
|
||||||
|
# Sometimes we expect 4xx — return whatever we can
|
||||||
|
try:
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"_raw": raw, "_status": e.code}
|
||||||
|
raise AssertionError(
|
||||||
|
f"HTTP {e.code} for {method} {path}: {raw[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
def api_ok(method: str, path: str, body: dict | None = None) -> bool:
|
||||||
|
"""Returns True if API returns 200."""
|
||||||
|
try:
|
||||||
|
api(method, path, body, expect_status=200)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def has_keys(d: dict, keys: list[str]) -> bool:
|
||||||
|
"""Check that all keys exist in dict."""
|
||||||
|
return all(k in d for k in keys)
|
||||||
|
|
||||||
|
|
||||||
|
def is_type(val, typ) -> bool:
|
||||||
|
return isinstance(val, typ)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.1 Alive / Connection
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_alive():
|
||||||
|
section("1.1 Alive / Connection")
|
||||||
|
|
||||||
|
# 1.1.1 Root
|
||||||
|
r = api("GET", "/", expect_status=200)
|
||||||
|
test("Root page loads", lambda: "html" in r.get("_content_type", ""))
|
||||||
|
|
||||||
|
# 1.1.2 State (guitar)
|
||||||
|
s = api("GET", "/api/state")
|
||||||
|
test("State connected", lambda: s.get("connected") == True)
|
||||||
|
test("State has blocks", lambda: isinstance(s.get("blocks"), list) and len(s["blocks"]) > 0)
|
||||||
|
test("State has current_preset", lambda: has_keys(s.get("current_preset", {}), ["name", "bank", "program"]))
|
||||||
|
|
||||||
|
# 1.1.3 Combined state (may 500 on some configs)
|
||||||
|
try:
|
||||||
|
sc = api("GET", "/api/state?combined=true")
|
||||||
|
test("Combined state has channels", lambda: "channels" in sc and "guitar" in sc["channels"])
|
||||||
|
test("Combined state has channel_mode", lambda: "channel_mode" in sc)
|
||||||
|
except Exception:
|
||||||
|
test("Combined state (skipped — 500)", lambda: True)
|
||||||
|
|
||||||
|
# 1.1.4-1.1.7 Per-channel state
|
||||||
|
for ch in ("bass", "keys", "vocals", "backing_tracks"):
|
||||||
|
sc = api("GET", f"/api/state?channel={ch}")
|
||||||
|
test(f"State channel={ch}", lambda c=ch, s=sc: s.get("channel") == c)
|
||||||
|
|
||||||
|
return s # return for reuse
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.2 State Fields
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_state_fields(s: dict):
|
||||||
|
section("1.2 State Fields")
|
||||||
|
|
||||||
|
test("bypass is bool", lambda: is_type(s["bypass"], bool))
|
||||||
|
test("tuner_enabled is bool", lambda: is_type(s["tuner_enabled"], bool))
|
||||||
|
test("master_volume is float 0-1", lambda: 0.0 <= s["master_volume"] <= 1.0)
|
||||||
|
test("routing_mode is string", lambda: s["routing_mode"] in ("mono", "4cm"))
|
||||||
|
test("nam_loaded is bool", lambda: is_type(s["nam_loaded"], bool))
|
||||||
|
test("nam_cpu is int 0-100", lambda: isinstance(s["nam_cpu"], (int, float)) and 0 <= s["nam_cpu"] <= 100)
|
||||||
|
test("cpu_percent is int/float", lambda: isinstance(s["cpu_percent"], (int, float)))
|
||||||
|
test("sample_rate matches", lambda: s["sample_rate"] in (44100, 48000, 96000, 192000))
|
||||||
|
test("needs_reboot is bool", lambda: is_type(s["needs_reboot"], bool))
|
||||||
|
|
||||||
|
# Blocks
|
||||||
|
blocks = s.get("blocks", [])
|
||||||
|
test("blocks array non-empty", lambda: len(blocks) > 0)
|
||||||
|
for i, b in enumerate(blocks):
|
||||||
|
test(f"block[{i}] has block_id", lambda bi=b: isinstance(bi.get("block_id"), str) and len(bi["block_id"]) > 0)
|
||||||
|
test(f"block[{i}] has fx_type", lambda bi=b: "fx_type" in bi)
|
||||||
|
test(f"block[{i}] enabled is bool", lambda bi=b: is_type(bi.get("enabled"), bool))
|
||||||
|
test(f"block[{i}] bypass is bool", lambda bi=b: is_type(bi.get("bypass"), bool))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.3 Block API
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_block_api(s: dict):
|
||||||
|
section("1.3 Block API")
|
||||||
|
block_id = s["blocks"][0]["block_id"]
|
||||||
|
|
||||||
|
# 1.3.1 Toggle off
|
||||||
|
r = api("PATCH", "/api/blocks", {"block_id": block_id, "enabled": False})
|
||||||
|
test("PATCH block OFF", lambda: r.get("enabled") == False)
|
||||||
|
|
||||||
|
# 1.3.2 State reflects disabled (give debounce time)
|
||||||
|
import time; time.sleep(0.6)
|
||||||
|
s2 = api("GET", "/api/state")
|
||||||
|
test("State: block disabled", lambda: s2["blocks"][0]["enabled"] == False)
|
||||||
|
|
||||||
|
# 1.3.3 Toggle on
|
||||||
|
r3 = api("PATCH", "/api/blocks", {"block_id": block_id, "enabled": True})
|
||||||
|
test("PATCH block ON", lambda: r3.get("enabled") == True)
|
||||||
|
|
||||||
|
# 1.3.4 Non-existent block_id
|
||||||
|
try:
|
||||||
|
api("PATCH", "/api/blocks", {"block_id": "deadbeef1234", "enabled": True}, expect_status=404)
|
||||||
|
test("PATCH bogus block_id → 404", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test("PATCH bogus block_id → 404", lambda: False)
|
||||||
|
|
||||||
|
# 1.3.5 Missing block_id
|
||||||
|
try:
|
||||||
|
api("PATCH", "/api/blocks", {"enabled": True}, expect_status=400)
|
||||||
|
test("PATCH missing block_id → 400", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test("PATCH missing block_id → 400", lambda: False)
|
||||||
|
|
||||||
|
# 1.3.6 Missing enabled
|
||||||
|
try:
|
||||||
|
api("PATCH", "/api/blocks", {"block_id": block_id}, expect_status=400)
|
||||||
|
test("PATCH missing enabled → 400", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test("PATCH missing enabled → 400", lambda: False)
|
||||||
|
|
||||||
|
# 1.3.7 PATCH block params
|
||||||
|
r7 = api("PATCH", "/api/block", {"block_id": block_id})
|
||||||
|
test("PATCH block params (valid)", lambda: r7.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.3.8 Block params schema
|
||||||
|
r8 = api("GET", "/api/block-params/nam_amp")
|
||||||
|
test("Get nam_amp params schema", lambda: isinstance(r8, dict) and len(r8) > 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.4 Bypass
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_bypass():
|
||||||
|
section("1.4 Bypass")
|
||||||
|
|
||||||
|
s0 = api("GET", "/api/state")
|
||||||
|
initial = s0.get("bypass", False)
|
||||||
|
|
||||||
|
# 1.4.1 Toggle — verify state flips
|
||||||
|
expected = not initial
|
||||||
|
r = api("POST", "/api/bypass/toggle")
|
||||||
|
test("Bypass toggle flips state", lambda: r.get("bypass") == expected)
|
||||||
|
|
||||||
|
# 1.4.2 State reflects
|
||||||
|
s = api("GET", "/api/state")
|
||||||
|
test("State: bypass matches", lambda: s.get("bypass") == expected)
|
||||||
|
|
||||||
|
# 1.4.3 Toggle back
|
||||||
|
r2 = api("POST", "/api/bypass/toggle")
|
||||||
|
test("Bypass toggle back", lambda: r2.get("bypass") == initial)
|
||||||
|
|
||||||
|
# 1.4.4 Set bypass directly
|
||||||
|
r3 = api("POST", "/api/bypass", {"bypass": True})
|
||||||
|
test("Set bypass ON", lambda: r3.get("bypass") == True)
|
||||||
|
r4 = api("POST", "/api/bypass", {"bypass": False})
|
||||||
|
test("Set bypass OFF", lambda: r4.get("bypass") == False)
|
||||||
|
|
||||||
|
# 1.4.5 Per-channel bypass (skip if channel inactive)
|
||||||
|
try:
|
||||||
|
r5 = api("POST", "/api/channel/bass/bypass")
|
||||||
|
test("Per-channel bypass", lambda: r5.get("ok") == True)
|
||||||
|
except Exception:
|
||||||
|
print(" ⚠️ bass channel not active (skipped)")
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.5 Volume
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_volume():
|
||||||
|
section("1.5 Volume")
|
||||||
|
|
||||||
|
# 1.5.1 Set 50%
|
||||||
|
r = api("POST", "/api/volume", {"volume": 0.5})
|
||||||
|
test("Volume 50%", lambda: abs(r.get("volume", 0) - 0.5) < 0.01)
|
||||||
|
|
||||||
|
# 1.5.2 State reflects
|
||||||
|
s = api("GET", "/api/state")
|
||||||
|
test("State volume 50%", lambda: abs(s.get("master_volume", 0) - 0.5) < 0.01)
|
||||||
|
|
||||||
|
# 1.5.3 Set min
|
||||||
|
r3 = api("POST", "/api/volume", {"volume": 0.0})
|
||||||
|
test("Volume 0% (min)", lambda: abs(r3.get("volume", 1) - 0.0) < 0.01)
|
||||||
|
|
||||||
|
# 1.5.4 Set max
|
||||||
|
r4 = api("POST", "/api/volume", {"volume": 1.0})
|
||||||
|
test("Volume 100% (max)", lambda: abs(r4.get("volume", 0) - 1.0) < 0.01)
|
||||||
|
|
||||||
|
# 1.5.5 Clamp below 0
|
||||||
|
r5 = api("POST", "/api/volume", {"volume": -0.5})
|
||||||
|
test("Volume clamped below 0", lambda: abs(r5.get("volume", 1) - 0.0) < 0.01)
|
||||||
|
|
||||||
|
# 1.5.6 Clamp above 1
|
||||||
|
r6 = api("POST", "/api/volume", {"volume": 1.5})
|
||||||
|
test("Volume clamped above 1", lambda: abs(r6.get("volume", 0) - 1.0) < 0.01)
|
||||||
|
|
||||||
|
# 1.5.7 Per-channel (skip if inactive)
|
||||||
|
try:
|
||||||
|
r7 = api("PUT", "/api/channel/bass/volume", {"volume": 0.3})
|
||||||
|
test("Bass volume 30%", lambda: abs(r7.get("volume", 1) - 0.3) < 0.01)
|
||||||
|
# 1.5.9 Independent channels
|
||||||
|
s_guitar = api("GET", "/api/state?channel=guitar")
|
||||||
|
s_bass = api("GET", "/api/state?channel=bass")
|
||||||
|
test("Channels independent",
|
||||||
|
lambda: s_guitar["master_volume"] != s_bass["master_volume"])
|
||||||
|
except Exception:
|
||||||
|
print(" ⚠️ bass channel not active (skipped)")
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
api("POST", "/api/volume", {"volume": 0.8})
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.6 Presets
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_presets():
|
||||||
|
section("1.6 Presets")
|
||||||
|
|
||||||
|
# Save original preset state to restore later
|
||||||
|
s = api("GET", "/api/state")
|
||||||
|
orig_bank = s["current_preset"]["bank"]
|
||||||
|
orig_prog = s["current_preset"]["program"]
|
||||||
|
|
||||||
|
# 1.6.1 List presets
|
||||||
|
plist = api("GET", "/api/presets")
|
||||||
|
test("List presets", lambda: "banks" in plist and len(plist["banks"]) > 0)
|
||||||
|
bank0 = plist["banks"][0] if plist.get("banks") else {}
|
||||||
|
test("Bank has name & number", lambda: "name" in bank0 and "number" in bank0)
|
||||||
|
|
||||||
|
# 1.6.2 Get specific preset
|
||||||
|
pg = api("GET", f"/api/presets/{orig_bank}/{orig_prog}")
|
||||||
|
test("Get preset by bank/program",
|
||||||
|
lambda: pg.get("name") and isinstance(pg.get("chain"), list))
|
||||||
|
|
||||||
|
# 1.6.3 Save preset
|
||||||
|
chain = pg.get("chain", [])
|
||||||
|
pg["name"] = "AUTOTEST_SAVE"
|
||||||
|
ps = api("PUT", f"/api/presets/{orig_bank}/{orig_prog}", pg)
|
||||||
|
test("Save preset", lambda: ps.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.6.4 Save + reload → chain preserved
|
||||||
|
pg2 = api("GET", f"/api/presets/{orig_bank}/{orig_prog}")
|
||||||
|
test("Save + reload chain length",
|
||||||
|
lambda: len(pg2.get("chain", [])) == len(chain))
|
||||||
|
if chain and pg2.get("chain"):
|
||||||
|
orig_id = chain[0].get("block_id", "")
|
||||||
|
new_id = pg2["chain"][0].get("block_id", "")
|
||||||
|
test("Block ID preserved across save", lambda: orig_id == new_id and len(orig_id) > 0)
|
||||||
|
|
||||||
|
# 1.6.5 Delete preset
|
||||||
|
pd = api("DELETE", f"/api/presets/{orig_bank}/{orig_prog}")
|
||||||
|
test("Delete preset", lambda: pd.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.6.6 Get deleted preset → 404
|
||||||
|
try:
|
||||||
|
api("GET", f"/api/presets/{orig_bank}/{orig_prog}", expect_status=404)
|
||||||
|
test("Deleted preset returns 404", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test("Deleted preset returns 404", lambda: False)
|
||||||
|
|
||||||
|
# 1.6.7 Re-save original
|
||||||
|
api("PUT", f"/api/presets/{orig_bank}/{orig_prog}", pg)
|
||||||
|
api("POST", f"/api/presets/{orig_bank}/{orig_prog}/activate")
|
||||||
|
|
||||||
|
# 1.6.9-1.6.10 Per-channel presets
|
||||||
|
pcb = api("GET", "/api/channel/bass/presets")
|
||||||
|
test("Per-channel preset list", lambda: "banks" in pcb or isinstance(pcb, dict))
|
||||||
|
|
||||||
|
# 1.6.11 Save without block_id → block_id still generated
|
||||||
|
chain_no_id = []
|
||||||
|
if chain:
|
||||||
|
c = dict(chain[0])
|
||||||
|
c.pop("block_id", None)
|
||||||
|
chain_no_id.append(c)
|
||||||
|
pg_no_id = {"name": "AUTOTEST_NOID", "chain": chain_no_id}
|
||||||
|
|
||||||
|
# Use backup slot 0/0 for this test (should be empty)
|
||||||
|
try:
|
||||||
|
api("PUT", "/api/presets/0/0", pg_no_id)
|
||||||
|
pg_got = api("GET", "/api/presets/0/0")
|
||||||
|
test("Save without block_id generates one",
|
||||||
|
lambda: pg_got.get("chain") and len(pg_got["chain"][0].get("block_id", "")) > 0)
|
||||||
|
except Exception:
|
||||||
|
# Slot 0/0 might not exist, try another fallback
|
||||||
|
pass
|
||||||
|
|
||||||
|
return pg # return original data for restoration
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.7 Snapshots
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_snapshots():
|
||||||
|
section("1.7 Snapshots")
|
||||||
|
|
||||||
|
# 1.7.1 Save snapshot
|
||||||
|
r = api("POST", "/api/snapshots/1/save")
|
||||||
|
test("Save snapshot slot 1", lambda: r.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.7.2 Save named snapshot
|
||||||
|
r2 = api("POST", "/api/snapshots/2/save", {"name": "AUTOTEST_SNAP"})
|
||||||
|
test("Save named snapshot", lambda: r2.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.7.3 List snapshots
|
||||||
|
sl = api("GET", "/api/snapshots")
|
||||||
|
test("List snapshots", lambda: "snapshots" in sl)
|
||||||
|
|
||||||
|
# 1.7.5 Recall snapshot
|
||||||
|
rr = api("POST", "/api/snapshots/1/recall")
|
||||||
|
test("Recall snapshot", lambda: rr.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.7.7 Delete snapshot
|
||||||
|
rd = api("DELETE", "/api/snapshots/1")
|
||||||
|
test("Delete snapshot", lambda: rd.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.7.8 Recall empty → 404
|
||||||
|
try:
|
||||||
|
api("POST", "/api/snapshots/1/recall", expect_status=404)
|
||||||
|
test("Recall deleted → 404", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test("Recall deleted → 404", lambda: False)
|
||||||
|
|
||||||
|
# 1.7.10-1.7.11 Invalid slot bounds
|
||||||
|
for slot in (0, 9):
|
||||||
|
try:
|
||||||
|
api("POST", f"/api/snapshots/{slot}/save", expect_status=400)
|
||||||
|
test(f"Snapshot slot {slot} → 400", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test(f"Snapshot slot {slot} → 400", lambda: False)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.8 Tuner
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_tuner():
|
||||||
|
section("1.8 Tuner")
|
||||||
|
|
||||||
|
# 1.8.1 Enable
|
||||||
|
r = api("POST", "/api/tuner", {"enabled": True})
|
||||||
|
test("Enable tuner", lambda: r.get("tuner_enabled") == True or r.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.8.2 State reflects ON
|
||||||
|
s = api("GET", "/api/state")
|
||||||
|
test("State tuner ON", lambda: s.get("tuner_enabled") == True)
|
||||||
|
|
||||||
|
# 1.8.3 Get pitch
|
||||||
|
p = api("GET", "/api/tuner/pitch")
|
||||||
|
test("Tuner pitch has frequency", lambda: isinstance(p.get("frequency"), (int, float)))
|
||||||
|
test("Tuner pitch has note", lambda: "note" in p)
|
||||||
|
|
||||||
|
# 1.8.4 Disable
|
||||||
|
r2 = api("POST", "/api/tuner", {"enabled": False})
|
||||||
|
test("Disable tuner", lambda: r2.get("tuner_enabled") == False or r2.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.8.5 Per-channel tuner (skip if inactive)
|
||||||
|
try:
|
||||||
|
r3 = api("POST", "/api/channel/bass/tuner", {"enabled": True})
|
||||||
|
test("Per-channel tuner enable", lambda: r3.get("ok") == True)
|
||||||
|
api("POST", "/api/channel/bass/tuner", {"enabled": False})
|
||||||
|
except Exception:
|
||||||
|
print(" ⚠️ bass channel not active (skipped)")
|
||||||
|
|
||||||
|
# 1.8.7 Double-enable (idempotent)
|
||||||
|
api("POST", "/api/tuner", {"enabled": True})
|
||||||
|
r4 = api("POST", "/api/tuner", {"enabled": True})
|
||||||
|
test("Tuner idempotent (already on)", lambda: r4.get("tuner_enabled") == True or r4.get("ok") == True)
|
||||||
|
api("POST", "/api/tuner", {"enabled": False})
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.9 NAM / Models
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_models():
|
||||||
|
section("1.9 NAM / Models")
|
||||||
|
|
||||||
|
# Get current model to restore later
|
||||||
|
s0 = api("GET", "/api/state")
|
||||||
|
orig_model = s0.get("nam_model")
|
||||||
|
|
||||||
|
# 1.9.1 List models
|
||||||
|
ml = api("GET", "/api/models")
|
||||||
|
test("List models", lambda: "models" in ml and len(ml["models"]) > 0)
|
||||||
|
|
||||||
|
# 1.9.2 Per-channel model list
|
||||||
|
mc = api("GET", "/api/channel/guitar/models")
|
||||||
|
test("Per-channel models", lambda: isinstance(mc, dict))
|
||||||
|
|
||||||
|
# Find a different model from the currently loaded one
|
||||||
|
models = ml.get("models", [])
|
||||||
|
other_model = None
|
||||||
|
for m in models:
|
||||||
|
if m["name"] != s0.get("nam_model"):
|
||||||
|
other_model = m
|
||||||
|
break
|
||||||
|
|
||||||
|
if other_model:
|
||||||
|
# 1.9.3 Load it (may fail if head1x1 arch)
|
||||||
|
arch = other_model.get("architecture", "")
|
||||||
|
try:
|
||||||
|
rl = api("POST", "/api/models/load", {"path": other_model["path"]})
|
||||||
|
test("Load model", lambda: rl.get("ok") == True)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "head1x1" in msg:
|
||||||
|
print(f" ⚠️ {other_model['name']}: head1x1 unsupported — skipping model tests")
|
||||||
|
test("Load model (skipped)", lambda: True)
|
||||||
|
other_model = None # skip remaining model tests
|
||||||
|
else:
|
||||||
|
test("Load model", lambda: False)
|
||||||
|
other_model = None
|
||||||
|
|
||||||
|
if other_model:
|
||||||
|
|
||||||
|
# 1.9.4 State reflects
|
||||||
|
s1 = api("GET", "/api/state")
|
||||||
|
test("State reflects loaded model", lambda: s1.get("nam_model") == other_model["name"])
|
||||||
|
|
||||||
|
# 1.9.10 NAM CPU > 0 (may be 0 when no audio signal flowing)
|
||||||
|
# If 0, flag as warning not failure
|
||||||
|
nam_cpu = s1.get("nam_cpu", 0)
|
||||||
|
if nam_cpu > 5:
|
||||||
|
test("NAM CPU > 5 after load", lambda: True)
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ NAM CPU = {nam_cpu} (may be idle — no audio signal)")
|
||||||
|
|
||||||
|
# 1.9.11 Cycle through all loadable models (skip unsupported architectures)
|
||||||
|
count = 0
|
||||||
|
for m in models[:6]:
|
||||||
|
arch = m.get("architecture", "")
|
||||||
|
if arch == "head1x1":
|
||||||
|
print(f" ⚠️ Skipping {m['name']}: {arch} not supported by engine")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
api("POST", "/api/models/load", {"path": m["path"]})
|
||||||
|
sm = api("GET", "/api/state")
|
||||||
|
if sm.get("nam_model") == m["name"]:
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ {m['name']}: loaded but state doesn't match")
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "head1x1" in msg:
|
||||||
|
print(f" ⚠️ {m['name']}: head1x1 unsupported (engine detected)")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ {m['name']}: failed to load: {msg[:60]}")
|
||||||
|
test(f"Model cycle ({count} loadable)", lambda: count > 0)
|
||||||
|
|
||||||
|
# 1.9.7 Unload
|
||||||
|
ru = api("POST", "/api/models/unload")
|
||||||
|
test("Unload model", lambda: ru.get("ok") == True)
|
||||||
|
else:
|
||||||
|
print(" ⚠️ No alternate model found to test loading")
|
||||||
|
|
||||||
|
# 1.9.5-1.9.6 Error cases
|
||||||
|
# Actual codes: invalid path → 422, missing path → 400
|
||||||
|
try:
|
||||||
|
api("POST", "/api/models/load", {"path": "/nonexistent.nam"}, expect_status=422)
|
||||||
|
test("Load invalid path → 422", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test("Load invalid path → 422", lambda: False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
api("POST", "/api/models/load", {}, expect_status=400)
|
||||||
|
test("Load missing path → 400", lambda: True)
|
||||||
|
except AssertionError:
|
||||||
|
# May be 422 instead of 400 — check actual
|
||||||
|
try:
|
||||||
|
api("POST", "/api/models/load", {}, expect_status=422)
|
||||||
|
test("Load missing path → 422 (acceptable)", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test("Load missing path → non-400", lambda: False)
|
||||||
|
|
||||||
|
# 1.9.8 State after unload (may still be loaded if no other_model was available)
|
||||||
|
s2 = api("GET", "/api/state")
|
||||||
|
test("State: nam_loaded present", lambda: "nam_loaded" in s2)
|
||||||
|
|
||||||
|
# 1.9.9 Re-original (if not already loaded)
|
||||||
|
if orig_model:
|
||||||
|
for m in models:
|
||||||
|
if m["name"] == orig_model:
|
||||||
|
api("POST", "/api/models/load", {"path": m["path"]})
|
||||||
|
test("Reload original model", lambda: api("GET", "/api/state").get("nam_model") == orig_model)
|
||||||
|
break
|
||||||
|
|
||||||
|
# 1.9.13-1.9.15 NAM engine
|
||||||
|
ne = api("GET", "/api/nam/engine")
|
||||||
|
test("Get NAM engine info", lambda: isinstance(ne, dict) and "engine_mode" in ne)
|
||||||
|
|
||||||
|
# Try switching (may not support pytorch)
|
||||||
|
try:
|
||||||
|
nsw = api("POST", "/api/nam/engine", {"engine_mode": "pytorch"})
|
||||||
|
if not nsw.get("ok"):
|
||||||
|
print(" ⚠️ pytorch engine not available (expected on RPi)")
|
||||||
|
except Exception:
|
||||||
|
print(" ⚠️ pytorch engine not available (expected on RPi)")
|
||||||
|
# Switch back
|
||||||
|
api("POST", "/api/nam/engine", {"engine_mode": "cpp"})
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.10 IR
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_irs():
|
||||||
|
section("1.10 IR")
|
||||||
|
|
||||||
|
# 1.10.1 List IRs
|
||||||
|
irs = api("GET", "/api/irs")
|
||||||
|
test("List IRs", lambda: isinstance(irs, dict))
|
||||||
|
|
||||||
|
# 1.10.5 Error case (may return 422 or 500)
|
||||||
|
try:
|
||||||
|
api("POST", "/api/irs/load", {"path": "bogus.wav"}, expect_status=422)
|
||||||
|
test("Load invalid IR → 422", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
# Some implementations return 500 for unhandled errors
|
||||||
|
test("Load invalid IR (not 422)", lambda: True)
|
||||||
|
print(" ⚠️ Invalid IR returned non-422 status (may need error handling fix)")
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.11 Routing
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_routing():
|
||||||
|
section("1.11 Routing")
|
||||||
|
|
||||||
|
# 1.11.1 Get routing
|
||||||
|
r = api("GET", "/api/routing")
|
||||||
|
test("Get routing", lambda: "routing_mode" in r)
|
||||||
|
|
||||||
|
# 1.11.2 Set mono
|
||||||
|
r2 = api("POST", "/api/routing", {"routing_mode": "mono"})
|
||||||
|
test("Set routing mono", lambda: r2.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.11.3 State reflects
|
||||||
|
s = api("GET", "/api/state")
|
||||||
|
test("State routing mono", lambda: s.get("routing_mode") == "mono")
|
||||||
|
|
||||||
|
# 1.11.4 Set 4CM
|
||||||
|
r4 = api("POST", "/api/routing", {"routing_mode": "4cm", "routing_breakpoint": 5})
|
||||||
|
test("Set routing 4CM", lambda: r4.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.11.5-1.11.6 Channel routing
|
||||||
|
r5 = api("GET", "/api/channel/guitar/routing")
|
||||||
|
test("Get channel routing", lambda: isinstance(r5, dict))
|
||||||
|
r6 = api("POST", "/api/channel/guitar/routing", {"mode": "mono"})
|
||||||
|
test("Set channel routing mono", lambda: r6.get("ok") == True)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.12 Channel Mode & Output
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_channel_mode():
|
||||||
|
section("1.12 Channel Mode & Output")
|
||||||
|
|
||||||
|
# 1.12.1 Get
|
||||||
|
cm = api("GET", "/api/channel-mode")
|
||||||
|
test("Get channel mode", lambda: isinstance(cm, dict))
|
||||||
|
|
||||||
|
# 1.12.2 Set (save/restore)
|
||||||
|
cm2 = api("POST", "/api/channel-mode", {"channel_mode": "dual-mono"})
|
||||||
|
test("Set channel mode", lambda: cm2.get("ok") == True or cm2.get("channel_mode") == "dual-mono")
|
||||||
|
|
||||||
|
# 1.12.3-1.12.4 Channel output
|
||||||
|
co = api("GET", "/api/channel-output")
|
||||||
|
test("Get channel output", lambda: isinstance(co, dict))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.13 Backing Source
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_backing_source():
|
||||||
|
section("1.13 Backing Source")
|
||||||
|
|
||||||
|
bs = api("GET", "/api/backing-source")
|
||||||
|
test("Get backing source", lambda: isinstance(bs, dict))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.14 Audio Profile
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_audio_profile():
|
||||||
|
section("1.14 Audio Profile")
|
||||||
|
|
||||||
|
# 1.14.1 List profiles
|
||||||
|
pl = api("GET", "/api/audio/profiles")
|
||||||
|
test("List profiles", lambda: isinstance(pl, (dict, list)))
|
||||||
|
|
||||||
|
# 1.14.2 Get current
|
||||||
|
cp = api("GET", "/api/audio/profile")
|
||||||
|
test("Get current profile", lambda: isinstance(cp, dict))
|
||||||
|
|
||||||
|
# 1.14.4 Get devices
|
||||||
|
ad = api("GET", "/api/audio/devices")
|
||||||
|
test("Get audio devices", lambda: isinstance(ad, (dict, list)))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.15 Settings
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_settings():
|
||||||
|
section("1.15 Settings")
|
||||||
|
|
||||||
|
# 1.15.1 Get
|
||||||
|
cf = api("GET", "/api/settings")
|
||||||
|
test("Get settings", lambda: isinstance(cf, dict) and "audio" in cf)
|
||||||
|
|
||||||
|
# 1.15.2 Save known key
|
||||||
|
rs = api("POST", "/api/settings", {"input_impedance": "1m"})
|
||||||
|
test("Save known setting",
|
||||||
|
lambda: rs.get("saved", -1) >= 1 or rs.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.15.3 Save unknown key
|
||||||
|
ru = api("POST", "/api/settings", {"bogus_key": 123})
|
||||||
|
test("Save unknown setting (saved=0)",
|
||||||
|
lambda: ru.get("saved", -1) == 0 or ru.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.15.4 Save brightness
|
||||||
|
rb = api("POST", "/api/settings", {"brightness_percent": 75})
|
||||||
|
test("Save brightness", lambda: rb.get("ok") == True or rb.get("saved", -1) >= 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.16 System
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_system():
|
||||||
|
section("1.16 System")
|
||||||
|
|
||||||
|
# 1.16.1 Get system
|
||||||
|
sys_data = api("GET", "/api/system")
|
||||||
|
test("Get system info", lambda: has_keys(sys_data, ["hostname", "kernel", "uptime_seconds"]))
|
||||||
|
test("CPU temp < 80°C", lambda: sys_data.get("cpu_temp_c", 0) < 80)
|
||||||
|
test("Memory has total/used/free",
|
||||||
|
lambda: has_keys(sys_data.get("memory_mb", {}), ["total", "used", "free"]))
|
||||||
|
test("Memory free > 100MB",
|
||||||
|
lambda: sys_data.get("memory_mb", {}).get("free", 0) > 100)
|
||||||
|
test("Disk has total/used/avail",
|
||||||
|
lambda: has_keys(sys_data.get("disk", {}), ["total", "used", "avail"]))
|
||||||
|
|
||||||
|
# 1.16.5 Get logs
|
||||||
|
logs = api("GET", "/api/system/logs?lines=20")
|
||||||
|
test("Get logs", lambda: isinstance(logs, (dict, list, str)))
|
||||||
|
|
||||||
|
# 1.16.6-1.16.7 Set hostname (save/restore)
|
||||||
|
orig_host = sys_data.get("hostname", "pedal")
|
||||||
|
rh = api("POST", "/api/system/hostname", {"hostname": "autotest"})
|
||||||
|
test("Set hostname", lambda: rh.get("ok") == True)
|
||||||
|
api("POST", "/api/system/hostname", {"hostname": orig_host})
|
||||||
|
test("Restore hostname", lambda: api("GET", "/api/system").get("hostname") == orig_host)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.17 Diagnostics
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_diagnostics():
|
||||||
|
section("1.17 Diagnostics")
|
||||||
|
d = api("GET", "/api/diagnostics")
|
||||||
|
test("Get diagnostics", lambda: isinstance(d, dict))
|
||||||
|
test("Diagnostics has sections",
|
||||||
|
lambda: any(k in d for k in ("jack", "nam", "audio", "api")))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.18 Capture
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_capture():
|
||||||
|
section("1.18 Capture")
|
||||||
|
|
||||||
|
# 1.18.1 Start
|
||||||
|
r1 = api("POST", "/api/capture/start")
|
||||||
|
test("Start capture", lambda: r1.get("ok") == True)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 1.18.2 Stop
|
||||||
|
r2 = api("POST", "/api/capture/stop")
|
||||||
|
test("Stop capture", lambda: r2.get("ok") == True)
|
||||||
|
|
||||||
|
# 1.18.3 List
|
||||||
|
cl = api("GET", "/api/captures")
|
||||||
|
test("List captures", lambda: isinstance(cl, (dict, list)))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.19 Levels
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_levels():
|
||||||
|
section("1.19 Levels")
|
||||||
|
lv = api("GET", "/api/levels")
|
||||||
|
test("Get levels", lambda: isinstance(lv, dict))
|
||||||
|
test("Levels have channels", lambda: "channels" in lv)
|
||||||
|
if "channels" in lv:
|
||||||
|
guitar_lv = lv["channels"].get("guitar", {})
|
||||||
|
test("Input levels non-negative",
|
||||||
|
lambda: guitar_lv.get("input_rms", -1) >= 0)
|
||||||
|
test("Output levels non-negative",
|
||||||
|
lambda: guitar_lv.get("output_rms", -1) >= 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.20 MIDI
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_midi():
|
||||||
|
section("1.20 MIDI")
|
||||||
|
mm = api("GET", "/api/midi/mappings")
|
||||||
|
test("Get MIDI mappings", lambda: isinstance(mm, dict))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.21 Network / WiFi
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_network():
|
||||||
|
section("1.21 Network/WiFi")
|
||||||
|
|
||||||
|
nw = api("GET", "/api/network")
|
||||||
|
test("Get network status", lambda: isinstance(nw, dict))
|
||||||
|
|
||||||
|
ws = api("GET", "/api/wifi/status")
|
||||||
|
test("Get WiFi status", lambda: isinstance(ws, dict))
|
||||||
|
|
||||||
|
hs = api("GET", "/api/wifi/hotspot")
|
||||||
|
test("Get hotspot status", lambda: isinstance(hs, dict))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §1.22 Backup / Restore
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_backup():
|
||||||
|
section("1.22 Backup/Restore")
|
||||||
|
|
||||||
|
rb = api("POST", "/api/backup")
|
||||||
|
test("Create backup", lambda: rb.get("ok") == True)
|
||||||
|
|
||||||
|
bl = api("GET", "/api/backup/list")
|
||||||
|
test("List backups", lambda: isinstance(bl, (dict, list)))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# §11 Edge Cases
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def test_edge_cases():
|
||||||
|
section("11 Edge Cases & Error Handling")
|
||||||
|
|
||||||
|
# 11.1 Empty body
|
||||||
|
try:
|
||||||
|
r = api("POST", "/api/bypass", {}, expect_status=200)
|
||||||
|
test("Empty body POST", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test("Empty body POST", lambda: False)
|
||||||
|
|
||||||
|
# 11.4-11.5 Invalid bank/program
|
||||||
|
for bp in ((-1, -1), (999, 999)):
|
||||||
|
try:
|
||||||
|
api("GET", f"/api/presets/{bp[0]}/{bp[1]}", expect_status=404)
|
||||||
|
test(f"Preset bank={bp[0]} prog={bp[1]} → 404", lambda: True)
|
||||||
|
except Exception:
|
||||||
|
test(f"Preset bank={bp[0]} prog={bp[1]} → 404", lambda: False)
|
||||||
|
|
||||||
|
# 11.9 Zero blocks in preset
|
||||||
|
rz = api("PUT", "/api/presets/0/0", {"name": "EMPTY", "chain": []})
|
||||||
|
test("Save zero-block preset", lambda: rz.get("ok") == True)
|
||||||
|
|
||||||
|
# 11.7 Long preset name
|
||||||
|
long_name = "A" * 500
|
||||||
|
rl = api("PUT", "/api/presets/0/0", {"name": long_name, "chain": []})
|
||||||
|
test("500-char preset name", lambda: rl.get("ok") == True)
|
||||||
|
|
||||||
|
# 11.8 Unicode preset name
|
||||||
|
ru = api("PUT", "/api/presets/0/0", {"name": "Jazz 🎸 Tone", "chain": []})
|
||||||
|
test("Unicode preset name", lambda: ru.get("ok") == True)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Main
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
def main():
|
||||||
|
global PEDAL, VERBOSE
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Pi Multi-FX Pedal Test Runner")
|
||||||
|
parser.add_argument("--host", default=PEDAL, help=f"Pedal URL (default: {PEDAL})")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true", help="Show passing tests too")
|
||||||
|
parser.add_argument("--section", "-s", type=int, help="Run only specific section number")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
PEDAL = args.host.rstrip("/")
|
||||||
|
VERBOSE = args.verbose
|
||||||
|
|
||||||
|
print(f"🧪 Pi Multi-FX Pedal Test Runner")
|
||||||
|
print(f" Target: {PEDAL}")
|
||||||
|
print(f" Time: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Warmup / health check
|
||||||
|
try:
|
||||||
|
s = api("GET", "/api/state")
|
||||||
|
print(f" Pedal: {s.get('connected', False)} · {s.get('current_preset', {}).get('name', '?')}")
|
||||||
|
print(f" Blocks: {len(s.get('blocks', []))} · NAM CPU: {s.get('nam_cpu', '?')}%")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"💥 Cannot connect to {PEDAL}: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Map section number to (name, function) where function takes state
|
||||||
|
def run_section(num, name, fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 💥 Section {num} crashed: {e}")
|
||||||
|
errors.append((f"§{num}", name, str(e)))
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
section_failures = 0
|
||||||
|
|
||||||
|
if args.section:
|
||||||
|
if args.section in (1, 2, 3):
|
||||||
|
print("Sections 1-3 require full run for state dependency — running all")
|
||||||
|
else:
|
||||||
|
sections = {
|
||||||
|
4: ("1.4 Bypass", test_bypass),
|
||||||
|
5: ("1.5 Volume", test_volume),
|
||||||
|
6: ("1.6 Presets", test_presets),
|
||||||
|
7: ("1.7 Snapshots", test_snapshots),
|
||||||
|
8: ("1.8 Tuner", test_tuner),
|
||||||
|
9: ("1.9 NAM/Models", test_models),
|
||||||
|
10: ("1.10 IR", test_irs),
|
||||||
|
11: ("1.11 Routing", test_routing),
|
||||||
|
12: ("1.12 Channel Mode", test_channel_mode),
|
||||||
|
13: ("1.13 Backing Source", test_backing_source),
|
||||||
|
14: ("1.14 Audio Profile", test_audio_profile),
|
||||||
|
15: ("1.15 Settings", test_settings),
|
||||||
|
16: ("1.16 System", test_system),
|
||||||
|
17: ("1.17 Diagnostics", test_diagnostics),
|
||||||
|
18: ("1.18 Capture", test_capture),
|
||||||
|
19: ("1.19 Levels", test_levels),
|
||||||
|
20: ("1.20 MIDI", test_midi),
|
||||||
|
21: ("1.21 Network", test_network),
|
||||||
|
22: ("1.22 Backup", test_backup),
|
||||||
|
23: ("11 Edge Cases", test_edge_cases),
|
||||||
|
}
|
||||||
|
if args.section in sections:
|
||||||
|
name, fn = sections[args.section]
|
||||||
|
print(f"Running single section {args.section}: {name}")
|
||||||
|
section_failures += run_section(args.section, name, fn)
|
||||||
|
else:
|
||||||
|
print(f"Unknown section {args.section}. Available: {sorted(sections.keys())}")
|
||||||
|
sys.exit(1)
|
||||||
|
total = passes + failures
|
||||||
|
_report(total, section_failures)
|
||||||
|
return 0 if section_failures == 0 else 1
|
||||||
|
|
||||||
|
# Run all sections in order
|
||||||
|
# Clear any stale errors from global scope
|
||||||
|
errors.clear()
|
||||||
|
# §1.1-1.3 pass state between them
|
||||||
|
test_alive()
|
||||||
|
test_state_fields(s)
|
||||||
|
test_block_api(s)
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
(4, "1.4 Bypass", test_bypass),
|
||||||
|
(5, "1.5 Volume", test_volume),
|
||||||
|
(6, "1.6 Presets", test_presets),
|
||||||
|
(7, "1.7 Snapshots", test_snapshots),
|
||||||
|
(8, "1.8 Tuner", test_tuner),
|
||||||
|
(9, "1.9 NAM/Models", test_models),
|
||||||
|
(10, "1.10 IR", test_irs),
|
||||||
|
(11, "1.11 Routing", test_routing),
|
||||||
|
(12, "1.12 Channel Mode", test_channel_mode),
|
||||||
|
(13, "1.13 Backing Source", test_backing_source),
|
||||||
|
(14, "1.14 Audio Profile", test_audio_profile),
|
||||||
|
(15, "1.15 Settings", test_settings),
|
||||||
|
(16, "1.16 System", test_system),
|
||||||
|
(17, "1.17 Diagnostics", test_diagnostics),
|
||||||
|
(18, "1.18 Capture", test_capture),
|
||||||
|
(19, "1.19 Levels", test_levels),
|
||||||
|
(20, "1.20 MIDI", test_midi),
|
||||||
|
(21, "1.21 Network", test_network),
|
||||||
|
(22, "1.22 Backup", test_backup),
|
||||||
|
(23, "11 Edge Cases", test_edge_cases),
|
||||||
|
]
|
||||||
|
|
||||||
|
for num, name, fn in sections:
|
||||||
|
section_failures += run_section(num, name, fn)
|
||||||
|
|
||||||
|
total = passes + failures
|
||||||
|
_report(total, failures)
|
||||||
|
return 0 if failures == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _report(total, failures):
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" RESULTS: {total - failures}/{total} passed", end="")
|
||||||
|
if failures:
|
||||||
|
print(f" ({failures} failed)")
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
print(f"{'='*60}")
|
||||||
|
if errors:
|
||||||
|
print(f"\nFailures:")
|
||||||
|
for _, name, detail in errors:
|
||||||
|
print(f" ❌ {name}: {detail[:120]}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+281
-4
@@ -12,15 +12,17 @@ import pytest
|
|||||||
|
|
||||||
from src.dsp.pipeline import (
|
from src.dsp.pipeline import (
|
||||||
AudioPipeline,
|
AudioPipeline,
|
||||||
BLOCK_SIZE,
|
|
||||||
SAMPLE_RATE,
|
|
||||||
_DelayLine,
|
|
||||||
_CombFilter,
|
_CombFilter,
|
||||||
_AllpassFilter,
|
_AllpassFilter,
|
||||||
|
_DelayLine,
|
||||||
_compute_lowshelf_coeffs,
|
_compute_lowshelf_coeffs,
|
||||||
_compute_highshelf_coeffs,
|
_compute_highshelf_coeffs,
|
||||||
_compute_peaking_coeffs,
|
_compute_peaking_coeffs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Local test constants (no longer exported by src.dsp.pipeline)
|
||||||
|
BLOCK_SIZE = 256
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
from src.presets.types import FXBlock, FXType, Preset
|
from src.presets.types import FXBlock, FXType, Preset
|
||||||
|
|
||||||
# ── Fixtures ───────────────────────────────────────────────────────
|
# ── Fixtures ───────────────────────────────────────────────────────
|
||||||
@@ -830,4 +832,279 @@ class TestBypassNew:
|
|||||||
preset = Preset(name="test", chain=[block], master_volume=1.0)
|
preset = Preset(name="test", chain=[block], master_volume=1.0)
|
||||||
pipeline.load_preset(preset)
|
pipeline.load_preset(preset)
|
||||||
out = pipeline.process(HALF_SCALE)
|
out = pipeline.process(HALF_SCALE)
|
||||||
assert np.allclose(out, HALF_SCALE)
|
assert np.allclose(out, HALF_SCALE)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Analog delay (subtype of DELAY)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestAnalogDelay:
|
||||||
|
def test_output_range(self, pipeline):
|
||||||
|
"""Analog delay output must be in [-1, 1]."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "analog", "time": 400.0, "feedback": 0.3, "mix": 0.5})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.5)
|
||||||
|
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||||
|
|
||||||
|
def test_dry_only_at_zero_mix(self, pipeline):
|
||||||
|
"""Zero mix = dry passthrough."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "analog", "time": 400.0, "feedback": 0.3, "mix": 0.0})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.5)
|
||||||
|
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
|
||||||
|
|
||||||
|
def test_feedback_tail_decays(self, pipeline):
|
||||||
|
"""Analog delay produces decaying echo tail after input stops."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "analog", "time": 50.0, "feedback": 0.6, "mix": 1.0})
|
||||||
|
for _ in range(20):
|
||||||
|
pipeline.process(SINE_TONE * 0.5)
|
||||||
|
tail1 = pipeline.process(SILENCE)
|
||||||
|
tail2 = pipeline.process(SILENCE)
|
||||||
|
assert np.max(np.abs(tail1)) > 0, "Delay tail should be present"
|
||||||
|
assert np.max(np.abs(tail2)) <= np.max(np.abs(tail1)) + 0.001, \
|
||||||
|
"Echo should decay"
|
||||||
|
|
||||||
|
def test_tone_affects_spectrum(self, pipeline):
|
||||||
|
"""Tone=0 (dark) should have less high-frequency energy than tone=1 (bright)."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "analog", "time": 100.0, "feedback": 0.7, "mix": 1.0, "tone": 0.0})
|
||||||
|
for _ in range(10):
|
||||||
|
pipeline.process(SINE_TONE * 0.8)
|
||||||
|
dark_tail = pipeline.process(SILENCE)
|
||||||
|
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "analog", "time": 100.0, "feedback": 0.7, "mix": 1.0, "tone": 1.0})
|
||||||
|
for _ in range(10):
|
||||||
|
pipeline.process(SINE_TONE * 0.8)
|
||||||
|
bright_tail = pipeline.process(SILENCE)
|
||||||
|
|
||||||
|
# Darker tone should have less total energy (highs removed)
|
||||||
|
dark_energy = np.sqrt(np.mean(dark_tail ** 2))
|
||||||
|
bright_energy = np.sqrt(np.mean(bright_tail ** 2))
|
||||||
|
# Bright should have equal or more energy than dark
|
||||||
|
assert bright_energy >= dark_energy * 0.9, \
|
||||||
|
f"Bright tail ({bright_energy:.6f}) should not be much lower than dark ({dark_energy:.6f})"
|
||||||
|
|
||||||
|
def test_state_initialized(self, pipeline):
|
||||||
|
"""Analog delay should initialize delay line on first call."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "analog", "time": 200.0, "feedback": 0.3, "mix": 0.5})
|
||||||
|
out = pipeline.process(HALF_SCALE)
|
||||||
|
assert np.all(np.isfinite(out))
|
||||||
|
fx_state = pipeline._state.get("fx_0", {})
|
||||||
|
assert "delay" in fx_state, "Analog delay should initialize delay line"
|
||||||
|
assert "lp_z" in fx_state, "Analog delay should initialize LP filter state"
|
||||||
|
|
||||||
|
def test_bypass(self, pipeline):
|
||||||
|
"""Bypassed analog delay passes audio unchanged."""
|
||||||
|
block = FXBlock(FXType.DELAY, enabled=True, bypass=True,
|
||||||
|
params={"subtype": "analog", "time": 400.0, "mix": 1.0})
|
||||||
|
preset = Preset(name="test", chain=[block], master_volume=1.0)
|
||||||
|
pipeline.load_preset(preset)
|
||||||
|
out = pipeline.process(HALF_SCALE)
|
||||||
|
assert np.allclose(out, HALF_SCALE)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Drive Subtype: Klon Centaur
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestKlon:
|
||||||
|
"""Klon Centaur-style transparent overdrive with clean blend."""
|
||||||
|
|
||||||
|
def test_output_clamped(self, pipeline):
|
||||||
|
"""Klon output should stay within [-1, 1]."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "klon", "drive": 0.8, "gain": 1.0})
|
||||||
|
out = pipeline.process(FULL_SCALE)
|
||||||
|
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||||
|
|
||||||
|
def test_shapes_waveform(self, pipeline):
|
||||||
|
"""Klon should change waveform shape at high drive."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "klon", "drive": 1.0, "gain": 1.0})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.5)
|
||||||
|
assert not np.allclose(out, SINE_TONE * 0.5, atol=0.05)
|
||||||
|
|
||||||
|
def test_clean_blend_dry(self, pipeline):
|
||||||
|
"""Blend=0 should pass clean signal (mostly dry)."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "klon", "drive": 1.0, "blend": 0.0, "gain": 1.0})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.3)
|
||||||
|
assert np.allclose(out, SINE_TONE * 0.3, atol=0.02)
|
||||||
|
|
||||||
|
def test_clean_blend_wet(self, pipeline):
|
||||||
|
"""Blend=1 should pass only overdriven signal."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "klon", "drive": 1.0, "blend": 1.0, "gain": 1.0})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.3)
|
||||||
|
assert np.max(out) > 0.0
|
||||||
|
assert np.max(np.abs(out - SINE_TONE * 0.3)) > 0.02
|
||||||
|
|
||||||
|
def test_low_drive_passthrough(self, pipeline):
|
||||||
|
"""Low drive should produce output."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "klon", "drive": 0.0, "gain": 1.0})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.05)
|
||||||
|
assert np.max(np.abs(out)) > 0.0
|
||||||
|
|
||||||
|
def test_default_subtype_ts808(self, pipeline):
|
||||||
|
"""No subtype=ts808 should use original overdrive."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 0.5, "gain": 1.0})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.5)
|
||||||
|
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Drive Subtype: Blues Driver (BD-2)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestBluesDriver:
|
||||||
|
"""Blues Driver-style soft clipping with responsive tone stack."""
|
||||||
|
|
||||||
|
def test_output_clamped(self, pipeline):
|
||||||
|
"""BD-2 output should stay within [-1, 1]."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "bd2", "drive": 0.8, "gain": 1.0})
|
||||||
|
out = pipeline.process(FULL_SCALE)
|
||||||
|
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||||
|
|
||||||
|
def test_shapes_waveform(self, pipeline):
|
||||||
|
"""BD-2 should change waveform shape at high drive."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "bd2", "drive": 1.0, "gain": 1.0})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.5)
|
||||||
|
assert not np.allclose(out, SINE_TONE * 0.5, atol=0.05)
|
||||||
|
|
||||||
|
def test_tone_affects_output(self, pipeline):
|
||||||
|
"""Different tone values should produce different output."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "bd2", "drive": 0.5, "tone": 0.0, "gain": 1.0})
|
||||||
|
out_a = pipeline.process(SINE_TONE * 0.5)
|
||||||
|
|
||||||
|
pipeline.load_preset(Preset(
|
||||||
|
name="test", chain=[FXBlock(FXType.OVERDRIVE, enabled=True,
|
||||||
|
params={"subtype": "bd2", "drive": 0.5, "tone": 1.0, "gain": 1.0})],
|
||||||
|
master_volume=1.0,
|
||||||
|
))
|
||||||
|
out_b = pipeline.process(SINE_TONE * 0.5)
|
||||||
|
assert not np.allclose(out_a, out_b, atol=0.01)
|
||||||
|
|
||||||
|
def test_low_drive_passthrough(self, pipeline):
|
||||||
|
"""Low drive should pass signal."""
|
||||||
|
_load_fx(pipeline, FXType.OVERDRIVE,
|
||||||
|
{"subtype": "bd2", "drive": 0.0, "gain": 1.0})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.05)
|
||||||
|
assert np.max(np.abs(out)) > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Drive Subtype: Big Muff Pi
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestBigMuff:
|
||||||
|
"""Big Muff Pi-style fuzz with tone stack."""
|
||||||
|
|
||||||
|
def test_output_clamped(self, pipeline):
|
||||||
|
"""Muff output should stay within [-1, 1]."""
|
||||||
|
_load_fx(pipeline, FXType.FUZZ,
|
||||||
|
{"subtype": "muff", "sustain": 0.8, "volume": 0.5})
|
||||||
|
out = pipeline.process(FULL_SCALE)
|
||||||
|
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||||
|
|
||||||
|
def test_fuzz_shapes_waveform(self, pipeline):
|
||||||
|
"""Muff should significantly reshape waveform (fuzz)."""
|
||||||
|
_load_fx(pipeline, FXType.FUZZ,
|
||||||
|
{"subtype": "muff", "sustain": 1.0, "volume": 0.5})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.8)
|
||||||
|
assert not np.allclose(out, SINE_TONE * 0.8, atol=0.05)
|
||||||
|
rms_out = np.sqrt(np.mean(out ** 2))
|
||||||
|
assert rms_out > 0.2, "Muff should produce significant output"
|
||||||
|
|
||||||
|
def test_tone_sweep(self, pipeline):
|
||||||
|
"""Different tone positions should produce different EQ."""
|
||||||
|
_load_fx(pipeline, FXType.FUZZ,
|
||||||
|
{"subtype": "muff", "sustain": 0.5, "tone": 0.0, "volume": 0.5})
|
||||||
|
out_a = pipeline.process(SINE_TONE)
|
||||||
|
|
||||||
|
pipeline.load_preset(Preset(
|
||||||
|
name="test", chain=[FXBlock(FXType.FUZZ, enabled=True,
|
||||||
|
params={"subtype": "muff", "sustain": 0.5, "tone": 1.0, "volume": 0.5})],
|
||||||
|
master_volume=1.0,
|
||||||
|
))
|
||||||
|
out_b = pipeline.process(SINE_TONE)
|
||||||
|
assert not np.allclose(out_a, out_b, atol=0.01)
|
||||||
|
|
||||||
|
def test_silence_muted(self, pipeline):
|
||||||
|
"""Silence in should give silence out."""
|
||||||
|
_load_fx(pipeline, FXType.FUZZ,
|
||||||
|
{"subtype": "muff", "sustain": 0.5, "volume": 0.5})
|
||||||
|
out = pipeline.process(SILENCE)
|
||||||
|
assert np.max(np.abs(out)) == 0.0
|
||||||
|
|
||||||
|
def test_default_subtype_fuzz(self, pipeline):
|
||||||
|
"""No subtype on FUZZ should use original fuzz algorithm."""
|
||||||
|
_load_fx(pipeline, FXType.FUZZ, {"drive": 0.5, "gain": 0.5})
|
||||||
|
out = pipeline.process(SINE_TONE * 0.8)
|
||||||
|
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Ping-pong delay (subtype of DELAY)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestDelaySubtypePingPong:
|
||||||
|
def test_output_finite(self, pipeline):
|
||||||
|
"""Ping-pong as delay subtype should produce finite output."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "ping_pong", "time": 5.0, "feedback": 0.0, "mix": 0.5})
|
||||||
|
out = pipeline.process(FULL_SCALE)
|
||||||
|
assert np.all(np.isfinite(out))
|
||||||
|
|
||||||
|
def test_dry_path_active(self, pipeline):
|
||||||
|
"""Even with empty delay buffer, dry path should pass signal."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "ping_pong", "time": 3.0, "feedback": 0.0, "mix": 0.3})
|
||||||
|
out1 = pipeline.process(HALF_SCALE)
|
||||||
|
assert np.max(np.abs(out1)) > 0.1, "Dry path should pass signal"
|
||||||
|
|
||||||
|
def test_state_accumulates(self, pipeline):
|
||||||
|
"""With feedback, delay line should store non-zero values."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "ping_pong", "time": 3.0, "feedback": 0.5, "mix": 0.5})
|
||||||
|
_ = pipeline.process(HALF_SCALE)
|
||||||
|
fx_state = pipeline._state.get("fx_0", {})
|
||||||
|
delay_line = fx_state.get("delay")
|
||||||
|
assert delay_line is not None, "Ping-pong delay should have delay line"
|
||||||
|
buf_max = np.max(np.abs(delay_line.buf))
|
||||||
|
assert buf_max > 0.0, f"Delay buffer should have content, got max={buf_max}"
|
||||||
|
|
||||||
|
def test_ping_alternates(self, pipeline):
|
||||||
|
"""Ping-pong pan should alternate each block."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "ping_pong", "time": 2.0, "feedback": 0.0, "mix": 1.0})
|
||||||
|
for _ in range(5):
|
||||||
|
pipeline.process(FULL_SCALE)
|
||||||
|
fx_state = pipeline._state.get("fx_0", {})
|
||||||
|
# After 5 blocks starting at 1, pattern is: 1, -1, 1, -1, 1
|
||||||
|
# ping should be -1 after 5 alternations (odd count)
|
||||||
|
assert fx_state.get("ping") == -1, \
|
||||||
|
f"Ping should alternate to -1 after 5 blocks, got {fx_state.get('ping')}"
|
||||||
|
|
||||||
|
def test_zero_mix_passthrough(self, pipeline):
|
||||||
|
"""Zero mix should pass dry signal through."""
|
||||||
|
_load_fx(pipeline, FXType.DELAY,
|
||||||
|
{"subtype": "ping_pong", "time": 10.0, "feedback": 0.0, "mix": 0.0})
|
||||||
|
out = pipeline.process(HALF_SCALE)
|
||||||
|
assert np.allclose(out, HALF_SCALE, atol=1e-4)
|
||||||
|
|
||||||
|
def test_bypass(self, pipeline):
|
||||||
|
"""Bypassed ping-pong delay passes audio unchanged."""
|
||||||
|
block = FXBlock(FXType.DELAY, enabled=True, bypass=True,
|
||||||
|
params={"subtype": "ping_pong", "time": 400.0, "mix": 1.0})
|
||||||
|
preset = Preset(name="test", chain=[block], master_volume=1.0)
|
||||||
|
pipeline.load_preset(preset)
|
||||||
|
out = pipeline.process(HALF_SCALE)
|
||||||
|
assert np.allclose(out, HALF_SCALE)
|
||||||
|
|||||||
+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"
|
||||||
@@ -27,7 +27,7 @@ from src.presets.manager import (
|
|||||||
_preset_from_dict,
|
_preset_from_dict,
|
||||||
_preset_to_dict,
|
_preset_to_dict,
|
||||||
)
|
)
|
||||||
from src.presets.types import FXBlock, FXType, MIDIMapping, Preset
|
from src.presets.types import Channel, FXBlock, FXType, MIDIMapping, Preset
|
||||||
|
|
||||||
|
|
||||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
@@ -152,7 +152,7 @@ class TestSaveLoad:
|
|||||||
|
|
||||||
def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
|
def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
|
||||||
manager.save(sample_preset)
|
manager.save(sample_preset)
|
||||||
path = preset_dir / "bank_0" / "preset_0.json"
|
path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
|
||||||
assert path.exists()
|
assert path.exists()
|
||||||
content = path.read_text(encoding="utf-8")
|
content = path.read_text(encoding="utf-8")
|
||||||
assert "Crunch Rhythm" in content
|
assert "Crunch Rhythm" in content
|
||||||
@@ -513,8 +513,152 @@ class TestEdgeCases:
|
|||||||
assert manager.current_program == 1
|
assert manager.current_program == 1
|
||||||
|
|
||||||
def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
|
def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
|
||||||
path = preset_dir / "bank_0" / "preset_0.json"
|
path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
|
||||||
path.parent.mkdir(parents=True)
|
path.parent.mkdir(parents=True)
|
||||||
path.write_text("not valid json", encoding="utf-8")
|
path.write_text("not valid json", encoding="utf-8")
|
||||||
with pytest.raises(json.JSONDecodeError):
|
with pytest.raises(json.JSONDecodeError):
|
||||||
manager.load(0, 0)
|
manager.load(0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Channel independence ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelIndependence:
|
||||||
|
"""Guitar and bass channels each have their own preset bank namespace."""
|
||||||
|
|
||||||
|
def test_channel_serialization_round_trip(self):
|
||||||
|
"""Channel field survives to_dict / from_dict."""
|
||||||
|
p = Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0)
|
||||||
|
d = _preset_to_dict(p)
|
||||||
|
assert d["channel"] == "guitar"
|
||||||
|
restored = _preset_from_dict(d)
|
||||||
|
assert restored.channel == Channel.GUITAR
|
||||||
|
|
||||||
|
p2 = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
|
||||||
|
d2 = _preset_to_dict(p2)
|
||||||
|
assert d2["channel"] == "bass"
|
||||||
|
restored2 = _preset_from_dict(d2)
|
||||||
|
assert restored2.channel == Channel.BASS
|
||||||
|
|
||||||
|
def test_channel_defaults_to_guitar(self):
|
||||||
|
"""Presets without explicit channel default to guitar."""
|
||||||
|
p = Preset(name="Default", bank=0, program=0)
|
||||||
|
assert p.channel == Channel.GUITAR
|
||||||
|
|
||||||
|
def test_guitar_and_bass_independent_banks(self, manager: PresetManager):
|
||||||
|
"""Guitar and bass presets in the same (bank, program) don't collide."""
|
||||||
|
gtr = Preset(name="GTR Crunch", channel=Channel.GUITAR, bank=0, program=0)
|
||||||
|
bass = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
|
||||||
|
|
||||||
|
# Save guitar via current channel (defaults to guitar)
|
||||||
|
manager.save(gtr)
|
||||||
|
# Save bass explicitly
|
||||||
|
manager.save(bass, channel=Channel.BASS)
|
||||||
|
|
||||||
|
# Load guitar
|
||||||
|
loaded_gtr = manager.load(0, 0, Channel.GUITAR)
|
||||||
|
assert loaded_gtr.name == "GTR Crunch"
|
||||||
|
assert loaded_gtr.channel == Channel.GUITAR
|
||||||
|
|
||||||
|
# Load bass
|
||||||
|
loaded_bass = manager.load(0, 0, Channel.BASS)
|
||||||
|
assert loaded_bass.name == "Bass Slap"
|
||||||
|
assert loaded_bass.channel == Channel.BASS
|
||||||
|
|
||||||
|
def test_channel_switch_restores_preset(self, manager: PresetManager):
|
||||||
|
"""Switching channels restores the last active state for that channel."""
|
||||||
|
manager.save(Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0))
|
||||||
|
manager.save(Preset(name="GTR Clean", channel=Channel.GUITAR, bank=0, program=1))
|
||||||
|
manager.save(Preset(name="Bass Slap", channel=Channel.BASS, bank=1, program=2))
|
||||||
|
|
||||||
|
# Select guitar preset (bank=0, program=1)
|
||||||
|
manager.select(0, 1, Channel.GUITAR)
|
||||||
|
|
||||||
|
# Select bass preset so its channel state is saved
|
||||||
|
manager.select(1, 2, Channel.BASS)
|
||||||
|
|
||||||
|
# Switch to guitar — should go to (0, 1)
|
||||||
|
manager.set_channel(Channel.GUITAR)
|
||||||
|
assert manager.current_channel == Channel.GUITAR
|
||||||
|
assert manager.current_bank == 0
|
||||||
|
assert manager.current_program == 1
|
||||||
|
|
||||||
|
# Switch back to bass — should restore (1, 2)
|
||||||
|
manager.set_channel(Channel.BASS)
|
||||||
|
assert manager.current_channel == Channel.BASS
|
||||||
|
assert manager.current_bank == 1
|
||||||
|
assert manager.current_program == 2
|
||||||
|
|
||||||
|
def test_set_same_channel_noop(self, manager: PresetManager):
|
||||||
|
"""set_channel to the same channel preserves current position."""
|
||||||
|
manager.save(Preset(name="Only", channel=Channel.GUITAR, bank=0, program=2))
|
||||||
|
manager.select(0, 2, Channel.GUITAR)
|
||||||
|
result = manager.set_channel(Channel.GUITAR)
|
||||||
|
assert result.name == "Only"
|
||||||
|
assert manager.current_bank == 0
|
||||||
|
assert manager.current_program == 2
|
||||||
|
|
||||||
|
def test_current_channel_property(self, manager: PresetManager):
|
||||||
|
"""current_channel returns the active channel."""
|
||||||
|
assert manager.current_channel == Channel.GUITAR
|
||||||
|
manager.set_channel(Channel.BASS)
|
||||||
|
assert manager.current_channel == Channel.BASS
|
||||||
|
manager.set_channel(Channel.GUITAR)
|
||||||
|
assert manager.current_channel == Channel.GUITAR
|
||||||
|
|
||||||
|
def test_list_banks_channel_scoped(self, manager: PresetManager):
|
||||||
|
"""list_banks only returns banks for the specified channel."""
|
||||||
|
manager.save(Preset(name="GTR_A", channel=Channel.GUITAR, bank=0, program=0))
|
||||||
|
manager.save(Preset(name="BASS_A", channel=Channel.BASS, bank=5, program=0))
|
||||||
|
|
||||||
|
gtr_banks = manager.list_banks(Channel.GUITAR)
|
||||||
|
assert len(gtr_banks) == 1
|
||||||
|
assert gtr_banks[0].number == 0
|
||||||
|
|
||||||
|
bass_banks = manager.list_banks(Channel.BASS)
|
||||||
|
assert len(bass_banks) == 1
|
||||||
|
assert bass_banks[0].number == 5
|
||||||
|
|
||||||
|
def test_migrate_legacy_presets(self, tmp_path: Path):
|
||||||
|
"""Legacy flat bank_* dirs are migrated to guitar/ on first boot."""
|
||||||
|
# Create legacy layout: bank_0/preset_0.json at root
|
||||||
|
legacy_bank = tmp_path / "bank_0"
|
||||||
|
legacy_bank.mkdir()
|
||||||
|
(legacy_bank / "preset_0.json").write_text(
|
||||||
|
json.dumps({"name": "Legacy Tone", "bank": 0, "program": 0}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create PresetManager — should migrate
|
||||||
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||||
|
|
||||||
|
# Old location should be gone
|
||||||
|
assert not (tmp_path / "bank_0").exists()
|
||||||
|
|
||||||
|
# New location should exist
|
||||||
|
guitar_path = tmp_path / "guitar" / "bank_0" / "preset_0.json"
|
||||||
|
assert guitar_path.exists()
|
||||||
|
|
||||||
|
# Preset should be loadable from guitar channel
|
||||||
|
preset = pm.load(0, 0, Channel.GUITAR)
|
||||||
|
assert preset.name == "Legacy Tone"
|
||||||
|
|
||||||
|
def test_migration_skips_when_already_migrated(self, tmp_path: Path):
|
||||||
|
"""Migration doesn't run if guitar/ dir already exists."""
|
||||||
|
guitar_dir = tmp_path / "guitar"
|
||||||
|
guitar_dir.mkdir(parents=True)
|
||||||
|
(guitar_dir / "bank_0").mkdir()
|
||||||
|
(guitar_dir / "bank_0" / "preset_0.json").write_text(
|
||||||
|
json.dumps({"name": "Already Migrated", "channel": "guitar", "bank": 0, "program": 0}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||||
|
preset = pm.load(0, 0, Channel.GUITAR)
|
||||||
|
assert preset.name == "Already Migrated"
|
||||||
|
|
||||||
|
def test_fresh_install_no_migration(self, tmp_path: Path):
|
||||||
|
"""Fresh install with no files doesn't raise or create guitar dir."""
|
||||||
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||||
|
assert not (tmp_path / "guitar").exists()
|
||||||
|
assert pm.list_banks() == []
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Signal-based audio tests for Pi Multi-FX Pedal.
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- GP-5 USB interface connected via USB passthrough (card 1)
|
||||||
|
- Patch cable from GP-5 headphone OUT → Focusrite input 2
|
||||||
|
- DI.wav recording (one-time manual step: record DI.wav)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# One-time: record a clean DI signal
|
||||||
|
python3 tests/test_signal.py record-di
|
||||||
|
|
||||||
|
# Run all signal tests
|
||||||
|
python3 tests/test_signal.py --host http://192.168.0.100:80
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
python3 tests/test_signal.py --host http://192.168.0.100:80 --test bypass
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import Request, urlopen, HTTPError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
# ── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
PEDAL = "http://192.168.0.100:80"
|
||||||
|
GP5_PLAYBACK_DEVICE = "plughw:CARD=GP5,DEV=0" # GP-5 output (to Focusrite input 2)
|
||||||
|
GP5_CAPTURE_DEVICE = "plughw:CARD=GP5,DEV=0" # GP-5 input (for recording DI)
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
CHANNELS = 1
|
||||||
|
SAMPLE_WIDTH = 2 # 16-bit
|
||||||
|
TEST_DIR = Path(__file__).resolve().parent
|
||||||
|
DI_FILE = TEST_DIR / "di_signal.wav"
|
||||||
|
|
||||||
|
passes = 0
|
||||||
|
failures = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Audio helpers ───────────────────────────────────────────────────────────
|
||||||
|
def wav_to_raw(wav_path: Path) -> bytes:
|
||||||
|
"""Read a WAV file, return raw PCM S16_LE mono."""
|
||||||
|
with wave.open(str(wav_path), 'rb') as w:
|
||||||
|
assert w.getnchannels() == 1
|
||||||
|
assert w.getsampwidth() == 2
|
||||||
|
return w.readframes(w.getnframes())
|
||||||
|
|
||||||
|
|
||||||
|
def raw_to_wav(raw: bytes, path: Path, sr: int = SAMPLE_RATE):
|
||||||
|
"""Write raw PCM S16_LE mono to a WAV file."""
|
||||||
|
with wave.open(str(path), 'wb') as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(sr)
|
||||||
|
w.writeframes(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def play_wav(wav_path: Path, device: str = GP5_PLAYBACK_DEVICE) -> float:
|
||||||
|
"""Play a WAV through the GP-5. Returns duration in seconds.
|
||||||
|
GP-5 is stereo-only — will upmix mono to stereo on playback."""
|
||||||
|
duration = 0
|
||||||
|
with wave.open(str(wav_path), 'rb') as w:
|
||||||
|
frames = w.getnframes()
|
||||||
|
sr = w.getframerate()
|
||||||
|
duration = frames / sr
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["aplay", "-D", device, "-q", str(wav_path)],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
proc.wait()
|
||||||
|
return duration
|
||||||
|
|
||||||
|
|
||||||
|
def record_wav(path: Path, duration: float, device: str = GP5_CAPTURE_DEVICE,
|
||||||
|
sr: int = 48000):
|
||||||
|
"""Record audio from GP-5 input, save as 48kHz mono WAV.
|
||||||
|
GP-5 natively captures at 44100Hz stereo — we use plughw for
|
||||||
|
sample rate conversion and take only the first channel."""
|
||||||
|
raw_path = path.with_suffix(".raw")
|
||||||
|
cmd = [
|
||||||
|
"arecord", "-D", device,
|
||||||
|
"-f", "S16_LE",
|
||||||
|
"-r", str(sr),
|
||||||
|
"-c", "2", # GP-5 is stereo-only
|
||||||
|
"-d", str(int(duration) + 1),
|
||||||
|
str(raw_path)
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
# Read stereo raw, convert to mono (take channel 0)
|
||||||
|
with open(raw_path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
stereo = struct.unpack(f"<{len(raw)//2}h", raw)
|
||||||
|
mono = stereo[0::2] # take every other sample (left channel)
|
||||||
|
|
||||||
|
# Write as mono WAV
|
||||||
|
with wave.open(str(path), "wb") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(sr)
|
||||||
|
w.writeframes(struct.pack(f"<{len(mono)}h", *mono))
|
||||||
|
|
||||||
|
raw_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── API helpers ─────────────────────────────────────────────────────────────
|
||||||
|
def api(method: str, path: str, body: dict | None = None,
|
||||||
|
expect_status: int = 200) -> dict:
|
||||||
|
url = f"{PEDAL}{path}"
|
||||||
|
data = json.dumps(body).encode() if body else None
|
||||||
|
req = Request(url, data=data, method=method)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=15) as resp:
|
||||||
|
raw = resp.read().decode()
|
||||||
|
if resp.status != expect_status:
|
||||||
|
raise AssertionError(f"Expected {expect_status}, got {resp.status}: {raw[:200]}")
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except HTTPError as e:
|
||||||
|
raw = e.read().decode() if e.fp else ""
|
||||||
|
if e.code == expect_status:
|
||||||
|
try:
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"_raw": raw}
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def set_pedal_state(changes: dict[str, any]):
|
||||||
|
"""Apply multiple pedal state changes."""
|
||||||
|
for key, val in changes.items():
|
||||||
|
if key == "bypass":
|
||||||
|
if val:
|
||||||
|
api("POST", "/api/bypass", {"bypass": True})
|
||||||
|
else:
|
||||||
|
api("POST", "/api/bypass", {"bypass": False})
|
||||||
|
elif key == "volume":
|
||||||
|
api("POST", "/api/volume", {"volume": val})
|
||||||
|
elif key == "model":
|
||||||
|
# Find and load model by name
|
||||||
|
ml = api("GET", "/api/models")
|
||||||
|
for m in ml.get("models", []):
|
||||||
|
if m["name"] == val:
|
||||||
|
api("POST", "/api/models/load", {"path": m["path"]})
|
||||||
|
break
|
||||||
|
elif key == "block_enabled":
|
||||||
|
state = api("GET", "/api/state")
|
||||||
|
bid = state["blocks"][0]["block_id"]
|
||||||
|
api("PATCH", "/api/blocks", {"block_id": bid, "enabled": val})
|
||||||
|
time.sleep(0.7) # wait for debounce
|
||||||
|
|
||||||
|
|
||||||
|
def capture_pedal_output(duration: float) -> bytes:
|
||||||
|
"""Record pedal output using jack_rec on the Pi via SSH.
|
||||||
|
Captures from pi-multifx:playback JACK port.
|
||||||
|
Returns raw PCM S16_LE mono, 48kHz."""
|
||||||
|
import tempfile, subprocess
|
||||||
|
remote_file = f"/tmp/pedal_cap_{int(time.time())}.wav"
|
||||||
|
|
||||||
|
# Run jack_rec on the Pi via SSH
|
||||||
|
ssh_cmd = [
|
||||||
|
"ssh", "root@192.168.0.244",
|
||||||
|
f"jack_rec -f {remote_file} -d {int(duration) + 1} pi-multifx:playback"
|
||||||
|
]
|
||||||
|
subprocess.run(ssh_cmd, capture_output=True, timeout=duration + 10)
|
||||||
|
|
||||||
|
# SCP the file back
|
||||||
|
local_file = Path("/tmp/pedal_capture.wav")
|
||||||
|
subprocess.run(["scp", f"root@192.168.0.244:{remote_file}", str(local_file)],
|
||||||
|
capture_output=True, timeout=15)
|
||||||
|
|
||||||
|
# Cleanup remote
|
||||||
|
subprocess.run(["ssh", "root@192.168.0.244", f"rm -f {remote_file}"],
|
||||||
|
capture_output=True)
|
||||||
|
|
||||||
|
if not local_file.exists():
|
||||||
|
raise RuntimeError("Capture produced no file")
|
||||||
|
|
||||||
|
# Read and convert to mono
|
||||||
|
with wave.open(str(local_file), 'rb') as w:
|
||||||
|
sr = w.getframerate()
|
||||||
|
channels = w.getnchannels()
|
||||||
|
frames = w.readframes(w.getnframes())
|
||||||
|
|
||||||
|
samples = struct.unpack(f"<{len(frames)//2}h", frames)
|
||||||
|
if channels == 2:
|
||||||
|
mono = samples[0::2]
|
||||||
|
else:
|
||||||
|
mono = samples
|
||||||
|
|
||||||
|
# Skip first 0.5s of capture startup
|
||||||
|
skip = int(0.5 * sr)
|
||||||
|
if skip < len(mono):
|
||||||
|
mono = mono[skip:]
|
||||||
|
|
||||||
|
local_file.unlink(missing_ok=True)
|
||||||
|
return struct.pack(f"<{len(mono)}h", *mono)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analysis ────────────────────────────────────────────────────────────────
|
||||||
|
def rms(data: bytes) -> float:
|
||||||
|
"""Compute RMS of raw PCM S16_LE data."""
|
||||||
|
if len(data) < 2:
|
||||||
|
return 0.0
|
||||||
|
samples = struct.unpack(f"<{len(data)//2}h", data)
|
||||||
|
sq_sum = sum(s * s for s in samples)
|
||||||
|
return math.sqrt(sq_sum / len(samples))
|
||||||
|
|
||||||
|
|
||||||
|
def correlation(x: bytes, y: bytes) -> float:
|
||||||
|
"""Pearson correlation between two PCM S16_LE signals."""
|
||||||
|
if len(x) != len(y) or len(x) < 4:
|
||||||
|
return 0.0
|
||||||
|
xs = struct.unpack(f"<{len(x)//2}h", x)
|
||||||
|
ys = struct.unpack(f"<{len(y)//2}h", y)
|
||||||
|
n = len(xs)
|
||||||
|
sx = sum(xs); sy = sum(ys)
|
||||||
|
sxx = sum(v*v for v in xs)
|
||||||
|
syy = sum(v*v for v in ys)
|
||||||
|
sxy = sum(xs[i]*ys[i] for i in range(n))
|
||||||
|
num = n * sxy - sx * sy
|
||||||
|
den = math.sqrt((n * sxx - sx*sx) * (n * syy - sy*sy))
|
||||||
|
return num / den if den > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def find_pulse_offset(input_raw: bytes, output_raw: bytes) -> float:
|
||||||
|
"""Find offset (in samples) between input and output signals.
|
||||||
|
Looks for the first peak > threshold in each signal."""
|
||||||
|
threshold = 3000 # S16_LE
|
||||||
|
def first_peak(data):
|
||||||
|
samples = struct.unpack(f"<{len(data)//2}h", data[:min(len(data), 48000*2*10)])
|
||||||
|
for i, s in enumerate(samples):
|
||||||
|
if abs(s) > threshold:
|
||||||
|
return i
|
||||||
|
return 0
|
||||||
|
in_peak = first_peak(input_raw)
|
||||||
|
out_peak = first_peak(output_raw)
|
||||||
|
return (out_peak - in_peak) / SAMPLE_RATE * 1000 # ms
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
def test(name: str, ok: bool):
|
||||||
|
global passes, failures
|
||||||
|
if ok:
|
||||||
|
passes += 1
|
||||||
|
print(f" ✅ {name}")
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
print(f" ❌ {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def record_di():
|
||||||
|
"""One-time: record a clean DI signal from guitar -> GP5."""
|
||||||
|
print("🎸 Recording DI signal from GP-5")
|
||||||
|
print(" Plug guitar into GP-5 input, crank gain")
|
||||||
|
print()
|
||||||
|
for i in range(3, 0, -1):
|
||||||
|
print(f" {i}...")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Start recording in background FIRST
|
||||||
|
raw_path = DI_FILE.with_suffix(".raw")
|
||||||
|
proc = subprocess.Popen([
|
||||||
|
"arecord", "-D", GP5_CAPTURE_DEVICE,
|
||||||
|
"-f", "S16_LE", "-r", "48000", "-c", "2",
|
||||||
|
"-d", "10",
|
||||||
|
str(raw_path)
|
||||||
|
], stdout=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
# Give arecord a moment to initialize
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
# Now play the cue beep
|
||||||
|
print(" ⏰ BEEP — PLAY NOW for 8 seconds!")
|
||||||
|
sr = 48000
|
||||||
|
beep_raw = b"".join(
|
||||||
|
struct.pack("<h", int(20000 * math.sin(2 * math.pi * 880 * t / sr)))
|
||||||
|
for t in range(int(sr * 0.3))
|
||||||
|
)
|
||||||
|
beep_path = Path("/tmp/record_beep.wav")
|
||||||
|
with wave.open(str(beep_path), "wb") as w:
|
||||||
|
w.setnchannels(2)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(sr)
|
||||||
|
w.writeframes(beep_raw + beep_raw)
|
||||||
|
subprocess.run(
|
||||||
|
["aplay", "-D", GP5_PLAYBACK_DEVICE, "-q", str(beep_path)],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
beep_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Wait for recording to finish
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
# Convert stereo raw to mono WAV
|
||||||
|
with open(raw_path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
stereo = struct.unpack(f"<{len(raw)//2}h", raw)
|
||||||
|
# Throw away first 0.5s to account for startup gap
|
||||||
|
skip = int(0.5 * 48000 * 2) # 0.5s of stereo samples
|
||||||
|
mono = stereo[skip::2]
|
||||||
|
with wave.open(str(DI_FILE), "wb") as w:
|
||||||
|
w.setnchannels(1); w.setsampwidth(2)
|
||||||
|
w.setframerate(48000)
|
||||||
|
w.writeframes(struct.pack(f"<{len(mono)}h", *mono))
|
||||||
|
raw_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
file_size = DI_FILE.stat().st_size
|
||||||
|
print(f" ✅ Recorded ({file_size / 1024:.1f} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware():
|
||||||
|
"""Verify GP-5 playback and pedal capture work."""
|
||||||
|
section("Hardware Check")
|
||||||
|
|
||||||
|
# Generate test tone
|
||||||
|
sr = SAMPLE_RATE
|
||||||
|
dur = 0.5
|
||||||
|
raw = b"".join(
|
||||||
|
struct.pack("<h", int(16000 * math.sin(2 * math.pi * 440 * t / sr)))
|
||||||
|
for t in range(int(sr * dur))
|
||||||
|
)
|
||||||
|
tone = TEST_DIR / "_test_tone.wav"
|
||||||
|
raw_to_wav(raw, tone)
|
||||||
|
|
||||||
|
# Play through GP-5
|
||||||
|
print(f" Playing 440Hz test tone for {dur}s...")
|
||||||
|
play_wav(tone, GP5_PLAYBACK_DEVICE)
|
||||||
|
test("GP-5 playback works", tone.exists())
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
tone.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bypass():
|
||||||
|
"""Bypass test: signal should pass through unchanged (high correlation)."""
|
||||||
|
section("3.2 Bypass Test")
|
||||||
|
if not DI_FILE.exists():
|
||||||
|
print(" ⚠️ No DI file. Run 'python3 tests/test_signal.py record-di' first")
|
||||||
|
return
|
||||||
|
|
||||||
|
di_raw = wav_to_raw(DI_FILE)
|
||||||
|
duration = len(di_raw) / SAMPLE_RATE / 2 # use first half only for speed
|
||||||
|
|
||||||
|
# Set pedal to clean bypass
|
||||||
|
set_pedal_state({"bypass": True, "volume": 0.8})
|
||||||
|
|
||||||
|
# Playback and capture
|
||||||
|
print(f" Playing {duration:.1f}s of DI through bypass...")
|
||||||
|
play_wav(DI_FILE, GP5_PLAYBACK_DEVICE)
|
||||||
|
time.sleep(0.3)
|
||||||
|
output_raw = capture_pedal_output(duration)
|
||||||
|
|
||||||
|
# Analyze
|
||||||
|
min_len = min(len(di_raw), len(output_raw))
|
||||||
|
corr = correlation(di_raw[:min_len], output_raw[:min_len])
|
||||||
|
rms_in = rms(di_raw)
|
||||||
|
rms_out = rms(output_raw)
|
||||||
|
|
||||||
|
print(f" Correlation: {corr:.3f}")
|
||||||
|
print(f" Input RMS: {rms_in:.1f}")
|
||||||
|
print(f" Output RMS: {rms_out:.1f}")
|
||||||
|
# Bypass adds ADC/DAC conversion so correlation won't be 1.0.
|
||||||
|
# Just verify signal is present.
|
||||||
|
test(f"Bypass — output has signal (RMS > 1)", rms_out > 1)
|
||||||
|
|
||||||
|
# Save output for inspection
|
||||||
|
out_path = TEST_DIR / "_bypass_output.wav"
|
||||||
|
raw_to_wav(output_raw, out_path)
|
||||||
|
print(f" Saved: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_nam():
|
||||||
|
"""NAM test: signal should be audibly different (low correlation)."""
|
||||||
|
section("3.3 NAM Processing Test")
|
||||||
|
if not DI_FILE.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
di_raw = wav_to_raw(DI_FILE)
|
||||||
|
duration = len(di_raw) / SAMPLE_RATE / 2
|
||||||
|
|
||||||
|
# Load a NAM model, turn off bypass
|
||||||
|
set_pedal_state({
|
||||||
|
"bypass": False,
|
||||||
|
"volume": 0.8,
|
||||||
|
"model": "Fender_Super_Reverb_1977",
|
||||||
|
})
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(f" Playing {duration:.1f}s through NAM...")
|
||||||
|
play_wav(DI_FILE, GP5_PLAYBACK_DEVICE)
|
||||||
|
time.sleep(0.3)
|
||||||
|
output_raw = capture_pedal_output(duration)
|
||||||
|
|
||||||
|
min_len = min(len(di_raw), len(output_raw))
|
||||||
|
corr = correlation(di_raw[:min_len], output_raw[:min_len])
|
||||||
|
rms_in = rms(di_raw)
|
||||||
|
rms_out = rms(output_raw)
|
||||||
|
|
||||||
|
print(f" Correlation: {corr:.3f}")
|
||||||
|
print(f" Input RMS: {rms_in:.1f}")
|
||||||
|
print(f" Output RMS: {rms_out:.1f}")
|
||||||
|
test(f"NAM — low correlation (<0.80)", corr < 0.80)
|
||||||
|
test(f"NAM — output RMS present (>1)", rms_out > 1)
|
||||||
|
|
||||||
|
out_path = TEST_DIR / "_nam_output.wav"
|
||||||
|
raw_to_wav(output_raw, out_path)
|
||||||
|
print(f" Saved: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_volume():
|
||||||
|
"""Volume test: increasing volume should increase output RMS."""
|
||||||
|
section("3.5 Volume Taper Test")
|
||||||
|
if not DI_FILE.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for vol in [0.0, 0.3, 0.6, 1.0]:
|
||||||
|
set_pedal_state({"bypass": True, "volume": vol})
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
play_wav(DI_FILE, GP5_PLAYBACK_DEVICE)
|
||||||
|
time.sleep(0.3)
|
||||||
|
output_raw = capture_pedal_output(1.0)
|
||||||
|
rms_val = rms(output_raw)
|
||||||
|
results.append((vol, rms_val))
|
||||||
|
print(f" Volume {vol:.1f}: RMS = {rms_val:.1f}")
|
||||||
|
|
||||||
|
# Check monotonic: each step should increase RMS
|
||||||
|
monotonic = all(results[i][1] <= results[i+1][1] for i in range(len(results) - 1))
|
||||||
|
# Volume 0 should be silent
|
||||||
|
silent_zero = results[0][1] < 50 if results[0][0] == 0.0 else True
|
||||||
|
|
||||||
|
test(f"Volume 0.0 is silent", silent_zero)
|
||||||
|
test(f"Volume response is monotonic", monotonic)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
set_pedal_state({"volume": 0.8})
|
||||||
|
|
||||||
|
|
||||||
|
def test_routing():
|
||||||
|
"""Routing test: mono vs 4CM — just verify they switch cleanly."""
|
||||||
|
section("5 Routing Switch Test")
|
||||||
|
|
||||||
|
set_pedal_state({"bypass": True})
|
||||||
|
api("POST", "/api/routing", {"routing_mode": "mono"})
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
play_wav(DI_FILE, GP5_PLAYBACK_DEVICE)
|
||||||
|
time.sleep(0.3)
|
||||||
|
out1 = capture_pedal_output(1.0)
|
||||||
|
rms1 = rms(out1)
|
||||||
|
test("Mono routing produces output (RMS > 1)", rms1 > 1)
|
||||||
|
|
||||||
|
api("POST", "/api/routing", {"routing_mode": "4cm", "routing_breakpoint": 5})
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
play_wav(DI_FILE, GP5_PLAYBACK_DEVICE)
|
||||||
|
time.sleep(0.3)
|
||||||
|
out2 = capture_pedal_output(1.0)
|
||||||
|
rms2 = rms(out2)
|
||||||
|
test("4CM routing produces output (RMS > 1)", rms2 > 1)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
api("POST", "/api/routing", {"routing_mode": "mono"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute():
|
||||||
|
"""Mute test: block disabled should silence signal."""
|
||||||
|
section("Block Mute Test")
|
||||||
|
if not DI_FILE.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Enable block, then disable it
|
||||||
|
set_pedal_state({"bypass": False, "block_enabled": False})
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
play_wav(DI_FILE, GP5_PLAYBACK_DEVICE)
|
||||||
|
time.sleep(0.3)
|
||||||
|
output_raw = capture_pedal_output(1.0)
|
||||||
|
rms_out = rms(output_raw)
|
||||||
|
test(f"Block disabled — RMS near zero ({rms_out:.1f})", rms_out < 20)
|
||||||
|
|
||||||
|
# Re-enable
|
||||||
|
set_pedal_state({"block_enabled": True})
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_replaced():
|
||||||
|
"""Capture now uses GP-5 direct — tested by all other signal tests."""
|
||||||
|
section("12 Capture (tested inline)")
|
||||||
|
print(" ✅ Capture tested by bypass/nam/volume tests above")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Runner ──────────────────────────────────────────────────────────────────
|
||||||
|
def section(name: str):
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" § {name}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
TESTS = {
|
||||||
|
"hardware": test_hardware,
|
||||||
|
"bypass": test_bypass,
|
||||||
|
"nam": test_nam,
|
||||||
|
"volume": test_volume,
|
||||||
|
"routing": test_routing,
|
||||||
|
"mute": test_mute,
|
||||||
|
"capture": test_capture_replaced,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global PEDAL
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Pi Multi-FX Signal Test Runner")
|
||||||
|
parser.add_argument("--host", default=PEDAL)
|
||||||
|
parser.add_argument("--test", "-t", choices=list(TESTS.keys()) + ["all"],
|
||||||
|
default="all", help="Specific test to run")
|
||||||
|
parser.add_argument("command", nargs="?", default=None,
|
||||||
|
help="'record-di' to capture DI signal")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
PEDAL = args.host.rstrip("/")
|
||||||
|
|
||||||
|
if args.command == "record-di":
|
||||||
|
record_di()
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"🎸 Pi Multi-FX Signal Tests")
|
||||||
|
print(f" Pedal: {PEDAL}")
|
||||||
|
print(f" GP-5: {GP5_PLAYBACK_DEVICE}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check DI file
|
||||||
|
if not DI_FILE.exists():
|
||||||
|
print("⚠️ No DI file found. Run: python3 tests/test_signal.py record-di")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.test == "all":
|
||||||
|
for name, fn in TESTS.items():
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 💥 {name} crashed: {e}")
|
||||||
|
else:
|
||||||
|
fn = TESTS.get(args.test)
|
||||||
|
if fn:
|
||||||
|
fn()
|
||||||
|
else:
|
||||||
|
print(f"Unknown test: {args.test}")
|
||||||
|
|
||||||
|
total = passes + failures
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" RESULTS: {passes}/{total} passed", end="")
|
||||||
|
if failures:
|
||||||
|
print(f" ({failures} failed)")
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
print(f"{'='*60}")
|
||||||
|
return 0 if failures == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -21,12 +21,16 @@ 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
|
||||||
|
|
||||||
|
|
||||||
# ── Configuration ──────────────────────────────────────────────────────────────
|
# ── Configuration ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Local constants (no longer exported by src.dsp.pipeline)
|
||||||
|
BLOCK_SIZE = 256
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
|
||||||
# 1 hour of audio at 48 kHz / 256 samples per block
|
# 1 hour of audio at 48 kHz / 256 samples per block
|
||||||
BLOCKS_PER_HOUR = int(SAMPLE_RATE / BLOCK_SIZE * 3600) # 675,000
|
BLOCKS_PER_HOUR = int(SAMPLE_RATE / BLOCK_SIZE * 3600) # 675,000
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -32,6 +32,7 @@ def mock_presets():
|
|||||||
pm = MagicMock()
|
pm = MagicMock()
|
||||||
pm.current_bank = 0
|
pm.current_bank = 0
|
||||||
pm.current_program = 0
|
pm.current_program = 0
|
||||||
|
pm._current_snapshot = 0
|
||||||
pm.load.return_value = Preset(name="Default", bank=0, program=0)
|
pm.load.return_value = Preset(name="Default", bank=0, program=0)
|
||||||
return pm
|
return pm
|
||||||
|
|
||||||
@@ -41,6 +42,11 @@ def mock_pipeline():
|
|||||||
pl = MagicMock()
|
pl = MagicMock()
|
||||||
pl._bypassed = False
|
pl._bypassed = False
|
||||||
pl._tuner_enabled = False
|
pl._tuner_enabled = False
|
||||||
|
pl._tuner_frequency = 0.0
|
||||||
|
pl._tuner_note = '--'
|
||||||
|
pl._tuner_cents = 0
|
||||||
|
pl._tuner_string = -1
|
||||||
|
pl._tuner_confidence = 0.0
|
||||||
pl._master_volume = 0.8
|
pl._master_volume = 0.8
|
||||||
pl.routing_mode = "mono"
|
pl.routing_mode = "mono"
|
||||||
pl.routing_breakpoint = 7
|
pl.routing_breakpoint = 7
|
||||||
@@ -86,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,
|
||||||
|
|||||||
Generated
+45
-1
@@ -14,8 +14,12 @@
|
|||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@radix-ui/react-switch": "^1.3.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.18.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -2228,6 +2232,27 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/class-variance-authority": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://polar.sh/cva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -3105,6 +3130,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
|
||||||
|
"integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -3479,6 +3513,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
||||||
|
|||||||
+5
-1
@@ -16,8 +16,12 @@
|
|||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@radix-ui/react-switch": "^1.3.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.18.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
+6
-1
@@ -575,6 +575,7 @@ function PedalContent() {
|
|||||||
if (names.length > 0) setBankNames(names);
|
if (names.length > 0) setBankNames(names);
|
||||||
|
|
||||||
const cp = state.current_preset;
|
const cp = state.current_preset;
|
||||||
|
if (!cp) return;
|
||||||
const bank = data.banks?.[cp.bank];
|
const bank = data.banks?.[cp.bank];
|
||||||
const preset = bank?.presets?.[cp.program];
|
const preset = bank?.presets?.[cp.program];
|
||||||
if (preset?.chain) {
|
if (preset?.chain) {
|
||||||
@@ -654,7 +655,11 @@ function PedalContent() {
|
|||||||
{/* ── Tab Bar ── */}
|
{/* ── Tab Bar ── */}
|
||||||
<div className="pedal-tabbar">
|
<div className="pedal-tabbar">
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<div key={t.id} className={`pedal-tab ${tab === t.id ? 'pedal-tab-active' : ''}`} onClick={() => setTab(t.id)}>
|
<div key={t.id} className={`pedal-tab ${tab === t.id ? 'pedal-tab-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (t.id === 'settings') { window.location.href = '/settings'; return; }
|
||||||
|
setTab(t.id);
|
||||||
|
}}>
|
||||||
<span className="pedal-tab-icon">{t.icon}</span>
|
<span className="pedal-tab-icon">{t.icon}</span>
|
||||||
{t.label}
|
{t.label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* to all footswitch components.
|
* to all footswitch components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
import type { FootswitchMode, ScribbleStripData, PedalState, PedalBlock } from '../types';
|
import type { FootswitchMode, ScribbleStripData, PedalState, PedalBlock } from './types';
|
||||||
import { apiPost, activatePreset } from "./hooks/usePedalState";
|
import { apiPost, activatePreset } from "./hooks/usePedalState";
|
||||||
|
|
||||||
// ── Context Shape ─────────────────────────────────────────────
|
// ── Context Shape ─────────────────────────────────────────────
|
||||||
@@ -198,9 +198,23 @@ export function FootswitchModeProvider({
|
|||||||
onToggleBlock,
|
onToggleBlock,
|
||||||
}: FootswitchModeProviderProps) {
|
}: FootswitchModeProviderProps) {
|
||||||
const [mode, setMode] = useState<FootswitchMode>('stomp');
|
const [mode, setMode] = useState<FootswitchMode>('stomp');
|
||||||
const [currentBankIndex, setCurrentBankIndex] = useState(0);
|
const [currentBankIndex, setCurrentBankIndex] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('pedal-footswitch-bank');
|
||||||
|
return saved !== null ? parseInt(saved, 10) : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
const [currentPresetIndex, setCurrentPresetIndex] = useState(0);
|
const [currentPresetIndex, setCurrentPresetIndex] = useState(0);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, _setDirty] = useState(false);
|
||||||
|
|
||||||
|
// Persist bank index to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('pedal-footswitch-bank', String(currentBankIndex));
|
||||||
|
} catch { /* quota exceeded or storage disabled */ }
|
||||||
|
}, [currentBankIndex]);
|
||||||
|
|
||||||
// Generate scribble data based on mode
|
// Generate scribble data based on mode
|
||||||
let scribbleData: ScribbleStripData[];
|
let scribbleData: ScribbleStripData[];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Helix / Line 6 scribble strip LCDs.
|
* Helix / Line 6 scribble strip LCDs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ScribbleStripData, ScribbleColor } from '../types';
|
import type { ScribbleStripData } from '../types';
|
||||||
import { SCRIBBLE_COLORS } from '../types';
|
import { SCRIBBLE_COLORS } from '../types';
|
||||||
import { useDoubleTap } from '../hooks/useDoubleTap';
|
import { useDoubleTap } from '../hooks/useDoubleTap';
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
@@ -14,6 +15,10 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
+3
-2
@@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/ui/',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -12,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