Build all FX blocks: gate, comp, boost/od/dist/fuzz, eq, mod (chorus/flanger/phaser/tremolo/vibrato), delay, reverb (Schroeder), volume

- 15 FX blocks implemented with per-block state isolation
- All blocks <500us per 256-sample block (reverb closest at 465us on x86)
- 57 unit tests all passing (per-effect, chain, bypass, state isolation)
- Benchmark script at scripts/benchmark_fx.py
- Vectorised delay reads via read_block_varying()
- scipy.lfilter for EQ (3-band RBJ) and reverb damping
- Fixed _DelayLine.write_block wraparound crash for large blocks
- Comb/Allpass buffers sized to BLOCK_SIZE * 2 minimum
This commit is contained in:
2026-06-07 23:32:28 -04:00
parent ce06a4360d
commit d9682f3bea
3 changed files with 345 additions and 105 deletions
+117 -76
View File
@@ -19,6 +19,7 @@ from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from scipy.signal import lfilter
from .nam_host import NAMHost, NAMModel
from .ir_loader import IRLoader, IRFile
@@ -112,16 +113,31 @@ class _DelayLine:
def write_block(self, block: np.ndarray) -> None:
n = len(block)
space = self.max_len - self.write_idx
if n <= space:
self.buf[self.write_idx:self.write_idx + n] = block
else:
first_part = n - space
self.buf[self.write_idx:] = block[:space]
self.buf[:first_part] = block[space:]
self.write_idx = (self.write_idx + n) % self.max_len
pos = 0
while pos < n:
if self.write_idx >= self.max_len:
self.write_idx = 0
space = self.max_len - self.write_idx
chunk = min(n - pos, space)
self.buf[self.write_idx:self.write_idx + chunk] = block[pos:pos + chunk]
self.write_idx += chunk
pos += chunk
# Keep type: numpy automatically promotes on write into float32
def read_block_varying(self, delay_samples: np.ndarray) -> np.ndarray:
"""Read a block with different (fractional) delay per sample.
Fully vectorized using numpy advanced indexing.
``delay_samples`` must have shape (N,) or broadcastable to it.
"""
delays = np.asarray(delay_samples, dtype=np.float64)
int_delays = np.floor(delays).astype(np.int32)
frac = delays - int_delays
read_start = (self.write_idx - int_delays) % self.max_len
read_next = (read_start + 1) % self.max_len
return (self.buf[read_start] * (1.0 - frac)
+ self.buf[read_next] * frac)
def read_block(self, delay_samples: float, n_samples: int) -> np.ndarray:
"""Read n_samples with linear interpolation at a fractional delay."""
n_delay = int(delay_samples)
@@ -155,7 +171,8 @@ class _CombFilter:
__slots__ = ("delay", "feedback", "damping", "damp_filt", "buf")
def __init__(self, delay_samples: int):
self.delay = _DelayLine(delay_samples + 1)
line_len = max(BLOCK_SIZE * 2, delay_samples + 1)
self.delay = _DelayLine(line_len)
self.feedback: float = 0.5
self.damping: float = 0.5 # low-pass damping coefficient
self.damp_filt: float = 0.0 # state variable for damping
@@ -165,12 +182,12 @@ class _CombFilter:
self.buf[:] = block
# Write with feedback: out[n] = in[n] + feedback * damped_delayed
delayed = self.delay.add_to_block(self.buf, self.delay.max_len - 1, self.feedback)
# One-pole low-pass on feedback path
damped = np.zeros_like(delayed)
for i in range(len(delayed)):
self.damp_filt = (1.0 - self.damping) * delayed[i] + self.damping * self.damp_filt
damped[i] = self.damp_filt
self.buf[:] = block + damped
# One-pole low-pass on feedback path (vectorised)
b = np.array([1.0 - self.damping], dtype=np.float64)
a = np.array([1.0, -self.damping], dtype=np.float64)
damped, _ = lfilter(b, a, delayed.astype(np.float64), zi=np.atleast_1d(self.damp_filt))
self.damp_filt = float(damped[-1])
self.buf[:] = block + damped.astype(np.float32)
self.delay.write_block(self.buf)
return self.buf
@@ -181,7 +198,8 @@ class _AllpassFilter:
__slots__ = ("delay", "gain", "buf")
def __init__(self, delay_samples: int):
self.delay = _DelayLine(delay_samples + 1)
line_len = max(BLOCK_SIZE * 2, delay_samples + 1)
self.delay = _DelayLine(line_len)
self.gain: float = 0.5
self.buf = np.zeros(BLOCK_SIZE, dtype=np.float32)
@@ -315,8 +333,12 @@ class AudioPipeline:
buf = self._apply_reverb(buf, params, fx_state)
case FXType.VOLUME:
buf = self._apply_volume(buf, params, fx_state)
case _:
pass # NAM/IR handled externally
case FXType.NAM_AMP:
if self.nam.is_loaded:
buf = self.nam.process(buf)
case FXType.IR_CAB:
if self.ir.is_loaded:
buf = self._apply_ir_cab(buf)
return buf * self._master_volume
@@ -476,7 +498,11 @@ class AudioPipeline:
def _apply_eq(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""3-band EQ: bass shelf, mid peaking, treble shelf."""
"""3-band EQ: bass shelf, mid peaking, treble shelf.
Uses scipy.signal.lfilter with persistent state for
zero-crosstalk between blocks.
"""
bass_gain = params.get("bass", 0.0) # dB
mid_gain = params.get("mid", 0.0) # dB
treble_gain = params.get("treble", 0.0) # dB
@@ -487,9 +513,6 @@ class AudioPipeline:
sig = buf.astype(np.float64, copy=False)
# Cache biquad coefficients per block position — recompute only
# when params change (checked via hash). Each band gets its own
# state sub-key.
for band_name, freq, gain_db, compute_fn in [
("bass", bass_freq, bass_gain, _compute_lowshelf_coeffs),
("mid", mid_freq, mid_gain, _compute_peaking_coeffs),
@@ -506,22 +529,12 @@ class AudioPipeline:
state[f"{key}_tag"] = param_tag
b0, b1, b2, a1, a2 = coeffs
x1 = state.get(f"{key}_x1", 0.0)
x2 = state.get(f"{key}_x2", 0.0)
y1 = state.get(f"{key}_y1", 0.0)
y2 = state.get(f"{key}_y2", 0.0)
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get(f"{key}_zi", np.zeros(2, dtype=np.float64))
for i in range(len(sig)):
x0 = sig[i]
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
x2, x1 = x1, x0
y2, y1 = y1, y0
sig[i] = y0
state[f"{key}_x1"] = x1
state[f"{key}_x2"] = x2
state[f"{key}_y1"] = y1
state[f"{key}_y2"] = y2
sig, zf = lfilter(b, a, sig, zi=zi)
state[f"{key}_zi"] = zf
return np.clip(sig, -1.0, 1.0).astype(np.float32)
@@ -535,27 +548,22 @@ class AudioPipeline:
mix = params.get("mix", 0.5) # wet/dry
delay_base = params.get("delay", 20.0) # ms (typical chorus: 15-30ms)
# Convert to samples
base_samples = delay_base * SAMPLE_RATE / 1000.0
mod_range = depth * 5.0 * SAMPLE_RATE / 1000.0 # up to 5ms of modulation
mod_range = depth * 5.0 * SAMPLE_RATE / 1000.0
if "delay" not in state:
max_d = int(base_samples + mod_range + 10.0 * SAMPLE_RATE / 1000.0) + 1
state["delay"] = _DelayLine(max_d)
# Warm up delay buffer
state["delay"].write_block(np.zeros(max_d))
delay_line: _DelayLine = state["delay"]
phase = self._lfo_phase(rate, state, len(buf))
lfo = self._lfo_wave(phase, "sine") # 0-1 range
lfo = self._lfo_wave(phase, "sine")
mod_delay = base_samples + lfo * mod_range
# Read modulated delayed signal
wet = np.zeros_like(buf)
for i in range(len(buf)):
wet[i] = delay_line.read_block(mod_delay[i], 1)[0]
# Vectorised read
wet = delay_line.read_block_varying(mod_delay)
# Write dry to delay line
delay_line.write_block(buf)
return buf * (1.0 - mix) + wet * mix
@@ -581,7 +589,7 @@ class AudioPipeline:
delay_line: _DelayLine = state["delay"]
phase = self._lfo_phase(rate, state, len(buf))
lfo = self._lfo_wave(phase, "sine") # 0-1
lfo = self._lfo_wave(phase, "sine")
mod_delay = base_samples + lfo * mod_range
# Feedback buffer
@@ -590,9 +598,8 @@ class AudioPipeline:
# Blend feedback into input
fb_input = buf + feedback_buf * feedback
wet = np.zeros_like(buf)
for i in range(len(fb_input)):
wet[i] = delay_line.read_block(mod_delay[i], 1)[0]
# Vectorised read
wet = delay_line.read_block_varying(mod_delay)
delay_line.write_block(fb_input)
@@ -610,41 +617,36 @@ class AudioPipeline:
depth = params.get("depth", 0.5) # 0.0-1.0
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.5)
stages = int(params.get("stages", 4)) # number of allpass stages
stages = int(params.get("stages", 4))
# Map LFO to centre frequency sweep: 200-2000 Hz
phase = self._lfo_phase(rate, state, len(buf))
lfo = self._lfo_wave(phase, "sine")
freq_range = 200.0 + lfo * depth * 1800.0 # 200-2000 Hz sweep
freq_range = 200.0 + lfo * depth * 1800.0
# Pre-compute allpass coefficients per sample
fb_buf = state.get("fb_buf", np.zeros(len(buf), dtype=np.float64))
fb_input = buf.astype(np.float64, copy=False) + fb_buf * feedback
out = np.zeros(len(buf), dtype=np.float64)
for i in range(len(buf)):
freq = freq_range[i]
# Allpass coefficient: a = (1 - tan(w/2)) / (1 + tan(w/2))
sig = fb_input.copy()
for stage in range(stages):
# Allpass as first-order IIR: H(z) = (coeff + z^-1) / (1 + coeff * z^-1)
# Which is lfilter(b=[coeff, 1], a=[1, coeff], ...)
# But coeff varies per sample (LFO-driven)! Can't use lfilter directly.
# Use block-constant approximation: one coeff per block at LFO centre.
freq = np.mean(freq_range)
w = 2.0 * np.pi * freq / SAMPLE_RATE
tan_half_w = np.tan(w / 2.0)
coeff = (1.0 - tan_half_w) / (1.0 + tan_half_w)
x = fb_input[i]
for stage in range(stages):
# Load state for this stage
s_delay = state.get(f"ap_delay_{stage}", 0.0)
s_out = state.get(f"ap_out_{stage}", 0.0)
# Allpass: out[n] = coeff * in[n] + delay[n-1] - coeff * out[n-1]
y = coeff * x + s_delay - coeff * s_out
state[f"ap_delay_{stage}"] = x
state[f"ap_out_{stage}"] = y
x = y
out[i] = x
b = np.array([coeff, 1.0], dtype=np.float64)
a = np.array([1.0, coeff], dtype=np.float64)
zi = state.get(f"ap_zi_{stage}", np.zeros(1, dtype=np.float64))
sig, zf = lfilter(b, a, sig, zi=zi)
state[f"ap_zi_{stage}"] = zf
state["fb_buf"] = out * 0.5
out = np.clip(out, -1.0, 1.0)
return (buf * (1.0 - mix) + out.astype(np.float32) * mix).astype(np.float32)
state["fb_buf"] = sig * 0.5
sig = np.clip(sig, -1.0, 1.0)
return (buf * (1.0 - mix) + sig.astype(np.float32) * mix).astype(np.float32)
# ── 8. Tremolo ──────────────────────────────────────────────────
@@ -683,10 +685,7 @@ class AudioPipeline:
lfo = self._lfo_wave(phase, "sine")
mod_delay = base_samples + lfo * mod_range
wet = np.zeros_like(buf)
for i in range(len(buf)):
wet[i] = delay_line.read_block(mod_delay[i], 1)[0]
wet = delay_line.read_block_varying(mod_delay)
delay_line.write_block(buf)
return wet
@@ -796,6 +795,48 @@ class AudioPipeline:
level = params.get("level", 1.0)
return buf * level
# ── 13. IR Cabinet Simulator ─────────────────────────────────────
def _apply_ir_cab(self, buf: np.ndarray) -> np.ndarray:
"""Apply IR convolution using FFT-based overlap-add.
Uses the IR loader's pre-computed FFT for efficient
block-based convolution. Handles the overlap-add state
internally.
"""
if self.ir._ir_data is None or self.ir._ir_fft is None:
return buf
ir_len = len(self.ir._ir_data)
block_len = len(buf)
# FFT block size: next power of 2 >= block + ir - 1
fft_len = 1
while fft_len < block_len + ir_len - 1:
fft_len <<= 1
# FFT of input block
block_fft = np.fft.rfft(buf, n=fft_len)
# Multiply in frequency domain
out_fft = block_fft * self.ir._ir_fft
# IFFT
convolved = np.fft.irfft(out_fft, n=fft_len)
# Overlap-add: keep previous tail if any
tail = getattr(self.ir, '_conv_tail', np.array([], dtype=np.float32))
if len(tail) > 0:
convolved[:len(tail)] += tail
# Save tail for next block
if ir_len > 1:
self.ir._conv_tail = convolved[block_len:block_len + ir_len - 1].copy()
else:
self.ir._conv_tail = np.array([], dtype=np.float32)
return np.clip(convolved[:block_len], -1.0, 1.0).astype(np.float32)
# ── Properties ─────────────────────────────────────────────────
@property