c38a7b0fd8
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
404 lines
15 KiB
Python
404 lines
15 KiB
Python
"""Tests for NAM model host — loading, inference, and model switching.
|
|
|
|
Tests use synthetically generated .nam model files so no
|
|
external downloads are required. The test helper `make_test_nam()`
|
|
generates valid TorchScript-able NAM models using the nam library.
|
|
|
|
Uses real NAM model inference via the `nam` Python package,
|
|
so these are integration tests, not mocks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from src.dsp.nam_host import NAMHost, NAMModel, ModelSwitchMode
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_nam_config(arch: str = "Linear", num_weights: int = 1200) -> dict:
|
|
"""Create a minimal valid NAM model config dict.
|
|
|
|
Generates random weights so the model compiles and runs.
|
|
The resulting .nam file is tiny (~5 KB) and suitable for testing.
|
|
|
|
Uses Linear architecture (simplest, supported by init_from_nam).
|
|
|
|
Args:
|
|
arch: Model architecture (only "Linear" and "WaveNet" are
|
|
supported by init_from_nam in the nam package).
|
|
num_weights: Number of weight parameters.
|
|
|
|
Returns:
|
|
A dict that can be serialized to a .nam file.
|
|
"""
|
|
rng = np.random.RandomState(42)
|
|
|
|
if arch == "Linear":
|
|
config = {
|
|
"receptive_field": 16,
|
|
}
|
|
elif arch == "WaveNet":
|
|
config = {
|
|
"layers": [{
|
|
"channels": [4] * 3,
|
|
"kernel_size": 3,
|
|
"dilation_base": 2,
|
|
"activation": [{"type": "Tanh"}],
|
|
"gating": True,
|
|
"head": {"channels": [4, 1], "kernel_size": 3},
|
|
"head_scale": 1.0,
|
|
}],
|
|
}
|
|
else:
|
|
raise ValueError(f"Test helper only supports Linear/WaveNet (got {arch})")
|
|
|
|
return {
|
|
"version": "0.13.0",
|
|
"architecture": arch,
|
|
"config": config,
|
|
"sample_rate": 48000,
|
|
"weights": rng.uniform(-0.5, 0.5, num_weights).tolist(),
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def test_nam_file(tmp_path: Path) -> Path:
|
|
"""Create a valid test .nam file and return its path."""
|
|
config = _make_nam_config()
|
|
path = tmp_path / "test_model.nam"
|
|
with open(path, "w") as f:
|
|
json.dump(config, f)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def host(tmp_path: Path) -> NAMHost:
|
|
"""Create a NAMHost with a temp models directory."""
|
|
return NAMHost(models_dir=tmp_path, device="cpu")
|
|
|
|
|
|
# ── Model metadata tests ──────────────────────────────────────────────
|
|
|
|
|
|
class TestNAMModelMetadata:
|
|
def test_minimal_fields(self):
|
|
"""NAMModel can be constructed with required fields."""
|
|
model = NAMModel(
|
|
name="test",
|
|
path="/tmp/test.nam",
|
|
architecture="WaveNet",
|
|
size_mb=4.5,
|
|
params_k=6.4,
|
|
receptive_field=64,
|
|
sample_rate=48000,
|
|
compatible=True,
|
|
)
|
|
assert model.name == "test"
|
|
assert model.compatible
|
|
assert model.size_mb == 4.5
|
|
assert model.receptive_field == 64
|
|
|
|
def test_large_model_not_compatible(self):
|
|
"""Models > 10 MB are flagged as incompatible."""
|
|
model = NAMModel(
|
|
name="big",
|
|
path="/tmp/big.nam",
|
|
architecture="WaveNet",
|
|
size_mb=42.0,
|
|
params_k=500.0,
|
|
receptive_field=512,
|
|
sample_rate=48000,
|
|
compatible=False,
|
|
)
|
|
assert not model.compatible
|
|
assert model.size_mb == 42.0
|
|
|
|
|
|
# ── NAMHost lifecycle tests ───────────────────────────────────────────
|
|
|
|
|
|
class TestNAMHost:
|
|
def test_initial_state(self, host: NAMHost):
|
|
"""Fresh host has no model loaded."""
|
|
assert not host.is_loaded
|
|
assert host.current_model is None
|
|
assert host.avg_inference_ms == 0.0
|
|
|
|
def test_load_model_success(self, host: NAMHost, test_nam_file: Path):
|
|
"""Can load a valid .nam model file."""
|
|
result = host.load_model(str(test_nam_file))
|
|
assert result
|
|
assert host.is_loaded
|
|
assert host.current_model is not None
|
|
assert host.current_model.name == "test_model"
|
|
assert host.current_model.architecture == "Linear"
|
|
|
|
def test_load_model_not_found(self, host: NAMHost):
|
|
"""Loading a non-existent file returns False."""
|
|
result = host.load_model("/nonexistent/model.nam")
|
|
assert not result
|
|
assert not host.is_loaded
|
|
|
|
def test_load_model_bad_extension(self, host: NAMHost, tmp_path: Path):
|
|
"""Loading a non-.nam file returns False."""
|
|
bad_file = tmp_path / "model.wav"
|
|
bad_file.write_bytes(b"fake")
|
|
result = host.load_model(str(bad_file))
|
|
assert not result
|
|
|
|
def test_unload(self, host: NAMHost, test_nam_file: Path):
|
|
"""Unload clears model state."""
|
|
host.load_model(str(test_nam_file))
|
|
assert host.is_loaded
|
|
|
|
host.unload()
|
|
assert not host.is_loaded
|
|
assert host.current_model is None
|
|
|
|
def test_double_load(self, host: NAMHost, test_nam_file: Path,
|
|
tmp_path: Path):
|
|
"""Loading a second model replaces the first."""
|
|
host.load_model(str(test_nam_file))
|
|
assert host.current_model.name == "test_model"
|
|
|
|
# Create second model
|
|
config2 = _make_nam_config()
|
|
path2 = tmp_path / "model2.nam"
|
|
with open(path2, "w") as f:
|
|
json.dump(config2, f)
|
|
|
|
host.load_model(str(path2))
|
|
assert host.current_model.name == "model2"
|
|
assert host.is_loaded
|
|
|
|
|
|
# ── Inference tests ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestNAMInference:
|
|
def test_process_1d(self, host: NAMHost, test_nam_file: Path):
|
|
"""1D float32 input produces 1D float32 output."""
|
|
host.load_model(str(test_nam_file))
|
|
audio_in = np.random.randn(256).astype(np.float32)
|
|
audio_out = host.process(audio_in)
|
|
assert audio_out.shape == (256,)
|
|
assert audio_out.dtype == np.float32
|
|
# Output should be finite (model may overshoot with random weights)
|
|
assert np.all(np.isfinite(audio_out))
|
|
|
|
def test_process_2d(self, host: NAMHost, test_nam_file: Path):
|
|
"""2D (1, N) float32 input produces matching 2D output."""
|
|
host.load_model(str(test_nam_file))
|
|
audio_in = np.random.randn(1, 256).astype(np.float32)
|
|
audio_out = host.process(audio_in)
|
|
assert audio_out.shape == (1, 256)
|
|
assert audio_out.dtype == np.float32
|
|
|
|
def test_process_no_model(self, host: NAMHost):
|
|
"""Processing with no model loaded should pass through."""
|
|
audio_in = np.random.randn(256).astype(np.float32)
|
|
audio_out = host.process(audio_in)
|
|
np.testing.assert_array_equal(audio_out, audio_in)
|
|
|
|
def test_process_sine_wave(self, host: NAMHost, test_nam_file: Path):
|
|
"""A sine wave should produce a non-silent output."""
|
|
host.load_model(str(test_nam_file))
|
|
t = np.linspace(0, 256 / 48000, 256, dtype=np.float32)
|
|
sine = (np.sin(2 * np.pi * 440 * t) * 0.5).astype(np.float32)
|
|
out = host.process(sine)
|
|
# Should have non-zero energy
|
|
rms_out = np.sqrt(np.mean(out ** 2))
|
|
assert rms_out > 0.0, "Model output should not be silent"
|
|
|
|
def test_process_multiple_blocks(self, host: NAMHost,
|
|
test_nam_file: Path):
|
|
"""Processing multiple blocks should maintain state consistency."""
|
|
host.load_model(str(test_nam_file))
|
|
block = np.random.randn(256).astype(np.float32)
|
|
|
|
# Process same block twice
|
|
out1 = host.process(block.copy())
|
|
out2 = host.process(block.copy())
|
|
|
|
# Models with no state (ConvNet) should produce same output
|
|
assert out1.shape == out2.shape == (256,)
|
|
assert out1.dtype == np.float32
|
|
|
|
def test_process_different_block_sizes(self, host: NAMHost,
|
|
test_nam_file: Path):
|
|
"""Should handle various block sizes >= receptive field."""
|
|
host.load_model(str(test_nam_file))
|
|
rf = host.current_model.receptive_field
|
|
|
|
for block_size in [rf, rf * 2, 128, 256, 512]:
|
|
audio = np.random.randn(block_size).astype(np.float32)
|
|
out = host.process(audio)
|
|
assert out.shape == (block_size,), (
|
|
f"Block size {block_size} produced {out.shape}"
|
|
)
|
|
|
|
|
|
# ── Model switching tests ─────────────────────────────────────────────
|
|
|
|
|
|
class TestModelSwitching:
|
|
def test_instant_switch(self, tmp_path: Path):
|
|
"""Instant switch mode should immediately route through new model."""
|
|
host = NAMHost(switch_mode=ModelSwitchMode.INSTANT)
|
|
|
|
# Load first model (seed 42 — default)
|
|
c1 = _make_nam_config()
|
|
p1 = tmp_path / "m1.nam"
|
|
with open(p1, "w") as f:
|
|
json.dump(c1, f)
|
|
host.load_model(str(p1))
|
|
|
|
audio_in = np.random.randn(256).astype(np.float32)
|
|
out_before = host.process(audio_in)
|
|
|
|
# Load second model with DIFFERENT weights by using WaveNet arch
|
|
c2 = _make_nam_config(arch="WaveNet", num_weights=2400)
|
|
p2 = tmp_path / "m2.nam"
|
|
with open(p2, "w") as f:
|
|
json.dump(c2, f)
|
|
host.load_model(str(p2))
|
|
|
|
out_after = host.process(audio_in)
|
|
|
|
# Different architectures should produce different output
|
|
assert not np.allclose(out_before, out_after), (
|
|
"Different models should produce different output"
|
|
)
|
|
|
|
def test_warm_up(self, host: NAMHost, test_nam_file: Path):
|
|
"""Warm-up should not crash and load model state."""
|
|
host.load_model(str(test_nam_file))
|
|
# First warm-up after model load
|
|
host.warm_up(256)
|
|
# Should be able to process after warm-up
|
|
out = host.process(np.random.randn(256).astype(np.float32))
|
|
assert out.shape == (256,)
|
|
|
|
def test_list_available_models(self, host: NAMHost,
|
|
test_nam_file: Path):
|
|
"""List available models should find test model."""
|
|
models = host.list_available_models()
|
|
assert len(models) >= 1
|
|
names = [m.name for m in models]
|
|
assert "test_model" in names
|
|
|
|
|
|
# ── Standalone function tests ─────────────────────────────────────────
|
|
|
|
|
|
class TestStandaloneFunctions:
|
|
def test_available_models(self, tmp_path: Path):
|
|
"""available_models() returns lightweight model info."""
|
|
from src.dsp.nam_host import available_models
|
|
|
|
# Create a test model
|
|
config = _make_nam_config()
|
|
path = tmp_path / "test.nam"
|
|
with open(path, "w") as f:
|
|
json.dump(config, f)
|
|
|
|
models = available_models(tmp_path)
|
|
assert len(models) == 1
|
|
assert models[0]["name"] == "test"
|
|
assert models[0]["architecture"] == "Linear"
|
|
assert models[0]["feather"] is True
|
|
assert models[0]["size_mb"] > 0
|
|
|
|
def test_available_models_empty_dir(self, tmp_path: Path):
|
|
"""Empty directory returns empty list."""
|
|
from src.dsp.nam_host import available_models
|
|
assert available_models(tmp_path) == []
|
|
|
|
def test_process_with_model(self, tmp_path: Path):
|
|
"""process_with_model convenience function works end-to-end."""
|
|
from src.dsp.nam_host import process_with_model
|
|
|
|
config = _make_nam_config()
|
|
path = tmp_path / "test.nam"
|
|
with open(path, "w") as f:
|
|
json.dump(config, f)
|
|
|
|
audio_in = np.random.randn(256).astype(np.float32)
|
|
audio_out = process_with_model(str(path), audio_in, device="cpu")
|
|
assert audio_out.shape == audio_in.shape
|
|
assert np.all(np.isfinite(audio_out))
|
|
|
|
|
|
# ── Edge case tests ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_block_smaller_than_rf(self, host: NAMHost,
|
|
test_nam_file: Path):
|
|
"""Blocks smaller than receptive field are padded."""
|
|
host.load_model(str(test_nam_file))
|
|
rf = host.current_model.receptive_field
|
|
|
|
# Create block smaller than receptive field
|
|
small_block = np.random.randn(max(1, rf // 4)).astype(np.float32)
|
|
out = host.process(small_block)
|
|
assert out.shape == small_block.shape
|
|
assert np.all(np.isfinite(out))
|
|
|
|
def test_silent_input(self, host: NAMHost, test_nam_file: Path):
|
|
"""Silent input should produce valid (possibly non-zero) output."""
|
|
host.load_model(str(test_nam_file))
|
|
silence = np.zeros(256, dtype=np.float32)
|
|
out = host.process(silence)
|
|
assert out.shape == (256,)
|
|
assert np.all(np.isfinite(out))
|
|
|
|
def test_model_cache_reuse(self, host: NAMHost, test_nam_file: Path):
|
|
"""Loading the same model twice uses cache."""
|
|
path = str(test_nam_file)
|
|
host.load_model(path)
|
|
assert host.is_loaded
|
|
name1 = host.current_model.name
|
|
|
|
# Load same path again
|
|
host.load_model(path)
|
|
assert host.current_model.name == name1
|
|
|
|
|
|
# ── Performance tests ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestPerformance:
|
|
def test_inference_timing(self, host: NAMHost, test_nam_file: Path):
|
|
"""Track average inference time (should be < 5ms on CPU)."""
|
|
host.load_model(str(test_nam_file))
|
|
|
|
# Process many blocks to get stable average
|
|
for _ in range(50):
|
|
block = np.random.randn(256).astype(np.float32)
|
|
host.process(block)
|
|
|
|
avg_ms = host.avg_inference_ms
|
|
assert avg_ms > 0.0
|
|
# On modern x86 CPU, ConvNet with 6.4K params should be < 1ms
|
|
# On RPi 4B, expect < 5ms per block (256 samples @ 48kHz = 5.3ms)
|
|
assert avg_ms < 10.0, (
|
|
f"Inference too slow: {avg_ms:.3f}ms (target < 5ms)"
|
|
)
|
|
|
|
def test_memory_stability(self, host: NAMHost, test_nam_file: Path):
|
|
"""Processing many blocks should not leak or crash."""
|
|
host.load_model(str(test_nam_file))
|
|
for _ in range(200):
|
|
block = np.random.randn(256).astype(np.float32)
|
|
_ = host.process(block)
|
|
# If we get here, no crash
|
|
assert host.avg_inference_ms > 0 |