feat: full FX palette expansion — 32 new effects
Adds all 27 new DSP implementations plus tests and parameter schemas: Pitch & Frequency (5): octaver, pitch_shifter, harmonizer, whammy, detune Modulation (7): ring_modulator, auto_wah, envelope_filter, rotary_speaker, uni_vibe, auto_pan, stereo_widener Drive & Saturation (3): bitcrusher, wavefolder, rectifier Dynamics (4): expander, de_esser, transient_shaper, sidechain_compressor Filters & EQ (6): parametric_eq, high_pass_filter, low_pass_filter, band_pass_filter, notch_filter, formant_filter Time-Based (6): ping_pong_delay, multi_tap_delay, reverse_delay, tape_echo, shimmer_reverb, looper Ambience (1): early_reflections Each effect has: DSP function (5-30 lines numpy), case dispatch entry, parameter schema in _FX_PARAM_SCHEMAS, and tests for critical effects. CPU-heavy effects (pitch_shifter, shimmer_reverb) tagged as BETA. All 79 tests pass.
This commit is contained in:
+177
-1
@@ -654,4 +654,180 @@ class TestStateIsolation:
|
||||
_load_fx(pipeline, FXType.DELAY, {"time": 100.0, "mix": 0.5})
|
||||
pipeline.process(SINE_TONE * 0.3)
|
||||
out1 = pipeline._state.get("fx_0", {}).get("delay", None)
|
||||
assert out1 is not None, "Delay block should have 'delay' in state"
|
||||
assert out1 is not None, "Delay block should have 'delay' in state"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 16-21. New effects: Octaver, Ring Mod, Bitcrusher, Parametric EQ,
|
||||
# Ping-pong Delay, Multi-tap Delay
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestOctaver:
|
||||
def test_output_shape_and_range(self, pipeline):
|
||||
"""Octaver should produce finite output in [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.OCTAVER, {"mix": 0.5})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.all(np.isfinite(out))
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
assert np.max(np.abs(out)) > 0.5, "Octaver should pass signal"
|
||||
|
||||
def test_dry_only(self, pipeline):
|
||||
"""Mix=0 should pass dry signal unchanged (minus clip)."""
|
||||
_load_fx(pipeline, FXType.OCTAVER, {"mix": 0.0})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.allclose(out, np.clip(FULL_SCALE, -1.0, 1.0), atol=0.01)
|
||||
|
||||
def test_silence_muted(self, pipeline):
|
||||
"""Silence in should produce silence out."""
|
||||
_load_fx(pipeline, FXType.OCTAVER, {"mix": 1.0})
|
||||
out = pipeline.process(SILENCE)
|
||||
assert np.max(np.abs(out)) < 0.001
|
||||
|
||||
|
||||
class TestRingModulator:
|
||||
def test_output_finite(self, pipeline):
|
||||
"""Ring mod should produce finite output."""
|
||||
_load_fx(pipeline, FXType.RING_MODULATOR, {"rate": 100.0, "depth": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.3)
|
||||
assert np.all(np.isfinite(out))
|
||||
|
||||
def test_zero_depth_is_dry(self, pipeline):
|
||||
"""Depth=0 should pass signal unchanged."""
|
||||
_load_fx(pipeline, FXType.RING_MODULATOR, {"depth": 0.0, "mix": 1.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE, atol=0.01)
|
||||
|
||||
def test_output_clamped(self, pipeline):
|
||||
"""Output should stay in [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.RING_MODULATOR, {"depth": 1.0, "mix": 1.0})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
|
||||
class TestBitcrusher:
|
||||
def test_output_finite(self, pipeline):
|
||||
"""Bitcrusher should produce finite output."""
|
||||
_load_fx(pipeline, FXType.BITCRUSHER, {"bits": 4, "rate": 1.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(np.isfinite(out))
|
||||
|
||||
def test_16bit_no_crush(self, pipeline):
|
||||
"""16-bit at rate=1 should be near-transparent."""
|
||||
_load_fx(pipeline, FXType.BITCRUSHER, {"bits": 16, "rate": 1.0, "mix": 1.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE, atol=0.01)
|
||||
|
||||
def test_rate_reduction_holds_samples(self, pipeline):
|
||||
"""Rate reduction >1 should create stepped output."""
|
||||
_load_fx(pipeline, FXType.BITCRUSHER, {"bits": 8, "rate": 8.0, "mix": 1.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
# With rate=8, every 8th sample should repeat 8 times
|
||||
diffs = np.diff(out)
|
||||
# At least some consecutive samples should be zero-difference (held)
|
||||
assert np.any(np.abs(diffs) < 1e-6), "Rate reduction should create held samples"
|
||||
|
||||
def test_silence_muted(self, pipeline):
|
||||
"""Silence in should produce silence out."""
|
||||
_load_fx(pipeline, FXType.BITCRUSHER, {"bits": 4})
|
||||
out = pipeline.process(SILENCE)
|
||||
assert np.max(np.abs(out)) < 0.001
|
||||
|
||||
|
||||
class TestParametricEQ:
|
||||
def test_output_finite(self, pipeline):
|
||||
"""PEQ should produce finite output."""
|
||||
_load_fx(pipeline, FXType.PARAMETRIC_EQ, {})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(np.isfinite(out))
|
||||
|
||||
def test_zero_gain_passthrough(self, pipeline):
|
||||
"""All gains 0 should pass signal (some phase shift OK)."""
|
||||
_load_fx(pipeline, FXType.PARAMETRIC_EQ, {
|
||||
"freq_0": 500.0, "gain_0": 0.0, "q_0": 0.707,
|
||||
})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.max(np.abs(out)) > 0.4 # Signal should pass
|
||||
|
||||
def test_boost_band(self, pipeline):
|
||||
"""Positive gain should boost signal in that band."""
|
||||
_load_fx(pipeline, FXType.PARAMETRIC_EQ, {
|
||||
"freq_0": 440.0, "gain_0": 6.0, "q_0": 1.0,
|
||||
})
|
||||
out = pipeline.process(SINE_TONE * 0.3)
|
||||
assert np.max(np.abs(out)) > 0.3, "Boosted signal should be louder"
|
||||
|
||||
def test_output_clamped(self, pipeline):
|
||||
"""Output should stay in [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.PARAMETRIC_EQ, {
|
||||
"freq_0": 440.0, "gain_0": 12.0, "q_0": 1.0,
|
||||
})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
|
||||
class TestPingPongDelay:
|
||||
def test_output_finite(self, pipeline):
|
||||
"""Ping-pong delay should produce finite output."""
|
||||
_load_fx(pipeline, FXType.PING_PONG_DELAY, {"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.PING_PONG_DELAY, {"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.PING_PONG_DELAY, {"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"
|
||||
# Delay line buffer should have non-zero content after processing
|
||||
buf_max = np.max(np.abs(delay_line.buf))
|
||||
assert buf_max > 0.0, f"Delay buffer should have content, got max={buf_max}"
|
||||
|
||||
|
||||
class TestMultiTapDelay:
|
||||
def test_output_finite(self, pipeline):
|
||||
"""Multi-tap delay should produce finite output."""
|
||||
_load_fx(pipeline, FXType.MULTI_TAP_DELAY, {"pattern": "quarter", "mix": 1.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.all(np.isfinite(out))
|
||||
|
||||
def test_state_initialized(self, pipeline):
|
||||
"""Multi-tap delay should initialize delay state on first call."""
|
||||
_load_fx(pipeline, FXType.MULTI_TAP_DELAY, {"pattern": "quarter", "feedback": 0.0, "mix": 1.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.all(np.isfinite(out))
|
||||
# State should have a delay line after first call
|
||||
fx_state = pipeline._state.get("fx_0", {})
|
||||
assert "delay" in fx_state, "Multi-tap should initialize delay line"
|
||||
|
||||
|
||||
class TestBypassNew:
|
||||
def test_octaver_bypass(self, pipeline):
|
||||
"""Bypassed octaver passes audio unchanged."""
|
||||
block = FXBlock(FXType.OCTAVER, enabled=True, bypass=True, params={"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)
|
||||
|
||||
def test_bitcrusher_bypass(self, pipeline):
|
||||
"""Bypassed bitcrusher passes audio unchanged."""
|
||||
block = FXBlock(FXType.BITCRUSHER, enabled=True, bypass=True, params={"bits": 4})
|
||||
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)
|
||||
|
||||
def test_ring_mod_bypass(self, pipeline):
|
||||
"""Bypassed ring modulator passes audio unchanged."""
|
||||
block = FXBlock(FXType.RING_MODULATOR, enabled=True, bypass=True, params={"rate": 100.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)
|
||||
Reference in New Issue
Block a user