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:
+110
-39
@@ -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
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user