diff --git a/README.md b/README.md index 96eb1a9f..50550342 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Drive large LED installations and DMX lighting from ESP32, Teensy, Raspberry Pi, Windows, macOS or Linux desktop. One source tree, multiple targets. -![Web UI](docs/assets/screenshots/ui_theme.gif) +![Web UI](docs/assets/ui/ui_theme.gif) πŸ‘‰ **Try it now:** flash an ESP32 straight from your browser β†’ β€” step-by-step in the [Getting started guide](docs/gettingstarted.md). @@ -90,7 +90,7 @@ The numbers above are observations. The **contracts** projectMM commits to, what **ESP32: flash from your browser.** Open the [web installer](https://moonmodules.org/projectMM/install/) in Chrome or Edge; it walks you through release, device and firmware selection, flashing, and network setup. The installer lists stable releases and a `latest` build (published automatically on every merge to main) carrying the newest unreleased changes. -![Installer](docs/assets/screenshots/installer.png) +![Installer](docs/assets/ui/installer.png) **Desktop: download and run.** Grab the build for your OS from the [releases page](https://github.com/MoonModules/projectMM/releases): @@ -113,7 +113,7 @@ uv run scripts/moondeck.py Open `http://localhost:8420`: PC tab to build / run / test, ESP32 tab to flash, Live tab to discover devices. Full per-command reference: [scripts/MoonDeck.md](scripts/MoonDeck.md). -![Moondeck Pc](docs/assets/screenshots/moondeck_pc.png) +![Moondeck Pc](docs/assets/ui/moondeck_pc.png) ## Documentation @@ -165,6 +165,7 @@ Specific people whose work directly shaped parts of projectMM. We study their th - **[hpwit](https://github.com/hpwit) (Yves Bazin)** β€” the clockless I2S / RMT / Parlio LED-driver techniques and the [ESPLiveScript](https://github.com/hpwit/ESPLiveScript) live-script engine behind the LED drivers and MoonLive. - **Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve))** β€” author of the native [WLED-Android](https://github.com/Moustachauve/WLED-Android) app. Its source let us reverse-engineer exactly what the WLED app reads, so projectMM devices appear in (and are controllable from) the native WLED apps. - **The [Improv Wi-Fi](https://github.com/improv-wifi) project** β€” the open Improv serial provisioning standard ([sdk-cpp](https://github.com/improv-wifi/sdk-cpp) / [sdk-js](https://github.com/improv-wifi/sdk-js)) that the projectMM web installer uses to provision a freshly-flashed device over USB. +- **[FastLED](https://github.com/FastLED/FastLED)** β€” the canonical LED-effects library whose conventions the LED-effect world shares. projectMM links no part of FastLED, but it carries forward FastLED's recognisable *names and models* for the colour/animation primitives β€” `scale8`, `sin8`, the gradient-palette model (`CRGBPalette16` / `colorFromPalette`), the `beatsin8` / `inoise8` / `qadd8` family β€” so a contributor recognises them on sight. The implementations are projectMM's own, integer-only and hot-path-tuned for our render loop; FastLED is the prior art behind the convention, credited here and in each primitive's notes. ## Contributing diff --git a/docs/architecture.md b/docs/architecture.md index cf101a8a..2257f900 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -259,7 +259,7 @@ The defining line is the **data relationship, not the connector**: *does the mod Peripherals are **user-add/deletable children of SystemModule**: the firmware is identical whether or not the hardware is wired, so the user adds the module when they solder a gyro on and removes it later, reusing the generic child add/replace/delete + persistence machinery (SystemModule declares `acceptsChildRoles("peripheral")`). Direction is per-module, not a role: a peripheral may read (gyro), write (relay), or both, so one `Peripheral` role spans the category. Each is a header-only or `.h`+`.cpp` core module under `src/core/`, reaches hardware only through a domain-neutral platform primitive (`platform::i2c*`, `platform::audioMic*`, …), and gets a spec in `docs/moonmodules/core/` (enforced by `check_specs.py`). Most poll in `loop20ms`/`loop1s`; the exception is a peripheral whose data an effect consumes *every frame*: [AudioModule](moonmodules/core/AudioModule.md) reads + analyses its IΒ²S microphone in `loop()` because the audio effects react per render tick, and its per-tick cost (one FFT) is part of the render budget. Automatic bus-probe detection is out of scope; the manual path is the foundation. -**An effect reads a peripheral's data** via the shared-struct pull pattern from [Β§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral owns a small POD struct overwritten in place each poll/tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects/AudioVolumeEffect.md) and [AudioSpectrumEffect](moonmodules/light/effects/AudioSpectrumEffect.md) consume. It reaches the frame through a static `AudioModule::latestFrame()` rather than a boot-time setter, a small variation on the pattern, because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely. +**An effect reads a peripheral's data** via the shared-struct pull pattern from [Β§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral owns a small POD struct overwritten in place each poll/tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects/effects.md) and [AudioSpectrumEffect](moonmodules/light/effects/effects.md) consume. It reaches the frame through a static `AudioModule::latestFrame()` rather than a boot-time setter, a small variation on the pattern, because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely. ## Multi-device runtime @@ -369,17 +369,32 @@ Effects know nothing about hardware, protocols, physical LED layout, or mapping. **Speed convention.** Effects with a speed control use BPM (beats per minute). `uint8_t`, default 60 (= 1 beat per second). Human-readable, musically meaningful, DMX-compatible. The effect converts BPM to animation rate internally using elapsed millis. +### Buffer persistence β€” the layer does not clear each frame + +The Layer's buffer **persists** frame to frame: `Layer::loop()` does not clear it before running effects. The buffer holds the previous frame until an effect overwrites or fades it, and is zeroed **once** on allocation/resize. (This is the standard LED-animation model β€” FastLED, WLED, and MoonLight all persist their frame buffer rather than auto-clear.) Persistence holds **between frames, not across rebuilds**: `Layer::onBuildState()` clears the buffer once after `rebuildLUT()`, so adding, replacing, or reconfiguring an effect (or a resize) starts from black, then persistence takes over frame to frame again. Each effect owns its own background: + +- A **full-grid** effect (Plasma, Rainbow, Fire, Noise) writes every pixel each frame β€” the previous frame is simply overwritten. +- A **trail** effect calls `layer()->fadeToBlackBy(amt)` to decay the previous frame before painting its new pixels β€” a comet leaves a fading tail because the old pixels are still there to fade. +- A **read-prior** effect (a scroll like FreqMatrix, Game-of-Life, a blur) reads last frame's pixels via `draw::get` / `draw::blur` and acts on them β€” the persistence *is* its state. +- A **sparse** effect that wants a clean frame calls `draw::fill(buf, {0,0,0})` itself (e.g. RubiksCube, whose draw touches only surface voxels). + +There is deliberately **no per-effect "persistence" flag**: persistence is universal, so a flag would change no framework behaviour (unlike `dimensions()`, which drives extrude). Multiple effects on one layer *interact* through the shared persistent buffer β€” that is a feature. + +**Collected fade (`Layer::fadeToBlackBy`).** Fade is a Layer operation, not a per-effect buffer pass (MoonLight's `VirtualLayer::fadeToBlackBy`). Effects register a fade amount; the Layer keeps the **MIN** across all requesting effects (the gentlest fade wins, so the longest requested trail is honoured) and applies **one** whole-buffer pass at the start of the next frame, then resets the collected amount. So N fading effects on one layer cost a single pass, not N, and one effect's fade never darkens another's just-painted pixels mid-frame. An auto-clear would make trails and read-prior effects impossible without a shadow buffer; this model gets them for free. + ### Dimensionality Every effect declares its native dimensionality through `EffectBase::dimensions()`, returning `Dim::D1`, `Dim::D2`, or `Dim::D3` (default: "I iterate every axis the layer gives me"). The Layer uses this to **extrude** lower-dimensional output across the unused axes after each effect's `loop()`: -- **D1**: the effect writes only the row at `(y=0, z=0)`. Layer copies that row across every other y in z=0, then copies z=0 across every z. -- **D2**: the effect writes only the z=0 slice. Layer copies z=0 across every z. +- **D1**: the effect writes only the column at `(x=0, z=0)` β€” **1D runs along Y**. Layer copies that column across every other x in z=0, then copies z=0 across every z. +- **D2**: the effect writes only the z=0 slice (the front `(x, y)` face). Layer copies z=0 across every z. - **D3**: the effect writes every axis itself. Extrude is a one-comparison no-op. -D1/D2 are **opt-in promises**: declaring them tells the framework it can fill the missing axes, saving the per-effect work of iterating z (or y and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer. +D1/D2 are **opt-in promises**: declaring them tells the framework it can fill the missing axes, saving the per-effect work of iterating z (or x and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer. -Hot-path cost: extrude pays one comparison and returns for the D3 case. For D1/D2 on a layer whose unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D layer) the inner loops are guarded by `depth_ > 1` / `height_ > 1` and never run. Real `memcpy` work happens only for a D1 or D2 effect on a layer with more dimensions than the effect writes: exactly the case where you wanted the framework to do the duplication. +**Why 1D runs along Y, and the unified expand rule.** A lower-D effect occupies the *low* axes and the framework expands it across the *next* axis: **1D β†’ 2D adds columns across X** (the 1D output is the first column, duplicated rightward); **2D β†’ 3D adds slices across Z** (the 2D front face, duplicated in depth). 1D-along-Y is the deliberate choice (shared with MoonLight): it makes a 1D effect the natural **first column** of its 2D form β€” write the effect once down Y, and expanding to a panel is just "repeat the column," same math, no special-casing. (The alternative, 1D-along-X, would make 1D a *row* that expands *downward* β€” a less natural fit, since a strip is a column and a 2D effect's columns are what you tile.) Concretely: a 1D effect draws its shape down Y, so it renders correctly on a layer whose single populated axis is Y (a `1 Γ— N` grid, width 1, height N); on an `N Γ— 1` grid the extrude would run the wrong way and collapse the shape to a flat line. How a physical output (a strip, a row of [Hue lights](moonmodules/light/drivers/HueDriver.md)) maps to a `1 Γ— N` grid is a layout concern β€” see the layout docs. + +Hot-path cost: extrude pays one comparison and returns for the D3 case. For D1/D2 on a layer whose unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D `1 Γ— N` layer) the inner loops are guarded by `depth_ > 1` / `width_ > 1` and never run. Real `memcpy` work happens only for a D1 or D2 effect on a layer with more dimensions than the effect writes: exactly the case where you wanted the framework to do the duplication. Each effect's `dimensions()` is a claim about which axes its loop iterates, not which axes its math could in principle vary along. A "D2 fire" can in future be promoted to D3 by adding z-aware heat propagation; until then declaring it D2 honestly describes what the loop does today. @@ -520,7 +535,7 @@ How lighting uses the core [multi-device runtime](#multi-device-runtime) (discov # Web UI -![UI overview](assets/screenshots/ui_overview.png) +![UI overview](assets/ui/ui_overview.png) The UI is a handful of hand-maintained files: `index.html`, `app.js`, `style.css`, plus two focused ES modules `app.js` imports (`preview3d.js` for the WebGL 3D preview, `install-picker.js` shared with the web installer). No frameworks, no build tools, no npm. Served directly by the embedded HTTP server. @@ -534,6 +549,20 @@ Adding a new MoonModule with controls needs **zero changes** to the UI files. Th The light domain plugs into the UI at three points: a fixed top-level tree (Layouts / Layers / Drivers pinned in `main.cpp`, root reorder disabled while child reorder works via drag-and-drop), a binary WebSocket preview channel ([PreviewDriver](moonmodules/light/drivers/PreviewDriver.md): a `0x03` coordinate table sent once per LUT rebuild plus per-frame `0x02` RGB point lists, so sparse layouts preview at their real positions), and per-role emoji for the chip filter (the `ROLE_EMOJI` map in `app.js` is the single source of truth: `effect`, `driver`, …, `peripheral`). Full UI spec: [docs/moonmodules/core/ui.md](moonmodules/core/ui.md). +## Tag emoji legend + +A module's chips come from three sources, rendered identically on the card and the type picker: a **role** chip (UI-derived from `role`), a **dimensional** chip (UI-derived from `dim`), and the curated **`tags()`** string (a flash literal the module returns; the UI splits it into grapheme clusters, one chip each). Role and dim are *not* repeated in `tags()` β€” only the categories below are. The `ROLE_EMOJI` / `DIM_EMOJI` maps in `app.js` are the single source of truth for the UI-derived chips; the legend takes [MoonLight](https://github.com/MoonModules/MoonLight)'s set as the canonical basis: + +| Category | Emoji | Meaning | +|---|---|---| +| **Role** (UI-derived) | πŸ”₯ effect Β· πŸ’Ž modifier Β· πŸš₯ layout Β· ☸️ driver Β· πŸ›°οΈ peripheral Β· πŸ₯ž layer Β· βš™οΈ generic | what kind of module (from `role`, via `ROLE_EMOJI`) | +| **Dimensionality** (UI-derived) | πŸ“ 1D Β· 🟦 2D Β· 🧊 3D | native axes (from `dim`) | +| **Origin / library** (`tags()`) | πŸ’« MoonLight Β· πŸ™ WLED Β· ⚑️ FastLED Β· *(projectMM-native is the default origin β€” an origin emoji marks a module that came from elsewhere)* | which library the module came from; the [migration](../backlog/moonlight-effect-inventory.md) files docs by this, the emoji filters by it | +| **Creator** (`tags()`) | πŸ¦… a named contributor (credited at the introduction site) | individual authorship credit | +| **Audio** (`tags()`) | πŸ”Š audio-reactive | reads `AudioModule::latestFrame()` | + +`tags()` carries **only** origin + creator + audio (+ any genuinely module-specific marker); a module can carry several (e.g. `πŸ’«πŸ¦…` = MoonLight origin, a named creator). Role and dim are added by the UI, so a module never duplicates them in its string. When migrating, set each module's `tags()` from this legend so the chip set is consistent across the library. + ## What we leave undesigned Genuinely open questions, *not* the same as a 🚧 marker. A 🚧 item has a settled, committed design (two-core handover, clock sync, device-to-device light distribution) β€” code is written toward it; the items here are ones where the *design itself* is still open, deferred until a concrete need forces the decision: diff --git a/docs/assets/screenshots/Devices module.png b/docs/assets/core/Devices module.png similarity index 100% rename from docs/assets/screenshots/Devices module.png rename to docs/assets/core/Devices module.png diff --git a/docs/assets/screenshots/FilesystemModule.png b/docs/assets/core/FilesystemModule.png similarity index 100% rename from docs/assets/screenshots/FilesystemModule.png rename to docs/assets/core/FilesystemModule.png diff --git a/docs/assets/screenshots/FirmwareUpdateModule.png b/docs/assets/core/FirmwareUpdateModule.png similarity index 100% rename from docs/assets/screenshots/FirmwareUpdateModule.png rename to docs/assets/core/FirmwareUpdateModule.png diff --git a/docs/assets/core/Hue device disco.png b/docs/assets/core/Hue device disco.png new file mode 100644 index 00000000..cec4b461 Binary files /dev/null and b/docs/assets/core/Hue device disco.png differ diff --git a/docs/assets/screenshots/Layers.png b/docs/assets/core/Layers.png similarity index 100% rename from docs/assets/screenshots/Layers.png rename to docs/assets/core/Layers.png diff --git a/docs/assets/screenshots/Layouts.png b/docs/assets/core/Layouts.png similarity index 100% rename from docs/assets/screenshots/Layouts.png rename to docs/assets/core/Layouts.png diff --git a/docs/assets/screenshots/NetworkModule.png b/docs/assets/core/NetworkModule.png similarity index 100% rename from docs/assets/screenshots/NetworkModule.png rename to docs/assets/core/NetworkModule.png diff --git a/docs/assets/screenshots/SystemModule.png b/docs/assets/core/SystemModule.png similarity index 100% rename from docs/assets/screenshots/SystemModule.png rename to docs/assets/core/SystemModule.png diff --git a/docs/assets/screenshots/WLED Native discovers projectMM.jpeg b/docs/assets/core/WLED Native discovers projectMM.jpeg similarity index 100% rename from docs/assets/screenshots/WLED Native discovers projectMM.jpeg rename to docs/assets/core/WLED Native discovers projectMM.jpeg diff --git a/docs/assets/screenshots/Wled discovers projectMM.png b/docs/assets/core/Wled discovers projectMM.png similarity index 100% rename from docs/assets/screenshots/Wled discovers projectMM.png rename to docs/assets/core/Wled discovers projectMM.png diff --git a/docs/assets/screenshots/Drivers.png b/docs/assets/light/drivers/Drivers.png similarity index 100% rename from docs/assets/screenshots/Drivers.png rename to docs/assets/light/drivers/Drivers.png diff --git a/docs/assets/light/drivers/Hue driver.png b/docs/assets/light/drivers/Hue driver.png new file mode 100644 index 00000000..be88ac39 Binary files /dev/null and b/docs/assets/light/drivers/Hue driver.png differ diff --git a/docs/assets/light/drivers/Hue friendly effect.png b/docs/assets/light/drivers/Hue friendly effect.png new file mode 100644 index 00000000..57e92a46 Binary files /dev/null and b/docs/assets/light/drivers/Hue friendly effect.png differ diff --git a/docs/assets/screenshots/NetworkSendDriver.png b/docs/assets/light/drivers/NetworkSendDriver.png similarity index 100% rename from docs/assets/screenshots/NetworkSendDriver.png rename to docs/assets/light/drivers/NetworkSendDriver.png diff --git a/docs/assets/screenshots/PreviewDriver.png b/docs/assets/light/drivers/PreviewDriver.png similarity index 100% rename from docs/assets/screenshots/PreviewDriver.png rename to docs/assets/light/drivers/PreviewDriver.png diff --git a/docs/assets/screenshots/FireEffect.gif b/docs/assets/light/effects/FireEffect.gif similarity index 100% rename from docs/assets/screenshots/FireEffect.gif rename to docs/assets/light/effects/FireEffect.gif diff --git a/docs/assets/screenshots/FireEffect.png b/docs/assets/light/effects/FireEffect.png similarity index 100% rename from docs/assets/screenshots/FireEffect.png rename to docs/assets/light/effects/FireEffect.png diff --git a/docs/assets/screenshots/LavaLampEffect.gif b/docs/assets/light/effects/LavaLampEffect.gif similarity index 100% rename from docs/assets/screenshots/LavaLampEffect.gif rename to docs/assets/light/effects/LavaLampEffect.gif diff --git a/docs/assets/screenshots/LavaLampEffect.png b/docs/assets/light/effects/LavaLampEffect.png similarity index 100% rename from docs/assets/screenshots/LavaLampEffect.png rename to docs/assets/light/effects/LavaLampEffect.png diff --git a/docs/assets/screenshots/LinesEffect.gif b/docs/assets/light/effects/LinesEffect.gif similarity index 100% rename from docs/assets/screenshots/LinesEffect.gif rename to docs/assets/light/effects/LinesEffect.gif diff --git a/docs/assets/screenshots/LinesEffect.png b/docs/assets/light/effects/LinesEffect.png similarity index 100% rename from docs/assets/screenshots/LinesEffect.png rename to docs/assets/light/effects/LinesEffect.png diff --git a/docs/assets/screenshots/MetaballsEffect.gif b/docs/assets/light/effects/MetaballsEffect.gif similarity index 100% rename from docs/assets/screenshots/MetaballsEffect.gif rename to docs/assets/light/effects/MetaballsEffect.gif diff --git a/docs/assets/screenshots/MetaballsEffect.png b/docs/assets/light/effects/MetaballsEffect.png similarity index 100% rename from docs/assets/screenshots/MetaballsEffect.png rename to docs/assets/light/effects/MetaballsEffect.png diff --git a/docs/assets/screenshots/NoiseEffect.gif b/docs/assets/light/effects/NoiseEffect.gif similarity index 100% rename from docs/assets/screenshots/NoiseEffect.gif rename to docs/assets/light/effects/NoiseEffect.gif diff --git a/docs/assets/screenshots/NoiseEffect.png b/docs/assets/light/effects/NoiseEffect.png similarity index 100% rename from docs/assets/screenshots/NoiseEffect.png rename to docs/assets/light/effects/NoiseEffect.png diff --git a/docs/assets/screenshots/ParticlesEffect.gif b/docs/assets/light/effects/ParticlesEffect.gif similarity index 100% rename from docs/assets/screenshots/ParticlesEffect.gif rename to docs/assets/light/effects/ParticlesEffect.gif diff --git a/docs/assets/screenshots/ParticlesEffect.png b/docs/assets/light/effects/ParticlesEffect.png similarity index 100% rename from docs/assets/screenshots/ParticlesEffect.png rename to docs/assets/light/effects/ParticlesEffect.png diff --git a/docs/assets/screenshots/PlasmaEffect.gif b/docs/assets/light/effects/PlasmaEffect.gif similarity index 100% rename from docs/assets/screenshots/PlasmaEffect.gif rename to docs/assets/light/effects/PlasmaEffect.gif diff --git a/docs/assets/screenshots/PlasmaEffect.png b/docs/assets/light/effects/PlasmaEffect.png similarity index 100% rename from docs/assets/screenshots/PlasmaEffect.png rename to docs/assets/light/effects/PlasmaEffect.png diff --git a/docs/assets/screenshots/RainbowEffect.gif b/docs/assets/light/effects/RainbowEffect.gif similarity index 100% rename from docs/assets/screenshots/RainbowEffect.gif rename to docs/assets/light/effects/RainbowEffect.gif diff --git a/docs/assets/screenshots/RainbowEffect.png b/docs/assets/light/effects/RainbowEffect.png similarity index 100% rename from docs/assets/screenshots/RainbowEffect.png rename to docs/assets/light/effects/RainbowEffect.png diff --git a/docs/assets/screenshots/RingsEffect.gif b/docs/assets/light/effects/RingsEffect.gif similarity index 100% rename from docs/assets/screenshots/RingsEffect.gif rename to docs/assets/light/effects/RingsEffect.gif diff --git a/docs/assets/screenshots/RingsEffect.png b/docs/assets/light/effects/RingsEffect.png similarity index 100% rename from docs/assets/screenshots/RingsEffect.png rename to docs/assets/light/effects/RingsEffect.png diff --git a/docs/assets/screenshots/RipplesEffect.gif b/docs/assets/light/effects/RipplesEffect.gif similarity index 100% rename from docs/assets/screenshots/RipplesEffect.gif rename to docs/assets/light/effects/RipplesEffect.gif diff --git a/docs/assets/screenshots/RipplesEffect.png b/docs/assets/light/effects/RipplesEffect.png similarity index 100% rename from docs/assets/screenshots/RipplesEffect.png rename to docs/assets/light/effects/RipplesEffect.png diff --git a/docs/assets/screenshots/SpiralEffect.gif b/docs/assets/light/effects/SpiralEffect.gif similarity index 100% rename from docs/assets/screenshots/SpiralEffect.gif rename to docs/assets/light/effects/SpiralEffect.gif diff --git a/docs/assets/screenshots/SpiralEffect.png b/docs/assets/light/effects/SpiralEffect.png similarity index 100% rename from docs/assets/screenshots/SpiralEffect.png rename to docs/assets/light/effects/SpiralEffect.png diff --git a/docs/assets/light/effects/StarSkyEffect.gif b/docs/assets/light/effects/StarSkyEffect.gif new file mode 100644 index 00000000..76ef363f Binary files /dev/null and b/docs/assets/light/effects/StarSkyEffect.gif differ diff --git a/docs/assets/light/layouts/CarLightsLayout.gif b/docs/assets/light/layouts/CarLightsLayout.gif new file mode 100644 index 00000000..d8bcffe2 Binary files /dev/null and b/docs/assets/light/layouts/CarLightsLayout.gif differ diff --git a/docs/assets/screenshots/GridLayout.png b/docs/assets/light/layouts/GridLayout.png similarity index 100% rename from docs/assets/screenshots/GridLayout.png rename to docs/assets/light/layouts/GridLayout.png diff --git a/docs/assets/light/layouts/TorontoBarGourdsLayout.gif b/docs/assets/light/layouts/TorontoBarGourdsLayout.gif new file mode 100644 index 00000000..60b1e196 Binary files /dev/null and b/docs/assets/light/layouts/TorontoBarGourdsLayout.gif differ diff --git a/docs/assets/screenshots/CheckerboardModifier.gif b/docs/assets/light/modifiers/CheckerboardModifier.gif similarity index 100% rename from docs/assets/screenshots/CheckerboardModifier.gif rename to docs/assets/light/modifiers/CheckerboardModifier.gif diff --git a/docs/assets/screenshots/CheckerboardModifier.png b/docs/assets/light/modifiers/CheckerboardModifier.png similarity index 100% rename from docs/assets/screenshots/CheckerboardModifier.png rename to docs/assets/light/modifiers/CheckerboardModifier.png diff --git a/docs/assets/screenshots/MirrorModifier.gif b/docs/assets/light/modifiers/MirrorModifier.gif similarity index 100% rename from docs/assets/screenshots/MirrorModifier.gif rename to docs/assets/light/modifiers/MirrorModifier.gif diff --git a/docs/assets/screenshots/MirrorModifier.png b/docs/assets/light/modifiers/MirrorModifier.png similarity index 100% rename from docs/assets/screenshots/MirrorModifier.png rename to docs/assets/light/modifiers/MirrorModifier.png diff --git a/docs/assets/screenshots/MultiplyModifier.gif b/docs/assets/light/modifiers/MultiplyModifier.gif similarity index 100% rename from docs/assets/screenshots/MultiplyModifier.gif rename to docs/assets/light/modifiers/MultiplyModifier.gif diff --git a/docs/assets/screenshots/MultiplyModifier.png b/docs/assets/light/modifiers/MultiplyModifier.png similarity index 100% rename from docs/assets/screenshots/MultiplyModifier.png rename to docs/assets/light/modifiers/MultiplyModifier.png diff --git a/docs/assets/screenshots/CheckerboardEffect.gif b/docs/assets/screenshots/CheckerboardEffect.gif deleted file mode 100644 index b52a9756..00000000 Binary files a/docs/assets/screenshots/CheckerboardEffect.gif and /dev/null differ diff --git a/docs/assets/screenshots/CheckerboardEffect.png b/docs/assets/screenshots/CheckerboardEffect.png deleted file mode 100644 index e3795c78..00000000 Binary files a/docs/assets/screenshots/CheckerboardEffect.png and /dev/null differ diff --git a/docs/assets/screenshots/GameOfLifeEffect.gif b/docs/assets/screenshots/GameOfLifeEffect.gif deleted file mode 100644 index a0715a51..00000000 Binary files a/docs/assets/screenshots/GameOfLifeEffect.gif and /dev/null differ diff --git a/docs/assets/screenshots/GameOfLifeEffect.png b/docs/assets/screenshots/GameOfLifeEffect.png deleted file mode 100644 index 1fae96ad..00000000 Binary files a/docs/assets/screenshots/GameOfLifeEffect.png and /dev/null differ diff --git a/docs/assets/screenshots/GlowParticlesEffect.gif b/docs/assets/screenshots/GlowParticlesEffect.gif deleted file mode 100644 index 704e7842..00000000 Binary files a/docs/assets/screenshots/GlowParticlesEffect.gif and /dev/null differ diff --git a/docs/assets/screenshots/GlowParticlesEffect.png b/docs/assets/screenshots/GlowParticlesEffect.png deleted file mode 100644 index 80760ec7..00000000 Binary files a/docs/assets/screenshots/GlowParticlesEffect.png and /dev/null differ diff --git a/docs/assets/screenshots/PlasmaPaletteEffect.gif b/docs/assets/screenshots/PlasmaPaletteEffect.gif deleted file mode 100644 index bca860f4..00000000 Binary files a/docs/assets/screenshots/PlasmaPaletteEffect.gif and /dev/null differ diff --git a/docs/assets/screenshots/PlasmaPaletteEffect.png b/docs/assets/screenshots/PlasmaPaletteEffect.png deleted file mode 100644 index b6d76108..00000000 Binary files a/docs/assets/screenshots/PlasmaPaletteEffect.png and /dev/null differ diff --git a/docs/assets/screenshots/installer-board-picker-collapsed.png b/docs/assets/ui/installer-board-picker-collapsed.png similarity index 100% rename from docs/assets/screenshots/installer-board-picker-collapsed.png rename to docs/assets/ui/installer-board-picker-collapsed.png diff --git a/docs/assets/screenshots/installer-board-picker-expanded.png b/docs/assets/ui/installer-board-picker-expanded.png similarity index 100% rename from docs/assets/screenshots/installer-board-picker-expanded.png rename to docs/assets/ui/installer-board-picker-expanded.png diff --git a/docs/assets/screenshots/installer.png b/docs/assets/ui/installer.png similarity index 100% rename from docs/assets/screenshots/installer.png rename to docs/assets/ui/installer.png diff --git a/docs/assets/screenshots/installer2.png b/docs/assets/ui/installer2.png similarity index 100% rename from docs/assets/screenshots/installer2.png rename to docs/assets/ui/installer2.png diff --git a/docs/assets/screenshots/installer3.png b/docs/assets/ui/installer3.png similarity index 100% rename from docs/assets/screenshots/installer3.png rename to docs/assets/ui/installer3.png diff --git a/docs/assets/screenshots/moondeck_esp32.png b/docs/assets/ui/moondeck_esp32.png similarity index 100% rename from docs/assets/screenshots/moondeck_esp32.png rename to docs/assets/ui/moondeck_esp32.png diff --git a/docs/assets/screenshots/moondeck_live.png b/docs/assets/ui/moondeck_live.png similarity index 100% rename from docs/assets/screenshots/moondeck_live.png rename to docs/assets/ui/moondeck_live.png diff --git a/docs/assets/screenshots/moondeck_pc.png b/docs/assets/ui/moondeck_pc.png similarity index 100% rename from docs/assets/screenshots/moondeck_pc.png rename to docs/assets/ui/moondeck_pc.png diff --git a/docs/assets/screenshots/ui_light.png b/docs/assets/ui/ui_light.png similarity index 100% rename from docs/assets/screenshots/ui_light.png rename to docs/assets/ui/ui_light.png diff --git a/docs/assets/screenshots/ui_overview.png b/docs/assets/ui/ui_overview.png similarity index 100% rename from docs/assets/screenshots/ui_overview.png rename to docs/assets/ui/ui_overview.png diff --git a/docs/assets/screenshots/ui_theme.gif b/docs/assets/ui/ui_theme.gif similarity index 100% rename from docs/assets/screenshots/ui_theme.gif rename to docs/assets/ui/ui_theme.gif diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 48e63761..aac5ef01 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -25,7 +25,7 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`DevicePlugin`](../../src/core/DevicePlugin.h) seam (shipped: projectMM + WLED plugins). mDNS is advertise-only so projectMM appears in the native WLED apps + Home Assistant; the WLED-app interop (list + live colour + brightness control) is shipped too. What remains is *growth on the seam*, each piece additive (one plugin file, no core change): -- **More discovery plugins** β€” ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only β€” never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. +- **More discovery plugins** β€” ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only β€” never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../moonmodules/light/drivers/HueDriver.md) β€” bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the colour-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver** β€” pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP β€” do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.) - **The command half** β€” `DevicePlugin::command()` (+ per-plugin capability/auth), so projectMM can *control* a discovered foreign device, not just list it: set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`. Built when a control consumer exists; the discovery seam is already shaped to accept it (incl. hub plugins). This is the **multi-ecosystem selling point** β€” one UI controlling WLED + ESPHome + Hue. Commands split by need (the rule, not "all REST"): must-arrive config over REST; latency-critical sync over UDP (~0.5–1 ms vs REST's 10–50 ms β€” REST would visibly de-sync). - **Live peer state** β€” a discovered peer's brightness / on-off shown in our list, refreshed by polling its REST `/json` after discovery gives the IP (discovery = UDP/mDNS, state = REST). The read-side complement to the command half. - **Non-IP transports (board-gated, far future)** β€” Tasmota-MQTT / zigbee2mqtt need an MQTT client; **direct Zigbee/Thread** (S31/C6/H2 802.15.4 radio) makes projectMM the *hub itself*, driving bulbs over the mesh with no gateway β€” the standout differentiator, the biggest lift. Same plugin philosophy, a transport addition + board gate. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index f85e8600..7dd18e8c 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -76,6 +76,14 @@ Study the proven audio pipeline in MoonLight / WLED-MM (FFT band layout, AGC, be ## Effects and preview +### DemoReel: extrude hosted lower-D effects (pending) + +DemoReel hosts one effect at a time and drives its `loop()` directly. A hosted **D1/D2** effect only writes its own slice (D1 β†’ the x=0 column; D2 β†’ the z=0 plane), and β€” unlike a normal Layer child β€” it does NOT get `Layer::extrude()` applied, so on a 2D/3D grid its output stays on one column/slice instead of spreading. A first fix (call `layer()->extrude(child->dimensions())` after the child's `loop()`, mirroring `Layer::loop`) **crashed the test suite with a heap/vtable smash** β€” `Layer::extrude` copies within `buffer_` using the Layer's `width_/height_/depth_`, and something in the reel's host path leaves those out of sync with the allocated buffer (root-cause not yet pinned). Redo it carefully: verify the Layer's dims match its buffer at the extrude call, add a bounds guard in `extrude` (or a reel-local extrude that reads the child's real dims/buffer), and pin it with a DemoReel test that hosts a D1 and a D2 effect on a 3D grid and checks the spread. Until then the reel renders D1/D2 hosts on a single slice (visible but not full-grid) β€” acceptable, not a crash. + +### A real 2D/3D PacMan (pending) + +The migrated 1D PacMan and 1D Ant effects were removed β€” a chase rendered on a single strip reads as a blob of moving dots, not a game, so they weren't worth keeping. A proper **2D (or 3D) PacMan** β€” an actual maze, Pac-Man and ghosts navigating it, power dots, the blue-ghost flee state β€” would be a genuinely fun signature effect. Build it fresh as a grid game (not a strip port); the WLED/MoonLight 1D versions are reference for the state machine only, not the rendering. + ### Add real z-axis variation to 2D effects (pending) Only **NoiseEffect**, **PlasmaEffect** and **RipplesEffect** have z-aware math. The other honest-D2 effects use `Layer::extrude` to duplicate the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Rings/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its `dynamicBytes` budget for the full 3D buffer. diff --git a/docs/backlog/backlog-mixed.md b/docs/backlog/backlog-mixed.md index c477d657..99d50537 100644 --- a/docs/backlog/backlog-mixed.md +++ b/docs/backlog/backlog-mixed.md @@ -4,6 +4,18 @@ Forward-looking items whose work genuinely spans **both** the core and light dom ## Cross-domain +### LightsControl β€” the central control hub (palette + global params + presets + external controllers) + +The eventual home for **global light control**, modelled on MoonLight's `ModuleLightsControl` ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Modules/ModuleLightsControl.h), [docs](https://github.com/MoonModules/MoonLight/blob/main/docs/moonlight/lightscontrol.md) β€” our own project, study don't copy). The concept worth carrying forward: **one module is the integration point between the device and the outside world** (IR remotes, DMX-in, MQTT / Home Assistant, hardware buttons, PIR) *and* the owner of the global light state effects read β€” so effects respond to one normalised interface instead of N disparate inputs. It owns: master on/off + brightness, RGB tint multipliers, the **active palette**, global **bpm / intensity** sliders broadcast to all active effects, and the **preset** system (below). + +**Pragmatic interim (decided 2026-06-30):** we are *not* building this module now. Its two pieces we need first live on the **Drivers** container (already the owner of global render params β€” brightness, lightPreset, the shared Correction): the **active palette** lands there in the palette stage (a `palette` select; effects read it via a static `Palettes::active()` seam, the `AudioModule::latestFrame()` pattern), and global **bpm / intensity** can follow the same way when a consumer needs them. When LightsControl is eventually built, it **absorbs** these controls from Drivers and becomes the hub the external controllers feed. Not a blocker for the palette work. + +### Light presets β€” save / load / loop a whole effect configuration + +MoonLight's preset system (part of `ModuleLightsControl`): **64 named slots**, each capturing the *complete* effect-tree configuration (all module control values), with save / load / delete, **preset looping** on a timer (rotate between a first/last slot), and Home-Assistant registration for external recall. The product owner has flagged this as **definitely needed**. + +Not a dependency of palettes β€” a separate feature, its natural home the LightsControl module above (or Drivers in the interim). Persistence already round-trips control values (the same overlay that restores the device-tree), so a preset is "snapshot the relevant subtree's JSON to a named file, restore it on recall" β€” the mechanism mostly exists; what's missing is the slot management + UI + loop timer. Complements the existing [presets UI note](backlog-core.md) (control-value bundles) β€” this is the light-domain, whole-effect-config version with looping. Build as its own stage when LightsControl (or its interim) is ready. + ### MultiplyModifier mapping-LUT memory at large grids (investigation, re-verify on classic) `scenario_perf_full` on the S3 (2026-06-17) measured the MultiplyModifier's cost across grid sizes. The finding, stated correctly: the modifier **reduces compute** (with the default 2Γ—2 kaleidoscope the effect renders only the ΒΌ-size logical quadrant β€” Noise+Multiply at 16K is 29,647Β΅s vs 50,555Β΅s for Noise alone), and its real cost is **memory** β€” the mapping LUT's destinations array. Measured modifier heap cost on the S3: 16Β²β†’1.7KB, 32Β²β†’10.8KB, 64Β²β†’23.5KB, **128Β²(16K)β†’93KB** (`nrOfLightsType` is `uint32_t` on a PSRAM board). On the S3's 8MB PSRAM this is trivial. Under the physicalβ†’logical fold build each physical light contributes ≀1 destination, so the destinations array is bounded by the real light count regardless of chain depth β€” there is no build-time fan-out. diff --git a/docs/backlog/folder-structure-proposal.md b/docs/backlog/folder-structure-proposal.md new file mode 100644 index 00000000..fb7cb191 --- /dev/null +++ b/docs/backlog/folder-structure-proposal.md @@ -0,0 +1,99 @@ +# Folder-structure consistency β€” decision + +A *Refactor for simplicity* decision (per CLAUDE.md). Alternatives were weighed; the product owner chose. This records the chosen structure and the work to reach it. Nothing moves until each move is executed deliberately. + +## The three axes β€” and where each one earns a place + +1. **Domain** β€” `core` vs `light`. Already structured (src, docs, tests). +2. **Module type** β€” effects / modifiers / layouts / drivers. Structured in `src/light/` + `docs/moonmodules/light/`; **missing** in `test/` and `assets/` β€” added here. +3. **Library** β€” a module's *origin* (MoonLight, WLED, MoonModules, projectMM-native). **Used only as a doc split and a UI tag β€” NOT a folder axis** (decision below). + +## Decision: `domain / type` folders; library is a tag (+ a doc split) + +The structure is **` / / Module`**, flat within type. Library does **not** become a folder level. + +**Why library is not a folder** (the deciding analysis): an effect's origin is frequently *blended*, not a single fact β€” e.g. `DistortionWavesEffect` cites MoonLight + WLED + v1 + v2; `GameOfLifeEffect` cites MoonLight + MoonModules + v1; several have no clear origin. A folder forces one answer to a multi-valued question, and a *wrong* or *shifting* answer means a multi-file move (src + assets + tests + the registered `.md` path). It also duplicates the dimension the `tags()` emoji already carries (and the emoji can carry *several* origins; a folder can't). **The end user does not care about a module's library** β€” they filter by the emoji chip in the UI if they want origin at all. So library stays where it's free and non-duplicative: + +- **In code:** the `tags()` emoji (already there; drives the UI origin-filter; can be multi-valued). +- **In docs:** the page split (below) β€” the one place library earns a structural role, because docs have an explosion problem src doesn't. + +This drops every drawback of library-as-folder (fuzzy-origin filing, two-places-disagree, reclassification churn, sparse subfolders, deep paths) at once. + +### The target tree + +```text +src/light/ + effects/ EffectBase.h, Rainbow.h, Wave.h, DistortionWaves.h, … (flat per type) + modifiers/ ModifierBase.h, Multiply.h, Rotate.h, … + layouts/ GridLayout.h, SphereLayout.h, … + drivers/ Drivers.h, Correction.h, RmtLedDriver.h, HueDriver.h, … +src/core/ … (unchanged β€” no type or library axis) +``` + +Identical shape for `docs/assets/` and `test/` (below). `src/` itself is **unchanged** β€” it's already `domain/type`, flat within type; library was never there as a folder and stays out. + +## Docs: per-library pages with compact rows (solves explosion *and* giant-file) + +Per-module `.md` would be ~65 files post-migration (explosion); one all-effects file would be ~2100 lines (MoonLight's mistake). The middle ground: **one page per library**, each effect a **compact table row**. + +```text +docs/moonmodules/light/ + effects_moonlight.md ← ~30 effects, one row each (~120-150 lines) + effects_wled.md ← ~20 + effects_projectmm.md ← ~15 + modifiers_.md layouts_.md drivers_.md (where a library has them) +docs/moonmodules/core/ ← unchanged: per-module .md (stable count, no explosion) +``` + +- **Row format** (MoonLight-style): `| Name + tags | gif | one-line description | controls |`. Drops the per-module `Tests` / `Design notes` / `Source` sections β€” source is derivable from the name, tests are auto-discovered. ~4 lines/effect, so a 30-effect page is ~120 lines. +- **Why library splits docs but not src:** docs are the only area with the explosion problem, and a doc page is *forgiving* about fuzzy origin β€” a blended-lineage effect goes on one page with its full origin in the row's tags/prose; mis-filing is a one-line edit, not a multi-file move. So the drawbacks that made library-as-*folder* bad are soft for library-as-*doc-page*. +- **`check_specs.py` rewrite:** every registered module's control names must appear somewhere in its library page (preserves the anti-drift guarantee). The registered `.md` arg changes from `light/effects/Rainbow.md` to `light/effects_moonlight.md` (or `#rainbow`). This is migration **Stage 2** work. + +## assets + tests: add the missing type-split (mirror src) + +The consistency win that's independent of library β€” make `test/` and `assets/` match src's existing `domain/type` shape: + +```text +docs/assets/ + core/ DevicesModule.png, Drivers.png, … + light/ + effects/ Rainbow.gif, Wave.gif, DistortionWaves.gif, … + modifiers/ layouts/ drivers/ + boards/ gettingstarted/ ← kept as-is + +test/unit/light/ + effects/ unit_Rainbow.cpp, unit_Wave.cpp, … + modifiers/ layouts/ drivers/ +test/unit/core/ ← unchanged +test/scenarios/light/{effects,layouts,drivers,…}/ (mirror) +``` + +## The one rule, across all four areas + +| | core/light | type | leaf | library | +|---|---|---|---|---| +| **src** | `light/` | `effects/` | `DistortionWaves.h` | tag in `tags()` | +| **assets** | `light/` | `effects/` | `DistortionWaves.gif` | β€” | +| **tests** | `light/` | `effects/` | `unit_DistortionWaves.cpp` | β€” | +| **docs** | `light/` | the type+library collapse into the page name β†’ `effects_wled.md` | (row inside) | the page split | + +`docs` is the one area where `type` is expressed as part of a **page name** (`effects_.md`) rather than a folder, because the migration compacts docs to per-library pages β€” and library, the only axis with an explosion problem, rides along in that name. Everywhere else: plain `domain/type` folders, library as a tag. + +## Impacted folders + the work + +| Folder | Change | Cost | When | +|---|---|---|---| +| `docs/assets/` | flat `screenshots/` β†’ `assets/{core, light/{effects,drivers,layouts,modifiers}}/`; keep `boards/`, `gettingstarted/`. Move 63 files, update ~34 referencing docs. | Medium, mechanical | **Now** (clearly-broken flat area; PO specified the asset structure) | +| `test/unit/`, `test/scenarios/` | add the type-split under `core/`/`light/`; re-path the 81 CMake test entries. | Medium, mechanical | **Now or fold into next test-touching change** | +| `docs/moonmodules/` | per-library pages (`effects_.md`) with compact rows + the `check_specs.py` rewrite; delete the ~21 per-module effect `.md`s. | Medium | **Migration Stage 2** (already planned) | +| `src/` | **unchanged** (already `domain/type`, flat). | none | β€” | + +**Not reshaped** (correctly orthogonal): `test/js`, `test/python` (host-side; test scripts/installer, not modules), `src/platform/{desktop,esp32}` (platform split, already consistent), `src/{core,light}/moonlive` (feature sub-tree). + +## Sequencing + +1. **Now:** `docs/assets/` reorg (the broken flat area). +2. **Now / next test change:** `test/` type-split. +3. **Migration Stage 2:** per-library doc pages (compact rows) + the `check_specs.py` rewrite + delete the per-module `.md`s. + +`src/` needs no move at all β€” the leanest possible outcome. diff --git a/docs/backlog/moonlight-effect-inventory.md b/docs/backlog/moonlight-effect-inventory.md new file mode 100644 index 00000000..055953f6 --- /dev/null +++ b/docs/backlog/moonlight-effect-inventory.md @@ -0,0 +1,69 @@ +# MoonLight effect inventory (migration reference) + +The full set of MoonLight effects to migrate, grouped by **origin library** (the doc-page split: `effects_.md`), with audio/3D markers. Source: [MoonLight effects.md](https://github.com/MoonModules/MoonLight/blob/main/docs/moonlight/effects.md) + the `E_*.h` source files β€” studied for *behaviour*, reimplemented fresh per the migration plan's *Industry standards, our own code* rule. This reference feeds the [migration plan's](../history/plans/Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md) Stage-3 batches; it is *what to build*, not a copy of how. + +**Markers:** β™« / β™ͺ audio-reactive Β· 🧊 native 3D. **Status:** βœ… already in projectMM Β· ⬜ to migrate. + +## MoonLight library β†’ `effects_moonlight.md` + +| Effect | Markers | Status | Notes | +|---|---|---|---| +| Solid | | ⬜ | background/base colour | +| Lines | | βœ… | LinesEffect | +| Frequency Saws | β™« | ⬜ | audio (Stage 3d) | +| Moon Man | | ⬜ | | +| Particles | 🧊 | βœ… | ParticlesEffect | +| Rainbow | | βœ… | RainbowEffect | +| Random | | ⬜ | | +| Ripples | 🧊 | βœ… | RipplesEffect | +| Rubik's Cube | 🧊 | ⬜ | 3D | +| Scrolling Text | | ⬜ | needs a font/glyph blitter (Stage 3e) | +| Sinus | | βœ… | SineEffect | +| Sphere Move | 🧊 | ⬜ | 3D; pairs with SphereLayout | +| StarField | | ⬜ | | +| Praxis | | ⬜ | | +| Wave | | βœ… | WaveEffect | +| Fixed Rectangle | | ⬜ | | +| Star Sky | | ⬜ | | + +## MoonModules library β†’ `effects_moonmodules.md` + +| Effect | Markers | Status | Notes | +|---|---|---|---| +| GEQ 3D | β™« | ⬜ | audio + 3D | +| PaintBrush | β™« 🧊 | ⬜ | audio + 3D | +| Game Of Life | 🧊 | βœ…βš οΈ | **re-port** β€” current version flagged not faithful (migration Stage 1 proof) | + +## WLED library β†’ `effects_wled.md` + +| Effect | Markers | Status | Notes | +|---|---|---|---| +| Blackhole | | ⬜ | | +| Bouncing Balls | | ⬜ | physics (Stage 3c) | +| Blurz | β™« | ⬜ | audio | +| Distortion Waves | | βœ… | DistortionWavesEffect | +| Frequency Matrix | β™ͺ | ⬜ | audio | +| GEQ | β™« | ⬜ | audio | +| Lissajous | | ⬜ | geometric (Stage 3a) | +| Noise 2D | | βœ… | NoiseEffect | +| Noise Meter | β™ͺ | ⬜ | audio | +| PopCorn | β™ͺ | ⬜ | physics + audio | +| Waverly | β™ͺ | ⬜ | audio | + +## Moving-head library β†’ `effects_movingheads.md` (Stage 5, DMX fixtures) + +| Effect | Markers | Status | +|---|---|---| +| Troy1 Color / Troy1 Move / Troy2 Color / Troy2 Move | β™« | ⬜ | +| FreqColors Β· Wowi Move Β· Ambient Move | β™« | ⬜ | + +## projectMM-native (no external origin) β†’ `effects_projectmm.md` + +Already in projectMM, our own (not from a MoonLight library β€” kept here so the inventory is complete): +AudioSpectrumEffect β™«, AudioVolumeEffect β™«, FireEffect, GlowParticlesEffect, LavaLampEffect, MetaballsEffect, NetworkReceiveEffect, PlasmaEffect, PlasmaPaletteEffect, RingsEffect, SpiralEffect, CheckerboardEffect. + +*(Several have a MoonLight/WLED lineage in their prior-art notes; "origin" here is the page they'll file under β€” settle per-effect at migration time, per the [folder-structure decision](folder-structure-proposal.md): the page is the primary-steward bucket, the `tags()` emoji carries full lineage.)* + +## Tally + +47 MoonLight-listed effects. **Already covered: ~7** (Lines, Particles, Rainbow, Ripples, Sinus, Wave, Distortion Waves, Noise 2D β€” direct equivalents) + GoL (re-port). **To migrate: ~38**, of which ~14 are audio (Stage 3d) and 7 are moving-head (Stage 5). The non-audio, non-moving-head remainder (~17) are the Stage 3a/b/c batches β€” the bulk of the parity work the [rename gate](rename-to-moonlight.md#must--the-rename-is-a-downgrade-without-these) needs. diff --git a/docs/backlog/moonlight-fidelity-tensions.md b/docs/backlog/moonlight-fidelity-tensions.md new file mode 100644 index 00000000..529bb748 --- /dev/null +++ b/docs/backlog/moonlight-fidelity-tensions.md @@ -0,0 +1,112 @@ +# MoonLight migration β€” fidelity tensions + +A running log of places where **strict fidelity to MoonLight's behaviour** (the migration mandate: +end users must see the same effect they always have) collides with a **projectMM principle** +(robustness / no-crash-at-any-grid-size, correctness, hot-path discipline, *common patterns first*). + +For each: what MoonLight does, what the principle wants, what was shipped, and the **decision needed**. +The product owner resolves these β€” the agent records them rather than silently choosing. + +Status legend: 🟑 open (needs PO decision) Β· 🟒 resolved (decision recorded) Β· βšͺ accepted-as-is (kept faithful, no change wanted) + +--- + +## 1. 🟒 GEQ3D β€” `cols / NUM_BANDS` narrow-grid collapse β€” RESOLVED (2026-07-01) + +Resolved per the "same UX, improvements allowed" rule: **clamp the drawn band count to the column +count** so bars spread instead of piling at x=0 on a narrow grid. Invisible on normal grids +(cols β‰₯ numBands β†’ no-op), so no fidelity loss where it matters. See +[moonlight-improvements.md](moonlight-improvements.md). (GEQ β€” the flat 2D one β€” was *not* affected: +it maps each column to a band, so it never had the collapse.) + +## 2. 🟒 GEQ3D β€” frame-counter sweep β†’ time-based β€” RESOLVED (2026-07-01) + +Resolved: converted the projector sweep to a **time-based triangle wave** (`triwave8(beat8(...))`), +so `speed` means the same on every device (frame-rate-independent). Not throttled β€” a fast board +renders the same sweep more smoothly, a slow one choppier. Once-per-frame, no per-pixel cost. See +[moonlight-improvements.md](moonlight-improvements.md). + +--- + +## 3. βšͺ SolidEffect / general β€” `scale8` vs integer `*bri/255` rounding + +- **MoonLight:** brightness in several effects is plain integer `channel * brightness / 255` + (truncating), not FastLED's `scale8` (which has a +1 video-rounding so `scale8(x,255)==x`). +- **Principle in tension:** projectMM's standard channel-scale op is `scale8`. Using it would be the + "common patterns first" choice, but it rounds ~1 LSB higher than MoonLight at non-255 brightness. +- **Shipped:** kept faithful β€” used MoonLight's exact `* bri / 255` where the source does (the 3a + verifier caught and corrected a `scale8` slip in SolidEffect). At brightness=255 (default) both + agree; the difference is sub-perceptual at other values. Accepted as-is (faithful), recorded so we + know the rule: **match MoonLight's exact rounding per effect, don't reflexively swap in scale8.** + +## 4. 🟑 Audio effects β€” `volume` normalization scale (0..1 float vs our 0..255 `level`) + +- **MoonLight:** audio effects read `sharedData.volume` as a normalized float (roughly 0.0..1.0+), + e.g. FreqMatrix's gate `volume > 0.25`. projectMM's `AudioFrame::level` is a small integer + (~0..255, the VU value). +- **Principle in tension:** fidelity needs the *same trigger points* (a beat that lights MoonLight + must light ours), but the units differ, so a literal `> 0.25` is wrong against `level`. +- **Shipped (3d batch):** the ports scale the thresholds proportionally (e.g. `0.25` of full-scale β†’ + `~64` on the 0..255 `level`) and flag each with a comment. This is a **reconstruction**, not a + verbatim match β€” the exact trigger point depends on how our AudioModule scales `level` vs how + MoonLight scales `volume`. +- **Decision needed:** confirm the level-scale mapping on hardware (does our `level` reach ~255 at + the same loudness MoonLight's `volume` reaches ~1.0?), and whether `volumeRaw` (which we don't + have separately β€” NoiseMeter uses it) needs a real raw value added to AudioFrame, or `level` is a + good-enough stand-in. **PO call after bench.** +- **Synth-audio bench (2026-07-01, P4 mic-less + `simulate=music`):** under a known-good synthesized + frame the FreqMatrix gate (`peakHz > 80 && levelSmoothed > 64`) opens on **398/400** frames, so the + *threshold and code path are correct* β€” a synth signal lights the effect. This isolates the open + question to **real-mic scaling only**: whether a live INMP441 `level`/`levelSmoothed` reaches the + same ~64+ the synth does at the loudness a user calls "music playing". The earlier "FreqMatrix/Blurz + show nothing on the S3 with music" report is therefore a *mic gain/scale* question (does the real + `gain`/`floor` bring the signal over the gate), not an effect bug. **Cross-check on the S3 with the + synth as the reference: if the synth lights it and the real mic doesn't, raise `gain` / lower the + gate, don't touch the effect.** + +## 5. 🟒 Audio effects β€” raw vs smoothed level β€” RESOLVED (2026-07-01) + +The premise was backwards: `AudioFrame::level` was NOT smoothed β€” `computeLevel` recomputes it raw +per audio block, so it's already WLED's `volumeRaw` (the instantaneous, transient-snapping value). +NoiseMeter using it was correct, not a stand-in. The real gap was the *other* direction: no smoothed +value. Resolved by **adding `AudioFrame::levelSmoothed`** (an EMA of `level` in AudioModule) so +effects that want WLED's calm `volume`/`volumeSmth` can read it, and doing an **audio-effect sweep** +to point each effect at the value matching its behaviour: NoiseMeter β†’ raw `level` (unchanged, VU +snaps to beats); FreqMatrix, AudioSpectrum's VU bar, AudioVolume β†’ `levelSmoothed` (breathing/flowing +look). Bands-driven effects (GEQ, GEQ3D, PaintBrush, FreqSaws, Blurz) read per-band magnitudes, +unaffected. See [moonlight-improvements.md](moonlight-improvements.md). + +## 6. 🟑 Reconstructed logic β€” effects whose MoonLight source was incomplete (cross-check on bench) + +The fetched MoonLight source for some effects was partial, so parts were **reconstructed** from the +WLED algorithm + the documented intent and marked `// RECONSTRUCTED` in the code. These render and +are plausible, but the exact behaviour needs a side-by-side bench check vs MoonLight/WLED: + +- **Tetrix** β€” *no full source was available* (only condensed pseudocode). The fall-speed physics + (`map(speed,1,255,40000,250)` descending, the per-frame drop, the brick-width formula) are + reconstructed from the spec. (A real bug was caught + fixed: the speed-map result 250..40000 was + being truncated into a `uint8_t`, wrapping the physics β€” now widened.) **Cross-check the fall + cadence vs WLED 2D Tetrix.** +- **FreqMatrix** β€” the scroll-throttle gate is reconstructed (WLED uses a `micros()`-based 0..15 + "second hand"; the port reproduces the *intent* with an elapsed()-ms period). A **real D1 + dimensionality bug was caught + fixed** (it was writing along X; D1 writes the x=0 column along Y). + **Cross-check scroll speed + orientation.** +- **Blurz** β€” two position branches (the `freqMap` log-frequency placement and the band-scan) were + reconstructed (source incomplete). **Cross-check dot placement vs WLED Blurz.** +- **FreqSaws** β€” the `targetSpeed = volume * increaser * 257` overflow/scaling behaviour is + uncertain (`volume*increaser` already exceeds 16-bit before `*257`); reproduced as-described but + flagged. **Cross-check band-speed response.** +- **GEQ** β€” the falling-peak (`ripple`) markers and `fadeOut` are reconstructed to the standard WLED + 2D GEQ (the core bandβ†’bar math is verbatim-equivalent). **Cross-check peak-dot fall.** +- **NoiseMeter** β€” the `aux0/aux1` per-frame noise-scroll increments were unspecified in source; + reproduced with `beatsin8(5/4,...)` (matches WLED mode). Minor; **cross-check drift speed.** +- **BouncingBalls / PacMan / Ant** β€” had full source; only standard `map()`/`random` helper bodies + were reconstructed (FastLED-equivalent). Note: `random16(min,max)` was done as the contract's + modulo form, NOT FastLED's scaled-draw, so the *seeded random sequence* differs slightly (Ant's + initial velocities) β€” visually equivalent, not bit-identical. + +These are all in the code with `// RECONSTRUCTED` markers; grep for them to find the exact lines. + +## Resolved / accepted + +_(βšͺ entries above are kept-faithful, no change wanted unless the PO revisits; 🟑 entries await a decision.)_ diff --git a/docs/backlog/moonlight-improvements.md b/docs/backlog/moonlight-improvements.md new file mode 100644 index 00000000..592d82a9 --- /dev/null +++ b/docs/backlog/moonlight-improvements.md @@ -0,0 +1,35 @@ +# Effect improvements over MoonLight + +Where a migrated projectMM effect **deliberately behaves differently from the MoonLight original** β€” a +change that *improves* the effect (more correct, smoother, works at more grid sizes, a control that +matches its label) rather than a straight port. The migration mandate is fidelity ("effects look like +MoonLight"), so every intentional divergence is registered here with its reason, so it's a decision on +record, not accidental drift. + +Distinct from [moonlight-fidelity-tensions.md](moonlight-fidelity-tensions.md): that log +holds *undecided* fidelity-vs-principle conflicts awaiting a call; this doc holds *decided* improvements +the product owner approved (correctness/UX wins that ship). + +Product-owner ruling (2026-07-01): improvements that increase user satisfaction are allowed even when +they diverge from MoonLight β€” register each here. + +| Effect / primitive | MoonLight behaviour | projectMM behaviour | Why it's an improvement | +|---|---|---|---| +| `math8::map8` (audio bar heights) | `lo + scale8(in, hi-lo)` β€” the input top never reaches `hi`, so a one-step span (bar height 1) collapses to 0 | `lo + in*(hi-lo)/255` β€” the input top reaches `hi` exactly (FastLED's documented `map8 == map(in,0,255,lo,hi)`) | Audio bars reach full height; a 1-row bar is now possible. Matches FastLED's *documented* semantics. Affects every audio effect's bar heights slightly. | +| **FreqSaws** (band timing on wide panels) | Each band's sawtooth physics advanced once per **column**, so a band spanning K columns integrated KΓ— per frame β€” it ran and decayed ~KΓ— too fast | Each of the 16 bands integrates exactly **once per frame**; the column loop just draws the cached per-band Y | Animation speed/decay no longer depend on panel width β€” identical on a 32-wide and a 256-wide grid. Matches WLED's per-band-per-frame physics. | +| **SphereMove** (motion smoothness) | `time_interval` did the first divide as **integer** (`ms/(100-speed)`), so the origin/diameter only advanced when that ticked to a new whole number β€” visible stutter (~20 updates/s at 60 FPS, worse at low speed) | Full expression in float, so the shell sweeps and breathes smoothly every frame | Smooth motion at all speeds instead of discrete jumps. (MoonLight *intended* float here, so this is also more faithful.) | +| **Lissajous** (thin grids) | A 1-wide or 1-tall grid mapped every sample to coordinate **1**, which clips (only index 0 is valid) β†’ blank | The size-1 axis maps to coordinate **0**, so the figure draws | Produces visible output on degenerate/thin grids instead of nothing; normal grids (both axes β‰₯2) unchanged. | +| **PaintBrush** (large grids) | Oscillator endpoints truncated into `uint8_t`, so on grids >256 per axis strokes swept only a small low corner; the top band/hue were unreachable (off-by-one) | Oscillators generated 0..255 then scaled to the full grid; loop uses `numLines-1` as the map high bound | Strokes span the full grid at any size and use the full palette/band range. ≀256-per-axis grids: identical pixels. | +| **FixedRectangle** (RGBW white channel) | On RGBW with `alternateWhite`, the **W channel was written on every box cell** (tinting coloured tiles), and left stale when `white==0` | W follows the checker: only white tiles carry `W=white`; coloured tiles clear W to 0 | Coloured tiles render as pure RGB instead of RGB+white; no stale W persists. The checker actually alternates colour vs white. | +| **GEQ3D** (projector sweep cadence) | The sweep advanced by a per-frame counter (`counter++ % (11-speed)`), so its speed tracked the device frame rate β€” faster on a 280 FPS board than a 30 FPS one at the same `speed` | Time-based triangle wave (`triwave8(beat8(speedΒ·3, elapsed()))`), so the projector is at the same wall-clock position on every device | The `speed` control means the same thing on every device (frame-rate-independent, the projectMM convention). A fast board renders the same sweep more smoothly, a slow one choppier β€” neither is throttled to the other. Once-per-frame calc, no per-pixel cost. | +| **GEQ3D** (narrow-grid bars) | Bar width is `cols / NUM_BANDS`, which truncates to 0 when `cols < numBands` (e.g. 8 cols, 16 bands) β†’ every bar collapses to x=0 | The drawn band count is clamped to the column count, so bar width stays β‰₯ 1 and bars spread across the available width | Bars render on grids narrower than the band count instead of piling at x=0. Invisible on normal grids (cols β‰₯ numBands β†’ the clamp is a no-op). | +| **AudioFrame** (raw + smoothed level) | WLED exposes both `volumeRaw` (instant) and `volume`/`volumeSmth` (smoothed); a straight port only had our raw `level` | Added `levelSmoothed` (an EMA of `level`); each audio effect reads the value matching its intent β€” NoiseMeter uses raw `level` (VU snaps to beats), FreqMatrix / AudioSpectrum VU bar / AudioVolume use `levelSmoothed` (breathing/flowing) | Effects that should glide with the music no longer jitter per audio block, and beat-reactive ones stay snappy β€” the full WLED raw/smoothed pair instead of one value forced everywhere. | + + + + diff --git a/docs/backlog/moonlight_images/moonlight/drivers/Art-Net-In.png b/docs/backlog/moonlight_images/moonlight/drivers/Art-Net-In.png new file mode 100644 index 00000000..2cbeaaa4 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/Art-Net-In.png differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/ArtNetInControls.png b/docs/backlog/moonlight_images/moonlight/drivers/ArtNetInControls.png new file mode 100644 index 00000000..26276898 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/ArtNetInControls.png differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/ArtNetOutControls.png b/docs/backlog/moonlight_images/moonlight/drivers/ArtNetOutControls.png new file mode 100644 index 00000000..9e9e535b Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/ArtNetOutControls.png differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Audio.png b/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Audio.png new file mode 100644 index 00000000..ebbac7c0 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Audio.png differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Driver.png b/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Driver.png new file mode 100644 index 00000000..3f521622 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Driver.png differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/IMUDriverControls.png b/docs/backlog/moonlight_images/moonlight/drivers/IMUDriverControls.png new file mode 100644 index 00000000..cfa2b966 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/IMUDriverControls.png differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/IRDriver.jpeg b/docs/backlog/moonlight_images/moonlight/drivers/IRDriver.jpeg new file mode 100644 index 00000000..d40c192e Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/IRDriver.jpeg differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/MPU-6050.jpg b/docs/backlog/moonlight_images/moonlight/drivers/MPU-6050.jpg new file mode 100644 index 00000000..8f50cc7a Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/MPU-6050.jpg differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png b/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png new file mode 100644 index 00000000..bbca0d99 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Ball2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Ball2D.gif new file mode 100644 index 00000000..2634919a Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Ball2D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Cosmic3D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Cosmic3D.gif new file mode 100644 index 00000000..d7c76736 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Cosmic3D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Geq2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Geq2D.gif new file mode 100644 index 00000000..5b3bdfc7 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Geq2D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Hello1D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Hello1D.gif new file mode 100644 index 00000000..b75ce65b Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Hello1D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Noise2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Noise2D.gif new file mode 100644 index 00000000..64dd706c Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Noise2D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Noise3D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Noise3D.gif new file mode 100644 index 00000000..51e08a19 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Noise3D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Orbit2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Orbit2D.gif new file mode 100644 index 00000000..a9423438 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Orbit2D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Oscillate2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Oscillate2D.gif new file mode 100644 index 00000000..2d69f7e6 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Oscillate2D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Random1D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Random1D.gif new file mode 100644 index 00000000..4fe658a2 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Random1D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Sweep2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Sweep2D.gif new file mode 100644 index 00000000..6e304f34 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Sweep2D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Vu1D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Vu1D.gif new file mode 100644 index 00000000..212337c4 Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Vu1D.gif differ diff --git a/docs/backlog/moonlight_images/moonlight/effects/layers.gif b/docs/backlog/moonlight_images/moonlight/effects/layers.gif new file mode 100644 index 00000000..8b5ca66a Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/layers.gif differ diff --git a/docs/backlog/rename-to-moonlight.md b/docs/backlog/rename-to-moonlight.md index ea87fe74..2ac14878 100644 --- a/docs/backlog/rename-to-moonlight.md +++ b/docs/backlog/rename-to-moonlight.md @@ -105,7 +105,7 @@ This is parity-to-take-the-name, not parity-for-parity's-sake β€” projectMM's ar These are pointers to existing backlog items; the rename doesn't create new work so much as set a **bar** for which items gate it. Each links to its detailed entry rather than restating it. ### Must β€” the rename is a downgrade without these -- **Effect breadth at a credible fraction of 60+** β€” not all 60, but enough that the library doesn't feel thin. Today's ~20 cover the common families (noise, fire, plasma, particles, audio); a Must is closing the obvious *category* gaps a predecessor user expects (see Should), not matching the count. (MoonLive softens even this: a user can *author* a missing effect on-device rather than wait for a built-in.) +- **Effect breadth at a credible fraction of 60+** β€” not all 60, but enough that the library doesn't feel thin. Today's ~20 cover the common families (noise, fire, plasma, particles, audio); a Must is closing the obvious *category* gaps a predecessor user expects (see Should), not matching the count. (MoonLive softens even this: a user can *author* a missing effect on-device rather than wait for a built-in.) **This gate is executed by the staged MoonLight migration** β€” its plan ([`Plan-20260630 - MoonLight migration (multi-stage)`](../history/plans/Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md)) brings the predecessor's effects / modifiers / layouts across in batches on a shared palette + primitive foundation; the rename's bar is "enough batches landed to not feel thin," not "all stages done." - **Mapping / layout parity for real fixtures** β€” the predecessor's "memory-optimised mapping" across non-trivial fixtures (matrices, rings, cubes, custom). projectMM has Grid/Sphere/Wheel + modifiers; a Must is that a user's existing physical layout from the predecessor has a path here. - **OTA continuity for in-field devices** (also in Phase 2/3) β€” a predecessor user's deployed devices must keep updating across the rename, not brick on a dead URL. diff --git a/docs/building.md b/docs/building.md index 903d57bc..8b9d324f 100644 --- a/docs/building.md +++ b/docs/building.md @@ -59,7 +59,7 @@ uv run scripts/test/test_desktop.py # unit tests Or use MoonDeck's PC tab for the same operations with a status dot per card. The desktop run detaches and outlives the launching script β€” the same model as flashing an ESP32, where the device runs independently afterwards. -![MoonDeck PC tab](assets/screenshots/moondeck_pc.png) +![MoonDeck PC tab](assets/ui/moondeck_pc.png) Each host writes into its own build dir: `build/macos/`, `build/linux/`, `build/windows/`. The per-host layout mirrors the ESP32 side's `build/esp32-/` shape β€” one directory per target, no cross-target clobbering on a multi-host dev machine. @@ -126,7 +126,7 @@ On Windows, the `--port` argument is a `COM*` name (e.g. `COM3`) instead of `/de The ESP32 tab in MoonDeck wraps the same steps as cards (Setup β†’ Firmware β†’ Build β†’ Port β†’ Flash β†’ Run). The Network bar at the top is the same one shown on the Live tab β€” it remembers which serial port and WiFi credentials belong to the current LAN, so moving the laptop between networks doesn't require re-picking. -![MoonDeck ESP32 tab](assets/screenshots/moondeck_esp32.png) +![MoonDeck ESP32 tab](assets/ui/moondeck_esp32.png) ### ESP-IDF version diff --git a/docs/history/FastLED-FastLED.md b/docs/history/FastLED-FastLED.md index 10cbdc68..33fb2702 100644 --- a/docs/history/FastLED-FastLED.md +++ b/docs/history/FastLED-FastLED.md @@ -2,6 +2,40 @@ What landed on [FastLED](https://github.com/FastLED/FastLED)'s main branch, month by month. External-context reference (like the v1/v2/MoonLight inventories) β€” a factual log of a friend repo's releases, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these digests lives in [README.md](README.md). +## June 2026 (up to 3.10.4) + +Released **3.10.4** (2026-06-16), cut from `master`. + +**New** +- STM32: Arduino UNO Q board support. +- New NXP LPC8xx family drivers land (LPC804 PLU, LPC845 bit-bang + PWM/DMA-to-GPIO, LPC11xx) β€” early bring-up, bench-validated. +- Unified `fl::Watchdog` API with real hardware implementations across platforms (ESP32, Teensy 4, AVR, Apollo3, RP2040, STM32) plus a non-allocating reset/crash-classification helper. +- Wave simulation: opt-in 9-point isotropic Laplacian (smoother 2D waves), exposed as a UI toggle in several example sketches. +- ScreenMap gains a v2 schema (auto-detected), and a new `.fled` container format for video/screenmaps that `FxSdCard` can load. +- FFT now auto-detects ESP-DSP on ESP32 (no opt-in macro needed). + +**Fixed** +- RGBW colorimetric path reworked: native LED gamut + D65 as the default source, improved strict/boosted solvers, corrected dual- and 3-channel solving. +- ESP32: reliable streaming for SPI strips over ~680 LEDs; ESP32-C6 routes Serial to HWCDC with non-blocking writes and rejects USB-serial pins for LED output. +- Teensy Audio selection fixed on low-memory boards; nRF52 now honors configured SPI data rates. +- LuminescentGrand example: corrected serpentine column wiring/orientation. + +## June 2026 (post-3.10.4) + +**New** +- Teensy 4.x LED driver bring-up: ObjectFLED and FlexIO parallel output engines plus a new LPUART-based WS2812 driver (inverted-TX + eDMA), and FlexPWM-based RX capture. +- New WS2812-style RX capture path on classic ESP32 (RMT4) and LPC845 (SCT+DMA). +- ARM Cortex-M DSP-extension SIMD backend wired up for Teensy 3.x/4.x (faster scale/blend on those chips). + +**Fixed** +- SM16824E chipset timing corrected to match the datasheet. +- RGBW gamut configuration is now kept per strip. +- Fixed a memory leak in the chunked `fl::deque` container. + +Note: the bulk of June's ~481 commits were internal (LPC bring-up scaffolding, the AutoResearch hardware-test harness, a Pythonβ†’Rust C++ linter migration, RPC/JSON size-and-speed tuning, and test-file splits) and are not user-visible. Notable issue traffic was dominated by the Teensy driver and LPC845 bring-up trackers; a user-reported "multi strip problem" (#3340) was triaged and closed, and a deque refactor briefly broke a macOS-arm64 audio unit test (#3286, fixed same month). + +_Auditability: 481 commits with author-date in 2026-06-01..2026-06-30 on `master` (first-parent/merged view); split at the 3.10.4 release (published 2026-06-16, an ancestor of `master`). Issues reviewed via `search/issues` for `created:2026-06-01..2026-06-30` and `closed:2026-06-01..2026-06-30` (~50 each); user-facing ones folded in above, the remainder were internal bring-up/CI/linter trackers._ + ## May 2026 *Summarised from 150 first-parent commits on `master`, 2026-05-01 … 2026-05-31.* diff --git a/docs/history/MoonModules-WLED-MM.md b/docs/history/MoonModules-WLED-MM.md index 055816aa..f21276ae 100644 --- a/docs/history/MoonModules-WLED-MM.md +++ b/docs/history/MoonModules-WLED-MM.md @@ -2,6 +2,24 @@ What landed on [WLED-MM](https://github.com/MoonModules/WLED-MM)'s `mdev` (default) branch, month by month. External-context reference β€” a factual log of a friend repo's releases, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](README.md). Months are split at versioned-release boundaries (the rolling `nightly` tag is not a release). +## June 2026 + +*Summarised from 38 commits on `mdev`, 2026-06-01 … 2026-06-25 (no versioned release cut this month, so the month is not split; the `nightly` prerelease is not a release).* + +**New** +- Waveshare ESP32-S3 Matrix Driver board profile added. +- Audio-reactive is now an out-of-tree usermod (pulled in as a dependency) rather than baked into the tree β€” no user-facing change to how it works, but a cleaner build. + +**Fixed** +- Output settings no longer revert to defaults after a reboot (regression in 14.7.2 nightly, reported on ESP32-D0WDQ5 and ESP32-S3; issue #367). +- LEDs no longer flicker on startup and during GIF playback. +- Custom palettes reload immediately after you upload a palette file (previously needed a manual refresh; relates to long-standing issue #105). +- Fixed `esp32dev_compat` firmware build failing (issue #371) and a broken `-D WLEDMM` build flag. +- ArtiFX (ARTI effect engine) robustness: call-stack bounds checks, safer string/lexer handling, and fixes for glitches seen only in speed-optimised builds (relates to issue #295); ARTI status now shown in the Info panel. +- Upstream compatibility: accepts `I2CSDAPIN`/`I2CSCLPIN` as alternatives to the older I2C pin defines; fixed the arduinoFFT dependency; brown-out restart info now checked on both cores. + +*Auditability: 38 commits on `mdev`, author-date 2026-06-01..2026-06-30 (range 84669c3 … 70fe1b8; several are CHANGELOG/version-bump/CodeRabbit-config/internal-refactor commits, omitted as not user-facing). Issues checked: created 2026-06-01..2026-06-30 and closed 2026-06-01..2026-06-30 β€” 4 relevant surfaced (#367, #371 fixed this month; #105, #295 long-standing, closed/addressed this month).* + ## May 2026 *Summarised from 34 first-parent commits on `mdev`, 2026-05-01 … 2026-05-30.* diff --git a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md index d3e43bcb..262f222d 100644 --- a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md +++ b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md @@ -4,6 +4,38 @@ What landed on [NightDriverStrip](https://github.com/PlummersSoftwareLLC/NightDr Summarised via the GitHub commits API (no local clone), so counts are all commits on `main`, not first-parent merges β€” the bullets filter out dependency bumps, whitespace, and pure refactors. The one release in the window, **v1.3.0**, was published 2026-01-10 but tagged from a late-November commit; it isn't a clean month boundary, so months are kept whole with the release noted as context. +## June 2026 (up to v2.0.0) + +The big one: **NightDriverStrip 2.0.0** shipped on June 14 β€” a major release cut from `main`. + +**New** +- Brand-new Web UI replacing the old one, plus a new browser-based web installer for flashing devices. +- Runtime-selectable output drivers: settings that used to be compile-time (like strip type) can now be changed on the device without rebuilding. +- APA102 / SK9822 strips are now a first-class output type alongside WS281x and HUB75. +- Multi-layered, categorized device settings structure. +- New optional WiFi-activity output pin that goes HIGH while WiFi is drawing; projects can now define zero active effects and stay idle until then. +- Effect-timeout reset on a remote-control effect switch is now a user setting (previous always-reset behavior stays the default). +- Effects reworked (weather, stocks, subscribers, etc.) to display usefully on short 48x16 matrices. +- New and revised effects, plus smarter memory handling (targeted mix of internal RAM and PSRAM). + +**Fixed** +- Render hiccup/stutter when settings auto-saved: SPIFFS/JSON writes no longer block the render thread. +- Effect-manager crash shortly after load when the effect set changed. +- Serpentine-matrix visualization corrected. +- Lower COLORDATA server framerate optimized for smoother remote color streaming. +- PSRAM-related instability during flash/cache writes reduced by keeping save-time JSON in internal RAM. + +## June 2026 (post-v2.0.0) + +- **v2.0.1** (also June 14): patch that fixes the web installer failing to build for the 2.0.0 release (removed a stale project entry). +- Stock-ticker effect now shows correct live data, fetched through the new V2 API. +- Fixed 64x32 (wide-and-short) displays that were rotating and doubling their content instead of scaling β€” effect previews in the Web UI and CLI now match the active output driver's pixel mapping (issue #878). + +**Watching** +- Issue #877 ("networking seems broken") drew heavy discussion (38 comments) around the WebUI being unreachable after a recent merge on some setups; closed in June. + +Auditability: ~40 first-parent merges/commits on `main` with author-date in 2026-06-01..2026-06-30 (`commits?sha=main&since=…until=…`); two versioned releases published June 14 β€” v2.0.0 (commit ce00eaa) and v2.0.1 (commit 835015b), both ancestors of `main`, so the month is split at v2.0.0. Issues checked via `search/issues` for `created:2026-06-01..2026-06-30` (0 opened in range) and `closed:2026-06-01..2026-06-30` (#877, #878, #825 closed; #878 and #877 user-facing). + ## May 2026 *~75 commits on `main`, 2026-05-01 … 2026-05-31.* diff --git a/docs/history/README.md b/docs/history/README.md index 249b7230..7789ef64 100644 --- a/docs/history/README.md +++ b/docs/history/README.md @@ -35,13 +35,13 @@ One-time surveys of earlier projects, used to decide what to harvest into projec ## Cross-repo trends -Reading across the friend-repo digests, the themes the wider ESP32-LED ecosystem converged on over this release cycle (Sept 2025 β†’ May 2026): +Reading across the friend-repo digests, the themes the wider ESP32-LED ecosystem converged on over this release cycle (Sept 2025 β†’ June 2026): - **ESP32-P4 / S3 parallel output.** FastLED poured effort into the PARLIO and LCD_CAM drivers (P4/S3 parallel LED output, big encode speedups); NightDriverStrip added custom RMT output; hpwit's I2SClocklessLedDriver pushed IDF-5.5 + arduino-less-ESP-IDF support for its I2S/LCD DMA driver (the canonical implementation of this technique), and troyhacks ran ESP32-P4 bring-up branches. The frontier is parallel, DMA-driven output on the newer chips. - **PSRAM strategy is unsettled everywhere.** All four wrestled with PSRAM this cycle β€” WLED-MM moved preview buffers into PSRAM, NightDriverStrip did a full PSRAM-default reversal (then tuned the threshold), WLED added S3-no-PSRAM builds. Nobody has a clean answer; the cache-disabled-during-flash hazard recurs across repos. - **Audio-reactive maturing.** FastLED added a silence-gate + ESP-DSP FFT backend; WLED-MM and WLED both refined audio sync and auto-disable-during-realtime; NightDriverStrip modernised its SoundAnalyzer/FFT. Audio-reactive is table stakes now, and the polish is in *not* reacting to noise/silence. - **The FastLED dependency question.** WLED merged a *full FastLED replacement* (its own colour/math); projectMM already made the same call (own colour math, no FastLED in core). Two independent projects concluded the dependency wasn't worth it. -- **UI as a firmware-driven consumer.** Both WLED and NightDriverStrip pushed toward "the official UI knows nothing the firmware doesn't publish over the wire" β€” exactly projectMM's MoonModule-driven, no-hardcoded-knowledge UI principle. Convergent design. +- **UI as a firmware-driven consumer.** Both WLED and NightDriverStrip pushed toward "the official UI knows nothing the firmware doesn't publish over the wire" β€” exactly projectMM's MoonModule-driven, no-hardcoded-knowledge UI principle. Convergent design. NightDriverStrip's **2.0.0** (June 2026) crystallised this: a brand-new web UI, a browser-based installer, and settings (like strip type) moved from compile-time to *runtime-selectable* on the device β€” the same "reconfigure live, no reflash" direction projectMM builds around. - **Effect velocity.** WLED and WLED-MM shipped many new effects (PacMan, Color Clouds, Shimmer, the user_fx pack); new effects remain the most visible user-facing output. - **Display / HDMI output beyond LED strips.** troyhacks ran a cluster of branches probing HDMI video output and large hardware panels (WaveShare 10.1β€³, M5Stack, ESP32-P4 panels) β€” driving *displays*, not just addressable strips, off the same firmware. - **On-device live scripting.** hpwit's ESPLiveScript compiles small C-like effect scripts that run live on the ESP32 with no reflash β€” a different answer to effect authoring than C++ recompilation or a fixed effect table. @@ -65,10 +65,11 @@ To add a new month (or a new friend repo), run the digest prompt below. When a t > **Friend-repo monthly digest.** For the repo `` (local clone at ``, or via `gh api repos//`), summarise what landed on its **main/default branch** during ``. > > 1. Read the merged commits on the default branch with author-date in that calendar month (`git log --first-parent --since/--until` on the local clone, or the GitHub API). Use `--first-parent` so it's the merged-feature view, not every squashed sub-commit. The default branch isn't always `main`/`master` β€” check (`git remote show origin`); e.g. WLED-MM's is `mdev`. +> 1b. **Also investigate the issues over that month.** The REST `gh api repos///issues` endpoint returns **pull requests too** β€” filter them out (`--jq '.[] | select(.pull_request == null)'`) or use the GitHub **search** API, which already excludes them: `search/issues?q=repo:/+is:issue+created:` (and the same with `closed:`). Only real issues, not PRs. The commit log shows what shipped; the issues show what users *hit* and what the maintainers are prioritising β€” the two together are the real activity picture. Skim: notable bugs opened (recurring pain points, hardware quirks), fixes closed that map to a commit, and any heavily-discussed feature request or design thread. Fold the user-facing ones into the summary below (a widely-reported bug that got fixed, a feature the community is pushing for); an issue with no user-facing outcome yet is still worth a one-line "watching:" note if it signals a direction. Don't list every issue β€” surface the few that matter, the same bar as the commit summary. > 2. **Split a month at any release boundary β€” but only if the release was cut from the branch you're summarising.** If a *versioned* release was published mid-month (check `git for-each-ref refs/tags` / the GitHub releases API; ignore rolling tags like `nightly` and prereleases), AND the tag is an ancestor of the digest branch (`git merge-base --is-ancestor `), split that month at the release date into `## (up to v)` / `## (post-v)`. If the tag is NOT an ancestor (the project cuts releases from a separate release branch β€” e.g. upstream WLED tags off `0_15`/release branches, not `main`), do NOT split: keep the month whole and just note which release shipped that month as context, since the trunk you're summarising feeds future releases rather than being the release line. Whole months with no in-branch release stay one section. > 3. Write an **end-user-readable** summary: what changed that a *user of the library* would notice or care about β€” new features, new hardware/platform support, notable fixes, breaking changes. Skip internal refactors, CI, test-only, and dependency bumps unless they affect users. > 4. Format as **short bullet points**, each one line, plainest language, minimal jargon. Group only if there's a natural split (e.g. "New" / "Fixed"); otherwise a flat list. > 5. Add it as a `## ` section to `docs/history/.md`, newest month on top. Don't editorialise or compare to projectMM β€” just report what they shipped. -> 6. State the commit range / count summarised so the digest is auditable. +> 6. State the commit range / count **and the issue query** summarised so the digest is auditable. > > When backfilling several months (e.g. since the last release), run this once per month for a consistent timeline, then optionally add a `## Since v β€” overview` intro at the top with 3–5 bullets naming the multi-month threads the per-month slices can't show on their own. diff --git a/docs/history/decisions.md b/docs/history/decisions.md index a73cbaea..2187016d 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -537,16 +537,29 @@ The apparent blocker was "core uses `lengthType`." On inspection the uses were i **The rule that held:** don't accept "core uses X" at face value β€” check whether the use is *essential* or *incidental*. Every tie here turned out incidental and severable: a comment (`Control.h`), a serializer following a misplaced struct's field type (`PreviewFrame`/`put16`), and a SFINAE constraint that named a type it immediately discarded (`Dim`). The fixes: move the real owner and give core a domain-neutral seam β€” `BinaryBroadcaster` for the preview bytes, a return-type-agnostic probe for `dimensions()`. End state: **no `core/types.h`; core names zero light types.** -## Time-gated effects must still paint every frame (GameOfLifeEffect) +## The layer buffer persists frame-to-frame; effects own their background (fade/fill/read-prior) -`GameOfLifeEffect` advances one generation per `bpm`-derived "beat", so most frames don't step the simulation. The first version skipped the render on non-step frames as an optimisation β€” and the display went black between beats, "a flash now and then". The cause: `Layer::loop()` calls `buffer_.clear()` **before every effect frame**, so an effect that doesn't write produces a black frame, not a held one. The render hot path has no frame-to-frame persistence; the buffer is the effect's to fill, every time. +The render buffer is **not** cleared each frame β€” `Layer::loop()` leaves last frame's pixels in place, the FastLED / WLED / MoonLight convention (their `leds[]` / segment / VirtualLayer buffers all persist; none auto-clears). An earlier design cleared the buffer before every effect frame ("the buffer is the effect's to fill, every time"), but that silently broke every persistence effect: a scroll (FreqMatrix) reads the prior column via `draw::get` and shifts it β€” reading a wiped buffer, so only the freshly-painted pixel survived; a trail effect calls `fadeToBlackBy` to decay the previous frame β€” fading zeroes, so the trail never formed; Game-of-Life reads its prior cell state β€” gone. The symptom that surfaced it: FreqMatrix lit only one row, and ~13 effects' `fade` controls did nothing. -**The rule:** separate *when the simulation advances* from *when the effect paints*. Time-gate the state update (the `dt*bpm` accumulator, same shape as `CheckerboardEffect`), but **always repaint the current state**. The sim runs at `bpm`; the paint runs at frame rate. This applies to any future effect whose internal clock is slower than the tick (a slow automaton, a beat-synced pattern). A `unit_GameOfLifeEffect` case ("renders every frame between generations") pins it. +**The model (matches the three reference frameworks):** +- **Persistence is the default and universal** β€” the buffer holds the previous frame; there is no per-effect "persist" flag (a flag would be bespoke and change no framework behaviour, unlike `dimensions()` which drives `extrude`). The buffer is zeroed **once** on allocation/resize, then persists. +- **Each effect owns its background inside its own `loop()`:** a full-grid effect overwrites every pixel; a **trail** effect calls `layer()->fadeToBlackBy(amt)`; a **sparse** effect that wants a clean frame calls `draw::fill(buf, {0,0,0})` itself (e.g. RubiksCube, whose `drawCube` writes only surface voxels). Multiple effects on one layer deliberately *interact* through the shared persistent buffer β€” that's a feature, not a defect. +- **`fadeToBlackBy` is a Layer operation, collected once per frame** (MoonLight's `VirtualLayer::fadeToBlackBy`): effects register an amount, the Layer keeps the **MIN** across them (the gentlest fade wins, so the longest requested trail is honoured), and applies **one** buffer pass at the next frame's start, then resets. N fading effects cost one pass, not N, and never fade each other's fresh pixels. `unit_Layer_persistence` pins persistence + the MIN-collect + reset. + +**GoL corollary (still holds, now for the right reason):** separate *when the simulation advances* from *when the effect paints*. `GameOfLifeEffect` steps one generation per `bpm` beat but its state lives in the persistent buffer, so between beats it simply leaves the buffer untouched (the automaton holds). Time-gate the state update (the `dt*bpm` accumulator, same shape as `CheckerboardEffect`); the sim runs at `bpm`, the buffer persists at frame rate. A `unit_GameOfLifeEffect` case pins it. **Two adjacent traps the same effect hit:** - **First-frame `dt`.** `lastElapsed_` starts at 0, so the first `now - lastElapsed_` is the whole device uptime β€” a huge `dt` that pins the step accumulator above the beat threshold *permanently* (max rate forever, `bpm` ignored). Bootstrap `lastElapsed_` on the first call and take `dt = 0` that frame. - **Width of the change delta.** The stagnation check narrowed `alive - lastAlive_` to `uint16_t`; at the 512Γ—512 max grid on a PSRAM board (`nrOfLightsType == uint32_t`, 262144 cells) that truncates and triggers false re-seeds. Counters derived from cell counts must be `nrOfLightsType`, not a fixed width. +## A static "current instance" pointer needs re-election, not just claim/vacate + +`AudioModule::latestFrame()` hands effects the active mic via a process-wide `static AudioModule* active_`. The original design was "setup() claims the seat, teardown() vacates it" β€” which silently breaks with **two** mics: removing the one that holds `active_` leaves the seat null while a second, still-running mic sits captured-but-unread, and every audio effect goes silent. The fix is a three-part protocol: the **first** live module claims in `setup()`, `teardown()` vacates, and any running module **re-claims an empty seat in `loop()`** β€” so the survivor takes over on its next tick for *any* add/remove order (the robustness rule). Rule: a singleton-accessor backed by a static pointer must have a *self-election* path in the periodic loop, not only claim-on-setup β€” otherwise it's only correct for exactly one instance. (`unit_AudioModule` pins the two-mic first-wins + re-election.) Tempting CodeRabbit "fix": gate the claim on `inited_` β€” rejected, because a claimed-but-uninited module publishes valid *silence* (the documented contract), and a mic-less board running `simulate` publishes synth frames without being `inited_`; gating on init breaks both. + +## An "Effect-role" module is not guaranteed to be an `EffectBase` + +DemoReel hosts a child effect and needs its `dimensions()` to extrude it. The first cut did `static_cast(current_)->dimensions()` every frame on the `MoonModule*` child β€” which **crashed** (SIGBUS in RTTI/vtable) when the eligible list included a test `EffectStub` that registers with `ModuleRole::Effect` but is a bare `MoonModule`, not an `EffectBase`. Two lessons: (1) **role is a registration property, not a type guarantee** β€” a module can carry any `role()` without deriving from the matching base, so a downcast keyed on role is undefined behaviour; and (2) the data was already available without the cast β€” the factory probes `dimensions()` at registration via `if constexpr` and stores it, so `ModuleFactory::typeDim(index)` gives the dimension RTTI-free (ESP32 builds `-fno-rtti`, so `dynamic_cast` isn't even an option). Reach for the factory's probed metadata instead of a cross-tree downcast whenever you need a registered type's declared property. + ## uint16 intermediate overflow blanks the display β€” and a status check doesn't prove the render works (MultiplyModifier) A high fan-out modifier (`MultiplyModifier` at 8Γ—8Γ—4) black-screened the no-PSRAM Olimex while the desktop showed it working. Root cause: `Layer::rebuildLUT` computed `maxDest = logicalCount * mod->maxMultiplier()` in `nrOfLightsType`. On no-PSRAM that's `uint16_t`, and `256 * 256 = 65536` **wraps to 0** β€” the LUT was sized to ~nothing, so almost every light mapped nowhere and the frame went black. On desktop (`nrOfLightsType == uint32_t`) the product fits, so the bug was invisible there. **Fix:** compute the product in `uint64_t`, clamp to the ceiling, then narrow back. This is the same family as the GameOfLife delta-width trap above, but for an *intermediate product*, not a stored counter β€” **any `nrOfLightsType * nrOfLightsType` (or `Γ— a multiplier`) can overflow uint16 even when both operands are individually small; do the arithmetic in a wider type before narrowing.** diff --git a/docs/history/hpwit-ESPLiveScript.md b/docs/history/hpwit-ESPLiveScript.md index a845f676..b8d4878a 100644 --- a/docs/history/hpwit-ESPLiveScript.md +++ b/docs/history/hpwit-ESPLiveScript.md @@ -6,6 +6,12 @@ The library: Yves Bazin's (hpwit) C-like compiler/interpreter for the ESP32 β€” **Branch note:** `main` is quiet (last touched June 2025), but this repo develops on a long series of **version branches** (`v2`…`v4.3`, plus `vjson`/`vjson2`/`vdrop`/`memory*`), and that's where the recent work is. The activity below is read across those branches, not just `main`. +## June 2026 + +No user-facing activity: no commits on `main` **or any of the ~30 version branches** (v2.x/v3.x, dev, mem*) in June 2026, and no notable issues. (Latest commit on `main` predates the window β€” June 2025.) + +_Checked: commits author-dated 2026-06-01..2026-06-30 on `main` and every version branch (`v2`…`v3.3`, `dev`, `mem2`…`mem4`, `memory`) β€” 0 on each; issues created / closed / updated 2026-06-01..2026-06-30 (0 each); PRs created in-window (0); no versioned release published in June 2026._ + ## February 2026 *Latest in-window activity, on the `vjson2` branch.* diff --git a/docs/history/hpwit-I2SClocklessLedDriver.md b/docs/history/hpwit-I2SClocklessLedDriver.md index f9bdebf3..84572fb6 100644 --- a/docs/history/hpwit-I2SClocklessLedDriver.md +++ b/docs/history/hpwit-I2SClocklessLedDriver.md @@ -6,6 +6,12 @@ The library: Yves Bazin's (hpwit) clockless-LED driver that clocks WS2812-class > **Authorship note.** Most of the activity in this window is projectMM's own β€” `ewowi` authored ~53 of the in-window commits, with the rest from the maintainer (Yves Bazin / hpwit) and a couple of others. The IDF 5.5 / arduino-less ESP-IDF / RGBCCT / >65K-LED work below is largely projectMM upstreaming its driver needs into hpwit's library, then tracking the result here. +## June 2026 + +No user-facing activity: no commits merged to `main` (latest activity is April 6, 2026) and no notable issues. + +_Auditability: commits on `main` author-dated 2026-06-01..2026-06-30 = 0 (0 merged); issues created/closed/updated in June 2026 = 0. No versioned release published in June (latest tag `1.4`, 2026-04-06)._ + ## April 2026 *~17 commits on `main`, 2026-04-01 … 2026-04-30.* diff --git a/docs/history/hpwit-I2SClocklessVirtualLedDriver.md b/docs/history/hpwit-I2SClocklessVirtualLedDriver.md index 8185bb86..a7038d4c 100644 --- a/docs/history/hpwit-I2SClocklessVirtualLedDriver.md +++ b/docs/history/hpwit-I2SClocklessVirtualLedDriver.md @@ -4,6 +4,12 @@ What landed on [hpwit/I2SClocklessVirtualLedDriver](https://github.com/hpwit/I2S The library: Yves Bazin's (hpwit) "virtual pins" variant of the I2S clockless driver β€” drives far more strips than the chip has usable pins by fanning the I2S output through external shift registers. This multiplex technique is the load-bearing idea projectMM's LED-driver analysis singles out (factoring the shift-register multiplex out of the I2S/LCD peripheral code). Summarised via the GitHub commits API, read across all branches (`main`, `integration`, `int2`, `variable`, `hpwit-patch-1`), not just `main`. +## June 2026 + +No user-facing activity this month: no commits merged to `main` (latest commit on `main` dates to November 2024), no releases published, and no issues opened, closed, or updated. + +_Checked: commits on `main` with author-date 2026-06-01..2026-06-30 (0 commits); issues created 2026-06-01..2026-06-30 (0), closed in that range (0), and updated in that range (0); releases (none in June β€” latest versioned tag is 2.1, Jan 2024)._ + ## No activity in the digest window (Sept 2025 – May 2026) No branch has any commits in the window these digests cover; the project went quiet at the end of 2024. Its last active stretch: diff --git a/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md b/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md new file mode 100644 index 00000000..379c5bf4 --- /dev/null +++ b/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md @@ -0,0 +1,57 @@ +# Plan β€” Doc consolidation: per-type compact pages (effects / modifiers / layouts) + +This is **Stage 2** of the [MoonLight migration](./Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md), narrowed by two product-owner decisions made at planning time: + +1. **Docs only.** `src/` stays one-`.h`-per-module (the recorded [folder-structure decision](../../backlog/folder-structure-proposal.md): a blended-origin effect in a per-library *folder* forces a wrong, costly multi-file move). This change touches only `docs/` + `check_specs.py`. +2. **One page per *type*, not per *library* β€” for now.** Our effect origins are lopsided (10 MoonLight, 1 WLED, 2 FastLED, 4 projectMM-native); a strict per-library page split (the proposal's first draft) yields 1–2-effect sparse pages. Instead: one `effects.md` with **library *sections* inside** (MoonLight / WLED / FastLED / projectMM-native), exactly like the [MoonLight effects page](https://moonmodules.org/MoonLight/moonlight/effects/). The per-library *file* split stays in the backlog as **future growth** β€” when the WLED/FastLED sets grow, a section lifts into its own file with no row rework. + +Inspiration for the compact per-module presentation: the MoonLight effects page (one table row per effect: name + tags, preview gif, one-line description, controls screenshot). + +## Scope + +- **Effects:** ~17 per-module `.md` β†’ one `docs/moonmodules/light/effects.md`, compact rows, library sections. +- **Modifiers:** ~5 per-module `.md` β†’ one `docs/moonmodules/light/modifiers.md`. +- **Layouts:** ~4 per-module `.md` β†’ one `docs/moonmodules/light/layouts.md`. +- **Drivers:** **unchanged** β€” one `.md` per driver (PO decision: drivers are larger and structurally distinct β€” RMT/LCD/Parlio/Network/Hue avg ~215 lines with platform-specific wire contracts a compact row can't hold). +- **Core modules:** unchanged (stable count, no explosion). + +## Row format (per module) + +`| Name (+ tags emoji) | preview | one-line description | controls |` + +- **Drops** the per-module `Tests` / `Prior art` / `Source` / `Design notes` sections: source is derivable from the name, tests are auto-discovered by `generate_test_docs.py`, prior-art/origin rides in the tags emoji + a short note in the description cell. Wire contracts (the one thing the row CAN'T hold) β€” none of effects/modifiers/layouts have integrator-facing wire contracts except `NetworkReceiveEffect`, which keeps a short protocol note in its description cell (or a footnote under its section). +- ~3–4 lines per module, so `effects.md` β‰ˆ 17 rows β‰ˆ 90 lines (vs ~1100 lines across 17 files today). + +## `check_specs.py` rewrite (the contract change) + +Today: `check_specs.py` rglobs each module `.h` β†’ requires a matching per-module `.md` whose body mentions every control name. New contract: + +- A module registered with `light/effects.md` (the page, not a per-module file) passes if **every one of its control names appears somewhere on that page** (in its row). Same anti-drift guarantee, page-scoped instead of file-scoped. +- The registered `.md` arg in `main.cpp` changes from `light/effects/Rainbow.md` β†’ `light/effects.md` (+ optional `#rainbow` anchor) for every effect; same for modifiers/layouts. Drivers keep their per-driver `.md`. +- Map each module β†’ its page by type suffix (`*Effect` β†’ `effects.md`, `*Modifier` β†’ `modifiers.md`, `*Layout`/`Layouts` β†’ `layouts.md`, `*Driver`/`Drivers` β†’ its own per-driver page), mirroring `asset_dir_for()`. + +## Files + +- **New:** `docs/moonmodules/light/effects.md`, `modifiers.md`, `layouts.md` (compact-row pages with library sections). +- **Delete:** the ~17 `docs/moonmodules/light/effects/*.md`, ~5 `modifiers/*.md`, ~4 `layouts/*.md` (folded into the pages). +- **Edit:** `src/main.cpp` (registered `.md` path per effect/modifier/layout β†’ the page), `scripts/check/check_specs.py` (page-scoped control-name check + the typeβ†’page map), `scripts/docs/generate_test_docs.py` if it links per-module `.md` anchors, and any doc that links a deleted per-module page (re-point to `effects.md#name`). +- **Unchanged:** `src/light/effects/*.h` etc. (code), `docs/moonmodules/light/drivers/*.md` (per-driver), `docs/moonmodules/core/*`. + +## Origin sections (from each module's `tags()` today) + +- **Effects** β€” MoonLight πŸ’«: DistortionWaves, LavaLamp, Lines, Metaballs, Particles, Plasma, Rainbow, Rings, Ripples, Spiral Β· WLED 🌊: Wave Β· FastLED ⚑️: Fire, Noise Β· projectMM-native: AudioSpectrum πŸ“Š, AudioVolume πŸ”Š, Sine πŸŒ€, NetworkReceive πŸ“‘πŸŒ™. +- **Modifiers** β€” MoonLight πŸ’«: Checkerboard, Multiply Β· projectMM-native: RandomMap, Region, Rotate. +- **Layouts** β€” projectMM-native: Grid, Sphere, Wheel, Layouts. + +## Verification + +- `check_specs.py` green under the new page-scoped contract (every control name present on its page). +- Build/tests/scenarios unaffected (docs-only + a script change; no `src/` compile impact). +- No dangling links: every former per-module `.md` link re-points to `page.md#anchor`; `generate_test_docs.py` output still resolves. +- The three pages render as compact tables with library sections; drivers + core docs untouched. + +## Out of scope (future growth, kept in backlog) + +- **Per-library *file* split** (`effects_moonlight.md`, …) β€” revisit when a library's effect count earns its own page; the within-page sections make it a lift-not-rewrite. +- **Driver doc consolidation** β€” drivers stay per-file. +- **gif previews** β€” adding MoonLight preview gifs into the rows is the migration's separate asset work. diff --git a/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md b/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md new file mode 100644 index 00000000..7b98dc27 --- /dev/null +++ b/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md @@ -0,0 +1,83 @@ +# Plan β€” HueDriver: Philips Hue lights as a projectMM effect output (shipped) + +## Context + +The product owner has Hue lights and a bridge ("Hue Ewoud", BSB002, API 1.77, at 192.168.1.143). The reframe that drives this plan, from the product owner: **Hue is an *output*, not a device to list.** projectMM already drives "an array of lights" through the effect β†’ layout β†’ buffer β†’ driver pipeline; Hue maps onto that directly β€” a handful of bulbs are a small **grid** (e.g. 5Γ—1Γ—1), an **effect** runs on them, and a **`HueDriver`** (a sibling of `RmtLedDriver` / `NetworkSendDriver` in the Drivers container) reads its window of the output buffer and pushes each pixel's colour to the corresponding bulb. The bulbs are *pixels of an effect*, not rows in DevicesModule. + +This is *Common patterns first* + *Concrete first, abstract later*: a new driver is the recognised unit of "a new output target," and the architecture already has the seam. No new core concept β€” one new `DriverBase` subclass + a small outbound-HTTP helper. + +### Verified on the wire (not assumed) + +- The bridge advertises `_hue._tcp` (mDNS) and answers N-UPnP β€” discoverable. +- **The bridge still allows the plain-HTTP Hue v1 API**: `http:///api/0/config` β†’ 200 (not HTTPS-only). So **no TLS / self-signed-cert handling on the ESP32** β€” the single biggest simplifier. CLIP v2 exists (`/clip/v2` β†’ 403) but is HTTPS-only + event-stream; **not used**. +- Hue v1 is HTTP + JSON: `POST /api` (link-button pairing β†’ app key), `GET /api//lights` (list), `PUT /api//lights//state` (`{"on":bool,"bri":0-254}`, optional `xy`/`hue`/`sat` later). + +## Decisions locked (product owner) + +- **Hue is an output driver** (`HueDriver : DriverBase`), sibling of `NetworkSendDriver`. Not a DevicesModule entry. (Listing the bridge in DevicesModule + auto-filling the driver's IP from discovery is a **follow-up**, per *concrete first* β€” build the working output, add the discovery nicety after.) +- **Scope: on/off + brightness** from the effect's per-pixel value (luminance β†’ `bri`). Colour (`xy`/`hue`/`sat`) is a clean later extension on the same PUT. +- **Update model: throttled, changed-lights-only.** Hue's bridge rate-limits to ~10 commands/s/light; a real-time stream would need the Entertainment API (DTLS) β€” out of scope. The driver samples its window on a **slow tick** (target ≀ ~10 Hz total across its lights) and PUTs **only the lights whose colour changed** since the last push. This is the standard way apps drive Hue from animations. +- **Plain-HTTP Hue v1 API** (no TLS). The bridge IP + app key are **controls on the HueDriver** (self-contained config, like NetworkSendDriver owns its target IP/universe); a **Pair button** runs the link-button POST to fill the app key. Persisted with the module. + +## Design + +### 1. Outbound HTTP helper (platform seam) + +The repo had `httpGet`; it was removed as dead code when DevicesModule's HTTP sweep went away. The HueDriver re-introduces the outbound-HTTP capability β€” but **minimal and with a real consumer this time** (the prior removal's lesson: don't keep an unused seam). Add a small `platform::httpRequest`: + +``` +// Outbound HTTP request to a LAN host (plain HTTP, no TLS β€” Hue v1 allows it). Builds the +// request, returns the status code (0 on failure), fills `body` (NUL-terminated, truncated). +// Synchronous + bounded by `timeoutMs`. Desktop + ESP32 over the existing TcpConnection +// (which has connect()/writeSome()/read()). GET/PUT/POST via `method`. +int httpRequest(const char* method, const char* host, uint16_t port, const char* path, + const char* reqBody, uint32_t timeoutMs, char* body, size_t bodyLen); +``` + +- Desktop + ESP32 build it over `TcpConnection::connect()` + `writeSome()` + non-blocking `read()` (the same primitives the HTTP *server* uses). Plain socket, no libcurl, LAN HTTP only β€” exactly what the removed `httpGet` did, generalised to GET/PUT/POST. +- **Off the render hot path**: the HueDriver calls this on its slow tick (loop1s-cadence), never `loop()`. A bounded blocking call there is fine (same rule as the old mDNS browse / the OTA fetch). + +### 2. `HueDriver : DriverBase` (`src/light/drivers/HueDriver.h`) + +Header-only light module, mirroring `NetworkSendDriver`'s shape: + +- **Controls** (`onBuildControls`): `bridgeIp` (IPv4), `appKey` (Text, persisted β€” the credential), `pair` (Button β†’ link-button pairing), `start`/`count` (via `addWindowControls()` β€” its slice of the buffer). A read-only `status` line ("paired, N lights" / "press the bridge button" / "unpaired"). +- **`setSourceBuffer` / `setLayer` / `setWindow`** β€” standard DriverBase wiring (Drivers container passes the shared buffer + the active layer for dimensions). +- **`loop()`** β€” does NOTHING on the render tick (Hue can't keep up; never block the hot path here). The driver's window is sampled in `loop1s()` instead. +- **`loop1s()`** (the throttle): read the window slice from the source buffer (`windowSlice()`), map each light's RGB β†’ on/off + `bri` (luminance), and for each light whose value **changed** since the last push, `httpRequest("PUT", bridgeIp, 80, "/api//lights//state", "{\"on\":…,\"bri\":…}", …)`. A per-light `lastSent` cache (small fixed array, bounded by the window count, capped at e.g. 32 Hue lights) drives the changed-only filter. PUTs are spread (one or a few per tick) so a tick never blocks long. +- **`pair`** (Button β†’ `onUpdate`): for ~a few seconds, `POST /api {"devicetype":"projectMM#"}`; on success store the returned `username` into `appKey` (persist), then `GET /api//lights` to learn the light id list. The "press the link button" instruction shows in `status`. +- **Light-id mapping**: window index β†’ Hue light id. First cut: the lights list from `GET /api//lights` in id order maps to window indices 0..N-1. (A future control could let the user reorder / pick which bulbs.) + +### 3. Drivers registration + UI + +- Register `HueDriver` in the driver factory next to the other drivers (one `ModuleFactory::registerType` line) so it's addable from the UI like any driver. +- The generic UI renders its controls with zero per-driver code (the whole point of the module tree). + +### 4. Desktop testability + +- `httpRequest` works on desktop (real sockets) β†’ the PUT/GET formatting + the changed-only diff + the windowβ†’light mapping are **host-unit-testable** against a tiny stub HTTP responder (or by asserting the formatted request bytes from a seam, no live bridge needed). The product owner's real bridge is the bench cross-check. + +## Files + +- **New:** `src/light/drivers/HueDriver.h` (the driver), `docs/moonmodules/light/drivers/HueDriver.md` (spec β€” controls, the Hue v1 wire contract, pairing flow, the rate-limit rationale, prior art). +- **Edit:** `src/platform/platform.h` (+ `src/platform/esp32/` + `src/platform/desktop/` impls) for `httpRequest`; the driver registration in `src/main.cpp`; `test/CMakeLists.txt` + a `test/unit/light/unit_HueDriver.cpp` (request formatting + changed-only diff + window mapping); `docs/backlog/backlog-light.md` (mark the Hue-driver item building / add the follow-ups: colour, DevicesModule bridge discovery, Entertainment-API streaming). + +## Riskiest parts + +1. **Rate limit / not blocking the loop.** The throttle (changed-only, ≀~10 Hz, a few PUTs per `loop1s`) must keep the bridge happy AND keep each tick short. A PUT is a bounded blocking `httpRequest` off the render path β€” but many lights Γ— a slow bridge could still make `loop1s` long. Mitigation: cap PUTs-per-tick (round-robin the changed lights across ticks), short `timeoutMs` (~200 ms), and degrade gracefully on a 429/timeout (skip, retry next tick). +2. **Pairing UX is asynchronous + physical.** The user must press the bridge button within the window. The Button handler can't block the loop for seconds β€” so pairing runs as a short bounded poll across a few `loop1s` ticks (a small state machine: "pairing… press the button" β†’ key obtained / timed out), status-reported. Don't block the render loop during pairing. +3. **Re-introducing outbound HTTP** β€” keep `httpRequest` minimal (the lesson from deleting `httpGet`: an unused seam is debt). It ships *with* its consumer (HueDriver), so it's earned. +4. **App key is a credential** β€” persisted in the module JSON like other settings; it's a LAN bridge key (low sensitivity), stored the same way as e.g. a static IP. Note it in the spec. + +## Verification + +- Desktop build (0 warnings); `ctest` incl. the new HueDriver unit test (request formatting + changed-only diff); scenarios + spec-check green; ESP32 all variants build. +- **Bench (the real test):** on the product owner's bridge β€” add a 5Γ—1Γ—1 layout, an effect, a HueDriver (window [0,5)), press Pair + the bridge button β†’ key obtained, lights listed; the effect animates the 5 bulbs (on/off + brightness) at the throttled rate, no bridge 429s, render FPS unaffected (the PUTs are on loop1s, off the hot path). +- Save this plan (done); mark `(shipped)` when it lands. + +## Out of scope (clean follow-ups) + +- **Colour** (`xy` / `hue`/`sat` from the pixel RGB) β€” same PUT, one more field; the obvious next slice. +- **DevicesModule lists the Hue bridge** + auto-fills the driver's `bridgeIp` from discovery (the product owner's "list it in devices" idea, done as the second step β€” discovery feeds the output). +- **Hue Entertainment API** (DTLS streaming, ~25–50 Hz) for true real-time effect sync β€” a major separate feature (TLS-PSK on ESP32, entertainment-area setup, v2 API). +- **DMX lights** as another such output driver (the product owner noted this is coming β€” Hue maps the "array of foreign lights" pattern that DMX will reuse). diff --git a/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md b/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md new file mode 100644 index 00000000..7ce8b133 --- /dev/null +++ b/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md @@ -0,0 +1,111 @@ +# Plan β€” Migrate MoonLight effects / modifiers / layouts (multi-stage) + +## Goal & shape + +Bring MoonLight's full library of **effects, modifiers and layouts** into projectMM. This is large, so it is **staged**: each stage ships independently, builds on the previous, and is its own `/plan` + commit. This document is the *map* β€” the per-stage plans get written when we reach them. Stages 1–2 are specified enough to start; later stages are scoped, not detailed. + +**Why this matters beyond features:** this migration is the execution vehicle for the **effect-breadth parity gate** in the [projectMM β†’ MoonLight rename plan](../../backlog/rename-to-moonlight.md#must--the-rename-is-a-downgrade-without-these) β€” taking the MoonLight name requires the library not to feel thin next to the predecessor's 60+ effects. The rename's bar is "enough batches landed," not "every stage done"; this plan is *how* that bar is reached. (The two docs stay in their folders β€” the rename is the forward-looking backlog item that sets the bar; this is the approved staged plan that meets it β€” linked, not duplicated.) + +Two cross-cutting rules govern every stage, from [CLAUDE.md](../../../CLAUDE.md): + +- **Industry standards, our own code.** MoonLight effects are studied for *behaviour and algorithm*, then written **fresh** against our architecture (our `EffectBase`, our primitives, our names). We do **not** trace MoonLight/WLED/FastLED structure or copy code. For *effects specifically* the **visual behaviour is the spec** β€” we reproduce what the effect looks like faithfully (the product owner's clarification), but the implementation is ours. Prior art credited per-module + in `history/`. +- **A shared light primitive library.** Effects need a common set of small math/colour helpers (a beat/sine oscillator, integer noise, saturating add/subtract, scale, fade, a colour blend, a fast PRNG, draw primitives). projectMM provides these, extending the `color.h` set (`scale8`, `sin8`, `cos8`, `hsvToRgb` already there): **hot-path-tuned** (integer-only, LUT-backed, no float in the per-light path) and **dimension-agnostic where it makes sense** (the product owner's steer: our 3D-native model means a primitive like `drawLine` works 1Dβ†’3D, written once, not re-implemented per effect). + - **Naming follows *Common patterns first* + *Industry standards, our own code*: the recognisable name AND our own implementation.** The LED-embedded world's canonical resource is FastLED, and its names (`beatsin8`, `inoise8`, `qadd8`, `nscale8`, `random8`/`random16`, `ColorFromPalette`) are exactly the ones a contributor recognises in 30 seconds β€” and consistent with the `scale8`/`sin8` we already ship. So **we use those names** (carrying the established convention), **write our own implementation** against our engine, and **credit FastLED as prior art** in each module's "Prior art" section. The point of the principle is independence-by-construction (own code, own architecture, behaviour pinned by tests), *not* a renamed copy β€” so the names stay recognisable; only the implementation is ours. Each primitive's design is justified at its introduction site, and we reorganise a borrowed concept when ours is genuinely cleaner (e.g. the dimension-agnostic draw set). + +## What exists today (baseline) + +- **Primitives:** `src/core/color.h` has `RGB`, `hsvToRgb`, `scale8`, `sin8`/`cos8` (LUT). `src/light/light_types.h` has `Coord3D`, `Dim`, `lengthType`. That's it β€” no beat/noise/blend/random helpers, no shared palette, no draw primitives. +- **Palette:** none shared. `PlasmaPaletteEffect` hard-codes a 256-entry `RGB palette_[256]` in flash β€” the pattern to generalise. +- **Effects:** ~21 already ported (Rainbow, Noise, Plasma, Fire, Particles, Metaballs, GameOfLife, Wave, …). GameOfLife (272 lines) is flagged by the product owner as **not faithful β€” re-port from the real algorithm**. +- **Modifiers:** Multiply, Rotate, Region, Checkerboard, RandomMap. **Layouts:** Grid, Sphere, Wheel. +- **Tags/emoji:** projectMM already has `tags()` + UI-derived role/dim emoji (architecture.md Β§ Web UI). MoonLight's legend (πŸ”₯ effect, πŸ’Ž modifier, β™« audio, 🧊 3D, …) becomes the **canonical basis** (product owner's choice). +- **Docs:** one `.md` per module (21 effect specs already), enforced by `check_specs.py` (it `rglob`s each `.h` β†’ a matching `.md`). Moving to **per-library pages** (`effects_.md`, compact table rows) β€” see Stage 2 and the [folder-structure decision](../../backlog/folder-structure-proposal.md). This requires changing the spec-check contract. +- **Assets:** **already reorganised** to `docs/assets/{core, light/{effects,modifiers,layouts,drivers}, ui}/` (the per-module move done ahead of the migration). Stage 2's gif work is *adding* MoonLight previews into this structure, not re-homing. + +## Dependency analysis (what must come first) + +1. **Palette** β€” hard prerequisite. Many MoonLight effects colour via `ColorFromPalette`. Nothing palette-dependent can be faithfully ported until this lands. **Stage 1.** +2. **The shared primitive library** (beat / noise / blend / scale / random / draw) β€” most effects need several. **Stage 1.** +3. **Tags/emoji legend** β€” must be settled before batch-migrating, so every migrated module is consistent from the first batch. Cheap; **Stage 1** (a doc + a sweep of existing `tags()`). +4. **Doc model change** β€” must land before the doc explosion, i.e. before batch migration. A page per **library** (type-first name, underscore-joined): `effects_moonlight.md`, `effects_wled.md`, … (and `modifiers_.md` etc. only where a library has them; most are effects-only). Library is a *doc* split only β€” NOT a `src`/`assets`/`tests` folder (those stay `domain/type` flat; library is the `tags()` emoji there). Fixed by the [folder-structure decision](../../backlog/folder-structure-proposal.md). **Stage 2**. +5. **Audio** β€” audio-reactive effects (β™«) depend on `AudioModule::latestFrame()` (already exists). A later stage; not a blocker for non-audio effects. +6. **Moving heads / Art-Net fixtures** β€” `E_MovingHeads` targets DMX moving heads; depends on fixture-layout + Art-Net (partly present). Last, separate. + +No other hidden hard dependencies: our `EffectBase` + extrude (now 1D-along-Y, matching MoonLight) + `Buffer` already provide the render context. + +## Stages + +### Stage 1 β€” Foundations (palette + primitives + GoL re-port) ← start here + +The proving-ground stage: build the shared tools, prove them on one hard effect. + +- **Palette.** Take **MoonLight's palette set** (~80 gradient palettes, [palettes.h](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Modules/palettes.h) β€” study + carry the gradient *data*, written into our own format). The definition format is the textbook **gradient-stop** one: a compact `{position, R, G, B, …}` list (position 0..255, terminating at 255), expanded off-loop into a 256-entry lookup. Our `Palette` type + `colorFromPalette(palette, index, brightness)`: the per-light lookup is an array index + one `scale8` (hot-path-tuned; the 256-entry table precomputed on selection, not per frame). Generalises `PlasmaPaletteEffect`'s hard-coded table. + - **Ownership (decided 2026-06-30):** the **active palette is global**, owned by the **Drivers** container (already the home of global render params β€” brightness, lightPreset, the shared Correction) via a new `palette` select control. Effects read it through a static `Palettes::active()` seam (the `AudioModule::latestFrame()` pattern), so an effect just calls `colorFromPalette(Palettes::active(), idx)`. This mirrors MoonLight's global `layerP.palette` without needing MoonLight's `ModuleLightsControl` β€” which, with **presets** and the **external-controller hub** concept, is **backlogged** ([backlog-mixed.md](../../backlog/backlog-mixed.md)) and will absorb the palette control from Drivers when built. Presets are *not* a palette dependency β€” separate feature, backlogged. + - Palettes are light-domain β†’ live under `src/light/` (file split decided in the stage plan). +- **The shared primitive library** (file split β€” one `light/Fx.h` vs focused `light/Beat.h`/`Noise.h`/`Blend.h` β€” decided in the stage plan; recognisable names, our implementation, FastLED credited as prior art). Hot-path-tuned, integer-only, LUT-backed: + - *timing/beat:* `beatsin8/16`, `beat8/16`, `triwave8` (on `sin8` + `elapsed()`). + - *noise:* `inoise8` 1D/2D/3D (promote + generalise `NoiseEffect`'s existing hash β€” the textbook value/Perlin noise). + - *blend/scale:* `qadd8`/`qsub8` (saturating), `nscale8`, `fadeToBlackBy`, `blend(RGB, RGB, amt)` (`scale8` already in `color.h`). + - *random:* `random8`/`random16` β€” a small fast seedable PRNG, hot-path-cheap (not `std::rand`). + - *draw (the dimension-agnostic part the product owner called out):* `drawPixel`/`drawLine` (and later `drawCircle`/fill) operating on `Coord3D`, working **1Dβ†’3D** against the `Buffer`, so effects and modifiers share one set instead of re-rolling Bresenham per effect. This is the "core absorbs the hard part" principle β€” geometry primitives live once. +- **Re-port Game of Life** properly β€” the *real* MoonLight GoL algorithm (the cellular-automaton rules + its palette colouring + blur/mutation it actually uses), on top of the new palette + primitives, replacing the current 272-line version. This is the stage's proof: a real effect that exercises palette + random + neighbour math, done faithfully. +- **Tags/emoji legend.** Write the canonical legend (MoonLight as basis) into architecture.md Β§ Web UI / a tags reference, and sweep existing effects' `tags()` to match. Lightweight. + +Stage-1 exit: palette + primitives compile (-Werror), are unit-tested (each primitive pinned: `beatsin8` range, `inoise8` determinism, `qadd8` saturation, `drawLine` endpoints in 1D/2D/3D), GoL re-port renders correctly + has a scenario, tags legend documented. **No doc explosion yet** (GoL keeps its existing single `.md`; the doc-model change is Stage 2). + +### Stage 2 β€” Doc model: per-library pages + +Before migrating dozens of effects (which would create dozens of `.md`s), switch the doc model. The naming + structure is fixed by the [folder-structure decision](../../backlog/folder-structure-proposal.md): **`src`/`assets`/`tests` are `domain/type` folders, flat β€” library is NOT a folder there**, only a `tags()` emoji; **docs** are the one place library splits, as a **page name** (type-first, underscore-joined, matching how you'd read the folder path): `effects_moonlight.md`, `effects_wled.md`, `effects_projectmm.md`, … (and `modifiers_.md` etc. only where a library has that type β€” most libraries are effects-only). + +- **New per-library pages:** each effect is a **compact table row** β€” `| name + tags | gif | one-line description | controls |` β€” dropping the per-module `Tests`/`Design notes`/`Source` boilerplate (source is derivable, tests auto-discovered), so a ~30-effect page is ~120 lines (avoids both the per-module explosion *and* the one-giant-file extreme). Migrate the ~21 existing per-module effect specs into the right `effects_.md` by origin (from each effect's "Prior art"/tags β€” see the effect inventory reference). A short index page links the set. +- **Rewrite `check_specs.py`** to the new contract: every registered module's **control names** must appear *somewhere in its library page* (preserves the anti-drift guarantee the per-module check gave). The `registerType` second arg changes from `Foo.md` to the library page (`effects_moonlight.md`, or `…#foo`). +- **Gifs:** the per-module asset *move* is **already done** (assets are now `docs/assets/{core, light/{effects,…}}/` per the folder decision). What remains for this stage: **download MoonLight's preview gifs** (the WLED-Utils `FX_*.gif` set + the user-attachment gifs listed on MoonLight's effects/layouts/modifiers/drivers pages) into the matching `light/effects/…` folders as the new effects land, crediting source. +- **Wire-contract docs** that don't fit a table row (the genuinely technical ones β€” HueDriver's API, NetworkSend's protocols) keep a deeper section; the library page links to it. + +Stage-2 exit: the library pages render with gifs, `check_specs.py` green on the new contract, the per-module effect `.md`s deleted (subtraction). This is the "kills the explosion permanently" stage. + +### Stage 3+ β€” Effect migration in batches + +With foundations + doc model in place, migrate MoonLight effects in **themed batches**, each a stage/commit: study behaviour β†’ write fresh on our primitives β†’ unit + scenario test β†’ add to `effects.md` + gif. Batching keeps each commit reviewable. + +**Scope: ALL effects across MoonLight's `Nodes/Effects/E_*.h` files**, not a cherry-picked subset β€” the [breadth-parity gate](../../backlog/rename-to-moonlight.md) needs the full set. The source files (each an effect library, mapped to our origin sections + future per-library doc pages): +- **`E_MoonModules.h`** (MoonModules-authored, 3): **GameOfLife** (Conway, 2D/3D, rulesets/wrap/colour-aging/infinite-mode), **GEQ3D** β™« (perspective 3D equalizer bars), **PaintBrush** β™« (frequency-modulated animated lines, chaos/softness). β€” verified 2026-06-30 from source. +- **`E_MoonLight.h`** (MoonLight-original geometric set). +- **`E_WLED.h`** (WLED ports/enhancements). +- moving-head / DMX effect files β†’ Stage 5. + +The batch order below is by dependency/complexity (refine per batch), and **cuts ACROSS the source files** (an audio-reactive batch pulls GEQ3D+PaintBrush from E_MoonModules and the GEQ/Blurz family from E_WLED together) rather than migrating one file at a time β€” themed batches keep each commit coherent: + +- **3a β€” simple 2D/3D non-audio** (the `E_MoonLight` / `E_WLED` geometric ones: lines, scrolling, lissajous, distortion, starfield…). +- **3b β€” palette-heavy** (now that palettes exist: the gradient/noise/plasma family not yet ported). +- **3c β€” particle/physics** (bouncing balls, popcorn, blackhole β€” build on the draw primitives + PRNG). +- **3d β€” audio-reactive (β™«)** (GEQ, Blurz, Waverly, FreqMatrix… β€” depend on `AudioModule::latestFrame()`; a shared audio-read helper may be its own small sub-stage). +- **3e β€” text/scrolling** (scrolling text needs a font + glyph blitter β€” its own primitive). + +### Stage 4 β€” Modifiers + layouts migration + +The MoonLight modifiers (mirror/tile/kaleidoscope/pinwheel/transpose…) and layouts (panel/cube/ring/sphere/spiral/fixture variants) not yet ported. Modifiers are pure geometry (they fit our modifier model cleanly β€” and per architecture.md, geometry transforms belong in modifiers, not effects). Layouts are coordinate iterators. Smaller than the effect batches; can interleave with Stage 3. + +### Stage 5 β€” Moving heads / DMX fixtures (last) + +`E_MovingHeads` + fixture layouts + Art-Net moving-head control. Most specialised, fewest dependencies on the rest; deferred to last. + +## Riskiest parts + +1. **Palette + primitives are the load-bearing wall** β€” if their API or performance is wrong, every later effect inherits it. Stage 1 must get the hot-path shape right (measure tick cost; these run per-light). Worth over-investing in. +2. **Primitive implementation is ours** β€” the temptation under deadline is to copy a source's implementation, not just its recognisable name. The names follow the established FastLED convention (what a contributor recognises); the *code* is written fresh against our engine, behaviour pinned by tests, FastLED credited as prior art. Guard: independence-by-construction (own implementation + own architecture), not a renamed copy and not a traced one. +3. **Dimension-agnostic draw** β€” making `drawLine` etc. genuinely 1Dβ†’3D (not 2D with a z-loop bolted on) needs thought; get the abstraction right in Stage 1 or effects will work around it. +4. **Doc-model migration is a one-way door** β€” deleting 21 per-module `.md`s and rewriting the spec-check; do it as one coherent Stage-2 change, not piecemeal, so docs are never half-migrated. +5. **GoL "done right"** β€” we already got it wrong once; Stage 1 must pin the real algorithm against a reference (the actual rules + colouring), tested, so it's faithful this time. +6. **Scope discipline** β€” "migrate all of it" is dozens of modules. The batching is what keeps it from becoming one un-reviewable mega-diff; resist merging batches. + +## Verification (per stage) + +Every stage: desktop build (-Werror), `ctest` (new primitives/effects pinned), scenarios, spec-check, ESP32 build, KPI (watch the per-light hot-path cost as primitives land). Bench on hardware for the visual effects. Each stage saves its own `/plan` and commits independently. + +## Open questions (settle at each stage, not now) + +- Exact home + file split of the primitive library (one `Fx.h` vs several focused headers) β€” Stage 1 plan. +- Palette storage (flash tables vs computed gradients) + how the UI selects a palette (a `palette` control type?) β€” Stage 1 plan. +- The `registerType` β†’ category-page mapping mechanics β€” Stage 2 plan. +- Per-batch effect list + order β€” each Stage-3 sub-plan. diff --git a/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md b/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md new file mode 100644 index 00000000..6b6fe263 --- /dev/null +++ b/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md @@ -0,0 +1,40 @@ +# Plan β€” Stage 1 (palette) of the MoonLight migration + +The first executable slice of the [migration plan](Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md): the palette foundation. Design already decided in [moonlight-palettes-data.md](../../backlog/moonlight-palettes-data.md); this plan is the file split + the implementation specifics. The shared **primitive library** (beat/noise/blend/draw) and the **GoL re-port** are *separate* slices of Stage 1, planned + committed after this β€” palette is the load-bearing one, done first and alone so it's reviewable. + +## What ships + +1. **`src/light/Palette.h`** β€” a light-domain header (sibling of `light_types.h`), holding: + - **`Palette`** β€” the active palette: **16 RGB entries** (the `CRGBPalette16` model, recognisable name carried, our implementation on `RGB`/`scale8`). 48 bytes. + - **gradient-stop β†’ 16-entry expansion** (`fromGradient(const uint8_t* stops, size_t n)`): the textbook two-point lerp across the stop list, sampling 16 evenly-spaced positions. Off the hot path (called on selection). + - **`RGB colorFromPalette(const Palette& p, uint8_t index, uint8_t brightness = 255)`** β€” the per-light lookup: map `index` (0–255, wraps) to a position across the 16 entries, blend the two bracketing entries with `scale8`, apply `brightness` with `scale8`. Integer-only, hot-path-cheap. + - **Built-in palette set** β€” the gradient `{pos,R,G,B}` definitions from the [captured data](../../backlog/moonlight-palettes-data.md) as flash `constexpr`, plus the trivially-generated ones (rainbow via `hsvToRgb`, and a few solids). A `kPaletteNames[]` / `kPaletteCount` parallel to feed the select control. Start with a curated subset (~12–16: rainbow, party, ocean, lava, forest, heat, a couple of the named gradients) β€” not all ~54; the rest are data we can add later without design change. +2. **`Palettes::active()`** β€” the static seam effects read (the `AudioModule::latestFrame()` pattern): a `Palettes` holder with a static `const Palette* active()` + `setActive(index)` that expands the selected built-in into the live `Palette`. Lives in `Palette.h`. +3. **Drivers wiring** β€” a `palette` **select** control on the `Drivers` container (beside `brightness`/`lightPreset`), index into `kPaletteNames`. `Drivers::onUpdate` on a `palette` change calls `Palettes::setActive(index)` (rebuild the 16-entry lookup β€” cheap, like the `correction_.rebuild` it sits next to; `controlChangeTriggersBuildState` stays false, no pipeline realloc). `setup()` sets the initial active palette. +4. **Migrate `PlasmaPaletteEffect`** β€” replace its hard-coded `static constexpr RGB palette_[256]` with `colorFromPalette(Palettes::active(), idx)`. Proves the seam end-to-end on a real effect + removes a 256-entry duplicate (subtraction). (Its current fixed fire-ocean look changes to "whatever palette is active" β€” that's the point; the effect becomes palette-driven like its MoonLight original.) + +## Decided (from the design doc β€” not re-opened here) + +- 16-entry model, interpolate at lookup (not a 256 table); hard-swap on select (crossfade backlogged); global active palette owned by Drivers; `Palette` is an interface shape so a later **MoonLivePalette** (dynamic, script-authored) drops into the same `colorFromPalette` seam β€” Stage 1 builds only the gradient case but leaves `colorFromPalette` dispatchable. +- Naming: `Palette`, `colorFromPalette`, the `CRGBPalette16` model β€” recognisable names carried, FastLED credited (README + the spec's Prior art), implementation ours. + +## Files + +- **New:** `src/light/Palette.h`; `docs/moonmodules/light/Palette.md` (spec β€” the `Palette` type, `colorFromPalette` contract, the built-in set, the active-palette seam, Prior art crediting FastLED's gradient-palette model); `test/unit/light/unit_Palette.cpp`. +- **Edit:** `src/light/drivers/Drivers.h` (the `palette` select + onUpdate/setup); `src/light/effects/PlasmaPaletteEffect.h` (use the shared palette); `test/CMakeLists.txt`; the migration plan (mark palette slice landing). + +## Riskiest parts + +1. **The expansion + lookup must be correct *and* cheap** β€” pin both with tests (endpoints exact, a mid-gradient colour interpolates, the wheel wraps at 255β†’0, brightness folds correctly). It runs per-light, so eyeball the KPI tick after wiring PlasmaPalette. +2. **`colorFromPalette` dispatchable without a per-pixel cost** β€” Stage 1 has only the gradient case, so it's a direct call now; the *interface shape* (so MoonLivePalette slots in later) must not impose a per-pixel branch today. Keep it a plain function over the 16-entry `Palette`; the static/dynamic dispatch is a later per-frame concern, not built now. +3. **PlasmaPalette visual change** β€” it goes from a fixed palette to the active one; confirm it still looks good on the default palette and that the effect's index math still maps sensibly. + +## Verification + +Desktop build (-Werror); `ctest` incl. `unit_Palette` (expand/lookup/wrap/brightness) + the existing PlasmaPalette test still green; scenarios; spec-check (new `Palette.md`); ESP32 build; KPI (watch the per-light cost β€” PlasmaPalette is the canary). Bench: on desktop, switch the `palette` control and confirm PlasmaPalette (and the preview) recolours live. + +## Out of scope (later Stage-1 slices / later stages) + +- The primitive library (beat/noise/blend/draw) β€” next Stage-1 slice. +- GoL re-port β€” next Stage-1 slice. +- The full ~54 palette set, MoonLivePalette (dynamic), crossfade, the per-library doc pages (Stage 2). diff --git a/docs/history/plans/Plan-20260630 - Stage 1 primitive library (math8 + noise + draw + blend) (shipped).md b/docs/history/plans/Plan-20260630 - Stage 1 primitive library (math8 + noise + draw + blend) (shipped).md new file mode 100644 index 00000000..c1918033 --- /dev/null +++ b/docs/history/plans/Plan-20260630 - Stage 1 primitive library (math8 + noise + draw + blend) (shipped).md @@ -0,0 +1,52 @@ +# Plan β€” Stage 1 primitive library (math8 + noise + draw + blend) + +The remaining foundation of [MoonLight migration Stage 1](./Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md) (palette + tags-legend already shipped in `d00559c`). Builds the shared, hot-path-tuned integer primitives every migrated effect (Stage 3+) will call, so each effect stays short by leaning on one recognisable set instead of re-rolling beat/noise/blend/draw per effect. + +**Prior art:** FastLED β€” the canonical 8-bit-fixed-point LED library. We carry its *ideas and recognisable names* (`beatsin8`, `inoise8`, `qadd8`, `nscale8`, `random8`, `fadeToBlackBy`, `blend`) and write our own implementation against our architecture, crediting FastLED at each file's header. FastLED's own split is the model: `lib8tion` (math+timing+random), `noise` (inoise), `colorutils` (blend/fade), `hsv2rgb` (color) β€” draw/Bresenham lives in its 2D/matrix add-ons, not core. + +## File split (decided 2026-06-30) β€” split-by-concern, math in core / draw in light + +**Core (`src/core/`)** β€” domain-neutral integer math: +- **`color.h`** (slimmed) β€” keeps `RGB`, `hsvToRgb`, `scale8` (the color surface). `sin8`/`cos8`/`atan2_8`/`dist8` MOVE OUT (they're trig/geometry, not color). +- **`math8.h`** (new) β€” the `lib8tion` surface: `sin8`/`cos8`/`triwave8` (moved from color.h), `beatsin8`/`beatsin16`/`beat8` (timing, on `sin8`+`elapsed()`), `qadd8`/`qsub8`/`nscale8`, `map8`, a small seedable PRNG β€” the **`Random8` class** (`next8()`/`next16()`/`below(n)`/`below(min,max)`, not free `random8`/`random16` functions, so each effect owns an independent reproducible stream), `atan2_8`/`dist8` (moved from color.h). *(As shipped, `beatsin8` is the FastLED 5-arg form `(bpm, ms, low, high, timebase, phase)`.)* +- **`noise.h`** (new) β€” `inoise8` 1D/2D/3D (promotes + generalises `NoiseEffect`'s existing hash into the textbook value/Perlin noise; the effect then calls it). + +**Light (`src/light/`)** β€” operates on the light `Buffer`/`Coord3D`: +- **`draw.h`** (new) β€” `drawPixel(buf, Coord3D, RGB)` / `drawLine(buf, Coord3D a, Coord3D b, RGB)` working 1Dβ†’3D against the `Buffer` (integer Bresenham). Geometry lives once (the "core absorbs the hard part" principle), light-domain because it touches the light Buffer. Circle/fill are a later add. +- **`Palette.h`** (extend) β€” fold in `fadeToBlackBy(RGB&, amt)` and `blend(RGB, RGB, amt)` next to `colorFromPalette` (FastLED's `colorutils` shape; RGB-blend ops belong with the palette/color-lookup file, not a new file for two functions). + +Net: 2 new core files + 1 new light file + 2 edits (color.h slim, Palette.h extend). ~20 primitives across 4–5 focused files β€” recognisable to any FastLED/embedded dev in 30s, no per-function explosion, color.h cleaned to color-only. + +## The `color.h` move (the churn this incurs) + +`sin8`/`cos8`/`atan2_8`/`dist8` move `color.h` β†’ `math8.h`. 18 files include `core/color.h`; the ones using sin8/dist8 (effects: Wave, Spiral, Plasma, Metaballs, DistortionWaves, Ripples, Sine…) add `#include "core/math8.h"`. `color.h` includes nothing new. Mechanical, caught by -Werror (missing symbol) if an include is missed. + +## Files + +- **New:** `src/core/math8.h`, `src/core/noise.h`, `src/light/draw.h`. +- **Edit:** `src/core/color.h` (remove sin8/cos8/atan2_8/dist8 β†’ math8.h; keep RGB/hsvToRgb/scale8), `src/light/Palette.h` (add fadeToBlackBy/blend), every effect that used sin8/dist8 (add the math8 include), `NoiseEffect.h` (call `inoise8` instead of its inline hash β€” proves the promotion). +- **Tests (new):** `test/unit/core/unit_math8.cpp` (beatsin8 range + period, qadd8/qsub8 saturation, nscale8, random8 determinism+distribution, triwave8), `test/unit/core/unit_noise.cpp` (inoise8 determinism + smoothness + 1D/2D/3D bounds), `test/unit/light/unit_draw.cpp` (drawPixel in-bounds/clipped, drawLine endpoints + a known diagonal in 1D/2D/3D). +- **Docs:** a short `docs/coding-standards.md` or architecture note pointing effects at the primitive set (one reference, not per-primitive docs). No module `.md` (these are libraries, not MoonModules). + +## Hard-rule conformance + +- **Hot path:** all integer, LUT-backed where applicable (`sin8` already a 256-LUT; `inoise8` interpolates a hash, no float). No heap, no allocation. `random8` is a 1-line LCG/xorshift, not `std::rand`. +- **Platform boundary:** pure computation, no platform calls β€” lives outside `src/platform/`. `beatsin8` reads time via the existing `elapsed()`/`platform::millis()` seam the effects already use (passed in, not called inside core β€” confirm the cleanest signature in implementation: likely `beatsin8(bpm, low, high, timeMs)` so core stays time-source-agnostic). +- **Domain boundary:** math8/noise are domain-neutral (core); draw touches the light Buffer (light). No `#ifdef`. + +## Verification + +- Build -Werror (the color.h move surfaces any missed include immediately). +- `ctest`: each primitive pinned (ranges, saturation, determinism, endpoints) per the stage-1 exit criteria. Scenarios still green (NoiseEffect now via inoise8 must still render non-zero + vary). +- KPI: tick unchanged or better (primitives are the same work the effects already did, now shared β€” NoiseEffect's hash promotion should be perf-neutral). +- No P4/S3 regression (bench after, given the recent stack lesson β€” though these add no large members). + +## Stage-1 exit (from the migration plan) + +After this: palette βœ… (shipped), tags legend βœ… (shipped), primitives βœ… (here, unit-tested), doc-model βœ… (shipped early in Stage 2's compact pages). The one remaining Stage-1 item β€” **the GoL re-port** (the proof effect on palette+primitives) β€” is the NEXT plan after this, since it depends on `inoise8`/`random8`/`drawPixel` landing first. + +## Out of scope + +- GoL re-port (next plan β€” needs these primitives first). +- `drawCircle`/fill, font/glyph blitter (Stage 3e), `ease8`/gamma (add when an effect needs them β€” concrete-first). +- Touching `colorFromPalette` itself (shipped, working). diff --git a/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md b/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md new file mode 100644 index 00000000..24f187ca --- /dev/null +++ b/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md @@ -0,0 +1,55 @@ +# Plan β€” Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) + +The first effect-migration batch of [MoonLight migration Stage 3](./Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md): port all three effects in MoonLight's `Nodes/Effects/E_MoonModules.h` (MoonModules-authored set), built fresh on the Stage-1 primitives (palette, `math8`, `noise`, `draw`). One commit (PO decision). Each effect: study behaviour β†’ reimplement against EffectBase β†’ unit + scenario test β†’ `effects.md` row. + +**Method (CLAUDE.md):** study the MoonLight source for *behaviour* (controls, algorithm, state), then write our own code on our architecture β€” never trace/copy. FastLED/MoonLight credited as prior art in each `tags()` + the effect's header + the `effects.md` row. + +## Decisions locked (product owner, 2026-06-30) + +- **All three in one commit** (the E_MoonModules batch). +- **GameOfLife: FULL faithful port** β€” all 7 rulesets + custom `B#/S#` parser, CRC16 stasis/oscillator/spaceship detection, infinite-mode pentomino/glider respawn, color-by-age, blur, 2D **and** 3D (8- vs 26-neighbour). The Stage-1 "proof effect" done properly, on the new palette + `random8`. +- **Skip soft/anti-aliased lines** β€” our `draw::line` is hard-edged; drop the `soft` control on GEQ3D/PaintBrush. Crisp lines read fine on an LED grid; an AA `draw::line` is a later foundation add if visibly needed. + +## The three effects + +### GameOfLifeEffect (2D/3D, πŸ’«πŸŒ™ MoonModules) +- **Controls:** `backgroundColor` (Coord3D/RGB), `ruleset` (select: Custom/Conway B3-S23/HighLife/InverseLife/Maze/Mazecentric/DrighLife), `customRule` (text "B#/S#"), `speed` (0-100, gen/s), `density` (10-90% initial life), `mutation` (0-100%), `wrap` (bool), `colorByAge` (bool: greenβ†’red), `infinite` (bool: respawn), `blur` (0-255 dead-cell fade). (`disablePause` dropped β€” UI nicety, not behaviour.) +- **State (heap, onBuildState β€” like Fire's heat_/Wave's trail_):** `cells_`/`future_` bit-packed (count/8 bytes each), `colors_` palette-index per cell (count bytes), `generation_`, `step_` ms, parsed `birth_[9]`/`survive_[9]` bool arrays, CRC history (`oscCrc_`/`shipCrc_`) for stasis detection. ~20 KB at 128Β² β†’ PSRAM via `platform::alloc`, freed in teardown + dtor. NOT inline members (the HueDriver stack lesson). +- **Algorithm:** gen 0 = random fill by `density`, random palette colour per live cell. Each gen (throttled by `speed`): count neighbours (8 in 2D, 26 in 3D, `wrap` toroidal), apply `birth_/survive_`, newborns inherit a neighbour's colour with `mutation` chance of a fresh one; dead cells fade toward `backgroundColor` by `blur`. `colorByAge` overrides colour (green new β†’ red aging). After each gen, CRC16 the grid; on detected oscillation/extinction, `infinite` ? place an R-pentomino/glider : reset to gen 0. +- **New primitive needed:** `crc16` (textbook CCITT) β€” add to a small `core/crc.h` (a hash, not math8; reusable, ~15 lines). Uses `Random8` (math8) for fill/mutation/respawn placement, `colorFromPalette` for colour, `fadeToBlackBy`/`blend` for the dead-cell blur. + +### GEQ3DEffect (2D, audio β™«, πŸ’«πŸŒ™) +- **Controls:** `speed` (1-10 projector sweep), `frontFill` (0-255), `horizon` (0..width-1 vanishing row), `depth` (0-255 perspective), `numBands` (2-16), `borders` (bool). (`softHack` dropped.) +- **State:** `projector_` x-position + `projectorDir_` (Β±1), `counter_` frame throttle. +- **Algorithm:** sweep the projector (vanishing point) left/right at `speed`, bounce at edges. Map `AudioFrame::bands[]` β†’ bar heights (75-85% of height); scale band indices if `numBands` < 16. Per band draw a 3D-perspective bar: darker side edges, a top surface with perspective lines toward the projector, a front fill (`frontFill` blend) bottomβ†’height, optional `borders`. Bands left of the projector paint right-to-left, right side left-to-right. Reads `AudioModule::latestFrame()` (the AudioSpectrum pattern); uses `draw::line` + `blend`. + +### PaintBrushEffect (3D, audio β™«, πŸ’«πŸŒ™) +- **Controls:** `oscillatorOffset` (0-16 phase mult), `numLines` (2-255), `fadeRate` (0-128 bg decay), `minLength` (0-255 draw threshold), `colorChaos` (bool per-line hue), `phaseChaos` (bool per-frame jitter). (`soft` dropped.) +- **State:** `hue_` (cycles per frame), `chaos_` (per-frame random phase or 0). +- **Algorithm:** each frame: advance `hue_`, fade the whole field by `fadeRate` (`fadeToBlackBy` over the buffer). Per line (0..numLines): map lineβ†’audio band; build two 3D endpoints via `beatsin8(... oscillatorOffset, ms)` modulated by band amplitude; Euclidean distance Γ— band magnitude = length; if length > `minLength` draw `draw::line(a, b, colour)`. Colour = `colorChaos` ? per-line hue+`hue_` : per-band gradient. Uses `beatsin8` (math8), `AudioFrame::bands[]`, `draw::line`, `fadeToBlackBy`. + +## Files + +- **New:** `src/light/effects/GameOfLifeEffect.h`, `GEQ3DEffect.h`, `PaintBrushEffect.h`; `src/core/crc.h` (crc16 for GoL stasis). +- **Edit:** `src/main.cpp` (register the 3, each β†’ `light/effects/effects.md#`), `test/scenario_runner.cpp` (register for scenarios), `docs/moonmodules/light/effects/effects.md` (3 rows + `## Source` links + anchors), `test/CMakeLists.txt` (the new unit tests). +- **Tests (new):** `unit_GameOfLifeEffect.cpp` (Conway still-life stays, blinker oscillates, B/S parser, neighbour count 2D+3D, no-crash on 0Γ—0Γ—0), `unit_crc.cpp` (crc16 known vectors), and GEQ3D/PaintBrush covered by the shared render test (`unit_effects_render` STATELESS / non-zero with a fed AudioFrame) + a scenario. Audio effects render dark on silence (safe) β€” assert structure on a synthetic frame where testable. +- **Scenarios:** add the 3 to the perf/all-effects sweep; GoL gets its own scenario (gen progression renders + doesn't crash at any grid size). + +## Hard-rule conformance + +- **Hot path:** integer-only; GoL's grid state is heap (PSRAM), allocated off the hot path in `onBuildState`, freed in teardown/dtor β€” never inline (sizeof stays small; the registerType-probe stack stays tiny, per the HueDriver/P4 lesson). The neighbour sweep is integer; the per-frame render writes the buffer directly. +- **Effects at every grid size:** all three guard 0Γ—0Γ—0 and tiny grids (GoL with <1 cell = no-op; GEQ3D/PaintBrush clip via `draw::`). Animation math doesn't truncate to zero on a fast tick. +- **Audio safety:** GEQ3D/PaintBrush read `latestFrame()`; no mic β†’ zero bands β†’ dark, safe on any target (the AudioSpectrum/Volume contract). +- **Domain/platform:** pure light-domain effects on EffectBase; no `#ifdef`, no platform calls (heap via `platform::alloc`). + +## Verification + +- Build -Werror (desktop + ESP32). `ctest` green incl. the new GoL + crc tests. Scenarios green incl. the new GoL scenario + the 3 in the sweep. `check_specs.py` green (3 new control sets all named in `effects.md`). +- KPI: GoL is the heaviest (full-grid neighbour sweep) β€” measure its tick; it throttles by `speed` so the per-frame cost is the render, not the update, most frames. +- Bench on P4 + S3: each effect renders; GoL progresses generations + respawns; the two audio effects react to sound. (Given the stack lesson, confirm GoL's heap state doesn't bloat sizeof β€” it's pointers, not arrays.) + +## Out of scope + +- `disablePause` / `softHack` / `soft` controls (UI niceties / AA we don't do yet). +- Anti-aliased `draw::line` (a later foundation add). +- The other `E_*.h` files (E_MoonLight, E_WLED) β€” later Stage 3 batches. diff --git a/docs/history/troyhacks-WLED.md b/docs/history/troyhacks-WLED.md index 2c1561a0..28631ae8 100644 --- a/docs/history/troyhacks-WLED.md +++ b/docs/history/troyhacks-WLED.md @@ -6,6 +6,12 @@ This is a personal fork of [MoonModules/WLED-MM](https://github.com/MoonModules/ **Branch note β€” the experiments live off `mdev`.** troyhacks branches heavily: `mdev` is the merge/alignment stream, but the distinctive work happens in named experimental branches (HDMI output, ESP32-P4, W5500 Ethernet, hardware-panel ports, voice control, a pure-IDFv5 port, a new settings subsystem). Those are *experiments*, not necessarily destined for `mdev`, so each month below carries a separate **Experimental branches** line for what moved on them β€” the frontier of what this fork is probing. +## June 2026 + +No user-facing activity: no commits were merged to `mdev` in June 2026 (the branch's most recent commit is dated 2026-05-20), and no versioned release was published. The repository's issue tracker is disabled, so no issues were opened or closed. + +_Checked: merged commits on `mdev` for author-date 2026-06-01..2026-06-30 (0 commits); releases published in June 2026 (none); issue search `repo:troyhacks/WLED is:issue created:2026-06-01..2026-06-30` and `closed:2026-06-01..2026-06-30` (0 results β€” issues disabled on this fork)._ + ## May 2026 *~18 commits on `mdev`, 2026-05-01 … 2026-05-31.* diff --git a/docs/history/wled-WLED.md b/docs/history/wled-WLED.md index 97d5d114..5053d566 100644 --- a/docs/history/wled-WLED.md +++ b/docs/history/wled-WLED.md @@ -4,6 +4,33 @@ What landed on [wled/WLED](https://github.com/wled/WLED)'s `main` branch, month Months are **not** split at release dates: upstream WLED cuts releases from separate release branches (`0_15`, `16_x`), so the version tags aren't on `main` β€” `main` is the development trunk that feeds future releases. Each month notes which release shipped, as context. +## June 2026 + +Post-16.0 stabilisation month: no new version tag (v16.0.0 shipped 2026-05-03 off a release branch, so the month is not split), just a steady stream of bugfixes and small additions landing on `main`. + +**New** +- HUB75 matrix panels: added a "Seengreat" pinout, plus fixes for 4-scan and chained-panel setups. +- Renamed the "CW" LED type to "CCT" for clarity when configuring CCT white strips. +- V5-C6 boards are now covered by the V5 build. + +**Fixed** +- "Rainbow" and other color-wheel effects no longer mis-drive the white channel on RGBW strips. +- Restored the pre-16.0 look of several effects that had changed appearance after the 16.0 upgrade (also fixes the DJ Light intensity regression). +- HUB75: restored missing pixel trails on some 2D effects (Black Hole, Lissajous, Spaceships). +- Fixed a crash when creating a 2D setup larger than the actual number of LEDs. +- Fixed LED glitches on long strips with ESP32-C3. +- Nightlight: brightness now applies correctly, including small transition steps, and no longer resets when set to max brightness. +- Color no longer jumps when you change it mid-transition; gamma is now applied correctly during realtime/live-data override. +- Improved boot behaviour for boot presets, and "Reset segments" now respects "Make a segment for each output". +- Fixed a ledmap parser reading past the end of the map, an analog-button reading fix, and a pixel-buffer refresh after changing matrix dimensions. +- Better brownout detection and extended error codes aligned with WLED-MM. + +**Watching** +- Discussion opened on switching from plain gamma to an sRGB transfer function for better low-brightness accuracy (#5707), and on improving the Nodes/Instances page (#5711) β€” no shipped outcome yet. +- Several v16.0 field reports still open: multi-controller sync losing colour (#5705), UDP sync failing in AP mode (#5709), and OTA-update trouble on some boards (#5682, #5702). + +_Auditability: 43 commits on `main` with author-date 2026-06-01..2026-06-30 (`repos/wled/WLED/commits?sha=main`, first-line view; a few older-dated cherry-picks appear in-range and were excluded as non-June). Issues via `search/issues` for repo:wled/WLED created:2026-06-01..2026-06-30 (18 opened) and closed:2026-06-01..2026-06-30 (25 closed); only user-facing ones surfaced. No versioned release published in June (v16.0.0 was 2026-05-03), so no month split._ + ## May 2026 *Summarised from 72 first-parent commits on `main`, 2026-05-01 … 2026-05-31. (Trunk after the **v16.0.0** release on May 3; v16 is codenamed "Kagayaki".)* diff --git a/docs/install/README.md b/docs/install/README.md index 0c0571f8..6fcc8ddd 100644 --- a/docs/install/README.md +++ b/docs/install/README.md @@ -62,7 +62,7 @@ labelled summary with a thumbnail: | Collapsed | Expanded | |---|---| -| ![Board picker collapsed](../assets/screenshots/installer-board-picker-collapsed.png) | ![Board picker expanded](../assets/screenshots/installer-board-picker-expanded.png) | +| ![Board picker collapsed](../assets/ui/installer-board-picker-collapsed.png) | ![Board picker expanded](../assets/ui/installer-board-picker-expanded.png) | ## Catalog schema (`deviceModels.json`) diff --git a/docs/moonmodules/core/AudioModule.md b/docs/moonmodules/core/AudioModule.md index 0fc50b42..88ad5ffb 100644 --- a/docs/moonmodules/core/AudioModule.md +++ b/docs/moonmodules/core/AudioModule.md @@ -1,6 +1,6 @@ # AudioModule -Acquires an audio source and publishes an **AudioFrame** β€” an overall sound **level**, a 16-band frequency **spectrum**, and the **dominant peak**. The frame is available to consumers every render tick, but its analysed values are *recomputed* only when a full sample block has accumulated (a 512-sample block at 22 kHz takes ~23 ms, longer than one tick), so a tick that doesn't complete a block re-publishes the previous `AudioFrame` unchanged rather than re-analysing. It is the producer half of the audio-reactive pipeline; [AudioVolumeEffect](../light/effects/AudioVolumeEffect.md) and [AudioSpectrumEffect](../light/effects/AudioSpectrumEffect.md) are the consumers. +Acquires an audio source and publishes an **AudioFrame** β€” an overall sound **level**, a 16-band frequency **spectrum**, and the **dominant peak**. The frame is available to consumers every render tick, but its analysed values are *recomputed* only when a full sample block has accumulated (a 512-sample block at 22 kHz takes ~23 ms, longer than one tick), so a tick that doesn't complete a block re-publishes the previous `AudioFrame` unchanged rather than re-analysing. It is the producer half of the audio-reactive pipeline; [AudioVolumeEffect](../light/effects/effects.md) and [AudioSpectrumEffect](../light/effects/effects.md) are the consumers. It is named for what it does, audio acquisition plus analysis, not for one source: today the source is a digital IΒ²S MEMS microphone (the only one wired), and the same analysis pipeline is built to serve other sources (line-in, USB audio) behind the platform read seam as they are added. The candidate source types β€” IΒ²S with an MCLK for line-in, PDM mics, analog line-in, and IΒ²C-configured codecs β€” are surveyed in the [Troy (troyhacks)](#prior-art) prior-art notes below. Most of the module is the analysis (DC-blocker, RMS level, windowed FFT, band mapping), which is source-independent. @@ -25,7 +25,7 @@ The part is self-clocked from the bit clock; there is no master-clock (MCLK) pin Each `loop()`: read a block of samples β†’ DC-blocker high-pass β†’ compute the level β†’ window + FFT β†’ map to bands. The high-pass conditions the raw block once, up front, so both the level and the spectrum see the same cleaned signal. - **DC-blocker high-pass** (`AudioLevel.h::DcBlocker`, host-tested): a one-pole/one-zero IIR high-pass (`y[n] = x[n] βˆ’ x[nβˆ’1] + RΒ·y[nβˆ’1]`, `R = 0.99`, β‰ˆ 40 Hz corner at 22 kHz) applied to the whole block before any analysis. It removes the MEMS mic's large constant DC bias *and* sub-bass rumble below ~40 Hz (handling/wind/structural) that would otherwise leak into the lowest band. Its state carries across blocks (it's a continuous filter, not per-block), and it resets when the channel re-inits. This is distinct from, and runs before, the level path's own block-mean subtraction below. -- **Level** (`AudioLevel.h`, host-tested): subtract the block's DC mean (belt-and-braces after the high-pass), take the RMS, and map it through the log/dB window (`floor` / `gain`). It is the overall loudness, independent of the FFT: the VU value. (It uses a gentler floor than the bands so the meter keeps moving with volume rather than gating hard.) +- **Level** (`AudioLevel.h`, host-tested): subtract the block's DC mean (belt-and-braces after the high-pass), take the RMS, and map it through the log/dB window (`floor` / `gain`). It is the overall loudness, independent of the FFT: the VU value. (It uses a gentler floor than the bands so the meter keeps moving with volume rather than gating hard.) The frame carries this **raw** value as `level` (recomputed each block, snaps to transients β€” for beat-reactive effects) plus `levelSmoothed`, an exponential moving average of it (lags and rounds off sudden changes β€” for calm/breathing effects). Consumers pick the one matching their look: NoiseMeter reads raw `level`; the AudioVolume / AudioSpectrum VU meters and FreqMatrix read `levelSmoothed`. (WLED's `volumeRaw` / `volume` pair.) - **Spectrum** (`AudioBands.h`, host-tested): apply a [Hann window](https://en.wikipedia.org/wiki/Hann_function) (the standard general-purpose FFT window, tapers the block edges so a tone doesn't smear across bins), run the FFT (`platform::audioFft`), then group the magnitude bins into 16 log-spaced bands (a plain geometric / equal-ratio bin split) and pick the loudest bin as the dominant peak (argmax). The peak is held when no real signal is present so it doesn't wander in silence. Only the IΒ²S read and the FFT kernel are platform code (`platform_esp32_i2s.cpp`: IDF's [`i2s_std`](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/i2s.html) driver + [esp-dsp](https://github.com/espressif/esp-dsp)'s float `dsps_fft2r_fc32`); everything else is plain domain math that runs in CI on the desktop's reference DFT. @@ -40,6 +40,7 @@ The DSP choices are the textbook defaults on purpose: a **Hann** window, **RMS** - `gain`, sensitivity: higher = more (a narrower dB window, so a given sound fills more of the bar). Default 222. - `level RMS`: read-only RMS sound level. The display shows the PEAK level over each 1-second window (the live value the LEDs use is recomputed ~43Γ—/s; sampling it once a second would read 0 between beats even while the meter LEDs move). - `peakHz`: read-only dominant frequency (updates each second). +- `simulate`: a dropdown that replaces the mic signal with a **synthesized `AudioFrame`**, so audio-reactive effects light up on a board with no microphone (a preview/demo device) and so audio behaviour can be exercised deterministically without playing real sound. Options: **off** (mic only); **music (silence)** / **sweep (silence)** β€” synthesize *only* when the real mic is quiet, so a mic board falls back to the synth between songs; **music (always)** / **sweep (always)** β€” always synthesize, ignoring the mic. *music* is a plausible song (per-band sine oscillators, a swelling volume, a periodic beat pulse, a drifting peak frequency); *sweep* marches a single lit band bassβ†’treble, a frequency-response test pattern. Default **off**. ## Cross-domain wiring diff --git a/docs/moonmodules/core/DevicesModule.md b/docs/moonmodules/core/DevicesModule.md index 41a7f5b5..44db02a1 100644 --- a/docs/moonmodules/core/DevicesModule.md +++ b/docs/moonmodules/core/DevicesModule.md @@ -1,6 +1,6 @@ # DevicesModule -![DevicesModule controls](../../assets/screenshots/Devices%20module.png) +![DevicesModule controls](../../assets/core/Devices%20module.png) A **core**, domain-neutral module that discovers other devices on the LAN, identifies what each is, and presents them as a browsable list. It focuses on *all* devices on the network (including this one, marked as self), not on the host's own state β€” so its card looks the same on every projectMM instance, ESP32 or PC. Light-domain modules consume the device list; the discovery machinery itself stays domain-neutral. @@ -34,6 +34,10 @@ Foreign ecosystems hook in as **plugins**, not hardcoded branches β€” the adapte The plugin classification is pure and host-unit-tested (`unit_DeviceIdentify.cpp` feeds synthetic packets, incl. short/garbage β†’ declined), with no network. The full pipeline is tested via `injectPacketForTest` (`unit_DevicesModule_discovery.cpp`) β€” and because `UdpSocket` works on desktop, the discovery path is host-testable with real datagrams, not just stubs. +### Out-of-band devices (Hue bridge) + +A device not discovered by UDP presence β€” a Philips Hue bridge, found over HTTP by a [HueDriver](../light/drivers/HueDriver.md) β€” registers itself through `upsertHueBridge(ip, name, colourCount)`, reached via `active()` (the boot-instance static accessor, the `AudioModule::latestFrame()` seam shape). The bridge then lists like any device, with a `colour` field (its colour-light count, for sizing a layout). This keeps the module domain-neutral: the Hue HTTP/pairing lives entirely in the light-domain driver; the core only stores the resulting row. (`unit_DevicesModule_hue.cpp` pins the row + its persistence round trip.) + ### Age-out Each sighting stamps the device's `lastSeenMs`; `ageOut()` runs every tick. A live-confirmed device is kept for `kStaleMs` (**24 h**) after its last presence packet, so the list is a durable "devices I've seen" history; a **cached** row (restored from persistence, not yet re-heard this session) gets only a short `kCachedGraceMs` (**60 s**) probation, so a long-gone persisted device can't survive forever across reboots β€” a live packet promotes it to the 24 h window. A **timestamp**, not a counter. The self row never ages out (it tracks the current local IP). Storage is a fixed `devices_[kMaxDevices]` array β€” bounded, no heap. @@ -44,11 +48,11 @@ Because the presence broadcast and the mDNS advertise are WLED-shaped, a project **In WLED's own "Sync interfaces" instances list** β€” a real WLED lists every projectMM board it heard on UDP 65506. (The `undefined` columns are WLED-sync fields projectMM doesn't fill β€” the presence packet carries identity, not the full WLED sync state; listing is what we're after.) -![projectMM devices in WLED's instances list](../../assets/screenshots/Wled%20discovers%20projectMM.png) +![projectMM devices in WLED's instances list](../../assets/core/Wled%20discovers%20projectMM.png) **In the native WLED app** (iOS / Android) β€” discovered via the mDNS `_wled._tcp` advertise, validated via the `/json/info` shim, with live colour + a working brightness slider over the `/ws` WebSocket. See [HttpServerModule Β§ WLED-compatibility shim](HttpServerModule.md#wled-compatibility-shim) for the wire contract (reverse-engineered from the [WLED-Android](https://github.com/Moustachauve/WLED-Android) client). -![projectMM devices in the native WLED app](../../assets/screenshots/WLED%20Native%20discovers%20projectMM.jpeg) +![projectMM devices in the native WLED app](../../assets/core/WLED%20Native%20discovers%20projectMM.jpeg) ## Transport boundary (discovery vs commands) diff --git a/docs/moonmodules/core/FilesystemModule.md b/docs/moonmodules/core/FilesystemModule.md index a8d37a6a..46015b6b 100644 --- a/docs/moonmodules/core/FilesystemModule.md +++ b/docs/moonmodules/core/FilesystemModule.md @@ -1,6 +1,6 @@ # FilesystemModule -![FilesystemModule controls](../../assets/screenshots/FilesystemModule.png) +![FilesystemModule controls](../../assets/core/FilesystemModule.png) Persists control values to flash so settings survive a reboot. Always loaded, runs first in the scheduler so its load hook fires before any other module's `setup()`. @@ -50,6 +50,8 @@ A subtree's dirty flag is cleared only after its write succeeds; a failed write The `lastSaved` read-only control shows how long ago the last write happened (`"never"`, `"5s ago"`, `"3m ago"`), refreshed each `loop1s()`. +The `filesystem` progress control shows the config-partition usage (bytes used / total), refreshed each `loop1s()` from `platform::filesystemUsed()` / `filesystemTotal()`. It is bound only when the platform reports a real partition (a chip without a data partition, or desktop, reports 0 and the bar is omitted). This bar lives here, on the module that owns the filesystem β€” not on SystemModule. + ## Conditional visibility (`hidden` flag) Modules with conditional controls (e.g. NetworkModule's static-IP fields under `addressing=Static`) bind their full control set unconditionally and toggle a `hidden` flag per descriptor: diff --git a/docs/moonmodules/core/FirmwareUpdateModule.md b/docs/moonmodules/core/FirmwareUpdateModule.md index 491eba50..0d7e9838 100644 --- a/docs/moonmodules/core/FirmwareUpdateModule.md +++ b/docs/moonmodules/core/FirmwareUpdateModule.md @@ -1,6 +1,6 @@ # FirmwareUpdateModule -![FirmwareUpdateModule controls](../../assets/screenshots/FirmwareUpdateModule.png) +![FirmwareUpdateModule controls](../../assets/core/FirmwareUpdateModule.png) A thin status surface for OTA flashing. The flash itself is driven by `POST /api/firmware/url` in HttpServerModule, which hands the URL to `platform::http_fetch_to_ota` (a task that downloads via `esp_https_ota` and writes the next OTA partition). The task and this module communicate through shared file-scope globals; the module polls them in `loop1s()` and the existing WebSocket state push surfaces the change at 1 Hz. diff --git a/docs/moonmodules/core/NetworkModule.md b/docs/moonmodules/core/NetworkModule.md index 419fd7d9..afa62b45 100644 --- a/docs/moonmodules/core/NetworkModule.md +++ b/docs/moonmodules/core/NetworkModule.md @@ -1,6 +1,6 @@ # NetworkModule -![NetworkModule controls](../../assets/screenshots/NetworkModule.png) +![NetworkModule controls](../../assets/core/NetworkModule.png) Manages all device connectivity with automatic fallback: Ethernet β†’ WiFi STA β†’ WiFi AP. One MoonModule, one UI card β€” the user sees "Network", not three separate technologies. diff --git a/docs/moonmodules/core/SystemModule.md b/docs/moonmodules/core/SystemModule.md index a9d00b06..28610e1c 100644 --- a/docs/moonmodules/core/SystemModule.md +++ b/docs/moonmodules/core/SystemModule.md @@ -1,6 +1,6 @@ # SystemModule -![SystemModule controls](../../assets/screenshots/SystemModule.png) +![SystemModule controls](../../assets/core/SystemModule.png) System-level diagnostics and device identity. Always loaded, always visible in the UI. @@ -21,11 +21,9 @@ System-level diagnostics and device identity. Always loaded, always visible in t **Static (set at boot):** - `chip` (read-only) β€” chip model (ESP32, ESP32-S3, etc.) - `sdk` (read-only) β€” ESP-IDF version string (or compiler on desktop) -- `sdkDate` (read-only) β€” the date this firmware image was built, from the IDF app descriptor (e.g. `May 26 2026`); the compiler `__DATE__` on desktop - `wifiCoproc` (read-only) β€” WiFi co-processor firmware status, shown only on boards whose radio is a separate chip (the ESP32-P4 with its on-board [ESP32-C6](https://www.espressif.com/en/products/socs/esp32-c6) over [esp_hosted](https://github.com/espressif/esp-hosted-mcu)). Reports the detected slave firmware version (`C6 fw 2.12.9`) when the link is up, or `not detected` when the C6 never completes its handshake / reports 0.0.0, which is the signature of absent or incompatible C6 slave firmware. Absent on native-radio targets (the platform returns an empty string and the control is not added). - `flash` (read-only) β€” total flash chip size - `psram` (read-only, progress) β€” used / total PSRAM (only if present) -- `filesystem` (read-only, progress) β€” used / total filesystem - `bootReason` (read-only) β€” human-readable reset reason from `platform::resetReason()` (e.g. `POWERON`, `SW`, `PANIC`, `INT_WDT`, `TASK_WDT`, `BROWNOUT`, `DEEPSLEEP`). Desktop always reports `OK`. The UI flags the reboot button with a red border (`data-crashed="true"`) when the value is one of PANIC / INT_WDT / TASK_WDT / BROWNOUT, indicating the prior boot ended unexpectedly. On desktop these show "desktop" / "N/A" for hardware-specific fields. diff --git a/docs/moonmodules/light/Drivers.md b/docs/moonmodules/light/Drivers.md index ce91f3f2..95413415 100644 --- a/docs/moonmodules/light/Drivers.md +++ b/docs/moonmodules/light/Drivers.md @@ -1,6 +1,6 @@ # Drivers -![Drivers controls](../../assets/screenshots/Drivers.png) +![Drivers controls](../../assets/light/drivers/Drivers.png) Top-level container for one or more drivers. The consumer side of the pipeline β€” owns the shared output buffer (when memory allows) and performs blend+map from every layer's buffer into it each frame. @@ -26,6 +26,7 @@ The Drivers container owns the shared output-correction state and exposes two co |---|---|---| | `brightness` | uint8 (0–255) | Global brightness. Scales every channel through a 256-entry LUT (`(v Γ— brightness) / 255`). Changing it rebuilds only the LUT on the cheap `onUpdate` tier β€” no pipeline realloc, so the slider is fluent. Gamma / white-balance fold into this LUT later as a per-channel R/G/B split. | | `lightPreset` | select | The physical wire format: channel order and whether the light is RGBW. Options: `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, `BGR`, `RGBW`, `GRBW`. Defaults to `GRB` β€” the WS2812/SK6812 wire order, so a strip shows correct colours out of the box (PreviewDriver reads the RGB source buffer directly and is unaffected). RGBW presets make each driver emit 4 channels per light with white derived as `min(R,G,B)` from the (brightness-scaled) RGB. | +| `palette` | select | The **global active colour palette** (`Rainbow`, `Party`, `Lava`, `Ocean`, …). Palette-driven effects read it via `Palettes::active()` and colour their pixels through `colorFromPalette(index)`, so changing this recolours every such effect live. The select index expands the chosen gradient into the active 16-entry palette on `onUpdate` (cheap, off the hot path). Drivers owns this as a global render parameter, alongside `brightness` and `lightPreset`. Palette model + names follow FastLED's, credited as prior art; implementation in `src/light/Palette.h`. | The state lives on `Correction` (`src/light/drivers/Correction.h`): a brightness LUT, channel-order table, output channel count, derive-white flag. `Drivers::onUpdate` rebuilds it on a `brightness`/`lightPreset` change and hands each child a `const Correction*`. Every driver sees the same composited output; per-driver layer assignment (different drivers reading different layers) is a [backlog](../../backlog/README.md) item. diff --git a/docs/moonmodules/light/Layers.md b/docs/moonmodules/light/Layers.md index ef475279..57c61ed9 100644 --- a/docs/moonmodules/light/Layers.md +++ b/docs/moonmodules/light/Layers.md @@ -1,6 +1,6 @@ # Layers -![Layers controls](../../assets/screenshots/Layers.png) +![Layers controls](../../assets/core/Layers.png) Top-level container for one or more layers. Each layer renders independently into its own buffer; the Drivers container composes those buffers downstream. diff --git a/docs/moonmodules/light/Layouts.md b/docs/moonmodules/light/Layouts.md index a96629e5..bd31f33c 100644 --- a/docs/moonmodules/light/Layouts.md +++ b/docs/moonmodules/light/Layouts.md @@ -1,6 +1,6 @@ # Layouts -![Layouts controls](../../assets/screenshots/Layouts.png) +![Layouts controls](../../assets/core/Layouts.png) Top-level container for one or more layouts. Shared by every layer in the Layers container β€” defines the physical light topology of the installation. diff --git a/docs/moonmodules/light/ModifierBase.md b/docs/moonmodules/light/ModifierBase.md index 70e9e686..aa0148da 100644 --- a/docs/moonmodules/light/ModifierBase.md +++ b/docs/moonmodules/light/ModifierBase.md @@ -20,7 +20,7 @@ Because the build walks physical lights, fan-out (one logical cell driving N phy ## Affine modifiers and the matrix reference -Most modifiers are **non-affine** (a mask is a predicate, a tile is modulo) and express their fold directly. [RotateModifier](modifiers/RotateModifier.md) is the exception and the codebase's **transform-matrix reference**: rotation is the canonical affine transform, written as an explicit integer 2Γ—2 rotation matrix in `modifyLive`. A future affine "Transform" modifier (translate+scale+rotate+shear in one) would compose its matrix the same way and apply it through the same hook β€” the fold interface hosts a matrix-backed modifier with no change. +Most modifiers are **non-affine** (a mask is a predicate, a tile is modulo) and express their fold directly. [RotateModifier](modifiers/modifiers.md) is the exception and the codebase's **transform-matrix reference**: rotation is the canonical affine transform, written as an explicit integer 2Γ—2 rotation matrix in `modifyLive`. A future affine "Transform" modifier (translate+scale+rotate+shear in one) would compose its matrix the same way and apply it through the same hook β€” the fold interface hosts a matrix-backed modifier with no change. ## Prior art diff --git a/docs/moonmodules/light/drivers/HueDriver.md b/docs/moonmodules/light/drivers/HueDriver.md new file mode 100644 index 00000000..a46dd12b --- /dev/null +++ b/docs/moonmodules/light/drivers/HueDriver.md @@ -0,0 +1,35 @@ +# HueDriver + +Overview and controls: [drivers.md Β§ HueDriver](drivers.md#hue). This page carries the reference detail a control list can't β€” what makes Hue different (rate limit, bridge-smoothed transitions, pairing), the Hue v1 HTTP wire contract, and the Devices-module listing. + +![A HueDriver in the UI](../../../assets/light/drivers/Hue%20driver.png) + +## What makes Hue different (and why the driver is shaped this way) + +Hue is an HTTP hub, not a strip, and these properties drive the design: + +- **It's rate-limited** (~10 commands/s across the bridge; true real-time needs the [Hue Entertainment API](https://developers.meethue.com/develop/hue-entertainment/) β€” DTLS streaming at ~25 Hz, a separate future). So the driver paces itself: **`loop()` does at most one bridge PUT every `kPutIntervalMs`**, gated by a `platform::millis()` check (never work-every-tick), round-robined across the lights. A single bounded ~ms PUT can't stall a frame; the rest of `loop()` returns instantly. This is **smooth ambient colour**, the standard API's sweet spot β€” not fast strobing. +- **Only colour-capable, reachable lights are driven.** The bridge reports each light's capabilities; the driver keeps only lights whose state has a `hue` field (an "Extended color light") *and* `reachable:true`, so every window pixel maps to a bulb that can actually show the effect's colour right now. A dimmable-only white, an on/off plug, or a powered-off bulb is skipped. +- **Transitions are smoothed by the bridge.** Each PUT carries a `transitiontime` matched to how often that light is refreshed (lights Γ— interval), so the bulb *glides* from its current colour to the next instead of snapping β€” the bridge's built-in fade, tuned to the cadence. (The Hue default of 400 ms is too long for this rate and smears into a frozen look.) +- **The shared output Correction applies**, same as the physical LED / network drivers: the global brightness slider and a swapped colour-order preset reach the Hue lights too (each pixel runs through the brightness LUT + channel reorder before RGBβ†’HSV). Brightness 0 β†’ black β†’ the light turns off. +- **It needs an app key** (a "username"): the user presses the bridge's physical link button once, then the device claims a key. The driver runs this as a short bounded poll across a few 1 Hz ticks β€” never blocking the loop waiting for the press. +- **It's plain HTTP, no TLS.** The Hue v1 API answers over `http:///api/...`, so there is no certificate handling on the device. Bench-confirmed against a BSB002 bridge (API 1.77). + +![An effect driving the Hue lights](../../../assets/light/drivers/Hue%20friendly%20effect.png) + +## Wire contract (Hue v1 API, plain HTTP) + +The driver talks to the bridge over `platform::httpRequest` (declared in `src/platform/platform.h` β€” a synchronous LAN HTTP GET/PUT/POST helper that reads the response straight into the caller's buffer): + +- **Pair** β€” `POST http:///api` with `{"devicetype":"projectMM#device"}`. Before the link button is pressed the bridge returns `link button not pressed`; after, `[{"success":{"username":""}}]` β€” the `` becomes `appKey`. +- **List lights** β€” `GET http:///api//lights` β†’ a JSON object keyed by light id (`{"1":{…},"2":{…}}`). A real bridge's response runs several KB; the driver scans it for colour-capable (`"hue"` in state) + reachable (`"reachable":true`) lights and maps window index β†’ light id. +- **List rooms** β€” `GET http:///api//groups` β†’ a JSON object keyed by group id (`{"1":{"name":…,"lights":["1","2"],"type":"Room"},…}`). The driver scans it for `"type":"Room"` entries, reads each room's `name` and `lights` id array, and records which colour lights belong to each room (a bitmask over the colour-light list). This feeds the `room`/`light` dropdowns and the driven-set filter. +- **Set a light** β€” `PUT http:///api//lights//state` with `{"on":true,"bri":0-254,"hue":0-65535,"sat":0-254,"transitiontime":N}` (or `{"on":false,…}` when the pixel is black). `hue`/`sat`/`bri` come from a textbook integer RGBβ†’HSV of the pixel; `transitiontime` (deciseconds) is the cadence-matched fade. + +## Prior art + +The [Hue v1 CLIP API](https://developers.meethue.com/develop/hue-api/) (link-button pairing, `/lights//state`, `transitiontime`). The effect-as-output mapping (bulbs as pixels of the render buffer, driven through the window) is projectMM's own β€” the same shape its UDP and LED drivers use. + +## Source + +[HueDriver.h](../../../../src/light/drivers/HueDriver.h) diff --git a/docs/moonmodules/light/drivers/LcdLedDriver.md b/docs/moonmodules/light/drivers/LcdLedDriver.md index 38e29dba..849782a7 100644 --- a/docs/moonmodules/light/drivers/LcdLedDriver.md +++ b/docs/moonmodules/light/drivers/LcdLedDriver.md @@ -1,6 +1,6 @@ # LCD LED Driver -Parallel [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) output on the **ESP32-S3** over the [LCD_CAM](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/lcd/index.html) peripheral: up to **8 strands clock out simultaneously**, one GPIO per strand, all fed by a single autonomous DMA transfer. The S3's scale path β€” where the [RMT driver](RmtLedDriver.md) tops out at 4 channels on the S3, this drives 8 lanes for the wall time of one. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) per light, and bit-transposes corrected bytes across the lanes. +Overview and controls: [drivers.md Β§ LCD LED](drivers.md#lcdled). This page carries the reference detail a control list can't β€” 3-slot-per-bit wire contract, buffer slicing, DMA memory sizing, and cross-domain wiring. ## Wire contract β€” 3 slots per bit @@ -12,16 +12,6 @@ Because the whole frame is pre-encoded into one DMA buffer off the hot path and Identical semantics to the [RMT driver](RmtLedDriver.md#buffer-slicing-across-pins): consecutive slices of the source buffer in `pins` order, sizes from `ledsPerPin`, even-split remainder. The parsers are shared (`PinList.h`). -## Controls - -- `pins` (text, default empty) β€” comma-separated data GPIOs, one lane each, **exactly 8** (the i80 peripheral configures every data line of the bus width and rejects partial sets, so all 8 GPIOs are claimed even when fewer strands are wired). Empty by default (the strand is user-soldered, so no pin is assumed β€” the driver idles until set); a known-good ESP32-S3 N16R8 Dev set is `1,2,4,5,6,7,8,9`, which clears the octal-PSRAM pins (26–37), USB (19/20) and strapping pins. Changing it re-creates the i80 bus **live, no reboot** ([Β§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)). -- `ledsPerPin` (text, default empty) β€” lights per lane, matched by position; empty = even split. To drive fewer than 8 strands, give the unused lanes `0` (or list only the used lanes' counts summing to the grid size β€” the remainder lanes get 0 and idle LOW). -- `clockPin` (pin, default 10) β€” the i80 bus WR line. The peripheral *requires* it on a real GPIO (the IDF i80 bus rejects `wr_gpio_num < 0`); WS2812 strands ignore the waveform. Peripheral-fixed (not user-strand wiring), so it keeps a sensible overridable default β€” point it at any otherwise-free GPIO if 10 is taken. -- `dcPin` (pin, default 11) β€” the i80 data/command line, same story: required by the peripheral (`dc_gpio_num < 0` is rejected), unused by the LEDs, overridable default. -- `loopbackTest` (bool) β€” one-shot signal self-test: jumper the **first** pin in `pins` to `loopbackRxPin`, tick the box; the driver transmits its **real frame** (full size, real DMA chain, repeated back to back like the render loop) with a known pattern in every row, captures the whole frame back with an RMT RX channel (the increment-1 rig reused β€” RMT receive is transmitter-agnostic) and verifies every bit. Result lands in the status field; on failure it names the first corrupted light. -- `loopbackTxPin` (pin, default unset / βˆ’1) β€” optional **TX override** for the self-test: the loopback drives only lane 0 with the test pattern, so when this is set it transmits on this pin in place of lane 0 (`pins[0]`), the other 7 lanes unchanged β€” letting the test run on a dedicated jumper without re-typing `pins`. Falls back to lane 0 when unset. Test-only β€” normal output uses `pins`. Shown only while `loopbackTest` is on. -- `loopbackRxPin` (pin, default unset / βˆ’1) β€” the RX pin for the self-test; set it when you wire the jumper (the bench used 12). Shown only while `loopbackTest` is on. - ## Memory One internal-RAM DMA frame buffer owned by the platform (PSRAM is deliberately not used β€” the peripheral streams from internal SRAM): `longest lane Γ— channels Γ— 24 + latch pad` bytes, ~72 B per RGB light. A 1000-light installation across 8 lanes β‰ˆ 9 KB; the documented boundary is ~1500+ lights on a *single* lane (~110 KB), where a future streaming/PSRAM increment takes over. Allocation respects the platform heap reserve and degrades to a status error β€” never a crash. diff --git a/docs/moonmodules/light/drivers/NetworkSendDriver.md b/docs/moonmodules/light/drivers/NetworkSendDriver.md index 47dc3ccf..4103bf61 100644 --- a/docs/moonmodules/light/drivers/NetworkSendDriver.md +++ b/docs/moonmodules/light/drivers/NetworkSendDriver.md @@ -1,16 +1,8 @@ # Network Send Driver -![NetworkSendDriver controls](../../../assets/screenshots/NetworkSendDriver.png) +Overview and controls: [drivers.md Β§ Network Send](drivers.md#networksend). This page carries the reference detail a control list can't β€” the per-protocol chunking table, E1.31/Art-Net interop notes, the synchronous-send caveat, and the packet-layout headers. -Streams the light buffer over UDP in one of three industry protocols, selected by a control: **[Art-Net](https://art-net.org.uk/downloads/art-net.pdf)**, **[E1.31 / sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf)** (the ANSI E1.31 streaming-ACN standard), or **[DDP](http://www.3waylabs.com/ddp/)** (Distributed Display Protocol). Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) (brightness / channel order / RGBW white) per light, chunks the corrected bytes per the selected protocol, and sends the whole frame as one burst at the configured rate. The single-node-multiple-protocols shape follows MoonLight's D_NetworkOut (architecture studied, not copied). Compatible with industry receivers β€” pixel controllers (Falcon, Advatek), xLights, LedFx, and ArtNet-controllable software. - -## Controls - -- `protocol` (select: ArtNet / E1.31 / DDP, default ArtNet) β€” the wire protocol; the destination port follows it automatically (6454 / 5568 / 4048). Changing it re-targets the socket **live, no reboot** ([Β§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)) β€” switch output protocol on a running device mid-show. -- `ip` (IPv4, default 255.255.255.255) β€” destination address. The default is the limited-broadcast address, so a fresh sender reaches every receiver on the LAN with no IP to type; set a unicast address to target one device. Changing it re-binds live. E1.31 multicast is deliberately not implemented (see Interop below). -- `universe_start` (uint16_t, default 0) β€” first universe for ArtNet and E1.31; DDP is byte-addressed and ignores it. This is the *protocol* offset (which universe the slice maps onto), distinct from the buffer `start` below. -- `start` / `count` β€” the shared source-buffer window (every driver has it; see [Drivers Β§ Per-driver source window](../Drivers.md#per-driver-source-window-start--count)). This sink packs and sends exactly the lights `[start, start+count)` (count 0 = the whole buffer from `start`), so one sender covers just its slice β€” e.g. drive some lights over LEDs and the rest over ArtNet, or run two senders for different ranges. -- `fps` (uint8_t, default 50, range 1-120) β€” frame rate limit. Without it the loop would re-send on every render tick; receivers expect a steady frame cadence. +![NetworkSendDriver controls](../../../assets/light/drivers/NetworkSendDriver.png) ## Chunking per protocol diff --git a/docs/moonmodules/light/drivers/ParlioLedDriver.md b/docs/moonmodules/light/drivers/ParlioLedDriver.md index bbe65d95..77ec1874 100644 --- a/docs/moonmodules/light/drivers/ParlioLedDriver.md +++ b/docs/moonmodules/light/drivers/ParlioLedDriver.md @@ -1,26 +1,6 @@ # Parlio LED Driver -Parallel [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) output on the **[ESP32-P4](https://www.espressif.com/en/products/socs/esp32-p4)** over the [Parlio (Parallel IO)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/peripherals/parlio/index.html) TX peripheral: up to **8 strands clock out simultaneously**, one GPIO per strand, all fed by a single autonomous DMA transfer. The P4's scale path β€” the sibling of the [LCD driver](LcdLedDriver.md) on the S3. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) per light, and bit-transposes corrected bytes across the lanes. - -The P4 actually carries **all three** LED peripherals β€” [RMT](RmtLedDriver.md) (4 DMA-backed channels), [LCD_CAM i80](LcdLedDriver.md) (8 lanes), and Parlio (this driver) β€” and all three drivers auto-wire there off their SOC-capability gates. Parlio is the **preferred** parallel path on the P4 (its dedicated parallel-output engine), with RMT the easy single-strand option and LCD_CAM the other parallel route; the user picks per install by enabling the driver that matches their wiring. - -### Running all three at once β€” the P4 pin budget - -The three drivers are independent children of the `Drivers` container: each has its own `pins`, each reads the same logical buffer, and they're separate peripherals (RMT, LCD_CAM, and Parlio are distinct engines), so **they can all transmit simultaneously, each on its own GPIOs**. The combined output-pin ceiling, with the drivers' current per-chip caps: - -| Driver | Pins (current cap) | Peripheral max | -|---|---|---| -| RMT | 4 (the P4 has 4 TX channels) | 4 | -| LCD_CAM i80 | 8 | 16 | -| Parlio | 8 (any 1–8) | 16 | -| **Total simultaneous** | **20** | up to 36 if the LCD/Parlio caps were raised | - -So **up to 20 parallel WS2812 strands** at once on the P4 today. The Waveshare P4-NANO physically exposes exactly 20 clear GPIOs (`20–27, 32–33, 39–48`, after Ethernet, the C6 SDIO, I2C and the strapping pins), so that board can in principle drive all 20 β€” but two honest limits apply beyond pin count: (1) **throughput is bounded by internal DMA RAM and the render tick, not pins** β€” the per-frame DMA buffers (~72 B/RGB light per parallel driver) and the encode time set the real ceiling on *long* strands, so 20 short strands is very different from 20 long ones; and (2) raising the LCD/Parlio caps to 16 (a constant change) only helps where a board breaks out that many free pins, which the P4-NANO does not. For most installs one parallel driver (8 lanes) is plenty; the multi-driver headroom is there for unusually wide, short-strand layouts. - -It is the [LCD driver](LcdLedDriver.md) shape with two simplifications, because Parlio is a simpler peripheral than the LCD_CAM i80 bus: - -- **No clock/dc pins.** The i80 bus needs two GPIOs (WR + DC) on real pins even though WS2812 ignores them (the IDF i80 layer rejects `wr/dc < 0`); Parlio generates the pixel clock itself (`clk_out_gpio_num = GPIO_NUM_NC`) and has no command/data phase, so there are none. (The LCD driver keeps an overridable default for its two; dropping them there would need a direct-LCD_CAM driver β€” backlogged.) -- **No exactly-8-pins rule.** The i80 layer rejects a partial bus (every data line must be a real GPIO), so the LCD driver demands exactly 8 pins. Parlio takes the data GPIOs directly and runs on **1–8 lanes** β€” whatever `pins` names. +Overview and controls: [drivers.md Β§ Parlio LED](drivers.md#parlioled). This page carries the reference detail a control list can't β€” the P4 pin budget (all three LED peripherals at once), the shared 3-slot wire contract, memory sizing, and cross-domain wiring. ## Wire contract β€” 3 slots per bit @@ -32,14 +12,6 @@ Because the whole frame is pre-encoded into one DMA buffer off the hot path and Identical semantics to the [RMT driver](RmtLedDriver.md#buffer-slicing-across-pins): consecutive slices of the source buffer in `pins` order, sizes from `ledsPerPin`, even-split remainder. The parsers are shared (`PinList.h`). -## Controls - -- `pins` (text, default empty) β€” comma-separated data GPIOs, one lane each, **1 to 8** (no all-pins rule). Empty by default (the strand is user-soldered, so no pin is assumed β€” the driver idles until set). Choosing pins on the P4-NANO, **avoid**: STRAPPING pins **34–38** (boot-mode control β€” driving these can break boot, never use them for output), Ethernet RMII (28–31, 49–52), the ESP32-C6 SDIO (14–19, 54), and I2C (7–8). The clear GPIOs are **20–27, 32–33, 39–48**; a known-good bench set is `20,21,22,23,24,25,26,27`. Add pins for parallel strips. Changing it re-creates the Parlio TX unit **live, no reboot** ([Β§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)). The loopback self-test transmits on the **first** pin. -- `ledsPerPin` (text, default empty) β€” lights per lane, matched by position; empty = even split over the wired lanes (all lights on the first lane when one pin is set), remainder to the last lane. Same semantics as the RMT/LCD drivers. -- `loopbackTest` (bool) β€” one-shot **whole-frame** signal self-test: TX on the first pin in `pins`, RX on `loopbackRxPin`. It builds the real frame (test pattern in every row on lane 0), transmits it back to back like the render loop through a private Parlio TX unit, captures the entire frame on the RX pin (RMT-RX with the P4's DMA backend β€” the [same `rmtWs2812RxCapture`](RmtLedDriver.md#loopback-self-test-on-device) the RMT/LCD rigs use, transmitter-agnostic), and bit-verifies every WS2812 bit. The verdict lands in the status field: `loopback PASS`, `loopback FAIL: bad bit N/M (light K)`, or `loopback: jumper not detected` (a plain-GPIO continuity pre-check runs first). The strip on lane 0 flickers once during the run; normal output resumes after. -- `loopbackTxPin` (pin, default unset / βˆ’1) β€” optional **TX override** for the self-test: the loopback drives only lane 0 with the test pattern, so when this is set it transmits on this pin in place of lane 0 (`pins[0]`), the other lanes unchanged β€” letting the test run on a dedicated jumper without re-typing `pins`. Falls back to lane 0 when unset. Test-only β€” normal output uses `pins`. Shown only while `loopbackTest` is on. -- `loopbackRxPin` (pin, default unset / βˆ’1) β€” the RX pin for the self-test; set it when you wire the jumper (the bench used 33, jumper the TX pin β†’ 32, both strapping-safe). Shown only while `loopbackTest` is on. - ## Memory One internal-RAM DMA frame buffer owned by the platform (PSRAM is deliberately not used β€” Parlio streams from internal SRAM): `longest lane Γ— channels Γ— 24 + latch pad` bytes, ~72 B per RGB light, same sizing as the LCD driver. A 1000-light installation across 8 lanes β‰ˆ 9 KB; the ~1500+-lights-on-a-single-lane (~110 KB) boundary where a future streaming/PSRAM increment takes over is the same documented limit. Allocation respects the platform heap reserve and degrades to a status error β€” never a crash. diff --git a/docs/moonmodules/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md index f4626fac..afdf7052 100644 --- a/docs/moonmodules/light/drivers/PreviewDriver.md +++ b/docs/moonmodules/light/drivers/PreviewDriver.md @@ -1,12 +1,8 @@ # Preview Driver -![PreviewDriver controls](../../../assets/screenshots/PreviewDriver.png) +Overview and controls: [drivers.md Β§ Preview](drivers.md#preview). This page carries the reference detail a control list can't β€” the binary WebSocket protocol (coordinate table + per-frame channels), sparse-layout handling, and the large-layout spatial downsample. -Streams a true-shape 3D preview to the web UI over WebSocket. The preview is a **point list** β€” only the real lights, at their real positions β€” not a dense grid. So a sphere, ring, or arbitrary fixture map shows in its true shape, and the per-frame data is just the lights that exist (much less than a padded bounding box). - -## Controls - -- `fps` (uint8_t, default 24, range 1-60) β€” preview stream rate (independent of the render loop) +![PreviewDriver controls](../../../assets/light/drivers/PreviewDriver.png) ## Protocol diff --git a/docs/moonmodules/light/drivers/RmtLedDriver.md b/docs/moonmodules/light/drivers/RmtLedDriver.md index 62243b76..b38642c7 100644 --- a/docs/moonmodules/light/drivers/RmtLedDriver.md +++ b/docs/moonmodules/light/drivers/RmtLedDriver.md @@ -1,6 +1,8 @@ # RMT LED Driver -Output driver for WS2812B-class addressable LEDs over the ESP32 **[RMT (Remote Control Transceiver)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html)** peripheral β€” one or more strands, one GPIO and one RMT TX channel per strand. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) (brightness / channel order / RGBW white) per light, and emits the WS2812 1-wire signal. Runs on any chip whose RMT peripheral has TX channels: classic ESP32 (8 channels), ESP32-S3 (4 channels), and ESP32-P4 (4 channels, DMA-backed). On desktop the RMT platform seam is a no-op and the driver is inert. +Overview and controls: [drivers.md Β§ RMT LED](drivers.md#rmtled). This page carries the reference detail a control list can't β€” the WS2812B wire contract, buffer slicing, the on-device loopback self-test, and the LED-flicker troubleshooting playbook. + +Output driver for WS2812B-class addressable LEDs over the ESP32 **[RMT (Remote Control Transceiver)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html)** peripheral β€” one GPIO and one RMT TX channel per strand. Reads the [Drivers](../Drivers.md) buffer, applies the shared [output correction](../Drivers.md#output-correction) per light, and emits the WS2812 1-wire signal. ## Wire contract β€” [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) @@ -21,15 +23,6 @@ The source buffer is split into **consecutive slices**, one per pin, in list ord `loop()` encodes the whole frame once, then starts every pin's transmission (`platform::rmtWs2812Transmit`) before waiting on each (`platform::rmtWs2812Wait`) β€” the RMT channels clock out concurrently, so the render tick is charged roughly the **longest** strand, not the sum (~3 ms per 100 pixels on the longest slice), plus one shared reset gap. The transmission runs synchronously on the render task, so a large strand or a WiFi-interrupt-sensitive install can show timing artifacts. -## Controls - -- `pins` (text, default empty) β€” comma-separated data / TX GPIO list, e.g. `18,17,16`. Empty by default (the strand is user-soldered, so no pin is assumed β€” the driver idles until set; the bench used `18`). One RMT TX channel per pin: up to 8 on classic ESP32, 4 on the S3 and P4 (exceeding the chip's limit, a bad token, or a duplicate pin puts an error in the status field and the driver idles). Changing it re-initialises the channels **live, no reboot** ([Β§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)) β€” edit pins, counts, or colour order on a running device and the next frame uses them. The loopback self-test transmits on the **first** pin in the list. -- `ledsPerPin` (text, default empty) β€” comma-separated lights-per-pin, e.g. `100,100,50`, matched to `pins` by position. It may be empty or shorter than `pins` (the unassigned remainder splits evenly across the leftover pins); see Buffer slicing above. -- `loopbackTxPin` (pin, default unset / βˆ’1) β€” optional **TX override** for the self-test: when set, the loopback transmits on this pin instead of the first pin in `pins`, so the test can run on a dedicated jumper without re-typing the operational `pins`. Falls back to `pins[0]` when unset. Test-only β€” normal output always uses `pins`. Shown only while `loopbackTest` is on. -- `loopbackRxPin` (pin, default unset / βˆ’1) β€” the RX pin for the loopback self-test; set it when you wire the jumper (the bench used 5). Jumper it to the TX pin (`loopbackTxPin` if set, else the **first** pin in `pins`) to run the test. Shown only while `loopbackTest` is on. -- `loopbackTest` (bool) β€” a persistent on/off mode for the RMT TXβ†’RX loopback self-test (see Self-test below). While it is on, the test re-runs whenever a relevant control changes (`pins`, `loopbackTxPin`, `loopbackRxPin`, `loopbackFrame`), so the pins can be set in any order and the result always reflects the current wiring; the verdict lands in the module's status field. Turning it off clears the verdict. -- `loopbackFrame` (bool) β€” whole-frame variant of the self-test, shown only while `loopbackTest` is on. Instead of a 24-bit burst it transmits a real frame (the first pin's slice, or 64 lights) back to back and bit-verifies the entire capture. This is what catches frame-rate corruption and RF interference on the data line β€” a 24-bit burst can pass through a wire that mangles a sustained frame. On failure the status names the first corrupted bit and light. - ## Cross-domain wiring The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry) β€” not boot-wired, exactly like [NetworkSendDriver](NetworkSendDriver.md). RMT is the default LED driver for classic ESP32 and S3 board entries. The type is registered on every target; on a chip without RMT TX channels it is inert. Once added, it receives `setSourceBuffer` / `setCorrection` / `setLayer` from `Drivers::passBufferToDrivers` (which wires every child, boot- or runtime-added), and applies the same `const Correction*` ArtNet uses. The **symbol encode** (`encodeWs2812Symbols` in `RmtSymbol.h`) is domain code in `src/light/` so it is host-testable; the **peripheral** (`platform::rmtWs2812*` in `src/platform/esp32/platform_esp32_rmt.cpp`) is the only ESP-IDF-touching part. Per-chip channel and memory limits come from the IDF SOC capability macros, so the same code serves classic, S3 and P4. diff --git a/docs/moonmodules/light/drivers/drivers.md b/docs/moonmodules/light/drivers/drivers.md new file mode 100644 index 00000000..c7c78141 --- /dev/null +++ b/docs/moonmodules/light/drivers/drivers.md @@ -0,0 +1,111 @@ +# Drivers + +Every driver, one block each: what it does and what each control means β€” together. A driver reads its window of the [Drivers](../Drivers.md) container's shared buffer, applies the shared [output correction](../Drivers.md#output-correction) (brightness / channel order / RGBW white) per light, and sends the result out β€” over a wire protocol (WS2812 on RMT / LCD_CAM / Parlio), over the network (Art-Net / E1.31 / DDP), to a smart-light hub (Hue), or to the web UI (Preview). A driver is added as a child of the `Drivers` container per board through the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry), so a device only carries the outputs its board actually has; `PreviewDriver` is the one boot-wired driver. + +Each block links to that driver's **detail page** for the deeper material a control list can't carry β€” wire contracts, buffer slicing, memory sizing, per-protocol chunking, the on-device loopback self-test, and troubleshooting. Every driver shares the `start` / `count` **source-window** controls ([Drivers Β§ Per-driver source window](../Drivers.md#per-driver-source-window-start--count)): the slice `[start, start+count)` of the buffer this driver sends (`count` 0 = to the end), so different drivers can cover different ranges of one buffer. + +**Jump to:** [LED output](#led-output-drivers) Β· [Network](#network-drivers) Β· [Smart light](#smart-light-drivers) Β· [Preview](#preview-drivers) + +## LED output drivers + + + +### RMT LED + +WS2812B-class addressable LEDs over the ESP32 **RMT** peripheral β€” one GPIO and one RMT TX channel per strand. The general single-/few-strand LED output; the default LED driver for classic ESP32 and S3 board entries. Runs on any chip whose RMT has TX channels (classic ESP32: 8, S3: 4, P4: 4 DMA-backed); inert on desktop. + +- `pins` β€” comma-separated data / TX GPIO list, e.g. `18,17,16` (one RMT TX channel per pin). Empty by default (idles until set). Changing it re-inits the channels live, no reboot. +- `ledsPerPin` β€” comma-separated lights-per-pin, matched to `pins` by position; empty or shorter than `pins` splits the remainder evenly. +- `loopbackTest` β€” persistent on/off mode for the RMT TXβ†’RX loopback self-test (jumper the first `pins` entry to `loopbackRxPin`); verdict lands in the status field. +- `loopbackFrame` β€” whole-frame variant of the self-test (transmits a real frame back-to-back, bit-verifies the whole capture β€” catches frame-rate / RF corruption a 24-bit burst misses). Shown only while `loopbackTest` is on. +- `loopbackTxPin` β€” optional TX override for the self-test (transmit on this pin instead of `pins[0]`). Shown only while `loopbackTest` is on. +- `loopbackRxPin` β€” the RX pin for the self-test. Shown only while `loopbackTest` is on. + +Detail: [RmtLedDriver.md](RmtLedDriver.md) β€” WS2812B wire contract, buffer slicing, concurrent show, the loopback self-test, and the LED-flicker troubleshooting playbook. + + + +### LCD LED + +Parallel WS2812B on the **ESP32-S3** over the **LCD_CAM** peripheral: up to **8 strands clock out simultaneously**, one GPIO per strand, all fed by a single autonomous DMA transfer β€” the S3's scale path where RMT tops out at 4 channels. + +- `pins` β€” comma-separated data GPIOs, one lane each, **exactly 8** (the i80 peripheral configures every data line of the bus width). Empty by default (idles until set). Changing it re-creates the i80 bus live. +- `ledsPerPin` β€” lights per lane, matched by position; empty = even split. Give unused lanes `0` to drive fewer than 8 strands. +- `clockPin` (default 10) β€” the i80 bus WR line; required on a real GPIO by the peripheral, ignored by the LEDs, overridable. +- `dcPin` (default 11) β€” the i80 data/command line; same story β€” required, unused by the LEDs, overridable. +- `loopbackTest` β€” one-shot whole-frame signal self-test (jumper the first pin to `loopbackRxPin`). Result in the status field. +- `loopbackTxPin` β€” optional TX override (drives lane 0 with the test pattern on this pin instead of `pins[0]`). Shown only while `loopbackTest` is on. +- `loopbackRxPin` β€” the RX pin for the self-test. Shown only while `loopbackTest` is on. + +Detail: [LcdLedDriver.md](LcdLedDriver.md) β€” 3-slot-per-bit wire contract, buffer slicing, DMA memory sizing, and cross-domain wiring. + + + +### Parlio LED + +Parallel WS2812B on the **ESP32-P4** over the **Parlio (Parallel IO)** TX peripheral: up to **8 strands** clock out simultaneously via one autonomous DMA transfer β€” the P4's preferred parallel path (the sibling of the LCD driver on the S3). Runs on **1–8 lanes** (no exactly-8 rule), with no clock/dc pins (Parlio generates its own pixel clock). + +- `pins` β€” comma-separated data GPIOs, one lane each, **1 to 8**. Empty by default (idles until set). On the P4-NANO avoid the strapping (34–38), Ethernet, C6-SDIO and I2C pins; a known-good set is `20,21,22,23,24,25,26,27`. Changing it re-creates the Parlio TX unit live. +- `ledsPerPin` β€” lights per lane, matched by position; empty = even split over the wired lanes. +- `loopbackTest` β€” one-shot whole-frame signal self-test (TX on the first pin, RX on `loopbackRxPin`). Verdict in the status field. +- `loopbackTxPin` β€” optional TX override (lane 0's test pattern goes to this pin instead of `pins[0]`). Shown only while `loopbackTest` is on. +- `loopbackRxPin` β€” the RX pin for the self-test. Shown only while `loopbackTest` is on. + +Detail: [ParlioLedDriver.md](ParlioLedDriver.md) β€” the P4 pin budget (all three LED peripherals at once, up to 20 parallel strands), the shared 3-slot wire contract, memory sizing, and cross-domain wiring. + +## Network drivers + + + +### Network Send + +![NetworkSend controls](../../../assets/light/drivers/NetworkSendDriver.png) + +Streams the light buffer over UDP in one of three industry protocols β€” **Art-Net**, **E1.31 / sACN**, or **DDP** β€” selected by a control. Sends the whole frame as one burst per configured rate; compatible with pixel controllers (Falcon, Advatek), xLights, and LedFx. + +- `protocol` (Art-Net / E1.31 / DDP, default Art-Net) β€” the wire protocol; the destination port follows automatically (6454 / 5568 / 4048). Changing it re-targets the socket live. +- `ip` (default 255.255.255.255) β€” destination address; the default limited-broadcast reaches every LAN receiver with no IP to type. Set a unicast address to target one device. (E1.31 multicast is deliberately not implemented β€” see the detail page.) +- `universe_start` (default 0) β€” first universe for Art-Net and E1.31; DDP is byte-addressed and ignores it. +- `fps` (default 50, range 1–120) β€” frame-rate limit (without it the loop would re-send every render tick; receivers expect a steady cadence). + +Detail: [NetworkSendDriver.md](NetworkSendDriver.md) β€” per-protocol chunking table, E1.31/Art-Net interop notes, the synchronous-send caveat, and the packet-layout headers. + +## Smart light drivers + + + +### Hue + +![A HueDriver in the UI](../../../assets/light/drivers/Hue%20driver.png) + +Drives **Philips Hue bulbs as pixels of an effect**: make a small grid, run any effect, add a HueDriver, and each colour bulb in the driver's window becomes one pixel, pushed to the bridge over the Hue HTTP API. Paces itself to the bridge's ~10 command/s rate limit β€” smooth ambient colour, not fast strobing. Only colour-capable, reachable bulbs are driven. + +- `bridgeIp` β€” the Hue bridge's LAN IPv4 (find it via the bridge app, the router, or `discovery.meethue.com`). +- `appKey` β€” the Hue app key (username); filled automatically by `pair`, persisted as the driver's credential. +- `pair` β€” a button: press it, then press the bridge's physical link button within ~30 s; the driver claims a key and learns the light list. +- `room` / `light` β€” two dropdowns narrowing which colour lights are driven (both default to `All`); pick a room, then optionally one bulb. + +Detail: [HueDriver.md](HueDriver.md) β€” what makes Hue different (rate limit, bridge-smoothed transitions, pairing), the Hue v1 HTTP wire contract, and the Devices-module listing. + +## Preview drivers + + + +### Preview + +![PreviewDriver controls](../../../assets/light/drivers/PreviewDriver.png) + +Streams a true-shape 3D preview to the web UI over WebSocket as a **point list** β€” only the real lights at their real positions, not a dense grid β€” so a sphere, ring, or arbitrary fixture map shows in its true shape. The one boot-wired driver. + +- `fps` (default 24, range 1–60) β€” preview stream rate (independent of the render loop). + +Detail: [PreviewDriver.md](PreviewDriver.md) β€” the binary WebSocket protocol (coordinate table + per-frame channels), sparse-layout handling, and the large-layout spatial downsample. + +## Source + +- [RmtLedDriver.h](../../../../src/light/drivers/RmtLedDriver.h) +- [LcdLedDriver.h](../../../../src/light/drivers/LcdLedDriver.h) +- [ParlioLedDriver.h](../../../../src/light/drivers/ParlioLedDriver.h) +- [NetworkSendDriver.h](../../../../src/light/drivers/NetworkSendDriver.h) +- [HueDriver.h](../../../../src/light/drivers/HueDriver.h) +- [PreviewDriver.h](../../../../src/light/drivers/PreviewDriver.h) diff --git a/docs/moonmodules/light/effects/AudioSpectrumEffect.md b/docs/moonmodules/light/effects/AudioSpectrumEffect.md deleted file mode 100644 index 7da392b6..00000000 --- a/docs/moonmodules/light/effects/AudioSpectrumEffect.md +++ /dev/null @@ -1,26 +0,0 @@ -# AudioSpectrumEffect - -The classic equalizer display: the microphone's **16 frequency bands** (bass β†’ treble) spread across the grid's X axis, each column lighting from the bottom up in proportion to its band's magnitude β€” a bar graph that dances with the music. - -On a grid at least 3 rows tall, the **bottom row is an overall level/volume meter** (a horizontal VU bar lit left-to-right in proportion to `level`) and the spectrum bars sit in the rows above it. A shorter grid uses the full height for the spectrum. - -Reads the live frame from [AudioModule](../../core/AudioModule.md)`::latestFrame()`; no microphone or silence β†’ all bands zero β†’ dark. - -## Controls - -- `colorMode` β€” `height` (the default: each bar green at its base ramping to red at the top) or `per-band` (each column a distinct hue across the colour wheel, bass red β†’ treble violet β€” the rainbow-analyser look). - -## Scaling to the grid - -The 16 bands map onto whatever the grid width is β€” column `x` shows band `x * 16 / width`: - -- a **16-wide** grid is one column per band, -- a **32-wide** grid gives each band two columns, -- an **8-wide** grid samples every other band, -- a **1-row strip** (height 1) collapses the bars to per-column brightness. - -So the analyser fills the surface at any size, including a 0Γ—0 grid (it simply draws nothing). - -## Source - -[AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h) diff --git a/docs/moonmodules/light/effects/AudioVolumeEffect.md b/docs/moonmodules/light/effects/AudioVolumeEffect.md deleted file mode 100644 index 7ba54c29..00000000 --- a/docs/moonmodules/light/effects/AudioVolumeEffect.md +++ /dev/null @@ -1,17 +0,0 @@ -# AudioVolumeEffect - -A VU meter on the whole grid: every light pulses with the microphone's sound **level**, its colour ramping from calm green (quiet) toward hot red (loud). The simplest audio-reactive effect: one scalar drives one brightness. - -Reads the live frame from [AudioModule](../../core/AudioModule.md)`::latestFrame()`; with no microphone (or in silence) the level is zero and the grid stays dark, so the effect is safe on any target and grid size. - -## Controls - -- **brightness**: overall ceiling (1-255). Default 255. - -## Notes - -The colour is a level-driven greenβ†’red ramp; modifiers and layouts give the flat VU surface its shape. Like every effect it writes only logical RGB; the driver's [Correction](../drivers/Correction.md) applies channel order and, for an RGBW preset, derives the white channel (`W = min(R, G, B)`) after brightness scaling. - -## Source - -[AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h) diff --git a/docs/moonmodules/light/effects/CheckerboardEffect.md b/docs/moonmodules/light/effects/CheckerboardEffect.md deleted file mode 100644 index bddbb9c4..00000000 --- a/docs/moonmodules/light/effects/CheckerboardEffect.md +++ /dev/null @@ -1,22 +0,0 @@ -# Checkerboard 2D Effect - -![CheckerboardEffect controls](../../../assets/screenshots/CheckerboardEffect.png) - -![CheckerboardEffect preview](../../../assets/screenshots/CheckerboardEffect.gif) - -Animated checker pattern with two configurable hues. - -## Controls - -- `cell_size` (uint8_t, default 4, range 1-32) β€” cell width/height in grid units -- `bpm` (uint8_t, default 60, range 1-255) β€” phase shift speed (cells appear to move) -- `hue_a` (uint8_t, default 0) β€” colour of even cells -- `hue_b` (uint8_t, default 128) β€” colour of odd cells - -## Tests - -[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) β€” non-zero output, spatial variation. The file also covers other stateless effects via a shared macro. - -## Source - -[CheckerboardEffect.h](../../../../src/light/effects/CheckerboardEffect.h) diff --git a/docs/moonmodules/light/effects/DistortionWavesEffect.md b/docs/moonmodules/light/effects/DistortionWavesEffect.md deleted file mode 100644 index 6e36702e..00000000 --- a/docs/moonmodules/light/effects/DistortionWavesEffect.md +++ /dev/null @@ -1,22 +0,0 @@ -# DistortionWavesEffect - -Two interfering sine waves whose sum drives the hue β€” a flowing, moirΓ©-like colour field. A horizontal and a vertical wave run at independent frequencies and slightly different time rates, so they beat against each other and the pattern drifts. 2D ([Layer](../Layer.md) extrudes it onto a 3D grid). Ported from WLED's "Distortion Waves". - -## Controls - -- `freq_x` β€” horizontal wave frequency (1–8, default 3). -- `freq_y` β€” vertical wave frequency (1–8, default 3). -- `speed` β€” animation speed (0–100, default 50; `0` = frozen). - -## Rendering - -For a light at (x, y): `hue = (sin8(xΒ·freq_x + t) + sin8(yΒ·freq_y + t_y)) / 2`, where `t` is the time phase and `t_y β‰ˆ 1.3Β·t` (WLED runs the vertical wave's time faster; approximated as `(tΒ·333) >> 8` to stay integer). The hue feeds `hsvToRgb(hue, 240, 255)`, keeping WLED's slightly-desaturated look. Integer-only: angles are `uint8_t` and the [`sin8`](../../core/Control.md) LUT replaces `sinf`. - -## Prior art - -- **MoonLight β€” E_WLED.h** β€” the WLED port of Distortion Waves. -- **projectMM v1 / v2 β€” DistortionWaves** β€” same two-interfering-sines algorithm; those used float `sinf`, this is the integer-`sin8` equivalent. - -## Source - -[DistortionWavesEffect.h](../../../../src/light/effects/DistortionWavesEffect.h) diff --git a/docs/moonmodules/light/effects/FireEffect.md b/docs/moonmodules/light/effects/FireEffect.md deleted file mode 100644 index 92aec26e..00000000 --- a/docs/moonmodules/light/effects/FireEffect.md +++ /dev/null @@ -1,47 +0,0 @@ -# Fire 2D Effect - -![FireEffect controls](../../../assets/screenshots/FireEffect.png) - -![FireEffect preview](../../../assets/screenshots/FireEffect.gif) - -Classic demoscene fire simulation on the XY plane. Maintains a `width x height` heat field; sparks spawn at the bottom row, drift upward with cooling, and are mapped to a black-red-yellow-white palette. - -## Controls - -- `cooling` (uint8_t, default 55, range 10-200) β€” heat loss per frame (higher = shorter flames) -- `sparking` (uint8_t, default 120, range 50-255) β€” probability of new sparks at the bottom row -- `hue_shift` (uint8_t, default 0, range 0-255) β€” rotate the fire palette around the colour wheel (0 = classic fire, 96 = green ghost-fire, 160 = blue plasma-fire) - -## Rendering - -Per frame (in this order): - -1. **Cool** every cell by a small random amount. -2. **Rise** β€” each row averages from the row below (4-tap neighbourhood), heat propagates from `y = h-1` up toward `y = 0`. -3. **Sparks** β€” up to 4 random sparks at the bottom row each frame, gated by `sparking`. -4. **Render** the heat field to RGB via the fire palette (or hue-rotated HSV when `hue_shift != 0`). - -Integer-only, no floats. Internal PRNG (LCG) avoids `rand()`. - -## Memory - -Allocates `width * height` bytes for the heat buffer in `onBuildState()` when `enabled` is true. Freed in `teardown()` and when disabled. Toggling `enabled` triggers a scheduler rebuild that (re)allocates. - -| Logical size | Heat buffer | -|--------------|-------------| -| 64x64 | 4 KB | -| 128x128 | 16 KB | - -`dynamicBytes()` reports the live size. - -## Tests - -[Unit tests: FireEffect](../../../tests/unit-tests.md#fireeffect) β€” buffer becomes non-zero after several frames of sparking. - -## Prior art - -Standard demoscene fire (Lode's tutorials, FastLED's `Fire2012`). Adapted to the integer-only, no-Arduino style of this codebase. - -## Source - -[FireEffect.h](../../../../src/light/effects/FireEffect.h) diff --git a/docs/moonmodules/light/effects/GameOfLifeEffect.md b/docs/moonmodules/light/effects/GameOfLifeEffect.md deleted file mode 100644 index 38ed7595..00000000 --- a/docs/moonmodules/light/effects/GameOfLifeEffect.md +++ /dev/null @@ -1,71 +0,0 @@ -# Game of Life Effect - -![GameOfLifeEffect controls](../../../assets/screenshots/GameOfLifeEffect.png) - -![GameOfLifeEffect preview](../../../assets/screenshots/GameOfLifeEffect.gif) - -Conway's Game of Life (B3/S23) on the XY plane. A D2 effect: it simulates the -z=0 plane and `Layer::extrude` fills z on 3D layers. - -## Controls - -- `seed` β€” PRNG seed for the first initial state. Later re-seeds continue the - same PRNG stream, so each revival is a fresh soup rather than a replay. -- `wraparound` β€” when on, the grid edges wrap (a torus); when off, off-grid - neighbours count as dead. -- `hue` β€” base hue of living cells; a per-cell spatial offset is added so the - colony shows a gradient rather than a flat colour. -- `bpm` β€” generation rate. Roughly `bpm / 8` generations per second (bpm 8 β‰ˆ - 1/s for watching gliders move, 255 β‰ˆ as fast as the frame rate allows). The - step is time-gated, so the speed is independent of the device's tick rate. - -## Rendering - -Two `width Γ— height` byte grids (cur/nxt) hold one cell each. The step reads -cur, applies B3/S23 (birth on exactly 3 live neighbours, survival on 2 or 3), -writes nxt, then swaps. Living cells render as -`hsvToRgb(hue + x*3 + y*5, 200, 255)`; dead cells are black. - -**Keeping it lively.** A random Conway soup always decays toward sparse -still-lifes plus a few blinkers β€” visually frozen, even though a plain -"nothing changed" check never fires (the blinkers keep flipping). So the grid -re-seeds when it goes **extinct**, **thins below ~3% density**, or **stops -growing for ~32 generations** (the live count barely moving). That keeps fresh -gliders and chaos coming. MoonLight does the richer version (pentomino -injection + CRC cycle detection); this is the minimal equivalent. - -## Memory - -`2 Γ— width Γ— height` bytes, allocated in `onBuildState` (PSRAM-first via -`platform::alloc`, like `FireEffect`'s heat grid) and reallocated when the -layer's dimensions change. At 128Γ—128 that is 32 KB. Freed in `teardown` and the -destructor. Reported via `setDynamicBytes` so the per-effect heap figure is -honest. - -## Extension seams - -The simulation step and the colouring are decoupled: the rule lives in one -predicate (B3/S23 β€” birth on 3 neighbours, survival on 2 or 3) and the colour -in one render line (`hsvToRgb`). A different birth/survival mask drops into the -predicate without touching the rest; a palette lookup drops into the render -line in place of `hsvToRgb`. Nothing else is coupled to either. - -## Tests - -[Unit tests: GameOfLifeEffect](../../../tests/unit-tests.md#gameoflifeeffect) β€” B3/S23 rule (blinker oscillates, block is a still-life, lone cell dies), wraparound, grid (re)allocation and free, 0Γ—0 survival, bpm pacing, and the renders-every-frame regression. - -## Prior art - -- **MoonLight `E_MoonModules.h` GameOfLife** β€” the feature-rich origin - (rulesets, palette colouring, blur, mutation, pentomino seeding, CRC stasis - detection). We take the proven algorithm and re-seed idea, not the structure. - ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h), - Ewoud Wijma 2022 / Brandon Butler 2024). -- **projectMM v1 β€” GameOfLifeEffect** - ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/GameOfLifeEffect.h)) β€” - used PSRAM grids and exposed `setCell` / `getCell` / `liveCount` test helpers - for deterministic rule testing without rendering; mirrored here. - -## Source - -[GameOfLifeEffect.h](../../../../src/light/effects/GameOfLifeEffect.h) diff --git a/docs/moonmodules/light/effects/GlowParticlesEffect.md b/docs/moonmodules/light/effects/GlowParticlesEffect.md deleted file mode 100644 index 8b606736..00000000 --- a/docs/moonmodules/light/effects/GlowParticlesEffect.md +++ /dev/null @@ -1,24 +0,0 @@ -# Glow Particles 2D Effect - -![GlowParticlesEffect controls](../../../assets/screenshots/GlowParticlesEffect.png) - -![GlowParticlesEffect preview](../../../assets/screenshots/GlowParticlesEffect.gif) - -Soft-glowing particles rendered as a metaball field. Particles move with independent velocities and bounce off the edges; the per-pixel field summation produces chaotic organic blobs β€” like `MetaballsEffect` with more freedom of movement. - -## Controls - -- `count` (uint8_t, default 5, range 1-8) β€” number of glow sources -- `speed` (uint8_t, default 60, range 1-255) β€” movement speed -- `radius` (uint8_t, default 24, range 4-64) β€” influence radius (larger = more merging) -- `hue_shift` (uint8_t, default 0, range 0-255) β€” global hue rotation - -Same metaball field as [MetaballsEffect](MetaballsEffect.md), but the sources move freely (12.4 fixed-point, edge-bouncing) instead of orbiting. Fixed particle array, no heap. - -## Tests - -[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) (GlowParticlesEffect shares the same baseline assertions β€” non-zero output, spatial variation β€” alongside other effects). - -## Source - -[GlowParticlesEffect.h](../../../../src/light/effects/GlowParticlesEffect.h) diff --git a/docs/moonmodules/light/effects/LavaLampEffect.md b/docs/moonmodules/light/effects/LavaLampEffect.md deleted file mode 100644 index b078b933..00000000 --- a/docs/moonmodules/light/effects/LavaLampEffect.md +++ /dev/null @@ -1,23 +0,0 @@ -# Lava Lamp 2D Effect - -![LavaLampEffect controls](../../../assets/screenshots/LavaLampEffect.png) - -![LavaLampEffect preview](../../../assets/screenshots/LavaLampEffect.gif) - -Three slow blobs whose summed field is mapped through a black β†’ red β†’ orange β†’ yellow β†’ white palette. Atmospheric, fluid look β€” like a real lava lamp rather than the bright HSV of `MetaballsEffect`. - -## Controls - -- `bpm` (uint8_t, default 8, range 1-64) β€” orbit speed in beats per minute -- `radius` (uint8_t, default 36, range 8-80) β€” blob influence radius -- `intensity` (uint8_t, default 200, range 64-255) β€” how strongly the field maps into the palette - -Three orbiting blobs share [MetaballsEffect](MetaballsEffect.md)'s field sum, but `intensity` maps it through a blackβ†’redβ†’orangeβ†’yellowβ†’white palette (flash table) for the atmospheric look instead of bright HSV. No heap. - -## Tests - -[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) β€” LavaLampEffect is included in the shared baseline coverage: non-zero output, spatial variation. - -## Source - -[LavaLampEffect.h](../../../../src/light/effects/LavaLampEffect.h) diff --git a/docs/moonmodules/light/effects/LinesEffect.md b/docs/moonmodules/light/effects/LinesEffect.md deleted file mode 100644 index 9290b792..00000000 --- a/docs/moonmodules/light/effects/LinesEffect.md +++ /dev/null @@ -1,22 +0,0 @@ -# LinesEffect - -![LinesEffect controls](../../../assets/screenshots/LinesEffect.png) - -![LinesEffect preview](../../../assets/screenshots/LinesEffect.gif) - -Sweeps one or more axis-aligned planes across the grid in sync at a given BPM. Each plane is a distinct colour: red (YZ, sweeps along X), green (XZ, sweeps along Y), blue (XY, sweeps along Z). Useful for verifying preview axis orientation β€” each colour names the axis it sweeps. - -Port of MoonLight's Lines effect. - -## Controls - -- **speed** β€” sweep rate in BPM (1–240). Default 30. -- **axis** β€” which plane(s) to draw: `all`, `x (red)`, `y (green)`, `z (blue)`. Default `all`. - -## Notes - -On a 1D layout only the red plane is active (width > 1 check). On a 2D layout blue is suppressed (depth = 1). On a 3D layout all three sweep simultaneously. - -## Source - -[LinesEffect.h](../../../../src/light/effects/LinesEffect.h) diff --git a/docs/moonmodules/light/effects/MetaballsEffect.md b/docs/moonmodules/light/effects/MetaballsEffect.md deleted file mode 100644 index 660deea2..00000000 --- a/docs/moonmodules/light/effects/MetaballsEffect.md +++ /dev/null @@ -1,27 +0,0 @@ -# Metaballs 2D Effect - -![MetaballsEffect controls](../../../assets/screenshots/MetaballsEffect.png) - -![MetaballsEffect preview](../../../assets/screenshots/MetaballsEffect.gif) - -Four "blobs" moving on the XY plane via integer sin/cos, with a metaball field summation per pixel. Visually similar to a lava lamp β€” blobs fluidly merge and separate. - -## Controls - -- `bpm` (uint8_t, default 30, range 1-255) β€” orbit speed in beats per minute -- `radius` (uint8_t, default 28, range 4-64) β€” ball influence radius (larger = more merging) -- `hue_shift` (uint8_t, default 0, range 0-255) β€” rotate the resulting hue - -Four `sin8`-driven balls orbit the XY plane (phase-accumulator, so `bpm` changes don't jump); a metaball field sum per pixel drives both brightness and hue. No floats, no heap. - -## Tests - -[Unit tests: MetaballsEffect](../../../tests/unit-tests.md#metaballseffect) β€” non-zero output, spatial variation. - -## Prior art - -Classic demoscene effect (1980s). Same integer field-summation technique as countless WLED ports. - -## Source - -[MetaballsEffect.h](../../../../src/light/effects/MetaballsEffect.h) diff --git a/docs/moonmodules/light/effects/NetworkReceiveEffect.md b/docs/moonmodules/light/effects/NetworkReceiveEffect.md deleted file mode 100644 index dec06bd4..00000000 --- a/docs/moonmodules/light/effects/NetworkReceiveEffect.md +++ /dev/null @@ -1,55 +0,0 @@ -# Network Receive Effect - -Receives lights-over-UDP data β€” **[Art-Net](https://art-net.org.uk/downloads/art-net.pdf), [E1.31 / sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf), and [DDP](http://www.3waylabs.com/ddp/), all at once** β€” and writes it into the layer buffer, behaving like any other effect: composable with modifiers, part of layer blending, selectable through the same UI. The receive side for industry senders (Resolume Arena, Madrix, xLights, LedFx, …) and the end-to-end pair with [NetworkSendDriver](../drivers/NetworkSendDriver.md). - -There is deliberately **no protocol control**: the effect binds the three well-known ports (6454 ArtNet, 5568 E1.31, 4048 DDP) simultaneously and validates each packet against its port's wire format β€” WLED's multi-port pattern. Whatever a sender speaks just works; the status field shows what is being received (`receiving DDP`, …). - -## Controls - -- `universe_start` (uint16_t, default 0) β€” first universe to accept (ArtNet/E1.31); a packet for universe `u` lands at byte offset `(u βˆ’ universe_start) Γ— channels_per_universe`. Universes below the start or beyond the buffer are ignored. E1.31 senders conventionally start at universe 1 β€” set both ends accordingly (see the sender's universe rule). -- `channels_per_universe` (uint16_t, default 510) β€” bytes each universe maps to. 510 = whole RGB lights per universe (the xLights/Falcon convention and our own sender's split); set **512** for senders that pack pixels across universe boundaries (Madrix-style). Also clamps each universe's payload to its slot, so a 512-channel frame from a 510-packed source can't bleed its 2 padding bytes into the next universe. - -DDP skips the universe math entirely: its packets carry a byte offset and land directly (clamped to the buffer). - -## ArtNet discovery (Resolume node lists) - -Controllers find output nodes by broadcasting **ArtPoll**; this effect answers with **ArtPollReply** (our IP, MAC, names, bound universe), so the device appears automatically in Resolume's Advanced Output, Madrix and xLights node lists instead of needing manual IP entry. The reply goes unicast to the poller via the platform's `sendToAddr`. - -## Rendering - -Opens and binds the three sockets in `setup()` (a taken port is reported in the status field; the other sockets still drain). `loop()` polls non-blocking at the frame boundary: it drains each socket (bounded per tick, so a packet flood can't wedge the render loop), validates each packet with its protocol's shared parser, and copies payloads into a **staging buffer**; staging is copied to the layer buffer every tick. - -The staging buffer is load-bearing: the Layer clears its buffer at the start of every tick, so writing packets straight into it would strobe black between frames. Staging gives hold-last-frame semantics. Sequence fields (and DDP's push flag) are ignored: out-of-order packets are last-write-wins. - -## Wire contracts - -The byte layouts live in [ArtNetPacket.h](../../../../src/light/ArtNetPacket.h), [E131Packet.h](../../../../src/light/E131Packet.h) and [DdpPacket.h](../../../../src/light/DdpPacket.h), shared with the sender. The receiver is liberal: any ArtNet protocol version, any E1.31 priority/sequence (no multi-source arbitration), any DDP data type. E1.31 **multicast is not joined** (unicast only β€” platform IGMP support is a backlog item); point sACN senders at the device's IP. - -## Tests - -[Unit tests: NetworkReceiveEffect](../../../tests/unit-tests.md#networkreceiveeffect) β€” per-protocol buildβ†’parse round-trips and reject cases, cross-protocol rejects, universe placement with `channels_per_universe` 510 and 512, DDP byte placement with hostile-offset clamping, ArtPoll/ArtPollReply layout, staging lifecycle, and a localhost round-trip driving all three protocol sockets at once. - -Live tier: `uv run scripts/scenario/run_network_live.py` ([MoonDeck.md Β§ run_network_live](../../../../scripts/MoonDeck.md#run_network_live)) seeds real boards via all three protocols per round. - -## Design notes - -- Receive as an effect (not a separate input mechanism): any external light source is just another MoonModule that writes into a layer buffer. -- Processing is synchronous at the frame boundary β€” check for pending packets, never block ([architecture.md](../../../architecture.md) network-input rule). - -## Prior art - -### MoonLight β€” D_NetworkIn ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Drivers/D_NetworkIn.h)) - -ArtNet/E1.31/DDP receive in one driver node (protocol selected by control; we autodetect by port instead). - -### WLED β€” realtime UDP input - -Multi-port listening with per-packet header validation, plus ArtPollReply for controller discovery β€” the pattern this effect follows. - -### projectMM v1 β€” ArtNetInModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/ArtNetInModule.h)) - -v1 treated ArtNet receive as an effect within a layer, the same architectural choice. - -## Source - -[NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h) diff --git a/docs/moonmodules/light/effects/NoiseEffect.md b/docs/moonmodules/light/effects/NoiseEffect.md deleted file mode 100644 index 2b32921c..00000000 --- a/docs/moonmodules/light/effects/NoiseEffect.md +++ /dev/null @@ -1,40 +0,0 @@ -# Noise Effect - -![NoiseEffect controls](../../../assets/screenshots/NoiseEffect.png) - -![NoiseEffect preview](../../../assets/screenshots/NoiseEffect.gif) - -Smooth animated noise. Samples a 2D field on flat (`depth == 1`) layouts and a true 3D field on volumetric (`depth > 1`) layouts, so a cube renders as a varied volume rather than stacked identical slices. - -## Controls - -- `scale` (slider, default 4, range 1-32) β€” spatial frequency (higher = finer detail) -- `bpm` (slider, default 60, range 1-255) β€” animation speed in beats per minute - -## Design notes - -The effect picks the 2D (`depth == 1`) or 3D path per `loop()`. The noise value drives **hue, not brightness** β€” driving brightness would leave most lights near-black; full brightness keeps the field visible. Time is applied as a coordinate offset into the field (smooth drift, not a per-frame hash reseed), scaled by panel width so a 16-wide and 128-wide panel look equally fast at the same `bpm`; in 3D the z-axis scrolls at 1/5 the x-rate so the field flows rather than slides flat. `scale` defaults low (4) so the pattern reads on small grids; higher suits larger panels. - -## Tests - -[Unit tests: NoiseEffect](../../../tests/unit-tests.md#noiseeffect) β€” non-zero output, spatial variation, differs from rainbow. - -[Scenario: scenario_MultiplyModifier_pipeline](../../../tests/scenario-tests.md#scenario_multiplymodifier_pipeline) β€” full pipeline with noise + multiply/mirror, performance bounds. - -## Prior art - -### MoonLight β€” E_MoonLight.h ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h)) - -Multiple noise effects (Noise2D, Noise3D variants). Uses FastLED noise functions. Time via `millis()`. - -### projectMM v2 β€” Noise2DEffect ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/lights/effects/Noise2DEffect.h)) - -Same hash-based value noise as v1. Uses PixelEffectBase spine. - -### projectMM v1 β€” NoiseEffect2D ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/NoiseEffect2D.h)) - -Hash-based value noise with trilinear interpolation. Controls: scale (1-32), speed (0-255). Uses `timeMicros()` for animation. v1 ran scale 4 with a 0.1x multiplier (effective 0.4) β€” projectMM's default of 4 is informed by this. - -## Source - -[NoiseEffect.h](../../../../src/light/effects/NoiseEffect.h) diff --git a/docs/moonmodules/light/effects/ParticlesEffect.md b/docs/moonmodules/light/effects/ParticlesEffect.md deleted file mode 100644 index c4d6cc0c..00000000 --- a/docs/moonmodules/light/effects/ParticlesEffect.md +++ /dev/null @@ -1,47 +0,0 @@ -# Particles 2D Effect - -![ParticlesEffect controls](../../../assets/screenshots/ParticlesEffect.png) - -![ParticlesEffect preview](../../../assets/screenshots/ParticlesEffect.gif) - -A swarm of particles drifting on the XY plane with persistent trails. Each particle has fixed-point position, velocity, and hue. A private RGB trail buffer is faded each frame, particles are drawn on top, and the trail buffer is copied to the layer buffer (which the Layer clears every frame). - -## Controls - -- `count` (uint8_t, default 32, range 1-64) β€” number of active particles -- `speed` (uint8_t, default 80, range 1-255) β€” velocity multiplier -- `fade` (uint8_t, default 240, range 200-255) β€” trail persistence (255 = no fade, 200 = quick fade) -- `hue_shift` (uint8_t, default 0, range 0-255) β€” rotate all particle hues - -## Rendering - -Per frame: - -1. **Fade** every byte in the trail buffer via `scale8(byte, fade)` (integer multiply, no float). -2. **Update + draw** β€” for each active particle: advance position using 12.4 fixed-point math, bounce off edges, draw RGB into the trail buffer. -3. **Copy** the trail buffer into the layer buffer. - -Maximum particles is `MAX_PARTICLES = 64` (a compile-time constant). Particle state is a fixed array β€” no heap allocation for the particle list itself. - -## Memory - -Allocates `width * height * channelsPerLight` bytes for the persistent trail buffer in `onBuildState()` when `enabled` is true. Freed in `teardown()` and when disabled. - -| Logical size | Trail buffer (RGB) | -|--------------|--------------------| -| 64x64 | 12 KB | -| 128x128 | 48 KB | - -Particle list (`64 * 8` bytes) is part of `sizeof(ParticlesEffect)`, not `dynamicBytes()`. `dynamicBytes()` reports only the trail buffer. - -## Tests - -[Unit tests: ParticlesEffect](../../../tests/unit-tests.md#particleseffect) β€” buffer becomes non-zero after one frame (particles draw immediately). - -## Prior art - -Classic "snow"/"stars"/"fireflies" effect from many LED firmwares. The fade-and-draw approach (a.k.a. *afterimage*) matches FastLED's `fadeToBlackBy` idiom. - -## Source - -[ParticlesEffect.h](../../../../src/light/effects/ParticlesEffect.h) diff --git a/docs/moonmodules/light/effects/PlasmaEffect.md b/docs/moonmodules/light/effects/PlasmaEffect.md deleted file mode 100644 index 489c50f2..00000000 --- a/docs/moonmodules/light/effects/PlasmaEffect.md +++ /dev/null @@ -1,32 +0,0 @@ -# Plasma Effect - -![PlasmaEffect controls](../../../assets/screenshots/PlasmaEffect.png) - -![PlasmaEffect preview](../../../assets/screenshots/PlasmaEffect.gif) - -Animated plasma pattern from summed sine waves on orthogonal and diagonal axes. Default effect in the desktop and ESP32 pipeline. 2D on flat (`depth == 1`) layouts, 3D on volumetric (`depth > 1`) layouts so a cube renders as a varied volume. - -## Controls - -- `bpm` (uint8_t, default 30, range 1-255) β€” animation speed in beats per minute -- `scale_x` (uint8_t, default 16, range 1-64) β€” horizontal wave length in grid cells (`step = 256 / scale_x`) -- `scale_y` (uint8_t, default 16, range 1-64) β€” vertical wave length in grid cells -- `hue_shift` (uint8_t, default 0, range 0-255) β€” rotates the entire color wheel - -## Design notes - -Sums orthogonal + diagonal `sin8` waves (256-byte LUT, no float, no heap), adding a fifth depth sine on `depth > 1`. `hue_shift` rotates the result before `hsvToRgb`. A phase accumulator (matching NoiseEffect) means a `bpm` change doesn't jump the animation. The default effect in both pipelines, paired with MultiplyModifier (see `src/main.cpp`). - -## Tests - -[Unit tests: PlasmaEffect](../../../tests/unit-tests.md#plasmaeffect) β€” non-zero output, spatial variation, differs from NoiseEffect. - -Default pipeline uses Plasma + MultiplyModifier (see `src/main.cpp`). - -## Prior art - -Classic demoscene plasma effect (sum of sines). Integer sin8 LUT approach matches FastLED-style tables. No direct v1/v2 module port β€” simpler than NoiseEffect (no hash/bilinear). - -## Source - -[PlasmaEffect.h](../../../../src/light/effects/PlasmaEffect.h) diff --git a/docs/moonmodules/light/effects/PlasmaPaletteEffect.md b/docs/moonmodules/light/effects/PlasmaPaletteEffect.md deleted file mode 100644 index 87be51f4..00000000 --- a/docs/moonmodules/light/effects/PlasmaPaletteEffect.md +++ /dev/null @@ -1,21 +0,0 @@ -# Plasma Palette 2D Effect - -![PlasmaPaletteEffect controls](../../../assets/screenshots/PlasmaPaletteEffect.png) - -![PlasmaPaletteEffect preview](../../../assets/screenshots/PlasmaPaletteEffect.gif) - -Same four-sine plasma field as `PlasmaEffect`, but colours come from a 256-entry fire-ocean RGB palette in flash instead of `hsvToRgb`. - -## Controls - -- `bpm` (uint8_t, default 30, range 1-255) -- `scale_x` (uint8_t, default 16, range 1-64) -- `scale_y` (uint8_t, default 16, range 1-64) - -## Tests - -[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) (PlasmaPaletteEffect is one of the stateless effects covered) β€” non-zero output, spatial variation. - -## Source - -[PlasmaPaletteEffect.h](../../../../src/light/effects/PlasmaPaletteEffect.h) diff --git a/docs/moonmodules/light/effects/RainbowEffect.md b/docs/moonmodules/light/effects/RainbowEffect.md deleted file mode 100644 index 5cf87545..00000000 --- a/docs/moonmodules/light/effects/RainbowEffect.md +++ /dev/null @@ -1,26 +0,0 @@ -# Rainbow 2D Effect - -![RainbowEffect controls](../../../assets/screenshots/RainbowEffect.png) - -![RainbowEffect preview](../../../assets/screenshots/RainbowEffect.gif) - -Diagonal rainbow pattern across a 2D grid, animated over time. Good default/test effect β€” always produces visible, colorful output. - -## Controls - -- `speed` (uint8_t, default 60, range 1-255) β€” animation speed in BPM (beats per minute). 60 = 1 full cycle per second. - -## Tests - -[Unit tests: RainbowEffect](../../../tests/unit-tests.md#rainboweffect) β€” non-zero output, valid RGB, spatial variation. - -[Scenario: scenario_Layer_base_pipeline](../../../tests/scenario-tests.md#scenario_layer_base_pipeline) β€” full pipeline with rainbow effect, performance bounds. - -## Design notes - -- Test effect. Dead simple β€” proves the pipeline works. -- No palette, no variants. Rainbow is visually recognizable, which makes it easy to spot in tests. - -## Source - -[RainbowEffect.h](../../../../src/light/effects/RainbowEffect.h) diff --git a/docs/moonmodules/light/effects/RingsEffect.md b/docs/moonmodules/light/effects/RingsEffect.md deleted file mode 100644 index 7f3d4449..00000000 --- a/docs/moonmodules/light/effects/RingsEffect.md +++ /dev/null @@ -1,26 +0,0 @@ -# Rings 2D Effect - -![RingsEffect controls](../../../assets/screenshots/RingsEffect.png) - -![RingsEffect preview](../../../assets/screenshots/RingsEffect.gif) - -Expanding concentric rings from random centre points. Each ring grows outward and respawns once it has expanded past the visible area. Multiple rings overlap with additive blending. - -(This concentric-rings effect is "Rings"; the name "Ripples" belongs to the MoonLight sine-wave water-surface effect.) - -## Controls - -- `count` (uint8_t, default 4, range 1-8) β€” number of simultaneously active rings -- `speed` (uint8_t, default 60, range 1-255) β€” expansion rate -- `thickness` (uint8_t, default 3, range 1-16) β€” ring thickness in pixels -- `hue_shift` (uint8_t, default 0, range 0-255) β€” global hue rotation - -An age-based fade makes old, wide rings disappear softly. Per-ring state (position + radius + hue) lives in a fixed array β€” no heap. - -## Tests - -[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) β€” shared rendering/smoke coverage: non-zero output, spatial variation. (RingsEffect carries per-ring mutable state β€” position, radius, hue β€” with random respawn; that behaviour isn't unit-tested today.) - -## Source - -[RingsEffect.h](../../../../src/light/effects/RingsEffect.h) diff --git a/docs/moonmodules/light/effects/RipplesEffect.md b/docs/moonmodules/light/effects/RipplesEffect.md deleted file mode 100644 index 5ef2d96c..00000000 --- a/docs/moonmodules/light/effects/RipplesEffect.md +++ /dev/null @@ -1,28 +0,0 @@ -# Ripples 3D Effect - -![RipplesEffect controls](../../../assets/screenshots/RipplesEffect.png) - -![RipplesEffect preview](../../../assets/screenshots/RipplesEffect.gif) - -3D dancing sine-wave ripples β€” a reimplementation of MoonLight's Ripples. For each `(x, z)` column on the floor plane, the distance from the centre sets a wave phase, and one pixel per column is lit at the height `y = floor(h/2 Β· (1 + sin(dist / interval + time)))`. The lit surface ripples like water filling the volume, with the hue cycling over time and position. - -Genuinely 3D (`Dim::D3`): it writes a height across the y-axis. On a flat 2D layout (depth 1) it degenerates to a single rippling y-row, which is honest for a flat grid. - -## Controls - -- `speed` (uint8_t, default 50, range 0-99) β€” animation speed; 0 = frozen, 99 = fast -- `interval` (uint8_t, default 128, range 1-254) β€” wavefront spacing; low = tight rings, high = wide - -## Prior art - -Ported from [MoonLight](https://github.com/ewowi/MoonLight)'s Ripples (via projectMM-v1), studied and rewritten against this project's `EffectBase` β€” we read the approach and implemented our own, reusing `core/color.h`'s `hsvToRgb` rather than MoonLight's inlined HSV. The wavefront math (distance β†’ phase β†’ sine height, the `1.3Β·(255βˆ’interval)/128·√h` spacing and `millis/(100βˆ’speed)/6.4` time base) follows MoonLight's so the look matches. - -Float trig (`sinf`/`sqrtf`) in the loop is consistent with the existing wave effects (Plasma, LavaLamp); the hot-path integer-math preference is for per-light colour work, not the handful of transcendental ops a wavefront needs. - -## Tests - -[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) β€” shared rendering/smoke coverage: non-zero output, spatial variation, plus a 0Γ—0Γ—0 grid robustness check. - -## Source - -[RipplesEffect.h](../../../../src/light/effects/RipplesEffect.h) diff --git a/docs/moonmodules/light/effects/SineEffect.md b/docs/moonmodules/light/effects/SineEffect.md deleted file mode 100644 index df37a634..00000000 --- a/docs/moonmodules/light/effects/SineEffect.md +++ /dev/null @@ -1,21 +0,0 @@ -# SineEffect - -A 3D colour sine field: the red, green, and blue channels each follow a sine wave along one axis (x, y, z) with a 120Β° phase offset between channels, so the box glows through shifting colours that scroll over time. Every axis drives a channel, so it is a true 3D effect; on a 2D grid the z term is constant ([Layer](../Layer.md) extrudes a lower-dimensional effect across the unused axis). - -## Controls - -- `frequency` β€” spatial frequency, how many wave cycles span the box (1–20, default 1). -- `amplitude` β€” peak brightness, DMX-aligned (0–255, default 255 = full). -- `bpm` β€” scroll speed of the wave over time (1–255, default 30). - -## Rendering - -For a light at (x, y, z) the channel value is `sin8(coordΒ·frequency + t + phase) Β· amplitude / 255`, where `t` is the time phase and `phase` is 0 / 85 / 170 (the 0Β° / 120Β° / 240Β° channel offsets in `uint8_t` angle units, 256 = full turn). All integer: angles are bytes and the project's [`sin8`](../../core/Control.md) LUT replaces `sinf`, so there is no per-light float. - -## Prior art - -- **projectMM v1 / v2 β€” SineEffect** β€” same 3D sine algorithm. Those used float `sinf` and published a normalized brightness via a KvStore for inter-module communication; this version is integer-only (`sin8`) and carries no KvStore. - -## Source - -[SineEffect.h](../../../../src/light/effects/SineEffect.h) diff --git a/docs/moonmodules/light/effects/SpiralEffect.md b/docs/moonmodules/light/effects/SpiralEffect.md deleted file mode 100644 index 7c268169..00000000 --- a/docs/moonmodules/light/effects/SpiralEffect.md +++ /dev/null @@ -1,21 +0,0 @@ -# Spiral 2D Effect - -![SpiralEffect controls](../../../assets/screenshots/SpiralEffect.png) - -![SpiralEffect preview](../../../assets/screenshots/SpiralEffect.gif) - -Rotating spiral from angle + distance. Uses shared `atan2_8` and `dist8` in `core/color.h`. - -## Controls - -- `bpm` (uint8_t, default 40, range 1-255) β€” rotation speed -- `twist` (uint8_t, default 4, range 1-16) β€” tightness of spiral arms -- `hue_shift` (uint8_t, default 0, range 0-255) β€” colour offset - -## Tests - -[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) (SpiralEffect is one of the stateless effects covered) β€” non-zero output, spatial variation. - -## Source - -[SpiralEffect.h](../../../../src/light/effects/SpiralEffect.h) diff --git a/docs/moonmodules/light/effects/effects.md b/docs/moonmodules/light/effects/effects.md new file mode 100644 index 00000000..94b99952 --- /dev/null +++ b/docs/moonmodules/light/effects/effects.md @@ -0,0 +1,655 @@ +# Effects + +Every effect, one block each: its preview, what it does, and what each control means β€” together. An effect writes per-pixel colour into its [Layer](../Layer.md)'s buffer each tick; [modifiers](../modifiers/modifiers.md) reshape the result and a [driver](../drivers/PreviewDriver.md) sends it out. Effects that name an index colour read the global palette (the `palette` control on [Drivers](../Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio β€” see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](../Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) + +**Jump to:** [MoonLight](#moonlight-effects) Β· [MoonModules](#moonmodules-effects) Β· [WLED](#wled-effects) Β· [FastLED](#fastled-effects) Β· [projectMM-native](#projectmm-native-effects) + +> Some WLED-origin effects show a preview gif from [WLED-Utils](https://github.com/scottrbailey/WLED-Utils) by scottrbailey (the canonical WLED effect gif set, cross-linked with credit); these show WLED's rendering. Effects with a local `../../../assets/…` gif show our own output. + +## MoonLight effects + + + +### DistortionWaves πŸ’« Β· 2D + +Two interfering sine waves beat against each other into a moirΓ© colour field. + +- `freq_x` / `freq_y` β€” horizontal/vertical wave frequency (1–8). +- `speed` β€” animation rate (0 = frozen). + +Origin: WLED Β· by ldirko & blazoncek (WLED port) Β· [gallery](https://editor.soulmatelights.com/gallery/1089-distorsion-waves) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [DistortionWavesEffect.h](../../../../src/light/effects/DistortionWavesEffect.h) + +[Tests](../../../tests/unit-tests.md#distortionwaveseffect) + + + +### FixedRectangle πŸ’« Β· 3D + +A solid colour filling a positioned box within the grid, with an optional alternating-white checker on the box's pixels. + +- `red` / `green` / `blue` / `white` β€” the box colour. +- `X position` / `Y position` / `Z position` β€” the box's origin corner. +- `Rectangle width` / `Rectangle height` / `Rectangle depth` β€” the box extent on each axis. +- `alternateWhite` β€” alternate box pixels to white in a checker pattern. + +Origin: MoonLight Β· by [limpkin](https://github.com/limpkin) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [FixedRectangleEffect.h](../../../../src/light/effects/FixedRectangleEffect.h) + +[Tests](../../../tests/unit-tests.md#fixedrectangleeffect) + + + +### FreqSaws πŸ’«πŸ“Š Β· 2D + +Audio-reactive sawtooth waves: each column maps to a frequency band whose magnitude drives a per-band oscillator speed, so louder bands sweep their sawtooth up the column faster, with three phase methods. + +- `fade` β€” background decay per frame. +- `increaser` β€” how fast a band's speed ramps up with its magnitude. +- `decreaser` β€” how fast a silent band's speed decays. +- `bpmMax` β€” ceiling on a band's oscillation speed. +- `invert` β€” flip alternate columns vertically. +- `keepOn` β€” keep oscillating even when a band is silent. +- `method` β€” phase model (`Chaos`, `Chaos fix`, `BandPhases`). + +Origin: MoonLight (audio) Β· by [@TroyHacks](https://github.com/troyhacks) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [FreqSawsEffect.h](../../../../src/light/effects/FreqSawsEffect.h) + +[Tests](../../../tests/unit-tests.md#freqsawseffect) + + + +### LavaLamp πŸ’«πŸ¦… Β· 2D + +LavaLamp effect preview + +Three slow blobs through a blackβ†’redβ†’orangeβ†’yellowβ†’white ramp β€” atmospheric lava look. + +- `bpm` β€” blob drift speed. +- `radius` β€” blob influence radius. +- `intensity` β€” field gain into the blackβ†’redβ†’orangeβ†’yellowβ†’white ramp. + +Origin: projectMM original (metaball lava lamp) Β· source [LavaLampEffect.h](../../../../src/light/effects/LavaLampEffect.h) + +[Tests](../../../tests/unit-tests.md#spiraleffect) + + + +### Lines πŸ’« Β· β€” + +Lines effect preview + +Sweeps axis-aligned planes in sync; red/green/blue name the X/Y/Z axis β€” a preview-orientation test pattern. + +- `speed` β€” sweep BPM. +- `axis` β€” which plane sweeps (`all`, `x (red)`, `y (green)`, `z (blue)`). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [LinesEffect.h](../../../../src/light/effects/LinesEffect.h) + + + +### Metaballs πŸ’«πŸ¦… Β· 2D + +Metaballs effect preview + +`count` blobs orbit via integer sin/cos; metaball field per pixel β€” bright HSV merge/split. + +- `bpm` β€” orbit speed. +- `radius` β€” blob influence radius. +- `count` β€” number of orbiting balls (1–8). +- `hue_shift` β€” rotate the palette index. + +Origin: projectMM original (metaballs) Β· source [MetaballsEffect.h](../../../../src/light/effects/MetaballsEffect.h) + +[Tests](../../../tests/unit-tests.md#metaballseffect) + + + +### Particles πŸ’«πŸ¦… Β· 2D + +Particles effect preview + +A swarm of drifting particles with persistent fading trails. + +- `count` β€” number of particles (1–255). +- `speed` β€” drift velocity. +- `fade` β€” trail persistence (higher = longer tails). +- `hue_shift` β€” rotate every particle's hue. + +Origin: MoonLight Β· by WildCats08 / [@Brandon502](https://github.com/Brandon502) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [ParticlesEffect.h](../../../../src/light/effects/ParticlesEffect.h) + +[Tests](../../../tests/unit-tests.md#particleseffect) + + + +### Plasma πŸ’«πŸ¦… Β· 2D/3D + +Plasma effect preview + +Summed sine waves on orthogonal + diagonal axes; large rolling blobs (3D on volumetric layouts). + +- `bpm` β€” roll speed. +- `scale_x` / `scale_y` β€” blob size on each axis (larger = bigger, calmer blobs, lower spatial frequency). +- `hue_shift` β€” rotate the palette index. + +Origin: FastLED / WLED lineage (classic plasma) Β· source [PlasmaEffect.h](../../../../src/light/effects/PlasmaEffect.h) + +[Tests](../../../tests/unit-tests.md#plasmaeffect) + + + +### Praxis πŸ’« Β· 2D + +An algorithmic palette pattern driven by two beat oscillators (a macro and a micro mutator) whose frequencies and ranges reshape the hue field over time. + +- `macroMutatorFreq` / `macroMutatorMin` / `macroMutatorMax` β€” the coarse mutator's beat frequency and its oscillation range. +- `microMutatorFreq` / `microMutatorMin` / `microMutatorMax` β€” the fine mutator's beat frequency and range. + +Origin: MoonLight Β· by MONSOONO / @Flavourdynamics Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [PraxisEffect.h](../../../../src/light/effects/PraxisEffect.h) + +[Tests](../../../tests/unit-tests.md#praxiseffect) + + + +### Rainbow πŸ’« Β· 2D + +Rainbow effect preview + +Diagonal animated rainbow β€” always-visible default/test effect. + +- `speed` β€” animation BPM (one full hue cycle per beat). + +Origin: FastLED Β· Mark Kriegsman (rainbow) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_FastLED.h) Β· source [RainbowEffect.h](../../../../src/light/effects/RainbowEffect.h) + +[Tests](../../../tests/unit-tests.md#rainboweffect) + + + +### Random πŸ’« Β· 3D + +Lights one random light per frame in a random palette colour over a fading background β€” a sparse, palette-tinted sparkle. + +- `fade` β€” how fast prior sparkles fade to black. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [RandomEffect.h](../../../../src/light/effects/RandomEffect.h) + +[Tests](../../../tests/unit-tests.md#randomeffect) + + + +### Rings πŸ’«πŸ¦… Β· 2D + +Rings effect preview + +Expanding concentric rings from random centres, additive overlap (calm defaults). + +- `count` β€” simultaneous rings (1–8 active). +- `speed` β€” expansion rate. +- `thickness` β€” ring band width. +- `hue_shift` β€” rotate every ring's hue. + +Origin: projectMM original (concentric rings) Β· source [RingsEffect.h](../../../../src/light/effects/RingsEffect.h) + +[Tests](../../../tests/unit-tests.md#spiraleffect) + + + +### Ripples πŸ’«πŸŸ¦πŸ¦… Β· 3D + +Ripples effect preview + +Distance-from-centre sets a per-column wave phase; the lit surface ripples like water. + +- `speed` β€” wave animation rate (0 = frozen, 99 = fast). +- `interval` β€” wavefront spacing (low = tight rings, high = wide). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [RipplesEffect.h](../../../../src/light/effects/RipplesEffect.h) + +[Tests](../../../tests/unit-tests.md#spiraleffect) + + + +### RubiksCube πŸ’«πŸ§Š Β· 3D + +A 3D Rubik's Cube projected onto the volume: it scrambles, then plays its solution back one turn at a time, the six faces in their standard colours. + +- `turnsPerSecond` β€” how fast the cube turns. +- `cubeSize` β€” the cube order (2Γ—2 up to 8Γ—8). +- `randomTurning` β€” turn endlessly at random instead of scramble-then-solve. + +Origin: MoonLight Β· by WildCats08 / [@Brandon502](https://github.com/Brandon502) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [RubiksCubeEffect.h](../../../../src/light/effects/RubiksCubeEffect.h) + +[Tests](../../../tests/unit-tests.md#rubikscubeeffect) + + + +### Solid πŸ’« Β· 3D + +A flat fill with five colour modes: a plain RGB(W) colour, the active palette spread across the lights, an RMS-averaged single palette colour, or the palette banded along the grid's rows or columns. + +- `red` / `green` / `blue` / `white` β€” the flat colour in `RGB(W)` mode (ignored in the palette modes). +- `brightness` β€” scales the flat and palette-spread output. +- `colorMode` β€” `RGB(W)`, `Palette` (spread across the lights), `Palette avg` (RMS mean of the palette), `Palette rows`, `Palette cols` (palette banded along that axis). +- `minRGB` β€” in the band modes, drops palette entries whose every channel is below this floor. +- `randomColors` β€” in the band modes, deterministically shuffles the surviving palette entries. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [SolidEffect.h](../../../../src/light/effects/SolidEffect.h) + +[Tests](../../../tests/unit-tests.md#solideffect) + + + +### SphereMove πŸ’«πŸ§Š Β· 3D + +A hollow spherical shell that bounces through the 3D volume, its surface coloured from the palette, leaving no trail. + +- `speed` β€” how fast the sphere moves through the volume. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [SphereMoveEffect.h](../../../../src/light/effects/SphereMoveEffect.h) + +[Tests](../../../tests/unit-tests.md#spheremoveeffect) + + + +### Spiral πŸ’«πŸ¦… Β· 2D + +Spiral effect preview + +Rotating spiral from angle + distance (`atan2_8`/`dist8`). + +- `bpm` β€” rotation speed. +- `twist` β€” how tightly the arm winds (hue gain per unit of distance). +- `hue_shift` β€” rotate the palette index. + +Origin: projectMM original (rotating spiral) Β· source [SpiralEffect.h](../../../../src/light/effects/SpiralEffect.h) + +[Tests](../../../tests/unit-tests.md#spiraleffect) + + + +### StarField πŸ’« Β· 2D + +A perspective starfield: stars approach the viewer from a vanishing point, brightening as they near, then respawn at depth. + +- `speed` β€” how fast stars approach (frame throttle). +- `numStars` β€” how many stars are active. +- `blur` β€” motion-trail fade per frame. +- `usePalette` β€” colour the stars from the palette instead of white. + +Origin: MoonLight Β· by [@Brandon502](https://github.com/Brandon502), inspired by Daniel Shiffman / [Coding Train](https://www.youtube.com/watch?v=17WoOqgXsRM) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [StarFieldEffect.h](../../../../src/light/effects/StarFieldEffect.h) + +[Tests](../../../tests/unit-tests.md#starfieldeffect) + + + +### StarSky πŸ’« Β· 3D + +StarSky effect preview + +Twinkling stars at random light positions, each fading in and out independently over a dark background. + +- `speed` β€” fade rate per frame (how fast each star brightens/dims). +- `star_fill_ratio` β€” how many stars (as a fraction of the light count). +- `usePalette` β€” colour the stars from the active palette instead of white. + +Origin: MoonLight Β· by [limpkin](https://github.com/limpkin) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [StarSkyEffect.h](../../../../src/light/effects/StarSkyEffect.h) + +[Tests](../../../tests/unit-tests.md#starskyeffect) + + + +### Text πŸ’« Β· 2D + +Renders a multi-line string in a bitmap font. Static by default (laid out top-left, each newline dropping one font-height, clipped where it runs off the grid); turn on `scroll` to march the whole block leftwards as a wrapping marquee. Text colour comes from the active palette. + +- `text` β€” the string to show; a **multi-line text area** (each line renders on its own row). +- `scroll` β€” off (default) = static; on = horizontal marquee. +- `font` β€” glyph size (`4x6` compact, `6x8` larger). +- `speed` β€” marquee speed (only used when `scroll` is on). +- `hue` β€” palette index for the text colour. + +Origin: projectMM original, on MoonLight's Scrolling Text Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [TextEffect.h](../../../../src/light/effects/TextEffect.h) + +[Tests](../../../tests/unit-tests.md#texteffect) + +## MoonModules effects + + + +### GameOfLife πŸ’«πŸŒ™ Β· 2D/3D + +Conway's cellular automaton generalised to 2D/3D: selectable rulesets (+ custom `B#/S#`), cells that inherit a neighbour's palette colour on birth, optional greenβ†’red age colouring, a dead-cell blur fading toward the background colour, toroidal `wrap`, a 1.5 s settle pause, and 3-CRC stasis self-respawn (R-pentomino/glider) when the board goes static. + +- `backgroundColorR` / `backgroundColorG` / `backgroundColorB` β€” the colour dead cells fade toward (0–255 each). +- `ruleset` β€” the birth/survive rule (Conway, HighLife, InverseLife, Maze, Mazecentric, DrighLife, or Custom). +- `customRuleString` β€” a custom `B#/S#` rule, read only when `ruleset` = Custom. +- `GameSpeed (FPS)` β€” generation rate (0–100, 100 = uncapped). +- `startingLifeDensity` β€” % of cells alive at start (10–90). +- `mutationChance` β€” % chance a newborn gets a random colour (0–100). +- `wrap` β€” toroidal edges (cells wrap around). +- `disablePause` β€” skip the 1.5 s settle pause between boards. +- `colorByAge` β€” greenβ†’red aging instead of inheriting a neighbour's palette colour. +- `infinite` β€” respawn on stasis (R-pentomino/glider) instead of resetting. +- `blur` β€” dead-cell fade strength toward the background colour. + +Origin: MoonModules Β· by Ewoud Wijma (2022), mods by Brandon Butler / [@Brandon502](https://github.com/Brandon502) Β· [natureofcode](https://natureofcode.com/book/chapter-7-cellular-automata/) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) Β· source [GameOfLifeEffect.h](../../../../src/light/effects/GameOfLifeEffect.h) + +[Tests](../../../tests/unit-tests.md#gameoflifeeffect) + + + +### GEQ πŸ’«πŸ™πŸ“Š Β· 2D + +GEQ effect preview + +A flat graphic equaliser: the 16 audio bands rise as vertical bars from the bottom, with optional smoothing between bars, per-bar palette colouring, and falling peak markers. + +- `fadeOut` β€” how fast bars fade each frame. +- `ripple` β€” falling-peak marker decay. +- `colorBars` β€” colour each bar from the palette by band instead of by row. +- `smoothBars` β€” blend neighbouring bands for smoother bar heights. + +Origin: WLED (audio) Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [GEQEffect.h](../../../../src/light/effects/GEQEffect.h) + +[Tests](../../../tests/unit-tests.md#geqeffect) + + + +### GEQ3D πŸ’«πŸŒ™πŸ“Š Β· 2D + +A 3D-perspective graphic equaliser: audio bands rise as bars with faked depth, their side/top lines drawn toward a "projector" vanishing point (sweeping left↔right) and shortened by `depth`. Bands left of the projector are painted right-to-left, bands right of it left-to-right; per-face darkening (side/top/front) and optional `borders`. + +- `speed` β€” projector sweep rate (1–10, higher = faster). +- `frontFill` β€” bar front-face fill strength (0–255). +- `horizon` β€” vanishing-point row the projector sits on. +- `depth` β€” how far the side/top perspective lines reach toward the projector. +- `numBands` β€” bands shown (2–16, fewer = wider bars). +- `borders` β€” outline each bar. + +Origin: MoonModules (audio) Β· by [@TroyHacks](https://github.com/troyhacks) (GPLv3) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) Β· source [GEQ3DEffect.h](../../../../src/light/effects/GEQ3DEffect.h) + +[Tests](../../../tests/unit-tests.md#geq3deffect) + + + +### Noise2D πŸ’«πŸŒ™πŸ™ Β· 2D + +A smoothly drifting value-noise field: each pixel samples 3D noise (grid position Γ— `scale`, time on the Z axis) and indexes the palette directly, giving an organic plasma wash that morphs over time. + +- `speed` β€” how fast the field morphs (time-flow rate). +- `scale` β€” noise zoom (higher = finer, more detailed). + +Origin: WLED Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [Noise2DEffect.h](../../../../src/light/effects/Noise2DEffect.h) + +[Tests](../../../tests/unit-tests.md#noise2deffect) + + + +### PaintBrush πŸ’«πŸŒ™πŸ“Š Β· 3D + +Audio-reactive brush strokes: lines whose 3D endpoints oscillate on the beat (`beatsin8`, audio-band timebase), each stroke shortened to a band-magnitude length so the moving tip sweeps a curve over the fading field. + +- `oscillatorOffset` β€” phase-spread between the oscillating endpoints (0–16). +- `numLines` β€” parallel animated strokes (2–255). +- `fadeRate` β€” background decay per frame (0–128, higher = shorter strokes). +- `minLength` β€” a stroke draws only if longer than this, so quiet bands stay dark. +- `color_chaos` β€” per-line random hue vs a per-band gradient. +- `phase_chaos` β€” random per-frame phase jitter. + +Origin: MoonModules (audio) Β· by [@TroyHacks](https://github.com/troyhacks) (GPLv3) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) Β· source [PaintBrushEffect.h](../../../../src/light/effects/PaintBrushEffect.h) + +[Tests](../../../tests/unit-tests.md#paintbrusheffect) + + + +### Tetrix πŸ’«πŸŒ™ Β· 2D + +Falling Tetris-style blocks: each column drops a brick that lands on the growing stack, fills the column, then clears and restarts. + +- `speed` β€” fall speed (0 = randomised per brick). +- `width` β€” brick height (0 = randomised). +- `oneColor` β€” one advancing palette colour for all bricks instead of random per-brick colours. + +Origin: WLED Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [TetrixEffect.h](../../../../src/light/effects/TetrixEffect.h) + +[Tests](../../../tests/unit-tests.md#tetrixeffect) + +## WLED effects + + + +### Blurz πŸ™πŸ“Š Β· 2D + +Blurz effect preview + +Audio-reactive blurred dots: one frequency band per frame lights a dot whose position maps to that band (or to the major-peak frequency), then the whole frame is blurred for soft trails. + +- `fadeRate` β€” background decay per frame. +- `blur` β€” blur strength applied each frame. +- `freqMap` β€” place the dot by the major-peak frequency instead of scanning bands. +- `geqScanner` β€” scan the dot across the strip in a GEQ-like sweep. + +Origin: WLED (audio) Β· by Andrew Tuline (WLED-SR), enhancements by [@softhack007](https://github.com/softhack007) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [BlurzEffect.h](../../../../src/light/effects/BlurzEffect.h) + +[Tests](../../../tests/unit-tests.md#blurzeffect) + + + +### BouncingBalls πŸ™ Β· 2D + +BouncingBalls effect preview + +A row of balls per column bounce under gravity, each losing energy on impact and relaunching when it stops, palette-coloured by ball index over a fading background. + +- `grav` β€” gravity strength (higher = faster fall, snappier bounce). +- `numBalls` β€” balls per column (1–16). + +Origin: WLED Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [BouncingBallsEffect.h](../../../../src/light/effects/BouncingBallsEffect.h) + +[Tests](../../../tests/unit-tests.md#bouncingballseffect) + + + +### FreqMatrix πŸ™πŸ“Š Β· 1D + +FreqMatrix effect preview + +A 1D scrolling frequency display: each frame shifts the strip and injects a new pixel at one end whose hue comes from the dominant frequency and whose brightness from the volume. + +- `speed` β€” scroll rate. +- `fx` β€” sound-effect intensity (scales the injected brightness). +- `lowBin` / `highBin` β€” the frequency window mapped across the hue range. +- `sensitivity` β€” input gain (10–100). +- `audioSpeed` β€” let the volume modulate the scroll speed. + +Origin: WLED (audio) Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [FreqMatrixEffect.h](../../../../src/light/effects/FreqMatrixEffect.h) + +[Tests](../../../tests/unit-tests.md#freqmatrixeffect) + + + +### Lissajous πŸ™ Β· 2D + +Lissajous effect preview + +A Lissajous curve traced across the grid from two phase-shifted `sin8`/`cos8` sweeps, palette-coloured along its length, with a fading trail. + +- `xFrequency` β€” the x-axis sweep frequency (sets the curve's lobe count). +- `fadeRate` β€” trail fade per frame. +- `speed` β€” how fast the curve's phase advances. + +Origin: WLED Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [LissajousEffect.h](../../../../src/light/effects/LissajousEffect.h) + +[Tests](../../../tests/unit-tests.md#lissajouseffect) + + + +### NoiseMeter πŸ™πŸ“Š Β· 3D + +NoiseMeter effect preview + +An audio VU meter rendered as a noise bar: the volume sets how many rows light from the bottom, each row coloured by drifting Perlin noise, filling the full width and depth. + +- `fadeRate` β€” trail decay per frame (200–254). +- `width` β€” how strongly the volume drives the bar height. + +Origin: WLED (audio) Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [NoiseMeterEffect.h](../../../../src/light/effects/NoiseMeterEffect.h) + +[Tests](../../../tests/unit-tests.md#noisemetereffect) + + + +### Wave 🌊 Β· 2D + +An oscilloscope waveform scrolls across the grid with a fading trail; six selectable shapes. + +- `bpm` β€” travel speed (phase advance per minute). +- `fade` β€” trail fade per frame (0 = instant clear, 255 = long tail). +- `type` β€” waveform shape (`Sawtooth`, `Triangle`, `Sine`, `Square`, `Sin3`, `Noise`). + +Origin: MoonLight Β· by Ewoud Wijma Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [WaveEffect.h](../../../../src/light/effects/WaveEffect.h) + +[Tests](../../../tests/unit-tests.md#waveeffect) + +## FastLED effects + + + +### Fire βš‘οΈπŸ¦… Β· 2D + +Fire effect preview + +Fire2012-style heat field β€” sparks at the base rise and cool through the active palette (heat = palette index, cold at the low end, hottest at the high end); spark count scales with width. + +- `cooling` β€” how fast heat dissipates as it rises (higher = shorter flames). +- `sparking` β€” chance of a new spark at the base each frame (higher = livelier fire). + +The flame colour comes from the **active palette**. For the classic fire look pick the **Lava** palette (blackβ†’redβ†’orangeβ†’yellowβ†’white β€” the recommended default); any palette works, so an Ocean or Forest palette turns the flame blue or green. + +Origin: FastLED / MoonLight Β· Mark Kriegsman's Fire2012; MoonLight adapts [MatrixFireFast](https://github.com/toggledbits/MatrixFireFast) (toggledbits) Β· source [FireEffect.h](../../../../src/light/effects/FireEffect.h) + +[Tests](../../../tests/unit-tests.md#fireeffect) + + + +### Noise ⚑️ Β· 2D/3D + +Noise effect preview + +Smooth animated value noise; true 3D field on volumetric layouts. + +- `scale` β€” spatial frequency of the field (1–32, higher = finer detail). +- `bpm` β€” scroll speed (8 noise cells per beat). + +Origin: FastLED Β· inoise field (Mark Kriegsman) Β· source [NoiseEffect.h](../../../../src/light/effects/NoiseEffect.h) + +[Tests](../../../tests/unit-tests.md#noiseeffect) + +## projectMM-native effects + + + +### AudioSpectrum πŸ“Š + +The 16 mic frequency bands spread across X, each column lit bottom-up by its magnitude. + +- `colorMode` β€” bar colouring: `height` (green base β†’ red top, the VU look) or `per-band` (each column its own hue, the rainbow analyser look). + +Origin: projectMM original, on the WLED-SR GEQ / spectrum concept (Andrew Tuline) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h) + +[Tests](../../../tests/unit-tests.md#audiomodule) + + + +### AudioVolume πŸ”Š + +A whole-grid VU meter: every light pulses with the mic level, colour indexing the palette by loudness. + +- `brightness` β€” overall brightness ceiling for the VU pulse (1–255). + +Origin: projectMM original (VU meter) Β· source [AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h) + +[Tests](../../../tests/unit-tests.md#audiomodule) + + + +### DemoReel 🎬 Β· 3D + +A demo reel: plays every other registered effect in turn, auto-advancing on a timer, so one Layer cycles the whole library hands-free β€” the showcase/test tool for everything. It hosts a single live effect at a time (created from the effect registry, rendered into this Layer) and swaps to the next when the interval elapses β€” new effects are picked up automatically. It can also pick a fresh palette each cycle and overlay the playing effect's name. The `status` line shows which effect is playing (e.g. `playing: Plasma (3/20)`). It never hosts itself, and it plays effects in sequence rather than compositing them (layering is the [Layer](../Layer.md) stack's job). + +- `interval` β€” seconds each effect plays before advancing (1–120). +- `shuffle` β€” jump to a random next effect instead of registry order. +- `randomPalette` β€” pick a random palette on each cycle (showcases the palette set); default on. +- `showName` β€” overlay the playing effect's name in a small font; default on. + +Origin: FastLED Β· Mark Kriegsman's [DemoReel100](https://github.com/FastLED/FastLED/blob/master/examples/DemoReel100/DemoReel100.ino); projectMM reel Β· source [DemoReelEffect.h](../../../../src/light/effects/DemoReelEffect.h) + +[Tests](../../../tests/unit-tests.md#demoreeleffect) + + + +### NetworkReceive πŸ“‘πŸŒ™ + +Receives lights-over-UDP (Art-Net, E1.31/sACN, DDP) and writes it into the layer β€” the receive side for Resolume/Madrix/xLights/LedFx. + +- `universe_start` β€” the first incoming universe to map onto the layer (mirrors the sender). +- `channels_per_universe` β€” bytes each universe maps to (510 = whole RGB lights per universe, the xLights/Falcon convention; 512 for Madrix-style senders that pack pixels across universe boundaries). + +Origin: projectMM original (E1.31 / Art-Net receive) Β· source [NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h) + +[Tests](../../../tests/unit-tests.md#networkreceiveeffect) + +**Wire contract:** listens for [Art-Net](https://art-net.org.uk/downloads/art-net.pdf), [E1.31 / sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf), and [DDP](http://www.3waylabs.com/ddp/) simultaneously; `universe_start` + `channels_per_universe` map incoming universes onto the layer buffer. The end-to-end pair with [NetworkSendDriver](../drivers/NetworkSendDriver.md). + + + +### Sine πŸŒ€ Β· 3D + +R/G/B each follow a sine along one axis at 120Β° phase offset β€” a glowing, scrolling colour box. + +- `frequency` β€” spatial frequency, waves across the box (1–20). +- `amplitude` β€” peak brightness (0–255, 255 = full). +- `bpm` β€” scroll speed. + +Origin: MoonLight (Sinus, AI-generated) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [SineEffect.h](../../../../src/light/effects/SineEffect.h) + +[Tests](../../../tests/unit-tests.md#sineeffect) + +## Source + +- [AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h) +- [AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h) +- [BlurzEffect.h](../../../../src/light/effects/BlurzEffect.h) +- [BouncingBallsEffect.h](../../../../src/light/effects/BouncingBallsEffect.h) +- [DemoReelEffect.h](../../../../src/light/effects/DemoReelEffect.h) +- [DistortionWavesEffect.h](../../../../src/light/effects/DistortionWavesEffect.h) +- [FireEffect.h](../../../../src/light/effects/FireEffect.h) +- [FixedRectangleEffect.h](../../../../src/light/effects/FixedRectangleEffect.h) +- [FreqMatrixEffect.h](../../../../src/light/effects/FreqMatrixEffect.h) +- [FreqSawsEffect.h](../../../../src/light/effects/FreqSawsEffect.h) +- [GEQ3DEffect.h](../../../../src/light/effects/GEQ3DEffect.h) +- [GEQEffect.h](../../../../src/light/effects/GEQEffect.h) +- [GameOfLifeEffect.h](../../../../src/light/effects/GameOfLifeEffect.h) +- [LavaLampEffect.h](../../../../src/light/effects/LavaLampEffect.h) +- [LinesEffect.h](../../../../src/light/effects/LinesEffect.h) +- [LissajousEffect.h](../../../../src/light/effects/LissajousEffect.h) +- [MetaballsEffect.h](../../../../src/light/effects/MetaballsEffect.h) +- [NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h) +- [Noise2DEffect.h](../../../../src/light/effects/Noise2DEffect.h) +- [NoiseEffect.h](../../../../src/light/effects/NoiseEffect.h) +- [NoiseMeterEffect.h](../../../../src/light/effects/NoiseMeterEffect.h) +- [PaintBrushEffect.h](../../../../src/light/effects/PaintBrushEffect.h) +- [ParticlesEffect.h](../../../../src/light/effects/ParticlesEffect.h) +- [PlasmaEffect.h](../../../../src/light/effects/PlasmaEffect.h) +- [PraxisEffect.h](../../../../src/light/effects/PraxisEffect.h) +- [RainbowEffect.h](../../../../src/light/effects/RainbowEffect.h) +- [RandomEffect.h](../../../../src/light/effects/RandomEffect.h) +- [RingsEffect.h](../../../../src/light/effects/RingsEffect.h) +- [RipplesEffect.h](../../../../src/light/effects/RipplesEffect.h) +- [RubiksCubeEffect.h](../../../../src/light/effects/RubiksCubeEffect.h) +- [SineEffect.h](../../../../src/light/effects/SineEffect.h) +- [SolidEffect.h](../../../../src/light/effects/SolidEffect.h) +- [SphereMoveEffect.h](../../../../src/light/effects/SphereMoveEffect.h) +- [SpiralEffect.h](../../../../src/light/effects/SpiralEffect.h) +- [StarFieldEffect.h](../../../../src/light/effects/StarFieldEffect.h) +- [StarSkyEffect.h](../../../../src/light/effects/StarSkyEffect.h) +- [TetrixEffect.h](../../../../src/light/effects/TetrixEffect.h) +- [TextEffect.h](../../../../src/light/effects/TextEffect.h) +- [WaveEffect.h](../../../../src/light/effects/WaveEffect.h) diff --git a/docs/moonmodules/light/layouts/GridLayout.md b/docs/moonmodules/light/layouts/GridLayout.md deleted file mode 100644 index 1abe202c..00000000 --- a/docs/moonmodules/light/layouts/GridLayout.md +++ /dev/null @@ -1,31 +0,0 @@ -# Grid Layout - -![GridLayout controls](../../../assets/screenshots/GridLayout.png) - -Arranges lights in a 3D grid, row-major (x fastest, then y, then z). Full-density β€” every position maps to a light. Controls: `width`, `height`, `depth`, `serpentine`. - -## Mapping - -A plain grid (`serpentine` off) emits driver index `i` at box cell `i`, so the Layer takes the **1:1 unshuffled memcpy fast path** β€” the mapping isn't *declared* identity, it's *measured*: the Layer walks the coords once and only skips the mapping table when the order is natural. `serpentine` wires odd rows in reverse (boustrophedon β€” the strip snakes back and forth), so driver index `i` no longer equals box cell `i`: the grid is dense but **shuffled**, which routes it through the boxβ†’driver mapping LUT exactly as a sparse layout does. A handy lever for exercising both the identity and non-identity mapping paths from one layout. The Layer buffer and driver buffer are separate when memory allows (for parallelism), shared when memory is tight. `defaultGridSize` (16) is owned here and also read by the composition roots to size the boot grid. - -## Tests - -[Unit tests: GridLayout](../../../tests/unit-tests.md#gridlayout) β€” row-major coordinate iteration, 3D grids, Layouts multi-layout offset. - -## Prior art - -### MoonLight β€” L_MoonLight.h Panel ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h)) - -Panel layout with serpentine, multiple panel arrangements. Uses `addLight()` to define each position. - -### projectMM v1 β€” GridLayout ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/layouts/GridLayout.h)) - -Width/height/depth/serpentine controls. Mapping rebuilt in onUpdate(), parent notified via onChildrenReady(). - -### projectMM v2 β€” GridLayoutModule ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/lights/GridLayoutModule.h)) - -Same controls. Uses LayoutModule base class. - -## Source - -[GridLayout.h](../../../../src/light/layouts/GridLayout.h) diff --git a/docs/moonmodules/light/layouts/SphereLayout.md b/docs/moonmodules/light/layouts/SphereLayout.md deleted file mode 100644 index 87ef07e6..00000000 --- a/docs/moonmodules/light/layouts/SphereLayout.md +++ /dev/null @@ -1,27 +0,0 @@ -# Sphere Layout - -Arranges lights on the **surface of a hollow sphere** β€” a one-light-thick shell, no interior lights. Lattice layout: every light sits at an integer `(x, y, z)` inside a `(2Β·radius+1)Β³` bounding box centred at `(radius, radius, radius)`. - -## Controls - -- `radius` (default 4, range 1–64) β€” surface radius in light-units. A lattice point is on the shell when its distance from the centre rounds to `radius` (it falls in the half-open band `[radiusβˆ’0.5, radius+0.5)`). `radius = 1` is the smallest hollow sphere β€” 18 lights: the 6 axis-neighbours (dΒ²=1) plus the 12 edge-neighbours (dΒ²=2) of the centre, all of which round to distance 1. - -## Light count and mapping - -Light count is derived from `radius` (not set directly) β€” the lattice points landing in the shell band, growing roughly with surface area (`~4π·radiusΒ²`). The iterator and `lightCount()` share one shell predicate, so the count always matches the emitted points. Distances compare in squared integer space (no `sqrt`, no per-light float), so the shell is exact and deterministic across platforms. - -A sphere is **not** 1:1 unshuffled β€” the shell points are sparse within the bounding box, so it supplies explicit coordinates via `forEachCoord` like every non-grid layout; Layer/Drivers wiring treats it identically to any other `LayoutBase`. - -## Tests - -[Unit tests: SphereLayout](../../../tests/unit-tests.md#spherelayout) β€” shell-only (no interior/centre point), symmetry, count matches the iterator, radius-1 base case. Add / replace / remove / multiple layouts are covered in [Layouts](../../../tests/unit-tests.md#layouts) and the layout-mutation scenario. - -## Prior art - -### MoonLight / projectMM v1–v2 β€” layout nodes ([MoonLight L_MoonLight.h](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h)) - -Prior projects expose layouts that call an `addLight(x, y, z)` per position; SphereLayout follows the same "a layout enumerates its light coordinates" shape, computing the shell analytically rather than from a stored list. - -## Source - -[SphereLayout.h](../../../../src/light/layouts/SphereLayout.h) diff --git a/docs/moonmodules/light/layouts/WheelLayout.md b/docs/moonmodules/light/layouts/WheelLayout.md deleted file mode 100644 index 7b0db846..00000000 --- a/docs/moonmodules/light/layouts/WheelLayout.md +++ /dev/null @@ -1,23 +0,0 @@ -# WheelLayout - -A bicycle-wheel arrangement: a number of straight spokes radiate from a centre hub, each carrying a row of LEDs spaced one unit apart from the centre outward. Spoke *k* points at angle *k / spokes* of a full turn; the LEDs along it sit at increasing radius. - -## Controls - -- `spokes` β€” number of spokes (2–64, default 8). -- `ledsPerSpoke` β€” LEDs along each spoke (1–256, default 10). - -`lightCount()` = `spokes Γ— ledsPerSpoke`. - -## Coordinate iterator - -`forEachCoord` emits `(index, x, y, 0)` for each LED, walking spoke by spoke. A spoke's angle is `spoke Β· 256 / spokes` in `uint8_t` turn units; the LED at radius *r* sits at `(maxR + rΒ·cos, maxR + rΒ·sin)`, where cos/sin come from the project's [`cos8`/`sin8`](../../core/Control.md) integer LUT (signed component `val βˆ’ 128`, divided back by 128). The whole wheel is shifted by `+ledsPerSpoke` so every coordinate is β‰₯ 0 within a `(2Β·ledsPerSpoke)`-wide bounding box. Integer-only (same discipline as [SphereLayout](SphereLayout.md)) β€” no `double` cos/sin/round. - -## Prior art - -- **MoonLight β€” ring/spoke layouts** (L_MoonLight.h): Ring, Rings241, and other radial layouts. -- **projectMM v2 β€” WheelLayoutModule** β€” spoke-based coordinate generation; that used `double` trig, this is the integer-LUT equivalent. - -## Source - -[WheelLayout.h](../../../../src/light/layouts/WheelLayout.h) diff --git a/docs/moonmodules/light/layouts/layouts.md b/docs/moonmodules/light/layouts/layouts.md new file mode 100644 index 00000000..387e477d --- /dev/null +++ b/docs/moonmodules/light/layouts/layouts.md @@ -0,0 +1,214 @@ +# Layouts + +Every layout, one block each: what it does and what each control means β€” together. A layout maps light indices to physical `(x, y, z)` positions β€” it defines the *shape* an [effect](../effects/effects.md) draws onto and a [driver](../drivers/) sends out. The [Layouts](../Layouts.md) container holds one or more layout children and composes them into one coordinate space; a [Layer](../Layer.md) renders over that combined space. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) + +## MoonLight layouts + + + +### Car Lights + +A pair of concentric-ring "headlight" clusters (nested rings of 1/8/12/16/24 LEDs) positioned to mimic a car's front lights β€” a fixed arrangement composed from [Ring](#ring) geometry. + +Car Lights layout preview + +- `scale` β€” overall size scale (1–10). + +Origin: projectMM / custom fixture Β· source [CarLightsLayout.h](../../../../src/light/layouts/CarLightsLayout.h) + + + +### Cube + +A 3D cube volume, `width`Γ—`height`Γ—`depth`, wired in a configurable axis order with optional per-axis serpentine β€” the 3D generalisation of Panel. + +- `width` / `height` / `depth` β€” cube extent per axis (1–128). +- `wiringOrder` β€” the axis nesting order the strip follows. +- `X++` / `Y++` / `Z++` β€” count up (vs down) along that axis. +- `snakeX` / `snakeY` / `snakeZ` β€” serpentine (alternate rows/columns reverse) on that axis. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [CubeLayout.h](../../../../src/light/layouts/CubeLayout.h) + + + +### Human-Sized Cube + +A hollow walk-in cube built from five LED-curtain faces (front, back, top, left, right), each a `width`Γ—`height`Γ—`depth` curtain β€” for large/room-scale cube installations. + +- `width` / `height` / `depth` β€” cube extent per axis (1–20). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [HumanSizedCubeLayout.h](../../../../src/light/layouts/HumanSizedCubeLayout.h) + + + +### Panel + +A 2D matrix panel with full wiring control: choose the axis order, per-axis direction, and serpentine β€” the general matrix layout ([Grid](#grid) is the simple case). + +- `panelWidth` / `panelHeight` β€” panel size in lights (1–512). +- `wiringOrder` β€” `XY` (rows) or `YX` (columns) nesting. +- `X++` / `Y++` β€” count up vs down along that axis. +- `snake` β€” serpentine wiring (alternate lines reverse). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [PanelLayout.h](../../../../src/light/layouts/PanelLayout.h) + + + +### Panels + +Tiles an MΓ—N grid of full matrix panels into one large display: an outer walk over the panel grid plus an inner walk over each panel's lights, both independently wired β€” for multi-panel video walls. + +- `horizontalPanels` / `verticalPanels` β€” panel-grid size (1–32 each). +- `wiringOrderP` / `X++P` / `Y++P` / `snakeP` β€” the panel-to-panel wiring (order, direction, serpentine). +- `panelWidth` / `panelHeight` β€” each panel's size (1–512). +- `wiringOrder` / `X++` / `Y++` / `snake` β€” the per-panel light wiring. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [PanelsLayout.h](../../../../src/light/layouts/PanelsLayout.h) + + + +### Ring + +A single ring of LEDs evenly spaced around a circle β€” `nrOfLEDs` points, starting at `angleFirst`, spanning `rotation` degrees. + +- `nrOfLEDs` β€” LEDs around the ring (1–255). +- `angleFirst` β€” starting angle in degrees. +- `rotation` β€” arc spanned (360 = full circle). +- `clockwise` β€” direction of travel. +- `scale` β€” spacing/radius scale. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [RingLayout.h](../../../../src/light/layouts/RingLayout.h) + + + +### Rings 241 + +The classic 241-LED concentric-ring disc: nested rings of 1, 8, 12, 16, 24, 32, 40, 48, 60 LEDs sharing a centre. + +- `scale` β€” overall radius scale (1–10). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [Rings241Layout.h](../../../../src/light/layouts/Rings241Layout.h) + + + +### Single Column + +A vertical line of LEDs at a fixed X β€” the 1D column primitive. + +- `starting Y` β€” the column's start row. +- `height` β€” LEDs in the column (1–1000). +- `X position` β€” the column's x. +- `reversed order` β€” wire top-to-bottom instead of bottom-to-top. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [SingleColumnLayout.h](../../../../src/light/layouts/SingleColumnLayout.h) + + + +### Single Row + +A horizontal line of LEDs at a fixed Y β€” the 1D row primitive. + +- `starting X` β€” the row's start column. +- `width` β€” LEDs in the row (1–1000). +- `Y position` β€” the row's y. +- `reversed order` β€” wire right-to-left instead of left-to-right. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [SingleRowLayout.h](../../../../src/light/layouts/SingleRowLayout.h) + + + +### Spiral + +A conical spiral: `ledCount` LEDs winding up a cone from `bottomRadius` to a point over `height`. + +- `ledCount` β€” LEDs along the spiral (1–2048). +- `bottomRadius` β€” radius at the base. +- `height` β€” spiral height. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [SpiralLayout.h](../../../../src/light/layouts/SpiralLayout.h) + + + +### Toronto Bar Gourds + +Maps a set of decorative "gourd" objects (a specific bar installation), each rendered at one of three granularities β€” one light per gourd, per side, or per LED. + +Toronto Bar Gourds layout preview + +- `granularity` β€” `One Gourd One Light`, `One Side One Light`, or `One LED One Light`. +- `nrOfLightsPerGourd` β€” LEDs per gourd in the coarsest mode (1–128). + +Origin: projectMM / custom fixture Β· source [TorontoBarGourdsLayout.h](../../../../src/light/layouts/TorontoBarGourdsLayout.h) + + + +### Tubes + +Parallel vertical tubes: `nrOfTubes` columns of `ledsPerTube` LEDs, spaced `tubeDistance` apart. + +- `nrOfTubes` β€” number of tubes (1–64). +- `ledsPerTube` β€” LEDs per tube (1–255). +- `tubeDistance` β€” spacing between tubes. +- `reversed` β€” reverse the wiring order. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [TubesLayout.h](../../../../src/light/layouts/TubesLayout.h) + +## projectMM-native layouts + + + +### Grid + +A dense 3D grid, row-major (x fastest, then y, then z); every position maps to a light. + +- `width` / `height` / `depth` β€” grid extent on each axis in lights (1–512). +- `serpentine` β€” boustrophedon-wire alternate rows (every other row runs in reverse, matching a snaked strip). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [GridLayout.h](../../../../src/light/layouts/GridLayout.h) + +[Tests](../../../tests/unit-tests.md#gridlayout) + + + +### Sphere + +Lights on the surface of a hollow sphere β€” a one-light-thick shell inside a `(2Β·radius+1)Β³` box, no interior lights. + +- `radius` β€” surface radius in light-units (1–64); the shell is every cell whose distance from the centre rounds to `radius`. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [SphereLayout.h](../../../../src/light/layouts/SphereLayout.h) + +[Tests](../../../tests/unit-tests.md#spherelayout) + + + +### Wheel + +A bicycle-wheel: `spokes` straight rows radiate from a centre hub, each carrying `ledsPerSpoke` LEDs spaced one unit apart outward. + +- `spokes` β€” number of spokes radiating from the hub (2–64). +- `ledsPerSpoke` β€” LEDs along each spoke, spaced one unit apart from the centre outward. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [WheelLayout.h](../../../../src/light/layouts/WheelLayout.h) + +[Tests](../../../tests/unit-tests.md#wheellayout) + +The [Layouts](../Layouts.md) container itself takes no controls β€” see its page for coordinate iteration, reordering, and rebuild propagation. + +## Source + +- [CarLightsLayout.h](../../../../src/light/layouts/CarLightsLayout.h) +- [CubeLayout.h](../../../../src/light/layouts/CubeLayout.h) +- [GridLayout.h](../../../../src/light/layouts/GridLayout.h) +- [HumanSizedCubeLayout.h](../../../../src/light/layouts/HumanSizedCubeLayout.h) +- [PanelLayout.h](../../../../src/light/layouts/PanelLayout.h) +- [PanelsLayout.h](../../../../src/light/layouts/PanelsLayout.h) +- [RingLayout.h](../../../../src/light/layouts/RingLayout.h) +- [Rings241Layout.h](../../../../src/light/layouts/Rings241Layout.h) +- [SingleColumnLayout.h](../../../../src/light/layouts/SingleColumnLayout.h) +- [SingleRowLayout.h](../../../../src/light/layouts/SingleRowLayout.h) +- [SphereLayout.h](../../../../src/light/layouts/SphereLayout.h) +- [SpiralLayout.h](../../../../src/light/layouts/SpiralLayout.h) +- [TorontoBarGourdsLayout.h](../../../../src/light/layouts/TorontoBarGourdsLayout.h) +- [TubesLayout.h](../../../../src/light/layouts/TubesLayout.h) +- [WheelLayout.h](../../../../src/light/layouts/WheelLayout.h) diff --git a/docs/moonmodules/light/modifiers/CheckerboardModifier.md b/docs/moonmodules/light/modifiers/CheckerboardModifier.md deleted file mode 100644 index ee6395fb..00000000 --- a/docs/moonmodules/light/modifiers/CheckerboardModifier.md +++ /dev/null @@ -1,38 +0,0 @@ -# Checkerboard Modifier - -![CheckerboardModifier controls](../../../assets/screenshots/CheckerboardModifier.png) - -![CheckerboardModifier preview](../../../assets/screenshots/CheckerboardModifier.gif) - -Static modifier. Masks the layer in a checkerboard pattern: lights in the "off" squares are dropped (they receive nothing), lights in the "on" squares pass through unchanged. Unlike Multiply, this doesn't remap or resize β€” it's a spatial on/off mask applied to whatever the effect drew. - -## Controls - -- `size` (Uint8, 1–64, default 2) β€” checker square edge, in lights -- `invert` (Bool, default false) β€” flip which squares pass through - -## Effect on the pipeline - -- **Logical box unchanged** β€” a mask doesn't resize the box (no `modifyLogicalSize`); only which cells contribute changes. -- **Pass or drop** β€” `modifyLogical` returns `true` to pass a light through unchanged, or `false` to drop it (an "off" square), so a dropped physical light has no logical source and stays dark. -- **Square parity**: a light at `(x,y,z)` belongs to square `(x/size, y/size, z/size)`; the square is "on" when the sum of those indices is even (flipped by `invert`). - -## Cross-domain wiring - -A Layer folds all its enabled modifiers as a chain (Checkerboard-then-Multiply differs from Multiply-then-Checkerboard). The fold + reject contract is in [ModifierBase](../ModifierBase.md). - -## Tests - -[Unit tests: CheckerboardModifier](../../../tests/unit-tests.md#checkerboardmodifier) β€” identity dimensions, the drop pattern for both `invert` phases, `size` grouping into squares. - -[Scenario: scenario_modifier_swap](../../../tests/scenario-tests.md#scenario_modifier_swap) β€” replaces the Layer's modifier between Multiply and Checkerboard and verifies the pipeline stays live across each swap. - -## Prior art - -### MoonLight β€” M_MoonLight.h Checkerboard ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) - -MoonLight's Checkerboard drops lights by setting `position.x = UINT16_MAX` (a sentinel the layout pass skips), with `size`, `invert`, and a `group` flag. We express the drop as `modifyLogical` returning `false` (no sentinel needed) and start with `size` + `invert`; `group` is deferred. - -## Source - -[CheckerboardModifier.h](../../../../src/light/modifiers/CheckerboardModifier.h) diff --git a/docs/moonmodules/light/modifiers/MultiplyModifier.md b/docs/moonmodules/light/modifiers/MultiplyModifier.md deleted file mode 100644 index 96b3d661..00000000 --- a/docs/moonmodules/light/modifiers/MultiplyModifier.md +++ /dev/null @@ -1,45 +0,0 @@ -# Multiply Modifier - -![MultiplyModifier controls](../../../assets/screenshots/MultiplyModifier.png) - -![MultiplyModifier preview](../../../assets/screenshots/MultiplyModifier.gif) - -Static modifier. Tiles the logical image across the physical box `multiply` times per axis, optionally reflecting alternate tiles. With a multiplier of 2 and mirror enabled on an axis, that axis folds in half β€” the classic kaleidoscope mirror. Multiply subsumes the old MirrorModifier: a pure mirror is `multiply = 2, mirror = true` on the chosen axes. - -## Controls - -- `multiplyX` (Uint8, 1–64, default 2) β€” tiles along X (1 = no tiling) -- `multiplyY` (Uint8, 1–64, default 2) β€” tiles along Y -- `multiplyZ` (Uint8, 1–64, default 1) β€” tiles along Z -- `mirrorX` (Bool, default true) β€” reflect alternate (odd) tiles along X -- `mirrorY` (Bool, default true) β€” reflect alternate tiles along Y -- `mirrorZ` (Bool, default true) β€” reflect alternate tiles along Z - -The defaults (`multiply 2/2/1`, `mirror all on`) reproduce the canonical mirror-XY pipeline: a 128Γ—128 physical grid folds to a 64Γ—64 logical buffer, each logical light fanning out to its four reflected quadrants. (`mirrorZ` on is a no-op on a 2D/depth-1 layout.) - -## Effect on the pipeline - -- **Logical box shrinks by the multiplier**: `logW = physW / multiplyX` (etc.). 128Γ—128 with multiply 2/2 β†’ 64Γ—64 logical (the effect renders a quarter of the lights). The effective multiplier clamps to the axis extent β€” `multiplyZ` on a depth-1 layout clamps to 1 (no-op), never blanking the layer. -- **Fan-out is the fold**: each physical light folds (`pos % logicalSize`) onto its logical cell, so the `multiplyXΒ·multiplyYΒ·multiplyZ` physical lights of the tiles all land on one logical light β€” the 1:N mapping emerges from the build with no fan-out list and no cap (see [ModifierBase Β§ Fan-out is free](../ModifierBase.md)). -- **Tile vs fold**: with mirror **off** on an axis, tiles repeat (translate); with mirror **on**, odd-numbered tiles reflect within their tile (`size βˆ’ 1 βˆ’ pos`), so multiply 2 + mirror = a fold. -- **Integer division**: a physical extent not divisible by the multiplier leaves uncovered cells at the high edge (they map to nothing) β€” the same edge behaviour the old mirror had on odd widths, without a shared centre line. - -## Cross-domain wiring - -A Layer folds all its enabled modifiers as a chain (order matters: multiply-then-checkerboard β‰  checkerboard-then-multiply). The fold contract is in [ModifierBase](../ModifierBase.md). - -## Tests - -[Unit tests: MultiplyModifier](../../../tests/unit-tests.md#multiplymodifier) β€” logical dimensions, tile fan-out, per-axis mirror reflection, the pure-fold equivalence to the old Mirror, the multiplyZ-on-2D no-op, and the extent clamp. - -[Scenario: scenario_MultiplyModifier_pipeline](../../../tests/scenario-tests.md#scenario_multiplymodifier_pipeline) β€” full pipeline with the multiply/mirror kaleidoscope, performance bounds. - -## Prior art - -### MoonLight β€” M_MoonLight.h Multiply ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) - -MoonLight's Multiply node tiles via `position % modifierSize` and reflects odd tiles when its `mirror` flag is set (`position = size βˆ’ 1 βˆ’ position`). We expose **per-axis** mirror bools and per-axis multipliers instead of MoonLight's single multiplier coord + single mirror flag, so X/Y/Z fold and tile independently. MoonLight keeps Mirror, Multiply, Transpose, and Kaleidoscope as separate nodes; we fold mirror into Multiply since mirror is just multiply-2-with-reflection. - -## Source - -[MultiplyModifier.h](../../../../src/light/modifiers/MultiplyModifier.h) diff --git a/docs/moonmodules/light/modifiers/RandomMapModifier.md b/docs/moonmodules/light/modifiers/RandomMapModifier.md deleted file mode 100644 index d1e426c0..00000000 --- a/docs/moonmodules/light/modifiers/RandomMapModifier.md +++ /dev/null @@ -1,27 +0,0 @@ -# RandomMapModifier - -A **modifier** that randomly remaps every light to another light β€” a true 1:1 permutation (every light goes somewhere, no two lights land on the same place, none are dropped) β€” and reshuffles to a fresh random permutation on a `bpm` timer. The image scrambles into a new arrangement every beat; the *content* is untouched, only *where each pixel lands* changes. - -It is a **dynamic** modifier: where a static modifier (Multiply, Checkerboard) shapes the layer's mapping once, RandomMapModifier re-shapes it on a timer. - -## Controls - -- `bpm` β€” reshuffles per minute, `0`–`60`, default `6`. `6` β‰ˆ one new permutation every 10 seconds; `60` is the cap (one per second β€” faster would strobe, and the per-beat work is bounded for that reason). `0` **freezes** the current permutation: a fixed random remap that never changes. - -## How it works - -The permutation rides the layer's existing LUT. Like [CheckerboardModifier](CheckerboardModifier.md), it leaves the logical box the same size as the physical box (identity dimensions) and maps each logical light to exactly one physical light β€” here, the light at `perm[index]`, where `perm` is a [Fisher–Yates](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle)-shuffled array of every light index. On each beat the modifier reshuffles `perm` and asks its parent [Layer](../Layer.md) to rebuild the LUT β€” the same rebuild a control change triggers, so no new mechanism, just a timer-driven trigger. The shuffle uses an integer LCG seeded from a generation counter, so a given generation is reproducible (and unit-testable) while successive beats differ. - -The permutation buffer is sized to the light count and (re)allocated only when the grid resizes β€” never per frame. If that allocation fails, the modifier degrades to identity passthrough (no remap), the same way the LUT degrades; an empty (0Γ—0Γ—0) grid is a no-op. On a **sparse** layout the permutation is over grid-cell indices, so a real light can be remapped onto a cell with no LED (it goes dark); this is acceptable for the current version. - -## Cost - -Each beat re-runs the layer's LUT rebuild on the render thread β€” a transient one-frame cost, like a device scan, not a steady per-frame tax. The `bpm`≀60 cap bounds how often this happens. Steady-state tick/FPS between beats is unchanged. - -## Prior art - -MoonLight has no direct equivalent; the random-remap idea is common in pixel-mapper tools (e.g. shuffle/scramble transitions). The permutation-rides-the-LUT approach and the bpm-accumulator timer are projectMM's own, reusing the [CheckerboardModifier](CheckerboardModifier.md) 1:1 mapping shape and the effect bpm-timer pattern. - -## Source - -[RandomMapModifier.h](../../../../src/light/modifiers/RandomMapModifier.h) diff --git a/docs/moonmodules/light/modifiers/RegionModifier.md b/docs/moonmodules/light/modifiers/RegionModifier.md deleted file mode 100644 index 09117cb6..00000000 --- a/docs/moonmodules/light/modifiers/RegionModifier.md +++ /dev/null @@ -1,48 +0,0 @@ -# Region Modifier - -Static modifier. Carves the layer down to a sub-rectangle of the physical bounding box: the effect renders only inside the region, everything outside is dark. The region is given as **percentages of the physical extent on each axis**, so it survives a physical resize β€” a `0..50` region stays the left half whether the panel is 64 or 128 wide. Default `0..100` on every axis is the full box (an identity carve). - -Region and Multiply are independent and **compose**: a layer can occupy a region *and* be tiled/mirrored within it (Region then Multiply), since a Layer folds its whole enabled modifier chain in order (see [ModifierBase](../ModifierBase.md)). - -## Controls - -- `startX` / `startY` / `startZ` (Int16, default 0) β€” region start, as a percentage of physical width / height / depth. -- `endX` / `endY` / `endZ` (Int16, default 100) β€” region end, as a percentage of physical width / height / depth. - -`Int16`, not a 0–100 slider: the UI renders an unbounded int16 as a **βˆ’100..200 percentage slider** so the window can slide **off-screen** (negative start / >100 end). Values round-trip through `/api/state`, `/api/types`, and persistence. - -## Region math - -Per axis, **half-open** `[startPixel, endPixel)`, **un-clamped** to the box: - -- `startPixel = floor(start% / 100 Β· extent)` β€” may be negative. -- `endPixel = ceil(end% / 100 Β· extent)`, **exclusive** β€” may exceed `extent`. -- window size = `endPixel βˆ’ startPixel` (floored to β‰₯ 1 on a non-empty axis). - -Half-open makes abutting windows **tile exactly**: a `0..50` and a `50..100` layer split a 128-wide axis into pixels `0..63` and `64..127` β€” no overlap, no gap. `start` floors and `end` ceils so a small panel never rounds to an empty window. Default `0..100` on a `W`-wide axis β†’ the full width. - -### Off-screen windows (move, don't rescale) - -The window's **logical size is the full `start..end` span**, so the effect always renders at a fixed scale β€” moving `start` and `end` together slides the window without resizing it (like dragging an OS window). A physical light outside the window is dropped; window cells with no physical light under them (the off-screen part) stay dark. A window slid **entirely** off the box (e.g. `start=-100, end=0`) maps no lights β€” the layer goes dark, the way you move an effect completely out of view. Because the span is fixed, sweeping `start`/`end` translates an effect across and off the panel without distorting it. - -## Effect on the pipeline - -- **Logical box = the region size** β€” `modifyLogicalSize` shrinks the box to the carved rectangle, so the Layer's render buffer (and the status line `wΓ—hΓ—d`) shrinks to the region. The effect only ever renders the region. -- **Fold + reject** β€” `modifyLogical` folds a physical light into region-local space (subtract the start offset) and returns `false` for any physical light outside the region, so everything beyond the region stays dark. A 1:1 fold, never fans out. -- **Fast path**: the cheapest carve is *no modifier at all* β€” then `Layer::rebuildLUT` keeps its identity-memcpy / sparse fast path with zero carving cost. The default is to not add a RegionModifier; a full-region `0..100` one is correct but not the absolute cheapest, so full-coverage layers simply omit it. - -## Cross-domain wiring - -Region is a normal `ModifierBase` β€” carving is its `modifyLogicalSize` + `modifyLogical` fold, composed into the chain like any modifier. See [ModifierBase](../ModifierBase.md). - -## Tests - -[Unit tests: RegionModifier](../../../tests/unit-tests.md#regionmodifier) β€” the region math (full box, exact half, abutting-tile, small-panel rounding, β‰₯1-pixel floor, off-screen / fully-off / wider-than-box windows, degenerate axes) and the coordinate offset mapping. [Unit tests: Layer](../../../tests/unit-tests.md#layer) adds the integration case: a RegionModifier shrinks the Layer's logical box to the region and the LUT maps only region cells. - -## Prior art - -The crop / region node of any compositor (After Effects' crop, a shader scissor rect): restrict rendering to a rectangle, the rest is transparent. MoonLight has no single "region" modifier β€” its layers map through the same coordinate-transform mechanism, which is the lineage for expressing this as a modifier rather than a Layer control. - -## Source - -[RegionModifier.h](../../../../src/light/modifiers/RegionModifier.h) diff --git a/docs/moonmodules/light/modifiers/RotateModifier.md b/docs/moonmodules/light/modifiers/RotateModifier.md deleted file mode 100644 index 2de85ffa..00000000 --- a/docs/moonmodules/light/modifiers/RotateModifier.md +++ /dev/null @@ -1,19 +0,0 @@ -# RotateModifier - -A **dynamic modifier** that rotates the 2D image around its centre, turning continuously over time. The one modifier that overrides `modifyLive` (per-frame, no mapping rebuild) β€” so the rotation is smooth, and the Layer runs its live pass only because this modifier is present (a static-only chain pays nothing per frame; see [ModifierBase](../ModifierBase.md)). Also the codebase's **transform-matrix reference**. - -## Controls - -- `speed` β€” rotation speed (1–255, default 1). `loop()` advances the angle on the timer; `modifyLive` applies it on the next frame (no rebuild). - -## How it works - -`loop()` advances the angle on the `speed` timer; rotation is applied each frame in the Layer's live pass (`modifyLive`), not baked into the mapping β€” so a `speed` change is a cheap live edit, no rebuild. A source that rotates outside the box leaves that destination dark. 2D only: the z axis passes through. The integer 2Γ—2-matrix backward map is in the header. - -## Prior art - -- **MoonLight β€” M_MoonLight.h Rotate / PinWheel** β€” a per-light `modifyXYZ()` coordinate transform. Our `modifyLive` is the same per-frame hook; we carry an explicit rotation matrix. - -## Source - -[RotateModifier.h](../../../../src/light/modifiers/RotateModifier.h) diff --git a/docs/moonmodules/light/modifiers/modifiers.md b/docs/moonmodules/light/modifiers/modifiers.md new file mode 100644 index 00000000..961eb0ff --- /dev/null +++ b/docs/moonmodules/light/modifiers/modifiers.md @@ -0,0 +1,161 @@ +# Modifiers + +Every modifier, one block each: its preview, what it does, and what each control means β€” together. A modifier sits between an [effect](../effects/effects.md) and the output: it reshapes *where* pixels land (or masks them) without changing the effect's drawing. Modifiers compose β€” a [Layer](../Layer.md) folds its whole modifier stack each rebuild; a *dynamic* modifier (one that overrides `modifyLive`) also runs a per-frame pass. See [ModifierBase](../ModifierBase.md) for the static-vs-dynamic split. Each block's emoji are its `tags()` (see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Kind** is static (baked into the mapping at rebuild) or dynamic (per-frame remap). Modifiers are grouped into sections, and each block carries that modifier's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).) + +## MoonLight modifiers + + + +### Block πŸ’« Β· static + +Expands a 1D effect into concentric **square rings** (Chebyshev distance from the centre): the effect's linear position becomes the ring index, so a gradient effect draws nested squares. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [BlockModifier.h](../../../../src/light/modifiers/BlockModifier.h) + +[Tests](../../../tests/unit-tests.md#blockmodifier) + + + +### Checkerboard πŸ’« Β· static + +Checkerboard modifier preview + +Masks the layer in a checkerboard: "off" squares are dropped, "on" squares pass through unchanged. + +- `size` β€” checker square edge in lights (β‰₯1). +- `invert` β€” flip which squares pass through vs are masked. + +Origin: MoonLight Β· by WildCats08 / [@Brandon502](https://github.com/Brandon502) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [CheckerboardModifier.h](../../../../src/light/modifiers/CheckerboardModifier.h) + +[Tests](../../../tests/unit-tests.md#checkerboardmodifier) + + + +### Circle πŸ’« Β· static + +Expands a 1D effect into concentric **circular rings** (Euclidean distance from the centre): the effect's linear position becomes the radius, so a gradient effect draws nested circles. The circular counterpart to [Block](#block). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [CircleModifier.h](../../../../src/light/modifiers/CircleModifier.h) + +[Tests](../../../tests/unit-tests.md#circlemodifier) + + + +### Mirror πŸ’« Β· static + +Folds the far half of the box back onto the near half per axis, mirroring the image across the box centre (top-left quadrant reflected into the others in 2D, near octant into all eight in 3D). + +- `mirrorX` / `mirrorY` / `mirrorZ` β€” mirror across the centre on that axis (each default on; enabling an axis the layout doesn't use is a no-op). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [MirrorModifier.h](../../../../src/light/modifiers/MirrorModifier.h) + +[Tests](../../../tests/unit-tests.md#mirrormodifier) + + + +### Multiply πŸ’« Β· static + +Multiply modifier preview + +Tiles the logical image across the box `multiply` times per axis, optionally mirroring alternate tiles (a pure mirror is `multiply = 2, mirror = true`). + +- `multiplyX` / `multiplyY` / `multiplyZ` β€” tile count per axis (1–64; 1 = no tiling). +- `mirrorX` / `mirrorY` / `mirrorZ` β€” reflect alternate tiles on that axis (with a count of 2, folds the axis in half β€” the kaleidoscope mirror). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [MultiplyModifier.h](../../../../src/light/modifiers/MultiplyModifier.h) + +[Tests](../../../tests/unit-tests.md#multiplymodifier) + + + +### Pinwheel πŸ’« Β· static + +Remaps the grid into radial **petals** around the centre β€” the angle to each pixel picks its petal, with an optional swirl (angle sheared by radius), symmetry, and z-twist. Turns a linear or 2D effect into a rotating flower/spokes pattern. + +- `petals` β€” number of petals radiating from the centre. +- `swirl` β€” shear the angle by radius (βˆ’127..127; a spiral; negative reverses). +- `reverse` β€” reverse the petal order. +- `symmetry` β€” fold the petals into a factor-of-360 symmetry. +- `zTwist` β€” twist the petals along z (3D). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [PinwheelModifier.h](../../../../src/light/modifiers/PinwheelModifier.h) + +[Tests](../../../tests/unit-tests.md#pinwheelmodifier) + + + +### RippleXZ πŸ’« Β· static + +Collapses an axis to a single plane so a higher-dimensional effect ripples along the remaining axes β€” used to drive a 1Dβ†’2D/3D ripple. + +- `shrink` β€” collapse the selected axis (on = collapse). +- `towardsX` / `towardsZ` β€” which axis collapses to a single line. + +Origin: MoonLight Β· by @Troy (WLEDMM Art-Net) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [RippleXZModifier.h](../../../../src/light/modifiers/RippleXZModifier.h) + +[Tests](../../../tests/unit-tests.md#ripplexzmodifier) + + + +### Transpose πŸ’« Β· static + +Swaps a pair of box axes (and every coordinate through them), then optionally inverts each axis β€” rotate/flip the image without redrawing the effect. + +- `XY` / `XZ` / `YZ` β€” swap that pair of axes. +- `inverse X` / `inverse Y` / `inverse Z` β€” flip that axis after the swap. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [TransposeModifier.h](../../../../src/light/modifiers/TransposeModifier.h) + +[Tests](../../../tests/unit-tests.md#transposemodifier) + +## projectMM-native modifiers + + + +### RandomMap Β· dynamic + +Remaps every light to another via a true 1:1 permutation, reshuffling to a fresh permutation on a `bpm` timer β€” the arrangement scrambles each beat, the content is untouched. + +- `bpm` β€” reshuffles per minute (0–60; 6 β‰ˆ a fresh permutation every 10 s; 0 = frozen). + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [RandomMapModifier.h](../../../../src/light/modifiers/RandomMapModifier.h) + +[Tests](../../../tests/unit-tests.md#randommapmodifier) + + + +### Region Β· static + +Carves the layer to a sub-rectangle given as percentages of the physical extent (so it survives a resize); outside the region is dark. + +- `startX` / `startY` / `startZ` and `endX` / `endY` / `endZ` β€” the sub-rectangle bounds as **percentages** of each axis's physical extent (0 = start of axis, 100 = end), so the region survives a resize; values may go negative or past 100 to push the window off-screen. + +Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [RegionModifier.h](../../../../src/light/modifiers/RegionModifier.h) + +[Tests](../../../tests/unit-tests.md#regionmodifier) + + + +### Rotate Β· dynamic + +Rotates the 2D image around its centre, turning continuously over time (the codebase's transform-matrix reference). + +- `speed` β€” rotation speed (1–255; turns faster as it rises). + +Origin: MoonLight Β· by WildCats08 / [@Brandon502](https://github.com/Brandon502) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [RotateModifier.h](../../../../src/light/modifiers/RotateModifier.h) + +[Tests](../../../tests/unit-tests.md#rotatemodifier) + +## Source + +- [BlockModifier.h](../../../../src/light/modifiers/BlockModifier.h) +- [CheckerboardModifier.h](../../../../src/light/modifiers/CheckerboardModifier.h) +- [CircleModifier.h](../../../../src/light/modifiers/CircleModifier.h) +- [MirrorModifier.h](../../../../src/light/modifiers/MirrorModifier.h) +- [MultiplyModifier.h](../../../../src/light/modifiers/MultiplyModifier.h) +- [PinwheelModifier.h](../../../../src/light/modifiers/PinwheelModifier.h) +- [RandomMapModifier.h](../../../../src/light/modifiers/RandomMapModifier.h) +- [RegionModifier.h](../../../../src/light/modifiers/RegionModifier.h) +- [RippleXZModifier.h](../../../../src/light/modifiers/RippleXZModifier.h) +- [RotateModifier.h](../../../../src/light/modifiers/RotateModifier.h) +- [TransposeModifier.h](../../../../src/light/modifiers/TransposeModifier.h) diff --git a/docs/moonmodules/light/moonlive/MoonLiveEffect.md b/docs/moonmodules/light/moonlive/MoonLiveEffect.md index fdff70db..2d932383 100644 --- a/docs/moonmodules/light/moonlive/MoonLiveEffect.md +++ b/docs/moonmodules/light/moonlive/MoonLiveEffect.md @@ -14,7 +14,7 @@ The functions are **not built into the compiler** β€” `setRGB`, `fill`, `random1 ## Controls -- `source` β€” the script text (default `fill(0, 0, 255);` β€” solid blue). Editing it recompiles live: a valid script swaps in on the next tick; a failed compile frees the old code, shows the diagnostic in the module status, and renders dark until fixed (the script-editor loop, robust + no reboot). +- `source` β€” the script text (default: random pixels β€” `setRGB(random16(256), random16(256), random16(256), random16(256));`, one random light in a random colour each tick). Editing it recompiles live: a valid script swaps in on the next tick; a failed compile frees the old code, shows the diagnostic in the module status, and renders dark until fixed (the script-editor loop, robust + no reboot). - **Scripted controls** β€” a script declares a tunable variable with a range annotation, and the engine surfaces it as a real `uint8` MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick: ```c diff --git a/docs/performance.md b/docs/performance.md index ba1a956c..ed9f079a 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -4,6 +4,8 @@ projectMM's per-step **performance contracts** live in the scenario JSONs β€” ea This document holds what scenarios can't carry: structural sizes (`sizeof`), build-variant deltas, and the WiFi/Ethernet physics that explain *why* a contract comes out where it does. +**Render-loop model.** The Layer's buffer **persists** frame-to-frame β€” `Layer::loop()` does not clear it (the FastLED/WLED/MoonLight convention; see [architecture.md Β§ Buffer persistence](architecture.md#buffer-persistence--the-layer-does-not-clear-each-frame)). This removed the per-frame full-buffer `memset` that a clear-every-frame model pays, and replaced N per-effect `draw::fade` passes with a single **collected fade** (`Layer::fadeToBlackBy` MINs the requested amounts and applies one buffer pass per frame) β€” so a layer with several fading effects now pays one fade pass, not N. Net hot-path effect on the tick numbers below is small (the clear/fade are one linear pass over the buffer, dwarfed by per-light effect compute and the output driver), but the *model* is what the scenario `observed` blocks were re-measured against on this cycle. + --- ## Desktop (64-bit) diff --git a/docs/testing.md b/docs/testing.md index 3cb322a4..cae3c299 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -389,7 +389,7 @@ uv run scripts/scenario/run_live_scenario.py --compare-baseline # check MoonDeck's Live tab wraps the same workflow: the Network bar at the top selects the LAN, Discover/Refresh populates the device list, the Live Scenarios card runs the selected scenario against every checked device. -![MoonDeck Live tab](assets/screenshots/moondeck_live.png) +![MoonDeck Live tab](assets/ui/moondeck_live.png) All scenarios use relative FPS bounds (`min_pct`) so they pass on any device β€” desktop at 10K FPS or ESP32 at 17 FPS. Settle time is 3 seconds to let the pipeline stabilise after rebuilds. diff --git a/docs/tests/scenario-tests.md b/docs/tests/scenario-tests.md index d98db6fb..8530d721 100644 --- a/docs/tests/scenario-tests.md +++ b/docs/tests/scenario-tests.md @@ -23,9 +23,9 @@ Baseline: the render pipeline runs with no audio module present. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 32,258-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 15,625-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-12 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-12 β†’ 2026-07-01 #### `measure-audio-added` (measure) πŸ“ @@ -41,9 +41,9 @@ Pipeline still renders with the (idle, unconfigured) mic added. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 34,483-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 15,873-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-12 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-12 β†’ 2026-07-01 #### `measure-pins-configured` (measure) πŸ“ @@ -61,9 +61,9 @@ All three mic pins set via the sequential install-fan-out order: pipeline still | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 32,258-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 15,873-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-13 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-13 β†’ 2026-07-01 #### `measure-consumer-live` (measure) πŸ“ @@ -79,9 +79,9 @@ Pipeline renders with the producer + consumer both wired. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 30,303-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 13,889-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-12 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-12 β†’ 2026-07-01 #### `measure-after-mic-removed` (measure) πŸ“ @@ -97,9 +97,9 @@ Mic gone, consumer remains: pipeline keeps rendering on silent audio (buffer non | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 31,250-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 13,514-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-12 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-12 β†’ 2026-07-01 #### `measure-back-to-baseline` (measure) πŸ“ @@ -115,9 +115,9 @@ Both audio modules gone: back to the pipeline-only baseline, still rendering. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 40,000-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 15,873-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-12 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-12 β†’ 2026-07-01 ## DevicesModule @@ -259,10 +259,10 @@ Add NetworkSendDriver and run the bounded FPS measurement (expected to stay at > | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β‰₯ 20,000 / 7,576-β€” | unlimited / unlimited | β€” / unlimited | +| `pc-macos` | β‰₯ 20,000 / 4,115-β€” | unlimited / unlimited | β€” / unlimited | | `pc-windows` | β€” / 7,874-8,475 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β†’ 2026-06-05 +- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β†’ 2026-07-01 - `pc-windows`: observed 2026-06-07 ### scenario_Layer_memory_1to1 @@ -312,9 +312,9 @@ Add a third modifier (Checkerboard mask) on top of the chain β€” a 3-deep fold. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 142,857-200,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 18,182-200,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-26 +- `pc-macos`: observed 2026-06-26 β†’ 2026-07-01 #### `remove-middle` (remove_module) πŸ“ @@ -324,9 +324,9 @@ Remove the middle modifier (Multiply) β€” the chain re-folds with Region then Ch | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 45,455-55,556 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 6,536-55,556 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-26 +- `pc-macos`: observed 2026-06-26 β†’ 2026-07-01 #### `add-live-rotate` (add_module) πŸ“ @@ -336,9 +336,9 @@ Add a DYNAMIC Rotate on top of the static chain β€” its modifyLive runs the per- | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 25,641-28,571 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 5,128-31,250 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-26 +- `pc-macos`: observed 2026-06-26 β†’ 2026-07-01 ### scenario_modifier_swap @@ -368,13 +368,13 @@ Multiply modifier active β€” pipeline live, LUT folds the grid. | `esp32-eth` | β€” / 1,580-7,752 | β€” / 172KB-225KB | β€” / 76KB-108KB | | `esp32p4-eth` | β€” / 5,587-6,061 | β€” / 33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 1,773-2,571 | β€” / 8350KB | β€” / 92KB | -| `pc-macos` | β€” / 50,000-166,667 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 25,000-166,667 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-25 - `esp32-eth`: observed 2026-06-07 β†’ 2026-06-08 - `esp32p4-eth`: observed 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-25 -- `pc-macos`: observed 2026-06-07 β†’ 2026-06-21 +- `pc-macos`: observed 2026-06-07 β†’ 2026-06-30 #### `checkerboard` (measure) πŸ“ @@ -391,13 +391,13 @@ Checkerboard modifier active β€” masks half the lights; pipeline stays live (dri | `esp32-eth` | β€” / 769-990 | β€” / 170KB-225KB | β€” / 76KB-108KB | | `esp32p4-eth` | β€” / 2,747-2,762 | β€” / 33242KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 924-943 | β€” / 8349KB | β€” / 92KB | -| `pc-macos` | β€” / 15,873-58,824 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 4,184-58,824 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-25 - `esp32-eth`: observed 2026-06-07 β†’ 2026-06-08 - `esp32p4-eth`: observed 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-25 -- `pc-macos`: observed 2026-06-07 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-07 β†’ 2026-07-01 #### `multiply-2` (measure) πŸ“ @@ -414,23 +414,23 @@ Back to Multiply β€” replace round-trips cleanly, pipeline live again. | `esp32-eth` | β€” / 1,587-2,278 | β€” / 169KB-225KB | β€” / 76KB-108KB | | `esp32p4-eth` | β€” / 6,329-6,410 | β€” / 33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 2,146-2,604 | β€” / 8349KB-8350KB | β€” / 92KB | -| `pc-macos` | β€” / 45,455-166,667 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 10,101-166,667 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-25 - `esp32-eth`: observed 2026-06-07 β†’ 2026-06-08 - `esp32p4-eth`: observed 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-25 -- `pc-macos`: observed 2026-06-07 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-07 β†’ 2026-07-01 ### scenario_perf_full -`test/scenarios/light/scenario_perf_full.json` β€” Comprehensive incremental performance check (the SLOW, on-device companion to scenario_perf_light). Mutate mode + canvas-preparing: clear_children whatever the device already had (pre-wired apparatus like PreviewDriver/Board survives β€” clear_children only drops user-editable children), rebuild a known minimal tree, then add one subsystem at a time β€” audio, device discovery, a modifier, then EVERY output driver this board has (each optional + capped to 64 output LEDs so its per-frame cost is comparable, not its transmit-all-16K time), then a network driver β€” measuring the tick/heap delta after each so each subsystem's cost is isolated. Then sweep the grid 16Β²β†’32Β²β†’64Β²β†’128Β² (16K) for both a LIGHT effect (Checkerboard) and a HEAVY one (Noise) to bracket the compute range across sizes. LED drivers are platform-gated (RMT on classic/S3, LCD on S3, Parlio on P4; none on desktop) so each driver step is optional:true and skipped where absent β€” the all-drivers comparison is assembled across boards (S3 gives RMT vs LCD, P4 gives RMT vs Parlio). Subsumes the old scenario_Layer_buildup (incremental module cost), scenario_GridLayout_grid_sizes (grid sweep), and scenario_AllEffects_grid_sizes (per-effect size sweep, here reduced to a light/heavy bracket). Runs minutes on a device; not a per-commit gate. +`test/scenarios/light/scenario_perf_full.json` β€” Comprehensive incremental performance check (the SLOW, on-device companion to scenario_perf_light). Mutate mode + canvas-preparing: clear_children whatever the device already had (pre-wired apparatus like PreviewDriver/Board survives β€” clear_children only drops user-editable children), rebuild a known minimal tree, then add one subsystem at a time β€” audio, device discovery, a modifier, then EVERY output driver this board has (each optional + capped to 64 output LEDs so its per-frame cost is comparable, not its transmit-all-16K time), then a network driver β€” measuring the tick/heap delta after each so each subsystem's cost is isolated. Then sweep the grid 16Β²β†’32Β²β†’64Β²β†’128Β² (16K) for both a LIGHT effect (Spiral) and a HEAVY one (Noise) to bracket the compute range across sizes. LED drivers are platform-gated (RMT on classic/S3, LCD on S3, Parlio on P4; none on desktop) so each driver step is optional:true and skipped where absent β€” the all-drivers comparison is assembled across boards (S3 gives RMT vs LCD, P4 gives RMT vs Parlio). Subsumes the old scenario_Layer_buildup (incremental module cost), scenario_GridLayout_grid_sizes (grid sweep), and scenario_AllEffects_grid_sizes (per-effect size sweep, here reduced to a light/heavy bracket). Runs minutes on a device; not a per-commit gate. -**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, NetworkSendDriver, RmtLedDriver, LcdLedDriver, ParlioLedDriver, MultiplyModifier, CheckerboardEffect, NoiseEffect +**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, NetworkSendDriver, RmtLedDriver, LcdLedDriver, ParlioLedDriver, MultiplyModifier, SpiralEffect, NoiseEffect #### `measure-minimal` (measure) πŸ“ -Bare minimum at 16Β²: Grid + Layer + Checkerboard, no output driver, audio/discovery still on as the device ships. The floor for the subsystem-cost diffs below. +Bare minimum at 16Β²: Grid + Layer + Spiral, no output driver, audio/discovery still on as the device ships. The floor for the subsystem-cost diffs below. **Setup** (preceding non-measured steps): - `clear-layers` (clear_children) β€” Start clean: drop whatever effects/modifiers/layouts/drivers the device had (pre-wired Preview survives). @@ -447,12 +447,12 @@ Bare minimum at 16Β²: Grid + Layer + Checkerboard, no output driver, audio/disco | `esp32` | β€” / 7,692-8,929 | β€” / 134KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 14,925-17,544 | β€” / 33226KB-33245KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 5,376-9,009 | β€” / 8340KB-8352KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 83,333-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-no-audio` (measure) πŸ“ @@ -466,12 +466,12 @@ Bare minimum at 16Β²: Grid + Layer + Checkerboard, no output driver, audio/disco | `esp32` | β€” / 8,621-9,901 | β€” / 134KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 18,182-18,868 | β€” / 33228KB-33245KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 8,065-9,901 | β€” / 8338KB-8352KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 125,000-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-quiet` (measure) πŸ“ @@ -487,12 +487,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 7,246-9,901 | β€” / 131KB-146KB | β€” / 108KB | | `esp32p4-eth` | β€” / 17,544-18,519 | β€” / 33226KB-33245KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 7,752-9,901 | β€” / 8337KB-8352KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 142,857-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-modifier` (measure) πŸ“ @@ -506,12 +506,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 2,786-3,610 | β€” / 130KB-145KB | β€” / 108KB | | `esp32p4-eth` | β€” / 8,772-10,638 | β€” / 33224KB-33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 3,413-4,237 | β€” / 8336KB-8350KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 333,333-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-preview` (measure) πŸ“ @@ -526,12 +526,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 8,696-9,524 | β€” / 123KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 15,873-18,182 | β€” / 33228KB-33245KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 8,065-9,434 | β€” / 8335KB-8352KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 200,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 83,333-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-network` (measure) πŸ“ @@ -545,12 +545,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 6,098-7,194 | β€” / 131KB-145KB | β€” / 108KB | | `esp32p4-eth` | β€” / 14,493-17,544 | β€” / 33226KB-33244KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 6,452-8,065 | β€” / 8334KB-8351KB | β€” / 84KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 142,857-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-26 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-rmt` (measure) πŸ“ @@ -565,12 +565,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 6,579-9,174 | β€” / 106KB-122KB | β€” / 84KB-108KB | | `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33200KB-33221KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 7,194-9,346 | β€” / 8307KB-8328KB | β€” / 84KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 32,258-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-26 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-lcd` (measure) πŸ“ @@ -585,12 +585,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 8,403-9,901 | β€” / 126KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33225KB-33245KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 7,042-9,259 | β€” / 8333KB-8352KB | β€” / 88KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 76,923-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-parlio` (measure) πŸ“ @@ -605,19 +605,19 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 8,475-9,901 | β€” / 135KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33225KB-33245KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 7,692-9,434 | β€” / 8338KB-8352KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 90,909-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-light-16` (measure) πŸ“ **Setup** (preceding non-measured steps): - `remove-parlio-driver` (remove_module) - `add-preview-for-sweep` (add_module) β€” Re-add PreviewDriver as the output for the grid sweep (the per-driver adds above each removed their driver; Preview is the cheap, every-board output for a pure-render size curve). -- `light-16-w` (set_control) β€” Grid sweep, LIGHT effect (Checkerboard is already FX). +- `light-16-w` (set_control) β€” Grid sweep, LIGHT effect (Spiral is already FX). - `light-16-h` (set_control) **Performance** (contract / observed) β€” tick stored, FPS shown: @@ -627,12 +627,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 6,711-9,804 | β€” / 134KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 15,385-18,868 | β€” / 33226KB-33245KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 8,403-9,901 | β€” / 8336KB-8352KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 100,000-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-light-32` (measure) πŸ“ @@ -647,12 +647,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 2,801-3,367 | β€” / 134KB-144KB | β€” / 108KB | | `esp32p4-eth` | β€” / 7,246-7,576 | β€” / 33225KB-33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 3,049-3,597 | β€” / 8331KB-8350KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 333,333-1,000,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 35,714-1,000,000 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-light-64` (measure) πŸ“ @@ -667,12 +667,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 870-928 | β€” / 125KB-135KB | β€” / 108KB | | `esp32p4-eth` | β€” / 2,008-2,232 | β€” / 33218KB-33234KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 894-1,011 | β€” / 8312KB-8341KB | β€” / 88KB-112KB | -| `pc-macos` | β€” / 12,658-250,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 9,091-333,333 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-light-128` (measure) πŸ“ @@ -687,12 +687,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 224-238 | β€” / 89KB-99KB | β€” / 62KB | | `esp32p4-eth` | β€” / 515-573 | β€” / 33182KB-33198KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 114-134 | β€” / 8291KB-8305KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 5,348-62,500 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 2,165-62,500 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-heavy-16` (measure) πŸ“ @@ -708,12 +708,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 990-1,224 | β€” / 136KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 2,865-3,367 | β€” / 33229KB-33245KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 1,100-1,361 | β€” / 8342KB-8352KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 62,500-333,333 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 47,619-333,333 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-26 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-06-27 #### `measure-heavy-32` (measure) πŸ“ @@ -728,12 +728,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 306-314 | β€” / 134KB-144KB | β€” / 108KB | | `esp32p4-eth` | β€” / 799-898 | β€” / 33227KB-33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 290-356 | β€” / 8339KB-8350KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 15,152-71,429 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 11,765-71,429 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-06-27 #### `measure-heavy-64` (measure) πŸ“ @@ -748,12 +748,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 73.8-79.4 | β€” / 125KB-135KB | β€” / 108KB | | `esp32p4-eth` | β€” / 196-229 | β€” / 33218KB-33234KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 85.2-90.3 | β€” / 8330KB-8341KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 2,924-16,129 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 2,119-16,129 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-21 +- `pc-macos`: observed 2026-06-17 β†’ 2026-06-27 #### `measure-heavy-128` (measure) πŸ“ @@ -768,12 +768,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 16.0-19.0 | β€” / 89KB-99KB | β€” / 62KB | | `esp32p4-eth` | β€” / 53.7-57.4 | β€” / 33182KB-33198KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 19.2-20.8 | β€” / 8293KB-8305KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 1,094-3,247 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 584-3,676 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-mod-16` (measure) πŸ“ @@ -789,12 +789,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 2,020-2,222 | β€” / 135KB-145KB | β€” / 108KB | | `esp32p4-eth` | β€” / 5,263-6,494 | β€” / 33224KB-33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 2,193-2,618 | β€” / 8340KB-8350KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 250,000-1,000,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 200,000-1,000,000 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-mod-32` (measure) πŸ“ @@ -829,12 +829,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 144-149 | β€” / 111KB-122KB | β€” / 96KB-100KB | | `esp32p4-eth` | β€” / 438-486 | β€” / 33194KB-33210KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 119-162 | β€” / 8307KB-8317KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 23,256-71,429 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 9,434-71,429 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-26 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-mod-128` (measure) πŸ“ @@ -849,22 +849,22 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | `esp32` | β€” / 29.8-35.1 | β€” / 36KB-47KB | β€” / 24KB-26KB | | `esp32p4-eth` | β€” / 86.3-102 | β€” / 33089KB-33105KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 16.8-35.6 | β€” / 8202KB-8212KB | β€” / 92KB-112KB | -| `pc-macos` | β€” / 5,128-16,129 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 3,378-16,393 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 ### scenario_perf_light -`test/scenarios/light/scenario_perf_light.json` β€” Fast incremental performance check: start from the bare minimum render pipeline and add one thing at a time, measuring the tick/heap delta each step, so a regression shows up as a per-step jump. The LIGHT companion to scenario_perf_full β€” it stays small (≀64Β²) and driver-free so it runs in seconds. Mutate mode + canvas-preparing: the steps clear_children whatever Layouts/Layers/Drivers the device already had (the pre-wired apparatus like PreviewDriver/Board survives β€” clear_children only drops user-editable children) and rebuild a known tree, so it runs from any starting state and always measures the same minimal pipeline. Order: (1) minimal = Grid(16Β²)+Layer+a LIGHT effect (Checkerboard, the cheapest), no modifier/driver/audio/discovery; (2) +MultiplyModifier (adds the mapping LUT β€” the heavy memory path); (3) +PreviewDriver; (4) swap to a HEAVY effect (Noise) to bracket the compute range; (5) grid 16Β²β†’32Β²β†’64Β² to show the size scaling. Full 128Β²/16K sweep, real LED/network drivers, audio+discovery cost: see scenario_perf_full. +`test/scenarios/light/scenario_perf_light.json` β€” Fast incremental performance check: start from the bare minimum render pipeline and add one thing at a time, measuring the tick/heap delta each step, so a regression shows up as a per-step jump. The LIGHT companion to scenario_perf_full β€” it stays small (≀64Β²) and driver-free so it runs in seconds. Mutate mode + canvas-preparing: the steps clear_children whatever Layouts/Layers/Drivers the device already had (the pre-wired apparatus like PreviewDriver/Board survives β€” clear_children only drops user-editable children) and rebuild a known tree, so it runs from any starting state and always measures the same minimal pipeline. Order: (1) minimal = Grid(16Β²)+Layer+a LIGHT effect (Spiral, a light effect), no modifier/driver/audio/discovery; (2) +MultiplyModifier (adds the mapping LUT β€” the heavy memory path); (3) +PreviewDriver; (4) swap to a HEAVY effect (Noise) to bracket the compute range; (5) grid 16Β²β†’32Β²β†’64Β² to show the size scaling. Full 128Β²/16K sweep, real LED/network drivers, audio+discovery cost: see scenario_perf_full. -**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, CheckerboardEffect, NoiseEffect, MultiplyModifier +**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, SpiralEffect, NoiseEffect, MultiplyModifier #### `measure-minimal` (measure) πŸ“ -Bare minimum: Grid(16Β²) + Layer + Checkerboard (light effect). No modifier, no driver. The render floor everything else is measured against. +Bare minimum: Grid(16Β²) + Layer + Spiral (light effect). No modifier, no driver. The render floor everything else is measured against. **Setup** (preceding non-measured steps): - `disable-audio` (set_control) β€” Quiet I2S sampling so it can't pollute the tick (optional β€” device only). @@ -883,12 +883,12 @@ Bare minimum: Grid(16Β²) + Layer + Checkerboard (light effect). No modifier, no | `esp32` | β€” / 6,173-8,850 | β€” / 125KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 13,699-18,519 | β€” / 33228KB-33246KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 5,814-8,850 | β€” / 8316KB-8347KB | β€” / 80KB-104KB | -| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 125,000-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-with-modifier` (measure) πŸ“ @@ -904,12 +904,12 @@ Cost of the modifier + LUT over the minimal pipeline. Heap delta vs measure-mini | `esp32` | β€” / 3,077-9,709 | β€” / 131KB-147KB | β€” / 108KB | | `esp32p4-eth` | β€” / 8,621-10,309 | β€” / 33226KB-33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 3,195-4,032 | β€” / 8330KB-8345KB | β€” / 92KB-100KB | -| `pc-macos` | β€” / β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 500,000-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-with-preview` (measure) πŸ“ @@ -922,12 +922,12 @@ PreviewDriver is the pre-wired apparatus β€” it survives clear_children and is a | `esp32` | β€” / 3,067-9,804 | β€” / 132KB-146KB | β€” / 108KB | | `esp32p4-eth` | β€” / 10,417-10,753 | β€” / 33226KB-33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 3,802-4,274 | β€” / 8330KB-8345KB | β€” / 84KB-100KB | -| `pc-macos` | β€” / β€” | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 333,333-β€” | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-heavy-16` (measure) πŸ“ @@ -941,12 +941,12 @@ PreviewDriver is the pre-wired apparatus β€” it survives clear_children and is a | `esp32` | β€” / 1,142-3,268 | β€” / 131KB-146KB | β€” / 108KB | | `esp32p4-eth` | β€” / 5,556-6,494 | β€” / 33224KB-33243KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 2,299-2,506 | β€” / 8332KB-8342KB | β€” / 88KB-100KB | -| `pc-macos` | β€” / 333,333-1,000,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 200,000-1,000,000 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-heavy-32` (measure) πŸ“ @@ -961,12 +961,12 @@ PreviewDriver is the pre-wired apparatus β€” it survives clear_children and is a | `esp32` | β€” / 265-826 | β€” / 130KB-144KB | β€” / 108KB | | `esp32p4-eth` | β€” / 1,603-1,880 | β€” / 33221KB-33237KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 562-715 | β€” / 8328KB-8333KB | β€” / 84KB-104KB | -| `pc-macos` | β€” / 90,909-333,333 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 26,316-333,333 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 #### `measure-heavy-64` (measure) πŸ“ @@ -981,20 +981,20 @@ PreviewDriver is the pre-wired apparatus β€” it survives clear_children and is a | `esp32` | β€” / 77.1-227 | β€” / 111KB-135KB | β€” / 88KB-108KB | | `esp32p4-eth` | β€” / 411-491 | β€” / 33195KB-33210KB | β€” / 376KB | | `esp32s3-n16r8` | β€” / 129-162 | β€” / 8302KB-8317KB | β€” / 92KB-108KB | -| `pc-macos` | β€” / 20,000-71,429 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 11,111-71,429 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-17 β†’ 2026-06-25 - `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 - `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-26 +- `pc-macos`: observed 2026-06-17 β†’ 2026-07-01 ## Layers ### scenario_Layers_composition -`test/scenarios/light/scenario_Layers_composition.json` β€” Multi-layer composition end-to-end: Layoutsβ†’Grid, TWO Layers under one Layers container (bottom Checkerboard, top Rainbow), Driversβ†’NetworkSendDriver. Proves the Drivers composite loop builds, allocates its output buffer, blends both enabled layers and feeds the result to the driver without crashing, and gates the bounded FPS so the N-pass composite cost is tracked. The exact alpha/additive blend math and the disable-drops-to-single-layer path are pinned by the unit tests (unit_BlendMap, unit_Layers_container); construct-mode set_control can't apply controls (built post-scheduler), so this scenario uses each Layer's default blend (alpha, full opacity) and asserts wired liveness + tick, not per-byte blend output. +`test/scenarios/light/scenario_Layers_composition.json` β€” Multi-layer composition end-to-end: Layoutsβ†’Grid, TWO Layers under one Layers container (bottom Spiral, top Rainbow), Driversβ†’NetworkSendDriver. Proves the Drivers composite loop builds, allocates its output buffer, blends both enabled layers and feeds the result to the driver without crashing, and gates the bounded FPS so the N-pass composite cost is tracked. The exact alpha/additive blend math and the disable-drops-to-single-layer path are pinned by the unit tests (unit_BlendMap, unit_Layers_container); construct-mode set_control can't apply controls (built post-scheduler), so this scenario uses each Layer's default blend (alpha, full opacity) and asserts wired liveness + tick, not per-byte blend output. -**Mode**: `construct` Β· **Also touches**: Layer, GridLayout, RainbowEffect, CheckerboardEffect, Drivers, NetworkSendDriver +**Mode**: `construct` Β· **Also touches**: Layer, GridLayout, RainbowEffect, SpiralEffect, Drivers, NetworkSendDriver #### `add-artnet` (add_module) πŸ“ @@ -1005,9 +1005,9 @@ Add NetworkSendDriver and run the bounded FPS measurement over the two-layer com - `add-grid` (add_module) β€” 128x128 GridLayout under Layouts (above host clock resolution so the composite tick is measurable). - `add-layers-group` (add_module) β€” Top-level Layers container β€” the multi-layer composition host. - `add-bottom-layer` (add_module) β€” Bottom Layer (composited first β€” clears + overwrites the output buffer). RGB. -- `add-bottom-effect` (add_module) β€” A Checkerboard base as the bottom layer's effect. +- `add-bottom-effect` (add_module) β€” A Spiral base as the bottom layer's effect. - `add-top-layer` (add_module) β€” Top Layer (composited second β€” blends onto the bottom with its default blend). RGB. -- `add-top-effect` (add_module) β€” Rainbow as the top layer's effect β€” composited over the Checkerboard base. +- `add-top-effect` (add_module) β€” Rainbow as the top layer's effect β€” composited over the Spiral base. - `add-driver-group` (add_module) β€” Top-level Drivers container wired to the Layers container (composites all enabled layers into its output buffer). **Bounds**: @@ -1018,9 +1018,9 @@ Add NetworkSendDriver and run the bounded FPS measurement over the two-layer com | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 6,135-18,519 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 1,149-19,231 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-25 +- `pc-macos`: observed 2026-06-25 β†’ 2026-07-01 ## Layouts @@ -1042,11 +1042,11 @@ Baseline: a single 64x64 grid layout drives the pipeline. | Board | FPS | heap | block | |---|---|---|---| | `esp32-eth` | β€” / 41,667 | β€” / 224KB | β€” / 108KB | -| `pc-macos` | β€” / 25,000-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 15,873-125,000 | β€” / unlimited | β€” / unlimited | | `pc-windows` | β€” / 32,258-37,037 | β€” / unlimited | β€” / unlimited | - `esp32-eth`: observed 2026-06-08 -- `pc-macos`: observed 2026-06-05 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-05 β†’ 2026-07-01 - `pc-windows`: observed 2026-06-07 #### `measure-two-layouts` (measure) πŸ“ @@ -1064,11 +1064,11 @@ Pipeline still renders with two layouts wired (buffer non-null, fps measurable). | Board | FPS | heap | block | |---|---|---|---| | `esp32-eth` | β€” / 37,037 | β€” / 223KB | β€” / 108KB | -| `pc-macos` | β€” / 11,905-111,111 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 3,953-111,111 | β€” / unlimited | β€” / unlimited | | `pc-windows` | β€” / 16,393-23,810 | β€” / unlimited | β€” / unlimited | - `esp32-eth`: observed 2026-06-08 -- `pc-macos`: observed 2026-06-05 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-05 β†’ 2026-06-27 - `pc-windows`: observed 2026-06-07 #### `measure-after-replace` (measure) πŸ“ @@ -1086,11 +1086,11 @@ Pipeline still renders after replacing a grid with a sphere (different layout ty | Board | FPS | heap | block | |---|---|---|---| | `esp32-eth` | β€” / 38,462 | β€” / 223KB | β€” / 108KB | -| `pc-macos` | β€” / 3,690-100,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 1,957-100,000 | β€” / unlimited | β€” / unlimited | | `pc-windows` | β€” / 5,848-9,009 | β€” / unlimited | β€” / unlimited | - `esp32-eth`: observed 2026-06-08 -- `pc-macos`: observed 2026-06-05 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-05 β†’ 2026-06-27 - `pc-windows`: observed 2026-06-07 #### `measure-after-remove` (measure) πŸ“ @@ -1108,15 +1108,120 @@ Pipeline renders with the single remaining grid, same as the baseline. | Board | FPS | heap | block | |---|---|---|---| | `esp32-eth` | β€” / 41,667 | β€” / 224KB | β€” / 108KB | -| `pc-macos` | β€” / 16,949-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 6,623-125,000 | β€” / unlimited | β€” / unlimited | | `pc-windows` | β€” / 33,333-38,462 | β€” / unlimited | β€” / unlimited | - `esp32-eth`: observed 2026-06-08 -- `pc-macos`: observed 2026-06-05 +- `pc-macos`: observed 2026-06-05 β†’ 2026-07-01 - `pc-windows`: observed 2026-06-07 ## MoonLiveEffect +### scenario_MoonLiveEffect_controls + +`test/scenarios/light/scenario_MoonLiveEffect_controls.json` β€” Exercise MoonLive Stage-1 CONTROLS end-to-end as a wired module. A script declares a control (`uint8_t speed = 7; // @control 0..15`) and uses it (`setRGB(speed, ...)`); the engine surfaces the control, the binding creates a real uint8 MoonModule control bound to the live control-values arena slot. The scenario: add the effect with a control script (the control appears, renders), change the CONTROL value live (a slider move β€” must NOT recompile; the arena byte updates and the next tick reads it), edit the SOURCE to add a second control (recompile re-derives the set, existing slider value preserved by the stable-address grow-only arena), edit the source to remove a control (the orphaned value drops), push a broken script (compile fails, renders dark, status shows the diagnostic, no crash), recover, and remove + re-add (resource teardown + re-acquire). A crash in the LoadCtrl codegen, a dangling arena pointer across a recompile, or a value change that wrongly triggers a recompile all show up as a failed measure or a tick spike. The codegen + live-read contract is pinned by unit_moonlive_ir / unit_moonlive_compiler; this is the wired-module gate. + +**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Layers, Layer, Drivers, NetworkSendDriver + +#### `add-control-script` (add_module) πŸ“ + +Add a MoonLiveEffect whose source declares a `speed` control and uses it. The control appears bound to the arena slot (seeded to its default 7); the wired effect renders one pixel. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 250,000-β€” | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-28 β†’ 2026-07-01 + +#### `set-source-with-control` (set_control) πŸ“ + +Edit the source to the control script. A source edit recompiles (controlChangeTriggersBuildState gates on `source`); the engine derives the `speed` control and the binding surfaces it. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-28 β†’ 2026-07-01 + +#### `change-control-live` (set_control) πŸ“ + +Change the `speed` control value (a slider move). This must NOT recompile β€” controlChangeTriggersBuildState returns false for a scripted control; the arena byte updates and the next render tick reads it. Tick stays cheap (a recompile would spike it). + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-28 β†’ 2026-07-01 + +#### `edit-source-two-controls` (set_control) πŸ“ + +Edit the source to add a second control. The recompile re-derives the control set; the stable-address grow-only arena keeps `speed`'s live value while seeding the new slot. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-28 β†’ 2026-07-01 + +#### `edit-source-shrink-to-one-control` (set_control) πŸ“ + +Edit the source back to a single control. The control set shrinks 2 -> 1: `speed` stays bound (its live value kept), the removed `hue`'s value is dropped, and the value change path is exercised without an unexpected recompile crash. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-28 β†’ 2026-07-01 + +#### `edit-source-broken` (set_control) πŸ“ + +Push a broken script. Compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status β€” no crash. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 125,000-β€” | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-28 β†’ 2026-07-01 + +#### `edit-source-recover` (set_control) πŸ“ + +Recover with a valid control script β€” the effect compiles and renders again. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-28 β†’ 2026-07-01 + +#### `re-add-control-effect` (add_module) πŸ“ + +Re-add a fresh effect after the remove β€” exec memory + control arena re-acquired clean (it renders its default fill on add). Control re-acquisition itself is proven by the add-control-script step at the top: a freshly-added effect compiling a control source surfaces + seeds its control; construct-mode set_control can't apply a dynamically-added scripted control as the final asserted render, so the gate here is the bare re-add's liveness. + +**Setup** (preceding non-measured steps): +- `remove-control-effect` (remove_module) β€” Remove the effect β€” the engine releases its exec block AND its control arena (teardown). + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 500,000-β€” | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-28 β†’ 2026-07-01 + ### scenario_MoonLiveEffect_livescript `test/scenarios/light/scenario_MoonLiveEffect_livescript.json` β€” Exercise a scripted MoonLiveEffect as a wired MoonModule end-to-end β€” the integration layer the unit tests can't reach. The effect compiles its `source` text to native code on-device and renders it into the Layer buffer each tick. Prepares its own canvas: Layout(Grid 16x16) + Layer + MoonLiveEffect, measures the default compile, then edits `source` live (a new fill colour recompiles and keeps rendering), pushes a BROKEN script (compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status, no crash), recovers with a valid script, and finally removes + re-adds the effect (add/remove robustness in any order). A crash in the JIT/emit path, a failed recompile that wedges the tick, or a buffer overrun on an odd grid all show up as a failed measure. The compiler + emit golden bytes are pinned by unit_moonlive_compiler / unit_moonlive_fill; this is the live wired-module gate. @@ -1125,7 +1230,7 @@ Pipeline renders with the single remaining grid, same as the baseline. #### `add-moonlive` (add_module) πŸ“ -Add a MoonLiveEffect to the Layer. Its default source `fill(0, 0, 255);` compiles on-device to native code; measure that the wired effect renders. +Add a MoonLiveEffect to the Layer. Its default source (random pixels) compiles on-device to native code; measure that the wired effect renders. **Performance** (contract / observed) β€” tick stored, FPS shown: @@ -1303,14 +1408,14 @@ Disable mirrorX. Modifier control triggers a pipeline rebuild β€” measures the r | `esp32-eth` | β€” / 10.4 | β€” / 132KB | β€” / 48KB-50KB | | `esp32-eth-wifi` | β‰₯ 10.0 / 12.0 | β‰₯ 103KB / 94KB | β€” / 48KB | | `esp32p4-eth` | β€” / 5,952-6,135 | β€” / 33238KB | β€” / 376KB | -| `pc-macos` | β‰₯ 5,000 / 3,636-9,259 | unlimited / unlimited | β€” / unlimited | +| `pc-macos` | β‰₯ 5,000 / 2,857-9,259 | unlimited / unlimited | β€” / unlimited | | `pc-windows` | β€” / 2,024-2,392 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 - `esp32p4-eth`: observed 2026-06-17 -- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β†’ 2026-06-14 +- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β†’ 2026-07-01 - `pc-windows`: observed 2026-06-07 #### `disable-mirrorY` (set_control) πŸ“ @@ -1350,14 +1455,14 @@ Re-enable mirrorY and measure β€” the heavy LUT path must recover (FPS within 50 | `esp32-eth` | β€” / 10.5-10.6 | β€” / 132KB | β€” / 48KB-50KB | | `esp32-eth-wifi` | β‰₯ 10.0 / 12.1 | β‰₯ 103KB / 94KB | β€” / 48KB | | `esp32p4-eth` | β€” / 5,319-6,098 | β€” / 33238KB | β€” / 376KB | -| `pc-macos` | β‰₯ 8,333 / 3,390-10,417 | unlimited / unlimited | β€” / unlimited | +| `pc-macos` | β‰₯ 8,333 / 3,356-10,417 | unlimited / unlimited | β€” / unlimited | | `pc-windows` | β€” / 4,065-4,854 | β€” / unlimited | β€” / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 - `esp32p4-eth`: observed 2026-06-17 -- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β†’ 2026-06-16 +- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β†’ 2026-06-30 - `pc-windows`: observed 2026-06-07 ## MultiplyModifier @@ -1571,9 +1676,9 @@ Baseline: the pipeline renders with one driver (Preview) wired. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 29,412-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 13,699-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-13 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-13 β†’ 2026-06-30 #### `measure-two-drivers` (measure) πŸ“ @@ -1589,9 +1694,9 @@ Pipeline renders with both drivers wired. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 17,857-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 10,204-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-13 β†’ 2026-06-22 +- `pc-macos`: observed 2026-06-13 β†’ 2026-07-01 #### `measure-three-drivers` (measure) πŸ“ @@ -1607,9 +1712,9 @@ Pipeline renders with three drivers wired. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 38,462-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 13,333-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-13 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-13 β†’ 2026-07-01 #### `measure-after-first-remove` (measure) πŸ“ @@ -1625,9 +1730,9 @@ One ArtNet gone, Preview + ArtNet2 remain: pipeline keeps rendering (buffer non- | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 30,303-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 15,152-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-13 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-13 β†’ 2026-07-01 #### `measure-back-to-one-driver` (measure) πŸ“ @@ -1643,6 +1748,6 @@ Both added drivers gone, back to the single Preview baseline, still rendering | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 38,462-125,000 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 15,873-125,000 | β€” / unlimited | β€” / unlimited | -- `pc-macos`: observed 2026-06-13 β†’ 2026-06-24 +- `pc-macos`: observed 2026-06-13 β†’ 2026-07-01 diff --git a/docs/tests/unit-tests.md b/docs/tests/unit-tests.md index 5f96101c..1a350af4 100644 --- a/docs/tests/unit-tests.md +++ b/docs/tests/unit-tests.md @@ -38,7 +38,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - _AudioModule: a fresh, unconfigured module is idle (pins default unset)_ - _AudioModule: setup/teardown is repeatable with no residual state_ - _AudioModule: teardown clears the active mic (latestFrame falls back to silence)_ -- _AudioModule: last setup() wins, any add/remove order stays coherent_ +- _AudioModule: two mics β€” first wins, survivor re-elects, any order stays coherent_ ## BlendMap @@ -73,29 +73,14 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Calling free() twice is harmless; pointer and count remain zeroed. - allocate() refuses zero-count or zero-channels (returns false, no allocation, buffer left empty so a caller that ignores the bool doesn't get a partial state). -## CheckerboardEffect - -`test/unit/light/unit_CheckerboardEffect.cpp` -*Also touches: SpiralEffect, PlasmaPaletteEffect, RingsEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect.* - -- Checkerboard paints at least one non-zero byte on a 16Γ—16 grid (effect actually renders). -- With cell_size=4, adjacent cells render different colours (the checker pattern is real, not uniform). -- LavaLampEffect has localised blob features that can land on identical corner palette indices at some t values (corner-pair check is too strict). Scan the whole buffer for any two distinct pixels instead β€” same approach as RingsEffect below. LavaLamp paints at least one non-zero byte (effect actually renders). -- Across 10 frames at bpm=60, at least one frame shows two distinct colours somewhere in the buffer (blobs move and the field varies). -- RingsEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Rings paints at least one non-zero byte (effect actually renders). -- At least two distinct pixels exist somewhere in the buffer (rings are localised, so corner-pair would be too strict). -- RipplesEffect (MoonLight sine-wave water surface) lights one pixel per column at a sine-driven height. On a flat 2D layer it still paints a visible wavefront β€” assert it renders something and varies across the surface. -- Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colours (wavefront vs background) β€” scan the whole buffer, corner-pair would be too strict. - ## CheckerboardModifier `test/unit/light/unit_CheckerboardModifier.cpp` -- Identity dimensions β€” a mask doesn't resize the logical box. -- size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert false) keeps even-parity cells, drops odd-parity. -- invert flips which parity passes β€” the cell that was dropped now passes and vice versa. -- size>1 groups cells into squares: with size=2, the 2Γ—2 block at the origin is all one square (parity of 0/2=0), so all four pass; the next block over drops. -- Never fans out β€” at most one destination. +- A mask leaves the logical box unchanged. +- size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert false) keeps even-parity cells, drops odd-parity. Passing cells keep their coord. +- invert flips which parity passes. +- size>1 groups cells into squares: with size=2, the 2Γ—2 block at the origin is all one square (parity 0), so all four pass; the next block over drops. ## Color @@ -145,26 +130,52 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Brightness scaling runs before white derivation so W = min of the *scaled* RGB values. - rebuild() can switch the output channel count between RGB (3) and RGBW (4) on the fly. -## DevicesModule +## DemoReelEffect + +`test/unit/light/unit_DemoReelEffect.cpp` + +- The reel enumerates the effect registry, hosts one effect at a time, renders it, and advances through the whole list without crashing β€” the create/teardown/delete churn every tick is the robustness path this pins. Registering two real effects + the reel gives it something to cycle. + +## DevicePlugin `test/unit/core/unit_DeviceIdentify.cpp` +*Also touches: DevicesModule.* -- _classifyDevice: projectMM from /api/state modules array_ -- _classifyDevice: WLED from /json/info brand_ -- _classifyDevice: a live non-projectMM/non-WLED host is generic_ -- _classifyDevice: a truncated projectMM body still classifies (modules is early)_ -- _extractDeviceName: projectMM reads the deviceName control's value, not the type_ -- _extractDeviceName: WLED reads the top-level name field_ -- _extractDeviceName: generic / garbage / null bodies yield empty_ -- _extractDeviceName: respects the output buffer size (no overflow)_ -- _devTypeStr maps every type_ +- _MmPlugin claims a presence packet carrying the projectMM marker_ +- _MmPlugin declines a plain WLED packet (no projectMM marker)_ +- _WledPlugin claims a plain WLED packet as WLED_ +- _WledPlugin declines a projectMM-marked packet (that's a peer, not a WLED)_ +- _Plugins decline a short / garbage datagram, never read out of bounds_ +- _WledPlugin tolerates an empty name (the module supplies the IP fallback)_ + +## DevicesModule `test/unit/core/unit_DevicesModule_ageout.cpp` -- _DevicesModule: a still-fresh device survives just under kStaleMs (24h)_ -- _DevicesModule: a device drops once past kStaleMs (24h)_ +- A cached (restored-but-never-re-heard) device is on a short probation, NOT the full 24 h β€” else a long-gone persisted device would survive forever across reboots (its clock resets to "boot" each restore). It drops once past kCachedGraceMs. +- _DevicesModule: a cached device drops once past the probation window_ +- A live-confirmed device (a presence packet cleared its `cached` flag) gets the full 24 h. +- _DevicesModule: a live-confirmed device drops once past kStaleMs (24h)_ +- A projectMM peer also answers as a plain WLED (its presence packet without our marker), so a later WLED-classified sighting must NOT relabel a restored projectMM row. This drives the downgrade-prevention in upsertDevice through the public path: restore the row as projectMM, inject a plain WLED packet from the same IP, confirm it stays projectMM. - _DevicesModule: restore tolerates an empty / malformed cache_ +`test/unit/core/unit_DevicesModule_discovery.cpp` +*Also touches: DevicePlugin.* + +- _DevicesModule: a plain WLED packet lists a WLED device with its name_ +- _DevicesModule: a projectMM-marked packet lists a projectMM device_ +- _DevicesModule: a short / garbage datagram is ignored, never listed_ +- The P4-bench bug: two DIFFERENT devices (a WLED and a projectMM peer) must each keep their OWN name + type β€” no cross-contamination between packets. +- A peer RENAME must propagate: a later packet from the same IP with a new name updates the row in place β€” the live-update requirement (the name rides the presence packet). +- A projectMM device stays projectMM even when a later plain-WLED packet arrives from the same address β€” the type only RAISES toward projectMM, never downgrades. (A projectMM peer could be seen via an unmarked packet too; that must not relabel it WLED.) + +`test/unit/core/unit_DevicesModule_hue.cpp` + +- _DevicesModule: a Hue bridge is listed with its colour count_ +- _DevicesModule: upsertHueBridge is idempotent, updates count in place_ +- _DevicesModule: a persisted Hue bridge restores as a Hue row with its count_ +- _DevicesModule: a corrupt persisted colour clamps to the valid range, row still restores_ + ## DistortionWavesEffect `test/unit/light/unit_DistortionWavesEffect.cpp` @@ -180,6 +191,13 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Disabled child drivers don't tick: toggling `enabled` flips whether that driver's loop() runs. +`test/unit/light/unit_Drivers_firstOutputRgb.cpp` + +- _Drivers::firstOutputRgb reads pixel 0 of the driven buffer_ +- _Drivers::firstOutputRgb reports black pixel 0 as-is (caller substitutes the default)_ +- _Drivers::firstOutputRgb returns false when there is no driven buffer_ +- _MoonModule::firstOutputRgb defaults to false (no output module)_ + ## FilesystemModule `test/unit/core/unit_FilesystemModule_persistence.cpp` @@ -191,7 +209,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Companion to the wiredByCode case above: when the JSON describes a different type at the position where a code-wired child lives, the position-replacement must NOT kill the code-wired child. Stop reconciliation at that index instead and let the next save re-write the file with the actual tree shape. When the saved JSON wants a different type at the position where a code-wired child lives, reconciliation stops at that index instead of destroying the wired child. - Round-trip persistence with children: write a Layer subtree that contains both controls and child modules with controls of their own, then read the file back as text and verify it parses as valid JSON. Regresses the missing-comma bug between each child's "N.type" field and that child's first control (e.g. "0.type":"X""0.foo":1 instead of "0.type":"X","0.foo":1). Saving a Layer with multiple children produces valid JSON β€” comma separators between child `N.type` and the child's first control field are present. - Singleton survives probe lifecycle: /api/types factory-creates a probe of every registered type (including FilesystemModule) to capture defaults, then deletes it. The probe's destructor must NOT clear the singleton β€” otherwise every save path (noteDirty, debounced loop1s, flushPending on reboot) silently no-ops for the rest of the device's life. The fix is to register the singleton in setScheduler(), not in the constructor. This test catches that singleton-clear regression. /api/types factory-creates a temporary FilesystemModule probe; its destruction must NOT clear the static singleton (otherwise every later save silently no-ops). -- Int16 controls preserve their saved value across a filesystem load β€” the load path does not clamp them to `c.min`/`c.max` (which are `uint8_t` and so default to 0,0, a range that can't represent an int16). Without this a 128Γ—128 grid would reload as 0Γ—0Γ—0 and the pipeline would allocate no buffers; the test pins the round-trip so an Int16 value survives unclamped. +- Regression: Int16 controls (GridLayout's width/height/depth, Layer's start/end) round-tripped through the filesystem load path were clamped to c.min/c.max, which default to 0,0 because ControlDescriptor.min/max are uint8_t and can't represent an int16 range. Every Int16 control loaded as 0 β€” so a 128Γ—128 grid became 0Γ—0Γ—0 after restart and the whole pipeline allocated no buffers. Int16 controls (GridLayout width/height, RegionModifier start/end) preserve their saved value across load β€” no zero-clamping from uint8 min/max bounds. ## FireEffect @@ -206,21 +224,17 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run `test/unit/core/unit_FirmwareUpdateModule.cpp` - The `firmware` control is always present and non-empty (either a real firmware key from build_info.h or the fallback "unknown"). The firmware card owns firmware identity (version/build/firmware) + the partition usage. +- OTA phase is surfaced through the shared status slot (MoonModule::setStatus()), not a control. publishStatus() runs in setup()/loop1s() and maps the platform OTA status string to a severity: "idle" clears the banner, an "error: " prefix is Severity::Error, anything else is neutral Severity::Status. ## GameOfLifeEffect `test/unit/light/unit_GameOfLifeEffect.cpp` -- Two cell grids of width Γ— height bytes each. -- Disabling releases both grids (dynamicBytes drops to 0) via the parent lifecycle. -- A blinker (horizontal 3-in-a-row) oscillates with period 2 under B3/S23: it becomes a vertical 3-in-a-row, then back. Pins both birth (B3) and survival (S23) on a known pattern. -- A 2Γ—2 block is a still-life: every live cell has 3 neighbours (S3), no dead cell has exactly 3 (no B3), so stepOnce leaves it unchanged. -- A lone cell dies (underpopulation: 0 neighbours, not S2/S3) β†’ extinction. -- Wraparound: a blinker on the right edge stays a valid 3-cell pattern because neighbours wrap, rather than losing cells to a hard edge. -- Reallocation on dimension change: grids resize, byte count tracks new wΓ—h. -- Must not crash on a zero-size grid (no allocation, loop is a no-op). -- bpm time-gates the generation rate: a low bpm advances fewer generations per unit time than a high bpm over the same elapsed window. Drives time via the desktop millis() test seam (Layer reads platform::millis in loop()). -- Regression: the Layer clears the buffer before every effect frame, so the grid must be re-painted on EVERY frame, not just on the (rarer) beats where a generation advances. A bpm gate that skipped the paint left non-step frames black β€” visible as "a flash now and then" at low bpm. Drive several frames at a slow bpm (most are non-step) and require the buffer stays lit on all of them. +- The B#/S# parser turns a rule string into birth/survive neighbour sets. Conway = B3/S23. +- A 2Γ—2 block is a Conway still life: every live cell has 3 neighbours (survives), and the surrounding dead cells never have exactly 3 (no births). It must be identical after a step. +- Regression: a 3D grid gives a cell up to 26 neighbours (3Γ—3Γ—3 minus self), but the B/S rule tables are sized 9 (single-digit Conway notation, 0..8). A dense 3D neighbourhood must not read those tables out of bounds β€” a count β‰₯9 is in no single-digit ruleset, so the cell dies / stays dead. This fills a 3Γ—3Γ—3 cube (the centre has all 26 neighbours alive) and just steps: the test passing under ASan/bounds-checking is the OOB-read pin; behaviourally the over-crowded centre dies (26 βˆ‰ S) and the dense interior doesn't survive. +- A horizontal 3-cell blinker oscillates to vertical after one step (period-2 oscillator). This is the canonical "the rules actually run" check: birth on 3, death of the ends (1 neighbour each). +- A lone cell (0 neighbours) dies β€” the dead-by-isolation rule, and a sanity check that an empty grid stays empty (no spontaneous births at count 0 under Conway). ## GridLayout @@ -244,6 +258,19 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - _apply-core: applyOp dispatches each op type and tolerates bad input_ - A per-control validator (like SystemModule.deviceModel's printable-ASCII rule) is enforced THROUGH the apply-core β€” so the APPLY_OP `set` the installer pushes over serial is guarded exactly like an HTTP write, with no per-transport special-casing. This is the point of moving validation onto the control: one backend check, every path. +## HueDriver + +`test/unit/light/unit_HueDriver.cpp` + +- _HueDriver: a coloured pixel becomes an on/bri/hue/sat state body_ +- _HueDriver: a black pixel becomes on:false_ +- _HueDriver: RGBβ†’HSV maps the primaries to the right Hue wheel positions_ +- _HueDriver: unchanged colour is not resent, a changed one is_ +- _HueDriver: parseLights keeps only colour-capable, reachable lights_ +- Room + light selection filters which colour lights the driver actually drives. Both dropdowns default to "All" (index 0): then every colour light is driven (unchanged behaviour). Selecting a room narrows the driven set to that room's colour lights; selecting a light drives just that one. +- The single status line (folding what were the separate hueStatus / colourLights controls) shows the light count as driven-of-total: "N-M lights" while filtered, the plain "M lights" when not. +- fetchLights sizes its read buffer by growing while the body looks truncated. The signal is "does the body end in '}'": a too-small buffer cuts the JSON mid-content. (Regression: an earlier check tested strlen==cap-1, which never fires because httpRequest strips headers first, so a >2 KB bridge response was parsed truncated and lights silently disappeared.) + ## ImprovFrame `test/unit/core/unit_ImprovFrame.cpp` @@ -291,27 +318,43 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - _overflow safety: too many nodes fails cleanly_ - _overflow safety: nesting deeper than kMaxDepth fails cleanly_ - _input longer than the text buffer fails cleanly_ +- parseString must DECODE the JSON string escapes our own writer emits (JsonSink/writeJsonString) β€” \" \\ \n \r \t \b \f β€” so reader and writer are symmetric. A multi-line value (a script with `\n`) must arrive as a real newline, not a literal backslash-n. ## Layer `test/unit/light/unit_Layer_extrude.cpp` -*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, FireEffect, ParticlesEffect.* +*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, FireEffect, ParticlesEffect.* - A D2 effect (Rainbow) on a 3D layer writes z=0 once; Layer::extrude copies that slice across every z>0 β€” slices are byte-identical. -- A D1 effect writes row y=0,z=0; extrude duplicates that row across every y and every z-slice. +- A D1 effect writes the x=0 column; extrude duplicates it across every x and every z-slice. - NoiseEffect declared D3 still produces a valid image on a depth=1 layer (it honours the runtime depth instead of hardcoding z). - PlasmaEffect (D3) on a 2D layer same contract: valid 2D image, no buffer overrun. - NoiseEffect (D3) on a 1D layer (height=depth=1) writes a valid strip and never overflows. - PlasmaEffect (D3) on a 1D layer same contract: valid 1D strip, no overflow. -- CheckerboardEffect (D2) on a 3D layer: extrude copies z=0 to every z>0 (stateless D2 contract). +- SpiralEffect (D2) on a 3D layer: extrude copies z=0 to every z>0 (stateless D2 contract). - FireEffect (D2, stateful β€” heat buffer sized to wΓ—h) extrudes cleanly across z on a 3D layer. - ParticlesEffect (D2, stateful β€” trail sized to wΓ—hΓ—cpl) extrudes cleanly across z on a 3D layer. +`test/unit/light/unit_Layer_live_modifier.cpp` +*Also touches: RotateModifier, ModifierBase.* + +- With a Rotate present, the live pass rotates the gradient each frame as the angle advances β€” so two frames at different times differ. A static GradientEffect alone would produce identical frames, so any difference is the live remap. +- PAY-FOR-WHAT-YOU-USE: a Layer with no live modifier must NOT run the live pass β€” the static gradient is byte-identical across frames regardless of the clock. +- A DISABLED Rotate must not run the live pass either (the gate keys off ENABLED live modifiers). Same static gradient β†’ identical frames. +- COALESCED REBUILD: two beat-driven modifiers (RandomMap) on one Layer both ask for a rebuild on a beat; Layer::loop() must rebuild ONCE (not re-enter onBuildState per modifier) and the Layer must stay valid β€” the composed mapping changes, no crash. + +`test/unit/light/unit_Layer_modifier_chain.cpp` +*Also touches: ModifierBase.* + +- Region (left half) THEN Multiply (2Γ— mirror): the logical box folds twice. On a 16-wide axis: Region 0..50 β†’ 8, then Multiply 2 β†’ 4. Both modifiers apply β€” the second is no longer dead weight. +- Order matters: Region-then-Multiply differs from Multiply-then-Region. Region's percentage applies to whatever box it sees, so the composed logical size differs. +- A DISABLED middle modifier is skipped β€” the chain folds only the enabled ones. + `test/unit/light/unit_Layer_phase_animation.cpp` -*Also touches: MetaballsEffect, CheckerboardEffect, LavaLampEffect, SpiralEffect.* +*Also touches: MetaballsEffect, SpiralEffect, LavaLampEffect, SpiralEffect.* - Metaballs visibly changes over 100ms even when per-tick dt is sub-millisecond (no phase-accumulator truncation). -- Checkerboard advances at desktop speed (cells flip across 100ms). +- SpiralEffect advances at desktop speed (the spiral rotates across 100ms). - LavaLamp animates across 100ms (blobs move). - Spiral animates across 100ms (rotation visible). - Replace path: swap one effect for another mid-flight (same shape as HttpServerModule::handleReplaceModule) and confirm the new effect animates. Replacing one effect with another mid-tick (HttpServerModule's swap path) leaves the new effect animating, not frozen. @@ -323,23 +366,24 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Sparse sphere: a LUT is built; its destinations are driver indices in [0, lightCount), and the render buffer stays the dense bounding box. - Sphere + Mirror: the modifier's box-coordinate destinations are translated into driver-index space; no destination escapes [0, lightCount). - REGRESSION: a high fan-out Multiply (8Γ—8Γ—4 = 256) on a 128Γ—128 grid must build a NON-EMPTY LUT that covers every physical light. The maxDest estimate (logicalCount Γ— maxMultiplier) is computed in 64-bit; before that fix it overflowed uint16 on no-PSRAM boards (256 Γ— 256 = 65536 wraps to 0), sized the LUT to ~nothing, and blanked the display. Here we assert the LUT actually maps the full light set, in range β€” the symptom that black-screened the device. +- Region carving: a RegionModifier shrinks the Layer's LOGICAL box to the region (so the effect renders only there), and the LUT maps each region cell to its box cell at the start offset β€” every destination in range, none outside the region. The driver buffer still holds all physical lights; cells outside the region simply get no logical source (dark). Default 0/100 = full box (the no-carve fast path) is covered by unit_RegionModifier; here we carve a quarter. `test/unit/light/unit_Layer_zero_grid.cpp` -*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, SpiralEffect, MetaballsEffect, PlasmaPaletteEffect, RingsEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect, FireEffect, ParticlesEffect.* +*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, MetaballsEffect, RingsEffect, RipplesEffect, LavaLampEffect, FireEffect, ParticlesEffect, GameOfLifeEffect, GEQ3DEffect, PaintBrushEffect.* - Rainbow on 0,0,0 grid: no crash. - Noise on 0,0,0 grid: no crash. - Plasma on 0,0,0 grid: no crash. -- Checkerboard on 0,0,0 grid: no crash. - Spiral on 0,0,0 grid: no crash. - Metaballs on 0,0,0 grid: no crash. -- PlasmaPalette on 0,0,0 grid: no crash. - Rings on 0,0,0 grid: no crash. - Ripples on 0,0,0 grid: no crash. -- GlowParticles on 0,0,0 grid: no crash. - LavaLamp on 0,0,0 grid: no crash. - Fire on 0,0,0 grid: no heat buffer allocated, no crash. - Particles on 0,0,0 grid: no trail buffer allocated, no crash. +- GameOfLife on 0,0,0 grid: no heap alloc for 0 cells, no crash. +- GEQ3D / PaintBrush on 0,0,0 grid: audio effects, no crash with no buffer. +- _PaintBrushEffect on 0,0,0 grid_ ## Layers @@ -352,6 +396,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Disabling the top layer drops cleanly to the single (bottom) layer β€” no crash, the driver now sees the bottom layer's content. Pins the robustness path. - Drivers' composition/output-buffer allocation contract (architecture.md Β§ Adaptive allocation). The driver output buffer exists ONLY when the pipeline must blend into physical space; otherwise the lone layer's buffer is handed to drivers directly (zero-copy). dynamicBytes() reflects outputBuffer_.bytes(), so it's 0 ⇔ no buffer. Pins all three cases in one place: 1. one identity (no-LUT) layer β†’ NO output buffer (zero-copy) 2. two enabled layers β†’ output buffer (must composite) 3. one layer WITH a LUT β†’ output buffer (must map logicalβ†’physical) - activeLayer() returns the first enabled child, or the only child if all are disabled (so dimensions stay queryable during boot/toggle-off). +- firstEnabledLayer() is the output-selection counterpart to activeLayer(): it never falls back to a disabled layer, so it returns nullptr exactly when nothing renders. - If the container holds only non-Layer children, activeLayer() returns nullptr (the role-guard skips, never miscasts). ## Layouts @@ -427,6 +472,47 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - typeName/typeRole with an out-of-range index returns nullptr / Generic safely (never UB). - The factory grows its registry capacity dynamically β€” registering 10+ extra types past the initial size still works and every name stays discoverable. +## MoonLive + +`test/unit/core/unit_moonlive_compiler.cpp` + +- _compileSource: fill(r,g,b) fills every light_ +- _compileSource: setRGB(index, r,g,b) writes one pixel_ +- REMARK #1: every argument is an expression β€” random16 in ANY slot. +- REMARK #2: a literal / random16 bound may be a uint16 (0..65535), not capped at 255. +- _compileSource: out-of-range index is bounds-rejected at runtime_ +- _compileSource rejects malformed programs with a diagnostic, never crashes_ +- _MoonLive.compile(source) on a bad script leaves the engine !ok with an error_ +- VREG REUSE: a chain of calls must fit the small device register file. Each argument temp dies once its call consumes it and is recycled, so peak register pressure stays low no matter how many calls a statement nests β€” setRGB with all four arguments a random16 still compiles. +- DOMAIN-NEUTRAL: the core compiler owns no function names. With an EMPTY table it knows nothing β€” `setRGB`/`fill`/`random16` are all "unknown function". The LED vocabulary lives only in the host's table; a different host registers different names. (Remark #3.) +- _MoonLive recompiling swaps the program live (fill <-> setRGB)_ +- STAGE 1 CONTROLS β€” parse layer: a `uint8_t name = def; // @control min..max` declaration surfaces a DeclaredControl, and a declared name used in a statement resolves to it. +- _compileSource: malformed control declarations fail with a diagnostic, never crash_ + +`test/unit/core/unit_moonlive_fill.cpp` + +- _MoonLive emitFill produces a non-empty routine_ +- _MoonLive emitFill rejects a too-small buffer (degrades, no overrun)_ +- _MoonLive emitFill/emitAnimatedFill reject a null output buffer (no crash)_ +- _MoonLive compiles and fills a buffer with the chosen colour_ +- _MoonLive run on zero lights writes nothing (robust to empty)_ +- The native routines write channels +0/+1/+2 per light, so a layer with fewer than 3 channels per light can't hold RGB β€” run() must leave it untouched, not overrun it. +- _MoonLive recompile swaps the colour; free returns to !ok_ +- _MoonLive animated fill derives colour from the per-frame t_ +- _platform allocExec returns usable executable memory, freeExec releases it_ +- _MoonLive controls: declaredControls + controlSlot seeded from the default_ +- _MoonLive controls: arena address is STABLE across a recompile and the slot value survives_ +- _MoonLive controls: free() releases the arena (no stale slot after teardown)_ + +`test/unit/core/unit_moonlive_ir.cpp` + +- _MoonLive compiled fill is BEHAVIORALLY identical to the hand-encoded emitFill (golden)_ +- _MoonLive compiled fill is robust: zero lights writes nothing_ +- _MoonLive compileSource degrades on a too-small code buffer_ +- _MoonLive compiled setRGB writes one pixel; out-of-range is bounds-rejected_ +- _MoonLive control: a declared control reads the arena live (no recompile on value change)_ +- _MoonLive control survives a host call (kArg4 live across random16)_ + ## MoonModule `test/unit/core/unit_MoonModule.cpp` @@ -481,19 +567,16 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run `test/unit/light/unit_MultiplyModifier.cpp` -- Reports D3 β€” handles all three axes. Pins the ModifierBase default too. -- Defaults (multiply 2/2/1, mirror true/true/false) reproduce the canonical mirror-XY pipeline: a 128Γ—128 physical grid β†’ 64Γ—64 logical (each axis folds). -- multiplyZ tiles the Z axis too: 128Γ—128Γ—4 with multiply 2/2/2 β†’ 64Γ—64Γ—2. -- PURE-FOLD EQUIVALENCE: with the defaults (mult 2, mirror XY), the corner logical pixel (0,0) fans out to all four physical corners β€” byte-identical to the old MirrorModifier corner test. This is the canonical-pipeline guarantee. -- PURE-FOLD EQUIVALENCE: an interior pixel folds to the same two columns the old mirrorX-only produced β€” original + horizontal reflection. -- No multiplication on any axis (all multipliers 1) β†’ identity pass-through. -- Tiling WITHOUT mirror repeats (does not reflect) β€” multiply 2 on X, mirror off: logical x=0 lands at physical x=0 (tile 0) and x=64 (tile 1, identity offset), NOT x=127. This is the difference from a fold. -- multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier clamps to the axis extent (1), so logD stays 1 and the layer isn't blanked. Before the clamp, multiplyZ=4 made logD = 1/4 = 0 β†’ empty layer. -- A multiplier larger than the axis extent clamps to the extent (can't tile more times than there are pixels). -- maxMultiplier is the product of the raw controls (the fan-out upper bound). -- REGRESSION: maxMultiplier() must NOT wrap when all axes are maxed. The product 64Γ—64Γ—16 = 65536 overflows nrOfLightsType (uint16 on no-PSRAM) and would wrap to 0 β€” feeding the uint64 maxDest math in Layer::rebuildLUT an already-wrapped (possibly 0) multiplier β†’ empty LUT β†’ black display. It must saturate to the type max instead. (Single-axis tests above stay under the wrap; this one crosses it.) On uint32 (PSRAM) the product fits and isn't saturated β€” assert only the non-wrap, non-zero invariant that holds on both widths. -- REGRESSION: an 8Γ—8 multiply must emit all 64 tile positions, not be truncated to 8. The Layer's scratch buffer is sized to ModifierBase::kMaxFanout (64); a smaller buffer (the original physicals[8]) silently dropped 56 of the 64 tiles, so a 128Γ—128 grid showed only 8 tiles instead of the full 8Γ—8 = 64. -- Fan-out never exceeds maxOut even if asked for more than the buffer holds. +- _MultiplyModifier advertises D3 dimensions_ +- Defaults (multiply 2/2/1) β†’ a 128Γ—128 physical grid folds to a 64Γ—64 logical box. +- _MultiplyModifier logical size on Z_ +- FAN-OUT (fold direction): with the defaults (mult 2, mirror XY), all four physical CORNERS fold onto the single logical pixel (0,0) β€” the inverse of the old "logical (0,0) β†’ 4 physical corners". This is the kaleidoscope fold made concrete. +- mirrorX only: two physical columns fold to the same logical column (original + its horizontal reflection). The logical box is 64 wide. +- All multipliers 1 β†’ identity: the box is unchanged and every coord folds to itself. +- Tiling WITHOUT mirror repeats (does not reflect): physical x=64 (tile 1) folds to logical x=0, same as physical x=0 β€” both tiles map identically, no reflection. +- multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier clamps to the axis extent (1), so depth stays 1 and the layer isn't blanked. +- A multiplier larger than the axis extent clamps to the extent. +- REGRESSION (πŸ‡): a non-divisible extent leaves a leftover edge strip that the tiles don't cover β€” those pixels must be DROPPED, not wrapped back into a tile (which would duplicate the edge). 5-wide, multiply 2 β†’ tile width 2, covers pixels 0..3; pixel 4 is the leftover and has no tile. ## NetworkModule @@ -573,6 +656,17 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - With depth > 1, adjacent and distant z-slices each render differently (3D noise, not a stack of identical 2D slices). - Same z-slice variation requirement holds for Plasma β€” each depth plane renders differently. +## Palette + +`test/unit/light/unit_Palette.cpp` + +- _Palette: gradient endpoints land on the first/last stop colours_ +- _Palette: a mid-gradient sample interpolates between stops_ +- _Palette: colorFromPalette index 0 reads entry 0; brightness scales_ +- _Palette: the index wraps at 255β†’0 (no out-of-range read)_ +- _Palette: a degenerate (empty) gradient is all black, never out-of-bounds_ +- _Palettes::active swaps the global palette on setActive_ + ## ParlioLedDriver `test/unit/light/unit_ParlioLedDriver.cpp` @@ -632,7 +726,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run `test/unit/light/unit_RainbowEffect.cpp` - A single frame on a 4Γ—4 grid leaves the buffer non-zero (rainbow always paints somewhere). -- Pixel (0,0) is at full saturation and value (one channel exactly 255) β€” confirms hsvToRgb wiring. +- Pixel (0,0) carries a lit palette colour β€” confirms the effect writes a real RGB there. - Distant pixels carry different hues (the rainbow gradient is spatial, not uniform). ## RandomMapModifier @@ -640,16 +734,30 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run `test/unit/light/unit_RandomMapModifier.cpp` *Also touches: Layer.* -- A remap doesn't resize the logical box. -- _RandomMapModifier maxMultiplier is 1_ -- The core property: the mapping is a true bijection over [0, w*h*d) β€” every destination index appears exactly once (no gaps, no duplicates). -- A fresh modifier with the same generation produces the same permutation (deterministic seed β†’ reproducible, which is what makes it testable). -- Reshuffling (a beat) changes the mapping, and the result is still a bijection. -- Robustness: an empty (0Γ—0Γ—0) grid must not crash β€” maxOut 0 yields no destination. -- A resize (different box count) rebuilds the permutation to the new size, still a bijection. +- A remap leaves the logical box unchanged. +- The core property: a true bijection over [0, w*h*d) β€” every destination index appears exactly once (no gaps, no duplicates). +- Deterministic seed β†’ reproducible permutation (what makes it testable). +- Reshuffling (a beat) changes the mapping, still a bijection. +- Robustness: an empty (0Γ—0Γ—0) box must not crash β€” it folds to a no-op. +- A resize (different box count) rebuilds the permutation to the new size. - _RandomMapModifier loop() reshuffles on a beat (bpm 60 β‰ˆ 1/s)_ - _RandomMapModifier loop() with bpm 0 never reshuffles (frozen)_ +## RegionModifier + +`test/unit/light/unit_RegionModifier.cpp` + +- Default region (0/100 on every axis) is the full box: identity size, no rejection. +- Half of an axis, half-open: end=50 on 128 β†’ region width 64, not 65. +- Two abutting regions tile a 128-wide axis with no overlap and no gap. +- A physical coord inside the region folds to region-local (subtract the start pixel); a coord outside is rejected. +- Rounding rule on a small panel: start floors, end ceils to an exclusive pixel. start 33 / end 66 on a 4-wide axis β†’ floor(1.32)=1 .. ceil(2.64)=3 β†’ pixels 1,2. +- A region that rounds to nothing still gets a 1-pixel floor. +- OFF-SCREEN: a window slid half off the left edge keeps its FULL size (the effect renders at a fixed scale); only the visible half maps to physical lights. startX=-50 on 64 β†’ window [βˆ’32, 32), span 64. Physical x 0..31 land at window-local 32..63 (the right half of the window β€” the visible part); the left half of the window (0..31) has no physical light, so it's dark. The effect isn't rescaled. +- A window entirely off the box maps NO lights β€” the layer goes dark on that axis, which is how an effect is moved completely out of view. The box still has a valid size (the effect renders), nothing just reaches the screen. +- A window stretched WIDER than the box (start<0 and end>100) renders the full span; the box shows the middle slice. startX=-50,endX=150 on 64 β†’ window [βˆ’32, 96), span 128. +- Degenerate axes don't crash: a 1-wide axis stays 1, a 0-extent axis yields 0. + ## RmtLedDriver `test/unit/light/unit_RmtLedDriver_lifecycle.cpp` @@ -685,6 +793,10 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - _RmtLedDriver idles with a status error on a bad pin list_ - _RmtLedDriver with the empty default pins idles cleanly (no pin assumed)_ - _RmtLedDriver re-slices when the source buffer changes_ +- _RmtLedDriver window: ledsPerPin distributes over the window, not the whole buffer_ +- _RmtLedDriver window: count 0 means the rest of the buffer from start_ +- _RmtLedDriver window: a size-1 window at 0 is the onboard-LED case_ +- _RmtLedDriver window: a start past the buffer end yields an empty slice_ - loop() is a safe no-op across single-pin, multi-pin and zero-grid configs. `test/unit/light/unit_RmtLedEncoder.cpp` @@ -699,11 +811,10 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run `test/unit/light/unit_RotateModifier.cpp` -- _RotateModifier logicalDimensions are identity_ -- _RotateModifier maxMultiplier is 1_ -- At the initial angle (0) the rotation is identity β€” every light maps to itself. -- Every emitted destination is in range (no out-of-bounds index), and the count is always 0 or 1 (a remap never fans out). -- _RotateModifier tolerates an empty grid_ +- _RotateModifier advertises a live (per-frame) modifier_ +- At the initial angle (0) the rotation matrix is the identity β€” every cell samples itself. +- z passes through (2D rotation) β€” a 3D coord's z is untouched. +- An empty box doesn't divide-by-zero or wrap: the remap is a no-op-ish transform that the Layer's live pass then treats as out-of-box (dark), never a crash. ## Scheduler @@ -746,6 +857,18 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Physical indices are sequential 0..N-1 over the emitted shell points (no gaps from the unindexed lattice voids), so the buffer maps 1:1 to emitted lights. - Default radius is a sensible small sphere (not 0, not huge). +## SpiralEffect + +`test/unit/light/unit_effects_render.cpp` +*Also touches: RingsEffect, RipplesEffect, LavaLampEffect.* + +- LavaLampEffect has localised blob features that can land on identical corner palette indices at some t values (corner-pair check is too strict). Scan the whole buffer for any two distinct pixels instead β€” same approach as RingsEffect below. LavaLamp paints at least one non-zero byte (effect actually renders). +- Across 10 frames at bpm=60, at least one frame shows two distinct colours somewhere in the buffer (blobs move and the field varies). +- RingsEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Rings paints at least one non-zero byte (effect actually renders). +- At least two distinct pixels exist somewhere in the buffer (rings are localised, so corner-pair would be too strict). +- RipplesEffect (MoonLight sine-wave water surface) lights one pixel per column at a sine-driven height. On a flat 2D layer it still paints a visible wavefront β€” assert it renders something and varies across the surface. +- Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colours (wavefront vs background) β€” scan the whole buffer, corner-pair would be too strict. + ## SystemModule `test/unit/core/unit_SystemModule.cpp` @@ -769,6 +892,35 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - _sanitizeHostname trims leading and trailing dashes / invalid runs_ - _sanitizeHostname yields empty for all-invalid input (caller falls back)_ +## TextEffect + +`test/unit/light/unit_TextEffect.cpp` + +- Static text renders glyph pixels top-left. On a grid tall/wide enough for one line of the 6x8 font, a non-empty string lights some pixels; an empty string lights none. +- A multi-line string wraps: the second line renders on a lower row (font-height down), so a two-line string lights pixels below the first font's height. Uses the 4x6 font (height 6). +- Scroll mode advances the text over time and never crashes; on a degenerate grid it's a safe no-op. + +## Uncategorized + +`test/unit/light/unit_Layer_persistence.cpp` + +- _Layer: buffer persists across frames (no per-frame clear)_ +- _Layer: fadeToBlackBy decays the persisted buffer once per frame_ +- _Layer: multiple fade requests combine with MIN (gentlest wins, longest trail)_ +- _Layer: collected fade resets after it is consumed_ +- _Layer: onBuildState clears the buffer (a rebuild wipes stale pixels)_ + +## WaveEffect + +`test/unit/light/unit_WaveEffect.cpp` + +- _WaveEffect: sawtooth ramps 0β†’top across the phase_ +- _WaveEffect: triangle peaks in the middle and returns_ +- _WaveEffect: sine sits mid at the zero crossings_ +- _WaveEffect: square is low then high_ +- _WaveEffect: every type stays within the grid bounds_ +- _WaveEffect: a zero-height grid never reads out of bounds_ + ## WheelLayout `test/unit/light/unit_WheelLayout.cpp` @@ -778,6 +930,69 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - _WheelLayout coordinates are non-negative (centre-shifted into address space)_ - _WheelLayout different spoke counts give different layouts_ +## WledPacket + +`test/unit/core/unit_WledPacket.cpp` + +- _WledPacket::build produces a valid WLED header (token/id/size)_ +- _WledPacket::readName round-trips the device name_ +- _WledPacket marker is set only when stamped, and stays WLED-valid_ +- _WledPacket::isValid rejects short / wrong-magic / null input_ +- _WledPacket::readName truncates a long name to the buffer, never overruns_ + +## crc + +`test/unit/core/unit_crc.cpp` + +- CRC-16/CCITT-FALSE has a well-known check value: "123456789" β†’ 0x29B1. Pinning it proves the polynomial/init/reflection match the standard variant (so a fingerprint computed here matches any other CCITT-FALSE implementation). +- A change-detector: different content β†’ (almost always) different CRC; identical content β†’ same. +- Empty span returns the init value (no bytes processed). + +## draw + +`test/unit/light/unit_draw.cpp` + +- mm::draw::pixel() writes inside the grid and silently clips outside it (no out-of-bounds write). +- A 1D line (a row): every pixel from a.x to b.x inclusive is lit. +- A 2D diagonal: endpoints are lit and the line is contiguous (one pixel per step on the main diagonal of a square). +- A 3D line: drives all three axes, endpoints lit, no out-of-bounds on a small cube. +- A line running off the grid clips: it draws the on-grid part and stops, no crash. +- The `shorten` parameter pulls the far endpoint back toward `a` by shorten/255 (with WLEDMM *2 rounding), so an effect can sweep a partial segment. For aβ†’b = (0,0)β†’(8,0): shorten 255 draws the whole line (tip at 8), 128 β‰ˆ half (tip at (16*128/255+1)/2 = 4), 1 = just the start pixel (tip 0), 0 = nothing. This pins the rounding of the shorten branch. +- draw::blur on a 1D row matches the canonical carryover-seep reference byte-for-byte (same behaviour as FastLED blur1d / MoonLight blurRows), and is symmetric around a centred bright pixel. +- blur runs separably on every axis with extent>1: a 2D blur spreads a centre pixel to all four orthogonal neighbours; a 3D blur reaches the z neighbours too. And it never writes out of bounds. +- A glyph blits in the correct orientation β€” neither X-mirrored (a 'b' as a 'd') nor Y-flipped. 'L' is the ideal probe: its vertical bar must be on the LEFT and its foot on the BOTTOM row. This guards the column-bit and row-direction reads, so the DemoReel name overlay renders each letter upright and un-mirrored. + +## light_types + +`test/unit/light/unit_Coord3D.cpp` + +- _Coord3D arithmetic is per-axis_ +- _Coord3D modulo and divide fold per axis_ +- _Coord3D % and / guard a zero or degenerate axis_ +- _Coord3D equality_ + +## math8 + +`test/unit/core/unit_math8.cpp` + +- sin8: a 256-entry sine LUT centred on 128, peaking near 255 and 0 a quarter and three-quarters of the way round. cos8 is sin8 shifted a quarter turn. +- triwave8: linear up 0β†’255 then down 255β†’0, peaking at the midpoint. +- qadd8/qsub8 clamp at the 0..255 ends instead of wrapping. +- nscale8 is the recognisable spelling of scale8 (n/256 channel scale), so nscale8(x,255)==x. +- beat8: a sawtooth completing `bpm` cycles per minute. At t=0 it's 0; halfway through a beat ~128. +- beatsin8: a sine oscillating in [low,high] at bpm. Stays in range across the cycle and actually moves (not stuck at one value). +- Random8: a seeded PRNG β€” same seed gives the same sequence (determinism), and below(n) stays under n. Two different seeds diverge. +- atan2_8 / dist8: the geometry helpers moved here from color.h still behave. +- map8 rescales 0..255 onto [lo,hi] inclusively β€” the top of the input must REACH hi (FastLED's map8 == map(in,0,255,lo,hi)). Regression: an earlier scale8-based form left hi unreachable, so a one-step span (a bar height of 1) collapsed to 0 β€” the bug GEQ3D's height mapping hit. + +## noise + +`test/unit/core/unit_noise.cpp` + +- Determinism: the same coordinate always gives the same value (a pure function of position), so a field is reproducible frame to frame and across the 1D/2D/3D entry points at z/y = 0. +- Smoothness: neighbouring positions WITHIN a cell (sub-256 steps) differ only a little β€” that's what makes it value noise rather than a raw hash (which would jump randomly every step). +- Range: output is a full byte; over a swept field it uses a wide span (not stuck near one value). + ## platform `test/unit/core/unit_platform_clock.cpp` diff --git a/scripts/MoonDeck.md b/scripts/MoonDeck.md index 600f5365..96e62fe8 100644 --- a/scripts/MoonDeck.md +++ b/scripts/MoonDeck.md @@ -21,7 +21,7 @@ Below: the UI behaviours common to every card, described once, then one section ## PC Tab -![Moondeck Pc](../docs/assets/screenshots/moondeck_pc.png) +![Moondeck Pc](../docs/assets/ui/moondeck_pc.png) ### build_desktop @@ -57,9 +57,9 @@ While the app is running, MoonDeck shows the button as **Stop** (a 5-second poll ### preview_installer -![Installer](../docs/assets/screenshots/installer.png) -![Installer2](../docs/assets/screenshots/installer2.png) -![Installer3](../docs/assets/screenshots/installer3.png) +![Installer](../docs/assets/ui/installer.png) +![Installer2](../docs/assets/ui/installer2.png) +![Installer3](../docs/assets/ui/installer3.png) Locally preview the web installer page at without tagging a release. Stages `docs/install/index.html` + `src/ui/install-picker.js` into `build/install-preview/` and serves them via Python's `http.server` on port 8000. @@ -117,7 +117,7 @@ The MoonDeck button writes the file, prints a `MOONDECK_VIEW: /api/history-repor ### screenshot_modules -Capture UI screenshots of every module that has controls and save them to `docs/assets/screenshots/`. +Capture UI screenshots of every module that has controls and save them to `docs/assets/`. ```bash uv run scripts/docs/install_playwright.py # one-time (or use Install Playwright button in MoonDeck) @@ -152,16 +152,16 @@ uv run scripts/docs/update_module_docs.py # update all uv run scripts/docs/update_module_docs.py --dry-run # preview without writing ``` -For each `.md` file, if `docs/assets/screenshots/.png` exists and the file doesn't already contain a screenshot reference, inserts the image after the first heading. If a matching `.gif` also exists, inserts the GIF reference on the next line. Safe to re-run β€” skips files that already have all references. +For each `.md` file, if `docs/assets//.png` exists and the file doesn't already contain a screenshot reference, inserts the image after the first heading. If a matching `.gif` also exists, inserts the GIF reference on the next line. Safe to re-run β€” skips files that already have all references. Also inserts MoonDeck tab screenshots and the installer screenshot into `scripts/MoonDeck.md` and `README.md` at fixed anchor points (defined in the `EXTRA_SHOTS` list in the script). -Reports unreferenced screenshots β€” any PNG or GIF in `docs/assets/screenshots/` not mentioned anywhere in `docs/` or `scripts/`. +Reports unreferenced screenshots β€” any PNG or GIF in `docs/assets/` not mentioned anywhere in `docs/` or `scripts/`. ## Live Tab -![Moondeck Live](../docs/assets/screenshots/moondeck_live.png) +![Moondeck Live](../docs/assets/ui/moondeck_live.png) ### live_scenario @@ -218,7 +218,7 @@ When the verdict is `CHOPPY`/`DEAD`, the *cause* (which close path fired on the ## ESP32 Tab -![Moondeck Esp32](../docs/assets/screenshots/moondeck_esp32.png) +![Moondeck Esp32](../docs/assets/ui/moondeck_esp32.png) The tab is laid out top-to-bottom along the firmware workflow. Each dropdown sits between the script groups that consume it, so picking a dropdown is the natural prelude to the buttons below it. diff --git a/scripts/check/check_specs.py b/scripts/check/check_specs.py index a0fbe30b..1e98077a 100644 --- a/scripts/check/check_specs.py +++ b/scripts/check/check_specs.py @@ -50,11 +50,43 @@ def find_moonmodules(): modules.append(h_file) return modules +# Effects, modifiers, layouts, and drivers each document themselves as one compact-row page per type +# rather than a file per module (docs consolidation β€” see the folder-structure decision). A module +# whose type name ends in one of these suffixes is documented on the matching shared page; everything +# else keeps a per-module page named for the type. The match is purely on the type-name **suffix**, so +# EVERY *Layout module (CarLightsLayout, CubeLayout, PanelLayout, RingLayout, …) routes to layouts.md, +# every *Driver to drivers.md, etc. β€” not just a hand-picked subset. New effect/modifier/layout/driver +# types fold in automatically. (Layouts/Layers/Drivers are CONTAINERS, not leaf modules β€” none of those +# stems ends in a suffix below ("Drivers" β‰  "Driver"), so each container keeps its own per-module page; +# the CRTP base ParallelLedDriver is skipped in discover_modules as a template.) +CONSOLIDATED_PAGES = { + "Effect": SPECS / "light" / "effects" / "effects.md", + "Modifier": SPECS / "light" / "modifiers" / "modifiers.md", + "Layout": SPECS / "light" / "layouts" / "layouts.md", + "Driver": SPECS / "light" / "drivers" / "drivers.md", +} + + def find_spec(module_path): - """Find the matching spec .md file for a source .h file.""" + """Find the matching spec .md for a source .h. + + A module whose type name ends in Effect/Modifier/Layout is documented on the shared + per-type page (its row must carry every control name β€” checked by check_spec_freshness). + Everything else uses the per-module page (stem match), as before. + """ name = module_path.stem # e.g. "NoiseEffect" - # Search all spec directories + # MoonLive is a live-script module under light/moonlive/, not a normal effect β€” it keeps its + # own page despite the *Effect suffix. Only the per-type folders consolidate. + in_moonlive = "moonlive" in module_path.parts + if not in_moonlive: + for suffix, page in CONSOLIDATED_PAGES.items(): + if name.endswith(suffix): + if page.exists(): + return page + break # consolidated page missing β€” fall through to the per-module stem search + + # Per-module page: match by filename stem anywhere under docs/moonmodules/. for md in SPECS.rglob("*.md"): if md.stem == name: return md diff --git a/scripts/docs/screenshot_modules.py b/scripts/docs/screenshot_modules.py index 309a2a56..b15d5e68 100644 --- a/scripts/docs/screenshot_modules.py +++ b/scripts/docs/screenshot_modules.py @@ -12,14 +12,13 @@ Also captures MoonDeck tab screenshots and the web installer page, and inserts them into the appropriate docs files. -Saves to: - docs/assets/screenshots/.png β€” module card screenshot - docs/assets/screenshots/.gif β€” preview animation (effects/modifiers) - docs/assets/screenshots/ui_overview.png β€” projectMM full-page screenshot - docs/assets/screenshots/moondeck_pc.png β€” MoonDeck PC tab - docs/assets/screenshots/moondeck_esp32.png β€” MoonDeck ESP32 tab - docs/assets/screenshots/moondeck_live.png β€” MoonDeck Live tab - docs/assets/screenshots/installer.png β€” web installer page +Saves to (by domain/type, mirroring src β€” see docs/backlog/folder-structure-proposal.md): + docs/assets/light/effects/.png/.gif β€” effect card + preview + docs/assets/light/{modifiers,layouts,drivers}/ β€” other light modules + docs/assets/core/.png β€” core modules + docs/assets/ui/ui_overview.png β€” projectMM full-page screenshot + docs/assets/ui/moondeck_{pc,esp32,live}.png β€” MoonDeck tabs + docs/assets/ui/installer.png β€” web installer page Usage: # one effect, raw (no modifier), good preview size, with its GIF, overwriting: @@ -71,7 +70,23 @@ from playwright.sync_api import sync_playwright, Page ROOT = Path(__file__).resolve().parent.parent.parent -OUT_DIR = ROOT / "docs" / "assets" / "screenshots" +ASSETS = ROOT / "docs" / "assets" +UI_DIR = ASSETS / "ui" # tooling / installer / full-page shots (not per-module) + +# Map a module to its asset subfolder (domain/type), mirroring src. Module screenshots live in +# docs/assets/{core, light/{effects,modifiers,layouts,drivers}}/ β€” see folder-structure-proposal. +def asset_dir_for(type_name: str) -> Path: + if type_name.endswith("Effect"): + return ASSETS / "light" / "effects" + if type_name.endswith("Modifier"): + return ASSETS / "light" / "modifiers" + if type_name.endswith("Layout"): + return ASSETS / "light" / "layouts" + if type_name.endswith("Driver"): + return ASSETS / "light" / "drivers" + if type_name in ("Layouts", "Layers", "Drivers"): + return ASSETS / "light" + return ASSETS / "core" # SystemModule, FilesystemModule, DevicesModule, … and the rest # --------------------------------------------------------------------------- # Module catalogue @@ -125,7 +140,7 @@ # --------------------------------------------------------------------------- # Extra shots: MoonDeck tabs + web installer # Each entry: (filename, url, wait_selector, doc_files, anchor_text) -# filename: saved as docs/assets/screenshots/ +# filename: saved as docs/assets/ui/ # url: full URL to load # wait_selector: CSS selector to wait for before screenshotting (or "") # doc_files: list of repo-relative paths to insert image into @@ -701,7 +716,9 @@ def _sweep_orphans(modules: list) -> None: container_names = find_container_nav_names(args.host) core_names = find_core_module_names(args.host) - OUT_DIR.mkdir(parents=True, exist_ok=True) + for _d in (UI_DIR, ASSETS/'core', ASSETS/'light', ASSETS/'light'/'effects', + ASSETS/'light'/'modifiers', ASSETS/'light'/'layouts', ASSETS/'light'/'drivers'): + _d.mkdir(parents=True, exist_ok=True) captured, gif_captured, skipped, failed = [], [], [], [] @@ -716,7 +733,7 @@ def _sweep_orphans(modules: list) -> None: try: # --- Full-page UI overview screenshot --- (needs projectMM) if not args.extras_only: - overview_path = OUT_DIR / "ui_overview.png" + overview_path = UI_DIR / "ui_overview.png" filter_allows = (not filt or filt in "ui_overview") if filter_allows: if not overview_path.exists() or args.force: @@ -742,7 +759,7 @@ def _sweep_orphans(modules: list) -> None: for filename, url, wait_sel, _doc_files, _anchor in EXTRA_SHOTS: if filt and filt not in filename.lower(): continue - out_path = OUT_DIR / filename + out_path = UI_DIR / filename if out_path.exists() and not args.force: print(f" skip {filename} (already captured)") skipped.append((filename, "already exists")) @@ -768,7 +785,7 @@ def _sweep_orphans(modules: list) -> None: cname = container_names.get(container_type, "") if not cname: continue - out_path = OUT_DIR / f"{container_type}.png" + out_path = asset_dir_for(container_type) / f"{container_type}.png" if out_path.exists() and not args.force: print(f" skip {container_type} (already captured)") skipped.append((container_type, "already exists")) @@ -790,7 +807,7 @@ def _sweep_orphans(modules: list) -> None: print(f" skip {type_name} (not in state β€” ESP32-only?)") skipped.append((type_name, "not in state")) continue - out_path = OUT_DIR / f"{type_name}.png" + out_path = asset_dir_for(type_name) / f"{type_name}.png" if out_path.exists() and not args.force: print(f" skip {type_name} (already captured)") skipped.append((type_name, "already exists")) @@ -807,8 +824,8 @@ def _sweep_orphans(modules: list) -> None: for type_name, parent_type, extra_props, want_gif in MODULES: if filt and filt not in type_name.lower(): continue - out_path = OUT_DIR / f"{type_name}.png" - gif_path = OUT_DIR / f"{type_name}.gif" + out_path = asset_dir_for(type_name) / f"{type_name}.png" + gif_path = asset_dir_for(type_name) / f"{type_name}.gif" need_png = not out_path.exists() or args.force need_gif = want_gif and args.gif and (not gif_path.exists() or args.force) @@ -851,7 +868,7 @@ def _sweep_orphans(modules: list) -> None: print("gif-failed ", end="", flush=True) failed.append((type_name, "gif failed")) - print(f"β†’ {OUT_DIR.relative_to(ROOT)}/") + print(f"β†’ {asset_dir_for(type_name).relative_to(ROOT)}/") delete_module(args.host, actual_name) added_ids.remove(actual_name) time.sleep(0.5) @@ -911,7 +928,7 @@ def _sweep(modules: list) -> None: print(" Add module screenshots: uv run scripts/docs/update_module_docs.py") if "ui_overview" in captured: print(" Add UI overview to docs/architecture.md # Web UI section:") - print(" ![UI overview](assets/screenshots/ui_overview.png)") + print(" ![UI overview](assets/ui/ui_overview.png)") return 0 if not failed else 1 diff --git a/scripts/docs/update_module_docs.py b/scripts/docs/update_module_docs.py index 9ef7ee6d..34db0d43 100644 --- a/scripts/docs/update_module_docs.py +++ b/scripts/docs/update_module_docs.py @@ -6,15 +6,15 @@ """Insert screenshot references into docs/moonmodules/**/*.md files. For each .md file, if a matching screenshot exists at -docs/assets/screenshots/.png and the file doesn't already +docs/assets//.png and the file doesn't already contain a screenshot reference, insert one line after the first heading: - ![ controls](../../../assets/screenshots/.png) + ![ controls](../../../assets//.png) The relative path is computed from the .md file's location so links work both on GitHub and in the MoonDeck /api/docs/ renderer. -Also reports any screenshots (PNG or GIF) in docs/assets/screenshots/ that +Also reports any screenshots (PNG or GIF) under docs/assets/ that are not referenced anywhere in docs/. Usage: @@ -28,14 +28,29 @@ ROOT = Path(__file__).resolve().parent.parent.parent DOCS_DIR = ROOT / "docs" / "moonmodules" -SCREENSHOTS_DIR = ROOT / "docs" / "assets" / "screenshots" - -SCREENSHOT_RE = re.compile(r'!\[.*?\]\(.*?assets/screenshots/.*?\)') -GIF_RE = re.compile(r'!\[.*?\]\(.*?assets/screenshots/.*?\.gif.*?\)') +ASSETS = ROOT / "docs" / "assets" +UI_DIR = ASSETS / "ui" # tooling/installer/full-page shots (not per-module) + +# A module's asset subfolder (domain/type), mirroring src β€” see folder-structure-proposal.md. +def asset_dir_for(type_name: str): + if type_name.endswith("Effect"): + return ASSETS / "light" / "effects" + if type_name.endswith("Modifier"): + return ASSETS / "light" / "modifiers" + if type_name.endswith("Layout"): + return ASSETS / "light" / "layouts" + if type_name.endswith("Driver"): + return ASSETS / "light" / "drivers" + if type_name in ("Layouts", "Layers", "Drivers"): + return ASSETS / "light" + return ASSETS / "core" + +SCREENSHOT_RE = re.compile(r'!\[.*?\]\(.*?assets/.*?\.(?:png|jpe?g)\)') +GIF_RE = re.compile(r'!\[.*?\]\(.*?assets/.*?\.gif.*?\)') # Extra screenshots with fixed placement in specific doc files. # Each entry: (filename, doc_rel_path, anchor_text) -# filename: file in docs/assets/screenshots/ +# filename: file in docs/assets/ui/ # doc_rel_path: repo-relative path to the doc file # anchor_text: heading/line after which to insert (exact prefix match) EXTRA_SHOTS = [ @@ -74,7 +89,7 @@ def insert_extra_shot(doc_path: Path, filename: str, anchor: str, # Relative path from the doc file's directory to the screenshot. depth = len(doc_path.parent.relative_to(ROOT).parts) - rel = "../" * depth + f"docs/assets/screenshots/{filename}" + rel = "../" * depth + f"docs/assets/ui/{filename}" label = filename.replace(".png", "").replace("_", " ").title() img_line = f"\n![{label}]({rel})\n\n" @@ -169,7 +184,7 @@ def main() -> int: # Fix spacing around image blocks: ensure one blank line after the last # image in a block, and collapse any triple+ blank lines to one blank. - img_line_re = re.compile(r'^!\[.*?\]\(.*?assets/screenshots/.*?\)\n', re.MULTILINE) + img_line_re = re.compile(r'^!\[.*?\]\(.*?assets/.*?\)\n', re.MULTILINE) for md_file in sorted(DOCS_DIR.rglob("*.md")): original = md_file.read_text(encoding="utf-8") lines = original.splitlines(keepends=True) @@ -190,8 +205,8 @@ def main() -> int: for md_file in sorted(DOCS_DIR.rglob("*.md")): type_name = type_name_from_md(md_file) - png = SCREENSHOTS_DIR / f"{type_name}.png" - gif = SCREENSHOTS_DIR / f"{type_name}.gif" + png = asset_dir_for(type_name) / f"{type_name}.png" + gif = asset_dir_for(type_name) / f"{type_name}.gif" if not png.exists(): skipped_no_screenshot.append(md_file.relative_to(ROOT)) @@ -209,7 +224,7 @@ def main() -> int: # --- Extra shots: insert MoonDeck tab + installer images into fixed doc files --- extra_updated = [] for filename, doc_rel, anchor in EXTRA_SHOTS: - screenshot = SCREENSHOTS_DIR / filename + screenshot = UI_DIR / filename if not screenshot.exists(): continue doc_path = ROOT / doc_rel @@ -242,7 +257,7 @@ def main() -> int: all_docs_text += (ROOT / "README.md").read_text(encoding="utf-8") unreferenced = [] - for f in sorted(SCREENSHOTS_DIR.glob("*")): + for f in sorted(ASSETS.rglob("*")): if f.suffix.lower() not in (".png", ".gif"): continue if f.name not in all_docs_text: diff --git a/scripts/moondeck.py b/scripts/moondeck.py index 0d744e80..046b745d 100644 --- a/scripts/moondeck.py +++ b/scripts/moondeck.py @@ -1534,7 +1534,11 @@ def _serve_doc_asset(self): The renderer resolves relative image src values to ROOT-relative paths before building the URL, so this handler only needs a simple join.""" import mimetypes - rel = self.path[len("/api/doc-asset/"):] + from urllib.parse import unquote + # URL-decode the path: a doc image with a space in its name is written `Hue%20driver.png` in + # the markdown, so the request path carries `%20`; without decoding, the file lookup would seek + # a literal "%20" in the name and 404. + rel = unquote(self.path[len("/api/doc-asset/"):]) # Resolve against ROOT and ensure no escape. try: asset_path = (ROOT / rel).resolve() @@ -1633,6 +1637,58 @@ def _link_tag(m): s = re.sub(r'(?\1', s) return s + def _render_cell(c: str) -> str: + """Render one table cell. The compact module pages (effects/modifiers/layouts) + use raw previews and row anchors inside cells β€” + two tags GitHub/VS Code honor but the default escape path would turn to literal + text. Pass those two through (resolving an src to /api/doc-asset/ like the + markdown-image path does); escape + inline-render the rest.""" + def _img_attrs(tag: str) -> dict: + """Pull src/width/alt/style from an tag regardless of attribute ORDER β€” each + is matched by its own name="value" search, so an author may write them in any sequence + (the previous fixed srcβ†’widthβ†’altβ†’style regex silently dropped out-of-order attrs).""" + def attr(name): + m = re.search(rf'\b{name}="([^"]*)"', tag) + return m.group(1) if m else None + return {k: attr(k) for k in ("src", "width", "alt", "style")} + def _img(a): + src_ = a.get("src") or "" + width = a.get("width") + style = a.get("style") + alt_ = a.get("alt") + if not src_.startswith(("http://", "https://", "/")): + abs_src = (md_path.parent / src_).resolve() + try: + src_ = str(abs_src.relative_to(ROOT.resolve())) + except ValueError: + pass + src_ = f"/api/doc-asset/{src_}" + # Escape every attribute value that reaches the HTML β€” src/width/style as well as alt β€” + # so a doc-page value with a quote or angle bracket can't break the tag or inject markup. + wattr = f' width="{html_mod.escape(width)}"' if width else "" + altattr = f' alt="{html_mod.escape(alt_)}"' if alt_ else "" + # Preserve an author-set width style (the cross-renderer size lever) and append our + # margin so the preview isn't flush against the cell edges. + style = (style + ";" if style else "") + "margin:4px 0" + return f'' + # No raw HTML β†’ ordinary escaped+inline cell (the common case). + if " line + # breaks in a "card" cell), escape the rest, render markdown, then restore them. + tokens = [] + def _stash(html: str) -> str: + tokens.append(html) + return f"\x00{len(tokens)-1}\x00" + # Match the envelope, then extract attributes by name (order-independent). + c = re.sub(r']*>', lambda m: _stash(_img(_img_attrs(m.group(0)))), c) + c = re.sub(r'', lambda m: _stash(f''), c) + c = re.sub(r'', lambda _: _stash("
"), c) + out = render_inline(html_mod.escape(c)) + for i, html in enumerate(tokens): + out = out.replace(f"\x00{i}\x00", html) + return out + def close_list_if_open(): nonlocal in_list if in_list: @@ -1706,7 +1762,7 @@ def _heading_slug(text: str) -> tuple[str, str]: # CSS handle the first-row styling. cell_tag = "td" cell_html = "".join( - f"<{cell_tag}>{render_inline(html_mod.escape(c))}" + f"<{cell_tag}>{_render_cell(c)}" for c in cells ) lines.append(f"{cell_html}") @@ -1762,10 +1818,36 @@ def _heading_slug(text: str) -> tuple[str, str]: continue close_list_if_open() + stripped_check = raw_line.strip() + + # A standalone line (the per-module doc pages put the preview gif on its own + # line above the description). Resolve a relative src to /api/doc-asset/ and keep the + # width, like the table-cell path does β€” the allowlist below doesn't cover . + if re.fullmatch(r']*>(?:\s*)?', stripped_check): + # Extract each attribute by name, order-independent (an author may write src/alt/width + # in any sequence β€” a fixed-order regex would silently drop the out-of-order ones). + def _attr(name): + m = re.search(rf'\b{name}="([^"]*)"', stripped_check) + return m.group(1) if m else None + src_ = _attr("src") or "" + if not src_.startswith(("http://", "https://", "/")): + abs_src = (md_path.parent / src_).resolve() + try: + src_ = f"/api/doc-asset/{abs_src.relative_to(ROOT.resolve())}" + except ValueError: + pass + w_ = _attr("width") + # Escape every attribute value before it reaches the HTML (like _render_cell._img does) + # so a doc-page src/width/alt with a quote or bracket can't break the tag or inject markup. + wattr = f' width="{html_mod.escape(w_)}"' if w_ else "" + alt_ = _attr("alt") + aattr = f' alt="{html_mod.escape(alt_)}"' if alt_ is not None else "" + lines.append(f'') + continue + # Pass-through for a fixed allowlist of structural HTML tags used # by history_report.py's combined graph+commits output. Narrowed # to known safe tags so arbitrary doc content can't inject scripts. - stripped_check = raw_line.strip() if (stripped_check.startswith("<") and stripped_check.endswith(">") and _allowed_html_re.match(stripped_check)): diff --git a/src/core/AudioFrame.h b/src/core/AudioFrame.h index 5aa3cf10..cd4a1031 100644 --- a/src/core/AudioFrame.h +++ b/src/core/AudioFrame.h @@ -16,11 +16,18 @@ namespace mm { // math straight off them (the hot-path rule); the float FFT magnitudes never // leave the module. struct AudioFrame { - uint16_t level = 0; // overall sound level (RMS), 0..255-ish β€” the VU value - uint16_t peakHz = 0; // dominant frequency this frame, in Hz (0 = none) - uint16_t peakMag = 0; // magnitude of that peak (gates the peakHz update) - uint8_t bands[16] = {}; // 16 log-spaced frequency-band magnitudes, 0..255 - // (bass = bands[0], treble = bands[15]) + uint16_t level = 0; // RAW overall sound level (RMS), 0..255-ish β€” the instantaneous VU + // value, recomputed each audio block with NO smoothing. Snaps to a + // transient (a drum hit spikes immediately) β€” use this for punchy, + // beat-reactive effects. (WLED calls this `volumeRaw`.) + uint16_t levelSmoothed = 0; // SMOOTHED level: an exponential moving average of `level`, so it + // lags and rounds off sudden changes and "breathes" with the music + // instead of twitching. Use this for calm/glowing effects that + // should swell, not flash. (WLED calls this `volume`/`volumeSmth`.) + uint16_t peakHz = 0; // dominant frequency this frame, in Hz (0 = none) + uint16_t peakMag = 0; // magnitude of that peak (gates the peakHz update) + uint8_t bands[16] = {}; // 16 log-spaced frequency-band magnitudes, 0..255 + // (bass = bands[0], treble = bands[15]) }; } // namespace mm diff --git a/src/core/AudioModule.h b/src/core/AudioModule.h index 602761a0..7d3b87cc 100644 --- a/src/core/AudioModule.h +++ b/src/core/AudioModule.h @@ -28,6 +28,7 @@ #include "core/AudioFrame.h" #include "core/AudioLevel.h" #include "core/AudioBands.h" +#include "core/math8.h" // beatsin8 / sin8 β€” the simulated-audio oscillators #include "platform/platform.h" #include @@ -73,6 +74,16 @@ class AudioModule : public MoonModule { // ambient room dark, lower for a quiet room. uint8_t gain = 222; // sensitivity β€” HIGHER = more (a narrower dB window // so a given sound fills more of the bar). + // Simulated audio: fill the frame with a synthesized signal so audio-reactive effects are demoable + // (and testable) without a mic or music. Two patterns: + // `music` β€” a plausible song: multi-sine bands + a swelling volume + a periodic beat + a + // sweeping peak. Nice for demos (bars dance, VU breathes, peaks move). + // `sweep` β€” a single band lit, marching bassβ†’treble on a timer, with the peak frequency and a + // steady volume tracking it. Deterministic β€” the clean test pattern to check that each + // effect responds across the whole spectrum. + // A real mic always wins: when `mode` is a fill-in mode it only runs while there's no real signal. + uint8_t simulate = 0; // 0 = off, 1 = music (fill silence), 2 = sweep (fill silence), + // 3 = music (always), 4 = sweep (always) static constexpr uint16_t kSampleRates[] = {8000, 16000, 22050, 44100}; static constexpr uint8_t kSampleRateCount = 4; @@ -87,6 +98,9 @@ class AudioModule : public MoonModule { controls_.addSelect("sampleRate", sampleRateSel, kRateOptions, kSampleRateCount); controls_.addUint8("floor", floor, 0, 255); controls_.addUint8("gain", gain, 1, 255); + static constexpr const char* kSimulateOptions[] = { + "off", "music (silence)", "sweep (silence)", "music (always)", "sweep (always)"}; + controls_.addSelect("simulate", simulate, kSimulateOptions, 5); // Read-only live read-outs (formatted in loop1s). Derived every second, // nothing to persist, so ReadOnly (the display-only type) not a flipped // Text β€” same idiom as SystemModule's uptime/fps. @@ -107,10 +121,13 @@ class AudioModule : public MoonModule { } void onBuildState() override { reinit(); MoonModule::onBuildState(); } - void setup() override { active_ = this; reinit(); } + void setup() override { + if (active_ == nullptr) active_ = this; // first live mic wins; a 2nd mic is captured but not read + reinit(); + } void teardown() override { deinit(); - if (active_ == this) active_ = nullptr; // effects fall back to silence + if (active_ == this) active_ = nullptr; // vacate; a surviving module re-elects itself in loop() } // The latest analysed frame β€” what effects read. Always valid (zeroed until @@ -122,17 +139,38 @@ class AudioModule : public MoonModule { // and an effect can be added/removed via the UI at any time, so it can't rely // on a boot-time setter β€” it asks here. Returns the live mic's frame while one // exists, else a static all-silent frame, so an effect added before/without a - // mic still reads valid silence instead of null. The active instance registers - // itself in setup() and clears the pointer in teardown(), so add/remove in any - // order leaves a coherent answer (the robustness rule). + // mic still reads valid silence instead of null. The FIRST live module claims the seat in setup(), + // vacates it in teardown(), and any running module re-claims an empty seat in loop() β€” so a device + // with two mics reads the first consistently, and removing the active one lets a survivor take over. + // Add/remove in any order leaves a coherent answer (the robustness rule). static const AudioFrame* latestFrame() { static const AudioFrame kSilence{}; return active_ ? &active_->frame_ : &kSilence; } void loop() override { - if constexpr (!platform::hasI2sMic) return; // inert off I2S targets - if (!inited_) return; // bad init β†’ idle (zero frame) + // Self-elect as the active mic if the seat is empty. setup() gives it to the first live module + // and teardown() vacates it, but removing the active module while a second one is still running + // would otherwise leave the seat empty (effects go silent). A running module re-claiming an + // empty seat here keeps latestFrame() pointing at a live frame for ANY add/remove order β€” the + // survivor takes over on its next tick (robustness). + // + if (active_ == nullptr) active_ = this; + + // Simulated audio (see the `simulate` control): 0=off, 1=music-on-silence, 2=sweep-on-silence, + // 3=music-always, 4=sweep-always. Real mic input always wins in the "on-silence" modes β€” the + // mic path below resets realQuietMs_ whenever a block carries signal, and synthesizeFrame() + // no-ops while that timer says the mic is live. Off I2S (desktop / mic-less) or on a bad init + // there's no mic, so the sim is the only possible source: run it and return. + const bool simSweep = (simulate == 2 || simulate == 4); + const bool simAlways = (simulate >= 3); + if constexpr (!platform::hasI2sMic) { + if (simulate != 0) synthesizeFrame(simSweep); // the only possible source off I2S + return; + } else { + if (!inited_) { if (simulate != 0) synthesizeFrame(simSweep); return; } + if (simAlways) { synthesizeFrame(simSweep); return; } // forced: skip the mic entirely + } // Drain whatever the DMA holds this tick (non-blocking) into the free tail // of the block accumulator. A full kBlock takes ~23 ms to arrive (longer @@ -155,6 +193,13 @@ class AudioModule : public MoonModule { // per-band display. computeLevel(samples_, kBlock, static_cast(floor / 2), gain, frame_); + // Smoothed level: a one-pole exponential moving average of the raw `level`, so effects that + // want a calm, breathing VU (rather than the raw value's snap-to-transient) read a value that + // lags and rounds off sudden changes. 3/4 old + 1/4 new is the textbook light smoothing β€” + // fast enough to follow the music, slow enough to hide per-block jitter. Integer-only, one + // block behind, off the per-light path. (WLED's `volume`/`volumeSmth` to our raw `level`.) + frame_.levelSmoothed = static_cast((frame_.levelSmoothed * 3 + frame_.level) / 4); + // Spectrum: window -> FFT -> 16 log bands, same floor/gain mapping. uint16_t peakHz = 0, peakMag = 0; applyWindow(samples_, kBlock, windowed_); @@ -172,6 +217,59 @@ class AudioModule : public MoonModule { // live every render tick) move with the music. The window peak is the representative reading. // Display-only β€” the live frame_.level the effects/LEDs use is untouched. if (frame_.level > levelPeak_) levelPeak_ = frame_.level; + + // Auto fill-in (simulate 1/2): if the mic has been quiet for a bit, synthesize so the effects + // still have something to react to. `level` this block re-arms the "real audio" grace period; + // once it lapses, the sim takes over and yields again the instant real sound returns. + if (simulate == 1 || simulate == 2) { + if (frame_.level > kSimRealThreshold) realBlocks_ = kSimRealGraceBlocks; + else if (realBlocks_ > 0) realBlocks_--; + if (realBlocks_ == 0) synthesizeFrame(simulate == 2); + } + } + + // Fill frame_ with a synthesized signal. sweep=false β†’ a plausible "song" (each band its own + // oscillator, a swelling volume, a periodic beat, a drifting peak); sweep=true β†’ one band lit + // marching bassβ†’treble on a timer (the deterministic test pattern). All integer LUT math (sin8), + // once per tick, off the per-light path. Also runs the same levelSmoothed EMA the mic path does. + void synthesizeFrame(bool sweep) { + const uint32_t t = platform::millis(); + if (sweep) { + // One band lit at a time, stepping bassβ†’treble every ~250 ms and wrapping. The lit band + // ramps up-and-down (triangle) so it's not a hard on/off, and the peak frequency + volume + // track the swept band so frequency-mapped and volume effects follow it too. + const uint8_t pos = static_cast((t / 250u) % 16u); + const uint8_t env = triwave8(static_cast((t % 250u) * 255u / 250u)); // 0..255 within a step + for (uint8_t b = 0; b < 16; b++) frame_.bands[b] = (b == pos) ? env : 0; + frame_.level = env; + frame_.peakHz = static_cast(80 + pos * 700); // bassβ†’~10.6 kHz across the 16 steps + frame_.peakMag = env; + } else { + // Musical "song": each band an independent sine at its own rate/phase (bass slow, treble + // fast), a slow overall volume swell, and a periodic beat pulse that briefly lifts the low + // bands + volume so beat-reactive effects fire. A drifting peak sweeps the dominant tone. + const uint8_t beat = (t % 600u < 90u) ? static_cast(triwave8(static_cast((t % 600u) * 255u / 90u))) : 0; + uint16_t sum = 0; + for (uint8_t b = 0; b < 16; b++) { + // Per-band oscillator: rate rises with b (treble flickers faster), phase spread by b. + const uint8_t rate = static_cast(1 + b); // BPM-ish multiplier + const uint8_t osc = sin8(static_cast(t * rate / 8u + b * 24u)); + uint16_t v = static_cast((osc * 3u) / 4u); // 0..191 base + if (b < 4) v = static_cast(v + beat / 2u); // beat lifts the bass + frame_.bands[b] = static_cast(v > 255 ? 255 : v); + sum = static_cast(sum + frame_.bands[b]); + } + const uint8_t swell = sin8(static_cast(t / 24u)); // slow volume breath + uint16_t lvl = static_cast(swell / 2u + sum / 32u + beat / 2u); + frame_.level = lvl > 255 ? 255 : lvl; + // Peak drifts across the spectrum so freq-mapped effects move. + frame_.peakHz = static_cast(80 + sin8(static_cast(t / 40u)) * 40u); + frame_.peakMag = frame_.level; + } + frame_.levelSmoothed = static_cast((frame_.levelSmoothed * 3 + frame_.level) / 4); + // Feed the same 1 s peak-hold the mic path uses, so the "level RMS" display tracks the + // synthesized level instead of reading a stale 0 (the display is peak-over-window, not live). + if (frame_.level > levelPeak_) levelPeak_ = static_cast(frame_.level); } void loop1s() override { @@ -203,6 +301,12 @@ class AudioModule : public MoonModule { char peakStr_[12] = {}; uint8_t levelPeak_ = 0; // peak frame_.level across the current 1 s display window (UI only) + // Auto fill-in (simulate 1/2): treat the mic as "live" for a grace window after any block above + // the threshold, so brief gaps between beats don't flip to the sim. ~2 s of blocks (β‰ˆ86/blockΒ·23ms). + static constexpr uint16_t kSimRealThreshold = 4; // a block level above this counts as real sound + static constexpr uint16_t kSimRealGraceBlocks = 86; // ~2 s at ~23 ms/block before the sim takes over + uint16_t realBlocks_ = 0; // grace countdown: >0 = mic was recently live, hold off the sim + static constexpr const char* kInitFailMsg = "mic init failed β€” check pins / rate"; void reinit() { diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 3a77774a..333af7db 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -33,6 +33,7 @@ const char* controlTypeName(ControlType t) { case ControlType::ReadOnly: return "display"; case ControlType::ReadOnlyInt: return "display-int"; case ControlType::Select: return "select"; + case ControlType::Palette: return "palette"; case ControlType::Progress: return "progress"; case ControlType::IPv4: return "ipv4"; case ControlType::List: return "list"; @@ -102,8 +103,9 @@ void writeControlValue(JsonSink& sink, const ControlDescriptor& c) { sink.appendf("%d", *static_cast(c.ptr)); return; case ControlType::Select: - // The selected index β€” the option strings go in the metadata - // block (writeControlMetadata) where the UI also wants them. + case ControlType::Palette: + // The selected index β€” the option strings / swatch colours go in the + // metadata block (writeControlMetadata) where the UI also wants them. sink.appendf("%u", *static_cast(c.ptr)); return; case ControlType::Progress: @@ -169,6 +171,14 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { sink.append("]"); return; } + case ControlType::Palette: { + // The light domain supplies the option objects ({name, colors}) via the function + // pointer in `aux` β€” core stays palette-agnostic. Falls back to an empty array. + sink.append(",\"options\":["); + if (c.aux) reinterpret_cast(c.aux)(sink); + sink.append("]"); + return; + } case ControlType::Progress: // `bytes` (in min, see addProgress): 1 β†’ KB label, 0 β†’ plain count. sink.appendf(",\"total\":%lu,\"bytes\":%s", static_cast(c.aux), @@ -296,7 +306,8 @@ ApplyResult applyControlValue(const ControlDescriptor& c, mm::json::parseString(json, key, static_cast(c.ptr), maxLen); return ApplyResult::Ok; } - case ControlType::Select: { + case ControlType::Select: + case ControlType::Palette: { int v = mm::json::parseInt(json, key); const int hi = c.max > 0 ? c.max - 1 : 0; if (policy == ApplyPolicy::Strict && (v < 0 || v > hi)) { diff --git a/src/core/Control.h b/src/core/Control.h index a9524e15..59687a9b 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -126,16 +126,26 @@ enum class ControlType : uint8_t { // "control holds a void* into module-owned data" shape every addX() // uses, one level up. (Data-over-objects: no per-row object graph, // no allocation on rebuild β€” see docs/architecture.md hot-path.) - Button // a momentary action, not a stored value. The UI renders a button; + Button, // a momentary action, not a stored value. The UI renders a button; // a click POSTs a value and the module's onUpdate() runs the action. // No backing storage (ptr unused) and non-persistable β€” distinct // from Bool, which is an on/off STATE that renders as a toggle and a // toggle is the wrong affordance for "do this now" (e.g. rescan). + Palette // a colour-palette dropdown (ptr β†’ uint8_t index). Like Select, but + // each option carries its gradient *colours* (16 hex stops) so the UI + // renders a gradient swatch per option, not just a name. The light + // domain supplies the names + swatches via the Palette type; the wire + // shape (options:[{name,colors}]) is serialized in writeControlMetadata. }; // Forward-declared (defined below the enum) so the descriptor can hold a pointer. class JsonSink; +// A ControlType::Palette control's options come from the light domain (it owns the palette set +// and the swatch colours). The descriptor's `aux` holds a pointer to this function; core calls it +// to emit the `"options":[{"name":…,"colors":…}, …]` array β€” core stays palette-agnostic. +using PaletteOptionsFn = void (*)(JsonSink& sink); + // Backing for a ControlType::List control. The module that owns the data (e.g. // DevicesModule over its device array) implements this; the control descriptor's // `ptr` points at the implementation. Serialization (writeControlValue) walks @@ -294,6 +304,13 @@ class ControlList { ControlType::ReadOnlyInt, 0, 0}; } + // A colour-palette dropdown: like a Select (ptr β†’ uint8_t index, max = optionCount), but the + // options carry swatch colours. `optionsFn` (light-domain) emits the {name,colors} objects. + void addPalette(const char* name, uint8_t& var, PaletteOptionsFn optionsFn, uint8_t optionCount) { + grow(); + controls_[count_++] = {&var, name, reinterpret_cast(optionsFn), ControlType::Palette, 0, optionCount}; + } + void addSelect(const char* name, uint8_t& var, const char* const* options, uint8_t optionCount) { grow(); controls_[count_++] = {&var, name, reinterpret_cast(options), ControlType::Select, 0, optionCount}; diff --git a/src/core/DeviceIdentify.h b/src/core/DeviceIdentify.h index 3eacf3d4..6a774a9f 100644 --- a/src/core/DeviceIdentify.h +++ b/src/core/DeviceIdentify.h @@ -12,12 +12,13 @@ namespace mm { // What a discovered device is. -enum class DevType : uint8_t { Generic = 0, ProjectMM = 1, Wled = 2 }; +enum class DevType : uint8_t { Generic = 0, ProjectMM = 1, Wled = 2, Hue = 3 }; inline const char* devTypeStr(DevType t) { switch (t) { case DevType::ProjectMM: return "projectMM"; case DevType::Wled: return "WLED"; + case DevType::Hue: return "Hue bridge"; case DevType::Generic: return "generic"; } return "generic"; diff --git a/src/core/DevicesModule.h b/src/core/DevicesModule.h index fdb87c5b..7db9ee94 100644 --- a/src/core/DevicesModule.h +++ b/src/core/DevicesModule.h @@ -51,6 +51,7 @@ class DevicesModule : public MoonModule, public ListSource { sink.append("{\"name\":"); sink.writeJsonString(d.name[0] ? d.name : ip); sink.appendf(",\"ip\":\"%s\",\"type\":\"%s\"", ip, devTypeStr(d.type)); + if (d.type == DevType::Hue) sink.appendf(",\"colour\":%u", d.colourCount); if (d.self) sink.append(",\"self\":true"); sink.append("}"); } @@ -64,6 +65,7 @@ class DevicesModule : public MoonModule, public ListSource { sink.writeJsonString(d.name[0] ? d.name : ip); sink.appendf(",\"ip\":\"%s\",\"url\":\"http://%s/\",\"type\":\"%s\"", ip, ip, devTypeStr(d.type)); + if (d.type == DevType::Hue) sink.appendf(",\"colour\":%u", d.colourCount); if (d.self) { sink.append(",\"self\":true"); // self is always "now" β€” no meaningful age } else if (d.cached) { @@ -101,9 +103,15 @@ class DevicesModule : public MoonModule, public ListSource { Device& d = devices_[deviceCount_++]; std::memcpy(d.ip, octets, 4); std::snprintf(d.name, sizeof(d.name), "%s", name); - d.type = (std::strcmp(typeStr, "projectMM") == 0) ? DevType::ProjectMM - : (std::strcmp(typeStr, "WLED") == 0) ? DevType::Wled - : DevType::Generic; + d.type = (std::strcmp(typeStr, "projectMM") == 0) ? DevType::ProjectMM + : (std::strcmp(typeStr, "WLED") == 0) ? DevType::Wled + : (std::strcmp(typeStr, "Hue bridge") == 0) ? DevType::Hue + : DevType::Generic; + // Clamp the persisted count to the valid range (0..127, the HueDriver's + // colourCount_ range) so a corrupt or hand-edited entry can't wrap into a bogus + // value when narrowed. 0 for non-bridge rows (the key is absent β†’ readInt = 0). + const long colour = mm::json::readInt(mm::json::member(doc, el, "colour")); + d.colourCount = static_cast(colour < 0 ? 0 : (colour > 127 ? 127 : colour)); d.self = false; d.cached = true; // restored, not re-heard live β†’ UI shows "cached", not a time // Stamp "now" so the cached entry gets its kCachedGraceMs PROBATION window @@ -123,8 +131,44 @@ class DevicesModule : public MoonModule, public ListSource { controls_.addList("devices", *this); // this module is the ListSource } + // The boot DevicesModule (exactly one exists). A foreign-bridge driver in the light domain + // (HueDriver) registers a discovered bridge through this without a compile-time dependency + // on DevicesModule's address β€” the same static-seam shape as AudioModule::latestFrame(). + static DevicesModule* active() { return active_; } + + // Register a Hue bridge a HueDriver has connected to. Unlike upsertDevice (driven by a UDP + // presence packet), a bridge is discovered out-of-band β€” the driver already holds its IP + + // app key β€” so this is the explicit entry point for that. Idempotent: updates the name + + // colour count of the existing row, or inserts one. `colour` is how many of the bridge's + // lights are colour-capable, the figure for sizing a layout. Persisted like any device row. + void upsertHueBridge(const uint8_t ip[4], const char* name, uint8_t colour) { + Device* d = findByIp(ip); + bool persistChanged = false; + if (!d) { + if (deviceCount_ >= kMaxDevices) return; // bounded; silently cap + d = &devices_[deviceCount_++]; + std::memcpy(d->ip, ip, 4); + persistChanged = true; + } + if (d->type != DevType::Hue) { d->type = DevType::Hue; persistChanged = true; } + if (name && name[0] && std::strcmp(d->name, name) != 0) { + std::snprintf(d->name, sizeof(d->name), "%s", name); + persistChanged = true; + } + if (!d->name[0]) { formatDottedQuad(d->name, ip); persistChanged = true; } + if (d->colourCount != colour) { d->colourCount = colour; persistChanged = true; } + d->lastSeenMs = platform::millis(); // transient β€” keeps the bridge from ageing out + // A cached row coming (back) online is a status change even with no persisted field edit β€” + // refresh on that transition too, else a re-announced bridge stays greyed-out in the UI. + const bool wasCached = d->cached; + d->cached = false; + if (persistChanged) sortByName(); // re-sort only on a real persisted change + if (persistChanged || wasCached) refreshStatus(); + } + void setup() override { MoonModule::setup(); + active_ = this; // The last-known device list is restored automatically before setup() by the // persistence overlay (the `devices` List control round-trips as JSON). So the // UI shows it INSTANTLY on boot β€” no waiting for an announcement to re-arrive. @@ -192,8 +236,13 @@ class DevicesModule : public MoonModule, public ListSource { // session. Cleared on the first live sighting. uint32_t lastSeenMs = 0; // platform::millis() at the most recent mDNS sighting. // Age-out drops a non-self device unheard for kStaleMs. + uint8_t colourCount = 0; // Hue bridge only: how many of its lights are colour-capable + // (the figure for sizing a layout). 0 for non-bridge rows. }; + // The boot instance, for active() β€” the foreign-bridge static seam (mirrors AudioModule). + static inline DevicesModule* active_ = nullptr; + static constexpr uint8_t kMaxDevices = 32; // a LAN's worth; bounded, no heap // Broadcast our presence every this-many loop1s ticks (β‰ˆ seconds). Slow + light, like // WLED's ~30 s beacon; a new device appears within this window. A departed device diff --git a/src/core/FilesystemModule.cpp b/src/core/FilesystemModule.cpp index 039f9953..05fb68b0 100644 --- a/src/core/FilesystemModule.cpp +++ b/src/core/FilesystemModule.cpp @@ -36,10 +36,22 @@ void FilesystemModule::setup() { void FilesystemModule::onBuildControls() { controls_.addReadOnly("lastSaved", lastSaveStr_, sizeof(lastSaveStr_)); + // Filesystem-partition usage bar (bytes used / total). Lives here β€” on the module that owns the + // filesystem β€” not on SystemModule. Read the total once; loop1s refreshes the used value. Bound + // only when the platform reports a real partition (desktop / a chip without a data partition + // reports 0, so the bar is omitted rather than showing 0/0). + totalFsVal_ = static_cast(platform::filesystemTotal()); + fsUsedVal_ = static_cast(platform::filesystemUsed()); + if (totalFsVal_ > 0) { + controls_.addProgress("filesystem", fsUsedVal_, totalFsVal_); + } MoonModule::onBuildControls(); } void FilesystemModule::loop1s() { + // Refresh the usage bar first β€” cheap, and it should track saves even before the mount/scheduler + // guards below (the total is fixed; only the used value moves as files are written). + if (totalFsVal_ > 0) fsUsedVal_ = static_cast(platform::filesystemUsed()); if (!mounted_ || !scheduler_) return; updateLastSavedStr(); if (!dirtyPending_) return; diff --git a/src/core/FilesystemModule.h b/src/core/FilesystemModule.h index 8b59cfaf..80fcbf9c 100644 --- a/src/core/FilesystemModule.h +++ b/src/core/FilesystemModule.h @@ -83,6 +83,8 @@ class FilesystemModule : public MoonModule { uint32_t lastDirtyMs_ = 0; uint32_t lastSaveMs_ = 0; char lastSaveStr_[24] = "never"; // "lastSaved" read-only control value + uint32_t fsUsedVal_ = 0; // "filesystem" progress: bytes used, refreshed in loop1s + uint32_t totalFsVal_ = 0; // "filesystem" progress: partition total, read once in onBuildControls // Shared load/save buffer β€” load runs once at boot (phase 2), save runs in loop1s after // the 2s debounce. Mutually exclusive, so one buffer is enough. Kept off the task stack // since 2KB plus recursive applyNode/writeNode frames is uncomfortably close to the ESP32 diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index fc50e2bf..3292b8b4 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -46,8 +46,8 @@ class SystemModule : public MoonModule { // on the next WebSocket push. std::snprintf(chipInfo_, sizeof(chipInfo_), "%s", platform::chipModel()); std::snprintf(sdkInfo_, sizeof(sdkInfo_), "%s", platform::sdkVersion()); - std::snprintf(sdkDateInfo_, sizeof(sdkDateInfo_), "%s", platform::sdkDate()); - // version / build / firmware (firmware identity) moved to FirmwareUpdateModule. + // version / build / firmware (firmware identity, incl. the build date) moved to + // FirmwareUpdateModule β€” SystemModule keeps only the IDF version string (`sdk`). std::snprintf(bootReasonStr_, sizeof(bootReasonStr_), "%s", platform::resetReason()); if constexpr (platform::hasWifiCoprocessor) { std::snprintf(coprocStr_, sizeof(coprocStr_), "%s", platform::coprocessorWifi()); @@ -70,7 +70,6 @@ class SystemModule : public MoonModule { totalInternalVal_ = static_cast(platform::totalInternalHeap()); totalHeapVal_ = static_cast(platform::totalHeap()); chipFlashVal_ = static_cast(platform::flashChipSize()); - totalFsVal_ = static_cast(platform::filesystemTotal()); // Device name on top controls_.addText("deviceName", deviceName_, sizeof(deviceName_)); @@ -107,19 +106,16 @@ class SystemModule : public MoonModule { } controls_.addReadOnly("maxBlock", maxBlockStr_, sizeof(maxBlockStr_)); - // Flash/filesystem. (version / build / firmware / firmwarePartition moved to - // FirmwareUpdateModule β€” the firmware card owns firmware identity + partition usage.) + // Flash. (version / build / firmware / firmwarePartition moved to FirmwareUpdateModule β€” the + // firmware card owns firmware identity + partition usage; the filesystem-usage bar moved to + // FilesystemModule β€” the module that owns the filesystem.) if (chipFlashVal_ > 0) { controls_.addReadOnly("flash", flashStr_, sizeof(flashStr_)); } - if (totalFsVal_ > 0) { - controls_.addProgress("filesystem", fsUsedVal_, totalFsVal_); - } // Static info controls_.addReadOnly("chip", chipInfo_, sizeof(chipInfo_)); controls_.addReadOnly("sdk", sdkInfo_, sizeof(sdkInfo_)); - controls_.addReadOnly("sdkDate", sdkDateInfo_, sizeof(sdkDateInfo_)); controls_.addReadOnly("bootReason", bootReasonStr_, sizeof(bootReasonStr_)); // WiFi co-processor (P4 + on-board C6) firmware read-out. Gated at compile // time on hasWifiCoprocessor, so the whole control β€” and the snprintf/query @@ -176,8 +172,6 @@ class SystemModule : public MoonModule { uint32_t totalPsram = totalHeapVal_ > totalInternalVal_ ? totalHeapVal_ - totalInternalVal_ : 0; psramUsedVal_ = totalPsram > freePsram ? totalPsram - freePsram : 0; - fsUsedVal_ = static_cast(platform::filesystemUsed()); - // maxInternalAllocBlock β€” NOT maxAllocBlock. The internal-RAM block // is the scarce-resource KPI; the all-memory variant reports ~8 MB // on PSRAM-equipped boards (S3/S2) and tells the user nothing. @@ -242,19 +236,16 @@ class SystemModule : public MoonModule { char maxBlockStr_[12] = {}; uint32_t heapUsedVal_ = 0; uint32_t psramUsedVal_ = 0; - uint32_t fsUsedVal_ = 0; // Static (set in setup) char chipInfo_[16] = {}; char sdkInfo_[24] = {}; - char sdkDateInfo_[16] = {}; // IDF app-descriptor compile date, e.g. "May 26 2026" char bootReasonStr_[16] = {}; char coprocStr_[24] = {}; // WiFi co-processor status, e.g. "C6 fw 2.12.9" / "not detected" uint32_t totalInternalVal_ = 0; uint32_t totalHeapVal_ = 0; char flashStr_[12] = {}; uint32_t chipFlashVal_ = 0; // total chip flash - uint32_t totalFsVal_ = 0; }; } // namespace mm diff --git a/src/core/color.h b/src/core/color.h index 4eb095e2..874b37d0 100644 --- a/src/core/color.h +++ b/src/core/color.h @@ -33,55 +33,11 @@ constexpr RGB hsvToRgb(uint8_t h, uint8_t s, uint8_t v) { } } -// scale8: (val * scale) / 256, with +1 correction so scale8(x, 255) == x +// scale8: (val * scale) / 256, with +1 correction so scale8(x, 255) == x. The fundamental +// channel-scale op (brightness, blend), kept here with the colour type it scales. Integer +// trig (sin8/cos8/atan2_8/dist8) and the rest of the 8-bit math library live in math8.h. constexpr uint8_t scale8(uint8_t val, uint8_t scale) { return static_cast(((static_cast(val) * static_cast(scale)) + 1 + ((static_cast(val) * static_cast(scale)) >> 8)) >> 8); } -// 256-entry sine LUT: sin(2*pi*i/256)*127+128, stored in flash. -// Integer-only sin/cos for effects. Use sin8 for sine, cos8 = sin8(i + 64). -inline constexpr uint8_t sin8_lut[256] = { - 128,131,134,137,140,144,147,150,153,156,159,162,165,168,171,174, - 177,179,182,185,188,191,193,196,199,201,204,206,209,211,213,216, - 218,220,222,224,226,228,230,232,234,235,237,239,240,241,243,244, - 245,246,248,249,250,250,251,252,253,253,254,254,254,255,255,255, - 255,255,255,255,254,254,254,253,253,252,251,250,250,249,248,246, - 245,244,243,241,240,239,237,235,234,232,230,228,226,224,222,220, - 218,216,213,211,209,206,204,201,199,196,193,191,188,185,182,179, - 177,174,171,168,165,162,159,156,153,150,147,144,140,137,134,131, - 128,125,122,119,116,112,109,106,103,100,97,94,91,88,85,82, - 79,77,74,71,68,65,63,60,57,55,52,50,47,45,43,40, - 38,36,34,32,30,28,26,24,22,21,19,17,16,15,13,12, - 11,10,8,7,6,6,5,4,3,3,2,2,2,1,1,1, - 1,1,1,1,2,2,2,3,3,4,5,6,6,7,8,10, - 11,12,13,15,16,17,19,21,22,24,26,28,30,32,34,36, - 38,40,43,45,47,50,52,55,57,60,63,65,68,71,74,77, - 79,82,85,88,91,94,97,100,103,106,109,112,116,119,122,125 -}; - -constexpr uint8_t sin8(uint8_t i) { return sin8_lut[i]; } -constexpr uint8_t cos8(uint8_t i) { return sin8_lut[static_cast(i + 64)]; } - -// Fast octant atan2: returns 0-255 for full circle (y, x) in -32768..32767 -constexpr uint8_t atan2_8(int16_t y, int16_t x) { - uint8_t r = 0; - if (y < 0) { y = static_cast(-y); r = 0x80; } - if (x < 0) { x = static_cast(-x); r = static_cast(r | 0x40); } - uint8_t offset = (x > y) ? 0 : 32; - if (x < y) { - int16_t t = y; - y = x; - x = t; - } - uint8_t b = (x == 0) ? 0 : static_cast((static_cast(y) * 64) / static_cast(x)); - return static_cast(r + offset + b); -} - -// Octagonal distance approximation (no sqrt) -constexpr uint8_t dist8(int16_t dx, int16_t dy) { - int16_t ax = dx < 0 ? static_cast(-dx) : dx; - int16_t ay = dy < 0 ? static_cast(-dy) : dy; - return static_cast(ax > ay ? ax + (ay >> 1) : ay + (ax >> 1)); -} - } // namespace mm diff --git a/src/core/crc.h b/src/core/crc.h new file mode 100644 index 00000000..a107613c --- /dev/null +++ b/src/core/crc.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +// CRC-16/CCITT-FALSE: a 16-bit checksum over a byte span. Used as a cheap, well-distributed +// "fingerprint" of a block of state β€” e.g. Game of Life hashes its grid each generation to +// detect that the pattern has gone static or fallen into a short oscillation (the same CRC +// recurring) so it can respawn instead of looping forever. The textbook polynomial 0x1021, +// init 0xFFFF, no reflection β€” the recognisable CCITT-FALSE variant. +// +// Not a security hash (a CRC is trivially collidable); it's a fast change-detector, which is all +// the stasis check needs. Integer-only, no table (the bit-serial form is tiny and the call sites +// run off the hot path β€” once per generation, not per pixel). + +namespace mm { + +constexpr uint16_t crc16(const uint8_t* data, size_t len, uint16_t crc = 0xFFFFu) { + for (size_t i = 0; i < len; i++) { + crc = static_cast(crc ^ (static_cast(data[i]) << 8)); + for (uint8_t bit = 0; bit < 8; bit++) { + crc = (crc & 0x8000u) ? static_cast((crc << 1) ^ 0x1021u) + : static_cast(crc << 1); + } + } + return crc; +} + +} // namespace mm diff --git a/src/core/math8.h b/src/core/math8.h new file mode 100644 index 00000000..b9854e1f --- /dev/null +++ b/src/core/math8.h @@ -0,0 +1,160 @@ +#pragma once + +#include "core/color.h" // scale8 (nscale8 builds on it), RGB not needed here + +#include + +// 8-bit fixed-point math for LED effects: integer trig, beat/timing, saturating +// arithmetic, and a fast PRNG. The recognisable "lib8tion" surface β€” same names an +// embedded/LED developer knows β€” written fresh against projectMM's architecture. +// +// Prior art: FastLED's lib8tion (Mark Kriegsman). We carry the ideas + names +// (sin8/beatsin8/qadd8/nscale8/random8) and the textbook algorithms; the code is ours. +// +// All integer, LUT-backed where it pays, no float, no heap β€” safe in the render loop. +// Time-dependent helpers (beat8/beatsin8) take the current time in ms as a parameter so +// this stays platform-agnostic (the caller passes elapsed()/platform::millis(), keeping +// the time source at the domain edge, not buried in core). + +namespace mm { + +// --- Integer trig (256-step circle) ----------------------------------------- +// 256-entry sine LUT: sin(2*pi*i/256)*127+128, in flash. sin8 for sine, cos8 = sin8(i+64). +inline constexpr uint8_t sin8_lut[256] = { + 128,131,134,137,140,144,147,150,153,156,159,162,165,168,171,174, + 177,179,182,185,188,191,193,196,199,201,204,206,209,211,213,216, + 218,220,222,224,226,228,230,232,234,235,237,239,240,241,243,244, + 245,246,248,249,250,250,251,252,253,253,254,254,254,255,255,255, + 255,255,255,255,254,254,254,253,253,252,251,250,250,249,248,246, + 245,244,243,241,240,239,237,235,234,232,230,228,226,224,222,220, + 218,216,213,211,209,206,204,201,199,196,193,191,188,185,182,179, + 177,174,171,168,165,162,159,156,153,150,147,144,140,137,134,131, + 128,125,122,119,116,112,109,106,103,100,97,94,91,88,85,82, + 79,77,74,71,68,65,63,60,57,55,52,50,47,45,43,40, + 38,36,34,32,30,28,26,24,22,21,19,17,16,15,13,12, + 11,10,8,7,6,6,5,4,3,3,2,2,2,1,1,1, + 1,1,1,1,2,2,2,3,3,4,5,6,6,7,8,10, + 11,12,13,15,16,17,19,21,22,24,26,28,30,32,34,36, + 38,40,43,45,47,50,52,55,57,60,63,65,68,71,74,77, + 79,82,85,88,91,94,97,100,103,106,109,112,116,119,122,125 +}; + +constexpr uint8_t sin8(uint8_t i) { return sin8_lut[i]; } +constexpr uint8_t cos8(uint8_t i) { return sin8_lut[static_cast(i + 64)]; } + +// Triangle wave: 0β†’255 over the first half of the cycle, 255β†’0 over the second (the +// textbook fold of a ramp). A cheaper, sharper alternative to sin8 for some effects. +constexpr uint8_t triwave8(uint8_t i) { + return i < 128 ? static_cast(i * 2) + : static_cast((255 - i) * 2); +} + +// Fast octant atan2: angle of (x, y) as 0..255 around the full circle (x, y in int16 range). +constexpr uint8_t atan2_8(int16_t y, int16_t x) { + uint8_t r = 0; + if (y < 0) { y = static_cast(-y); r = 0x80; } + if (x < 0) { x = static_cast(-x); r = static_cast(r | 0x40); } + uint8_t offset = (x > y) ? 0 : 32; + if (x < y) { int16_t t = y; y = x; x = t; } + uint8_t b = (x == 0) ? 0 : static_cast((static_cast(y) * 64) / static_cast(x)); + return static_cast(r + offset + b); +} + +// Octagonal distance approximation: |Ξ”| without a sqrt (max + half-min β€” the cheap 8-bit norm). +constexpr uint8_t dist8(int16_t dx, int16_t dy) { + int16_t ax = dx < 0 ? static_cast(-dx) : dx; + int16_t ay = dy < 0 ? static_cast(-dy) : dy; + return static_cast(ax > ay ? ax + (ay >> 1) : ay + (ax >> 1)); +} + +// --- Saturating + scaling arithmetic ---------------------------------------- +// qadd8/qsub8: add/subtract clamping at the 0..255 ends instead of wrapping β€” so a bright +// pixel + more stays white rather than rolling over to black (the LED-blend staple). +constexpr uint8_t qadd8(uint8_t a, uint8_t b) { + uint16_t t = static_cast(a) + b; + return t > 255 ? 255 : static_cast(t); +} +constexpr uint8_t qsub8(uint8_t a, uint8_t b) { + return a > b ? static_cast(a - b) : 0; +} + +// nscale8: scale a byte by a 0..255 fraction (n/256). Same as scale8 β€” the `nscale8` name +// is the recognisable in-place-channel-scale spelling; one definition, two names. +constexpr uint8_t nscale8(uint8_t val, uint8_t scale) { return scale8(val, scale); } + +// map8: rescale a 0..255 input onto the range [rangeStart, rangeEnd]. FastLED documents this as +// map8(in, lo, hi) == map(in, 0, 255, lo, hi), i.e. the input top (255) reaches `hi` exactly. The +// earlier `lo + scale8(in, hi-lo)` form used scale8's /256, which made `hi` unreachable and +// collapsed a one-step span (a bar height of 1 could never be reached) β€” the bug this fixes. Div by +// 255 (not >>8) so in=255 lands on hi; span held in uint16 so hi=255,lo=0 doesn't wrap. +// Used to turn an audio band (0..255) into a bar height, a line length, etc. +constexpr uint8_t map8(uint8_t in, uint8_t rangeStart, uint8_t rangeEnd) { + const uint16_t span = static_cast(rangeEnd - rangeStart); + return static_cast(rangeStart + (static_cast(in) * span) / 255u); +} + +// --- Timing / beat ---------------------------------------------------------- +// The time source is passed in (ms since boot, e.g. platform::millis()/elapsed()) so core stays +// platform-agnostic. `timebase` shifts the phase origin: an effect that wants its oscillation to +// start (or jump) at a chosen moment passes that moment as the timebase, so the beat is measured +// from there. This matches FastLED's beat8(bpm, timebase) / beatsin8(bpm,low,high,timebase,phase). + +// beat8: a 0..255 sawtooth completing `bpm` cycles per minute, measured from `timebase`. +constexpr uint8_t beat8(uint8_t bpm, uint32_t ms, uint32_t timebase = 0) { + if (bpm == 0) return 0; + const uint32_t period = 60000u / bpm; + if (period == 0) return 0; + const uint32_t pos = (ms - timebase) % period; + return static_cast((pos * 256u) / period); +} + +// beatsin8: a sine oscillating in [low,high] at `bpm`. `timebase` shifts the phase origin (in ms); +// `phase` adds a fixed 0..255 offset to the beat (a constant lead/lag). FastLED's signature +// (bpm, low, high, timebase, phase) β€” `ms` (the current time) is threaded as the first non-FastLED +// arg so the function stays time-source-agnostic. +constexpr uint8_t beatsin8(uint8_t bpm, uint32_t ms, uint8_t low = 0, uint8_t high = 255, + uint32_t timebase = 0, uint8_t phase = 0) { + const uint8_t beat = static_cast(beat8(bpm, ms, timebase) + phase); + const uint8_t s = sin8(beat); // 0..255 sine + const uint8_t range = static_cast(high - low); + return static_cast(low + scale8(s, range)); +} + +// beatsin16: 16-bit range version, for positions across a wide grid. +constexpr uint16_t beatsin16(uint8_t bpm, uint32_t ms, uint16_t low = 0, uint16_t high = 65535, + uint32_t timebase = 0, uint8_t phase = 0) { + const uint8_t beat = static_cast(beat8(bpm, ms, timebase) + phase); + const uint8_t s = sin8(beat); // 0..255 sine + const uint16_t range = static_cast(high - low); + return static_cast(low + ((static_cast(s) * range) >> 8)); +} + +// --- Fast PRNG -------------------------------------------------------------- +// A small seedable xorshift β€” hot-path-cheap and deterministic (NOT std::rand, which is +// slow, non-reentrant, and unseedable per-effect). Each effect owns a Random8 so its +// sequence is reproducible and independent. Same shape as FastLED's random8/16. +class Random8 { +public: + constexpr explicit Random8(uint32_t seed = 0x1234ABCDu) : state_(seed ? seed : 1u) {} + constexpr void seed(uint32_t s) { state_ = s ? s : 1u; } + constexpr uint8_t next8() { return static_cast(advance() >> 24); } + constexpr uint16_t next16() { return static_cast(advance() >> 16); } + // 0..bound-1 (bound>0): scale the 16-bit draw to avoid modulo bias on small bounds. + constexpr uint8_t below(uint8_t bound) { + return bound ? static_cast((static_cast(next16()) * bound) >> 16) : 0; + } + // min..max-1 (FastLED's random8(min,max)): an offset draw over the half-open range. + constexpr uint8_t below(uint8_t min, uint8_t max) { + return max > min ? static_cast(min + below(static_cast(max - min))) : min; + } +private: + constexpr uint32_t advance() { + state_ ^= state_ << 13; + state_ ^= state_ >> 17; + state_ ^= state_ << 5; + return state_; + } + uint32_t state_; +}; + +} // namespace mm diff --git a/src/core/noise.h b/src/core/noise.h new file mode 100644 index 00000000..480442da --- /dev/null +++ b/src/core/noise.h @@ -0,0 +1,85 @@ +#pragma once + +#include + +// Value noise: a smooth, deterministic pseudo-random field, the staple "organic motion" +// source for LED effects. inoise8 returns a 0..255 value that varies smoothly across space, +// so neighbouring coordinates give similar values (unlike a raw hash). Sample it across a +// grid for clouds/plasma/fire-like fields; scroll a coordinate (or pass a time offset) to +// animate. 1D, 2D and 3D variants share one hash + a smoothstep interpolation. +// +// Prior art: FastLED's inoise8 (Perlin/value noise; Mark Kriegsman / Ken Perlin's method). +// Same recognisable name + 0..255 contract; the hash + smoothstep + lerp here are ours, +// promoted from NoiseEffect's own implementation so every effect shares one field generator. +// +// Coordinates are 16.0 fixed scaled however the caller likes β€” the high byte selects the +// noise CELL, the low byte the interpolation position within it. So a larger coordinate step +// per pixel = finer noise (more cells across the grid); a smaller step = broader, smoother. + +namespace mm { +namespace noise { + +// Integer hash β†’ 0..255. Three coords (z=0 for 1D/2D) feed one well-mixed avalanche. +constexpr uint8_t hash(uint32_t x, uint32_t y, uint32_t z) { + uint32_t h = x * 1619u + y * 31337u + z * 6271u; + h = (h >> 13) ^ h; + h = h * (h * h * 60493u + 19990303u) + 1376312589u; + return static_cast((h >> 16) & 0xFF); +} + +// Smoothstep 3tΒ²βˆ’2tΒ³ on 0..255 β€” turns the linear cell fraction into an eased one so the +// field has no hard creases at cell boundaries (the difference between value noise and a +// blocky grid). +constexpr uint8_t smoothstep(uint8_t t) { + uint16_t t2 = static_cast(t) * t / 255; + uint16_t t3 = static_cast(t2) * t / 255; + return static_cast((3 * t2 - 2 * t3) & 0xFF); +} + +// Linear interpolate aβ†’b by t/255. +constexpr uint8_t lerp8(uint8_t a, uint8_t b, uint8_t t) { + int16_t delta = static_cast(b) - static_cast(a); + return static_cast(static_cast(a) + delta * t / 255); +} + +} // namespace noise + +// 1D value noise: x is a 16.0 fixed coordinate (high byte = cell, low byte = position). +constexpr uint8_t inoise8(uint32_t x) { + const uint32_t ix = x >> 8; + const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF)); + return noise::lerp8(noise::hash(ix, 0, 0), noise::hash(ix + 1, 0, 0), fx); +} + +// 2D value noise with bilinear interpolation over the 4 cell corners. +constexpr uint8_t inoise8(uint32_t x, uint32_t y) { + const uint32_t ix = x >> 8, iy = y >> 8; + const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF)); + const uint8_t fy = noise::smoothstep(static_cast(y & 0xFF)); + const uint8_t v00 = noise::hash(ix, iy, 0); + const uint8_t v10 = noise::hash(ix + 1, iy, 0); + const uint8_t v01 = noise::hash(ix, iy + 1, 0); + const uint8_t v11 = noise::hash(ix + 1, iy + 1, 0); + return noise::lerp8(noise::lerp8(v00, v10, fx), noise::lerp8(v01, v11, fx), fy); +} + +// 3D value noise with trilinear interpolation over the 8 cube corners. +constexpr uint8_t inoise8(uint32_t x, uint32_t y, uint32_t z) { + const uint32_t ix = x >> 8, iy = y >> 8, iz = z >> 8; + const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF)); + const uint8_t fy = noise::smoothstep(static_cast(y & 0xFF)); + const uint8_t fz = noise::smoothstep(static_cast(z & 0xFF)); + const uint8_t v000 = noise::hash(ix, iy, iz); + const uint8_t v100 = noise::hash(ix + 1, iy, iz); + const uint8_t v010 = noise::hash(ix, iy + 1, iz); + const uint8_t v110 = noise::hash(ix + 1, iy + 1, iz); + const uint8_t v001 = noise::hash(ix, iy, iz + 1); + const uint8_t v101 = noise::hash(ix + 1, iy, iz + 1); + const uint8_t v011 = noise::hash(ix, iy + 1, iz + 1); + const uint8_t v111 = noise::hash(ix + 1, iy + 1, iz + 1); + const uint8_t z0 = noise::lerp8(noise::lerp8(v000, v100, fx), noise::lerp8(v010, v110, fx), fy); + const uint8_t z1 = noise::lerp8(noise::lerp8(v001, v101, fx), noise::lerp8(v011, v111, fx), fy); + return noise::lerp8(z0, z1, fz); +} + +} // namespace mm diff --git a/src/light/Palette.h b/src/light/Palette.h new file mode 100644 index 00000000..c3b928df --- /dev/null +++ b/src/light/Palette.h @@ -0,0 +1,256 @@ +#pragma once + +#include "core/color.h" // RGB, scale8, hsvToRgb +#include "core/JsonSink.h" // paletteOptions emits the dropdown {name,colors} objects + +#include +#include + +namespace mm { + +// A colour palette: the active palette is 16 evenly-spaced RGB entries (the CRGBPalette16 model), +// and colorFromPalette() reads a 0-255 wheel index by interpolating between the two bracketing +// entries. The gradient definitions (a {pos,R,G,B,…} stop list) live in flash and expand into the +// 16 entries on selection, off the hot path; the per-light lookup is then a single scale8 blend. +// +// Prior art: FastLED's gradient palettes (CRGBPalette16 / ColorFromPalette), the convention WLED + +// MoonLight share β€” the recognisable names + model are carried; this implementation is our own, on +// our RGB/scale8. The gradient *data* in kBuiltinPalettes is from MoonLight's palettes.h (a public +// palette set), reformatted; see docs/backlog/moonlight-palettes-data.md. +struct Palette { + static constexpr uint8_t kEntries = 16; + RGB entry[kEntries] = {}; + + // Build the 16 entries from a gradient-stop list: {pos0,R,G,B, pos1,R,G,B, …} with pos 0..255, + // ascending, ending at 255. Each of the 16 evenly-spaced sample positions is the linear blend + // of the two stops it falls between. Off the hot path (called on selection). + void fromGradient(const uint8_t* stops, size_t count) { + const size_t nStops = count / 4; + if (nStops == 0) { for (auto& e : entry) e = {0, 0, 0}; return; } + for (uint8_t i = 0; i < kEntries; i++) { + // The sample position for entry i, spread 0..255 across the 16 entries. + const uint8_t pos = static_cast((static_cast(i) * 255) / (kEntries - 1)); + entry[i] = sampleGradient(stops, nStops, pos); + } + } + +private: + // The colour at `pos` (0..255) on the gradient: find the bracketing stops and lerp. + static RGB sampleGradient(const uint8_t* stops, size_t nStops, uint8_t pos) { + // Before the first stop (a gradient whose first stop sits above 0): clamp to it, so the + // `pos - p0` below can't underflow. + if (pos <= stops[0]) return {stops[1], stops[2], stops[3]}; + // Walk to the last stop whose position <= pos. + size_t s = 0; + while (s + 1 < nStops && stops[(s + 1) * 4] <= pos) s++; + const uint8_t* lo = stops + s * 4; + if (s + 1 >= nStops) return {lo[1], lo[2], lo[3]}; // at/after the last stop + const uint8_t* hi = stops + (s + 1) * 4; + const uint8_t p0 = lo[0], p1 = hi[0]; + if (p1 == p0) return {lo[1], lo[2], lo[3]}; + // Fraction of the way from lo to hi, as 0..255 for scale8. + const uint8_t frac = static_cast((static_cast(pos - p0) * 255) / (p1 - p0)); + return lerpRGB({lo[1], lo[2], lo[3]}, {hi[1], hi[2], hi[3]}, frac); + } + +public: + // Linear blend aβ†’b by frac (0 = a, 255 = b). Integer-only. + static RGB lerpRGB(const RGB& a, const RGB& b, uint8_t frac) { + const uint8_t inv = static_cast(255 - frac); + return { static_cast(scale8(a.r, inv) + scale8(b.r, frac)), + static_cast(scale8(a.g, inv) + scale8(b.g, frac)), + static_cast(scale8(a.b, inv) + scale8(b.b, frac)) }; + } +}; + +// The per-light lookup: `index` is a 0..255 wheel position (wraps), mapped across the 16 entries; +// blend the two bracketing entries, then scale by `brightness`. Hot-path-cheap (two scale8 + a +// blend). This is the single seam every palette-driven effect calls, so the palette source is +// swappable behind one signature without touching effects. +inline RGB colorFromPalette(const Palette& p, uint8_t index, uint8_t brightness = 255) { + // Position across 16 entries: the high nibble selects the entry, the low byte the blend. + const uint8_t hi = static_cast(index >> 4); // 0..15 β€” bracket start + const uint8_t frac = static_cast((index & 0x0F) * 17); // 0..255 within the bracket + const RGB& a = p.entry[hi]; + const RGB& b = p.entry[(hi + 1) & 0x0F]; // wrap 15β†’0 + RGB c = Palette::lerpRGB(a, b, frac); + if (brightness != 255) { + c.r = scale8(c.r, brightness); + c.g = scale8(c.g, brightness); + c.b = scale8(c.b, brightness); + } + return c; +} + +// Cross-fade two colours: `amt`/255 of the way from `a` to `b` (amt 0 = a, 255 = b). The textbook +// RGB lerp, the staple for compositing/transitions. Prior art: FastLED's blend (colorutils). +inline RGB blend(RGB a, RGB b, uint8_t amt) { return Palette::lerpRGB(a, b, amt); } + +// Dim a colour toward black by `amt`/255 (amt 0 = unchanged, 255 = black) β€” the per-frame fade +// that gives effects a decaying trail. Prior art: FastLED's fadeToBlackBy. +inline void fadeToBlackBy(RGB& c, uint8_t amt) { + const uint8_t keep = static_cast(255 - amt); + c.r = scale8(c.r, keep); + c.g = scale8(c.g, keep); + c.b = scale8(c.b, keep); +} + +// --- Built-in palettes ------------------------------------------------------------------------- +// Gradient-stop definitions in flash ({pos,R,G,B,…}). The full MoonLight set: the gradient *data* +// is from MoonLight's palettes.h (a public palette set, the WLED/SoundReactive gradient lineage), +// reformatted into our {pos,R,G,B} stop layout (source: MoonLight's Modules/palettes.h). The +// handful of procedurally-generated FastLED/MoonLight entries (Rainbow, Party, …) MoonLight builds +// from code rather than a gradient array; we generate the equivalents the same way (rainbow via +// hsvToRgb, the rest from representative stops) so the named set a MoonLight user knows is present. +namespace palettes { + +// Gradient definitions β€” verbatim {pos,R,G,B,…} from MoonLight's palettes.h, names kept recognisable. +inline constexpr uint8_t kParty[] = {0,85,0,171, 42,150,0,107, 85,201,0,42, 128,212,32,0, 170,191,98,0, 213,128,160,0, 255,85,212,0}; // FastLED party-colors stops +inline constexpr uint8_t kForest[] = {0,0,100,0, 64,34,139,34, 128,0,128,0, 192,107,142,35, 255,0,100,0}; +inline constexpr uint8_t kLava[] = {0,0,0,0, 46,18,0,0, 96,113,0,0, 108,142,3,1, 119,175,17,1, 146,213,44,2, 174,255,82,4, 188,255,115,4, 202,255,156,4, 218,255,203,4, 234,255,255,4, 244,255,255,71, 255,255,255,255}; +inline constexpr uint8_t kOceanBreeze[] = {0,1,6,7, 89,1,99,111, 153,144,209,255, 255,0,73,82}; +inline constexpr uint8_t kFierceIce[] = {0,0,0,0, 59,0,9,45, 119,0,38,255, 149,3,100,255, 180,23,199,255, 217,100,235,255, 255,255,255,255}; +inline constexpr uint8_t kSunset[] = {0,120,0,0, 22,179,22,0, 51,255,104,0, 85,167,22,18, 135,100,0,103, 198,16,0,130, 255,0,0,160}; +inline constexpr uint8_t kSunset2[] = {0,10,62,123, 36,56,130,103, 87,153,225,85, 100,199,217,68, 107,255,207,54, 115,247,152,57, 120,239,107,61, 128,247,152,57, 180,255,207,54, 223,255,227,48, 255,255,248,42}; +inline constexpr uint8_t kOrangeTeal[] = {0,0,150,92, 55,0,150,92, 200,255,72,0, 255,255,72,0}; +inline constexpr uint8_t kAurora[] = {0,1,5,45, 64,0,200,23, 128,0,255,0, 170,0,243,45, 200,0,135,7, 255,1,5,45}; +inline constexpr uint8_t kAurora2[] = {0,17,177,13, 64,121,242,5, 128,25,173,121, 192,250,77,127, 255,171,101,221}; +inline constexpr uint8_t kAtlantica[] = {0,0,28,112, 50,32,96,255, 100,0,243,45, 150,12,95,82, 200,25,190,95, 255,40,170,80}; +inline constexpr uint8_t kAnalogous[] = {0,3,0,255, 63,23,0,255, 127,67,0,255, 191,142,0,45, 255,255,0,0}; +inline constexpr uint8_t kAprilNight[] = {0,1,5,45, 10,1,5,45, 25,5,169,175, 40,1,5,45, 61,1,5,45, 76,45,175,31, 91,1,5,45, 112,1,5,45, 127,249,150,5, 143,1,5,45, 162,1,5,45, 178,255,92,0, 193,1,5,45, 214,1,5,45, 229,223,45,72, 244,1,5,45, 255,1,5,45}; +inline constexpr uint8_t kAquaFlash[] = {0,0,0,0, 66,57,227,233, 96,255,255,8, 124,255,255,255, 153,255,255,8, 188,57,227,233, 255,0,0,0}; +inline constexpr uint8_t kAutumn[] = {0,26,1,1, 51,67,4,1, 84,118,14,1, 104,137,152,52, 112,113,65,1, 122,133,149,59, 124,137,152,52, 135,113,65,1, 142,139,154,46, 163,113,13,1, 204,55,3,1, 249,17,1,1, 255,17,1,1}; +inline constexpr uint8_t kBeech[] = {0,255,252,214, 12,255,252,214, 22,255,252,214, 26,190,191,115, 28,137,141,52, 28,112,255,205, 50,51,246,214, 71,17,235,226, 93,2,193,199, 120,0,156,174, 133,1,101,115, 136,1,59,71, 136,7,131,170, 208,1,90,151, 255,0,56,133}; +inline constexpr uint8_t kBlinkRed[] = {0,1,1,1, 43,4,1,11, 76,10,1,3, 109,161,4,29, 127,255,86,123, 165,125,16,160, 204,35,13,223, 255,18,2,18}; +inline constexpr uint8_t kC9[] = {0,184,4,0, 60,184,4,0, 65,144,44,2, 125,144,44,2, 130,4,96,2, 190,4,96,2, 195,7,7,88, 255,7,7,88}; +inline constexpr uint8_t kC9_2[] = {0,6,126,2, 45,6,126,2, 45,4,30,114, 90,4,30,114, 90,255,5,0, 135,255,5,0, 135,196,57,2, 180,196,57,2, 180,137,85,2, 255,137,85,2}; +inline constexpr uint8_t kC9New[] = {0,255,5,0, 60,255,5,0, 60,196,57,2, 120,196,57,2, 120,6,126,2, 180,6,126,2, 180,4,30,114, 255,4,30,114}; +inline constexpr uint8_t kCandy[] = {0,229,227,1, 15,227,101,3, 142,40,1,80, 198,17,1,79, 255,0,0,45}; +inline constexpr uint8_t kCandy2[] = {0,39,33,34, 25,4,6,15, 48,49,29,22, 73,224,173,1, 89,177,35,5, 130,4,6,15, 163,255,114,6, 186,224,173,1, 211,39,33,34, 255,1,1,1}; +inline constexpr uint8_t kColorfull[] = {0,10,85,5, 25,29,109,18, 60,59,138,42, 93,83,99,52, 106,110,66,64, 109,123,49,65, 113,139,35,66, 116,192,117,98, 124,255,255,137, 168,100,180,155, 255,22,121,174}; +inline constexpr uint8_t kDeparture[] = {0,8,3,0, 42,23,7,0, 63,75,38,6, 84,169,99,38, 106,213,169,119, 116,255,255,255, 138,135,255,138, 148,22,255,24, 170,0,255,0, 191,0,136,0, 212,0,55,0, 255,0,55,0}; +inline constexpr uint8_t kDrywet[] = {0,47,30,2, 42,213,147,24, 84,103,219,52, 127,3,219,207, 170,1,48,214, 212,1,1,111, 255,1,7,33}; +inline constexpr uint8_t kFairyReaf[] = {0,184,1,128, 160,1,193,182, 219,153,227,190, 255,255,255,255}; +inline constexpr uint8_t kGrintage[] = {0,2,1,1, 53,18,1,0, 104,69,29,1, 153,167,135,10, 255,46,56,4}; // es_vintage_57 +inline constexpr uint8_t kHult[] = {0,247,176,247, 48,255,136,255, 89,220,29,226, 160,7,82,178, 216,1,124,109, 255,1,124,109}; +inline constexpr uint8_t kHult64[] = {0,1,124,109, 66,1,93,79, 104,52,65,1, 130,115,127,1, 150,52,65,1, 201,1,86,72, 239,0,55,45, 255,0,55,45}; +inline constexpr uint8_t kJul[] = {0,194,1,1, 94,1,29,18, 132,57,131,28, 255,113,1,1}; +inline constexpr uint8_t kLandscape[] = {0,0,0,0, 37,2,25,1, 76,15,115,5, 127,79,213,1, 128,126,211,47, 130,188,209,247, 153,144,182,205, 204,59,117,250, 255,1,37,192}; +inline constexpr uint8_t kLightPink[] = {0,19,2,39, 25,26,4,45, 51,33,6,52, 76,68,62,125, 102,118,187,240, 109,163,215,247, 114,217,244,255, 122,159,149,221, 149,113,78,188, 183,128,57,155, 255,146,40,123}; // Pink_Purple +inline constexpr uint8_t kLiteLight[] = {0,0,0,0, 9,1,1,1, 40,5,5,6, 66,5,5,6, 101,10,1,12, 255,0,0,0}; +inline constexpr uint8_t kMagenta[] = {0,0,0,0, 42,0,0,45, 84,0,0,255, 127,42,0,255, 170,255,0,255, 212,255,55,255, 255,255,255,255}; // BlacK_Blue_Magenta_White +inline constexpr uint8_t kMagred[] = {0,0,0,0, 63,42,0,45, 127,255,0,255, 191,255,0,45, 255,255,0,0}; // BlacK_Magenta_Red +inline constexpr uint8_t kOrangery[] = {0,255,95,23, 30,255,82,0, 60,223,13,8, 90,144,44,2, 120,255,110,17, 150,255,69,0, 180,158,13,11, 210,241,82,17, 255,213,37,4}; +inline constexpr uint8_t kPastel[] = {0,0,0,255, 63,0,55,255, 127,0,255,255, 191,42,255,45, 255,255,255,0}; // Blue_Cyan_Yellow +inline constexpr uint8_t kPinkCandy[] = {0,255,255,255, 45,7,12,255, 112,227,1,127, 112,227,1,127, 140,255,255,255, 155,227,1,127, 196,45,1,99, 255,255,255,255}; +inline constexpr uint8_t kRedBlue[] = {0,0,0,0, 42,42,0,0, 84,255,0,0, 127,255,0,45, 170,255,0,255, 212,255,55,45, 255,255,255,0}; // BlacK_Red_Magenta_Yellow +inline constexpr uint8_t kRedFlash[] = {0,0,0,0, 99,227,1,1, 130,249,199,95, 155,227,1,1, 255,0,0,0}; +inline constexpr uint8_t kRedReaf[] = {0,3,13,43, 104,78,141,240, 188,255,0,0, 255,28,1,1}; +inline constexpr uint8_t kRedShift[] = {0,31,1,27, 45,34,1,16, 99,137,5,9, 132,213,128,10, 175,199,22,1, 201,199,9,6, 255,1,0,1}; +inline constexpr uint8_t kRedTide[] = {0,247,5,0, 28,255,67,1, 43,234,88,11, 58,234,176,51, 84,229,28,1, 114,113,12,1, 140,255,225,44, 168,113,12,1, 196,244,209,88, 216,255,28,1, 255,53,1,1}; +inline constexpr uint8_t kRetroClown[] = {0,227,101,3, 117,194,18,19, 255,92,8,192}; +inline constexpr uint8_t kRewhi[] = {0,188,135,1, 255,46,7,1}; // retro2_16 +inline constexpr uint8_t kRivendell[] = {0,1,14,5, 101,16,36,14, 165,56,68,30, 242,150,156,99, 255,150,156,99}; +inline constexpr uint8_t kSakura[] = {0,196,19,10, 65,255,69,45, 130,223,45,72, 195,255,82,103, 255,223,13,17}; +inline constexpr uint8_t kSemiBlue[] = {0,0,0,0, 12,1,1,3, 53,8,1,22, 80,4,6,89, 119,2,25,216, 145,7,10,99, 186,15,2,31, 233,2,1,5, 255,0,0,0}; +inline constexpr uint8_t kSherbet[] = {0,255,33,4, 43,255,68,25, 86,255,7,25, 127,255,82,103, 170,255,255,242, 209,42,255,22, 255,87,255,65}; // rainbowsherbet +inline constexpr uint8_t kSplash[] = {0,126,11,255, 127,197,1,22, 175,210,157,172, 221,157,3,112, 255,157,3,112}; // es_pinksplash_08 +inline constexpr uint8_t kTemperature[] = {0,1,27,105, 14,1,40,127, 28,1,70,168, 42,1,92,197, 56,1,119,221, 70,3,130,151, 84,23,156,149, 99,67,182,112, 113,121,201,52, 127,142,203,11, 141,224,223,1, 155,252,187,2, 170,247,147,1, 184,237,87,1, 198,229,43,1, 226,171,2,2, 240,80,3,3, 255,80,3,3}; +inline constexpr uint8_t kTertiary[] = {0,0,1,255, 63,3,68,45, 127,23,255,0, 191,100,68,1, 255,255,1,4}; +inline constexpr uint8_t kTiamat[] = {0,1,2,14, 33,2,5,35, 100,13,135,92, 120,43,255,193, 140,247,7,249, 160,193,17,208, 180,39,255,154, 200,4,213,236, 220,39,252,135, 240,193,213,253, 255,255,249,255}; +inline constexpr uint8_t kToxyReaf[] = {0,1,221,53, 255,73,3,178}; +inline constexpr uint8_t kVintage[] = {0,4,1,1, 51,16,0,1, 76,97,104,3, 101,255,131,19, 127,67,9,4, 153,16,0,1, 229,4,1,1, 255,4,1,1}; // es_vintage_01 +inline constexpr uint8_t kYelbluHot[] = {0,4,2,9, 58,16,0,47, 122,24,0,16, 158,144,9,1, 183,179,45,1, 219,220,114,2, 255,234,237,1}; +inline constexpr uint8_t kYelblu[] = {0,0,0,0, 42,0,0,45, 84,0,0,255, 127,42,0,255, 170,255,0,255, 212,255,55,255, 255,255,255,255}; // GMT_drywet-adjacent yelblu lineage +inline constexpr uint8_t kYelmag[] = {0,4,1,70, 31,55,1,30, 63,255,4,7, 95,59,2,29, 127,11,3,50, 159,39,8,60, 191,112,19,40, 223,78,11,39, 255,29,8,59}; // rgi_15 +inline constexpr uint8_t kYellowout[] = {0,0,1,255, 63,0,55,255, 127,0,255,255, 191,42,255,45, 255,255,0,0}; + +// A built-in is a gradient ({stops,len}) or the special "rainbow" (generated via hsvToRgb). +struct Builtin { const char* name; const uint8_t* stops; size_t len; bool rainbow; }; + +#define MM_PAL(name, arr) {name, arr, sizeof(arr), false} +inline constexpr Builtin kBuiltins[] = { + {"Rainbow", nullptr, 0, true}, + MM_PAL("Party", kParty), MM_PAL("Lava", kLava), + MM_PAL("Ocean", kOceanBreeze), MM_PAL("Forest", kForest), + MM_PAL("Fierce Ice", kFierceIce), MM_PAL("Sunset", kSunset), + MM_PAL("Sunset 2", kSunset2), MM_PAL("Orange & Teal",kOrangeTeal), + MM_PAL("Aurora", kAurora), MM_PAL("Aurora 2", kAurora2), + MM_PAL("Atlantica", kAtlantica), MM_PAL("Analogous", kAnalogous), + MM_PAL("April Night", kAprilNight), MM_PAL("Aqua Flash", kAquaFlash), + MM_PAL("Autumn", kAutumn), MM_PAL("Beech", kBeech), + MM_PAL("Blink Red", kBlinkRed), MM_PAL("C9", kC9), + MM_PAL("C9 2", kC9_2), MM_PAL("C9 New", kC9New), + MM_PAL("Candy", kCandy), MM_PAL("Candy2", kCandy2), + MM_PAL("Colorfull", kColorfull), MM_PAL("Departure", kDeparture), + MM_PAL("Drywet", kDrywet), MM_PAL("Fairy Reaf", kFairyReaf), + MM_PAL("Grintage", kGrintage), MM_PAL("Hult", kHult), + MM_PAL("Hult 64", kHult64), MM_PAL("Jul", kJul), + MM_PAL("Landscape", kLandscape), MM_PAL("Light Pink", kLightPink), + MM_PAL("Lite Light", kLiteLight), MM_PAL("Magenta", kMagenta), + MM_PAL("Magred", kMagred), MM_PAL("Orangery", kOrangery), + MM_PAL("Pastel", kPastel), MM_PAL("Pink Candy", kPinkCandy), + MM_PAL("Red & Blue", kRedBlue), MM_PAL("Red Flash", kRedFlash), + MM_PAL("Red Reaf", kRedReaf), MM_PAL("Red Shift", kRedShift), + MM_PAL("Red Tide", kRedTide), MM_PAL("Retro Clown", kRetroClown), + MM_PAL("Rewhi", kRewhi), MM_PAL("Rivendell", kRivendell), + MM_PAL("Sakura", kSakura), MM_PAL("Semi Blue", kSemiBlue), + MM_PAL("Sherbet", kSherbet), MM_PAL("Splash", kSplash), + MM_PAL("Temperature", kTemperature), MM_PAL("Tertiary", kTertiary), + MM_PAL("Tiamat", kTiamat), MM_PAL("Toxy Reaf", kToxyReaf), + MM_PAL("Vintage", kVintage), MM_PAL("Yelblu Hot", kYelbluHot), + MM_PAL("Yelblu", kYelblu), MM_PAL("Yelmag", kYelmag), + MM_PAL("Yellowout", kYellowout), +}; +#undef MM_PAL +inline constexpr uint8_t kCount = sizeof(kBuiltins) / sizeof(kBuiltins[0]); + +} // namespace palettes + +// The global active palette effects read β€” the AudioModule::latestFrame() static-seam pattern. +// Drivers owns the `palette` select control and calls setActive() on change; effects just call +// colorFromPalette(*Palettes::active(), idx). +class Palettes { +public: + static const Palette* active() { return &active_; } + + // Expand built-in `index` into the active palette (off the hot path β€” on selection). + static void setActive(uint8_t index) { + active_ = fromBuiltin(index); + } + + // Build the 16-entry palette for built-in `index` (rainbow generated via hsvToRgb, the rest + // expanded from their gradient stops). Shared by setActive() and the default + the swatches. + static Palette fromBuiltin(uint8_t index) { + if (index >= palettes::kCount) index = 0; + const auto& b = palettes::kBuiltins[index]; + Palette p; + if (b.rainbow) { + for (uint8_t i = 0; i < Palette::kEntries; i++) + p.entry[i] = hsvToRgb(static_cast((static_cast(i) * 256) / Palette::kEntries), 255, 255); + } else { + p.fromGradient(b.stops, b.len); + } + return p; + } + +private: + // Default to a full rainbow (index 0): always colourful, so an effect renders visible output + // before any palette is selected. setActive() (Drivers setup) overrides from the saved index. + static inline Palette active_ = fromBuiltin(0); +}; + +// Emit the palette dropdown's options for a ControlType::Palette control (the PaletteOptionsFn): +// one {"name":…,"colors":"rrggbb rrggbb …"} object per built-in, the colours being the 16 entries +// as space-separated hex so the UI renders each option as a gradient swatch. +inline void paletteOptions(JsonSink& sink) { + for (uint8_t i = 0; i < palettes::kCount; i++) { + const Palette p = Palettes::fromBuiltin(i); + sink.appendf("%s{\"name\":\"%s\",\"colors\":\"", i > 0 ? "," : "", palettes::kBuiltins[i].name); + for (uint8_t e = 0; e < Palette::kEntries; e++) + sink.appendf("%s%02x%02x%02x", e > 0 ? " " : "", p.entry[e].r, p.entry[e].g, p.entry[e].b); + sink.append("\"}"); + } +} + +} // namespace mm diff --git a/src/light/draw.h b/src/light/draw.h new file mode 100644 index 00000000..246bc81d --- /dev/null +++ b/src/light/draw.h @@ -0,0 +1,264 @@ +#pragma once + +#include "light/light_types.h" // Coord3D, lengthType +#include "light/layers/Buffer.h" // Buffer (flat light array) +#include "core/color.h" // RGB, scale8 +#include "core/math8.h" // qadd8 β€” saturating add for blur's seep accumulation +#include "light/Palette.h" // blend(RGB,RGB,amt) β€” for blendPixel +#include "light/fonts.h" // fonts::Font β€” bitmap glyph tables for draw::text + +// Geometry draw primitives for effects/modifiers: set a pixel, draw a line β€” bounds-clipped, +// integer-only, working 1Dβ†’3D against the flat light Buffer. The "core absorbs the hard part" +// rule applied to drawing: the Bresenham + clipping lives here once, so an effect calls +// drawLine() instead of re-rolling it. Light-domain (it touches the light Buffer), not core. +// +// Prior art: the line algorithm is Bresenham (1962) generalised to 3D (the textbook DDA-error +// form). FastLED keeps draw in its 2D/matrix add-ons, not core β€” same split here. +// +// The Buffer is a flat array of `count` lights Γ— `cpl` channels; the grid SHAPE (w,h,d) lives on +// the Layer/Layout, so the caller passes `dims` (the Coord3D extent). Index order matches the +// engine: off = (zΒ·hΒ·w + yΒ·w + x)Β·cpl. A pixel outside [0,w)Γ—[0,h)Γ—[0,d) is silently clipped, so +// a line that runs off the grid just stops drawing β€” no out-of-bounds write (the robustness rule). + +namespace mm { +namespace draw { + +// One pixel, clipped to the grid. Writes R/G/B where channels fit (cpl may be 1..N); extra +// channels (e.g. a W in RGBW) are left as-is β€” the driver derives white, same as effects do. +inline void pixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) { + if (p.x < 0 || p.y < 0 || p.z < 0 || p.x >= dims.x || p.y >= dims.y || p.z >= dims.z) return; + const uint8_t cpl = buf.channelsPerLight(); + const size_t off = (static_cast(p.z) * dims.y * dims.x + + static_cast(p.y) * dims.x + p.x) * cpl; + if (off + (cpl < 3 ? cpl : 3) > buf.bytes()) return; // defends a dims/buffer mismatch + uint8_t* d = buf.data(); + if (cpl >= 1) d[off + 0] = c.r; + if (cpl >= 2) d[off + 1] = c.g; + if (cpl >= 3) d[off + 2] = c.b; +} + +// A straight line aβ†’b, clipped to the grid. 3D Bresenham: step along the dominant axis and carry +// an integer error term per other axis (the textbook generalisation of the 2D line). Works for +// 1D (a row), 2D (a plane), and 3D (a volume) without special-casing β€” a degenerate axis just +// never steps. Endpoints are inclusive. +// +// `shorten` (0..255, default 255 = full line) draws only the first shorten/255 of the way from a +// toward b β€” the far endpoint is pulled back toward `a`. 255 = whole line, 128 β‰ˆ half, 1 = the +// start pixel, 0 = nothing. This is the perspective/length lever (MoonLight's `depth` param): +// effects animate the drawn tip by varying `shorten`, so a fixed pair of endpoints traces a +// sweeping partial segment over successive frames. (WLEDMM's *2-rounding shorten, generalised 3D.) +inline void line(Buffer& buf, Coord3D dims, Coord3D a, Coord3D b, RGB c, uint8_t shorten = 255) { + if (shorten == 0) return; + if (shorten < 255) { + // Pull b back toward a by shorten/255, with *2 rounding like WLEDMM. + const int bx = ((2 * int(b.x) - 2 * int(a.x)) * int(shorten)) / 255 + 2 * int(a.x); + const int by = ((2 * int(b.y) - 2 * int(a.y)) * int(shorten)) / 255 + 2 * int(a.y); + const int bz = ((2 * int(b.z) - 2 * int(a.z)) * int(shorten)) / 255 + 2 * int(a.z); + b = {static_cast((bx + 1) / 2), + static_cast((by + 1) / 2), + static_cast((bz + 1) / 2)}; + } + Coord3D p = a; + const lengthType dx = b.x > a.x ? static_cast(b.x - a.x) : static_cast(a.x - b.x); + const lengthType dy = b.y > a.y ? static_cast(b.y - a.y) : static_cast(a.y - b.y); + const lengthType dz = b.z > a.z ? static_cast(b.z - a.z) : static_cast(a.z - b.z); + const lengthType sx = b.x >= a.x ? 1 : -1; + const lengthType sy = b.y >= a.y ? 1 : -1; + const lengthType sz = b.z >= a.z ? 1 : -1; + + // Drive the loop off the longest axis; accumulate error toward the other two. + if (dx >= dy && dx >= dz) { + lengthType ey = static_cast(dx / 2), ez = ey; + for (;; p.x = static_cast(p.x + sx)) { + pixel(buf, dims, p, c); + if (p.x == b.x) break; + if ((ey = static_cast(ey - dy)) < 0) { ey = static_cast(ey + dx); p.y = static_cast(p.y + sy); } + if ((ez = static_cast(ez - dz)) < 0) { ez = static_cast(ez + dx); p.z = static_cast(p.z + sz); } + } + } else if (dy >= dz) { + lengthType ex = static_cast(dy / 2), ez = ex; + for (;; p.y = static_cast(p.y + sy)) { + pixel(buf, dims, p, c); + if (p.y == b.y) break; + if ((ex = static_cast(ex - dx)) < 0) { ex = static_cast(ex + dy); p.x = static_cast(p.x + sx); } + if ((ez = static_cast(ez - dz)) < 0) { ez = static_cast(ez + dy); p.z = static_cast(p.z + sz); } + } + } else { + lengthType ex = static_cast(dz / 2), ey = ex; + for (;; p.z = static_cast(p.z + sz)) { + pixel(buf, dims, p, c); + if (p.z == b.z) break; + if ((ex = static_cast(ex - dx)) < 0) { ex = static_cast(ex + dz); p.x = static_cast(p.x + sx); } + if ((ey = static_cast(ey - dy)) < 0) { ey = static_cast(ey + dz); p.y = static_cast(p.y + sy); } + } + } +} + +// --- Buffer read/modify helpers -------------------------------------------- +// The offset of a pixel in the flat buffer, or buf.bytes() if out of bounds (caller checks). +inline size_t offsetOf(const Buffer& buf, Coord3D dims, Coord3D p) { + if (p.x < 0 || p.y < 0 || p.z < 0 || p.x >= dims.x || p.y >= dims.y || p.z >= dims.z) return buf.bytes(); + return (static_cast(p.z) * dims.y * dims.x + static_cast(p.y) * dims.x + p.x) + * buf.channelsPerLight(); +} + +// Read the RGB at a pixel (black if out of bounds / fewer than 3 channels). +inline RGB get(const Buffer& buf, Coord3D dims, Coord3D p) { + const size_t off = offsetOf(buf, dims, p); + if (off + 2 >= buf.bytes()) return {0, 0, 0}; + const uint8_t* d = buf.data(); + return {d[off + 0], d[off + 1], d[off + 2]}; +} + +// Blend a colour into a pixel by amt/255 (amt 0 = leave as-is, 255 = replace). The in-place +// read-modify-write that GoL's dead-cell fade-to-background and age-toward-red use +// (MoonLight's blendColor). Clipped like pixel(). +inline void blendPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c, uint8_t amt) { + const size_t off = offsetOf(buf, dims, p); + if (off + 2 >= buf.bytes()) return; + uint8_t* d = buf.data(); + const RGB cur{d[off + 0], d[off + 1], d[off + 2]}; + const RGB out = blend(cur, c, amt); + d[off + 0] = out.r; d[off + 1] = out.g; d[off + 2] = out.b; +} + +// Add a colour into a pixel, saturating (a bright pixel can't wrap to dark) β€” WLED's addRGB / additive +// setPixelColor. Used to re-stamp a light on top of a blur so its centre stays bright. Clipped like pixel(). +inline void addPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) { + const size_t off = offsetOf(buf, dims, p); + if (off + 2 >= buf.bytes()) return; + uint8_t* d = buf.data(); + d[off + 0] = qadd8(d[off + 0], c.r); + d[off + 1] = qadd8(d[off + 1], c.g); + d[off + 2] = qadd8(d[off + 2], c.b); +} + +// Fade the whole buffer toward black by amt/255 β€” one pass over the bytes. This is the primitive the +// Layer's once-per-frame collected fade (Layer::fadeToBlackBy) applies; effects request a fade through +// the Layer (which MINs the amount across effects and calls this once) rather than calling it directly. +inline void fade(Buffer& buf, uint8_t amt) { + const uint8_t keep = static_cast(255 - amt); + uint8_t* d = buf.data(); + const size_t n = buf.bytes(); + for (size_t i = 0; i < n; i++) d[i] = scale8(d[i], keep); +} + +// Box blur, working 1Dβ†’3D against the flat Buffer β€” one unified primitive, not a blur1d/blur2d/blur3d +// trio (the *common patterns first* / "primitives are 3D-aware" rule, same as draw::line). It runs a +// separable seep pass along each axis whose extent is >1: a 1Γ—N 1D layer blurs along y (its only +// axis with extent>1); 2D along x then y; 3D along x, y, z. `amt` (0 = none, 255 = max) is split +// keep=255-amt / seep=amt>>1 per pixel. +// +// Algorithm: the canonical FastLED blur1d single-forward-pass with carryover β€” each pixel keeps +// `keep` of itself, seeps `seep` forward to the next pixel and `seep` back to the previous one, so +// one O(N) pass per axis approximates a symmetric box blur. Behaviour is identical to MoonLight's +// blur1d/blurRows/blurColumns (verified against VirtualLayer.cpp); the speed comes from doing it on +// the raw bytes β€” a stride walk with three uint8 carried in registers, no per-pixel getRGB/setRGB/ +// Coord3D construction (the overhead that makes a generic-layer blur an FPS killer). Prior art: +// FastLED's blur1d (Mark Kriegsman), the recognisable carryover-seep; our byte-level implementation. +// +// `stride` is the byte step between adjacent pixels ALONG the blurred axis (cpl for x, wΒ·cpl for y, +// wΒ·hΒ·cpl for z); `lineCount`/`lineStride` walk the starts of each parallel line. RGB only (first 3 +// channels); a W channel is untouched. Saturating adds (qadd8) so a bright pixel can't wrap to dark. +inline void blurAxis(uint8_t* d, size_t cpl, size_t len, size_t stride, + size_t lineCount, size_t lineStride, uint8_t amt) { + if (len < 2 || cpl < 3) return; // nothing to seep along a 1-pixel (or sub-RGB) axis + const uint8_t keep = static_cast(255 - amt); + const uint8_t seep = static_cast(amt >> 1); + for (size_t l = 0; l < lineCount; l++) { + uint8_t* base = d + l * lineStride; + uint8_t cr = 0, cg = 0, cb = 0; // carryover (the seep flowing forward), starts black + size_t off = 0, prev = 0; + for (size_t i = 0; i < len; i++, off += stride) { + uint8_t* px = base + off; + const uint8_t pr = scale8(px[0], seep), pg = scale8(px[1], seep), pb = scale8(px[2], seep); + px[0] = qadd8(scale8(px[0], keep), cr); // keep self + receive prev pixel's forward seep + px[1] = qadd8(scale8(px[1], keep), cg); + px[2] = qadd8(scale8(px[2], keep), cb); + if (i) { // seep back into the previous pixel (deferred add) + uint8_t* pv = base + prev; + pv[0] = qadd8(pv[0], pr); pv[1] = qadd8(pv[1], pg); pv[2] = qadd8(pv[2], pb); + } + cr = pr; cg = pg; cb = pb; prev = off; + } + uint8_t* last = base + prev; // the final forward seep lands on the last pixel + last[0] = qadd8(last[0], cr); last[1] = qadd8(last[1], cg); last[2] = qadd8(last[2], cb); + } +} + +// Blur the whole buffer by `amt`, separably along every axis with extent >1 (x, then y, then z β€” +// MoonLight's blur2d order, extended to z). One call covers 1D/2D/3D. Off the per-pixel-effect path. +inline void blur(Buffer& buf, Coord3D dims, uint8_t amt) { + if (amt == 0) return; + uint8_t* d = buf.data(); + const size_t cpl = buf.channelsPerLight(); + const size_t w = dims.x > 0 ? static_cast(dims.x) : 0; + const size_t h = dims.y > 0 ? static_cast(dims.y) : 0; + const size_t z = dims.z > 0 ? static_cast(dims.z) : 0; + if (w == 0 || h == 0 || z == 0) return; + if (static_cast(w * h * z) * cpl > buf.bytes()) return; // dims/buffer mismatch guard + // x-pass: each (y,z) line is `w` pixels, stride cpl; lines start every wΒ·cpl bytes, hΒ·z of them. + blurAxis(d, cpl, w, cpl, h * z, w * cpl, amt); + // y-pass: each (x,z) line is `h` pixels, stride wΒ·cpl. Lines: for each z, the w columns β€” start + // offsets are zΒ·(hΒ·wΒ·cpl) + xΒ·cpl. Walk them as one run of (wΒ·z) lines stepping by cpl, but the + // z blocks aren't contiguous in column-start, so loop z outside. + for (size_t zz = 0; zz < z; zz++) + blurAxis(d + zz * h * w * cpl, cpl, h, w * cpl, w, cpl, amt); + // z-pass (3D only): each (x,y) line is `z` pixels, stride wΒ·hΒ·cpl; wΒ·h lines stepping by cpl. + if (z > 1) blurAxis(d, cpl, z, w * h * cpl, w * h, cpl, amt); +} + +// Fill the whole buffer with one colour (MoonLight's fill_solid). +inline void fill(Buffer& buf, RGB c) { + const uint8_t cpl = buf.channelsPerLight(); + if (cpl == 0) return; // a 0-channel buffer has no colour to write; guards off += 0 spinning + uint8_t* d = buf.data(); + const size_t n = buf.bytes(); + for (size_t off = 0; off + cpl <= n; off += cpl) { + if (cpl >= 1) d[off + 0] = c.r; + if (cpl >= 2) d[off + 1] = c.g; + if (cpl >= 3) d[off + 2] = c.b; + } +} + +// Blit one glyph of `font` at grid position (x, y). Each of the font's `height` rows is one byte; +// bit set β†’ pixel on, columns MSB-first across the glyph's `width` (bit (7) is the left column, down +// to bit (8-width)). Only printable ASCII 32..126 is drawn; anything else is skipped (a gap). Pixels +// are clipped to the grid (draw::pixel). Prior art: the WLED/MoonLight console-font blitter shape. +inline void glyph(Buffer& buf, Coord3D dims, const fonts::Font& font, char ch, lengthType x, lengthType y, RGB c) { + if (ch < 32 || ch > 126) return; + const uint8_t idx = static_cast(ch - 32); + const uint8_t* rows = font.rows + static_cast(idx) * font.height; + for (uint8_t ry = 0; ry < font.height; ry++) { + const uint8_t bits = rows[ry]; + // Columns are MSB-first: the LEFTMOST glyph column (rx=0) is bit 7, the next bit 6, … so + // read column rx from bit (7 - rx). (Reading (rx + 8-width) instead mirrors each glyph + // left-to-right β€” a 'b' renders as a 'd'.) + for (uint8_t rx = 0; rx < font.width; rx++) + if ((bits >> (7 - rx)) & 0x01) + pixel(buf, dims, {static_cast(x + rx), static_cast(y + ry), 0}, c); + } +} + +// Draw a NUL-terminated string starting at (x, y), advancing `font.width` per character. `\n` starts +// a new line one `font.height` down and resets x to the start column (multi-line layout). Off-grid +// glyphs clip. Returns the total pixel width drawn on the first line (for scroll bookkeeping). +inline lengthType text(Buffer& buf, Coord3D dims, const fonts::Font& font, const char* str, + lengthType x, lengthType y, RGB c) { + if (!str) return 0; + lengthType cx = x, cy = y; + lengthType firstLineWidth = 0; // frozen at the first '\n' so a multi-line string still reports line 1 + bool onFirstLine = true; + for (const char* p = str; *p; p++) { + if (*p == '\n') { + if (onFirstLine) { firstLineWidth = static_cast(cx - x); onFirstLine = false; } + cx = x; cy = static_cast(cy + font.height); continue; + } + glyph(buf, dims, font, *p, cx, cy, c); + cx = static_cast(cx + font.width); + } + return onFirstLine ? static_cast(cx - x) : firstLineWidth; +} + +} // namespace draw +} // namespace mm diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 5c17e028..14ac5b7e 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -6,6 +6,7 @@ #include "light/layers/Layers.h" #include "light/layers/BlendMap.h" #include "light/drivers/Correction.h" +#include "light/Palette.h" // the global active palette + its select control #include "platform/platform.h" #include // std::strcmp in onUpdate @@ -159,6 +160,7 @@ class Drivers : public MoonModule { // PreviewDriver reads the RGB source buffer directly, so the simulator is // unaffected. RGB-ordered outputs (some ArtNet/network sinks) flip it back. uint8_t lightPreset = 2; // index into kLightPresetOptions; 2 = GRB + uint8_t palette = 0; // index into mm::palettes::kBuiltins; the global active palette effects read // Two ways to wire the source Layer: // - setLayers(Layers*): bind the container; layer_ is re-resolved from @@ -179,6 +181,7 @@ class Drivers : public MoonModule { void onBuildControls() override { controls_.addUint8("brightness", brightness, 0, 255); controls_.addSelect("lightPreset", lightPreset, kLightPresetOptions, kLightPresetCount); + controls_.addPalette("palette", palette, mm::paletteOptions, mm::palettes::kCount); MoonModule::onBuildControls(); // cascade to driver children } @@ -186,6 +189,10 @@ class Drivers : public MoonModule { // pipeline realloc. This is what keeps the brightness slider fluent: controlChangeTriggersBuildState // stays false for Drivers, so handleSetControl skips scheduler_->buildState(). void onUpdate(const char* controlName) override { + if (std::strcmp(controlName, "palette") == 0) { + Palettes::setActive(palette); // rebuild the active 16-entry lookup (cheap, off the hot path) + return; + } if (std::strcmp(controlName, "brightness") == 0 || std::strcmp(controlName, "lightPreset") == 0) { correction_.rebuild(brightness, static_cast(lightPreset)); @@ -201,6 +208,7 @@ class Drivers : public MoonModule { void setup() override { correction_.rebuild(brightness, static_cast(lightPreset)); + Palettes::setActive(palette); // seed the global active palette from the persisted index MoonModule::setup(); passBufferToDrivers(); } diff --git a/src/light/drivers/HueDriver.h b/src/light/drivers/HueDriver.h new file mode 100644 index 00000000..7b7f9823 --- /dev/null +++ b/src/light/drivers/HueDriver.h @@ -0,0 +1,718 @@ +#pragma once + +#include "light/drivers/Drivers.h" +#include "core/JsonUtil.h" // parse the bridge's JSON responses +#include "core/FilesystemModule.h" // noteDirty β€” persist the app key after pairing +#include "core/DevicesModule.h" // DevicesModule::active() β€” list the bridge as a device +#include "platform/platform.h" + +#include +#include +#include + +namespace mm { + +// Philips Hue lights as a projectMM OUTPUT β€” a driver, not a listed device. The bulbs are +// pixels of an effect: make a small grid (e.g. 4Γ—1Γ—1), run any effect, and this driver reads +// its window of the shared buffer and pushes each light's colour to the bridge. Same shape as +// NetworkSendDriver (read a window, send it out), but over the Hue v1 HTTP API instead of UDP. +// +// What makes Hue different from a strip and shapes the design: +// - It's HTTP, not a wire protocol: GET /api//lights, PUT .../lights//state. +// - Connection churn, not just Hue's ~10/s command budget, bounds the rate: each PUT opens a +// fresh TCP connection (the bridge speaks Connection: close), so loop() does AT MOST ONE PUT +// every kPutIntervalMs β€” a millis() gate, never work-every-tick β€” so a synchronous round-trip +// can't stall the single-thread render loop AND the TIME_WAIT sockets don't pile up. Smooth +// ambient colour, not real-time (that's the Entertainment API, a separate future). +// - Only colour-capable, reachable lights are driven (parseLights filters them); each PUT sends +// hue/sat/bri from a textbook RGBβ†’HSV plus a cadence-matched transitiontime so the bulb glides. +// - It needs an app key: press the bridge's link button once, then POST /api to claim a key. +// Pairing is a short bounded poll across a few loop1s ticks (never blocking the loop). +// +// Room / light selection. Two dropdowns ("room", "light") let the user aim the effect at a +// subset of the bridge's colour bulbs without touching the window controls. fetchGroups reads +// GET /api//groups and keeps the bridge's Rooms (name + member light ids); fetchLights keeps +// each colour light's name. Option index 0 is "All" in BOTH dropdowns, so the common "drive +// everything" case never shifts index and persists as 0 for free (the Select stores its uint8 +// index). Picking a room narrows the light dropdown to that room's colour lights AND filters the +// driven set to that room; picking a specific light drives just that one. The filter builds +// drivenIdx_[] β€” the colour-light subset pushOneChangedLight actually walks β€” so room=All & +// light=All leaves the original behaviour (drive every colour bulb) untouched. +// +// Plain HTTP, no TLS β€” the Hue v1 API allows it (bench-confirmed on a BSB002 bridge). Prior +// art: the Hue v1 CLIP API (public docs); the effect-as-output mapping is projectMM's own. +class HueDriver : public DriverBase { +public: + uint8_t bridgeIp[4] = {}; // the bridge's LAN IP (from the UI) + char appKey[48] = {}; // the Hue username/app key (filled by Pair, persisted) + + void onBuildControls() override { + controls_.addIPv4("bridgeIp", bridgeIp); + controls_.addText("appKey", appKey, sizeof(appKey)); // persisted credential + controls_.addButton("pair"); // link-button pairing + // Room + light filter. Both default to index 0 = "All". The option arrays are rebuilt + // (in place, into stable member buffers) from the parsed bridge data; onBuildControls is + // re-run on every control change (HttpServerModule), so these reflect the current room_. + buildRoomOptions(); + buildLightOptions(); + controls_.addSelect("room", room_, roomOptions_, roomOptionCount_); + controls_.addSelect("light", light_, lightOptions_, lightOptionCount_); + addWindowControls(); // start / count β€” its slice of the buffer + // The generic "status" line (setStatus) carries the pairing state + driven-of-total light + // count β€” see refreshStatus(); no separate hueStatus / colourLights controls. + refreshStatus(); + } + + void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; } + + // The shared output Correction (global brightness LUT + channel order), same as the physical + // LED / network drivers β€” so the brightness slider and a swapped colour order reach the Hue + // lights too. Applied per pixel before RGBβ†’HSV; the RGBW/white part is irrelevant here (Hue + // takes hue/sat), we use the RGB result. + void setCorrection(const Correction* c) override { correction_ = c; } + + // A control click. "pair" starts the link-button pairing poll. Changing the bridge IP or app + // key points the driver at a (possibly) different bridge, so the learned light list + push + // cache are stale β€” drop them and let loop1s re-fetch against the new config. + void onUpdate(const char* controlName) override { + if (controlName && std::strcmp(controlName, "pair") == 0) { + pairTicksLeft_ = kPairWindowTicks; // begin: poll the bridge for ~30 s on loop1s + std::snprintf(statusBuf_, sizeof(statusBuf_), "pairing: press the bridge button"); + setStatus(statusBuf_); + } else if (controlName && + (std::strcmp(controlName, "bridgeIp") == 0 || std::strcmp(controlName, "appKey") == 0)) { + resetLightCache(); // re-fetch the light list + groups for the new bridge/key + } else if (controlName && std::strcmp(controlName, "room") == 0) { + // Room changed: the light dropdown's options now describe a different set, so the old + // light_ index may point past the new (shorter) list β€” clamp it back to "All". The + // option arrays themselves were already rebuilt by the rebuildControls() that ran just + // before this onUpdate (it re-ran onBuildControls() against the new room_). Recompute + // the driven subset and refresh the status line. + if (light_ >= lightOptionCount_) light_ = 0; + rebuildDriven(); + refreshStatus(); + } else if (controlName && std::strcmp(controlName, "light") == 0) { + rebuildDriven(); // a different single light (or back to room's all) + refreshStatus(); // the driven-of-total count changed β†’ refresh the status line + } + DriverBase::onUpdate(controlName); + } + + // loop() runs every render tick. It must NEVER do more than ONE bounded bridge call, and + // only when the rate-limit interval has elapsed (a millis() gate, NOT work-every-tick) β€” + // otherwise a synchronous HTTP round-trip stalls the whole single-thread render loop (the + // "never block the loop" rule, decisions.md). So: at most one PUT every kPutIntervalMs, + // round-robined across the lights. Pairing + the bridge announce ride the slow 1 Hz tick. + void loop() override { + if (pairTicksLeft_ > 0) return; // pairing owns the bridge during its window + if (!appKey[0] || !haveBridge() || lightCount_ == 0) return; + const uint32_t now = platform::millis(); + if (now - lastPutMs_ < kPutIntervalMs) return; // not time yet β€” return instantly, no I/O + lastPutMs_ = now; + pushOneChangedLight(); // exactly one bounded PUT this tick + } + + // The 1 Hz tick handles the non-render-critical, slower bridge work: pairing poll, the + // one-shot light fetch, and the periodic DevicesModule announce. Each is at most one bridge + // call per second β€” acceptable on a 1 Hz tick, and never in the per-frame loop(). + void loop1s() override { + if (pairTicksLeft_ > 0) { pollPairing(); DriverBase::loop1s(); return; } + if (!appKey[0] || !haveBridge()) { DriverBase::loop1s(); return; } + if (!sawLights_) { fetchLights(); DriverBase::loop1s(); return; } + if (!sawGroups_) { fetchGroups(); DriverBase::loop1s(); return; } + if (++reportTick_ >= kReportEverySec) { reportTick_ = 0; reportBridge(); } + DriverBase::loop1s(); + } + + void teardown() override { + pairTicksLeft_ = 0; + freeNameBuffers(); // release the dropdown-name heap; a re-add re-fetches and re-allocs + DriverBase::teardown(); + } + + // Test seam: drive the changed-light diff + PUT formatting without a live bridge β€” feed a + // light's RGB and get back whether it would PUT + the body it would send. Records the push + // (like pushChangedLights does) so a follow-up call with the same RGB exercises the + // unchanged-skip path. + bool wouldPushForTest(uint8_t idx, uint8_t r, uint8_t g, uint8_t b, char* outBody, size_t cap) { + if (!diffAndFormat(idx, r, g, b, outBody, cap)) return false; + if (idx < kMaxLights) { + lastRgb_[idx][0] = r; lastRgb_[idx][1] = g; lastRgb_[idx][2] = b; + sent_[idx] = true; + } + return true; + } + + // Test seam: parse a real /lights JSON body through fetchLights' colour-light extractor. + void parseLightsForTest(const char* json) { parseLights(json); rebuildDriven(); } + uint8_t lightCountForTest() const { return lightCount_; } // kept colour+reachable lights + uint16_t hueIdForTest(uint8_t i) const { return i < kMaxLights ? hueId_[i] : 0; } + int8_t colourCountForTest() const { return colourCount_; } + + // Test seam: parse a real /groups JSON body through fetchGroups' Room extractor. Call + // parseLightsForTest FIRST β€” room membership resolves against the known colour lights (hueId_), + // exactly as production order guarantees (fetchGroups runs only after fetchLights). + void parseGroupsForTest(const char* json) { parseGroups(json); rebuildDriven(); } + uint8_t roomCountForTest() const { return roomCount_; } // kept Rooms (type=="Room") + + // Test seams for the roomβ†’light filtering. setRoomForTest/setLightForTest mirror what a UI + // Select change does (write the index, then re-derive the driven subset); drivenCountForTest / + // drivenIdForTest report the filtered set pushOneChangedLight walks. + void setRoomForTest(uint8_t r) { room_ = r; if (light_ >= lightOptionCount_) light_ = 0; rebuildDriven(); refreshStatus(); } + void setLightForTest(uint8_t l) { light_ = l; rebuildDriven(); refreshStatus(); } + void refreshStatusForTest() { refreshStatus(); } + uint8_t drivenCountForTest() const { return drivenLightCount_; } + uint16_t drivenIdForTest(uint8_t i) const { return i < drivenLightCount_ ? hueId_[drivenIdx_[i]] : 0; } + + // Test seam for the RGBβ†’HSV mapping (no bridge needed). + static void rgbToHsvForTest(uint8_t r, uint8_t g, uint8_t b, uint16_t& h, uint8_t& s, uint8_t& v) { + rgbToHsv(r, g, b, h, s, v); + } + + // Test seam: the truncation signal fetchLights grows against (a complete /lights body ends '}'). + static bool bodyLooksCompleteForTest(const char* body) { return bodyLooksComplete(body); } + +private: + static constexpr uint8_t kMaxLights = 32; // a LAN's worth of Hue bulbs; bounded, no heap + static constexpr uint8_t kMaxRooms = 16; // bounded room count; option index 0 is "All" + static constexpr uint8_t kNameLen = 24; // per-light / per-room friendly-name buffer + // kMaxLights == 32 == the width of a uint32_t, so a Room's colour-light membership fits one + // bitmask (bit i ⇔ colour light hueId_[i]) β€” resolved at parse time, since fetchGroups runs + // after fetchLights (the sawGroups_ gate), so hueId_ is already populated. A bitmask is the + // textbook small-set membership (a bit test replaces a per-id scan), and 16Γ—4 B = 64 B beats a + // 16Γ—32 id-list's 1 KB inline. static_assert pins the width assumption. + static_assert(kMaxLights == 32, "Room membership bitmask (roomMask_) assumes 32 colour lights"); + // One PUT at most every kPutIntervalMs (a millis() gate in loop()). Each PUT opens a fresh + // TCP connection (the bridge speaks Connection: close), so the rate is bounded by connection + // CHURN, not just Hue's command budget: at ~7/s the TIME_WAIT sockets pile into the hundreds + // and the bridge starts refusing connections (PUTs fail, lights freeze). 500 ms β†’ ~2 PUTs/s + // keeps TIME_WAIT small and is plenty for smooth ambient colour (each light glides over its + // ~2 s refresh via the matched transitiontime). Real-time would need keep-alive or the + // Entertainment API β€” out of scope; this is the standard API's comfortable rate. + static constexpr uint32_t kPutIntervalMs = 500; + static constexpr int kPairWindowTicks = 30; // ~30 s pairing window (link-button press) + static constexpr uint16_t kReportEverySec = 30; // re-announce the bridge to DevicesModule + // Per-frame PUT (loop()) timeout. A successful PUT to a LAN bridge returns in ~20-50 ms, so + // this only bounds the WORST case (an unreachable/overloaded bridge) β€” not the normal cost. + // 200 ms gives comfortable margin over the real latency (a 60 ms cap intermittently tripped + // under rapid back-to-back PUTs, failing them) while still bounding a bad frame. kSlowTimeoutMs + // is for the 1 Hz calls (pair / fetch / announce), where the 8 KB /lights GET wants headroom. + static constexpr uint32_t kHttpTimeoutMs = 200; + static constexpr uint32_t kSlowTimeoutMs = 400; + + Buffer* sourceBuffer_ = nullptr; + const Correction* correction_ = nullptr; // shared brightness LUT + channel order (may be null) + + // Per-light Hue id + the last RGB we pushed (the changed-only filter). hueId maps a window + // index β†’ the bridge's light id, learned from GET /api//lights. + uint16_t hueId_[kMaxLights] = {}; + uint8_t lastRgb_[kMaxLights][3] = {}; + bool sent_[kMaxLights] = {}; // have we pushed this light at least once + // hueId_ holds ONLY colour-capable lights (the bridge's "Extended color light"s) β€” a + // dimmable-only white or an on/off plug is skipped, so every window pixel maps to a bulb + // that can show the effect's full colour. lightCount_ is that filtered count. + uint8_t lightCount_ = 0; // number of colour-capable lights + int8_t colourCount_ = 0; // same, as the read-only control / bridge field + bool sawLights_ = false; // fetchLights ran β†’ the list is trustworthy + // Friendly names for the dropdowns. Heap, NOT inline: a fixed [kMaxLights][kNameLen] array + // would reserve 768 B whether the bridge has 4 lights or 32 (and cap at 32). Instead one + // contiguous block of (count Γ— kNameLen) is allocated to the ACTUAL light/room count when the + // fetch runs, and freed in release()/teardown β€” so memory scales to the real bridge and + // sizeof(HueDriver) stays small (the lightsBuf_ stack-overflow lesson, applied to the names). + char* lightNames_ = nullptr; // kMaxLights Γ— kNameLen; lightNameAt(i) indexes it + char* roomNames_ = nullptr; // kMaxRooms Γ— kNameLen + char* lightNameAt(uint8_t i) { return lightNames_ ? lightNames_ + static_cast(i) * kNameLen : nullptr; } + char* roomNameAt(uint8_t i) { return roomNames_ ? roomNames_ + static_cast(i) * kNameLen : nullptr; } + // Allocate the two name blocks lazily on first parse (so an unconfigured driver pays nothing), + // and free them on teardown / cache reset (so a removed-then-readded bridge starts clean). The + // blocks are sized to the kMax bound, not the live count, because the parser fills them + // incrementally and the count isn't known until it finishes β€” keeping the names off the + // resident sizeof(HueDriver) is the win (the lightsBuf_ stack-probe lesson), not per-byte fit. + void ensureNameBuffers() { + if (!lightNames_) lightNames_ = static_cast(platform::alloc(static_cast(kMaxLights) * kNameLen)); + if (!roomNames_) roomNames_ = static_cast(platform::alloc(static_cast(kMaxRooms) * kNameLen)); + } + void freeNameBuffers() { + platform::free(lightNames_); lightNames_ = nullptr; + platform::free(roomNames_); roomNames_ = nullptr; + } + + // --- Rooms (GET /api//groups, type=="Room"): name + a colour-light membership bitmask. + uint32_t roomMask_[kMaxRooms] = {}; // bit i set ⇔ this Room references colour light hueId_[i] + uint8_t roomCount_ = 0; // number of Rooms kept + bool sawGroups_ = false; // fetchGroups ran β†’ the room list is trustworthy + + // --- Filter selection (Select indices, persisted as uint8) and the derived driven subset. + uint8_t room_ = 0; // 0 = "All", else roomName_[room_-1] + uint8_t light_ = 0; // 0 = "All", else the n-th light of the current option list + uint8_t drivenIdx_[kMaxLights] = {}; // colour-light array-indices actually driven (after filter) + uint8_t drivenLightCount_ = 0; // size of drivenIdx_ β€” what pushOneChangedLight walks + + // --- Stable option pointer arrays for the two Selects. addSelect borrows the pointer; these + // live for the driver's lifetime and are refilled in place (pointing into the *Name_ buffers) + // by buildRoomOptions / buildLightOptions on every onBuildControls. "All" is always index 0. + const char* roomOptions_[kMaxRooms + 1] = {}; + uint8_t roomOptionCount_ = 1; + const char* lightOptions_[kMaxLights + 1] = {}; + uint8_t lightOptionCount_ = 1; + + uint8_t pushCursor_ = 0; // round-robin position across the lights + uint8_t drivenCount_ = 0; // lights driven this pass (n); fade-time basis + uint32_t lastPutMs_ = 0; // millis() of the last PUT β€” the loop() rate gate + int pairTicksLeft_ = 0; + uint16_t reportTick_ = 0; // counts loop1s ticks toward kReportEverySec + char statusBuf_[40] = "unpaired"; + // /lights read buffer is sized dynamically in fetchLights (grow-and-retry): a small first try + // covers a typical home; it doubles up to the cap only when a bigger bridge's response fills it. + // 16 KB caps the worst case (kMaxLights=32 Γ— ~512 B/light). Heap-allocated per fetch, never an + // inline member (an 8 KB member would overflow the main-task stack in registerType's probe). + static constexpr size_t kLightsBufInitial = 2048; + static constexpr size_t kLightsBufMax = 16384; + + bool haveBridge() const { return bridgeIp[0] || bridgeIp[1] || bridgeIp[2] || bridgeIp[3]; } + + // Does the JSON span [begin, end) contain `key` (e.g. "\"hue\"") β€” used to read a light's + // capabilities off its state block (a colour light has "hue"; the bridge omits it otherwise). + static bool containsKey(const char* begin, const char* end, const char* key) { + const size_t kl = std::strlen(key); + for (const char* s = begin; s + kl <= end; s++) + if (std::strncmp(s, key, kl) == 0) return true; + return false; + } + + // Read a `"":""` string from WITHIN a JSON object span [begin, end) β€” the + // span-bounded analogue of containsKey, used to grab one light's / one room's "name" without + // matching the first "name" elsewhere in the bridge's big response. Copies the raw value up to + // its closing quote (the bridge's names carry no escapes worth decoding) into out[cap], NUL- + // terminated; leaves out empty if the key isn't in the span. + static void parseStringIn(const char* begin, const char* end, const char* key, char* out, size_t cap) { + if (cap == 0) return; + out[0] = 0; + char search[24]; + std::snprintf(search, sizeof(search), "\"%s\":\"", key); + const size_t sl = std::strlen(search); + for (const char* s = begin; s + sl <= end; s++) { + if (std::strncmp(s, search, sl) != 0) continue; + const char* v = s + sl; + size_t oi = 0; + for (; v < end && *v != '"' && oi + 1 < cap; v++) out[oi++] = *v; + out[oi] = 0; + return; + } + } + + void bridgeStr(char out[16]) const { + std::snprintf(out, 16, "%u.%u.%u.%u", bridgeIp[0], bridgeIp[1], bridgeIp[2], bridgeIp[3]); + } + + // The single status line, folding what were three separate controls (status / hueStatus / + // colourLights). Shows the pairing state and the light count as driven-of-total: "paired, + // 3-4 lights" = the room/light filter narrowed 4 colour lights to 3 driven. When nothing is + // filtered (driven == total) it collapses to the plain count, "paired, 4 lights". + void refreshStatus() { + if (!appKey[0]) std::snprintf(statusBuf_, sizeof(statusBuf_), "unpaired"); + else if (!lightCount_) std::snprintf(statusBuf_, sizeof(statusBuf_), "paired"); + else if (drivenLightCount_ < lightCount_) + std::snprintf(statusBuf_, sizeof(statusBuf_), "paired, %u-%u lights", drivenLightCount_, lightCount_); + else std::snprintf(statusBuf_, sizeof(statusBuf_), "paired, %u lights", lightCount_); + setStatus(statusBuf_); + } + + // --- Pairing: POST /api {"devicetype":"projectMM#"} until the user presses the button. + void pollPairing() { + if (!haveBridge()) { pairTicksLeft_ = 0; std::snprintf(statusBuf_, sizeof(statusBuf_), "set bridge IP first"); setStatus(statusBuf_); return; } + char host[16]; bridgeStr(host); + // The pairing body is tiny, but httpRequest reads headers + body into one buffer and the + // bridge's headers run ~700 bytes β€” size past them or the success body gets squeezed out. + char resp[1024]; + int st = platform::httpRequest("POST", host, 80, "/api", + "{\"devicetype\":\"projectMM#device\"}", kSlowTimeoutMs, + resp, sizeof(resp)); + if (st == 200 && std::strstr(resp, "\"username\"")) { + // [{"success":{"username":""}}] β€” extract the username. + char key[48] = {}; + mm::json::parseString(resp, "username", key, sizeof(key)); + if (key[0]) { + std::snprintf(appKey, sizeof(appKey), "%s", key); + pairTicksLeft_ = 0; + resetLightCache(); // clear the light list + the per-light push cache + // (sent_/lastRgb_) so the new session re-sends all + refreshStatus(); + markDirty(); // persist the new app key + FilesystemModule::noteDirty(); + return; + } + } + // "link button not pressed" β†’ keep polling until the window elapses. + if (--pairTicksLeft_ <= 0) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "pairing timed out"); + setStatus(statusBuf_); + } + } + + // Drop the learned light list + room list + push cache so loop1s re-fetches (bridge/key change). + void resetLightCache() { + lightCount_ = 0; + colourCount_ = 0; + sawLights_ = false; + roomCount_ = 0; + sawGroups_ = false; + pushCursor_ = 0; + for (uint8_t i = 0; i < kMaxLights; i++) sent_[i] = false; + freeNameBuffers(); // drop the old bridge's names; the re-fetch re-allocs for the new one + // Rebuild the option arrays NOW so roomOptions_/lightOptions_ don't dangle into the freed + // name buffers until the next onBuildControls. With the counts above zeroed, both collapse + // to the single "All" sentinel (no name-buffer deref), which is the correct empty state. + buildRoomOptions(); + buildLightOptions(); + rebuildDriven(); // empty caches β†’ empty driven set, until the re-fetch repopulates them + } + + // A complete /lights response is a JSON object: its last non-whitespace char is '}'. A read cut + // short by a too-small buffer ends mid-content, so this is the truncation signal fetchLights + // grows against. (Not a full JSON validator β€” the bridge's well-formed body is the contract; + // this only distinguishes "whole" from "cut off".) + static bool bodyLooksComplete(const char* body) { + size_t len = std::strlen(body); + while (len > 0 && (body[len - 1] == '\n' || body[len - 1] == '\r' + || body[len - 1] == ' ' || body[len - 1] == '\t')) len--; + return len > 0 && body[len - 1] == '}'; + } + + // --- Learn the bridge's light ids (window index β†’ hue id, in id order). + void fetchLights() { + char host[16]; bridgeStr(host); + char path[80]; std::snprintf(path, sizeof(path), "/api/%s/lights", appKey); + // The /lights body grows with the bridge's light count (~300-800 bytes/light of metadata), + // so its size is unknown up front. Size the read buffer DYNAMICALLY: start small and, if the + // response came back filling the buffer (httpRequest truncates to its capacity, which would + // silently drop trailing lights from parseLights' linear scan), double and refetch until it + // fits or we hit the cap. A typical home (a few lights) fits the first try; only a large + // bridge grows. The buffer lives on the heap (PSRAM when present) for the fetch and is freed + // after β€” fetchLights runs at 1 Hz, off the render loop, so the alloc/refetch isn't hot-path. + // It is NOT an inline member: an 8 KB member would make sizeof(HueDriver) overflow the + // main-task stack when registerType constructs a throwaway probe. + for (size_t cap = kLightsBufInitial; cap <= kLightsBufMax; cap *= 2) { + char* buf = static_cast(platform::alloc(cap)); + if (!buf) return; + const int st = platform::httpRequest("GET", host, 80, path, "", kSlowTimeoutMs, buf, cap); + if (st != 200) { platform::free(buf); return; } + // Detect a truncated read by the body's SHAPE, not its length: httpRequest strips the + // HTTP headers in place, so strlen(body) is body-only and never reaches cap-1 even when + // the raw read filled the buffer. So grow + retry while the body looks incomplete, until + // it parses whole or we hit the cap (then parse best-effort). + const bool truncated = !bodyLooksComplete(buf) && (cap < kLightsBufMax); + if (!truncated) { + parseLights(buf); + rebuildControls(); // the light-dropdown options changed β†’ re-bind for the UI + refreshStatus(); + reportBridge(); + platform::free(buf); + return; + } + platform::free(buf); + } + } + + // List the bridge in DevicesModule (so it shows alongside discovered WLED/projectMM peers, + // carrying its dimmable-light count for layout sizing). The bridge isn't a UDP-presence + // device, so it's registered explicitly through the static seam β€” no compile-time core↔light + // dependency beyond the same DevicesModule::active() shape AudioModule::latestFrame() uses. + void reportBridge() { + auto* dev = DevicesModule::active(); + if (!dev || !haveBridge()) return; + char host[16]; bridgeStr(host); + // httpRequest reads headers + body into this one buffer, and the bridge's response + // headers alone run ~700 bytes β€” so size for headers + the small config body, not just + // the body, or the body gets squeezed out. + char cfg[1024], name[24] = {}; + if (platform::httpRequest("GET", host, 80, "/api/0/config", "", kSlowTimeoutMs, cfg, sizeof(cfg)) == 200) + mm::json::parseString(cfg, "name", name, sizeof(name)); // the bridge's friendly name + dev->upsertHueBridge(bridgeIp, name, static_cast(colourCount_)); + } + + // Extract the COLOUR-capable, REACHABLE light ids from a /lights JSON body: + // {"1":{…},"5":{…},…}. A colour light's object carries a "hue" field in its state; a + // dimmable-only white or an on/off plug does not. A light that's powered off / out of mesh + // reports "reachable":false. We keep only lights that are BOTH colour-capable and reachable + // β€” those are the ones an effect can actually animate right now β€” so the window maps every + // pixel to a live colour bulb. The bridge response (~8 KB / hundreds of fields) exceeds the + // recursive JSON reader's node arena, so this is a lightweight forward scan: spot each + // top-level id key, then keep it iff its object span (up to the next id key) has both. + void parseLights(const char* resp) { + ensureNameBuffers(); + lightCount_ = 0; + const char* p = resp; + int pendingId = 0; // a light id seen, not yet committed (need its span first) + const char* pendingStart = nullptr; + auto commit = [&](const char* objEnd) { + if (pendingId > 0 && pendingStart && lightCount_ < kMaxLights && lightNames_ + && containsKey(pendingStart, objEnd, "\"hue\"") + && containsKey(pendingStart, objEnd, "\"reachable\":true")) { + hueId_[lightCount_] = static_cast(pendingId); + // Keep the friendly name for the dropdown β€” read the "name" string from this + // light's object span (bounded, NUL-terminated). Falls back to the id if absent. + char* name = lightNameAt(lightCount_); + parseStringIn(pendingStart, objEnd, "name", name, kNameLen); + if (!name[0]) std::snprintf(name, kNameLen, "%d", pendingId); + lightCount_++; + } + }; + while (true) { + const char* q = std::strchr(p, '"'); // next key open-quote + if (!q) break; + int id = std::atoi(q + 1); // light id is a quoted integer key + const char* close = std::strchr(q + 1, '"'); + // A top-level light-id key: a quoted positive integer followed by ':'. + if (id > 0 && close && close[1] == ':') { + commit(q); // the PREVIOUS light's object ends here + pendingId = id; + pendingStart = close + 1; + } + p = close ? close + 1 : q + 1; + } + commit(resp + std::strlen(resp)); // the last light runs to the end + sawLights_ = true; + colourCount_ = static_cast(lightCount_ > 127 ? 127 : lightCount_); + rebuildDriven(); // the colour-light set changed β†’ re-derive the filtered driven subset + } + + // --- Learn the bridge's Rooms (GET /api//groups). Same dynamic grow-and-retry read as + // fetchLights β€” the /groups body grows with the room+zone count, so size the heap buffer up + // until the response parses whole. fetchGroups runs at 1 Hz, off the render loop, after + // fetchLights (gated by sawGroups_), so this alloc/refetch is never hot-path. + void fetchGroups() { + char host[16]; bridgeStr(host); + char path[80]; std::snprintf(path, sizeof(path), "/api/%s/groups", appKey); + for (size_t cap = kLightsBufInitial; cap <= kLightsBufMax; cap *= 2) { + char* buf = static_cast(platform::alloc(cap)); + if (!buf) return; + const int st = platform::httpRequest("GET", host, 80, path, "", kSlowTimeoutMs, buf, cap); + if (st != 200) { platform::free(buf); return; } + const bool truncated = !bodyLooksComplete(buf) && (cap < kLightsBufMax); + if (!truncated) { + parseGroups(buf); + rebuildControls(); // the room dropdown options changed β†’ re-bind for the UI + platform::free(buf); + return; + } + platform::free(buf); + } + } + + // Extract the Rooms from a /groups JSON body: {"1":{"name":"Living","lights":["3","5"], + // "type":"Room",…},…}. Keep only type=="Room" (drop Zones, LightGroups, Entertainment); for + // each, store its name and the light ids its "lights" array references. Same lightweight + // forward scan as parseLights (the response exceeds the recursive reader's node arena): spot + // each top-level id key, then read the object span up to the next id key. + void parseGroups(const char* resp) { + ensureNameBuffers(); + roomCount_ = 0; + const char* p = resp; + int pendingId = 0; + const char* pendingStart = nullptr; + auto commit = [&](const char* objEnd) { + if (pendingId > 0 && pendingStart && roomCount_ < kMaxRooms && roomNames_ + && containsKey(pendingStart, objEnd, "\"type\":\"Room\"")) { + char* name = roomNameAt(roomCount_); + parseStringIn(pendingStart, objEnd, "name", name, kNameLen); + if (!name[0]) std::snprintf(name, kNameLen, "%d", pendingId); + roomMask_[roomCount_] = roomMaskFor(pendingStart, objEnd); + roomCount_++; + } + }; + while (true) { + const char* q = std::strchr(p, '"'); + if (!q) break; + int id = std::atoi(q + 1); + const char* close = std::strchr(q + 1, '"'); + if (id > 0 && close && close[1] == ':') { + commit(q); // the PREVIOUS group's object ends here + pendingId = id; + pendingStart = close + 1; + } + p = close ? close + 1 : q + 1; + } + commit(resp + std::strlen(resp)); // the last group runs to the end + sawGroups_ = true; + } + + // Resolve a Room's "lights":["3","5",…] array (within [begin, end)) to a colour-light + // membership bitmask: for each listed bridge id, set bit i if it equals a kept colour light + // hueId_[i]. Ids the Room lists that aren't colour-capable (a white bulb, a plug) simply don't + // match and are dropped. Scans from the "lights" key to the array's ']' so a later array + // (e.g. a Zone's "lights" in a wider scan) can't bleed in. + uint32_t roomMaskFor(const char* begin, const char* end) const { + const char* s = begin; + const size_t kl = std::strlen("\"lights\":["); + for (; s + kl <= end; s++) if (std::strncmp(s, "\"lights\":[", kl) == 0) { s += kl; break; } + uint32_t mask = 0; + for (const char* q = s; q < end && *q != ']'; ) { + if (*q == '"') { + const int id = std::atoi(q + 1); + for (uint8_t i = 0; i < lightCount_; i++) // map the id to its colour-light bit + if (hueId_[i] == id) { mask |= (1u << i); break; } + const char* c = std::strchr(q + 1, '"'); // skip to the value's closing quote + if (!c || c >= end) break; + q = c + 1; + } else q++; + } + return mask; + } + + // The colour-light array-indices (into hueId_ / lightName_) that the CURRENT room selection + // exposes: room_==0 ("All") β†’ every colour light, in order; else only the colour lights whose + // id appears in that Room's member list. Writes up to kMaxLights indices into `out`, returns + // the count. The single source of truth both the light-dropdown options and the driven set + // derive from, so the dropdown and the driven subset can never disagree. + uint8_t roomColourLights(uint8_t* out) const { + uint8_t n = 0; + if (room_ == 0 || room_ > roomCount_) { // "All" (or a stale index) β†’ every colour light + for (uint8_t i = 0; i < lightCount_; i++) out[n++] = i; + return n; + } + const uint32_t mask = roomMask_[room_ - 1]; + for (uint8_t i = 0; i < lightCount_; i++) // keep colour lights in this Room's bitmask + if (mask & (1u << i)) out[n++] = i; + return n; + } + + // Rebuild the room dropdown options: {"All", room0, room1, …}, pointing into roomName_. + void buildRoomOptions() { + roomOptions_[0] = "All"; + uint8_t n = 1; + for (uint8_t i = 0; i < roomCount_ && n <= kMaxRooms; i++) roomOptions_[n++] = roomNameAt(i); + roomOptionCount_ = n; + } + + // Rebuild the light dropdown options: {"All", }, + // pointing into lightName_. The option count tracks the current room, so the light index + // selects within that narrowed list (index 0 = "All", index k = the k-th listed light). + void buildLightOptions() { + lightOptions_[0] = "All"; + uint8_t idx[kMaxLights]; + const uint8_t m = roomColourLights(idx); + uint8_t n = 1; + for (uint8_t i = 0; i < m && n <= kMaxLights; i++) lightOptions_[n++] = lightNameAt(idx[i]); + lightOptionCount_ = n; + } + + // Derive drivenIdx_ from the current room+light filter β€” the subset pushOneChangedLight walks. + // room=All & light=All β†’ every colour light (the original behaviour, unchanged). + // room=X β†’ that room's colour lights. + // light=Y β†’ just that one light (the Y-th of the current room's list). + void rebuildDriven() { + drivenLightCount_ = 0; + uint8_t idx[kMaxLights]; + const uint8_t m = roomColourLights(idx); + if (light_ == 0 || light_ > m) { // "All" within the (possibly room-narrowed) set + for (uint8_t i = 0; i < m; i++) drivenIdx_[drivenLightCount_++] = idx[i]; + } else { // a single light: the (light_-1)-th listed one + drivenIdx_[drivenLightCount_++] = idx[light_ - 1]; + } + if (pushCursor_ >= drivenLightCount_) pushCursor_ = 0; + } + + // Push AT MOST ONE changed light per call (the loop() gate already limited the rate). The + // round-robin cursor walks every light over successive calls, so each gets its turn; we + // advance the cursor whether or not this light changed, scanning at most one full lap so an + // all-unchanged frame costs no PUT and returns fast (no blocking I/O on the render loop). + void pushOneChangedLight() { + if (!sourceBuffer_ || !sourceBuffer_->data()) return; + nrOfLightsType winStart, winLen; + windowSlice(sourceBuffer_->count(), winStart, winLen); + const uint8_t cpl = sourceBuffer_->channelsPerLight(); + if (cpl < 3) return; + const uint8_t* base = sourceBuffer_->data(); + // Walk the FILTERED driven set (drivenIdx_), not every colour light: room=All & light=All + // makes it the full colour-light set (unchanged behaviour), a room/light pick narrows it. + const uint8_t n = drivenLightCount_ < winLen ? drivenLightCount_ : static_cast(winLen); + if (n == 0) return; + drivenCount_ = n; // the round-robin size β€” drives the Hue fade time (transitionDeciseconds) + + for (uint8_t step = 0; step < n; step++) { + const uint8_t i = (pushCursor_ + step) % n; // position within the driven window + const uint8_t li = drivenIdx_[i]; // the colour-light array index it maps to + const uint8_t* px = base + static_cast(winStart + i) * cpl; + // Apply the shared Correction (brightness LUT + channel order) so the global + // brightness slider and a swapped colour order reach Hue too β€” same as the physical + // drivers. apply() writes outChannels bytes; we read the first three (RGB) for HSV. + uint8_t rgb[4] = { px[0], px[1], px[2], 0 }; + if (correction_) correction_->apply(px, rgb); + char body[80]; + if (diffAndFormat(li, rgb[0], rgb[1], rgb[2], body, sizeof(body))) { + char host[16]; bridgeStr(host); + char path[96]; + std::snprintf(path, sizeof(path), "/api/%s/lights/%u/state", appKey, hueId_[li]); + const int st = platform::httpRequest("PUT", host, 80, path, body, kHttpTimeoutMs, nullptr, 0); + // Mark the light sent only on a successful PUT β€” on a failure/timeout it stays + // eligible so the next lap retries it instead of skipping it as "already sent". + if (st == 200) { + lastRgb_[li][0] = rgb[0]; lastRgb_[li][1] = rgb[1]; lastRgb_[li][2] = rgb[2]; + sent_[li] = true; + } + pushCursor_ = static_cast((i + 1) % n); // resume after this one next time + return; // ONE PUT attempt β€” done + } + } + // No light changed this lap β€” nothing to send. Cursor stays put. + } + + // The changed-only diff + the Hue state body. Returns true (and fills `out`) when light + // `idx`'s RGB differs from the last push (or was never sent). Every driven light is colour- + // capable (parseLights keeps only those), so the body carries the full colour: on/off, plus + // bri (value) + hue + sat from a textbook RGBβ†’HSV β€” so a colour effect actually animates. + // "transitiontime" is the bridge's built-in fade β€” the smoothing knob. Set to roughly the + // per-light update interval (a light updates every kPutIntervalMs Γ— lightCount), so the bulb + // glides from its current colour to the next instead of snapping. The bridge's default is + // 400 ms (too long for our cadence β€” it smears and looks frozen); we compute a value matched + // to the actual rate so transitions are smooth but keep up. transitiontime is in deciseconds + // (Γ—100 ms). The Hue standard API tops out ~10 cmd/s β€” true real-time needs the Entertainment + // API; this is smooth ambient colour, the standard API's sweet spot. + bool diffAndFormat(uint8_t idx, uint8_t r, uint8_t g, uint8_t b, char* out, size_t cap) { + if (idx >= kMaxLights) return false; + if (sent_[idx] && lastRgb_[idx][0] == r && lastRgb_[idx][1] == g && lastRgb_[idx][2] == b) + return false; // unchanged β€” skip + const uint8_t tt = transitionDeciseconds(); + if ((r | g | b) == 0) { std::snprintf(out, cap, "{\"on\":false,\"transitiontime\":%u}", tt); return true; } + uint16_t hue; uint8_t sat, val; + rgbToHsv(r, g, b, hue, sat, val); + std::snprintf(out, cap, "{\"on\":true,\"bri\":%u,\"hue\":%u,\"sat\":%u,\"transitiontime\":%u}", + val, hue, sat, tt); + return true; + } + + // Fade time matched to how often THIS light is refreshed: with n lights round-robined one + // per kPutIntervalMs, each light's turn comes every (n Γ— kPutIntervalMs) ms. Convert to + // deciseconds and clamp to β‰₯1 (0 = snap) so the fade lasts about until the next update β€” + // continuous glide, no visible steps. + uint8_t transitionDeciseconds() const { + // Use the count actually driven this pass (n = min(lightCount_, window)), not the full + // discovered lightCount_ β€” a partial window refreshes each of its lights sooner, so a + // lightCount_-based fade would overshoot and lag. drivenCount_ is set by pushOneChangedLight. + const uint8_t driven = drivenCount_ ? drivenCount_ : (lightCount_ ? lightCount_ : 1); + const uint32_t intervalMs = static_cast(driven) * kPutIntervalMs; + const uint32_t ds = intervalMs / 100; + return static_cast(ds < 1 ? 1 : (ds > 30 ? 30 : ds)); + } + + // Textbook RGBβ†’HSV mapped to Hue's ranges: hue 0..65535 (Hue's 16-bit wheel), sat 0..254, + // val(=bri) 0..254. Integer math, no float β€” the standard max/min/chroma formulation. + static void rgbToHsv(uint8_t r, uint8_t g, uint8_t b, uint16_t& hueOut, uint8_t& satOut, uint8_t& valOut) { + const uint8_t mx = r > g ? (r > b ? r : b) : (g > b ? g : b); + const uint8_t mn = r < g ? (r < b ? r : b) : (g < b ? g : b); + const uint8_t chroma = mx - mn; + valOut = static_cast(mx > 254 ? 254 : mx); // value β†’ bri + satOut = mx == 0 ? 0 : static_cast((chroma * 254u) / mx); // saturation + if (chroma == 0) { hueOut = 0; return; } // grey β†’ hue irrelevant + // Hue in sixths of the wheel, scaled to 0..65535. h6 is the position within [0,6). + int32_t h6; // numerator over chroma, in units where a full sixth = chroma*... see below + if (mx == r) h6 = ((g - b) * 65535) / (6 * chroma) + (g < b ? 65535 : 0); + else if (mx == g) h6 = ((b - r) * 65535) / (6 * chroma) + 65535 / 3; + else h6 = ((r - g) * 65535) / (6 * chroma) + (65535 * 2) / 3; + if (h6 < 0) h6 += 65535; + hueOut = static_cast(h6 % 65536); + } +}; + +} // namespace mm diff --git a/src/light/effects/AudioSpectrumEffect.h b/src/light/effects/AudioSpectrumEffect.h index 1c60c24c..1268b222 100644 --- a/src/light/effects/AudioSpectrumEffect.h +++ b/src/light/effects/AudioSpectrumEffect.h @@ -1,6 +1,7 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette + the global active palette #include "core/color.h" // hsvToRgb, RGB #include "core/AudioModule.h" // AudioModule::latestFrame() @@ -20,9 +21,11 @@ namespace mm { // Reads the live frame from AudioModule::latestFrame(); no mic / silence β†’ all // bands zero β†’ dark, so it is safe on any target and any grid size (including // 0Γ—0). On a 1D strip (height 1) the bars collapse to per-column brightness. +// Author: projectMM original, on the WLED-SR GEQ / spectrum-analyser concept (Andrew Tuline) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h class AudioSpectrumEffect : public EffectBase { public: const char* tags() const override { return "πŸ“Š"; } + Dim dimensions() const override { return Dim::D2; } // writes the z=0 slice; extrude fills z // 0 = height gradient (green base β†’ red top, the VU look); 1 = per-band hue // (each column its own colour across the spectrum, the rainbow analyser look). @@ -63,14 +66,18 @@ class AudioSpectrumEffect : public EffectBase { if (levelRow) { const lengthType y = static_cast(h - 1); // bottom row + // The VU bar uses the SMOOTHED level so it glides with the music instead of jittering + // per audio block β€” the calm VU look. (The spectrum bars above use the raw per-band + // magnitudes, which stay snappy.) + const uint16_t vu = f->levelSmoothed; const lengthType litW = static_cast( - static_cast(f->level > 255 ? 255 : f->level) * w / 255u); + static_cast(vu > 255 ? 255 : vu) * w / 255u); for (lengthType x = 0; x < litW; x++) { - // Green β†’ red across the width, the VU-meter look. + // Green β†’ red across the width, the VU-meter look. D2: write the z=0 slice only; + // Layer::extrude fans it across z (the framework's job, not the effect's). const uint8_t frac = static_cast( static_cast(x) * 255u / (w > 1 ? w : 1)); - for (lengthType z = 0; z < d; z++) - setRGB(x, y, z, frac, static_cast(255 - frac), 0); + setRGB(x, y, 0, frac, static_cast(255 - frac), 0); } } @@ -101,7 +108,7 @@ class AudioSpectrumEffect : public EffectBase { // Per-band: the column's hue at full brightness (a strip dims // its single row by magnitude instead). const uint8_t v = (h == 1) ? mag : 255; - const RGB c = hsvToRgb(bandHue, 255, v); + const RGB c = colorFromPalette(*Palettes::active(), bandHue, v); r = c.r; g = c.g; b = c.b; } else { // Height gradient: green at the base β†’ red at the top. The @@ -119,8 +126,7 @@ class AudioSpectrumEffect : public EffectBase { : static_cast(static_cast(255 - frac) * mag / 255u); b = 0; } - for (lengthType z = 0; z < d; z++) - setRGB(x, y, z, r, g, b); + setRGB(x, y, 0, r, g, b); // D2: z=0 only; extrude fans across z } } } diff --git a/src/light/effects/AudioVolumeEffect.h b/src/light/effects/AudioVolumeEffect.h index db2ade01..44e184ed 100644 --- a/src/light/effects/AudioVolumeEffect.h +++ b/src/light/effects/AudioVolumeEffect.h @@ -1,6 +1,7 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette + the global active palette #include "core/AudioModule.h" // AudioModule::latestFrame() namespace mm { @@ -10,6 +11,7 @@ namespace mm { // single brightness, a colour shifting from calm to hot as it rises. Reads the // live frame from AudioModule::latestFrame(); with no mic (or silence) the frame is // zero and the grid stays dark, so the effect is safe on any target. +// Author: projectMM original (VU-meter) class AudioVolumeEffect : public EffectBase { public: const char* tags() const override { return "πŸ”Š"; } @@ -28,17 +30,19 @@ class AudioVolumeEffect : public EffectBase { const uint8_t cpl = channelsPerLight(); const size_t total = static_cast(w) * h * d * cpl; - // level is ~0..255; scale to the brightness ceiling. - const uint16_t level = AudioModule::latestFrame()->level; + // level is ~0..255; scale to the brightness ceiling. Uses the SMOOTHED level so the whole + // surface glows/breathes with the music instead of jittering per audio block (the calm VU + // look, the same choice as AudioSpectrum's VU bar). + const uint16_t level = AudioModule::latestFrame()->levelSmoothed; const uint16_t v = static_cast( (level > 255 ? 255 : level) * brightness / 255); - // A simple level-driven colour ramp: green (quiet) β†’ red (loud), with v - // setting overall intensity. Fill every light identically β€” a VU meter on - // the whole surface; modifiers/layouts give it shape. - const uint8_t r = static_cast(v); - const uint8_t g = static_cast(v > 128 ? (255 - v) * 2 : 255 * v / 128); - const uint8_t b = 0; + // Level drives BOTH the palette index and the brightness: quiet maps to the palette's + // start, loud to its end, dimmed by v. Fill every light identically β€” a VU meter on the + // whole surface; modifiers/layouts give it shape. + const uint8_t lvl = static_cast(level > 255 ? 255 : level); + const RGB c = colorFromPalette(*Palettes::active(), lvl, static_cast(v)); + const uint8_t r = c.r, g = c.g, b = c.b; // Write logical RGB only: channel order and any RGBW white are the // driver's Correction (W = min(r,g,b)), like every other effect. Effect diff --git a/src/light/effects/BlurzEffect.h b/src/light/effects/BlurzEffect.h new file mode 100644 index 00000000..c5777319 --- /dev/null +++ b/src/light/effects/BlurzEffect.h @@ -0,0 +1,158 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade, draw::fill, draw::blur +#include "core/math8.h" // Random8 +#include "core/AudioModule.h" // AudioModule::latestFrame() +#include "core/AudioFrame.h" // AudioFrame::bands[16], peakHz + +#include // log10f, roundf (once-per-frame freqMap, not per-light) + +namespace mm { + +// Blurz β€” an audio-reactive "blurred dot" effect. Each frame it lights ONE pixel coloured by the +// current frequency band's magnitude, then blurs the whole strip, so the dot bleeds into a soft +// glowing smear that drifts and fades. A `freqBand` cursor advances one band per frame (0..15, +// wrapping), so over 16 frames the colour cycles through the whole spectrum. Where the lit pixel +// lands is the lever: +// - freqMap on: the dot's position maps to the dominant frequency (majorPeak) β€” bass at one end, +// treble at the other, so the spectrum scrolls spatially with pitch. +// - geqScanner: the dot scans steadily across the strip (a sweeping cursor). +// - default: the dot jumps to a random position each frame (WLED's classic Blurz). +// fadeRate dims the trail each frame; blur is the box-blur strength applied after the dot is drawn. +// +// Prior art: WLED's "Blurz" audio effect, carried into MoonLight. The per-band colour cursor, the +// frequencyβ†’position map, and the fade-then-blur pipeline are reproduced here, written fresh on +// EffectBase + the shared draw primitives. Reads AudioModule::latestFrame(); with simulation off or no +// publisher the bands read 0 β†’ the strip fades to black, safe on any target and grid size. +// Author: Andrew Tuline (WLED-SR), with enhancements by @softhack007 β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +// +// 1D in spirit (a strip of `nrOfLights` pixels) but declared D2 so it spans a 2D panel as a flat run +// along the buffer's pixel index; the dot and blur work over the whole pixel count either way. +class BlurzEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ™πŸ“Š"; } // WLED-lineage Β· audio + Dim dimensions() const override { return Dim::D2; } + + // MoonLight/WLED defaults (fadeRate 48, blur 127). fadeRate 48 fades the trail fast enough that + // each dot stays distinct and punchy rather than smearing into a wash; blur 127 spreads each dot + // into a soft halo. + uint8_t fadeRate = 48; // per-frame fade-to-black strength (1..255) + uint8_t blur = 127; // box-blur strength applied after drawing the dot (1..255) + bool freqMap = false; // position the dot by dominant frequency instead of scanning/random + bool geqScanner = false; // steady sweep across the strip (vs. random jump) when freqMap is off + + void onBuildControls() override { + controls_.addUint8("fadeRate", fadeRate, 1, 255); + controls_.addUint8("blur", blur, 1, 255); + controls_.addBool("freqMap", freqMap); + controls_.addBool("geqScanner", geqScanner); + } + + // WLED clears the segment once on the first call (SEGENV.call == 0 β†’ fadeToBlackBy(255), a full + // wipe to black). A grid rebuild (resize / re-add) restarts the effect, so reset the one-shot + // clear + the scanner/band cursors here so the dot starts from a known state on any reconfig. + void onBuildState() override { + firstFrame_ = true; + freqBand_ = 0; + scanPos_ = 0; + MoonModule::onBuildState(); + } + + void loop() override { + const int cols = width(); + const int rows = height(); + if (cols <= 0 || rows <= 0 || channelsPerLight() < 3) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(cols), static_cast(rows), depthDim()}; + + // The strip is the flat run of all pixels; the dot is addressed by a single linear index. + const int maxLen = static_cast(nrOfLights()); + if (maxLen <= 0) return; + + // One-shot clear on the first frame after (re)build β€” WLED's SEGENV.call == 0 fadeToBlackBy(255). + if (firstFrame_) { draw::fill(buf, RGB{0, 0, 0}); firstFrame_ = false; } + + // Per-frame fade gives the blurred dot its decaying trail (WLED fadeToBlackBy(fadeRate)). + layer()->fadeToBlackBy(fadeRate); + + const AudioFrame* f = AudioModule::latestFrame(); + if (!f) return; + + // Advance the band cursor: one band per frame, wrapping 0..15 (NUM_GEQ_CHANNELS). + freqBand_ = static_cast((freqBand_ + 1) % 16); + + // Decide where the dot lands this frame. + int segLoc; + if (freqMap && f->peakHz > 0) { + // Map the dominant frequency f->peakHz to a position along the strip on a log10 scale, + // so pitch drives the dot: kLoLog10 (10^1.78 β‰ˆ 60 Hz, just under the 80 Hz mic floor) + // maps to index 0 and kMaxFreqLog10 (log10(11025) β‰ˆ 4.0424) maps to index maxLen. The + // dot walks from one end to the other as the dominant frequency rises through the band. + constexpr float kMaxFreqLog10 = 4.0424f; // log10(11025) + constexpr float kLoLog10 = 1.78f; // ~60 Hz, just below the 80 Hz mic floor + const float lp = log10f(static_cast(f->peakHz)); + const int freqLocn = static_cast( + roundf((lp - kLoLog10) * static_cast(maxLen) / (kMaxFreqLog10 - kLoLog10))); + segLoc = freqLocn; + } else if (geqScanner) { + // geqScanner sweeps the dot steadily across the strip, one pixel per frame, wrapping at + // maxLen β€” a scanning cursor independent of the audio content's position. + segLoc = scanPos_; + scanPos_ = static_cast((scanPos_ + 1) % maxLen); + } else { + // WLED's classic Blurz: the dot jumps to a random position each frame (random16(SEGLEN)). + segLoc = static_cast(rng_.next16() % static_cast(maxLen)); + } + + // Clamp the position into [0, maxLen-1] (the freqMap path can land out of range): + // segLoc = max(0, min(nrOfLights-1, segLoc)). + if (segLoc < 0) segLoc = 0; + if (segLoc > maxLen - 1) segLoc = maxLen - 1; + + // Colour the dot by the current band's magnitude, scaled across the strip the way WLED's + // Blurz does: pixColor = (2 * fftResult[band] * 240) / max(1, maxLen - 1). WLED passes this + // straight to ColorFromPalette, whose index is a uint8_t β€” so a value above 255 WRAPS around + // the palette wheel (mod 256), it does NOT clamp. We reproduce that by truncating to uint8_t. + const int denom = (maxLen - 1) > 1 ? (maxLen - 1) : 1; // max(1, maxLen-1) + const int pixColor = (2 * static_cast(f->bands[freqBand_]) * 240) / denom; + const RGB c = colorFromPalette(*Palettes::active(), static_cast(pixColor)); + + // Address the linear dot index as an (x,y) on the flat run: x = idx % cols, y = idx / cols. + const int dx = segLoc % cols; + const int dy = segLoc / cols; + + // Dot RADIUS scales with the fixture so the blob reads the same size on a 16Γ—16 and a 128Γ—128 + // panel (WLED draws a single pixel, which vanishes on a big grid β€” this is the projectMM + // improvement). r = 0 on a grid up to 32 (one pixel, the WLED look), then min(w,h)/32 above + // that (4 on a 128 grid, a 9Γ—9 core). Drawn as a small filled square, blurred into a soft blob. + const int minDim = cols < rows ? cols : rows; + const int r = minDim > 32 ? minDim / 32 : 0; // ≀32 β†’ one pixel (WLED look); larger β†’ scaled blob + for (int oy = -r; oy <= r; oy++) + for (int ox = -r; ox <= r; ox++) + draw::pixel(buf, dims, {static_cast(dx + ox), static_cast(dy + oy), 0}, c); + + // Blur the whole buffer β€” the defining smear (WLED SEGMENT.blur(custom1)). + draw::blur(buf, dims, blur); + + // Re-stamp the dot core ON TOP of the blur (WLED's addRGB after blur2d). The blur spreads the + // dot into its halo but also dilutes its centre β€” a blurred dot fades to near-nothing without + // this. Re-adding the colour keeps the core bright so the smear reads as a glowing dot. + for (int oy = -r; oy <= r; oy++) + for (int ox = -r; ox <= r; ox++) + draw::addPixel(buf, dims, {static_cast(dx + ox), static_cast(dy + oy), 0}, c); + } + +private: + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + bool firstFrame_ = true; // WLED SEGENV.call == 0 one-shot clear + uint8_t freqBand_ = 0; // band cursor, 0..15, ++ each frame + int scanPos_ = 0; // geqScanner sweep position + Random8 rng_; // random dot position (WLED random16(SEGLEN)) +}; + +} // namespace mm \ No newline at end of file diff --git a/src/light/effects/BouncingBallsEffect.h b/src/light/effects/BouncingBallsEffect.h new file mode 100644 index 00000000..39c5748d --- /dev/null +++ b/src/light/effects/BouncingBallsEffect.h @@ -0,0 +1,171 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade +#include "core/math8.h" // Random8 +#include "platform/platform.h" // platform::alloc / platform::free + +#include // sqrtf, lroundf β€” per-ball physics, not per-light + +namespace mm { + +// Bouncing Balls: a column of balls per x. Each ball is launched upward, falls under gravity, +// bounces with energy loss (dampening), and is re-launched when its velocity dies out β€” the +// classic 1D "BouncingBalls" pattern, run independently for every grid column so a 2D panel shows +// a forest of bouncing dots. Each ball's vertical position is the analytic projectile equation +// (Β½Β·gΒ·tΒ² + vβ‚€Β·t), so the motion is real physics rather than a frame-step integration; the per-ball +// `impactVelocity` and `lastBounceTime` carry the state between frames. +// +// Prior art: MoonLight's BouncingBalls (E_MoonModules / MoonModules), itself the 2D-column +// generalisation of WLED's "Bouncing Balls" (Aircoookie, ported from Danny Wilson's idea) which is +// in turn the FastLED bouncing-balls demo lineage. The gravity constant (-9.81), the +// (255-grav)/64+1 time-scale, the 0.9 - i/(numBallsΒ²) dampening, the √(-2g)Β·rand(5,11)/10 relaunch +// kick, and the palette-index spacing are reproduced exactly here, written fresh on EffectBase + the +// shared draw primitives. Per-column ball state lives on the heap (sized to width()Γ—maxNumBalls), +// allocated in onBuildState and freed in teardown β€” never a large inline member. +// Author: Andrew Tuline (WLED-SR) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +class BouncingBallsEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸ™"; } // MoonLight origin Β· 2D + // Writes only the z=0 slice (one ball column per x, ball drawn at (x, pos)); Layer::extrude + // duplicates it across z on 3D layers. + Dim dimensions() const override { return Dim::D2; } + + static constexpr uint8_t maxNumBalls = 16; + + uint8_t grav = 128; // gravity strength (0..255); higher = faster fall (shorter time-scale) + uint8_t numBalls = 8; // balls per column (1..maxNumBalls) + + void onBuildControls() override { + controls_.addUint8("grav", grav, 0, 255); + controls_.addUint8("numBalls", numBalls, 1, maxNumBalls); + } + + void onBuildState() override { + // One ball array per x column: balls[width][maxNumBalls], flattened. Reallocate only when + // the column count changes. MoonLight zero-initialises the array (onSizeChanged), so every + // ball starts with height 0 / impactVelocity 0 and bounces on the very first frame β€” matched + // here with a memset to zero. + const size_t cols = static_cast(width() > 0 ? width() : 0); + const size_t count = cols * maxNumBalls; + if (enabled() && count > 0) { + if (count != ballCount_) { + releaseBalls(); + balls_ = static_cast(platform::alloc(count * sizeof(Ball))); + if (balls_) { + for (size_t i = 0; i < count; i++) balls_[i] = Ball{}; // zero-init, like MoonLight + ballCount_ = count; + } + } + } else { + releaseBalls(); + } + setDynamicBytes(ballCount_ * sizeof(Ball)); + } + + void teardown() override { + releaseBalls(); + setDynamicBytes(0); + } + + ~BouncingBallsEffect() override { releaseBalls(); } + + void loop() override { + if (!balls_) return; + + const int cols = width(); + const int rows = height(); + if (cols <= 0 || rows <= 0) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(cols), static_cast(rows), depthDim()}; + + // Motion trail: dim the whole buffer each frame (MoonLight: fadeToBlackBy(100)). + layer()->fadeToBlackBy(100); + + constexpr float gravity = -9.81f; + const uint32_t time = elapsed(); + + // Clamp the active ball count to the array bound, exactly as the source does (min(numBalls, + // maxNumBalls)). numBalls is also the divisor in the dampening and palette spacing below. + const int nBalls = numBalls < maxNumBalls ? numBalls : maxNumBalls; + if (nBalls <= 0) return; + + // Time-scale: divides real elapsed-ms so higher `grav` means a shorter (faster) fall. + // MoonLight: timeSinceLastBounce = (time - lastBounceTime) / ((255-grav)/64 + 1). + const uint32_t timeScale = static_cast((255 - grav) / 64 + 1); + + for (int x = 0; x < cols; x++) { + Ball* column = balls_ + static_cast(x) * maxNumBalls; + for (int i = 0; i < nBalls; i++) { + Ball& ball = column[i]; + + // Integer ms-division then float β€” matches MoonLight's `(unsigned long) / (int)` + // (truncating) assigned to a float, NOT a full-float divide (which would keep + // sub-millisecond precision the source discards). Fidelity: the truncation shifts + // every trajectory identically to the original. + const float timeSinceLastBounce = static_cast((time - ball.lastBounceTime) / timeScale); + const float timeSec = timeSinceLastBounce / 1000.0f; + float height = (0.5f * gravity * timeSec + ball.impactVelocity) * timeSec; + + if (height <= 0.0f) { + // Ball has hit the floor: bounce. Lose energy by `dampening`, which grows with i + // so higher balls in the stack die out faster. MoonLight: + // dampening = 0.9 - float(i)/(numBalls*numBalls) + height = 0.0f; + const float dampening = 0.9f - static_cast(i) / static_cast(nBalls * nBalls); + ball.impactVelocity = dampening * ball.impactVelocity; + ball.lastBounceTime = time; + + if (ball.impactVelocity < 0.015f) { + // Energy spent: relaunch with a fresh upward kick. MoonLight: + // impactVelocity = sqrt(-2 * gravity) * random8(5,11)/10 + // sqrt(-2*gravity) = sqrt(19.62); random8(5,11) ∈ [5,10] (FastLED random8 is + // half-open), so the kick scales by 0.5..1.0. + ball.impactVelocity = std::sqrt(-2.0f * gravity) * static_cast(rng_.below(5, 11)) / 10.0f; + } + } else if (height > 1.0f) { + continue; // ball flew off the top of the column this frame β€” draw nothing + } + + // Map the 0..1 ball height onto the column, top-anchored: height 0 β†’ bottom row. + const int pos = (rows - 1) - static_cast(lroundf(height * static_cast(rows - 1))); + + // Palette spacing: each ball gets a slice of the wheel; divisor is max(numBalls,8) so + // small ball counts still spread across the palette (MoonLight: i * (256/max(numBalls,8))). + const int paletteDiv = nBalls > 8 ? nBalls : 8; + const uint8_t index = static_cast(i * (256 / paletteDiv)); + const RGB color = colorFromPalette(*Palettes::active(), index); + + draw::pixel(buf, dims, {static_cast(x), static_cast(pos), 0}, color); + } + } + } + +private: + // One ball's state. MoonLight's struct: { float height; float impactVelocity; unsigned long + // lastBounceTime; }. `height` isn't carried between frames (recomputed analytically each loop), + // so only the two persistent fields are stored. + struct Ball { + float impactVelocity = 0.0f; + uint32_t lastBounceTime = 0; + }; + + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + void releaseBalls() { + if (balls_) { + platform::free(balls_); + balls_ = nullptr; + } + ballCount_ = 0; + } + + Ball* balls_ = nullptr; // balls[width][maxNumBalls], flattened; column x = balls_ + x*maxNumBalls + size_t ballCount_ = 0; // number of Ball entries allocated (width * maxNumBalls) + Random8 rng_; // relaunch-kick randomness (FastLED random8(5,11) β†’ below(5,11)) +}; + +} // namespace mm \ No newline at end of file diff --git a/src/light/effects/CheckerboardEffect.h b/src/light/effects/CheckerboardEffect.h deleted file mode 100644 index a814ed5b..00000000 --- a/src/light/effects/CheckerboardEffect.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#include "light/layers/Layer.h" -#include "core/color.h" - -namespace mm { - -class CheckerboardEffect : public EffectBase { -public: - const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step - // Iterates y and x only; Layer::extrude fills z on 3D layers. - Dim dimensions() const override { return Dim::D2; } - - uint8_t cell_size = 4; - uint8_t bpm = 60; - uint8_t hue_a = 0; - uint8_t hue_b = 128; - - void onBuildControls() override { - controls_.addUint8("cell_size", cell_size, 1, 255); - controls_.addUint8("bpm", bpm, 1, 255); - controls_.addUint8("hue_a", hue_a, 0, 255); - controls_.addUint8("hue_b", hue_b, 0, 255); - } - - void loop() override { - uint8_t* buf = buffer(); - lengthType w = width(); - lengthType h = height(); - uint8_t cpl = channelsPerLight(); - - uint32_t now = elapsed(); - uint32_t dt = now - lastElapsed_; - lastElapsed_ = now; - // Accumulate the raw (dt * bpm) product; divide only at the read site. - // Per-tick `dt*bpm*256/60000` rounds to 0 on desktop (dt β‰ˆ 0..1ms) and - // freezes the animation; see MetaballsEffect for the same fix. - phase_num_ += static_cast(dt) * bpm; - uint8_t phaseCell = static_cast((phase_num_ * 16) / 60000); - - RGB ca = hsvToRgb(hue_a, 255, 255); - RGB cb = hsvToRgb(hue_b, 255, 255); - - for (lengthType y = 0; y < h; y++) { - uint8_t cy = static_cast(static_cast(y) / cell_size); - uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; - for (lengthType x = 0; x < w; x++) { - uint8_t cx = static_cast(static_cast(x) / cell_size); - bool on = ((cx + cy + phaseCell) & 1) != 0; - RGB c = on ? cb : ca; - - if (cpl >= 1) row[0] = c.r; - if (cpl >= 2) row[1] = c.g; - if (cpl >= 3) row[2] = c.b; - row += cpl; - } - } - } - -private: - // Numerator-only accumulator (units of dt*bpm). See loop() for why. - uint64_t phase_num_ = 0; - uint32_t lastElapsed_ = 0; -}; - -} // namespace mm diff --git a/src/light/effects/DemoReelEffect.h b/src/light/effects/DemoReelEffect.h new file mode 100644 index 00000000..2ef7240f --- /dev/null +++ b/src/light/effects/DemoReelEffect.h @@ -0,0 +1,198 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer() β€” the child renders into the same Layer buffer +#include "core/ModuleFactory.h" // enumerate + create the effects to cycle through +#include "core/math8.h" // Random8 β€” the shuffle pick +#include "light/Palette.h" // Palettes::setActive β€” random palette per cycle +#include "light/draw.h" // draw::text β€” the effect-name overlay +#include "light/fonts.h" // fonts::kFont4x6 β€” the overlay font + +#include // strcmp + +namespace mm { + +// Demo reel: cycles through every OTHER registered effect, one at a time, auto-advancing every +// `interval` seconds. It hosts a single live child effect β€” created from the ModuleFactory registry, +// parented to this effect's own Layer so the child's layer()/buffer()/width()/elapsed() resolve to +// the same render target β€” and delegates loop() to it each tick. On the interval it tears the child +// down, deletes it, and instantiates the next effect in the registry (or a random one when +// `shuffle`). This reuses the exact create β†’ onBuildControls β†’ setup β†’ onBuildState β†’ loop lifecycle +// a Layer runs for a normal effect child (HttpServerModule::applyAddModule), so no new machinery: +// the reel is just an effect that swaps which effect it *is* over time. +// +// It deliberately does NOT composite effects (that's what the Layer stack + blend modes already do); +// it plays them in sequence β€” the FastLED DemoReel100 / WLED preset-cycle pattern. The child's own +// controls aren't surfaced (they run at their defaults); the reel exposes only the cycle controls. +// +// Prior art: FastLED's DemoReel100 sketch (Mark Kriegsman) β€” the canonical "rotate through a list of +// patterns on a timer" demo; the registry-driven, self-skipping variant is ours. +// Author: projectMM original, on Mark Kriegsman's FastLED DemoReel100 pattern β€” https://github.com/FastLED/FastLED/blob/master/examples/DemoReel100/DemoReel100.ino +class DemoReelEffect : public EffectBase { +public: + const char* tags() const override { return "🎬"; } // demo reel + // D3: the reel produces a COMPLETE frame β€” it runs the child, extrudes the child's output itself, + // then draws the name overlay on top β€” so the Layer must not extrude again (that would fan the + // child's x=0 column across X and wipe the overlay). The child's own dimensionality is handled + // inside loop() via layer()->extrude(child dim) BEFORE the overlay, not by reporting it here. + Dim dimensions() const override { return Dim::D3; } + + uint8_t interval = 8; // seconds each effect plays before advancing + bool shuffle = false; // random next-effect pick instead of registry order + bool randomPalette = true; // pick a random palette on each cycle (showcases the palette set) + bool showName = true; // overlay the playing effect's name (the reel as a showcase tool) + + void onBuildControls() override { + controls_.addUint8("interval", interval, 1, 120); + controls_.addBool("shuffle", shuffle); + controls_.addBool("randomPalette", randomPalette); + controls_.addBool("showName", showName); + } + + // Build the eligible-effect list (all Effect-role types except this one) and start the first. + void onBuildState() override { + buildEligibleList(); + // Restart the reel from the top on any rebuild (grid resize, control change): tear down the + // current child (its buffers were sized to the old grid) and re-create against the new one. + swapTo(cursor_ < eligibleCount_ ? cursor_ : 0); + } + + void loop() override { + if (eligibleCount_ == 0 || !current_) return; + + // Advance on the interval. elapsed() is the Layer's monotonic ms clock (same source every + // effect uses), so the cadence is frame-rate-independent. + const uint32_t now = elapsed(); + if (now - lastSwitchMs_ >= static_cast(interval) * 1000u) { + lastSwitchMs_ = now; + advance(); + } + + current_->loop(); // render the hosted effect into our Layer's buffer this tick + // Extrude the child's output NOW (the Layer skips it β€” the reel is D3). A hosted D1/D2 effect + // (NoiseMeter, FreqMatrix, GEQ…) writes only its x=0 column / z=0 slice; fan it across the + // unused axes here, BEFORE the name overlay, so the overlay isn't wiped by a later extrude. + // Use the dim cached at swapTo() β€” not a per-frame downcast of current_, which is a MoonModule* + // whose EffectBase subobject cast is only valid for a live effect child. + layer()->extrude(currentDim_); + + // Overlay the playing effect's name (the reel as a showcase tool). Drawn on top of the + // hosted output in the compact 4x6 font at the TOP-left β€” overwriting a few pixels is fine + // (the reel owns the whole frame). Uses metadata the reel already has (current_->name()), so + // no system/pipeline reach-in. draw::text clips to the grid, so a tiny grid just shows what + // fits (or nothing). + if (showName && current_) { + Buffer& buf = layer()->buffer(); + const Coord3D dims{width(), height(), depthDim()}; + draw::text(buf, dims, fonts::kFont4x6, current_->name(), 0, 0, {255, 255, 255}); + } + } + + void teardown() override { + destroyCurrent(); + EffectBase::teardown(); + } + + ~DemoReelEffect() override { destroyCurrent(); } + + // Test seams. + uint8_t eligibleCountForTest() const { return eligibleCount_; } + const char* currentTypeForTest() const { return current_ ? current_->typeName() : nullptr; } + void advanceForTest() { advance(); } + +private: + // The registry indices of every Effect-role type that isn't DemoReel itself. Bounded by the + // registry size; stored inline (a byte per effect, a few dozen at most β€” no heap needed). + static constexpr uint8_t kMaxEligible = 64; + uint8_t eligible_[kMaxEligible] = {}; + uint8_t eligibleCount_ = 0; + uint8_t cursor_ = 0; // index INTO eligible_ of the current effect + MoonModule* current_ = nullptr; // the live hosted effect (owned; deleted on swap/teardown) + Dim currentDim_ = Dim::D3; // cached dimensionality of current_ (set in swapTo, drives extrude) + uint32_t lastSwitchMs_ = 0; + Random8 rng_; + + void buildEligibleList() { + eligibleCount_ = 0; + const uint8_t n = ModuleFactory::typeCount(); + for (uint8_t i = 0; i < n && eligibleCount_ < kMaxEligible; i++) { + if (ModuleFactory::typeRole(i) != ModuleRole::Effect) continue; + const char* name = ModuleFactory::typeName(i); + if (name && std::strcmp(name, "DemoReelEffect") == 0) continue; // never host ourselves + eligible_[eligibleCount_++] = i; + } + } + + // Move to the next effect: the following registry entry, or a random one when shuffle. + void advance() { + if (eligibleCount_ == 0) return; + uint8_t next; + if (shuffle && eligibleCount_ > 1) { + do { next = rng_.below(eligibleCount_); } while (next == cursor_); // don't repeat in place + } else { + next = static_cast((cursor_ + 1) % eligibleCount_); + } + // Showcase the palette set: pick a fresh random palette on each cycle. This overrides the + // global Drivers palette while the reel runs; the next Drivers rebuild restores the user's + // choice when the reel is removed (intended β€” the reel takes over the display). Only on a + // real cycle advance, not on rebuild (swapTo is also called from onBuildState). + if (randomPalette && palettes::kCount > 0) Palettes::setActive(rng_.below(palettes::kCount)); + swapTo(next); + } + + // Tear down the current child and stand up the effect at eligible_[which], wired to our Layer. + void swapTo(uint8_t which) { + destroyCurrent(); + if (which >= eligibleCount_) return; + cursor_ = which; + const char* typeName = ModuleFactory::typeName(eligible_[which]); + MoonModule* mod = ModuleFactory::create(typeName); + if (!mod) return; + // Parent to the LAYER, not to us: EffectBase::layer() is static_cast(parent()), so + // the child must see the Layer as its parent for buffer()/dims/elapsed() to resolve. We hold + // it privately and drive its loop() ourselves β€” we do NOT addChild() it (that would make the + // Layer tick it a second time). Same createβ†’build lifecycle as a normal effect child. + mod->setParent(layer()); + mod->onBuildControls(); + mod->setup(); + mod->onBuildState(); + current_ = mod; + // Cache the child's dimensionality from the FACTORY (which probed it at registration via + // if-constexpr), not by downcasting mod. An Effect-role type is not guaranteed to be an + // EffectBase β€” a bare MoonModule can register with Effect role β€” so static_cast + // then a virtual call is undefined behaviour (it crashed on such a type). typeDim returns 0 + // ("N/A") for a non-EffectBase, treated as D3 (no extrude); 1/2/3 for a real effect. The + // extrude then runs through the same registry data the Layer uses. RTTI-free (-fno-rtti). + const uint8_t d = ModuleFactory::typeDim(eligible_[which]); + currentDim_ = (d == 1) ? Dim::D1 : (d == 2) ? Dim::D2 : Dim::D3; + lastSwitchMs_ = elapsed(); + // No clear on switch: the buffer persists, so the incoming effect settles over the outgoing + // one β€” a full-grid effect overwrites in a frame, a trail effect fades the old content away. + // Letting them blend for a moment is the intended behaviour, not a defect (the Layer never + // clears; effects own their background). + refreshStatus(); + } + + void destroyCurrent() { + if (!current_) return; + current_->teardown(); + delete current_; // teardown-then-delete, the Scheduler's ownership pattern + current_ = nullptr; + } + + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + void refreshStatus() { + // Show which effect is playing (its display name), e.g. "playing: Plasma (3/19)". + if (current_) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "playing: %s (%u/%u)", + current_->name(), static_cast(cursor_ + 1), + static_cast(eligibleCount_)); + setStatus(statusBuf_); + } + } + + char statusBuf_[48] = {}; +}; + +} // namespace mm diff --git a/src/light/effects/DistortionWavesEffect.h b/src/light/effects/DistortionWavesEffect.h index 03ea06a6..7b3bc636 100644 --- a/src/light/effects/DistortionWavesEffect.h +++ b/src/light/effects/DistortionWavesEffect.h @@ -1,7 +1,8 @@ #pragma once #include "light/layers/Layer.h" -#include "core/color.h" // sin8, hsvToRgb +#include "light/Palette.h" // colorFromPalette + active palette +#include "core/math8.h" // sin8 (integer sine LUT) namespace mm { @@ -13,10 +14,11 @@ namespace mm { // Integer-only: angles are uint8_t (256 = full turn), sin8() returns 0..255. The two // sines are averaged into a hue byte. WLED runs the vertical wave's time ~1.3Γ— the // horizontal; we approximate 1.3 as (t*333)>>8 = t*1.301..., staying in integer math. -// hsvToRgb(hue, 240, 255) keeps WLED's slightly-desaturated look. +// The hue indexes the global active palette via colorFromPalette (WLED used an hsvToRgb sweep). // // Prior art: MoonLight E_WLED.h (the WLED port); projectMM v1/v2 DistortionWaves (those // used float sinf β€” this is the integer-sin8 equivalent). +// Author: ldirko & blazoncek (WLED port) β€” https://editor.soulmatelights.com/gallery/1089-distorsion-waves , https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h class DistortionWavesEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«"; } // MoonLight / WLED origin @@ -45,7 +47,11 @@ class DistortionWavesEffect : public EffectBase { // high byte as the time phase (uint8 angle), same accumulator idiom as elsewhere. if (speed) phase_ += static_cast(dt) * speed; const uint8_t t = static_cast((phase_ * 256) / 60000); - const uint8_t ty = static_cast((static_cast(t) * 333) >> 8); // ~1.3Β·t + // ty is the y-axis time phase, running ~1.3Γ— t. Deriving it from the raw phase_ accumulator + // (not from the already-wrapped uint8 t) keeps it CONTINUOUS: computing ty as (t*333)>>8 made + // ty jump by ~76 every time t wrapped 255β†’0 (~once a second), because 1.3 isn't an integer + // multiple of 256 β€” a visible shift. From phase_ the wrap is seamless (both are smooth uint8s). + const uint8_t ty = static_cast((phase_ * 333) / 60000); // ~1.3Β·t, continuous across wraps for (lengthType y = 0; y < h; y++) { const uint8_t sy = sin8(static_cast(static_cast(y) * freq_y + ty)); @@ -55,7 +61,7 @@ class DistortionWavesEffect : public EffectBase { // Average the two sines (each 0..255) β†’ a hue byte. The interference of // the two frequencies + the 1.3Γ— time skew is what makes the pattern move. const uint8_t hue = static_cast((static_cast(sx) + sy) >> 1); - const RGB c = hsvToRgb(hue, 240, 255); + const RGB c = colorFromPalette(*Palettes::active(), hue); if (cpl >= 1) row[0] = c.r; if (cpl >= 2) row[1] = c.g; if (cpl >= 3) row[2] = c.b; diff --git a/src/light/effects/EffectBase.h b/src/light/effects/EffectBase.h index 403f7584..1466cbbd 100644 --- a/src/light/effects/EffectBase.h +++ b/src/light/effects/EffectBase.h @@ -11,7 +11,7 @@ class Layer; // forward declaration // Dim enum lives in light/light_types.h so both EffectBase and ModifierBase can // refer to it without including each other. Used by Layer::extrude to fill unused -// axes (D1 row β†’ y and z; D2 slice β†’ z; D3 native) and by the UI to derive the +// axes (D1 column β†’ x and z; D2 slice β†’ z; D3 native) and by the UI to derive the // πŸ“/🟦/🧊 dimensional emoji (so it isn't repeated in each module's tags()). // ModuleFactory::registerType captures dim from a probe via if-constexpr β€” // no per-domain registration wrapper is needed. @@ -23,7 +23,7 @@ class EffectBase : public MoonModule { // Default D3 means "I iterate every axis the layer gives me" β€” the framework // doesn't extrude on your behalf. Override to D2 if you write only the z=0 // slice (Layer::extrude duplicates it across z); to D1 if you write only the - // y=0,z=0 row (extrude fills y and z). Declaring D2/D1 is an opt-in promise: + // x=0,z=0 column (1D runs along Y β€” extrude fills x and z). Declaring D2/D1 is an opt-in promise: // the framework treats slices you don't write as authoritative copies of the // ones you did. On a 2D layer (depth=1) the D3-vs-D2 distinction is free β€” // extrude's z-fill loop is guarded by `depth_ > 1` and does nothing. @@ -37,7 +37,7 @@ class EffectBase : public MoonModule { // layer's. Concretely: // - A D3 effect on a 1D layer (h=d=1) iterates only x; y and z stay 0. // - A D2 effect on a 1D layer (h=d=1) iterates only x; y stays 0. - // - A D1 effect on a 3D layer writes its row and extrude fills the rest. + // - A D1 effect on a 3D layer writes its (x=0) column and extrude fills the rest. // Hardcoding a fixed `z < SOMETHING` is a buffer-overrun bug β€” the buffer // is sized to width Γ— height Γ— depth Γ— channels, no more. Tests in // test_extrude.cpp pin the D3-on-2D and D3-on-1D paths for the shipped diff --git a/src/light/effects/FireEffect.h b/src/light/effects/FireEffect.h index d2b4e481..3d0cbaf4 100644 --- a/src/light/effects/FireEffect.h +++ b/src/light/effects/FireEffect.h @@ -1,13 +1,16 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette, Palettes::active β€” heat β†’ palette colour #include "core/color.h" +#include "core/math8.h" // mm::Random8 β€” the shared per-effect PRNG (sparks/cooling) #include "platform/platform.h" #include namespace mm { +// Author: Mark Kriegsman's Fire2012 (FastLED); MoonLight adapts MatrixFireFast by toggledbits β€” https://github.com/toggledbits/MatrixFireFast class FireEffect : public EffectBase { public: const char* tags() const override { return "βš‘οΈπŸ¦…"; } // FastLED origin (Fire2012-style) Β· David Jupijn / Rising Step @@ -17,12 +20,10 @@ class FireEffect : public EffectBase { uint8_t cooling = 55; uint8_t sparking = 120; - uint8_t hue_shift = 0; void onBuildControls() override { controls_.addUint8("cooling", cooling, 1, 255); controls_.addUint8("sparking", sparking, 1, 255); - controls_.addUint8("hue_shift", hue_shift, 0, 255); } void onBuildState() override { @@ -82,12 +83,20 @@ class FireEffect : public EffectBase { } } - // 3. Random sparks at the bottom row - if (h > 0) { + // 3. Random sparks at the bottom row. Scale the number of spark attempts with width so a + // wide grid lights up across its whole base instead of leaving most columns cold β€” a + // fixed 4 sparks fills a 16-wide grid but barely speckles a 256-wide one. One attempt + // per ~4 columns (min 4) keeps the bottom row evenly seeded at any width. + if (h > 0 && w > 0) { lengthType bottomRow = static_cast(h - 1); - for (uint8_t i = 0; i < 4; i++) { + lengthType sparks = w / 4; + if (sparks < 4) sparks = 4; + for (lengthType i = 0; i < sparks; i++) { if (rand8() < sparking) { - uint8_t sx = static_cast((static_cast(rand8()) * w) >> 8); + // 16-bit random scaled by width: a single rand8 (8-bit) maps to only 256 + // buckets, leaving columns >256 unreachable on a wide grid (width goes to 512). + const uint16_t r16 = static_cast((rand8() << 8) | rand8()); + lengthType sx = static_cast((static_cast(r16) * w) >> 16); uint8_t add = static_cast(160 + (rand8() & 0x5F)); uint16_t newHeat = static_cast(heat_[bottomRow * w + sx]) + add; heat_[bottomRow * w + sx] = newHeat > 255 ? 255 : static_cast(newHeat); @@ -95,9 +104,16 @@ class FireEffect : public EffectBase { } } - // 4. Render heat to RGB via fire palette (with optional hue rotation) + // 4. Render heat to RGB through the active palette: the heat value (0 = cold, 255 = hottest) + // is the palette index, so the flame takes the palette's lowβ†’high gradient. The Lava + // palette (blackβ†’redβ†’orangeβ†’yellowβ†’white) gives the classic fire look; any palette works + // (an Ocean/Forest palette makes a blue/green "fire"). + // A completely cold cell (heat 0) always stays black β€” the "sky" above the flame β€” rather + // than taking the palette's index-0 colour (Lava's is black, but Ocean's is blue, which + // would tint the whole background). Only a warm cell is coloured. + const Palette& pal = *Palettes::active(); for (nrOfLightsType i = 0; i < heatCount_; i++) { - RGB c = heatToRgb(heat_[i], hue_shift); + RGB c = heat_[i] == 0 ? RGB{0, 0, 0} : colorFromPalette(pal, heat_[i]); size_t off = static_cast(i) * cpl; if (cpl >= 1) buf[off + 0] = c.r; if (cpl >= 2) buf[off + 1] = c.g; @@ -108,12 +124,8 @@ class FireEffect : public EffectBase { private: uint8_t* heat_ = nullptr; nrOfLightsType heatCount_ = 0; - uint32_t rngState_ = 0xC0FFEEu; - - uint8_t rand8() { - rngState_ = rngState_ * 1103515245u + 12345u; - return static_cast((rngState_ >> 16) & 0xFF); - } + Random8 rng_{0xC0FFEEu}; // the shared PRNG; rand8() adapts it to the call shape below + uint8_t rand8() { return rng_.next8(); } void releaseHeat() { if (heat_) { @@ -122,33 +134,6 @@ class FireEffect : public EffectBase { } heatCount_ = 0; } - - // Heat 0-255 mapped to black -> red -> yellow -> white. - // hue_shift rotates the resulting RGB around the colour wheel (via HSV). - static RGB heatToRgb(uint8_t heat, uint8_t hue_shift) { - uint8_t r, g, b; - if (heat < 85) { - r = static_cast(heat * 3); - g = 0; - b = 0; - } else if (heat < 170) { - r = 255; - g = static_cast((heat - 85) * 3); - b = 0; - } else { - r = 255; - g = 255; - b = static_cast((heat - 170) * 3); - } - if (hue_shift == 0) return {r, g, b}; - // Cheap hue rotation: re-encode via HSV using max-channel as value - uint8_t maxc = r > g ? (r > b ? r : b) : (g > b ? g : b); - if (maxc == 0) return {0, 0, 0}; - // Approximate hue from RGB by walking the existing fire ramp range - // and rotating it by hue_shift. Simpler: just rotate via hsvToRgb with - // heat as the hue input. - return hsvToRgb(static_cast(heat + hue_shift), 255, maxc); - } }; } // namespace mm diff --git a/src/light/effects/FixedRectangleEffect.h b/src/light/effects/FixedRectangleEffect.h new file mode 100644 index 00000000..506525e6 --- /dev/null +++ b/src/light/effects/FixedRectangleEffect.h @@ -0,0 +1,120 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/draw.h" // draw::pixel, draw::fade, draw::offsetOf +#include "light/light_types.h" // Coord3D, lengthType +#include "core/color.h" // RGB + +namespace mm { + +// Fixed Rectangle: paints a solid axis-aligned RGB box at a fixed grid position and extent, +// over a slow motion-trail fade. The box origin (X/Y/Z position) and size (width/height/depth) +// are plain controls, so the user dials in exactly which cells light up β€” a static fixture / +// alignment / region paint. When `alternateWhite` is on, the box is rendered as a chequerboard +// of white and the RGB colour: a per-cell toggle flips along the box's dominant axis (it flips +// every cell when the box is wider than tall, and once per row when it is taller than wide), so +// the white/colour pattern follows the box's longer side. On RGBW grids the 4th (white) channel +// carries `white` on the white tiles and is cleared to 0 on the coloured tiles, so a coloured +// cell never picks up the white LED and no stale W lingers from a prior frame. +// +// Prior art: MoonLight's FixedRectangle (E_MoonModules / MoonModules). The defaults, the +// origin+extent clamping, the alternate-toggle rule (flip per-cell when heightwidth), and the white-vs-colour chequerboard are reproduced exactly, +// written fresh on EffectBase + the shared draw primitives. +// Author: limpkin (MoonLight) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class FixedRectangleEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«"; } // MoonLight origin + Dim dimensions() const override { return Dim::D3; } + + // Colour of the box (MoonLight defaults). White is the 4th-channel value, used only on RGBW. + uint8_t red = 182; + uint8_t green = 15; + uint8_t blue = 98; + uint8_t white = 0; + + // Box origin (min 0) and extent (min 1). Named rect* to avoid hiding the inherited + // width()/height()/depth() grid accessors; the UI control names carry the source's labels. + // Default to a 15Γ—15Γ—15 box at the origin (0,0,0): visible out of the box on a large panel + // instead of the source's 1Γ—1Γ—1 single pixel. Origin 0 is the most grid-proof choice β€” it can + // never clip to nothing on a small or flat grid. The draw loop clamps each axis to the grid + // (MINi(origin+extent, size)), so on a grid smaller than 15 the box just fills to the edge. + int16_t rectX = 0, rectY = 0, rectZ = 0; + int16_t rectW = 15, rectH = 15, rectD = 15; + + bool alternateWhite = false; + + void onBuildControls() override { + controls_.addUint8("red", red); + controls_.addUint8("green", green); + controls_.addUint8("blue", blue); + controls_.addUint8("white", white); + controls_.addInt16("X position", rectX, 0, INT16_MAX); + controls_.addInt16("Y position", rectY, 0, INT16_MAX); + controls_.addInt16("Z position", rectZ, 0, INT16_MAX); + controls_.addInt16("Rectangle width", rectW, 1, INT16_MAX); + controls_.addInt16("Rectangle height", rectH, 1, INT16_MAX); + controls_.addInt16("Rectangle depth", rectD, 1, INT16_MAX); + controls_.addBool("alternateWhite", alternateWhite); + } + + void loop() override { + const int w = width(); + const int h = height(); + const int d = depth(); + if (w <= 0 || h <= 0) return; + + Buffer& buf = layer()->buffer(); + const uint8_t cpl = channelsPerLight(); + if (cpl < 3) return; + const Coord3D dims{static_cast(w), static_cast(h), depthDim()}; + + // Motion trail: dim the whole buffer each frame (source: layer->fadeToBlackBy(10)). + layer()->fadeToBlackBy(10); + + // The white/colour chequerboard toggle. MoonLight keeps it as a member but resets it to + // false at the top of each draw, so it is effectively per-frame state β€” a plain local here. + bool alternate = false; + + const RGB rgb{red, green, blue}; + + // Iterate the box clamped to the live grid (origin + extent, capped at each axis size). + const int zEnd = MINi(rectZ + rectD, d > 0 ? d : 1); + const int yEnd = MINi(rectY + rectH, h); + const int xEnd = MINi(rectX + rectW, w); + + for (int z = rectZ; z < zEnd; z++) { + for (int y = rectY; y < yEnd; y++) { + for (int x = rectX; x < xEnd; x++) { + // One chequerboard decision drives both the RGB colour and the W channel: + // a white tile paints white RGB + W=white; a coloured tile paints rgb + W=0. + const bool isWhiteTile = alternateWhite && alternate; + const Coord3D p{static_cast(x), static_cast(y), static_cast(z)}; + // Always write RGB: a white tile paints {255,255,255} even when the colour is all + // zero, and a coloured tile writes rgb (clearing any stale pixel from a prior frame). + draw::pixel(buf, dims, p, isWhiteTile ? RGB{255, 255, 255} : rgb); + // White channel (4th) only on RGBW grids. Follow the chequerboard branch: the + // white tile carries `white`, a coloured tile clears W so it never tints the + // colour and no stale W persists in the RGBW buffer. draw::pixel writes RGB only. + if (cpl >= 4) { + const size_t off = draw::offsetOf(buf, dims, p); + if (off + 3 < buf.bytes()) buf.data()[off + 3] = isWhiteTile ? white : 0; + } + // Box wider than tall: flip the white/colour toggle every cell along X. + if (rectH < rectW) alternate = !alternate; + } + // Box taller than wide: flip once per row instead. + if (rectH > rectW) alternate = !alternate; + } + } + } + +private: + static int MINi(int a, int b) { return a < b ? a : b; } + // Mirror GEQ3D's helper so the dims build reads the same across effects: a degenerate (0) + // grid depth still yields a 1-deep extent. (rectD is a member but does not shadow depth().) + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } +}; + +} // namespace mm \ No newline at end of file diff --git a/src/light/effects/FreqMatrixEffect.h b/src/light/effects/FreqMatrixEffect.h new file mode 100644 index 00000000..f99328e6 --- /dev/null +++ b/src/light/effects/FreqMatrixEffect.h @@ -0,0 +1,148 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::get +#include "core/color.h" // RGB +#include "core/AudioModule.h" // AudioModule::latestFrame() +#include "core/AudioFrame.h" // AudioFrame::level / peakHz + +namespace mm { + +// Freq Matrix: a 1D vertical shift register driven by the dominant audio frequency. Every frame the +// whole column scrolls one pixel away from the source end and a single new pixel is painted at that +// end, its HUE derived from the music's major peak frequency (mapped through a tunable +// lowBin..highBin frequency window) and its BRIGHTNESS from the overall loudness. The result is a +// scrolling "waterfall" where pitch becomes colour and loudness becomes intensity β€” speak or play a +// rising tone and a coloured streak climbs the strip. Silence (or a sub-bass-only signal) paints +// black, so quiet rooms scroll dark. +// +// The column itself IS the shift register: each loop reads pixel y-1 into pixel y (from the far end +// back toward the source) and writes the freshly-computed colour at y=0, so no separate history +// buffer is needed β€” the look is entirely in the Buffer's own scroll. As a D1 effect it writes only +// the x=0 column running along Y (the project's "1D runs along Y" contract, docs/architecture.md); +// Layer::extrude fans that single column across x (and z on a cube) on wider layers, so the same +// code renders a strip or tiles a panel. +// +// Prior art: WLED's "Freqmatrix" sound-reactive effect (Andrew Tuline / the WLED SR fork), carried +// into MoonLight (E_MoonModules / MoonModules). The shift-register scroll, the +// pixVal = levelΒ·fxΒ·sensitivity/256 brightness, the 80 Hz / quarter-volume gate, the +// upperLimit = 80 + 42Β·highBin / lowerLimit = 80 + 3Β·lowBin frequency window, and the +// map(peakHz, lower, upper, 0, 255) hue index are reproduced here, written fresh on EffectBase + the +// shared draw/palette primitives. Reads AudioModule::latestFrame() (null-safe via the static silence +// frame); safe on any target and grid size. +// Author: Andrew Tuline (WLED-SR) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +// +// Fidelity-scale note: WLED's volume threshold is `volumeSmth > 0.25` on a 0..~1 normalised volume, +// and its brightness divisor is 256.0 on that same 0..1 scale. projectMM's AudioFrame::level is a +// pre-scaled 0..255-ish integer (the RMS VU value), not a 0..1 float. The threshold is therefore +// reproduced as level > 64 (β‰ˆ0.25Β·255) and the brightness math is done on the 0..255 level with a +// /2560 divisor so a full-scale levelΒ·fxΒ·sensitivity lands near 255 β€” the same response curve on our +// integer level. These two scale conversions are the only deviations from the verbatim WLED math; +// every constant (80 Hz, 0.25, 42Β·highBin, 3Β·lowBin) is otherwise preserved. +class FreqMatrixEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ™πŸ“Š"; } // 1D Β· audio + Dim dimensions() const override { return Dim::D1; } // writes the x=0 column, runs along Y (1D) + + // Defaults from WLED Freqmatrix (speed=255, fx/intensity=128, lowBin/custom1=18, + // highBin/custom2=48, sensitivity/custom3=30, audioSpeed off). + uint8_t speed = 255; // scroll throttle: higher = the column scrolls more often + uint8_t fx = 128; // brightness intensity scaler (WLED "intensity") + uint8_t lowBin = 18; // lower edge of the frequencyβ†’hue window (WLED custom1) + uint8_t highBin = 48; // upper edge of the frequencyβ†’hue window (WLED custom2) + uint8_t sensitivity = 30; // brightness sensitivity (WLED custom3, 10..100) + bool audioSpeed = false; // when set, the audio level modulates the scroll rate + + void onBuildControls() override { + controls_.addUint8("speed", speed, 1, 255); + controls_.addUint8("fx", fx, 0, 255); + controls_.addUint8("lowBin", lowBin, 0, 255); + controls_.addUint8("highBin", highBin, 0, 255); + controls_.addUint8("sensitivity", sensitivity, 10, 100); + controls_.addBool("audioSpeed", audioSpeed); + } + + void loop() override { + // D1: read the live grid each frame; the scroll runs down the x=0 column, length = height(). + const int cols = width(); + const int len = height(); + if (cols <= 0 || len <= 0) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(cols), static_cast(len), depthDim()}; + + const AudioFrame* f = AudioModule::latestFrame(); + if (!f) return; // static silence frame is non-null in practice; guard for safety + + // --- Scroll throttle (WLED: secondHand = micros()/(256-speed)/500 % 16; act only when it + // ticks). RECONSTRUCTED: WLED gates the whole effect on a micros-derived 0..15 "second hand" + // changing, so a higher speed scrolls more often. We reproduce the same gate from elapsed(): + // the throttle period (in ms) is (256 - speed), so speed=255 scrolls almost every frame and a + // low speed scrolls rarely. With audioSpeed, the louder the sound the shorter the period + // (WLED's optional audio-driven speed), down to a 1 ms floor. + uint32_t period = static_cast(256 - speed); // RECONSTRUCTED + if (audioSpeed) { // RECONSTRUCTED + const uint32_t lvl = f->levelSmoothed > 255 ? 255 : f->levelSmoothed; + period = period > (lvl >> 2) ? period - (lvl >> 2) : 1; // louder β†’ faster scroll + } + if (period == 0) period = 1; + const uint32_t now = elapsed(); + if (now - lastScrollMs_ < period) return; // not time to scroll yet + lastScrollMs_ = now; + + // --- Brightness of the new pixel (WLED: pixVal = volumeSmth * intensity * sensitivity + // / 256.0, clamped 255). WLED uses the SMOOTHED volume here (this is a flowing scroll, not a + // transient meter), so read f->levelSmoothed. On our 0..255 integer level the divisor becomes + // /2560 so a full-scale levelΒ·fxΒ·sensitivity maps near 255 (see the fidelity-scale note above). + const uint32_t level = f->levelSmoothed > 255 ? 255 : f->levelSmoothed; + uint32_t pixVal = (level * fx * sensitivity) / 2560u; + if (pixVal > 255) pixVal = 255; + const uint8_t bri = static_cast(pixVal); + + // --- Colour of the new pixel. Black unless there is a real tone above 80 Hz and the (smoothed) + // volume is above a quarter scale (WLED: peakHz > 80 && volumeSmth > 0.25). 0.25 on WLED's + // 0..1 volume is reproduced as level > 64 on our 0..255 smoothed level (fidelity-scale note). + RGB newColor{0, 0, 0}; + if (f->peakHz > 80 && level > 64) { + // Frequency window β†’ 0..255 hue index. WLED: + // upperLimit = 80 + 42*highBin; lowerLimit = 80 + 3*lowBin; + // i = (lowerLimit != upperLimit) ? map(peakHz, lowerLimit, upperLimit, 0, 255) : peakHz; + // i = abs(i) & 0xFF; + const int upperLimit = 80 + 42 * static_cast(highBin); + const int lowerLimit = 80 + 3 * static_cast(lowBin); + int idx; + if (lowerLimit != upperLimit) + idx = imap(static_cast(f->peakHz), lowerLimit, upperLimit, 0, 255); + else + idx = static_cast(f->peakHz); + if (idx < 0) idx = -idx; // WLED: abs(i) + const uint8_t i = static_cast(idx & 0xFF); + newColor = colorFromPalette(*Palettes::active(), i, bri); + } + + // --- Shift the column one pixel away from the source end (WLED: for i = SEGLEN-1 .. 1, + // setPixelColor(i, getPixelColor(i-1))), then paint the new colour at y=0. The effect writes + // only x=0; Layer::extrude duplicates this column across x (and z) on wider layers. + for (int y = len - 1; y > 0; y--) { + const RGB c = draw::get(buf, dims, {0, static_cast(y - 1), 0}); + draw::pixel(buf, dims, {0, static_cast(y), 0}, c); + } + draw::pixel(buf, dims, {0, 0, 0}, newColor); + } + +private: + // Standard integer map (FastLED/WLED ::map), used for the frequencyβ†’hue index rescale. + static int imap(int x, int inLo, int inHi, int outLo, int outHi) { + const int den = inHi - inLo; + if (den == 0) return outLo; + return (x - inLo) * (outHi - outLo) / den + outLo; + } + // depth>0 ? depth : 1 β€” the dims z-extent, so draw clipping/indexing is correct on a 3D layer. + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + uint32_t lastScrollMs_ = 0; // last elapsed() ms the column scrolled (throttle state) +}; + +} // namespace mm diff --git a/src/light/effects/FreqSawsEffect.h b/src/light/effects/FreqSawsEffect.h new file mode 100644 index 00000000..26fbb29b --- /dev/null +++ b/src/light/effects/FreqSawsEffect.h @@ -0,0 +1,195 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade +#include "core/math8.h" // beat8 +#include "core/AudioModule.h" // AudioModule::latestFrame() +#include "core/AudioFrame.h" // AudioFrame::bands[16] + +#include // memset (clear the per-band state on (re)build) + +namespace mm { + +// Freq Saws: one vertical "saw" per audio band, each rising and falling like a tilt-shifted sawtooth +// whose run-rate is driven by that band's loudness. Each frame the buffer fades a little (motion +// trail), then every band sets a target speed from its current loudness; the band's speed RISES +// instantly to a loud hit (max with the target) and DECAYS slowly back toward zero when the sound +// stops, so a struck band keeps sawing for a while and a quiet one winds down. The current speed +// becomes a BPM (0..bpmMax), and that BPM picks the Y position of the lit pixel via one of three +// methods: +// 0 "Chaos" β€” y straight off beat8(bpm): the BPM jumps with the band, so the saw teleports +// (visually chaotic, the original look). +// 1 "Chaos fix" β€” same beat8(bpm) but a per-band phase offset is carried so a BPM change continues +// from the current sawtooth position instead of snapping (a smoother chaos). +// 2 "BandPhases" β€” a per-band phase accumulator integrated from the BPM each frame (deltaMs-scaled), +// so the saw advances continuously and never jumps (the default, smoothest). +// The band physics run ONCE per tick (a loop over the 16 bands), caching each band's Y; the column +// loop then just maps xβ†’band and draws that band's cached Y β€” so a band spanning many columns on a +// wide panel integrates exactly once per frame, not once per column (WLED's per-band-per-frame +// physics). `invert` mirrors every other column (x even) top-to-bottom for a woven look; `keepOn` +// keeps a band drawing even once its speed has fully decayed (so the panel never goes fully dark +// between hits). +// +// Prior art: MoonLight's FreqSaws (E_MoonModules / MoonModules), an audio-reactive matrix effect. The +// per-band rise/decay physics, the three position methods, the bpmMax / increaser / decreaser knobs, +// and the per-band phase bookkeeping are reproduced exactly here, written fresh on projectMM's +// EffectBase + the shared draw / palette / beat8 primitives. Reads AudioModule::latestFrame(); +// silence β†’ every band decays β†’ flat β†’ dark, safe on any target and grid size. +// Author: @TroyHacks (MoonLight / WLED MoonModules) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class FreqSawsEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸ“Š"; } // MoonLight origin Β· audio + Dim dimensions() const override { return Dim::D2; } // writes only the z=0 slice; extrude fills z + + // Defaults match MoonLight's FreqSaws exactly. + uint8_t fade = 4; // per-frame fade-to-black amount (motion trail) + uint8_t increaser = 211; // gain from band loudness into target speed + uint8_t decreaser = 18; // decay rate when a band falls silent (0 = never decays) + uint8_t bpmMax = 198; // top BPM a fully-sped band maps to + bool invert = false; // mirror every even column top↔bottom + bool keepOn = false; // keep drawing a band whose speed has decayed to zero + uint8_t method = 2; // 0 Chaos, 1 Chaos fix, 2 BandPhases + + void onBuildControls() override { + controls_.addUint8("fade", fade, 0, 255); + controls_.addUint8("increaser", increaser, 0, 255); + controls_.addUint8("decreaser", decreaser, 0, 255); + controls_.addUint8("bpmMax", bpmMax, 0, 255); + controls_.addBool("invert", invert); + controls_.addBool("keepOn", keepOn); + static constexpr const char* kMethodOptions[] = {"Chaos", "Chaos fix", "BandPhases"}; + controls_.addSelect("method", method, kMethodOptions, 3); + } + + // Per-band state is a fixed 16-element set (one per GEQ channel, NOT per light), so it stays a + // small inline member β€” the "no large inline members" rule targets per-light buffers sized to + // nrOfLights, which this isn't. Cleared on every (re)build so a grid/control change starts the + // bands from rest. + void onBuildState() override { + clearState(); + MoonModule::onBuildState(); + } + + void loop() override { + const int sizeX = width(); + const int sizeY = height(); + if (sizeX <= 0 || sizeY <= 0 || channelsPerLight() < 3) return; + + const AudioFrame* f = AudioModule::latestFrame(); + if (!f) return; // null-safe (latestFrame returns silence, never null, but guard regardless) + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(sizeX), static_cast(sizeY), depthDim()}; + + layer()->fadeToBlackBy(fade); + + // deltaMs since the last frame β€” drives the band decay and the BandPhases integrator. + const unsigned long now = elapsed(); + const unsigned long deltaMs = now - lastTime; + lastTime = now; + + // Advance the 16-band physics ONCE per tick and cache each band's Y + active flag, so a band + // spanning several columns on a wide panel integrates once per frame (not once per column). + bool bandActive[NUM_GEQ_CHANNELS] = {}; + uint8_t bandY[NUM_GEQ_CHANNELS] = {}; + for (int band = 0; band < NUM_GEQ_CHANNELS; band++) { + const uint8_t volume = f->bands[band]; + // targetSpeed = volume * increaser * 257 β€” scaled into the 16-bit speed space (β‰ˆ Γ—65535). + const uint32_t targetSpeed = static_cast(volume) * increaser * 257u; + + if (volume > 0) { + // Rise instantly to a loud hit: take the louder of current and target speed. + if (targetSpeed > bandSpeed[band]) + bandSpeed[band] = static_cast(targetSpeed > 65535u ? 65535u : targetSpeed); + } else if (decreaser > 0 && bandSpeed[band] > 0) { + // Decay back toward zero when silent, proportional to elapsed time and 1/decreaser. + uint32_t decay = (static_cast(bandSpeed[band]) * deltaMs) / + (static_cast(decreaser) * 10u); + if (decay < 1) decay = 1; + bandSpeed[band] = decay >= bandSpeed[band] ? 0 + : static_cast(bandSpeed[band] - decay); + } + + if (bandSpeed[band] > 1 || keepOn) { + bandActive[band] = true; + // Current speed β†’ a BPM in 0..bpmMax. + const uint8_t bpm = static_cast(imap(bandSpeed[band], 0, 65535, 0, bpmMax)); + + if (method == 0) { + // Chaos: y straight off the beat β€” jumps as the BPM changes. + bandY[band] = static_cast(imap(beat8(bpm, now), 0, 255, 0, sizeY - 1)); + } else if (method == 1) { + // Chaos fix: carry a per-band phase offset so a BPM change continues from the + // current sawtooth position instead of snapping. + if (bpm != lastBpm[band]) { + const uint8_t currentPos = static_cast(beat8(lastBpm[band], now) + phaseOffset[band]); + const uint8_t newPos = beat8(bpm, now); + phaseOffset[band] = static_cast(currentPos - newPos); + lastBpm[band] = bpm; + } + bandY[band] = static_cast(imap(static_cast(beat8(bpm, now) + phaseOffset[band]), + 0, 255, 0, sizeY - 1)); + } else { + // BandPhases: integrate a per-band phase accumulator from the BPM each frame + // (deltaMs-scaled), halved, so the saw advances continuously with no jumps. + // phaseInc = (bpm * deltaMs * 65536) / (60 * 1000); phaseInc /= 2. + uint32_t phaseInc = (static_cast(bpm) * static_cast(deltaMs) * 65536u) / + (60u * 1000u); + phaseInc /= 2u; + bandPhase[band] = static_cast(bandPhase[band] + phaseInc); + bandY[band] = static_cast(imap(bandPhase[band] >> 8, 0, 255, 0, sizeY - 1)); + } + } + } + + // Column loop: map each x onto its band and draw that band's cached Y. Per-column concerns + // (invert mirroring, palette colour) stay here; the band physics already ran above. + for (int x = 0; x < sizeX; x++) { + // Map this column onto one of the 16 GEQ bands (band = map(x, 0, sizeX, 0, 16)). + int band = imap(x, 0, sizeX, 0, NUM_GEQ_CHANNELS); + if (band < 0) band = 0; + if (band > NUM_GEQ_CHANNELS - 1) band = NUM_GEQ_CHANNELS - 1; + + if (!bandActive[band]) continue; + + const uint8_t y = bandY[band]; + // invert mirrors every even column (x % 2 == 0) top-to-bottom. + const int drawY = (invert && (x % 2 == 0)) ? (sizeY - 1 - y) : y; + const uint8_t colorIndex = static_cast(imap(x, 0, sizeX - 1, 0, 255)); + const RGB col = colorFromPalette(*Palettes::active(), colorIndex); + draw::pixel(buf, dims, {static_cast(x), static_cast(drawY), 0}, col); + } + } + +private: + static constexpr int NUM_GEQ_CHANNELS = 16; + + // Standard integer map (MoonLight's ::map), used for the band/colour/position remaps. Guards a + // zero input span so a degenerate grid (sizeX/sizeY <= 1) can't divide by zero. + static int imap(int v, int inLo, int inHi, int outLo, int outHi) { + const int den = inHi - inLo; + if (den == 0) return outLo; + return (v - inLo) * (outHi - outLo) / den + outLo; + } + + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + void clearState() { + std::memset(bandSpeed, 0, sizeof(bandSpeed)); + std::memset(bandPhase, 0, sizeof(bandPhase)); + std::memset(lastBpm, 0, sizeof(lastBpm)); + std::memset(phaseOffset, 0, sizeof(phaseOffset)); + lastTime = 0; + } + + // Per-band physics state (one entry per GEQ channel, fixed 16). Small enough to stay inline. + uint16_t bandSpeed[NUM_GEQ_CHANNELS] = {}; // current saw run-rate (16-bit speed space) + uint16_t bandPhase[NUM_GEQ_CHANNELS] = {}; // BandPhases (method 2) phase accumulator + uint8_t lastBpm[NUM_GEQ_CHANNELS] = {}; // Chaos fix (method 1) previous BPM + uint8_t phaseOffset[NUM_GEQ_CHANNELS] = {}; // Chaos fix (method 1) carried phase offset + unsigned long lastTime = 0; // elapsed() at the previous frame (for deltaMs) +}; + +} // namespace mm diff --git a/src/light/effects/GEQ3DEffect.h b/src/light/effects/GEQ3DEffect.h new file mode 100644 index 00000000..edc00139 --- /dev/null +++ b/src/light/effects/GEQ3DEffect.h @@ -0,0 +1,218 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, blend, Palettes::active() +#include "light/draw.h" // draw::line (perspective edges + depth shorten), draw::fade +#include "core/AudioModule.h" // AudioModule::latestFrame() +#include "core/AudioFrame.h" // AudioFrame::bands[16] +#include "core/math8.h" // map8 + +#include // lroundf (once-per-frame maxHeight, not per-light) + +namespace mm { + +// GEQ 3D: a 3D-perspective graphic equaliser. The 16 audio bands rise as bars on a 2D grid, drawn +// with faked depth. Each bar's side faces and top surface are drawn as lines that run FROM a bar +// pixel TOWARD a sweeping "projector" vanishing point at (projector, horizon), each line shortened +// by `depth` so it stops partway β€” that converging foreshortening is the defining look. The +// projector sweeps left↔right; bands left of it are painted right-to-left, bands right of it +// left-to-right, so the perspective always points away from the moving vanishing point. The bar +// front faces are filled flat (frontFill), and an optional border outlines each bar. +// +// Prior art: MoonLight's GEQ3D (E_MoonModules / MoonModules, TroyHacks), itself descended from the +// WLED-MM "GEQ 3D" effect. The perspective-bar geometry, the projector split, the per-face +// darkening, and the `depth` line-shorten are reproduced exactly here, written fresh on EffectBase +// + the shared draw primitives. Reads AudioModule::latestFrame(); silence β†’ flat β†’ dark, safe on +// any target and grid size. (MoonLight's `softHack` anti-alias toggle is dropped β€” draw::line is a +// crisp Bresenham; the `soft` arg has no projectMM equivalent.) +// Author: @TroyHacks (MoonModules, GPLv3) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h +class GEQ3DEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸŒ™πŸ“Š"; } // MoonLight origin Β· MoonModules Β· audio + Dim dimensions() const override { return Dim::D2; } + + uint8_t speed = 2; // projector sweep rate (1..10; higher = faster). Time-based (BPM), so + // the sweep is at the same wall-clock position on every device β€” a + // fast board just renders it more smoothly, a slow board choppier. + uint8_t frontFill = 228; // bar front-face fill strength (0..255) + uint8_t horizon = 0; // vanishing-point Y row (0..rows-1); the projector sits at this row + uint8_t depth = 176; // perspective depth: how far the side/top lines reach toward the projector + uint8_t numBands = 16; // bands shown (2..16); fewer = wider bars + bool borders = true; // outline each bar + + void onBuildControls() override { + controls_.addUint8("speed", speed, 1, 10); + controls_.addUint8("frontFill", frontFill, 0, 255); + // MoonLight's horizon range is 0..size.x-1 (set at runtime). The control descriptor here is a + // fixed 0..255 slider β€” the source's row index β€” and the value is clamped to the live row + // count in loop(). A width/height-relative descriptor range isn't expressible at build time. + controls_.addUint8("horizon", horizon, 0, 255); + controls_.addUint8("depth", depth, 0, 255); + controls_.addUint8("numBands", numBands, 2, 16); + controls_.addBool("borders", borders); + } + + void loop() override { + if (numBands == 0) return; + + const int cols = width(); + const int rows = height(); + if (cols <= 0 || rows <= 0 || channelsPerLight() < 3) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(cols), static_cast(rows), depthDim()}; + + // Motion trail: dim the whole buffer each frame instead of clearing it (source: + // layer->fadeToBlackBy(16) per frame). + layer()->fadeToBlackBy(16); + + // Projector (vanishing point) position along X, as a TIME-BASED triangle wave: it sweeps + // 0β†’colsβ†’0 driven by elapsed(), so at any wall-clock instant every device shows the projector + // at the same place β€” a fast board renders the sweep more smoothly, a slow one choppier, but + // neither is throttled to the other's pace. (MoonLight advanced it by a per-frame counter, so + // its sweep ran faster on a high-FPS device; this is the frame-rate-independent improvement.) + // triwave8(beat8(bpm)) is the textbook time triangle: beat8 ramps 0..255 at `bpm`, triwave8 + // folds it into an up-then-down 0..255, scaled to the column span. speed 1..10 β†’ ~3..30 BPM. + const uint8_t bpm = static_cast(speed * 3); + const uint8_t sweep = triwave8(beat8(bpm, elapsed())); // 0..255 triangle over time + // Clamp the drawn band count to the column count: MoonLight's bar width is `cols / NUM_BANDS`, + // which truncates to 0 when there are fewer columns than bands (e.g. 8 cols, 16 bands), so + // every bar piles at x=0. Capping bands to cols keeps the width β‰₯ 1 so the bars spread across + // the available width. Invisible on normal grids (cols β‰₯ numBands β†’ this is a no-op); it only + // fixes the degenerate narrow-grid case. Once per frame, off the per-pixel path. + const int NUM_BANDS = numBands <= cols ? static_cast(numBands) : cols; + const int projector = static_cast(static_cast(sweep) * cols / 255u); + // horizon is a Y row used as the vanishing point's y; clamp the 0..255 control to the grid. + const int hzn = horizon < rows ? horizon : rows - 1; + const int split = imap(projector, 0, cols, 0, NUM_BANDS - 1); + + const AudioFrame* f = AudioModule::latestFrame(); + + // Bar heights: map each band magnitude onto maxHeight (slightly reduced on small panels). + uint8_t heights[16] = {0}; + const int maxHeight = lroundf(float(rows) * ((rows < 18) ? 0.75f : 0.85f)); + for (int i = 0; i < NUM_BANDS; i++) { + int band = i; + if (NUM_BANDS < 16) band = imap(band, 0, NUM_BANDS, 0, 16); // always use the full 16-band range + if (band > 15) band = 15; + heights[i] = map8(f->bands[band], 0, static_cast(maxHeight)); + } + + const RGB black{0, 0, 0}; + + // Right vertical faces + top β€” bands at/left of the split, painted LEFT to RIGHT. + for (int i = 0; i <= split; i++) { + const uint16_t colorIndex = imap(cols / NUM_BANDS * i, 0, cols, 0, 256); + const RGB ledColor = colorFromPalette(*Palettes::active(), static_cast(colorIndex)); + const int linex = i * (cols / NUM_BANDS); + + if (heights[i] > 1) { + const RGB sideColor = blend(ledColor, black, static_cast(255 - 32)); + const int pPos = MAXi(0, linex + (cols / NUM_BANDS) - 1); + // Right side face: stacked perspective lines from the bar's right edge toward the projector. + for (int y = (i < NUM_BANDS - 1) ? heights[i + 1] : 0; y <= heights[i]; y++) { + if (rows - y > 0) + draw::line(buf, dims, {static_cast(pPos), static_cast(rows - y - 1), 0}, + {static_cast(projector), static_cast(hzn), 0}, sideColor, depth); + } + + const RGB topColor = blend(ledColor, black, static_cast(255 - 128)); + // Top surface: skip when directly under the projector (handled as a special case below). + if (heights[i] < rows - hzn && (projector <= linex || projector >= pPos)) { + if (rows - heights[i] > 1) { + for (int x = linex; x <= pPos; x++) + draw::line(buf, dims, {static_cast(x), static_cast(rows - heights[i] - 2), 0}, + {static_cast(projector), static_cast(hzn), 0}, topColor, depth); + } + } + } + } + + // Left vertical faces + top β€” bands right of the split, painted RIGHT to LEFT. + for (int i = NUM_BANDS - 1; i > split; i--) { + const uint16_t colorIndex = imap(cols / NUM_BANDS * i, 0, cols - 1, 0, 255); + const RGB ledColor = colorFromPalette(*Palettes::active(), static_cast(colorIndex)); + const int linex = i * (cols / NUM_BANDS); + const int pPos = MAXi(0, linex + (cols / NUM_BANDS) - 1); + + if (heights[i] > 1) { + const RGB sideColor = blend(ledColor, black, static_cast(255 - 32)); + // Left side face: stacked perspective lines from the bar's left edge toward the projector. + for (int y = (i > 0) ? heights[i - 1] : 0; y <= heights[i]; y++) { + if (rows - y > 0) + draw::line(buf, dims, {static_cast(linex), static_cast(rows - y - 1), 0}, + {static_cast(projector), static_cast(hzn), 0}, sideColor, depth); + } + + const RGB topColor = blend(ledColor, black, static_cast(255 - 128)); + if (heights[i] < rows - hzn && (projector <= linex || projector >= pPos)) { + if (rows - heights[i] > 1) { + for (int x = linex; x <= pPos; x++) + draw::line(buf, dims, {static_cast(x), static_cast(rows - heights[i] - 2), 0}, + {static_cast(projector), static_cast(hzn), 0}, topColor, depth); + } + } + } + } + + // Projector special-case top + front fill + borders, all bands left to right. + for (int i = 0; i < NUM_BANDS; i++) { + const uint16_t colorIndex = imap(cols / NUM_BANDS * i, 0, cols - 1, 0, 255); + const RGB ledColor = colorFromPalette(*Palettes::active(), static_cast(colorIndex)); + const int linex = i * (cols / NUM_BANDS); + const int pPos = linex + (cols / NUM_BANDS) - 1; + const int pPos1 = linex + (cols / NUM_BANDS); + + // Special case: top perspective for the bar directly under the projector (skipped above). + if (projector >= linex && projector <= pPos) { + if ((heights[i] > 1) && (heights[i] < rows - hzn) && (rows - heights[i] > 1)) { + const RGB topColor = blend(ledColor, black, static_cast(255 - 128)); + for (int x = linex; x <= pPos; x++) + draw::line(buf, dims, {static_cast(x), static_cast(rows - heights[i] - 2), 0}, + {static_cast(projector), static_cast(hzn), 0}, topColor, depth); + } + } + + if ((heights[i] > 1) && (rows - heights[i] > 0)) { + RGB frontColor = blend(ledColor, black, static_cast(255 - frontFill)); + // Front fill: vertical lines across the bar face from the floor up to its height. + for (int x = linex; x < pPos1; x++) + draw::line(buf, dims, {static_cast(x), static_cast(rows - 1), 0}, + {static_cast(x), static_cast(rows - heights[i] - 1), 0}, frontColor); + + if (!borders && heights[i] > rows - hzn) { + // Match the side fill in blackout mode, then draw a top line to simulate hidden top fill. + if (frontFill == 0) frontColor = blend(ledColor, black, static_cast(255 - 32)); + draw::line(buf, dims, {static_cast(linex), static_cast(rows - heights[i] - 1), 0}, + {static_cast(linex + (cols / NUM_BANDS) - 1), static_cast(rows - heights[i] - 1), 0}, frontColor); + } + + if (borders && (rows - heights[i] > 1)) { + const lengthType bottom = static_cast(rows - 1); + const lengthType topY = static_cast(rows - heights[i] - 1); + const lengthType topY2 = static_cast(rows - heights[i] - 2); + const lengthType lx = static_cast(linex); + const lengthType rx = static_cast(linex + (cols / NUM_BANDS) - 1); + draw::line(buf, dims, {lx, bottom, 0}, {lx, topY, 0}, ledColor); // left side line + draw::line(buf, dims, {rx, bottom, 0}, {rx, topY, 0}, ledColor); // right side line + draw::line(buf, dims, {lx, topY2, 0}, {rx, topY2, 0}, ledColor); // top line + draw::line(buf, dims, {lx, bottom, 0}, {rx, bottom, 0}, ledColor); // bottom line + } + } + } + } + +private: + // Standard integer map (MoonLight's ::map), used for the color index / split / band remaps. + static int imap(int x, int inLo, int inHi, int outLo, int outHi) { + const int den = inHi - inLo; + if (den == 0) return outLo; + return (x - inLo) * (outHi - outLo) / den + outLo; + } + static int MAXi(int a, int b) { return a > b ? a : b; } + // The member `depth` (control) hides the inherited grid-depth accessor name; qualify it. + lengthType depthDim() const { return EffectBase::depth() > 0 ? EffectBase::depth() : 1; } +}; + +} // namespace mm diff --git a/src/light/effects/GEQEffect.h b/src/light/effects/GEQEffect.h new file mode 100644 index 00000000..9c2c749e --- /dev/null +++ b/src/light/effects/GEQEffect.h @@ -0,0 +1,192 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade +#include "core/math8.h" // (no beat/trig here; included for parity with the effect family) +#include "core/AudioModule.h" // AudioModule::latestFrame() +#include "core/AudioFrame.h" // AudioFrame::bands[16] +#include "platform/platform.h" // platform::alloc / platform::free (per-column peak-fall state) + +namespace mm { + +// GEQ: the classic flat 2D graphic equaliser. The 16 audio bands are spread across the columns of a +// 2D panel; each column rises from the bottom to a bar height set by its band's loudness, and a peak +// "dot" sits at the highest the bar has recently reached and falls back down slowly β€” the recognisable +// WLED "GEQ" look (distinct from the 3D-perspective GEQ3D effect in this folder). +// +// Per frame the whole buffer fades a little (fadeOut β†’ motion trail), then for each column x its band +// is read, optionally smoothed against its neighbours (smoothBars), mapped to a bar height, and the +// column is filled from the floor up. The bar colour is either per-column (colorBars) or per-row (the +// gradient runs up the bar). A per-column peak tracker remembers the tallest the bar reached; when the +// live bar is shorter, the remembered peak is drawn as a single dot and decays downward at a rate set +// by `ripple` (0 = the peak dot is disabled; otherwise it falls one row every `ripple` frames). +// +// Prior art: WLED's "GEQ" / 2D GEQ (mode_2DGEQ, Aircoookie / Andrew Tuline lineage), carried into +// MoonLight as the GEQ effect. The bandβ†’column mapping, the 7Β·band + 3Β·prev + 3Β·next smoothing weights, +// the bottom-up bar fill, the colorBars / smoothBars toggles, and the falling-peak dot are reproduced +// here, written fresh on projectMM's EffectBase + the shared draw / palette primitives. Reads +// AudioModule::latestFrame(); silence β†’ bars flat β†’ peaks fall away β†’ dark, safe on any target and grid +// size. The per-column peak-fall state lives on the heap (sized to width()), allocated in onBuildState +// and freed in teardown β€” never a large inline member. +// Author: Andrew Tuline (WLED-SR) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +class GEQEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸ™πŸ“Š"; } // MoonLight origin Β· 2D Β· audio + Dim dimensions() const override { return Dim::D2; } // writes only the z=0 slice; extrude fills z + + // Defaults match the WLED/MoonLight GEQ. + uint8_t fadeOut = 248; // per-frame fade-to-black amount (motion trail). WLED maps its 0..255 + // "fade" slider straight onto fadeToBlackBy; the GEQ default is a fast + // fade so bars snap rather than smear. + uint8_t ripple = 4; // peak-dot fall rate: the dot drops one row every `ripple` frames + // (0 = no peak dot). WLED's "ripple" slider gates the falling peak. + bool colorBars = false; // colour each bar by its column (true) instead of by row height (false) + bool smoothBars = false; // blend each band with its neighbours for a smoother profile + + void onBuildControls() override { + controls_.addUint8("fadeOut", fadeOut, 0, 255); + controls_.addUint8("ripple", ripple, 0, 255); + controls_.addBool("colorBars", colorBars); + controls_.addBool("smoothBars", smoothBars); + } + + // One peak tracker per column: previousBarHeight[width]. WLED stores this in the segment's data + // block, sized to the column count and zero-initialised; matched here with a heap allocation that + // re-sizes only when the column count changes, zeroed on (re)build so a grid/control change starts + // every peak at the floor. Entries are lengthType (the row-count type) so a panel taller than 255 + // rows doesn't truncate the remembered peak height. + void onBuildState() override { + const size_t cols = static_cast(width() > 0 ? width() : 0); + if (enabled() && cols > 0) { + if (cols != peakCount_) { + releasePeaks(); + peaks_ = static_cast(platform::alloc(cols * sizeof(lengthType))); + if (peaks_) peakCount_ = cols; + } + if (peaks_) for (size_t i = 0; i < peakCount_; i++) peaks_[i] = 0; // zero-init, like WLED + } else { + releasePeaks(); + } + rippleCounter_ = 0; + setDynamicBytes(peakCount_ * sizeof(lengthType)); + MoonModule::onBuildState(); + } + + void teardown() override { + releasePeaks(); + setDynamicBytes(0); + } + + ~GEQEffect() override { releasePeaks(); } + + void loop() override { + const int cols = width(); + const int rows = height(); + if (cols <= 0 || rows <= 0 || channelsPerLight() < 3) return; + if (!peaks_) return; // build hasn't allocated yet (e.g. disabled) β€” nothing to draw + + const AudioFrame* f = AudioModule::latestFrame(); + if (!f) return; // null-safe (latestFrame returns silence, never null, but guard regardless) + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(cols), static_cast(rows), depthDim()}; + + // Motion trail: dim the whole buffer each frame (WLED: fadeToBlackBy(fadeOut)). + layer()->fadeToBlackBy(fadeOut); + + // Advance the peak-fall clock once per frame. The remembered peaks drop one row whenever the + // counter wraps `ripple`; ripple == 0 disables the dot entirely (handled at draw time). + bool fallThisFrame = false; + if (ripple > 0) { + if (++rippleCounter_ >= ripple) { rippleCounter_ = 0; fallThisFrame = true; } + } + + for (int x = 0; x < cols; x++) { + // Map this column onto one of the 16 GEQ bands (band = map(x, 0, cols-1, 0, 15)). The + // 0..cols-1 / 0..15 form (vs the spec's literal map(x,0,size.x,0,16)) is the real WLED + // mode_2DGEQ shape and keeps the last column on band 15 rather than an out-of-range 16. + int band = imap(x, 0, cols - 1, 0, NUM_GEQ_CHANNELS - 1); + if (band < 0) band = 0; + if (band > NUM_GEQ_CHANNELS - 1) band = NUM_GEQ_CHANNELS - 1; + + int bandHeight = f->bands[band]; + + // smoothBars: weighted blend with the neighbouring bands so the bar profile is less spiky. + // WLED weights: (7Β·band + 3Β·prev + 3Β·next) / 12, only for interior bands. RECONSTRUCTED from + // WLED's mode_2DGEQ smoothing (the fetched source did not include the exact body; the 7/3/3 + // weights and /12 divisor are the WLED constants). + if (smoothBars && band > 0 && band < NUM_GEQ_CHANNELS - 1) { + const int lastBandHeight = f->bands[band - 1]; + const int nextBandHeight = f->bands[band + 1]; + bandHeight = (7 * bandHeight + 3 * lastBandHeight + 3 * nextBandHeight) / 12; + if (bandHeight < 0) bandHeight = 0; + if (bandHeight > 255) bandHeight = 255; + } + + // Bar height in rows: map the 0..255 band magnitude onto 0..rows. + int barHeight = imap(bandHeight, 0, 255, 0, rows); + if (barHeight < 0) barHeight = 0; + if (barHeight > rows) barHeight = rows; + + // Per-column peak: rise instantly to a new high, otherwise fall slowly. peaks_[x] is the + // row count (0..rows) the dot currently sits at, measured from the floor. + if (barHeight > peaks_[x]) { + peaks_[x] = static_cast(barHeight); + } else if (fallThisFrame && peaks_[x] > 0) { + peaks_[x] = static_cast(peaks_[x] - 1); // RECONSTRUCTED: WLED's peak decays one row per ripple tick + } + + // Fill the bar from the floor (row rows-1) up to barHeight rows. + for (int h = 0; h < barHeight; h++) { + const int y = rows - 1 - h; // row 0 = top, so the bar grows upward from the floor + if (y < 0) break; + // colorBars: one hue per column. else: the gradient runs up the bar by row height. + const uint8_t colorIndex = colorBars + ? static_cast(imap(x, 0, cols - 1, 0, 255)) + : static_cast(imap(h, 0, rows - 1, 0, 255)); + const RGB col = colorFromPalette(*Palettes::active(), colorIndex); + draw::pixel(buf, dims, {static_cast(x), static_cast(y), 0}, col); + } + + // Falling peak dot, drawn at the remembered peak row if it stands above the live bar. + // RECONSTRUCTED: WLED draws a single peak pixel (white-ish / palette top) at previousBarHeight. + if (ripple > 0 && peaks_[x] > 0 && peaks_[x] > barHeight) { + const int y = rows - peaks_[x]; // peaks_[x] rows up from the floor + if (y >= 0 && y < rows) { + // Peak colour: top of the palette (index 255) so the dot reads as the crest. + const RGB peakCol = colorFromPalette(*Palettes::active(), 255); + draw::pixel(buf, dims, {static_cast(x), static_cast(y), 0}, peakCol); + } + } + } + } + +private: + static constexpr int NUM_GEQ_CHANNELS = 16; + + // Standard integer map (WLED/MoonLight's ::map), used for the band/colour/height remaps. Guards a + // zero input span so a degenerate grid (cols/rows <= 1) can't divide by zero. + static int imap(int v, int inLo, int inHi, int outLo, int outHi) { + const int den = inHi - inLo; + if (den == 0) return outLo; + return (v - inLo) * (outHi - outLo) / den + outLo; + } + + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + void releasePeaks() { + if (peaks_) { + platform::free(peaks_); + peaks_ = nullptr; + } + peakCount_ = 0; + } + + lengthType* peaks_ = nullptr; // previousBarHeight[width]: per-column peak-dot row (0..rows from floor) + size_t peakCount_ = 0; // number of peak entries allocated (== width) + uint8_t rippleCounter_ = 0; // counts frames toward the next peak-fall step (gated by `ripple`) +}; + +} // namespace mm \ No newline at end of file diff --git a/src/light/effects/GameOfLifeEffect.h b/src/light/effects/GameOfLifeEffect.h index a081784d..3c2c74be 100644 --- a/src/light/effects/GameOfLifeEffect.h +++ b/src/light/effects/GameOfLifeEffect.h @@ -1,272 +1,497 @@ #pragma once -#include "light/layers/Layer.h" -#include "core/color.h" -#include "platform/platform.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/draw.h" // draw::pixel/blendPixel/get β€” setRGB/blendColor/getRGB +#include "light/Palette.h" // colorFromPalette, Palettes::active +#include "core/math8.h" // Random8 +#include "core/crc.h" // crc16 β€” grid fingerprint for stasis detection +#include "platform/platform.h" // alloc β€” the heap grid state #include namespace mm { -// Conway's Game of Life on the XY plane (B3/S23). Two cell grids (cur/nxt) hold -// one byte per cell (0 dead, 1 alive); the step reads cur and writes nxt, then -// swaps. On extinction (no live cells) or stasis (no cell changed) the grid -// re-seeds from the PRNG so the effect never stops. +// Conway's Game of Life, generalised to 2D and 3D, with selectable rulesets, palette-coloured +// cells that inherit a living neighbour's colour on birth, optional greenβ†’red age colouring, a +// dead-cell blur trail that fades toward a configurable background colour, a 1.5 s settle pause on +// each new game, and self-respawn (R-pentomino / glider) when the pattern goes static. A living +// cell survives if its live-neighbour count is in the ruleset's SURVIVE set; a dead cell is born if +// its count is in the BIRTH set. Neighbours are the 8 around a cell in 2D, the 26 in 3D, optionally +// wrapping toroidally. The board fingerprints itself (crc16) at three periods β€” every 16 gens +// (oscillators), every lcm(h,w)Β·4 gens (spaceships), every thatΒ·6 (cube gliders) β€” and respawns or +// resets when a fingerprint recurs, dies out, density floors, or at random. // -// Scope is deliberately the minimal classic rule β€” MoonLight's E_MoonModules -// GameOfLife adds rulesets, palette colouring, blur, mutation and pentomino -// seeding; those are out by design (concrete first). The simulation step is -// decoupled from colouring (one render line), so a future ruleset control or -// palette swap is a localised change. Prior art: MoonLight (Ewoud Wijma 2022, -// Brandon Butler 2024) and projectMM v1's GameOfLifeEffect. +// Prior art: MoonLight's GameOfLife (E_MoonModules, MoonModules; Ewoud Wijma 2022 after +// natureofcode ch.7 + DougHaber/nlife-color, Brandon Butler / @Brandon502 2024) β€” its behaviour is +// reproduced here (rulesets, 2D/3D neighbourhoods, neighbour-colour inheritance, age colouring, +// background blur, 3-CRC stasis, R-pentomino respawn, settle pause), written fresh on projectMM's +// EffectBase + shared primitives (Random8, colorFromPalette, draw::, crc16). Conway's Game of Life +// (John Conway, 1970) is the underlying automaton. +// Author: Ewoud Wijma (2022), modifications by Brandon Butler / @Brandon502 / wildcats08 β€” https://natureofcode.com/book/chapter-7-cellular-automata/ , https://github.com/DougHaber/nlife-color , https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h class GameOfLifeEffect : public EffectBase { public: - const char* tags() const override { return "πŸ”¬πŸŒ™"; } // cellular automaton Β· MoonLight / v1 lineage - // Iterates y and x only; Layer::extrude fills z on 3D layers. The cell grids - // cover only the z=0 plane (w*h), not the full 3D buffer. - Dim dimensions() const override { return Dim::D2; } + const char* tags() const override { return "πŸ’«πŸŒ™"; } // MoonLight origin Β· MoonModules - uint8_t seed = 42; - bool wraparound = false; - uint8_t hue = 160; - uint8_t bpm = 60; // generation rate; β‰ˆ bpm/8 generations per second + // Rulesets: index β†’ B(orn)/S(urvive) string. Index 0 reads customRuleString. The label is + // descriptive only; parsing reads the digits around the '/' (see parseRuleset). + static constexpr const char* kRulesetOptions[] = { + "Custom B/S", + "Conway's Game of Life B3/S23", + "HighLife B36/S23", + "InverseLife B0123478/S34678", + "Maze B3/S12345", + "Mazecentric B3/S1234", + "DrighLife B367/S23"}; + static constexpr uint8_t kRulesetCount = 7; + + // The B/S string a given ruleset parses (index 0 = custom). Kept separate from the UI label so + // a preset parses exactly its rule, not the words in its menu entry. + static constexpr const char* kRulesetStrings[] = { + nullptr, // 0: custom β†’ customRuleString + "B3/S23", // Conway + "B36/S23", // HighLife + "B0123478/S34678", // InverseLife + "B3/S12345", // Maze + "B3/S1234", // Mazecentric + "B367/S23"}; // DrighLife + + // Defaults match MoonLight's E_MoonModules GameOfLife exactly. + uint8_t backgroundColorR = 0, backgroundColorG = 0, backgroundColorB = 0; // bgC {0,0,0} + uint8_t ruleset = 1; // Conway + char customRuleString[20] = "B/S"; + uint8_t speed = 20; // GameSpeed (FPS), 0..100 (100 = uncapped) + uint8_t lifeChance = 32; // startingLifeDensity, 10..90 % + uint8_t mutation = 2; // mutationChance, 0..100 % + bool wrap = true; + bool disablePause = false; + bool colorByAge = false; + bool infinite = true; + uint8_t blur = 128; void onBuildControls() override { - controls_.addUint8("seed", seed, 0, 255); - controls_.addBool("wraparound", wraparound); - controls_.addUint8("hue", hue, 0, 255); - controls_.addUint8("bpm", bpm, 1, 255); + // MoonLight's bgC is a Coord3D 0..255 read as RGB. projectMM has no colour control, so the + // three components are three uint8s β€” the native, recognisable shape for an RGB triple here. + controls_.addUint8("backgroundColorR", backgroundColorR, 0, 255); + controls_.addUint8("backgroundColorG", backgroundColorG, 0, 255); + controls_.addUint8("backgroundColorB", backgroundColorB, 0, 255); + controls_.addSelect("ruleset", ruleset, kRulesetOptions, kRulesetCount); + controls_.addText("customRuleString", customRuleString, sizeof(customRuleString)); + controls_.addUint8("GameSpeed (FPS)", speed, 0, 100); + controls_.addUint8("startingLifeDensity", lifeChance, 10, 90); + controls_.addUint8("mutationChance", mutation, 0, 100); + controls_.addBool("wrap", wrap); + controls_.addBool("disablePause", disablePause); + controls_.addBool("colorByAge", colorByAge); + controls_.addBool("infinite", infinite); + controls_.addUint8("blur", blur, 0, 255); } + // Grid state lives on the heap (cells + next-gen + per-cell colour), sized to the light count. + // Bit-packed alive/dead keeps it small (16K cells = 2KB each plane); colours are one byte each. + // Off the hot path (cf. Fire's heat_) β€” never an inline member, so sizeof(GameOfLife) stays tiny + // (an inline array here caused a P4 stack-overflow bootloop with HueDriver). void onBuildState() override { - nrOfLightsType count = static_cast(width()) * height(); + const nrOfLightsType count = nrOfLights(); if (enabled() && count > 0) { + const size_t planeBytes = (static_cast(count) + 7) / 8; if (count != cellCount_) { - releaseGrids(); - cur_ = static_cast(platform::alloc(count)); - nxt_ = static_cast(platform::alloc(count)); - if (cur_ && nxt_) { + release(); + cells_ = static_cast(platform::alloc(planeBytes)); + future_ = static_cast(platform::alloc(planeBytes)); + colors_ = static_cast(platform::alloc(count)); + if (cells_ && future_ && colors_) { cellCount_ = count; - reseed(); // fresh state for the new dimensions + planeBytes_ = planeBytes; + generation_ = 0; // force a fresh fill on the next loop } else { - releaseGrids(); // partial alloc β†’ keep nothing + release(); } } } else { - releaseGrids(); + release(); } - // Two grids: report both so the UI's per-effect heap figure is honest. - setDynamicBytes(static_cast(cellCount_) * 2); + setDynamicBytes(cellCount_ ? planeBytes_ * 2 + cellCount_ : 0); } - void teardown() override { - releaseGrids(); - setDynamicBytes(0); + void teardown() override { release(); setDynamicBytes(0); } + ~GameOfLifeEffect() override { release(); } + + void onUpdate(const char* name) override { + if (std::strcmp(name, "ruleset") == 0 || std::strcmp(name, "customRuleString") == 0) + parseRuleset(); } - ~GameOfLifeEffect() override { - releaseGrids(); + // --- Test seams: drive the automaton deterministically without a Layer/clock. allocateForTest + // sizes the grid; setCellForTest seeds a pattern; stepForTest runs one generation; isAliveForTest + // reads a cell. parseRulesetForTest / birthForTest / surviveForTest expose the B/S parser. + bool allocateForTest(lengthType w, lengthType h, lengthType d) { + testW_ = w; testH_ = h; testD_ = d; + const nrOfLightsType count = static_cast(w) * h * d; + const size_t planeBytes = (static_cast(count) + 7) / 8; + release(); + cells_ = static_cast(platform::alloc(planeBytes)); + future_ = static_cast(platform::alloc(planeBytes)); + colors_ = static_cast(platform::alloc(count)); + if (!cells_ || !future_ || !colors_) { release(); return false; } + cellCount_ = count; planeBytes_ = planeBytes; + std::memset(cells_, 0, planeBytes); std::memset(future_, 0, planeBytes); std::memset(colors_, 0, count); + generation_ = 1; // skip the random-fill path + return true; + } + void setCellForTest(lengthType x, lengthType y, lengthType z, bool on) { + setBit(cells_, idx(x, y, z, testW_, testH_), on); } + bool isAliveForTest(lengthType x, lengthType y, lengthType z) const { + return getBit(cells_, idx(x, y, z, testW_, testH_)); + } + void stepForTest() { parseRuleset(); evolveAutomaton(testW_, testH_, testD_, true); } + void parseRulesetForTest() { parseRuleset(); } + bool birthForTest(uint8_t n) const { return birthNumbers_[n]; } + bool surviveForTest(uint8_t n) const { return surviveNumbers_[n]; } void loop() override { - if (!cur_ || !nxt_) return; - - lengthType w = width(); - lengthType h = height(); - if (w <= 0 || h <= 0) return; - - // 1. Time-gate the generation rate so bpm controls speed independent of - // frame rate. Accumulate dt*bpm (msΒ·bpm) and spend whole "beats"; - // one beat = one generation. bpm/8 β‰ˆ generations per second (bpm 8 β†’ - // 1/s, 60 β†’ ~7.5/s, 255 β†’ ~32/s). Numerator-only accumulator, divide - // at the read site β€” same shape as CheckerboardEffect. - uint32_t now = elapsed(); - // Bootstrap on the first frame: lastElapsed_ starts at 0, so a raw - // now-0 would be a huge dt that pins stepAccum_ above the beat threshold - // for good (max-rate forever, bpm ignored). Seed the baseline and take - // dt=0 this frame instead. - uint32_t dt = startedClock_ ? (now - lastElapsed_) : 0; - startedClock_ = true; - lastElapsed_ = now; - stepAccum_ += static_cast(dt) * bpm; - constexpr uint64_t kMsPerBeat = 8000; // 8000 msΒ·bpm == one generation - // Cap catch-up so a long stall (e.g. tab hidden) can't run thousands of - // generations in one frame. - uint8_t budget = 4; - while (stepAccum_ >= kMsPerBeat && budget-- > 0) { - stepAccum_ -= kMsPerBeat; - advance(); + if (!cells_ || !future_ || !colors_ || cellCount_ == 0) return; + const lengthType w = width(), h = height(), d = depth(); + const uint8_t cpl = channelsPerLight(); + if (w == 0 || h == 0 || d == 0 || cpl == 0) return; + + parseRuleset(); + + // generation 0 = "between games": wait out the settle/respawn delay, then start fresh and + // show the initial fill before the first step (MoonLight: gen==0 && step(idx(x, y, w)) * cpl; - if (cur_[idx(x, y, w)]) { - RGB c = hsvToRgb(static_cast(hue + x * 3 + y * 5), 200, 255); - if (cpl >= 1) buf[off + 0] = c.r; - if (cpl >= 2) buf[off + 1] = c.g; - if (cpl >= 3) buf[off + 2] = c.b; - } else { - if (cpl >= 1) buf[off + 0] = 0; - if (cpl >= 2) buf[off + 1] = 0; - if (cpl >= 3) buf[off + 2] = 0; - } - } + Buffer& buf = layer()->buffer(); + const Coord3D dims{w, h, d}; + const RGB bg{backgroundColorR, backgroundColorG, backgroundColorB}; + + // blur>220 (&& !colorByAge) keeps a faded background instead of fully clearing dead cells: + // raise a floor and pull blur back under 220 for this frame. + int fadedBackground = 0; + uint8_t frameBlur = blur; + if (blur > 220 && !colorByAge) { + fadedBackground = bg.r + bg.g + bg.b + 20 + (blur - 220); + frameBlur = static_cast(blur - (blur - 220)); // == 220 + } + const bool blurDead = step_ > now() && !fadedBackground; // still in the settle pause + + // Redraw pass: paints the just-placed fill, ages paused cells, blurs dead cells while paused. + if (generation_ <= 1 || blurDead) { + for (lengthType z = 0; z < d; z++) + for (lengthType y = 0; y < h; y++) + for (lengthType x = 0; x < w; x++) { + const nrOfLightsType i = idx(x, y, z, w, h); + const Coord3D p{x, y, z}; + const bool alive = getBit(cells_, i); + const bool recolor = alive && generation_ == 1 && colors_[i] == 0 && !rng_.below(16); + if (alive && recolor) { + colors_[i] = rng_.below(1, 255); + draw::pixel(buf, dims, p, liveColor(colors_[i])); + } else if (alive && colorByAge && generation_ == 0) { + draw::blendPixel(buf, dims, p, RGB{255, 0, 0}, 248); // age while paused + } else if (alive && colors_[i] != 0) { + draw::pixel(buf, dims, p, liveColor(colors_[i])); + } else if (!alive && blurDead) { + draw::blendPixel(buf, dims, p, bg, frameBlur); // blur dead while paused + } else if (!alive && generation_ == 1) { + draw::blendPixel(buf, dims, p, bg, 248); // fade dead on new game + } + } } + + // Speed throttle: 100 runs uncapped; otherwise advance only once 1000/speed ms have passed. + if (!speed || step_ > now() || (speed != 100 && now() - step_ < 1000u / speed)) return; + + evolveAutomaton(w, h, d, false, &buf, dims, bg, frameBlur, fadedBackground); } - // --- Test helpers (deterministic stepping without rendering) ------------- - // Mirror the v1 effect's test surface so the rule can be pinned directly. - void setCell(lengthType x, lengthType y, bool alive) { - if (cur_ && x >= 0 && y >= 0 && x < width() && y < height()) - cur_[idx(x, y, width())] = alive ? 1 : 0; +private: + uint8_t* cells_ = nullptr; // bit-packed alive/dead, current generation + uint8_t* future_ = nullptr; // bit-packed, next generation (swapped in) + uint8_t* colors_ = nullptr; // palette index (or 0 = dead) per cell + nrOfLightsType cellCount_ = 0; + size_t planeBytes_ = 0; + + uint32_t generation_ = 0; + uint32_t step_ = 0; // ms timestamp gating the next step / settle pause + Random8 rng_{0x6C0FFEE5u}; + + bool birthNumbers_[9] = {}; // birthNumbers_[n] = a dead cell with n live neighbours is born + bool surviveNumbers_[9] = {}; // surviveNumbers_[n] = a live cell with n live neighbours survives + + // Three stasis fingerprints sampled at three periods, plus the solo-glider flag. + uint16_t oscillatorCRC_ = 0, spaceshipCRC_ = 0, cubeGliderCRC_ = 0; + uint16_t gliderLength_ = 0, cubeGliderLength_ = 0; + bool soloGlider_ = false; + + lengthType testW_ = 0, testH_ = 0, testD_ = 0; // test-seam grid dims (see allocateForTest) + + uint32_t now() const { return elapsed(); } + + void release() { + if (cells_) { platform::free(cells_); cells_ = nullptr; } + if (future_) { platform::free(future_); future_ = nullptr; } + if (colors_) { platform::free(colors_); colors_ = nullptr; } + cellCount_ = 0; planeBytes_ = 0; } - bool getCell(lengthType x, lengthType y) const { - if (!cur_ || x < 0 || y < 0 || x >= width() || y >= height()) return false; - return cur_[idx(x, y, width())] != 0; + + // --- bit-packed cell access --- + static bool getBit(const uint8_t* plane, nrOfLightsType i) { return (plane[i >> 3] >> (i & 7)) & 1; } + static void setBit(uint8_t* plane, nrOfLightsType i, bool on) { + const uint8_t m = static_cast(1u << (i & 7)); + if (on) plane[i >> 3] |= m; else plane[i >> 3] = static_cast(plane[i >> 3] & ~m); } - nrOfLightsType liveCount() const { - nrOfLightsType n = 0; - for (nrOfLightsType i = 0; i < cellCount_; i++) n += (cur_[i] != 0); - return n; + static nrOfLightsType idx(lengthType x, lengthType y, lengthType z, lengthType w, lengthType h) { + return static_cast((static_cast(z) * h + y) * w + x); } - void clearGrid() { - if (cur_) std::memset(cur_, 0, cellCount_); + + // A live cell's colour: green when colorByAge (it ages toward red), else its palette colour. + RGB liveColor(uint8_t colorIndex) const { + return colorByAge ? RGB{0, 255, 0} : colorFromPalette(*Palettes::active(), colorIndex); } - // Advance one B3/S23 generation (no re-seed, no render); returns the number - // of cells that changed and, if `aliveOut` is given, writes the live count. - // loop() calls this then re-seeds on extinction/stasis. Public so tests can - // step a known pattern deterministically. - nrOfLightsType stepOnce(nrOfLightsType* aliveOut = nullptr) { - if (!cur_ || !nxt_) { if (aliveOut) *aliveOut = 0; return 0; } - lengthType w = width(); - lengthType h = height(); - nrOfLightsType alive = 0, changed = 0; - for (lengthType y = 0; y < h; y++) { - for (lengthType x = 0; x < w; x++) { - uint8_t n = neighbors(x, y, w, h); - uint8_t self = cur_[idx(x, y, w)]; - // Birth on exactly 3 neighbours; survival on 2 or 3. - uint8_t next = (n == 3 || (self && n == 2)) ? 1 : 0; - nxt_[idx(x, y, w)] = next; - if (next) alive++; - if (next != self) changed++; + + // Parse "B#/S#" into the birth/survive sets. Digits 0..8 before the '/' are birth counts, after + // are survive counts β€” no 'B'/'S' letters required, so a user typing "36/23" works. Matches + // MoonLight: index into the slash, classify each digit by side. + void parseRuleset() { + const char* r = (ruleset == 0) ? customRuleString + : (ruleset < kRulesetCount ? kRulesetStrings[ruleset] : kRulesetStrings[1]); + std::memset(birthNumbers_, 0, sizeof(birthNumbers_)); + std::memset(surviveNumbers_, 0, sizeof(surviveNumbers_)); + if (!r) return; + const char* slash = std::strchr(r, '/'); + const long slashIndex = slash ? (slash - r) : -1; + for (const char* p = r; *p; p++) { + const int num = *p - '0'; + if (num >= 0 && num < 9) { + if (slashIndex >= 0 && (p - r) < slashIndex) birthNumbers_[num] = true; + else surviveNumbers_[num] = true; } } - uint8_t* tmp = cur_; cur_ = nxt_; nxt_ = tmp; // swap: cur = new gen - if (aliveOut) *aliveOut = alive; - return changed; } -private: - // One production generation plus the liveliness logic. A random soup always - // decays to sparse still-lifes + a few blinkers (changed never hits 0, so a - // plain stasis check won't fire) β€” so we re-seed when the colony goes - // extinct, thins below a density floor, or stops growing for a while. That - // keeps gliders and chaos coming instead of a frozen field. (MoonLight does - // the richer version with pentomino injection + CRC cycle detection; this - // is the minimal equivalent β€” see the spec's Extending note.) - void advance() { - nrOfLightsType alive = 0; - stepOnce(&alive); - generation_++; + // Integer gcd/lcm β€” gliderLength = lcm(h,w)*4 (the spaceship sampling period). + static uint32_t gcd(uint32_t a, uint32_t b) { while (b) { const uint32_t t = a % b; a = b; b = t; } return a; } + static uint32_t lcm(uint32_t a, uint32_t b) { if (!a || !b) return 0; return a / gcd(a, b) * b; } - nrOfLightsType floor = cellCount_ / 32; // ~3% of the grid - if (alive <= floor) { - reseed(); - return; - } - // Stagnation: if the live count barely moves over a window, the colony - // has settled into still-lifes + oscillators. Re-seed to revive it. - nrOfLightsType delta = (alive > lastAlive_) - ? static_cast(alive - lastAlive_) - : static_cast(lastAlive_ - alive); - if (delta <= (cellCount_ / 256 + 1)) { // <0.4% change this generation - if (++stagnantGens_ >= 32) reseed(); - } else { - stagnantGens_ = 0; - } - lastAlive_ = alive; - } + // Begin a new game: random fill at lifeChance density, seed the three CRCs from the fill, set the + // settle pause (1.5 s unless disablePause), reset glider state. MoonLight: startNewGameOfLife. + void startNewGame(lengthType w, lengthType h, lengthType d) { + generation_ = 1; + step_ = disablePause ? now() : now() + 1500; - uint8_t* cur_ = nullptr; - uint8_t* nxt_ = nullptr; - nrOfLightsType cellCount_ = 0; - uint32_t rngState_ = 0; + std::memset(cells_, 0, planeBytes_); + std::memset(colors_, 0, cellCount_); + + Buffer& buf = layer()->buffer(); + const Coord3D dims{w, h, d}; + for (lengthType z = 0; z < d; z++) + for (lengthType y = 0; y < h; y++) + for (lengthType x = 0; x < w; x++) { + if (rng_.below(100) < lifeChance) { + const nrOfLightsType i = idx(x, y, z, w, h); + setBit(cells_, i, true); + colors_[i] = rng_.below(1, 255); // never 0 (0 = dead marker) + draw::pixel(buf, dims, {x, y, z}, liveColor(colors_[i])); + } + } + std::memcpy(future_, cells_, planeBytes_); - uint8_t rand8() { - rngState_ = rngState_ * 1103515245u + 12345u; - return static_cast((rngState_ >> 16) & 0xFF); + soloGlider_ = false; + const uint16_t crc = crc16(cells_, planeBytes_); + oscillatorCRC_ = spaceshipCRC_ = cubeGliderCRC_ = crc; + gliderLength_ = static_cast(lcm(static_cast(h), static_cast(w)) * 4); + cubeGliderLength_ = static_cast(gliderLength_ * 6); // rectangular-cuboid case left as-is } - // Row-major cell index (z=0 plane only). - static nrOfLightsType idx(lengthType x, lengthType y, lengthType w) { - return static_cast(y) * w + x; + // Repaint every live cell on a fresh fill β€” the "show the start" frame between games while the + // settle timer runs. (MoonLight relies on the redraw loop; here the cells/colours are already + // set by startNewGame, so painting them is a straight pass.) + void renderInitial(lengthType w, lengthType h, lengthType d) { + Buffer& buf = layer()->buffer(); + const Coord3D dims{w, h, d}; + for (lengthType z = 0; z < d; z++) + for (lengthType y = 0; y < h; y++) + for (lengthType x = 0; x < w; x++) { + const nrOfLightsType i = idx(x, y, z, w, h); + if (getBit(cells_, i) && colors_[i] != 0) + draw::pixel(buf, dims, {x, y, z}, liveColor(colors_[i])); + } } - // Count of the 8 Moore neighbours that are alive. Edges either wrap or are - // treated as dead, per the wraparound control. - uint8_t neighbors(lengthType x, lengthType y, lengthType w, lengthType h) const { - uint8_t n = 0; - for (int8_t dy = -1; dy <= 1; dy++) { - for (int8_t dx = -1; dx <= 1; dx++) { - if (dx == 0 && dy == 0) continue; - lengthType nx = static_cast(x + dx); - lengthType ny = static_cast(y + dy); - if (wraparound) { - if (nx < 0) nx = static_cast(w - 1); - else if (nx >= w) nx = 0; - if (ny < 0) ny = static_cast(h - 1); - else if (ny >= h) ny = 0; - } else if (nx < 0 || ny < 0 || nx >= w || ny >= h) { - continue; // out-of-bounds counts as dead + // Place an R-pentomino (1/5 chance a glider), up to 100 attempts avoiding overlap; bounds and the + // z-plane pick match MoonLight's placePentomino. Writes both future_ and the buffer. + void placePentomino(lengthType w, lengthType h, lengthType d, Buffer* buf, Coord3D dims) { + // R-pentomino offsets; pattern[0][1] becomes 3 for the glider variant. + uint8_t pattern[5][2] = {{1, 0}, {0, 1}, {1, 1}, {2, 1}, {2, 2}}; + if (!rng_.below(5)) pattern[0][1] = 3; + const uint8_t colorIndex = rng_.below(1, 255); // 1..254, never 0 (0 = dead marker) + const RGB color = colorFromPalette(*Palettes::active(), colorIndex); + + // random8(1, size-N) needs size>N; guard tiny grids (degenerate axes collapse to 0). + const uint8_t xHi = w > 3 ? static_cast(w > 258 ? 255 : w - 3) : 1; + const uint8_t yHi = h > 5 ? static_cast(h > 260 ? 255 : h - 5) : 1; + + for (int attempts = 0; attempts < 100; attempts++) { + const lengthType x = static_cast(xHi > 1 ? rng_.below(1, xHi) : 0); + const lengthType y = static_cast(yHi > 1 ? rng_.below(1, yHi) : 0); + const lengthType z = static_cast(d > 1 ? rng_.below(2) * (d - 1) : 0); + bool canPlace = true; + for (int i = 0; i < 5; i++) { + const lengthType nx = static_cast(x + pattern[i][0]); + const lengthType ny = static_cast(y + pattern[i][1]); + if (nx >= w || ny >= h) continue; + if (getBit(future_, idx(nx, ny, z, w, h))) { canPlace = false; break; } + } + if (canPlace || attempts == 99) { + for (int i = 0; i < 5; i++) { + const lengthType nx = static_cast(x + pattern[i][0]); + const lengthType ny = static_cast(y + pattern[i][1]); + if (nx >= w || ny >= h) continue; + const nrOfLightsType i2 = idx(nx, ny, z, w, h); + setBit(future_, i2, true); + // Record the cell's colour index so later neighbour-colour inheritance sees a + // live (non-zero marker) colour for these injected cells, not 0 (dead). Drawn + // green under colorByAge, but colors_ still carries the palette index it ages from. + colors_[i2] = colorIndex; + if (buf) draw::pixel(*buf, dims, {nx, ny, z}, colorByAge ? RGB{0, 255, 0} : color); } - n = static_cast(n + (cur_[idx(nx, ny, w)] != 0)); + return; } } - return n; } - // Random initial state. The very first seeding (per grid) starts the PRNG - // from the `seed` control so a given seed gives a reproducible opening; - // later re-seeds continue the same stream so each revival differs β€” without - // that, every reseed would replay the identical soup and the effect would - // loop forever. ~31% alive: dense enough to evolve, sparse enough to avoid - // instant gridlock. - void reseed() { - if (!cur_) return; - if (!seeded_) { - rngState_ = 0x9E3779B9u ^ (static_cast(seed) * 2654435761u); - seeded_ = true; + // One generation: count neighbours (collecting up to 9 neighbour colours for inheritance), apply + // the rules into future_, paint each cell, then run the 3-CRC stasis + respawn / reset logic. + // `testMode` skips rendering and the timing/respawn rendering side-effects (test seam path); + // buf/dims/bg/frameBlur/fadedBackground are only read off the test path. + void evolveAutomaton(lengthType w, lengthType h, lengthType d, bool testMode, + Buffer* buf = nullptr, Coord3D dims = {}, RGB bg = {}, + uint8_t frameBlur = 0, int fadedBackground = 0) { + int aliveCount = 0, deadCount = 0; + const int zAxis = (d > 1) ? 1 : 0; + const bool disableWrap = !wrap || soloGlider_ || generation_ % 1500 == 0 || zAxis; + + for (lengthType x = 0; x < w; x++) + for (lengthType y = 0; y < h; y++) + for (lengthType z = 0; z < d; z++) { + const nrOfLightsType cIndex = idx(x, y, z, w, h); + const bool cellValue = getBit(cells_, cIndex); + if (cellValue) aliveCount++; else deadCount++; + + uint8_t neighbors = 0, colorCount = 0; + uint8_t nColors[9]; + for (int i = -1; i <= 1; i++) + for (int j = -1; j <= 1; j++) + for (int k = -zAxis; k <= zAxis; k++) { + if (i == 0 && j == 0 && k == 0) continue; + lengthType nx = static_cast(x + i); + lengthType ny = static_cast(y + j); + lengthType nz = static_cast(z + k); + if (nx < 0 || ny < 0 || nz < 0 || nx >= w || ny >= h || nz >= d) { + if (disableWrap) continue; + nx = static_cast((nx + w) % w); + ny = static_cast((ny + h) % h); + nz = static_cast((nz + d) % d); + } + const nrOfLightsType nIndex = idx(nx, ny, nz, w, h); + if (getBit(cells_, nIndex)) { + neighbors++; + if (cellValue || colorByAge) continue; // colour not needed + if (colors_[nIndex] == 0) continue; // dead-marker colour + // Cap collected colours at nColors' size 9: 3D's 26-neighbour + // count can exceed 9, and the random pick below indexes with + // rng_.below(colorCount), so an uncapped colorCount would read + // out of bounds. Nine samples are plenty for the inheritance pick. + if (colorCount < 9) nColors[colorCount++] = colors_[nIndex]; + } + } + + const Coord3D p{x, y, z}; + // B/S rulesets are single-digit (0..8, classic Conway notation), so the tables + // are sized 9. In 3D the 3Γ—3Γ—3 neighbourhood yields up to 26 neighbours; a count + // β‰₯9 is in no single-digit ruleset, so it reads as "not a birth/survive count" + // (the cell dies / stays dead) β€” clamp the lookup to avoid the OOB table read. + const bool survives = neighbors < 9 && surviveNumbers_[neighbors]; + const bool born = neighbors < 9 && birthNumbers_[neighbors]; + if (cellValue && !survives) { + // Loneliness / overpopulation: dies, blur toward background. + setBit(future_, cIndex, false); + if (!testMode && buf) draw::blendPixel(*buf, dims, p, bg, frameBlur); + } else if (!cellValue && born) { + // Reproduction: inherit a living neighbour's colour, mutate sometimes. Both + // fallbacks use rng_.below(1, 255) (1..254) so a live cell never gets 0, the + // dead-cell marker (matches startNewGame's fill colour). + setBit(future_, cIndex, true); + uint8_t colorIndex = (colorCount > 0) ? nColors[rng_.below(colorCount)] : rng_.below(1, 255); + if (rng_.below(100) < mutation) colorIndex = rng_.below(1, 255); + colors_[cIndex] = colorIndex; + if (!testMode && buf) draw::pixel(*buf, dims, p, liveColor(colorIndex)); + } else { + // Unchanged cell: dead β†’ blur (honour the faded-background floor); live β†’ + // age toward red, or repaint its palette colour. + if (!cellValue) { + setBit(future_, cIndex, false); + if (!testMode && buf) { + if (fadedBackground) { + const RGB val = draw::get(*buf, dims, p); + if (fadedBackground < val.r + val.g + val.b) + draw::blendPixel(*buf, dims, p, bg, frameBlur); + } else { + draw::blendPixel(*buf, dims, p, bg, frameBlur); + } + } + } else { + setBit(future_, cIndex, true); + if (!testMode && buf) { + if (colorByAge) draw::blendPixel(*buf, dims, p, RGB{255, 0, 0}, 248); + else draw::pixel(*buf, dims, p, liveColor(colors_[cIndex])); + } + } + } + } + + soloGlider_ = (aliveCount == 5); + std::memcpy(cells_, future_, planeBytes_); + + // Test seam runs the pure automaton only: a deterministic block/blinker must not be perturbed + // by the stasis/respawn machinery (which would fire on its low density and fixed RNG). + if (testMode) return; + + const uint16_t crc = crc16(cells_, planeBytes_); + + bool repetition = false; + if (!aliveCount || crc == oscillatorCRC_ || crc == spaceshipCRC_ || crc == cubeGliderCRC_) + repetition = true; + + // Respawn triggers: stasis, a 1/50 random nudge, or density floor under 5% (integer form of + // float(alive)/(alive+dead) < 0.05 β†’ alive*20 < alive+dead). + const int total = aliveCount + deadCount; + const bool densityFloor = total > 0 && aliveCount * 20 < total; + if ((repetition && infinite) || (infinite && !rng_.below(50)) || (infinite && densityFloor)) { + placePentomino(w, h, d, testMode ? nullptr : buf, dims); + std::memcpy(cells_, future_, planeBytes_); + repetition = false; } - for (nrOfLightsType i = 0; i < cellCount_; i++) { - cur_[i] = (rand8() < 80) ? 1 : 0; // 80/256 β‰ˆ 31% + if (repetition) { + generation_ = 0; + step_ = disablePause ? now() : now() + 1000; + return; } - lastAlive_ = 0; - stagnantGens_ = 0; - } - void releaseGrids() { - if (cur_) { platform::free(cur_); cur_ = nullptr; } - if (nxt_) { platform::free(nxt_); nxt_ = nullptr; } - cellCount_ = 0; - seeded_ = false; // a fresh grid re-derives the seed + // Periodic CRC sampling: oscillators every 16 gens, spaceships every gliderLength, cube + // gliders every cubeGliderLength. + if (generation_ % 16 == 0) oscillatorCRC_ = crc; + if (gliderLength_ && generation_ % gliderLength_ == 0) spaceshipCRC_ = crc; + if (cubeGliderLength_ && generation_ % cubeGliderLength_ == 0) cubeGliderCRC_ = crc; + generation_++; + step_ = now(); } - - // Generation-pacing + liveliness state. - uint32_t lastElapsed_ = 0; - bool startedClock_ = false; // false until the first loop seeds lastElapsed_ - uint64_t stepAccum_ = 0; // accumulated dt*bpm (msΒ·bpm) - bool seeded_ = false; // first reseed derives from `seed` - uint32_t generation_ = 0; - nrOfLightsType lastAlive_ = 0; - uint16_t stagnantGens_ = 0; }; } // namespace mm diff --git a/src/light/effects/GlowParticlesEffect.h b/src/light/effects/GlowParticlesEffect.h deleted file mode 100644 index 9ccc024b..00000000 --- a/src/light/effects/GlowParticlesEffect.h +++ /dev/null @@ -1,125 +0,0 @@ -#pragma once - -#include "light/layers/Layer.h" -#include "core/color.h" - -namespace mm { - -// Soft-glowing particles rendered as a metaball field. Particles move with -// independent velocities and bounce off the edges. Field summation gives a -// chaotic, organic blob look β€” like metaballs with more freedom. -class GlowParticlesEffect : public EffectBase { -public: - const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step - // Iterates y and x only; Layer::extrude fills z on 3D layers. - Dim dimensions() const override { return Dim::D2; } - - static constexpr uint8_t MAX_PARTICLES = 8; - - uint8_t count = 5; - uint8_t speed = 60; - uint8_t radius = 24; - uint8_t hue_shift = 0; - - void onBuildControls() override { - controls_.addUint8("count", count, 1, 255); - controls_.addUint8("speed", speed, 1, 255); - controls_.addUint8("radius", radius, 4, 255); - controls_.addUint8("hue_shift", hue_shift, 0, 255); - } - - void loop() override { - uint8_t* buf = buffer(); - lengthType w = width(); - lengthType h = height(); - uint8_t cpl = channelsPerLight(); - if (w <= 0 || h <= 0) return; - - if (!initialized_) initParticles(w, h); - - uint32_t now = elapsed(); - uint32_t dt = now - lastElapsed_; - lastElapsed_ = now; - - int16_t maxXfp = static_cast((w - 1) << 4); - int16_t maxYfp = static_cast((h - 1) << 4); - - for (uint8_t i = 0; i < count && i < MAX_PARTICLES; i++) { - auto& p = particles_[i]; - int16_t sx = static_cast((static_cast(p.vx) * speed * static_cast(dt)) >> 12); - int16_t sy = static_cast((static_cast(p.vy) * speed * static_cast(dt)) >> 12); - p.x = static_cast(p.x + sx); - p.y = static_cast(p.y + sy); - if (p.x < 0) { p.x = 0; p.vx = static_cast(-p.vx); } - if (p.x > maxXfp) { p.x = maxXfp; p.vx = static_cast(-p.vx); } - if (p.y < 0) { p.y = 0; p.vy = static_cast(-p.vy); } - if (p.y > maxYfp) { p.y = maxYfp; p.vy = static_cast(-p.vy); } - } - - int16_t bx[MAX_PARTICLES] = {}; - int16_t by[MAX_PARTICLES] = {}; - for (uint8_t i = 0; i < count && i < MAX_PARTICLES; i++) { - bx[i] = static_cast(particles_[i].x >> 4); - by[i] = static_cast(particles_[i].y >> 4); - } - int32_t r2 = static_cast(radius) * radius; - - for (lengthType y = 0; y < h; y++) { - uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; - for (lengthType x = 0; x < w; x++) { - uint32_t field = 0; - for (uint8_t i = 0; i < count && i < MAX_PARTICLES; i++) { - int32_t dx = static_cast(x) - bx[i]; - int32_t dy = static_cast(y) - by[i]; - int32_t d2 = dx * dx + dy * dy + 1; - field += static_cast((r2 * 64) / d2); - } - uint8_t bright = field > 255 ? 255 : static_cast(field); - uint8_t hue = static_cast((field >> 1) + hue_shift); - RGB c = hsvToRgb(hue, 240, bright); - if (cpl >= 1) row[0] = c.r; - if (cpl >= 2) row[1] = c.g; - if (cpl >= 3) row[2] = c.b; - row += cpl; - } - } - } - -private: - struct Particle { - int16_t x; // 12.4 fixed-point pixel position - int16_t y; - int8_t vx; - int8_t vy; - uint8_t hue; - uint8_t pad; - }; - - Particle particles_[MAX_PARTICLES] = {}; - bool initialized_ = false; - uint32_t lastElapsed_ = 0; - uint32_t rngState_ = 0xACE1BEEFu; - - uint8_t rand8() { - rngState_ = rngState_ * 1103515245u + 12345u; - return static_cast((rngState_ >> 16) & 0xFF); - } - - void initParticles(lengthType w, lengthType h) { - for (uint8_t i = 0; i < MAX_PARTICLES; i++) { - particles_[i].x = static_cast((static_cast(rand8()) * w) >> 4); - particles_[i].y = static_cast((static_cast(rand8()) * h) >> 4); - int8_t vx = static_cast((rand8() >> 1) - 32); - int8_t vy = static_cast((rand8() >> 1) - 32); - if (vx == 0) vx = 1; - if (vy == 0) vy = 1; - particles_[i].vx = vx; - particles_[i].vy = vy; - particles_[i].hue = rand8(); - particles_[i].pad = 0; - } - initialized_ = true; - } -}; - -} // namespace mm diff --git a/src/light/effects/LavaLampEffect.h b/src/light/effects/LavaLampEffect.h index 044a0de7..bf023a10 100644 --- a/src/light/effects/LavaLampEffect.h +++ b/src/light/effects/LavaLampEffect.h @@ -1,13 +1,16 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette, Palettes::active β€” the blob field β†’ palette colour #include "core/color.h" +#include "core/math8.h" // sin8/cos8/dist8/atan2_8 namespace mm { // Atmospheric lava-lamp: three slow blobs whose summed field is mapped // through a black β†’ red β†’ orange β†’ yellow β†’ white palette. // Distinct from MetaballsEffect (which is fast, HSV-coloured). +// Author: projectMM original (metaball lava lamp) class LavaLampEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step @@ -66,7 +69,10 @@ class LavaLampEffect : public EffectBase { } uint32_t scaled = (field * intensity) >> 8; uint8_t idx = scaled > 255 ? 255 : static_cast(scaled); - const RGB& c = palette_[idx]; + // The metaball field value (0 = between blobs, 255 = blob core) is the palette index, + // so the lamp takes the active palette. Lava gives the classic molten look (its low + // end is black, so the space between blobs stays dark); any palette recolours the blobs. + const RGB c = colorFromPalette(*Palettes::active(), idx); if (cpl >= 1) row[0] = c.r; if (cpl >= 2) row[1] = c.g; if (cpl >= 3) row[2] = c.b; @@ -79,43 +85,6 @@ class LavaLampEffect : public EffectBase { // Numerator-only accumulator (units of dt*bpm). See loop() for why. uint64_t phase_num_ = 0; uint32_t lastElapsed_ = 0; - - // Lava palette: black β†’ deep red β†’ red β†’ orange β†’ yellow β†’ near-white. - // 256 RGB entries (768 bytes) β€” stored in flash. - static constexpr RGB palette_[256] = { - {0,0,0}, {4,0,0}, {8,0,0}, {12,0,0}, {16,0,0}, {20,0,0}, {24,0,0}, {28,0,0}, - {32,0,0}, {36,0,0}, {40,0,0}, {44,0,0}, {48,0,0}, {52,0,0}, {56,0,0}, {60,0,0}, - {64,0,0}, {68,0,0}, {72,0,0}, {76,0,0}, {80,0,0}, {84,0,0}, {88,0,0}, {92,0,0}, - {96,0,0}, {100,0,0}, {104,0,0}, {108,0,0}, {112,0,0}, {116,0,0}, {120,0,0}, {124,0,0}, - {128,0,0}, {132,0,0}, {136,0,0}, {140,0,0}, {144,0,0}, {148,0,0}, {152,0,0}, {156,0,0}, - {160,0,0}, {164,0,0}, {168,0,0}, {172,0,0}, {176,0,0}, {180,0,0}, {184,0,0}, {188,0,0}, - {192,0,0}, {196,0,0}, {200,0,0}, {204,0,0}, {208,0,0}, {212,0,0}, {216,0,0}, {220,0,0}, - {224,0,0}, {228,0,0}, {232,0,0}, {236,0,0}, {240,0,0}, {244,0,0}, {248,0,0}, {252,0,0}, - {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, - {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, - {255,0,0}, {255,2,0}, {255,4,0}, {255,6,0}, {255,8,0}, {255,10,0}, {255,12,0}, {255,14,0}, - {255,16,0}, {255,18,0}, {255,20,0}, {255,22,0}, {255,24,0}, {255,26,0}, {255,28,0}, {255,30,0}, - {255,32,0}, {255,34,0}, {255,36,0}, {255,38,0}, {255,40,0}, {255,42,0}, {255,44,0}, {255,46,0}, - {255,48,0}, {255,50,0}, {255,52,0}, {255,54,0}, {255,56,0}, {255,58,0}, {255,60,0}, {255,62,0}, - {255,64,0}, {255,66,0}, {255,68,0}, {255,70,0}, {255,72,0}, {255,74,0}, {255,76,0}, {255,78,0}, - {255,80,0}, {255,82,0}, {255,84,0}, {255,86,0}, {255,88,0}, {255,90,0}, {255,92,0}, {255,94,0}, - {255,96,0}, {255,98,0}, {255,100,0}, {255,102,0}, {255,104,0}, {255,106,0}, {255,108,0}, {255,110,0}, - {255,112,0}, {255,114,0}, {255,116,0}, {255,118,0}, {255,120,0}, {255,122,0}, {255,124,0}, {255,126,0}, - {255,128,0}, {255,130,0}, {255,132,0}, {255,134,0}, {255,136,0}, {255,138,0}, {255,140,0}, {255,142,0}, - {255,144,0}, {255,146,0}, {255,148,0}, {255,150,0}, {255,152,0}, {255,154,0}, {255,156,0}, {255,158,0}, - {255,160,0}, {255,162,0}, {255,164,0}, {255,166,0}, {255,168,0}, {255,170,0}, {255,172,0}, {255,174,0}, - {255,176,0}, {255,178,0}, {255,180,0}, {255,182,0}, {255,184,0}, {255,186,0}, {255,188,0}, {255,190,0}, - {255,192,0}, {255,194,0}, {255,196,0}, {255,198,0}, {255,200,0}, {255,202,0}, {255,204,0}, {255,206,0}, - {255,208,0}, {255,210,0}, {255,212,0}, {255,214,0}, {255,216,0}, {255,218,0}, {255,220,0}, {255,222,0}, - {255,224,0}, {255,226,0}, {255,228,0}, {255,230,0}, {255,232,0}, {255,234,0}, {255,236,0}, {255,238,0}, - {255,240,0}, {255,242,4}, {255,244,8}, {255,246,12}, {255,248,16}, {255,250,20}, {255,252,24}, {255,254,28}, - {255,255,32}, {255,255,36}, {255,255,40}, {255,255,44}, {255,255,48}, {255,255,52}, {255,255,56}, {255,255,60}, - {255,255,64}, {255,255,68}, {255,255,72}, {255,255,76}, {255,255,80}, {255,255,84}, {255,255,88}, {255,255,92}, - {255,255,96}, {255,255,100}, {255,255,104}, {255,255,108}, {255,255,112}, {255,255,116}, {255,255,120}, {255,255,124}, - {255,255,128}, {255,255,132}, {255,255,136}, {255,255,140}, {255,255,144}, {255,255,148}, {255,255,152}, {255,255,156}, - {255,255,160}, {255,255,164}, {255,255,168}, {255,255,172}, {255,255,176}, {255,255,180}, {255,255,184}, {255,255,188}, - {255,255,192}, {255,255,196}, {255,255,200}, {255,255,204}, {255,255,208}, {255,255,212}, {255,255,216}, {255,255,220} - }; }; } // namespace mm diff --git a/src/light/effects/LinesEffect.h b/src/light/effects/LinesEffect.h index 8833e4be..b8e0bb1c 100644 --- a/src/light/effects/LinesEffect.h +++ b/src/light/effects/LinesEffect.h @@ -10,6 +10,7 @@ namespace mm { // Blue β€” XY plane sweeps frontβ†’back (z oscillates) // Useful for verifying preview axis orientation: each colour names its axis. // Port of MoonLight's Lines effect via projectMM-v1/LinesEffect.h. +// Author: MoonLight β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h class LinesEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«"; } diff --git a/src/light/effects/LissajousEffect.h b/src/light/effects/LissajousEffect.h new file mode 100644 index 00000000..a039e9b1 --- /dev/null +++ b/src/light/effects/LissajousEffect.h @@ -0,0 +1,86 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade +#include "core/math8.h" // sin8, cos8 (pure 256-step LUT) + +namespace mm { + +// Lissajous: traces a Lissajous figure across the 2D grid. 256 sample points sweep an +// x-oscillator (sin8) and a y-oscillator (cos8) whose relative frequencies and shared phase +// drift over time, so the closed curve continuously morphs and rotates. Each sample is mapped +// onto the grid and painted from the palette, with a per-frame fade leaving a decaying trail. +// +// `speed` advances the shared phase (a function of elapsed time), `xFrequency` sets how many +// x-oscillations occur per y-oscillation (the figure's lobe count), and `fadeRate` controls the +// trail length. The x-oscillator runs at half-phase plus iΒ·xFrequency/64, the y-oscillator at +// half-phase plus iΒ·2, exactly as the source. `phase` (= elapsedΒ·speed/256) is kept wide; only +// the final sin8/cos8 argument is truncated to a uint8_t, so it wraps at 256 β€” that wrap over the +// 256 samples is what produces the closed figure, and it is the source's only truncation point. +// +// Prior art: MoonLight's Lissajous (E_MoonModules / MoonModules), itself the WLED "Lissajous" +// effect (Andrew Tuline / WLED). The oscillator phase math, the sin8/cos8 split, the +// 2Β·locn β†’ 2Β·(sizeβˆ’1) remap, and the elapsed()/100 + i palette walk are reproduced exactly here, +// written fresh on EffectBase + the shared draw primitives. +// Author: Andrew Tuline (WLED-SR) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +class LissajousEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ™"; } // WLED-lineage + Dim dimensions() const override { return Dim::D2; } + + uint8_t xFrequency = 64; // x-oscillation count per y-oscillation (the lobe count) + uint8_t fadeRate = 128; // per-frame trail fade (higher = shorter trail) + uint8_t speed = 128; // phase advance rate + + void onBuildControls() override { + controls_.addUint8("xFrequency", xFrequency, 0, 255); + controls_.addUint8("fadeRate", fadeRate, 0, 255); + controls_.addUint8("speed", speed, 0, 255); + } + + void loop() override { + const int w = width(); + const int h = height(); + if (w <= 0 || h <= 0 || channelsPerLight() < 3) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(w), static_cast(h), depthDim()}; + + // Motion trail: dim the whole buffer each frame (source: layer->fadeToBlackBy(fadeRate)). + layer()->fadeToBlackBy(fadeRate); + + // Shared phase, advancing with elapsed time. Kept wide (16-bit) like the source; only the + // sin8/cos8 LUT argument below is truncated to uint8_t (the mod-256 wrap), so the high bits + // of phase survive into half-phase exactly as in MoonLight (phase = millis()*speed/256). + const uint32_t ms = elapsed(); + const uint16_t phase = static_cast(ms * speed / 256); + const uint16_t halfPhase = static_cast(phase / 2); + + for (int i = 0; i < 256; i++) { + // x oscillator: sin8 of half-phase + iΒ·xFrequency/64; y oscillator: cos8 of half-phase + iΒ·2. + // The uint8_t cast at the call is the source's truncation point (sin8/cos8 take a uint8_t). + const uint8_t sx = sin8(static_cast(halfPhase + (i * xFrequency) / 64)); + const uint8_t sy = cos8(static_cast(halfPhase + i * 2)); + + // Map the 0..255 oscillator value onto the grid: MoonLight's + // (map(2Β·s, 0, 511, 0, 2Β·(sizeβˆ’1)) + 1) / 2. When an axis has only one cell the only + // valid index is 0 (MoonLight's fallback of 1 assumes a 1-based extent and clips on our + // 0-indexed grid), so a size-1 axis maps to coordinate 0. + const int lx = (w < 2) ? 0 : (((2 * sx) * (2 * (w - 1))) / 511 + 1) / 2; + const int ly = (h < 2) ? 0 : (((2 * sy) * (2 * (h - 1))) / 511 + 1) / 2; + + const uint8_t colorIndex = static_cast(ms / 100 + i); + draw::pixel(buf, dims, {static_cast(lx), static_cast(ly), 0}, + colorFromPalette(*Palettes::active(), colorIndex, 255)); + } + } + +private: + // depth() (the inherited grid-depth accessor) isn't shadowed here β€” this effect has no `depth` + // member β€” but mirror the GEQ3D helper shape for the z extent the draw primitives expect. + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } +}; + +} // namespace mm diff --git a/src/light/effects/MetaballsEffect.h b/src/light/effects/MetaballsEffect.h index 4ce38120..7610e815 100644 --- a/src/light/effects/MetaballsEffect.h +++ b/src/light/effects/MetaballsEffect.h @@ -1,10 +1,13 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette + active palette #include "core/color.h" +#include "core/math8.h" // sin8/cos8/dist8/atan2_8 namespace mm { +// Author: projectMM original (metaballs) class MetaballsEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step @@ -13,13 +16,15 @@ class MetaballsEffect : public EffectBase { uint8_t bpm = 30; uint8_t radius = 28; + uint8_t count = 4; // number of balls (1..MAX_BALLS); each follows its own sine path uint8_t hue_shift = 0; - static constexpr uint8_t NUM_BALLS = 4; + static constexpr uint8_t MAX_BALLS = 8; void onBuildControls() override { controls_.addUint8("bpm", bpm, 1, 255); controls_.addUint8("radius", radius, 4, 255); + controls_.addUint8("count", count, 1, MAX_BALLS); controls_.addUint8("hue_shift", hue_shift, 0, 255); } @@ -40,12 +45,13 @@ class MetaballsEffect : public EffectBase { phase_num_ += static_cast(dt) * bpm; uint8_t t = static_cast((phase_num_ * 256) / 60000); - int16_t bx[NUM_BALLS]; - int16_t by[NUM_BALLS]; - static constexpr uint8_t SPEED_MUL[NUM_BALLS] = { 1, 2, 3, 1 }; - static constexpr uint8_t PHASE_X[NUM_BALLS] = { 0, 30, 60, 120 }; - static constexpr uint8_t PHASE_Y[NUM_BALLS] = { 64, 94, 124, 184 }; - for (uint8_t b = 0; b < NUM_BALLS; b++) { + const uint8_t n = count < MAX_BALLS ? count : MAX_BALLS; + int16_t bx[MAX_BALLS]; + int16_t by[MAX_BALLS]; + static constexpr uint8_t SPEED_MUL[MAX_BALLS] = { 1, 2, 3, 1, 2, 3, 1, 2 }; + static constexpr uint8_t PHASE_X[MAX_BALLS] = { 0, 30, 60, 120, 160, 200, 90, 220 }; + static constexpr uint8_t PHASE_Y[MAX_BALLS] = { 64, 94, 124, 184, 16, 210, 150, 40 }; + for (uint8_t b = 0; b < n; b++) { uint8_t tb = static_cast(t * SPEED_MUL[b]); bx[b] = static_cast((sin8(static_cast(tb + PHASE_X[b])) * w) >> 8); by[b] = static_cast((sin8(static_cast(tb + PHASE_Y[b])) * h) >> 8); @@ -58,7 +64,7 @@ class MetaballsEffect : public EffectBase { uint8_t* row = buf + static_cast(y) * w * cpl; for (lengthType x = 0; x < w; x++) { uint32_t field = 0; - for (uint8_t b = 0; b < NUM_BALLS; b++) { + for (uint8_t b = 0; b < n; b++) { int32_t dx = static_cast(x) - bx[b]; int32_t dy = static_cast(y) - by[b]; int32_t d2 = dx * dx + dy * dy + 1; @@ -66,7 +72,7 @@ class MetaballsEffect : public EffectBase { } uint8_t bright = field > 255 ? 255 : static_cast(field); uint8_t hue = static_cast((field >> 1) + hue_shift); - RGB c = hsvToRgb(hue, 240, bright); + RGB c = colorFromPalette(*Palettes::active(), hue, bright); if (cpl >= 1) row[0] = c.r; if (cpl >= 2) row[1] = c.g; diff --git a/src/light/effects/NetworkReceiveEffect.h b/src/light/effects/NetworkReceiveEffect.h index 007ea8b6..90320299 100644 --- a/src/light/effects/NetworkReceiveEffect.h +++ b/src/light/effects/NetworkReceiveEffect.h @@ -37,6 +37,7 @@ namespace mm { // Prior art: MoonLight's D_NetworkIn (single node, three protocols), WLED's // realtime UDP input (multi-port + per-packet validation, ArtPollReply), and // projectMM v1's ArtNetInModule. +// Author: projectMM original (E1.31 / Art-Net receive) class NetworkReceiveEffect : public EffectBase { public: const char* tags() const override { return "πŸ“‘πŸŒ™"; } // network input Β· MoonLight / v1 lineage diff --git a/src/light/effects/Noise2DEffect.h b/src/light/effects/Noise2DEffect.h new file mode 100644 index 00000000..8f6f68c8 --- /dev/null +++ b/src/light/effects/Noise2DEffect.h @@ -0,0 +1,68 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel +#include "core/noise.h" // inoise8 (3-arg value noise) + +namespace mm { + +// Noise 2D: a smoothly drifting value-noise field. Each (x,y) pixel reads a 3D noise sample whose +// X/Y coordinates are the grid position scaled by `scale` (larger scale = finer, more detailed +// noise; smaller = broad, smooth blobs) and whose Z coordinate is time, so the whole field flows / +// morphs over the frames. The 0..255 noise value indexes the active palette directly, giving the +// classic organic, plasma-like colour wash. +// +// Source math (MoonLight's Noise2D): for every (x,y), +// pixelHue8 = inoise8(x*scale, y*scale, millis()/(16-speed)); +// setRGB(x,y, ColorFromPalette(pal, pixelHue8)); +// `16-speed` is the time divisor: a higher `speed` (max 15) shrinks the divisor, so time advances +// faster through the noise and the field morphs quicker. speed maxes at 15, so 16-speed is at least +// 1 β€” the division can never be by zero. +// +// Prior art: MoonLight's Noise2D effect (E_MoonModules / MoonModules), itself in the WLED +// noise-effect lineage (FastLED inoise8 β€” Perlin/value noise, Mark Kriegsman / Ken Perlin). The +// per-pixel coordinate-scale + time-on-Z animation and the direct palette indexing are reproduced +// exactly here, written fresh on EffectBase + the shared draw / noise primitives. +// Author: WLED (Noise 2D) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +class Noise2DEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸŒ™πŸ™"; } // MoonLight origin Β· MoonModules Β· 2D + Dim dimensions() const override { return Dim::D2; } + + uint8_t speed = 8; // time-flow rate (0..15); higher = faster morph (divisor is 16-speed) + uint8_t scale = 64; // noise zoom (2..255); higher = finer/more-detailed field + + void onBuildControls() override { + controls_.addUint8("speed", speed, 0, 15); + controls_.addUint8("scale", scale, 2, 255); + } + + void loop() override { + const int cols = width(); + const int rows = height(); + if (cols <= 0 || rows <= 0 || channelsPerLight() < 3) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(cols), static_cast(rows), depthDim()}; + + // Time coordinate on the noise Z axis: millis() / (16 - speed). speed <= 15 keeps the + // divisor >= 1 (no divide-by-zero). uint32_t throughout β€” matches inoise8's coordinate type. + const uint32_t t = elapsed() / static_cast(16 - speed); + + for (int y = 0; y < rows; y++) { + for (int x = 0; x < cols; x++) { + const uint8_t pixelHue8 = inoise8(static_cast(x) * scale, + static_cast(y) * scale, t); + const RGB c = colorFromPalette(*Palettes::active(), pixelHue8); + draw::pixel(buf, dims, {static_cast(x), static_cast(y), 0}, c); + } + } + } + +private: + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } +}; + +} // namespace mm \ No newline at end of file diff --git a/src/light/effects/NoiseEffect.h b/src/light/effects/NoiseEffect.h index f2e503f4..48a0c78f 100644 --- a/src/light/effects/NoiseEffect.h +++ b/src/light/effects/NoiseEffect.h @@ -1,10 +1,12 @@ #pragma once #include "light/layers/Layer.h" -#include "core/color.h" +#include "light/Palette.h" // colorFromPalette + active palette +#include "core/noise.h" // inoise8 β€” the shared value-noise field namespace mm { +// Author: FastLED inoise field (Mark Kriegsman) class NoiseEffect : public EffectBase { public: const char* tags() const override { return "⚑️"; } // FastLED-style noise @@ -27,13 +29,18 @@ class NoiseEffect : public EffectBase { nrOfLightsType count = nrOfLights(); nrOfLightsType wh = static_cast(w) * h; - // Accumulate phase incrementally β€” changing BPM doesn't cause a jump. - // Factor 32 tuned so 60 BPM at 128-wide gives smooth motion. + // Accumulate phase incrementally β€” changing BPM doesn't cause a jump. phase_ holds the RAW + // numerator (dtΒ·bpmΒ·wΒ·64) and the /60000 divide runs only at the sampling point below; dividing + // per tick would truncate sub-unit progress to zero on a fast board (short dt) or small grid, + // stalling the field. Factor 64 tuned so 60 BPM at 128-wide gives smooth motion. uint32_t now = elapsed(); + // First tick: seed lastElapsed_ to now so the field starts at phase 0 instead of jumping by + // the whole device uptime (lastElapsed_ is 0 until the first loop) β€” the WaveEffect pattern. + if (!started_) { lastElapsed_ = now; started_ = true; } uint32_t dt = now - lastElapsed_; lastElapsed_ = now; - phase_ += static_cast(dt) * bpm * w * 64 / 60000; - uint32_t t = static_cast(phase_); + phase_ += static_cast(dt) * bpm * w * 64; + uint32_t t = static_cast(phase_ / 60000); // Buffer layout is (z * h * w + y * w + x). For a 2D grid (d == 1) z // is always 0 and we sample 2D noise β€” no perf cost vs. the old 2D-only @@ -44,8 +51,16 @@ class NoiseEffect : public EffectBase { lengthType y = static_cast(rem / w); lengthType z = static_cast(i / wh); - uint8_t n = (d > 1) ? noise3d(x, y, z, t) : noise2d(x, y, t); - RGB c = hsvToRgb(n, 200, 255); + // Scale coords into noise space: a finer `scale` packs more cells across the grid, + // and the time offset scrolls each axis at a slightly different rate so the field + // flows rather than slides flat. inoise8's high byte selects the cell, low byte the + // position within it. + const uint32_t nx = (static_cast(x) * 256u + t) / scale; + const uint32_t ny = (static_cast(y) * 256u + t / 3u) / scale; + const uint8_t n = (d > 1) + ? inoise8(nx, ny, (static_cast(z) * 256u + t / 5u) / scale) + : inoise8(nx, ny); + RGB c = colorFromPalette(*Palettes::active(), n); size_t offset = static_cast(i) * cpl; if (cpl >= 1) buf[offset + 0] = c.r; @@ -57,91 +72,10 @@ class NoiseEffect : public EffectBase { private: uint64_t phase_ = 0; uint32_t lastElapsed_ = 0; - - // Hash function for value noise (uint32_t to avoid signed overflow UB) - static uint8_t hash(uint32_t x, uint32_t y, uint32_t t) { - uint32_t h = x * 1619u + y * 31337u + t * 6271u; - h = (h >> 13) ^ h; - h = h * (h * h * 60493u + 19990303u) + 1376312589u; - return static_cast((h >> 16) & 0xFF); - } - - // Smoothstep: 3t^2 - 2t^3, input/output in 0-255 range - static uint8_t smoothstep(uint8_t t) { - uint16_t t2 = static_cast(t) * t / 255; - uint16_t t3 = static_cast(t2) * t / 255; - return static_cast((3 * t2 - 2 * t3) & 0xFF); - } - - // Linear interpolation: a + (b-a) * t/255 - static uint8_t lerp8(uint8_t a, uint8_t b, uint8_t t) { - int16_t delta = static_cast(b) - static_cast(a); - return static_cast(static_cast(a) + delta * t / 255); - } - - // 2D value noise with bilinear interpolation - // Time scrolls the noise field smoothly (offset, not hash seed) - uint8_t noise2d(lengthType px, lengthType py, uint32_t timeOffset) const { - // Scale coordinates and add time as smooth scroll offset - int32_t sx = (static_cast(px) * 256 + static_cast(timeOffset)) / scale; - int32_t sy = (static_cast(py) * 256 + static_cast(timeOffset / 3)) / scale; - - // Integer cell coordinates - int32_t ix = sx >> 8; - int32_t iy = sy >> 8; - - // Fractional part (0-255) - uint8_t fx = smoothstep(static_cast(sx & 0xFF)); - uint8_t fy = smoothstep(static_cast(sy & 0xFF)); - - // Hash at four corners (time=0, motion comes from coordinate scrolling) - uint8_t v00 = hash(static_cast(ix), static_cast(iy), 0); - uint8_t v10 = hash(static_cast(ix + 1), static_cast(iy), 0); - uint8_t v01 = hash(static_cast(ix), static_cast(iy + 1), 0); - uint8_t v11 = hash(static_cast(ix + 1), static_cast(iy + 1), 0); - - // Bilinear interpolation - uint8_t top = lerp8(v00, v10, fx); - uint8_t bot = lerp8(v01, v11, fx); - return lerp8(top, bot, fy); - } - - // 3D value noise with trilinear interpolation over 8 cube corners. The - // hash's third argument doubles as the z axis (kept consistent with 2D β€” - // the 2D path passes 0 there). Time scrolls all three axes at slightly - // different rates so the field flows rather than slides flat. - uint8_t noise3d(lengthType px, lengthType py, lengthType pz, uint32_t timeOffset) const { - int32_t sx = (static_cast(px) * 256 + static_cast(timeOffset)) / scale; - int32_t sy = (static_cast(py) * 256 + static_cast(timeOffset / 3)) / scale; - int32_t sz = (static_cast(pz) * 256 + static_cast(timeOffset / 5)) / scale; - - int32_t ix = sx >> 8; - int32_t iy = sy >> 8; - int32_t iz = sz >> 8; - - uint8_t fx = smoothstep(static_cast(sx & 0xFF)); - uint8_t fy = smoothstep(static_cast(sy & 0xFF)); - uint8_t fz = smoothstep(static_cast(sz & 0xFF)); - - // 8 cube-corner hashes - uint8_t v000 = hash(static_cast(ix), static_cast(iy), static_cast(iz)); - uint8_t v100 = hash(static_cast(ix + 1), static_cast(iy), static_cast(iz)); - uint8_t v010 = hash(static_cast(ix), static_cast(iy + 1), static_cast(iz)); - uint8_t v110 = hash(static_cast(ix + 1), static_cast(iy + 1), static_cast(iz)); - uint8_t v001 = hash(static_cast(ix), static_cast(iy), static_cast(iz + 1)); - uint8_t v101 = hash(static_cast(ix + 1), static_cast(iy), static_cast(iz + 1)); - uint8_t v011 = hash(static_cast(ix), static_cast(iy + 1), static_cast(iz + 1)); - uint8_t v111 = hash(static_cast(ix + 1), static_cast(iy + 1), static_cast(iz + 1)); - - // Trilinear interpolation: lerp along x, then y (two faces), then z. - uint8_t z0top = lerp8(v000, v100, fx); - uint8_t z0bot = lerp8(v010, v110, fx); - uint8_t z0 = lerp8(z0top, z0bot, fy); - uint8_t z1top = lerp8(v001, v101, fx); - uint8_t z1bot = lerp8(v011, v111, fx); - uint8_t z1 = lerp8(z1top, z1bot, fy); - return lerp8(z0, z1, fz); - } + bool started_ = false; // first-tick guard: seed lastElapsed_ before the first delta + // The value-noise field itself (hash + smoothstep + bi/trilinear interp) is the shared + // inoise8 in core/noise.h β€” this effect just scales coordinates into it and colours the + // result through the palette. }; } // namespace mm diff --git a/src/light/effects/NoiseMeterEffect.h b/src/light/effects/NoiseMeterEffect.h new file mode 100644 index 00000000..f280bf66 --- /dev/null +++ b/src/light/effects/NoiseMeterEffect.h @@ -0,0 +1,107 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade +#include "core/math8.h" // beatsin8 +#include "core/noise.h" // inoise8 (2-arg 2D field) +#include "core/AudioModule.h" // AudioModule::latestFrame() +#include "core/AudioFrame.h" // AudioFrame::level + +namespace mm { + +// Noise Meter: a vertical VU column whose height tracks the overall sound level and whose colour is a +// scrolling 2D value-noise field, so a loud moment fills the panel from the bottom up with a drifting, +// organic gradient instead of a flat bar. Each frame the buffer fades a little (motion trail), the +// audio level (scaled by `width`) sets how many rows light up from the bottom, and for each lit row a +// noise sample β€” taken from a field that both scrolls (the aux0/aux1 phase accumulators) and is +// modulated by the live level β€” picks the palette colour. The colour depends only on the row (y), so +// the effect writes the x=0 column and Layer::extrude fans each row across every x and z β€” the meter +// reads as one wide block of light without the effect duplicating the broadcast itself (that is the +// framework's job; see architecture.md Β§ Dimensionality). +// +// Prior art: WLED's "Noisemeter" sound-reactive effect (Andrew Tuline / WLED-SR). The fadeRate/width +// knobs, the levelβ†’length mapping, the inoise8(rowΒ·level + aux0, aux1 + rowΒ·level) field sampling, and +// the bottom-up fill are reproduced here, written fresh on projectMM's EffectBase + the shared draw / +// palette / noise / beatsin8 primitives. Reads AudioModule::latestFrame(); silence β†’ level 0 β†’ +// maxLen 0 β†’ the panel fades to dark, safe on any target and grid size. +// Author: Andrew Tuline (WLED-SR) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +class NoiseMeterEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ™πŸ“Š"; } // WLED origin Β· audio + Dim dimensions() const override { return Dim::D1; } // writes the x=0 column; extrude fans x and z + + // Defaults match WLED's Noisemeter exactly. + uint8_t fadeRate = 240; // per-frame fade-to-black amount (motion trail), range 200..254 + uint8_t width = 128; // levelβ†’length gain: how much of the column a given level fills (0..255) + + void onBuildControls() override { + controls_.addUint8("fadeRate", fadeRate, 200, 254); + controls_.addUint8("width", width, 0, 255); + } + + void loop() override { + const int sizeX = width_(); + const int sizeY = height(); + const int sizeZ = depthDim(); + if (sizeX <= 0 || sizeY <= 0 || channelsPerLight() < 3) return; + + const AudioFrame* f = AudioModule::latestFrame(); + if (!f) return; // null-safe (latestFrame returns silence, never null, but guard regardless) + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(sizeX), static_cast(sizeY), + static_cast(sizeZ)}; + + layer()->fadeToBlackBy(fadeRate); + + // Level scaled by `width` into a 0..255 length proxy, then mapped onto the column height. + // tmpSound2 = level * 2 * width / 255. WLED uses volumeRaw here (the instantaneous, un-smoothed + // level) so the meter snaps to transients; projectMM's f->level IS that raw value (computeLevel + // recomputes it per block with no smoothing β€” see AudioFrame::level), so this is faithful. + const uint16_t level = f->level; + uint32_t tmpSound2 = (static_cast(level) * 2u * width) / 255u; + if (tmpSound2 > 255u) tmpSound2 = 255u; // tmpSound2 feeds map(0..255 β†’ 0..sizeY); cap the + // 0..255 byte WLED's map() takes (the maxLen + // constrain below lands on sizeY either way) + + // maxLen = map(tmpSound2, 0, 255, 0, sizeY); constrain 0..sizeY. + int maxLen = static_cast((tmpSound2 * static_cast(sizeY)) / 255u); + if (maxLen < 0) maxLen = 0; + if (maxLen > sizeY) maxLen = sizeY; + + for (int y = 0; y < maxLen; y++) { + // Scrolling, level-modulated 2D noise field. The two coordinates are 16.8 fixed (our + // inoise8 treats the high byte as the cell), exactly as WLED feeds inoise8: the row index + // times the live level walks one axis, the aux phase the other, so the colour drifts both + // with motion (aux) and with loudness (level). + const uint32_t coordA = static_cast(y) * level + aux0_; + const uint32_t coordB = aux1_ + static_cast(y) * level; + const uint8_t index = inoise8(coordA, coordB); + const RGB col = colorFromPalette(*Palettes::active(), index); + + // D1: write only the x=0 column; Layer::extrude fans this row across every x and z. + const lengthType drawY = static_cast(sizeY - 1 - y); + draw::pixel(buf, dims, {0, drawY, 0}, col); + } + + // Scroll the noise field each frame. aux0 and aux1 are advanced by two slow, slightly-detuned + // oscillators (bpm 5 and 4) so the noise field weaves rather than scrolling at a constant rate. + aux0_ += beatsin8(5, elapsed(), 0, 10); + aux1_ += beatsin8(4, elapsed(), 0, 10); + } + +private: + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + // The inherited EffectBase::width() (grid width) is shadowed by the `width` control member, so + // reach the grid width through a thin alias. + lengthType width_() const { return EffectBase::width(); } + + // Two scrolling-noise phase accumulators (WLED's SEGENV.aux0 / aux1). Scalar state, tiny β€” stays + // inline (the "no large inline members" rule targets per-light buffers sized to nrOfLights). + uint16_t aux0_ = 0; + uint16_t aux1_ = 0; +}; + +} // namespace mm diff --git a/src/light/effects/PaintBrushEffect.h b/src/light/effects/PaintBrushEffect.h new file mode 100644 index 00000000..bc783561 --- /dev/null +++ b/src/light/effects/PaintBrushEffect.h @@ -0,0 +1,151 @@ +#pragma once + +#include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette, Palettes::active +#include "light/draw.h" // draw::line, draw::fade +#include "core/math8.h" // beatsin8, map8, Random8 +#include "core/AudioModule.h" // AudioModule::latestFrame() +#include "core/AudioFrame.h" // AudioFrame, bands[] + +namespace mm { + +// Audio-reactive "paintbrush": a set of lines whose endpoints oscillate in 3D on the beat, each +// line shortened toward its first endpoint by an audio band's magnitude so the strokes curve and +// sweep. The field fades a little each frame so the moving lines leave brush strokes rather than +// redrawing cleanly. A line only draws when it's longer than `minLength`, so quiet bands stay dark. +// +// Prior art: MoonLight's PaintBrush (@TroyHacks, E_MoonModules, MoonModules). Behaviour reproduced +// exactly β€” the same six oscillating endpoints, the per-band Euclidean length fed back through the +// draw-line shorten parameter (this is what makes the strokes curve), the per-frame fade and the +// length gate β€” written fresh on projectMM's EffectBase + the shared primitives (beatsin8, map8, +// draw::line, draw::fade, the audio frame). Reads AudioModule::latestFrame(); silence β†’ no lines β†’ +// fades to dark, safe on any target and any grid size. The 'soft' anti-alias control is omitted +// (the one approved omission β€” draw::line is crisp, projectMM has no Xiaolin-Wu line yet). +// Author: @TroyHacks (WLED MoonModules, GPLv3) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h +class PaintBrushEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸŒ™πŸ“Š"; } // MoonLight origin Β· MoonModules Β· audio + Dim dimensions() const override { return Dim::D3; } + + uint8_t oscillatorOffset = 6 * 160 / 255; // = 3; phase-spread multiplier (0..16) + uint8_t numLines = 255; // parallel animated lines (2..255) + uint8_t fadeRate = 40; // background decay per frame (0..128) + uint8_t minLength = 0; // a line draws only if longer than this (slider 0..255) + bool color_chaos = false; // per-line hue variation vs a per-band gradient + bool phase_chaos = false; // random per-frame phase jitter + + void onBuildControls() override { + controls_.addUint8("oscillatorOffset", oscillatorOffset, 0, 16); + controls_.addUint8("numLines", numLines, 2, 255); + controls_.addUint8("fadeRate", fadeRate, 0, 128); + controls_.addUint8("minLength", minLength); // slider over the full 0..255 range + controls_.addBool("color_chaos", color_chaos); + controls_.addBool("phase_chaos", phase_chaos); + } + + void loop() override { + const lengthType cols = width(), rows = height(), depth = this->depth(); + const uint8_t cpl = channelsPerLight(); + if (cols == 0 || rows == 0 || cpl < 3) return; // 0Γ—0Γ—0 and short-channel guard + + Buffer& buf = layer()->buffer(); + + // Per-frame: advance the hue, then fade the whole field toward black (a decaying trail). + aux0Hue++; + layer()->fadeToBlackBy(fadeRate); + + // Optional per-frame phase jitter shared by every endpoint this frame. + aux1Chaos = phase_chaos ? rng_.next8() : 0; + + const Coord3D dims{cols, rows, depth}; + const AudioFrame* f = AudioModule::latestFrame(); + if (!f) return; // silence frame is non-null in practice; guard before dereferencing bands[] + const uint32_t ms = elapsed(); + // bass term added to every oscillator bpm (MoonLight's bands[0]/NUM_GEQ_CHANNELS). + const uint8_t base = static_cast(f->bands[0] / kBands); + + // The loop visits i in [0, numLines), so the map input-high is numLines-1 β€” otherwise the + // top band/hue (the value produced at i == numLines) is never reached (off-by-one). + const int lineHi = numLines - 1; + + for (size_t i = 0; i < numLines; i++) { + const uint8_t bin = static_cast( + map(static_cast(i), 0, lineHi, 0, kBands - 1)); + const uint8_t band = f->bands[bin]; + + // Six endpoints: each axis pair oscillates at a multiple of oscillatorOffset (+ a bass + // term), timebased on the band magnitude, phase-jittered by aux1Chaos. beatsin8's range + // args are uint8 (0..255), so the oscillator is generated full-range and then scaled to + // the grid extent with map() β€” passing (cols-1)/(rows-1)/(depth-1) directly would + // truncate to 8 bits and collapse the strokes into a corner on grids wider than 256. + const lengthType x1 = osc(static_cast(oscillatorOffset * 1 + base), ms, band, cols); + const lengthType x2 = osc(static_cast(oscillatorOffset * 2 + base), ms, band, cols); + const lengthType y1 = osc(static_cast(oscillatorOffset * 3 + base), ms, band, rows); + const lengthType y2 = osc(static_cast(oscillatorOffset * 4 + base), ms, band, rows); + + lengthType z1 = 0, z2 = 0; + int length; + if (depth > 1) { + z1 = osc(static_cast(oscillatorOffset * 5 + base), ms, band, depth); + z2 = osc(static_cast(oscillatorOffset * 6 + base), ms, band, depth); + length = isqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1) + (z2 - z1) * (z2 - z1)); + } else { + length = isqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)); + } + + // The Euclidean span scaled by the band magnitude becomes the shorten amount: louder + // bands draw a longer fraction of the line, so the strokes curve as the length pulses. + // shorten is a 0..255 fraction, so clamp before the uint8 cast β€” on a >256 grid the raw + // span can exceed 255 and would otherwise wrap. + length = map8(band, 0, static_cast(length > 255 ? 255 : length)); + + if (length > MAX(1, minLength)) { + const uint8_t index = color_chaos + ? static_cast(i * 255 / numLines + (aux0Hue & 0xFF)) + : static_cast(map(static_cast(i), 0, lineHi, 0, 255)); + const RGB color = colorFromPalette(*Palettes::active(), index, 255); + draw::line(buf, dims, {x1, y1, z1}, {x2, y2, z2}, color, static_cast(length)); + } + } + } + +private: + // The audio frame's frequency-band count (MoonLight's NUM_GEQ_CHANNELS), tied to the array so + // the band-index map and the bass term stay in sync with AudioFrame::bands if it ever changes. + static constexpr int kBands = static_cast(sizeof(AudioFrame::bands) / sizeof(AudioFrame::bands[0])); + + uint16_t aux0Hue = 0; // running hue, incremented each frame + uint8_t aux1Chaos = 0; // per-frame phase jitter (0 unless phase_chaos) + Random8 rng_{0xB17EB00Bu}; + + // One endpoint on an axis of extent `len`: an 8-bit sine (0..255) scaled to [0, len-1] so grids + // wider than 256 keep their full sweep (beatsin8's range params are only 8-bit). len==0 β†’ 0. + lengthType osc(uint8_t bpm, uint32_t ms, uint8_t timebase, lengthType len) const { + const uint8_t s = beatsin8(bpm, ms, 0, 255, timebase, aux1Chaos); + return static_cast(map(s, 0, 255, 0, len > 0 ? len - 1 : 0)); + } + + // FastLED-style MAX/map kept local so the loop reads like the MoonLight source. + static constexpr int MAX(int a, int b) { return a > b ? a : b; } + + // Standard integer map (outlo + (i-inlo)*(outhi-outlo)/(inhi-inlo)); guards a zero input span. + static constexpr int map(int i, int inlo, int inhi, int outlo, int outhi) { + return inhi == inlo ? outlo : outlo + (i - inlo) * (outhi - outlo) / (inhi - inlo); + } + + // Integer square root (binary digit-by-digit) β€” the true Euclidean length the source takes via + // float sqrt(), without floats in the hot path; dist8() is an octagonal approximation, not this. + static int isqrt(int n) { + if (n <= 0) return 0; + unsigned int x = static_cast(n), res = 0, bit = 1u << 30; + while (bit > x) bit >>= 2; + while (bit != 0) { + if (x >= res + bit) { x -= res + bit; res = (res >> 1) + bit; } + else res >>= 1; + bit >>= 2; + } + return static_cast(res); + } +}; + +} // namespace mm diff --git a/src/light/effects/ParticlesEffect.h b/src/light/effects/ParticlesEffect.h index 1998cbb4..99405660 100644 --- a/src/light/effects/ParticlesEffect.h +++ b/src/light/effects/ParticlesEffect.h @@ -2,12 +2,14 @@ #include "light/layers/Layer.h" #include "core/color.h" +#include "core/math8.h" // mm::Random8 β€” the shared per-effect PRNG (particle spawn) #include "platform/platform.h" #include namespace mm { +// Author: WildCats08 / @Brandon502 (MoonLight) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h class ParticlesEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step @@ -117,12 +119,8 @@ class ParticlesEffect : public EffectBase { bool initialized_ = false; uint8_t* trail_ = nullptr; size_t trailBytes_ = 0; - uint32_t rngState_ = 0xBADF00Du; - - uint8_t rand8() { - rngState_ = rngState_ * 1103515245u + 12345u; - return static_cast((rngState_ >> 16) & 0xFF); - } + Random8 rng_{0xBADF00Du}; // the shared PRNG; rand8() adapts it to the call shape below + uint8_t rand8() { return rng_.next8(); } void initParticles() { lengthType w = width(); diff --git a/src/light/effects/PlasmaEffect.h b/src/light/effects/PlasmaEffect.h index 891ad72e..8ff89a32 100644 --- a/src/light/effects/PlasmaEffect.h +++ b/src/light/effects/PlasmaEffect.h @@ -1,18 +1,24 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette + active palette #include "core/color.h" +#include "core/math8.h" // sin8/cos8/dist8/atan2_8 namespace mm { +// Author: classic plasma, FastLED / WLED lineage class PlasmaEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step Dim dimensions() const override { return Dim::D3; } uint8_t bpm = 30; - uint8_t scale_x = 16; - uint8_t scale_y = 16; + // Larger scale = smaller per-pixel step (256/scale) = lower spatial frequency = bigger, calmer + // rolling blobs. The default is high so plasma reads as large blobs, not fine noise; lower it + // in the UI for a busier field. + uint8_t scale_x = 48; + uint8_t scale_y = 48; uint8_t hue_shift = 0; void onBuildControls() override { @@ -32,16 +38,21 @@ class PlasmaEffect : public EffectBase { uint32_t now = elapsed(); uint32_t dt = now - lastElapsed_; lastElapsed_ = now; - phase_ += static_cast(dt) * bpm * static_cast(w) * 64 / 60000; + // Phase advances purely with time and bpm β€” NOT with grid width, so the speed is the same on + // every fixture. phase_ accumulates the RAW numerator (dtΒ·bpmΒ·256) and the /60000 divide runs + // only at the sampling point below β€” dividing per tick would truncate the sub-unit progress to + // zero on a fast board (short dt), stalling the animation. 256 phase units = one beat's wrap. + phase_ += static_cast(dt) * bpm * 256; + const uint32_t phase = static_cast(phase_ / 60000); uint8_t step_x = static_cast(256 / scale_x); uint8_t step_y = static_cast(256 / scale_y); // z reuses scale_y for spatial frequency β€” keeps the control surface // simple while still varying the field along the third axis. uint8_t step_z = step_y; - uint8_t t1 = static_cast(phase_); - uint8_t t2 = static_cast(phase_ * 2); - uint8_t t3 = static_cast(phase_ * 3); + uint8_t t1 = static_cast(phase); + uint8_t t2 = static_cast(phase * 2); + uint8_t t3 = static_cast(phase * 3); // For d == 1 take the original 4-sine path unchanged β€” bit-for-bit // identical to the previous 2D-only output. For d > 1 add a 5th sine @@ -73,7 +84,7 @@ class PlasmaEffect : public EffectBase { ? static_cast( (static_cast(s1 + s2_y + s3 + s4 + s5_z) / 5) + hue_shift) : static_cast(((s1 + s2_y + s3 + s4) >> 2) + hue_shift); - RGB c = hsvToRgb(hue, 255, 255); + RGB c = colorFromPalette(*Palettes::active(), hue); if (cpl >= 1) row[0] = c.r; if (cpl >= 2) row[1] = c.g; diff --git a/src/light/effects/PlasmaPaletteEffect.h b/src/light/effects/PlasmaPaletteEffect.h deleted file mode 100644 index 167b12d6..00000000 --- a/src/light/effects/PlasmaPaletteEffect.h +++ /dev/null @@ -1,105 +0,0 @@ -#pragma once - -#include "light/layers/Layer.h" -#include "core/color.h" - -namespace mm { - -class PlasmaPaletteEffect : public EffectBase { -public: - const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step - // Iterates y and x only; Layer::extrude fills z on 3D layers. - Dim dimensions() const override { return Dim::D2; } - - uint8_t bpm = 30; - uint8_t scale_x = 16; - uint8_t scale_y = 16; - - void onBuildControls() override { - controls_.addUint8("bpm", bpm, 1, 255); - controls_.addUint8("scale_x", scale_x, 1, 255); - controls_.addUint8("scale_y", scale_y, 1, 255); - } - - void loop() override { - uint8_t* buf = buffer(); - lengthType w = width(); - lengthType h = height(); - uint8_t cpl = channelsPerLight(); - - uint32_t now = elapsed(); - uint32_t dt = now - lastElapsed_; - lastElapsed_ = now; - phase_ += static_cast(dt) * bpm * static_cast(w) * 64 / 60000; - - uint8_t step_x = static_cast(256 / scale_x); - uint8_t step_y = static_cast(256 / scale_y); - uint8_t t1 = static_cast(phase_); - uint8_t t2 = static_cast(phase_ * 2); - uint8_t t3 = static_cast(phase_ * 3); - - for (lengthType y = 0; y < h; y++) { - uint8_t s2_y = sin8(static_cast(static_cast(y) * step_y + t2)); - uint8_t yx_off = static_cast(static_cast(y) * step_x - t3); - uint8_t yx_neg = static_cast(128 - static_cast(y) * step_y + t1); - - uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; - for (lengthType x = 0; x < w; x++) { - uint8_t xs = static_cast(static_cast(x) * step_x); - uint8_t s1 = sin8(static_cast(xs + t1)); - uint8_t s3 = sin8(static_cast(xs + yx_off)); - uint8_t s4 = sin8(static_cast( - static_cast(static_cast(x) * step_y) + yx_neg)); - uint8_t idx = static_cast(((s1 + s2_y + s3 + s4) >> 2)); - const RGB& c = palette_[idx]; - - if (cpl >= 1) row[0] = c.r; - if (cpl >= 2) row[1] = c.g; - if (cpl >= 3) row[2] = c.b; - row += cpl; - } - } - } - -private: - uint64_t phase_ = 0; - uint32_t lastElapsed_ = 0; - - // Fire-ocean gradient palette (256 RGB entries in flash) - static constexpr RGB palette_[256] = { - {0,0,0}, {4,0,2}, {8,0,4}, {12,0,6}, {16,0,8}, {20,0,10}, {24,0,12}, {28,0,14}, - {32,0,16}, {36,0,18}, {40,0,20}, {44,0,22}, {48,0,24}, {52,0,26}, {56,0,28}, {60,0,30}, - {64,0,32}, {68,0,34}, {72,0,36}, {76,0,38}, {80,0,40}, {84,0,42}, {88,0,44}, {92,0,46}, - {96,0,48}, {100,0,50}, {104,0,52}, {108,0,54}, {112,0,56}, {116,0,58}, {120,0,60}, {124,0,62}, - {128,0,64}, {132,0,66}, {136,0,68}, {140,0,70}, {144,0,72}, {148,0,74}, {152,0,76}, {156,0,78}, - {160,0,80}, {164,0,82}, {168,0,84}, {172,0,86}, {176,0,88}, {180,0,90}, {184,0,92}, {188,0,94}, - {192,0,96}, {196,0,98}, {200,0,100}, {204,0,102}, {208,0,104}, {212,0,106}, {216,0,108}, {220,0,110}, - {224,0,112}, {228,0,114}, {232,0,116}, {236,0,118}, {240,0,120}, {244,0,122}, {248,0,124}, {252,0,126}, - {255,0,0}, {255,4,0}, {255,8,0}, {255,12,0}, {255,16,0}, {255,20,0}, {255,24,0}, {255,28,0}, - {255,32,0}, {255,36,0}, {255,40,0}, {255,44,0}, {255,48,0}, {255,52,0}, {255,56,0}, {255,60,0}, - {255,64,0}, {255,68,0}, {255,72,0}, {255,76,0}, {255,80,0}, {255,84,0}, {255,88,0}, {255,92,0}, - {255,96,0}, {255,100,0}, {255,104,0}, {255,108,0}, {255,112,0}, {255,116,0}, {255,120,0}, {255,124,0}, - {255,128,0}, {255,132,0}, {255,136,0}, {255,140,0}, {255,144,0}, {255,148,0}, {255,152,0}, {255,156,0}, - {255,160,0}, {255,164,0}, {255,168,0}, {255,172,0}, {255,176,0}, {255,180,0}, {255,184,0}, {255,188,0}, - {255,192,0}, {255,196,0}, {255,200,0}, {255,204,0}, {255,208,0}, {255,212,0}, {255,216,0}, {255,220,0}, - {255,224,0}, {255,228,0}, {255,232,0}, {255,236,0}, {255,240,0}, {255,244,0}, {255,248,0}, {255,252,0}, - {255,255,0}, {253,255,2}, {251,255,4}, {249,255,6}, {247,255,8}, {245,255,10}, {243,255,12}, {241,255,14}, - {239,255,16}, {237,255,18}, {235,255,20}, {233,255,22}, {231,255,24}, {229,255,26}, {227,255,28}, {225,255,30}, - {223,255,32}, {221,255,34}, {219,255,36}, {217,255,38}, {215,255,40}, {213,255,42}, {211,255,44}, {209,255,46}, - {207,255,48}, {205,255,50}, {203,255,52}, {201,255,54}, {199,255,56}, {197,255,58}, {195,255,60}, {193,255,62}, - {191,255,64}, {189,255,66}, {187,255,68}, {185,255,70}, {183,255,72}, {181,255,74}, {179,255,76}, {177,255,78}, - {175,255,80}, {173,255,82}, {171,255,84}, {169,255,86}, {167,255,88}, {165,255,90}, {163,255,92}, {161,255,94}, - {159,255,96}, {157,255,98}, {155,255,100}, {153,255,102}, {151,255,104}, {149,255,106}, {147,255,108}, {145,255,110}, - {143,255,112}, {141,255,114}, {139,255,116}, {137,255,118}, {135,255,120}, {133,255,122}, {131,255,124}, {129,255,126}, - {0,255,255}, {0,253,255}, {0,251,255}, {0,249,255}, {0,247,255}, {0,245,255}, {0,243,255}, {0,241,255}, - {0,239,255}, {0,237,255}, {0,235,255}, {0,233,255}, {0,231,255}, {0,229,255}, {0,227,255}, {0,225,255}, - {0,223,255}, {0,221,255}, {0,219,255}, {0,217,255}, {0,215,255}, {0,213,255}, {0,211,255}, {0,209,255}, - {0,207,255}, {0,205,255}, {0,203,255}, {0,201,255}, {0,199,255}, {0,197,255}, {0,195,255}, {0,193,255}, - {0,191,255}, {0,189,255}, {0,187,255}, {0,185,255}, {0,183,255}, {0,181,255}, {0,179,255}, {0,177,255}, - {0,175,255}, {0,173,255}, {0,171,255}, {0,169,255}, {0,167,255}, {0,165,255}, {0,163,255}, {0,161,255}, - {0,159,255}, {0,157,255}, {0,155,255}, {0,153,255}, {0,151,255}, {0,149,255}, {0,147,255}, {0,145,255}, - {0,143,255}, {0,141,255}, {0,139,255}, {0,137,255}, {0,135,255}, {0,133,255}, {0,131,255}, {0,129,255} - }; -}; - -} // namespace mm diff --git a/src/light/effects/PraxisEffect.h b/src/light/effects/PraxisEffect.h new file mode 100644 index 00000000..a21c9fe0 --- /dev/null +++ b/src/light/effects/PraxisEffect.h @@ -0,0 +1,103 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel +#include "core/math8.h" // beatsin16 + +#include + +namespace mm { + +// Praxis: a flowing, palette-coloured field whose hue at each pixel is driven by two +// independently-oscillating "mutators". A slow macro mutator and a faster micro mutator +// (each a beatsin16 sweeping a tight high range) combine with the pixel's (x, y) position +// and a steadily-advancing hue base, so the colour pattern continually stretches, shears, +// and rolls across the grid. The micro mutator divides the spatial term (so it sets the +// pattern's spatial "frequency"), while the macro mutator multiplies the yΒ·x cross term +// (so it warps the field), and huebase = elapsed/40 scrolls the whole thing through the +// palette over time. +// +// Prior art: MoonLight's Praxis effect (E_MoonModules / MoonModules). The two-mutator +// oscillator model (macro = beatsin16 in [min<<8, max<<8], micro = beatsin16 in [min, max]) +// and the per-pixel hue = huebase + (x + yΒ·macroΒ·x)/(micro+1) are reproduced exactly here, +// written fresh on EffectBase + the shared draw / palette / math8 primitives. Our beatsin16 +// takes the current time (elapsed()) as its second argument, matching the lib8tion shape +// (bpm, timebase) with the time source threaded in at the domain edge. +// Author: MONSOONO / @Flavourdynamics (MoonLight) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class PraxisEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«"; } // MoonLight origin + Dim dimensions() const override { return Dim::D2; } // writes only the z=0 slice; extrude fills depth + + // Hue-scroll speed lever (projectMM addition β€” MoonLight has no speed control on Praxis; its + // scroll rate is fixed at elapsed/40). speed scales the temporal hue scroll only, leaving the + // MoonLight mutator defaults untouched. Default 8 reproduces MoonLight's elapsed/40 rate + // (huebase = now*speed/320 = now/40 at speed 8); lower it for a calmer drift, raise it for faster. + uint8_t speed = 4; // 1..64; 8 == MoonLight's fixed rate, default 4 = half (calmer) + + // Controls β€” MoonLight's exact defaults and ranges. + uint8_t macroMutatorFreq = 3; // macro mutator beat frequency (0..15) + uint8_t macroMutatorMin = 250; // macro mutator low end (0..255), scaled <<8 into the 16-bit sweep + uint8_t macroMutatorMax = 255; // macro mutator high end (0..255), scaled <<8 + uint8_t microMutatorFreq = 4; // micro mutator beat frequency (0..15) + uint8_t microMutatorMin = 200; // micro mutator low end (0..255) + uint8_t microMutatorMax = 255; // micro mutator high end (0..255) + + void onBuildControls() override { + controls_.addUint8("speed", speed, 1, 64); + controls_.addUint8("macroMutatorFreq", macroMutatorFreq, 0, 15); + controls_.addUint8("macroMutatorMin", macroMutatorMin, 0, 255); + controls_.addUint8("macroMutatorMax", macroMutatorMax, 0, 255); + controls_.addUint8("microMutatorFreq", microMutatorFreq, 0, 15); + controls_.addUint8("microMutatorMin", microMutatorMin, 0, 255); + controls_.addUint8("microMutatorMax", microMutatorMax, 0, 255); + } + + void loop() override { + const int w = width(); + const int h = height(); + if (w <= 0 || h <= 0 || channelsPerLight() < 3) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(w), static_cast(h), depthDim()}; + + const uint32_t now = elapsed(); + + // The two oscillating mutators. macro sweeps the high 16-bit range (min<<8 .. max<<8); + // micro sweeps the raw 0..255 range. beatsin16(bpm, ms, low, high) β€” our time helper takes + // elapsed() as the 2nd argument. + const uint16_t macro = beatsin16(macroMutatorFreq, now, + static_cast(macroMutatorMin << 8), + static_cast(macroMutatorMax << 8)); + const uint16_t micro = beatsin16(microMutatorFreq, now, + microMutatorMin, microMutatorMax); + + // 64-bit before the multiply: now (millis) Β· speed overflows uint32 after a few hours' uptime, + // which would make the hue jump. speed 8 == MoonLight's now/40; lower = calmer. + const uint32_t huebase = static_cast(static_cast(now) * speed / 320); + const int64_t microDiv = static_cast(micro) + 1; // micro+1, guards a divide-by-zero + + // hue = huebase + (x + yΒ·macroΒ·x) / (micro+1), truncated to the 0..255 palette wheel index. + // The yΒ·macroΒ·x cross term can reach ~grid Β· 65280 Β· grid, so the accumulation runs in 64-bit + // before the divide and the implicit wrap to a uint8 palette index (MoonLight uses 32-bit int; + // identical up to ~128Β² grids, where the product still fits β€” fidelity-preserving on real grids, + // overflow-safe on extreme ones). + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + const int64_t spatial = static_cast(x) + + static_cast(y) * static_cast(macro) * static_cast(x); + const uint32_t hue = huebase + static_cast(spatial / microDiv); + const RGB c = colorFromPalette(*Palettes::active(), static_cast(hue), 255); + draw::pixel(buf, dims, {static_cast(x), static_cast(y), 0}, c); + } + } + } + +private: + // depth() is the grid's z extent; guard a zero so dims stays valid on a 2D layer. + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } +}; + +} // namespace mm diff --git a/src/light/effects/RainbowEffect.h b/src/light/effects/RainbowEffect.h index a1f49f5a..6aab6de4 100644 --- a/src/light/effects/RainbowEffect.h +++ b/src/light/effects/RainbowEffect.h @@ -1,10 +1,12 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette + the global active palette #include "core/color.h" namespace mm { +// Author: FastLED rainbow (Mark Kriegsman) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_FastLED.h class RainbowEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«"; } // MoonLight origin @@ -12,7 +14,7 @@ class RainbowEffect : public EffectBase { // 3D layouts. Opt-in to that promise so the framework doesn't iterate z. Dim dimensions() const override { return Dim::D2; } - uint8_t speed = 60; // BPM + uint8_t speed = 20; // BPM β€” one full hue cycle every 3 s; 60 (a whole rainbow per second) reads too fast void onBuildControls() override { controls_.addUint8("speed", speed, 1, 255); @@ -38,7 +40,7 @@ class RainbowEffect : public EffectBase { (static_cast(x + y) * 256 / (w + h)) + phase ); - RGB c = hsvToRgb(hue, 255, 255); + RGB c = colorFromPalette(*Palettes::active(), hue); size_t offset = (static_cast(y) * w + x) * cpl; if (cpl >= 1) buf[offset + 0] = c.r; if (cpl >= 2) buf[offset + 1] = c.g; diff --git a/src/light/effects/RandomEffect.h b/src/light/effects/RandomEffect.h new file mode 100644 index 00000000..c871de34 --- /dev/null +++ b/src/light/effects/RandomEffect.h @@ -0,0 +1,65 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer(), nrOfLights() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::fade (whole-buffer fadeToBlackBy) +#include "core/math8.h" // Random8 + +namespace mm { + +// Random: each frame the whole buffer is dimmed a little, then exactly ONE randomly chosen +// light is lit to a random palette colour. Over many frames this scatters fading sparkles of +// colour across the whole volume β€” a slow, twinkling field whose density is set by the fade +// amount (less fade = pixels linger and the field fills; more fade = sparse, quick-decaying +// specks). +// +// Prior art: MoonLight's Random effect (E_MoonModules / MoonModules). The behaviour is +// reproduced exactly β€” one fadeToBlackBy(fade) plus one setRGB(random index, palette[random]) +// per frame β€” written fresh on EffectBase + the shared draw/Palette primitives. The light is +// chosen by a flat light index across all nrOfLights (the engine's native ordering, the direct +// equivalent of MoonLight's index-based setRGB), so it can land anywhere in a 1D/2D/3D layer. +// Author: MoonLight β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class RandomEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«"; } // MoonLight origin + // D3: the single lit light is picked by flat index over the entire volume, so this effect + // writes into any z slice β€” it iterates (addresses) every axis the layer has. + Dim dimensions() const override { return Dim::D3; } + + uint8_t fade = 70; // per-frame fadeToBlackBy amount (0..255) + + void onBuildControls() override { + controls_.addUint8("fade", fade, 0, 255); + } + + void loop() override { + Buffer& buf = layer()->buffer(); + const nrOfLightsType n = nrOfLights(); + const uint8_t cpl = buf.channelsPerLight(); + if (n == 0 || cpl < 1) return; + + // Dim the whole buffer (source: layer->fadeToBlackBy(fade)). + layer()->fadeToBlackBy(fade); + + // Light one random light to a random palette colour (source: + // setRGB(random16(nrOfLights), ColorFromPalette(pal, random8()))). The index is a flat + // light index β€” the engine's native light ordering β€” so the write goes straight into the + // buffer at that light, the direct equivalent of MoonLight's index-based setRGB. (There is + // no flat-index draw primitive; draw::pixel takes a coordinate, hence the byte write here.) + const nrOfLightsType idx = static_cast(rng_.next16() % n); + const RGB c = colorFromPalette(*Palettes::active(), rng_.next8()); + + const size_t off = static_cast(idx) * cpl; + if (off + (cpl < 3 ? cpl : 3) > buf.bytes()) return; + uint8_t* d = buf.data(); + d[off + 0] = c.r; + if (cpl >= 2) d[off + 1] = c.g; + if (cpl >= 3) d[off + 2] = c.b; + } + +private: + Random8 rng_; // per-effect PRNG (deterministic, independent sequence) +}; + +} // namespace mm \ No newline at end of file diff --git a/src/light/effects/RingsEffect.h b/src/light/effects/RingsEffect.h index 4ed648f9..9df1e7ee 100644 --- a/src/light/effects/RingsEffect.h +++ b/src/light/effects/RingsEffect.h @@ -1,7 +1,9 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette + active palette #include "core/color.h" +#include "core/math8.h" // sin8/cos8/dist8/atan2_8 namespace mm { @@ -10,6 +12,7 @@ namespace mm { // position once it leaves the visible area. Multiple rings overlap. // (Renamed from RipplesEffect: the Ripples name now holds the MoonLight // sine-wave water-surface port; this concentric-rings effect is Rings.) +// Author: projectMM original (concentric rings) class RingsEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step @@ -18,8 +21,10 @@ class RingsEffect : public EffectBase { static constexpr uint8_t MAX_RIPPLES = 8; - uint8_t count = 4; - uint8_t speed = 60; + // Calm defaults: a couple of slow rings read as clean expanding circles; more/faster reads as + // chaos. Raise count/speed in the UI for a busier field. + uint8_t count = 2; + uint8_t speed = 30; uint8_t thickness = 3; uint8_t hue_shift = 0; @@ -81,7 +86,7 @@ class RingsEffect : public EffectBase { // Older ripples (large radius) fade out. uint8_t age_fade = static_cast(255 - ((radius_[i] * 255u) / maxR)); uint8_t intensity = scale8(falloff, age_fade); - RGB c = hsvToRgb(static_cast(hue_[i] + hue_shift), 240, intensity); + RGB c = colorFromPalette(*Palettes::active(), static_cast(hue_[i] + hue_shift), intensity); r_acc = static_cast(r_acc + c.r); g_acc = static_cast(g_acc + c.g); b_acc = static_cast(b_acc + c.b); @@ -102,12 +107,8 @@ class RingsEffect : public EffectBase { uint8_t hue_[MAX_RIPPLES] = {}; bool initialized_ = false; uint32_t lastElapsed_ = 0; - uint32_t rngState_ = 0xC0DECAFEu; - - uint8_t rand8() { - rngState_ = rngState_ * 1103515245u + 12345u; - return static_cast((rngState_ >> 16) & 0xFF); - } + Random8 rng_{0xC0DECAFEu}; // the shared PRNG; rand8() adapts it to the call shape below + uint8_t rand8() { return rng_.next8(); } void spawn(uint8_t i, lengthType w, lengthType h) { cx_[i] = static_cast((static_cast(rand8()) * w) >> 8); diff --git a/src/light/effects/RipplesEffect.h b/src/light/effects/RipplesEffect.h index e1b26f35..86daa11f 100644 --- a/src/light/effects/RipplesEffect.h +++ b/src/light/effects/RipplesEffect.h @@ -22,6 +22,7 @@ namespace mm { // Float trig in the loop matches the existing wave effects (Plasma, LavaLamp); // the hot-path integer-math preference is for per-light colour work, not the // handful of transcendental ops a wave front needs. +// Author: MoonLight β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h class RipplesEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«πŸŸ¦πŸ¦…"; } // MoonLight origin Β· water-ripple diff --git a/src/light/effects/RubiksCubeEffect.h b/src/light/effects/RubiksCubeEffect.h new file mode 100644 index 00000000..0ebfeaf0 --- /dev/null +++ b/src/light/effects/RubiksCubeEffect.h @@ -0,0 +1,313 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/draw.h" // draw::pixel +#include "core/math8.h" // Random8 + +#include +#include +#include // std::strcmp + +namespace mm { + +// A solved 3D Rubik's cube that scrambles itself, then plays the scramble back in reverse so the +// cube visibly un-mixes turn by turn, and re-scrambles once solved. The cube is a full 6-face model +// (up to 8Γ—8 stickers per face) with the real face/row/column rotations; each frame it is drawn onto +// the LED volume by classifying every in-bounds voxel as belonging to whichever of the six outer +// faces it sits nearest, and colouring it from that face's sticker. Turns play at `turnsPerSecond`; +// `cubeSize` is the order of the cube (2..8 are real cubes, 1 is a degenerate single block); with +// `randomTurning` the cube tumbles through endless random moves instead of solving a stored scramble. +// +// Prior art: MoonLight's RubiksCube effect (E_MoonModules / MoonModules). The cube model +// (init/rotateFace/rotateRow/rotateColumn/rotateFaceLayer and the six face rotations), the packed +// move list + scramble/playback, and drawCube's nearest-face projection with the +// {Red, DarkOrange, Blue, Green, Yellow, White} colour map are reproduced exactly here, written +// fresh on EffectBase + the shared draw primitive. projectMM has no per-cell mapping mask, so every +// in-bounds voxel is treated as mapped (the source's isMapped()-skip and the mapping-driven +// sizeX++/sizeY++/sizeZ++ adjustments are dropped; the projection uses sizeX = max(size.x-1, 1)). +// Author: WildCats08 / @Brandon502 (MoonLight) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class RubiksCubeEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸ§Š"; } // MoonLight origin Β· 3D-native + Dim dimensions() const override { return Dim::D3; } + + // Defaults match MoonLight's RubiksCube exactly. + uint8_t turnsPerSecond = 2; // 0..20 + uint8_t cubeSize = 3; // 1..8 (cube order) + bool randomTurning = false; + + void onBuildControls() override { + controls_.addUint8("turnsPerSecond", turnsPerSecond, 0, 20); + controls_.addUint8("cubeSize", cubeSize, 1, 8); + controls_.addBool("randomTurning", randomTurning); + } + + // cubeSize / randomTurning changes re-scramble (MoonLight reinitialises on those controls). No + // heap state to rebuild, so the cheap onUpdate hook is enough β€” flag a fresh init for the next loop. + void onUpdate(const char* name) override { + if (std::strcmp(name, "cubeSize") == 0 || std::strcmp(name, "randomTurning") == 0) + doInit_ = true; + } + + void loop() override { + const lengthType w = width(), h = height(), d = depth(); + if (w <= 0 || h <= 0 || d <= 0 || channelsPerLight() < 3) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{w, h, d}; + + const uint32_t now = elapsed(); + + // (Re-)scramble on a requested re-scramble (doInit_ β€” set on first run and on control change, + // once past the initial delay), OR when `step_` is stuck unreasonably far in the future. + // MoonLight writes the second check as `step - 3100 > now`, but with unsigned `step_` that + // UNDERFLOWS whenever step_ < 3100 (the first ~3 s of uptime, and after every turn where step_ + // is set to `now`), so it fires init EVERY frame β€” the cube re-scrambles each tick and the + // display FLASHES instead of turning one slice at a time. The fix is a wrap-safe SIGNED + // difference: re-init only when step_ is genuinely more than 3100 ms ahead of now. + const int32_t ahead = static_cast(step_ - now); // how far step_ is in the future (signed) + if ((doInit_ && now > step_) || ahead > 3100) { + step_ = now + 1000; + doInit_ = false; + init(buf, dims, w, h, d); + } + + // Turn pacing: nothing to do until 1000/turnsPerSecond ms have passed since the last turn. + if (!turnsPerSecond || now - step_ < 1000u / turnsPerSecond || now < step_) return; + + const Move move = randomTurning ? createRandomMoveStruct(cubeSize, prevFaceMoved_) + : unpackMove(moveList_[moveIndex_]); + // Playback applies the inverse direction so the scramble unwinds toward solved. + (cube_.*kRotateFuncs[move.face])(!move.direction, static_cast(move.width + 1)); + cube_.drawCube(buf, dims, w, h, d); + + if (!randomTurning && moveIndex_ == 0) { + step_ = now + 3000; // solved: hold for 3 s, then re-scramble + doInit_ = true; + return; + } + if (!randomTurning) moveIndex_--; + step_ = now; + } + +private: + // --- The cube model ------------------------------------------------------------------------- + struct Cube { + uint8_t SIZE = 3; + static const uint8_t MAX_SIZE = 8; + using Face = std::array, MAX_SIZE>; + Face front, back, left, right, top, bottom; + + void init(uint8_t cubeSize) { + SIZE = cubeSize; + for (int i = 0; i < MAX_SIZE; i++) + for (int j = 0; j < MAX_SIZE; j++) { + front[i][j] = 0; back[i][j] = 1; left[i][j] = 2; + right[i][j] = 3; top[i][j] = 4; bottom[i][j] = 5; + } + } + + void rotateFace(Face& face, bool clockwise) { + Face temp = face; + if (clockwise) + for (int i = 0; i < SIZE; i++) for (int j = 0; j < SIZE; j++) face[j][SIZE - 1 - i] = temp[i][j]; + else + for (int i = 0; i < SIZE; i++) for (int j = 0; j < SIZE; j++) face[SIZE - 1 - j][i] = temp[i][j]; + } + + void rotateRow(int startRow, int stopRow, bool clockwise) { + std::array temp; + for (int row = startRow; row <= stopRow; row++) { + if (clockwise) + for (int i = 0; i < SIZE; i++) { + temp[i] = left[row][i]; + left[row][i] = front[row][i]; front[row][i] = right[row][i]; + right[row][i] = back[row][i]; back[row][i] = temp[i]; + } + else + for (int i = 0; i < SIZE; i++) { + temp[i] = left[row][i]; + left[row][i] = back[row][i]; back[row][i] = right[row][i]; + right[row][i] = front[row][i]; front[row][i] = temp[i]; + } + } + } + + void rotateColumn(int startCol, int stopCol, bool clockwise) { + std::array temp; + for (int col = startCol; col <= stopCol; col++) { + if (clockwise) + for (int i = 0; i < SIZE; i++) { + temp[i] = top[i][col]; + top[i][col] = front[i][col]; front[i][col] = bottom[i][col]; + bottom[i][col] = back[SIZE - 1 - i][SIZE - 1 - col]; back[SIZE - 1 - i][SIZE - 1 - col] = temp[i]; + } + else + for (int i = 0; i < SIZE; i++) { + temp[i] = top[i][col]; + top[i][col] = back[SIZE - 1 - i][SIZE - 1 - col]; back[SIZE - 1 - i][SIZE - 1 - col] = bottom[i][col]; + bottom[i][col] = front[i][col]; front[i][col] = temp[i]; + } + } + } + + void rotateFaceLayer(bool clockwise, int startLayer, int endLayer) { + for (int layer = startLayer; layer <= endLayer; layer++) { + std::array temp; + for (int i = 0; i < SIZE; i++) temp[i] = clockwise ? top[SIZE - 1 - layer][i] : bottom[layer][i]; + for (int i = 0; i < SIZE; i++) { + if (clockwise) { + top[SIZE - 1 - layer][i] = left[SIZE - 1 - i][SIZE - 1 - layer]; + left[SIZE - 1 - i][SIZE - 1 - layer] = bottom[layer][SIZE - 1 - i]; + bottom[layer][SIZE - 1 - i] = right[i][layer]; + right[i][layer] = temp[i]; + } else { + bottom[layer][SIZE - 1 - i] = left[SIZE - 1 - i][SIZE - 1 - layer]; + left[SIZE - 1 - i][SIZE - 1 - layer] = top[SIZE - 1 - layer][i]; + top[SIZE - 1 - layer][i] = right[i][layer]; + right[i][layer] = temp[SIZE - 1 - i]; + } + } + } + } + + void rotateFront(bool clockwise, uint8_t width) { + rotateFaceLayer(clockwise, 0, width - 1); + rotateFace(front, clockwise); + if (width >= SIZE) rotateFace(back, !clockwise); + } + void rotateBack(bool clockwise, uint8_t width) { + rotateFaceLayer(!clockwise, SIZE - width, SIZE - 1); + rotateFace(back, clockwise); + if (width >= SIZE) rotateFace(front, !clockwise); + } + void rotateLeft(bool clockwise, uint8_t width) { + rotateFace(left, clockwise); + rotateColumn(0, width - 1, !clockwise); + if (width >= SIZE) rotateFace(right, !clockwise); + } + void rotateRight(bool clockwise, uint8_t width) { + rotateFace(right, clockwise); + rotateColumn(SIZE - width, SIZE - 1, clockwise); + if (width >= SIZE) rotateFace(left, !clockwise); + } + void rotateTop(bool clockwise, uint8_t width) { + rotateFace(top, clockwise); + rotateRow(0, width - 1, clockwise); + if (width >= SIZE) rotateFace(bottom, !clockwise); + } + void rotateBottom(bool clockwise, uint8_t width) { + rotateFace(bottom, clockwise); + rotateRow(SIZE - width, SIZE - 1, !clockwise); + if (width >= SIZE) rotateFace(top, !clockwise); + } + + // Project the cube onto the LED volume: every in-bounds voxel is coloured by the outer face + // it sits nearest. (MoonLight's drawCube, with the isMapped()-skip and sizeX++/etc dropped.) + void drawCube(Buffer& buf, Coord3D dims, lengthType sx, lengthType sy, lengthType sz) const { + // This effect owns its background: drawCube writes only the SURFACE voxels (the loop has + // no else for the interior), and a turn moves stickers to new positions, so without a + // wipe the old pose's stickers linger and the cube accretes garbage β€” it never settles. + // One fill per draw (drawCube runs once per turn, not per frame), the sparse-effect idiom. + draw::fill(buf, {0, 0, 0}); + const int sizeX = MAXi(sx - 1, 1), sizeY = MAXi(sy - 1, 1), sizeZ = MAXi(sz - 1, 1); + // Integer form of round(coord * (SIZE+1) / size): for non-negative operands round(a/b) is + // (2a + b) / (2b), which reproduces round(coord*scale) exactly at these magnitudes β€” no + // per-voxel float multiply or round() in the hot loop. num = 2Β·(SIZE+1) is the shared + // numerator factor; denX/Y/Z = 2Β·size are the doubled per-axis denominators. + const int num = 2 * (SIZE + 1); + const int denX = 2 * sizeX, denY = 2 * sizeY, denZ = 2 * sizeZ; + const int halfX = sizeX / 2, halfY = sizeY / 2, halfZ = sizeZ / 2; + + // Red, DarkOrange, Blue, Green, Yellow, White. + const RGB COLOR_MAP[6] = { + {255, 0, 0}, {255, 140, 0}, {0, 0, 255}, {0, 128, 0}, {255, 255, 0}, {255, 255, 255}}; + + for (int x = 0; x < sx; x++) + for (int y = 0; y < sy; y++) + for (int z = 0; z < sz; z++) { + const Coord3D led{static_cast(x), static_cast(y), static_cast(z)}; + const int nX = constrainI((x * num + sizeX) / denX - 1, 0, SIZE - 1); + const int nY = constrainI((y * num + sizeY) / denY - 1, 0, SIZE - 1); + const int nZ = constrainI((z * num + sizeZ) / denZ - 1, 0, SIZE - 1); + const int distX = MINi(x, sizeX - x), distY = MINi(y, sizeY - y), distZ = MINi(z, sizeZ - z); + const int dist = MINi(distX, MINi(distY, distZ)); + + if (dist == distZ && z < halfZ) draw::pixel(buf, dims, led, COLOR_MAP[front[nY][nX]]); + else if (dist == distX && x < halfX) draw::pixel(buf, dims, led, COLOR_MAP[left[nY][SIZE - 1 - nZ]]); + else if (dist == distY && y < halfY) draw::pixel(buf, dims, led, COLOR_MAP[top[SIZE - 1 - nZ][nX]]); + else if (dist == distZ && z >= halfZ) draw::pixel(buf, dims, led, COLOR_MAP[back[nY][SIZE - 1 - nX]]); + else if (dist == distX && x >= halfX) draw::pixel(buf, dims, led, COLOR_MAP[right[nY][nZ]]); + else if (dist == distY && y >= halfY) draw::pixel(buf, dims, led, COLOR_MAP[bottom[nZ][nX]]); + } + } + }; + + // A move: which face turns, how many layers wide, which direction. Packed into one byte for the + // stored scramble list (3 bits face, 3 bits width, 1 bit direction). + struct Move { uint8_t face, width, direction; }; + + Move createRandomMoveStruct(uint8_t size, uint8_t prevFace) { + Move move; + do { move.face = rng_.below(6); } while (move.face / 2 == prevFace / 2); + move.width = (size > 2) ? rng_.below(static_cast(size - 2)) : 0; // random(cubeSize-2) + move.direction = rng_.below(2); + return move; + } + static uint8_t packMove(Move m) { + return static_cast((m.face & 0b111) | ((m.width << 3) & 0b111000) | ((m.direction << 6) & 0b1000000)); + } + static Move unpackMove(uint8_t p) { + Move m; + m.face = static_cast(p & 0b111); + m.width = static_cast((p >> 3) & 0b111); + m.direction = static_cast((p >> 6) & 0b1); + return m; + } + + using RotateFunc = void (Cube::*)(bool, uint8_t); + static constexpr RotateFunc kRotateFuncs[6] = { + &Cube::rotateFront, &Cube::rotateBack, &Cube::rotateLeft, + &Cube::rotateRight, &Cube::rotateTop, &Cube::rotateBottom}; + + // Build a fresh solved cube, give it a few random whole-cube turns, then scramble it with a + // stored move list (so playback can reverse it), and draw the scrambled state. + void init(Buffer& buf, Coord3D dims, lengthType w, lengthType h, lengthType d) { + cube_.init(cubeSize); + const int moveCount = cubeSize * 10 + rng_.below(20); + + for (int x = 0; x < 3; x++) { + if (rng_.below(2)) cube_.rotateRight(1, cubeSize); + if (rng_.below(2)) cube_.rotateTop(1, cubeSize); + if (rng_.below(2)) cube_.rotateFront(1, cubeSize); + } + + const int cappedMoves = (moveCount > kMaxMoves) ? kMaxMoves : moveCount; + for (int i = 0; i < cappedMoves; i++) { + Move move = createRandomMoveStruct(cubeSize, prevFaceMoved_); + prevFaceMoved_ = move.face; + moveList_[i] = packMove(move); + (cube_.*kRotateFuncs[move.face])(move.direction, static_cast(move.width + 1)); + } + moveIndex_ = static_cast(cappedMoves - 1); + cube_.drawCube(buf, dims, w, h, d); + } + + // Inline integer helpers (MoonLight's MIN/MAX/constrain). + static int MINi(int a, int b) { return a < b ? a : b; } + static int MAXi(int a, int b) { return a > b ? a : b; } + static int constrainI(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); } + + static constexpr int kMaxMoves = 100; // moveList capacity (cubeSize*10 + 0..19 ≀ 99 for size ≀ 8) + + Cube cube_; // 6 Γ— 8 Γ— 8 = 384 bytes β€” small enough to keep inline + uint8_t moveList_[kMaxMoves] = {}; // packed scramble for reverse playback + uint8_t moveIndex_ = 0; + uint8_t prevFaceMoved_ = 0; + uint32_t step_ = 0; // ms timestamp gating the next turn / hold window + bool doInit_ = true; // request a fresh scramble (first run + on control change) + Random8 rng_{0x52554249u}; // "RUBI" +}; + +} // namespace mm diff --git a/src/light/effects/SineEffect.h b/src/light/effects/SineEffect.h index 1ff725f0..d7f2a462 100644 --- a/src/light/effects/SineEffect.h +++ b/src/light/effects/SineEffect.h @@ -1,7 +1,7 @@ #pragma once #include "light/layers/Layer.h" -#include "core/color.h" // sin8 β€” integer sine LUT +#include "core/math8.h" // sin8 β€” integer sine LUT namespace mm { @@ -17,6 +17,7 @@ namespace mm { // // Prior art: projectMM v1/v2 SineEffect (same 3D sine; those used float sinf and a // KvStore brightness publish we don't carry). +// Author: MoonLight (Sinus, AI-generated) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h class SineEffect : public EffectBase { public: const char* tags() const override { return "πŸŒ€"; } diff --git a/src/light/effects/SolidEffect.h b/src/light/effects/SolidEffect.h new file mode 100644 index 00000000..d9a80b79 --- /dev/null +++ b/src/light/effects/SolidEffect.h @@ -0,0 +1,213 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fill +#include "core/math8.h" // (scale8 lives in color.h, pulled in via draw.h) +#include "platform/platform.h" // alloc/free β€” heap validIndices table + +#include // sqrtf β€” RMS palette average (cold, once per frame) + +namespace mm { + +// Solid colour fill with five colour modes: a flat RGB(W) colour, the active palette laid across +// the lights, an RMS-averaged single palette colour, or the palette banded along the rows / columns +// of the grid. A brightness scales the flat and palette-spread results. In the two band modes a +// `minRGB` floor drops near-black palette entries, and `randomColors` shuffles the surviving entries +// with a fixed LCG so the bands re-order deterministically. The R/G/B/white members are the flat- +// colour source; in the palette modes they're unused. +// +// Prior art: MoonLight's Solid effect (E_MoonModules / MoonModules). The five colour modes, the +// brightness scaling, the RMS palette average (skip black, sqrt of the mean of squares), the +// minRGB valid-entry filter, and the deterministic LCG shuffle (seed 12345, *25173 +13849) are +// reproduced exactly here, written fresh on EffectBase + the shared palette/draw primitives. +// MoonLight iterates a 256-entry palette; projectMM's palette is a 16-entry gradient read through +// colorFromPalette()'s 0..255 wheel index, so the 256 wheel positions sampled below reproduce +// MoonLight's per-entry scan one-for-one. The optional white channel is written only when the layer +// carries a 4th channel (channelsPerLight() >= 4); on RGB layers the white member is ignored. +// Author: MoonLight β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class SolidEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«"; } // MoonLight origin + // Modes 3/4 read y vs x and write every (x,y,z); modes 0–2 fill flat. Iterating all axes keeps + // the band orientation correct on a 3D layer, so declare D3 (no extrude on our behalf). + Dim dimensions() const override { return Dim::D3; } + + // Defaults match MoonLight's Solid exactly. + uint8_t red = 182, green = 15, blue = 98, white = 0; + uint8_t brightness = 255; + uint8_t colorMode = 0; // 0 RGB(W) Β· 1 Palette Β· 2 Palette avg Β· 3 Palette rows Β· 4 Palette cols + uint8_t minRGB = 10; // band modes: drop palette entries whose every channel < minRGB + bool randomColors = false; // band modes: LCG-shuffle the surviving palette entries + + static constexpr const char* kColorModeOptions[] = { + "RGB(W)", "Palette", "Palette avg", "Palette rows", "Palette cols"}; + static constexpr uint8_t kColorModeCount = 5; + + void onBuildControls() override { + controls_.addUint8("red", red, 0, 255); + controls_.addUint8("green", green, 0, 255); + controls_.addUint8("blue", blue, 0, 255); + controls_.addUint8("white", white, 0, 255); + controls_.addUint8("brightness", brightness, 0, 255); + controls_.addSelect("colorMode", colorMode, kColorModeOptions, kColorModeCount); + controls_.addUint8("minRGB", minRGB, 0, 255); + controls_.addBool("randomColors", randomColors); + } + + // The band modes (3/4) need a 256-entry table of valid wheel indices. 256 bytes is small, but + // the contract keeps per-effect buffers off the inline footprint (the registerType probe + // lives on an 8 KB stack), so it's a lazily-allocated heap buffer, freed on teardown. + void onBuildState() override { + if (enabled() && !validIndices_) { + validIndices_ = static_cast(platform::alloc(256)); + } else if (!enabled() && validIndices_) { + release(); + } + setDynamicBytes(validIndices_ ? 256 : 0); + } + + void teardown() override { release(); setDynamicBytes(0); } + ~SolidEffect() override { release(); } + + void loop() override { + const int w = width(); + const int h = height(); + const int d = depth(); + if (w <= 0 || h <= 0 || channelsPerLight() < 3) return; + + Buffer& buf = layer()->buffer(); + const lengthType dz = d > 0 ? static_cast(d) : 1; + const Coord3D dims{static_cast(w), static_cast(h), dz}; + const Palette& pal = *Palettes::active(); + const uint8_t cpl = channelsPerLight(); + const nrOfLightsType nLights = nrOfLights(); + + switch (colorMode) { + case 0: { // RGB(W): flat colour, brightness pre-applied per channel (CRGB(red*bri/255,…)). + const RGB c{static_cast(red * brightness / 255), + static_cast(green * brightness / 255), + static_cast(blue * brightness / 255)}; + draw::fill(buf, c); + // Write W every frame (white may be 0) so a stale W from a prior frame/effect is cleared. + // Scale W by brightness like RGB, so the whole RGBW colour dims together. + if (cpl >= 4) writeWhite(buf, nLights, cpl, static_cast(white * brightness / 255)); + break; + } + case 1: { // Palette spread across the lights: light i β†’ wheel index map(i,0,nLights,0,256). + uint8_t* data = buf.data(); + const size_t bytes = buf.bytes(); + for (nrOfLightsType i = 0; i < nLights; i++) { + const uint8_t idx = static_cast(mapI(static_cast(i), 0, static_cast(nLights), 0, 256)); + const RGB c = colorFromPalette(pal, idx, brightness); + const size_t off = static_cast(i) * cpl; + if (off + 3 > bytes) break; + data[off + 0] = c.r; data[off + 1] = c.g; data[off + 2] = c.b; + } + // Palette modes carry no white source: clear W so an RGBW buffer doesn't keep stale white. + if (cpl >= 4) writeWhite(buf, nLights, cpl, 0); + break; + } + case 2: { // RMS average of the (non-black) palette colours, filled solid (no brightness β€” source). + uint32_t sumR = 0, sumG = 0, sumB = 0; + int n = 0; + for (int i = 0; i < 256; i++) { + const RGB e = colorFromPalette(pal, static_cast(i)); + if (e.r == 0 && e.g == 0 && e.b == 0) continue; // skip black + sumR += static_cast(e.r) * e.r; + sumG += static_cast(e.g) * e.g; + sumB += static_cast(e.b) * e.b; + n++; + } + RGB avg{0, 0, 0}; + if (n > 0) { + avg.r = static_cast(sqrtf(static_cast(sumR) / n)); + avg.g = static_cast(sqrtf(static_cast(sumG) / n)); + avg.b = static_cast(sqrtf(static_cast(sumB) / n)); + } + draw::fill(buf, avg); + if (cpl >= 4) writeWhite(buf, nLights, cpl, 0); // no white source: clear stale W + break; + } + default: { // 3 rows / 4 cols: band the (filtered, optionally shuffled) palette along an axis. + const bool rows = (colorMode == 3); + const int axisSize = rows ? h : w; + + // Collect the wheel indices whose any channel >= minRGB. + int nrValid = 0; + if (validIndices_) { + for (int i = 0; i < 256; i++) { + const RGB e = colorFromPalette(pal, static_cast(i)); + if (e.r >= minRGB || e.g >= minRGB || e.b >= minRGB) + validIndices_[nrValid++] = static_cast(i); + } + if (randomColors && nrValid > 1) { + // Fisher-Yates with MoonLight's exact LCG (seed 12345, *25173 +13849). + uint32_t seed = 12345u; + for (int i = nrValid - 1; i > 0; i--) { + seed = seed * 25173u + 13849u; + const int j = static_cast(seed % static_cast(i + 1)); + const uint8_t t = validIndices_[i]; + validIndices_[i] = validIndices_[j]; + validIndices_[j] = t; + } + } + } + + for (int z = 0; z < dz; z++) { + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + const int axisValue = rows ? y : x; + uint8_t idx; + if (nrValid > 0 && validIndices_) { + const int vi = axisSize <= 1 ? 0 + : mapI(axisValue, 0, axisSize - 1, 0, nrValid - 1); + idx = validIndices_[vi]; + } else { + // No surviving entry: map the axis straight onto the 0..255 wheel. + idx = axisSize <= 1 ? 0 + : static_cast(mapI(axisValue, 0, axisSize - 1, 0, 255)); + } + const RGB c = colorFromPalette(pal, idx, brightness); + draw::pixel(buf, dims, {static_cast(x), static_cast(y), + static_cast(z)}, c); + } + } + } + // Band modes carry no white source: clear W so an RGBW buffer doesn't keep stale white. + if (cpl >= 4) writeWhite(buf, nLights, cpl, 0); + break; + } + } + } + +private: + uint8_t* validIndices_ = nullptr; + + void release() { + if (validIndices_) { platform::free(validIndices_); validIndices_ = nullptr; } + } + + // Write the white channel (4th) on every light. RGB stays as already filled. `w` may be 0 to + // clear a stale white the palette modes never overwrite (draw::pixel/draw::fill touch RGB only). + static void writeWhite(Buffer& buf, nrOfLightsType n, uint8_t cpl, uint8_t w) { + uint8_t* data = buf.data(); + const size_t bytes = buf.bytes(); + for (nrOfLightsType i = 0; i < n; i++) { + const size_t off = static_cast(i) * cpl + 3; + if (off >= bytes) break; + data[off] = w; + } + } + + // FastLED-style integer map (inHi exclusive at the call sites that pass nLights/256, matching + // MoonLight's ::map). Guards a zero span so a degenerate axis maps to outLo. + static int mapI(int x, int inLo, int inHi, int outLo, int outHi) { + const int den = inHi - inLo; + if (den == 0) return outLo; + return (x - inLo) * (outHi - outLo) / den + outLo; + } +}; + +} // namespace mm diff --git a/src/light/effects/SphereMoveEffect.h b/src/light/effects/SphereMoveEffect.h new file mode 100644 index 00000000..05c220e2 --- /dev/null +++ b/src/light/effects/SphereMoveEffect.h @@ -0,0 +1,91 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade +#include "core/math8.h" // Random8 + +#include // sinf, cosf, sqrtf β€” per-frame origin + per-pixel distance (float kept for fidelity) + +namespace mm { + +// SphereMove: a hollow sphere whose surface shell sweeps through the 3D volume. Each frame the +// buffer is fully cleared, a sphere origin is driven on a Lissajous-like path (sin on x, cos on +// y and z) and a slowly breathing diameter is computed; every voxel whose Euclidean distance from +// the origin falls within the one-unit-thick shell (d > diameter && d < diameter+1) is lit from +// the active palette, the palette index drifting with time plus a small per-pixel random jitter so +// the shell shimmers. The origin sweep speeds up with `speed` (the divisor 100-speed shrinks). +// +// This is float-per-pixel (a sqrtf distance per voxel) β€” kept deliberately for exact visual +// fidelity with the original, as the contract allows when the source is float per-pixel. +// +// Prior art: MoonLight's SphereMove (E_MoonModules / MoonModules). The origin oscillator math +// (millis()/(100-speed)/6.4, the sin/cos origin path), the diameter = 2 + sin(ti/3) breathing, +// the one-unit shell test, and the millis()/50 + random8(64) palette index are reproduced. +// time_interval is computed in full float (ms and 100-speed as float through the whole +// expression) so the origin sweep and diameter breathing integrate continuously rather than in +// quantised integer-time steps β€” a smooth sweep at every speed. +// Author: MoonLight β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class SphereMoveEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸ§Š"; } // MoonLight origin Β· 3D-native + Dim dimensions() const override { return Dim::D3; } + + uint8_t speed = 50; // origin sweep rate (0..99); higher = faster (divisor is 100-speed) + + void onBuildControls() override { + controls_.addUint8("speed", speed, 0, 99); + } + + void loop() override { + const int w = width(); + const int h = height(); + const int d = depth(); + if (w <= 0 || h <= 0 || d <= 0 || channelsPerLight() < 3) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{static_cast(w), static_cast(h), static_cast(d)}; + + // Full clear each frame (source: fadeToBlackBy(255)). + layer()->fadeToBlackBy(255); + + const uint32_t ms = elapsed(); + + // Origin oscillator: time_interval = ms / (100-speed) / 6.4, all-float so time advances + // continuously (integer-dividing ms/(100-speed) first would truncate the sub-step time and + // quantise the sweep). The divisor 100-speed (speed clamped 0..99 β†’ divisor 1..100, never 0) + // and the (256-128)/20.0 == 6.4 factor are MoonLight's exact constants. + const float time_interval = static_cast(ms) / static_cast(100 - speed) / 6.4f; + + const float ox = w / 2.0f * (1.0f + sinf(time_interval)); + const float oy = h / 2.0f * (1.0f + cosf(time_interval)); + const float oz = d / 2.0f * (1.0f + cosf(time_interval)); + + const float diameter = 2.0f + sinf(time_interval / 3.0f); + + // Palette index base drifts with time; a per-pixel random jitter (0..63) is added per lit voxel. + const uint8_t indexBase = static_cast(ms / 50); + + for (int z = 0; z < d; z++) { + const float dz = z - oz; + for (int y = 0; y < h; y++) { + const float dy = y - oy; + for (int x = 0; x < w; x++) { + const float dx = x - ox; + const float dist = sqrtf(dx * dx + dy * dy + dz * dz); + if (dist > diameter && dist < diameter + 1.0f) { + const uint8_t index = static_cast(indexBase + rng_.below(64)); + draw::pixel(buf, dims, {static_cast(x), static_cast(y), static_cast(z)}, + colorFromPalette(*Palettes::active(), index)); + } + } + } + } + } + +private: + Random8 rng_; // per-pixel palette jitter (MoonLight's random8(64)) +}; + +} // namespace mm diff --git a/src/light/effects/SpiralEffect.h b/src/light/effects/SpiralEffect.h index 653d86d4..2c8d84b1 100644 --- a/src/light/effects/SpiralEffect.h +++ b/src/light/effects/SpiralEffect.h @@ -1,10 +1,13 @@ #pragma once #include "light/layers/Layer.h" +#include "light/Palette.h" // colorFromPalette + active palette #include "core/color.h" +#include "core/math8.h" // sin8/cos8/dist8/atan2_8 namespace mm { +// Author: projectMM original (rotating spiral) class SpiralEffect : public EffectBase { public: const char* tags() const override { return "πŸ’«πŸ¦…"; } // MoonLight origin Β· David Jupijn / Rising Step @@ -48,7 +51,7 @@ class SpiralEffect : public EffectBase { uint8_t dist = dist8(dx, dy); uint8_t hue = static_cast( angle + static_cast(dist * twist) - t + hue_shift); - RGB c = hsvToRgb(hue, 255, 255); + RGB c = colorFromPalette(*Palettes::active(), hue); if (cpl >= 1) row[0] = c.r; if (cpl >= 2) row[1] = c.g; diff --git a/src/light/effects/StarFieldEffect.h b/src/light/effects/StarFieldEffect.h new file mode 100644 index 00000000..995b5ba5 --- /dev/null +++ b/src/light/effects/StarFieldEffect.h @@ -0,0 +1,197 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade +#include "core/math8.h" // Random8 +#include "platform/platform.h" // platform::alloc / platform::free (heap star table) + +namespace mm { + +// StarField: a forward-flying starfield. Each star is a 3D point (x, y, z) drifting toward the +// viewer; every advance z decreases by 1 and the star is re-projected onto the 2D panel with a +// simple pinhole projection (screen position = centre + (coord / z) scaled to half the panel), so +// stars near the camera (small z) splay outward and fly off the edges while distant stars sit near +// the centre β€” the classic "warp" / hyperspace look. A star that reaches z<=0 or leaves the panel +// respawns at the far plane (z = width). Brightness (and, with usePalette, hue) rides the depth: +// nearer stars are brighter. `blur` fades the previous frame instead of clearing it, so each star +// leaves a motion streak; `speed` throttles how often the field advances. +// +// Per-star math keeps floats (the x/z, y/z perspective division): it runs once per star, not per +// pixel, off the hot pixel loop, and reproducing MoonLight's exact projection preserves the visual +// the user has known for years (fidelity wins here). The centre offset and the projection scale are +// integer `size/2`, exactly as MoonLight computes them (size is int there), so odd-width panels +// project identically. +// +// Prior art: MoonLight's StarField effect (E_MoonModules / MoonModules). The star model +// (x,y,z + colorIndex), the z-=1 advance, the pinhole re-projection, the depthβ†’brightness map, the +// respawn rule (random depth at first seed, far-plane z=width on respawn), and the +// speed/blur/numStars/usePalette controls are reproduced exactly here, written fresh on EffectBase +// + the shared draw/palette primitives. The star table lives on the heap (platform::alloc), sized +// to the control maximum, rather than as a large inline member. +// Author: @Brandon502 (MoonLight), inspired by Daniel Shiffman / Coding Train β€” https://www.youtube.com/watch?v=17WoOqgXsRM , https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class StarFieldEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«"; } // MoonLight origin + Dim dimensions() const override { return Dim::D2; } // writes only the z=0 slice + + uint8_t speed = 20; // advance rate (0..30); 0 = paused. Throttle is 1000/speed ms. + uint8_t numStars = 16; // active stars (1..255) + uint8_t blur = 128; // per-frame fade-to-black amount (0..255); higher = stronger fade = shorter streaks (draw::fade keep = 255-blur, matching MoonLight's fadeToBlackBy(blur)) + bool usePalette = false; // colour stars from the palette instead of greyscale + + void onBuildControls() override { + controls_.addUint8("speed", speed, 0, 30); + controls_.addUint8("numStars", numStars, 1, 255); + controls_.addUint8("blur", blur, 0, 255); + controls_.addBool("usePalette", usePalette); + } + + // Star table is sized to the control maximum (255) so a live numStars change never reallocates, + // and re-seeds whenever the grid changes (the random spawn ranges depend on width/height). The + // initial seed scatters stars at random depths (z in [0,width)); respawns later fly in from the + // far plane. + void onBuildState() override { + const lengthType w = width(); + const lengthType h = height(); + if (enabled() && w > 0 && h > 0) { + if (!stars_) { + stars_ = static_cast(platform::alloc(sizeof(Star) * kMaxStars)); + } + if (stars_ && (w != seedW_ || h != seedH_)) { + for (uint16_t i = 0; i < kMaxStars; i++) spawn(stars_[i], w, h, /*far=*/false); + seedW_ = w; + seedH_ = h; + } + } else { + release(); + } + setDynamicBytes(stars_ ? sizeof(Star) * kMaxStars : 0); + } + + void teardown() override { + release(); + setDynamicBytes(0); + } + + ~StarFieldEffect() override { release(); } + + void loop() override { + if (!stars_) return; + + const lengthType w = width(); + const lengthType h = height(); + if (w <= 0 || h <= 0 || channelsPerLight() < 3) return; + + // Throttle: pause when speed==0, else advance at most once per 1000/speed ms. + if (speed == 0) return; + const uint32_t now = elapsed(); + if (now - step_ < 1000u / speed) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{w, h, depthDim()}; + + // Motion streaks: fade the previous frame rather than clearing it. + layer()->fadeToBlackBy(blur); + + const int sizeX = w; + const int sizeY = h; + // Integer centre/scale, exactly as MoonLight (size is int there): size.x/2, size.y/2. + const int halfX = sizeX / 2; + const int halfY = sizeY / 2; + + const uint8_t n = numStars; // 1..255, never exceeds kMaxStars + for (uint8_t i = 0; i < n; i++) { + Star& s = stars_[i]; + + // Pinhole projection: fmap(coord/z, 0,1, 0, half) == half * (coord/z) (in_min=0,in_max=1). + // z<=0 maps to MoonLight's +inf (off-grid, not drawn); guard the divide and treat as such. + bool inBounds = false; + int sx = 0, sy = 0; + if (s.z > 0.0f) { + const float invZ = 1.0f / s.z; + sx = halfX + static_cast(halfX * (s.x * invZ)); + sy = halfY + static_cast(halfY * (s.y * invZ)); + inBounds = (sx >= 0 && sx < sizeX && sy >= 0 && sy < sizeY); + } + + if (inBounds) { + RGB col; + if (usePalette) { + // Nearer (smaller z) = brighter: depth 0..sizeX maps brightness 255..150. + const uint8_t bri = static_cast(imap(static_cast(s.z), 0, sizeX, 255, 150)); + col = colorFromPalette(*Palettes::active(), s.colorIndex, bri); + } else { + // Greyscale: base intensity from colorIndex (120..255), scaled by depth (7..10)/10. + int color = imap(s.colorIndex, 0, 255, 120, 255); + const int brightness = imap(static_cast(s.z), 0, sizeX, 7, 10); + color = static_cast(color * (brightness / 10.0f)); + if (color < 0) color = 0; + if (color > 255) color = 255; + const uint8_t c = static_cast(color); + col = RGB{c, c, c}; + } + draw::pixel(buf, dims, {static_cast(sx), static_cast(sy), 0}, col); + } + + // Advance toward the viewer; respawn at the far plane when it passes the camera or flies + // off-panel (MoonLight: z = size.x on respawn). + s.z -= 1.0f; + if (s.z <= 0.0f || !inBounds) spawn(s, w, h, /*far=*/true); + } + + step_ = now; + } + +private: + struct Star { + float x, y, z; + uint8_t colorIndex; + }; + static constexpr uint16_t kMaxStars = 255; // the numStars control maximum + + // Standard integer map (FastLED ::map), guarded against a zero input span. + static int imap(int v, int inLo, int inHi, int outLo, int outHi) { + const int den = inHi - inLo; + if (den == 0) return outLo; + return (v - inLo) * (outHi - outLo) / den + outLo; + } + + // Spawn a star at a random x/y far position with a fresh colour index. `far` selects the depth: + // far=false β†’ initial seed: z in [0, w) (MoonLight init: z = random(size.x)) + // far=true β†’ respawn: z = w (MoonLight respawn: z = size.x) + void spawn(Star& s, lengthType w, lengthType h, bool far) { + s.x = static_cast(randRange(-w, w)); + s.y = static_cast(randRange(-h, h)); + s.z = far ? static_cast(w) + : static_cast(w > 0 ? rng_.next16() % w : 0); + s.colorIndex = rng_.next8(); + } + + // A uniform integer in [lo, hi) (half-open, like FastLED's random(lo, hi)). + int randRange(int lo, int hi) { + if (hi <= lo) return lo; + const uint32_t span = static_cast(hi - lo); + return lo + static_cast(rng_.next16() % span); + } + + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + void release() { + if (stars_) { + platform::free(stars_); + stars_ = nullptr; + } + seedW_ = 0; + seedH_ = 0; + } + + Star* stars_ = nullptr; + lengthType seedW_ = 0; + lengthType seedH_ = 0; + uint32_t step_ = 0; + Random8 rng_; +}; + +} // namespace mm \ No newline at end of file diff --git a/src/light/effects/StarSkyEffect.h b/src/light/effects/StarSkyEffect.h new file mode 100644 index 00000000..2f965514 --- /dev/null +++ b/src/light/effects/StarSkyEffect.h @@ -0,0 +1,166 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::fade +#include "core/math8.h" // Random8 +#include "platform/platform.h" // alloc/free β€” the per-star heap state + +namespace mm { + +// Star Sky: a field of independently twinkling stars over a 3D grid. A fixed pool of stars +// (sized from the light count) each pick a random cell, a random initial brightness, and a random +// fade direction; every frame the whole buffer dims a little and each star steps its brightness +// toward full (then reverses) or toward zero (then respawns at a fresh random cell). A small per- +// frame chance (random8() < 10) flips a star's direction early, scattering the twinkle so it never +// pulses in sync. Stars are white (b,b,b) unless usePalette, in which case each carries its own +// palette index. The colour drawn each frame is taken from the brightness BEFORE this frame's step +// (MoonLight computes `color` once from the current brightness, then steps, then setRGB(color)). +// +// Prior art: MoonLight's StarSky (E_MoonModules / MoonModules) β€” the star-pool model (fill-ratio +// sizing, fade-up/fade-down/respawn, the random early-reverse, the optional palette colour) is +// reproduced here, written fresh on projectMM's EffectBase + shared primitives (Random8, +// colorFromPalette, draw::). The per-star arrays live on the heap (platform::alloc), never as inline +// members, so sizeof(StarSkyEffect) stays tiny. +// Author: limpkin (MoonLight) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h +class StarSkyEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«"; } // MoonLight origin + Dim dimensions() const override { return Dim::D3; } + + // Defaults match MoonLight's StarSky exactly. + uint8_t speed = 1; // fade step per frame (0..42) + uint8_t star_fill_ratio = 42; // stars per 10000 lights (the pool-size lever) + bool usePalette = false; // false β†’ white stars; true β†’ per-star palette colour + + void onBuildControls() override { + controls_.addUint8("speed", speed, 0, 42); + controls_.addUint8("star_fill_ratio", star_fill_ratio, 0, 255); + controls_.addBool("usePalette", usePalette); + } + + // Per-star state on the heap, sized to nb_stars = star_fill_ratio*nrOfLights/10000 + 1. Reallocated + // whenever the light count or fill ratio changes (so it tracks a live grid/control edit). Off the + // hot path; never an inline member (a large inline array overflows the registerType probe stack). + void onBuildState() override { + const nrOfLightsType count = nrOfLights(); + const size_t wanted = enabled() && count > 0 + ? (static_cast(star_fill_ratio) * count) / 10000u + 1u + : 0u; + if (wanted != nbStars_ || count != lightCount_) { + release(); + if (wanted > 0) { + indexes_ = static_cast(platform::alloc(wanted * sizeof(nrOfLightsType))); + fadeDir_ = static_cast(platform::alloc(wanted)); + brightness_ = static_cast(platform::alloc(wanted)); + colors_ = static_cast(platform::alloc(wanted)); + if (indexes_ && fadeDir_ && brightness_ && colors_) { + nbStars_ = wanted; + lightCount_ = count; + initStars(count); + } else { + release(); + } + } + } + setDynamicBytes(nbStars_ ? nbStars_ * (sizeof(nrOfLightsType) + 3) : 0); + } + + void teardown() override { release(); setDynamicBytes(0); } + ~StarSkyEffect() override { release(); } + + void loop() override { + if (!indexes_ || !fadeDir_ || !brightness_ || !colors_ || nbStars_ == 0) return; + const lengthType w = width(), h = height(), d = depthDim(); + if (w <= 0 || h <= 0 || channelsPerLight() < 3) return; + const nrOfLightsType count = nrOfLights(); + if (count == 0) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{w, h, d}; + + layer()->fadeToBlackBy(50); + + for (size_t i = 0; i < nbStars_; i++) { + const nrOfLightsType index = indexes_[i]; + // Decode the linear index back to a grid cell (index < nrOfLights β†’ always in bounds). + const lengthType x = static_cast(index % w); + const lengthType y = static_cast((index / w) % h); + const lengthType z = static_cast(index / (static_cast(w) * h)); + const Coord3D p{x, y, z}; + + // Colour is computed ONCE from the CURRENT (pre-step) brightness, then the brightness is + // stepped, then the pre-step colour is drawn β€” matching MoonLight's + // color = usePalette ? ColorFromPalette(pal, colors[i], brightness[i]) : CRGB(b,b,b); + // brightness[i] += speed; setRGB(pos, color); + const uint8_t b = brightness_[i]; + const RGB color = usePalette + ? colorFromPalette(*Palettes::active(), colors_[i], b) + : RGB{b, b, b}; + + if (fadeDir_[i]) { + // Fading up toward full brightness. + const uint16_t nb = static_cast(b) + speed; + brightness_[i] = nb > 255 ? 255 : static_cast(nb); + draw::pixel(buf, dims, p, color); + if (brightness_[i] == 255) fadeDir_[i] = 0; + if (rng_.next8() < 10) fadeDir_[i] = 0; + } else { + // Fading down toward black; respawn at a fresh cell when it reaches zero. + brightness_[i] = b > speed ? static_cast(b - speed) : 0; + draw::pixel(buf, dims, p, color); + if (brightness_[i] == 0) { + indexes_[i] = randomIndex(count); + fadeDir_[i] = 1; + } + if (rng_.next8() < 10) fadeDir_[i] = 1; + } + } + } + +private: + nrOfLightsType* indexes_ = nullptr; // linear cell index per star (< nrOfLights) + uint8_t* fadeDir_ = nullptr; // 0 = fading down, 1 = fading up + uint8_t* brightness_ = nullptr; // current 0..255 brightness per star + uint8_t* colors_ = nullptr; // per-star palette index (used only when usePalette) + size_t nbStars_ = 0; + nrOfLightsType lightCount_ = 0; + Random8 rng_{0x57A55C1Eu}; + + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + // A uniform cell pick over 0..count-1. nrOfLightsType is uint32_t on PSRAM builds (>65535 lights), + // so a single next16() draw can't reach the top of a large grid β€” compose a full-width draw from + // two 16-bit draws when the index type is wider than 16 bits, then take it modulo count. On the + // no-PSRAM (uint16_t) build this collapses to the plain next16() pick. + nrOfLightsType randomIndex(nrOfLightsType count) { + if (count == 0) return 0; + if constexpr (sizeof(nrOfLightsType) > sizeof(uint16_t)) { + const uint32_t draw = (static_cast(rng_.next16()) << 16) | rng_.next16(); + return static_cast(draw % count); + } else { + return static_cast(rng_.next16() % count); + } + } + + void release() { + if (indexes_) { platform::free(indexes_); indexes_ = nullptr; } + if (fadeDir_) { platform::free(fadeDir_); fadeDir_ = nullptr; } + if (brightness_) { platform::free(brightness_); brightness_ = nullptr; } + if (colors_) { platform::free(colors_); colors_ = nullptr; } + nbStars_ = 0; lightCount_ = 0; + } + + // Seed every star: random cell, random fade direction, random mid brightness, random colour. + void initStars(nrOfLightsType count) { + for (size_t i = 0; i < nbStars_; i++) { + indexes_[i] = randomIndex(count); + fadeDir_[i] = rng_.below(2); // 0 or 1 (random8(2)) + brightness_[i] = rng_.below(1, 254); // 1..253 (random8(1,254)) + colors_[i] = rng_.next8(); + } + } +}; + +} // namespace mm diff --git a/src/light/effects/TetrixEffect.h b/src/light/effects/TetrixEffect.h new file mode 100644 index 00000000..0bf7d48c --- /dev/null +++ b/src/light/effects/TetrixEffect.h @@ -0,0 +1,191 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::pixel, draw::blendPixel +#include "core/math8.h" // Random8 +#include "platform/platform.h" // platform::alloc/free, platform::millis + +namespace mm { + +// Tetrix: each column drops a "brick" of light that falls under a per-column speed, lands on a +// growing stack at the bottom, and once the stack fills the column the whole column blanks back +// to black and the cycle restarts β€” the falling-block "Tetris" look applied per LED column. Each +// column runs an independent little state machine (idle β†’ start-delay β†’ fall β†’ stack β†’ blank), +// so the columns desync into a shimmering rain of stacking bricks. With `oneColor` every brick in +// a column shares one slowly-advancing palette index; otherwise each new brick picks a random one. +// +// Prior art: MoonLight's Tetrix (E_MoonModules / MoonModules), descended from the WLED "Tetrix" +// effect (Aircoookie / blazoncek). The per-column physics (mapped fall speed = grid-heightΒ·FRAMETIME +// / map(speed,1,255,40000,250), the `pos`/`stack`/`brick` integers, the millis()+2000 start/blank +// delays, and the step-machine values 0/1/2/>2) and the colour rules are reproduced from the +// MoonLight spec, written fresh on EffectBase + the shared draw primitives. One drop per X column; +// safe at any grid size. +// Author: Andrew Tuline (WLED-SR) β€” https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h +class TetrixEffect : public EffectBase { +public: + const char* tags() const override { return "πŸ’«πŸŒ™"; } // MoonLight origin Β· MoonModules + Dim dimensions() const override { return Dim::D2; } // writes only the z=0 slice; iterates x and y + + // Controls β€” MoonLight's exact defaults. `speedControl` is the UI "speed" (0 = random per brick). + uint8_t speedControl = 0; // 0..255; 0 β†’ each brick gets a random fall speed + uint8_t widthControl = 0; // 0..255; 0 β†’ random brick height, else derived from this + bool oneColor = false; // all bricks in a column share one slowly-advancing colour + + void onBuildControls() override { + controls_.addUint8("speed", speedControl, 0, 255); + controls_.addUint8("width", widthControl, 0, 255); + controls_.addBool("oneColor", oneColor); + } + + // Per-column falling-brick state (MoonLight's Tetris struct). `step` doubles as the state + // machine value (0 idle, 1 start-roll, 2 falling) AND as a future-millis timestamp once it + // holds millis()+2000 (the start delay and the post-fill blank delay) β€” those are all > 2. + struct Tetris { + float pos = 0.0f; // current head position of the falling brick (in LED rows) + float speed = 0.0f; // fall speed in rows per frame + uint8_t col = 0; // palette index for this column's brick(s) + uint16_t brick = 0; // brick height in LEDs + uint16_t stack = 0; // current stacked height at the bottom + uint32_t step = 0; // state machine / timestamp (see above) + }; + + void onBuildState() override { + // One drop per X column. Reallocate only when the column count changes. + const nrOfLightsType cols = (enabled() && width() > 0) + ? static_cast(width()) : 0; + if (cols != nrOfDrops_) { + releaseDrops(); + if (cols > 0) { + drops_ = static_cast(platform::alloc(cols * sizeof(Tetris))); + if (drops_) nrOfDrops_ = cols; + } + } + if (drops_) { + // MoonLight's onSizeChanged init: every column starts idle (stack=0), with a 2 s start + // delay (step = millis()+2000), and oneColor columns seeded to palette index 0. + const uint32_t now = platform::millis(); + for (nrOfLightsType i = 0; i < nrOfDrops_; i++) { + drops_[i] = Tetris{}; + drops_[i].stack = 0; + drops_[i].step = now + 2000; + if (oneColor) drops_[i].col = 0; + } + } + setDynamicBytes(static_cast(nrOfDrops_) * sizeof(Tetris)); + } + + void teardown() override { + releaseDrops(); + setDynamicBytes(0); + } + + ~TetrixEffect() override { releaseDrops(); } + + void loop() override { + if (!drops_) return; + + const lengthType w = width(); + const lengthType h = height(); + if (w <= 0 || h <= 0 || channelsPerLight() < 1) return; + + Buffer& buf = layer()->buffer(); + const Coord3D dims{w, h, depthDim()}; + + const uint32_t now = platform::millis(); + const RGB black{0, 0, 0}; + + // Process exactly the live column count (never the allocated max β€” robust to a shrink + // before onBuildState reruns). + const nrOfLightsType nrOfDrops = (static_cast(w) < nrOfDrops_) + ? static_cast(w) : nrOfDrops_; + + for (nrOfLightsType x = 0; x < nrOfDrops; x++) { + Tetris& d = drops_[x]; + + if (d.step == 0) { + // Idle β†’ spawn a new brick. speed input is the control (or random when 0). + const uint8_t in = speedControl ? speedControl : rng_.below(1, 255); + // FastLED map(in, 1, 255, 40000, 250) β€” descending; the result (250..40000) needs a + // wide type (it does NOT fit in a byte). Fall speed = grid-heightΒ·FRAMETIME / mapped. + const long mapped = mapRange(in, 1, 255, 40000, 250); + d.speed = (mapped > 0) ? (static_cast(h) * FRAMETIME) / static_cast(mapped) + : 1.0f; + d.pos = static_cast(h); // start above the top, fall downward + if (!oneColor) d.col = static_cast(rng_.below(0, 15) << 4); + d.step = 1; + // Brick height: from the width control (if set) or random 1..4, scaled up on tall grids. + d.brick = static_cast((widthControl ? ((widthControl >> 5) + 1) + : rng_.below(1, 5)) + * (1 + (h >> 6))); + } else if (d.step == 1) { + // Start-roll: ~75% chance each frame to begin falling (random8() >> 6 is 0 ~1/4 of the time). + if (rng_.next8() >> 6) d.step = 2; + } else if (d.step == 2) { + // Falling: descend until the brick head reaches the top of the stack. + if (d.pos > static_cast(d.stack)) { + d.pos -= d.speed; + if (d.pos < static_cast(d.stack)) d.pos = static_cast(d.stack); + // Render the brick: rows [pos, pos+brick) lit in the column colour, above it black. + for (lengthType i = static_cast(d.pos); i < h; i++) { + const RGB c = (i < static_cast(d.pos) + static_cast(d.brick)) + ? colorFromPalette(*Palettes::active(), d.col) + : black; + draw::pixel(buf, dims, {static_cast(x), + static_cast(h - 1 - i), 0}, c); + } + } else { + // Landed: grow the stack by the brick height. If the column is full, start the + // blank delay (step = millis()+2000); otherwise idle for the next brick. + d.step = 0; + d.stack = static_cast(d.stack + d.brick); + if (d.stack >= static_cast(h)) d.step = now + 2000; + } + } else { + // step > 2: the column is full and waiting to blank. While the delay is in the + // future, fade the whole column toward black; once it elapses, reset the column. + // The compare is the wrap-safe signed-difference form ((int32_t)(step - now) > 0) + // rather than raw `step > now`, so it stays correct across the 32-bit millis() + // wrap (~49 days) β€” the modular difference fits a signed range for the 2 s delay. + d.brick = 0; + if (static_cast(d.step - now) > 0) { + for (lengthType i = 0; i < h; i++) + draw::blendPixel(buf, dims, {static_cast(x), i, 0}, black, 25); + } else { + d.stack = 0; + d.step = 0; + if (oneColor) d.col = static_cast(d.col + 8); + } + } + } + } + +private: + // FRAMETIME at MoonLight's 40 FPS rate (1000/40 = 25 ms): the per-frame step the fall speed + // is calibrated against, so the descent rate is grid-rate-independent. + static constexpr int FRAMETIME = 1000 / 40; + + // FastLED's integer ::map(x, inMin, inMax, outMin, outMax) β€” used here with a descending out + // range (40000 β†’ 250), so the result falls as the speed control rises. Guards inMax == inMin. + static long mapRange(long x, long inMin, long inMax, long outMin, long outMax) { + const long den = inMax - inMin; + if (den == 0) return outMin; + return (x - inMin) * (outMax - outMin) / den + outMin; + } + + // The grid depth accessor needs the >0 guard for the dims z extent; the width/oneColor control + // members are named *Control so they don't shadow the inherited width()/depth() accessors. + lengthType depthDim() const { return depth() > 0 ? depth() : 1; } + + void releaseDrops() { + if (drops_) { platform::free(drops_); drops_ = nullptr; } + nrOfDrops_ = 0; + } + + Tetris* drops_ = nullptr; + nrOfLightsType nrOfDrops_ = 0; + Random8 rng_; +}; + +} // namespace mm \ No newline at end of file diff --git a/src/light/effects/TextEffect.h b/src/light/effects/TextEffect.h new file mode 100644 index 00000000..33fcf44b --- /dev/null +++ b/src/light/effects/TextEffect.h @@ -0,0 +1,93 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // layer()->buffer() +#include "light/Palette.h" // colorFromPalette, Palettes::active() +#include "light/draw.h" // draw::text / draw::glyph / draw::fill +#include "light/fonts.h" // fonts::kAll β€” the selectable bitmap fonts +#include "core/math8.h" // beat8-style time (elapsed()) + +#include // strlen, strchr + +namespace mm { + +// Text: renders a multi-line string on the grid in a selectable bitmap font. By DEFAULT the text is +// STATIC β€” laid out from the top-left, each `\n` dropping one font-height, clipped where it runs off +// the grid. Turn on `scroll` to march the whole block leftwards as a marquee (wrapping), at `speed`. +// The colour comes from the active palette (one index, so it follows the global palette control). +// +// Multi-line entry uses the shared TextArea control (the same widget MoonLive's `source` uses β€” a +// real