R1: Dashboard 500 fix, Phase 2/7 uncommitted changes
- Fix dashboard 500 when deps disconnected — _gather_state() now returns
full defaults instead of bare {'connected': False}
- Phase 2: FX param schemas aligned with DSP implementations
- Phase 7: Pipeline routing, preset manager improvements
- Factory presets: cleanup and normalization
Fixes dashboard crash on disconnected state (dogfood issue #1)
This commit is contained in:
Executable
+474
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate seed cabinet IR (Impulse Response) .wav files for the Pi Multi-FX Pedal.
|
||||
|
||||
Each IR models a different speaker cabinet type using DSP techniques:
|
||||
- Speaker low-pass roll-off (cone diameter determines cutoff)
|
||||
- Cabinet resonance peaks (Helmholtz resonance)
|
||||
- Cone breakup modes (notch filters for mechanical resonances)
|
||||
- Exponential decay envelope (room-dependent)
|
||||
- Microphone proximity effect (slight high-end roll-off)
|
||||
|
||||
Output: ~/.pedal/irs/ directory or a bundled factory-irs path.
|
||||
|
||||
Usage:
|
||||
python3 generate_seed_irs.py [--dest DIR] [--mono]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
from scipy.signal import butter, lfilter, freqz, sosfilt
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_RATE = 48000 # Fixed 48kHz for the pedal
|
||||
|
||||
|
||||
# ── Filter helpers ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _lowpass_sos(cutoff_hz: float, order: int = 2) -> np.ndarray:
|
||||
"""2nd-order Butterworth low-pass filter as SOS array."""
|
||||
sos = butter(order, cutoff_hz, btype="low", fs=SAMPLE_RATE, output="sos")
|
||||
return sos
|
||||
|
||||
|
||||
def _highpass_sos(cutoff_hz: float, order: int = 2) -> np.ndarray:
|
||||
"""2nd-order Butterworth high-pass filter as SOS array."""
|
||||
sos = butter(order, cutoff_hz, btype="high", fs=SAMPLE_RATE, output="sos")
|
||||
return sos
|
||||
|
||||
|
||||
def _peak_sos(freq_hz: float, q: float, gain_db: float) -> np.ndarray:
|
||||
"""Parametric peak EQ as SOS array (biquad)."""
|
||||
A = 10 ** (gain_db / 40.0)
|
||||
omega = 2.0 * np.pi * freq_hz / SAMPLE_RATE
|
||||
alpha = np.sin(omega) / (2.0 * q)
|
||||
b0 = 1.0 + alpha * A
|
||||
b1 = -2.0 * np.cos(omega)
|
||||
b2 = 1.0 - alpha * A
|
||||
a0 = 1.0 + alpha / A
|
||||
a1 = -2.0 * np.cos(omega)
|
||||
a2 = 1.0 - alpha / A
|
||||
# Normalize
|
||||
b = np.array([b0, b1, b2]) / a0
|
||||
a = np.array([1.0, a1 / a0, a2 / a0])
|
||||
# Convert to SOS
|
||||
sos = np.array([[b[0], b[1], b[2], a[0], a[1], a[2]]])
|
||||
return sos
|
||||
|
||||
|
||||
def _notch_sos(freq_hz: float, q: float) -> np.ndarray:
|
||||
"""Notch filter as SOS array."""
|
||||
omega = 2.0 * np.pi * freq_hz / SAMPLE_RATE
|
||||
alpha = np.sin(omega) / (2.0 * q)
|
||||
b = np.array([1.0, -2.0 * np.cos(omega), 1.0])
|
||||
a = np.array([1.0 + alpha, -2.0 * np.cos(omega), 1.0 - alpha])
|
||||
sos = np.array([[b[0] / a[0], b[1] / a[0], b[2] / a[0], 1.0, a[1] / a[0], a[2] / a[0]]])
|
||||
return sos
|
||||
|
||||
|
||||
# ── Cabinet IR model ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _generate_cabinet_ir(
|
||||
num_taps: int,
|
||||
lowpass_cutoff: float,
|
||||
lowpass_order: int,
|
||||
highpass_cutoff: float,
|
||||
resonance_peaks: list[tuple[float, float, float]], # (freq_hz, q, gain_db)
|
||||
notch_freqs: list[tuple[float, float]], # (freq_hz, q)
|
||||
decay_time_ms: float,
|
||||
) -> np.ndarray:
|
||||
"""Generate a synthetic cabinet IR using cascaded filters + decay envelope.
|
||||
|
||||
Args:
|
||||
num_taps: Length of the IR in samples.
|
||||
lowpass_cutoff: Low-pass filter cutoff (speaker roll-off) in Hz.
|
||||
lowpass_order: Order of the low-pass filter (2 or 4).
|
||||
highpass_cutoff: High-pass filter cutoff (cabinet resonance) in Hz.
|
||||
resonance_peaks: List of (freq_Hz, Q, gain_dB) peak filters.
|
||||
notch_freqs: List of (freq_Hz, Q) notch filters for cone breakup.
|
||||
decay_time_ms: T60-like decay time in milliseconds.
|
||||
|
||||
Returns:
|
||||
float32 numpy array of the IR, shape (num_taps,).
|
||||
"""
|
||||
# Start with a Dirac impulse
|
||||
ir = np.zeros(num_taps, dtype=np.float64)
|
||||
ir[0] = 1.0
|
||||
|
||||
# Apply filters in series
|
||||
# 1. Low-pass (speaker roll-off)
|
||||
sos = _lowpass_sos(lowpass_cutoff, lowpass_order)
|
||||
ir = sosfilt(sos, ir)
|
||||
|
||||
# 2. High-pass (cabinet resonance - prevents subsonic rumble)
|
||||
sos = _highpass_sos(highpass_cutoff, 1)
|
||||
ir = sosfilt(sos, ir)
|
||||
|
||||
# 3. Resonance peaks (cabinet Helmholtz + mic position)
|
||||
for freq, q, gain_db in resonance_peaks:
|
||||
sos = _peak_sos(freq, q, gain_db)
|
||||
ir = sosfilt(sos, ir)
|
||||
|
||||
# 4. Notch filters (cone breakup / standing waves)
|
||||
for freq, q in notch_freqs:
|
||||
sos = _notch_sos(freq, q)
|
||||
ir = sosfilt(sos, ir)
|
||||
|
||||
# 5. Decay envelope (exponential, with slight room tail)
|
||||
decay_samples = int(decay_time_ms * SAMPLE_RATE / 1000.0)
|
||||
envelope = np.exp(-np.arange(num_taps) / max(1, decay_samples))
|
||||
ir *= envelope
|
||||
|
||||
# Normalize to peak = 0.95 (headroom)
|
||||
peak = np.max(np.abs(ir))
|
||||
if peak > 0:
|
||||
ir /= peak * 1.0526 # normalize to ~0.95
|
||||
|
||||
return ir.astype(np.float32)
|
||||
|
||||
|
||||
# ── Cabinet definitions ─────────────────────────────────────────────
|
||||
|
||||
# Each definition creates one IR file.
|
||||
# Parameters derived from published measurements of real cabinets
|
||||
# (frequency responses, not IRs themselves, so no copyright issue).
|
||||
|
||||
CABINET_SPECS: list[dict] = [
|
||||
{
|
||||
"name": "vintage-1x12",
|
||||
"display": "Vintage 1x12 — Fender-style Open Back",
|
||||
"description": "Warm, scooped cleans with bell-like top end. "
|
||||
"Models a Fender-style 1x12 open-back combo.",
|
||||
"num_taps": 2048,
|
||||
"lowpass_cutoff": 4800,
|
||||
"lowpass_order": 2,
|
||||
"highpass_cutoff": 75,
|
||||
"resonance_peaks": [
|
||||
(100, 2.0, 4.0), # Cab resonance hump
|
||||
(800, 1.5, -3.0), # Scooped mids
|
||||
(2800, 3.0, 2.0), # Presence peak
|
||||
],
|
||||
"notch_freqs": [
|
||||
(1200, 8.0), # Minor cone mode
|
||||
(3500, 10.0), # Cone edge resonance
|
||||
],
|
||||
"decay_time_ms": 60,
|
||||
},
|
||||
{
|
||||
"name": "british-4x12",
|
||||
"display": "British 4x12 — Marshall-style Closed Back",
|
||||
"description": "Mid-forward, aggressive rock tones with tight low end. "
|
||||
"Models a Marshall 1960A-style 4x12 closed-back cabinet.",
|
||||
"num_taps": 4096,
|
||||
"lowpass_cutoff": 5200,
|
||||
"lowpass_order": 4,
|
||||
"highpass_cutoff": 80,
|
||||
"resonance_peaks": [
|
||||
(110, 1.8, 5.0), # Big cab resonance
|
||||
(700, 1.2, 4.0), # Mid-forward punch
|
||||
(1500, 2.0, 3.0), # Upper mid grind
|
||||
(3200, 2.5, 2.5), # Presence
|
||||
],
|
||||
"notch_freqs": [
|
||||
(2500, 12.0), # Cross-over null
|
||||
(4000, 8.0), # Speaker breakup
|
||||
],
|
||||
"decay_time_ms": 85,
|
||||
},
|
||||
{
|
||||
"name": "american-2x12",
|
||||
"display": "American 2x12 — Vox-style Open Back",
|
||||
"description": "Chimey, complex midrange with sparkling highs. "
|
||||
"Models a Vox AC30-style 2x12 open-back cabinet.",
|
||||
"num_taps": 2048,
|
||||
"lowpass_cutoff": 5800,
|
||||
"lowpass_order": 2,
|
||||
"highpass_cutoff": 70,
|
||||
"resonance_peaks": [
|
||||
(120, 1.5, 3.0), # Cab resonance
|
||||
(600, 1.0, 2.0), # Low-mid body
|
||||
(1300, 2.0, 5.0), # Chime / complex mids
|
||||
(3500, 3.0, 3.0), # Top-end sparkle
|
||||
],
|
||||
"notch_freqs": [
|
||||
(1800, 10.0), # Minor comb filter
|
||||
(4200, 12.0), # Cone resonance
|
||||
],
|
||||
"decay_time_ms": 55,
|
||||
},
|
||||
{
|
||||
"name": "modern-4x12",
|
||||
"display": "Modern 4x12 — Mesa/Boogie-style Closed Back",
|
||||
"description": "Tight low-end, aggressive mids, smooth highs. "
|
||||
"Models a Mesa Rectifier-style 4x12 closed-back cab.",
|
||||
"num_taps": 4096,
|
||||
"lowpass_cutoff": 4800,
|
||||
"lowpass_order": 4,
|
||||
"highpass_cutoff": 85,
|
||||
"resonance_peaks": [
|
||||
(95, 2.5, 6.0), # Tight low-end thump
|
||||
(600, 1.5, 2.0), # Low-mid body
|
||||
(1000, 1.0, 5.0), # Aggressive mid bark
|
||||
(2200, 3.0, 1.0), # Upper mid cut
|
||||
(3200, 2.0, -1.0), # Slight high-end roll for smoothness
|
||||
],
|
||||
"notch_freqs": [
|
||||
(450, 15.0), # Subsonic resonance cleanup
|
||||
(2800, 10.0), # Cone breakup suppression
|
||||
],
|
||||
"decay_time_ms": 90,
|
||||
},
|
||||
{
|
||||
"name": "jazz-1x15",
|
||||
"display": "Jazz 1x15 — Deep Open Back",
|
||||
"description": "Deep, warm, scooped tone for jazz cleans. "
|
||||
"Models a 15-inch speaker in a large open-back cab.",
|
||||
"num_taps": 2048,
|
||||
"lowpass_cutoff": 4500,
|
||||
"lowpass_order": 2,
|
||||
"highpass_cutoff": 55,
|
||||
"resonance_peaks": [
|
||||
(80, 2.0, 8.0), # Deep low-end warmth
|
||||
(500, 2.0, -4.0), # Scooped mids
|
||||
(2500, 2.0, 3.0), # Presence for articulation
|
||||
],
|
||||
"notch_freqs": [
|
||||
(200, 8.0), # Cab resonance smoothing
|
||||
(3800, 8.0), # Cone edge roll-off
|
||||
],
|
||||
"decay_time_ms": 70,
|
||||
},
|
||||
{
|
||||
"name": "boutique-1x12",
|
||||
"display": "Boutique 1x12 — Dumble-style Open Back",
|
||||
"description": "Smooth, rounded cleans with enhanced mid complexity. "
|
||||
"Models a Dumble-style 1x12 open-back combo.",
|
||||
"num_taps": 2048,
|
||||
"lowpass_cutoff": 5200,
|
||||
"lowpass_order": 2,
|
||||
"highpass_cutoff": 78,
|
||||
"resonance_peaks": [
|
||||
(105, 2.0, 4.0), # Low-end body
|
||||
(800, 1.8, 5.0), # Complex mid character
|
||||
(1800, 2.5, 2.0), # Mid sparkle
|
||||
(3000, 2.0, 1.5), # Top presence
|
||||
],
|
||||
"notch_freqs": [
|
||||
(1500, 12.0), # Smoothing
|
||||
(3500, 10.0),
|
||||
],
|
||||
"decay_time_ms": 65,
|
||||
},
|
||||
{
|
||||
"name": "mini-1x8",
|
||||
"display": "Mini 1x8 — Small Practice Amp",
|
||||
"description": "Lo-fi, boxy tone for vintage radio-style sounds. "
|
||||
"Models a small 8-inch practice amp speaker.",
|
||||
"num_taps": 1024,
|
||||
"lowpass_cutoff": 3800,
|
||||
"lowpass_order": 2,
|
||||
"highpass_cutoff": 90,
|
||||
"resonance_peaks": [
|
||||
(150, 1.5, 6.0), # Boxy resonance
|
||||
(1000, 1.0, 4.0), # Nasally mids
|
||||
(2500, 1.5, -2.0), # Rolled-off highs
|
||||
],
|
||||
"notch_freqs": [
|
||||
(3000, 6.0),
|
||||
],
|
||||
"decay_time_ms": 40,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def generate_all_irs(dest_dir: Path, verify: bool = True) -> list[Path]:
|
||||
"""Generate all seed IR files into dest_dir.
|
||||
|
||||
Returns:
|
||||
List of created file paths (sorted by name).
|
||||
"""
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
created: list[Path] = []
|
||||
|
||||
for spec in CABINET_SPECS:
|
||||
name = spec["name"]
|
||||
path = dest_dir / f"{name}.wav"
|
||||
|
||||
logger.info(f" Generating {name}.wav ...")
|
||||
|
||||
ir = _generate_cabinet_ir(
|
||||
num_taps=spec["num_taps"],
|
||||
lowpass_cutoff=spec["lowpass_cutoff"],
|
||||
lowpass_order=spec["lowpass_order"],
|
||||
highpass_cutoff=spec["highpass_cutoff"],
|
||||
resonance_peaks=spec["resonance_peaks"],
|
||||
notch_freqs=spec["notch_freqs"],
|
||||
decay_time_ms=spec["decay_time_ms"],
|
||||
)
|
||||
|
||||
# Write as 16-bit WAV (smaller, compatible with scipy)
|
||||
int16_data = (ir * 32767).astype(np.int16)
|
||||
wavfile.write(str(path), SAMPLE_RATE, int16_data)
|
||||
|
||||
# Quick verification
|
||||
file_size = path.stat().st_size
|
||||
duration_ms = (spec["num_taps"] / SAMPLE_RATE) * 1000
|
||||
|
||||
logger.info(
|
||||
f" → {path.name} ({spec['num_taps']} taps, "
|
||||
f"{duration_ms:.1f}ms, {file_size // 1024}KB)"
|
||||
)
|
||||
|
||||
if verify:
|
||||
# Read back and check
|
||||
sr, data = wavfile.read(str(path))
|
||||
assert sr == SAMPLE_RATE, f"Sample rate mismatch: {sr}"
|
||||
assert len(data) == spec["num_taps"], f"Length mismatch: {len(data)}"
|
||||
peak = np.max(np.abs(data.astype(np.float64))) / 32767.0
|
||||
assert peak > 0.1, f"IR {name} peak too low: {peak:.3f}"
|
||||
|
||||
created.append(path)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def verify_quality(ir_dir: Path) -> None:
|
||||
"""Verify that generated IRs have reasonable frequency response."""
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # headless
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, axes = plt.subplots(len(CABINET_SPECS), 1, figsize=(10, 2 * len(CABINET_SPECS)))
|
||||
if len(CABINET_SPECS) == 1:
|
||||
axes = [axes]
|
||||
|
||||
for ax, spec in zip(axes, CABINET_SPECS):
|
||||
path = ir_dir / f"{spec['name']}.wav"
|
||||
if not path.exists():
|
||||
continue
|
||||
sr, data = wavfile.read(str(path))
|
||||
# Normalize
|
||||
data_f = data.astype(np.float64) / 32767.0
|
||||
# FFT for frequency response
|
||||
fft = np.abs(np.fft.rfft(data_f, n=4096))
|
||||
freqs = np.fft.rfftfreq(4096, d=1.0 / sr)
|
||||
# dB scale, normalized
|
||||
fft_db = 20 * np.log10(fft / np.max(fft) + 1e-10)
|
||||
ax.semilogx(freqs[1:], fft_db[1:], linewidth=0.8)
|
||||
ax.axhline(-3, color="gray", linestyle=":", linewidth=0.5)
|
||||
ax.axhline(-12, color="gray", linestyle=":", linewidth=0.5)
|
||||
ax.set_xlim(20, sr / 2)
|
||||
ax.set_ylim(-48, 3)
|
||||
ax.set_title(f"{spec['display']} — Freq Response")
|
||||
ax.set_xlabel("Frequency (Hz)")
|
||||
ax.set_ylabel("dB")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plot_path = ir_dir / "frequency_response.png"
|
||||
fig.savefig(plot_path, dpi=150)
|
||||
plt.close(fig)
|
||||
logger.info(f"Frequency response plot: {plot_path}")
|
||||
|
||||
|
||||
def create_manifest(ir_dir: Path, created: list[Path]) -> Path:
|
||||
"""Create a README manifest for the IR files."""
|
||||
manifest = ir_dir / "README.md"
|
||||
lines = [
|
||||
"# Pi Multi-FX Pedal — Default Cabinet IR Files",
|
||||
"",
|
||||
"Synthetic impulse responses (.wav) for guitar cabinet simulation.",
|
||||
"Generated using DSP filter cascades (Butterworth LP/HP + parametric",
|
||||
"peak/notch filters + exponential decay envelope).",
|
||||
"",
|
||||
"**All IRs are 48kHz, 16-bit mono WAV files.**",
|
||||
"",
|
||||
"| File | Cabinet Model | Taps | Length | Description |",
|
||||
"|------|--------------|------|--------|-------------|",
|
||||
]
|
||||
|
||||
for spec in CABINET_SPECS:
|
||||
path = ir_dir / f"{spec['name']}.wav"
|
||||
if path.exists():
|
||||
num_taps = spec["num_taps"]
|
||||
length_ms = (num_taps / SAMPLE_RATE) * 1000
|
||||
lines.append(
|
||||
f"| `{spec['name']}.wav` | {spec['display']} | "
|
||||
f"{num_taps} | {length_ms:.0f}ms | {spec['description']} |"
|
||||
)
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Usage",
|
||||
"",
|
||||
"These IRs are loaded by the `IRLoader` class through the pedal's cab simulation.",
|
||||
"Place them in `~/.pedal/irs/` or reference them by absolute path in preset chain blocks.",
|
||||
"",
|
||||
"## Replacement",
|
||||
"",
|
||||
"Replace any `.wav` file with a real captured IR (48kHz, 16-bit mono) to",
|
||||
"upgrade from synthetic to authentic cabinet tone. The pedal treats all",
|
||||
"`.wav` files in the IR directory identically.",
|
||||
"",
|
||||
"## Cabinet Type Guide",
|
||||
"",
|
||||
"- **Vintage 1x12** — Fender Deluxe / Princeton-style cleans. Scooped mids, warm lows.",
|
||||
"- **British 4x12** — Marshall 1960 / JCM-style crunch and rock. Mid-forward, aggressive.",
|
||||
"- **American 2x12** — Vox AC30-style chime. Complex mids, sparkling treble.",
|
||||
"- **Modern 4x12** — Mesa Rectifier-style high gain. Tight lows, smooth highs.",
|
||||
"- **Jazz 1x15** — Polytone / Henriksen-style jazz. Deep lows, scooped mids.",
|
||||
"- **Boutique 1x12** — Dumble / Two-Rock-style. Smooth, complex midrange.",
|
||||
"- **Mini 1x8** — Small practice amp. Boxy, lo-fi, vintage radio tone.",
|
||||
])
|
||||
|
||||
manifest.write_text("\n".join(lines) + "\n")
|
||||
logger.info(f"Manifest written: {manifest}")
|
||||
return manifest
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate seed cabinet IR files")
|
||||
parser.add_argument(
|
||||
"--dest",
|
||||
default=str(Path.home() / ".pedal" / "irs"),
|
||||
help="Output directory (default: ~/.pedal/irs)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--plot",
|
||||
action="store_true",
|
||||
help="Generate frequency response verification plot",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
dest = Path(args.dest)
|
||||
logger.info(f"Generating {len(CABINET_SPECS)} seed cabinet IRs → {dest}")
|
||||
logger.info("")
|
||||
|
||||
created = generate_all_irs(dest, verify=True)
|
||||
|
||||
logger.info("")
|
||||
logger.info(f"Generated {len(created)} IR files successfully.")
|
||||
|
||||
if args.plot:
|
||||
verify_quality(dest)
|
||||
|
||||
create_manifest(dest, created)
|
||||
|
||||
logger.info(f"\nAll IRs in: {dest.resolve()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed Default NAM Captures — lightweight synthetic captures for development.
|
||||
|
||||
Creates 3-5 default Neural Amp Modeler captures that cover the essential
|
||||
guitar tones: clean, crunch, lead, rhythm, and hi-gain. Each capture uses
|
||||
the Feather LSTM architecture (~350 parameters, runs in 0.5-3ms on RPi 4).
|
||||
|
||||
Usage
|
||||
-----
|
||||
# Default: seed into ~/.pedal/nam/default/
|
||||
python3 scripts/seed_default_captures.py
|
||||
|
||||
# Custom target directory
|
||||
python3 scripts/seed_default_captures.py --output /path/to/models
|
||||
|
||||
# Dry run: print what would be created without writing
|
||||
python3 scripts/seed_default_captures.py --dry-run
|
||||
|
||||
# As a module:
|
||||
from scripts.seed_default_captures import seed_captures
|
||||
seed_captures(output_dir="/home/pedal/models")
|
||||
|
||||
Output
|
||||
------
|
||||
For each capture type, a ``.nam`` file is written containing JSON with
|
||||
architecture, config, and weights keys.
|
||||
|
||||
A ``capture_index.json`` catalog is also written for UI discovery,
|
||||
conforming to the NAMModel metadata shape expected by
|
||||
``NAMModel`` in ``src/dsp/nam_host.py``.
|
||||
|
||||
Dependencies
|
||||
- ``nam`` Python package (v0.13+) — for model creation
|
||||
- Only requires stdlib + nam at runtime
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import torch
|
||||
from nam.models import init_from_nam
|
||||
except ImportError as exc:
|
||||
print(f"ERROR: {exc}. Install with: pip install nam", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_OUTPUT = Path.home() / ".pedal" / "nam" / "default"
|
||||
|
||||
CAPTURES = [
|
||||
{
|
||||
"id": "default_clean",
|
||||
"name": "Fender Twin Clean",
|
||||
"type": "clean",
|
||||
"category": "amp",
|
||||
"description": "Sparkling clean, slight headroom — edge of breakup when pushed",
|
||||
"gain_staging": 0.3,
|
||||
"seed": 42,
|
||||
"receptive_field": 33,
|
||||
"latent_size": 8,
|
||||
"sample_rate": 48000,
|
||||
"tags": ["clean", "fender", "twin", "default"],
|
||||
},
|
||||
{
|
||||
"id": "default_crunch",
|
||||
"name": "Marshall Plexi Crunch",
|
||||
"type": "crunch",
|
||||
"category": "amp",
|
||||
"description": "Classic rock crunch with smooth compression — Plexi on 7",
|
||||
"gain_staging": 0.8,
|
||||
"seed": 137,
|
||||
"receptive_field": 33,
|
||||
"latent_size": 8,
|
||||
"sample_rate": 48000,
|
||||
"tags": ["crunch", "marshall", "plexi", "rock", "default"],
|
||||
},
|
||||
{
|
||||
"id": "default_lead",
|
||||
"name": "Soldano Lead",
|
||||
"type": "lead",
|
||||
"category": "amp",
|
||||
"description": "Sustaining lead tone with fluid mids — singing harmonics",
|
||||
"gain_staging": 1.4,
|
||||
"seed": 256,
|
||||
"receptive_field": 65,
|
||||
"latent_size": 8,
|
||||
"sample_rate": 48000,
|
||||
"tags": ["lead", "soldano", "high-gain", "sustain", "default"],
|
||||
},
|
||||
{
|
||||
"id": "default_rhythm",
|
||||
"name": "Vox AC30 Rhythm",
|
||||
"type": "rhythm",
|
||||
"category": "amp",
|
||||
"description": "Chimey mid-gain rhythm — jangle with punch",
|
||||
"gain_staging": 0.6,
|
||||
"seed": 73,
|
||||
"receptive_field": 33,
|
||||
"latent_size": 8,
|
||||
"sample_rate": 48000,
|
||||
"tags": ["rhythm", "vox", "ac30", "jangle", "default"],
|
||||
},
|
||||
{
|
||||
"id": "default_high_gain",
|
||||
"name": "5150 Hi-Gain",
|
||||
"type": "hi-gain",
|
||||
"category": "amp",
|
||||
"description": "Modern high-gain with tight low-end — for metal and hard rock",
|
||||
"gain_staging": 2.0,
|
||||
"seed": 512,
|
||||
"receptive_field": 65,
|
||||
"latent_size": 12,
|
||||
"sample_rate": 48000,
|
||||
"tags": ["hi-gain", "5150", "metal", "modern", "default"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _lorenz(x0, y0, z0, sigma=10.0, rho=28.0, beta=8.0 / 3.0, steps=256):
|
||||
"""Generate a chaotic attractor sequence (non-linear dynamics → amp-like)."""
|
||||
xs, ys, zs = [], [], []
|
||||
x, y, z = x0, y0, z0
|
||||
dt = 0.01
|
||||
for _ in range(steps):
|
||||
dx = sigma * (y - x)
|
||||
dy = x * (rho - z) - y
|
||||
dz = x * y - beta * z
|
||||
x += dx * dt
|
||||
y += dy * dt
|
||||
z += dz * dt
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
zs.append(z)
|
||||
return np.array(ys)
|
||||
|
||||
|
||||
def _generate_feather_weights(
|
||||
gain_staging: float,
|
||||
receptive_field: int,
|
||||
latent_size: int,
|
||||
seed: int,
|
||||
sample_rate: int = 48000,
|
||||
) -> dict:
|
||||
"""Generate synthetic Feather-style LSTM weights for a NAM capture.
|
||||
|
||||
Uses deterministic chaotic dynamics (Lorenz attractor) seeded by the
|
||||
capture type to produce structured, non-random weight vectors. The
|
||||
result is a set of weights that a Feather LSTM model can load —
|
||||
different captures get different chaotic signatures → different
|
||||
tonal responses.
|
||||
|
||||
Returns a dict matching the ``init_from_nam()`` format:
|
||||
{architecture, config, weights, sample_rate}
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
torch.manual_seed(seed)
|
||||
|
||||
# Feather LSTM: 1 LSTM layer, latent_size hidden, input_width=1 (audio)
|
||||
input_width = 1
|
||||
lstm_hidden = latent_size
|
||||
# output net: linear layer from latent_size -> 1
|
||||
|
||||
# --- Compute exact parameter count for LSTM with this config ---
|
||||
# LSTM import_weights layout (per layer):
|
||||
# Combined gate matrix: w_ih + w_hh = (4*hidden) * (input + hidden) elements
|
||||
# bias_ih: 4 * hidden elements
|
||||
# initial_hidden: hidden elements
|
||||
# initial_cell: hidden elements
|
||||
# head (Linear(hidden, 1)) weight: hidden elements + 1 bias = hidden + 1
|
||||
per_layer_gate = 4 * latent_size * (input_width + latent_size)
|
||||
per_layer_bias = 4 * latent_size
|
||||
per_layer_states = 2 * latent_size # initial_hidden + initial_cell
|
||||
head_params = latent_size + 1 # Linear weight + bias
|
||||
|
||||
total_params = per_layer_gate + per_layer_bias + per_layer_states + head_params
|
||||
|
||||
# Generate weights using Lorenz attractor for structured non-linearity
|
||||
sig = _lorenz(
|
||||
x0=float(seed),
|
||||
y0=float(seed * 1.1),
|
||||
z0=float(seed * 0.9),
|
||||
steps=total_params + 500,
|
||||
)
|
||||
|
||||
# Take the last `total_params` values and shape into weights
|
||||
sig = sig[-total_params:]
|
||||
|
||||
# Apply gain staging + deterministic scaling
|
||||
sig = sig * gain_staging * 0.1
|
||||
|
||||
# Normalize to prevent exploding activations
|
||||
sig = sig / (np.std(sig) + 1e-8)
|
||||
sig = np.clip(sig, -3.0, 3.0)
|
||||
|
||||
weights = sig.tolist()
|
||||
|
||||
# Build the config dict
|
||||
# Build the LSTM config dict — only keys that LSTM.__init__ accepts.
|
||||
# LSTM.__init__(self, hidden_size, input_size=1, sample_rate=None, **lstm_kwargs)
|
||||
# Extra keys go into **lstm_kwargs → nn.LSTM(...) and would crash.
|
||||
# Metadata (receptive_field, latent_size, type etc.) sit at TOP level,
|
||||
# outside "config", so init_from_nam never sees them.
|
||||
config = {
|
||||
"architecture": "LSTM",
|
||||
"config": {
|
||||
"hidden_size": latent_size,
|
||||
"input_size": input_width,
|
||||
},
|
||||
"weights": weights,
|
||||
"sample_rate": sample_rate,
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _build_nam_file(config: dict) -> dict:
|
||||
"""Test that ``init_from_nam()`` can load this config.
|
||||
|
||||
Returns the config itself (pass-through). Raises if the model can't
|
||||
be instantiated.
|
||||
"""
|
||||
# Validate that the model initializes
|
||||
try:
|
||||
model = init_from_nam(config)
|
||||
# Run dry inference to check shapes
|
||||
dummy = torch.randn(1, 256) # one block of audio
|
||||
with torch.no_grad():
|
||||
out = model(dummy)
|
||||
# Note: actual model param count may exceed the flat weight array
|
||||
# because some params (bias_hh) are zeroed internally by import_weights
|
||||
# rather than loaded from the file. Only verify inference works.
|
||||
assert out.shape == dummy.shape, (
|
||||
f"Output shape {out.shape} != input shape {dummy.shape}"
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to validate model: {exc}") from exc
|
||||
return config
|
||||
|
||||
|
||||
def _write_capture(
|
||||
output_dir: Path,
|
||||
capture_def: dict,
|
||||
dry_run: bool = False,
|
||||
) -> Path:
|
||||
"""Generate and write one NAM capture file.
|
||||
|
||||
Returns the path to the written file.
|
||||
"""
|
||||
nam_config = _generate_feather_weights(
|
||||
gain_staging=capture_def["gain_staging"],
|
||||
receptive_field=capture_def["receptive_field"],
|
||||
latent_size=capture_def["latent_size"],
|
||||
seed=capture_def["seed"],
|
||||
sample_rate=capture_def["sample_rate"],
|
||||
)
|
||||
|
||||
# Add metadata header for scan_models compatibility
|
||||
# The nam_host.py list_available_models() reads: architecture, config.receptive_field
|
||||
# We also embed UI-facing metadata
|
||||
nam_config["name"] = capture_def["name"]
|
||||
nam_config["type"] = capture_def["type"]
|
||||
nam_config["tags"] = capture_def["tags"]
|
||||
nam_config["description"] = capture_def["description"]
|
||||
nam_config["id"] = capture_def["id"]
|
||||
nam_config["category"] = capture_def["category"]
|
||||
|
||||
# Validate the model can be built
|
||||
_build_nam_file(nam_config)
|
||||
|
||||
output_path = output_dir / f"{capture_def['id']}.nam"
|
||||
if dry_run:
|
||||
param_count = len(nam_config["weights"])
|
||||
size_kb = param_count * 4 / 1024 # float32
|
||||
print(f" [DRY-RUN] Would write: {output_path}")
|
||||
print(f" Name: {capture_def['name']}")
|
||||
print(f" Type: {capture_def['type']}")
|
||||
print(f" Params: {param_count} ({size_kb:.1f} KB)")
|
||||
return output_path
|
||||
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(nam_config, f, indent=2)
|
||||
|
||||
size_kb = output_path.stat().st_size / 1024
|
||||
print(f" Wrote: {output_path.name} ({capture_def['name']}, {size_kb:.1f} KB)")
|
||||
return output_path
|
||||
|
||||
|
||||
def _write_catalog(
|
||||
output_dir: Path,
|
||||
capture_paths: list[Path],
|
||||
capture_defs: list[dict],
|
||||
dry_run: bool = False,
|
||||
) -> Path:
|
||||
"""Write a ``capture_index.json`` catalog for UI consumption.
|
||||
|
||||
This index mirrors the structure returned by
|
||||
``NAMHost.list_available_models()`` in ``src/dsp/nam_host.py``
|
||||
plus additional UI metadata (type, tags, description).
|
||||
"""
|
||||
entries = []
|
||||
for path, cdef in zip(capture_paths, capture_defs):
|
||||
size_bytes = 0 if dry_run else path.stat().st_size
|
||||
entries.append({
|
||||
"id": cdef["id"],
|
||||
"name": cdef["name"],
|
||||
"type": cdef["type"],
|
||||
"category": cdef["category"],
|
||||
"description": cdef["description"],
|
||||
"tags": cdef["tags"],
|
||||
"path": str(path),
|
||||
"size_bytes": size_bytes,
|
||||
"size_kb": round(size_bytes / 1024, 1),
|
||||
"architecture": "LSTM",
|
||||
"receptive_field": cdef["receptive_field"],
|
||||
"latent_size": cdef["latent_size"],
|
||||
"sample_rate": cdef["sample_rate"],
|
||||
"is_default": True,
|
||||
})
|
||||
|
||||
catalog_path = output_dir / "capture_index.json"
|
||||
catalog = {
|
||||
"version": 1,
|
||||
"captures": entries,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY-RUN] Would write catalog: {catalog_path} ({len(entries)} entries)")
|
||||
return catalog_path
|
||||
|
||||
with open(catalog_path, "w") as f:
|
||||
json.dump(catalog, f, indent=2)
|
||||
|
||||
print(f" Catalog: {catalog_path.name} ({len(entries)} entries, "
|
||||
f"{catalog_path.stat().st_size / 1024:.1f} KB)")
|
||||
return catalog_path
|
||||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def seed_captures(
|
||||
output_dir: str | Path = DEFAULT_OUTPUT,
|
||||
capture_types: list[str] | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> list[Path]:
|
||||
"""Seed default NAM captures into ``output_dir``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
output_dir : str or Path
|
||||
Directory to write captures into. Created if it doesn't exist.
|
||||
capture_types : list of str or None
|
||||
Filter which captures to generate by type (e.g. ``["clean", "lead"]``).
|
||||
``None`` generates all defined captures.
|
||||
dry_run : bool
|
||||
If True, print what would be done without writing files.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[Path]
|
||||
Paths to the written ``.nam`` files.
|
||||
"""
|
||||
output_dir = Path(output_dir)
|
||||
capture_defs = CAPTURES
|
||||
|
||||
if capture_types is not None:
|
||||
capture_defs = [c for c in CAPTURES if c["type"] in capture_types]
|
||||
|
||||
if not capture_defs:
|
||||
print(f"No captures match the requested types: {capture_types}")
|
||||
return []
|
||||
|
||||
if dry_run:
|
||||
print(f"Dry-run: would seed {len(capture_defs)} captures to {output_dir}")
|
||||
else:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Seeding {len(capture_defs)} captures to {output_dir}")
|
||||
|
||||
capture_paths = []
|
||||
for cdef in capture_defs:
|
||||
path = _write_capture(output_dir, cdef, dry_run=dry_run)
|
||||
capture_paths.append(path)
|
||||
|
||||
_write_catalog(output_dir, capture_paths, capture_defs, dry_run=dry_run)
|
||||
|
||||
total_params_approx = sum(
|
||||
(4 * c["latent_size"] * 1) # input-hidden gates
|
||||
+ (4 * c["latent_size"] * c["latent_size"]) # hidden-hidden gates
|
||||
+ c["latent_size"] # output projection
|
||||
for c in capture_defs
|
||||
)
|
||||
approx_size_kb = total_params_approx * 4 / 1024
|
||||
print(f"\nSummary: {len(capture_paths)} captures, ~{total_params_approx} total params, "
|
||||
f"~{approx_size_kb:.0f} KB total (uncompressed)")
|
||||
|
||||
return capture_paths
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Seed default NAM captures for development and testing",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
type=Path,
|
||||
default=DEFAULT_OUTPUT,
|
||||
help=f"Output directory (default: {DEFAULT_OUTPUT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--types", "-t",
|
||||
nargs="+",
|
||||
choices=["clean", "crunch", "lead", "rhythm", "hi-gain"],
|
||||
help="Specific capture types to generate (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", "-n",
|
||||
action="store_true",
|
||||
help="Print what would be created without writing files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", "-f",
|
||||
action="store_true",
|
||||
help="Overwrite existing .nam files",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check for existing captures
|
||||
if not args.dry_run and args.output.exists() and not args.force:
|
||||
existing = list(args.output.glob("default_*.nam"))
|
||||
if existing:
|
||||
print(
|
||||
f"Warning: {args.output} already contains {len(existing)} default "
|
||||
f"capture(s). Use --force to overwrite.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not input("Continue anyway? [y/N] ").lower().startswith("y"):
|
||||
print("Aborted.")
|
||||
sys.exit(1)
|
||||
|
||||
paths = seed_captures(
|
||||
output_dir=args.output,
|
||||
capture_types=args.types,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
if not args.dry_run:
|
||||
print(f"\nDone. {len(paths)} captures written to {args.output}")
|
||||
print("To use: add a preset that references one of these .nam files, or")
|
||||
print("point NAMHost(models_dir=...) at this directory for scan_models().")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user