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.
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
+39 -40
View File
@@ -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 ─────────────────────────────────────────────────