From b04978af581c2fadae8412e4a4174d53aee1312e Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 5 Apr 2026 01:40:34 +0300 Subject: [PATCH] feat: add music sync viz modes and auto_gain audio filter 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). --- .../api/routes/color_strip_sources.py | 1 + .../api/schemas/color_strip_sources.py | 5 + .../core/audio/filters/__init__.py | 1 + .../core/audio/filters/auto_gain.py | 84 +++++++ .../core/audio/music_analyzer.py | 223 ++++++++++++++++++ .../core/processing/audio_stream.py | 195 ++++++++++++++- .../core/processing/value_stream.py | 16 +- .../static/js/features/color-strips.ts | 41 +++- .../wled_controller/static/locales/en.json | 11 + .../wled_controller/static/locales/ru.json | 10 + .../wled_controller/static/locales/zh.json | 10 + .../storage/color_strip_source.py | 10 +- .../templates/modals/css-editor.html | 15 ++ .../templates/modals/value-source-editor.html | 13 +- 14 files changed, 598 insertions(+), 37 deletions(-) create mode 100644 server/src/wled_controller/core/audio/filters/auto_gain.py create mode 100644 server/src/wled_controller/core/audio/music_analyzer.py diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index aad7c62..c0940d2 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -191,6 +191,7 @@ _RESPONSE_MAP: dict = { color=s.color.to_dict(), color_peak=s.color_peak.to_dict(), mirror=s.mirror, + beat_decay=s.beat_decay.to_dict(), ), ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse( **kw, diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 023a4d1..0758232 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -164,6 +164,7 @@ class AudioCSSResponse(_CSSResponseBase): color: Any = Field(description="Primary color") color_peak: Any = Field(description="Peak color") mirror: bool = Field(description="Mirror mode") + beat_decay: Any = Field(default=0.15, description="Beat pulse decay rate (music modes)") class ApiInputCSSResponse(_CSSResponseBase): @@ -340,6 +341,9 @@ class AudioCSSCreate(_CSSCreateBase): color: Any = Field(default=None, description="Primary color") color_peak: Any = Field(default=None, description="Peak color [R,G,B]") 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): @@ -527,6 +531,7 @@ class AudioCSSUpdate(_CSSUpdateBase): color: Any = Field(default=None, description="Primary color") color_peak: Any = Field(default=None, description="Peak color [R,G,B]") mirror: Optional[bool] = Field(None, description="Mirror mode") + beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)") class ApiInputCSSUpdate(_CSSUpdateBase): diff --git a/server/src/wled_controller/core/audio/filters/__init__.py b/server/src/wled_controller/core/audio/filters/__init__.py index ca28a6e..99cb609 100644 --- a/server/src/wled_controller/core/audio/filters/__init__.py +++ b/server/src/wled_controller/core/audio/filters/__init__.py @@ -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.beat_gate # noqa: F401 import wled_controller.core.audio.filters.delay # noqa: F401 +import wled_controller.core.audio.filters.auto_gain # noqa: F401 __all__ = [ "AudioFilter", diff --git a/server/src/wled_controller/core/audio/filters/auto_gain.py b/server/src/wled_controller/core/audio/filters/auto_gain.py new file mode 100644 index 0000000..f240e65 --- /dev/null +++ b/server/src/wled_controller/core/audio/filters/auto_gain.py @@ -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), + ) diff --git a/server/src/wled_controller/core/audio/music_analyzer.py b/server/src/wled_controller/core/audio/music_analyzer.py new file mode 100644 index 0000000..9fbf896 --- /dev/null +++ b/server/src/wled_controller/core/audio/music_analyzer.py @@ -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 diff --git a/server/src/wled_controller/core/processing/audio_stream.py b/server/src/wled_controller/core/processing/audio_stream.py index 50299b4..3a8c9f1 100644 --- a/server/src/wled_controller/core/processing/audio_stream.py +++ b/server/src/wled_controller/core/processing/audio_stream.py @@ -3,12 +3,17 @@ Implements AudioColorStripStream which produces LED color arrays driven by real-time audio analysis (spectrum, beat detection, RMS levels). -Three visualization modes: - spectrum — FFT frequency bars mapped across LEDs with palette coloring - beat_pulse — full-strip flash on beat detection with exponential decay - vu_meter — volume level fills LEDs like a progress bar +Seven visualization modes: + spectrum — FFT frequency bars mapped across LEDs with palette coloring + beat_pulse — full-strip flash on beat detection with exponential decay + 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 time 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.audio_capture import AudioCaptureManager 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.effect_stream import _build_palette_lut from wled_controller.utils import get_logger @@ -68,6 +74,13 @@ class AudioColorStripStream(ColorStripStream): self._prev_spectrum: Optional[np.ndarray] = None 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._update_from_source(source) @@ -106,6 +119,17 @@ class AudioColorStripStream(ColorStripStream): 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._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 audio_source_id = getattr(source, "audio_source_id", "") @@ -287,6 +311,10 @@ class AudioColorStripStream(ColorStripStream): "spectrum": self._render_spectrum, "beat_pulse": self._render_beat_pulse, "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: @@ -331,6 +359,10 @@ class AudioColorStripStream(ColorStripStream): if analysis is not None and self._filter_pipeline is not None: 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) t_render = time.perf_counter() render_fn(buf, n, analysis) @@ -477,3 +509,158 @@ class AudioColorStripStream(ColorStripStream): buf[:, 0] = r buf[:, 1] = g 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) diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py index 05ee64d..a757158 100644 --- a/server/src/wled_controller/core/processing/value_stream.py +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -186,9 +186,9 @@ class AudioValueStream(ValueStream): self._smoothing = smoothing self._min = min_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._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_source_store = audio_source_store self._audio_template_store = audio_template_store @@ -288,12 +288,6 @@ class AudioValueStream(ValueStream): 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) # Temporal smoothing @@ -340,18 +334,12 @@ class AudioValueStream(ValueStream): return old_source_id = self._audio_source_id - old_auto_gain = self._auto_gain self._audio_source_id = source.audio_source_id self._mode = source.mode self._sensitivity = source.sensitivity self._smoothing = source.smoothing self._min = source.min_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 source.audio_source_id != old_source_id: diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index 3d96d9d..cc19941 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -68,6 +68,7 @@ class CSSEditorModal extends Modal { if (_smoothingWidget) { _smoothingWidget.destroy(); _smoothingWidget = null; } if (_audioSensitivityWidget) { _audioSensitivityWidget.destroy(); _audioSensitivityWidget = null; } if (_audioSmoothingWidget) { _audioSmoothingWidget.destroy(); _audioSmoothingWidget = null; } + if (_audioBeatDecayWidget) { _audioBeatDecayWidget.destroy(); _audioBeatDecayWidget = null; } if (_effectIntensityWidget) { _effectIntensityWidget.destroy(); _effectIntensityWidget = null; } if (_effectScaleWidget) { _effectScaleWidget.destroy(); _effectScaleWidget = 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_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0', 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_color: _audioColorWidget ? JSON.stringify(_audioColorWidget.getValue()) : '[]', audio_color_peak: _audioColorPeakWidget ? JSON.stringify(_audioColorPeakWidget.getValue()) : '[]', @@ -166,6 +168,7 @@ let _cssTagsInput: any = null; let _smoothingWidget: BindableScalarWidget | null = null; let _audioSensitivityWidget: BindableScalarWidget | null = null; let _audioSmoothingWidget: BindableScalarWidget | null = null; +let _audioBeatDecayWidget: BindableScalarWidget | null = null; let _effectIntensityWidget: BindableScalarWidget | null = null; let _effectScaleWidget: BindableScalarWidget | null = null; let _apiInputTimeoutWidget: BindableScalarWidget | null = null; @@ -881,6 +884,19 @@ function _ensureAudioSmoothingWidget(): BindableScalarWidget { 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 { if (!_effectIntensityWidget) { _effectIntensityWidget = new BindableScalarWidget({ @@ -1267,12 +1283,16 @@ function _ensureAudioVizIconSelect() { const sel = document.getElementById('css-editor-audio-viz') as HTMLSelectElement | null; if (!sel) return; const items = [ - { 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: 'vu_meter', icon: _icon(P.trendingUp), label: t('color_strip.audio.viz.vu_meter'), desc: t('color_strip.audio.viz.vu_meter.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: '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; } - _audioVizIconSelect = new IconSelect({ target: sel, items, columns: 3 }); + _audioVizIconSelect = new IconSelect({ target: sel, items, columns: 4 }); } function _buildGradientEntityItems() { @@ -1660,14 +1680,18 @@ function _resetMappedState() { export function onAudioVizChange() { 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 = - (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 (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'; // Mirror: spectrum only (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(); } @@ -1709,6 +1733,7 @@ function _loadAudioState(css: any) { _ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0); _ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3); + _ensureAudioBeatDecayWidget().setValue(css.beat_decay ?? 0.15); const audioGradientId = css.gradient_id || 'gr_builtin_rainbow'; (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId; @@ -1729,6 +1754,7 @@ function _resetAudioState() { if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum'); _ensureAudioSensitivityWidget().setValue(1.0); _ensureAudioSmoothingWidget().setValue(0.3); + _ensureAudioBeatDecayWidget().setValue(0.15); (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow'; if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue('gr_builtin_rainbow'); _ensureAudioColorWidget().setValue([0, 255, 0]); @@ -1819,7 +1845,7 @@ const CSS_CARD_RENDERERS: Record = { audio: (source, { audioSourceMap }) => { const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || 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) : ''; return ` ${ICON_MUSIC} ${escapeHtml(vizLabel)} @@ -2187,6 +2213,7 @@ const _typeHandlers: Record any; reset: (... audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null, sensitivity: _ensureAudioSensitivityWidget().getValue(), smoothing: _ensureAudioSmoothingWidget().getValue(), + beat_decay: _ensureAudioBeatDecayWidget().getValue(), gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, color: _ensureAudioColorWidget().getValue(), color_peak: _ensureAudioColorPeakWidget().getValue(), diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index df47b2e..ac7c205 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -562,6 +562,7 @@ "filters.inverter.desc": "Invert all levels (1 minus value)", "filters.beat_gate.desc": "Pass signal only around detected beats", "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_placeholder": "Describe this template...", "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.vu_meter": "VU Meter", "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.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:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 9258374..79ae1ad 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1194,6 +1194,16 @@ "color_strip.audio.viz.beat_pulse.desc": "Все LED пульсируют в такт", "color_strip.audio.viz.vu_meter": "VU-метр", "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.hint": "Аудиоисточник для визуализации. Может быть многоканальным (устройство) или моно (один канал). Создавайте и управляйте аудиоисточниками на вкладке Источники.", "color_strip.audio.sensitivity": "Чувствительность:", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 9433696..ca16cb5 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1194,6 +1194,16 @@ "color_strip.audio.viz.beat_pulse.desc": "所有LED随节拍脉动", "color_strip.audio.viz.vu_meter": "VU 表", "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.hint": "此可视化的音频源。可以是多声道(设备)或单声道(单通道)源。在源标签页中创建和管理音频源。", "color_strip.audio.sensitivity": "灵敏度:", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 1f63016..caf505a 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -712,7 +712,8 @@ class AudioColorStripSource(ColorStripSource): 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) sensitivity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) 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])) led_count: int = 0 # 0 = use device LED count mirror: bool = False # mirror spectrum from center outward + beat_decay: BindableFloat = field(default_factory=lambda: BindableFloat(0.15)) def to_dict(self) -> dict: d = super().to_dict() @@ -735,6 +737,7 @@ class AudioColorStripSource(ColorStripSource): d["color_peak"] = self.color_peak.to_dict() d["led_count"] = self.led_count d["mirror"] = self.mirror + d["beat_decay"] = self.beat_decay.to_dict() return d @classmethod @@ -753,6 +756,7 @@ class AudioColorStripSource(ColorStripSource): color_peak=BindableColor.from_raw(data.get("color_peak"), default=[255, 0, 0]), led_count=data.get("led_count") or 0, mirror=bool(data.get("mirror", False)), + beat_decay=BindableFloat.from_raw(data.get("beat_decay"), default=0.15), ) @classmethod @@ -777,6 +781,7 @@ class AudioColorStripSource(ColorStripSource): color_peak=None, led_count=0, mirror=False, + beat_decay=None, **_kwargs, ): return cls( @@ -798,6 +803,7 @@ class AudioColorStripSource(ColorStripSource): color_peak=BindableColor.from_raw(color_peak, default=[255, 0, 0]), led_count=led_count, mirror=bool(mirror), + beat_decay=BindableFloat.from_raw(beat_decay, default=0.15), ) def apply_update(self, **kwargs) -> None: @@ -824,6 +830,8 @@ class AudioColorStripSource(ColorStripSource): self.led_count = kwargs["led_count"] if kwargs.get("mirror") is not None: 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 diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 8c73c91..c1ff983 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -248,6 +248,10 @@ + + + + @@ -320,6 +324,17 @@
+ +