6745e25b20
Eight roadmap features from the 2026-06-19 review, each a full vertical (backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests: - automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared with the daylight cycle; window logic mirrors TimeOfDayRule) - ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest (release.yml; amd64 path untouched, continue-on-error) - game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown) - ui: color-harmony gradient generator (complementary/analogous/triadic/...) - effects: audio-reactive palette modulation (new audio_energy_tap; brightness/ saturation modulation across all 12 procedural effects) - capture: linear-light blending + spatio-temporal dithering, opt-in per calibration (new utils/linear_light.py, utils/dither.py) - devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode) Also bundles the pending 2026-06-18 production-review fixes and other in-progress work already in the working tree (manual-trigger rule, etc.), since they share files and could not be cleanly separated. Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing test (automation manual_trigger handler coverage) is a separate in-progress item owned elsewhere, intentionally left as-is.
182 lines
5.8 KiB
Python
182 lines
5.8 KiB
Python
"""Tests for audio-reactive palette modulation on procedural effects.
|
|
|
|
Covers the model round-trip, the AudioEnergyTap (resolve/acquire/energy via
|
|
fakes), and the per-frame brightness/saturation modulation applied to a
|
|
rendered effect frame.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import numpy as np
|
|
|
|
from ledgrab.core.processing.audio_energy_tap import AudioEnergyTap
|
|
from ledgrab.core.processing.effect_stream import EffectColorStripStream
|
|
from ledgrab.storage.color_strip_source import EffectColorStripSource
|
|
|
|
|
|
def _make_source(**overrides) -> EffectColorStripSource:
|
|
base = dict(
|
|
id="fx1",
|
|
name="fx",
|
|
source_type="effect",
|
|
created_at=datetime.now(timezone.utc),
|
|
updated_at=datetime.now(timezone.utc),
|
|
effect_type="plasma",
|
|
)
|
|
base.update(overrides)
|
|
return EffectColorStripSource.create_from_kwargs(**base)
|
|
|
|
|
|
# ── Model ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestModel:
|
|
def test_defaults(self):
|
|
s = _make_source()
|
|
assert s.audio_reactive is False
|
|
assert s.reactive_mode == "brightness"
|
|
assert s.reactive_audio_source_id == ""
|
|
|
|
def test_round_trip_preserves_reactive_fields(self):
|
|
s = _make_source(
|
|
audio_reactive=True,
|
|
reactive_audio_source_id="as_42",
|
|
reactive_mode="both",
|
|
reactive_intensity=0.5,
|
|
)
|
|
back = EffectColorStripSource.from_dict(s.to_dict())
|
|
assert back.audio_reactive is True
|
|
assert back.reactive_audio_source_id == "as_42"
|
|
assert back.reactive_mode == "both"
|
|
assert back.reactive_intensity.value == 0.5
|
|
|
|
def test_apply_update_changes_reactive_fields(self):
|
|
s = _make_source()
|
|
s.apply_update(audio_reactive=True, reactive_mode="saturation", reactive_intensity=0.9)
|
|
assert s.audio_reactive is True
|
|
assert s.reactive_mode == "saturation"
|
|
assert s.reactive_intensity.value == 0.9
|
|
|
|
|
|
# ── AudioEnergyTap ──────────────────────────────────────────────────────
|
|
|
|
|
|
class _Analysis:
|
|
def __init__(self, rms):
|
|
self.rms = rms
|
|
|
|
|
|
class _CaptureStream:
|
|
def __init__(self, rms=0.25):
|
|
self._rms = rms
|
|
|
|
def get_latest_analysis(self):
|
|
return _Analysis(self._rms)
|
|
|
|
|
|
class _Resolved:
|
|
device_index = 3
|
|
is_loopback = True
|
|
audio_template_id = ""
|
|
|
|
|
|
class _SourceStore:
|
|
def resolve_audio_source(self, sid):
|
|
return _Resolved()
|
|
|
|
|
|
class _Manager:
|
|
def __init__(self):
|
|
self.acquired = []
|
|
self.released = []
|
|
self.stream = _CaptureStream()
|
|
|
|
def acquire(self, device, loopback, engine_type=None, engine_config=None):
|
|
self.acquired.append((device, loopback))
|
|
return self.stream
|
|
|
|
def release(self, device, loopback, engine_type=None):
|
|
self.released.append((device, loopback))
|
|
|
|
|
|
class TestAudioEnergyTap:
|
|
def test_unavailable_without_manager(self):
|
|
tap = AudioEnergyTap(None)
|
|
assert tap.available is False
|
|
tap.start()
|
|
assert tap.energy() == 0.0
|
|
|
|
def test_configure_resolves_capture_params(self):
|
|
mgr = _Manager()
|
|
tap = AudioEnergyTap(mgr, _SourceStore())
|
|
tap.configure("as_1")
|
|
tap.start()
|
|
assert tap.active is True
|
|
assert mgr.acquired == [(3, True)]
|
|
|
|
def test_energy_smooths_rms(self):
|
|
mgr = _Manager()
|
|
tap = AudioEnergyTap(mgr, _SourceStore())
|
|
tap.configure("as_1")
|
|
tap.start()
|
|
# rms 0.25 * gain 4 = 1.0 (clamped); EMA rises toward 1.0
|
|
e1 = tap.energy()
|
|
e2 = tap.energy()
|
|
assert 0.0 < e1 < e2 <= 1.0
|
|
|
|
def test_stop_releases_and_resets(self):
|
|
mgr = _Manager()
|
|
tap = AudioEnergyTap(mgr, _SourceStore())
|
|
tap.configure("as_1")
|
|
tap.start()
|
|
tap.stop()
|
|
assert tap.active is False
|
|
assert mgr.released == [(3, True)]
|
|
assert tap.energy() == 0.0
|
|
|
|
|
|
# ── Modulation ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class _FixedTap:
|
|
def __init__(self, energy):
|
|
self._e = energy
|
|
|
|
def energy(self, smoothing=0.4):
|
|
return self._e
|
|
|
|
|
|
class TestModulation:
|
|
def _stream(self, mode, intensity, energy):
|
|
src = _make_source(audio_reactive=True, reactive_mode=mode, reactive_intensity=intensity)
|
|
st = EffectColorStripStream(src)
|
|
st._audio_tap = _FixedTap(energy)
|
|
return st
|
|
|
|
def test_brightness_silence_dims_to_zero(self):
|
|
st = self._stream("brightness", 1.0, 0.0)
|
|
buf = np.array([[200, 100, 50]], dtype=np.uint8)
|
|
st._apply_audio_modulation(buf)
|
|
assert np.array_equal(buf, np.array([[0, 0, 0]], dtype=np.uint8))
|
|
|
|
def test_brightness_full_energy_preserves(self):
|
|
st = self._stream("brightness", 1.0, 1.0)
|
|
buf = np.array([[200, 100, 50]], dtype=np.uint8)
|
|
st._apply_audio_modulation(buf)
|
|
assert np.array_equal(buf, np.array([[200, 100, 50]], dtype=np.uint8))
|
|
|
|
def test_saturation_silence_desaturates_to_luminance(self):
|
|
st = self._stream("saturation", 1.0, 0.0)
|
|
buf = np.array([[200, 100, 50]], dtype=np.uint8)
|
|
st._apply_audio_modulation(buf)
|
|
# All channels collapse to the luminance value (greyscale).
|
|
assert buf[0, 0] == buf[0, 1] == buf[0, 2]
|
|
|
|
def test_zero_intensity_is_noop(self):
|
|
st = self._stream("both", 0.0, 0.0)
|
|
buf = np.array([[200, 100, 50]], dtype=np.uint8)
|
|
st._apply_audio_modulation(buf)
|
|
assert np.array_equal(buf, np.array([[200, 100, 50]], dtype=np.uint8))
|