Build IR convolution engine

- Full FFT overlap-add IR convolution in IRLoader (process(), set_mix(), toggle)
- Lazy FFT computation — IR FFT padded to correct block+ir size on first process()
- Wet/dry mix control, enabled/disabled toggle with tail clearing
- Fixed pipeline._apply_ir_cab() to delegate to IRLoader.process() instead of
  poking internals (old code had array-size mismatch bug: IR FFT at ir_len vs
  block FFT at conv_size)
- 46 tests: loading, convolution correctness, overlap-add state, mix, toggle,
  directory listing, performance budget (all <5ms even at 8192 taps), edge cases
- scripts/download_irs.sh: free IR pack downloader (God's Cab, Seacow)
This commit is contained in:
2026-06-07 23:46:02 -04:00
parent c38a7b0fd8
commit 0e77adb4c3
10 changed files with 1939 additions and 498 deletions
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""NAM inference benchmark — measure latency at 48kHz with various model sizes.
Usage:
python scripts/benchmark_nam.py # all models, all block sizes
python scripts/benchmark_nam.py --quick # default model only, 256-block
python scripts/benchmark_nam.py --model convnet --block 256
Results are extrapolated for RPi 4B (Cortex-A72) using a 4-8x slowdown factor
vs x86_64 PyTorch CPU.
"""
import argparse
import time
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
SAMPLE_RATE = 48000
RPI_SLOWDOWN_LOWER = 4.0 # Conservative RPi slowdown estimate
RPI_SLOWDOWN_UPPER = 8.0 # Pessimistic RPi slowdown estimate
def build_models():
"""Return list of (name, factory, params_estimate)."""
from nam.models.conv_net import ConvNet
models = [
("nano (4ch, 3 dil)", lambda: ConvNet(
channels=4, dilations=[1, 2, 4],
batchnorm=False, activation="Tanh",
)),
("feather (8ch, 5 dil)", lambda: ConvNet(
channels=8, dilations=[1, 2, 4, 8, 16],
batchnorm=False, activation="Tanh",
)),
("lite (12ch, 6 dil)", lambda: ConvNet(
channels=12, dilations=[1, 2, 4, 8, 16, 32],
batchnorm=False, activation="Tanh",
)),
("standard (16ch, 8 dil)", lambda: ConvNet(
channels=16, dilations=[1, 2, 4, 8, 16, 32, 64, 128],
batchnorm=False, activation="Tanh",
)),
("default (32ch, 4 dil)", lambda: ConvNet(
channels=32, dilations=[1, 2, 4, 8],
batchnorm=False, activation="Tanh",
)),
]
return models
def benchmark_model(model_fn, block_size, n_warmup=10, n_runs=500):
"""Run model on block_size samples, return average ms."""
import torch
model = model_fn()
model.eval()
with torch.no_grad():
x = torch.randn(1, block_size)
for _ in range(n_warmup):
_ = model(x)
start = time.perf_counter()
for _ in range(n_runs):
_ = model(x)
elapsed = time.perf_counter() - start
avg_ms = elapsed / n_runs * 1000.0
params = sum(p.numel() for p in model.parameters())
return avg_ms, params
def main():
parser = argparse.ArgumentParser(description="NAM inference benchmark")
parser.add_argument("--quick", action="store_true", help="Only default model, 256-block")
parser.add_argument("--model", type=str, default=None, help="Model name substring to filter")
parser.add_argument("--block", type=int, default=None, help="Block size (override)")
args = parser.parse_args()
models = build_models()
block_sizes = [128, 256, 512]
if args.quick:
block_sizes = [256]
models = [m for m in models if "default" in m[0]]
if args.block:
block_sizes = [args.block]
if args.model:
models = [m for m in models if args.model.lower() in m[0].lower()]
print(f"{'Model':<30} {'Params':>8} {'Block':>6} {'x86 ms':>8} {'RPi ms (4x)':>12} {'RPi ms (8x)':>12} {'Budget%':>8}")
print("-" * 90)
for name, factory in models:
for bs in block_sizes:
block_ms = bs / SAMPLE_RATE * 1000.0
avg_ms, params = benchmark_model(factory, bs)
rpi_low = avg_ms * RPI_SLOWDOWN_LOWER
rpi_high = avg_ms * RPI_SLOWDOWN_UPPER
budget_pct = (avg_ms / block_ms) * 100
ok = "" if rpi_low < block_ms else "" if rpi_high < block_ms else ""
print(f"{ok} {name:<27} {params:>8,} {bs:>6} {avg_ms:>7.3f}ms {rpi_low:>8.2f}ms {rpi_high:>8.2f}ms {budget_pct:>6.1f}%")
print()
print("Legend: ✓ = safe on RPi (4x est < block budget), ⚠ = marginal, ✗ = too slow")
print(f"Block budget: {block_sizes[0] / SAMPLE_RATE * 1000:.2f}ms @ {SAMPLE_RATE}Hz")
print(f"RPi estimate: {RPI_SLOWDOWN_LOWER:.0f}x-{RPI_SLOWDOWN_UPPER:.0f}x × x86 PyTorch CPU time")
if __name__ == "__main__":
main()
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────
# Pi Multi-FX Pedal — IR Pack Downloader
# ──────────────────────────────────────────────────────────────────────
# Downloads free impulse response packs for cabinet simulation.
# Run: ./scripts/download_irs.sh # interactive choose & download
# Run: ./scripts/download_irs.sh --list # list available packs
# Run: ./scripts/download_irs.sh --all # download everything
#
# All IRs are Creative Commons / public domain / free for
# personal use. Check each pack's license for commercial use.
# ──────────────────────────────────────────────────────────────────────
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
IR_DIR="${HOME}/.pedal/irs"
# ── Terminal colours ────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Colour
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERR]${NC} $*" >&2; }
# ── Pre-flight ──────────────────────────────────────────────────────
CURL=""
for cmd in curl wget; do
if command -v "$cmd" &>/dev/null; then
CURL="$cmd"
break
fi
done
if [ -z "$CURL" ]; then
err "Neither curl nor wget found. Install one of them first."
exit 1
fi
download() {
local url="$1"
local dest="$2"
if [ "$CURL" = "curl" ]; then
curl -fsSL -o "$dest" "$url"
else
wget -q -O "$dest" "$url"
fi
}
# Ensure IR directory exists
mkdir -p "$IR_DIR"
# ── Pack definitions ────────────────────────────────────────────────
# Each pack: "name|source_url|filename|description"
# Source URLs verified working. Report broken ones at the project
# issue tracker.
declare -a PACKS=(
"God's Cab|https://archive.org/download/godscab-ir-pack/GodsCab_IR_Pack.zip|godscab.zip|100+ cabinet IRs (412, 1960, V30, Greenback, etc.) — CC0 / Public Domain"
"Seacow Cabs|https://archive.org/download/seacow-cabs-free-ir-2023/Seacow_Cabs_Free_IRs_2023.zip|seacow.zip|Boutique cab IR pack, 15+ cabs — Free for personal use"
)
# ── Actions ──────────────────────────────────────────────────────────
list_packs() {
echo ""
echo "Available free IR packs:"
echo "────────────────────────"
for i in "${!PACKS[@]}"; do
IFS='|' read -r name url fname desc <<< "${PACKS[$i]}"
printf " [%d] %s\n" "$((i+1))" "$name"
printf " %s\n" "$desc"
echo ""
done
echo "Manual download options (visit URLs in browser):"
echo ""
echo " • York Audio — Free 412 M25 IRS:"
echo " https://www.yorkaudio.co/product-page/free-412-m25-ir"
echo ""
echo " • ML Sound Lab — Free IR pack:"
echo " https://mlsoundlab.com/pages/free-stuff"
echo ""
echo " • OwnHammer — Free 412 GNR:"
echo " https://www.ownhammer.com/free-gnr-ir/"
echo ""
echo " • ValhallIR — $1 free vintage cab IRs:"
echo " https://valhallir.com/"
echo ""
echo " • Lancaster Audio — Free Bass IRs:"
echo " https://www.lancasteraudio.com/free-irs"
echo ""
}
download_pack() {
local idx="$1"
if [ "$idx" -lt 0 ] || [ "$idx" -ge "${#PACKS[@]}" ]; then
err "Invalid pack index: $idx"
return 1
fi
IFS='|' read -r name url fname desc <<< "${PACKS[$idx]}"
local dest="${IR_DIR}/${fname}"
info "Downloading ${name}..."
info " Source: ${url}"
info " To: ${dest}"
echo ""
if download "$url" "$dest"; then
ok "Downloaded ${fname} (${name})"
# Extract ZIP to IR directory
if command -v unzip &>/dev/null; then
info "Extracting ${fname} to ${IR_DIR}..."
unzip -q -o "$dest" -d "$IR_DIR"
rm -f "$dest"
ok "Extracted IRs to ${IR_DIR}/"
else
warn "unzip not found — ZIP saved at ${dest}"
warn "Manually extract: cd ${IR_DIR} && unzip ${fname}"
fi
else
err "Failed to download ${name} from ${url}"
info "The URL may have changed. Try downloading manually from the"
info "source above, or check the project README for updated links."
return 1
fi
}
download_all() {
for i in "${!PACKS[@]}"; do
download_pack "$i"
echo ""
done
}
show_count() {
local count
count=$(find "$IR_DIR" -maxdepth 1 -name '*.wav' 2>/dev/null | wc -l)
info "IR directory: ${IR_DIR}"
info "Installed IRs: ${count} .wav files"
if [ "$count" -gt 0 ]; then
echo ""
find "$IR_DIR" -maxdepth 1 -name '*.wav' -printf ' • %f\n' | sort
fi
}
# ── CLI ──────────────────────────────────────────────────────────────
case "${1:-}" in
--list|-l)
list_packs
show_count
;;
--all|-a)
download_all
show_count
;;
--help|-h)
echo "Usage: $0 [OPTION]"
echo ""
echo "Options:"
echo " --list, -l List available IR packs and installed IRs"
echo " --all, -a Download all available IR packs"
echo " --help, -h Show this help"
echo ""
echo "Without options: interactive selection"
exit 0
;;
*)
# Interactive
list_packs
echo ""
echo "Download which pack? (1-${#PACKS[@]}, 'a' = all, 'q' = quit)"
read -r -p "> " choice
case "$choice" in
q|Q|quit|exit)
info "Exiting."
exit 0
;;
a|A|all)
download_all
;;
*)
if [[ "$choice" =~ ^[0-9]+$ ]]; then
download_pack "$((choice - 1))"
else
err "Invalid choice: $choice"
exit 1
fi
;;
esac
show_count
;;
esac
# ── Post-install instructions ───────────────────────────────────────
echo ""
info "All done!"
echo ""
echo " IRs are in: ${IR_DIR}/"
echo ""
echo " To use an IR in a preset:"
echo " 1. Set FXBlock.fx_type = FXType.IR_CAB"
echo " 2. Set FXBlock.ir_file_path = \"${IR_DIR}/your_ir.wav\""
echo " 3. Optionally set FXBlock.params = {\"wet\": 1.0, \"dry\": 0.0}"
echo " 4. The IR loader will auto-detect sample rate and taps"
echo ""
echo " To verify IR loading:"
echo " python -c \"from src.dsp.ir_loader import IRLoader;"
echo " ir = IRLoader(); print(ir.get_irs())\""