Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""Tests for system/audio.py - AudioSystem, AudioConfig, and helpers."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from system.audio import (
|
||||
AudioConfig,
|
||||
AudioSystem,
|
||||
LATENCY_PROFILES,
|
||||
I2S_CONFIGS,
|
||||
CONFIG_TXT,
|
||||
JACK_SERVICE_PATH,
|
||||
LIMITS_CONF,
|
||||
)
|
||||
|
||||
|
||||
# ── AudioConfig tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_audio_config_defaults():
|
||||
cfg = AudioConfig()
|
||||
assert cfg.hat_type == "audioinjector"
|
||||
assert cfg.profile == "standard"
|
||||
assert cfg.input_device == "hw:0,0"
|
||||
assert cfg.output_device == "hw:0,0"
|
||||
assert cfg.jack_enabled is True
|
||||
assert cfg.auto_connect is True
|
||||
assert cfg.xrun_warn_only is True
|
||||
|
||||
|
||||
def test_audio_config_overlay_line():
|
||||
for key, expected_line in [
|
||||
("audioinjector", "dtoverlay=audioinjector-wm8731"),
|
||||
("pcm1808_pcm5102", "dtoverlay=audiosense-pi"),
|
||||
("iqaudio_codec", "dtoverlay=iqaudio-codec"),
|
||||
("justboom", "dtoverlay=justboom-dac"),
|
||||
("wm8731", "dtoverlay=wm8731"),
|
||||
]:
|
||||
cfg = AudioConfig(hat_type=key)
|
||||
assert cfg.overlay_line == expected_line, f"{key}: {cfg.overlay_line}"
|
||||
|
||||
|
||||
def test_audio_config_unknown_hat():
|
||||
cfg = AudioConfig(hat_type="nonexistent-hat")
|
||||
assert cfg.overlay_line is None
|
||||
|
||||
|
||||
def test_audio_config_latency_profile():
|
||||
cfg = AudioConfig(profile="standard")
|
||||
assert cfg.latency_profile["period"] == 128
|
||||
assert cfg.latency_profile["nperiods"] == 2
|
||||
assert cfg.latency_profile["rate"] == 48000
|
||||
assert cfg.latency_profile["rt_priority"] == 70
|
||||
|
||||
cfg2 = AudioConfig(profile="low")
|
||||
assert cfg2.latency_profile["period"] == 64
|
||||
assert cfg2.latency_profile["rt_priority"] == 80
|
||||
|
||||
|
||||
def test_audio_config_unknown_profile():
|
||||
cfg = AudioConfig(profile="bogus")
|
||||
# Should fall back to standard
|
||||
assert cfg.latency_profile["period"] == 128
|
||||
|
||||
|
||||
def test_audio_config_jack_device_arg():
|
||||
cfg = AudioConfig(output_device="hw:1,0")
|
||||
assert cfg.jack_device_arg == "dhw:1,0"
|
||||
|
||||
|
||||
# ── Latency buffer time computation ───────────────────────────────
|
||||
|
||||
|
||||
def test_latency_profiles_buffer_times():
|
||||
for name, profile in LATENCY_PROFILES.items():
|
||||
period_ms = (profile["period"] / profile["rate"]) * 1000
|
||||
buffer_ms = period_ms * profile["nperiods"]
|
||||
# Standard: 128/48000 * 2 = 5.33ms; Low: 64/48000 * 2 = 2.67ms
|
||||
assert buffer_ms < 10.0, f"{name}: {buffer_ms:.2f} ms >= 10 ms"
|
||||
assert buffer_ms > 0
|
||||
|
||||
|
||||
# ── I2S config entries ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_i2s_configs_have_all_fields():
|
||||
for key, (line, card, desc) in I2S_CONFIGS.items():
|
||||
assert line.startswith("dtoverlay="), f"{key}: bad overlay line"
|
||||
assert isinstance(card, int) and card >= 0, f"{key}: bad card index"
|
||||
assert len(desc) > 10, f"{key}: missing description"
|
||||
|
||||
|
||||
# ── AudioSystem tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_audio_system_init():
|
||||
sys = AudioSystem()
|
||||
assert sys.config.jack_enabled is True
|
||||
assert isinstance(sys.config, AudioConfig)
|
||||
|
||||
|
||||
def test_audio_system_custom_config():
|
||||
cfg = AudioConfig(hat_type="justboom", profile="low")
|
||||
sys = AudioSystem(config=cfg)
|
||||
assert sys.config.hat_type == "justboom"
|
||||
assert sys.config.profile == "low"
|
||||
|
||||
|
||||
def test_audio_system_list_devices_no_alsa():
|
||||
# On a non-RPi / without ALSA tools, list_devices returns []
|
||||
sys = AudioSystem()
|
||||
devices = sys.list_devices()
|
||||
assert isinstance(devices, list)
|
||||
|
||||
|
||||
def test_audio_system_stop_jack_no_crash():
|
||||
"""stop_jack should not crash even if jackd is not running."""
|
||||
sys = AudioSystem()
|
||||
# Should finish without exception
|
||||
sys.stop_jack()
|
||||
|
||||
|
||||
def test_audio_system_read_xrun_count_no_jack():
|
||||
"""read_xrun_count returns None when JACK isn't running."""
|
||||
result = AudioSystem.read_xrun_count()
|
||||
assert result is None or isinstance(result, int)
|
||||
|
||||
|
||||
def test_audio_system_get_active_overlay():
|
||||
"""get_active_overlay should not crash on a non-RPi."""
|
||||
result = AudioSystem.get_active_overlay()
|
||||
# May be None or a string — just shouldn't crash
|
||||
assert result is None or isinstance(result, str)
|
||||
|
||||
|
||||
def test_audio_system_monitor_xruns_no_jack():
|
||||
"""monitor_xruns should not crash when JACK isn't running."""
|
||||
sys = AudioSystem()
|
||||
result = sys.monitor_xruns(duration=1, interval=1)
|
||||
assert isinstance(result, dict)
|
||||
assert "xrun_total" in result
|
||||
assert "stable" in result
|
||||
|
||||
|
||||
def test_audio_system_measure_roundtrip_latency():
|
||||
"""measure_roundtrip_latency returns None when jack_iodelay is missing."""
|
||||
result = AudioSystem.measure_roundtrip_latency(samples=1, timeout=2)
|
||||
assert result is None # jack_iodelay not installed on dev machine
|
||||
|
||||
|
||||
# ── Systemd service content tests ─────────────────────────────────
|
||||
|
||||
|
||||
def test_systemd_service_content_default():
|
||||
cfg = AudioConfig()
|
||||
content = AudioSystem.systemd_service_content(cfg)
|
||||
assert "[Unit]" in content
|
||||
assert "Description=JACK Audio Server" in content
|
||||
assert "ExecStart=/usr/bin/jackd" in content
|
||||
assert "-P70" in content
|
||||
assert "-p128" in content
|
||||
assert "-n2" in content
|
||||
assert "-r48000" in content
|
||||
assert "LimitRTPRIO=95" in content
|
||||
assert "LimitMEMLOCK=infinity" in content
|
||||
assert "WantedBy=multi-user.target" in content
|
||||
|
||||
|
||||
def test_systemd_service_content_low_latency():
|
||||
cfg = AudioConfig(profile="low")
|
||||
content = AudioSystem.systemd_service_content(cfg)
|
||||
assert "-P80" in content # higher RT priority for low profile
|
||||
assert "-p64" in content # smaller period
|
||||
|
||||
|
||||
def test_systemd_service_content_different_hat():
|
||||
cfg = AudioConfig(hat_type="justboom")
|
||||
content = AudioSystem.systemd_service_content(cfg)
|
||||
assert "-dhw:0" in content # same device default
|
||||
|
||||
|
||||
def test_systemd_service_content_custom_device():
|
||||
cfg = AudioConfig(output_device="hw:2,0")
|
||||
content = AudioSystem.systemd_service_content(cfg)
|
||||
assert "-dhw:2,0" in content
|
||||
|
||||
|
||||
# ── Module exports ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_module_exports():
|
||||
from system import AudioConfig as AC, AudioSystem as AS
|
||||
assert AC is AudioConfig
|
||||
assert AS is AudioSystem
|
||||
@@ -0,0 +1,631 @@
|
||||
"""Unit tests for individual FX blocks in the audio pipeline.
|
||||
|
||||
Each test validates a specific effect against known output shapes
|
||||
(silence clipping, envelope tracking, modulation range, etc.).
|
||||
All tests use 256-sample blocks at 48kHz to match real-time operation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from src.dsp.pipeline import (
|
||||
AudioPipeline,
|
||||
BLOCK_SIZE,
|
||||
SAMPLE_RATE,
|
||||
_DelayLine,
|
||||
_CombFilter,
|
||||
_AllpassFilter,
|
||||
_compute_lowshelf_coeffs,
|
||||
_compute_highshelf_coeffs,
|
||||
_compute_peaking_coeffs,
|
||||
)
|
||||
from src.presets.types import FXBlock, FXType, Preset
|
||||
|
||||
# ── Fixtures ───────────────────────────────────────────────────────
|
||||
|
||||
SILENCE = np.zeros(BLOCK_SIZE, dtype=np.float32)
|
||||
SINE_TONE = (np.sin(2 * np.pi * 440.0 * np.arange(BLOCK_SIZE) / SAMPLE_RATE)
|
||||
.astype(np.float32))
|
||||
HALF_SCALE = np.full(BLOCK_SIZE, 0.5, dtype=np.float32)
|
||||
FULL_SCALE = np.full(BLOCK_SIZE, 0.99, dtype=np.float32)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline():
|
||||
p = AudioPipeline()
|
||||
yield p
|
||||
|
||||
|
||||
def _load_fx(pipeline: AudioPipeline, fx_type: FXType,
|
||||
params: dict[str, float] | None = None) -> None:
|
||||
"""Quick-apply a single FX block to the pipeline for testing."""
|
||||
block = FXBlock(
|
||||
fx_type=fx_type,
|
||||
enabled=True,
|
||||
bypass=False,
|
||||
params=params or {},
|
||||
)
|
||||
preset = Preset(
|
||||
name="test",
|
||||
chain=[block],
|
||||
master_volume=1.0,
|
||||
)
|
||||
pipeline.load_preset(preset)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 1. Noise Gate
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestNoiseGate:
|
||||
def test_silence_muted(self, pipeline):
|
||||
"""Gate at default threshold (0.01) should silence silence."""
|
||||
_load_fx(pipeline, FXType.NOISE_GATE, {"threshold": 0.01})
|
||||
out = pipeline.process(SILENCE)
|
||||
assert np.max(np.abs(out)) == 0.0, "Silence should be muted"
|
||||
|
||||
def test_strong_signal_passes(self, pipeline):
|
||||
"""Gate should pass full-scale tone."""
|
||||
_load_fx(pipeline, FXType.NOISE_GATE, {"threshold": 0.01})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.max(np.abs(out)) > 0.8, "Strong signal should pass"
|
||||
|
||||
def test_release_tail(self, pipeline):
|
||||
"""Gate should have exponential release, not instant cut."""
|
||||
_load_fx(pipeline, FXType.NOISE_GATE, {"threshold": 0.1, "release": 50.0})
|
||||
# Transition: silence -> tone -> silence
|
||||
out1 = pipeline.process(SILENCE) # below threshold — muted
|
||||
assert np.max(np.abs(out1)) == 0.0, "Initial silence muted"
|
||||
out2 = pipeline.process(SINE_TONE * 0.5) # strong — opens
|
||||
assert np.max(np.abs(out2)) > 0.1, "Gate opened for tone"
|
||||
out3 = pipeline.process(SILENCE) # release tail
|
||||
out4 = pipeline.process(SILENCE) # should fully decay
|
||||
out5 = pipeline.process(SILENCE)
|
||||
# After enough silence blocks, output should be zero
|
||||
assert np.max(np.abs(out5)) < 0.001, "Release should fully decay"
|
||||
|
||||
def test_bypass(self, pipeline):
|
||||
"""Bypassed gate passes audio unchanged."""
|
||||
block = FXBlock(
|
||||
fx_type=FXType.NOISE_GATE, enabled=True,
|
||||
bypass=True, params={"threshold": 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), "Bypassed gate should passthrough"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 2. Compressor
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCompressor:
|
||||
def test_below_threshold_no_change(self, pipeline):
|
||||
"""Signal below threshold should pass at unity (after makeup)."""
|
||||
_load_fx(pipeline, FXType.COMPRESSOR,
|
||||
{"threshold": 0.0, "ratio": 4.0, "gain": 1.0})
|
||||
out = pipeline.process(SINE_TONE * 0.01) # very quiet
|
||||
assert np.max(np.abs(out)) > 0, "Quiet signal passes"
|
||||
|
||||
def test_limits_above_threshold(self, pipeline):
|
||||
"""Signal well above threshold gets compressed."""
|
||||
_load_fx(pipeline, FXType.COMPRESSOR,
|
||||
{"threshold": -10.0, "ratio": 8.0, "gain": 1.0})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
rms_out = np.sqrt(np.mean(out ** 2))
|
||||
# High ratio should substantially reduce dynamics
|
||||
assert rms_out < 0.8, "Compressor should reduce level above threshold"
|
||||
|
||||
def test_makeup_gain(self, pipeline):
|
||||
"""Makeup gain should be applied after compression."""
|
||||
_load_fx(pipeline, FXType.COMPRESSOR,
|
||||
{"threshold": -20.0, "ratio": 4.0, "gain": 1.5})
|
||||
out = pipeline.process(SINE_TONE * 0.3)
|
||||
assert np.max(np.abs(out)) <= 1.0, "Output must be in [-1, 1]"
|
||||
|
||||
def test_output_clipped(self, pipeline):
|
||||
"""Compressor output never exceeds [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.COMPRESSOR,
|
||||
{"threshold": -50.0, "ratio": 2.0, "gain": 20.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 3. Boost / Overdrive / Distortion / Fuzz
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestBoost:
|
||||
def test_linear_gain(self, pipeline):
|
||||
"""Boost applies linear gain without clipping (small signal)."""
|
||||
_load_fx(pipeline, FXType.BOOST, {"gain_db": 6.0})
|
||||
quiet = SINE_TONE * 0.1
|
||||
out = pipeline.process(quiet)
|
||||
expected = np.clip(quiet * 10 ** (6.0 / 20.0), -1.0, 1.0)
|
||||
assert np.allclose(out, expected, atol=1e-6), \
|
||||
"6dB boost should double amplitude"
|
||||
|
||||
def test_clips_at_unity(self, pipeline):
|
||||
"""Boost clips to [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.BOOST, {"gain_db": 60.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||
|
||||
|
||||
class TestOverdrive:
|
||||
def test_asymmetric_clipping(self, pipeline):
|
||||
"""Overdrive clips asymmetrically (tube-like)."""
|
||||
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 0.8, "gain": 1.0})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||
# Should have harmonics — check shape differs from input
|
||||
assert not np.allclose(out, FULL_SCALE, atol=0.05)
|
||||
|
||||
def test_low_drive_passthrough(self, pipeline):
|
||||
"""Low drive should pass nearly clean."""
|
||||
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 0.0, "gain": 1.0})
|
||||
quiet = SINE_TONE * 0.05
|
||||
out = pipeline.process(quiet)
|
||||
assert np.max(np.abs(out)) > 0.0
|
||||
|
||||
|
||||
class TestDistortion:
|
||||
def test_harder_clipping(self, pipeline):
|
||||
"""Distortion clips harder than overdrive."""
|
||||
_load_fx(pipeline, FXType.DISTORTION, {"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_distortion_changes_waveform(self, pipeline):
|
||||
"""Distortion should significantly reshape the waveform."""
|
||||
_load_fx(pipeline, FXType.DISTORTION, {"drive": 1.0, "gain": 1.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
# Should have square-ish shape (overtones)
|
||||
assert np.std(out) > 0, "Distortion produces output"
|
||||
|
||||
|
||||
class TestFuzz:
|
||||
def test_hard_clip_shape(self, pipeline):
|
||||
"""Fuzz creates near-square-wave shape."""
|
||||
_load_fx(pipeline, FXType.FUZZ, {"drive": 1.0, "gain": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.8)
|
||||
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||
# Fuzz should be very clipped
|
||||
rms_out = np.sqrt(np.mean(out ** 2))
|
||||
assert rms_out > 0.2, "Fuzz should produce significant output"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 4. EQ
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEQ:
|
||||
def test_flat_chain(self, pipeline):
|
||||
"""EQ with 0dB on all bands passes signal unchanged."""
|
||||
_load_fx(pipeline, FXType.EQ, {"bass": 0.0, "mid": 0.0, "treble": 0.0})
|
||||
out = pipeline.process(SINE_TONE)
|
||||
assert np.allclose(out, SINE_TONE, atol=1e-4), \
|
||||
"Flat EQ should pass through"
|
||||
|
||||
def test_bass_boost(self, pipeline):
|
||||
"""Bass boost amplifies low frequencies."""
|
||||
_load_fx(pipeline, FXType.EQ,
|
||||
{"bass": 12.0, "mid": 0.0, "treble": 0.0,
|
||||
"bass_freq": 200.0})
|
||||
# Low frequency test tone
|
||||
low_tone = (np.sin(2 * np.pi * 80.0 * np.arange(BLOCK_SIZE) / SAMPLE_RATE)
|
||||
.astype(np.float32))
|
||||
out = pipeline.process(low_tone * 0.3)
|
||||
rms_out = np.sqrt(np.mean(out ** 2))
|
||||
rms_in = np.sqrt(np.mean((low_tone * 0.3) ** 2))
|
||||
assert rms_out > rms_in * 1.5, \
|
||||
"Bass boost should amplify low tones"
|
||||
|
||||
def test_output_range(self, pipeline):
|
||||
"""EQ stays in [-3, 3] before final clip in process()."""
|
||||
_load_fx(pipeline, FXType.EQ,
|
||||
{"bass": 15.0, "mid": 15.0, "treble": 15.0})
|
||||
out = pipeline.process(SINE_TONE * 0.3)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_biquad_coeffs_stable(self):
|
||||
"""Biquad coefficient generators produce stable filters."""
|
||||
coeffs = _compute_lowshelf_coeffs(200, 6.0, 0.707, SAMPLE_RATE)
|
||||
assert all(np.isfinite(c) for c in coeffs)
|
||||
coeffs = _compute_highshelf_coeffs(3500, -6.0, 0.707, SAMPLE_RATE)
|
||||
assert all(np.isfinite(c) for c in coeffs)
|
||||
coeffs = _compute_peaking_coeffs(1000, 6.0, 0.707, SAMPLE_RATE)
|
||||
assert all(np.isfinite(c) for c in coeffs)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 5. Chorus
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestChorus:
|
||||
def test_output_range(self, pipeline):
|
||||
"""Chorus output stays in [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.CHORUS,
|
||||
{"rate": 0.5, "depth": 0.5, "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):
|
||||
"""0% mix = pass-through."""
|
||||
_load_fx(pipeline, FXType.CHORUS,
|
||||
{"rate": 0.5, "depth": 0.5, "mix": 0.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
|
||||
|
||||
def test_wet_at_full_mix(self, pipeline):
|
||||
"""100% mix = modulated signal only."""
|
||||
_load_fx(pipeline, FXType.CHORUS,
|
||||
{"rate": 0.5, "depth": 0.5, "mix": 1.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
out2 = pipeline.process(SINE_TONE * 0.5)
|
||||
# Chorus should produce varied output (LFO modulation)
|
||||
assert not np.allclose(out, out2, atol=0.01), \
|
||||
"Chorus LFO should produce different samples each block"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 6. Flanger
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestFlanger:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.FLANGER,
|
||||
{"rate": 0.25, "depth": 0.7, "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_zero_mix_pass(self, pipeline):
|
||||
"""0% mix = pass-through."""
|
||||
_load_fx(pipeline, FXType.FLANGER,
|
||||
{"rate": 0.25, "depth": 0.7, "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_accumulates(self, pipeline):
|
||||
"""Flanger with high feedback should produce different output
|
||||
over successive blocks than without feedback."""
|
||||
_load_fx(pipeline, FXType.FLANGER,
|
||||
{"rate": 0.25, "depth": 0.7, "feedback": 0.8, "mix": 1.0})
|
||||
out1 = pipeline.process(SINE_TONE * 0.3)
|
||||
out2 = pipeline.process(SINE_TONE * 0.3)
|
||||
# With high feedback, two identical inputs produce different outputs
|
||||
# due to feedback accumulation
|
||||
assert not np.allclose(out1, out2, atol=0.01), \
|
||||
"High feedback should accumulate in flanger"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 7. Phaser
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPhaser:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.PHASER,
|
||||
{"rate": 0.4, "depth": 0.5, "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_zero_mix_pass(self, pipeline):
|
||||
_load_fx(pipeline, FXType.PHASER,
|
||||
{"rate": 0.4, "depth": 0.5, "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_phaser_modulates(self, pipeline):
|
||||
"""Phaser produces different output across successive blocks (LFO sweep)."""
|
||||
_load_fx(pipeline, FXType.PHASER,
|
||||
{"rate": 0.4, "depth": 0.5, "feedback": 0.3, "mix": 1.0})
|
||||
out1 = pipeline.process(SINE_TONE * 0.3)
|
||||
out2 = pipeline.process(SINE_TONE * 0.3)
|
||||
assert not np.allclose(out1, out2, atol=0.01), \
|
||||
"Phaser LFO should modulate across blocks"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 8. Tremolo
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestTremolo:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 4.0, "depth": 0.7, "shape": "sine"})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_silence_stays_silent(self, pipeline):
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 4.0, "depth": 1.0, "shape": "sine"})
|
||||
out = pipeline.process(SILENCE)
|
||||
assert np.max(np.abs(out)) == 0.0
|
||||
|
||||
def test_triangular_lfo_shape(self, pipeline):
|
||||
"""Triangle LFO produces different amplitude envelope."""
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 2.0, "depth": 1.0, "shape": "square"})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
# Square wave LFO — should have extreme variation
|
||||
max_val = np.max(out)
|
||||
min_val = np.min(out)
|
||||
assert max_val > 0.9 or min_val < 0.01, \
|
||||
f"Square LFO should produce extremes (max={max_val:.3f}, min={min_val:.3f})"
|
||||
|
||||
def test_zero_depth_no_effect(self, pipeline):
|
||||
"""0% depth = no modulation."""
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 4.0, "depth": 0.0, "shape": "sine"})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE, atol=1e-4)
|
||||
|
||||
def test_modulation_changes_each_block(self, pipeline):
|
||||
"""LFO phase advances across blocks."""
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 4.0, "depth": 1.0, "shape": "sine"})
|
||||
out1 = pipeline.process(HALF_SCALE)
|
||||
out2 = pipeline.process(HALF_SCALE)
|
||||
assert not np.allclose(out1, out2, atol=0.001), \
|
||||
"LFO should produce different modulation each block"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 9. Vibrato
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVibrato:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.VIBRATO,
|
||||
{"rate": 3.0, "depth": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_pitch_modulation(self, pipeline):
|
||||
"""Vibrato produces time-varying delay (pitch warble)."""
|
||||
_load_fx(pipeline, FXType.VIBRATO,
|
||||
{"rate": 3.0, "depth": 0.5})
|
||||
out1 = pipeline.process(SINE_TONE * 0.3)
|
||||
out2 = pipeline.process(SINE_TONE * 0.3)
|
||||
# Identical input blocks produce different output due to LFO
|
||||
assert not np.allclose(out1, out2, atol=0.001), \
|
||||
"Vibrato LFO should modulate pitch across blocks"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 10. Delay
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDelay:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.DELAY,
|
||||
{"time": 400.0, "feedback": 0.3, "mix": 0.4})
|
||||
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):
|
||||
_load_fx(pipeline, FXType.DELAY,
|
||||
{"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_no_silence_output_on_silence(self, pipeline):
|
||||
"""Silence in with delay should produce echo decay tail."""
|
||||
_load_fx(pipeline, FXType.DELAY,
|
||||
{"time": 50.0, "feedback": 0.5, "mix": 1.0})
|
||||
pipeline.process(SINE_TONE * 0.5) # Fill delay line
|
||||
pipeline.process(SILENCE)
|
||||
out3 = pipeline.process(SILENCE)
|
||||
out4 = pipeline.process(SILENCE)
|
||||
# Should have decaying echo
|
||||
assert np.max(np.abs(out3)) > 0, "Delay tail should still be present"
|
||||
# Echo should decay toward zero
|
||||
assert np.max(np.abs(out4)) <= np.max(np.abs(out3)) + 0.001, \
|
||||
"Echo should decay"
|
||||
|
||||
def test_tap_tempo_callback(self, pipeline):
|
||||
"""Tap tempo overrides time_ms when set."""
|
||||
_load_fx(pipeline, FXType.DELAY,
|
||||
{"time": 400.0, "tap_tempo": 200.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)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 11. Reverb (Schroeder)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestReverb:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.5, "damping": 0.4, "mix": 0.3})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_zero_mix_passthrough(self, pipeline):
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.5, "damping": 0.4, "mix": 0.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
|
||||
|
||||
def test_decay_tail(self, pipeline):
|
||||
"""Reverb produces decaying tail after input stops."""
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.8, "damping": 0.4, "mix": 1.0})
|
||||
pipeline.process(FULL_SCALE) # Fill reverb
|
||||
tail1 = pipeline.process(SILENCE)
|
||||
tail2 = pipeline.process(SILENCE)
|
||||
tail3 = pipeline.process(SILENCE)
|
||||
tail4 = pipeline.process(SILENCE)
|
||||
# Should have a decay tail
|
||||
assert np.max(np.abs(tail1)) > 0.001, "Reverb tail should be audible"
|
||||
# Should decay (not necessarily monotonic but trend downward)
|
||||
tail_energy = [np.sqrt(np.mean(t ** 2))
|
||||
for t in [tail1, tail2, tail3, tail4]]
|
||||
assert sum(tail_energy) > 0, "Tail must have energy"
|
||||
|
||||
def test_different_decay_values(self, pipeline):
|
||||
"""Higher decay = longer tail."""
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.9, "damping": 0.2, "mix": 1.0})
|
||||
pipeline.process(FULL_SCALE)
|
||||
high_tail = pipeline.process(SILENCE)
|
||||
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.3, "damping": 0.2, "mix": 1.0})
|
||||
pipeline.process(FULL_SCALE)
|
||||
low_tail = pipeline.process(SILENCE)
|
||||
|
||||
energy_high = np.sqrt(np.mean(high_tail ** 2))
|
||||
energy_low = np.sqrt(np.mean(low_tail ** 2))
|
||||
|
||||
# Higher decay should generally produce more tail energy
|
||||
# (not assertion — informational only; structural differences matter)
|
||||
assert energy_high >= 0 and energy_low >= 0, \
|
||||
"Both decay values should produce non-negative energy"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 12. Volume
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVolume:
|
||||
def test_linear_scaling(self, pipeline):
|
||||
_load_fx(pipeline, FXType.VOLUME, {"level": 0.5})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE * 0.5, atol=1e-6)
|
||||
|
||||
def test_unity_gain(self, pipeline):
|
||||
_load_fx(pipeline, FXType.VOLUME, {"level": 1.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 13. Integrated chain tests
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestChain:
|
||||
def test_multiple_fx_chain(self, pipeline):
|
||||
"""Chain multiple effects without error."""
|
||||
chain = [
|
||||
FXBlock(FXType.NOISE_GATE, params={"threshold": 0.001}),
|
||||
FXBlock(FXType.COMPRESSOR, params={"threshold": -20.0, "ratio": 4.0}),
|
||||
FXBlock(FXType.OVERDRIVE, params={"drive": 0.4}),
|
||||
FXBlock(FXType.CHORUS, params={"rate": 0.5, "mix": 0.3}),
|
||||
FXBlock(FXType.DELAY, params={"time": 300.0, "mix": 0.3}),
|
||||
FXBlock(FXType.REVERB, params={"decay": 0.4, "mix": 0.2}),
|
||||
FXBlock(FXType.VOLUME, params={"level": 0.8}),
|
||||
]
|
||||
preset = Preset(name="chain_test", chain=chain, master_volume=1.0)
|
||||
pipeline.load_preset(preset)
|
||||
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(np.isfinite(out)), "Chain should produce finite output"
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0), \
|
||||
"Chain output must be in [-1, 1]"
|
||||
|
||||
def test_all_bypass_passthrough(self, pipeline):
|
||||
"""All blocks bypassed = pass-through."""
|
||||
chain = [
|
||||
FXBlock(FXType.NOISE_GATE, enabled=False),
|
||||
FXBlock(FXType.COMPRESSOR, bypass=True),
|
||||
FXBlock(FXType.OVERDRIVE, bypass=True),
|
||||
FXBlock(FXType.EQ, bypass=True),
|
||||
FXBlock(FXType.DELAY, bypass=True),
|
||||
FXBlock(FXType.REVERB, bypass=True),
|
||||
]
|
||||
preset = Preset(name="bypass_test", chain=chain, master_volume=1.0)
|
||||
pipeline.load_preset(preset)
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE, atol=1e-6)
|
||||
|
||||
def test_global_bypass(self, pipeline):
|
||||
"""Global bypass = dry output at master volume."""
|
||||
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 1.0})
|
||||
pipeline.bypassed = True
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE, atol=1e-6)
|
||||
|
||||
def test_master_volume(self, pipeline):
|
||||
_load_fx(pipeline, FXType.VOLUME, {"level": 1.0})
|
||||
pipeline.master_volume = 0.5
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE * 0.5, atol=1e-6)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 14. DelayLine unit tests
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDelayLine:
|
||||
def test_write_read_identity(self):
|
||||
dl = _DelayLine(1024)
|
||||
block = np.arange(BLOCK_SIZE, dtype=np.float32) * 0.001
|
||||
dl.write_block(block)
|
||||
out = dl.read_block(float(BLOCK_SIZE), BLOCK_SIZE)
|
||||
assert np.allclose(out, block, atol=1e-5), \
|
||||
"Read after write at exact delay should return original"
|
||||
|
||||
def test_fractional_interpolation(self):
|
||||
dl = _DelayLine(128)
|
||||
dl.write_block(np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32))
|
||||
# Read at delay 2.5 — should interpolate between 0 and 0
|
||||
dl.write_block(np.zeros(120, dtype=np.float32))
|
||||
out = dl.read_block(2.5, 1)
|
||||
assert 0.0 <= out[0] <= 0.6, \
|
||||
f"Interpolation at 2.5 should be ~0.5 (±error), got {out[0]:.4f}"
|
||||
|
||||
def test_circular_wraparound(self):
|
||||
dl = _DelayLine(4)
|
||||
dl.write_block(np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32))
|
||||
dl.write_block(np.array([5.0, 6.0, 0.0, 0.0], dtype=np.float32))
|
||||
out = dl.read_block(2.0, 2)
|
||||
# After two writes: buffer = [3, 4, 5, 6], write_idx=0
|
||||
# Read at delay 2: idx = (0 - 2) % 4 = 2 -> buf[2]=5, idx=3-> buf[3]=6
|
||||
assert np.allclose(out, [5.0, 6.0], atol=1e-5), \
|
||||
f"Wraparound read should get [5, 6], got {out}"
|
||||
|
||||
|
||||
class TestCombFilter:
|
||||
def test_output_finite(self):
|
||||
cf = _CombFilter(7)
|
||||
out = cf.process(SINE_TONE * 0.5)
|
||||
assert np.all(np.isfinite(out))
|
||||
assert np.all(np.abs(out) < 10.0) # Should be well-behaved
|
||||
|
||||
|
||||
class TestAllpassFilter:
|
||||
def test_output_finite(self):
|
||||
ap = _AllpassFilter(5)
|
||||
out = ap.process(SINE_TONE * 0.5)
|
||||
assert np.all(np.isfinite(out))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 15. State isolation between blocks
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestStateIsolation:
|
||||
def test_two_reverbs_different(self, pipeline):
|
||||
"""Two reverb blocks in chain should have separate state."""
|
||||
chain = [
|
||||
FXBlock(FXType.REVERB, params={"decay": 0.7, "mix": 0.5}),
|
||||
FXBlock(FXType.REVERB, params={"decay": 0.7, "mix": 0.5}),
|
||||
]
|
||||
preset = Preset(name="dual_reverb", chain=chain, master_volume=1.0)
|
||||
pipeline.load_preset(preset)
|
||||
out = pipeline.process(SINE_TONE * 0.3)
|
||||
assert np.all(np.isfinite(out))
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_delay_and_reverb_state_independent(self, pipeline):
|
||||
"""Delay and reverb should not share state."""
|
||||
_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"
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Tests for the hardware UI layer — footswitch, LEDs, display.
|
||||
|
||||
Uses mock GPIO/LED/display so tests run on any machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ui.footswitch import (
|
||||
DEBOUNCE_MS,
|
||||
LONG_PRESS_MS,
|
||||
FootSwitch,
|
||||
FootswitchController,
|
||||
SwitchAction,
|
||||
)
|
||||
from src.ui.leds import LEDConfig, LEDController, LEDDriver, LEDPattern
|
||||
from src.ui.display import DisplayController, DisplayState, DISPLAY_W, DISPLAY_H
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Footswitch tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestFootSwitch:
|
||||
"""FootSwitch dataclass construction."""
|
||||
|
||||
def test_basic_switch(self):
|
||||
sw = FootSwitch(17, SwitchAction.PRESET_UP, SwitchAction.TAP_TEMPO)
|
||||
assert sw.gpio_pin == 17
|
||||
assert sw.action_default == SwitchAction.PRESET_UP
|
||||
assert sw.action_long_press == SwitchAction.TAP_TEMPO
|
||||
assert sw.active_low is True
|
||||
|
||||
def test_default_active_low(self):
|
||||
sw = FootSwitch(22, SwitchAction.BYPASS)
|
||||
assert sw.active_low is True
|
||||
|
||||
def test_no_long_press(self):
|
||||
sw = FootSwitch(22, SwitchAction.BYPASS)
|
||||
assert sw.action_long_press is None
|
||||
|
||||
|
||||
class TestFootswitchController:
|
||||
"""FootswitchController — debounce, long-press, callbacks."""
|
||||
|
||||
def test_default_layout_has_4_switches(self):
|
||||
ctrl = FootswitchController()
|
||||
assert len(ctrl._switches) == 4
|
||||
|
||||
def test_register_and_fire_callback(self):
|
||||
ctrl = FootswitchController()
|
||||
fired = []
|
||||
|
||||
def cb():
|
||||
fired.append("bye")
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, cb)
|
||||
ctrl._trigger(SwitchAction.BYPASS)
|
||||
assert fired == ["bye"]
|
||||
|
||||
def test_callback_error_does_not_crash(self):
|
||||
ctrl = FootswitchController()
|
||||
|
||||
def broken():
|
||||
raise ValueError("boom")
|
||||
|
||||
def ok():
|
||||
pass
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, broken)
|
||||
ctrl.register_callback(SwitchAction.BYPASS, ok)
|
||||
ctrl._trigger(SwitchAction.BYPASS) # should not raise
|
||||
|
||||
def test_simulate_press_triggers_callback(self):
|
||||
ctrl = FootswitchController()
|
||||
fired = []
|
||||
|
||||
def cb():
|
||||
fired.append("up")
|
||||
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, cb)
|
||||
ctrl.simulate_press(SwitchAction.PRESET_UP)
|
||||
assert fired == ["up"]
|
||||
|
||||
def test_start_stop_no_crash(self):
|
||||
ctrl = FootswitchController()
|
||||
ctrl.start()
|
||||
assert ctrl._running is True
|
||||
ctrl.stop()
|
||||
assert ctrl._running is False
|
||||
|
||||
def test_virtual_gpio_press_and_release(self):
|
||||
"""Test debounce engine via virtual GPIO pins."""
|
||||
ctrl = FootswitchController()
|
||||
actions = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.append("up"))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
pin = ctrl._switches[0].gpio_pin # pin 17, PRESET_UP
|
||||
|
||||
# Press the pin
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000) # Wait past debounce window
|
||||
|
||||
# Now release
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000)
|
||||
|
||||
# Should have fired PRESET_UP on release (short press)
|
||||
assert "up" in actions, f"Expected 'up' in {actions}"
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_long_press_via_gpio(self):
|
||||
"""Test long-press triggers the long-press action."""
|
||||
ctrl = FootswitchController()
|
||||
actions = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.append("tap"))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
# FS1 has PRESET_UP default, TAP_TEMPO long-press
|
||||
pin = ctrl._switches[0].gpio_pin
|
||||
|
||||
# Press and hold past LONG_PRESS_MS
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep((LONG_PRESS_MS + 50) / 1000)
|
||||
|
||||
# Long press should fire without release
|
||||
assert "tap" in actions, f"Expected 'tap' in {actions}"
|
||||
|
||||
# Release
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000)
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_short_press_no_long_press_action(self):
|
||||
"""Short press triggers default, not long-press."""
|
||||
actions = {"default": False, "long": False}
|
||||
|
||||
ctrl = FootswitchController()
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.update(default=True))
|
||||
ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.update(long=True))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
pin = ctrl._switches[0].gpio_pin
|
||||
|
||||
# Short press
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep(DEBOUNCE_MS / 1000 + 0.01)
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep(DEBOUNCE_MS / 1000 + 0.01)
|
||||
|
||||
# Poll cycle should have fired
|
||||
assert actions["default"] is True, f"Expected default fired, got {actions}"
|
||||
assert actions["long"] is False, f"Expected long NOT fired, got {actions}"
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_multiple_callbacks_same_action(self):
|
||||
ctrl = FootswitchController()
|
||||
results = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, lambda: results.append(1))
|
||||
ctrl.register_callback(SwitchAction.BYPASS, lambda: results.append(2))
|
||||
ctrl._trigger(SwitchAction.BYPASS)
|
||||
assert results == [1, 2]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# LED tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestLEDController:
|
||||
"""LEDController — initialization, pixel control, animations."""
|
||||
|
||||
def test_init_basic(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
assert ctrl._num_leds == 4
|
||||
assert ctrl._global_brightness == 0.5
|
||||
assert ctrl._initialized is False
|
||||
|
||||
def test_initialize_mock_returns_false(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
result = ctrl.initialize()
|
||||
assert result is False
|
||||
assert ctrl._initialized is False
|
||||
|
||||
def test_set_pixel(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_pixel(0, (255, 0, 0))
|
||||
assert ctrl._pixels[0] == (127, 0, 0) # scaled by brightness 0.5
|
||||
|
||||
def test_set_pixel_out_of_range(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_pixel(99, (255, 0, 0)) # should not crash
|
||||
assert ctrl._pixels[0] == (0, 0, 0) # unchanged
|
||||
|
||||
def test_set_all(self):
|
||||
ctrl = LEDController(3, driver=LEDDriver.MOCK)
|
||||
ctrl.set_all((100, 200, 50))
|
||||
for px in ctrl._pixels:
|
||||
assert px[0] == 50 # 100 * 0.5
|
||||
assert px[1] == 100 # 200 * 0.5
|
||||
|
||||
def test_bypass_led_red_when_bypassed(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.set_bypass_led(0, bypassed=True)
|
||||
assert ctrl._pixels[0] == (127, 0, 0) # red
|
||||
|
||||
def test_bypass_led_green_when_active(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.set_bypass_led(0, bypassed=False)
|
||||
assert ctrl._pixels[0] == (0, 127, 0) # green
|
||||
|
||||
def test_clear_all(self):
|
||||
ctrl = LEDController(3, driver=LEDDriver.MOCK)
|
||||
ctrl.set_all((255, 255, 255))
|
||||
ctrl.clear_all()
|
||||
assert all(px == (0, 0, 0) for px in ctrl._pixels)
|
||||
|
||||
def test_set_brightness(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_brightness(0.8)
|
||||
assert ctrl._global_brightness == 0.8
|
||||
|
||||
def test_set_brightness_clamps(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_brightness(2.0)
|
||||
assert ctrl._global_brightness == 1.0
|
||||
ctrl.set_brightness(-0.5)
|
||||
assert ctrl._global_brightness == 0.0
|
||||
|
||||
def test_context_manager(self):
|
||||
with LEDController(2, driver=LEDDriver.MOCK) as ctrl:
|
||||
assert ctrl._num_leds == 2
|
||||
# After exit, pixels should be cleared
|
||||
|
||||
def test_preset_animate_does_not_crash(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.preset_animate("up")
|
||||
ctrl.preset_animate("down")
|
||||
|
||||
def test_tap_tempo_blip_does_not_crash(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.tap_tempo_blip()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Display tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDisplayController:
|
||||
"""DisplayController — initialization, state updates, rendering modes."""
|
||||
|
||||
def test_init(self):
|
||||
dc = DisplayController()
|
||||
assert dc._i2c_bus == 1
|
||||
assert dc._i2c_addr == 0x3C
|
||||
assert dc._initialized is False
|
||||
|
||||
def test_initialize_returns_false_no_hardware(self):
|
||||
dc = DisplayController()
|
||||
result = dc.initialize()
|
||||
assert result is False # No hardware
|
||||
|
||||
def test_update_logs_state_in_headless(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
state = DisplayState(
|
||||
mode="preset",
|
||||
preset_name="Crunch",
|
||||
bypassed=False,
|
||||
fx_active=["overdrive", "delay"],
|
||||
tuner_note="",
|
||||
tuner_cents=0,
|
||||
)
|
||||
dc.update(state)
|
||||
assert "Crunch" in caplog.text
|
||||
assert "overdrive" in caplog.text
|
||||
|
||||
def test_update_tuner_mode(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
state = DisplayState(
|
||||
mode="tuner",
|
||||
tuner_note="A",
|
||||
tuner_cents=-12,
|
||||
)
|
||||
dc.update(state)
|
||||
assert "tuner" in caplog.text.lower()
|
||||
assert "A" in caplog.text
|
||||
assert "-12" in caplog.text
|
||||
|
||||
def test_display_state_defaults(self):
|
||||
state = DisplayState()
|
||||
assert state.mode == "preset"
|
||||
assert state.preset_name == ""
|
||||
assert state.fx_active == []
|
||||
assert state.bypassed is False
|
||||
|
||||
def test_display_state_full(self):
|
||||
state = DisplayState(
|
||||
mode="fx_edit",
|
||||
param_name="Drive",
|
||||
param_value=0.75,
|
||||
)
|
||||
assert state.param_name == "Drive"
|
||||
assert state.param_value == 0.75
|
||||
|
||||
def test_clear_no_crash(self):
|
||||
dc = DisplayController()
|
||||
dc.clear() # Should not crash in headless
|
||||
|
||||
def test_preset_mode_display_str(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
dc.update(DisplayState(preset_name="Clean", bank_name="A1", mode="preset"))
|
||||
assert "Clean" in caplog.text
|
||||
assert "A1" in caplog.text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Integration helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_switch_action_values():
|
||||
"""All SwitchAction values are unique and snake_case."""
|
||||
vals = [a.value for a in SwitchAction]
|
||||
assert len(vals) == len(set(vals)), "Duplicate SwitchAction values"
|
||||
for v in vals:
|
||||
assert "_" in v, f"SwitchAction value '{v}' not snake_case"
|
||||
|
||||
|
||||
def test_led_pattern_values():
|
||||
"""All LEDPattern values are unique."""
|
||||
vals = [p.value for p in LEDPattern]
|
||||
assert len(vals) == len(set(vals))
|
||||
|
||||
|
||||
def test_led_driver_values():
|
||||
"""All LEDDriver values are unique."""
|
||||
vals = [d.value for d in LEDDriver]
|
||||
assert len(vals) == len(set(vals))
|
||||
|
||||
|
||||
def test_display_constants():
|
||||
assert DISPLAY_W == 128
|
||||
assert DISPLAY_H == 64
|
||||
Reference in New Issue
Block a user