Files
pi-multifx-pedal/src/dsp/pipeline.py
T
shawn c65e4816c4 fix: add sample_rate and block_size properties to AudioPipeline
_gather_channel_state() was accessing pl.sample_rate but AudioPipeline
only had private _sample_rate. Added public properties so the /api/state
endpoint doesn't crash with AttributeError.
2026-06-17 21:36:33 -04:00

3147 lines
129 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FX/Audio pipeline — the main real-time signal chain.
Runs on RPi 4B under JACK, connecting:
Guitar -> Gate -> Comp -> Boost -> NAM Amp -> IR Cab -> EQ -> Mod -> Delay -> Reverb -> Volume -> Out
Each block can be bypassed per-preset. The pipeline manages
block-level audio routing using numpy arrays for zero-copy
inter-block communication.
All DSP state is stored per-block-instance in self._state,
keyed by chain index. This allows multiple instances of the
same effect type at different positions in the chain.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from scipy.signal import lfilter
from .nam_router import NAMEngineRouter
from .ir_loader import IRLoader, IRFile
from ..presets.types import FXBlock, FXType, Preset
logger = logging.getLogger(__name__)
BLOCK_SIZE = 256 # Samples per JACK callback
SAMPLE_RATE = 48000 # Standard guitar audio rate
# ── Biquad coefficient helpers ─────────────────────────────────────
_EPS = 1e-10
def _compute_lowshelf_coeffs(freq: float, gain_db: float, q: float, sr: float) -> tuple:
"""RBJ low-shelf biquad coefficients."""
a = 10 ** (gain_db / 40.0)
omega = 2 * np.pi * freq / sr
sn = np.sin(omega)
cs = np.cos(omega)
beta = np.sqrt(a) / q # sqrt(A) / Q
if gain_db >= 0:
b0 = a * (a + 1 - (a - 1) * cs + beta * sn)
b1 = 2 * a * (a - 1 - (a + 1) * cs)
b2 = a * (a + 1 - (a - 1) * cs - beta * sn)
a0 = a + 1 + (a - 1) * cs + beta * sn
a1 = -2 * a * (a - 1 + (a + 1) * cs)
a2 = a + 1 + (a - 1) * cs - beta * sn
else:
b0 = a * (a + 1 + (a - 1) * cs + beta * sn)
b1 = -2 * a * (a - 1 + (a + 1) * cs)
b2 = a * (a + 1 + (a - 1) * cs - beta * sn)
a0 = a + 1 - (a - 1) * cs + beta * sn
a1 = 2 * a * (a - 1 - (a + 1) * cs)
a2 = a + 1 - (a - 1) * cs - beta * sn
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
def _compute_highshelf_coeffs(freq: float, gain_db: float, q: float, sr: float) -> tuple:
"""RBJ high-shelf biquad coefficients."""
a = 10 ** (gain_db / 40.0)
omega = 2 * np.pi * freq / sr
sn = np.sin(omega)
cs = np.cos(omega)
beta = np.sqrt(a) / q
if gain_db >= 0:
b0 = a * (a + 1 + (a - 1) * cs + beta * sn)
b1 = -2 * a * (a - 1 + (a + 1) * cs)
b2 = a * (a + 1 + (a - 1) * cs - beta * sn)
a0 = a + 1 - (a - 1) * cs + beta * sn
a1 = 2 * a * (a - 1 - (a + 1) * cs)
a2 = a + 1 - (a - 1) * cs - beta * sn
else:
b0 = a * (a + 1 - (a - 1) * cs + beta * sn)
b1 = 2 * a * (a - 1 - (a + 1) * cs)
b2 = a * (a + 1 - (a - 1) * cs - beta * sn)
a0 = a + 1 + (a - 1) * cs + beta * sn
a1 = -2 * a * (a - 1 + (a + 1) * cs)
a2 = a + 1 + (a - 1) * cs - beta * sn
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
def _compute_peaking_coeffs(freq: float, gain_db: float, q: float, sr: float) -> tuple:
"""RBJ peaking biquad coefficients."""
a = 10 ** (gain_db / 40.0)
omega = 2 * np.pi * freq / sr
sn = np.sin(omega)
cs = np.cos(omega)
alpha = sn / (2 * q)
b0 = 1 + alpha * a
b1 = -2 * cs
b2 = 1 - alpha * a
a0 = 1 + alpha / a
a1 = -2 * cs
a2 = 1 - alpha / a
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
def _compute_hpf_coeffs(freq: float, q: float, sr: float) -> tuple:
"""RBJ high-pass biquad coefficients."""
omega = 2 * np.pi * freq / sr
sn = np.sin(omega)
cs = np.cos(omega)
alpha = sn / (2 * q)
b0 = (1 + cs) / 2
b1 = -(1 + cs)
b2 = (1 + cs) / 2
a0 = 1 + alpha
a1 = -2 * cs
a2 = 1 - alpha
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
def _compute_lpf_coeffs(freq: float, q: float, sr: float) -> tuple:
"""RBJ low-pass biquad coefficients."""
omega = 2 * np.pi * freq / sr
sn = np.sin(omega)
cs = np.cos(omega)
alpha = sn / (2 * q)
b0 = (1 - cs) / 2
b1 = 1 - cs
b2 = (1 - cs) / 2
a0 = 1 + alpha
a1 = -2 * cs
a2 = 1 - alpha
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
def _compute_bpf_coeffs(freq: float, q: float, sr: float) -> tuple:
"""RBJ band-pass biquad coefficients."""
omega = 2 * np.pi * freq / sr
sn = np.sin(omega)
cs = np.cos(omega)
alpha = sn / (2 * q)
b0 = sn / 2
b1 = 0.0
b2 = -sn / 2
a0 = 1 + alpha
a1 = -2 * cs
a2 = 1 - alpha
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
def _compute_notch_coeffs(freq: float, q: float, sr: float) -> tuple:
"""RBJ notch biquad coefficients."""
omega = 2 * np.pi * freq / sr
sn = np.sin(omega)
cs = np.cos(omega)
alpha = sn / (2 * q)
b0 = 1.0
b1 = -2 * cs
b2 = 1.0
a0 = 1 + alpha
a1 = -2 * cs
a2 = 1 - alpha
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
# ── Circular delay line (block-vectorised) ─────────────────────────
class _DelayLine:
"""Vectorised circular buffer with linear interpolation."""
__slots__ = ("buf", "max_len", "write_idx")
def __init__(self, max_delay_samples: int):
self.buf = np.zeros(max_delay_samples, dtype=np.float32)
self.max_len = max_delay_samples
self.write_idx = 0
def write_block(self, block: np.ndarray) -> None:
n = len(block)
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)
frac = delay_samples - n_delay
read_start = (self.write_idx - n_delay) % self.max_len
indices = (read_start + np.arange(n_samples)) % self.max_len
next_indices = (indices + 1) % self.max_len
return self.buf[indices] * (1.0 - frac) + self.buf[next_indices] * frac
def add_to_block(self, block: np.ndarray, delay_samples: float,
gain: float) -> np.ndarray:
"""Add delayed + gained signal to block (for feedback loops)."""
n_delay = int(delay_samples)
frac = delay_samples - n_delay
read_start = (self.write_idx - n_delay) % self.max_len
indices = (read_start + np.arange(len(block))) % self.max_len
next_indices = (indices + 1) % self.max_len
delayed = self.buf[indices] * (1.0 - frac) + self.buf[next_indices] * frac
return delayed * gain
def read_all(self) -> np.ndarray:
"""Return the full buffer (for debugging / IR export)."""
return self.buf.copy()
# ── Schroeder reverb helpers ───────────────────────────────────────
class _CombFilter:
"""Comb filter for Schroeder reverb."""
__slots__ = ("delay", "feedback", "damping", "damp_filt", "buf", "_buf_size")
def __init__(self, delay_samples: int, block_size: int = 256):
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
self.buf = np.zeros(block_size, dtype=np.float32)
self._buf_size = block_size
def process(self, block: np.ndarray) -> np.ndarray:
# Resize internal buffer if block size changed (e.g. JACK period switch)
n = len(block)
if n != self._buf_size:
self.buf = np.zeros(n, dtype=np.float32)
self._buf_size = n
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 (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
class _AllpassFilter:
"""Allpass filter for Schroeder reverb."""
__slots__ = ("delay", "gain", "buf", "_buf_size")
def __init__(self, delay_samples: int, block_size: int = 256):
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)
self._buf_size = block_size
def process(self, block: np.ndarray) -> np.ndarray:
# Resize internal buffer if block size changed (e.g. JACK period switch)
n = len(block)
if n != self._buf_size:
self.buf = np.zeros(n, dtype=np.float32)
self._buf_size = n
# out[n] = -gain * in[n] + delay[n - D] + gain * delay_output[n - D]
# Standard allpass: out = -g * in + delayed + g * delayed_out
# But block-wise: read delayed, write in + g * delayed, output = -g * in + delayed
self.buf[:] = block
delayed = self.delay.add_to_block(self.buf, self.delay.max_len - 1, self.gain)
# Write: buf + gain * delayed
self.buf[:] = block + delayed * self.gain
self.delay.write_block(self.buf)
# Output: -gain * block + delayed
return -self.gain * block + delayed
# ── Audio Pipeline ─────────────────────────────────────────────────
class AudioPipeline:
"""Orchestrates the real-time audio FX chain.
The pipeline processes audio block-by-block, chaining
effect modules in order. Each module receives a numpy
array of audio samples and returns processed samples.
Effect state (delay buffers, LFO phases, envelope followers,
filter memory) is stored per-instance in self._state.
"""
def __init__(
self,
nam_host: Optional[NAMEngineRouter] = None,
ir_loader: Optional[IRLoader] = None,
):
self.nam = nam_host or NAMEngineRouter()
self.ir = ir_loader or IRLoader()
# Signal chain — list of (FXType, enabled, bypass, params)
self._chain: list[dict] = []
self._master_volume: float = 0.8
self._tuner_enabled: bool = False
self._bypassed: bool = False # Global bypass
# 4-Cable Method routing
self._routing_mode: str = "mono" # "mono" or "4cm"
self._routing_breakpoint: int = 7 # chain index where pre/post split occurs
# Per-block DSP state: {f"fx_{idx}": {state_dict}}
self._state: dict[str, dict] = {}
# Cached filter coefficients per block
self._coeffs: dict[str, tuple] = {}
# Runtime audio params (may be updated via set_audio_profile)
self._block_size: int = 256
self._sample_rate: int = 48000
# VU meter level tracking — updated on every process() call
# Smoothed RMS levels (0.01.0) read by web server for live VU meters
self._input_level: float = 0.0
self._output_level: float = 0.0
# Smoothing factor: ~50ms time constant — recomputed on profile change
self._vu_alpha: float = np.exp(-256 / (0.05 * 48000))
# ── Tuner / pitch detection state ────────────────────────────────
self._tuner_frequency: float = 0.0 # detected fundamental freq (Hz)
self._tuner_note: str = "--" # closest note name
self._tuner_cents: float = 0.0 # cent deviation from closest note
self._tuner_string: int = -1 # string number (1-6) or -1 if not matched
self._tuner_confidence: float = 0.0 # 0.0 to 1.0
# Pitch detection buffer (keep last N samples for analysis)
self._pitch_buffer: np.ndarray = np.array([], dtype=np.float32)
self._pitch_buffer_max: int = 2048 # ~43ms at 48kHz
# Thread-safety lock — protects config state swapped by load_preset()
# and read by process(). process() snapshots under this lock briefly;
# load_preset() holds it only for the atomic swap (not during model I/O).
self._lock = threading.Lock()
logger.info("Audio pipeline initialized (block=%d, sr=%d)",
self._block_size, self._sample_rate)
@property
def sample_rate(self) -> int:
"""Current sample rate in Hz."""
return self._sample_rate
@property
def block_size(self) -> int:
"""Current audio block size (frames per callback)."""
return self._block_size
def load_preset(self, preset: Preset) -> None:
"""Load a complete preset (NAM, IR, and FX chain).
Builds the new chain list off-thread, then swaps atomically under
``_lock`` so that the audio thread's ``process()`` never sees a
half-constructed chain.
"""
new_chain: list[dict] = []
new_routing_mode = preset.routing_mode
new_routing_breakpoint = preset.routing_breakpoint
new_master_volume = preset.master_volume
new_tuner_enabled = preset.tuner_enabled
for block in preset.chain:
entry = {
"fx_type": block.fx_type,
"enabled": block.enabled,
"bypass": block.bypass,
"params": dict(block.params),
"subtype": block.subtype,
}
# Load NAM model if needed (may do I/O — don't hold pipeline lock)
if block.fx_type == FXType.NAM_AMP and block.nam_model_path:
self.nam.load_model(block.nam_model_path)
# Load IR if needed
if block.fx_type == FXType.IR_CAB and block.ir_file_path:
self.ir.load_ir(block.ir_file_path)
new_chain.append(entry)
# Atomic swap under lock — keep the window as short as possible
with self._lock:
self._chain = new_chain
self._state = {}
self._coeffs = {}
self._master_volume = new_master_volume
self._tuner_enabled = new_tuner_enabled
self._routing_mode = new_routing_mode
self._routing_breakpoint = new_routing_breakpoint
logger.info("Preset '%s' loaded: %d blocks, routing=%s breakpoint=%d",
preset.name, len(self._chain),
self._routing_mode, self._routing_breakpoint)
def process(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a block of audio through the entire FX chain.
Args:
audio_in: numpy array of PCM samples (float32 [-1, 1]).
Mono mode: shape (N,) — single audio channel.
4CM mode: shape (2, N) — two channels, [guitar_in, return_in].
Returns:
Processed audio block.
Mono mode: shape (N,) — processed output.
4CM mode: shape (2, N) — [send_out, return_out].
"""
# ── Snapshot config under lock (brief — no I/O or heavy work) ──
with self._lock:
tuner_enabled = self._tuner_enabled
bypassed = self._bypassed
routing_mode = self._routing_mode
routing_breakpoint = self._routing_breakpoint
master_volume = self._master_volume
# Shallow-copy the chain list so iteration is isolated
chain = list(self._chain)
# Snapshot the state dict — process() mutates entries inside it
# but load_preset() replaces the whole dict under lock
state = self._state
# ── Tuner mode: mute output, keep input tracking for pitch detection ──
if tuner_enabled:
# Still track input level for tuner display
if audio_in.ndim == 1:
in_rms = np.sqrt(np.mean(audio_in ** 2) + _EPS)
self._input_level = (
self._input_level * self._vu_alpha
+ in_rms * (1.0 - self._vu_alpha)
)
else:
ch0 = audio_in[0, :]
in_rms = np.sqrt(np.mean(ch0 ** 2) + _EPS)
self._input_level = (
self._input_level * self._vu_alpha
+ in_rms * (1.0 - self._vu_alpha)
)
# Run pitch detection on input
self._detect_pitch(audio_in)
return np.zeros_like(audio_in)
if bypassed:
return audio_in * master_volume
if routing_mode == "4cm":
return self._process_4cm(audio_in, master_volume, chain, state,
routing_breakpoint)
else:
return self._process_mono(audio_in, master_volume, chain, state)
# ── Pitch detection for tuner ──────────────────────────────────────────────
# Standard tuning frequencies (E2, A2, D3, G3, B3, E4) — guitar strings
_STRING_FREQS = [82.41, 110.0, 146.83, 196.0, 246.94, 329.63]
_STRING_NAMES = ["E", "A", "D", "G", "B", "e"]
# Note names in chromatic order (C = 0)
_NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
def _detect_pitch(self, audio_in: np.ndarray) -> None:
"""Run pitch detection on the input buffer using autocorrelation.
Updates ``self._tuner_frequency``, ``self._tuner_note``,
``self._tuner_cents``, and ``self._tuner_string``.
Args:
audio_in: Input audio block — mono (N,) or stereo (2, N).
"""
# Extract mono channel
if audio_in.ndim == 2:
signal = audio_in[0, :].copy()
else:
signal = audio_in.copy()
# Append to rolling pitch buffer
self._pitch_buffer = np.concatenate([self._pitch_buffer, signal])
if len(self._pitch_buffer) > self._pitch_buffer_max:
self._pitch_buffer = self._pitch_buffer[-self._pitch_buffer_max:]
# Need enough signal for meaningful analysis
if len(self._pitch_buffer) < 512:
self._tuner_confidence = 0.0
return
# Simple autocorrelation pitch detection
buf = self._pitch_buffer
# Remove DC offset
buf = buf - np.mean(buf)
# Check if there's enough amplitude
rms = np.sqrt(np.mean(buf ** 2))
if rms < 0.002: # Silence threshold
self._tuner_confidence = 0.0
self._tuner_frequency = 0.0
self._tuner_note = "--"
self._tuner_cents = 0.0
self._tuner_string = -1
return
# Autocorrelation: find the fundamental period
# Search lag range: 30 to 1024 samples (46.9Hz to 1600Hz at 48kHz)
min_lag = int(self._sample_rate / 1600) # ~30
max_lag = min(int(self._sample_rate / 50), len(buf) // 2) # ~960
if max_lag <= min_lag:
self._tuner_confidence = 0.0
return
corr = np.correlate(buf, buf, mode='full')
# Take only the second half (positive lags)
corr = corr[len(corr) // 2:]
# Simple autocorrelation
lag_slice = corr[min_lag:max_lag + 1]
# Find the first peak in the autocorrelation
diffs = np.diff(lag_slice)
# Look for zero crossings in diff (peaks: positive→negative)
peaks = []
for i in range(1, len(diffs)):
if diffs[i-1] > 0 and diffs[i] <= 0:
peaks.append((min_lag + i, lag_slice[i]))
if not peaks:
self._tuner_confidence = 0.0
return
# Pick the strongest peak
best_lag, best_val = max(peaks, key=lambda x: x[1])
# Confidence based on relative peak strength
noise_floor = np.mean(np.abs(corr[min_lag:max_lag + 1]))
confidence = best_val / (noise_floor + 1e-10)
# Parabolic interpolation for sub-sample accuracy
if best_lag > min_lag and best_lag < max_lag:
idx = best_lag - min_lag
if 0 < idx < len(lag_slice) - 1:
y0, y1, y2 = lag_slice[idx-1], lag_slice[idx], lag_slice[idx+1]
if y0 + y2 - 2 * y1 != 0:
correction = (y0 - y2) / (2 * (y0 + y2 - 2 * y1))
best_lag = best_lag + correction
# Fundamental frequency
freq = self._sample_rate / best_lag if best_lag > 0 else 0
# Clip confidence to 0-1 range
self._tuner_confidence = min(1.0, max(0.0, confidence / 10.0))
if freq < 30 or freq > 1600:
self._tuner_confidence = 0.0
return
self._tuner_frequency = freq
# ── Convert frequency to note name ──
# A4 = 440Hz, MIDI note 69
midi_note = 12 * np.log2(freq / 440.0) + 69
midi_rounded = round(midi_note)
cents = int(100 * (midi_note - midi_rounded))
# Clamp to valid MIDI range
if midi_rounded < 0 or midi_rounded > 127:
self._tuner_note = "--"
self._tuner_confidence = 0.0
return
octave = (midi_rounded // 12) - 1
note_idx = midi_rounded % 12
note_name = self._NOTE_NAMES[note_idx]
self._tuner_note = f"{note_name}{octave}"
self._tuner_cents = cents
# ── Guess guitar string ──
self._tuner_string = -1
for si, sf in enumerate(self._STRING_FREQS):
# +/- 3 semitones from the string's fundamental
if abs(freq - sf) / sf < 0.2:
self._tuner_string = si + 1
break
def _process_mono(self, audio_in: np.ndarray,
master_volume: float,
chain: list[dict],
state: dict[str, dict]) -> np.ndarray:
"""Process a mono block through the full chain (all blocks)."""
# Update input VU level (RMS with envelope smoothing)
in_rms = np.sqrt(np.mean(audio_in ** 2) + _EPS)
self._input_level = (
self._input_level * self._vu_alpha
+ in_rms * (1.0 - self._vu_alpha)
)
buf = audio_in.copy()
for idx, entry in enumerate(chain):
if entry["bypass"] or not entry["enabled"]:
continue
buf = self._process_single_block(buf, idx, entry, state)
out = np.clip(buf * master_volume, -1.0, 1.0)
# Update output VU level
out_rms = np.sqrt(np.mean(out ** 2) + _EPS)
self._output_level = (
self._output_level * self._vu_alpha
+ out_rms * (1.0 - self._vu_alpha)
)
return out
def _process_4cm(self, audio_in: np.ndarray,
master_volume: float,
chain: list[dict],
state: dict[str, dict],
routing_breakpoint: int) -> np.ndarray:
"""Process stereo block with 4CM split routing.
audio_in has shape (2, N):
ch0 = guitar input (Input 1)
ch1 = FX loop return (Input 2)
Splits at routing_breakpoint:
pre blocks → ch0 processed through [0..breakpoint)
post blocks → ch1 processed through [breakpoint..]
Returns (2, N):
ch0 = Send output (to amp input)
ch1 = Return output (to amp FX return)
"""
ch0 = audio_in[0, :].copy()
ch1 = audio_in[1, :].copy()
# Update input VU level from the guitar input channel (ch0)
in_rms = np.sqrt(np.mean(ch0 ** 2) + _EPS)
self._input_level = (
self._input_level * self._vu_alpha
+ in_rms * (1.0 - self._vu_alpha)
)
bp = routing_breakpoint
for idx, entry in enumerate(chain):
if entry["bypass"] or not entry["enabled"]:
continue
if idx < bp:
# Pre-amp block — process on guitar (ch0)
ch0 = self._process_single_block(ch0, idx, entry, state)
else:
# Post-amp block — process on return (ch1)
ch1 = self._process_single_block(ch1, idx, entry, state)
out = np.zeros_like(audio_in)
out[0, :] = np.clip(ch0 * master_volume, -1.0, 1.0)
out[1, :] = np.clip(ch1 * 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)
self._output_level = (
self._output_level * self._vu_alpha
+ out_rms * (1.0 - self._vu_alpha)
)
return out
def _process_single_block(self, buf: np.ndarray, idx: int,
entry: dict,
state: dict[str, dict]) -> np.ndarray:
"""Process a single mono audio block through one FX block.
Args:
buf: Mono audio block (N,) to process.
idx: Chain index for state lookup.
entry: Chain entry dict with fx_type, params.
state: Per-block DSP state dict (snapshotted by ``process()``).
Returns:
Processed mono block (N,).
"""
fx_type = entry["fx_type"]
params = dict(entry["params"]) # copy so dispatchers can safely inject subtype
# Inject entry-level subtype into params (only when set on FXBlock),
# so existing dispatchers that read params.get("subtype", ...) get it.
# This is backward-compatible: if FXBlock.subtype is empty (default),
# any legacy subtype set in params is preserved.
subtype = entry.get("subtype", "")
if subtype:
params["subtype"] = subtype
fx_state = state.setdefault(f"fx_{idx}", {})
match fx_type:
case FXType.NOISE_GATE:
return self._apply_gate(buf, params, fx_state)
case FXType.COMPRESSOR:
return self._apply_compressor(buf, params, fx_state)
case FXType.BOOST:
return self._apply_boost(buf, params, fx_state)
case FXType.OVERDRIVE:
subtype = params.get("subtype", "ts808")
match subtype:
case "ts808":
return self._apply_overdrive(buf, params, fx_state)
case "klon":
return self._apply_klon(buf, params, fx_state)
case "bd2":
return self._apply_bd2(buf, params, fx_state)
case _:
logger.warning("Unknown OVERDRIVE subtype '%s', falling back to ts808", subtype)
return self._apply_overdrive(buf, params, fx_state)
case FXType.DISTORTION:
subtype = params.get("subtype", "rat")
match subtype:
case "rat":
return self._apply_distortion(buf, params, fx_state)
case _:
logger.warning("Unknown DISTORTION subtype '%s', falling back to rat", subtype)
return self._apply_distortion(buf, params, fx_state)
case FXType.FUZZ:
subtype = params.get("subtype", "fuzz")
match subtype:
case "fuzz":
return self._apply_fuzz(buf, params, fx_state)
case "muff":
return self._apply_muff(buf, params, fx_state)
case _:
logger.warning("Unknown FUZZ subtype '%s', falling back to fuzz", subtype)
return self._apply_fuzz(buf, params, fx_state)
case FXType.EQ:
return self._apply_eq(buf, params, fx_state)
case FXType.CHORUS:
return self._apply_chorus(buf, params, fx_state)
case FXType.FLANGER:
return self._apply_flanger(buf, params, fx_state)
case FXType.PHASER:
return self._apply_phaser(buf, params, fx_state)
case FXType.TREMOLO:
return self._apply_tremolo(buf, params, fx_state)
case FXType.VIBRATO:
return self._apply_vibrato(buf, params, fx_state)
case FXType.DELAY:
return self._apply_delay(buf, params, fx_state)
case FXType.REVERB:
return self._apply_reverb(buf, params, fx_state)
case FXType.VOLUME:
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:
# 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)
# Crossfade on preset switch
if self.nam._crossfade_buf is not None:
processed = self.nam.apply_crossfade(processed)
# 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:
if self.ir.is_loaded:
return self._apply_ir_cab(buf, params, fx_state)
return buf
# ── Pitch & Frequency ──────────────────────────────────────
case FXType.OCTAVER:
return self._apply_octaver(buf, params, fx_state)
case FXType.PITCH_SHIFTER:
return self._apply_pitch_shifter(buf, params, fx_state)
case FXType.HARMONIZER:
return self._apply_harmonizer(buf, params, fx_state)
case FXType.WHAMMY:
return self._apply_whammy(buf, params, fx_state)
case FXType.DETUNE:
return self._apply_detune(buf, params, fx_state)
# ── Modulation ─────────────────────────────────────────────
case FXType.RING_MODULATOR:
return self._apply_ring_modulator(buf, params, fx_state)
case FXType.AUTO_WAH:
return self._apply_auto_wah(buf, params, fx_state)
case FXType.ENVELOPE_FILTER:
return self._apply_envelope_filter(buf, params, fx_state)
case FXType.ROTARY_SPEAKER:
return self._apply_rotary_speaker(buf, params, fx_state)
case FXType.UNI_VIBE:
return self._apply_uni_vibe(buf, params, fx_state)
case FXType.AUTO_PAN:
return self._apply_auto_pan(buf, params, fx_state)
case FXType.STEREO_WIDENER:
return self._apply_stereo_widener(buf, params, fx_state)
# ── Drive & Saturation ─────────────────────────────────────
case FXType.BITCRUSHER:
return self._apply_bitcrusher(buf, params, fx_state)
case FXType.WAVEFOLDER:
return self._apply_wavefolder(buf, params, fx_state)
case FXType.RECTIFIER:
return self._apply_rectifier(buf, params, fx_state)
# ── Dynamics ───────────────────────────────────────────────
case FXType.EXPANDER:
return self._apply_expander(buf, params, fx_state)
case FXType.DE_ESSER:
return self._apply_de_esser(buf, params, fx_state)
case FXType.TRANSIENT_SHAPER:
return self._apply_transient_shaper(buf, params, fx_state)
case FXType.SIDECHAIN_COMPRESSOR:
return self._apply_sidechain_compressor(buf, params, fx_state)
# ── Filters & EQ ──────────────────────────────────────────
case FXType.PARAMETRIC_EQ:
return self._apply_parametric_eq(buf, params, fx_state)
case FXType.HIGH_PASS_FILTER:
return self._apply_hpf(buf, params, fx_state)
case FXType.LOW_PASS_FILTER:
return self._apply_lpf(buf, params, fx_state)
case FXType.BAND_PASS_FILTER:
return self._apply_bpf(buf, params, fx_state)
case FXType.NOTCH_FILTER:
return self._apply_notch(buf, params, fx_state)
case FXType.FORMANT_FILTER:
return self._apply_formant_filter(buf, params, fx_state)
# ── Time-Based ─────────────────────────────────────────────
case FXType.PING_PONG_DELAY:
return self._apply_ping_pong_delay(buf, params, fx_state)
case FXType.MULTI_TAP_DELAY:
return self._apply_multi_tap_delay(buf, params, fx_state)
case FXType.REVERSE_DELAY:
return self._apply_reverse_delay(buf, params, fx_state)
case FXType.TAPE_ECHO:
return self._apply_tape_echo(buf, params, fx_state)
case FXType.SHIMMER_REVERB:
return self._apply_shimmer_reverb(buf, params, fx_state)
case FXType.LOOPER:
return self._apply_looper(buf, params, fx_state)
# ── Ambience ──────────────────────────────────────────────
case FXType.EARLY_REFLECTIONS:
return self._apply_early_reflections(buf, params, fx_state)
case _:
return buf
# ── LFO helpers ─────────────────────────────────────────────────
def _lfo_phase(self, rate_hz: float, state: dict, block_size: int) -> np.ndarray:
"""Generate LFO phase ramp (0->1), update state."""
phase = state.get("phase", 0.0)
delta = rate_hz / self._sample_rate
t = np.arange(block_size, dtype=np.float64) * delta + phase
t %= 1.0
state["phase"] = float(t[-1] + delta) % 1.0
return t
@staticmethod
def _lfo_wave(phase: np.ndarray, shape: str = "sine") -> np.ndarray:
"""Generate LFO waveform from phase array."""
match shape:
case "sine":
return 0.5 + 0.5 * np.sin(2 * np.pi * phase)
case "triangle":
return 2.0 * np.abs(2.0 * phase - 1.0) - 1.0
# Returns in [-1, 1]; normalise below
case "square":
return np.where(phase < 0.5, 1.0, 0.0)
case _:
return 0.5 + 0.5 * np.sin(2 * np.pi * phase)
# ── Effect implementations ──────────────────────────────────────
# ── 1. Noise Gate ───────────────────────────────────────────────
def _apply_gate(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Noise gate with adjustable threshold and release envelope."""
threshold = params.get("threshold", 0.01)
release_ms = params.get("release", 100.0)
envelope = state.get("envelope", 0.0)
rms = np.sqrt(np.mean(buf ** 2) + _EPS)
if rms >= threshold:
# Instant attack
envelope = rms
else:
# Exponential release — time constant per block
release_coeff = np.exp(-self._block_size / (release_ms * self._sample_rate / 1000.0))
envelope = envelope * release_coeff + rms * (1.0 - release_coeff)
state["envelope"] = envelope
if envelope < threshold:
return np.zeros_like(buf)
return buf
# ── 2. Compressor ───────────────────────────────────────────────
def _apply_compressor(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Compressor with threshold (dB), ratio, attack, release, make-up gain."""
threshold_db = params.get("threshold", -20.0) # dB
ratio = params.get("ratio", 3.0)
attack_ms = params.get("attack", 5.0)
release_ms = params.get("release", 100.0)
makeup = params.get("gain", 1.0)
# RMS envelope with attack/release shaping
rms = np.sqrt(np.mean(buf ** 2) + _EPS)
envelope = state.get("envelope", 0.0)
if rms > envelope:
alpha = np.exp(-self._block_size / (attack_ms * self._sample_rate / 1000.0))
else:
alpha = np.exp(-self._block_size / (release_ms * self._sample_rate / 1000.0))
envelope = envelope * alpha + rms * (1.0 - alpha)
state["envelope"] = envelope
# Compute gain reduction in dB domain
if envelope > 1e-10:
env_db = 20.0 * np.log10(envelope)
else:
env_db = -120.0
if env_db > threshold_db:
# gain_db = threshold + (env - threshold) / ratio - env
gain_db = threshold_db + (env_db - threshold_db) / ratio - env_db
else:
gain_db = 0.0
gain_lin = 10 ** (gain_db / 20.0)
return np.clip(buf * gain_lin * makeup, -1.0, 1.0)
# ── 3. Boost / Overdrive / Distortion / Fuzz ────────────────────
def _apply_boost(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Clean boost with linear gain."""
gain_db = params.get("gain_db", 6.0)
gain_linear = 10 ** (gain_db / 20.0)
return np.clip(buf * gain_linear, -1.0, 1.0)
def _apply_overdrive(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Tube-style overdrive with asymmetric soft clipping."""
drive = params.get("drive", 0.5)
tone = params.get("tone", 0.5)
gain = params.get("gain", 1.0)
drive_scaled = drive * 15.0 + 1.0
shaped = buf * drive_scaled
# Asymmetric soft clipping (tube-like)
# Positive half clips softer than negative (tube asymmetry)
pos = np.where(shaped > 0, shaped / (1.0 + shaped * 0.3), shaped)
neg = np.where(pos < 0, pos / (1.0 - pos * 0.5), pos)
out = np.tanh(neg) # Final polish with tanh
return np.clip(out * gain, -1.0, 1.0)
def _apply_distortion(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Harder asymmetric clipping with diode-style transfer."""
drive = params.get("drive", 0.7)
tone = params.get("tone", 0.5)
gain = params.get("gain", 1.0)
drive_scaled = drive * 30.0 + 1.0
shaped = buf * drive_scaled
# Diode-style asymmetric clipping
clipped = np.where(
shaped > 0,
np.clip(shaped, 0, 0.8) / (1.0 + np.abs(np.clip(shaped, 0, 0.8)) * 0.5),
np.clip(shaped, -0.6, 0) / (1.0 + np.abs(np.clip(shaped, -0.6, 0)) * 0.3),
)
return np.clip(clipped * gain, -1.0, 1.0)
def _apply_fuzz(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Octave-fuzzy hard clipping with gated sustain."""
drive = params.get("drive", 0.8)
tone = params.get("tone", 0.5)
gain = params.get("gain", 1.0)
drive_scaled = drive * 50.0 + 1.0
shaped = buf * drive_scaled
# Hard square-wave clip with asymmetric gate
clipped = np.sign(shaped) * (1.0 - np.exp(-np.abs(shaped) * 2.0))
# Foldover for octave effect
folded = np.abs(clipped) * 0.3 + clipped * 0.7
return np.clip(folded * gain, -1.0, 1.0)
# ── 3a. Klon Centaur (subtype: klon) ────────────────────────────
def _apply_klon(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Klon Centaur-style transparent overdrive with clean blend.
The Klon's hallmark is its ability to mix a clean (buffered) signal
with a lightly overdriven signal, preserving pick attack and dynamics.
Uses symmetrical soft clipping (germanium diode-style) with a
high-cut tone control on the drive path only.
Parameters:
drive (0-1): Overdrive gain.
tone (0-1): Treble cut — 0=bright, 1=dark.
gain (0-1): Output level recovery.
blend (0-1): Dry/wet mix — 0=clean only, 1=drive only.
Default 0.5 (~50/50 blend, original Klon character).
"""
drive = params.get("drive", 0.5)
tone = params.get("tone", 0.5)
gain = params.get("gain", 1.0)
blend = params.get("blend", 0.5)
# Pre-gain (Klon uses moderate gain staging)
drive_scaled = drive * 10.0 + 1.0
drive_path = buf * drive_scaled
# Symmetrical soft clipping (germanium diode character)
clipped = np.tanh(drive_path * 2.0)
# One-pole LPF on drive path for tone control (treble cut)
tone_cut = 1.0 - tone # 1 = max cut
if tone_cut > 0.001:
fc = 2000.0 + (1.0 - tone_cut) * 18000.0 # 2kHz to 20kHz
omega = 2.0 * np.pi * fc / self._sample_rate
a0 = 1.0 + omega # one-pole approximation
b0 = omega / a0
a1 = (1.0 - omega) / a0
# State tracking for the one-pole
lp_key = "klon_lp_zi"
zi = state.get(lp_key, 0.0)
clipped_filtered = np.zeros_like(clipped)
for i in range(len(clipped)):
clipped_filtered[i] = b0 * clipped[i] + a1 * zi
zi = clipped_filtered[i]
state[lp_key] = zi
clipped = clipped_filtered
# Clean blend: mix clean and overdrive signals
out = (1.0 - blend) * buf + blend * clipped
# Output level
out = out * gain
return np.clip(out, -1.0, 1.0)
# ── 3b. Blues Driver (subtype: bd2) ─────────────────────────────
def _apply_bd2(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Blues Driver-style soft clipping with responsive tone stack.
The Boss BD-2 uses a two-stage asymmetric clipping topology with an
active tone control that can both cut bass/boost treble, giving it
a very wide tonal range from warm low-gain to biting high-gain.
Parameters:
drive (0-1): Drive gain.
tone (0-1): Active tone control — 0=warm (bass), 1=bright (treble).
gain (0-1): Output level recovery.
"""
drive = params.get("drive", 0.5)
tone = params.get("tone", 0.5)
gain = params.get("gain", 1.0)
# Pre-gain boost
drive_scaled = drive * 12.0 + 1.0
shaped = buf * drive_scaled
# Two-stage asymmetric clipping
# Stage 1: moderate soft clip (asymmetric — positive softer)
pos = np.where(shaped > 0, np.tanh(shaped * 1.2), shaped)
stage1 = np.where(pos < 0, pos / (1.0 - pos * 0.2), pos)
# Stage 2: harder clip on positive side for BD-2 character
stage2 = np.tanh(stage1 * 2.0)
# Active tone control: shelving EQ shaped by tone param
# tone=0: bass boost / treble cut
# tone=0.5: flat
# tone=1.0: treble boost / bass cut
if abs(tone - 0.5) > 0.01:
# Map tone to gain: -6dB to +6dB
shelf_gain_db = (tone - 0.5) * 12.0 # -6 to +6 dB
# Treble shelf (3.5kHz, Q=0.7)
coeffs = state.get("bd2_tshelf_coeffs")
tag = round(shelf_gain_db, 2)
if coeffs is None or state.get("bd2_tshelf_tag") != tag:
coeffs = _compute_highshelf_coeffs(3500.0, shelf_gain_db, 0.7, self._sample_rate)
state["bd2_tshelf_coeffs"] = coeffs
state["bd2_tshelf_tag"] = tag
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("bd2_tshelf_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, stage2.astype(np.float64, copy=False), zi=zi)
state["bd2_tshelf_zi"] = zf
stage2 = np.clip(sig, -1.0, 1.0).astype(np.float32)
# Output level
out = np.clip(stage2 * gain, -1.0, 1.0)
return out
# ── 3c. Big Muff Pi (subtype: muff) ─────────────────────────────
def _apply_muff(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Big Muff Pi-style fuzz with tone stack.
The Big Muff uses three cascaded gain stages with clipping diodes
between each stage, creating massive sustain, followed by a
passive tone stack (bass cut + mid scoop + treble cut) and a
volume recovery stage.
Parameters:
sustain (0-1): Gain/fuzz amount (the "Sustain" knob).
tone (0-1): Tone stack position —
0=dark (bass), 0.5=mid-scoop (classic), 1=bright (treble).
volume (0-1): Output volume recovery.
"""
sustain = params.get("sustain", 0.5)
tone = params.get("tone", 0.5)
volume = params.get("volume", 0.7)
# Three-stage gain with inter-stage soft-clipping (1N4148 diode style)
# Stage 1
g1 = sustain * 20.0 + 1.0
s1 = np.tanh(buf * g1)
# Stage 2
g2 = sustain * 15.0 + 1.0
s2 = np.tanh(s1 * g2)
# Stage 3
g3 = sustain * 10.0 + 1.0
s3 = np.tanh(s2 * g3)
# Big Muff tone stack: three-band passive EQ
# Maps tone param (0-1) across the classic tone sweep:
# 0.0 = bass-heavy (low-pass dominant)
# 0.5 = mid-scoop (notch around 1kHz)
# 1.0 = treble-heavy (high-pass dominant)
if tone < 0.5:
# Bass range: low-pass emphasis
blend = tone * 2.0 # 0.0 -> 1.0
bass_gain = 6.0 * (1.0 - blend)
treble_gain = -6.0 * blend
elif tone > 0.5:
# Treble range: high-pass emphasis
blend = (tone - 0.5) * 2.0 # 0.0 -> 1.0
bass_gain = -6.0 * blend
treble_gain = 6.0 * (1.0 - blend)
else:
# Flat response (tone at 0.5 = minimal EQ)
bass_gain = 0.0
treble_gain = 0.0
sig = s3.astype(np.float64, copy=False)
# Bass shelf (200Hz)
if abs(bass_gain) > 0.5:
coeffs = state.get("muff_bass_coeffs")
tag = (round(bass_gain, 1), round(tone, 2))
if coeffs is None or state.get("muff_bass_tag") != tag:
coeffs = _compute_lowshelf_coeffs(200.0, bass_gain, 0.707, self._sample_rate)
state["muff_bass_coeffs"] = coeffs
state["muff_bass_tag"] = tag
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("muff_bass_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, sig, zi=zi)
state["muff_bass_zi"] = zf
# Treble shelf (3kHz)
if abs(treble_gain) > 0.5:
coeffs = state.get("muff_treb_coeffs")
tag = (round(treble_gain, 1), round(tone, 2))
if coeffs is None or state.get("muff_treb_tag") != tag:
coeffs = _compute_highshelf_coeffs(3000.0, treble_gain, 0.707, self._sample_rate)
state["muff_treb_coeffs"] = coeffs
state["muff_treb_tag"] = tag
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("muff_treb_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, sig, zi=zi)
state["muff_treb_zi"] = zf
out = np.clip(np.clip(sig, -1.0, 1.0).astype(np.float32) * volume, -1.0, 1.0)
return out
# ── 4. Three-band EQ ────────────────────────────────────────────
def _apply_eq(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""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
bass_freq = params.get("bass_freq", 200.0)
mid_freq = params.get("mid_freq", 1000.0)
treble_freq = params.get("treble_freq", 3500.0)
q = params.get("q", 0.707)
sig = buf.astype(np.float64, copy=False)
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),
("treble", treble_freq, treble_gain, _compute_highshelf_coeffs),
]:
if gain_db == 0.0:
continue
key = f"eq_{band_name}"
coeffs = state.get(f"{key}_coeffs")
param_tag = (bass_freq, mid_freq, treble_freq, bass_gain, mid_gain, treble_gain, q)
if coeffs is None or state.get(f"{key}_tag") != param_tag:
coeffs = compute_fn(freq, gain_db, q, self._sample_rate)
state[f"{key}_coeffs"] = coeffs
state[f"{key}_tag"] = param_tag
b0, b1, b2, a1, a2 = coeffs
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))
sig, zf = lfilter(b, a, sig, zi=zi)
state[f"{key}_zi"] = zf
return np.clip(sig, -1.0, 1.0).astype(np.float32)
# ── 5. Chorus ───────────────────────────────────────────────────
def _apply_chorus(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Chorus with LFO-driven modulated delay line (stereo-ish)."""
rate = params.get("rate", 0.5) # Hz
depth = params.get("depth", 0.5) # 0.0-1.0
mix = params.get("mix", 0.5) # wet/dry
delay_base = params.get("delay", 20.0) # ms (typical chorus: 15-30ms)
base_samples = delay_base * self._sample_rate / 1000.0
mod_range = depth * 5.0 * self._sample_rate / 1000.0
if "delay" not in state:
max_d = int(base_samples + mod_range + 10.0 * self._sample_rate / 1000.0) + 1
state["delay"] = _DelayLine(max_d)
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")
mod_delay = base_samples + lfo * mod_range
# Vectorised read
wet = delay_line.read_block_varying(mod_delay)
delay_line.write_block(buf)
return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 6. Flanger ──────────────────────────────────────────────────
def _apply_flanger(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Flanger with swept comb filter and feedback."""
rate = params.get("rate", 0.25) # Hz (slower than chorus)
depth = params.get("depth", 0.7) # 0.0-1.0
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.5) # wet/dry
delay_base = params.get("delay", 5.0) # ms (typical flanger: 1-10ms)
base_samples = delay_base * self._sample_rate / 1000.0
mod_range = depth * 5.0 * self._sample_rate / 1000.0
if "delay" not in state:
max_d = int(base_samples + mod_range + 10.0 * self._sample_rate / 1000.0) + 1
state["delay"] = _DelayLine(max_d)
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")
mod_delay = base_samples + lfo * mod_range
# Feedback buffer
feedback_buf = state.get("fb_buf", np.zeros(len(buf), dtype=np.float32))
# Blend feedback into input
fb_input = buf + feedback_buf * feedback
# Vectorised read
wet = delay_line.read_block_varying(mod_delay)
delay_line.write_block(fb_input)
# Store feedback for next block
state["fb_buf"] = wet * 0.5
return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 7. Phaser ───────────────────────────────────────────────────
def _apply_phaser(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Phaser with allpass filter cascade and feedback."""
rate = params.get("rate", 0.4) # Hz
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))
# 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
fb_buf = state.get("fb_buf", np.zeros(len(buf), dtype=np.float64))
fb_input = buf.astype(np.float64, copy=False) + fb_buf * feedback
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 / self._sample_rate
tan_half_w = np.tan(w / 2.0)
coeff = (1.0 - tan_half_w) / (1.0 + tan_half_w)
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"] = 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 ──────────────────────────────────────────────────
def _apply_tremolo(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Tremolo with configurable LFO shape."""
rate = params.get("rate", 4.0) # Hz
depth = params.get("depth", 0.7)
shape = params.get("shape", "sine") # sine / triangle / square
phase = self._lfo_phase(rate, state, len(buf))
lfo = self._lfo_wave(phase, shape)
# LFO is 0-1; tremolo scales between full volume and attenuated
mod = 1.0 - depth * (1.0 - lfo)
return buf * mod
# ── 9. Vibrato ──────────────────────────────────────────────────
def _apply_vibrato(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Vibrato — modulated delay with 100% wet (pitch modulation)."""
rate = params.get("rate", 3.0) # Hz
depth = params.get("depth", 0.5) # cents equivalent
base_samples = 2.0 * self._sample_rate / 1000.0 # fixed ~2ms base
mod_range = depth * 3.0 * self._sample_rate / 1000.0
if "delay" not in state:
max_d = int(base_samples + mod_range + 5.0 * self._sample_rate / 1000.0) + 1
state["delay"] = _DelayLine(max_d)
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")
mod_delay = base_samples + lfo * mod_range
wet = delay_line.read_block_varying(mod_delay)
delay_line.write_block(buf)
return wet
# ── 10. Delay ───────────────────────────────────────────────────
def _apply_delay(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Delay dispatcher — routes to subtype-specific implementation.
Subtype is read from ``params["subtype"]``, defaulting to ``"digital"``.
Available subtypes: ``digital``, ``analog``, ``ping_pong``, ``tape``.
"""
subtype = params.get("subtype", "digital")
match subtype:
case "analog":
return self._apply_analog_delay(buf, params, state)
case "ping_pong":
return self._apply_ping_pong_delay(buf, params, state)
case "tape":
return self._apply_tape_echo(buf, params, state)
case _:
return self._apply_digital_delay(buf, params, state)
def _apply_digital_delay(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Digital delay with feedback and tap-tempo support."""
time_ms = params.get("time", 400.0)
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4)
tap_tempo = params.get("tap_tempo", 0.0)
# Tap tempo overrides time_ms when > 0
if tap_tempo > 0:
time_ms = tap_tempo
delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state:
# Allocate 2x requested delay for headroom
max_d = max(delay_samples * 2, self._sample_rate) # at least 1s
state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2))
delay_line: _DelayLine = state["delay"]
# Read delayed signal
wet = delay_line.read_block(float(delay_samples), len(buf))
# Write dry + clipped feedback to prevent delay-line runaway
fb_gain = min(feedback, 0.98)
write_sig = np.clip(buf + wet * fb_gain, -1.0, 1.0)
delay_line.write_block(write_sig)
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:
"""BBD-style analog delay with low-pass filtering in the feedback path.
Each repeat gets progressively darker (less treble) — the classic
warm, murky analog delay sound. Uses a one-pole low-pass filter
in the feedback loop with subtle BBD-style saturation on the wet path.
Params:
time (float): delay time in ms (default 400.0)
feedback (float): feedback amount 0.0-1.0 (default 0.3)
mix (float): wet/dry blend 0.0-1.0 (default 0.4)
tone (float): feedback brightness 0.0-1.0 (default 0.5)
0.0 = very dark (heavy LPF), 1.0 = brighter (less LPF)
"""
time_ms = params.get("time", 400.0)
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4)
tone = params.get("tone", 0.5) # 0.0=dark, 1.0=bright
delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state:
max_d = max(delay_samples * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2))
delay_line: _DelayLine = state["delay"]
# Read delayed signal
wet = delay_line.read_block(float(delay_samples), len(buf))
# ── Low-pass filter on feedback path (darker repeats) ────────
# Map tone to cutoff: 0.0 → ~500Hz (very dark), 1.0 → ~12kHz (bright)
# cutoff_factor is the one-pole coefficient (0.1-0.99)
cutoff_factor = 0.1 + tone * 0.89
lp_z = state.get("lp_z", 0.0)
lp_out = np.zeros_like(wet)
for i in range(len(wet)):
lp_z = wet[i] * (1.0 - cutoff_factor) + lp_z * cutoff_factor
lp_out[i] = lp_z
state["lp_z"] = float(lp_z)
# ── BBD-style subtle saturation on feedback path ─────────────
# Soft-clip the filtered feedback (BBD companding characteristic)
lp_out = np.tanh(lp_out * 0.5)
# Write with clipped feedback
fb_gain = min(feedback, 0.98)
write_sig = np.clip(buf + lp_out * fb_gain, -1.0, 1.0)
delay_line.write_block(write_sig)
return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 11. Reverb (subtype dispatch) ───────────────────────────────
def _apply_reverb(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Reverb dispatcher — selects algorithm by ``params['subtype']``.
Supported subtypes:
'hall' — classic Schroeder reverb (8 comb + 4 allpass) [default]
'spring' — multi-spring delay-line model (metallic / boingy)
'plate' — dense plate reverb (smooth, rich tail)
'room' — room reverb (early reflections + diffuse late tail)
Backward-compatible: existing presets without ``subtype`` default to 'hall'.
"""
subtype = params.get("subtype", "hall")
mix = params.get("mix", 0.3)
match subtype:
case "spring":
wet = self._reverb_spring(buf, params, state)
case "plate":
wet = self._reverb_plate(buf, params, state)
case "room":
wet = self._reverb_room(buf, params, state)
case _:
wet = self._reverb_hall(buf, params, state)
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:
"""Hall reverb — classic Schroeder with 8 comb + 4 allpass."""
decay = params.get("decay", 0.5)
damping = params.get("damping", 0.4)
predelay_ms = params.get("predelay", 30.0)
if "combs" not in state:
comb_delays = [29, 37, 44, 50, 31, 39, 47, 53] # ms
ap_delays = [5, 7, 11, 13] # ms
state["combs"] = [
_CombFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size)
for d in comb_delays
]
state["allpasses"] = [
_AllpassFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size)
for d in ap_delays
]
state["predelay"] = _DelayLine(
int(predelay_ms * self._sample_rate / 1000.0) + 1
)
state["predelay"].write_block(np.zeros(self._block_size))
state["_computed"] = False
combs: list[_CombFilter] = state["combs"]
allpasses: list[_AllpassFilter] = state["allpasses"]
predelay_line: _DelayLine = state["predelay"]
param_tag = (decay, damping)
if state.get("_param_tag") != param_tag:
scaled_fb = 0.3 + decay * 0.6
scaled_damp = 0.1 + damping * 0.7
for comb in combs:
comb.feedback = min(scaled_fb, 0.95)
comb.damping = min(scaled_damp, 0.85)
for ap in allpasses:
ap.gain = 0.3 + damping * 0.3
state["_param_tag"] = param_tag
delayed = predelay_line.read_block(
float(predelay_ms * self._sample_rate / 1000.0), len(buf))
predelay_line.write_block(buf)
wet = np.zeros_like(buf, dtype=np.float64)
for comb in combs:
wet += comb.process(delayed)
wet /= len(combs)
for ap in allpasses:
wet = ap.process(wet)
return np.clip(wet, -1.0, 1.0).astype(np.float32)
# ── Spring reverb ──────────────────────────────────────────────
def _reverb_spring(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Spring reverb — multi-spring delay-line model.
Emulates 3-4 parallel springs with resonant bandpass feedback,
producing the characteristic metallic / boingy spring-tank sound.
Params:
decay — 0.0-1.0, spring tension / sustain length
damping — 0.0-1.0, high-frequency absorption per spring
colour — 0.0-1.0, spring resonant peak emphasis
"""
decay = params.get("decay", 0.5)
damping = params.get("damping", 0.3)
colour = params.get("colour", 0.5)
predelay_ms = params.get("predelay", 10.0)
if "spring_lines" not in state:
# Spring delay times in ms — prime-ish to avoid comb filtering
spring_delays = [18, 23, 30, 27] # ms — 4 springs
spring_q = [4.0, 6.0, 3.0, 5.0] # resonance Q per spring
state["spring_lines"] = [
{
"delay": _DelayLine(int(d * self._sample_rate / 1000.0
+ self._block_size + 1)),
"q": q,
"prev": 0.0,
"filt_prev": np.zeros(2, dtype=np.float64),
"lp_prev": 0.0,
}
for d, q in zip(spring_delays, spring_q)
]
state["predelay"] = _DelayLine(
int(predelay_ms * self._sample_rate / 1000.0) + 1)
state["predelay"].write_block(np.zeros(self._block_size))
predelay_line: _DelayLine = state["predelay"]
delayed = predelay_line.read_block(
float(predelay_ms * self._sample_rate / 1000.0), len(buf))
predelay_line.write_block(buf)
# Feedback / damping scaling
fb = 0.4 + decay * 0.5 # 0.4-0.9
damp = 0.05 + damping * 0.6 # 0.05-0.65 (one-pole LP coeff)
res_gain = 0.3 + colour * 0.6 # spring resonance emphasis 0.3-0.9
wet = np.zeros(len(buf), dtype=np.float64)
for spring in state["spring_lines"]:
dl: _DelayLine = spring["delay"]
q_val = spring["q"]
# Read delayed signal
delay_samps = dl.max_len - self._block_size - 1
spring_out = dl.read_block(float(delay_samps), len(buf))
# Bandpass filter per spring — emphasises resonant frequency
coeff = _compute_bpf_coeffs(
self._sample_rate / (delay_samps + 1), q_val, self._sample_rate)
b0, b1, b2, a1, a2 = coeff
b_arr = np.array([b0, b1, b2], dtype=np.float64)
a_arr = np.array([1.0, a1, a2], dtype=np.float64)
f_prev = spring["filt_prev"]
res, f_zf = lfilter(b_arr, a_arr,
spring_out.astype(np.float64), zi=f_prev)
spring["filt_prev"] = f_zf
# Apply resonance gain
res = res * res_gain
# High-frequency absorption (one-pole LP on feedback path)
lp_prev = spring["lp_prev"]
damped = np.zeros_like(res)
for i in range(len(res)):
lp_prev = lp_prev * (1.0 - damp) + res[i] * damp
damped[i] = lp_prev
spring["lp_prev"] = float(lp_prev)
# Write back with feedback
write_sig = delayed.astype(np.float64) + damped * fb
dl.write_block(np.clip(write_sig, -1.0, 1.0).astype(np.float32))
wet += damped
wet /= len(state["spring_lines"])
return np.clip(wet, -1.0, 1.0).astype(np.float32)
# ── Plate reverb ───────────────────────────────────────────────
def _reverb_plate(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Plate reverb — dense comb bank + allpass cascade + modulation.
Emulates a large metal plate using 12 parallel comb filters with
modulated delay times for realism, followed by a two-stage allpass
diffuser for smooth, dense decay.
Params:
decay — 0.0-1.0, plate sustain length
damping — 0.0-1.0, high-frequency absorption
density — 0.0-1.0, diffusion density (controls allpass gain)
"""
decay = params.get("decay", 0.5)
damping = params.get("damping", 0.4)
density = params.get("density", 0.6)
predelay_ms = params.get("predelay", 15.0)
if "combs" not in state:
# 12 combs — more = denser plate sound
comb_delays_ms = [5, 11, 19, 23, 34, 39, 42, 48, 53, 57, 62, 68]
ap_delays_ms = [2, 5, 9, 13]
state["combs"] = [
_CombFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size)
for d in comb_delays_ms
]
state["allpasses"] = [
_AllpassFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size)
for d in ap_delays_ms
]
state["predelay"] = _DelayLine(
int(predelay_ms * self._sample_rate / 1000.0) + 1)
state["predelay"].write_block(np.zeros(self._block_size))
# Plate modulation LFO phase
state["mod_phase"] = 0.0
combs: list[_CombFilter] = state["combs"]
allpasses: list[_AllpassFilter] = state["allpasses"]
predelay_line: _DelayLine = state["predelay"]
# Update comb parameters
param_tag = (decay, damping, density)
if state.get("_param_tag") != param_tag:
fb = 0.3 + decay * 0.6 # 0.3-0.9
damp = 0.05 + damping * 0.7 # 0.05-0.75
ap_gain = 0.3 + density * 0.4 # 0.3-0.7
for comb in combs:
comb.feedback = min(fb, 0.94)
comb.damping = min(damp, 0.85)
for ap in allpasses:
ap.gain = ap_gain
state["_param_tag"] = param_tag
# Predelay
delayed = predelay_line.read_block(
float(predelay_ms * self._sample_rate / 1000.0), len(buf))
predelay_line.write_block(buf)
# Plate modulation: very slow LFO (0.15 Hz) for subtle detuning
mod_phase = state.get("mod_phase", 0.0)
delta = 0.15 / self._sample_rate
t = np.arange(len(buf), dtype=np.float64) * delta + mod_phase
t %= 1.0
state["mod_phase"] = float((t[-1] + delta) % 1.0)
# Comb filters (parallel)
wet = np.zeros(len(buf), dtype=np.float64)
for comb in combs:
comb_out = comb.process(delayed)
wet += comb_out.astype(np.float64, copy=False)
wet /= len(combs)
# Allpass diffuser
for ap in allpasses:
wet = ap.process(wet)
# Apply modulation interpolation for subtle pitch wobble
mod = 0.5 + 0.5 * np.sin(2.0 * np.pi * t) # 0-1 wobble
mod_idx = np.arange(len(buf), dtype=np.float64) + (mod - 0.5) * 0.3
mod_idx = np.clip(mod_idx, 0, len(buf) - 1)
int_idx = mod_idx.astype(np.int32)
frac = mod_idx - int_idx
nxt = np.minimum(int_idx + 1, len(buf) - 1)
wet = wet[int_idx] * (1.0 - frac) + wet[nxt] * frac
return np.clip(wet, -1.0, 1.0).astype(np.float32)
# ── Room reverb ────────────────────────────────────────────────
def _reverb_room(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Room reverb — early reflection taps + late diffuse tail.
First 30-80 ms: distinct early reflections (scaled by room size).
After: a small Schroeder-style late reverb for the diffuse tail.
Params:
size — 0.0-1.0, room dimensions (scales all delays)
decay — 0.0-1.0, reverb tail length
damping — 0.0-1.0, high-frequency absorption
"""
size = params.get("size", 0.5)
decay = params.get("decay", 0.4)
damping = params.get("damping", 0.4)
predelay_ms = params.get("predelay", 5.0)
size_factor = 0.3 + size * 1.7 # 0.3-2.0
if "er_delay" not in state:
# Early reflection tap times (ms) — room-appropriate spacing
base_taps_ms = [3, 7, 12, 18, 26, 36, 48, 62]
# Small late comb/allpass for diffuse tail
tail_comb_ms = [21, 29, 37, 44]
tail_ap_ms = [4, 8, 13]
max_tap = int(max(base_taps_ms) * size_factor
* self._sample_rate / 1000.0)
state["er_delay"] = _DelayLine(max_tap + self._block_size + 1)
state["er_taps"] = [
int(t * size_factor * self._sample_rate / 1000.0)
for t in base_taps_ms
]
state["tail_combs"] = [
_CombFilter(int(d * size_factor * self._sample_rate / 1000.0 + 1), block_size=self._block_size)
for d in tail_comb_ms
]
state["tail_allpasses"] = [
_AllpassFilter(
int(d * size_factor * self._sample_rate / 1000.0 + 1),
block_size=self._block_size)
for d in tail_ap_ms
]
state["predelay"] = _DelayLine(
int(predelay_ms * self._sample_rate / 1000.0) + 1)
state["predelay"].write_block(np.zeros(self._block_size))
predelay_line: _DelayLine = state["predelay"]
delayed = predelay_line.read_block(
float(predelay_ms * self._sample_rate / 1000.0), len(buf))
predelay_line.write_block(buf)
er_delay: _DelayLine = state["er_delay"]
er_delay.write_block(delayed)
# ── Early reflections ──
taps = state["er_taps"]
num_taps = len(taps)
er = np.zeros(len(buf), dtype=np.float64)
amp = 0.5 + decay * 0.4 # 0.5-0.9
for i, tap in enumerate(taps):
tap_gain = amp * (1.0 - i * 0.6 / max(num_taps, 1))
tap_gain = max(tap_gain, 0.0)
reflected = er_delay.read_block(float(tap), len(buf))
er += reflected.astype(np.float64) * tap_gain
er_sum = sum(1.0 - i * 0.6 / num_taps for i in range(num_taps))
if er_sum > 0:
er /= er_sum
# ── Late reverb tail ──
tail_combs: list[_CombFilter] = state["tail_combs"]
tail_ap: list[_AllpassFilter] = state["tail_allpasses"]
param_tag = (decay, damping, size)
if state.get("_tag_tail") != param_tag:
fb = 0.2 + decay * 0.6 # 0.2-0.8
damp = 0.1 + damping * 0.6 # 0.1-0.7
for c in tail_combs:
c.feedback = min(fb, 0.92)
c.damping = min(damp, 0.85)
for ap in tail_ap:
ap.gain = 0.3 + damping * 0.2
state["_tag_tail"] = param_tag
tail = np.zeros(len(buf), dtype=np.float64)
for c in tail_combs:
tail += c.process(delayed)
tail /= len(tail_combs)
for ap in tail_ap:
tail = ap.process(tail)
# Blend: early reflections dominate early, tail fills in
fade_len = int(len(buf) * 0.3)
if fade_len > 0:
blend = np.ones(len(buf), dtype=np.float64)
blend[:fade_len] = np.linspace(0.0, 1.0, fade_len)
wet = er * (1.0 - blend * 0.3) + tail * blend
else:
wet = er + tail * 0.5
return np.clip(wet, -1.0, 1.0).astype(np.float32)
# ── 12. Volume ──────────────────────────────────────────────────
def _apply_volume(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Simple volume/level control."""
level = params.get("level", 1.0)
return buf * level
# ── 13. IR Cabinet Simulator ─────────────────────────────────────
def _apply_ir_cab(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Apply IR convolution via IRLoader.process().
Delegates to the IRLoader's FFT overlap-add engine.
Supports wet/dry mix control per-preset.
Params:
- ir_file: str (path to .wav IR) — already set via load_ir()
- enabled: bool
- wet: float 0.0-1.0
- dry: float 0.0-1.0
"""
# Update mix from preset params
wet = params.get("wet", 1.0)
dry = params.get("dry", 0.0)
self.ir.set_mix(wet=wet, dry=dry)
self.ir.enabled = params.get("enabled", True) and not params.get("bypass", False)
return self.ir.process(buf)
# ── 14. Octaver ──────────────────────────────────────────────────
def _apply_octaver(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Sub-octave via full-wave rectification + divide-by-2.
Mix control blends between dry and octave-down.
"""
mix = params.get("mix", 0.5)
# Full-wave rectification produces even harmonics including sub-octave
rectified = np.abs(buf)
# Low-pass to isolate fundamental (below ~300Hz corner)
# Simple one-pole: alpha ~ 0.95 at 48kHz
alpha = state.get("lp_alpha", 0.95)
lp = np.zeros_like(buf)
prev = state.get("lp_prev", 0.0)
for i in range(len(buf)):
prev = alpha * prev + (1.0 - alpha) * rectified[i]
lp[i] = prev
state["lp_prev"] = prev
# Scale to match input level
sub = lp * 0.5
out = buf * (1.0 - mix) + sub * mix
return np.clip(out, -1.0, 1.0)
# ── 15. Pitch Shifter ────────────────────────────────────────────
# BETA — test on RPi 4B for xruns
def _apply_pitch_shifter(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Granular/overlap-add pitch shifter for subtle shifts (±2 semitones).
CPU-heavy, tagged as beta.
"""
# BETA — test on RPi 4B for xruns
shift_semitones = params.get("shift", 0.0)
mix = params.get("mix", 0.5)
if shift_semitones == 0.0:
return buf
# Pitch factor: 2^(shift/12)
factor = 2.0 ** (shift_semitones / 12.0)
# Windowed overlap-add with grain size ~40ms
grain_size = int(0.040 * self._sample_rate) # 1920 samples at 48kHz
hop_in = grain_size
hop_out = int(grain_size / factor) if factor > 0 else grain_size
if "ring" not in state:
state["ring"] = np.zeros(grain_size * 2, dtype=np.float32)
state["write_pos"] = 0
state["read_pos"] = 0
ring: np.ndarray = state["ring"]
wpos = state["write_pos"]
rpos = state["read_pos"]
# Write input into ring buffer
for i, s in enumerate(buf):
ring[wpos % len(ring)] = s
wpos += 1
# Read with resampling via linear interpolation
out = np.zeros_like(buf)
for i in range(len(buf)):
idx_floor = int(np.floor(rpos))
frac = rpos - idx_floor
a = ring[idx_floor % len(ring)]
b = ring[(idx_floor + 1) % len(ring)]
out[i] = a + frac * (b - a)
rpos += factor
if rpos >= wpos:
rpos = wpos - 1
state["write_pos"] = wpos
state["read_pos"] = rpos
# Apply raised-cosine window
window = 0.5 * (1.0 - np.cos(2.0 * np.pi * np.arange(len(buf)) / len(buf)))
out = out * window
return buf * (1.0 - mix) + out.astype(np.float32) * mix
# ── 16. Harmonizer ──────────────────────────────────────────────
def _apply_harmonizer(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Pitch shift + dry mix, with MIDI-note-aware shift amount.
shift parameter is in semitones (e.g. 3 = minor third above).
"""
shift_semitones = params.get("shift", 3.0)
mix = params.get("mix", 0.5)
if shift_semitones == 0.0:
return buf
factor = 2.0 ** (shift_semitones / 12.0)
grain_size = int(0.040 * self._sample_rate)
hop_out = int(grain_size / factor)
if "ring" not in state:
state["ring"] = np.zeros(grain_size * 2, dtype=np.float32)
state["wpos"] = 0
state["rpos"] = 0
ring = state["ring"]
wpos = state["wpos"]
rpos = state["rpos"]
for s in buf:
ring[wpos % len(ring)] = s
wpos += 1
out = np.zeros_like(buf)
for i in range(len(buf)):
idx = int(np.floor(rpos))
frac = rpos - idx
a = ring[idx % len(ring)]
b = ring[(idx + 1) % len(ring)]
out[i] = a + frac * (b - a)
rpos += factor
if rpos >= wpos:
rpos = wpos - 1
state["wpos"] = wpos
state["rpos"] = rpos
window = 0.5 * (1.0 - np.cos(2.0 * np.pi * np.arange(len(buf)) / len(buf)))
out = out * window
return buf * (1.0 - mix) + out.astype(np.float32) * mix
# ── 17. Whammy ──────────────────────────────────────────────────
def _apply_whammy(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Continuous pitch bend via parameter (expression pedal target).
bend parameter: 0.0 = no shift, 1.0 = +12 semitones up.
"""
bend = params.get("bend", 0.0) # 0.0-1.0 maps to 0-12 semitones
mix = params.get("mix", 0.7)
shift_semitones = bend * 12.0
if "ring" not in state:
grain_size = int(0.040 * self._sample_rate)
state["ring"] = np.zeros(grain_size * 2, dtype=np.float32)
state["wpos"] = 0
state["rpos"] = 0
factor = 2.0 ** (shift_semitones / 12.0)
ring = state["ring"]
wpos = state["wpos"]
rpos = state["rpos"]
for s in buf:
ring[wpos % len(ring)] = s
wpos += 1
out = np.zeros_like(buf)
for i in range(len(buf)):
idx = int(np.floor(rpos))
frac = rpos - idx
a = ring[idx % len(ring)]
b = ring[(idx + 1) % len(ring)]
out[i] = a + frac * (b - a)
rpos += factor
if rpos >= wpos:
rpos = wpos - 1
state["wpos"] = wpos
state["rpos"] = rpos
window = 0.5 * (1.0 - np.cos(2.0 * np.pi * np.arange(len(buf)) / len(buf)))
out = out * window
return buf * (1.0 - mix) + out.astype(np.float32) * mix
# ── 18. Detune ──────────────────────────────────────────────────
def _apply_detune(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Very short modulated delay (0.5-5ms), chorus-like thickening.
Detunes by shifting pitch via modulated read of a tiny delay line.
"""
depth = params.get("depth", 0.5)
mix = params.get("mix", 0.5)
base_samples = 1.0 * self._sample_rate / 1000.0 # 1ms base
mod_range = depth * 3.0 * self._sample_rate / 1000.0
if "delay" not in state:
max_d = int(base_samples + mod_range + 5.0 * self._sample_rate / 1000.0) + 1
state["delay"] = _DelayLine(max_d)
state["delay"].write_block(np.zeros(max_d))
delay_line: _DelayLine = state["delay"]
phase = self._lfo_phase(4.0, state, len(buf)) # 4Hz LFO
lfo = self._lfo_wave(phase, "sine")
mod_delay = base_samples + lfo * mod_range
wet = delay_line.read_block_varying(mod_delay)
delay_line.write_block(buf)
return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 19. Ring Modulator ───────────────────────────────────────────
def _apply_ring_modulator(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Multiply signal with sine LFO. Rate, Depth, Mix."""
rate = params.get("rate", 100.0) # Hz
depth = params.get("depth", 0.5)
mix = params.get("mix", 0.5)
phase = self._lfo_phase(rate, state, len(buf))
lfo = self._lfo_wave(phase, "sine")
carrier = lfo * 2.0 - 1.0 # map to [-1, 1]
ring = buf * carrier
rm_signal = buf * (1.0 - depth) + ring * depth
return buf * (1.0 - mix) + rm_signal * mix
# ── 20. Auto-Wah ────────────────────────────────────────────────
def _apply_auto_wah(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Envelope follower drives bandpass filter cutoff.
Sensitivity, Q, Mix.
"""
sensitivity = params.get("sensitivity", 0.5)
q = params.get("q", 2.0)
mix = params.get("mix", 0.5)
# Envelope follower
rms = np.sqrt(np.mean(buf ** 2) + _EPS)
env = state.get("envelope", 0.0)
env = env * 0.9 + rms * 0.1 # smoothed
state["envelope"] = env
# Map envelope to cutoff frequency: 400-4000 Hz
cutoff = 400.0 + env * sensitivity * 3600.0
cutoff = np.clip(cutoff, 200.0, 5000.0)
# BPF biquad
coeffs = _compute_bpf_coeffs(cutoff, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("bpf_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, buf.astype(np.float64), zi=zi)
state["bpf_zi"] = zf
wet = sig.astype(np.float32)
return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 21. Envelope Filter ──────────────────────────────────────────
def _apply_envelope_filter(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Filter cutoff follows pick attack envelope.
Decay, Range, Mix.
"""
decay = params.get("decay", 0.5)
freq_range = params.get("range", 0.5)
mix = params.get("mix", 0.5)
# Envelope follower with decay
rms = np.sqrt(np.mean(buf ** 2) + _EPS)
env = state.get("envelope", 0.0)
if rms > env:
env = rms # instant attack
else:
env = env * np.exp(-self._block_size / (decay * 0.1 * self._sample_rate))
state["envelope"] = env
cutoff = 200.0 + env * freq_range * 3800.0
cutoff = np.clip(cutoff, 100.0, 4000.0)
coeffs = _compute_lpf_coeffs(cutoff, 0.707, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("lpf_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, buf.astype(np.float64), zi=zi)
state["lpf_zi"] = zf
wet = sig.astype(np.float32)
return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 22. Rotary Speaker ──────────────────────────────────────────
def _apply_rotary_speaker(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Dual modulated allpass filters (Leslie sim).
Speed, Drive, Mix.
"""
speed = params.get("speed", 0.5) # 0.0-1.0, slow-fast
drive = params.get("drive", 0.3)
mix = params.get("mix", 0.5)
# Map speed: slow rotor ~0.5Hz, fast ~6.5Hz
rotor_rate = 0.5 + speed * 6.0
# Horn: ~2x rotor speed
horn_rate = rotor_rate * 2.0
# Dual modulated delays (rotor + horn)
if "rotor_delay" not in state:
state["rotor_delay"] = _DelayLine(int(0.020 * self._sample_rate + 1))
state["horn_delay"] = _DelayLine(int(0.015 * self._sample_rate + 1))
state["rotor_delay"].write_block(np.zeros(int(0.020 * self._sample_rate)))
state["horn_delay"].write_block(np.zeros(int(0.015 * self._sample_rate)))
rotor_phase = self._lfo_phase(rotor_rate, state, len(buf))
horn_phase = self._lfo_phase(horn_rate, state, len(buf))
# Rotor: deeper modulation, slower
rotor_lfo = self._lfo_wave(rotor_phase, "sine")
rotor_delay_s = 0.003 + rotor_lfo * 0.005 # 3-8ms
# Horn: shallower, faster
horn_lfo = self._lfo_wave(horn_phase, "sine")
horn_delay_s = 0.001 + horn_lfo * 0.002 # 1-3ms
rotor_wet = state["rotor_delay"].read_block_varying(
rotor_delay_s * self._sample_rate)
state["rotor_delay"].write_block(buf)
horn_wet = state["horn_delay"].read_block_varying(
horn_delay_s * self._sample_rate)
state["horn_delay"].write_block(buf * 0.7)
# Mix with subtle drive (soft clip)
combined = rotor_wet + horn_wet
if drive > 0:
combined = np.tanh(combined * (1.0 + drive * 2.0))
return buf * (1.0 - mix) + combined.astype(np.float32) * mix
# ── 23. Uni-Vibe ────────────────────────────────────────────────
def _apply_uni_vibe(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Photoresistor-style phaser.
Rate, Depth, Feedback, Mix.
"""
rate = params.get("rate", 0.8)
depth = params.get("depth", 0.5)
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.5)
phase = self._lfo_phase(rate, state, len(buf))
lfo = self._lfo_wave(phase, "sine")
# Uni-Vibe uses 4 allpass stages with LFO-driven modulation
# Centre frequency sweeps between 200-2000 Hz
if "fb_buf" not in state:
state["fb_buf"] = np.zeros(len(buf), dtype=np.float64)
fb_buf = state["fb_buf"]
fb_input = buf.astype(np.float64, copy=False) + fb_buf * feedback
# Frequency per sample varies with LFO
sig = fb_input.copy()
for stage in range(4):
freq = 200.0 + lfo * depth * 1800.0
freq_mean = float(np.mean(freq))
w = 2.0 * np.pi * freq_mean / self._sample_rate
tan_half_w = np.tan(w / 2.0)
coeff = (1.0 - tan_half_w) / (1.0 + tan_half_w)
b = np.array([coeff, 1.0], dtype=np.float64)
a = np.array([1.0, coeff], dtype=np.float64)
zi = state.get(f"uv_zi_{stage}", np.zeros(1, dtype=np.float64))
sig, zf = lfilter(b, a, sig, zi=zi)
state[f"uv_zi_{stage}"] = zf
state["fb_buf"] = sig * 0.3
sig = np.clip(sig, -1.0, 1.0)
return (buf * (1.0 - mix) + sig.astype(np.float32) * mix).astype(np.float32)
# ── 24. Auto-Pan ────────────────────────────────────────────────
def _apply_auto_pan(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""LFO-driven stereo pan.
Rate, Depth, Waveform.
Currently returns mono with pan applied; full stereo needs
2-channel pipeline. Simulated here as amplitude modulation.
"""
rate = params.get("rate", 0.3)
depth = params.get("depth", 0.7)
waveform = params.get("waveform", "sine")
phase = self._lfo_phase(rate, state, len(buf))
lfo = self._lfo_wave(phase, waveform)
# Map LFO (0-1) to pan [-1, 1]
pan = (lfo * 2.0 - 1.0) * depth
# Apply pan as amplitude modulation
left_gain = np.clip(1.0 - pan, 0.0, 1.0)
right_gain = np.clip(1.0 + pan, 0.0, 1.0)
# Mono output: average both channels
mod = (left_gain + right_gain) * 0.5
return (buf * mod).astype(np.float32)
# ── 25. Stereo Widener ──────────────────────────────────────────
def _apply_stereo_widener(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Mid/side processing, widen by boosting side channel.
Width, Mix.
Mono input: creates synthetic side from filtered difference.
"""
width = params.get("width", 0.5)
mix = params.get("mix", 0.5)
# From mono: create synthetic stereo by delaying a copy
if "delay" not in state:
state["delay"] = _DelayLine(int(0.030 * self._sample_rate + 1))
state["delay"].write_block(np.zeros(int(0.030 * self._sample_rate)))
delay_line: _DelayLine = state["delay"]
# Read delayed signal for "side" channel
side = delay_line.read_block(0.010 * self._sample_rate, len(buf))
delay_line.write_block(buf)
# Mid = original, Side = delayed difference
mid = buf * 0.5 + side * 0.5
side_boosted = (buf - side) * (0.5 + width * 0.5)
# Mix: blend widened signal with original
widened = mid + side_boosted
return (buf * (1.0 - mix) + widened * mix).astype(np.float32)
# ── 26. Bitcrusher ──────────────────────────────────────────────
def _apply_bitcrusher(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Reduce bit depth + sample rate. Bits, Rate, Mix."""
bits = params.get("bits", 8) # 1-16
rate_reduction = params.get("rate", 1.0) # 1-10, higher = more crush
mix = params.get("mix", 0.5)
# Bit depth reduction
n_levels = max(2, 2 ** min(bits, 16))
crushed = np.round(buf * n_levels) / n_levels
# Sample rate reduction (hold each sample for N samples)
hold = max(1, int(rate_reduction))
if hold > 1:
out = np.zeros_like(crushed)
held_val = state.get("held_val", 0.0)
hold_count = state.get("hold_count", 0)
for i in range(len(crushed)):
if hold_count >= hold:
held_val = crushed[i]
hold_count = 0
out[i] = held_val
hold_count += 1
state["held_val"] = float(held_val)
state["hold_count"] = hold_count
crushed = out
return buf * (1.0 - mix) + crushed * mix
# ── 27. Wavefolder ──────────────────────────────────────────────
def _apply_wavefolder(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Fold signal back at thresholds (Klanggebiet-style).
Gain, Fold, Mix.
"""
gain = params.get("gain", 2.0)
fold = params.get("fold", 3.0)
mix = params.get("mix", 0.5)
shaped = buf * gain
# Multi-stage wavefolding
for _ in range(int(fold)):
# Fold: if abs > 1, fold back
above = np.abs(shaped) > 1.0
shaped = np.where(above, np.sign(shaped) * (2.0 - np.abs(shaped)), shaped)
return (buf * (1.0 - mix) + shaped * mix).astype(np.float32)
# ── 28. Rectifier ───────────────────────────────────────────────
def _apply_rectifier(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Full/half-wave rectification for octave-up fuzz.
Mode (full/half), Mix.
"""
mode = params.get("mode", "full") # "full" or "half"
mix = params.get("mix", 0.5)
if mode == "full":
rectified = np.abs(buf) # full-wave: all positive, octave up
else:
rectified = np.where(buf > 0, buf, 0.0) # half-wave
return buf * (1.0 - mix) + rectified * mix
# ── 29. Expander ────────────────────────────────────────────────
def _apply_expander(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Inverse compressor, widens dynamic range.
Threshold, Ratio, Attack, Release.
"""
threshold_db = params.get("threshold", -30.0)
ratio = params.get("ratio", 3.0)
attack_ms = params.get("attack", 5.0)
release_ms = params.get("release", 100.0)
rms = np.sqrt(np.mean(buf ** 2) + _EPS)
envelope = state.get("envelope", 0.0)
if rms > envelope:
alpha = np.exp(-self._block_size / (attack_ms * self._sample_rate / 1000.0))
else:
alpha = np.exp(-self._block_size / (release_ms * self._sample_rate / 1000.0))
envelope = envelope * alpha + rms * (1.0 - alpha)
state["envelope"] = envelope
if envelope > 1e-10:
env_db = 20.0 * np.log10(envelope)
else:
env_db = -120.0
if env_db < threshold_db:
# Expand: gain_db = (env - threshold) * (ratio - 1)
gain_db = (env_db - threshold_db) * (ratio - 1.0)
else:
gain_db = 0.0
gain_lin = 10 ** (gain_db / 20.0)
return np.clip(buf * gain_lin, -1.0, 1.0)
# ── 30. De-esser ────────────────────────────────────────────────
def _apply_de_esser(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Compress sibilance band (EQ split + compressor).
Frequency, Threshold, Mix.
"""
freq = params.get("frequency", 6000.0) # sibilance centre
threshold = params.get("threshold", -30.0)
mix = params.get("mix", 0.5)
# Bandpass the sibilance band
q = 3.0 # narrow Q for sibilance band
coeffs = _compute_bpf_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("bpf_zi", np.zeros(2, dtype=np.float64))
sibilance, zf = lfilter(b, a, buf.astype(np.float64), zi=zi)
state["bpf_zi"] = zf
# RMS of sibilance band
sib_rms = np.sqrt(np.mean(sibilance ** 2) + _EPS)
# Compress sibilance
if sib_rms > (10.0 ** (threshold / 20.0)):
gain = 0.3 # heavy reduction
else:
gain = 1.0
# Split: clean signal minus reduced sibilance
clean = buf.astype(np.float64) - sibilance * (1.0 - gain)
return (buf * (1.0 - mix) + clean.astype(np.float32) * mix).astype(np.float32)
# ── 31. Transient Shaper ────────────────────────────────────────
def _apply_transient_shaper(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Attack boost/sustain per note. Attack, Sustain, Mix."""
attack = params.get("attack", 0.5) # boost amount
sustain = params.get("sustain", 0.5) # sustain amount
mix = params.get("mix", 0.5)
# Envelope follower with fast attack/slow release
rms = np.sqrt(np.mean(buf ** 2) + _EPS)
env = state.get("envelope", 0.0)
if rms > env:
env = env * 0.5 + rms * 0.5 # fast attack
else:
env = env * 0.995 + rms * 0.005 # slow release
state["envelope"] = env
# Differentiate to find transients
if "prev" not in state:
state["prev"] = np.float32(0.0)
diff = np.abs(buf.astype(np.float64) - state["prev"])
state["prev"] = np.float32(buf[-1])
# Transient detection: diff > envelope * threshold
trans_thresh = max(env * 0.1, 0.001)
is_transient = diff > trans_thresh
# Attack boost on transients
boost = np.ones_like(buf)
boost[is_transient] = 1.0 + attack * 2.0
# Sustain: amplify quiet tail
tail = env > 0.0
sustain_gain = 1.0 + (sustain - 0.5) * 2.0
shaped = buf * boost
shaped[~is_transient] = buf[~is_transient] * sustain_gain
return (buf * (1.0 - mix) + shaped.astype(np.float32) * mix).astype(np.float32)
# ── 32. Sidechain Compressor ────────────────────────────────────
def _apply_sidechain_compressor(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Compression triggered by external input (4CM return channel).
Threshold, Ratio, Attack, Release, Mix.
NOTE: In mono mode, simulates sidechain from the input signal itself.
"""
threshold_db = params.get("threshold", -20.0)
ratio = params.get("ratio", 4.0)
attack_ms = params.get("attack", 2.0)
release_ms = params.get("release", 50.0)
mix = params.get("mix", 0.5)
# Sidechain signal = the input itself (in mono mode)
# In 4CM mode, the return channel would be the sidechain
sidechain = buf
rms = np.sqrt(np.mean(sidechain ** 2) + _EPS)
envelope = state.get("envelope", 0.0)
if rms > envelope:
alpha = np.exp(-self._block_size / (attack_ms * self._sample_rate / 1000.0))
else:
alpha = np.exp(-self._block_size / (release_ms * self._sample_rate / 1000.0))
envelope = envelope * alpha + rms * (1.0 - alpha)
state["envelope"] = envelope
if envelope > 1e-10:
env_db = 20.0 * np.log10(envelope)
else:
env_db = -120.0
if env_db > threshold_db:
gain_db = threshold_db + (env_db - threshold_db) / ratio - env_db
else:
gain_db = 0.0
gain_lin = 10 ** (gain_db / 20.0)
compressed = np.clip(buf * gain_lin, -1.0, 1.0)
return buf * (1.0 - mix) + compressed * mix
# ── 33. Parametric EQ ──────────────────────────────────────────
def _apply_parametric_eq(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""N-band biquad peaking filters.
Takes array-style params: freq_0, gain_0, q_0, freq_1, ...
Up to 4 bands.
"""
# Parse bands from flattened params
sig = buf.astype(np.float64, copy=False)
for band in range(4):
freq = params.get(f"freq_{band}", 0.0)
gain_db = params.get(f"gain_{band}", 0.0)
q = params.get(f"q_{band}", 0.707)
if freq == 0.0 or gain_db == 0.0:
continue
coeffs = _compute_peaking_coeffs(freq, gain_db, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get(f"peq_zi_{band}", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, sig, zi=zi)
state[f"peq_zi_{band}"] = zf
return np.clip(sig, -1.0, 1.0).astype(np.float32)
# ── 34-37. HPF / LPF / BPF / Notch ──────────────────────────────
def _apply_hpf(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""High-pass filter. Frequency, Slope (6/12dB oct)."""
freq = params.get("frequency", 200.0)
slope = params.get("slope", 12.0)
q = 0.707 if slope >= 12 else 0.5 # 12dB = Q=0.707 (Butter), 6dB = Q=0.5
coeffs = _compute_hpf_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("hpf_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, buf.astype(np.float64), zi=zi)
state["hpf_zi"] = zf
# If 6dB slope, mix in a first-order version (less aggressive)
if slope < 12:
# First-order HPF: simpler - just one pole
alpha = 1.0 / (1.0 + self._sample_rate / (2.0 * np.pi * freq))
zi1 = state.get("hpf_zi_1", np.float64(0.0))
out = np.zeros(len(buf), dtype=np.float64)
for i in range(len(buf)):
zi1 = alpha * (zi1 + buf[i] - state.get("hpf_last", np.float64(0.0)))
out[i] = zi1
state["hpf_last"] = np.float64(buf[i])
state["hpf_zi_1"] = zi1
return np.clip(out, -1.0, 1.0).astype(np.float32)
return np.clip(sig, -1.0, 1.0).astype(np.float32)
def _apply_lpf(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Low-pass filter. Frequency, Slope (6/12dB)."""
freq = params.get("frequency", 5000.0)
slope = params.get("slope", 12.0)
q = 0.707 if slope >= 12 else 0.5
coeffs = _compute_lpf_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("lpf_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, buf.astype(np.float64), zi=zi)
state["lpf_zi"] = zf
if slope < 12:
alpha = 1.0 / (1.0 + self._sample_rate / (2.0 * np.pi * freq))
zi1 = state.get("lpf_zi_1", np.float64(0.0))
out = np.zeros(len(buf), dtype=np.float64)
for i in range(len(buf)):
zi1 = zi1 + alpha * (buf[i] - zi1)
out[i] = zi1
state["lpf_zi_1"] = zi1
return np.clip(out, -1.0, 1.0).astype(np.float32)
return np.clip(sig, -1.0, 1.0).astype(np.float32)
def _apply_bpf(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Band-pass filter. Frequency, Q."""
freq = params.get("frequency", 1000.0)
q = params.get("q", 0.707)
coeffs = _compute_bpf_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("bpf_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, buf.astype(np.float64), zi=zi)
state["bpf_zi"] = zf
return np.clip(sig, -1.0, 1.0).astype(np.float32)
def _apply_notch(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Notch filter. Frequency, Q."""
freq = params.get("frequency", 60.0)
q = params.get("q", 10.0) # High Q for narrow notch
coeffs = _compute_notch_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get("notch_zi", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, buf.astype(np.float64), zi=zi)
state["notch_zi"] = zf
return np.clip(sig, -1.0, 1.0).astype(np.float32)
# ── 38. Formant Filter ──────────────────────────────────────────
def _apply_formant_filter(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Fixed resonant peaks (vowel shapes). Vowel (a/e/i/o/u), Mix."""
vowel = params.get("vowel", "a")
mix = params.get("mix", 0.5)
# Formant frequencies and bandwidths for vowels (F1, F2, F3)
formants = {
"a": [(700, 80), (1100, 90), (2500, 120)],
"e": [(400, 60), (1800, 80), (2600, 100)],
"i": [(300, 50), (2300, 80), (2900, 100)],
"o": [(450, 70), (800, 80), (2700, 110)],
"u": [(300, 50), (700, 70), (2700, 110)],
}
bands = formants.get(vowel, formants["a"])
sig = buf.astype(np.float64, copy=False)
for stage, (freq, bw) in enumerate(bands):
q_val = freq / bw # Q from bandwidth
coeffs = _compute_peaking_coeffs(freq, 12.0, q_val, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64)
zi = state.get(f"form_zi_{stage}", np.zeros(2, dtype=np.float64))
sig, zf = lfilter(b, a, sig, zi=zi)
state[f"form_zi_{stage}"] = zf
wet = np.clip(sig, -1.0, 1.0).astype(np.float32)
return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 39. Ping-pong Delay ─────────────────────────────────────────
def _apply_ping_pong_delay(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Alternate left/right taps. Time, Feedback, Mix.
Mono input: creates stereo effect by alternating.
"""
time_ms = params.get("time", 400.0)
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4)
delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state:
max_d = max(delay_samples * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2))
state["ping"] = 1 # 1 = left, -1 = right
delay_line: _DelayLine = state["delay"]
wet = delay_line.read_block(float(delay_samples), len(buf))
fb_gain = min(feedback, 0.98)
# Alternate pan per block
pan = state.get("ping", 1)
panned_wet = wet * (0.5 + pan * 0.5)
write_sig = buf + panned_wet * fb_gain
delay_line.write_block(write_sig)
state["ping"] = -pan
return buf * (1.0 - mix) + panned_wet * mix
# ── 40. Multi-tap Delay ─────────────────────────────────────────
def _apply_multi_tap_delay(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Rhythmic repeats (dotted eighth, triplet). Pattern, Feedback, Mix."""
pattern = params.get("pattern", "quarter") # quarter/dotted/triplet
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4)
# Tap times in ms, relative to quarter note = 500ms (120 BPM default)
quarter = 500.0
tap_patterns = {
"quarter": [quarter],
"dotted": [quarter * 1.5],
"triplet": [quarter / 3.0, quarter * 2.0 / 3.0, quarter],
}
taps = tap_patterns.get(pattern, tap_patterns["quarter"])
max_delay = int(max(taps) * self._sample_rate / 1000.0)
if "delay" not in state:
max_d = max(max_delay * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2))
delay_line: _DelayLine = state["delay"]
# Sum all tap reads
wet = np.zeros(len(buf), dtype=np.float32)
for tap_ms in taps:
tap_samples = int(tap_ms * self._sample_rate / 1000.0)
tap_sig = delay_line.read_block(float(tap_samples), len(buf))
wet += tap_sig * (1.0 / len(taps)) # Normalise
fb_gain = min(feedback, 0.98)
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 ───────────────────────────────────────────
def _apply_reverse_delay(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Buffer + reverse before playback. Time, Feedback, Mix."""
time_ms = params.get("time", 400.0)
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4)
delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state:
max_d = max(delay_samples * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2))
state["read_pos"] = 0
delay_line: _DelayLine = state["delay"]
rpos = state.get("read_pos", 0)
# Read reversed: read from buffer start, write at current pos
# Reverse: read from (write_pos - read_offset) going backwards
wpos = delay_line.write_idx
buf_len = len(buf)
out = np.zeros(buf_len, dtype=np.float32)
for i in range(buf_len):
# Read sample at (wpos - rpos) mod max_len
rev_idx = (wpos - rpos) % delay_line.max_len
out[i] = delay_line.buf[rev_idx]
rpos = (rpos + 1) % delay_samples
state["read_pos"] = rpos % delay_samples
fb_gain = min(feedback, 0.98)
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 ───────────────────────────────────────────────
def _apply_tape_echo(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Delay with wow/flutter + high-cut feedback. Time, Wow, High-Cut, Mix."""
time_ms = params.get("time", 300.0)
wow = params.get("wow", 0.3) # wow/flutter intensity
high_cut = params.get("high_cut", 0.5) # 0.0-1.0 high cut
feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4)
delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state:
max_d = max(delay_samples * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2))
delay_line: _DelayLine = state["delay"]
# Wow/flutter: LFO on read position (wobble)
phase = self._lfo_phase(4.0, state, len(buf)) # 4Hz wow
lfo = self._lfo_wave(phase, "sine")
wow_offset = (lfo * 2.0 - 1.0) * wow * 3.0 # up to ±3 samples wobble
read_delay = float(delay_samples) + wow_offset
wet = delay_line.read_block_varying(read_delay)
# High-cut: one-pole low-pass on feedback
if high_cut > 0:
cutoff_factor = 1.0 - high_cut * 0.9 # 0.1-1.0
lp_prev = state.get("lp_prev", 0.0)
lp_out = np.zeros_like(wet)
for i in range(len(wet)):
lp_prev = lp_prev * cutoff_factor + wet[i] * (1.0 - cutoff_factor)
lp_out[i] = lp_prev
state["lp_prev"] = float(lp_prev)
wet = lp_out.astype(np.float32)
fb_gain = min(feedback, 0.98)
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
def _apply_shimmer_reverb(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Reverb + pitch shift + re-verb. Shift, Decay, Mix.
CPU-heavy, tagged as beta.
"""
# BETA — test on RPi 4B for xruns
shift = params.get("shift", 0.0) # semitones
decay = params.get("decay", 0.5)
mix = params.get("mix", 0.4)
# Use regular reverb as base
if "combs" not in state:
comb_delays = [29, 37, 44, 50, 31, 39, 47, 53]
ap_delays = [5, 7, 11, 13]
state["combs"] = [_CombFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size) for d in comb_delays]
state["allpasses"] = [_AllpassFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size) for d in ap_delays]
state["predelay"] = _DelayLine(int(30.0 * self._sample_rate / 1000.0 + 1))
state["predelay"].write_block(np.zeros(int(30.0 * self._sample_rate / 1000.0)))
combs: list[_CombFilter] = state["combs"]
allpasses: list[_AllpassFilter] = state["allpasses"]
predelay_line: _DelayLine = state["predelay"]
scaled_fb = 0.3 + decay * 0.6
scaled_damp = 0.1 + decay * 0.5
for comb in combs:
comb.feedback = min(scaled_fb, 0.95)
comb.damping = min(scaled_damp, 0.85)
for ap in allpasses:
ap.gain = 0.3 + decay * 0.3
# Predelay
delayed = predelay_line.read_block(30.0 * self._sample_rate / 1000.0, len(buf))
predelay_line.write_block(buf)
# Comb + allpass reverb
wet = np.zeros_like(buf, dtype=np.float64)
for comb in combs:
wet += comb.process(delayed)
wet /= len(combs)
for ap in allpasses:
wet = ap.process(wet)
# Pitch shift the reverb tail (shimmer!)
if shift != 0.0:
factor = 2.0 ** (shift / 12.0)
if "shift_ring" not in state:
grain_size = int(0.040 * self._sample_rate)
state["shift_ring"] = np.zeros(grain_size * 2, dtype=np.float32)
state["swpos"] = 0
state["srpos"] = 0
ring = state["shift_ring"]
wpos = state["swpos"]
rpos = state["srpos"]
wet_float = wet.astype(np.float32)
for i, s in enumerate(wet_float):
ring[wpos % len(ring)] = s
wpos += 1
shifted_out = np.zeros_like(buf, dtype=np.float32)
for i in range(len(buf)):
idx = int(np.floor(rpos))
frac = rpos - idx
a = ring[idx % len(ring)]
b = ring[(idx + 1) % len(ring)]
shifted_out[i] = a + frac * (b - a)
rpos += factor
if rpos >= wpos:
rpos = wpos - 1
state["swpos"] = wpos
state["srpos"] = rpos
window = 0.5 * (1.0 - np.cos(2.0 * np.pi * np.arange(len(buf)) / len(buf)))
shifted_out = shifted_out * window
wet = wet + shifted_out.astype(np.float64) * 0.5 # feedback shimmer
wet = np.clip(wet, -1.0, 1.0).astype(np.float32)
return np.clip(buf * (1.0 - mix) + wet * mix, -1.0, 1.0)
# ── 44. Looper ─────────────────────────────────────────────────
def _apply_looper(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Record/overdub/playback (~30s buffer = 6MB).
Controls: record, overdub, play, stop.
State machine: idle -> recording -> playing -> overdub -> idle
"""
max_buffer = int(30.0 * self._sample_rate) # 30 seconds
if "looper_buf" not in state:
state["looper_buf"] = np.zeros(max_buffer, dtype=np.float32)
state["looper_pos"] = 0
state["looper_len"] = 0
state["looper_mode"] = "idle"
looper_buf: np.ndarray = state["looper_buf"]
pos = state.get("looper_pos", 0)
buf_len = state.get("looper_len", 0)
mode = state.get("looper_mode", "idle")
# Control actions
if params.get("record", 0):
mode = "recording"
state["looper_len"] = 0
state["looper_pos"] = 0
looper_buf[:] = 0.0
elif params.get("overdub", 0):
mode = "overdub"
state["looper_pos"] = 0
elif params.get("play", 0):
mode = "playing"
state["looper_pos"] = 0
elif params.get("stop", 0):
mode = "idle"
state["looper_mode"] = mode
if mode == "idle":
return buf # passthrough
out = np.zeros_like(buf)
n = len(buf)
if mode == "recording":
# Write input to buffer
end = min(pos + n, max_buffer)
looper_buf[pos:end] = buf[:end - pos]
state["looper_pos"] = pos + n
state["looper_len"] = max(state["looper_len"], pos + n)
out = buf * 0.5 # Monitor at half volume during recording
elif mode == "overdub":
# Play existing + record new
for i in range(n):
looper_buf[pos % max_buffer] = (
looper_buf[pos % max_buffer] + buf[i]
) * 0.7 # Mix down to avoid clipping
out[i] = looper_buf[pos % max_buffer]
pos += 1
state["looper_pos"] = pos
state["looper_len"] = max(state["looper_len"], pos)
elif mode == "playing":
# Play from buffer
for i in range(n):
out[i] = looper_buf[pos % buf_len] if buf_len > 0 else 0.0
pos += 1
state["looper_pos"] = pos
return out.astype(np.float32)
# ── 45. Early Reflections ───────────────────────────────────────
def _apply_early_reflections(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Sparse delay taps + allpass for room simulation.
Size, Decay, Mix.
"""
room_size = params.get("size", 0.5)
decay = params.get("decay", 0.4)
mix = params.get("mix", 0.4)
# Early reflection tap times (ms) scaled by room size
base_taps = [5, 12, 20, 30, 45, 65]
size_factor = 0.5 + room_size * 2.0 # 0.5-2.5x
taps = [int(t * size_factor * self._sample_rate / 1000.0) for t in base_taps]
if "delay" not in state:
max_d = max(taps) + self._block_size + 1
state["delay"] = _DelayLine(max_d)
delay_line: _DelayLine = state["delay"]
delay_line.write_block(buf)
# Read reflections with decreasing amplitude
wet = np.zeros(len(buf), dtype=np.float32)
amplitude = 0.6 + decay * 0.5 # 0.6-1.1
for i, tap in enumerate(taps):
gain = amplitude * (1.0 - i / len(taps))
reflected = delay_line.read_block(float(tap), len(buf))
wet += reflected * gain
# Normalize
wet = wet / max(len(taps), 1) * 0.5
return (buf * (1.0 - mix) + wet * mix).astype(np.float32)
def set_audio_profile(self, block_size: int, sample_rate: int) -> None:
"""Update block size and sample rate at runtime.
Recomputes profile-dependent constants and clears DSP state so
effects reinitialise with the new buffer sizes on the next
process() call.
Called by the web server when the JACK latency profile changes
(``POST /api/audio/profile``). Follows the same pattern as
:meth:`NAMEngineRouter.set_block_size`.
Args:
block_size: JACK period size in frames (e.g. 64, 128, 256, 512).
sample_rate: Sample rate in Hz (e.g. 44100, 48000, 96000).
"""
changed = (self._block_size != block_size
or self._sample_rate != sample_rate)
if not changed:
return
self._block_size = block_size
self._sample_rate = sample_rate
self._vu_alpha = np.exp(-block_size / (0.05 * sample_rate))
# Clear DSP state — effects will reinit with new block/sample rate
self._state.clear()
self._coeffs.clear()
logger.info("Audio profile updated: block=%d, sr=%d",
block_size, sample_rate)
@property
def block_size(self) -> int:
"""Current JACK period / block size in frames."""
return self._block_size
@property
def sample_rate(self) -> int:
"""Current audio sample rate in Hz."""
return self._sample_rate
# ── Properties ─────────────────────────────────────────────────
@property
def master_volume(self) -> float:
return self._master_volume
@master_volume.setter
def master_volume(self, value: float) -> None:
self._master_volume = max(0.0, min(1.0, value))
@property
def bypassed(self) -> bool:
return self._bypassed
@bypassed.setter
def bypassed(self, value: bool) -> None:
self._bypassed = value
logger.info("Global bypass: %s", "ON" if value else "OFF")
@property
def tuner_enabled(self) -> bool:
return self._tuner_enabled
@tuner_enabled.setter
def tuner_enabled(self, value: bool) -> None:
self._tuner_enabled = value
# ── 4CM routing properties ────────────────────────────────────
@property
def routing_mode(self) -> str:
return self._routing_mode
@routing_mode.setter
def routing_mode(self, value: str) -> None:
if value not in ("mono", "4cm"):
raise ValueError(f"routing_mode must be 'mono' or '4cm', got {value!r}")
self._routing_mode = value
logger.info("Routing mode: %s", value)
@property
def routing_breakpoint(self) -> int:
return self._routing_breakpoint
@routing_breakpoint.setter
def routing_breakpoint(self, value: int) -> None:
self._routing_breakpoint = max(0, value)
logger.info("Routing breakpoint: %d", self._routing_breakpoint)
# ── Chain access (read-only for external inspection) ────────────
@property
def chain(self) -> list[dict]:
"""Read-only access to the DSP chain."""
return list(self._chain)
def set_routing(self, mode: str, breakpoint: int = 7) -> None:
"""Set 4CM routing configuration at runtime.
Args:
mode: ``\"mono\"`` or ``\"4cm\"``.
breakpoint: Chain index where pre/post split occurs.
"""
self.routing_mode = mode
self.routing_breakpoint = breakpoint