Files
pi-multifx-pedal/scripts/seed_default_captures.py
shawn 77a757cee6 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)
2026-06-12 14:02:13 -04:00

469 lines
16 KiB
Python

#!/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()