From ace24715c86ead305f3b0a889846b92d8331596b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 5 Apr 2026 00:41:07 +0300 Subject: [PATCH] feat: add math_wave color strip source type Mathematical wave generator that produces per-LED colors from configurable waveform layers (sine, triangle, sawtooth, square) with superposition, mapped through a gradient palette. Supports sync clocks, bindable speed, and up to 8 wave layers. - Storage model with wave validation and apply_update - Numpy-vectorized stream with gradient LUT color mapping - API schemas (create/update/response) and route registration - Frontend editor with dynamic wave layer rows, gradient picker, speed widget, and IconSelect waveform selectors - i18n in en/ru/zh --- contexts/frontend.md | 2 + plans/math-wave/PLAN.md | 109 ++++++++ plans/music-sync/PLAN.md | 151 ++++++++++ .../api/routes/color_strip_sources.py | 8 + .../api/schemas/color_strip_sources.py | 24 ++ .../processing/color_strip_stream_manager.py | 2 + .../core/processing/math_wave_stream.py | 261 ++++++++++++++++++ server/src/wled_controller/static/js/app.ts | 2 + .../wled_controller/static/js/core/icons.ts | 1 + .../static/js/features/color-strips.ts | 187 ++++++++++++- server/src/wled_controller/static/js/types.ts | 6 +- .../wled_controller/static/locales/en.json | 20 ++ .../wled_controller/static/locales/ru.json | 20 ++ .../wled_controller/static/locales/zh.json | 20 ++ .../storage/color_strip_source.py | 136 +++++++++ .../templates/modals/css-editor.html | 33 +++ 16 files changed, 977 insertions(+), 5 deletions(-) create mode 100644 plans/math-wave/PLAN.md create mode 100644 plans/music-sync/PLAN.md create mode 100644 server/src/wled_controller/core/processing/math_wave_stream.py diff --git a/contexts/frontend.md b/contexts/frontend.md index caeb6d2..4135f39 100644 --- a/contexts/frontend.md +++ b/contexts/frontend.md @@ -90,6 +90,8 @@ Plain `` but keep it in the DOM with its value in sync. **The `` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes. +**CRITICAL pitfall — ` + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + container.appendChild(row); + + // Set waveform value and attach IconSelect + const wfSelect = row.querySelector('.mw-waveform') as HTMLSelectElement; + wfSelect.value = wave.waveform || 'sine'; + const iconSel = new IconSelect({ + target: wfSelect, + items: _buildMathWaveWaveformItems(), + columns: 2, + }); + _mathWaveWaveformIconSelects.push(iconSel); + }); +} + +function _mathWaveGetLayers(): Array<{ waveform: string; frequency: number; amplitude: number; phase: number; offset: number }> { + const container = document.getElementById('css-editor-math-wave-layers'); + if (!container) return []; + const rows = container.querySelectorAll('.math-wave-layer-row'); + return Array.from(rows).map(row => ({ + waveform: (row.querySelector('.mw-waveform') as HTMLSelectElement).value, + frequency: parseFloat((row.querySelector('.mw-frequency') as HTMLInputElement).value) || 1.0, + amplitude: parseFloat((row.querySelector('.mw-amplitude') as HTMLInputElement).value) || 1.0, + phase: parseFloat((row.querySelector('.mw-phase') as HTMLInputElement).value) || 0.0, + offset: parseFloat((row.querySelector('.mw-offset') as HTMLInputElement).value) || 0.0, + })); +} + +export function mathWaveAddLayer() { + const current = _mathWaveGetLayers(); + current.push({ waveform: 'sine', frequency: 1.0, amplitude: 1.0, phase: 0.0, offset: 0.0 }); + _mathWaveRenderLayers(current); +} + +export function mathWaveRemoveLayer(idx: number) { + const current = _mathWaveGetLayers(); + current.splice(idx, 1); + _mathWaveRenderLayers(current); +} + function _ensureAudioPaletteEntitySelect() { const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null; if (!sel) return; @@ -1177,13 +1309,14 @@ function _ensureGradientPresetEntitySelect() { /** Rebuild the gradient picker after entity changes. */ export function refreshGradientPresetPicker() { // Re-sync select options before refreshing entity selects - for (const selId of ['css-editor-gradient-preset', 'css-editor-effect-palette', 'css-editor-audio-palette']) { + for (const selId of ['css-editor-gradient-preset', 'css-editor-effect-palette', 'css-editor-audio-palette', 'css-editor-math-wave-gradient']) { const sel = document.getElementById(selId) as HTMLSelectElement | null; if (sel) _syncSelectOptions(sel, _buildGradientEntityItems()); } if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.refresh(); if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.refresh(); if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.refresh(); + if (_mathWaveGradientEntitySelect) _mathWaveGradientEntitySelect.refresh(); } /** Render the user-created gradient list below the save button. */ @@ -1615,6 +1748,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: { const NON_PICTURE_TYPES = new Set([ 'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped', 'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors', + 'math_wave', ]); const CSS_CARD_RENDERERS: Record = { @@ -1775,6 +1909,18 @@ const CSS_CARD_RENDERERS: Record = { ${mode} `; }, + math_wave: (source, { clockBadge }) => { + const waveCount = (source.waves || []).length; + const speedVal = bindableValue(source.speed, 1.0).toFixed(1); + const gr = source.gradient_id ? _getGradients().find(g => g.id === source.gradient_id) : null; + const grName = gr?.name || '—'; + return ` + ${ICON_ACTIVITY} ${waveCount} wave${waveCount !== 1 ? 's' : ''} + ${ICON_PALETTE} ${escapeHtml(grName)} + ${ICON_FAST_FORWARD} ${speedVal}x + ${clockBadge} + `; + }, processed: (source) => { const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id); const inputName = inputSrc?.name || source.input_source_id || '—'; @@ -2382,6 +2528,39 @@ const _typeHandlers: Record any; reset: (... }; }, }, + math_wave: { + load(css: any) { + _ensureMathWaveGradientEntitySelect(); + const gradientId = css.gradient_id || ''; + (document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement).value = gradientId; + if (_mathWaveGradientEntitySelect) _mathWaveGradientEntitySelect.setValue(gradientId); + _ensureMathWaveSpeedWidget().setValue(css.speed ?? 1.0); + _mathWaveRenderLayers(css.waves || [{ waveform: 'sine', frequency: 1.0, amplitude: 1.0, phase: 0.0, offset: 0.0 }]); + }, + reset() { + _ensureMathWaveGradientEntitySelect(); + const gradients = _getGradients(); + const defaultId = gradients.length > 0 ? gradients[0].id : ''; + (document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement).value = defaultId; + if (_mathWaveGradientEntitySelect) _mathWaveGradientEntitySelect.setValue(defaultId); + _ensureMathWaveSpeedWidget().setValue(1.0); + _mathWaveRenderLayers([{ waveform: 'sine', frequency: 1.0, amplitude: 1.0, phase: 0.0, offset: 0.0 }]); + }, + getPayload(name: any) { + const gradientId = (document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement).value; + const waves = _mathWaveGetLayers(); + if (waves.length === 0) { + cssEditorModal.showError(t('color_strip.math_wave.error.no_waves')); + return null; + } + return { + name, + gradient_id: gradientId || null, + speed: _ensureMathWaveSpeedWidget().getValue(), + waves, + }; + }, + }, game_event: { load(css: any) { _populateGameIntegrationDropdownCSS(css.game_integration_id || ''); @@ -2579,7 +2758,7 @@ export async function saveCSSEditor() { payload.source_type = knownType ? sourceType : 'picture'; // Attach clock_id for animated types - const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather']; + const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave']; if (clockTypes.includes(sourceType)) { const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value; payload.clock_id = clockVal || null; diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 4549bad..5125be5 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -136,7 +136,7 @@ export type CSSSourceType = | 'color_cycle' | 'effect' | 'composite' | 'mapped' | 'audio' | 'api_input' | 'notification' | 'daylight' | 'candlelight' | 'processed' | 'weather' | 'key_colors' - | 'game_event'; + | 'game_event' | 'math_wave'; export interface ColorStop { position: number; @@ -288,6 +288,10 @@ export interface ColorStripSource { game_integration_id?: string; idle_color?: BindableColor; event_mappings?: GameEventMapping[]; + + // Math Wave + waves?: Array<{ waveform: string; frequency: number; amplitude: number; phase: number; offset: number }>; + gradient_id?: string; } // ── Pattern Template ────────────────────────────────────────── diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 4d895ec..df47b2e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -2254,6 +2254,26 @@ "color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.", "color_strip.game_event.error.no_integration": "Please select a game integration.", + "color_strip.type.math_wave": "Math Wave", + "color_strip.type.math_wave.desc": "Mathematical wave generator with gradient color mapping", + "color_strip.math_wave.gradient": "Color Gradient:", + "color_strip.math_wave.gradient.hint": "The gradient used to color the wave output. Wave values are mapped to positions along this gradient.", + "color_strip.math_wave.speed": "Speed:", + "color_strip.math_wave.speed.hint": "Animation speed multiplier. Higher values make the wave move faster.", + "color_strip.math_wave.waves": "Wave Layers:", + "color_strip.math_wave.waves.hint": "Add multiple wave layers that are combined together. Each wave has its own waveform, frequency, amplitude, phase and offset.", + "color_strip.math_wave.add_wave": "+ Add Wave", + "color_strip.math_wave.waveform": "Waveform", + "color_strip.math_wave.waveform.sine": "Sine", + "color_strip.math_wave.waveform.triangle": "Triangle", + "color_strip.math_wave.waveform.sawtooth": "Sawtooth", + "color_strip.math_wave.waveform.square": "Square", + "color_strip.math_wave.frequency": "Frequency", + "color_strip.math_wave.amplitude": "Amplitude", + "color_strip.math_wave.phase": "Phase", + "color_strip.math_wave.offset": "Offset", + "color_strip.math_wave.error.no_waves": "Add at least one wave layer.", + "value_source.type.game_event": "Game Event", "value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values", "value_source.game_event.integration": "Game Integration:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index a08b322..9258374 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1970,6 +1970,26 @@ "color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.", "color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.", + "color_strip.type.math_wave": "Математическая волна", + "color_strip.type.math_wave.desc": "Генератор математических волн с цветовым градиентом", + "color_strip.math_wave.gradient": "Цветовой градиент:", + "color_strip.math_wave.gradient.hint": "Градиент для окраски волнового выхода. Значения волны отображаются на позиции вдоль этого градиента.", + "color_strip.math_wave.speed": "Скорость:", + "color_strip.math_wave.speed.hint": "Множитель скорости анимации. Более высокие значения ускоряют движение волны.", + "color_strip.math_wave.waves": "Слои волн:", + "color_strip.math_wave.waves.hint": "Добавьте несколько слоёв волн, которые комбинируются вместе. Каждая волна имеет собственную форму, частоту, амплитуду, фазу и смещение.", + "color_strip.math_wave.add_wave": "+ Добавить волну", + "color_strip.math_wave.waveform": "Форма волны", + "color_strip.math_wave.waveform.sine": "Синусоида", + "color_strip.math_wave.waveform.triangle": "Треугольная", + "color_strip.math_wave.waveform.sawtooth": "Пилообразная", + "color_strip.math_wave.waveform.square": "Прямоугольная", + "color_strip.math_wave.frequency": "Частота", + "color_strip.math_wave.amplitude": "Амплитуда", + "color_strip.math_wave.phase": "Фаза", + "color_strip.math_wave.offset": "Смещение", + "color_strip.math_wave.error.no_waves": "Добавьте хотя бы один слой волны.", + "value_source.type.game_event": "Игровое событие", "value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1", "value_source.game_event.integration": "Игровая интеграция:", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 8a1da66..9433696 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1968,6 +1968,26 @@ "color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。", "color_strip.game_event.error.no_integration": "请选择游戏集成。", + "color_strip.type.math_wave": "数学波", + "color_strip.type.math_wave.desc": "使用渐变色映射的数学波形生成器", + "color_strip.math_wave.gradient": "颜色渐变:", + "color_strip.math_wave.gradient.hint": "用于着色波形输出的渐变。波形值映射到此渐变的位置。", + "color_strip.math_wave.speed": "速度:", + "color_strip.math_wave.speed.hint": "动画速度倍数。较高的值使波移动更快。", + "color_strip.math_wave.waves": "波形层:", + "color_strip.math_wave.waves.hint": "添加多个组合在一起的波形层。每个波形有自己的波形、频率、振幅、相位和偏移。", + "color_strip.math_wave.add_wave": "+ 添加波形", + "color_strip.math_wave.waveform": "波形", + "color_strip.math_wave.waveform.sine": "正弦波", + "color_strip.math_wave.waveform.triangle": "三角波", + "color_strip.math_wave.waveform.sawtooth": "锯齿波", + "color_strip.math_wave.waveform.square": "方波", + "color_strip.math_wave.frequency": "频率", + "color_strip.math_wave.amplitude": "振幅", + "color_strip.math_wave.phase": "相位", + "color_strip.math_wave.offset": "偏移", + "color_strip.math_wave.error.no_waves": "请至少添加一个波形层。", + "value_source.type.game_event": "游戏事件", "value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值", "value_source.game_event.integration": "游戏集成:", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 24c1825..1f63016 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -1743,6 +1743,141 @@ class GameEventColorStripSource(ColorStripSource): self.led_count = kwargs["led_count"] +_VALID_WAVEFORMS = frozenset({"sine", "triangle", "sawtooth", "square"}) + + +@dataclass +class MathWaveColorStripSource(ColorStripSource): + """Color strip source generating colors from mathematical wave functions. + + Produces per-LED values via configurable waveform layers (sine, triangle, + sawtooth, square) with superposition, then maps to RGB through a gradient. + Supports sync clocks for the time parameter. + """ + + waves: list = field( + default_factory=lambda: [ + { + "waveform": "sine", + "frequency": 1.0, + "amplitude": 1.0, + "phase": 0.0, + "offset": 0.0, + } + ] + ) + speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) + gradient_id: Optional[str] = None + + def to_dict(self) -> dict: + d = super().to_dict() + d["waves"] = self.waves + d["speed"] = self.speed.to_dict() + d["gradient_id"] = self.gradient_id + return d + + @classmethod + def from_dict(cls, data: dict) -> "MathWaveColorStripSource": + common = _parse_css_common(data) + return cls( + **common, + source_type="math_wave", + waves=data.get("waves") + or [ + { + "waveform": "sine", + "frequency": 1.0, + "amplitude": 1.0, + "phase": 0.0, + "offset": 0.0, + } + ], + speed=BindableFloat.from_raw(data.get("speed"), default=1.0), + gradient_id=data.get("gradient_id"), + ) + + @classmethod + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + waves=None, + speed=None, + gradient_id=None, + **_kwargs, + ): + # Validate wave entries + validated_waves = [] + for w in waves or []: + wf = w.get("waveform", "sine") + if wf not in _VALID_WAVEFORMS: + wf = "sine" + validated_waves.append( + { + "waveform": wf, + "frequency": float(w.get("frequency", 1.0)), + "amplitude": float(w.get("amplitude", 1.0)), + "phase": float(w.get("phase", 0.0)), + "offset": float(w.get("offset", 0.0)), + } + ) + if not validated_waves: + validated_waves = [ + { + "waveform": "sine", + "frequency": 1.0, + "amplitude": 1.0, + "phase": 0.0, + "offset": 0.0, + } + ] + return cls( + id=id, + name=name, + source_type="math_wave", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], + waves=validated_waves, + speed=BindableFloat.from_raw(speed, default=1.0), + gradient_id=gradient_id, + ) + + def apply_update(self, **kwargs) -> None: + if kwargs.get("waves") is not None: + raw = kwargs["waves"] + if isinstance(raw, list): + validated = [] + for w in raw: + wf = w.get("waveform", "sine") + if wf not in _VALID_WAVEFORMS: + wf = "sine" + validated.append( + { + "waveform": wf, + "frequency": float(w.get("frequency", 1.0)), + "amplitude": float(w.get("amplitude", 1.0)), + "phase": float(w.get("phase", 0.0)), + "offset": float(w.get("offset", 0.0)), + } + ) + if validated: + self.waves = validated + if kwargs.get("speed") is not None: + self.speed = self.speed.apply_update(kwargs["speed"]) + if "gradient_id" in kwargs: + self.gradient_id = kwargs["gradient_id"] or None + + # -- Source type registry -- # Maps source_type string to its subclass for factory dispatch. _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { @@ -1763,4 +1898,5 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { "weather": WeatherColorStripSource, "key_colors": KeyColorsColorStripSource, "game_event": GameEventColorStripSource, + "math_wave": MathWaveColorStripSource, } diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 182a1de..8c73c91 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -39,6 +39,7 @@ + @@ -730,6 +731,38 @@ + + +