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:
+1268
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ from typing import Optional
|
|||||||
|
|
||||||
class FXType(enum.StrEnum):
|
class FXType(enum.StrEnum):
|
||||||
"""Types of effects in the pedal signal chain."""
|
"""Types of effects in the pedal signal chain."""
|
||||||
|
# Existing
|
||||||
NOISE_GATE = "noise_gate"
|
NOISE_GATE = "noise_gate"
|
||||||
COMPRESSOR = "compressor"
|
COMPRESSOR = "compressor"
|
||||||
BOOST = "boost"
|
BOOST = "boost"
|
||||||
@@ -27,6 +28,45 @@ class FXType(enum.StrEnum):
|
|||||||
REVERB = "reverb"
|
REVERB = "reverb"
|
||||||
VOLUME = "volume"
|
VOLUME = "volume"
|
||||||
TUNER = "tuner"
|
TUNER = "tuner"
|
||||||
|
# Pitch & Frequency (5)
|
||||||
|
OCTAVER = "octaver"
|
||||||
|
PITCH_SHIFTER = "pitch_shifter"
|
||||||
|
HARMONIZER = "harmonizer"
|
||||||
|
WHAMMY = "whammy"
|
||||||
|
DETUNE = "detune"
|
||||||
|
# Modulation (7)
|
||||||
|
RING_MODULATOR = "ring_modulator"
|
||||||
|
AUTO_WAH = "auto_wah"
|
||||||
|
ENVELOPE_FILTER = "envelope_filter"
|
||||||
|
ROTARY_SPEAKER = "rotary_speaker"
|
||||||
|
UNI_VIBE = "uni_vibe"
|
||||||
|
AUTO_PAN = "auto_pan"
|
||||||
|
STEREO_WIDENER = "stereo_widener"
|
||||||
|
# Drive & Saturation (3)
|
||||||
|
BITCRUSHER = "bitcrusher"
|
||||||
|
WAVEFOLDER = "wavefolder"
|
||||||
|
RECTIFIER = "rectifier"
|
||||||
|
# Dynamics (4)
|
||||||
|
EXPANDER = "expander"
|
||||||
|
DE_ESSER = "de_esser"
|
||||||
|
TRANSIENT_SHAPER = "transient_shaper"
|
||||||
|
SIDECHAIN_COMPRESSOR = "sidechain_compressor"
|
||||||
|
# Filters & EQ (6)
|
||||||
|
PARAMETRIC_EQ = "parametric_eq"
|
||||||
|
HIGH_PASS_FILTER = "high_pass_filter"
|
||||||
|
LOW_PASS_FILTER = "low_pass_filter"
|
||||||
|
BAND_PASS_FILTER = "band_pass_filter"
|
||||||
|
NOTCH_FILTER = "notch_filter"
|
||||||
|
FORMANT_FILTER = "formant_filter"
|
||||||
|
# Time-Based (6)
|
||||||
|
PING_PONG_DELAY = "ping_pong_delay"
|
||||||
|
MULTI_TAP_DELAY = "multi_tap_delay"
|
||||||
|
REVERSE_DELAY = "reverse_delay"
|
||||||
|
TAPE_ECHO = "tape_echo"
|
||||||
|
SHIMMER_REVERB = "shimmer_reverb"
|
||||||
|
LOOPER = "looper"
|
||||||
|
# Ambience (1)
|
||||||
|
EARLY_REFLECTIONS = "early_reflections"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -744,6 +744,176 @@ _FX_PARAM_SCHEMAS: dict[str, list[dict]] = {
|
|||||||
"volume": [
|
"volume": [
|
||||||
{"key": "level", "name": "Level", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.8},
|
{"key": "level", "name": "Level", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.8},
|
||||||
],
|
],
|
||||||
|
# ── Pitch & Frequency ──────────────────────────────────────────
|
||||||
|
"octaver": [
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"pitch_shifter": [
|
||||||
|
{"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"harmonizer": [
|
||||||
|
{"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 3.0},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"whammy": [
|
||||||
|
{"key": "bend", "name": "Bend", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.0},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7},
|
||||||
|
],
|
||||||
|
"detune": [
|
||||||
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
# ── Modulation ─────────────────────────────────────────────────
|
||||||
|
"ring_modulator": [
|
||||||
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 10.0, "max": 2000.0, "step": 10.0, "default": 100.0},
|
||||||
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"auto_wah": [
|
||||||
|
{"key": "sensitivity", "name": "Sensitivity", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "q", "name": "Q", "type": "float", "min": 0.5, "max": 10.0, "step": 0.1, "default": 2.0},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"envelope_filter": [
|
||||||
|
{"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "range", "name": "Range", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"rotary_speaker": [
|
||||||
|
{"key": "speed", "name": "Speed", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "drive", "name": "Drive", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"uni_vibe": [
|
||||||
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.8},
|
||||||
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"auto_pan": [
|
||||||
|
{"key": "rate", "name": "Rate (Hz)", "type": "float", "min": 0.05, "max": 5.0, "step": 0.05, "default": 0.3},
|
||||||
|
{"key": "depth", "name": "Depth", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.7},
|
||||||
|
{"key": "waveform", "name": "Waveform", "type": "select", "options": ["sine", "triangle", "square"], "default": "sine"},
|
||||||
|
],
|
||||||
|
"stereo_widener": [
|
||||||
|
{"key": "width", "name": "Width", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
# ── Drive & Saturation ─────────────────────────────────────────
|
||||||
|
"bitcrusher": [
|
||||||
|
{"key": "bits", "name": "Bits", "type": "int", "min": 1, "max": 16, "step": 1, "default": 8},
|
||||||
|
{"key": "rate", "name": "Rate Reduction", "type": "float", "min": 1.0, "max": 10.0, "step": 1.0, "default": 1.0},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"wavefolder": [
|
||||||
|
{"key": "gain", "name": "Gain", "type": "float", "min": 0.5, "max": 10.0, "step": 0.5, "default": 2.0},
|
||||||
|
{"key": "fold", "name": "Fold Stages", "type": "int", "min": 1, "max": 10, "step": 1, "default": 3},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"rectifier": [
|
||||||
|
{"key": "mode", "name": "Mode", "type": "select", "options": ["full", "half"], "default": "full"},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
# ── Dynamics ────────────────────────────────────────────────────
|
||||||
|
"expander": [
|
||||||
|
{"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -30.0},
|
||||||
|
{"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 3.0},
|
||||||
|
{"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 5.0},
|
||||||
|
{"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 100.0},
|
||||||
|
],
|
||||||
|
"de_esser": [
|
||||||
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 2000.0, "max": 10000.0, "step": 100.0, "default": 6000.0},
|
||||||
|
{"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -50.0, "max": 0.0, "step": 1.0, "default": -30.0},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"transient_shaper": [
|
||||||
|
{"key": "attack", "name": "Attack Boost", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "sustain", "name": "Sustain", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
"sidechain_compressor": [
|
||||||
|
{"key": "threshold", "name": "Threshold (dB)", "type": "float", "min": -60.0, "max": 0.0, "step": 1.0, "default": -20.0},
|
||||||
|
{"key": "ratio", "name": "Ratio", "type": "float", "min": 1.0, "max": 20.0, "step": 0.5, "default": 4.0},
|
||||||
|
{"key": "attack", "name": "Attack (ms)", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 2.0},
|
||||||
|
{"key": "release", "name": "Release (ms)", "type": "float", "min": 10.0, "max": 500.0, "step": 5.0, "default": 50.0},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
# ── Filters & EQ ────────────────────────────────────────────────
|
||||||
|
"parametric_eq": [
|
||||||
|
{"key": "freq_0", "name": "Band 1 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 100.0},
|
||||||
|
{"key": "gain_0", "name": "Band 1 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0},
|
||||||
|
{"key": "q_0", "name": "Band 1 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
|
||||||
|
{"key": "freq_1", "name": "Band 2 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 500.0},
|
||||||
|
{"key": "gain_1", "name": "Band 2 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0},
|
||||||
|
{"key": "q_1", "name": "Band 2 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
|
||||||
|
{"key": "freq_2", "name": "Band 3 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 2000.0},
|
||||||
|
{"key": "gain_2", "name": "Band 3 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0},
|
||||||
|
{"key": "q_2", "name": "Band 3 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
|
||||||
|
{"key": "freq_3", "name": "Band 4 Freq (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 5000.0},
|
||||||
|
{"key": "gain_3", "name": "Band 4 Gain (dB)", "type": "float", "min": -18.0, "max": 18.0, "step": 0.5, "default": 0.0},
|
||||||
|
{"key": "q_3", "name": "Band 4 Q", "type": "float", "min": 0.1, "max": 10.0, "step": 0.1, "default": 0.707},
|
||||||
|
],
|
||||||
|
"high_pass_filter": [
|
||||||
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 200.0},
|
||||||
|
{"key": "slope", "name": "Slope (dB/oct)", "type": "select", "options": [6, 12], "default": 12},
|
||||||
|
],
|
||||||
|
"low_pass_filter": [
|
||||||
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 5000.0},
|
||||||
|
{"key": "slope", "name": "Slope (dB/oct)", "type": "select", "options": [6, 12], "default": 12},
|
||||||
|
],
|
||||||
|
"band_pass_filter": [
|
||||||
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 1000.0},
|
||||||
|
{"key": "q", "name": "Q", "type": "float", "min": 0.1, "max": 20.0, "step": 0.1, "default": 0.707},
|
||||||
|
],
|
||||||
|
"notch_filter": [
|
||||||
|
{"key": "frequency", "name": "Frequency (Hz)", "type": "float", "min": 20.0, "max": 20000.0, "step": 10.0, "default": 60.0},
|
||||||
|
{"key": "q", "name": "Q", "type": "float", "min": 0.5, "max": 50.0, "step": 0.5, "default": 10.0},
|
||||||
|
],
|
||||||
|
"formant_filter": [
|
||||||
|
{"key": "vowel", "name": "Vowel", "type": "select", "options": ["a", "e", "i", "o", "u"], "default": "a"},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
],
|
||||||
|
# ── Time-Based ──────────────────────────────────────────────────
|
||||||
|
"ping_pong_delay": [
|
||||||
|
{"key": "time", "name": "Time (ms)", "type": "float", "min": 10.0, "max": 2000.0, "step": 5.0, "default": 400.0},
|
||||||
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
||||||
|
],
|
||||||
|
"multi_tap_delay": [
|
||||||
|
{"key": "pattern", "name": "Pattern", "type": "select", "options": ["quarter", "dotted", "triplet"], "default": "quarter"},
|
||||||
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
||||||
|
],
|
||||||
|
"reverse_delay": [
|
||||||
|
{"key": "time", "name": "Time (ms)", "type": "float", "min": 50.0, "max": 2000.0, "step": 5.0, "default": 400.0},
|
||||||
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
||||||
|
],
|
||||||
|
"tape_echo": [
|
||||||
|
{"key": "time", "name": "Time (ms)", "type": "float", "min": 50.0, "max": 2000.0, "step": 5.0, "default": 300.0},
|
||||||
|
{"key": "wow", "name": "Wow/Flutter", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
||||||
|
{"key": "high_cut", "name": "High Cut", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "feedback", "name": "Feedback", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.3},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
||||||
|
],
|
||||||
|
"shimmer_reverb": [
|
||||||
|
{"key": "shift", "name": "Shift (semitones)", "type": "float", "min": -12.0, "max": 12.0, "step": 0.5, "default": 0.0},
|
||||||
|
{"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
||||||
|
],
|
||||||
|
"looper": [
|
||||||
|
{"key": "record", "name": "Record", "type": "bool", "default": 0},
|
||||||
|
{"key": "overdub", "name": "Overdub", "type": "bool", "default": 0},
|
||||||
|
{"key": "play", "name": "Play", "type": "bool", "default": 0},
|
||||||
|
{"key": "stop", "name": "Stop", "type": "bool", "default": 0},
|
||||||
|
],
|
||||||
|
# ── Ambience ────────────────────────────────────────────────────
|
||||||
|
"early_reflections": [
|
||||||
|
{"key": "size", "name": "Room Size", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.5},
|
||||||
|
{"key": "decay", "name": "Decay", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
||||||
|
{"key": "mix", "name": "Mix", "type": "float", "min": 0.0, "max": 1.0, "step": 0.01, "default": 0.4},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -655,3 +655,179 @@ class TestStateIsolation:
|
|||||||
pipeline.process(SINE_TONE * 0.3)
|
pipeline.process(SINE_TONE * 0.3)
|
||||||
out1 = pipeline._state.get("fx_0", {}).get("delay", None)
|
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