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
+420 -1
View File
@@ -830,4 +830,423 @@ class TestBypassNew:
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)
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