feat(processed-audio-sources): phase 7 - testing and polish

Fix test_list_filters test (filter_id field name mismatch).
Add tests for audio filters, template store, and source store.
All 678 tests pass, ruff clean, tsc clean, esbuild clean.
No dead code remaining from old source types.
This commit is contained in:
2026-03-31 22:50:02 +03:00
parent 1ce0dc6c61
commit ce1f4847f3
11 changed files with 957 additions and 34 deletions
+319
View File
@@ -0,0 +1,319 @@
"""Tests for audio filters and the AudioFilterPipeline."""
import numpy as np
import pytest
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter
from wled_controller.core.audio.filters.pipeline import AudioFilterPipeline
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
# Import the package to trigger auto-registration of all built-in filters
import wled_controller.core.audio.filters # noqa: F401
from wled_controller.core.filters.filter_instance import FilterInstance
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_analysis(
rms: float = 0.5,
peak: float = 0.7,
spectrum: np.ndarray | None = None,
beat: bool = False,
beat_intensity: float = 0.0,
left_rms: float = 0.3,
right_rms: float = 0.6,
left_spectrum: np.ndarray | None = None,
right_spectrum: np.ndarray | None = None,
) -> AudioAnalysis:
"""Build an AudioAnalysis with sensible defaults for testing."""
if spectrum is None:
spectrum = np.linspace(0.0, 1.0, NUM_BANDS, dtype=np.float32)
if left_spectrum is None:
left_spectrum = np.full(NUM_BANDS, 0.3, dtype=np.float32)
if right_spectrum is None:
right_spectrum = np.full(NUM_BANDS, 0.6, dtype=np.float32)
return AudioAnalysis(
timestamp=1.0,
rms=rms,
peak=peak,
spectrum=spectrum,
beat=beat,
beat_intensity=beat_intensity,
left_rms=left_rms,
left_spectrum=left_spectrum,
right_rms=right_rms,
right_spectrum=right_spectrum,
)
def _zero_analysis() -> AudioAnalysis:
"""AudioAnalysis with all zeros."""
return AudioAnalysis(timestamp=1.0)
# ---------------------------------------------------------------------------
# Registry tests
# ---------------------------------------------------------------------------
class TestAudioFilterRegistry:
def test_all_built_in_filters_registered(self):
expected = {
"channel_extract",
"band_extract",
"gain",
"inverter",
"peak_hold",
"noise_gate",
"envelope_follower",
"spectral_smoothing",
"compressor",
"beat_gate",
"delay",
"audio_filter_template",
}
registered = set(AudioFilterRegistry.get_all().keys())
assert expected.issubset(registered), f"Missing: {expected - registered}"
def test_create_instance(self):
f = AudioFilterRegistry.create_instance("gain", {"factor": 2.0})
assert isinstance(f, AudioFilter)
assert f.options["factor"] == 2.0
def test_create_unknown_raises(self):
with pytest.raises(ValueError, match="Unknown audio filter type"):
AudioFilterRegistry.create_instance("nonexistent", {})
def test_is_registered(self):
assert AudioFilterRegistry.is_registered("gain")
assert not AudioFilterRegistry.is_registered("nonexistent")
# ---------------------------------------------------------------------------
# Channel Extract filter
# ---------------------------------------------------------------------------
class TestChannelExtractFilter:
def test_mono_averages_channels(self):
a = _make_analysis(left_rms=0.2, right_rms=0.8)
f = AudioFilterRegistry.create_instance("channel_extract", {"channel": "mono"})
result = f.process(a)
assert pytest.approx(result.rms, abs=1e-5) == 0.5
def test_left_channel(self):
a = _make_analysis(left_rms=0.2, right_rms=0.8)
f = AudioFilterRegistry.create_instance("channel_extract", {"channel": "left"})
result = f.process(a)
assert result.rms == 0.2
def test_right_channel(self):
a = _make_analysis(left_rms=0.2, right_rms=0.8)
f = AudioFilterRegistry.create_instance("channel_extract", {"channel": "right"})
result = f.process(a)
assert result.rms == 0.8
def test_does_not_mutate_input(self):
a = _make_analysis()
orig_rms = a.rms
f = AudioFilterRegistry.create_instance("channel_extract", {"channel": "left"})
f.process(a)
assert a.rms == orig_rms
# ---------------------------------------------------------------------------
# Band Extract filter
# ---------------------------------------------------------------------------
class TestBandExtractFilter:
def test_bass_preset_zeroes_high_bins(self):
a = _make_analysis()
f = AudioFilterRegistry.create_instance("band_extract", {"band": "bass"})
result = f.process(a)
# Top spectrum bins should be zeroed
assert result.spectrum[-1] == 0.0
def test_treble_preset_zeroes_low_bins(self):
a = _make_analysis()
f = AudioFilterRegistry.create_instance("band_extract", {"band": "treble"})
result = f.process(a)
# Bottom spectrum bins should be zeroed
assert result.spectrum[0] == 0.0
def test_zero_input_stays_zero(self):
a = _zero_analysis()
f = AudioFilterRegistry.create_instance("band_extract", {"band": "bass"})
result = f.process(a)
assert result.rms == 0.0
assert np.all(result.spectrum == 0.0)
# ---------------------------------------------------------------------------
# Gain filter
# ---------------------------------------------------------------------------
class TestGainFilter:
def test_unity_gain_passthrough(self):
a = _make_analysis(rms=0.5)
f = AudioFilterRegistry.create_instance("gain", {"factor": 1.0})
result = f.process(a)
# Unity gain returns the same object
assert result is a
def test_double_gain(self):
a = _make_analysis(rms=0.3, peak=0.4)
f = AudioFilterRegistry.create_instance("gain", {"factor": 2.0})
result = f.process(a)
assert pytest.approx(result.rms) == 0.6
assert pytest.approx(result.peak) == 0.8
def test_gain_clamps_to_one(self):
a = _make_analysis(rms=0.8)
f = AudioFilterRegistry.create_instance("gain", {"factor": 5.0})
result = f.process(a)
assert result.rms <= 1.0
def test_gain_clamps_spectrum(self):
a = _make_analysis()
f = AudioFilterRegistry.create_instance("gain", {"factor": 10.0})
result = f.process(a)
assert np.all(result.spectrum <= 1.0)
assert np.all(result.spectrum >= 0.0)
# ---------------------------------------------------------------------------
# Inverter filter
# ---------------------------------------------------------------------------
class TestInverterFilter:
def test_invert_rms(self):
a = _make_analysis(rms=0.3, peak=0.7)
f = AudioFilterRegistry.create_instance("inverter", {})
result = f.process(a)
assert pytest.approx(result.rms, abs=1e-6) == 0.7
assert pytest.approx(result.peak, abs=1e-6) == 0.3
def test_invert_spectrum(self):
a = _make_analysis()
f = AudioFilterRegistry.create_instance("inverter", {"invert_spectrum": True})
result = f.process(a)
np.testing.assert_allclose(result.spectrum, 1.0 - a.spectrum, atol=1e-6)
def test_no_spectrum_inversion(self):
a = _make_analysis()
f = AudioFilterRegistry.create_instance("inverter", {"invert_spectrum": False})
result = f.process(a)
np.testing.assert_array_equal(result.spectrum, a.spectrum)
# ---------------------------------------------------------------------------
# Peak Hold filter (stateful)
# ---------------------------------------------------------------------------
class TestPeakHoldFilter:
def test_is_stateful(self):
f = AudioFilterRegistry.create_instance("peak_hold", {})
assert f.is_stateful is True
def test_holds_peak_value(self):
f = AudioFilterRegistry.create_instance("peak_hold", {"decay_rate": 0.1})
# First: high value
a1 = _make_analysis(rms=0.9, peak=0.9)
r1 = f.process(a1)
assert r1.rms >= 0.89
# Second: low value — should still hold near the peak (tiny dt, minimal decay)
a2 = _make_analysis(rms=0.1, peak=0.1)
r2 = f.process(a2)
# Held value should be very close to 0.9 (only microseconds of decay)
assert r2.rms >= 0.85
def test_reset_clears_state(self):
f = AudioFilterRegistry.create_instance("peak_hold", {"decay_rate": 0.0})
a = _make_analysis(rms=0.9)
f.process(a)
f.reset()
# After reset, processing a low value should return the low value
a2 = _make_analysis(rms=0.1, peak=0.1)
r2 = f.process(a2)
assert r2.rms == pytest.approx(0.1)
# ---------------------------------------------------------------------------
# AudioFilterPipeline
# ---------------------------------------------------------------------------
class TestAudioFilterPipeline:
def test_empty_pipeline_passthrough(self):
pipeline = AudioFilterPipeline([])
assert pipeline.empty is True
a = _make_analysis()
result = pipeline.process(a)
assert result is a
def test_single_filter(self):
pipeline = AudioFilterPipeline(
[
FilterInstance("gain", {"factor": 2.0}),
]
)
assert pipeline.empty is False
a = _make_analysis(rms=0.3, peak=0.4)
result = pipeline.process(a)
assert pytest.approx(result.rms) == 0.6
def test_chained_filters(self):
"""Gain 2x then invert: rms=0.3 -> 0.6 -> 0.4."""
pipeline = AudioFilterPipeline(
[
FilterInstance("gain", {"factor": 2.0}),
FilterInstance("inverter", {"invert_spectrum": False}),
]
)
a = _make_analysis(rms=0.3, peak=0.4)
result = pipeline.process(a)
assert pytest.approx(result.rms, abs=1e-6) == 0.4
def test_unknown_filter_skipped(self):
"""Unknown filters are silently skipped, remaining filters still work."""
pipeline = AudioFilterPipeline(
[
FilterInstance("nonexistent_filter", {}),
FilterInstance("gain", {"factor": 2.0}),
]
)
a = _make_analysis(rms=0.3)
result = pipeline.process(a)
assert pytest.approx(result.rms) == 0.6
def test_reset_resets_stateful_filters(self):
pipeline = AudioFilterPipeline(
[
FilterInstance("peak_hold", {"decay_rate": 0.0}),
]
)
a = _make_analysis(rms=0.9)
pipeline.process(a)
pipeline.reset()
a2 = _make_analysis(rms=0.1, peak=0.1)
result = pipeline.process(a2)
assert result.rms == pytest.approx(0.1)
def test_close_clears_filters(self):
pipeline = AudioFilterPipeline(
[
FilterInstance("gain", {"factor": 2.0}),
]
)
assert not pipeline.empty
pipeline.close()
assert pipeline.empty