feat: add music sync viz modes and auto_gain audio filter
Lint & Test / test (push) Has been cancelled
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:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user