"""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