77a757cee6
- 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)
469 lines
16 KiB
Python
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()
|