fix: critical DSP bugs causing crackles/pops and clipping

1. NAM_AMP: removed double process() call (was processing through C++
   engine twice per block, corrupting internal state), removed os.system()
   debug logging on every audio callback (caused JACK xruns on RPi 4),
   fixed level*2.0 input clipping to proper 0.0-1.0 gain drive

2. Added np.clip() to all wet/dry mix returns across 9+ effect types
   (delay, reverb, chorus, flanger, phaser, ring mod, envelope filter,
   rotary, formant, ping-pong, multi-tap, reverse, shimmer reverb)

3. Clipped all delay feedback write paths (digital, analog, ping-pong,
   multi-tap, reverse, tape echo) to prevent delay-line runaway

4. Added output clipping to _process_mono and _process_4cm master volume

5. Fixed analog delay tanh(*0.5)*2.0 -> tanh(*0.5) (was re-expanding
   after soft-clip, reintroducing digital distortion)
This commit is contained in:
2026-06-13 20:35:38 -04:00
parent a43e65b81f
commit 1b250cd5a3
2 changed files with 149 additions and 79 deletions
+110 -39
View File
@@ -1,21 +1,30 @@
"""Drop-in replacement for NAMHost using the C++ nam_engine subprocess. """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 from __future__ import annotations
import logging import logging
import os
import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from dataclasses import dataclass from dataclasses import dataclass, field
import numpy as np import numpy as np
from .nam_engine import NAMEngineProcess from .nam_engine import NAMEngineProcess
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODELS_DIR = Path(__file__).parent.parent / "models" / "nam" MODELS_DIR = Path(__file__).parent.parent / "models" / "nam"
@dataclass @dataclass
class NAMFastModel: class NAMFastModel:
"""Metadata matching the NAMModel dataclass from nam_host.py."""
name: str name: str
path: str path: str
size_mb: float size_mb: float
@@ -27,97 +36,159 @@ class NAMFastModel:
@property @property
def family(self) -> str: def family(self) -> str:
if self.size_mb < 0.1: return "nano" if self.size_mb < 0.1:
elif self.size_mb < 1.0: return "feather" return "nano"
elif self.size_mb < 4.0: return "lite" elif self.size_mb < 1.0:
else: return "standard" return "feather"
elif self.size_mb < 4.0:
return "lite"
else:
return "standard"
@property @property
def estimated_latency_ms(self) -> str: 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: 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._models_dir = Path(models_dir)
self._block_size = block_size self._block_size = block_size
self._engine = None self._engine: Optional[NAMModel] = None # Using current naming matching nam_host
self._loaded_path = None self._loaded_path: Optional[str] = None
self._loaded_model = None self._loaded_model: Optional[NAMFastModel] = None
self._models_dir.mkdir(parents=True, exist_ok=True) self._models_dir.mkdir(parents=True, exist_ok=True)
# ── Properties ─────────────────────────────────────────────────
@property @property
def is_loaded(self): def is_loaded(self) -> bool:
return self._engine is not None and self._engine.is_loaded return self._engine is not None and self._engine.is_loaded
@property @property
def current_model(self): def current_model(self) -> Optional[NAMFastModel]:
return self._loaded_model return self._loaded_model
@property @property
def avg_inference_ms(self): def avg_inference_ms(self) -> float:
if self._engine is None: return 0.0 if self._engine is None:
return 0.0
return self._engine.avg_inference_ms 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) path = Path(model_path)
if not path.exists() or path.suffix.lower() not in (".nam",): if not path.exists() or path.suffix.lower() not in (".nam",):
logger.error("Model not found or invalid: %s", model_path) logger.error("Model not found or invalid: %s", model_path)
return False return False
# Stop any existing engine
self.unload() self.unload()
size_mb = path.stat().st_size / (1024 * 1024) size_mb = path.stat().st_size / (1024 * 1024)
# Create and start the engine
engine = NAMEngineProcess(str(path), self._block_size) engine = NAMEngineProcess(str(path), self._block_size)
if not engine.start(): if not engine.start():
logger.error("Failed to start NAM engine for: %s", model_path) logger.error("Failed to start NAM engine for: %s", model_path)
return False return False
self._engine = engine self._engine = engine
self._loaded_path = str(path) self._loaded_path = str(path)
self._loaded_model = NAMFastModel( 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( logger.info(
"Loaded NAM model via C++ engine: %s (%.1f KB, static=%s)", "Loaded NAM model via C++ engine: %s (%.1f KB, static=%s, engine=NeuralAudio)",
path.stem, size_mb * 1024, engine.is_static, path.stem,
size_mb * 1024,
engine.is_static,
) )
return True return True
def unload(self): def unload(self) -> None:
"""Unload the current model and stop the engine."""
if self._engine is not None: if self._engine is not None:
self._engine.stop() self._engine.stop()
self._engine = None self._engine = None
self._loaded_path = None self._loaded_path = None
self._loaded_model = 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: if self._engine is None or not self._engine.is_loaded:
return return
dummy = np.zeros(block_size, dtype=np.float32) dummy = np.zeros(block_size, dtype=np.float32)
for _ in range(5): for _ in range(5):
self._engine.process(dummy) 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: if self._engine is None or not self._engine.is_loaded:
return audio_block return audio_block # passthrough
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
_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 return buf
def list_available_models(self): # ── Model discovery ────────────────────────────────────────────
models = []
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")): for f in sorted(self._models_dir.glob("*.nam")):
size_mb = f.stat().st_size / (1024 * 1024) 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 return models
+39 -40
View File
@@ -556,7 +556,7 @@ class AudioPipeline:
if entry["bypass"] or not entry["enabled"]: if entry["bypass"] or not entry["enabled"]:
continue continue
buf = self._process_single_block(buf, idx, entry) 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 # Update output VU level
out_rms = np.sqrt(np.mean(out ** 2) + _EPS) out_rms = np.sqrt(np.mean(out ** 2) + _EPS)
@@ -606,8 +606,8 @@ class AudioPipeline:
ch1 = self._process_single_block(ch1, idx, entry) ch1 = self._process_single_block(ch1, idx, entry)
out = np.zeros_like(audio_in) out = np.zeros_like(audio_in)
out[0, :] = ch0 * self._master_volume out[0, :] = np.clip(ch0 * self._master_volume, -1.0, 1.0)
out[1, :] = ch1 * self._master_volume out[1, :] = np.clip(ch1 * self._master_volume, -1.0, 1.0)
# Update output VU level from the processed effect return (ch1) # Update output VU level from the processed effect return (ch1)
out_rms = np.sqrt(np.mean(out ** 2) + _EPS) out_rms = np.sqrt(np.mean(out ** 2) + _EPS)
@@ -695,26 +695,26 @@ class AudioPipeline:
case FXType.REVERB: case FXType.REVERB:
return self._apply_reverb(buf, params, fx_state) return self._apply_reverb(buf, params, fx_state)
case FXType.VOLUME: 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: case FXType.NAM_AMP:
# Use C++ NeuralAudio engine when a .nam file is loaded.
if self.nam.is_loaded: if self.nam.is_loaded:
import numpy as np # Apply input gain as pre-amp drive (level 0.0-1.0 maps to 0-1.0x gain)
import os as _os level = params.get("level", 0.75)
_os.system('echo "PIPELINE: NAM_AMP is_loaded=True buf_peak={:.6f}" >> /tmp/pipeline_debug.log'.format(float(np.max(np.abs(buf))))) drive = np.clip(level, 0.0, 1.0)
in_rms = 20 * np.log10(np.sqrt(np.mean(buf**2)) + 1e-10) if drive != 1.0:
buf = buf * drive
# Single process call through the C++ engine
processed = self.nam.process(buf) 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) # Crossfade on preset switch
logger.debug("NAM %s: in=%.1fdBFS out=%.1fdBFS gain=%.1fdB",
getattr(self.nam, '_loaded_path', '?'),
in_rms, out_rms, out_rms - in_rms)
if self.nam._crossfade_buf is not None: if self.nam._crossfade_buf is not None:
processed = self.nam.apply_crossfade(processed) processed = self.nam.apply_crossfade(processed)
level = params.get("level", 0.75)
attenuated = buf * (level * 2.0) # Clip output to prevent digital distortion
processed = self.nam.process(attenuated) return np.clip(processed, -1.0, 1.0)
return processed
_os.system('echo "PIPELINE: NAM_AMP is_loaded=False" >> /tmp/pipeline_debug.log')
logger.debug("NAM_AMP: engine not loaded, passing through") logger.debug("NAM_AMP: engine not loaded, passing through")
return buf return buf
case FXType.IR_CAB: case FXType.IR_CAB:
@@ -1219,7 +1219,7 @@ class AudioPipeline:
delay_line.write_block(buf) 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 ────────────────────────────────────────────────── # ── 6. Flanger ──────────────────────────────────────────────────
@@ -1259,7 +1259,7 @@ class AudioPipeline:
# Store feedback for next block # Store feedback for next block
state["fb_buf"] = wet * 0.5 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 ─────────────────────────────────────────────────── # ── 7. Phaser ───────────────────────────────────────────────────
@@ -1387,13 +1387,12 @@ class AudioPipeline:
# Read delayed signal # Read delayed signal
wet = delay_line.read_block(float(delay_samples), len(buf)) wet = delay_line.read_block(float(delay_samples), len(buf))
# Write dry + feedback (no self-oscillation guard) # Write dry + clipped feedback to prevent delay-line runaway
# clips feedback automatically
fb_gain = min(feedback, 0.98) 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) 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, def _apply_analog_delay(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray: state: dict) -> np.ndarray:
@@ -1441,14 +1440,14 @@ class AudioPipeline:
# ── BBD-style subtle saturation on feedback path ───────────── # ── BBD-style subtle saturation on feedback path ─────────────
# Soft-clip the filtered feedback (BBD companding characteristic) # 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) 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) 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) ─────────────────────────────── # ── 11. Reverb (subtype dispatch) ───────────────────────────────
@@ -1477,7 +1476,7 @@ class AudioPipeline:
case _: case _:
wet = self._reverb_hall(buf, params, state) 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, def _reverb_hall(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray: state: dict) -> np.ndarray:
@@ -2046,7 +2045,7 @@ class AudioPipeline:
wet = delay_line.read_block_varying(mod_delay) wet = delay_line.read_block_varying(mod_delay)
delay_line.write_block(buf) 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 ─────────────────────────────────────────── # ── 19. Ring Modulator ───────────────────────────────────────────
@@ -2095,7 +2094,7 @@ class AudioPipeline:
state["bpf_zi"] = zf state["bpf_zi"] = zf
wet = sig.astype(np.float32) 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 ────────────────────────────────────────── # ── 21. Envelope Filter ──────────────────────────────────────────
@@ -2129,7 +2128,7 @@ class AudioPipeline:
state["lpf_zi"] = zf state["lpf_zi"] = zf
wet = sig.astype(np.float32) 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 ────────────────────────────────────────── # ── 22. Rotary Speaker ──────────────────────────────────────────
@@ -2643,7 +2642,7 @@ class AudioPipeline:
state[f"form_zi_{stage}"] = zf state[f"form_zi_{stage}"] = zf
wet = np.clip(sig, -1.0, 1.0).astype(np.float32) 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 ───────────────────────────────────────── # ── 39. Ping-pong Delay ─────────────────────────────────────────
@@ -2711,8 +2710,8 @@ class AudioPipeline:
wet += tap_sig * (1.0 / len(taps)) # Normalise wet += tap_sig * (1.0 / len(taps)) # Normalise
fb_gain = min(feedback, 0.98) fb_gain = min(feedback, 0.98)
delay_line.write_block(buf + wet * fb_gain) delay_line.write_block(np.clip(buf + wet * fb_gain, -1.0, 1.0))
return buf * (1.0 - mix) + wet * mix return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 41. Reverse Delay ─────────────────────────────────────────── # ── 41. Reverse Delay ───────────────────────────────────────────
@@ -2748,8 +2747,8 @@ class AudioPipeline:
state["read_pos"] = rpos % delay_samples state["read_pos"] = rpos % delay_samples
fb_gain = min(feedback, 0.98) fb_gain = min(feedback, 0.98)
delay_line.write_block(buf + out * fb_gain) delay_line.write_block(np.clip(buf + out * fb_gain, -1.0, 1.0))
return buf * (1.0 - mix) + out * mix return np.clip(buf * (1.0 - mix) + out * mix, -1.0, 1.0)
# ── 42. Tape Echo ─────────────────────────────────────────────── # ── 42. Tape Echo ───────────────────────────────────────────────
@@ -2790,8 +2789,8 @@ class AudioPipeline:
wet = lp_out.astype(np.float32) wet = lp_out.astype(np.float32)
fb_gain = min(feedback, 0.98) fb_gain = min(feedback, 0.98)
delay_line.write_block(buf + wet * fb_gain) delay_line.write_block(np.clip(buf + wet * fb_gain, -1.0, 1.0))
return buf * (1.0 - mix) + wet * mix return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 43. Shimmer Reverb ────────────────────────────────────────── # ── 43. Shimmer Reverb ──────────────────────────────────────────
# BETA — test on RPi 4B for xruns # 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 = wet + shifted_out.astype(np.float64) * 0.5 # feedback shimmer
wet = np.clip(wet, -1.0, 1.0).astype(np.float32) 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 ───────────────────────────────────────────────── # ── 44. Looper ─────────────────────────────────────────────────