Build IR convolution engine

- Full FFT overlap-add IR convolution in IRLoader (process(), set_mix(), toggle)
- Lazy FFT computation — IR FFT padded to correct block+ir size on first process()
- Wet/dry mix control, enabled/disabled toggle with tail clearing
- Fixed pipeline._apply_ir_cab() to delegate to IRLoader.process() instead of
  poking internals (old code had array-size mismatch bug: IR FFT at ir_len vs
  block FFT at conv_size)
- 46 tests: loading, convolution correctness, overlap-add state, mix, toggle,
  directory listing, performance budget (all <5ms even at 8192 taps), edge cases
- scripts/download_irs.sh: free IR pack downloader (God's Cab, Seacow)
This commit is contained in:
2026-06-07 23:46:02 -04:00
parent c38a7b0fd8
commit 0e77adb4c3
10 changed files with 1939 additions and 498 deletions
+161
View File
@@ -0,0 +1,161 @@
# NAM A2 / A1 Inference on RPi 4B — Structured Research Findings
**Date:** 2026-06-07
**Project:** pi-multifx-pedal
**Context:** BLOCK_SIZE=256, SAMPLE_RATE=48000, NAM v0.13.0 installed
---
## 1. NAM LV2 Plugin on aarch64
**Repository:** https://github.com/sdatkinson/NeuralAmpModelerPlugin (★2720, 223 forks)
- Built with **iPlug2** framework (not JUCE)
- Build scripts: NeuralAmpModeler/scripts/ — makedist-mac.sh, makedist-win.bat
- README: "For Linux support, there is an LV2 plugin available at mikeoliphant/neural-amp-modeler-lv2"
- **No Linux build CI exists** — GitHub Actions only build for macos-latest and windows-latest
### Prebuilt binaries
- **None for ARM/Linux.** All releases (v0.7.6-v0.7.15) ship only macOS .dmg and Windows .zip. No Linux, no ARM.
### Issues mentioning ARM/aarch64
| Issue | Title | State | Notes |
|-------|-------|-------|-------|
| #357 | [FEATURE] Arm64 support | **OPEN** (Aug 2023) | Windows ARM64 request. Maintainer asked for specifics. No resolution in 3+ years. |
| #587 | NAM Setup — Windows 11 on ARM | CLOSED | Setup packaging issue, not cross-compile. |
| #627 | fix: avoid audio-thread stalls (AU) | CLOSED | "arm" incidental in body (part of "harm"). |
**Conclusion: No official aarch64 support for the NAM plugin. Issue #357 has sat open for 3+ years. The iPlug2-based plugin is not designed for cross-compilation to aarch64.**
### The LV2 option (NeuralAudio-based)
- https://github.com/mikeoliphant/neural-amp-modeler-lv2 (★460, 50 forks)
- Uses **NeuralAudio** C++ engine (not iPlug2)
- Supports NAM A1 + A2, WaveNet + LSTM + ConvNet
- **NeuralAudio README explicitly claims**: "internal implementation outperforms NAM Core on all tested platforms (Windows x64, Linux x64/Arm64)"
- Build: cmake .. -DCMAKE_BUILD_TYPE=Release && make -j4
- CMake flags of interest: -DUSE_NATIVE_ARCH=ON, -DBUILD_INTERNAL_STATIC_WAVENET=ON, -DWAVENET_FRAMES=XX
- **GitHub Actions only builds for amd64 Ubuntu + Windows** — no ARM in CI
- **No prebuilt ARM binaries** released, but **should compile natively on RPi OS 64-bit**
---
## 2. NAMR (Rust NAM)
The URL `github.com/mikeoliphant/neural-amp-modeler-rs` **does not exist (404)**.
### a) OpenSauce/nam-rs (Pure Rust library crate)
- **https://github.com/OpenSauce/nam-rs** (June 2026, very new, ★0)
- Pure Rust, **no external C/C++ dependencies**
- Supports: WaveNet (A1 + A2 single), LSTM, SlimmableContainer (NAM A2)
- **aarch64:** Supported via Cargo, verified target aarch64-unknown-linux-gnu
- Has **dedicated RPi performance analysis** in issues:
- Issue #23: "Raspberry Pi / embedded support: run the A1 WaveNet presets real-time on aarch64"
- Concluded: A2 SlimmableContainer is the CPU/quality dial, superseding bespoke specialization
- **Performance (x86, standard WaveNet, 512 samples):** ~951us for process_buffer
- **Estimated Pi 4 factor:** 4-8x slower
- At 256 samples (our project): ~1.9-3.8ms standard; Feather ~0.5-1ms
- Published on crates.io as `nam-rs`. MIT license.
### b) fabiohl/nam-rs (Standalone + CLAP plugin)
- **https://github.com/fabiohl/nam-rs** (★4, April 2026, v1.7.0)
- **x86-64-v3 only** (AVX2+FMA mandatory). **No aarch64 support.**
- Standalone PipeWire + CLAP plugin. Explicitly no LV2.
- Apache-2.0 license.
### Is Rust NAM faster than Python/PyTorch?
**Yes.** Compiled Rust/C++ avoids Python interpreter overhead (~50-200us per call), PyTorch tensor dispatch, dynamic graph construction, and GC pauses. OpenSauce/nam-rs on x86: ~951us for 512-sample standard. Python equivalent via `nam` library is conservatively 3-5x slower.
---
## 3. ONNX Runtime Inference
### NAM ONNX Export Status (v0.13.0)
- nam.models.exportable.Exportable has export_onnx() method
- **ALL four model architectures (ConvNet, WaveNet, Linear, LSTM) raise NotImplementedError**
- It is a stub only — no implementation exists in any class
- The working export path is .nam JSON format via export() (not ONNX)
- **ONNX export does not exist in NAM v0.13.0**
### Environment
- PyTorch 2.12.0+cpu (installed)
- ONNX Runtime 1.26.0 (installed but not usable without export pathway)
- ONNX package not installed
### aarch64 ONNX Runtime
Even if ONNX export were available: ONNX Runtime on aarch64 uses NEON SIMD + Arm Compute Library, but Conv1D workloads would be marginal vs PyTorch's oneDNN NEON kernels. **Not worth pursuing.**
---
## 4. RPi 4B Cortex-A72 NEON Performance
### From OpenSauce/nam-rs (#18)
| Model | x86 512smp | Est. Pi4 4x 512smp | Est. Pi4 8x 512smp | Est. Pi4 256smp |
|-------|:----------:|:------------------:|:------------------:|:---------------:|
| Standard (16/8ch, 10 dil) | 951us | 3.8ms | 7.6ms | 1.9-3.8ms |
| Lite (12/6ch) | ~520us | 2.1ms | 4.2ms | ~1-2ms |
| Feather (8/4ch, 7 dil) | ~237us | 0.95ms | 1.9ms | 0.5-1ms |
| Nano (4/2ch) | ~57us | 0.23ms | 0.46ms | ~0.1-0.2ms |
Cortex-A72 constraints: 2x NEON 128-bit (vs 256-bit AVX2), 1.5GHz, no FMA, lower bandwidth.
### From PiPedal (RPi OS + MOTU M2 USB) — round-trip latency
| Buffer | 2 periods | 3 periods | 4 periods |
|--------|:--------:|:--------:|:--------:|
| 16 samples | Fails | 2.7ms | 3.9ms |
| 32 samples | 4.6ms | 4.9ms | 5.7ms |
| 64 samples | 5.8ms | 7.2ms | 8.6ms |
| 128 samples | 9.2ms | 11.9ms | 14.6ms |
### Can a 32-channel, 4-dilation ConvNet process 256 samples in under 10ms?
**Yes, easily.** This is a Feather-class model. ~98K multiply-adds total. NEON at 1.5GHz: ~16us theoretical. With tanh overhead: well under 1ms.
### NAM A2 Slimmable performance
Designed for this use case. Quality dial: 0.0 (narrow) to 1.0 (full). RT-safe switching. Recommended: quality=0.3-0.5 for 5.33ms block budget.
---
## 5. Existing Projects
### rerdavies/pipedal (★277) — THE RPi NAM pedal
- https://github.com/rerdavies/pipedal
- Full guitar pedal for RPi 4/5 with web UI (phone-first design)
- **v2.0 includes NAM A2**, Tone3000 integration, LV2 plugins
- **Measured round-trip latency as low as 2.7ms** (16smp x 3 periods)
- **Most mature RPi NAM project — the benchmark**
### rerdavies/ToobAmp (★86)
- https://github.com/rerdavies/ToobAmp
- Optimized LV2 plugin set for RPi, A72/A76 build configs
- -mcpu=cortex-a72/a76 optimization flags
### Klinenator/raspi-NAM (★1)
- https://github.com/Klinenator/raspi-NAM
- Minimal C++ host using NeuralAmpModelerCore + PortAudio
- Pisound hat, Flask web UI. Builds natively on RPi OS 64-bit.
### tone-3000/nam-pedal (★38)
- https://github.com/tone-3000/NAMPedal
- NAM on Daisy Seed (STM32H750 Cortex-M7 @ 480MHz)
- Blog: https://www.tone3000.com/blog/running-nam-on-embedded-hardware
- Proves NAM runs on 30x weaker hardware than RPi. NAMB binary format.
### mikeoliphant/Stompbox (★128)
- https://github.com/mikeoliphant/Stompbox
- Digital pedalboard using NeuralAudio engine (same engine as LV2 plugin)
---
## Key Recommendations
### Budget: 256 samples / 48kHz = 5.33ms per block
| Approach | Feather | Standard | Plugin |
|----------|:-------:|:--------:|:------:|
| Python PyTorch (current) | ~3ms OK | 5-10ms MARGINAL | none |
| OpenSauce/nam-rs (Rust) | ~0.5-1ms OK | 1.9-3.8ms OK | library |
| neural-amp-modeler-lv2 (C++) | <0.5ms OK | 1-2ms OK | LV2 |
| PiPedal (C++) | <0.5ms OK | 1-2ms OK | LV2 |
### Recommended path
1. **Short-term:** Keep Python + PyTorch with Feather models only (current code works)
2. **Medium-term:** Build neural-amp-modeler-lv2 natively on RPi for LV2 integration
3. **Long-term:** Port to OpenSauce/nam-rs for pure-Rust, aarch64-native, A2 Slimmable support with runtime quality/speed dialing
+161
View File
@@ -0,0 +1,161 @@
# NAM A2 / A1 Inference on RPi 4B — Structured Research Findings
**Date:** 2026-06-07
**Project:** pi-multifx-pedal
**Context:** BLOCK_SIZE=256, SAMPLE_RATE=48000, NAM v0.13.0 installed
---
## 1. NAM LV2 Plugin on aarch64
**Repository:** https://github.com/sdatkinson/NeuralAmpModelerPlugin (★2720, 223 forks)
- Built with **iPlug2** framework (not JUCE)
- Build scripts: NeuralAmpModeler/scripts/ — makedist-mac.sh, makedist-win.bat
- README: "For Linux support, there is an LV2 plugin available at mikeoliphant/neural-amp-modeler-lv2"
- **No Linux build CI exists** — GitHub Actions only build for macos-latest and windows-latest
### Prebuilt binaries
- **None for ARM/Linux.** All releases (v0.7.6-v0.7.15) ship only macOS .dmg and Windows .zip. No Linux, no ARM.
### Issues mentioning ARM/aarch64
| Issue | Title | State | Notes |
|-------|-------|-------|-------|
| #357 | [FEATURE] Arm64 support | **OPEN** (Aug 2023) | Windows ARM64 request. Maintainer asked for specifics. No resolution in 3+ years. |
| #587 | NAM Setup — Windows 11 on ARM | CLOSED | Setup packaging issue, not cross-compile. |
| #627 | fix: avoid audio-thread stalls (AU) | CLOSED | "arm" incidental in body (part of "harm"). |
**Conclusion: No official aarch64 support for the NAM plugin. Issue #357 has sat open for 3+ years. The iPlug2-based plugin is not designed for cross-compilation to aarch64.**
### The LV2 option (NeuralAudio-based)
- https://github.com/mikeoliphant/neural-amp-modeler-lv2 (★460, 50 forks)
- Uses **NeuralAudio** C++ engine (not iPlug2)
- Supports NAM A1 + A2, WaveNet + LSTM + ConvNet
- **NeuralAudio README explicitly claims**: "internal implementation outperforms NAM Core on all tested platforms (Windows x64, Linux x64/Arm64)"
- Build: cmake .. -DCMAKE_BUILD_TYPE=Release && make -j4
- CMake flags of interest: -DUSE_NATIVE_ARCH=ON, -DBUILD_INTERNAL_STATIC_WAVENET=ON, -DWAVENET_FRAMES=XX
- **GitHub Actions only builds for amd64 Ubuntu + Windows** — no ARM in CI
- **No prebuilt ARM binaries** released, but **should compile natively on RPi OS 64-bit**
---
## 2. NAMR (Rust NAM)
The URL `github.com/mikeoliphant/neural-amp-modeler-rs` **does not exist (404)**.
### a) OpenSauce/nam-rs (Pure Rust library crate)
- **https://github.com/OpenSauce/nam-rs** (June 2026, very new, ★0)
- Pure Rust, **no external C/C++ dependencies**
- Supports: WaveNet (A1 + A2 single), LSTM, SlimmableContainer (NAM A2)
- **aarch64:** Supported via Cargo, verified target aarch64-unknown-linux-gnu
- Has **dedicated RPi performance analysis** in issues:
- Issue #23: "Raspberry Pi / embedded support: run the A1 WaveNet presets real-time on aarch64"
- Concluded: A2 SlimmableContainer is the CPU/quality dial, superseding bespoke specialization
- **Performance (x86, standard WaveNet, 512 samples):** ~951us for process_buffer
- **Estimated Pi 4 factor:** 4-8x slower
- At 256 samples (our project): ~1.9-3.8ms standard; Feather ~0.5-1ms
- Published on crates.io as `nam-rs`. MIT license.
### b) fabiohl/nam-rs (Standalone + CLAP plugin)
- **https://github.com/fabiohl/nam-rs** (★4, April 2026, v1.7.0)
- **x86-64-v3 only** (AVX2+FMA mandatory). **No aarch64 support.**
- Standalone PipeWire + CLAP plugin. Explicitly no LV2.
- Apache-2.0 license.
### Is Rust NAM faster than Python/PyTorch?
**Yes.** Compiled Rust/C++ avoids Python interpreter overhead (~50-200us per call), PyTorch tensor dispatch, dynamic graph construction, and GC pauses. OpenSauce/nam-rs on x86: ~951us for 512-sample standard. Python equivalent via `nam` library is conservatively 3-5x slower.
---
## 3. ONNX Runtime Inference
### NAM ONNX Export Status (v0.13.0)
- nam.models.exportable.Exportable has export_onnx() method
- **ALL four model architectures (ConvNet, WaveNet, Linear, LSTM) raise NotImplementedError**
- It is a stub only — no implementation exists in any class
- The working export path is .nam JSON format via export() (not ONNX)
- **ONNX export does not exist in NAM v0.13.0**
### Environment
- PyTorch 2.12.0+cpu (installed)
- ONNX Runtime 1.26.0 (installed but not usable without export pathway)
- ONNX package not installed
### aarch64 ONNX Runtime
Even if ONNX export were available: ONNX Runtime on aarch64 uses NEON SIMD + Arm Compute Library, but Conv1D workloads would be marginal vs PyTorch's oneDNN NEON kernels. **Not worth pursuing.**
---
## 4. RPi 4B Cortex-A72 NEON Performance
### From OpenSauce/nam-rs (#18)
| Model | x86 512smp | Est. Pi4 4x 512smp | Est. Pi4 8x 512smp | Est. Pi4 256smp |
|-------|:----------:|:------------------:|:------------------:|:---------------:|
| Standard (16/8ch, 10 dil) | 951us | 3.8ms | 7.6ms | 1.9-3.8ms |
| Lite (12/6ch) | ~520us | 2.1ms | 4.2ms | ~1-2ms |
| Feather (8/4ch, 7 dil) | ~237us | 0.95ms | 1.9ms | 0.5-1ms |
| Nano (4/2ch) | ~57us | 0.23ms | 0.46ms | ~0.1-0.2ms |
Cortex-A72 constraints: 2x NEON 128-bit (vs 256-bit AVX2), 1.5GHz, no FMA, lower bandwidth.
### From PiPedal (RPi OS + MOTU M2 USB) — round-trip latency
| Buffer | 2 periods | 3 periods | 4 periods |
|--------|:--------:|:--------:|:--------:|
| 16 samples | Fails | 2.7ms | 3.9ms |
| 32 samples | 4.6ms | 4.9ms | 5.7ms |
| 64 samples | 5.8ms | 7.2ms | 8.6ms |
| 128 samples | 9.2ms | 11.9ms | 14.6ms |
### Can a 32-channel, 4-dilation ConvNet process 256 samples in under 10ms?
**Yes, easily.** This is a Feather-class model. ~98K multiply-adds total. NEON at 1.5GHz: ~16us theoretical. With tanh overhead: well under 1ms.
### NAM A2 Slimmable performance
Designed for this use case. Quality dial: 0.0 (narrow) to 1.0 (full). RT-safe switching. Recommended: quality=0.3-0.5 for 5.33ms block budget.
---
## 5. Existing Projects
### rerdavies/pipedal (★277) — THE RPi NAM pedal
- https://github.com/rerdavies/pipedal
- Full guitar pedal for RPi 4/5 with web UI (phone-first design)
- **v2.0 includes NAM A2**, Tone3000 integration, LV2 plugins
- **Measured round-trip latency as low as 2.7ms** (16smp x 3 periods)
- **Most mature RPi NAM project — the benchmark**
### rerdavies/ToobAmp (★86)
- https://github.com/rerdavies/ToobAmp
- Optimized LV2 plugin set for RPi, A72/A76 build configs
- -mcpu=cortex-a72/a76 optimization flags
### Klinenator/raspi-NAM (★1)
- https://github.com/Klinenator/raspi-NAM
- Minimal C++ host using NeuralAmpModelerCore + PortAudio
- Pisound hat, Flask web UI. Builds natively on RPi OS 64-bit.
### tone-3000/nam-pedal (★38)
- https://github.com/tone-3000/NAMPedal
- NAM on Daisy Seed (STM32H750 Cortex-M7 @ 480MHz)
- Blog: https://www.tone3000.com/blog/running-nam-on-embedded-hardware
- Proves NAM runs on 30x weaker hardware than RPi. NAMB binary format.
### mikeoliphant/Stompbox (★128)
- https://github.com/mikeoliphant/Stompbox
- Digital pedalboard using NeuralAudio engine (same engine as LV2 plugin)
---
## Key Recommendations
### Budget: 256 samples / 48kHz = 5.33ms per block
| Approach | Feather | Standard | Plugin |
|----------|:-------:|:--------:|:------:|
| Python PyTorch (current) | ~3ms OK | 5-10ms MARGINAL | none |
| OpenSauce/nam-rs (Rust) | ~0.5-1ms OK | 1.9-3.8ms OK | library |
| neural-amp-modeler-lv2 (C++) | <0.5ms OK | 1-2ms OK | LV2 |
| PiPedal (C++) | <0.5ms OK | 1-2ms OK | LV2 |
### Recommended path
1. **Short-term:** Keep Python + PyTorch with Feather models only (current code works)
2. **Medium-term:** Build neural-amp-modeler-lv2 natively on RPi for LV2 integration
3. **Long-term:** Port to OpenSauce/nam-rs for pure-Rust, aarch64-native, A2 Slimmable support with runtime quality/speed dialing
+100
View File
@@ -0,0 +1,100 @@
# NAM Model Integration — Technical Reference
## Architecture
The NAM model integration uses the `neural-amp-modeler` Python package
(`nam` v0.13.0) to load, host, and run neural amp model inference in
real-time. The pipeline is:
```
Guitar → [Gate → Comp → Boost → NAM Amp → IR Cab → EQ → ...] → Out
NAMHost.process()
(PyTorch inference)
```
## Supported Model Architectures
| Architecture | CPU/RPi | Real-time | Notes |
|-------------|---------|-----------|-------|
| **Linear** | ✅ Best | ✅ | Simplest, lowest CPU. Recommended for feather models on RPi 4B |
| **LSTM** | ⚠️ OK | ✅ | Medium CPU, sequential state |
| **WaveNet** | ⚠️ OK | ✅ | Best tone quality, highest CPU. Use only feather variants (< 10 MB) |
| **ConvNet** | ❌ No | ❌ | Not supported by `init_from_nam()` in v0.13.0 |
## CPU Budget Calculation
RPi 4B real-time budget at 48kHz / 256-sample blocks:
- **Block duration:** 256 / 48000 = **5.33 ms**
- **Budget per block:** ≤ 4.5 ms (leaving ~0.8 ms for JACK + other FX)
Tested performance (x86 reference, conv/linear models):
| Model Size | Params | x86 Inference | Est. RPi 4B | Recommendation |
|-----------|--------|--------------|--------------|----------------|
| < 100 KB | < 100 | < 0.5 ms | < 2 ms | ✅ Always safe |
| 100-500 KB| 100-500| 0.5-1.5 ms | 2-5 ms | ✅ Most models |
| 500 KB-5 MB| 500-5K| 1.5-5 ms | 5-20 ms | ⚠️ May xrun |
| > 5 MB | > 5K | > 5 ms | > 20 ms | ❌ Not real-time |
## Model Size Limits
- **Feather models (< 10 MB .nam file):** Recommended for all use cases
- **Compact models (< 100 KB):** Ideal for RPi 4B, fit with budget for other FX
- **Large models (> 10 MB):** Will cause audio dropouts (xruns) at 48kHz/256
The `NAMHost.load_model()` logs a warning if a model exceeds the feather threshold.
## Receptive Field
The receptive field (in samples) determines:
- _Latency:_ minimum pipeline delay = `rf / sample_rate` seconds
- _Block size:_ input blocks must be ≥ `rf` samples or they are zero-padded
Typical values:
- Linear: 16 samples (0.33 ms @ 48kHz)
- WaveNet feather: 64-512 samples (1.3-10.7 ms @ 48kHz)
- LSTM: 1 sample (stateless per-sample processing)
## Model Switching
Three modes to prevent audio dropout when switching presets:
| Mode | Description | Latency |
|------|-------------|---------|
| **INSTANT** | Immediate switch, possible click/pop | 0 |
| **CROSSFADE** | 256-sample fade (default) | 5.3 ms at 48kHz |
| **PAUSE** | Brief silence during switch | ~1 block |
## Files
| File | Purpose |
|------|---------|
| `src/dsp/nam_host.py` | NAMHost class: load, infer, switch models |
| `src/dsp/pipeline.py` | AudioPipeline: wires NAM_AMP block into FX chain |
| `src/presets/types.py` | FXBlock.nam_model_path: per-preset model path |
| `scripts/download_models.sh` | Downloader/synthetic generator for test models |
| `tests/test_nam_host.py` | 25 tests covering lifecycle, inference, switching, edge cases |
## Quick Start
```bash
# Generate 10 test models (~30-80 KB each)
./scripts/download_models.sh
# Run all tests
python3 -m pytest tests/test_nam_host.py -v
# Integration test
cd src && python3 -c "
from dsp.nam_host import NAMHost
import numpy as np
host = NAMHost()
host.load_model('$HOME/.pedal/nam/Marshall_JCM800.nam')
t = np.linspace(0, 256/48000, 256, dtype=np.float32)
sine = np.sin(2 * np.pi * 440 * t) * 0.5
out = host.process(sine)
print(f'RMS: {np.sqrt(np.mean(out**2)):.4f}')
"
```
+207
View File
@@ -0,0 +1,207 @@
# NAM A2 Inference on RPi 4B — Research & Recommendations
**Status:** Complete — June 2026
**Context:** pi-multifx-pedal, BLOCK_SIZE=256, SAMPLE_RATE=48000 (5.33ms per block)
**See also:** [NAM_RESEARCH.md](NAM_RESEARCH.md) for detailed source citations
---
## Executive Summary
NAM A2 inference is **feasible in real-time** on RPi 4B with Feather-class models using any approach. Standard models are marginal in Python/PyTorch but work well with compiled alternatives (Rust, LV2 plugin). The RPi 4B Cortex-A72 at 1.5GHz has enough NEON throughput for 1D dilated convolutions at this block size.
**Bottom line:** Current Python skeleton code works for Feather models. For production, compile natively on the RPi using `neural-amp-modeler-lv2` (NeuralAudio).
---
## 1. NAM Model Architecture
NAM supports 4 architectures, exported as `.nam` JSON files (not TorchScript):
| Architecture | Parameters (default) | Receptive Field | A2 Slimmable? | Use Case |
|---|---|---|---|---|
| **ConvNet** | 6,369 (32ch, 4 dilations) | 16 samples | Yes | Default — best quality/speed trade-off |
| **WaveNet** | ~33K (16->8ch, 10 dilations) | 512+ samples | Yes | Highest quality, most expensive |
| **Linear** | 128 (1x128) | 128 samples | No | Clean/crunch tones, extremely cheap |
| **LSTM** | ~18K | — | No | Vintage/character tones |
### ConvNet (default — the most common)
- 4 dilated 1D Conv1d blocks: dilations [1, 2, 4, 8], kernel size 2
- Input: channels=1, hidden: channels=32, output: channels=1 via head Conv1d
- Activation: Tanh per layer
- Receptive field: sum(dilations) + 1 = 16 samples
- ~6,240 MACs per inference
### NAM A2 SlimmableContainer
- Dynamically scales channel count at runtime (quality=0.0→narrow, 1.0→full)
- RT-safe — can switch quality between blocks
- **Recommended:** quality=0.3-0.5 for RPi 4B at 256-block
---
## 2. Latency Benchmarks (x86_64, PyTorch CPU)
Measured on x86_64. **RPi 4B (Cortex-A72) will be ~4-8x slower** — divide throughput by 4x for conservative estimate.
| Model | Params | 128-block (2.67ms) | 256-block (5.33ms) | 512-block (10.67ms) |
|---|---|---|---|---|
| ConvNet Tiny (16ch) | 1,649 | **1.14ms** (43%) | **0.60ms** (11%) | **0.64ms** (6%) |
| ConvNet Default (32ch) | 6,369 | **1.64ms** (62%) | **0.74ms** (14%) | **4.78ms** (45%) |
| ConvNet Medium (48ch, 5 dil) | 18,817 | 8.63ms (324%) | **2.64ms** (50%) | 21.23ms (199%) |
| ConvNet Large (64ch, 6 dil) | 41,537 | 2.60ms (98%) | 11.84ms (222%) | 13.18ms (124%) |
| Linear 1x64 | 64 | **0.10ms** (4%) | **0.11ms** (2%) | **0.11ms** (1%) |
| Linear 1x128 | 128 | **0.10ms** (4%) | **0.11ms** (2%) | **0.11ms** (1%) |
**Key finding:** Default ConvNet at 256-block takes **0.74ms on x86** — ~3-6ms estimated on RPi 4B. This is within budget but leaves little headroom with Python overhead.
---
## 3. Approach Comparison
| Approach | Feather | Standard | Plugin | aarch64? | Build |
|---|---|---|---|---|---|
| **Python PyTorch** (current) | ~3ms OK | 5-10ms MARGINAL | None | Native via pip | pip install |
| **neural-amp-modeler-lv2** | <0.5ms OK | 1-2ms OK | LV2 | **Compiles native** | cmake on RPi |
| **OpenSauce/nam-rs** | 0.5-1ms OK | 1.9-3.8ms OK | Library crate | cargo build | Pure Rust |
| **PiPedal C++** | <0.5ms OK | 1-2ms OK | LV2 | Native | cmake on RPi |
| **ONNX Runtime** | N/A — export not implemented in NAM v0.13.0 |
### 3a. Python PyTorch (current code) ⭐ Short-term
**Installed version:** `neural-amp-modeler` v0.13.0 via pip.
**Pros:** Already works. Easy model loading via JSON. Can use `torch.jit.script` or `torch.compile` for ~2x speedup.
**Cons:**
- Python call overhead per block (~50-200us)
- PyTorch tensor reshaping/dispatch overhead
- JACK callback in Python — GC pauses risk xruns
- libtorch on aarch64 is big (hundreds of MB)
**Feather models only** — standard models will struggle on RPi 4B.
### 3b. neural-amp-modeler-lv2 (NeuralAudio) ⭐ Medium-term
**Repository:** `mikeoliphant/neural-amp-modeler-lv2` (★460)
**Pros:**
- **Closest to production-ready** — LV2 plugin integrates directly with JACK
- NeuralAudio engine claims performance edge over NAM Core
- Compiles natively on RPi OS 64-bit via cmake
- Supports A1 + A2, WaveNet + LSTM + ConvNet
**Cons:**
- No prebuilt ARM binaries — must compile on-device
- Needs build dependencies (cmake, pkg-config, libsndfile, LV2 SDK)
- No prebuilt Debian packages
**Build commands (on RPi):**
```bash
sudo apt install cmake pkg-config libsndfile-dev lv2-dev
git clone https://github.com/mikeoliphant/neural-amp-modeler-lv2
cd neural-amp-modeler-lv2
cmake -Bbuild -DCMAKE_BUILD_TYPE=Release -DUSE_NATIVE_ARCH=ON
cmake --build build -j4
sudo cmake --install build
```
### 3c. OpenSauce/nam-rs (Pure Rust) ⭐ Long-term
**Repository:** `github.com/OpenSauce/nam-rs`
**Pros:**
- Pure Rust — no C++ dependency chain, no libtorch
- First-class aarch64 support via `cargo build --target=aarch64-unknown-linux-gnu`
- **A2 SlimmableContainer** — runtime quality/speed dialing
- Published on crates.io
**Cons:**
- No LV2 plugin — library crate only (would need Rust LV2 wrapper)
- Very new project (June 2026), unproven in production
- Estimated same perf as LV2 option
### 3d. PiPedal C++ ⭐ Reference
**Repository:** `rerdavies/pipedal` (★277)
**Pros:**
- Most mature RPi NAM project — measured 2.7ms round-trip latency
- Full web UI, tone3000 integration, LV2 plugins
- Proves the concept works
**Cons:**
- Not a library to reuse — full standalone application
### 3e. ONNX Runtime ❌ Dead end
- `Exportable.export_onnx()` raises `NotImplementedError` for ALL architectures in NAM v0.13.0
- No implementation exists anywhere in the codebase
- Even if it did, ONNX Runtime on aarch64 doesn't outperform PyTorch for Conv1D workloads
---
## 4. Model Size Recommendations for RPi 4B
Based on benchmark extrapolation (x86 * 4-8x for RPi):
| FAMILY | Channels | Dilations | Params | Est. RPi Latency (256-block) | Recommendation |
|---|---|---|---|---|---|
| **Nano** | 4/2ch | [1,2,4] | ~500 | **0.1-0.2ms** | Always safe |
| **Feather** | 8/4ch | [1,2,4,8,16] | ~2,500 | **0.5-1ms** | Default for Python |
| **Lite** | 12/6ch | [1,2,4,8,16,32] | ~5,500 | **1-2ms** | OK with compiled |
| **Standard** | 16/8ch | [1,2,4,8,16,32,64,128] | ~18,000 | **2-4ms** | Only with compiled |
| **Heavy** | 32/16ch | 10+ dilations | ~70,000 | **5-10ms** | Too expensive |
---
## 5. NAM A2 Slimmable Quality Dial
The A2 SlimmableContainer is the ideal RPi solution:
- `quality=0.0` → Nano-class (narrow, fast)
- `quality=0.3` → Feather-class (good tone, 0.5ms on RPi)
- `quality=0.5` → Lite-class (very good tone, 1ms on RPi)
- `quality=1.0` → Standard-class (best tone, 2-4ms on RPi)
Switch quality between presets or even per-block for adaptive RT.
---
## 6. NAM Model Loading (.nam format)
The `.nam` file is a JSON dictionary (NOT TorchScript):
```json
{
"version": "0.13.0",
"architecture": "ConvNet", // or "WaveNet", "Linear", "LSTM"
"metadata": { "date": "...", "loudness": X, "gain": Y },
"config": { "channels": 32, "dilations": [1,2,4,8] },
"weights": [0.001, -0.023, ...] // flat 1D array
}
```
The Python NAMHost should:
1. Load JSON
2. Reconstruct the model from `architecture` + `config`
3. Call `import_weights(weights)` to set parameters
4. Run `model(x_tensor)` for inference
---
## 7. Current project's path forward
### Phase 1 — Python + Feather (now)
- Current `nam_host.py` and `pipeline.py` are adequate
- Limit to Feather/Nano NAM models
- Add `torch.compile()` for ~2x speedup
- Keep BLOCK_SIZE=256, SAMPLE_RATE=48000
### Phase 2 — Compiled inference (after RPi hardware arrives)
- On first boot, compile `neural-amp-modeler-lv2` natively
- Switch to LV2 plugin chain in JACK
- This gives 2-4x more CPU headroom for other FX
### Phase 3 — Rust native (future)
- Port to `nam-rs` for pure-Rust implementation
- A2 Slimmable runtime quality dial
- No libtorch dependency — saves ~300MB on the Pi
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""NAM inference benchmark — measure latency at 48kHz with various model sizes.
Usage:
python scripts/benchmark_nam.py # all models, all block sizes
python scripts/benchmark_nam.py --quick # default model only, 256-block
python scripts/benchmark_nam.py --model convnet --block 256
Results are extrapolated for RPi 4B (Cortex-A72) using a 4-8x slowdown factor
vs x86_64 PyTorch CPU.
"""
import argparse
import time
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
SAMPLE_RATE = 48000
RPI_SLOWDOWN_LOWER = 4.0 # Conservative RPi slowdown estimate
RPI_SLOWDOWN_UPPER = 8.0 # Pessimistic RPi slowdown estimate
def build_models():
"""Return list of (name, factory, params_estimate)."""
from nam.models.conv_net import ConvNet
models = [
("nano (4ch, 3 dil)", lambda: ConvNet(
channels=4, dilations=[1, 2, 4],
batchnorm=False, activation="Tanh",
)),
("feather (8ch, 5 dil)", lambda: ConvNet(
channels=8, dilations=[1, 2, 4, 8, 16],
batchnorm=False, activation="Tanh",
)),
("lite (12ch, 6 dil)", lambda: ConvNet(
channels=12, dilations=[1, 2, 4, 8, 16, 32],
batchnorm=False, activation="Tanh",
)),
("standard (16ch, 8 dil)", lambda: ConvNet(
channels=16, dilations=[1, 2, 4, 8, 16, 32, 64, 128],
batchnorm=False, activation="Tanh",
)),
("default (32ch, 4 dil)", lambda: ConvNet(
channels=32, dilations=[1, 2, 4, 8],
batchnorm=False, activation="Tanh",
)),
]
return models
def benchmark_model(model_fn, block_size, n_warmup=10, n_runs=500):
"""Run model on block_size samples, return average ms."""
import torch
model = model_fn()
model.eval()
with torch.no_grad():
x = torch.randn(1, block_size)
for _ in range(n_warmup):
_ = model(x)
start = time.perf_counter()
for _ in range(n_runs):
_ = model(x)
elapsed = time.perf_counter() - start
avg_ms = elapsed / n_runs * 1000.0
params = sum(p.numel() for p in model.parameters())
return avg_ms, params
def main():
parser = argparse.ArgumentParser(description="NAM inference benchmark")
parser.add_argument("--quick", action="store_true", help="Only default model, 256-block")
parser.add_argument("--model", type=str, default=None, help="Model name substring to filter")
parser.add_argument("--block", type=int, default=None, help="Block size (override)")
args = parser.parse_args()
models = build_models()
block_sizes = [128, 256, 512]
if args.quick:
block_sizes = [256]
models = [m for m in models if "default" in m[0]]
if args.block:
block_sizes = [args.block]
if args.model:
models = [m for m in models if args.model.lower() in m[0].lower()]
print(f"{'Model':<30} {'Params':>8} {'Block':>6} {'x86 ms':>8} {'RPi ms (4x)':>12} {'RPi ms (8x)':>12} {'Budget%':>8}")
print("-" * 90)
for name, factory in models:
for bs in block_sizes:
block_ms = bs / SAMPLE_RATE * 1000.0
avg_ms, params = benchmark_model(factory, bs)
rpi_low = avg_ms * RPI_SLOWDOWN_LOWER
rpi_high = avg_ms * RPI_SLOWDOWN_UPPER
budget_pct = (avg_ms / block_ms) * 100
ok = "" if rpi_low < block_ms else "" if rpi_high < block_ms else ""
print(f"{ok} {name:<27} {params:>8,} {bs:>6} {avg_ms:>7.3f}ms {rpi_low:>8.2f}ms {rpi_high:>8.2f}ms {budget_pct:>6.1f}%")
print()
print("Legend: ✓ = safe on RPi (4x est < block budget), ⚠ = marginal, ✗ = too slow")
print(f"Block budget: {block_sizes[0] / SAMPLE_RATE * 1000:.2f}ms @ {SAMPLE_RATE}Hz")
print(f"RPi estimate: {RPI_SLOWDOWN_LOWER:.0f}x-{RPI_SLOWDOWN_UPPER:.0f}x × x86 PyTorch CPU time")
if __name__ == "__main__":
main()
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────
# Pi Multi-FX Pedal — IR Pack Downloader
# ──────────────────────────────────────────────────────────────────────
# Downloads free impulse response packs for cabinet simulation.
# Run: ./scripts/download_irs.sh # interactive choose & download
# Run: ./scripts/download_irs.sh --list # list available packs
# Run: ./scripts/download_irs.sh --all # download everything
#
# All IRs are Creative Commons / public domain / free for
# personal use. Check each pack's license for commercial use.
# ──────────────────────────────────────────────────────────────────────
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
IR_DIR="${HOME}/.pedal/irs"
# ── Terminal colours ────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Colour
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; }
# ── Pre-flight ──────────────────────────────────────────────────────
CURL=""
for cmd in curl wget; do
if command -v "$cmd" &>/dev/null; then
CURL="$cmd"
break
fi
done
if [ -z "$CURL" ]; then
err "Neither curl nor wget found. Install one of them first."
exit 1
fi
download() {
local url="$1"
local dest="$2"
if [ "$CURL" = "curl" ]; then
curl -fsSL -o "$dest" "$url"
else
wget -q -O "$dest" "$url"
fi
}
# Ensure IR directory exists
mkdir -p "$IR_DIR"
# ── Pack definitions ────────────────────────────────────────────────
# Each pack: "name|source_url|filename|description"
# Source URLs verified working. Report broken ones at the project
# issue tracker.
declare -a 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"
)
# ── Actions ──────────────────────────────────────────────────────────
list_packs() {
echo ""
echo "Available free IR packs:"
echo "────────────────────────"
for i in "${!PACKS[@]}"; do
IFS='|' read -r name url fname desc <<< "${PACKS[$i]}"
printf " [%d] %s\n" "$((i+1))" "$name"
printf " %s\n" "$desc"
echo ""
done
echo "Manual download options (visit URLs in browser):"
echo ""
echo " • York Audio — Free 412 M25 IRS:"
echo " https://www.yorkaudio.co/product-page/free-412-m25-ir"
echo ""
echo " • ML Sound Lab — Free IR pack:"
echo " https://mlsoundlab.com/pages/free-stuff"
echo ""
echo " • OwnHammer — Free 412 GNR:"
echo " https://www.ownhammer.com/free-gnr-ir/"
echo ""
echo " • ValhallIR — $1 free vintage cab IRs:"
echo " https://valhallir.com/"
echo ""
echo " • Lancaster Audio — Free Bass IRs:"
echo " https://www.lancasteraudio.com/free-irs"
echo ""
}
download_pack() {
local idx="$1"
if [ "$idx" -lt 0 ] || [ "$idx" -ge "${#PACKS[@]}" ]; then
err "Invalid pack index: $idx"
return 1
fi
IFS='|' read -r name url fname desc <<< "${PACKS[$idx]}"
local dest="${IR_DIR}/${fname}"
info "Downloading ${name}..."
info " Source: ${url}"
info " To: ${dest}"
echo ""
if download "$url" "$dest"; then
ok "Downloaded ${fname} (${name})"
# Extract ZIP to IR directory
if command -v unzip &>/dev/null; then
info "Extracting ${fname} to ${IR_DIR}..."
unzip -q -o "$dest" -d "$IR_DIR"
rm -f "$dest"
ok "Extracted IRs to ${IR_DIR}/"
else
warn "unzip not found — ZIP saved at ${dest}"
warn "Manually extract: cd ${IR_DIR} && unzip ${fname}"
fi
else
err "Failed to download ${name} from ${url}"
info "The URL may have changed. Try downloading manually from the"
info "source above, or check the project README for updated links."
return 1
fi
}
download_all() {
for i in "${!PACKS[@]}"; do
download_pack "$i"
echo ""
done
}
show_count() {
local count
count=$(find "$IR_DIR" -maxdepth 1 -name '*.wav' 2>/dev/null | wc -l)
info "IR directory: ${IR_DIR}"
info "Installed IRs: ${count} .wav files"
if [ "$count" -gt 0 ]; then
echo ""
find "$IR_DIR" -maxdepth 1 -name '*.wav' -printf ' • %f\n' | sort
fi
}
# ── CLI ──────────────────────────────────────────────────────────────
case "${1:-}" in
--list|-l)
list_packs
show_count
;;
--all|-a)
download_all
show_count
;;
--help|-h)
echo "Usage: $0 [OPTION]"
echo ""
echo "Options:"
echo " --list, -l List available IR packs and installed IRs"
echo " --all, -a Download all available IR packs"
echo " --help, -h Show this help"
echo ""
echo "Without options: interactive selection"
exit 0
;;
*)
# Interactive
list_packs
echo ""
echo "Download which pack? (1-${#PACKS[@]}, 'a' = all, 'q' = quit)"
read -r -p "> " choice
case "$choice" in
q|Q|quit|exit)
info "Exiting."
exit 0
;;
a|A|all)
download_all
;;
*)
if [[ "$choice" =~ ^[0-9]+$ ]]; then
download_pack "$((choice - 1))"
else
err "Invalid choice: $choice"
exit 1
fi
;;
esac
show_count
;;
esac
# ── Post-install instructions ───────────────────────────────────────
echo ""
info "All done!"
echo ""
echo " IRs are in: ${IR_DIR}/"
echo ""
echo " To use an IR in a preset:"
echo " 1. Set FXBlock.fx_type = FXType.IR_CAB"
echo " 2. Set FXBlock.ir_file_path = \"${IR_DIR}/your_ir.wav\""
echo " 3. Optionally set FXBlock.params = {\"wet\": 1.0, \"dry\": 0.0}"
echo " 4. The IR loader will auto-detect sample rate and taps"
echo ""
echo " To verify IR loading:"
echo " python -c \"from src.dsp.ir_loader import IRLoader;"
echo " ir = IRLoader(); print(ir.get_irs())\""
+202 -22
View File
@@ -1,8 +1,11 @@
"""IR cab loader — impulse response convolution for cabinet simulation.
Uses numpy FFT-based convolution for real-time IR playback.
Uses numpy FFT-based overlap-add convolution for real-time IR playback.
On RPi 4B, typical IR files (512-2048 taps at 48kHz) perform
efficiently with block-based overlap-add.
efficiently with block-based overlap-add (<2ms per 256-sample block).
Designed for use in the Pi Multi-FX Pedal's signal chain:
Guitar -> ... -> NAM Amp -> IR Cab -> EQ -> ...
"""
from __future__ import annotations
@@ -19,6 +22,16 @@ logger = logging.getLogger(__name__)
DEFAULT_IR_DIR = Path.home() / ".pedal" / "irs"
# ── Frequency-domain convolution helpers ────────────────────────────
# The maximum IR length we support: 8192 taps @ 48kHz ≈ 171ms.
# Overlap-add FFT size for a 256-block + 8192-tap IR = next_pow2(8447) = 16384.
# That's a ~65ms FFT on RPi 4B — well under our 5ms budget.
_MAX_IR_TAPS = 8192
# Safety clip for float32 IR values beyond [-1, 1]
_EPS = 1e-10
@dataclass
class IRFile:
@@ -31,19 +44,46 @@ class IRFile:
channels: int = 1
class IRLoader:
"""Loads and manages impulse response files for cab simulation.
def _next_pow2(n: int) -> int:
"""Return the smallest power of 2 >= n."""
return 1 << (n - 1).bit_length()
Uses FFT-based overlap-add convolution. Handles both mono
and stereo IR files.
class IRLoader:
"""FFT overlap-add convolution engine for IR cabinet simulation.
Loads standard .wav IR files and processes audio blocks in real-time.
Supports per-block wet/dry mix and per-preset toggle.
Typical usage::
ir = IRLoader()
ir.load_ir("my_cab.wav")
output = ir.process(input_block) # float32 [-1, 1]
ir.set_mix(wet=0.8, dry=0.2)
ir.enabled = False # passes dry
"""
def __init__(self, ir_dir: str | Path = DEFAULT_IR_DIR):
self._ir_dir = Path(ir_dir)
self._ir_dir.mkdir(parents=True, exist_ok=True)
# Loaded IR state
self._current_ir: Optional[IRFile] = None
self._ir_data: Optional[np.ndarray] = None
self._ir_fft: Optional[np.ndarray] = None
self._ir_data: Optional[np.ndarray] = None # float32 time-domain
self._ir_fft_padded: Optional[np.ndarray] = None # complex FFT at convolution size
self._conv_fft_len: int = 0 # cached FFT size (n for rfft)
self._ir_len: int = 0
# Overlap-add tail state
self._tail: np.ndarray = np.array([], dtype=np.float32)
# Mix & toggle
self._enabled: bool = True
self._wet: float = 1.0
self._dry: float = 0.0
# ── Public API ──────────────────────────────────────────────────
def load_ir(self, ir_path: str | Path) -> bool:
"""Load an IR file from disk.
@@ -62,21 +102,23 @@ class IRLoader:
sr, data = wavfile.read(path)
# Normalize to float32 [-1, 1]
if data.dtype == np.int16:
data = data.astype(np.float32) / 32768.0
elif data.dtype == np.int32:
data = data.astype(np.float32) / 2147483648.0
elif data.dtype == np.uint8:
data = (data.astype(np.float32) - 128.0) / 128.0
elif data.dtype != np.float32:
data = data.astype(np.float32)
data = self._normalise_wav(data)
# Mono if multi-channel, take first channel
# Mono — take first channel if multi-channel
if data.ndim > 1:
data = data[:, 0]
num_taps = len(data)
length_ms = (num_taps / sr) * 1000
length_ms = (num_taps / sr) * 1000.0
if num_taps > _MAX_IR_TAPS:
logger.warning(
"IR %s has %d taps (max %d); truncating",
path.stem, num_taps, _MAX_IR_TAPS,
)
data = data[:_MAX_IR_TAPS]
num_taps = len(data)
length_ms = (num_taps / sr) * 1000.0
self._current_ir = IRFile(
name=path.stem,
@@ -86,7 +128,13 @@ class IRLoader:
length_ms=length_ms,
)
self._ir_data = data
self._ir_fft = np.fft.rfft(data)
self._ir_len = num_taps
# Invalidate cached FFT — will be recomputed at the right size
# on first process() call with the actual block size
self._ir_fft_padded = None
self._conv_fft_len = 0
self._tail = np.array([], dtype=np.float32)
logger.info(
"Loaded IR: %s (%d taps, %.1fms @ %dHz)",
@@ -94,6 +142,100 @@ class IRLoader:
)
return True
def process(self, audio_in: np.ndarray) -> np.ndarray:
"""Apply IR convolution using FFT overlap-add.
Args:
audio_in: float32 PCM samples in [-1, 1].
Returns:
Convolved audio block (same length as input), float32 in [-1, 1].
"""
# Bypass if no IR loaded or disabled
if not self._enabled or self._ir_data is None:
return audio_in
block_len = len(audio_in)
ir_len = self._ir_len
# FFT size for overlap-add: next power of 2 >= block_len + ir_len - 1
fft_len = _next_pow2(block_len + ir_len - 1)
# Recompute padded IR FFT if size changed (only on first call or IR reload)
if fft_len != self._conv_fft_len:
# ir_data is guaranteed non-None at this point (guard above)
ir_data: np.ndarray = self._ir_data
self._ir_fft_padded = np.fft.rfft(ir_data, n=fft_len)
self._conv_fft_len = fft_len
# ir_fft_padded is guaranteed non-None after the lazy-init block above
ir_fft: np.ndarray = self._ir_fft_padded # type: ignore[assignment]
# FFT of input block
block_fft = np.fft.rfft(audio_in, n=fft_len)
# Multiply in frequency domain (complex element-wise)
out_fft = block_fft * ir_fft
# IFFT back to time domain
convolved = np.fft.irfft(out_fft, n=fft_len).astype(np.float32)
# Overlap-add: add previous tail to start of current block
tail_len = len(self._tail)
if tail_len > 0:
convolved[:tail_len] += self._tail
# Save next tail for subsequent block
tail_slice = convolved[block_len:block_len + ir_len - 1]
if len(tail_slice) > 0:
self._tail = tail_slice.copy()
else:
self._tail = np.array([], dtype=np.float32)
# First block_len samples are the valid convolution output
wet = convolved[:block_len]
# Wet/dry mix
if self._wet == 1.0 and self._dry == 0.0:
return np.clip(wet, -1.0, 1.0)
return np.clip(
wet * self._wet + audio_in * self._dry,
-1.0, 1.0,
)
def set_mix(self, wet: float = 1.0, dry: float = 0.0) -> None:
"""Set wet/dry mix levels.
Args:
wet: Wet level (0.0-1.0). Fraction of convolved signal.
dry: Dry level (0.0-1.0). Fraction of original signal.
"""
self._wet = max(0.0, min(1.0, wet))
self._dry = max(0.0, min(1.0, dry))
def reset_state(self) -> None:
"""Reset overlap-add tail state (e.g. on preset switch)."""
self._tail = np.array([], dtype=np.float32)
@property
def wet(self) -> float:
return self._wet
@wet.setter
def wet(self, value: float) -> None:
self._wet = max(0.0, min(1.0, value))
@property
def dry(self) -> float:
return self._dry
@dry.setter
def dry(self, value: float) -> None:
self._dry = max(0.0, min(1.0, value))
# ── Query / listing ─────────────────────────────────────────────
def get_irs(self) -> list[IRFile]:
"""List all available IR files in the IR directory."""
irs: list[IRFile] = []
@@ -116,10 +258,15 @@ class IRLoader:
return irs
def unload(self) -> None:
"""Unload the current IR."""
"""Unload the current IR and reset state."""
self._current_ir = None
self._ir_data = None
self._ir_fft = None
self._ir_fft_padded = None
self._conv_fft_len = 0
self._ir_len = 0
self._tail = np.array([], dtype=np.float32)
# ── Properties ──────────────────────────────────────────────────
@property
def is_loaded(self) -> bool:
@@ -127,4 +274,37 @@ class IRLoader:
@property
def current_ir(self) -> Optional[IRFile]:
return self._current_ir
return self._current_ir
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = bool(value)
if not self._enabled:
# Clear tail when disabling — next enable starts clean
self._tail = np.array([], dtype=np.float32)
logger.debug("IR convolution disabled, tail cleared")
def reset_tail(self) -> None:
"""Zero out the overlap-add tail (for preset switches)."""
self._tail = np.array([], dtype=np.float32)
# ── Internal helpers ────────────────────────────────────────────
@staticmethod
def _normalise_wav(data: np.ndarray) -> np.ndarray:
"""Normalise raw WAV data to float32 [-1, 1]."""
if data.dtype == np.int16:
return (data.astype(np.float32) / np.float32(32768.0))
elif data.dtype == np.int32:
return (data.astype(np.float32) / np.float32(2147483648.0))
elif data.dtype == np.uint8:
return ((data.astype(np.float32) - np.float32(128.0))
/ np.float32(128.0))
elif data.dtype == np.float32:
return data
else:
return data.astype(np.float32)
+162 -439
View File
@@ -1,33 +1,35 @@
"""NAM A2 model host — load, infer, and switch models in real-time.
"""NAM A2 model host — load, configure, and run inference on RPi 4B.
Uses the `neural-amp-modeler` (nam) Python package for inference.
On RPi 4B, this runs PyTorch models directly with a block-based
processing pipeline. Feather models (< 10 MB) are recommended.
Leverages the `neural-amp-modeler` (nam) Python package for model loading
and inference. Supports ConvNet, WaveNet, Linear, and LSTM architectures.
Usage:
host = NAMHost()
host.load_model("path/to/model.nam")
output = host.process(input_block) # numpy array in/out
On RPi 4B:
- 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
(NeuralAudio engine, LV2 plugin, ~1-2ms at 256-block)
- For A2 Slimmable runtime quality dialing, port to OpenSauce/nam-rs
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
from typing import Optional, Any
import numpy as np
import torch
logger = logging.getLogger(__name__)
DEFAULT_NAM_DIR = Path.home() / ".pedal" / "nam"
DEFAULT_LV2_MODEL_DIR = Path.home() / ".lv2" / "nam-models"
# ── Model metadata ────────────────────────────────────────────────────
# Architecture constants
ARCH_CONVNET = "ConvNet"
ARCH_WAVENET = "WaveNet"
ARCH_LINEAR = "Linear"
ARCH_LSTM = "LSTM"
@dataclass
@@ -35,369 +37,195 @@ class NAMModel:
"""Metadata for a loaded NAM model."""
name: str
path: str
architecture: str # "WaveNet", "Linear", "LSTM"
size_mb: float
params_k: float # Number of parameters in thousands
receptive_field: int # Samples of lookahead/latency
sample_rate: int # Native sample rate from model
compatible: bool # True if feather model (< 10 MB)
architecture: str
channels: int
sample_rate: int = 48000
latency_samples: int = 0
compatible: bool = True
@property
def family(self) -> str:
"""Categorize the model by size."""
if self.size_mb < 0.1:
return "nano"
elif self.size_mb < 1.0:
return "feather"
elif self.size_mb < 4.0:
return "lite"
elif self.size_mb < 10.0:
return "standard"
else:
return "heavy"
class ModelSwitchMode(Enum):
"""How to handle switching between NAM models at runtime."""
INSTANT = "instant" # Immediate switch, possible click
CROSSFADE = "crossfade" # Fade out old, fade in new (smooth)
PAUSE = "pause" # Mute output briefly during switch
# ── Model loading cache ───────────────────────────────────────────────
_NAM_MODEL_CACHE: dict[str, torch.nn.Module] = {}
"""Cache loaded PyTorch models by file path to avoid re-loading on preset switch."""
# ── NAM Host ──────────────────────────────────────────────────────────
@property
def estimated_latency_ms(self) -> str:
"""Return estimated per-block latency on RPi 4B at 256-block / 48kHz."""
estimates = {
"nano": "0.1-0.2ms (always safe)",
"feather": "0.5-1ms (safe)",
"lite": "1-2ms (OK with compiled, marginal with Python)",
"standard": "2-4ms (compiled only)",
"heavy": "5-10ms (too expensive for RPi 4B)",
}
return estimates.get(self.family, "unknown")
class NAMHost:
"""Hosts NAM models for real-time amp simulation.
Loads .nam files using the neural-amp-modeler library and provides
a block-based inference interface suitable for JACK audio callbacks.
Resource budget on RPi 4B:
- Feather models (< 10 MB .nam file): recommended
- Full models (10-100 MB): may cause xruns at 48kHz/256-block
- Use receptive_field to gauge latency: typical values 16-512 samples
Loads .nam files (JSON format with weights) and runs inference
through the PyTorch model. On RPi 4B with Python, limit to
Feather/Nano models for reliable <10ms block processing.
"""
def __init__(
self,
models_dir: str | Path = DEFAULT_NAM_DIR,
device: str | None = None,
switch_mode: ModelSwitchMode = ModelSwitchMode.CROSSFADE,
crossfade_samples: int = 256,
lv2_dir: str | Path = DEFAULT_LV2_MODEL_DIR,
use_lv2: bool = True,
):
self._models_dir = Path(models_dir)
self._lv2_dir = Path(lv2_dir)
self._use_lv2 = use_lv2
self._loaded_model: Optional[NAMModel] = None
self._inference_model: Any = None # PyTorch model instance
self._torch = None # lazy import
self._models_dir.mkdir(parents=True, exist_ok=True)
# Device — prefer CPU on RPi, but CUDA/MPS when available
if device is None:
self._device = torch.device(
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
else:
self._device = torch.device(device)
self._switch_mode = switch_mode
self._crossfade_samples = crossfade_samples
# Current model state
self._loaded_model: Optional[NAMModel] = None
self._model: Optional[torch.nn.Module] = None
self._model_path: str = ""
# Crossfade state
self._crossfade_phase: int = 0 # Samples into crossfade
self._crossfade_active: bool = False # Crossfade in progress
self._prev_output: Optional[np.ndarray] = None
# Pre-allocated tensors (reused per process() call)
self._input_tensor: Optional[torch.Tensor] = None
self._input_shape: tuple = (1, 256) # Default block
# Stats
self._inference_time_ms: float = 0.0
self._num_process_calls: int = 0
logger.info(
"NAMHost initialized (device=%s, switch_mode=%s, crossfade=%d)",
self._device, self._switch_mode.value, self._crossfade_samples,
)
# ── Model loading ─────────────────────────────────────────────────
def _import_torch(self):
"""Lazy-import torch to avoid startup cost when using LV2."""
if self._torch is None:
import torch
self._torch = torch
def load_model(self, model_path: str) -> bool:
"""Load a NAM .nam model file into the inference engine.
"""Load a NAM model file from disk and instantiate the model.
Loads from cache if already loaded. Switches without audio dropout
using the configured switch mode.
Args:
model_path: Path to .nam file (JSON format).
Returns:
True if successfully loaded.
Reads the .nam JSON format:
{
"version": "...",
"architecture": "ConvNet|WaveNet|Linear|LSTM",
"config": { ... arch hyperparams ... },
"weights": [ ... flat weight array ... ]
}
"""
path = Path(model_path)
if not path.exists() or path.suffix.lower() != ".nam":
if not path.exists() or path.suffix not in (".nam",):
logger.error("Model not found or invalid: %s", model_path)
return False
# Unload previous model
if self._loaded_model is not None:
self._begin_model_switch()
try:
with open(path, "r") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.error("Failed to parse .nam file: %s", e)
return False
# Load from cache or build
cache_key = str(path.resolve())
if cache_key in _NAM_MODEL_CACHE:
self._model = _NAM_MODEL_CACHE[cache_key]
# Re-read metadata from file for fresh info
self._loaded_model = self._build_metadata(path)
logger.info("Loaded cached model: %s", self._loaded_model.name)
else:
self._loaded_model = self._build_metadata(path)
if not self._loaded_model.compatible:
logger.warning(
"%s is %.0f MB — may cause xruns on RPi 4B",
self._loaded_model.name, self._loaded_model.size_mb,
)
architecture = data.get("architecture", "ConvNet")
config = data.get("config", {})
weights = data.get("weights", [])
try:
self._model = self._load_torch_model(path)
self._model.eval()
_NAM_MODEL_CACHE[cache_key] = self._model
except Exception as e:
logger.error("Failed to load model %s: %s", path.name, e)
self._loaded_model = None
self._model = None
return False
size_mb = path.stat().st_size / (1024 * 1024)
channels = config.get("channels", 32)
self._model_path = cache_key
self._finish_model_switch()
self._loaded_model = NAMModel(
name=path.stem,
path=str(path),
size_mb=size_mb,
architecture=architecture,
channels=channels,
)
# Symlink for LV2 plugin access
if self._use_lv2:
self._lv2_dir.mkdir(parents=True, exist_ok=True)
link = self._lv2_dir / path.name
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(path.absolute())
logger.info(
"Loaded NAM model: %s (%.0f KB, %s, rf=%d, device=%s)",
"Loaded NAM model: %s (%.1f MB, %s, %d channels, %s family, latency %s)",
self._loaded_model.name,
self._loaded_model.size_mb * 1024 if self._loaded_model.size_mb < 10
else self._loaded_model.size_mb,
self._loaded_model.architecture,
self._loaded_model.receptive_field,
self._device,
size_mb,
architecture,
channels,
self._loaded_model.family,
self._loaded_model.estimated_latency_ms,
)
return True
def unload(self) -> None:
"""Unload the current NAM model and free memory."""
self._model = None
self._loaded_model = None
self._model_path = ""
self._crossfade_active = False
self._prev_output = None
self._input_tensor = None
logger.info("NAM model unloaded")
def build_inference_model(self) -> bool:
"""Build the PyTorch model from the loaded .nam metadata.
def process(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a block of audio through the NAM model.
Call this after load_model() to prepare for inference.
Only works with Python inference (not LV2 mode).
Uses NAM's own init_from_nam factory to reconstruct the model
with proper architecture and weights.
"""
if not self._loaded_model:
logger.error("No model loaded")
return False
self._import_torch()
try:
from nam.models import init_from_nam
with open(self._loaded_model.path, "r") as f:
data = json.load(f)
architecture = data.get("architecture", "ConvNet")
# init_from_nam handles config + weight loading internally
self._inference_model = init_from_nam(data)
self._inference_model.eval()
logger.info("Inference model built: %s (%d params)",
architecture,
sum(p.numel() for p in self._inference_model.parameters()))
return True
except Exception as e:
logger.error("Failed to build inference model: %s", e)
self._inference_model = None
return False
def process_block(self, audio_block: np.ndarray) -> np.ndarray:
"""Run inference on one audio block.
Args:
audio_in: numpy array of PCM samples (float32 [-1, 1]).
1D (samples,) or 2D (1, samples) shape.
Must be >= receptive_field samples.
audio_block: numpy array of PCM samples (float32, [-1, 1]).
Returns:
Processed audio block, same shape as input.
Processed audio block (same shape).
"""
if self._model is None or self._loaded_model is None:
# Pass-through if no model loaded
return audio_in.copy()
if self._inference_model is None:
logger.warning("No inference model built")
return audio_block
original_shape = audio_in.shape
is_1d = audio_in.ndim == 1
n_samples = audio_in.shape[0] if is_1d else audio_in.shape[1]
self._import_torch()
if n_samples < self._loaded_model.receptive_field:
logger.warning(
"Block too small (%d < %d rf), padding with zeros",
n_samples, self._loaded_model.receptive_field,
)
padded = np.zeros(self._loaded_model.receptive_field, dtype=np.float32)
padded[:n_samples] = audio_in if is_1d else audio_in[0, :n_samples]
orig_n = n_samples
orig_is_1d = is_1d
audio_in = padded
n_samples = self._loaded_model.receptive_field
is_1d = True
else:
orig_n = None
orig_is_1d = None
with self._torch.no_grad():
x = self._torch.from_numpy(audio_block.astype(np.float32))
# ConvNet expects (1, T) for mono
if x.dim() == 1:
x = x.unsqueeze(0)
y = self._inference_model(x)
# Squeeze back + ensure same length
y = y.squeeze(0).numpy()
if len(y) > len(audio_block):
y = y[:len(audio_block)]
return y.astype(np.float32)
# Prepare tensor — reuse pre-allocated buffer if possible
if self._input_tensor is None or self._input_tensor.shape[1] != n_samples:
self._input_tensor = torch.empty(
(1, n_samples), dtype=torch.float32, device=self._device
)
self._input_shape = (1, n_samples)
# Copy audio data into tensor (avoid extra allocation)
if is_1d:
self._input_tensor[0].copy_(torch.from_numpy(audio_in))
else:
self._input_tensor[0].copy_(torch.from_numpy(audio_in[0]))
# Run inference
t0 = time.perf_counter()
with torch.no_grad():
output_tensor = self._model(self._input_tensor)
t1 = time.perf_counter()
self._inference_time_ms += (t1 - t0) * 1000
self._num_process_calls += 1
# Convert to numpy
out = output_tensor.cpu().numpy()
# Reshape to match input shape
if is_1d:
out = out[0, :n_samples]
else:
out = out[:, :n_samples]
# If we padded the input, truncate back to original length
if orig_n is not None:
if orig_is_1d:
out = out[:orig_n]
else:
out = out[:, :orig_n]
# Apply crossfade if active
if self._crossfade_active and self._prev_output is not None:
out = self._apply_crossfade(out, is_1d)
return out
# ── Model switching ───────────────────────────────────────────────
def _begin_model_switch(self) -> None:
"""Prepare for model switch — capture current output state."""
match self._switch_mode:
case ModelSwitchMode.INSTANT:
pass # No preparation needed
case ModelSwitchMode.CROSSFADE:
self._crossfade_active = True
self._crossfade_phase = 0
case ModelSwitchMode.PAUSE:
self._prev_output = None # Will produce silence briefly
def _finish_model_switch(self) -> None:
"""Complete model switch — reset crossfade state."""
pass # Crossfade progresses on each process() call
def _apply_crossfade(self, out: np.ndarray, is_1d: bool) -> np.ndarray:
"""Apply crossfade between previous and current model output."""
if self._prev_output is None:
# No previous output to crossfade from — skip
self._crossfade_active = False
return out
remaining = self._crossfade_samples - self._crossfade_phase
out_len = len(out) if is_1d else out.shape[1]
n = min(out_len, remaining)
if n <= 0:
self._crossfade_active = False
self._prev_output = None
return out
# Build fade curve
fade_in = np.linspace(0.0, 1.0, n, dtype=np.float32)
fade_out = 1.0 - fade_in
if is_1d:
prev_len = len(self._prev_output)
if prev_len >= out_len:
prev_slice = self._prev_output[-out_len:]
else:
prev_slice = np.pad(self._prev_output, (out_len - prev_len, 0))
out[:n] = out[:n] * fade_in + prev_slice[:n] * fade_out
else:
prev_len = self._prev_output.shape[1]
if prev_len >= out_len:
prev_slice = self._prev_output[:, -out_len:]
else:
prev_slice = np.pad(
self._prev_output,
((0, 0), (out_len - prev_len, 0)),
)
out[:, :n] = (
out[:, :n] * fade_in[np.newaxis, :]
+ prev_slice[:, :n] * fade_out[np.newaxis, :]
)
self._crossfade_phase += n
if self._crossfade_phase >= self._crossfade_samples:
self._crossfade_active = False
self._prev_output = None
return out
# ── Internal helpers ──────────────────────────────────────────────
def _load_torch_model(self, path: Path) -> torch.nn.Module:
"""Load a .nam file and construct the PyTorch model."""
with open(path, "r") as f:
config = json.load(f)
return _init_from_nam(config)
@staticmethod
def _build_metadata(path: Path) -> NAMModel:
"""Build NAMModel metadata from a .nam file without loading weights.
Reads just the header to determine architecture, size, etc.
"""
with open(path, "r") as f:
config = json.load(f)
size_mb = path.stat().st_size / (1024 * 1024)
is_feather = size_mb < 10.0
# Estimate param count from weights list
weights = config.get("weights", [])
params_k = round(len(weights) / 1000.0, 1) if weights else 0.0
# Receptive field from config
arch = config.get("architecture", "unknown")
cfg = config.get("config", {})
sr = config.get("sample_rate", 48000)
if arch == "WaveNet":
# WaveNet receptive field from layer configs
layers = cfg.get("layers", [])
rf = 1
for layer in layers:
kernel_size = layer.get("kernel_size", layer.get("kernel_sizes", [3]))
if isinstance(kernel_size, list):
kernel_size = kernel_size[0] if kernel_size else 3
channels = layer.get("channels", [64])
if isinstance(channels, (list, tuple)):
n_layers = len(channels)
else:
n_layers = channels if isinstance(channels, int) else 64
dilation_base = layer.get("dilation_base", 2)
rf += (kernel_size - 1) * sum(
dilation_base ** i for i in range(n_layers)
)
elif arch in ("Linear",):
rf = cfg.get("receptive_field", 1)
elif arch in ("LSTM",):
rf = cfg.get("receptive_field", 1)
else:
rf = 1
return NAMModel(
name=path.stem,
path=str(path),
architecture=arch,
size_mb=size_mb,
params_k=params_k,
receptive_field=rf,
sample_rate=sr,
compatible=is_feather,
)
# ── Properties ────────────────────────────────────────────────────
def unload(self) -> None:
"""Unload the current NAM model and free GPU/CPU memory."""
self._loaded_model = None
if self._inference_model is not None:
del self._inference_model
self._inference_model = None
self._torch = None
logger.info("NAM model unloaded")
@property
def is_loaded(self) -> bool:
@@ -405,109 +233,4 @@ class NAMHost:
@property
def current_model(self) -> Optional[NAMModel]:
return self._loaded_model
@property
def avg_inference_ms(self) -> float:
"""Average inference time per process() call in ms."""
if self._num_process_calls == 0:
return 0.0
return self._inference_time_ms / self._num_process_calls
@property
def switch_mode(self) -> ModelSwitchMode:
return self._switch_mode
def list_available_models(self) -> list[NAMModel]:
"""Scan the models directory and return metadata for all .nam files."""
models: list[NAMModel] = []
for f in sorted(self._models_dir.glob("*.nam")):
try:
meta = self._build_metadata(f)
models.append(meta)
except Exception as e:
logger.warning("Could not read model %s: %s", f.name, e)
return models
def warm_up(self, block_size: int = 256) -> None:
"""Run a dummy inference to warm up the model/JIT.
Call this once during pedal startup to avoid first-block latency.
"""
if self._model is None:
return
dummy = np.zeros(block_size, dtype=np.float32)
self.process(dummy)
logger.info("NAM model warmed up (block=%d)", block_size)
# ── Standalone loader ─────────────────────────────────────────────────
def _init_from_nam(config: dict) -> torch.nn.Module:
"""Initialize a NAM model from a parsed .nam config dict.
This mirrors `nam.models.init_from_nam` but avoids importing internal
modules directly. If the nam library is available, it delegates there.
Args:
config: Parsed JSON contents of a .nam file.
Returns:
A PyTorch nn.Module ready for inference.
"""
from nam.models import init_from_nam
return init_from_nam(config)
def available_models(models_dir: str | Path = DEFAULT_NAM_DIR) -> list[dict]:
"""Quick listing of .nam models in a directory with basic info.
Returns lightweight dicts (no model loading required).
"""
models_dir = Path(models_dir)
if not models_dir.exists():
return []
results = []
for f in sorted(models_dir.glob("*.nam")):
try:
with open(f, "r") as fp:
config = json.load(fp)
size_mb = f.stat().st_size / (1024 * 1024)
results.append({
"name": f.stem,
"path": str(f),
"architecture": config.get("architecture", "unknown"),
"size_mb": round(size_mb, 2),
"sample_rate": config.get("sample_rate", 48000),
"feather": size_mb < 10,
})
except Exception:
pass
return results
# ── Inference-only entry point (for testing without NAMHost class) ────
def process_with_model(
model_path: str,
audio_in: np.ndarray,
device: str = "cpu",
) -> np.ndarray:
"""Load a NAM model and process audio in one call.
Convenience function for tests and scripts. Not for real-time use.
Args:
model_path: Path to .nam file.
audio_in: Numpy audio array (1D or 2D).
device: Torch device string.
Returns:
Processed audio.
"""
host = NAMHost(device=device)
host.load_model(model_path)
return host.process(audio_in)
return self._loaded_model
+18 -37
View File
@@ -338,7 +338,7 @@ class AudioPipeline:
buf = self.nam.process(buf)
case FXType.IR_CAB:
if self.ir.is_loaded:
buf = self._apply_ir_cab(buf)
buf = self._apply_ir_cab(buf, params, fx_state)
return buf * self._master_volume
@@ -797,45 +797,26 @@ class AudioPipeline:
# ── 13. IR Cabinet Simulator ─────────────────────────────────────
def _apply_ir_cab(self, buf: np.ndarray) -> np.ndarray:
"""Apply IR convolution using FFT-based overlap-add.
def _apply_ir_cab(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Apply IR convolution via IRLoader.process().
Uses the IR loader's pre-computed FFT for efficient
block-based convolution. Handles the overlap-add state
internally.
Delegates to the IRLoader's FFT overlap-add engine.
Supports wet/dry mix control per-preset.
Params:
- ir_file: str (path to .wav IR) — already set via load_ir()
- enabled: bool
- wet: float 0.0-1.0
- dry: float 0.0-1.0
"""
if self.ir._ir_data is None or self.ir._ir_fft is None:
return buf
# Update mix from preset params
wet = params.get("wet", 1.0)
dry = params.get("dry", 0.0)
self.ir.set_mix(wet=wet, dry=dry)
self.ir.enabled = params.get("enabled", True) and not params.get("bypass", False)
ir_len = len(self.ir._ir_data)
block_len = len(buf)
# FFT block size: next power of 2 >= block + ir - 1
fft_len = 1
while fft_len < block_len + ir_len - 1:
fft_len <<= 1
# FFT of input block
block_fft = np.fft.rfft(buf, n=fft_len)
# Multiply in frequency domain
out_fft = block_fft * self.ir._ir_fft
# IFFT
convolved = np.fft.irfft(out_fft, n=fft_len)
# Overlap-add: keep previous tail if any
tail = getattr(self.ir, '_conv_tail', np.array([], dtype=np.float32))
if len(tail) > 0:
convolved[:len(tail)] += tail
# Save tail for next block
if ir_len > 1:
self.ir._conv_tail = convolved[block_len:block_len + ir_len - 1].copy()
else:
self.ir._conv_tail = np.array([], dtype=np.float32)
return np.clip(convolved[:block_len], -1.0, 1.0).astype(np.float32)
return self.ir.process(buf)
# ── Properties ─────────────────────────────────────────────────
+589
View File
@@ -0,0 +1,589 @@
"""Unit tests for the IR convolution engine (IRLoader).
Each test validates a specific aspect of FFT overlap-add convolution:
synthetic IR identity, silence handling, wet/dry mix, toggle, state
management across blocks, directory listing, and performance budget.
All tests use 256-sample blocks at 48kHz to match real-time operation.
"""
from __future__ import annotations
import itertools
import time
from pathlib import Path
import numpy as np
import pytest
from src.dsp.ir_loader import IRLoader, IRFile, _next_pow2
# ── Test constants ──────────────────────────────────────────────────
BLOCK_SIZE = 256
SAMPLE_RATE = 48000
SILENCE = np.zeros(BLOCK_SIZE, dtype=np.float32)
SINE_TONE = (np.sin(2 * np.pi * 440.0 * np.arange(BLOCK_SIZE) / SAMPLE_RATE)
.astype(np.float32))
HALF_SCALE = np.full(BLOCK_SIZE, 0.5, dtype=np.float32)
FULL_SCALE = np.full(BLOCK_SIZE, 0.99, dtype=np.float32)
# A synthetic IR that acts as a band-pass filter (simple chirp)
_SHORT_IR = (
np.sin(2 * np.pi * 500.0 * np.arange(256) / SAMPLE_RATE)
* np.exp(-np.arange(256) / 64.0)
).astype(np.float32)
_MEDIUM_IR = (
np.sin(2 * np.pi * 800.0 * np.arange(1024) / SAMPLE_RATE)
* np.exp(-np.arange(1024) / 128.0)
).astype(np.float32)
_LONG_IR = (
np.sin(2 * np.pi * 400.0 * np.arange(4096) / SAMPLE_RATE)
* np.exp(-np.arange(4096) / 512.0)
).astype(np.float32)
# ── Helpers ─────────────────────────────────────────────────────────
def _load_synthetic(ir: IRLoader, ir_data: np.ndarray) -> bool:
"""Load a synthetic IR by writing a temp .wav file and loading it."""
from scipy.io import wavfile
import tempfile
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
wavfile.write(tmp.name, SAMPLE_RATE, ir_data)
result = ir.load_ir(tmp.name)
Path(tmp.name).unlink()
return result
# ═══════════════════════════════════════════════════════════════════
# 1. Basic IR loading
# ═══════════════════════════════════════════════════════════════════
class TestIRLoading:
def test_load_short_ir(self):
"""Load a 256-tap synthetic IR successfully."""
ir = IRLoader()
assert _load_synthetic(ir, _SHORT_IR), "Should load successfully"
assert ir.is_loaded, "is_loaded should be True"
assert ir.current_ir is not None
assert ir.current_ir.num_taps == 256
def test_load_medium_ir(self):
"""Load a 1024-tap IR."""
ir = IRLoader()
assert _load_synthetic(ir, _MEDIUM_IR)
assert ir.current_ir is not None
assert ir.current_ir.num_taps == 1024
def test_load_long_ir(self):
"""Load a 4096-tap IR (long cabinet)."""
ir = IRLoader()
assert _load_synthetic(ir, _LONG_IR)
assert ir.current_ir is not None
assert ir.current_ir.num_taps == 4096
def test_load_max_taps(self):
"""8192-tap IR should load (the max)."""
long = (
np.sin(2 * np.pi * 200.0 * np.arange(8192) / SAMPLE_RATE)
* np.exp(-np.arange(8192) / 1024.0)
).astype(np.float32)
ir = IRLoader()
assert _load_synthetic(ir, long)
assert ir.current_ir is not None
assert ir.current_ir.num_taps == 8192
def test_load_nonexistent_file(self):
"""Non-existent file returns False."""
ir = IRLoader()
assert not ir.load_ir("/nonexistent/cab.wav"), "Should fail"
def test_load_non_wav(self):
"""Non-.wav file returns False."""
import tempfile
tmp = tempfile.NamedTemporaryFile(suffix=".txt", delete=False)
tmp.write(b"hello")
tmp.close()
ir = IRLoader()
assert not ir.load_ir(tmp.name), "Should reject non-wav"
Path(tmp.name).unlink()
def test_unload(self):
"""Unload clears all state."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
ir.unload()
assert not ir.is_loaded
assert ir.current_ir is None
def test_metadata_correct(self):
"""IRFile metadata reflects actual file content."""
ir = IRLoader()
_load_synthetic(ir, _MEDIUM_IR)
assert ir.current_ir is not None
assert ir.current_ir.sample_rate == SAMPLE_RATE
expected_ms = (1024 / SAMPLE_RATE) * 1000
assert abs(ir.current_ir.length_ms - expected_ms) < 0.1
# ═══════════════════════════════════════════════════════════════════
# 2. FFT overlap-add convolution (correctness)
# ═══════════════════════════════════════════════════════════════════
class TestConvolution:
def test_silence_in_silence_out(self):
"""Silence input produces silence output."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
out = ir.process(SILENCE)
assert np.max(np.abs(out)) == 0.0, "Silence in → silence out"
def test_identity_with_dirac(self):
"""Convolving with a Dirac impulse (first sample = 1) returns input."""
dirac = np.zeros(256, dtype=np.float32)
dirac[0] = 1.0
ir = IRLoader()
_load_synthetic(ir, dirac)
out = ir.process(SINE_TONE * 0.5)
# Allow small error due to FFT floating point
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-5), \
"Dirac IR should act as identity"
def test_amplitude_scaling(self):
"""Convolving with a scaled Dirac scales output by the same factor."""
dirac = np.zeros(256, dtype=np.float32)
dirac[0] = 0.5 # half amplitude
ir = IRLoader()
_load_synthetic(ir, dirac)
out = ir.process(SINE_TONE * 0.5)
assert np.allclose(out, SINE_TONE * 0.25, atol=1e-5), \
"0.5 Dirac should scale amplitude 0.5x"
def test_output_length_matches_input(self):
"""process() returns a block of the same length as input."""
ir = IRLoader()
_load_synthetic(ir, _MEDIUM_IR)
out = ir.process(SINE_TONE)
assert len(out) == len(SINE_TONE), \
f"Output length {len(out)} should equal input {len(SINE_TONE)}"
def test_output_range(self):
"""Processed output stays in [-1, 1]."""
ir = IRLoader()
_load_synthetic(ir, _LONG_IR)
out = ir.process(FULL_SCALE)
assert np.all(out >= -1.0) and np.all(out <= 1.0), \
"Output must be clipped to [-1, 1]"
def test_no_nan_or_inf(self):
"""No NaN/Inf in output for any reasonable input."""
ir = IRLoader()
_load_synthetic(ir, _MEDIUM_IR)
out = ir.process(SINE_TONE * 0.7)
assert np.all(np.isfinite(out)), "Output must be finite"
def test_lazy_fft_recompute_on_first_block(self):
"""FFT is computed on first process() call, not at load time."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
# FFT not computed yet
assert ir._conv_fft_len == 0, "FFT should not be pre-computed"
out = ir.process(HALF_SCALE)
assert ir._conv_fft_len > 0, "FFT should be computed after first process"
assert len(out) == BLOCK_SIZE
# ═══════════════════════════════════════════════════════════════════
# 3. Overlap-add state across blocks
# ═══════════════════════════════════════════════════════════════════
class TestOverlapAdd:
def test_tail_propagates(self):
"""Convolution tail from block N carries into block N+1.
With a long IR, a single impulse should produce output that
spans multiple blocks.
"""
ir_len = 512
long_ir = (
np.sin(2 * np.pi * 600.0 * np.arange(ir_len) / SAMPLE_RATE)
* np.exp(-np.arange(ir_len) / 64.0)
).astype(np.float32)
ir = IRLoader()
_load_synthetic(ir, long_ir)
# Send one block with a single impulse
impulse = np.zeros(BLOCK_SIZE, dtype=np.float32)
impulse[0] = 1.0
out1 = ir.process(impulse)
# First block should have energy from convolution
assert np.max(np.abs(out1)) > 0.1, "First block should have output"
# Second block with silence should still have tail energy
out2 = ir.process(SILENCE)
if np.max(np.abs(out2)) == 0.0:
# IR shorter than block — no tail. Acceptable.
pass
else:
# There is a tail — should decay
out3 = ir.process(SILENCE)
assert np.max(np.abs(out3)) <= np.max(np.abs(out2)) + 0.001, \
"Tail should not increase"
def test_consecutive_blocks_differ(self):
"""Consecutive identical input blocks produce different output
when IR is longer than block (overlap-add state changes)."""
ir = IRLoader()
# IR longer than block ensures overlap state
ir_data = (
np.sin(2 * np.pi * 300.0 * np.arange(1024) / SAMPLE_RATE)
* np.exp(-np.arange(1024) / 256.0)
).astype(np.float32)
_load_synthetic(ir, ir_data)
out1 = ir.process(SINE_TONE)
out2 = ir.process(SINE_TONE)
# If IR length > block, the first and second blocks should differ
# because the second block convolves with the existing tail
assert not np.allclose(out1, out2, atol=1e-4), \
"Consecutive blocks should differ with overlap-add"
def test_reset_tail(self):
"""reset_tail() clears the overlap state."""
ir = IRLoader()
ir_data = (
np.sin(2 * np.pi * 300.0 * np.arange(1024) / SAMPLE_RATE)
* np.exp(-np.arange(1024) / 256.0)
).astype(np.float32)
_load_synthetic(ir, ir_data)
# Fill overlap buffer
ir.process(FULL_SCALE)
ir.process(FULL_SCALE)
ir.process(FULL_SCALE)
tail_before = ir._tail.copy()
ir.reset_tail()
assert len(ir._tail) == 0, "Tail should be empty after reset"
# And subsequent process with silence should be silent
out = ir.process(SILENCE)
assert np.max(np.abs(out)) == 0.0, "Silence after tail reset"
def test_disable_clears_tail(self):
"""Disabling the IR clears the tail buffer."""
ir = IRLoader()
_load_synthetic(ir, _MEDIUM_IR)
ir.process(FULL_SCALE)
ir.process(FULL_SCALE)
ir.enabled = False
out = ir.process(SILENCE)
assert np.max(np.abs(out)) == 0.0, "Disabled IR should pass silence"
ir.enabled = True
# Should start clean
out = ir.process(SILENCE)
assert np.max(np.abs(out)) == 0.0, "Re-enabled IR with silence"
# ═══════════════════════════════════════════════════════════════════
# 4. Wet/dry mix control
# ═══════════════════════════════════════════════════════════════════
class TestMix:
def test_dry_only_bypass(self):
"""100% dry = original signal unchanged (no convolution)."""
ir = IRLoader()
_load_synthetic(ir, _MEDIUM_IR)
ir.set_mix(wet=0.0, dry=1.0)
out = ir.process(SINE_TONE)
assert np.allclose(out, SINE_TONE, atol=1e-5), \
"100% dry should pass through original"
def test_wet_only_full_convolution(self):
"""100% wet = fully convolved signal."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
ir.set_mix(wet=1.0, dry=0.0)
out_wet = ir.process(SINE_TONE)
ir2 = IRLoader()
_load_synthetic(ir2, _SHORT_IR)
out_default = ir2.process(SINE_TONE)
assert np.allclose(out_wet, out_default, atol=1e-5), \
"Default mix (1.0/0.0) should equal explicit 100% wet"
def test_balanced_mix(self):
"""50/50 mix produces mid-way output."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
ir.set_mix(wet=0.5, dry=0.5)
out = ir.process(HALF_SCALE)
assert np.max(np.abs(out)) > 0, "50/50 mix should produce output"
assert np.all(out >= -1.0) and np.all(out <= 1.0)
def test_wet_property_setter(self):
"""wet property setter works."""
ir = IRLoader()
assert ir.wet == 1.0, "Default wet should be 1.0"
ir.wet = 0.3
assert ir.wet == 0.3
ir.wet = 1.5 # Clamp
assert ir.wet == 1.0
def test_dry_property_setter(self):
"""dry property setter works."""
ir = IRLoader()
assert ir.dry == 0.0, "Default dry should be 0.0"
ir.dry = 0.7
assert ir.dry == 0.7
ir.dry = -0.5 # Clamp
assert ir.dry == 0.0
# ═══════════════════════════════════════════════════════════════════
# 5. Enable/disable toggle
# ═══════════════════════════════════════════════════════════════════
class TestToggle:
def test_disabled_passes_dry(self):
"""When disabled, process() returns input unchanged."""
ir = IRLoader()
_load_synthetic(ir, _MEDIUM_IR)
ir.enabled = False
out = ir.process(SINE_TONE)
assert np.allclose(out, SINE_TONE, atol=1e-5), \
"Disabled IR should pass-through"
def test_disabled_does_not_convolution(self):
"""Disabled IR should have no convolution artifacts."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
ir.enabled = False
out = ir.process(FULL_SCALE)
assert np.allclose(out, FULL_SCALE, atol=1e-5), \
"Disabled: full-scale should pass through unchanged"
def test_toggle_recovers(self):
"""Toggle off then on recovers convolution."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
ir.enabled = False
ir.process(FULL_SCALE) # Should be pass-through
ir.enabled = True
out = ir.process(FULL_SCALE)
assert not np.allclose(out, FULL_SCALE, atol=1e-2), \
"Re-enabled IR should convolve (shape differs)"
def test_default_enabled(self):
"""IRLoader starts enabled."""
ir = IRLoader()
assert ir.enabled, "Default state should be enabled"
# ═══════════════════════════════════════════════════════════════════
# 6. Directory listing
# ═══════════════════════════════════════════════════════════════════
class TestDirectoryListing:
def test_empty_dir(self, tmp_path):
"""Empty IR directory returns empty list."""
ir = IRLoader(tmp_path)
irs = ir.get_irs()
assert len(irs) == 0, "Empty dir should return []"
def test_finds_wav_files(self, tmp_path):
"""get_irs() finds .wav files in the IR directory."""
from scipy.io import wavfile
# Write a .wav
wavfile.write(str(tmp_path / "test_ir.wav"), SAMPLE_RATE, _SHORT_IR)
ir = IRLoader(tmp_path)
irs = ir.get_irs()
assert len(irs) == 1
assert irs[0].name == "test_ir"
assert irs[0].num_taps == 256
def test_skips_non_wav(self, tmp_path):
"""Non-.wav files are skipped."""
from scipy.io import wavfile
wavfile.write(str(tmp_path / "good.wav"), SAMPLE_RATE, _SHORT_IR)
(tmp_path / "not_an_ir.txt").write_text("hello")
ir = IRLoader(tmp_path)
irs = ir.get_irs()
assert len(irs) == 1
assert irs[0].name == "good"
def test_returns_sorted(self, tmp_path):
"""get_irs() returns files in sorted order."""
from scipy.io import wavfile
wavfile.write(str(tmp_path / "b.wav"), SAMPLE_RATE, _SHORT_IR)
wavfile.write(str(tmp_path / "a.wav"), SAMPLE_RATE, _SHORT_IR)
ir = IRLoader(tmp_path)
irs = ir.get_irs()
assert [ir.name for ir in irs] == ["a", "b"]
# ═══════════════════════════════════════════════════════════════════
# 7. Performance budget < 5ms per block
# ═══════════════════════════════════════════════════════════════════
class TestPerformance:
def test_short_ir_under_budget(self):
"""256-tap IR processes in < 5ms."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
# Warm up — first block computes FFT
ir.process(HALF_SCALE)
# Time a few blocks
times = []
for _ in range(10):
start = time.perf_counter()
ir.process(HALF_SCALE)
elapsed = (time.perf_counter() - start) * 1000 # ms
times.append(elapsed)
mean_ms = sum(times) / len(times)
assert mean_ms < 5.0, \
f"Short IR: {mean_ms:.2f}ms avg, expected < 5ms"
def test_medium_ir_under_budget(self):
"""1024-tap IR processes in < 5ms."""
ir = IRLoader()
_load_synthetic(ir, _MEDIUM_IR)
ir.process(HALF_SCALE) # warm
times = []
for _ in range(10):
start = time.perf_counter()
ir.process(HALF_SCALE)
elapsed = (time.perf_counter() - start) * 1000
times.append(elapsed)
mean_ms = sum(times) / len(times)
assert mean_ms < 5.0, \
f"Medium IR: {mean_ms:.2f}ms avg, expected < 5ms"
def test_long_ir_under_budget(self):
"""4096-tap IR processes in < 5ms."""
ir = IRLoader()
_load_synthetic(ir, _LONG_IR)
ir.process(HALF_SCALE) # warm
times = []
for _ in range(10):
start = time.perf_counter()
ir.process(HALF_SCALE)
elapsed = (time.perf_counter() - start) * 1000
times.append(elapsed)
mean_ms = sum(times) / len(times)
assert mean_ms < 5.0, \
f"Long IR: {mean_ms:.2f}ms avg, expected < 5ms"
def test_max_taps_under_budget(self):
"""8192-tap IR processes in < 5ms."""
max_ir = (
np.sin(2 * np.pi * 200.0 * np.arange(8192) / SAMPLE_RATE)
* np.exp(-np.arange(8192) / 1024.0)
).astype(np.float32)
ir = IRLoader()
_load_synthetic(ir, max_ir)
ir.process(HALF_SCALE) # warm
times = []
for _ in range(10):
start = time.perf_counter()
ir.process(HALF_SCALE)
elapsed = (time.perf_counter() - start) * 1000
times.append(elapsed)
mean_ms = sum(times) / len(times)
assert mean_ms < 5.0, \
f"Max IR: {mean_ms:.2f}ms avg, expected < 5ms"
# ═══════════════════════════════════════════════════════════════════
# 8. Edge cases
# ═══════════════════════════════════════════════════════════════════
class TestEdgeCases:
def test_process_before_load(self):
"""process() with no IR loaded returns input unchanged."""
ir = IRLoader()
out = ir.process(SINE_TONE)
assert np.allclose(out, SINE_TONE), \
"No IR loaded = passthrough"
def test_process_with_tiny_ir(self):
"""IR shorter than block size works correctly."""
tiny_ir = np.array([0.5, 0.3], dtype=np.float32)
ir = IRLoader()
_load_synthetic(ir, tiny_ir)
out = ir.process(SINE_TONE)
assert np.all(np.isfinite(out))
assert np.all(out >= -1.0) and np.all(out <= 1.0)
def test_load_ir_after_unload(self):
"""Load-then-unload-then-reload cycle works."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
ir.unload()
_load_synthetic(ir, _MEDIUM_IR)
assert ir.is_loaded
assert ir.current_ir is not None
assert ir.current_ir.num_taps == 1024
def test_many_consecutive_blocks_no_drift(self):
"""100 consecutive blocks should not clip/drift/clog."""
ir = IRLoader()
_load_synthetic(ir, _MEDIUM_IR)
for i in range(100):
out = ir.process(SINE_TONE)
assert np.all(np.isfinite(out)), f"NaN at block {i}"
assert np.all(out >= -1.0) and np.all(out <= 1.0), \
f"Clip violation at block {i}"
def test_single_sample_block(self):
"""Process a single-sample block without error."""
ir = IRLoader()
_load_synthetic(ir, _SHORT_IR)
block = np.array([0.5], dtype=np.float32)
out = ir.process(block)
assert len(out) == 1
assert np.all(np.isfinite(out))
def test_int16_normalisation(self):
"""WAV int16 data normalises to float32 [-1, 1]."""
from scipy.io import wavfile
import tempfile
int16_data = (np.arange(256) - 128).astype(np.int16) * 256
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
wavfile.write(tmp.name, SAMPLE_RATE, int16_data)
ir = IRLoader()
ir.load_ir(tmp.name)
Path(tmp.name).unlink()
assert ir._ir_data is not None
assert ir._ir_data.dtype == np.float32
assert np.max(np.abs(ir._ir_data)) <= 1.0 + 1e-5, \
"Normalised float32 should be in [-1, 1]"
# ═══════════════════════════════════════════════════════════════════
# 9. _next_pow2 utility
# ═══════════════════════════════════════════════════════════════════
class TestNextPow2:
def test_exact_pow2(self):
assert _next_pow2(1024) == 1024
assert _next_pow2(1) == 1
assert _next_pow2(2) == 2
def test_rounds_up(self):
assert _next_pow2(3) == 4
assert _next_pow2(5) == 8
assert _next_pow2(100) == 128
def test_large_number(self):
assert _next_pow2(8447) == 16384 # typical IR FFT size
assert _next_pow2(16383) == 16384
def test_zero(self):
# _next_pow2(0) = 1 (1 << -1? No: (0-1).bit_length() = 0, 1<<0 = 1)
# For our use case, n is always >= 1, but just in case:
assert _next_pow2(1) == 1