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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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}')
|
||||
"
|
||||
```
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Executable
+219
@@ -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())\""
|
||||
+201
-21
@@ -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:
|
||||
@@ -128,3 +275,36 @@ class IRLoader:
|
||||
@property
|
||||
def current_ir(self) -> Optional[IRFile]:
|
||||
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)
|
||||
+161
-438
@@ -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()
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
self._model_path = cache_key
|
||||
self._finish_model_switch()
|
||||
architecture = data.get("architecture", "ConvNet")
|
||||
config = data.get("config", {})
|
||||
weights = data.get("weights", [])
|
||||
|
||||
size_mb = path.stat().st_size / (1024 * 1024)
|
||||
channels = config.get("channels", 32)
|
||||
|
||||
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:
|
||||
@@ -406,108 +234,3 @@ 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)
|
||||
+18
-37
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user