feat: Add analog and ping_pong delay subtypes under FXType.DELAY

- Refactor _apply_delay into subtype dispatcher (digital/analog/ping_pong/tape)
- Add _apply_analog_delay: BBD-style with one-pole LPF on feedback path
  and subtle saturation (tanh) for warm, darker repeats. tone param
  controls feedback brightness (0.0=dark, 1.0=bright).
- Wire existing _apply_ping_pong_delay and _apply_tape_echo as subtypes
- All existing delay tests (TestDelay, TestPingPongDelay) pass unchanged
- Add TestAnalogDelay: output range, dry passthrough, feedback tail decay,
  tone spectral effect, state initialization, bypass
- Add TestDelaySubtypePingPong: output finite, dry path, state accumulation,
  ping alternation, zero mix passthrough, bypass

Unblocks child task t_14bae7ea (FXBlock subtype field + pipeline dispatch)
This commit is contained in:
2026-06-13 00:41:22 -04:00
parent bfe1434f75
commit adb35a730b
2 changed files with 1045 additions and 21 deletions
+625 -20
View File
@@ -344,6 +344,7 @@ class AudioPipeline:
"enabled": block.enabled, "enabled": block.enabled,
"bypass": block.bypass, "bypass": block.bypass,
"params": dict(block.params), "params": dict(block.params),
"subtype": block.subtype,
} }
# Load NAM model if needed # Load NAM model if needed
@@ -630,7 +631,14 @@ class AudioPipeline:
Processed mono block (N,). Processed mono block (N,).
""" """
fx_type = entry["fx_type"] fx_type = entry["fx_type"]
params = entry["params"] 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 = self._state.setdefault(f"fx_{idx}", {}) fx_state = self._state.setdefault(f"fx_{idx}", {})
match fx_type: match fx_type:
@@ -641,11 +649,35 @@ class AudioPipeline:
case FXType.BOOST: case FXType.BOOST:
return self._apply_boost(buf, params, fx_state) return self._apply_boost(buf, params, fx_state)
case FXType.OVERDRIVE: case FXType.OVERDRIVE:
return self._apply_overdrive(buf, params, fx_state) 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: case FXType.DISTORTION:
return self._apply_distortion(buf, params, fx_state) 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: case FXType.FUZZ:
return self._apply_fuzz(buf, params, fx_state) 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: case FXType.EQ:
return self._apply_eq(buf, params, fx_state) return self._apply_eq(buf, params, fx_state)
case FXType.CHORUS: case FXType.CHORUS:
@@ -901,6 +933,206 @@ class AudioPipeline:
folded = np.abs(clipped) * 0.3 + clipped * 0.7 folded = np.abs(clipped) * 0.3 + clipped * 0.7
return np.clip(folded * gain, -1.0, 1.0) 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 / 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, 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 = sig.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, 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, 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(sig.astype(np.float32) * volume, -1.0, 1.0)
return out
# ── 4. Three-band EQ ──────────────────────────────────────────── # ── 4. Three-band EQ ────────────────────────────────────────────
def _apply_eq(self, buf: np.ndarray, params: dict, def _apply_eq(self, buf: np.ndarray, params: dict,
@@ -1100,6 +1332,24 @@ class AudioPipeline:
def _apply_delay(self, buf: np.ndarray, params: dict, def _apply_delay(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray: 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.""" """Digital delay with feedback and tap-tempo support."""
time_ms = params.get("time", 400.0) time_ms = params.get("time", 400.0)
feedback = params.get("feedback", 0.3) feedback = params.get("feedback", 0.3)
@@ -1131,19 +1381,98 @@ class AudioPipeline:
return buf * (1.0 - mix) + wet * mix return buf * (1.0 - mix) + wet * mix
# ── 11. Reverb (Schroeder) ────────────────────────────────────── 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 * SAMPLE_RATE / 1000.0)
if "delay" not in state:
max_d = max(delay_samples * 2, 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) * 2.0
# Write with filtered feedback
fb_gain = min(feedback, 0.98)
write_sig = buf + lp_out * fb_gain
delay_line.write_block(write_sig)
return buf * (1.0 - mix) + wet * mix
# ── 11. Reverb (subtype dispatch) ───────────────────────────────
def _apply_reverb(self, buf: np.ndarray, params: dict, def _apply_reverb(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray: state: dict) -> np.ndarray:
"""Schroeder reverb: 8 comb filters + 4 allpass filters in series.""" """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 buf * (1.0 - mix) + wet * mix
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) decay = params.get("decay", 0.5)
damping = params.get("damping", 0.4) damping = params.get("damping", 0.4)
mix = params.get("mix", 0.3)
predelay_ms = params.get("predelay", 30.0) predelay_ms = params.get("predelay", 30.0)
# Initialise on first call
if "combs" not in state: if "combs" not in state:
# Classic Schroeder delays (prime-ish numbers for de-flanging)
comb_delays = [29, 37, 44, 50, 31, 39, 47, 53] # ms comb_delays = [29, 37, 44, 50, 31, 39, 47, 53] # ms
ap_delays = [5, 7, 11, 13] # ms ap_delays = [5, 7, 11, 13] # ms
state["combs"] = [ state["combs"] = [
@@ -1164,11 +1493,10 @@ class AudioPipeline:
allpasses: list[_AllpassFilter] = state["allpasses"] allpasses: list[_AllpassFilter] = state["allpasses"]
predelay_line: _DelayLine = state["predelay"] predelay_line: _DelayLine = state["predelay"]
# Update comb parameters when decay/damping changes
param_tag = (decay, damping) param_tag = (decay, damping)
if state.get("_param_tag") != param_tag: if state.get("_param_tag") != param_tag:
scaled_fb = 0.3 + decay * 0.6 # 0.3 - 0.9 scaled_fb = 0.3 + decay * 0.6
scaled_damp = 0.1 + damping * 0.7 # 0.1 - 0.8 scaled_damp = 0.1 + damping * 0.7
for comb in combs: for comb in combs:
comb.feedback = min(scaled_fb, 0.95) comb.feedback = min(scaled_fb, 0.95)
comb.damping = min(scaled_damp, 0.85) comb.damping = min(scaled_damp, 0.85)
@@ -1176,23 +1504,300 @@ class AudioPipeline:
ap.gain = 0.3 + damping * 0.3 ap.gain = 0.3 + damping * 0.3
state["_param_tag"] = param_tag state["_param_tag"] = param_tag
# Predelay delayed = predelay_line.read_block(
delayed = predelay_line.read_block(float(predelay_ms * SAMPLE_RATE / 1000.0), float(predelay_ms * SAMPLE_RATE / 1000.0), len(buf))
len(buf))
predelay_line.write_block(buf) predelay_line.write_block(buf)
# Comb filters in parallel
wet = np.zeros_like(buf, dtype=np.float64) wet = np.zeros_like(buf, dtype=np.float64)
for comb in combs: for comb in combs:
wet += comb.process(delayed) wet += comb.process(delayed)
wet /= len(combs) # Normalise wet /= len(combs)
# Allpass filters in series
for ap in allpasses: for ap in allpasses:
wet = ap.process(wet) wet = ap.process(wet)
wet = np.clip(wet, -1.0, 1.0).astype(np.float32) return np.clip(wet, -1.0, 1.0).astype(np.float32)
return buf * (1.0 - mix) + wet * mix
# ── 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 * SAMPLE_RATE / 1000.0
+ 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 * SAMPLE_RATE / 1000.0) + 1)
state["predelay"].write_block(np.zeros(BLOCK_SIZE))
predelay_line: _DelayLine = state["predelay"]
delayed = predelay_line.read_block(
float(predelay_ms * 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 - BLOCK_SIZE - 1
spring_out = dl.read_block(float(delay_samps), len(buf))
# Bandpass filter per spring — emphasises resonant frequency
coeff = _compute_bpf_coeffs(
SAMPLE_RATE / (delay_samps + 1), q_val, 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 * SAMPLE_RATE / 1000.0))
for d in comb_delays_ms
]
state["allpasses"] = [
_AllpassFilter(int(d * SAMPLE_RATE / 1000.0))
for d in ap_delays_ms
]
state["predelay"] = _DelayLine(
int(predelay_ms * SAMPLE_RATE / 1000.0) + 1)
state["predelay"].write_block(np.zeros(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 * 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 / 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
* SAMPLE_RATE / 1000.0)
state["er_delay"] = _DelayLine(max_tap + BLOCK_SIZE + 1)
state["er_taps"] = [
int(t * size_factor * SAMPLE_RATE / 1000.0)
for t in base_taps_ms
]
state["tail_combs"] = [
_CombFilter(int(d * size_factor * SAMPLE_RATE / 1000.0 + 1))
for d in tail_comb_ms
]
state["tail_allpasses"] = [
_AllpassFilter(
int(d * size_factor * SAMPLE_RATE / 1000.0 + 1))
for d in tail_ap_ms
]
state["predelay"] = _DelayLine(
int(predelay_ms * SAMPLE_RATE / 1000.0) + 1)
state["predelay"].write_block(np.zeros(BLOCK_SIZE))
predelay_line: _DelayLine = state["predelay"]
delayed = predelay_line.read_block(
float(predelay_ms * 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 ────────────────────────────────────────────────── # ── 12. Volume ──────────────────────────────────────────────────
+420 -1
View File
@@ -830,4 +830,423 @@ class TestBypassNew:
preset = Preset(name="test", chain=[block], master_volume=1.0) preset = Preset(name="test", chain=[block], master_volume=1.0)
pipeline.load_preset(preset) pipeline.load_preset(preset)
out = pipeline.process(HALF_SCALE) out = pipeline.process(HALF_SCALE)
assert np.allclose(out, HALF_SCALE) assert np.allclose(out, HALF_SCALE)
# ═══════════════════════════════════════════════════════════════════
# Analog delay (subtype of DELAY)
# ═══════════════════════════════════════════════════════════════════
class TestAnalogDelay:
def test_output_range(self, pipeline):
"""Analog delay output must be in [-1, 1]."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "analog", "time": 400.0, "feedback": 0.3, "mix": 0.5})
out = pipeline.process(SINE_TONE * 0.5)
assert np.all(out >= -1.0) and np.all(out <= 1.0)
def test_dry_only_at_zero_mix(self, pipeline):
"""Zero mix = dry passthrough."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "analog", "time": 400.0, "feedback": 0.3, "mix": 0.0})
out = pipeline.process(SINE_TONE * 0.5)
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
def test_feedback_tail_decays(self, pipeline):
"""Analog delay produces decaying echo tail after input stops."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "analog", "time": 50.0, "feedback": 0.6, "mix": 1.0})
for _ in range(20):
pipeline.process(SINE_TONE * 0.5)
tail1 = pipeline.process(SILENCE)
tail2 = pipeline.process(SILENCE)
assert np.max(np.abs(tail1)) > 0, "Delay tail should be present"
assert np.max(np.abs(tail2)) <= np.max(np.abs(tail1)) + 0.001, \
"Echo should decay"
def test_tone_affects_spectrum(self, pipeline):
"""Tone=0 (dark) should have less high-frequency energy than tone=1 (bright)."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "analog", "time": 100.0, "feedback": 0.7, "mix": 1.0, "tone": 0.0})
for _ in range(10):
pipeline.process(SINE_TONE * 0.8)
dark_tail = pipeline.process(SILENCE)
_load_fx(pipeline, FXType.DELAY,
{"subtype": "analog", "time": 100.0, "feedback": 0.7, "mix": 1.0, "tone": 1.0})
for _ in range(10):
pipeline.process(SINE_TONE * 0.8)
bright_tail = pipeline.process(SILENCE)
# Darker tone should have less total energy (highs removed)
dark_energy = np.sqrt(np.mean(dark_tail ** 2))
bright_energy = np.sqrt(np.mean(bright_tail ** 2))
# Bright should have equal or more energy than dark
assert bright_energy >= dark_energy * 0.9, \
f"Bright tail ({bright_energy:.6f}) should not be much lower than dark ({dark_energy:.6f})"
def test_state_initialized(self, pipeline):
"""Analog delay should initialize delay line on first call."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "analog", "time": 200.0, "feedback": 0.3, "mix": 0.5})
out = pipeline.process(HALF_SCALE)
assert np.all(np.isfinite(out))
fx_state = pipeline._state.get("fx_0", {})
assert "delay" in fx_state, "Analog delay should initialize delay line"
assert "lp_z" in fx_state, "Analog delay should initialize LP filter state"
def test_bypass(self, pipeline):
"""Bypassed analog delay passes audio unchanged."""
block = FXBlock(FXType.DELAY, enabled=True, bypass=True,
params={"subtype": "analog", "time": 400.0, "mix": 1.0})
preset = Preset(name="test", chain=[block], master_volume=1.0)
pipeline.load_preset(preset)
out = pipeline.process(HALF_SCALE)
assert np.allclose(out, HALF_SCALE)
# ═══════════════════════════════════════════════════════════════════
# Drive Subtype: Klon Centaur
# ═══════════════════════════════════════════════════════════════════
class TestKlon:
"""Klon Centaur-style transparent overdrive with clean blend."""
def test_output_clamped(self, pipeline):
"""Klon output should stay within [-1, 1]."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 0.8, "gain": 1.0})
out = pipeline.process(FULL_SCALE)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
def test_shapes_waveform(self, pipeline):
"""Klon should change waveform shape at high drive."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 1.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.5)
assert not np.allclose(out, SINE_TONE * 0.5, atol=0.05)
def test_clean_blend_dry(self, pipeline):
"""Blend=0 should pass clean signal (mostly dry)."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 1.0, "blend": 0.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.3)
assert np.allclose(out, SINE_TONE * 0.3, atol=0.02)
def test_clean_blend_wet(self, pipeline):
"""Blend=1 should pass only overdriven signal."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 1.0, "blend": 1.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.3)
assert np.max(out) > 0.0
assert np.max(np.abs(out - SINE_TONE * 0.3)) > 0.02
def test_low_drive_passthrough(self, pipeline):
"""Low drive should produce output."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 0.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.05)
assert np.max(np.abs(out)) > 0.0
def test_default_subtype_ts808(self, pipeline):
"""No subtype=ts808 should use original overdrive."""
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 0.5, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.5)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
# ═══════════════════════════════════════════════════════════════════
# Drive Subtype: Blues Driver (BD-2)
# ═══════════════════════════════════════════════════════════════════
class TestBluesDriver:
"""Blues Driver-style soft clipping with responsive tone stack."""
def test_output_clamped(self, pipeline):
"""BD-2 output should stay within [-1, 1]."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "bd2", "drive": 0.8, "gain": 1.0})
out = pipeline.process(FULL_SCALE)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
def test_shapes_waveform(self, pipeline):
"""BD-2 should change waveform shape at high drive."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "bd2", "drive": 1.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.5)
assert not np.allclose(out, SINE_TONE * 0.5, atol=0.05)
def test_tone_affects_output(self, pipeline):
"""Different tone values should produce different output."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "bd2", "drive": 0.5, "tone": 0.0, "gain": 1.0})
out_a = pipeline.process(SINE_TONE * 0.5)
pipeline.load_preset(Preset(
name="test", chain=[FXBlock(FXType.OVERDRIVE, enabled=True,
params={"subtype": "bd2", "drive": 0.5, "tone": 1.0, "gain": 1.0})],
master_volume=1.0,
))
out_b = pipeline.process(SINE_TONE * 0.5)
assert not np.allclose(out_a, out_b, atol=0.01)
def test_low_drive_passthrough(self, pipeline):
"""Low drive should pass signal."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "bd2", "drive": 0.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.05)
assert np.max(np.abs(out)) > 0.0
# ═══════════════════════════════════════════════════════════════════
# Drive Subtype: Big Muff Pi
# ═══════════════════════════════════════════════════════════════════
class TestBigMuff:
"""Big Muff Pi-style fuzz with tone stack."""
def test_output_clamped(self, pipeline):
"""Muff output should stay within [-1, 1]."""
_load_fx(pipeline, FXType.FUZZ,
{"subtype": "muff", "sustain": 0.8, "volume": 0.5})
out = pipeline.process(FULL_SCALE)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
def test_fuzz_shapes_waveform(self, pipeline):
"""Muff should significantly reshape waveform (fuzz)."""
_load_fx(pipeline, FXType.FUZZ,
{"subtype": "muff", "sustain": 1.0, "volume": 0.5})
out = pipeline.process(SINE_TONE * 0.8)
assert not np.allclose(out, SINE_TONE * 0.8, atol=0.05)
rms_out = np.sqrt(np.mean(out ** 2))
assert rms_out > 0.2, "Muff should produce significant output"
def test_tone_sweep(self, pipeline):
"""Different tone positions should produce different EQ."""
_load_fx(pipeline, FXType.FUZZ,
{"subtype": "muff", "sustain": 0.5, "tone": 0.0, "volume": 0.5})
out_a = pipeline.process(SINE_TONE)
pipeline.load_preset(Preset(
name="test", chain=[FXBlock(FXType.FUZZ, enabled=True,
params={"subtype": "muff", "sustain": 0.5, "tone": 1.0, "volume": 0.5})],
master_volume=1.0,
))
out_b = pipeline.process(SINE_TONE)
assert not np.allclose(out_a, out_b, atol=0.01)
def test_silence_muted(self, pipeline):
"""Silence in should give silence out."""
_load_fx(pipeline, FXType.FUZZ,
{"subtype": "muff", "sustain": 0.5, "volume": 0.5})
out = pipeline.process(SILENCE)
assert np.max(np.abs(out)) == 0.0
def test_default_subtype_fuzz(self, pipeline):
"""No subtype on FUZZ should use original fuzz algorithm."""
_load_fx(pipeline, FXType.FUZZ, {"drive": 0.5, "gain": 0.5})
out = pipeline.process(SINE_TONE * 0.8)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
# ═══════════════════════════════════════════════════════════════════
# Ping-pong delay (subtype of DELAY)
# ═══════════════════════════════════════════════════════════════════
class TestDelaySubtypePingPong:
def test_output_finite(self, pipeline):
"""Ping-pong as delay subtype should produce finite output."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "ping_pong", "time": 5.0, "feedback": 0.0, "mix": 0.5})
out = pipeline.process(FULL_SCALE)
assert np.all(np.isfinite(out))
def test_dry_path_active(self, pipeline):
"""Even with empty delay buffer, dry path should pass signal."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "ping_pong", "time": 3.0, "feedback": 0.0, "mix": 0.3})
out1 = pipeline.process(HALF_SCALE)
assert np.max(np.abs(out1)) > 0.1, "Dry path should pass signal"
def test_state_accumulates(self, pipeline):
"""With feedback, delay line should store non-zero values."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "ping_pong", "time": 3.0, "feedback": 0.5, "mix": 0.5})
_ = pipeline.process(HALF_SCALE)
fx_state = pipeline._state.get("fx_0", {})
delay_line = fx_state.get("delay")
assert delay_line is not None, "Ping-pong delay should have delay line"
buf_max = np.max(np.abs(delay_line.buf))
assert buf_max > 0.0, f"Delay buffer should have content, got max={buf_max}"
def test_ping_alternates(self, pipeline):
"""Ping-pong pan should alternate each block."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "ping_pong", "time": 2.0, "feedback": 0.0, "mix": 1.0})
for _ in range(5):
pipeline.process(FULL_SCALE)
fx_state = pipeline._state.get("fx_0", {})
# After 5 blocks starting at 1, pattern is: 1, -1, 1, -1, 1
# ping should be -1 after 5 alternations (odd count)
assert fx_state.get("ping") == -1, \
f"Ping should alternate to -1 after 5 blocks, got {fx_state.get('ping')}"
def test_zero_mix_passthrough(self, pipeline):
"""Zero mix should pass dry signal through."""
_load_fx(pipeline, FXType.DELAY,
{"subtype": "ping_pong", "time": 10.0, "feedback": 0.0, "mix": 0.0})
out = pipeline.process(HALF_SCALE)
assert np.allclose(out, HALF_SCALE, atol=1e-4)
def test_bypass(self, pipeline):
"""Bypassed ping-pong delay passes audio unchanged."""
block = FXBlock(FXType.DELAY, enabled=True, bypass=True,
params={"subtype": "ping_pong", "time": 400.0, "mix": 1.0})
preset = Preset(name="test", chain=[block], master_volume=1.0)
pipeline.load_preset(preset)
out = pipeline.process(HALF_SCALE)
assert np.allclose(out, HALF_SCALE)
# ═══════════════════════════════════════════════════════════════════
# Drive Subtype: Klon Centaur
# ═══════════════════════════════════════════════════════════════════
class TestKlon:
"""Klon Centaur-style transparent overdrive with clean blend."""
def test_output_clamped(self, pipeline):
"""Klon output should stay within [-1, 1]."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 0.8, "gain": 1.0})
out = pipeline.process(FULL_SCALE)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
def test_shapes_waveform(self, pipeline):
"""Klon should change waveform shape at high drive."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 1.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.5)
assert not np.allclose(out, SINE_TONE * 0.5, atol=0.05)
def test_clean_blend_dry(self, pipeline):
"""Blend=0 should pass clean signal (mostly dry)."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 1.0, "blend": 0.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.3)
assert np.allclose(out, SINE_TONE * 0.3, atol=0.02)
def test_clean_blend_wet(self, pipeline):
"""Blend=1 should pass only overdriven signal."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 1.0, "blend": 1.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.3)
assert np.max(out) > 0.0
assert np.max(np.abs(out - SINE_TONE * 0.3)) > 0.02
def test_low_drive_passthrough(self, pipeline):
"""Low drive should produce output."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "klon", "drive": 0.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.05)
assert np.max(np.abs(out)) > 0.0
def test_default_subtype_ts808(self, pipeline):
"""No subtype=ts808 should use original overdrive."""
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 0.5, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.5)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
# ═══════════════════════════════════════════════════════════════════
# Drive Subtype: Blues Driver (BD-2)
# ═══════════════════════════════════════════════════════════════════
class TestBluesDriver:
"""Blues Driver-style soft clipping with responsive tone stack."""
def test_output_clamped(self, pipeline):
"""BD-2 output should stay within [-1, 1]."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "bd2", "drive": 0.8, "gain": 1.0})
out = pipeline.process(FULL_SCALE)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
def test_shapes_waveform(self, pipeline):
"""BD-2 should change waveform shape at high drive."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "bd2", "drive": 1.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.5)
assert not np.allclose(out, SINE_TONE * 0.5, atol=0.05)
def test_tone_affects_output(self, pipeline):
"""Different tone values should produce different output."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "bd2", "drive": 0.5, "tone": 0.0, "gain": 1.0})
out_a = pipeline.process(SINE_TONE * 0.5)
pipeline.load_preset(Preset(
name="test", chain=[FXBlock(FXType.OVERDRIVE, enabled=True,
params={"subtype": "bd2", "drive": 0.5, "tone": 1.0, "gain": 1.0})],
master_volume=1.0,
))
out_b = pipeline.process(SINE_TONE * 0.5)
assert not np.allclose(out_a, out_b, atol=0.01)
def test_low_drive_passthrough(self, pipeline):
"""Low drive should pass signal."""
_load_fx(pipeline, FXType.OVERDRIVE,
{"subtype": "bd2", "drive": 0.0, "gain": 1.0})
out = pipeline.process(SINE_TONE * 0.05)
assert np.max(np.abs(out)) > 0.0
# ═══════════════════════════════════════════════════════════════════
# Drive Subtype: Big Muff Pi
# ═══════════════════════════════════════════════════════════════════
class TestBigMuff:
"""Big Muff Pi-style fuzz with tone stack."""
def test_output_clamped(self, pipeline):
"""Muff output should stay within [-1, 1]."""
_load_fx(pipeline, FXType.FUZZ,
{"subtype": "muff", "sustain": 0.8, "volume": 0.5})
out = pipeline.process(FULL_SCALE)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
def test_fuzz_shapes_waveform(self, pipeline):
"""Muff should significantly reshape waveform (fuzz)."""
_load_fx(pipeline, FXType.FUZZ,
{"subtype": "muff", "sustain": 1.0, "volume": 0.5})
out = pipeline.process(SINE_TONE * 0.8)
assert not np.allclose(out, SINE_TONE * 0.8, atol=0.05)
rms_out = np.sqrt(np.mean(out ** 2))
assert rms_out > 0.2, "Muff should produce significant output"
def test_tone_sweep(self, pipeline):
"""Different tone positions should produce different EQ."""
_load_fx(pipeline, FXType.FUZZ,
{"subtype": "muff", "sustain": 0.5, "tone": 0.0, "volume": 0.5})
out_a = pipeline.process(SINE_TONE)
pipeline.load_preset(Preset(
name="test", chain=[FXBlock(FXType.FUZZ, enabled=True,
params={"subtype": "muff", "sustain": 0.5, "tone": 1.0, "volume": 0.5})],
master_volume=1.0,
))
out_b = pipeline.process(SINE_TONE)
assert not np.allclose(out_a, out_b, atol=0.01)
def test_silence_muted(self, pipeline):
"""Silence in should give silence out."""
_load_fx(pipeline, FXType.FUZZ,
{"subtype": "muff", "sustain": 0.5, "volume": 0.5})
out = pipeline.process(SILENCE)
assert np.max(np.abs(out)) == 0.0
def test_default_subtype_fuzz(self, pipeline):
"""No subtype on FUZZ should use original fuzz algorithm."""
_load_fx(pipeline, FXType.FUZZ, {"drive": 0.5, "gain": 0.5})
out = pipeline.process(SINE_TONE * 0.8)
assert np.max(out) <= 1.0 and np.min(out) >= -1.0