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
+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 ─────────────────────────────────────────────────