diff --git a/src/dsp/nam_engine_host.py b/src/dsp/nam_engine_host.py index e583c95..ad2b5c2 100644 --- a/src/dsp/nam_engine_host.py +++ b/src/dsp/nam_engine_host.py @@ -1,21 +1,30 @@ """Drop-in replacement for NAMHost using the C++ nam_engine subprocess. -Same interface as NAMHost but uses NeuralAudio C++ engine ~34x faster. + +Same interface as NAMHost (load_model, process, is_loaded, etc.) but +spawns the C++ NeuralAudio engine for ~34x faster inference. """ from __future__ import annotations + import logging +import os +import time from pathlib import Path from typing import Optional -from dataclasses import dataclass +from dataclasses import dataclass, field + import numpy as np + from .nam_engine import NAMEngineProcess logger = logging.getLogger(__name__) + MODELS_DIR = Path(__file__).parent.parent / "models" / "nam" @dataclass class NAMFastModel: + """Metadata matching the NAMModel dataclass from nam_host.py.""" name: str path: str size_mb: float @@ -27,97 +36,159 @@ class NAMFastModel: @property def family(self) -> str: - if self.size_mb < 0.1: return "nano" - elif self.size_mb < 1.0: return "feather" - elif self.size_mb < 4.0: return "lite" - else: return "standard" + if self.size_mb < 0.1: + return "nano" + elif self.size_mb < 1.0: + return "feather" + elif self.size_mb < 4.0: + return "lite" + else: + return "standard" @property def estimated_latency_ms(self) -> str: - return "0.05-0.2 ms (C++ NeuralAudio)" + return "0.05-0.2 ms (C++ NeuralAudio engine)" class FastNAMHost: - def __init__(self, models_dir=MODELS_DIR, block_size=256): + """NAM model host using the C++ nam_engine subprocess. + + Same API surface as NAMHost (from nam_host.py), but uses the + NeuralAudio C++ engine for much faster inference. + + Parameters + ---------- + models_dir : str | Path + Directory scanned for available .nam models. + block_size : int + Audio block size (must match the pipeline's JACK buffer). + """ + + def __init__( + self, + models_dir: str | Path = MODELS_DIR, + block_size: int = 256, + ): self._models_dir = Path(models_dir) self._block_size = block_size - self._engine = None - self._loaded_path = None - self._loaded_model = None + self._engine: Optional[NAMModel] = None # Using current naming matching nam_host + self._loaded_path: Optional[str] = None + self._loaded_model: Optional[NAMFastModel] = None + self._models_dir.mkdir(parents=True, exist_ok=True) + # ── Properties ───────────────────────────────────────────────── + @property - def is_loaded(self): + def is_loaded(self) -> bool: return self._engine is not None and self._engine.is_loaded @property - def current_model(self): + def current_model(self) -> Optional[NAMFastModel]: return self._loaded_model @property - def avg_inference_ms(self): - if self._engine is None: return 0.0 + def avg_inference_ms(self) -> float: + if self._engine is None: + return 0.0 return self._engine.avg_inference_ms - def load_model(self, model_path): + # ── Model loading ────────────────────────────────────────────── + + def load_model(self, model_path: str) -> bool: + """Load a .nam model into the C++ engine. + + Returns True on success, False on error. + """ path = Path(model_path) if not path.exists() or path.suffix.lower() not in (".nam",): logger.error("Model not found or invalid: %s", model_path) return False + + # Stop any existing engine self.unload() + size_mb = path.stat().st_size / (1024 * 1024) + + # Create and start the engine engine = NAMEngineProcess(str(path), self._block_size) if not engine.start(): logger.error("Failed to start NAM engine for: %s", model_path) return False + self._engine = engine self._loaded_path = str(path) self._loaded_model = NAMFastModel( - name=path.stem, path=str(path), size_mb=size_mb + name=path.stem, + path=str(path), + size_mb=size_mb, + architecture="LSTM", ) + logger.info( - "Loaded NAM model via C++ engine: %s (%.1f KB, static=%s)", - path.stem, size_mb * 1024, engine.is_static, + "Loaded NAM model via C++ engine: %s (%.1f KB, static=%s, engine=NeuralAudio)", + path.stem, + size_mb * 1024, + engine.is_static, ) return True - def unload(self): + def unload(self) -> None: + """Unload the current model and stop the engine.""" if self._engine is not None: self._engine.stop() self._engine = None self._loaded_path = None self._loaded_model = None + logger.info("NAM model unloaded") - def warm_up(self, block_size=256): + # ── Warm-up ──────────────────────────────────────────────────── + + def warm_up(self, block_size: int = 256) -> None: + """Run a dry inference to warm caches.""" if self._engine is None or not self._engine.is_loaded: return dummy = np.zeros(block_size, dtype=np.float32) for _ in range(5): self._engine.process(dummy) - def process(self, audio_block): + # ── Inference ────────────────────────────────────────────────── + + def process(self, audio_block: np.ndarray) -> np.ndarray: + """Run a block of audio through the NAM model. + + Args: + audio_block: float32 numpy array, shape (N,) or (1, N). + + Returns: + Processed audio, same shape, float32. + """ if self._engine is None or not self._engine.is_loaded: - return audio_block - in_peak = float(np.max(np.abs(audio_block))) - result = self._engine.process(audio_block) - out_peak = float(np.max(np.abs(result))) - has_nan = bool(np.any(np.isnan(result))) - has_inf = bool(np.any(np.isinf(result))) - import os as _os - _os.system('echo "NAM_PROC in_pk={:.6f} out_pk={:.6f} nan={} inf={} sz={}" >> /tmp/nam_debug.log'.format(in_peak, out_peak, has_nan, has_inf, len(audio_block))) - if has_nan or has_inf: - logger.error("NAM engine NaN/Inf block_size=%d", self._block_size) - return np.zeros_like(audio_block) - return result + return audio_block # passthrough - _crossfade_buf = None + return self._engine.process(audio_block) - def apply_crossfade(self, buf): + # ── Model switching (crossfade compatible) ───────────────────── + + _crossfade_buf = None # For pipeline crossfade compatibility + + def apply_crossfade(self, buf: np.ndarray) -> np.ndarray: + """Passthrough — crossfade not needed with fast C++ switching.""" return buf - def list_available_models(self): - models = [] + # ── Model discovery ──────────────────────────────────────────── + + def list_available_models(self) -> list[NAMFastModel]: + """Scan models_dir for .nam files and return metadata.""" + models: list[NAMFastModel] = [] for f in sorted(self._models_dir.glob("*.nam")): size_mb = f.stat().st_size / (1024 * 1024) - models.append(NAMFastModel(name=f.stem, path=str(f), size_mb=size_mb)) + models.append( + NAMFastModel( + name=f.stem, + path=str(f), + size_mb=size_mb, + architecture="LSTM", + ) + ) return models diff --git a/src/dsp/pipeline.py b/src/dsp/pipeline.py index 8413ce6..0ee1f6c 100644 --- a/src/dsp/pipeline.py +++ b/src/dsp/pipeline.py @@ -556,7 +556,7 @@ class AudioPipeline: if entry["bypass"] or not entry["enabled"]: continue buf = self._process_single_block(buf, idx, entry) - out = buf * self._master_volume + out = np.clip(buf * self._master_volume, -1.0, 1.0) # Update output VU level out_rms = np.sqrt(np.mean(out ** 2) + _EPS) @@ -606,8 +606,8 @@ class AudioPipeline: ch1 = self._process_single_block(ch1, idx, entry) out = np.zeros_like(audio_in) - out[0, :] = ch0 * self._master_volume - out[1, :] = ch1 * self._master_volume + out[0, :] = np.clip(ch0 * self._master_volume, -1.0, 1.0) + out[1, :] = np.clip(ch1 * self._master_volume, -1.0, 1.0) # Update output VU level from the processed effect return (ch1) out_rms = np.sqrt(np.mean(out ** 2) + _EPS) @@ -695,26 +695,26 @@ class AudioPipeline: case FXType.REVERB: return self._apply_reverb(buf, params, fx_state) case FXType.VOLUME: - return self._apply_volume(buf, params, fx_state) + return np.clip(self._apply_volume(buf, params, fx_state), -1.0, 1.0) case FXType.NAM_AMP: + # Use C++ NeuralAudio engine when a .nam file is loaded. if self.nam.is_loaded: - import numpy as np - import os as _os - _os.system('echo "PIPELINE: NAM_AMP is_loaded=True buf_peak={:.6f}" >> /tmp/pipeline_debug.log'.format(float(np.max(np.abs(buf))))) - in_rms = 20 * np.log10(np.sqrt(np.mean(buf**2)) + 1e-10) + # Apply input gain as pre-amp drive (level 0.0-1.0 maps to 0-1.0x gain) + level = params.get("level", 0.75) + drive = np.clip(level, 0.0, 1.0) + if drive != 1.0: + buf = buf * drive + + # Single process call through the C++ engine processed = self.nam.process(buf) - _os.system('echo "PIPELINE: NAM_AMP processed peak={:.6f}" >> /tmp/pipeline_debug.log'.format(float(np.max(np.abs(processed))))) - out_rms = 20 * np.log10(np.sqrt(np.mean(processed**2)) + 1e-10) - logger.debug("NAM %s: in=%.1fdBFS out=%.1fdBFS gain=%.1fdB", - getattr(self.nam, '_loaded_path', '?'), - in_rms, out_rms, out_rms - in_rms) + + # Crossfade on preset switch if self.nam._crossfade_buf is not None: processed = self.nam.apply_crossfade(processed) - level = params.get("level", 0.75) - attenuated = buf * (level * 2.0) - processed = self.nam.process(attenuated) - return processed - _os.system('echo "PIPELINE: NAM_AMP is_loaded=False" >> /tmp/pipeline_debug.log') + + # Clip output to prevent digital distortion + return np.clip(processed, -1.0, 1.0) + logger.debug("NAM_AMP: engine not loaded, passing through") return buf case FXType.IR_CAB: @@ -1219,7 +1219,7 @@ class AudioPipeline: delay_line.write_block(buf) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 6. Flanger ────────────────────────────────────────────────── @@ -1259,7 +1259,7 @@ class AudioPipeline: # Store feedback for next block state["fb_buf"] = wet * 0.5 - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 7. Phaser ─────────────────────────────────────────────────── @@ -1387,13 +1387,12 @@ class AudioPipeline: # Read delayed signal wet = delay_line.read_block(float(delay_samples), len(buf)) - # Write dry + feedback (no self-oscillation guard) - # clips feedback automatically + # Write dry + clipped feedback to prevent delay-line runaway fb_gain = min(feedback, 0.98) - write_sig = buf + wet * fb_gain + write_sig = np.clip(buf + wet * fb_gain, -1.0, 1.0) delay_line.write_block(write_sig) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) def _apply_analog_delay(self, buf: np.ndarray, params: dict, state: dict) -> np.ndarray: @@ -1441,14 +1440,14 @@ class AudioPipeline: # ── BBD-style subtle saturation on feedback path ───────────── # Soft-clip the filtered feedback (BBD companding characteristic) - lp_out = np.tanh(lp_out * 0.5) * 2.0 + lp_out = np.tanh(lp_out * 0.5) - # Write with filtered feedback + # Write with clipped feedback fb_gain = min(feedback, 0.98) - write_sig = buf + lp_out * fb_gain + write_sig = np.clip(buf + lp_out * fb_gain, -1.0, 1.0) delay_line.write_block(write_sig) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 11. Reverb (subtype dispatch) ─────────────────────────────── @@ -1477,7 +1476,7 @@ class AudioPipeline: case _: wet = self._reverb_hall(buf, params, state) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) def _reverb_hall(self, buf: np.ndarray, params: dict, state: dict) -> np.ndarray: @@ -2046,7 +2045,7 @@ class AudioPipeline: wet = delay_line.read_block_varying(mod_delay) delay_line.write_block(buf) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 19. Ring Modulator ─────────────────────────────────────────── @@ -2095,7 +2094,7 @@ class AudioPipeline: state["bpf_zi"] = zf wet = sig.astype(np.float32) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 21. Envelope Filter ────────────────────────────────────────── @@ -2129,7 +2128,7 @@ class AudioPipeline: state["lpf_zi"] = zf wet = sig.astype(np.float32) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 22. Rotary Speaker ────────────────────────────────────────── @@ -2643,7 +2642,7 @@ class AudioPipeline: state[f"form_zi_{stage}"] = zf wet = np.clip(sig, -1.0, 1.0).astype(np.float32) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 39. Ping-pong Delay ───────────────────────────────────────── @@ -2711,8 +2710,8 @@ class AudioPipeline: wet += tap_sig * (1.0 / len(taps)) # Normalise fb_gain = min(feedback, 0.98) - delay_line.write_block(buf + wet * fb_gain) - return buf * (1.0 - mix) + wet * mix + delay_line.write_block(np.clip(buf + wet * fb_gain, -1.0, 1.0)) + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 41. Reverse Delay ─────────────────────────────────────────── @@ -2748,8 +2747,8 @@ class AudioPipeline: state["read_pos"] = rpos % delay_samples fb_gain = min(feedback, 0.98) - delay_line.write_block(buf + out * fb_gain) - return buf * (1.0 - mix) + out * mix + delay_line.write_block(np.clip(buf + out * fb_gain, -1.0, 1.0)) + return np.clip(buf * (1.0 - mix) + out * mix, -1.0, 1.0) # ── 42. Tape Echo ─────────────────────────────────────────────── @@ -2790,8 +2789,8 @@ class AudioPipeline: wet = lp_out.astype(np.float32) fb_gain = min(feedback, 0.98) - delay_line.write_block(buf + wet * fb_gain) - return buf * (1.0 - mix) + wet * mix + delay_line.write_block(np.clip(buf + wet * fb_gain, -1.0, 1.0)) + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 43. Shimmer Reverb ────────────────────────────────────────── # BETA — test on RPi 4B for xruns @@ -2871,7 +2870,7 @@ class AudioPipeline: wet = wet + shifted_out.astype(np.float64) * 0.5 # feedback shimmer wet = np.clip(wet, -1.0, 1.0).astype(np.float32) - return buf * (1.0 - mix) + wet * mix + return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0) # ── 44. Looper ─────────────────────────────────────────────────