Add main entry point + systemd services + integration tests

New files:
  main.py                   - PedalApp: boots all subsystems in order,
                              wires MIDI/footswitch callbacks, graceful
                              teardown reverses boot order
  src/system/config.py      - YAML config loader with deep-merge
                              (separated to avoid hardware deps)
  src/system/services.py    - systemd unit generator for pedal.service
                              + multi-fx-pedal.target
  scripts/install_service.sh - copies project, creates venv, installs
                              + enables service units
  tests/test_integration.py - 41 tests: boot, routing, display sync,
                              teardown, systemd content, CLI, edge cases

Modified:
  tests/conftest.py         - add project root to sys.path
This commit is contained in:
2026-06-07 23:39:50 -04:00
parent d9682f3bea
commit c38a7b0fd8
32 changed files with 5428 additions and 342 deletions
+453 -37
View File
@@ -1,22 +1,33 @@
"""NAM A2 model host — load, configure, and run inference on RPi 4B.
"""NAM A2 model host — load, infer, and switch models in real-time.
Leverages `neural-amp-modeler` (nam) Python package or the NAM LV2 plugin
for real-time inference on the Raspberry Pi 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.
Usage:
host = NAMHost()
host.load_model("path/to/model.nam")
output = host.process(input_block) # numpy array in/out
"""
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
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 ────────────────────────────────────────────────────
@dataclass
@@ -24,74 +35,479 @@ class NAMModel:
"""Metadata for a loaded NAM model."""
name: str
path: str
architecture: str # "WaveNet", "Linear", "LSTM"
size_mb: float
sample_rate: int = 48000
latency_samples: int = 0
compatible: bool = True
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)
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 ──────────────────────────────────────────────────────────
class NAMHost:
"""Hosts NAM models for real-time amp simulation.
On RPi 4B, this delegates to either:
1. The NAM LV2 plugin (via JACK/Carla) — for production use
2. The nam Python package — for testing/development
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
"""
def __init__(
self,
models_dir: str | Path = DEFAULT_NAM_DIR,
lv2_dir: str | Path = DEFAULT_LV2_MODEL_DIR,
use_lv2: bool = True,
device: str | None = None,
switch_mode: ModelSwitchMode = ModelSwitchMode.CROSSFADE,
crossfade_samples: int = 256,
):
self._models_dir = Path(models_dir)
self._lv2_dir = Path(lv2_dir)
self._use_lv2 = use_lv2
self._loaded_model: Optional[NAMModel] = None
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 load_model(self, model_path: str) -> bool:
"""Load a NAM model file into the inference engine."""
"""Load a NAM .nam model file into the inference engine.
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.
"""
path = Path(model_path)
if not path.exists() or path.suffix not in (".nam",):
if not path.exists() or path.suffix.lower() != ".nam":
logger.error("Model not found or invalid: %s", model_path)
return False
size_mb = path.stat().st_size / (1024 * 1024)
is_feather = size_mb < 10
# Unload previous model
if self._loaded_model is not None:
self._begin_model_switch()
self._loaded_model = NAMModel(
name=path.stem,
path=str(path),
size_mb=size_mb,
compatible=is_feather,
)
# 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,
)
# 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())
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
return False
self._model_path = cache_key
self._finish_model_switch()
logger.info(
"Loaded NAM model: %s (%.1f MB, %s)",
"Loaded NAM model: %s (%.0f KB, %s, rf=%d, device=%s)",
self._loaded_model.name,
size_mb,
"compatible" if is_feather else "may cause xruns",
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,
)
return True
def unload(self) -> None:
"""Unload the current NAM model."""
"""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 process(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a block of audio through the NAM model.
Args:
audio_in: numpy array of PCM samples (float32 [-1, 1]).
1D (samples,) or 2D (1, samples) shape.
Must be >= receptive_field samples.
Returns:
Processed audio block, same shape as input.
"""
if self._model is None or self._loaded_model is None:
# Pass-through if no model loaded
return audio_in.copy()
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]
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
# 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 ────────────────────────────────────────────────────
@property
def is_loaded(self) -> bool:
return self._loaded_model is not None
@property
def current_model(self) -> Optional[NAMModel]:
return self._loaded_model
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)