feat: add math_wave color strip source type
Lint & Test / test (push) Has been cancelled

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
This commit is contained in:
2026-04-05 00:41:07 +03:00
parent edc6d27e2e
commit ace24715c8
16 changed files with 977 additions and 5 deletions
+2
View File
@@ -90,6 +90,8 @@ Plain `<select>` dropdowns should be enhanced with visual selectors depending on
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. **The `<select>` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
**CRITICAL pitfall — `<option>` elements required:** `IconSelect` does NOT create `<option>` elements in the native `<select>` — it only builds a visual popup grid. The native `<select>` must already contain matching `<option value="...">` elements (either from the Jinja2 template or added via JS) **before** `.value` is set. Setting `.value` on a `<select>` with no matching `<option>` **silently fails** — the value stays empty, and all downstream logic (section switching, auto-naming, type setup) breaks with no error. **When adding a new type to any IconSelect-enhanced `<select>`, you MUST add the `<option>` in the HTML template too.**
**Common pitfall:** Using a preset/palette selector (e.g. gradient preset dropdown or effect type picker) that changes the underlying `<select>` value but forgets to call `.setValue()` on the IconSelect — the visual grid still shows the old selection.
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
+109
View File
@@ -0,0 +1,109 @@
# math_wave Color Strip Source — Implementation Plan
## Overview
A new CSS type that generates LED colors from configurable mathematical wave functions. Each LED position gets a wave value based on spatial position and time, mapped to a color via a gradient palette. Supports multiple superimposed wave layers, sync clocks, and bindable parameters.
## Requirements
- Waveform types: sine, triangle, sawtooth, square
- Parameters per wave layer: waveform, frequency, amplitude, phase, offset
- Global parameters: speed (bindable), gradient_id (color mapping)
- Wave superposition: list of wave layers combined additively
- Spatial dimension: wave value depends on LED position (0.0-1.0) + time
- Sync clock integration for time parameter
- Color mapping: combined wave output (0.0-1.0) mapped through a gradient
## Phase 1: Storage Model
**File: `server/src/wled_controller/storage/color_strip_source.py`**
- Add `MathWaveColorStripSource` dataclass after `GameEventColorStripSource`
- Fields:
- `waves: list` — default `[{"waveform": "sine", "frequency": 1.0, "amplitude": 1.0, "phase": 0.0, "offset": 0.0}]`
- `speed: BindableFloat` — default 1.0
- `gradient_id: Optional[str]` — references Gradient entity
- Implement `to_dict`, `from_dict`, `create_from_kwargs`, `apply_update` (follow `CandlelightColorStripSource` pattern)
- Valid waveforms: `{"sine", "triangle", "sawtooth", "square"}`
- Add `"math_wave": MathWaveColorStripSource` to `_SOURCE_TYPE_MAP`
## Phase 2: Stream Implementation
**File: `server/src/wled_controller/core/processing/math_wave_stream.py`** (new)
- Class `MathWaveColorStripStream(ColorStripStream)` following `CandlelightColorStripStream` pattern
- Key methods: `__init__`, `_update_from_source`, `configure`, `start`, `stop`, `get_latest_colors`, `update_source`, `set_clock`, `set_gradient_store`
- Animation loop (`_animate_loop`):
- Get `t` from clock (if set) or wall clock
- For each LED at normalized position `p = i / (N-1)`:
- Sum all wave layers: `sum += amplitude * waveform(2*pi*frequency*(p + speed*t) + phase) + offset`
- Clamp result to [0.0, 1.0]
- Map per-LED values to RGB via gradient LUT
- Waveform functions (vectorized with numpy):
- `sine`: `0.5 + 0.5 * np.sin(x)`
- `triangle`: `2.0 * np.abs(np.mod(x / (2*pi), 1.0) - 0.5)`
- `sawtooth`: `np.mod(x / (2*pi), 1.0)`
- `square`: `(np.sin(x) >= 0).astype(float)`
- Double-buffering pattern (same as candlelight)
- Use `self.resolve("speed", self._speed)` for bindable speed
- Gradient resolution via `set_gradient_store` pattern
## Phase 3: API Integration
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
- Add `MathWaveCSSResponse`, `MathWaveCSSCreate`, `MathWaveCSSUpdate`
- Add to `ColorStripSourceResponse`, `ColorStripSourceCreate`, `ColorStripSourceUpdate` unions
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
- Import `MathWaveColorStripStream`
- Add `"math_wave": MathWaveColorStripStream` to `_SIMPLE_STREAM_MAP`
- Existing `set_gradient_store` and `_inject_clock` injection handles it automatically
## Phase 4: Frontend
**File: `server/src/wled_controller/static/js/types.ts`**
- Add `'math_wave'` to `CSSSourceType` union
**File: `server/src/wled_controller/static/js/core/icons.ts`**
- Add `math_wave: _svg(P.activity)` to `_colorStripTypeIcons`
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
- Add `<option value="math_wave">` to type select
- Add `<div id="css-editor-math-wave-section">` with:
- Gradient picker (EntitySelect)
- Speed (BindableScalarWidget)
- Wave layers list (dynamic rows: waveform IconSelect, frequency/amplitude/phase/offset inputs, add/remove buttons)
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
- Add to `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
- Add to `clockTypes` array in `saveCSSEditor`
- Add type handler: `load(css)`, `reset()`, `getPayload(name)`
- Add card renderer showing wave count, gradient swatch, speed, clock badge
- Add wave layer management: `_renderMathWaveRow`, `addMathWaveLayer`, `removeMathWaveLayer`
**Files: `en.json`, `ru.json`, `zh.json`**
- Add i18n keys for type name, description, all field labels, waveform names
## Phase 5: Testing
**File: `server/tests/core/test_math_wave_stream.py`** (new)
- Test wave functions produce expected values at known inputs
- Test single wave spatial pattern
- Test wave superposition
- Test gradient color mapping
- Test clock integration
- Test `update_source` hot-update
- Test `configure` auto-sizing
**File: `server/tests/e2e/test_color_strip_flow.py`**
- Add `test_math_wave_crud` to lifecycle tests
**Storage model tests:**
- Test `from_dict` roundtrip
- Test `create_from_kwargs` with valid/invalid waveforms
- Test `apply_update`
- Test `_SOURCE_TYPE_MAP` dispatch
## Risks & Mitigations
- **Wave layer UI complexity** — Follow existing composite layers / game event mappings patterns
- **Performance with many layers** — Vectorize with numpy; cap max wave layers to 8
- **Gradient resolution** — Stream manager already injects `set_gradient_store` automatically
+151
View File
@@ -0,0 +1,151 @@
# music_sync Color Strip Source — Implementation Plan
## Overview
A higher-level music-reactive CSS that provides semantic audio analysis (BPM detection, beat tracking, energy envelope, drop detection, frequency band energy) and multiple visualization modes. Builds on existing `AudioCaptureManager` and `AudioAnalysis` infrastructure. No new external dependencies — uses numpy-only analysis.
## Requirements
- BPM estimation from real-time audio
- Beat onset detection with configurable threshold
- Smoothed RMS energy envelope with attack/release
- Drop detection: energy drops/buildups
- Frequency band energy: bass (20-250 Hz), mid (250-4k Hz), treble (4k-20k Hz)
- Four visualization modes: `pulse_on_beat`, `energy_gradient`, `spectrum_bands`, `strobe_on_drop`
- Uses existing audio engine infrastructure (no new audio capture code)
- No external dependencies beyond numpy
## Phase 1: Music Analysis Engine
**File: `server/src/wled_controller/core/audio/music_analyzer.py`** (new)
### 1.1 `MusicFeatures` dataclass (frozen=True)
- `bpm: float` — estimated BPM (0 if unknown)
- `beat: bool` — beat detected this frame
- `beat_intensity: float` — 0.0-1.0
- `beat_phase: float` — 0.0-1.0 position in beat cycle
- `energy: float` — smoothed RMS 0.0-1.0
- `energy_delta: float` — rate of change
- `bass_energy, mid_energy, treble_energy: float` — 0.0-1.0
- `drop_state: str` — "idle"|"buildup"|"drop"|"recovery"
- `drop_intensity: float` — 0.0-1.0
### 1.2 `MusicAnalyzer` class
- **State**: rolling energy buffer (~4s at ~43 Hz = ~172 samples), beat history timestamps (last 30), smoothed band energies, BPM estimate, drop state machine
- **`update(analysis: AudioAnalysis) -> MusicFeatures`**: main entry point
- **BPM estimation**: Track beat timestamps, compute median inter-beat interval, exponential smoothing, clamp 40-220 BPM
- **Beat tracking**: Pass through `AudioAnalysis.beat` + compute `beat_phase` (position in current beat cycle)
- **Energy envelope**: Smoothed RMS with configurable attack/release
- **Drop detection**: State machine: `idle -> buildup` (energy rising steadily 1-2s), `buildup -> drop` (energy drops >50% within 100ms), `drop -> recovery` (after 500ms), `recovery -> idle`
- **Frequency bands**: Sum spectrum bins into 3 bands from 64-band spectrum
## Phase 2: Storage Model
**File: `server/src/wled_controller/storage/color_strip_source.py`**
- Add `MusicSyncColorStripSource` dataclass after `AudioColorStripSource`
- Fields:
- `visualization_mode: str` — default `"pulse_on_beat"`
- `audio_source_id: str` — references AudioSource
- `sensitivity: BindableFloat` — default 1.0
- `smoothing: BindableFloat` — default 0.3
- `palette: str` — default `"rainbow"`
- `gradient_id: Optional[str]`
- `color: BindableColor` — primary color
- `color_secondary: BindableColor` — for two-color modes
- `beat_decay: BindableFloat` — default 0.15
- `led_count: int` — 0 = auto-size
- `mirror: bool`
- Add `"music_sync": MusicSyncColorStripSource` to `_SOURCE_TYPE_MAP`
## Phase 3: Stream Implementation
**File: `server/src/wled_controller/core/processing/music_sync_stream.py`** (new)
- Class `MusicSyncColorStripStream(ColorStripStream)` following `AudioColorStripStream` pattern
- Constructor: accept source + audio_capture_manager + stores. Create `MusicAnalyzer` instance
- `start()`: Acquire audio stream, start background thread
- `stop()`: Release audio stream, stop thread
- `_animate_loop()`:
1. Get `AudioAnalysis` from audio stream
2. Apply audio filter pipeline (if any)
3. Feed to `MusicAnalyzer.update()``MusicFeatures`
4. Dispatch to visualization renderer
5. Double-buffer output
### Visualization Renderers
- **`pulse_on_beat`**: Full-strip flash on beat with exponential decay. Between beats: sine-wave pulsing synced to BPM. Color from palette indexed by beat_intensity.
- **`energy_gradient`**: Maps bass→warm, treble→cool. Overall brightness from energy. Gradient scrolls with beat_phase.
- **`spectrum_bands`**: 3 zones (bass/mid/treble), each fills proportionally to band energy. Mirror mode: bass center, treble edges.
- **`strobe_on_drop`**: Idle=gentle breathing. Buildup=increasing pulse. Drop=rapid strobe at 10 Hz. Recovery=fade back.
## Phase 4: API Integration
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
- Add `MusicSyncCSSResponse`, `MusicSyncCSSCreate`, `MusicSyncCSSUpdate`
- Add to all three union types
**File: `server/src/wled_controller/api/routes/color_strip_sources.py`**
- Import + add to `_RESPONSE_MAP`
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
- Add `music_sync` branch in `acquire()` (same pattern as `audio` branch)
- Update `refresh_audio_filter_pipelines()` to include `MusicSyncColorStripStream`
## Phase 5: Frontend
**File: `server/src/wled_controller/static/js/core/icons.ts`**
- Add `music_sync: _svg(P.radio)` icon
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
- Add `<div id="css-editor-music-sync-section">` with:
- Visualization mode selector (IconSelect)
- Audio source dropdown
- Sensitivity, Smoothing, Beat Decay (BindableScalarWidget containers)
- Palette/gradient selector (EntitySelect)
- Primary + Secondary color (BindableColorWidget)
- Mirror checkbox
**File: `server/src/wled_controller/static/js/features/color-strips-music-sync.ts`** (new, extracted)
- Editor logic, widget factories, card renderer
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
- Register type in `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
- Import and register from `color-strips-music-sync.ts`
**Files: `en.json`, `ru.json`, `zh.json`**
- Add i18n keys for type, visualization modes, all field labels
## Phase 6: Testing
**File: `server/tests/core/audio/test_music_analyzer.py`** (new)
- BPM estimation from regular beats (within 5 BPM accuracy)
- BPM handles no beats gracefully
- Beat phase progression
- Energy envelope attack/release
- Frequency band splitting
- Drop detection state machine transitions
- No false drops on steady signal
**File: `server/tests/core/processing/test_music_sync_stream.py`** (new)
- Stream lifecycle (start/stop)
- Produces valid colors
- Hot-update parameters
- Auto-size from device
- All 4 visualization modes produce valid (n,3) uint8 arrays
- Mirror mode symmetry
**Storage + API tests:**
- `from_dict` roundtrip
- CRUD via test client
## Risks & Mitigations
- **BPM accuracy** — Median-of-recent-IBIs is robust for dance/electronic. For ambient, BPM noisy but `energy_gradient` and `spectrum_bands` don't depend on BPM
- **Drop detection false positives** — Require minimum energy threshold + sustained increase before buildup state
- **Frontend file size** — Extract to `color-strips-music-sync.ts` (following composite/notification pattern)
- **Strobe photosensitivity** — Cap at 10 Hz (below 15-25 Hz danger zone), add UI warning
- **Thread safety** — `MusicAnalyzer` owned exclusively by one stream thread, no shared access
## Dependency Order
`math_wave` should be implemented first (simpler, no audio dependency), then `music_sync`.
@@ -38,6 +38,7 @@ from wled_controller.api.schemas.color_strip_sources import (
GradientCSSResponse,
KeyColorsCSSResponse,
MappedCSSResponse,
MathWaveCSSResponse,
NotificationCSSResponse,
NotifyRequest,
PictureAdvancedCSSResponse,
@@ -68,6 +69,7 @@ from wled_controller.storage.color_strip_source import (
GradientColorStripSource,
KeyColorsColorStripSource,
MappedColorStripSource,
MathWaveColorStripSource,
NotificationColorStripSource,
PictureColorStripSource,
ProcessedColorStripSource,
@@ -244,6 +246,12 @@ _RESPONSE_MAP: dict = {
smoothing=s.smoothing.to_dict(),
brightness=s.brightness.to_dict(),
),
MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse(
**kw,
waves=s.waves,
speed=s.speed.to_dict(),
gradient_id=s.gradient_id,
),
}
@@ -227,6 +227,13 @@ class KeyColorsCSSResponse(_CSSResponseBase):
brightness: Any = Field(description="Brightness")
class MathWaveCSSResponse(_CSSResponseBase):
source_type: Literal["math_wave"] = "math_wave"
waves: List[dict] = Field(description="Wave layer definitions")
speed: Any = Field(description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
ColorStripSourceResponse = Annotated[
Union[
Annotated[PictureCSSResponse, Tag("picture")],
@@ -245,6 +252,7 @@ ColorStripSourceResponse = Annotated[
Annotated[ProcessedCSSResponse, Tag("processed")],
Annotated[WeatherCSSResponse, Tag("weather")],
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
Annotated[MathWaveCSSResponse, Tag("math_wave")],
],
Discriminator("source_type"),
]
@@ -406,6 +414,13 @@ class KeyColorsCSSCreate(_CSSCreateBase):
)
class MathWaveCSSCreate(_CSSCreateBase):
source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
ColorStripSourceCreate = Annotated[
Union[
Annotated[PictureCSSCreate, Tag("picture")],
@@ -424,6 +439,7 @@ ColorStripSourceCreate = Annotated[
Annotated[ProcessedCSSCreate, Tag("processed")],
Annotated[WeatherCSSCreate, Tag("weather")],
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
Annotated[MathWaveCSSCreate, Tag("math_wave")],
],
Discriminator("source_type"),
]
@@ -585,6 +601,13 @@ class KeyColorsCSSUpdate(_CSSUpdateBase):
)
class MathWaveCSSUpdate(_CSSUpdateBase):
source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
ColorStripSourceUpdate = Annotated[
Union[
Annotated[PictureCSSUpdate, Tag("picture")],
@@ -603,6 +626,7 @@ ColorStripSourceUpdate = Annotated[
Annotated[ProcessedCSSUpdate, Tag("processed")],
Annotated[WeatherCSSUpdate, Tag("weather")],
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
],
Discriminator("source_type"),
]
@@ -25,6 +25,7 @@ from wled_controller.core.processing.notification_stream import NotificationColo
from wled_controller.core.processing.daylight_stream import DaylightColorStripStream
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
from wled_controller.core.processing.game_event_stream import GameEventColorStripStream
from wled_controller.core.processing.math_wave_stream import MathWaveColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -40,6 +41,7 @@ _SIMPLE_STREAM_MAP = {
"daylight": DaylightColorStripStream,
"candlelight": CandlelightColorStripStream,
"game_event": GameEventColorStripStream,
"math_wave": MathWaveColorStripStream,
}
@@ -0,0 +1,261 @@
"""Math wave LED stream — configurable waveform generator with gradient mapping.
Produces per-LED values from superimposed wave functions (sine, triangle,
sawtooth, square) that vary across LED position and time, then maps the
combined value through a gradient palette for color output.
"""
import math
import threading
import time
from typing import List, Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import (
ColorStripStream,
_compute_gradient_colors,
)
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
TWO_PI = 2.0 * math.pi
# ── Vectorized waveform functions ────────────────────────────────────
# Input x is in radians; output is normalised to [0, 1].
def _wave_sine(x: np.ndarray) -> np.ndarray:
return 0.5 + 0.5 * np.sin(x)
def _wave_triangle(x: np.ndarray) -> np.ndarray:
phase = np.mod(x / TWO_PI, 1.0)
return 1.0 - 2.0 * np.abs(phase - 0.5)
def _wave_sawtooth(x: np.ndarray) -> np.ndarray:
return np.mod(x / TWO_PI, 1.0)
def _wave_square(x: np.ndarray) -> np.ndarray:
return (np.sin(x) >= 0).astype(np.float32)
_WAVEFORM_FN = {
"sine": _wave_sine,
"triangle": _wave_triangle,
"sawtooth": _wave_sawtooth,
"square": _wave_square,
}
MAX_WAVE_LAYERS = 8
class MathWaveColorStripStream(ColorStripStream):
"""Color strip stream generating LED colors from mathematical waves.
Each LED at normalised position p gets a combined wave value from all
layers, clamped to [0, 1], then mapped through a gradient LUT to RGB.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None
self._led_count = 1
self._auto_size = True
self._gradient_store = None
self._gradient_lut: Optional[np.ndarray] = None
self._cached_gradient_id: Optional[str] = None
self._update_from_source(source)
def _update_from_source(self, source) -> None:
from wled_controller.storage.bindable import bfloat
self._speed = bfloat(getattr(source, "speed", 1.0), 1.0)
self._gradient_id = getattr(source, "gradient_id", None)
raw_waves = getattr(source, "waves", None) or []
self._waves: List[dict] = raw_waves[:MAX_WAVE_LAYERS]
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
# Invalidate LUT so it rebuilds on next frame
self._cached_gradient_id = None
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
if new_count != self._led_count:
self._led_count = new_count
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-math-wave",
daemon=True,
)
self._thread.start()
logger.info(
f"MathWaveColorStripStream started (leds={self._led_count}, "
f"waves={len(self._waves)})"
)
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("MathWaveColorStripStream thread did not terminate within 5s")
self._thread = None
logger.info("MathWaveColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import MathWaveColorStripSource
if isinstance(source, MathWaveColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
logger.info("MathWaveColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
self._clock = clock
def set_gradient_store(self, gradient_store) -> None:
self._gradient_store = gradient_store
# ── Gradient LUT ───────────────────────────────────────────────
def _ensure_gradient_lut(self) -> np.ndarray:
"""Build or return a 256-entry RGB lookup table from the current gradient."""
gid = self._gradient_id
if gid == self._cached_gradient_id and self._gradient_lut is not None:
return self._gradient_lut
self._cached_gradient_id = gid
stops = None
if gid and self._gradient_store:
stops = self._gradient_store.resolve_stops(gid)
if not stops:
# Fallback: rainbow-ish default
stops = [
{"position": 0.0, "color": [255, 0, 0]},
{"position": 0.25, "color": [255, 255, 0]},
{"position": 0.5, "color": [0, 255, 0]},
{"position": 0.75, "color": [0, 255, 255]},
{"position": 1.0, "color": [0, 0, 255]},
]
self._gradient_lut = _compute_gradient_colors(stops, 256)
return self._gradient_lut
# ── Animation loop ─────────────────────────────────────────────
def _animate_loop(self) -> None:
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
_positions: Optional[np.ndarray] = None # normalised LED positions [0,1]
try:
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
t = clock.get_time()
speed = clock.speed * self.resolve("speed", self._speed)
else:
t = wall_start
speed = self.resolve("speed", self._speed)
n = self._led_count
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
_positions = (
np.linspace(0.0, 1.0, n, dtype=np.float32)
if n > 1
else np.array([0.0], dtype=np.float32)
)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
self._render_waves(buf, n, _positions, t, speed)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"MathWaveColorStripStream animation error: {e}")
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(f"Fatal MathWaveColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False
# ── Render ─────────────────────────────────────────────────────
def _render_waves(
self,
buf: np.ndarray,
n: int,
positions: np.ndarray,
t: float,
speed: float,
) -> None:
"""Compute superimposed wave values and map through gradient LUT."""
combined = np.zeros(n, dtype=np.float32)
for wave in self._waves:
waveform = wave.get("waveform", "sine")
fn = _WAVEFORM_FN.get(waveform, _wave_sine)
freq = float(wave.get("frequency", 1.0))
amp = float(wave.get("amplitude", 1.0))
phase = float(wave.get("phase", 0.0))
offset = float(wave.get("offset", 0.0))
# x = 2*pi * frequency * (position + speed*t) + phase
x = TWO_PI * freq * (positions + speed * t) + phase
combined += amp * fn(x) + offset
# Clamp to [0, 1]
np.clip(combined, 0.0, 1.0, out=combined)
# Map through gradient LUT (256-entry)
lut = self._ensure_gradient_lut()
indices = (combined * 255.0).astype(np.uint8)
buf[:] = lut[indices]
@@ -144,6 +144,7 @@ import {
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
mathWaveAddLayer, mathWaveRemoveLayer,
} from './features/color-strips.ts';
// Layer 5: audio sources
@@ -489,6 +490,7 @@ Object.assign(window, {
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
mathWaveAddLayer, mathWaveRemoveLayer,
// audio sources
showAudioSourceModal,
@@ -30,6 +30,7 @@ const _colorStripTypeIcons = {
processed: _svg(P.sparkles),
key_colors: _svg(P.palette),
game_event: _svg(P.gamepad2),
math_wave: _svg(P.activity),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -91,6 +91,9 @@ class CSSEditorModal extends Modal {
if (_cssGameIntegrationEntitySelect) { _cssGameIntegrationEntitySelect.destroy(); _cssGameIntegrationEntitySelect = null; }
_destroyCSSGameMappingIconSelects();
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
if (_mathWaveSpeedWidget) { _mathWaveSpeedWidget.destroy(); _mathWaveSpeedWidget = null; }
if (_mathWaveGradientEntitySelect) { _mathWaveGradientEntitySelect.destroy(); _mathWaveGradientEntitySelect = null; }
_mathWaveWaveformIconSelects.forEach(s => s.destroy()); _mathWaveWaveformIconSelects = [];
compositeDestroyEntitySelects();
}
@@ -149,6 +152,9 @@ class CSSEditorModal extends Modal {
ge_integration: (document.getElementById('css-editor-game-integration') as HTMLInputElement)?.value || '',
ge_idle_color: _gameEventIdleColorWidget ? JSON.stringify(_gameEventIdleColorWidget.getValue()) : '[]',
ge_mappings: JSON.stringify(_cssGameMappings),
mw_gradient: (document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement)?.value || '',
mw_speed: _mathWaveSpeedWidget ? JSON.stringify(_mathWaveSpeedWidget.getValue()) : '1.0',
mw_waves: JSON.stringify(_mathWaveGetLayers()),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
}
@@ -168,6 +174,9 @@ let _candlelightSpeedWidget: BindableScalarWidget | null = null;
let _candlelightWindWidget: BindableScalarWidget | null = null;
let _weatherSpeedWidget: BindableScalarWidget | null = null;
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
let _mathWaveSpeedWidget: BindableScalarWidget | null = null;
let _mathWaveGradientEntitySelect: EntitySelect | null = null;
let _mathWaveWaveformIconSelects: IconSelect[] = [];
// ── BindableColorWidget instances for CSS editor ──
let _staticColorWidget: BindableColorWidget | null = null;
@@ -260,7 +269,7 @@ const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'game_event',
'game_event', 'math_wave',
];
function _buildCSSTypeItems() {
@@ -309,6 +318,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
'processed': 'css-editor-processed-section',
'key_colors': 'css-editor-key-colors-section',
'game_event': 'css-editor-game-event-section',
'math_wave': 'css-editor-math-wave-section',
};
const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
@@ -321,6 +331,7 @@ const CSS_TYPE_SETUP: Record<string, () => void> = {
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
candlelight: () => _ensureCandleTypeIconSelect(),
game_event: () => { _populateGameIntegrationDropdownCSS(); _initCSSGamePresetIconSelect(); },
math_wave: () => { _ensureMathWaveGradientEntitySelect(); },
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
composite: () => compositeRenderList(),
mapped: () => _mappedRenderList(),
@@ -376,7 +387,7 @@ export function onCSSTypeChange() {
hasLedCount.includes(type) ? '' : 'none';
// Sync clock — shown 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'];
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
@@ -1118,6 +1129,127 @@ function _ensureCandleTypeIconSelect() {
_candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
/* ── Math Wave helpers ──────────────────────────────────────────── */
function _ensureMathWaveSpeedWidget(): BindableScalarWidget {
if (!_mathWaveSpeedWidget) {
_mathWaveSpeedWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-math-wave-speed-container')!,
min: 0.1, max: 10.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-math-wave-speed',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _mathWaveSpeedWidget;
}
function _ensureMathWaveGradientEntitySelect() {
const sel = document.getElementById('css-editor-math-wave-gradient') as HTMLSelectElement | null;
if (!sel) return;
const items = _buildGradientEntityItems();
_syncSelectOptions(sel, items);
if (_mathWaveGradientEntitySelect) { _mathWaveGradientEntitySelect.refresh(); return; }
_mathWaveGradientEntitySelect = new EntitySelect({
target: sel,
getItems: _buildGradientEntityItems,
placeholder: t('palette.search'),
});
}
function _buildMathWaveWaveformItems(): IconSelectItem[] {
return [
{ value: 'sine', icon: _icon(P.activity), label: t('color_strip.math_wave.waveform.sine'), desc: '' },
{ value: 'triangle', icon: _icon(P.trendingUp), label: t('color_strip.math_wave.waveform.triangle'), desc: '' },
{ value: 'sawtooth', icon: _icon(P.rotateCw), label: t('color_strip.math_wave.waveform.sawtooth'), desc: '' },
{ value: 'square', icon: _icon(P.layoutDashboard), label: t('color_strip.math_wave.waveform.square'), desc: '' },
];
}
function _mathWaveRenderLayers(waves: Array<{ waveform: string; frequency: number; amplitude: number; phase: number; offset: number }>) {
const container = document.getElementById('css-editor-math-wave-layers');
if (!container) return;
// Destroy old waveform icon selects
_mathWaveWaveformIconSelects.forEach(s => s.destroy());
_mathWaveWaveformIconSelects = [];
container.innerHTML = '';
waves.forEach((wave, idx) => {
const row = document.createElement('div');
row.className = 'math-wave-layer-row';
row.style.cssText = 'border:1px solid var(--border-color,#444);border-radius:6px;padding:8px;margin-bottom:6px;position:relative';
row.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<strong>#${idx + 1}</strong>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="mathWaveRemoveLayer(${idx})" title="${t('common.delete')}" style="margin-left:auto">${ICON_TRASH}</button>
</div>
<div class="form-group" style="margin-bottom:4px">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.waveform">${t('color_strip.math_wave.waveform')}</label>
<select class="mw-waveform" data-idx="${idx}">
<option value="sine">${t('color_strip.math_wave.waveform.sine')}</option>
<option value="triangle">${t('color_strip.math_wave.waveform.triangle')}</option>
<option value="sawtooth">${t('color_strip.math_wave.waveform.sawtooth')}</option>
<option value="square">${t('color_strip.math_wave.waveform.square')}</option>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<div class="form-group" style="margin-bottom:0">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.frequency">${t('color_strip.math_wave.frequency')}</label>
<input type="number" class="mw-frequency" min="0.1" max="20" step="0.1" value="${wave.frequency}">
</div>
<div class="form-group" style="margin-bottom:0">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.amplitude">${t('color_strip.math_wave.amplitude')}</label>
<input type="number" class="mw-amplitude" min="0.0" max="2.0" step="0.1" value="${wave.amplitude}">
</div>
<div class="form-group" style="margin-bottom:0">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.phase">${t('color_strip.math_wave.phase')}</label>
<input type="number" class="mw-phase" min="0.0" max="6.28" step="0.1" value="${wave.phase}">
</div>
<div class="form-group" style="margin-bottom:0">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.offset">${t('color_strip.math_wave.offset')}</label>
<input type="number" class="mw-offset" min="-1.0" max="1.0" step="0.1" value="${wave.offset}">
</div>
</div>
`;
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<string, CardPropsRenderer> = {
@@ -1775,6 +1909,18 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
<span class="stream-card-prop">${mode}</span>
`;
},
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 `
<span class="stream-card-prop" title="${t('color_strip.math_wave.waves')}">${ICON_ACTIVITY} ${waveCount} wave${waveCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop" title="${t('color_strip.math_wave.gradient')}">${ICON_PALETTE} ${escapeHtml(grName)}</span>
<span class="stream-card-prop">${ICON_FAST_FORWARD} ${speedVal}x</span>
${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<string, { load: (...args: any[]) => 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;
@@ -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 ──────────────────────────────────────────
@@ -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:",
@@ -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": "Игровая интеграция:",
@@ -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": "游戏集成:",
@@ -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,
}
@@ -39,6 +39,7 @@
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
<option value="key_colors" data-i18n="color_strip.type.key_colors">Key Colors</option>
<option value="game_event" data-i18n="color_strip.type.game_event">Game Event</option>
<option value="math_wave" data-i18n="color_strip.type.math_wave">Math Wave</option>
</select>
</div>
@@ -730,6 +731,38 @@
</div>
</div>
<!-- Math Wave section -->
<div id="css-editor-math-wave-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.math_wave.gradient">Color Gradient:</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.math_wave.gradient.hint">The gradient used to color the wave output. Wave values are mapped to positions along this gradient.</small>
<select id="css-editor-math-wave-gradient"></select>
<div id="css-editor-math-wave-gradient-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label><span data-i18n="color_strip.math_wave.speed">Speed:</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.math_wave.speed.hint">Animation speed multiplier. Higher values make the wave move faster.</small>
<div id="css-editor-math-wave-speed-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.math_wave.waves">Wave Layers:</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.math_wave.waves.hint">Add multiple wave layers that are combined together. Each wave has its own waveform, frequency, amplitude, phase and offset.</small>
<div id="css-editor-math-wave-layers"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="mathWaveAddLayer()" style="margin-top:6px">
<span data-i18n="color_strip.math_wave.add_wave">+ Add Wave</span>
</button>
</div>
</div>
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">