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_peak=s.color_peak.to_dict(),
|
||||
mirror=s.mirror,
|
||||
beat_decay=s.beat_decay.to_dict(),
|
||||
),
|
||||
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
|
||||
**kw,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, CardPropsRenderer> = {
|
||||
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 `
|
||||
<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,
|
||||
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(),
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Чувствительность:",
|
||||
|
||||
@@ -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": "灵敏度:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
@@ -320,6 +324,17 @@
|
||||
<div id="css-editor-audio-color-peak-container"></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 class="label-row">
|
||||
<label for="css-editor-audio-mirror" data-i18n="color_strip.audio.mirror">Mirror:</label>
|
||||
|
||||
@@ -134,17 +134,8 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<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>
|
||||
<!-- Auto-gain removed: use the auto_gain audio filter instead -->
|
||||
<input type="hidden" id="value-source-auto-gain">
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
Reference in New Issue
Block a user