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:
@@ -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.
|
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.
|
**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.
|
**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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
GradientCSSResponse,
|
||||||
KeyColorsCSSResponse,
|
KeyColorsCSSResponse,
|
||||||
MappedCSSResponse,
|
MappedCSSResponse,
|
||||||
|
MathWaveCSSResponse,
|
||||||
NotificationCSSResponse,
|
NotificationCSSResponse,
|
||||||
NotifyRequest,
|
NotifyRequest,
|
||||||
PictureAdvancedCSSResponse,
|
PictureAdvancedCSSResponse,
|
||||||
@@ -68,6 +69,7 @@ from wled_controller.storage.color_strip_source import (
|
|||||||
GradientColorStripSource,
|
GradientColorStripSource,
|
||||||
KeyColorsColorStripSource,
|
KeyColorsColorStripSource,
|
||||||
MappedColorStripSource,
|
MappedColorStripSource,
|
||||||
|
MathWaveColorStripSource,
|
||||||
NotificationColorStripSource,
|
NotificationColorStripSource,
|
||||||
PictureColorStripSource,
|
PictureColorStripSource,
|
||||||
ProcessedColorStripSource,
|
ProcessedColorStripSource,
|
||||||
@@ -244,6 +246,12 @@ _RESPONSE_MAP: dict = {
|
|||||||
smoothing=s.smoothing.to_dict(),
|
smoothing=s.smoothing.to_dict(),
|
||||||
brightness=s.brightness.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")
|
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[
|
ColorStripSourceResponse = Annotated[
|
||||||
Union[
|
Union[
|
||||||
Annotated[PictureCSSResponse, Tag("picture")],
|
Annotated[PictureCSSResponse, Tag("picture")],
|
||||||
@@ -245,6 +252,7 @@ ColorStripSourceResponse = Annotated[
|
|||||||
Annotated[ProcessedCSSResponse, Tag("processed")],
|
Annotated[ProcessedCSSResponse, Tag("processed")],
|
||||||
Annotated[WeatherCSSResponse, Tag("weather")],
|
Annotated[WeatherCSSResponse, Tag("weather")],
|
||||||
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
|
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
|
||||||
|
Annotated[MathWaveCSSResponse, Tag("math_wave")],
|
||||||
],
|
],
|
||||||
Discriminator("source_type"),
|
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[
|
ColorStripSourceCreate = Annotated[
|
||||||
Union[
|
Union[
|
||||||
Annotated[PictureCSSCreate, Tag("picture")],
|
Annotated[PictureCSSCreate, Tag("picture")],
|
||||||
@@ -424,6 +439,7 @@ ColorStripSourceCreate = Annotated[
|
|||||||
Annotated[ProcessedCSSCreate, Tag("processed")],
|
Annotated[ProcessedCSSCreate, Tag("processed")],
|
||||||
Annotated[WeatherCSSCreate, Tag("weather")],
|
Annotated[WeatherCSSCreate, Tag("weather")],
|
||||||
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
|
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
|
||||||
|
Annotated[MathWaveCSSCreate, Tag("math_wave")],
|
||||||
],
|
],
|
||||||
Discriminator("source_type"),
|
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[
|
ColorStripSourceUpdate = Annotated[
|
||||||
Union[
|
Union[
|
||||||
Annotated[PictureCSSUpdate, Tag("picture")],
|
Annotated[PictureCSSUpdate, Tag("picture")],
|
||||||
@@ -603,6 +626,7 @@ ColorStripSourceUpdate = Annotated[
|
|||||||
Annotated[ProcessedCSSUpdate, Tag("processed")],
|
Annotated[ProcessedCSSUpdate, Tag("processed")],
|
||||||
Annotated[WeatherCSSUpdate, Tag("weather")],
|
Annotated[WeatherCSSUpdate, Tag("weather")],
|
||||||
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
|
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
|
||||||
|
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
|
||||||
],
|
],
|
||||||
Discriminator("source_type"),
|
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.daylight_stream import DaylightColorStripStream
|
||||||
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
|
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
|
||||||
from wled_controller.core.processing.game_event_stream import GameEventColorStripStream
|
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
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -40,6 +41,7 @@ _SIMPLE_STREAM_MAP = {
|
|||||||
"daylight": DaylightColorStripStream,
|
"daylight": DaylightColorStripStream,
|
||||||
"candlelight": CandlelightColorStripStream,
|
"candlelight": CandlelightColorStripStream,
|
||||||
"game_event": GameEventColorStripStream,
|
"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,
|
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||||
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
|
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
|
||||||
|
mathWaveAddLayer, mathWaveRemoveLayer,
|
||||||
} from './features/color-strips.ts';
|
} from './features/color-strips.ts';
|
||||||
|
|
||||||
// Layer 5: audio sources
|
// Layer 5: audio sources
|
||||||
@@ -489,6 +490,7 @@ Object.assign(window, {
|
|||||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||||
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
|
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
|
||||||
|
mathWaveAddLayer, mathWaveRemoveLayer,
|
||||||
|
|
||||||
// audio sources
|
// audio sources
|
||||||
showAudioSourceModal,
|
showAudioSourceModal,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const _colorStripTypeIcons = {
|
|||||||
processed: _svg(P.sparkles),
|
processed: _svg(P.sparkles),
|
||||||
key_colors: _svg(P.palette),
|
key_colors: _svg(P.palette),
|
||||||
game_event: _svg(P.gamepad2),
|
game_event: _svg(P.gamepad2),
|
||||||
|
math_wave: _svg(P.activity),
|
||||||
};
|
};
|
||||||
const _valueSourceTypeIcons = {
|
const _valueSourceTypeIcons = {
|
||||||
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
|
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; }
|
if (_cssGameIntegrationEntitySelect) { _cssGameIntegrationEntitySelect.destroy(); _cssGameIntegrationEntitySelect = null; }
|
||||||
_destroyCSSGameMappingIconSelects();
|
_destroyCSSGameMappingIconSelects();
|
||||||
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
|
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();
|
compositeDestroyEntitySelects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +152,9 @@ class CSSEditorModal extends Modal {
|
|||||||
ge_integration: (document.getElementById('css-editor-game-integration') as HTMLInputElement)?.value || '',
|
ge_integration: (document.getElementById('css-editor-game-integration') as HTMLInputElement)?.value || '',
|
||||||
ge_idle_color: _gameEventIdleColorWidget ? JSON.stringify(_gameEventIdleColorWidget.getValue()) : '[]',
|
ge_idle_color: _gameEventIdleColorWidget ? JSON.stringify(_gameEventIdleColorWidget.getValue()) : '[]',
|
||||||
ge_mappings: JSON.stringify(_cssGameMappings),
|
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() : []),
|
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -168,6 +174,9 @@ let _candlelightSpeedWidget: BindableScalarWidget | null = null;
|
|||||||
let _candlelightWindWidget: BindableScalarWidget | null = null;
|
let _candlelightWindWidget: BindableScalarWidget | null = null;
|
||||||
let _weatherSpeedWidget: BindableScalarWidget | null = null;
|
let _weatherSpeedWidget: BindableScalarWidget | null = null;
|
||||||
let _weatherTempInfluenceWidget: 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 ──
|
// ── BindableColorWidget instances for CSS editor ──
|
||||||
let _staticColorWidget: BindableColorWidget | null = null;
|
let _staticColorWidget: BindableColorWidget | null = null;
|
||||||
@@ -260,7 +269,7 @@ const CSS_TYPE_KEYS = [
|
|||||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||||
'effect', 'composite', 'mapped', 'audio',
|
'effect', 'composite', 'mapped', 'audio',
|
||||||
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||||
'game_event',
|
'game_event', 'math_wave',
|
||||||
];
|
];
|
||||||
|
|
||||||
function _buildCSSTypeItems() {
|
function _buildCSSTypeItems() {
|
||||||
@@ -309,6 +318,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
|
|||||||
'processed': 'css-editor-processed-section',
|
'processed': 'css-editor-processed-section',
|
||||||
'key_colors': 'css-editor-key-colors-section',
|
'key_colors': 'css-editor-key-colors-section',
|
||||||
'game_event': 'css-editor-game-event-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))];
|
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(); },
|
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
|
||||||
candlelight: () => _ensureCandleTypeIconSelect(),
|
candlelight: () => _ensureCandleTypeIconSelect(),
|
||||||
game_event: () => { _populateGameIntegrationDropdownCSS(); _initCSSGamePresetIconSelect(); },
|
game_event: () => { _populateGameIntegrationDropdownCSS(); _initCSSGamePresetIconSelect(); },
|
||||||
|
math_wave: () => { _ensureMathWaveGradientEntitySelect(); },
|
||||||
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
|
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
|
||||||
composite: () => compositeRenderList(),
|
composite: () => compositeRenderList(),
|
||||||
mapped: () => _mappedRenderList(),
|
mapped: () => _mappedRenderList(),
|
||||||
@@ -376,7 +387,7 @@ export function onCSSTypeChange() {
|
|||||||
hasLedCount.includes(type) ? '' : 'none';
|
hasLedCount.includes(type) ? '' : 'none';
|
||||||
|
|
||||||
// Sync clock — shown for animated types
|
// 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';
|
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
||||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||||
|
|
||||||
@@ -1118,6 +1129,127 @@ function _ensureCandleTypeIconSelect() {
|
|||||||
_candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
_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() {
|
function _ensureAudioPaletteEntitySelect() {
|
||||||
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
|
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -1177,13 +1309,14 @@ function _ensureGradientPresetEntitySelect() {
|
|||||||
/** Rebuild the gradient picker after entity changes. */
|
/** Rebuild the gradient picker after entity changes. */
|
||||||
export function refreshGradientPresetPicker() {
|
export function refreshGradientPresetPicker() {
|
||||||
// Re-sync select options before refreshing entity selects
|
// 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;
|
const sel = document.getElementById(selId) as HTMLSelectElement | null;
|
||||||
if (sel) _syncSelectOptions(sel, _buildGradientEntityItems());
|
if (sel) _syncSelectOptions(sel, _buildGradientEntityItems());
|
||||||
}
|
}
|
||||||
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.refresh();
|
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.refresh();
|
||||||
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.refresh();
|
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.refresh();
|
||||||
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.refresh();
|
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.refresh();
|
||||||
|
if (_mathWaveGradientEntitySelect) _mathWaveGradientEntitySelect.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the user-created gradient list below the save button. */
|
/** 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([
|
const NON_PICTURE_TYPES = new Set([
|
||||||
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
|
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
|
||||||
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||||
|
'math_wave',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
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>
|
<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) => {
|
processed: (source) => {
|
||||||
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
|
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
|
||||||
const inputName = inputSrc?.name || 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: {
|
game_event: {
|
||||||
load(css: any) {
|
load(css: any) {
|
||||||
_populateGameIntegrationDropdownCSS(css.game_integration_id || '');
|
_populateGameIntegrationDropdownCSS(css.game_integration_id || '');
|
||||||
@@ -2579,7 +2758,7 @@ export async function saveCSSEditor() {
|
|||||||
payload.source_type = knownType ? sourceType : 'picture';
|
payload.source_type = knownType ? sourceType : 'picture';
|
||||||
|
|
||||||
// Attach clock_id for animated types
|
// 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)) {
|
if (clockTypes.includes(sourceType)) {
|
||||||
const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value;
|
const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value;
|
||||||
payload.clock_id = clockVal || null;
|
payload.clock_id = clockVal || null;
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export type CSSSourceType =
|
|||||||
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
|
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
|
||||||
| 'audio' | 'api_input' | 'notification' | 'daylight'
|
| 'audio' | 'api_input' | 'notification' | 'daylight'
|
||||||
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
|
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
|
||||||
| 'game_event';
|
| 'game_event' | 'math_wave';
|
||||||
|
|
||||||
export interface ColorStop {
|
export interface ColorStop {
|
||||||
position: number;
|
position: number;
|
||||||
@@ -288,6 +288,10 @@ export interface ColorStripSource {
|
|||||||
game_integration_id?: string;
|
game_integration_id?: string;
|
||||||
idle_color?: BindableColor;
|
idle_color?: BindableColor;
|
||||||
event_mappings?: GameEventMapping[];
|
event_mappings?: GameEventMapping[];
|
||||||
|
|
||||||
|
// Math Wave
|
||||||
|
waves?: Array<{ waveform: string; frequency: number; amplitude: number; phase: number; offset: number }>;
|
||||||
|
gradient_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pattern Template ──────────────────────────────────────────
|
// ── 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.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.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": "Game Event",
|
||||||
"value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values",
|
"value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values",
|
||||||
"value_source.game_event.integration": "Game Integration:",
|
"value_source.game_event.integration": "Game Integration:",
|
||||||
|
|||||||
@@ -1970,6 +1970,26 @@
|
|||||||
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
|
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
|
||||||
"color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.",
|
"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": "Игровое событие",
|
||||||
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
|
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
|
||||||
"value_source.game_event.integration": "Игровая интеграция:",
|
"value_source.game_event.integration": "Игровая интеграция:",
|
||||||
|
|||||||
@@ -1968,6 +1968,26 @@
|
|||||||
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
|
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
|
||||||
"color_strip.game_event.error.no_integration": "请选择游戏集成。",
|
"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": "游戏事件",
|
||||||
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
|
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
|
||||||
"value_source.game_event.integration": "游戏集成:",
|
"value_source.game_event.integration": "游戏集成:",
|
||||||
|
|||||||
@@ -1743,6 +1743,141 @@ class GameEventColorStripSource(ColorStripSource):
|
|||||||
self.led_count = kwargs["led_count"]
|
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 --
|
# -- Source type registry --
|
||||||
# Maps source_type string to its subclass for factory dispatch.
|
# Maps source_type string to its subclass for factory dispatch.
|
||||||
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
||||||
@@ -1763,4 +1898,5 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
|||||||
"weather": WeatherColorStripSource,
|
"weather": WeatherColorStripSource,
|
||||||
"key_colors": KeyColorsColorStripSource,
|
"key_colors": KeyColorsColorStripSource,
|
||||||
"game_event": GameEventColorStripSource,
|
"game_event": GameEventColorStripSource,
|
||||||
|
"math_wave": MathWaveColorStripSource,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
|
<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="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="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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -730,6 +731,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Shared LED count field -->
|
||||||
<div id="css-editor-led-count-group" class="form-group">
|
<div id="css-editor-led-count-group" class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
Reference in New Issue
Block a user