feat: add music sync viz modes and auto_gain audio filter
Lint & Test / test (push) Has been cancelled

Add 4 new audio visualization modes powered by MusicAnalyzer:
- pulse_on_beat: BPM-synced pulsing with smooth beat phase
- energy_gradient: bass/mid/treble mapped to scrolling gradient
- spectrum_bands: three VU zones for frequency bands
- strobe_on_drop: state-driven strobe on detected musical drops

MusicAnalyzer provides BPM estimation (median IBI), beat phase tracking,
asymmetric energy envelope, 3-band frequency splitting, and drop
detection state machine (idle/buildup/drop/recovery).

Add auto_gain audio filter for automatic level normalization via rolling
peak tracking with configurable target level and response time.

Deprecate auto_gain on Audio Value Source (use the filter instead).
This commit is contained in:
2026-04-05 01:40:34 +03:00
parent 6e8b159126
commit b04978af58
14 changed files with 598 additions and 37 deletions
@@ -191,6 +191,7 @@ _RESPONSE_MAP: dict = {
color=s.color.to_dict(), color=s.color.to_dict(),
color_peak=s.color_peak.to_dict(), color_peak=s.color_peak.to_dict(),
mirror=s.mirror, mirror=s.mirror,
beat_decay=s.beat_decay.to_dict(),
), ),
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse( ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
**kw, **kw,
@@ -164,6 +164,7 @@ class AudioCSSResponse(_CSSResponseBase):
color: Any = Field(description="Primary color") color: Any = Field(description="Primary color")
color_peak: Any = Field(description="Peak color") color_peak: Any = Field(description="Peak color")
mirror: bool = Field(description="Mirror mode") mirror: bool = Field(description="Mirror mode")
beat_decay: Any = Field(default=0.15, description="Beat pulse decay rate (music modes)")
class ApiInputCSSResponse(_CSSResponseBase): class ApiInputCSSResponse(_CSSResponseBase):
@@ -340,6 +341,9 @@ class AudioCSSCreate(_CSSCreateBase):
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]") color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode") mirror: Optional[bool] = Field(None, description="Mirror mode")
beat_decay: Any = Field(
default=None, description="Beat pulse decay rate (music modes, 0.01-0.5)"
)
class ApiInputCSSCreate(_CSSCreateBase): class ApiInputCSSCreate(_CSSCreateBase):
@@ -527,6 +531,7 @@ class AudioCSSUpdate(_CSSUpdateBase):
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]") color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode") mirror: Optional[bool] = Field(None, description="Mirror mode")
beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)")
class ApiInputCSSUpdate(_CSSUpdateBase): class ApiInputCSSUpdate(_CSSUpdateBase):
@@ -21,6 +21,7 @@ import wled_controller.core.audio.filters.compressor # noqa: F401
import wled_controller.core.audio.filters.inverter # noqa: F401 import wled_controller.core.audio.filters.inverter # noqa: F401
import wled_controller.core.audio.filters.beat_gate # noqa: F401 import wled_controller.core.audio.filters.beat_gate # noqa: F401
import wled_controller.core.audio.filters.delay # noqa: F401 import wled_controller.core.audio.filters.delay # noqa: F401
import wled_controller.core.audio.filters.auto_gain # noqa: F401
__all__ = [ __all__ = [
"AudioFilter", "AudioFilter",
@@ -0,0 +1,84 @@
"""Auto-gain audio filter — automatic level normalization.
Tracks a rolling peak of the audio signal and scales all levels so the
output uses the full 0-1 range regardless of input volume. Zero-config
by default; optional parameters for tuning response speed and target level.
"""
from dataclasses import replace
from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
class AutoGainFilter(AudioFilter):
"""Normalize audio levels against a rolling observed peak.
Tracks the maximum signal level over a configurable time window and
scales all values so the loudest recent signal maps to ``target_level``.
The rolling peak decays slowly so the gain adapts to changing volumes.
"""
filter_id = "auto_gain"
filter_name = "Auto Gain"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._target = self.options["target_level"]
# Decay factor per frame (~30 fps): controls how fast the peak forgets.
# response_time seconds → peak halves in that time.
response_time = self.options["response_time"]
# decay = 0.5^(1 / (fps * response_time)) ≈ e^(-ln2 / (fps * t))
self._decay = 0.5 ** (1.0 / max(1.0, 30.0 * response_time))
self._rolling_peak = 0.0
@classmethod
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
return [
AudioFilterOptionDef(
key="target_level",
label="Target Level",
option_type="float",
default=0.8,
min_value=0.1,
max_value=1.0,
step=0.05,
),
AudioFilterOptionDef(
key="response_time",
label="Response Time (s)",
option_type="float",
default=3.0,
min_value=0.5,
max_value=15.0,
step=0.5,
),
]
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
# Track the rolling peak from the loudest signal component
current_peak = max(analysis.rms, analysis.peak)
self._rolling_peak = max(current_peak, self._rolling_peak * self._decay)
if self._rolling_peak < 0.001:
return analysis # signal too quiet, don't amplify noise
factor = self._target / self._rolling_peak
if 0.95 <= factor <= 1.05:
return analysis # close enough, avoid jitter
return replace(
analysis,
rms=min(1.0, analysis.rms * factor),
peak=min(1.0, analysis.peak * factor),
spectrum=np.clip(analysis.spectrum * factor, 0.0, 1.0).astype(np.float32),
left_rms=min(1.0, analysis.left_rms * factor),
left_spectrum=np.clip(analysis.left_spectrum * factor, 0.0, 1.0).astype(np.float32),
right_rms=min(1.0, analysis.right_rms * factor),
right_spectrum=np.clip(analysis.right_spectrum * factor, 0.0, 1.0).astype(np.float32),
)
@@ -0,0 +1,223 @@
"""Stateful music feature extractor — BPM, beat phase, energy, drop detection.
Consumes AudioAnalysis snapshots and produces higher-level MusicFeatures
for semantic music-reactive visualizations. Pure numpy, no external deps.
"""
import time
from dataclasses import dataclass
import numpy as np
# Frequency band boundaries (indices into 64-band log spectrum)
_BASS_END = 10 # bins 0-9 ≈ 20-250 Hz
_MID_END = 40 # bins 10-39 ≈ 250-4000 Hz
# bins 40-63 ≈ 4000-20000 Hz (treble)
_MAX_BEAT_HISTORY = 30
_BPM_MIN = 40.0
_BPM_MAX = 220.0
# Drop detection timing
_BUILDUP_MIN_DURATION = 1.0 # seconds of rising energy before "buildup"
_DROP_THRESHOLD = 0.5 # energy must drop by this fraction
_DROP_RECOVERY_TIME = 0.5 # seconds in "drop" state before "recovery"
_RECOVERY_DURATION = 1.0 # seconds in "recovery" before returning to idle
_MIN_ENERGY_FOR_BUILDUP = 0.15 # minimum energy to enter buildup
@dataclass(frozen=True)
class MusicFeatures:
"""Snapshot of semantic music features for one frame."""
bpm: float = 0.0
beat: bool = False
beat_intensity: float = 0.0
beat_phase: float = 0.0 # 0.0-1.0 position in current beat cycle
energy: float = 0.0 # smoothed RMS 0.0-1.0
energy_delta: float = 0.0 # rate of change
bass_energy: float = 0.0
mid_energy: float = 0.0
treble_energy: float = 0.0
drop_state: str = "idle" # idle | buildup | drop | recovery
drop_intensity: float = 0.0
class MusicAnalyzer:
"""Extracts semantic music features from AudioAnalysis snapshots.
Owned by a single stream thread not thread-safe, no locking needed.
"""
def __init__(self, sensitivity: float = 1.0):
self._sensitivity = sensitivity
# Beat history (timestamps of detected beats)
self._beat_times: list[float] = []
self._bpm: float = 0.0
self._last_beat_time: float = 0.0
# Energy tracking
self._smoothed_energy: float = 0.0
self._prev_energy: float = 0.0
self._energy_attack = 0.15 # fast attack
self._energy_release = 0.05 # slow release
# Band energy smoothing
self._bass: float = 0.0
self._mid: float = 0.0
self._treble: float = 0.0
self._band_smoothing = 0.25
# Drop detection state machine
self._drop_state = "idle"
self._drop_state_time: float = 0.0 # when current state started
self._buildup_start_energy: float = 0.0
self._peak_energy: float = 0.0
self._drop_intensity: float = 0.0
@property
def sensitivity(self) -> float:
return self._sensitivity
@sensitivity.setter
def sensitivity(self, value: float) -> None:
self._sensitivity = max(0.1, value)
def update(self, analysis) -> MusicFeatures:
"""Process one AudioAnalysis frame and return MusicFeatures."""
now = time.perf_counter()
spectrum = analysis.spectrum
rms = analysis.rms * self._sensitivity
beat = analysis.beat
beat_intensity = analysis.beat_intensity
# ── Energy envelope (asymmetric smoothing) ──
alpha = self._energy_attack if rms > self._smoothed_energy else self._energy_release
self._smoothed_energy += alpha * (rms - self._smoothed_energy)
energy = min(1.0, self._smoothed_energy)
energy_delta = energy - self._prev_energy
self._prev_energy = energy
# ── Frequency band energy ──
bass_raw = (
float(np.mean(spectrum[:_BASS_END])) * self._sensitivity
if len(spectrum) > _BASS_END
else 0.0
)
mid_raw = (
float(np.mean(spectrum[_BASS_END:_MID_END])) * self._sensitivity
if len(spectrum) > _MID_END
else 0.0
)
treble_raw = (
float(np.mean(spectrum[_MID_END:])) * self._sensitivity
if len(spectrum) > _MID_END
else 0.0
)
a = self._band_smoothing
self._bass += a * (min(1.0, bass_raw) - self._bass)
self._mid += a * (min(1.0, mid_raw) - self._mid)
self._treble += a * (min(1.0, treble_raw) - self._treble)
# ── BPM estimation ──
if beat:
self._beat_times.append(now)
self._last_beat_time = now
# Keep only recent beats
if len(self._beat_times) > _MAX_BEAT_HISTORY:
self._beat_times = self._beat_times[-_MAX_BEAT_HISTORY:]
self._bpm = self._estimate_bpm()
# ── Beat phase (0-1 position in current beat cycle) ──
beat_phase = self._compute_beat_phase(now)
# ── Drop detection ──
self._update_drop_state(now, energy, energy_delta)
return MusicFeatures(
bpm=self._bpm,
beat=beat,
beat_intensity=beat_intensity,
beat_phase=beat_phase,
energy=energy,
energy_delta=energy_delta,
bass_energy=self._bass,
mid_energy=self._mid,
treble_energy=self._treble,
drop_state=self._drop_state,
drop_intensity=self._drop_intensity,
)
def _estimate_bpm(self) -> float:
"""Estimate BPM from recent beat timestamps using median IBI."""
if len(self._beat_times) < 3:
return self._bpm # keep previous estimate
# Compute inter-beat intervals
times = self._beat_times
ibis = [times[i] - times[i - 1] for i in range(1, len(times))]
# Filter out unreasonable IBIs
ibis = [ibi for ibi in ibis if 60.0 / _BPM_MAX <= ibi <= 60.0 / _BPM_MIN]
if not ibis:
return self._bpm
median_ibi = sorted(ibis)[len(ibis) // 2]
raw_bpm = 60.0 / median_ibi
# Exponential smoothing to avoid jitter
if self._bpm > 0:
return self._bpm * 0.85 + raw_bpm * 0.15
return raw_bpm
def _compute_beat_phase(self, now: float) -> float:
"""Compute 0-1 phase within the current beat cycle."""
if self._bpm <= 0 or self._last_beat_time <= 0:
return 0.0
beat_period = 60.0 / self._bpm
elapsed = now - self._last_beat_time
phase = (elapsed % beat_period) / beat_period
return min(1.0, max(0.0, phase))
def _update_drop_state(self, now: float, energy: float, energy_delta: float) -> None:
"""State machine: idle → buildup → drop → recovery → idle."""
state_duration = now - self._drop_state_time
if self._drop_state == "idle":
# Enter buildup when energy is rising and above minimum
if energy > _MIN_ENERGY_FOR_BUILDUP and energy_delta > 0.001:
self._drop_state = "buildup"
self._drop_state_time = now
self._buildup_start_energy = energy
self._peak_energy = energy
elif self._drop_state == "buildup":
self._peak_energy = max(self._peak_energy, energy)
# Transition to drop on sudden energy decrease
if self._peak_energy > 0 and energy < self._peak_energy * (1 - _DROP_THRESHOLD):
self._drop_state = "drop"
self._drop_state_time = now
self._drop_intensity = min(
1.0, (self._peak_energy - energy) / max(self._peak_energy, 0.01)
)
# Cancel buildup if energy falls below minimum or state too long
elif energy < _MIN_ENERGY_FOR_BUILDUP or state_duration > 8.0:
self._drop_state = "idle"
self._drop_state_time = now
elif self._drop_state == "drop":
if state_duration >= _DROP_RECOVERY_TIME:
self._drop_state = "recovery"
self._drop_state_time = now
elif self._drop_state == "recovery":
# Fade drop intensity during recovery
progress = min(1.0, state_duration / _RECOVERY_DURATION)
self._drop_intensity = self._drop_intensity * (1.0 - progress)
if state_duration >= _RECOVERY_DURATION:
self._drop_state = "idle"
self._drop_state_time = now
self._drop_intensity = 0.0
@@ -3,12 +3,17 @@
Implements AudioColorStripStream which produces LED color arrays driven by Implements AudioColorStripStream which produces LED color arrays driven by
real-time audio analysis (spectrum, beat detection, RMS levels). real-time audio analysis (spectrum, beat detection, RMS levels).
Three visualization modes: Seven visualization modes:
spectrum FFT frequency bars mapped across LEDs with palette coloring spectrum FFT frequency bars mapped across LEDs with palette coloring
beat_pulse full-strip flash on beat detection with exponential decay beat_pulse full-strip flash on beat detection with exponential decay
vu_meter volume level fills LEDs like a progress bar vu_meter volume level fills LEDs like a progress bar
pulse_on_beat BPM-synced pulsing with smooth beat phase
energy_gradient bass/mid/treble mapped to gradient with energy modulation
spectrum_bands three VU zones for bass/mid/treble
strobe_on_drop strobe effect triggered by detected musical drops
""" """
import math
import threading import threading
import time import time
from typing import Optional from typing import Optional
@@ -18,6 +23,7 @@ import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS from wled_controller.core.audio.analysis import NUM_BANDS
from wled_controller.core.audio.audio_capture import AudioCaptureManager from wled_controller.core.audio.audio_capture import AudioCaptureManager
from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids
from wled_controller.core.audio.music_analyzer import MusicAnalyzer
from wled_controller.core.processing.color_strip_stream import ColorStripStream from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.core.processing.effect_stream import _build_palette_lut from wled_controller.core.processing.effect_stream import _build_palette_lut
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -68,6 +74,13 @@ class AudioColorStripStream(ColorStripStream):
self._prev_spectrum: Optional[np.ndarray] = None self._prev_spectrum: Optional[np.ndarray] = None
self._prev_rms = 0.0 self._prev_rms = 0.0
# Music analyzer (for semantic modes: pulse_on_beat, energy_gradient, etc.)
self._music_analyzer: Optional[MusicAnalyzer] = None
self._music_features = None # latest MusicFeatures snapshot
# Strobe state
self._strobe_phase = 0.0
self._gradient_store = None # injected by stream manager self._gradient_store = None # injected by stream manager
self._update_from_source(source) self._update_from_source(source)
@@ -106,6 +119,17 @@ class AudioColorStripStream(ColorStripStream):
self._auto_size = not source.led_count self._auto_size = not source.led_count
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1 self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
self._mirror = bool(getattr(source, "mirror", False)) self._mirror = bool(getattr(source, "mirror", False))
self._beat_decay = bfloat(getattr(source, "beat_decay", 0.15), 0.15)
# Lazily create/update MusicAnalyzer for semantic modes
_MUSIC_MODES = {"pulse_on_beat", "energy_gradient", "spectrum_bands", "strobe_on_drop"}
if self._visualization_mode in _MUSIC_MODES:
if self._music_analyzer is None:
self._music_analyzer = MusicAnalyzer(sensitivity=self._sensitivity)
else:
self._music_analyzer.sensitivity = self._sensitivity
else:
self._music_analyzer = None
# Resolve audio device/template via audio_source_id # Resolve audio device/template via audio_source_id
audio_source_id = getattr(source, "audio_source_id", "") audio_source_id = getattr(source, "audio_source_id", "")
@@ -287,6 +311,10 @@ class AudioColorStripStream(ColorStripStream):
"spectrum": self._render_spectrum, "spectrum": self._render_spectrum,
"beat_pulse": self._render_beat_pulse, "beat_pulse": self._render_beat_pulse,
"vu_meter": self._render_vu_meter, "vu_meter": self._render_vu_meter,
"pulse_on_beat": self._render_pulse_on_beat,
"energy_gradient": self._render_energy_gradient,
"spectrum_bands": self._render_spectrum_bands,
"strobe_on_drop": self._render_strobe_on_drop,
} }
try: try:
@@ -331,6 +359,10 @@ class AudioColorStripStream(ColorStripStream):
if analysis is not None and self._filter_pipeline is not None: if analysis is not None and self._filter_pipeline is not None:
analysis = self._filter_pipeline.process(analysis) analysis = self._filter_pipeline.process(analysis)
# Feed MusicAnalyzer for semantic modes
if analysis is not None and self._music_analyzer is not None:
self._music_features = self._music_analyzer.update(analysis)
render_fn = renderers.get(self._visualization_mode, self._render_spectrum) render_fn = renderers.get(self._visualization_mode, self._render_spectrum)
t_render = time.perf_counter() t_render = time.perf_counter()
render_fn(buf, n, analysis) render_fn(buf, n, analysis)
@@ -477,3 +509,158 @@ class AudioColorStripStream(ColorStripStream):
buf[:, 0] = r buf[:, 0] = r
buf[:, 1] = g buf[:, 1] = g
buf[:, 2] = b buf[:, 2] = b
# ── Music-sync modes (use MusicAnalyzer) ──────────────────────
def _render_pulse_on_beat(self, buf: np.ndarray, n: int, analysis) -> None:
"""BPM-synced pulsing with smooth beat phase and exponential decay."""
if analysis is None or self._music_features is None:
buf[:] = 0
return
mf = self._music_features
beat_decay = self.resolve("beat_decay", self._beat_decay)
# On beat: flash to full brightness
if mf.beat:
self._pulse_brightness = 1.0
else:
self._pulse_brightness = max(0.0, self._pulse_brightness - beat_decay)
# Smooth sine pulse synced to BPM between beats
phase_pulse = 0.5 + 0.5 * math.sin(2.0 * math.pi * mf.beat_phase - math.pi / 2)
# Blend: sharp beat flash dominates, phase pulse fills gaps
brightness = max(self._pulse_brightness, phase_pulse * 0.3 * mf.energy)
if brightness < 0.005:
buf[:] = 0
return
palette_idx = max(0, min(255, int(mf.beat_intensity * 255)))
base_color = self._palette_lut[palette_idx]
buf[:, 0] = int(base_color[0] * brightness)
buf[:, 1] = int(base_color[1] * brightness)
buf[:, 2] = int(base_color[2] * brightness)
def _render_energy_gradient(self, buf: np.ndarray, n: int, analysis) -> None:
"""Maps bass/mid/treble energy to gradient with energy-modulated brightness."""
if analysis is None or self._music_features is None:
buf[:] = 0
return
mf = self._music_features
lut = self._palette_lut
# Scroll gradient position based on beat phase
scroll = mf.beat_phase * 0.3 # subtle scroll
# Per-LED: position along gradient shifted by bass/treble balance
positions = np.linspace(0.0, 1.0, n, dtype=np.float32)
# Bass pushes warm, treble pushes cool
bass_shift = mf.bass_energy * 0.2
treble_shift = mf.treble_energy * 0.2
shifted = positions + scroll + bass_shift - treble_shift
np.mod(shifted, 1.0, out=shifted)
# Map to palette
indices = (shifted * 255).astype(np.int32)
np.clip(indices, 0, 255, out=indices)
np.copyto(buf, lut[indices])
# Modulate brightness by energy
brightness = max(0.05, mf.energy)
f32 = buf.astype(np.float32)
f32 *= brightness
np.clip(f32, 0, 255, out=f32)
np.copyto(buf, f32, casting="unsafe")
def _render_spectrum_bands(self, buf: np.ndarray, n: int, analysis) -> None:
"""Three VU zones for bass/mid/treble with optional mirror."""
if analysis is None or self._music_features is None:
buf[:] = 0
return
mf = self._music_features
lut = self._palette_lut
if self._mirror:
# Mirror: bass in center, treble on edges
half = (n + 1) // 2
third = max(1, half // 3)
self._fill_band_zone(buf, 0, third, mf.treble_energy, lut, 200)
self._fill_band_zone(buf, third, 2 * third, mf.mid_energy, lut, 128)
self._fill_band_zone(buf, 2 * third, half, mf.bass_energy, lut, 40)
# Mirror second half
buf[half:] = buf[half - 1 :: -1][: n - half]
else:
third = max(1, n // 3)
self._fill_band_zone(buf, 0, third, mf.bass_energy, lut, 40)
self._fill_band_zone(buf, third, 2 * third, mf.mid_energy, lut, 128)
self._fill_band_zone(buf, 2 * third, n, mf.treble_energy, lut, 200)
@staticmethod
def _fill_band_zone(
buf: np.ndarray,
start: int,
end: int,
energy: float,
lut: np.ndarray,
palette_center: int,
) -> None:
"""Fill a zone of LEDs proportionally to energy level."""
zone_len = end - start
if zone_len <= 0:
return
fill = int(energy * zone_len)
buf[start:end] = 0
if fill > 0:
color = lut[max(0, min(255, palette_center))]
# Brightness gradient within the filled portion
brightness = np.linspace(0.4, 1.0, fill, dtype=np.float32)
for ch in range(3):
buf[start : start + fill, ch] = (color[ch] * brightness).astype(np.uint8)
def _render_strobe_on_drop(self, buf: np.ndarray, n: int, analysis) -> None:
"""Strobe effect triggered by detected musical drops."""
if analysis is None or self._music_features is None:
buf[:] = 0
return
mf = self._music_features
color_main = self.resolve_color("color", self._color)
color_strobe = self.resolve_color("color_peak", self._color_peak)
if mf.drop_state == "idle":
# Gentle energy-modulated breathing
brightness = 0.1 + 0.3 * mf.energy
buf[:, 0] = int(color_main[0] * brightness)
buf[:, 1] = int(color_main[1] * brightness)
buf[:, 2] = int(color_main[2] * brightness)
elif mf.drop_state == "buildup":
# Increasing brightness pulsing
pulse = 0.5 + 0.5 * math.sin(time.perf_counter() * 8.0 * math.pi)
brightness = 0.3 + 0.7 * mf.energy * pulse
buf[:, 0] = int(color_main[0] * brightness)
buf[:, 1] = int(color_main[1] * brightness)
buf[:, 2] = int(color_main[2] * brightness)
elif mf.drop_state == "drop":
# Rapid strobe at ~10 Hz (capped for photosensitivity safety)
self._strobe_phase += 1
strobe_on = (self._strobe_phase % 3) < 2 # ~10 Hz at 30 fps
if strobe_on:
intensity = mf.drop_intensity
buf[:, 0] = int(color_strobe[0] * intensity)
buf[:, 1] = int(color_strobe[1] * intensity)
buf[:, 2] = int(color_strobe[2] * intensity)
else:
buf[:] = 0
elif mf.drop_state == "recovery":
# Fade from strobe back to breathing
fade = max(0.0, 1.0 - mf.drop_intensity)
brightness = 0.1 + 0.3 * mf.energy * fade
buf[:, 0] = int(color_main[0] * brightness)
buf[:, 1] = int(color_main[1] * brightness)
buf[:, 2] = int(color_main[2] * brightness)
@@ -186,9 +186,9 @@ class AudioValueStream(ValueStream):
self._smoothing = smoothing self._smoothing = smoothing
self._min = min_value self._min = min_value
self._max = max_value self._max = max_value
# auto_gain is deprecated — use the auto_gain audio filter instead.
# Field kept for backward compat but no longer has any effect.
self._auto_gain = auto_gain self._auto_gain = auto_gain
self._rolling_peak = 0.0 # tracks observed max raw audio value
self._rolling_decay = 0.995 # slow decay (~5-10s adaptation)
self._audio_capture_manager = audio_capture_manager self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store self._audio_template_store = audio_template_store
@@ -288,12 +288,6 @@ class AudioValueStream(ValueStream):
raw = self._extract_raw(analysis) raw = self._extract_raw(analysis)
# Auto-gain: normalize raw against rolling observed peak
if self._auto_gain:
self._rolling_peak = max(raw, self._rolling_peak * self._rolling_decay)
if self._rolling_peak > 0.001:
raw = raw / self._rolling_peak
raw = min(1.0, raw * self._sensitivity) raw = min(1.0, raw * self._sensitivity)
# Temporal smoothing # Temporal smoothing
@@ -340,18 +334,12 @@ class AudioValueStream(ValueStream):
return return
old_source_id = self._audio_source_id old_source_id = self._audio_source_id
old_auto_gain = self._auto_gain
self._audio_source_id = source.audio_source_id self._audio_source_id = source.audio_source_id
self._mode = source.mode self._mode = source.mode
self._sensitivity = source.sensitivity self._sensitivity = source.sensitivity
self._smoothing = source.smoothing self._smoothing = source.smoothing
self._min = source.min_value self._min = source.min_value
self._max = source.max_value self._max = source.max_value
self._auto_gain = source.auto_gain
# Reset rolling peak when auto-gain is toggled on
if self._auto_gain and not old_auto_gain:
self._rolling_peak = 0.0
# If audio source changed, re-resolve and swap capture stream # If audio source changed, re-resolve and swap capture stream
if source.audio_source_id != old_source_id: if source.audio_source_id != old_source_id:
@@ -68,6 +68,7 @@ class CSSEditorModal extends Modal {
if (_smoothingWidget) { _smoothingWidget.destroy(); _smoothingWidget = null; } if (_smoothingWidget) { _smoothingWidget.destroy(); _smoothingWidget = null; }
if (_audioSensitivityWidget) { _audioSensitivityWidget.destroy(); _audioSensitivityWidget = null; } if (_audioSensitivityWidget) { _audioSensitivityWidget.destroy(); _audioSensitivityWidget = null; }
if (_audioSmoothingWidget) { _audioSmoothingWidget.destroy(); _audioSmoothingWidget = null; } if (_audioSmoothingWidget) { _audioSmoothingWidget.destroy(); _audioSmoothingWidget = null; }
if (_audioBeatDecayWidget) { _audioBeatDecayWidget.destroy(); _audioBeatDecayWidget = null; }
if (_effectIntensityWidget) { _effectIntensityWidget.destroy(); _effectIntensityWidget = null; } if (_effectIntensityWidget) { _effectIntensityWidget.destroy(); _effectIntensityWidget = null; }
if (_effectScaleWidget) { _effectScaleWidget.destroy(); _effectScaleWidget = null; } if (_effectScaleWidget) { _effectScaleWidget.destroy(); _effectScaleWidget = null; }
if (_apiInputTimeoutWidget) { _apiInputTimeoutWidget.destroy(); _apiInputTimeoutWidget = null; } if (_apiInputTimeoutWidget) { _apiInputTimeoutWidget.destroy(); _apiInputTimeoutWidget = null; }
@@ -122,6 +123,7 @@ class CSSEditorModal extends Modal {
audio_source: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value, audio_source: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value,
audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0', audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0',
audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3', audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3',
audio_beat_decay: _audioBeatDecayWidget ? JSON.stringify(_audioBeatDecayWidget.getValue()) : '0.15',
audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
audio_color: _audioColorWidget ? JSON.stringify(_audioColorWidget.getValue()) : '[]', audio_color: _audioColorWidget ? JSON.stringify(_audioColorWidget.getValue()) : '[]',
audio_color_peak: _audioColorPeakWidget ? JSON.stringify(_audioColorPeakWidget.getValue()) : '[]', audio_color_peak: _audioColorPeakWidget ? JSON.stringify(_audioColorPeakWidget.getValue()) : '[]',
@@ -166,6 +168,7 @@ let _cssTagsInput: any = null;
let _smoothingWidget: BindableScalarWidget | null = null; let _smoothingWidget: BindableScalarWidget | null = null;
let _audioSensitivityWidget: BindableScalarWidget | null = null; let _audioSensitivityWidget: BindableScalarWidget | null = null;
let _audioSmoothingWidget: BindableScalarWidget | null = null; let _audioSmoothingWidget: BindableScalarWidget | null = null;
let _audioBeatDecayWidget: BindableScalarWidget | null = null;
let _effectIntensityWidget: BindableScalarWidget | null = null; let _effectIntensityWidget: BindableScalarWidget | null = null;
let _effectScaleWidget: BindableScalarWidget | null = null; let _effectScaleWidget: BindableScalarWidget | null = null;
let _apiInputTimeoutWidget: BindableScalarWidget | null = null; let _apiInputTimeoutWidget: BindableScalarWidget | null = null;
@@ -881,6 +884,19 @@ function _ensureAudioSmoothingWidget(): BindableScalarWidget {
return _audioSmoothingWidget; return _audioSmoothingWidget;
} }
function _ensureAudioBeatDecayWidget(): BindableScalarWidget {
if (!_audioBeatDecayWidget) {
_audioBeatDecayWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-audio-beat-decay-container')!,
min: 0.01, max: 0.5, step: 0.01, default: 0.15,
idPrefix: 'css-editor-audio-beat-decay',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(2),
});
}
return _audioBeatDecayWidget;
}
function _ensureEffectIntensityWidget(): BindableScalarWidget { function _ensureEffectIntensityWidget(): BindableScalarWidget {
if (!_effectIntensityWidget) { if (!_effectIntensityWidget) {
_effectIntensityWidget = new BindableScalarWidget({ _effectIntensityWidget = new BindableScalarWidget({
@@ -1267,12 +1283,16 @@ function _ensureAudioVizIconSelect() {
const sel = document.getElementById('css-editor-audio-viz') as HTMLSelectElement | null; const sel = document.getElementById('css-editor-audio-viz') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
const items = [ const items = [
{ value: 'spectrum', icon: _icon(P.activity), label: t('color_strip.audio.viz.spectrum'), desc: t('color_strip.audio.viz.spectrum.desc') }, { value: 'spectrum', icon: _icon(P.activity), label: t('color_strip.audio.viz.spectrum'), desc: t('color_strip.audio.viz.spectrum.desc') },
{ value: 'beat_pulse', icon: _icon(P.zap), label: t('color_strip.audio.viz.beat_pulse'), desc: t('color_strip.audio.viz.beat_pulse.desc') }, { value: 'beat_pulse', icon: _icon(P.zap), label: t('color_strip.audio.viz.beat_pulse'), desc: t('color_strip.audio.viz.beat_pulse.desc') },
{ value: 'vu_meter', icon: _icon(P.trendingUp), label: t('color_strip.audio.viz.vu_meter'), desc: t('color_strip.audio.viz.vu_meter.desc') }, { value: 'vu_meter', icon: _icon(P.trendingUp), label: t('color_strip.audio.viz.vu_meter'), desc: t('color_strip.audio.viz.vu_meter.desc') },
{ value: 'pulse_on_beat', icon: _icon(P.heart), label: t('color_strip.audio.viz.pulse_on_beat'), desc: t('color_strip.audio.viz.pulse_on_beat.desc') },
{ value: 'energy_gradient', icon: _icon(P.flame), label: t('color_strip.audio.viz.energy_gradient'), desc: t('color_strip.audio.viz.energy_gradient.desc') },
{ value: 'spectrum_bands', icon: _icon(P.radio), label: t('color_strip.audio.viz.spectrum_bands'), desc: t('color_strip.audio.viz.spectrum_bands.desc') },
{ value: 'strobe_on_drop', icon: _icon(P.sparkles), label: t('color_strip.audio.viz.strobe_on_drop'), desc: t('color_strip.audio.viz.strobe_on_drop.desc') },
]; ];
if (_audioVizIconSelect) { _audioVizIconSelect.updateItems(items); return; } if (_audioVizIconSelect) { _audioVizIconSelect.updateItems(items); return; }
_audioVizIconSelect = new IconSelect({ target: sel, items, columns: 3 }); _audioVizIconSelect = new IconSelect({ target: sel, items, columns: 4 });
} }
function _buildGradientEntityItems() { function _buildGradientEntityItems() {
@@ -1660,14 +1680,18 @@ function _resetMappedState() {
export function onAudioVizChange() { export function onAudioVizChange() {
const viz = (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value; const viz = (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value;
// Palette: spectrum / beat_pulse const MUSIC_MODES = new Set(['pulse_on_beat', 'energy_gradient', 'spectrum_bands', 'strobe_on_drop']);
// Palette: spectrum / beat_pulse / energy_gradient / spectrum_bands
(document.getElementById('css-editor-audio-palette-group') as HTMLElement).style.display = (document.getElementById('css-editor-audio-palette-group') as HTMLElement).style.display =
(viz === 'spectrum' || viz === 'beat_pulse') ? '' : 'none'; (viz === 'spectrum' || viz === 'beat_pulse' || viz === 'energy_gradient' || viz === 'spectrum_bands') ? '' : 'none';
// Base color + Peak color: vu_meter only // Base color + Peak color: vu_meter only
(document.getElementById('css-editor-audio-color-group') as HTMLElement).style.display = viz === 'vu_meter' ? '' : 'none'; (document.getElementById('css-editor-audio-color-group') as HTMLElement).style.display = viz === 'vu_meter' ? '' : 'none';
(document.getElementById('css-editor-audio-color-peak-group') as HTMLElement).style.display = viz === 'vu_meter' ? '' : 'none'; (document.getElementById('css-editor-audio-color-peak-group') as HTMLElement).style.display = viz === 'vu_meter' ? '' : 'none';
// Mirror: spectrum only // Mirror: spectrum only
(document.getElementById('css-editor-audio-mirror-group') as HTMLElement).style.display = viz === 'spectrum' ? '' : 'none'; (document.getElementById('css-editor-audio-mirror-group') as HTMLElement).style.display = viz === 'spectrum' ? '' : 'none';
// Beat decay: new music modes only
(document.getElementById('css-editor-audio-beat-decay-group') as HTMLElement).style.display = MUSIC_MODES.has(viz) ? '' : 'none';
if (MUSIC_MODES.has(viz)) _ensureAudioBeatDecayWidget();
_autoGenerateCSSName(); _autoGenerateCSSName();
} }
@@ -1709,6 +1733,7 @@ function _loadAudioState(css: any) {
_ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0); _ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0);
_ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3); _ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3);
_ensureAudioBeatDecayWidget().setValue(css.beat_decay ?? 0.15);
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow'; const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId; (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
@@ -1729,6 +1754,7 @@ function _resetAudioState() {
if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum'); if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
_ensureAudioSensitivityWidget().setValue(1.0); _ensureAudioSensitivityWidget().setValue(1.0);
_ensureAudioSmoothingWidget().setValue(0.3); _ensureAudioSmoothingWidget().setValue(0.3);
_ensureAudioBeatDecayWidget().setValue(0.15);
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow'; (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue('gr_builtin_rainbow'); if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue('gr_builtin_rainbow');
_ensureAudioColorWidget().setValue([0, 255, 0]); _ensureAudioColorWidget().setValue([0, 255, 0]);
@@ -1819,7 +1845,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
audio: (source, { audioSourceMap }) => { audio: (source, { audioSourceMap }) => {
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum'; const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
const vizMode = source.visualization_mode || 'spectrum'; const vizMode = source.visualization_mode || 'spectrum';
const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette; const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse' || vizMode === 'energy_gradient' || vizMode === 'spectrum_bands') && source.palette;
const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : ''; const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
return ` return `
<span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span> <span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span>
@@ -2187,6 +2213,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null, audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
sensitivity: _ensureAudioSensitivityWidget().getValue(), sensitivity: _ensureAudioSensitivityWidget().getValue(),
smoothing: _ensureAudioSmoothingWidget().getValue(), smoothing: _ensureAudioSmoothingWidget().getValue(),
beat_decay: _ensureAudioBeatDecayWidget().getValue(),
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
color: _ensureAudioColorWidget().getValue(), color: _ensureAudioColorWidget().getValue(),
color_peak: _ensureAudioColorPeakWidget().getValue(), color_peak: _ensureAudioColorPeakWidget().getValue(),
@@ -562,6 +562,7 @@
"filters.inverter.desc": "Invert all levels (1 minus value)", "filters.inverter.desc": "Invert all levels (1 minus value)",
"filters.beat_gate.desc": "Pass signal only around detected beats", "filters.beat_gate.desc": "Pass signal only around detected beats",
"filters.delay.desc": "Time-shift the audio analysis by a delay", "filters.delay.desc": "Time-shift the audio analysis by a delay",
"filters.auto_gain.desc": "Auto-normalize audio levels to use full range",
"postprocessing.description_label": "Description (optional):", "postprocessing.description_label": "Description (optional):",
"postprocessing.description_placeholder": "Describe this template...", "postprocessing.description_placeholder": "Describe this template...",
"postprocessing.created": "Template created successfully", "postprocessing.created": "Template created successfully",
@@ -1264,6 +1265,16 @@
"color_strip.audio.viz.beat_pulse.desc": "All LEDs pulse on the beat", "color_strip.audio.viz.beat_pulse.desc": "All LEDs pulse on the beat",
"color_strip.audio.viz.vu_meter": "VU Meter", "color_strip.audio.viz.vu_meter": "VU Meter",
"color_strip.audio.viz.vu_meter.desc": "Volume level fills the strip", "color_strip.audio.viz.vu_meter.desc": "Volume level fills the strip",
"color_strip.audio.viz.pulse_on_beat": "Pulse on Beat",
"color_strip.audio.viz.pulse_on_beat.desc": "LEDs pulse with each detected beat",
"color_strip.audio.viz.energy_gradient": "Energy Gradient",
"color_strip.audio.viz.energy_gradient.desc": "Gradient intensity follows audio energy",
"color_strip.audio.viz.spectrum_bands": "Spectrum Bands",
"color_strip.audio.viz.spectrum_bands.desc": "Grouped frequency bands across the strip",
"color_strip.audio.viz.strobe_on_drop": "Strobe on Drop",
"color_strip.audio.viz.strobe_on_drop.desc": "Flash strobe effect on bass drops",
"color_strip.audio.beat_decay": "Beat Decay:",
"color_strip.audio.beat_decay.hint": "How quickly the beat pulse fades. Lower values = longer fade, higher = snappier response.",
"color_strip.audio.source": "Audio Source:", "color_strip.audio.source": "Audio Source:",
"color_strip.audio.source.hint": "Audio source for this visualization. Can be a multichannel (device) or mono (single channel) source. Create and manage audio sources in the Sources tab.", "color_strip.audio.source.hint": "Audio source for this visualization. Can be a multichannel (device) or mono (single channel) source. Create and manage audio sources in the Sources tab.",
"color_strip.audio.sensitivity": "Sensitivity:", "color_strip.audio.sensitivity": "Sensitivity:",
@@ -1194,6 +1194,16 @@
"color_strip.audio.viz.beat_pulse.desc": "Все LED пульсируют в такт", "color_strip.audio.viz.beat_pulse.desc": "Все LED пульсируют в такт",
"color_strip.audio.viz.vu_meter": "VU-метр", "color_strip.audio.viz.vu_meter": "VU-метр",
"color_strip.audio.viz.vu_meter.desc": "Уровень громкости заполняет ленту", "color_strip.audio.viz.vu_meter.desc": "Уровень громкости заполняет ленту",
"color_strip.audio.viz.pulse_on_beat": "Пульс на бит",
"color_strip.audio.viz.pulse_on_beat.desc": "LED пульсируют при каждом ударе",
"color_strip.audio.viz.energy_gradient": "Энергетический градиент",
"color_strip.audio.viz.energy_gradient.desc": "Интенсивность градиента следует за энергией звука",
"color_strip.audio.viz.spectrum_bands": "Полосы спектра",
"color_strip.audio.viz.spectrum_bands.desc": "Группы частот по ленте",
"color_strip.audio.viz.strobe_on_drop": "Стробоскоп на дропе",
"color_strip.audio.viz.strobe_on_drop.desc": "Вспышка стробоскопа на басовых дропах",
"color_strip.audio.beat_decay": "Затухание бита:",
"color_strip.audio.beat_decay.hint": "Скорость затухания пульса. Меньшие значения = более долгое затухание, большие = более резкая реакция.",
"color_strip.audio.source": "Аудиоисточник:", "color_strip.audio.source": "Аудиоисточник:",
"color_strip.audio.source.hint": "Аудиоисточник для визуализации. Может быть многоканальным (устройство) или моно (один канал). Создавайте и управляйте аудиоисточниками на вкладке Источники.", "color_strip.audio.source.hint": "Аудиоисточник для визуализации. Может быть многоканальным (устройство) или моно (один канал). Создавайте и управляйте аудиоисточниками на вкладке Источники.",
"color_strip.audio.sensitivity": "Чувствительность:", "color_strip.audio.sensitivity": "Чувствительность:",
@@ -1194,6 +1194,16 @@
"color_strip.audio.viz.beat_pulse.desc": "所有LED随节拍脉动", "color_strip.audio.viz.beat_pulse.desc": "所有LED随节拍脉动",
"color_strip.audio.viz.vu_meter": "VU 表", "color_strip.audio.viz.vu_meter": "VU 表",
"color_strip.audio.viz.vu_meter.desc": "音量填充灯带", "color_strip.audio.viz.vu_meter.desc": "音量填充灯带",
"color_strip.audio.viz.pulse_on_beat": "节拍脉动",
"color_strip.audio.viz.pulse_on_beat.desc": "LED随每个检测到的节拍脉动",
"color_strip.audio.viz.energy_gradient": "能量渐变",
"color_strip.audio.viz.energy_gradient.desc": "渐变强度跟随音频能量",
"color_strip.audio.viz.spectrum_bands": "频谱频段",
"color_strip.audio.viz.spectrum_bands.desc": "分组频段分布在灯带上",
"color_strip.audio.viz.strobe_on_drop": "低音闪烁",
"color_strip.audio.viz.strobe_on_drop.desc": "低音下降时闪烁频闪效果",
"color_strip.audio.beat_decay": "节拍衰减:",
"color_strip.audio.beat_decay.hint": "节拍脉冲消退的速度。较低值 = 较长衰减,较高值 = 更灵敏的响应。",
"color_strip.audio.source": "音频源:", "color_strip.audio.source": "音频源:",
"color_strip.audio.source.hint": "此可视化的音频源。可以是多声道(设备)或单声道(单通道)源。在源标签页中创建和管理音频源。", "color_strip.audio.source.hint": "此可视化的音频源。可以是多声道(设备)或单声道(单通道)源。在源标签页中创建和管理音频源。",
"color_strip.audio.sensitivity": "灵敏度:", "color_strip.audio.sensitivity": "灵敏度:",
@@ -712,7 +712,8 @@ class AudioColorStripSource(ColorStripSource):
LED count auto-sizes from the connected device when led_count == 0. LED count auto-sizes from the connected device when led_count == 0.
""" """
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter # spectrum | beat_pulse | vu_meter | pulse_on_beat | energy_gradient | spectrum_bands | strobe_on_drop
visualization_mode: str = "spectrum"
audio_source_id: str = "" # references an AudioSource (capture or processed) audio_source_id: str = "" # references an AudioSource (capture or processed)
sensitivity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) sensitivity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3)) smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
@@ -722,6 +723,7 @@ class AudioColorStripSource(ColorStripSource):
color_peak: BindableColor = field(default_factory=lambda: BindableColor([255, 0, 0])) color_peak: BindableColor = field(default_factory=lambda: BindableColor([255, 0, 0]))
led_count: int = 0 # 0 = use device LED count led_count: int = 0 # 0 = use device LED count
mirror: bool = False # mirror spectrum from center outward mirror: bool = False # mirror spectrum from center outward
beat_decay: BindableFloat = field(default_factory=lambda: BindableFloat(0.15))
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
@@ -735,6 +737,7 @@ class AudioColorStripSource(ColorStripSource):
d["color_peak"] = self.color_peak.to_dict() d["color_peak"] = self.color_peak.to_dict()
d["led_count"] = self.led_count d["led_count"] = self.led_count
d["mirror"] = self.mirror d["mirror"] = self.mirror
d["beat_decay"] = self.beat_decay.to_dict()
return d return d
@classmethod @classmethod
@@ -753,6 +756,7 @@ class AudioColorStripSource(ColorStripSource):
color_peak=BindableColor.from_raw(data.get("color_peak"), default=[255, 0, 0]), color_peak=BindableColor.from_raw(data.get("color_peak"), default=[255, 0, 0]),
led_count=data.get("led_count") or 0, led_count=data.get("led_count") or 0,
mirror=bool(data.get("mirror", False)), mirror=bool(data.get("mirror", False)),
beat_decay=BindableFloat.from_raw(data.get("beat_decay"), default=0.15),
) )
@classmethod @classmethod
@@ -777,6 +781,7 @@ class AudioColorStripSource(ColorStripSource):
color_peak=None, color_peak=None,
led_count=0, led_count=0,
mirror=False, mirror=False,
beat_decay=None,
**_kwargs, **_kwargs,
): ):
return cls( return cls(
@@ -798,6 +803,7 @@ class AudioColorStripSource(ColorStripSource):
color_peak=BindableColor.from_raw(color_peak, default=[255, 0, 0]), color_peak=BindableColor.from_raw(color_peak, default=[255, 0, 0]),
led_count=led_count, led_count=led_count,
mirror=bool(mirror), mirror=bool(mirror),
beat_decay=BindableFloat.from_raw(beat_decay, default=0.15),
) )
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
@@ -824,6 +830,8 @@ class AudioColorStripSource(ColorStripSource):
self.led_count = kwargs["led_count"] self.led_count = kwargs["led_count"]
if kwargs.get("mirror") is not None: if kwargs.get("mirror") is not None:
self.mirror = bool(kwargs["mirror"]) self.mirror = bool(kwargs["mirror"])
if kwargs.get("beat_decay") is not None:
self.beat_decay = self.beat_decay.apply_update(kwargs["beat_decay"])
@dataclass @dataclass
@@ -248,6 +248,10 @@
<option value="spectrum" data-i18n="color_strip.audio.viz.spectrum">Spectrum Analyzer</option> <option value="spectrum" data-i18n="color_strip.audio.viz.spectrum">Spectrum Analyzer</option>
<option value="beat_pulse" data-i18n="color_strip.audio.viz.beat_pulse">Beat Pulse</option> <option value="beat_pulse" data-i18n="color_strip.audio.viz.beat_pulse">Beat Pulse</option>
<option value="vu_meter" data-i18n="color_strip.audio.viz.vu_meter">VU Meter</option> <option value="vu_meter" data-i18n="color_strip.audio.viz.vu_meter">VU Meter</option>
<option value="pulse_on_beat" data-i18n="color_strip.audio.viz.pulse_on_beat">Pulse on Beat</option>
<option value="energy_gradient" data-i18n="color_strip.audio.viz.energy_gradient">Energy Gradient</option>
<option value="spectrum_bands" data-i18n="color_strip.audio.viz.spectrum_bands">Spectrum Bands</option>
<option value="strobe_on_drop" data-i18n="color_strip.audio.viz.strobe_on_drop">Strobe on Drop</option>
</select> </select>
</div> </div>
@@ -320,6 +324,17 @@
<div id="css-editor-audio-color-peak-container"></div> <div id="css-editor-audio-color-peak-container"></div>
</div> </div>
<div id="css-editor-audio-beat-decay-group" class="form-group" style="display:none">
<div class="label-row">
<label>
<span data-i18n="color_strip.audio.beat_decay">Beat Decay:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.beat_decay.hint">How quickly the beat pulse fades. Lower values = longer fade, higher = snappier response.</small>
<div id="css-editor-audio-beat-decay-container"></div>
</div>
<div id="css-editor-audio-mirror-group" class="form-group" style="display:none"> <div id="css-editor-audio-mirror-group" class="form-group" style="display:none">
<div class="label-row"> <div class="label-row">
<label for="css-editor-audio-mirror" data-i18n="color_strip.audio.mirror">Mirror:</label> <label for="css-editor-audio-mirror" data-i18n="color_strip.audio.mirror">Mirror:</label>
@@ -134,17 +134,8 @@
</select> </select>
</div> </div>
<div class="form-group"> <!-- Auto-gain removed: use the auto_gain audio filter instead -->
<div class="label-row"> <input type="hidden" id="value-source-auto-gain">
<label for="value-source-auto-gain" data-i18n="value_source.auto_gain">Auto Gain:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.auto_gain.hint">Automatically normalize audio levels so output uses the full range, regardless of input volume</small>
<label class="toggle-label">
<input type="checkbox" id="value-source-auto-gain">
<span data-i18n="value_source.auto_gain.enable">Enable auto-gain</span>
</label>
</div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">