diff --git a/CLAUDE.md b/CLAUDE.md index 9d644695..b096b3f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,8 @@ A high-performance system driving large LED installations and DMX fixtures. One 3. **Architecture first.** The domain-neutral core owns the hard constructs, written once; the light domain stays simple on top of it. Platform-specific code lives only in the platform layer. When core enforces a rule on one path, extend core to the next path. No hacks: fix it the standard way the moment it's spotted, or backlog the real fix by name. Default to subtraction: the first question on any change is what it can remove. + **Build the best solution, not the compatible one.** projectMM is young and has no installed base to protect, so "it would break existing configs" is NOT an argument for keeping a worse design, and neither is "someone may have tuned it by hand". When a better shape replaces an older one, the old one GOES: two mechanisms doing one job is the technical debt this project exists to avoid. The break is documented rather than carried ([ADR-0013](docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md): no migration code, robust persistence plus a documented break), which costs a MIGRATING entry and buys a codebase with one way to do each thing. Weigh what a user LOSES, not what changes: a value they can re-set in seconds is not a reason to keep a design. + 4. **Guardrails everywhere.** Every behavior is pinned by tests, unit and scenario, whose descriptions read as functional documentation: a test states a behavior a user could understand, and a trivial test doesn't earn its place. Every commit is measured (performance, size, repo health), so growth and regression are visible the moment they happen. Judgment is reviewed; everything else is checked by the per-event tables. The final guardrail is physical: verified means it ran on real hardware, with the bench and the product owner's eyes as the measurement. 5. **The whole repo, continuously.** We are responsible for every line in the repository, not only the lines changed today. Anything spotted in passing is ours: a British spelling, a stale comment, a doc describing what the code no longer does, a duplicated block, a test pinning the wrong contract. Fix it in the change that found it, or backlog it by name; walking past a defect you have read is what lets debt accumulate. "Pre-existing", "out of scope" and "not mine" say nothing about whether the code is right, and the next reader meets it unchanged. The one thing provenance IS good for is scope: work belonging to another branch is backlogged rather than smuggled into this one. (Applied to review findings in [§ Handling review findings](#commit).) @@ -24,7 +26,7 @@ A high-performance system driving large LED installations and DMX fixtures. One ## The Process -Every change follows the same timeline: **main → branch → build → test → document → commit → merge → release**. The **product owner** (PO) is the person initiating a branch, and any contributor can be one. The PO initiates every event and every gate list; if unsure, ask ("Feature work is done; run pre-commit, or do you want to look first?"). This holds even when the list would only be *checking* work in progress: running it to see where things stand is still starting a gate list. Verify work in progress with the individual tools instead (a build, `ctest`, one check script); the list itself is the PO's to fire. A conditional check runs only when its objective trigger matches; an applicable-but-skipped check needs a one-line reason in the commit/PR/release notes. Each cycle produces visible output, and each cycle subtracts: remove code and docs that stopped earning their place, or know why each one stays. `backlog/` and `history/` shrink too. External contributors follow the same timeline: fork, branch, PR into main, with the same checks and review. +Every change follows the same timeline: **main → branch → build → test → document → commit → merge → release**. The **product owner** (PO) is the person initiating a branch, and any contributor can be one. The PO initiates every event and every gate list; if unsure, ask ("Feature work is done; run pre-commit, or do you want to look first?"). This holds even when the list would only be *checking* work in progress: running it to see where things stand is still starting a gate list. Verify work in progress with the individual tools instead (a build, `test_desktop.py`, one check script); the list itself is the PO's to fire. A conditional check runs only when its objective trigger matches; an applicable-but-skipped check needs a one-line reason in the commit/PR/release notes. Each cycle produces visible output, and each cycle subtracts: remove code and docs that stopped earning their place, or know why each one stays. `backlog/` and `history/` shrink too. External contributors follow the same timeline: fork, branch, PR into main, with the same checks and review. ### Main @@ -47,8 +49,8 @@ Implement against the architecture ([docs/architecture.md](docs/architecture.md) | Task | Command | |---|---| -| desktop build (zero warnings) | `cmake --build build` | -| unit tests | `ctest --test-dir build --output-on-failure` | +| desktop build (zero warnings) | `uv run moondeck/build/build_desktop.py` | +| unit tests | `uv run moondeck/test/test_desktop.py` | | scenario tests | `uv run moondeck/scenario/run_scenario.py` | | **run the desktop firmware** | `uv run moondeck/run/run_desktop.py` | | ESP32 firmware build | `uv run moondeck/build/build_esp32.py --firmware ` | @@ -68,6 +70,13 @@ Keep a branch under ~100 changed files: past that CodeRabbit declines the PR out **MoonDeck** is the project's tooling: every build, flash, monitor, test, and check task is one Python script under `moondeck/`, and MoonDeck itself is the local web dashboard that runs those same scripts for a human ([moondeck/MoonDeck.md](moondeck/MoonDeck.md) is the per-script reference). Agents invoke the scripts from the command line — one set of scripts, two front ends — and every gate invokes one of them. Deliberately our own scripts rather than an embedded toolchain like PlatformIO: the firmware builds vendor-native against pinned ESP-IDF versions, and the tooling covers far more than compile-and-flash — one script per task keeps humans, agents, and CI on the identical path (rationale: [building.md § MoonDeck](docs/building.md#moondeck--the-dev-console)). +**Never run the underlying tool directly when a script wraps it.** `ctest`, `cmake --build`, +`pytest`, `node --test` and `idf.py` all have a MoonDeck script in front of them, and the script is +the contract: it picks the right per-host build directory, applies the flags the gate expects, and +tees its output where the dashboard and the PO's report read it. Reaching past it produces a number +that looks right and is measured differently, or a stale binary the script would have rebuilt. If a +task seems to have no script, that is worth saying rather than working around. + ### Test New behavior is pinned before it ships: a unit test for module logic, a scenario test for a full pipeline, and every discovered crash becomes a regression test (§ Principles, Guardrails + Robustness). Test descriptions read as functional documentation — a statement a user could understand — and a trivial test doesn't earn its place. Placement: [coding-standards § Tests](docs/coding-standards.md#tests); inventory and strategy: [docs/testing.md](docs/testing.md). @@ -107,13 +116,22 @@ On "run pre-commit": run the checks whose trigger the diff matches, report one l | platform boundary | `uv run moondeck/check/check_platform_boundary.py` | `src/`, except `src/platform/` | | hot-path discipline | `uv run moondeck/check/check_nonblocking.py --incremental` | `src/` | | ESP32 firmware fresh | `uv run moondeck/check/check_esp32_built.py --firmware ` | `src/`, `esp32/`, `CMakeLists.txt`, `library.json`, except `src/platform/desktop/` | -| host tests (Python) | `uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q` | `moondeck/`, `test/python/`, `moonlive/` | -| host tests (JS) | `node --test "test/js/**/*.test.mjs"` | `mooninstaller/`, `test/js/`, `src/ui/` | -| desktop build (zero warnings) 🐢 | `cmake --build build` | `src/`, `test/`, `CMakeLists.txt`, `library.json` | -| unit tests 🐢 | `ctest --test-dir build --output-on-failure --no-tests=error -C Release` | same as the desktop build | +| host tests (Python) | `uv run moondeck/test/test_host.py --python` | `moondeck/`, `test/python/`, `moonlive/` | +| host tests (JS) | `uv run moondeck/test/test_host.py --js` | `mooninstaller/`, `test/js/`, `src/ui/` | +| desktop build (zero warnings) 🐢 | `uv run moondeck/build/build_desktop.py --tests` | `src/`, `test/`, `CMakeLists.txt`, `library.json` | +| unit tests 🐢 | `uv run moondeck/test/test_desktop.py` | same as the desktop build | | scenario tests 🐢 | `uv run moondeck/scenario/run_scenario.py` | same, plus `test/scenarios/` | | no-backend build 🐢 | `uv run moondeck/build/build_desktop.py --no-jit --tests` | MoonLive sources or their tests | | Improv smoke test (needs a board) | `uv run moondeck/build/improv_smoke_test.py --port ` | `src/core/ImprovFrame.h`, `src/platform/esp32/platform_esp32_improv.cpp`, `mooninstaller/index.html`, `src/ui/install-picker.js`, `moondeck/build/improv_` | +| repo health 🐢 | `uv run moondeck/check/collect_kpi.py --commit` | always | + +**Repo health runs on EVERY commit**, whatever the diff touches, because it is the only place the +numbers that creep are visible: flash and DRAM per target, binary size, the scenario tick matrix, +source and test line counts, and the complexity warnings. A docs-only commit moves none of them and +takes seconds to prove it; a one-line driver change can move flash by kilobytes and nothing else +would say so. It RECORDS rather than passes or fails, and writes to the tree, so its output belongs +in the commit message (see below) and its diff belongs in the commit. Read the deltas before +committing: a number that moved without a reason in the diff is an irregularity to explain. The Improv smoke test needs an ESP32 on a USB port, so it is a recommendation rather than a blocker: it covers the provisioning path a user meets before the device is on the network, which nothing else exercises. Run it when the diff touches that path and a board is at hand, and say so in the commit when it is skipped. diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index bd0500d3..7c540a95 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -24,6 +24,55 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul ## Unreleased (`next-iteration`) +### Audio: `floor` is now the silence threshold in both level modes + +**Action: re-set `floor` on a device whose microphone you had tuned.** +Affects any device running the Audio module with a local microphone or line-in. + +`levels = automatic` is tuned with `floor` alone: how hard the learner levels, and how far it may +lift a band, are constants rather than controls, because both act on a per-band range the +conditioner has already normalized per rig, so one value serves every source. + +`floor` is what changes meaning, and why re-setting it is worth a minute. It is now the **silence +threshold** in both modes: below it a band reads zero and the learner does not learn from it. That +is what stops a quiet room being amplified to full scale, but it also means a `floor` tuned under +the old behavior can now gate audible sound. Raise it until a silent room reads still, then stop; +there is no second knob to compensate with. `gain` remains manual-only and keeps its meaning. + +Two behavior changes ride along and need no action. The spectrum now starts at 40 Hz rather than +~11 Hz, dropping a first band that could only ever hold mains hum, DC drift and rumble. And +AudioSpectrum's VU bar reads the raw level instead of the smoothed one, because it is the audio +test instrument and wants maximum response; every other effect keeps the calm smoothed VU. + +### MoonBase serves the OTA routes under the application's names + +**Action: nothing on most devices; a serial flash on a MoonBase device updated from a browser.** +Affects the 4 MB classic, `esp32-16mb` and the S3-Zero, the variants that carry MoonBase. + +MoonBase served `/install`, `/install-url`, `/boot-app`, `/last-url` and `/cancel` while the +application served `/api/firmware/upload`, `/api/firmware/url` and `/api/firmware/moonbase`: two +names for one operation, across images that a single browser page talks to in turn during one +update. It now serves them under the application's names. + +The break is between the two images on a device, not between a device and its config. A device +whose MoonBase predates this change still answers only the old names, so an updated application +handing over to it leaves the browser calling routes that image does not have. The way through is +the same as any MoonBase update: flash both images over serial once +([building.md](building.md#flashing-a-running-device-over-the-network)). A device flashed serially +from this version on is consistent and needs nothing. + +### `soundReactive` is now `audioReactive` + +**Action: re-set one control.** Affects Fish Tank, Flying Toasters, Pacman, Pong, Space Invaders, +Sprite Fountain and MovingHead, if you had turned the control on. + +One name for one thing: the service is `AudioService`, the frame is `AudioFrame`, the effects are +audio-reactive. The control that made a sprite effect follow the music was the last place still +calling it sound, so it is renamed rather than left as the odd one out. + +A restored config maps the old name to the new one and carries its value. On a device upgraded in +place the control returns to its default (off); switch it back on where you had it. + ### Noise2D is gone; Noise renders it **Action: re-set one control.** Affects any device with a Noise2D effect on a layer. diff --git a/docs/assets/light/effects/BeatRipplesEffect.gif b/docs/assets/light/effects/BeatRipplesEffect.gif new file mode 100644 index 00000000..a2dce52c Binary files /dev/null and b/docs/assets/light/effects/BeatRipplesEffect.gif differ diff --git a/docs/assets/light/effects/BeatRipplesEffect.png b/docs/assets/light/effects/BeatRipplesEffect.png new file mode 100644 index 00000000..f5b45fb7 Binary files /dev/null and b/docs/assets/light/effects/BeatRipplesEffect.png differ diff --git a/docs/assets/light/effects/ColorTrailsEffect.gif b/docs/assets/light/effects/ColorTrailsEffect.gif new file mode 100644 index 00000000..f942f845 Binary files /dev/null and b/docs/assets/light/effects/ColorTrailsEffect.gif differ diff --git a/docs/assets/light/effects/ColorTrailsEffect.png b/docs/assets/light/effects/ColorTrailsEffect.png new file mode 100644 index 00000000..50cbbcc6 Binary files /dev/null and b/docs/assets/light/effects/ColorTrailsEffect.png differ diff --git a/docs/assets/light/effects/RadialSpectrumEffect.gif b/docs/assets/light/effects/RadialSpectrumEffect.gif new file mode 100644 index 00000000..a2dce52c Binary files /dev/null and b/docs/assets/light/effects/RadialSpectrumEffect.gif differ diff --git a/docs/assets/light/effects/RadialSpectrumEffect.png b/docs/assets/light/effects/RadialSpectrumEffect.png new file mode 100644 index 00000000..d5a05ee2 Binary files /dev/null and b/docs/assets/light/effects/RadialSpectrumEffect.png differ diff --git a/docs/assets/light/effects/VuMetersEffect.gif b/docs/assets/light/effects/VuMetersEffect.gif new file mode 100644 index 00000000..4b94b7eb Binary files /dev/null and b/docs/assets/light/effects/VuMetersEffect.gif differ diff --git a/docs/assets/light/effects/VuMetersEffect.png b/docs/assets/light/effects/VuMetersEffect.png new file mode 100644 index 00000000..617b6e02 Binary files /dev/null and b/docs/assets/light/effects/VuMetersEffect.png differ diff --git a/docs/backlog/audio-dsp-roadmap.md b/docs/backlog/audio-dsp-roadmap.md index 5955c099..5bb5ccce 100644 --- a/docs/backlog/audio-dsp-roadmap.md +++ b/docs/backlog/audio-dsp-roadmap.md @@ -42,6 +42,14 @@ His contribution has two parts: composes cleanly with the adaptive gate below (a learned gate on a cleanly-filtered signal beats one on a raw signal). +**LedFx** (Python, host-side) — a network LED effect engine whose whole purpose is audio reactivity, +running on a PC and streaming pixels to WLED-class devices. Different architecture to ours (the host +renders, the device receives; we already interoperate through Art-Net / E1.31 / DDP in both +directions), so most of it does not transfer. Two pieces of its *analysis* do, and both are studied +in § Band spacing below: its **mel/bark band spacing** with hand-tuned variants, and its +**per-band asymmetric smoothing**. Worth naming because it reached those two independently of the +WLED lineage above, and its own source comments are unusually candid about which variants work. + **Damian Schneider (DedeHai)** — WLED core dev; WLED's audioreactive usermod carries an integer / fixed-point FFT path (~1.5 ms on a C3, >10× ArduinoFFT on FPU-less chips). The consensus (Troy + Frank) is that with esp-dsp FFT + biquads, **fixed-point is not necessary on FPU chips** (S3/P4) — projectMM's @@ -74,8 +82,13 @@ codec work leaves room for that class of source. ## Adaptive noise gate (softhack007's concept, our analysis) -Replace the borrowed `squelch`/`noiseFloor` knob ("a WLED-SR workaround, not a real gate") with a -proper adaptive noise gate. From softhack007 (granted permission to analyse); the assessment is ours. +**Partly built.** `floor` is now a real silence threshold on both the level and the band paths: below +it a band reads zero AND is not learned from, which is what stopped the learner amplifying an empty +room to full scale. What remains from the design below is the ADAPTIVE half: hysteresis, the +asymmetric open-fast/close-slow timing, and a learned threshold rather than a set one. + +The original framing, kept because the remaining work is judged against it: replace the borrowed +`squelch`/`noiseFloor` knob ("a WLED-SR workaround, not a real gate") with a proper adaptive gate. From softhack007 (granted permission to analyse); the assessment is ours. **The concept:** a standard [noise gate](https://en.wikipedia.org/wiki/Noise_gate) (below a threshold the signal is silenced, above it passes), **asymmetric bang-bang timing** (open fast, close slow; diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 6f1030a2..882da853 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -2,6 +2,19 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/core/`, `src/platform/`, build, CI, network, persistence, UI). The light-domain counterpart is [backlog-light.md](backlog-light.md); items that genuinely span both are in [backlog-mixed.md](backlog-mixed.md). Index + overview: [README.md](README.md). Completed items are removed. +### The audio-sync test waits on the wall clock (2026-09-06) + +`test/unit/core/unit_AudioService_sync.cpp` drives the quiet-packet case with `platform::delayMs(1)` +inside a 100-iteration polling loop, so the assertion that `level` reaches zero depends on real +elapsed time and on loopback UDP delivering within that window. It passes today and has not been +seen to flake, but it is the shape that produces a rare CI failure nobody can reproduce, and it +spends real milliseconds in a suite that otherwise runs on a test clock. + +The fix is the deterministic UDP seam plus `platform::setTestNowMs`, the same pattern every other +timing test here uses: feed the packet, advance the clock explicitly, assert. Raised by CodeRabbit +on PR #96 and deliberately not taken in that pass: it is pre-existing rather than part of that +diff, and swapping a test's transport is a change that wants its own verification. + ## Distribution ### OTA upload refuses a normal client: the body must arrive within ~50 ms (2026-09-02) diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 75116d20..f096df52 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -25,6 +25,117 @@ Forward-looking to-build items for the **light domain** (`src/light/`: drivers, MoonLight has several moving-head effects that have no equivalent here, two of them troyhack's. Migrate them all, on the power functions per the standing mandate rather than traced across. +### RMT over DMA on the S3/P4, with a completion callback (Funkelfetisch, July 2026) + +Funkelfetisch's fork carries a finished branch, `codex/upstream-rmt-rgbw-performance`, that the +classic-ESP32 flicker work of 2026-09-05 makes worth adopting: on chips with RMT DMA +(`SOC_RMT_SUPPORT_DMA`: S3, P4) it sets `with_dma` with the IDF-recommended 1024-symbol block, so +the frame streams from RAM and the refill interrupt that causes flicker on a DMA-less chip does not +exist at all. It replaces the blocking `rmt_tx_wait_all_done` with `rmt_tx_register_event_callbacks` +(`on_trans_done`) plus a per-channel busy flag so the next tick skips while a frame is in flight, +and reports `"RMT DMA"` in the driver status so a user can see which path is live. Files: +`platform_esp32_rmt.cpp` (+105), `RmtLedDriver.h` (+75), `LedDriverConfig.h`, `Correction.h` +(RGBW presets, a separate topic in the same branch), with unit tests. + +What it does NOT address: on the classic ESP32 the DMA half compiles to nothing, and nothing in it +moves the channel's interrupt off core 0 (the root cause found on the Dig-Next-2, fixed by +creating the channel from core 1). The two are complementary, one driver with the right answer +per chip: DMA where the silicon has it, the core-1 refill where it does not. Adopt his DMA and +callback path, keep the core hop, and drop the classic-only `txInFlight_` guard where his busy +flag covers it. Study, do not copy: write it against the seam as it stands, credit the branch. + +### A script's setControl rebuilds a control subtree on every write (2026-09-06) + +Measured on the P4 at .139: **2 fps**, with `MoonLive-2` at 251 ms and `MoonLive-3` at 245 ms per +tick, together 498 ms of a 506 ms frame, and HTTP down to a 0.5 s round trip because it is served +from the same loop. Both services ran `sweep.mls`, whose `tick20ms` writes four faders. Two copies +at 50 Hz is 400 control writes a second, and `Scheduler::setControl` calls `rebuildControls()` +unconditionally on each one: + +``` +clearControlsRecursive(); // wipes this module's controls AND every child's, recursively +defineControls(); // then rebuilds them all +``` + +So each fader write tears down and re-creates the whole `Control` subtree. A person moving a slider +does this a few times a second and nobody notices; a script at 50 Hz multiplies it by hundreds. + +Two things to fix, and they are independent. **The rebuild should be conditional**: a `live` +control's value change cannot alter the schema, and `rebuildControls` already computes +`schemaSignature()` before and after to decide whether to notify, so it knows. Rebuilding only when +the shape can actually change (what `setLive` and `affectsPrepare` already distinguish) removes the +cost for every value write, scripted or human. **And the script tick is too fast for its job**: a +fader sweep does not need 50 Hz, so `sweep.mls` should run on a slower tick, which also caps the +damage any future script can do through this path. + +Not a regression from the RMT work: it predates it and was found while measuring an unrelated slow +board. + +### Speed up the fluid solver: 4 fps at 128x128, and it is not the divide (2026-09-05) + +Measured on an S31 (RISC-V, 320 MHz, octal PSRAM at 200 MHz), `iterations` 5, depth 1: + +| grid | Fluid tick | fps | cycles per cell-update | +|---|---|---|---| +| 32x32 | 8.5 ms | 109 | 151 | +| 64x64 | 38 ms | 24 | 159 | +| 128x128 | 204 ms | 4 | 205 | + +A cell-update is four loads, three adds, a multiply and a store: under 20 cycles of arithmetic. It +costs **151 even at 32x32**, where the whole working set is small enough to cache, so the loop +itself is roughly 8x more expensive than the work it does. Memory adds a further 35% by 128x128 but +is not the wall: at 4 fps the solver moves ~25 MB/s, about 5% of what this PSRAM delivers. + +**A wrong turn worth recording.** The first diagnosis blamed the 64-bit divide in `relax()`, on the +reasoning that Xtensa has no integer divide instruction. It was implemented (a power-of-two shift +dispatched once per call, bit-exact over 8M values) and measured on the board: **no change, 326 ms +before and after**, so it was reverted. Two errors: the S31 is RISC-V rather than Xtensa, and at +151 cycles per update the divide was never the dominant term. A desktop measurement could not have +caught either, since arm64 divides in hardware; only the board settles it. + +**Allocation placement is worth 1.6x, and nobody chose it.** Same firmware, same grid, same +controls: **326 ms after a fresh boot, 204 ms after resizing the grid to 32 and back to 128**, +reproducible across reboots. The solver's six buffers are ~638 KB and land in PSRAM either way, but +where they land at boot is slower than where they land once the heap has moved. Whatever is done +about speed, this says a boot-time allocation can be paying a large penalty invisibly, and it is +worth understanding before optimizing the loop around it. + +Ordered by expected return: + +1. **Cut the 64-bit arithmetic in the inner loop.** Every cell computes an `int64` shift, an + `int64` multiply and an `int64` divide on a 32-bit core, where each is several instructions and + a register pair. This is the most likely source of the 151 cycles. A 32-bit formulation, or a + narrower intermediate with a proven bound, is the first thing to measure. Precedent: the SWAR + work found the 32-bit pair form bit-identical and 41% smaller. +2. **Hoist `idx()`.** The loop addresses five neighbors per cell through `idx(x, y)`, each a + multiply-add. Walking row pointers instead is the standard fix and removes most of the address + arithmetic. +3. **Understand the allocation-placement effect above**, since it is worth more than most loop + tuning and costs nothing to trigger deliberately once understood. +4. **Solve the pressure at half resolution.** Pressure is smooth, so a half-scale solve with a + bilinear upsample of its gradient costs a quarter of the cells. Same trade `fieldScale` already + makes for noise fields, measured 3.0x there. Changes the picture slightly, unlike 1 to 3. +5. **Fewer iterations, documented per target.** Linear in cost: 5 to 2 is 2.5x, and the picture + gets springier. The card should say what a target can afford rather than leaving a user to find + 4 fps. +6. **Question whether the full solver belongs on this class of board at all.** See the ColorTrails + entry below: a separable noise advection gets a flowing, swirling picture for two passes over + the grid and no solve. The fluid's own header already calls it a desktop and P4 effect. + +**Why `fluid.mle` runs at 13 fps while the compiled effect runs at 3.** They are not the same +algorithm, and the script is not a faster fluid: it is not a solver at all. `fluid.mle` calls +`flowCurl`, which is curl noise, a divergence-free velocity read analytically from noise +derivatives in one advection pass, with no solve. Per frame at 128x128 the script visits ~16k cells +and divides nowhere; the compiled solver visits ~429k, of which 318k carry the 64-bit divide. That +is 27x the work, and the measured gap is only 4.3x because the interpreter gives most of it back in +dispatch overhead. So a COMPILED curl effect would beat both. What curl cannot do is what a solver +does: no pressure, no interaction between jets, and no vortex forming out of the flow's own +history. Whether that is worth 27x is a question for the product owner's eyes. + +**Measure on hardware, not on the desktop**: this is an in-order-core property and the desktop +divides in hardware, which is exactly how the wrong diagnosis above survived a desktop check. +Record before and after in performance.md per target. + ## Drivers ### Logarithmic brightness, and a power budget the device knows about (2026-09-02) @@ -132,6 +243,25 @@ When the bus stalls mid-frame the WS2812 strip is left holding **random / max-br **Reference (study, don't copy — write fresh against our architecture):** the line-by-line source read is in [led-driver-psram-ring-analysis.md](led-driver-psram-ring-analysis.md); the ADR framing is [ADR-0014](../adr/0014-own-i80-dma-driver-below-esp-lcd.md) (which calls the internal-RAM-ring-with-CPU-refill "the only thing that can ever work on the classic ESP32," deferred to a phase 2). The S3/P4 MoonI80 ring is the closest in-tree prior art for the ring mechanics (linear self-terminating chain, per-drain refill, drain-count termination) — but its refill is a task and its buffers are internal-only *because the LCD_CAM GDMA can't sustain a PSRAM read at the shift clock*; the classic I2S ring is the inverse (PSRAM framebuffer legal, ISR refill mandatory), so it borrows the *shape* but not the constraints. Do the S3/P4 **ISR-refill + `MM_HOT`** work first (it proves the ISR-refill pattern in-tree on the friendlier unified-DIRAM chips); the classic raw-I2S ring is the next tier up, reusing that pattern where IRAM is genuinely tight. +**Why MoonI80 cannot serve the classic, and what this driver inherits (2026-09-06).** `MoonI80` +is written against **LCD_CAM**: it drives the GDMA link list and the LCD registers directly +(`gdma_link_*`, `lcd_ll_*`) to bypass `esp_lcd`'s per-transaction peripheral reset (ADR-0014). The +classic ESP32 has no LCD_CAM at all; its i80 is the **I2S** block in LCD mode, a different +peripheral with its own register file (`i2s_ll_*`) and its own DMA, so none of MoonI80's code +applies and `MoonLedDriver::lanesAvailable()` reports `platform::lcdLanes`, which is 0 there. The +picker hides the backend rather than gating it, which is why the classic has exactly one parallel +route today: `esp_lcd`'s I2S backend, whole-frame, internal-RAM-only, capped near 2048 lights. +This driver is the second route, and it stands in the same relation to `esp_lcd` on I2S as MoonI80 +does on LCD_CAM: same shape, no shared code. + +Two things it inherits from the 2026-09-06 i80 work, both worth keeping. It should claim **I2S +instance 1** and leave 0 for audio, for the reason recorded in the instance-split entry above +(instance 0 alone carries the PDM converters, nothing needs 1), and owning the peripheral directly +it can simply ASK for instance 1 rather than steering `esp_lcd` by parking instance 0, which is the +workaround the current backend needs. And it inherits the package-aware pin refusal: a pin the +package lacks wedges the flash cache silently, which cost a full day of bisection on the +ESP32-PICO-V3-02. + ### P4 Parlio streaming ring — lift the P4 Parlio ceiling past ~21K to light-count-independent (WANTED) **Port the ring concept to the P4 Parlio path**, to drive far more than its current whole-frame ceiling. troyhacks' MoonLight Parlio driver reaches **~21K LEDs RGB (~16K RGBW)** at 16 lanes — but NOT by materialising the whole encoded frame: he stages into a **fixed ~512 KB PSRAM buffer** and DMAs it out in **64 KB chunks** (`max_transfer_size = 65535`), so the DMA never needs the whole frame contiguous. That is the SAME idea as our MoonI80 ring, applied to Parlio on the P4 (where — unlike the S3 shift clock — the DMA *can* sustain PSRAM reads at the WS2812 rate). His ceiling is a *chosen buffer size*, not a hardware wall, so it caps at ~21K. @@ -420,7 +550,6 @@ not), and a module each. The manual level + 16-band FFT spectrum has shipped (AudioService; what landed and why is in [lessons.md](../history/lessons.md)). These are the deferred follow-ups, each its own increment: -- **Per-band noise-floor (kill a steady single-frequency hum)** — the bench mic picks up a constant ~258 Hz tone (a mains harmonic via the mic/supply) that lights one band even in silence. A high-pass can't remove it (it's well above the ~40 Hz DC-blocker cutoff) without also killing real bass; the clean fix is a per-band adaptive floor that learns each band's idle baseline and subtracts it, so a constant tone in one band gates to dark while the others stay sensitive. Minimal version ≈ 16 floats of state + ~16 ops/frame. This is the next concrete audio step. - **Adaptive conditioning** — auto noise-floor / auto-gain / smoothing so the display self-calibrates to a room ("sound off → dark, sound on → vivid") instead of being tuned by hand. A self-calibrating version was prototyped and removed; the manual `floor`/`gain` is the shipped baseline. Reinvent from scratch when wanted, and **tune it in a quiet room** — a noisy environment (a strong, varying low-frequency ambient) is the adversarial case that made the prototype hard to settle. (The per-band floor above is the first piece of this.) - **Adaptive noise gate** — replace the borrowed `squelch`/`floor`-as-gate with a real noise gate: asymmetric bang-bang timing (open fast, close slow), a relative "detect silence" test (thresholds as factors of a learned floor, not absolute sample counts), keying off the RMS envelope we already compute, GEQ/FFT bands left untouched. A softhack007 concept; analysed and judged in full (good idea, industry-standard, but tight on the <30ms budget; decompose into steps rather than overhaul) in AudioService.md § Adaptive noise gate. The recommended sequencing: the per-band floor above is step 1 (its complementary frequency-domain half), the relative-threshold-over-RMS is the cheap high-value cherry-pick as step 2, hysteresis/timing step 3, log-domain + soft-gate optional. Eventually retires the manual squelch. - **Pin auto-scan** — detect the mic's `sdPin` with `wsPin`/`sckPin` fixed (a noise-prompt + confirm convenience); ships today with explicit pin controls. @@ -528,6 +657,8 @@ For driving **lots of LEDs**, internal SRAM is the scarce resource and the paral The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on classic ESP32 — [`RmtLedDriver.h`](../../src/light/drivers/RmtLedDriver.h), `RmtSymbol.h`, `platform_esp32_rmt.cpp`) and increment 2 (2a multi-pin RMT, 2b parallel LCD_CAM on the S3 — [`LcdLedDriver.h`](../../src/light/drivers/LcdLedDriver.h) via [`ParallelLedDriver.h`](../../src/light/drivers/ParallelLedDriver.h), `platform_esp32_lcd.cpp`), all with host + on-board-loopback tests, hardware-proven. The locked decisions, file-by-file phases, the WiFi-flicker test-rig analysis, and the bench deviations (8-GPIO i80 bus, 2.67 MHz slot clock, SOC-macro gate, real-frame loopback) are in [lessons.md](../history/lessons.md), the [driver docs](../moonmodules/light/moxygen/RmtLedDriver.md), and the [analysis docs](../history/leddriver-analysis-top-down.md). What remains here is only the work that has **not** shipped and is tracked nowhere else. +- **RMT `int_ena` read-modify-write race (classic ESP32, level-5 refill).** `RMT.int_ena` is one register written by both the render task (arming a frame) and the level-5 refill handler (disarming a finished one), through `rmt_ll_enable_interrupt`'s `|=` / `&=`. A handler firing between the task's read and its write loses the task's update, leaving a channel armed or silent. Never observed: the window is a few instructions and the two writes rarely target one channel, so the symptom would be a stuck channel after hours rather than anything the bench shows. Left unfixed deliberately, because the two obvious guards are both wrong here and each was tried on hardware: `portENTER_CRITICAL_ISR` spins on a lock the level-5 handler has just preempted (it runs above `XCHAL_EXCM_LEVEL` 3 by design) and deadlocks the core, and a compare-and-swap builds but crashes, since `S32C1I` addresses only data memory and a peripheral register raises `EXCCAUSE` 3. The remaining candidate is masking to level 5 (`XTOS_SET_INTLEVEL`) around the two-instruction update, which needs no lock and no atomic bus access; it compiles but is unproven on hardware and wants a soak before it displaces firmware that is flicker-free on two boards. + - **sigrok/fx2lafw cross-check + MoonDeck "LED driver test" Python script** — the independent-clock proof and the run-from-MoonDeck flow ([analysis §5.3](../history/leddriver-analysis-top-down.md)). The on-board RMT-RX loopback (shipped) is the cheap CI correctness gate but a *compromised witness* for WiFi-induced flicker — the RX capture runs on the same ESP32 whose WiFi causes the glitch. The real flicker test is a **sustained capture (seconds) with WiFi associated + a packet flood**, decoding every frame for a byte-slip or reset-gap deviation; it validates the SHIPPED render↔encode split's WiFi isolation (drivers tick on core 1; WiFi lives on core 0). A DSLogic Plus (100 MS/s) upgrade is reactive — only if a flicker reproduces that 24 MS/s can't resolve. - **Chunked transfer (Step 4) — the 16K lever, and now the ONE mechanism behind three separate ceilings.** Split a frame into transactions the DMA can actually swallow, feeding them back-to-back. It was scoped as a Parlio fix; it is really a **core-path** fix, and the shift-register expander is only its third beneficiary. @@ -630,27 +761,68 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on **What it costs when it comes:** a small preallocated record queue the built-in writes into, drained from a housekeeping path through the existing platform output seam. The budget and the burst-spent message stay as they are; only where the bytes are written moves. Worth doing when a script is left with a print in it on a real fixture, which is the case the cap exists for. -- **ParallelLedDriver hangs in `esp_lcd_new_i80_bus` on classic ESP32** (2026-09-03). Setting any - pin list on a QuinLED Dig-Next-2 (ESP32-PICO-V3-02, IDF v6.1-rc1) resets the board: - `TG1WDT_SYS_RESET`, both CPUs stopped at the same PC, no panic and no coredump. Traced to the - call itself, which never returns: a log line immediately before `esp_lcd_new_i80_bus` prints and - the "created OK" line after it never does. RmtLedDriver on the same board is fine, so it is this - bus API rather than the chip or the wiring. - - **What it is NOT**, each ruled out on the bench: the frame size (hangs at 3200 and 10112 bytes - alike, both far inside the internal-DMA budget), duplicate pins parked on WR (hangs with 8 - distinct data pins), and the WR/DC pin choice (hangs on 10/11, on 21/22 and on 18/23). IDF does - declare `SOC_LCD_I80_SUPPORTED` for this target, so the driver is configured for an API the SOC - caps say exists. - - **Next step:** call `esp_lcd_new_i80_bus` from a bare IDF example on the same chip and IDF pin. If - that hangs too it is upstream and belongs in an IDF issue; if it returns, the difference is in our - bus config. Until then classic-ESP32 boards use RmtLedDriver, and `ParallelLedDriver` stays - registered and selectable rather than compiled out: hiding it would remove the one path anyone can - retest with, and the driver is correct on every LCD_CAM chip. - - LCD-MM cannot substitute here. It is `lcdLanes`-only by design (MoonLedDriver.h, - `lanesAvailable`) because the classic ESP32's i80 IS the I2S peripheral, which that backend does - not implement, so a chip without LCD_CAM has no second parallel route. +- **A bus pin that belongs to another peripheral is accepted, and takes the board off the + network** (2026-09-06). ParallelLedDriver's classic-ESP32 WR/DC defaults are 18/23, which are + IDF's Ethernet MDIO/MDC defaults on the same chip, so an Olimex ESP32-Gateway that added the + driver lost its Ethernet link within seconds and looked crashed (the firmware kept ticking on + serial; it stayed reachable only through its WiFi fallback). A DC pin of 5, the Gateway's PHY + reset line, did the same. The driver's own comment claims 18/23 are "unused by the catalog's + boards", which is false for every RMII board. Two fixes, both wanted: (1) classic defaults that + collide with nothing common (the RMII set 0/16-19/21-23/25-27, flash 6-11, straps 0/2/12/15, + and the Dig-Next-2's relays 5/20-22 and I2C 14/15 all excluded); (2) the pin registry already + detects a double claim (`PinsModule::flagConflicts`) but only colors the edge, so a control + write that lands on a GPIO another control owns should be refused with a status naming the + owner, the way a reserved flash pin already is. A collision with a network pin is worse than + most: it ends the session that could have fixed it, and the SMI pins stay re-muxed until a + reboot even after the driver releases them. + +- **Bench-verified classic parallel setups, not yet in the catalog** (2026-09-06). Every classic + board in `deviceModels.json` ships `RmtLedDriver`, so none carries a `ParallelLedDriver` entry, + and the driver's defaults (`clockPin` unset, `dcPin` 33) work on a classic board without one. + Two setups are proven on hardware and worth recording before they are lost: the **Olimex + ESP32-Gateway** drives 64 lights on GPIO 16 with `clockPin` 32 / `dcPin` 4, and its free pins are + scarce enough that a catalog entry should pin them explicitly rather than inherit a default (its + Ethernet PHY holds 18/23 as MDIO/MDC and 5 as reset, all of which the old defaults collided + with); the **QuinLED Dig-Next-2** drives 256 lights on GPIO 2 with `clockPin` unset / `dcPin` 33. + Add them when a classic board actually ships parallel output as its default, rather than + speculatively across the other 16 classic boards, none of which has been tested this way. + +- **Classic-ESP32 I2S instance split: LEDs on 1, audio on 0** (2026-09-06, SHIPPED, kept as the + rationale). The classic ESP32's parallel LED bus IS an I2S peripheral, so it contends with the + audio input, and the 2026-09-03 "hangs in `esp_lcd_new_i80_bus`" entry is closed: that was a pin + fault (the ESP32-PICO-V3-02 has no GPIO 18/23, which were the WR/DC defaults), not contention. + The split is fixed in silicon rather than chosen: instance 0 alone carries the PDM converters + (`I2S_LL_PDM2PCM_SUPPORTED_PORT_MASK` is `1U << 0`), and NOTHING on this chip requires instance + 1, so the LED bus is the one consumer that can always yield. It therefore takes 1 unconditionally + and audio takes 0, which removes the boot race entirely, leaves 0 for every audio source (PDM, + standard I2S, line-in ADC, codec), and costs nothing. `esp_lcd` picks the first FREE instance + rather than taking one by number, so 1 is claimed by holding 0 across bus creation. For contrast, + hpwit's I2SClocklessLedDriver hard-codes `I2S_DEVICE 0` and would take the PDM instance instead. + Both sides also retry once a second while they want a busy instance, so the loser of any + contention recovers without the user touching a control. + +- **Bench-verified classic parallel setups, not yet in the catalog** (2026-09-06). Every classic + board in `deviceModels.json` ships `RmtLedDriver`, so none carries a `ParallelLedDriver` entry, + and the driver's defaults (`clockPin` unset, `dcPin` 33) work on a classic board without one. + Two setups are proven on hardware and worth recording before they are lost: the **Olimex + ESP32-Gateway** drives 64 lights on GPIO 16 with `clockPin` 32 / `dcPin` 4, and its free pins are + scarce enough that a catalog entry should pin them explicitly rather than inherit a default (its + Ethernet PHY holds 18/23 as MDIO/MDC and 5 as reset, all of which the old defaults collided + with); the **QuinLED Dig-Next-2** drives 256 lights on GPIO 2 with `clockPin` unset / `dcPin` 33. + Add them when a classic board actually ships parallel output as its default, rather than + speculatively across the other 16 classic boards, none of which has been tested this way. + +- **Classic-ESP32 parallel LEDs and a PDM microphone are exclusive** (2026-09-06). The + 2026-09-03 "hangs in `esp_lcd_new_i80_bus`" entry is closed: the QuinLED Dig-Next-2 carries an + ESP32-PICO-V3-02, whose package has no GPIO 18/23 (its pads serve the in-package flash and PSRAM), + and the classic WR/DC defaults were exactly 18/23. The driver now refuses a pin the package lacks + and defaults both lines to unset (sunk onto input-only pads). What remains: IDF's LCD mode exists + on I2S0 only, and `esp_lcd` falls through to I2S1 when I2S0 is taken, which wedges the chip the + same silent way. A PDM microphone is also I2S0-only in hardware, so the platform refuses the bus + with a named status when I2S0 is held. Two follow-ups: report the I2S1 fallthrough upstream (it + should return an error), and note that the raw-I2S classic driver above (the ring, on I2S1 as + hpwit's driver runs) is what lets the two coexist, on top of lifting the 2048-light cap. A + standard I2S microphone (INMP441) is content on I2S1 and coexists today when the LED bus claims + I2S0 first. (The shared lane-driver scaffolding extraction — when a 3rd parallel backend lands — is tracked separately under [§ Extract shared lane-driver scaffolding](#extract-shared-lane-driver-scaffolding-when-the-3rd-parallel-backend-lands-deferred) above.) diff --git a/docs/building.md b/docs/building.md index 2efc3aec..91bf4a73 100644 --- a/docs/building.md +++ b/docs/building.md @@ -265,6 +265,57 @@ The Ethernet PHY type and pin map are runtime config, not baked into the build: `--profile` is accepted one release for migration: `--profile default` → `--firmware esp32`, `--profile eth-only` → `--firmware esp32-eth`. +### Flashing a running device over the network + +A board already on the network is updated over HTTP, with no cable. Which route to use depends on +whether the variant carries [MoonBase](architecture.md#moonbase-the-second-boot-image): a board +cannot rewrite the partition it is executing from, so on a MoonBase variant the app hands over to +MoonBase and MoonBase does the writing. + +**On a MoonBase variant** (`esp32`, `esp32-16mb`, `esp32s3-zero`, and any variant `build_esp32.py` +builds MoonBase alongside), it is two requests: + +```sh +# 1. the app reboots into MoonBase, with nothing staged. Back in ~4 seconds. +curl -X POST http:///api/firmware/moonbase + +# 2. MoonBase writes the app slot and reboots into it +curl --http1.1 -H "Expect:" --data-binary @build/esp32-/projectMM.bin \ + http:///api/firmware/upload +``` + +The second request ends with **no HTTP status** (curl reports 000): the device reboots into the new +image as the write completes, so the socket closes before a response arrives. That is success, not +failure. Confirm by reading the build back: + +```sh +curl -s http:///api/modules/Firmware # the `build` control names the commit and date +``` + +MoonBase serves the same route names as the application, so a page driving an update keeps calling +the same paths after the handover. It also installs unattended from a URL, which is what the UI's +update button uses: `POST /api/firmware/url` with the URL as the body. `POST /api/firmware/boot-app` +returns to the application without installing anything, and only boots an image that validates. + +**Without MoonBase**, the application takes the image directly on the same route, +`POST /api/firmware/upload`. It is one of only two streaming routes (`/api/file` is the other), so +the body may exceed the request buffer; every other route rejects an oversized body with 413. + +Two failure modes are worth recognizing, because both look like something else: + +- **413 from `/api/firmware/upload`** on a MoonBase variant means the request reached the + APPLICATION rather than MoonBase, and the app rejected an oversized body on a route it does not + stream. The device did not reboot into MoonBase, or booted back before the upload. Check with + `GET /moonbase`, which MoonBase answers and the app 404s. +- **`{"error":"incomplete request body"}`** from `/api/firmware/upload` means the body did not + arrive within the read window. Send with `--http1.1 -H "Expect:"` so the transfer starts + immediately instead of waiting for a `100 Continue` the device does not send. + +**A partition-table change needs a cable.** OTA writes the app, never the table, so a device on an +older layout adopts a new one only through a full serial flash (see the note under +[Firmware variants](#firmware-variants)). On the 4 MB classic that migration also moves the +filesystem, so the device comes back unprovisioned. + ### Why not Arduino The ESP32 target uses ESP-IDF directly for three reasons: diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 4ce73609..8adcdf3d 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -193,7 +193,7 @@ something seems off. **System** — who this device is and how it's doing: its name, the device model, uptime, frame rate, and live memory / storage bars. You may also see an **Audio** module here — devices with a built-in mic come with it set up for you, and on any -device you can add it yourself (it's how sound-reactive effects hear the music). +device you can add it yourself (it's how audio-reactive effects hear the music). Audio is just the first of many: any sensor or input — from hardware or over the network — lives here as its own module, and we're adding more all the time. diff --git a/docs/history/lessons.md b/docs/history/lessons.md index 043e7392..f605bf3f 100644 --- a/docs/history/lessons.md +++ b/docs/history/lessons.md @@ -676,3 +676,30 @@ and confirm that condition is actually present. An agent's shell is a particular because it routinely runs with policies, permissions and paths that no user has. Sibling of the sabotage rule above: there, force the failure to prove the test sees it; here, prove the test is standing in the place where the failure lives. + +## A silent watchdog reset is a hardware question before it is a software one (2026-09-06) + +`ParallelLedDriver` reset a QuinLED Dig-Next-2 on any pin set: `TG1WDT_SYS_RESET`, both CPUs stopped, +no panic, no coredump. Six software theories were investigated and every one was wrong: PSRAM (a +`CONFIG_SPIRAM=n` image hung identically), the ECO3 cache-lock livelock, dual-core (a single-core +image hung too), an IDF regression, the I2S instance, and the microphone holding the peripheral. + +The cause was that the board's ESP32-PICO-V3-02 **has no GPIO 18 or 23**: those package pins are NC +because their pads serve the in-package flash and PSRAM (datasheet Table 7), and 18/23 were exactly +the driver's WR/DC defaults. Muxing a peripheral onto an absent pad wedges the flash cache, so the +chip dies with the watchdog PC parked in `panicHandler` itself: the handler is in IRAM but every +function it calls is in flash. + +**The lesson is the order of investigation.** That signature, a reset with no panic and a PC inside +the panic handler, means the flash cache is gone, which is a *pin* fault far more often than a code +fault. Check the package before any software theory: `esptool chip_id` prints it, and +`esp_efuse_get_pkg_ver()` gives it at runtime. The same die ships in packages with different pins +bonded, so `GPIO_IS_VALID_GPIO` (which knows the die, not the package) says yes to a pin that does +not physically exist. Our `gpioCapability` now reads the package and refuses those pins by name. + +Two corollaries worth keeping. **Bisect inside the failing call, not around it**: `esp_rom_printf` +probes placed between the steps of `esp_lcd_new_i80_bus` located the fault in one flash, after a day +of reasoning from the outside (note the runtime log level is WARN, so `ESP_LOGI` probes are silent). +And **a differential board settles a chip question fastest**: the same firmware on an Olimex Gateway, +a plain classic ESP32, worked immediately, which said "this board" rather than "this code" before any +theory was formed. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 81b3e7d2..5b41349e 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,36 +1,45 @@ { - "commit": "df8a8b0f", + "commit": "7f46574a", "flash": { - "esp32s3-n16r8": 1998944, - "desktop": 1692152, - "esp32": 1923264, - "esp32p4rev1-eth": 1675216, + "esp32s3-n16r8": 2065376, + "desktop": 1855496, + "esp32": 2023776, + "esp32p4rev1-eth": 1955408, "esp32p4rev1-eth-wifi": 2019392, "esp32s3-n8r8": 1971008, - "esp32s31": 2105072, + "esp32s31": 2348592, "esp32-16mb": 1809472, "esp32-eth": 1397456, "esp32-wrover": 1843760, "qemu": 1383648, "esp32p4rev3-eth": 1643760, - "esp32s3-zero": 1788624 + "esp32s3-zero": 1788624, + "esp32-pico": 2071584 + }, + "measured": { + "esp32p4rev1-eth": "2026-09-06", + "esp32s31": "2026-09-06", + "esp32": "2026-09-06", + "esp32-pico": "2026-09-06", + "esp32s3-n16r8": "2026-09-06", + "desktop": "2026-09-06" }, "perf": { "desktop": { - "tick_us": 135, - "fps": 7407, + "tick_us": 137, + "fps": 7299, "scenario_p50": { "Layer_base_pipeline": { - "p50": 107, - "p95": 211, + "p50": 71, + "p95": 179, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "Layer_memory_1to1": { - "p50": 10, - "p95": 41, + "p50": 5, + "p95": 40, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" } } }, @@ -41,10 +50,10 @@ "scenario_matrix": { "MoonModule_control_change": { "desktop-macos": { - "p50": 131, - "p95": 246, + "p50": 144, + "p95": 248, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "esp32-eth-wifi": { "p50": 89895, @@ -157,10 +166,10 @@ }, "Audio_mutation": { "desktop-macos": { - "p50": 45, - "p95": 790, + "p50": 26, + "p95": 70, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 40, @@ -183,18 +192,18 @@ }, "Aurora_fps": { "desktop-macos": { - "p50": 937, - "p95": 1423, - "n": 4, - "last": "2026-09-04" + "p50": 1514, + "p95": 1828, + "n": 23, + "last": "2026-09-06" } }, "Driver_mutation": { "desktop-macos": { - "p50": 49, - "p95": 285, + "p50": 21, + "p95": 64, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 42, @@ -217,10 +226,10 @@ }, "Effects_composition": { "desktop-macos": { - "p50": 497, - "p95": 2259, + "p50": 169, + "p95": 768, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 549, @@ -231,18 +240,26 @@ }, "Fields_polar_lut": { "desktop-macos": { - "p50": 981, - "p95": 1206, - "n": 6, - "last": "2026-09-04" + "p50": 1234, + "p95": 1402, + "n": 24, + "last": "2026-09-06" + } + }, + "Fluid_solver": { + "desktop-macos": { + "p50": 220, + "p95": 249, + "n": 17, + "last": "2026-09-06" } }, "GridBlacks_blackpixel": { "desktop-macos": { - "p50": 7, - "p95": 25, + "p50": 2, + "p95": 12, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "esp32s3-n16r8": { "p50": 267, @@ -265,10 +282,10 @@ }, "GridLayout_resize": { "desktop-macos": { - "p50": 132, - "p95": 298, + "p50": 127, + "p95": 311, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "esp32-eth-wifi": { "p50": 82231, @@ -309,10 +326,10 @@ }, "Layer_base_pipeline": { "desktop-macos": { - "p50": 107, - "p95": 211, + "p50": 71, + "p95": 179, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 118, @@ -323,10 +340,10 @@ }, "Layer_memory_1to1": { "desktop-macos": { - "p50": 10, - "p95": 41, + "p50": 5, + "p95": 40, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 1, @@ -337,10 +354,10 @@ }, "Layouts_mutation": { "desktop-macos": { - "p50": 171, - "p95": 2190, + "p50": 96, + "p95": 248, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 111, @@ -389,10 +406,10 @@ }, "MoonLiveEffect_livescript": { "desktop-macos": { - "p50": 14, - "p95": 151, + "p50": 6, + "p95": 22, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "esp32s3-n16r8": { "p50": 8255, @@ -439,10 +456,10 @@ "last": "2026-08-20" }, "desktop-macos": { - "p50": 11, - "p95": 185, + "p50": 6, + "p95": 21, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 1, @@ -453,10 +470,10 @@ }, "MultiplyModifier_memory_lut": { "desktop-macos": { - "p50": 6, - "p95": 165, + "p50": 3, + "p95": 21, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 3, @@ -467,10 +484,10 @@ }, "MultiplyModifier_pipeline": { "desktop-macos": { - "p50": 129, + "p50": 126, "p95": 283, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 225, @@ -479,12 +496,20 @@ "last": "2026-07-08" } }, + "Trails_ladder": { + "desktop-macos": { + "p50": 355, + "p95": 608, + "n": 18, + "last": "2026-09-06" + } + }, "modifier_chain": { "desktop-macos": { - "p50": 78, - "p95": 473, + "p50": 47, + "p95": 123, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 69, @@ -501,10 +526,10 @@ }, "modifier_swap": { "desktop-macos": { - "p50": 46, - "p95": 456, + "p50": 24, + "p95": 87, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "esp32-eth": { "p50": 1010, @@ -539,10 +564,10 @@ }, "perf_full": { "desktop-macos": { - "p50": 589, - "p95": 2027, + "p50": 279, + "p95": 1114, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "esp32s3-n16r8": { "p50": 16915, @@ -571,10 +596,10 @@ }, "perf_light": { "desktop-macos": { - "p50": 35, - "p95": 134, + "p50": 17, + "p95": 61, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "esp32s3-n16r8": { "p50": 2485, @@ -615,10 +640,10 @@ "last": "2026-07-25" }, "desktop-macos": { - "p50": 586, - "p95": 2098, + "p50": 308, + "p95": 881, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "desktop-windows": { "p50": 649, @@ -641,10 +666,10 @@ "last": "2026-07-24" }, "desktop-macos": { - "p50": 9, - "p95": 38, + "p50": 4, + "p95": 16, "n": 32, - "last": "2026-09-03" + "last": "2026-09-06" }, "esp32p4rev1-eth": { "p50": 217, @@ -668,54 +693,54 @@ } }, "loc": { - "core": 24938, - "light": 31807, - "platform": 17463, - "ui": 10089, - "test": 54681, - "moondeck": 22488 + "core": 25516, + "light": 34615, + "platform": 18036, + "ui": 10204, + "test": 56361, + "moondeck": 22570 }, "comments": { "core": { - "lines": 9940, - "ratio": 0.431 + "lines": 10242, + "ratio": 0.434 }, "light": { - "lines": 12233, - "ratio": 0.423 + "lines": 13197, + "ratio": 0.419 }, "platform": { - "lines": 6117, + "lines": 6336, "ratio": 0.385 }, "ui": { - "lines": 2969, - "ratio": 0.311 + "lines": 3014, + "ratio": 0.312 }, "test": { - "lines": 10179, - "ratio": 0.213 + "lines": 10536, + "ratio": 0.214 }, "moondeck": { - "lines": 3643, - "ratio": 0.185 + "lines": 3673, + "ratio": 0.186 } }, "tests": { - "cases": 1876, - "scenarios": 25 + "cases": 1955, + "scenarios": 27 }, "docs": { - "md_files": 218, - "md_lines": 35121, + "md_files": 219, + "md_lines": 36362, "plans_files": 115, - "backlog_lines": 6338, - "lessons_lines": 678, - "claude_md_lines": 237 + "backlog_lines": 6747, + "lessons_lines": 705, + "claude_md_lines": 259 }, "complexity": { - "functions": 3290, - "over_threshold": 220, + "functions": 3441, + "over_threshold": 239, "worst_ccn": 108 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 0c6d62fa..183df585 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `df8a8b0f`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `7f46574a`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,69 +8,72 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop | 1,652 KB | - | - | carried | -| esp32 | 1,878 KB | 2,496 KB | 75% | carried | -| esp32-16mb | 1,767 KB | - | - | carried | -| esp32-eth | 1,365 KB | - | - | carried | -| esp32-wrover | 1,801 KB | - | - | carried | -| esp32p4rev1-eth | 1,636 KB | 4,096 KB | 40% | carried | -| esp32p4rev1-eth-wifi | 1,972 KB | - | - | carried | -| esp32p4rev3-eth | 1,605 KB | - | - | carried | -| esp32s3-n16r8 | 1,952 KB (+94 KB) ⚠ | 4,096 KB | 48% | yes | -| esp32s3-n8r8 | 1,925 KB | - | - | carried | -| esp32s3-zero | 1,747 KB | - | - | carried | -| esp32s31 | 2,056 KB | 4,096 KB | 50% | carried | -| qemu | 1,351 KB | - | - | carried | - -`Built: carried` means that firmware was NOT rebuilt this run and its number is the previous one, so an absent delta says nothing about the change. `Used` is against the app slot in the firmware's own partition table. +| desktop | 1,812 KB (+0 KB) ⚠ | - | - | yes | +| esp32 | 1,976 KB (+2 KB) ⚠ | 2,496 KB | 79% | yes | +| esp32-16mb | 1,767 KB | - | - | carried (age?) | +| esp32-eth | 1,365 KB | - | - | carried (age?) | +| esp32-pico | 2,023 KB (+2 KB) ⚠ | 3,072 KB | 66% | yes | +| esp32-wrover | 1,801 KB | - | - | carried (age?) | +| esp32p4rev1-eth | 1,910 KB | 4,096 KB | 47% | yes | +| esp32p4rev1-eth-wifi | 1,972 KB | - | - | carried (age?) | +| esp32p4rev3-eth | 1,605 KB | - | - | carried (age?) | +| esp32s3-n16r8 | 2,017 KB | 4,096 KB | 49% | yes | +| esp32s3-n8r8 | 1,925 KB | - | - | carried (age?) | +| esp32s3-zero | 1,747 KB | - | - | carried (age?) | +| esp32s31 | 2,294 KB | 4,096 KB | 56% | yes | +| qemu | 1,351 KB | - | - | carried (age?) | + +`Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table. ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 135 µs (−710 µs) ✓ | 7,407 (+6,224) ✓ | +| desktop | 137 µs (−17 µs) ✓ | 7,299 (+806) ✓ | | esp32 | 8,354 µs | 119 | ### Scenario tick by target (p50 of each sample window) | Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | unknown | |---|---|---|---|---|---|---|---|---|---| -| Audio_mutation | 45 (+12) ⚠ | 40 ? | 13,152 | 47 ? | - | - | - | - | - | -| Aurora_fps | 937 | - | - | - | - | - | - | - | - | -| Driver_mutation | 49 (+12) ⚠ | 42 ? | 12,812 | 39 ? | - | - | - | - | - | -| Effects_composition | 497 (+154) ⚠ | 549 ? | - | - | - | - | - | - | - | -| Fields_polar_lut | 981 | - | - | - | - | - | - | - | - | -| GridBlacks_blackpixel | 7 (+2) ⚠ | 8 ? | 269 ? | 267 ? | - | - | - | - | - | -| GridLayout_resize | 132 (−35) ✓ | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - | -| Layer_base_pipeline | 107 (+32) ⚠ | 118 ? | - | - | - | - | - | - | - | -| Layer_memory_1to1 | 10 (+1) ⚠ | 1 ? | - | - | - | - | - | - | - | -| Layouts_mutation | 171 (+41) ⚠ | 111 ? | 13,692 | 45 ? | - | - | 27 ? | - | - | +| Audio_mutation | 26 (−2) ✓ | 40 ? | 13,152 | 47 ? | - | - | - | - | - | +| Aurora_fps | 1,514 (+8) ⚠ | - | - | - | - | - | - | - | - | +| Driver_mutation | 21 (−11) ✓ | 42 ? | 12,812 | 39 ? | - | - | - | - | - | +| Effects_composition | 169 (−197) ✓ | 549 ? | - | - | - | - | - | - | - | +| Fields_polar_lut | 1,234 (+6) ⚠ | - | - | - | - | - | - | - | - | +| Fluid_solver | 220 (−2) ✓ | - | - | - | - | - | - | - | - | +| GridBlacks_blackpixel | 2 (−3) ✓ | 8 ? | 269 ? | 267 ? | - | - | - | - | - | +| GridLayout_resize | 127 (−1) ✓ | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - | +| Layer_base_pipeline | 71 (−23) ✓ | 118 ? | - | - | - | - | - | - | - | +| Layer_memory_1to1 | 5 (−4) ✓ | 1 ? | - | - | - | - | - | - | - | +| Layouts_mutation | 96 (−42) ✓ | 111 ? | 13,692 | 45 ? | - | - | 27 ? | - | - | | MoonLiveEffect_controls | 11 ? | - | 12,901 | 4,624 ? | - | - | - | - | - | -| MoonLiveEffect_livescript | 14 (+6) ⚠ | - | 13,433 ? | 8,255 ? | 11,336 ? | - | - | - | - | -| MoonLive_pipeline | 11 (+4) ⚠ | 1 ? | 9,604 ? | 3,278 ? | - | 11,398 ? | - | - | 4,393 ? | -| MoonModule_control_change | 131 | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - | +| MoonLiveEffect_livescript | 6 (−3) ✓ | - | 13,433 ? | 8,255 ? | 11,336 ? | - | - | - | - | +| MoonLive_pipeline | 6 (−4) ✓ | 1 ? | 9,604 ? | 3,278 ? | - | 11,398 ? | - | - | 4,393 ? | +| MoonModule_control_change | 144 (+1) ⚠ | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - | | MqttModule_haDiscovery_toggle | 3 ? | - | 36 ? | 36 ? | - | - | - | - | - | -| MultiplyModifier_memory_lut | 6 (+2) ⚠ | 3 ? | - | - | - | - | - | - | - | -| MultiplyModifier_pipeline | 129 (−4) ✓ | 225 ? | - | - | - | - | - | - | - | +| MultiplyModifier_memory_lut | 3 (−1) ✓ | 3 ? | - | - | - | - | - | - | - | +| MultiplyModifier_pipeline | 126 | 225 ? | - | - | - | - | - | - | - | | NetworkModule_eth_reconfigure | - | - | 1,169 ? | 97,843 ? | - | - | - | - | - | | NetworkModule_mdns_toggle | 13 ? | - | 36 ? | 36 ? | 21 ? | - | 109,767 ? | 93,963 ? | - | -| modifier_chain | 78 (+16) ⚠ | 69 ? | 13,337 | - | - | - | - | - | - | -| modifier_swap | 46 (+15) ⚠ | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - | -| perf_full | 589 (+160) ⚠ | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - | -| perf_light | 35 (+12) ⚠ | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - | -| peripheral_grid_sweep | 586 (+208) ⚠ | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - | -| peripheral_switch | 9 (+3) ⚠ | 9 ? | 437 | 46 ? | 217 ? | - | - | - | - | +| Trails_ladder | 355 (+2) ⚠ | - | - | - | - | - | - | - | - | +| modifier_chain | 47 (−8) ✓ | 69 ? | 13,337 | - | - | - | - | - | - | +| modifier_swap | 24 (−9) ✓ | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - | +| perf_full | 279 (−150) ✓ | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - | +| perf_light | 17 (−10) ✓ | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - | +| peripheral_grid_sweep | 308 (−177) ✓ | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - | +| peripheral_switch | 4 (−3) ✓ | 9 ? | 437 | 46 ? | 217 ? | - | - | - | - | Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario. -**Coverage: 96/225 cells measured (42%), 30 of them with 4+ samples (13%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. +**Coverage: 98/243 cells measured (40%), 32 of them with 4+ samples (13%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. ### desktop: isolated scenarios (p50 of the sample window) | Scenario | p50 | p95 | n | |---|---:|---:|---:| -| Layer_base_pipeline | 107 µs (+32 µs) ⚠ | 211 µs | 32 | -| Layer_memory_1to1 | 10 µs (+1 µs) ⚠ | 41 µs | 32 | +| Layer_base_pipeline | 71 µs (−23 µs) ✓ | 179 µs | 32 | +| Layer_memory_1to1 | 5 µs (−4 µs) ✓ | 40 µs | 32 | These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. @@ -78,36 +81,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 24,938 (+336) ⚠ | 9,940 | 43.1 % (+0.1 %) ⚠ | -| light | 31,807 (+1,382) ⚠ | 12,233 | 42.3 % (+0.2 %) ⚠ | -| platform | 17,463 | 6,117 | 38.5 % | -| ui | 10,089 (+125) ⚠ | 2,969 | 31.1 % (+0.4 %) ⚠ | -| test | 54,681 (+1,555) ⚠ | 10,179 | 21.3 % | -| moondeck | 22,488 (+74) ⚠ | 3,643 | 18.5 % (−0.1 %) ✓ | +| core | 25,516 (+30) ⚠ | 10,242 | 43.4 % (+0.1 %) ⚠ | +| light | 34,615 (+57) ⚠ | 13,197 | 41.9 % | +| platform | 18,036 (+173) ⚠ | 6,336 | 38.5 % | +| ui | 10,204 | 3,014 | 31.2 % | +| test | 56,361 (+206) ⚠ | 10,536 | 21.4 % | +| moondeck | 22,570 (+7) ⚠ | 3,673 | 18.6 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,876 (+82) ✓ | -| scenarios | 25 (+2) ✓ | +| unit cases | 1,955 (+7) ✓ | +| scenarios | 27 | ## Complexity | Metric | Value | |---|---:| -| functions | 3,290 (+76) ✓ | -| over threshold | 220 (+8) ⚠ | +| functions | 3,441 (+12) ✓ | +| over threshold | 239 (+1) ⚠ | | worst CCN | 108 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 218 (+4) ⚠ | -| markdown lines | 35,121 (+1,072) ⚠ | -| plan files | 115 (+1) ⚠ | -| backlog lines | 6,338 (+818) ⚠ | -| lessons lines | 678 (+30) ⚠ | -| CLAUDE.md lines | 237 (+21) ⚠ | +| markdown files | 219 | +| markdown lines | 36,362 (−207) ✓ | +| plan files | 115 | +| backlog lines | 6,747 (−232) ✓ | +| lessons lines | 705 (+27) ⚠ | +| CLAUDE.md lines | 259 | diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md index e918684b..8f2cbfe1 100644 --- a/docs/moonmodules/core/services.md +++ b/docs/moonmodules/core/services.md @@ -19,13 +19,16 @@ A Service (added by the user, not auto-wired): the audio source that feeds the F Audio module controls - `mode` — Local audio / Receive network / Simulate: analyze the on-board mic/line-in, consume a peer's audio off the network (WLED-compatible), or feed a synthesized signal. Receive network appears only on a network build; the controls below are its detail, shown per mode. +- `micMode`: (Local, I²S targets) `I2S` for a three-wire part (the INMP441 and most MEMS mics, and line-in ADCs), `PDM` for a two-wire one (a clock and a data line, as on the QuinLED Dig-Next-2's onboard microphone). PDM uses `wsPin` as its clock and `sdPin` as its data, and hides the two clock pins it does not have. - `sckPin` / `wsPin` / `sdPin`: (Local, I²S targets) the I²S GPIOs (bit clock / word-select / data; unset until entered). - `mclkPin`: (Local, I²S targets) master-clock GPIO for a line-in ADC that needs one (e.g. the PCM1808); leave unset for a plain mic. - `device`: (Local, desktop) the OS capture input: `default` follows the system setting; loopback devices appear when present, so effects can follow what the machine plays. Picked by list position: if the OS reorders devices, re-pick (`default` is order-stable). **Capturing what the machine plays (loopback), per OS.** macOS has no native loopback: install [BlackHole](https://existential.audio/blackhole/), create a **Multi-Output Device** in Audio MIDI Setup (tick your speakers/DAC first plus BlackHole, enable drift correction on BlackHole) and set it as the system output; the speakers keep playing while an identical copy lands in BlackHole, which this control captures. The Mac's volume keys go dead on a multi-output device: set volume in the player or on the DAC. Windows usually needs nothing: enable **Stereo Mix** (Sound settings, Recording tab) and pick it here; it taps the output while the speakers keep playing (VB-Cable is the fallback where a driver lacks Stereo Mix). Linux PulseAudio/PipeWire expose a **Monitor of ** source natively; just pick it. - `sampleRate` — (Local) mic/ADC sample rate. -- `floor` / `gain`: (Local) noise floor and input gain for the analysis. The default gain suits a quiet MEMS mic; a loopback device delivers near-full-scale digital audio, so turn `gain` down hard (single digits) or everything clips to maximum. +- `levels` — (Local) who sets the display window: `manual`, the `floor` and `gain` sliders, or `automatic`, a learner that measures each band's own floor and typical loudest level and maps that range onto the window. Automatic is what makes a treble band read level with a bass one under spectrally balanced material, instead of sitting at a fraction of it. The choice picks which controls below are shown, so the mode is one decision rather than four interacting sliders. +- `floor`: (Local) the **silence threshold**, in both modes: below it a band reads zero and, in automatic, the learner does not learn from it. That second half matters as much as the first, because a learner that studies an empty room maps its noise floor onto the whole display and shows a silent room at full scale. Raise `floor` until a quiet room reads still. It is the one setting that depends on the part and the room, so it is the knob to reach for first, and the only one automatic mode exposes. +- `gain`: (Local, manual) the width of the display window: higher is a narrower window, so the display runs hotter. It sizes the band window directly and scales the level's own window, which is wider because a block RMS covers more dB than a single frequency bin's peak. The default suits a quiet MEMS mic; a loopback device delivers near-full-scale digital audio, so turn `gain` down hard (single digits) or everything clips to maximum. - `send audio` — (Local, network build) send the local analysis as WLED audio-sync packets for the WLED ecosystem. - `simulate` — (Simulate) the synthetic pattern: `music` (a plausible song) or `sweep` (a deterministic band-marching test pattern). - `syncPort` — (network build) the UDP port (default 11988, the WLED standard), shown when sending or receiving; set it the same on both ends. `sync status` reports the live send/receive state. diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index f65825b3..fc36ee70 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -301,7 +301,7 @@ RMT is its own driver; the rest are `peripheral` choices on the one **Parallel L | Output | `peripheral` | Chip | Strands | Extra controls | Notes | |---|---|---|---|---|---| | **RMT** ([detail](moxygen/RmtLedDriver.md)) | *(own driver)* | any ESP32 (classic 8 ch, S3 4, P4 4 DMA) | one per RMT TX channel | `loopbackFrame` | The general single-/few-strand output; default for classic + S3 board entries. `loopbackFrame` bit-verifies a *whole frame*, catching frame-rate / RF corruption a 24-bit burst misses. | -| Parallel LED | **`i80`** | S3 / P4 / S31 (LCD_CAM) · classic (I2S) | **1–16** | `clockPin` `dcPin` | Over IDF's `esp_lcd` i80 bus. The **bus** is 8 or 16 bits wide (≤8 pins → 8-bit, 9–16 → 16-bit) — but the **pin count is free**: configure only the pins that drive something and the driver rounds the bus up around them, parking the spare lanes on a pin the peripheral already drives. `clockPin`/`dcPin` are i80 bus lines the LEDs ignore. **Capped by one contiguous DMA buffer**: the classic backend is internal-RAM only (I2S can't reach PSRAM) → **2048 lights**; LCD_CAM draws from PSRAM → **16384**. Over the cap it idles with a status rather than crashing. | +| Parallel LED | **`i80`** | S3 / P4 / S31 (LCD_CAM) · classic (I2S) | **1–16** | `clockPin` `dcPin` | Over IDF's `esp_lcd` i80 bus. The **bus** is 8 or 16 bits wide (≤8 pins → 8-bit, 9–16 → 16-bit) — but the **pin count is free**: configure only the pins that drive something and the driver rounds the bus up around them, parking the spare lanes on a pin the peripheral already drives. `clockPin`/`dcPin` are i80 bus lines the LEDs ignore: on the classic ESP32 `clockPin` defaults to unset (the platform sinks it onto an input-only pad, so no GPIO is spent) while `dcPin` needs a real pin because the bus toggles it in software every frame; on the LCD_CAM chips both need a real pad. On the classic the bus is an I2S peripheral and takes instance 1, leaving instance 0 (the only one with a PDM converter) for the microphone, so both run. **Capped by one contiguous DMA buffer**: the classic backend is internal-RAM only (I2S can't reach PSRAM) → **2048 lights**; LCD_CAM draws from PSRAM → **16384**. Over the cap it idles with a status rather than crashing. | | Parallel LED | **`MoonI80`** | S3 / P4 / S31 (LCD_CAM only) | **1–16**; ×8 per pin with an expander (**6 pins → 48 strands**) | `clockPin` `pinExpander` `latchPin` `useRing` `ringAuto`; 🔧 `shiftOverclock` `ringRows` `ringBufs` `ringPadUs` | The same LCD_CAM output as `i80` on **our own GDMA chain**, which buys two things `esp_lcd` cannot: a frame **streamed** through a small buffer pool instead of held whole (so length stops being a memory question), and a **74HCT595 pin expander** — one GPIO fans out to 8 strands. `ringAuto` (default on) derives the streaming geometry per config, so the manual `ring*` knobs and `shiftOverclock` (a faster '595 clock for short-wired rigs) are expert-only tuning — the full guide is on the technical page. No `dcPin` at all, and WR is routed only when a '595 needs it as its shift clock. Not on the classic ESP32 (its i80 is the I2S peripheral). The prime-only ring (frame fits the buffer pool) and the pin expander are wall-solid; the **lapping** ring (very long strands, where the ISR refills from a PSRAM source) has a known last-row sparkle on the largest configs, tracked in [the backlog](../../backlog/backlog-light.md). Why + what it costs: [ADR-0014](../../adr/0014-own-i80-dma-driver-below-esp-lcd.md). | | Parallel LED | **`Parlio`** | ESP32-P4 | **1–16** | — | The P4's parallel path; Parlio generates its own pixel clock, so no clock/dc pins to spend. Bus width follows the pin count. On P4-NANO a known-good 8-set is `20,21,22,23,24,25,26,27`. | diff --git a/docs/moonmodules/light/effects.md b/docs/moonmodules/light/effects.md index a199103a..a0a4c500 100644 --- a/docs/moonmodules/light/effects.md +++ b/docs/moonmodules/light/effects.md @@ -10,6 +10,27 @@ Effects are built from the shared [power functions](power-functions.md): the dra ## MoonLight effects + + +### ColorTrails 💫🖌️💨🌫️ · 3D + +Emitters pouring color into a flow that carries and folds it. What makes this one worth reading is what the flow is NOT: there is no velocity field. One noise value per row shifts that row sideways, one per column shifts that column up or down, and the two shears compose into something that reads as a swirling current. A 128x128 grid is steered by 256 numbers rather than 16k, which is why it runs on hardware where a real solver does not. + +Three emitters feed it: circles on an orbit, a Lissajous point tracing a figure that never closes on itself, and the rim of the panel with its hue walking around. The flow pulls the border inward, so it is a source rather than a frame. + +- `speed`: how fast the emitters travel. +- `flow`: how far a row or column is pushed, which is the strength of the current. +- `flowSpeed`: how fast the flow itself drifts and reverses. +- `scale`: the flow's spatial frequency: a few broad bands or many fine ones. +- `persistence`: how long color survives, as a half-life, so a trail is the same length in seconds at any framerate. +- `colorSpeed`: how fast the emitters walk the palette. +- `size`: the orbit's radius and the Lissajous figure's reach. +- `mode`: all three emitters, or one at a time to see what each contributes. + +Compare with [Fluid](#fluid): that one solves for pressure and gets vortices forming out of the flow's own history, at roughly twenty passes over the grid against this one's one. Reach for the solver when the medium is the subject, and for this when the subject is the color being carried. + +Origin: MoonLight · concept by [Stefan Petrick](https://github.com/StefanPetrick), composition by Jeff (mindful_stone / [4wheeljive](https://github.com/4wheeljive)) in [AuroraPortal](https://github.com/4wheeljive/AuroraPortal/blob/main/src/programs/colorTrails_detail.hpp) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_FastLED.h) + ### DistortionWaves 💫 · 2D @@ -208,7 +229,7 @@ Detail: [technical](moxygen/RandomEffect.md) -### Rings 💫🦅🖌️ · 2D +### Rings 💫🦅🖌️🎡 · 2D Rings effect preview @@ -433,7 +454,7 @@ Uses the global palette. Origin: projectMM original; inspired by Atari's Pong (1 -### Aurora 💫🖌️ · 3D +### Aurora 💫🖌️🌫️🎡 · 3D Aurora effect preview @@ -544,7 +565,7 @@ Origin: projectMM original, on Sébastien Truchet's 1704 tiling and the standard -### Fluid 💫🖌️ · 3D +### Fluid 💫🖌️🌊💨 · 3D Fluid effect preview @@ -567,7 +588,7 @@ Origin: projectMM original, after Stam 1999 "Stable Fluids" -### Nebula 💫🖌️ · 3D +### Nebula 💫🖌️💨🌫️ · 3D Nebula effect preview @@ -587,7 +608,7 @@ Origin: projectMM original, composing the noise-field and curl-flow kernels: the -### Trails 💫🖌️ · 3D +### Trails 💫🖌️💨🌫️ · 3D Trails effect preview @@ -605,7 +626,7 @@ Origin: projectMM original, in the flow-field idiom (4wheeljive's FlowFields, fr -### Tunnel 💫🖌️ · 3D +### Tunnel 💫🖌️🌫️🎡 · 3D Tunnel effect preview @@ -677,7 +698,7 @@ Origin: projectMM original, on Iñigo Quilez's raymarching and distance-function -### PolarNoise 💫🖌️ · 3D +### PolarNoise 💫🖌️🌫️🎡 · 3D PolarNoise effect preview @@ -753,7 +774,7 @@ Detail: [technical](moxygen/SphereMoveEffect.md) -### Spiral 💫🦅🖌️ · 2D +### Spiral 💫🦅🖌️🎡 · 2D Spiral effect preview @@ -1021,7 +1042,7 @@ Detail: [technical](moxygen/LissajousEffect.md) -### NoiseMeter 🐙🎵 · 3D +### NoiseMeter 🐙🎵🌫️ · 3D NoiseMeter effect preview @@ -1040,7 +1061,7 @@ Detail: [technical](moxygen/NoiseMeterEffect.md) -### Wave 💫 · 2D +### Wave 💫🌫️ · 2D Wave effect preview @@ -1079,7 +1100,7 @@ Detail: [technical](moxygen/FireEffect.md) -### Noise ⚡️💫🌙🐙 · 1D/2D/3D +### Noise ⚡️💫🌙🐙🌫️ · 1D/2D/3D Noise effect preview @@ -1113,6 +1134,50 @@ Detail: [technical](moxygen/AudioSpectrumEffect.md) [Tests](../../tests/unit-tests.md#audioservice) + + +### BeatRipples 💫🎶🖌️ · 2D + +Every beat is a stone dropped in water. The surface is a real wave simulation, the classic two-buffer scheme: each cell's next height is its neighbors' average doubled minus its previous height, damped, which is the discrete wave equation. That gives what a drawn expanding circle cannot: ripples that pass THROUGH each other, reflect off the walls and interfere into standing patterns. The loudest band decides where the stone lands, so a bass hit falls near the center and a treble hit out at the rim, and the hit's strength sets how deep. The surface is rendered by SLOPE rather than height, because a water surface is visible where it bends light. + +- `damping`: how long the water keeps ringing. +- `drop`: how deep a beat's stone falls. +- `rain`: idle drops when there is no music, so the surface is alive in silence. +- `shine`: how strongly the slope lights the surface. + +Origin: projectMM original, the two-buffer water simulation (Gomez 2000) driven by the onset detector + + + +### VuMeters 💫🎶🖌️ · 3D + +Sixteen needles, one per band, each with real mass. What makes a VU meter beautiful is not the dial, it is the needle: a physical meter is a spring and a damper, so it accelerates toward the signal, overshoots a peak, swings back and settles. That overshoot is why a mechanical meter reads as alive where a bar graph reads as a readout, and it is why the standard (IEC 60268-17) specifies 300 ms to 99% with 1 to 1.5% overshoot rather than a smoothing constant. + +Each band drives a damped harmonic oscillator integrated per frame, with the bass needles deliberately heavier than the treble ones, as they are on a real meter bridge: the low end swings, the high end flickers. The sixteen meters tile the panel as a grid of cells, as square as the shape allows, so a 64x64 panel is 4x4 dials and a 256x64 wall is 8x2. Each dial has a peak marker held at the highest reading and falling by a half-life, and a red zone past three quarters. On a cube every slice carries its own bank. + +- `damping`: how much the needle overshoots. High is a critically damped studio meter, low is a loose needle that swings past and bounces off the pin. +- `response`: how hard the needle chases the signal at all. +- `peakHold`: how long the peak marker stays up, as a half-life. +- `smooth`: drive from the meter ballistic rather than the raw band. Raw is the truer instrument here, since the needle has its own ballistics already. + +Origin: projectMM original, on the VU ballistics of IEC 60268-17 + + + +### RadialSpectrum 💫🎶🖌️🎡 · 3D + +The spectrum as ripples. Each band owns a sector around the center, mirrored left and right with the bass at the top and bottom; sound is born at the center and travels outward, so the radius is time and a ring's length is that band's recent history. It is the circular visualizer the music-video world settled on, a radial spectrogram, and it is also the diagnostic a bar analyzer is: every sector is one band, so a band that is stuck or pinned shows as a sector that never moves or never dims. On a cube, under the spherical mapping, the ripples are expanding shells. + +Nothing is transported. The effect keeps a short history of band frames and every light reads it, its angle choosing the band and its radius the age: a table read per light, cheaper than drawing bars. + +- `speed`: how fast sound travels outward, a ring every 10 to 105 ms. +- `persistence`: how far out a ripple stays visible. +- `smooth`: read the meter ballistic (`bandsSmoothed`) rather than the raw bands. Switching it is the comparison a person tuning the audio path wants: raw twitches, smoothed breathes. +- `beat`: a white shockwave born at the center on every detected onset, traveling out with the ripples. +- `polarTable`, `polarTable16`, `mapping`: the polar address, and cylindrical, spherical or radial on a volume (light/polar.h). + +Origin: projectMM original, the radial spectrogram on `PolarLut` and the onset detector + ### AudioVolume 💫🎵 diff --git a/docs/performance.md b/docs/performance.md index 430f26a0..831e39ee 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -395,8 +395,8 @@ Each parallel LED driver run on real hardware at a 128×128 = 16384-light grid, | **LCD_CAM i80** (MultiPinLedDriver) | ESP32-S3 N16R8 Dev | data `18,5,6,7,8,9,10,11` · WR(clock) `12` · DC `13` | Same encoder, healthy on real i80; encode scales ~6 µs/light (8×512 = 4096 → 23 ms; 8×1024 = 8192 → 50 ms) | **single-DMA init ceiling 8192–12288 lights** (8×1024 inits; 8×1536 → "LCD init failed — check pins/memory"). A data lane on WR/DC only corrupts *that* lane (it carries the bus-control waveform, not pixels), so the driver **warns and keeps running** — a board that wires all lanes but drives fewer strands can legitimately park WR/DC on an unused data pin. WR and DC on the *same* GPIO is rejected up front (the bus needs two distinct control lines). | | **RMT** | classic ESP32 (LOLIN D32 / WROOM) | `2,4,13,14,16,17,18,19` (pin 2 = a real 24-LED strand) | 8-pin RMT drives **8×256 = 2048 lights** (tick ~12.6 ms), scales to ~8192 before the tick plateaus; all lanes healthy, pin-2 strand verified lit | **silent alloc-fail:** the RMT symbol buffer sizes for the driver's `count` window, so `count=0` on a 16384-grid needs ~1.5 MB, fails on the ~90 KB heap, and `tick()` bails with **no status** (LEDs dark). Bound the driver with the start/count window; a status for this is [backlogged](backlog/backlog-light.md). | | **I2S i80** | classic ESP32 (ESP32-WROVER) | data `2,4,13,14,18,19,21,22` · WR(clock) `32` · DC `33` (pin 2 = a real strand, verified lit) | The classic ESP32 runs the **same** `MultiPinLedDriver` over the **I2S peripheral in i80 mode** (IDF routes the i80 API to I2S here, to LCD_CAM on the S3/P4 — one driver, chip-picked backend). 8-lane doubling sweep (128×128 grid, 2026-07-13): 64/pin (512) → 4877 µs, 128/pin (1024) → 8575 µs, 256/pin (2048) → 15638 µs. Scales linearly at **~7.6 µs/light** (heavier than the S3's LCD_CAM ~6 µs — the classic I2S clock path). `frameTime` reports the WS2812 wire floor (512 → 243 fps, 2048 → 67 fps). The `MultiPinLed` status reports the live count. **16 lanes work on classic too** (the I2S peripheral does the 16-bit i80 bus, 16×256 = 4096 verified), but the WROVER exposes only ~13 non-strap pins, so 8-lane is the practical set. | **Internal-RAM ceiling: 2048 lights at 8 lanes (4096 at 16).** The classic I2S backend **cannot DMA from PSRAM** (`esp_lcd_i80_alloc_draw_buffer` rejects `MALLOC_CAP_SPIRAM` — "external memory is not supported"), so its frame buffer is internal-DMA-RAM only (`maxBlock` ≈ 76 KB). Swept at 8 lanes on a 128×128 grid (2026-07-13): 64/pin (512) ✅, 128/pin (1024) ✅, **256/pin (2048) ✅ — then 512/pin (4096) and above → `i80 bus init failed — check pins / memory`**, a **clean degrade, not a crash** (uptime kept climbing through every rung). That lands exactly on the parallel-I2S acceptance floor (8×256 = 2048), so the classic chip meets its floor and no more. The opposite of the LCD_CAM row below, which reaches 16384 via PSRAM — the classic chip's DMA simply can't get there. **The render is decoupled from this ceiling:** the same sweep kept rendering the full 128×128 = 16384-light grid at every rung (`Layer` ≈ 511 ms/frame, from PSRAM) while the *output* was capped — so a big grid still renders, it just can't all reach the LEDs. At 16K lights the effect render (511 ms) dwarfs the output (24 ms), so multicore cannot help: the render is the wall on this chip. Two classic-only quirks the driver handles: the I2S i80 tx has an unconditional command phase whose busy-wait hangs to a watchdog reset unless given a real 8-bit command (`lcd_cmd_bits=8` / `kI80Cmd=0`), and the draw buffer + a done-ISR marked `IRAM_ATTR`. | -| **LCD_CAM 16-lane** | ESP32-S3 (SE 16 V1 + LightCrafter 16, n8r8) | SE16 data `47,48,21,38,14,39,13,40,12,41,11,42,10,2,3,1` · WR/DC `5`/`6`; LC16 data `47,21,14,9,8,16,15,7,1,2,42,41,40,39,38,48` · WR/DC ghost `33`/`34` | **Reaches the full 16384 lights — the 16K target — where Parlio caps at 4096.** SE16 16-lane doubling sweep (128×128 grid), **async double-buffer ON** (re-measured 2026-07-13 after Step 1.5): 512 → 1843 µs, 1024 → 3422 µs, 2048 → 6612 µs, 4096 → 15153 µs, 8192 → 26788 µs, **16384 → 49916 µs (~20 fps)** — the driver tick is now the *encode* alone, the WS2812 wire wait overlapped in background DMA (`frameTime` reports it separately: 16384 → 28786 µs). That's **~30–56 % faster than the pre-Step-1.5 blocking path** the earlier row measured (async **OFF** reproduces it within 3 %: 4096 → 22518 µs, 16384 → 77732 µs vs the old 21945 / 76979 µs — so the [lcd→i80 rename](moonmodules/light/drivers.md#led-drivers) is behavior-neutral; the speedup is Step 1.5, not the rename). The `MultiPinLed` status reports the live count (`driving N of 16384 lights`). | **No contiguous-block ceiling — the key difference from Parlio.** LCD_CAM allocates its DMA buffer via `esp_lcd_i80_alloc_draw_buffer` **from PSRAM**, so it isn't bound by the ~368 KB largest-internal-block limit that caps Parlio at 4096 lights; it drives all 16384. **16K is now ~20 fps** (up from ~13 fps pre-Step-1.5). The ENCODE is the wall here, not the wire: async hides the 28,786 µs wire behind DMA (which alone would allow ~35 fps), so the tick *is* the 49,916 µs encode → ~20 fps. Recovering the rest of the deep-per-lane wall (§ Step 3, [multicore top-down]()) — though the ~50 ms encode still runs on **core 0**, which on the LC16 **starves the W5500 SPI-Ethernet** (also core 0) → link drops, HTTP times out while the render loop keeps ticking. This is the measured contention that justifies the [multicore pipeline (Step 2)]() on classic/S3 — a core-budget limit, not a fault. | -| **Parlio 16-lane** | ESP32-P4 (testbench, n16r8) | 16 data pins `21,20,22,23,24,25,26,27,32,33,39,40,41,42,43,44` | 16-lane doubling sweep (`ledsPerPin` 32→256/pin on a 128×128 grid, 2026-07-12; reproduced within 0.3% on a second P4). Tick scales **linearly** with lights: 512 → 1653 µs, 1024 → 2925 µs, 2048 → 5514 µs, **4096 → 10760 µs** at 256/pin. **Async double-buffer shipped (Step 1.5, 2026-07-13):** with `doubleBuffer` ON, the ~7.5 ms WS2812 wire wait moves into background DMA, so the *driver* tick at 256/pin drops **10,820 → 3,790 µs** and the whole board rises **48 → 76 fps** (system tick 20.6 → 13.0 ms). The **`frameTime`** KPI reports the measured wire floor directly — live **7474 µs (133 fps max)** here (the true, measured output ceiling). With the wire hidden, the tick is now **effect render (~7.3 ms) + driver (~3.8 ms) serial** → the effect is the next bottleneck, which the [multicore pipeline (Step 2)]() overlaps toward the 133 fps `frameTime` ceiling. (`doubleBuffer` OFF reproduces the pre-Step-1.5 10,820 µs / 92 driver-fps exactly — the synchronous path, kept as the opt-out. ON is simply the better configuration; the switch exists to A/B it. Its one-frame latency saving is below the perceptual A/V-sync threshold, so there is no user class — sound-reactive included — that should run it OFF for latency.) The `ParlioLed` status reports the live count (`driving N of 16384 lights`). | **Single-DMA ceiling ≈ 4096 lights (256/pin).** 512/pin (8192) → `Parlio init failed — check pins / memory`. The P4 has 33 MB free heap but the largest *contiguous* internal block is ~368 KB, and the 16-bit single-shot DMA buffer needs one contiguous block — so it's a **contiguous-block limit, not total memory** (it bites well before the 65535-byte/lane byte cap). Reaching the full 16384 (1024/pin) needs the [Parlio chunked-transfer](backlog/backlog-light.md) work (frame split across DMA bursts) — deferred indefinitely, since >~65K lights on one chip is a network-distribution problem. | +| **LCD_CAM 16-lane** | ESP32-S3 (SE 16 V1 + LightCrafter 16, n8r8) | SE16 data `47,48,21,38,14,39,13,40,12,41,11,42,10,2,3,1` · WR/DC `5`/`6`; LC16 data `47,21,14,9,8,16,15,7,1,2,42,41,40,39,38,48` · WR/DC ghost `33`/`34` | **Reaches the full 16384 lights (the 16K target) where Parlio caps at 4096.** SE16 16-lane doubling sweep (128×128 grid), **async double-buffer ON** (re-measured 2026-07-13 after Step 1.5): 512 → 1843 µs, 1024 → 3422 µs, 2048 → 6612 µs, 4096 → 15153 µs, 8192 → 26788 µs, **16384 → 49916 µs (~20 fps)**: the driver tick is now the *encode* alone, the WS2812 wire wait overlapped in background DMA (`frameTime` reports it separately: 16384 → 28786 µs). That's **~30–56 % faster than the pre-Step-1.5 blocking path** the earlier row measured (async **OFF** reproduces it within 3 %: 4096 → 22518 µs, 16384 → 77732 µs vs the old 21945 / 76979 µs, so the [lcd→i80 rename](moonmodules/light/drivers.md#led-drivers) is behavior-neutral; the speedup is Step 1.5, not the rename). The `MultiPinLed` status reports the live count (`driving N of 16384 lights`). | **No contiguous-block ceiling, the key difference from Parlio.** LCD_CAM allocates its DMA buffer via `esp_lcd_i80_alloc_draw_buffer` **from PSRAM**, so it isn't bound by the ~368 KB largest-internal-block limit that caps Parlio at 4096 lights; it drives all 16384. **16K is now ~20 fps** (up from ~13 fps pre-Step-1.5). The ENCODE is the wall here, not the wire: async hides the 28,786 µs wire behind DMA (which alone would allow ~35 fps), so the tick *is* the 49,916 µs encode → ~20 fps. Recovering the rest of the deep-per-lane wall (§ Step 3, [multicore top-down](#multicore-the-whole-output-stage-on-core-1-multicore-step-2)), though the ~50 ms encode still runs on **core 0**, which on the LC16 **starves the W5500 SPI-Ethernet** (also core 0) → link drops, HTTP times out while the render loop keeps ticking. This is the measured contention that justifies the [multicore pipeline (Step 2)](#multicore-the-whole-output-stage-on-core-1-multicore-step-2) on classic/S3, a core-budget limit, not a fault. | +| **Parlio 16-lane** | ESP32-P4 (testbench, n16r8) | 16 data pins `21,20,22,23,24,25,26,27,32,33,39,40,41,42,43,44` | 16-lane doubling sweep (`ledsPerPin` 32→256/pin on a 128×128 grid, 2026-07-12; reproduced within 0.3% on a second P4). Tick scales **linearly** with lights: 512 → 1653 µs, 1024 → 2925 µs, 2048 → 5514 µs, **4096 → 10760 µs** at 256/pin. **Async double-buffer shipped (Step 1.5, 2026-07-13):** with `doubleBuffer` ON, the ~7.5 ms WS2812 wire wait moves into background DMA, so the *driver* tick at 256/pin drops **10,820 → 3,790 µs** and the whole board rises **48 → 76 fps** (system tick 20.6 → 13.0 ms). The **`frameTime`** KPI reports the measured wire floor directly: live **7474 µs (133 fps max)** here (the true, measured output ceiling). With the wire hidden, the tick is now **effect render (~7.3 ms) + driver (~3.8 ms) serial**, so the effect is the next bottleneck, which the [multicore pipeline (Step 2)](#multicore-the-whole-output-stage-on-core-1-multicore-step-2) overlaps toward the 133 fps `frameTime` ceiling. (`doubleBuffer` OFF reproduces the pre-Step-1.5 10,820 µs / 92 driver-fps exactly: the synchronous path, kept as the opt-out. ON is simply the better configuration; the switch exists to A/B it. Its one-frame latency saving is below the perceptual A/V-sync threshold, so there is no user class (audio-reactive included) that should run it OFF for latency.) The `ParlioLed` status reports the live count (`driving N of 16384 lights`). | **Single-DMA ceiling ≈ 4096 lights (256/pin).** 512/pin (8192) → `Parlio init failed, check pins / memory`. The P4 has 33 MB free heap but the largest *contiguous* internal block is ~368 KB, and the 16-bit single-shot DMA buffer needs one contiguous block, so it's a **contiguous-block limit, not total memory** (it bites well before the 65535-byte/lane byte cap). Reaching the full 16384 (1024/pin) needs the [Parlio chunked-transfer](backlog/backlog-light.md) work (frame split across DMA bursts), deferred indefinitely since >~65K lights on one chip is a network-distribution problem. | **LOLIN D32 (classic ESP32-WROOM) usable LED GPIOs:** `4,13,14,18,19,21,22,23,25,26,27,32,33` plus `16,17` (free on WROOM — they're the PSRAM bus only on WROVER). Avoid straps `0,2,12,15`, the onboard LED on `5`, and battery-sense on `35`; input-only `34–39` can't drive an LED. (Chip-level set: [gpio-usage.md](reference/gpio-usage.md).) diff --git a/docs/reference/gpio-usage.md b/docs/reference/gpio-usage.md index 50202ea7..0c253edc 100644 --- a/docs/reference/gpio-usage.md +++ b/docs/reference/gpio-usage.md @@ -32,14 +32,16 @@ For **LED output** specifically — the pins a WS2812-class strand data line can |-------|-------|-----| | Reserved | **6-11** | SPI flash (the on-package flash bus). Always off-limits. | | Reserved | **16, 17** | Extra flash/PSRAM bus on WROVER (PSRAM) modules; free on plain WROOM. | +| Package | **PICO-V3-02: 9, 10 reserved; 16, 17, 18, 23 absent** | The system-in-package part on the QuinLED Dig-Next-2 wires its PSRAM to 9/10 and has no pads for 16/17/18/23 (datasheet Table 7); routing a peripheral onto an absent pad wedges the flash cache with no panic. 7/8 are free on it. The firmware reads the package from eFuse and refuses these by name. | +| Package | **PICO-D4: 6-11, 16, 17 reserved** | In-package flash on all of them, PSRAM or not. | | Role-conflict | **0, 2, 5, 12, 15** | Boot straps. Usable, but a wrong level at reset changes boot mode (GPIO 12 = flash voltage is the dangerous one). Don't drive during boot. | | Input-only | **34, 35, 36, 39** | No output driver and no internal pull-ups. Fine for a mic **SD/SCK/WS input**, useless for an LED output. | -**Clear for output I/O:** 4, 13, 14, 18, 19, 21-23, 25-27, 32, 33 (plus 16/17 on non-PSRAM). Input-only 34-39 suit mic data lines. +**Clear for output I/O:** 4, 13, 14, 18, 19, 21-23, 25-27, 32, 33 (plus 16/17 on non-PSRAM; minus 18/23 on a PICO-V3-02). Input-only 34-39 suit mic data lines, and 36 is where the classic i80 driver sinks an unset WR line. -**i80 parallel LED lanes (classic ESP32 = I2S backend).** The classic ESP32 runs the parallel LED driver over the **I2S peripheral in i80 mode** (the S3/P4 use LCD_CAM for the same i80 API; one driver, chip-picked backend). Like every i80 board it needs **exactly 8 or 16 data pins plus two bus-control pins** (`clockPin` = WR, `dcPin` = DC — the LEDs ignore both). Bench-verified default on the ESP32-WROVER (`/dev/cu.usbserial-0001`): +**i80 parallel LED lanes (classic ESP32 = I2S backend).** The classic ESP32 runs the parallel LED driver over the **I2S peripheral in i80 mode** (the S3/P4 use LCD_CAM for the same i80 API; one driver, chip-picked backend). The bus is 8 or 16 bits wide but the **pin count is free** (spare lanes park on WR), and the two bus-control lines (`clockPin` = WR, `dcPin` = DC) are ignored by the LEDs. **WR is unset by default** on the classic (the platform sinks it onto an input-only pad, so it costs no GPIO); set it only for a '595 expander that needs it as a shift clock. **DC always needs a real output pin** (default 33): `esp_lcd` toggles it in software every frame, which a pad without an output driver cannot do. The bus is an **I2S peripheral** and always drives from **instance 1**, leaving instance 0 for audio: only instance 0 has the PDM converter, and nothing needs instance 1, so a microphone (PDM or standard), a line-in ADC and parallel LEDs all run together. Bench-verified on the ESP32-WROVER (`/dev/cu.usbserial-0001`): -- **8 lanes:** data `2,4,13,14,18,19,21,22` · `clockPin` (WR) `32` · `dcPin` (DC) `33`. (Pin 2 is a boot strap — it drives an LED fine and idles LOW, so it's benign here, but the Pins UI flags it; swap it for `23` to silence the warning.) +- **8 lanes:** data `2,4,13,14,18,19,21,22`, WR unset, DC `33`. (Pin 2 is a boot strap: it drives an LED fine and idles LOW, so it is benign here, but the Pins UI flags it; swap it for `23` to silence the warning.) - **16 lanes: not cleanly reachable on the WROVER.** Only 13 non-strap output pins exist, and WR/DC consume 2 more, so a 16-lane set must borrow strap pins (0, 12, 15) — and **GPIO 12 is the flash-voltage strap: driving it at reset can brick the boot**, so don't. Use a board with more free GPIOs (an S3/P4) for 16 lanes; the classic ESP32 is an 8-lane i80 board in practice. - **Memory ceiling: 2048 lights at 8 lanes** (measured on the WROVER, 2026-07-13; 8×256 drives, 8×384 already fails). The I2S backend can't DMA from PSRAM, so its frame buffer is internal-RAM-only (~76 KB largest block) — an over-ceiling config degrades with `i80 bus init failed — check pins / memory`, it does not crash. Neither shrinking the grid (that memory is PSRAM) nor turning `doubleBuffer` off (one DMA buffer instead of two) raises it. See [performance.md § Multi-pin LED driving](../performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid). diff --git a/docs/tutorials/generative-effects.md b/docs/tutorials/generative-effects.md index 485aaf18..a7f3006c 100644 --- a/docs/tutorials/generative-effects.md +++ b/docs/tutorials/generative-effects.md @@ -287,6 +287,104 @@ script, pick one number, and move it a long way. | `persistence` low to high | tails from a flicker to seconds long | half-life decay | | Fluid's `iterations` to 1 (the compiled effect, not the script) | the flow reads springy | the pressure solve is what makes it a fluid | +--- + +## 10. Making one good: what the failures teach + +The kernels above are the easy half. An effect built correctly on them can still be +dull, and eight built in one day produced four keepers: the other four were dropped +for being unattractive, too sparse, too slow, or for dying out while the music played. +What follows is what those four cost, written down so the next effect skips them. + +### Measure the picture, do not theorize about it + +Every wrong diagnosis on this page came from reasoning about code instead of reading a +number off the frame. Rendering to a buffer and counting is cheap: what fraction of +lights are lit, what the brightest one is, how far the picture moves between frames. + +One effect rendered pure black for a day. The cause was a scale mismatch (a wave pressed +15 units deep while the renderer divided slopes by 512, so every ripple came out below the +visibility threshold), and no amount of reading the code found it. One measurement did: +brightest light 0, in a simulation whose physics was fine. Cheap checks, in order: + +| Reads | Means | +|---|---| +| lit fraction near 0 | nothing is reaching the buffer, or everything is below threshold | +| lit fraction near 1 | the effect has no structure, only a wash | +| brightest light far below 255 | a scale mismatch between what is computed and what is displayed | +| frames identical over time | the simulation has converged, or the clock is not advancing | + +Pick a quantity that cannot cancel. A center of mass on a symmetric flow stays put while +every parcel moves, and reports "nothing is happening" about a working effect. + +### Match the scales at every seam + +Half of the day's bugs were one number expressed in the wrong unit. A control is 0..255, +a height field rings to ±20000, a fixed-point position carries 16 fractional bits. Every +place two of those meet is a place where a plausible-looking line silently produces +nothing, and nothing about it looks wrong on the screen. + +Write the conversion where the value crosses, name the constant, and say in a comment what +range each side speaks in. When something renders black or blindingly white, suspect a +seam before suspecting the algorithm. + +### A simulation converges: keep feeding it + +A fluid driven by forces at fixed positions and fixed angles reaches a steady state and +stops. The picture goes still while the music keeps playing, which reads as a crash. This +killed one effect outright. + +Anything that integrates its own state needs its input to keep changing: rotate where the +forces are applied, lean their direction, alternate their sense. Motion in the input is +what keeps motion in the output. + +### Frame-rate independence is not optional + +A simulation stepped once per rendered frame runs at whatever speed the hardware happens +to deliver. Measured here: 16x faster on a desktop at 1200 fps than on a device at 60. The +same effect that reads well on a panel is unusable on a bench. + +Accumulate elapsed time and step a fixed amount (16 ms works), capping the catch-up so a +stall cannot spend a second of frames at once. Everything that moves goes inside that loop, +including trail fades: leaving the fade outside makes trail length depend on frame rate +even when the motion does not. + +### Fill the screen, and keep filling it + +Two effects were dropped for the same reason: at rest they showed too little. An effect is +judged on the whole panel, so a good one covers it, and covers it in the first second. +Sparse output at 6% of lights is a demo, not an effect. + +Two habits fix most of it. Give the effect something to do with no input at all, so it is +never blank while someone waits for a beat. And start its clocks primed rather than at +zero: an effect whose first event is two seconds away reads as broken long before it reads +as calm. + +### Slow is a design property, not a tuning problem + +An effect that misses its frame time on the target device is not a slow effect to be +optimized later. On the S31 one solver-per-frame effect was both too slow and too dull, +which is the common case rather than a coincidence: the expensive part was not the part +doing the visual work. Decide what the effect spends its budget on before building it, and +measure on the smallest device it claims to support. + +### Two failed attempts means stop + +The rule the project already carries applies hardest here, because a picture always +suggests one more plausible tweak. Two attempts that do not fix it mean the diagnosis is +wrong, not the parameters. On this page a second "fix" made an effect visibly worse than +the bug it targeted. + +### Pin the behavior, not just the pixels + +A golden-hash test passes happily on an all-black frame, which is how an effect shipped +rendering nothing while its test stayed green. A hash pins *which* pixels light; it says +nothing about whether any of them do. + +Pin the property that would have caught the failure: that the surface is visible, that it +still moves after a hundred frames. Then confirm the test fails against the broken code +before trusting it, because a test that passes on both is measuring nothing. + ## Where to go next - **[Power functions](../moonmodules/light/power-functions.md)**: the catalog, with what each one costs and who calls it diff --git a/docs/tutorials/how-projectmm-works.md b/docs/tutorials/how-projectmm-works.md index 3d8a2172..b75bbc64 100644 --- a/docs/tutorials/how-projectmm-works.md +++ b/docs/tutorials/how-projectmm-works.md @@ -183,11 +183,22 @@ The rest the module declares about itself: | 🦅 | a named contributor, credited on the module | | 🎵 volume · 🎶 frequency | it listens: one note reacts to how LOUD the room is, two to WHICH notes are playing | | 📡 | it takes its picture from the network | -| ✨ | built from particles: sparks that are born, fall and die | | 🎯 | it aims moving heads | -| 🖌️ | a shader: every pixel computed from its own position, the way a screen shader works | | 👾 | pixel art: the games and sprites | | 🧬 | a simulation: the picture emerges from cells evolving off their own last frame, rather than being drawn | + +And a last group, shown together at the end of the row, saying which **power functions** the effect +is built on. These are the kernels of [the shared library](../moonmodules/light/power-functions.md), +so they group effects by what they are made of, and by what that makes them look like: + +| | | +|---|---| +| 🖌️ | a shader: every pixel computed from its own position, the way a screen shader works | +| ✨ | particles: sparks that are born, move under forces and die | +| 🌊 | a fluid: a medium that works out its own motion, so a vortex forms and travels because the equations say so | +| 💨 | transport: light is CARRIED and fades rather than redrawn, so the effect has a memory of where it has been | +| 🌫️ | a noise field: texture sampled from a field rather than drawn, the cloud and smoke family | +| 🎡 | polar: composed around a center rather than across a grid, which is what suits a round fixture | | 📹 | motion-tracking aware: it follows people or objects moving in the room *(reserved, nothing carries it yet)* | A module can carry several: `💫🎶` is a MoonLight effect that reacts to frequency. diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index d4200a18..621865c2 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -23,6 +23,7 @@ idf_component_register( "../../src/platform/esp32/platform_esp32_ota.cpp" "../../src/platform/esp32/platform_esp32_improv.cpp" "../../src/platform/esp32/platform_esp32_rmt.cpp" + "../../src/platform/esp32/rmt_hi_vector.S" "../../src/platform/esp32/platform_esp32_tasks.cpp" "../../src/platform/esp32/platform_esp32_worker.cpp" "../../src/platform/esp32/platform_esp32_gpio.cpp" @@ -55,6 +56,12 @@ if(CONFIG_IDF_TARGET_ARCH_XTENSA) target_compile_options(${COMPONENT_LIB} PRIVATE -mauto-litpools) endif() +# Classic ESP32 only: the level-5 RMT refill vector (rmt_hi_vector.S) overrides a WEAK default, so +# nothing references its object and the linker would drop it. -u forces the symbol in. +if(CONFIG_IDF_TARGET_ESP32) + target_link_libraries(${COMPONENT_LIB} INTERFACE "-u ld_include_rmt_hi_vector") +endif() + # Firmware-variant defines, set by moondeck/build/build_esp32.py via firmware_cmake_args(). # See docs/architecture.md § Firmware vs board — "firmware" is the compiled # binary variant; the physical "board" is a separate concept the device diff --git a/moonbase/main/moonbase_main.cpp b/moonbase/main/moonbase_main.cpp index d778b253..2e8f5f5f 100644 --- a/moonbase/main/moonbase_main.cpp +++ b/moonbase/main/moonbase_main.cpp @@ -394,7 +394,7 @@ const char kPage[] = // size and matches how the application's own upload route works. "async function up(){const f=document.getElementById('f').files[0];if(!f)return;" "S('installing '+(f.size/1024|0)+' KB...');" - "const r=await fetch('/install',{method:'POST',body:f});" + "const r=await fetch('/api/firmware/upload',{method:'POST',body:f});" "S(await r.text());}" // The install runs on its own task (202); W() watches its status until the app answers // (404 on /moonbase means the new firmware is up, at this same address). @@ -403,14 +403,14 @@ const char kPage[] = "setTimeout(()=>location.reload(),3000);}else{S(await p.text());}}" "catch(_){S('restarting...');}},2000);}" "async function url(){const u=document.getElementById('u').value;if(!u)return;" - "const r=await fetch('/install-url',{method:'POST',body:u});S(await r.text());if(r.ok)W();}" + "const r=await fetch('/api/firmware/url',{method:'POST',body:u});S(await r.text());if(r.ok)W();}" // Prefill the URL field with the last install source (RAM-held), so Install doubles as // retry: the escape after a cancel or failure wiped the app slot. - "fetch('/last-url').then(r=>r.text()).then(u=>{if(u)document.getElementById('u').value=u;})" + "fetch('/api/firmware/last-url').then(r=>r.text()).then(u=>{if(u)document.getElementById('u').value=u;})" ".catch(()=>{});" - "async function ba(){const r=await fetch('/boot-app',{method:'POST'});S(await r.text());" + "async function ba(){const r=await fetch('/api/firmware/boot-app',{method:'POST'});S(await r.text());" "if(r.ok)setTimeout(()=>location.reload(),8000);}" - "async function cx(){S(await (await fetch('/cancel',{method:'POST'})).text());}" + "async function cx(){S(await (await fetch('/api/firmware/cancel',{method:'POST'})).text());}" ""; // The application slot. From the factory partition esp_ota_get_next_update_partition returns the @@ -656,9 +656,9 @@ void serveOne(int sock) { } bool installed = false; - if (std::strncmp(head, "POST /install-url", 17) == 0 && installing_) { + if (std::strncmp(head, "POST /api/firmware/url", 22) == 0 && installing_) { sendResponse(sock, "409 Conflict", "text/plain", "error: an install is already running"); - } else if (std::strncmp(head, "POST /install-url", 17) == 0) { + } else if (std::strncmp(head, "POST /api/firmware/url", 22) == 0) { // The body is the URL itself; small enough to finish reading into the same buffer. while (prefixLen < contentLen && headLen + prefixLen < sizeof(head) - 1) { const int n = ::recv(sock, head + headLen + prefixLen, @@ -688,7 +688,7 @@ void serveOne(int sock) { sendResponse(sock, "202 Accepted", "text/plain", status_); } } - } else if (std::strncmp(head, "POST /install", 13) == 0) { + } else if (std::strncmp(head, "POST /api/firmware/upload", 25) == 0) { if (installing_) { sendResponse(sock, "409 Conflict", "text/plain", "error: an install is already running"); } else { @@ -697,10 +697,10 @@ void serveOne(int sock) { installing_ = false; sendResponse(sock, installed ? "200 OK" : "500 Internal Server Error", "text/plain", status_); } - } else if (std::strncmp(head, "POST /boot-app", 14) == 0 && installing_) { + } else if (std::strncmp(head, "POST /api/firmware/boot-app", 27) == 0 && installing_) { // Booting away mid-write would abandon a half-written slot; refuse, visibly. sendResponse(sock, "409 Conflict", "text/plain", "error: an install is already running"); - } else if (std::strncmp(head, "POST /boot-app", 14) == 0) { + } else if (std::strncmp(head, "POST /api/firmware/boot-app", 27) == 0) { // Switch back to the installed application without installing anything. // esp_ota_set_boot_partition validates the image first, so a half-written app is // refused and the device stays here: only a bootable app can be booted. @@ -712,12 +712,12 @@ void serveOne(int sock) { installed = ok; // reuse the reply-then-restart tail below } else if (std::strncmp(head, "GET /logo.png", 13) == 0) { sendBinary(sock, "image/png", logoStart, static_cast(logoEnd - logoStart)); - } else if (std::strncmp(head, "GET /last-url", 13) == 0) { + } else if (std::strncmp(head, "GET /api/firmware/last-url", 26) == 0) { // The most recent install source, RAM-held: the page prefills its URL field with it, // so Install doubles as retry, the escape after a cancel wiped the app slot. Empty // after a power cycle. sendResponse(sock, "200 OK", "text/plain", stagedUrlTask_); - } else if (std::strncmp(head, "POST /cancel", 12) == 0) { + } else if (std::strncmp(head, "POST /api/firmware/cancel", 25) == 0) { // Cancel a running URL install: its loop polls the flag and aborts back to this page. // (An upload cancels by dropping the connection; this server is busy receiving it.) // Nothing to cancel is not an error worth a scary status, just say so. diff --git a/moondeck/check/repo_health.py b/moondeck/check/repo_health.py index abfaccbf..0f7b8319 100644 --- a/moondeck/check/repo_health.py +++ b/moondeck/check/repo_health.py @@ -25,6 +25,8 @@ """ import argparse +import datetime as _dt +import time as _time import json import subprocess import sys @@ -122,6 +124,25 @@ def measure_comments(): # to prevent, moved one step later. MEASURED_THIS_RUN = set() +# How long a carried number may go unmeasured before the report calls it stale. A carry is correct +# (the alternative, dropping the row, loses the only number there is), but an UNBOUNDED carry is +# not: esp32p4rev1-eth and esp32s31 both held a byte-identical value across eight commits while the +# code moved under them, and when they were finally rebuilt the accumulated growth landed as a +# single +274 KB / +238 KB jump that read as though one commit had caused it. Seven days is long +# enough that an untouched target stays quiet, short enough that a drifting one is caught while the +# cause is still findable. +STALE_AFTER_DAYS = 7 + +# When each firmware's size was last actually measured, ISO dates keyed by firmware. Stored in its +# own section rather than inside `flash`, which is a plain name->bytes map the report renders row +# by row: a non-firmware key there becomes a phantom target in the table. +MEASURED_DATES = {} + +# How recently a binary must have been built to count as measured rather than carried. A window +# rather than a source-timestamp comparison: what matters is that this target was built during the +# work being reported, not whether a file was touched afterwards. +MEASURED_WITHIN_HOURS = 12 + def app_partition_bytes(firmware): """The app slot's size for `firmware`, from the partition CSV its build actually used. @@ -176,11 +197,9 @@ def measure_flash(): holding five old firmwares that produced "−230 KB ✓", "−193 KB ✓", "−279 KB ✓" in one run, for firmwares nobody had rebuilt, because the baseline had been recorded on a different machine. A metric that moves when nothing was built is worse than a missing one: it is - read as a result. Same predicate as check_esp32_built.py, imported rather than restated. + read as a result. A binary built within MEASURED_WITHIN_HOURS counts; anything older is + carried and aged, so a number nobody has refreshed says so rather than passing as current. """ - sys.path.insert(0, str(ROOT / "moondeck" / "check")) - from check_esp32_built import newest_source, compiled_sources - flash = {} build = ROOT / "build" if not build.exists(): @@ -190,31 +209,34 @@ def measure_flash(): if not binary.exists(): continue # never built here: carry the previous number forward firmware = d.name.replace("esp32-", "", 1) - # One stat, so the size recorded and the timestamp judged describe the same file even if - # a build lands mid-loop. STRICTLY newer: equal mtimes mean a source was written in the - # same filesystem tick as the binary, and which came first is unknowable, so the honest - # reading is "might be stale" rather than "fresh". + # Measured means BUILT DURING THIS WORK, not "newer than every source". The stricter rule + # (binary newer than its newest source) rejected a binary whose source was merely touched + # afterwards, which is a real measurement of essentially this code, and it cost a full + # rebuild of every target to say a number the build had already produced. What the report + # needs to know is whether somebody built this target while working, and the next commit + # measures again regardless. One stat, so the size recorded and the age judged describe the + # same file even if a build lands mid-loop. st = binary.stat() - _, newest = newest_source(compiled_sources(firmware)) - if st.st_mtime <= newest: - continue + if (_time.time() - st.st_mtime) > MEASURED_WITHIN_HOURS * 3600: + continue # from an older session: carry it, and let it age flash[firmware] = st.st_size MEASURED_THIS_RUN.add(firmware) + MEASURED_DATES[firmware] = _dt.date.today().isoformat() # The desktop binary, located by build_desktop.desktop_binary() so this and collect_kpi.py # cannot name different files in the same run. A bare build/projectMM matched nothing off # macOS, so this metric silently carried a foreign machine's number forward while reading as # a measurement: the same defect the firmware freshness rule above exists to prevent. # - # Held to the SAME freshness rule as the firmwares rather than trusting the build gate to have - # just built it. That gate does, but `collect_kpi.py --commit` is also run standalone, where - # nothing builds first, and a rule that holds only inside one caller is not a rule. + # Held to the SAME rule as the firmwares rather than trusting the build gate to have just + # built it. That gate does, but `collect_kpi.py --commit` is also run standalone, where nothing + # builds first, and a rule that holds only inside one caller is not a rule. desktop = desktop_binary() if desktop: st = desktop.stat() - _, newest = newest_source() - if st.st_mtime > newest: + if (_time.time() - st.st_mtime) <= MEASURED_WITHIN_HOURS * 3600: flash["desktop"] = st.st_size - MEASURED_THIS_RUN.add("desktop") # same rule as the firmwares: measured, so say so + MEASURED_THIS_RUN.add("desktop") + MEASURED_DATES["desktop"] = _dt.date.today().isoformat() return flash @@ -279,13 +301,41 @@ def _head(): capture_output=True, text=True).stdout.strip() +def _built_label(firmware, measured_on): + """The Built cell: measured now, carried recently, or carried too long to trust. + + A carry is correct and must stay (dropping the row loses the only number there is), but an + unbounded one is how a metric goes quietly wrong: the report keeps printing a value nobody has + checked, and the growth surfaces later as one large jump attributed to whatever commit happened + to rebuild that target. Aging the carry is what turns that silence into a visible number. + """ + if firmware in MEASURED_THIS_RUN: + return "yes" + if not measured_on: + return "carried (age?)" # pre-dates this record: unknown, and honest about it + try: + age = (_dt.date.today() - _dt.date.fromisoformat(measured_on)).days + except ValueError: + return "carried (age?)" + if age >= STALE_AFTER_DAYS: + return f"**STALE {age}d**" + return f"carried {age}d" + + def snapshot(perf=None): """The full current-state measurement. `perf` is the tick/FPS block the KPI collector already gathered — passed in rather than re-measured, since it needs a running device.""" head = _head() + # Both are module-level and describe THIS run, so a second snapshot() in one process must not + # inherit the first's claims: a target that could not be measured the second time would + # otherwise still report "yes" and carry a stale date. + MEASURED_THIS_RUN.clear() + MEASURED_DATES.clear() + flash = measure_flash() # populates MEASURED_DATES as a side effect, so call it first return { "commit": head, - "flash": measure_flash(), + "flash": flash, + "measured": dict(MEASURED_DATES), "perf": perf or {}, "loc": measure_loc(), "comments": measure_comments(), @@ -307,7 +357,7 @@ def _valid_snapshot(data, source): print(f"repo-health: ignoring {source}: expected an object, got {type(data).__name__}", file=sys.stderr) return {} - for key in ("flash", "perf", "complexity"): + for key in ("flash", "perf", "complexity", "measured"): if key in data and not isinstance(data[key], dict): print(f"repo-health: ignoring {source}: section '{key}' is " f"{type(data[key]).__name__}, expected an object", file=sys.stderr) @@ -380,7 +430,10 @@ def merge_carry_forward(new, old): # filtered out. So the rule is "an esp32* key that is not a known firmware is a ghost", # which is exactly what a rename leaves behind and nothing else. known = set(FIRMWARES) - for key in ("flash", "perf", "complexity"): + # The measurement dates carry exactly like the values they describe: a target not built this + # run keeps both its number and the date that number was taken, which is what lets the report + # age a carry instead of presenting it as current. + for key in ("flash", "perf", "complexity", "measured"): merged = dict(old.get(key, {})) if key == "flash": merged = {k: v for k, v in merged.items() @@ -476,12 +529,17 @@ def render_markdown(new, old): cap = app_partition_bytes(k) if k.startswith("esp32") else 0 cap_s = _kb(cap) if cap else "-" used = f"{(100.0 * v / cap):.0f}%" if cap else "-" - built = "yes" if k in MEASURED_THIS_RUN else "carried" + built = _built_label(k, new.get("measured", {}).get(k)) L.append(f"| {k} | {_arrow(v, o.get('flash'), k, _kb)} | {cap_s} | {used} | {built} |") L += ["", - ("`Built: carried` means that firmware was NOT rebuilt this run and its number is " - "the previous one, so an absent delta says nothing about the change. `Used` is " - "against the app slot in the firmware's own partition table."), ""] + ("`Built: yes` was measured this run. `carried (age?)` was not rebuilt either and " + "predates this record, so its age is unknown: it dates itself on the next build. " + "`carried Nd` was NOT rebuilt and its number is " + f"N days old, so an absent delta says nothing about the change. **STALE** marks a " + f"carry older than {STALE_AFTER_DAYS} days: the number has gone unchecked long " + "enough that growth will surface later as one jump, blamed on whichever commit " + "happens to rebuild that target. `Used` is against the app slot in the firmware's " + "own partition table."), ""] if new.get("perf"): L += ["## Render performance", "", "| Target | Tick | FPS |", "|---|---:|---:|"] diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py index c199f1eb..58a13257 100644 --- a/moondeck/docs/screenshot_modules.py +++ b/moondeck/docs/screenshot_modules.py @@ -103,6 +103,7 @@ def asset_dir_for(type_name: str) -> Path: ("NoiseEffect", "Layer", {}, True), # The generative-fields showcases: each is Dim::D3, so the preview shows a volume. ("AuroraEffect", "Layer", {}, True), + ("ColorTrailsEffect", "Layer", {}, True), ("TrailsEffect", "Layer", {}, True), ("NebulaEffect", "Layer", {}, True), ("FluidEffect", "Layer", {}, True), diff --git a/mooninstaller/deviceModels.json b/mooninstaller/deviceModels.json index 3a9c8ee8..bedef4ac 100644 --- a/mooninstaller/deviceModels.json +++ b/mooninstaller/deviceModels.json @@ -179,11 +179,11 @@ "url": "https://quinled.info/dig-next-2/", "supported": [ "LEDs", - "WiFi" + "WiFi", + "Audio" ], "planned": [ - "Button", - "Microphone" + "Button" ], "flashBaud": 460800, "modules": [ @@ -208,6 +208,16 @@ "controls": { "pins": "2,4" } + }, + { + "type": "AudioService", + "id": "Audio", + "parent_id": "Services", + "controls": { + "micMode": 1, + "wsPin": 8, + "sdPin": 7 + } } ] }, diff --git a/src/core/AudioBands.h b/src/core/AudioBands.h index 4b51e83d..059a0d06 100644 --- a/src/core/AudioBands.h +++ b/src/core/AudioBands.h @@ -1,7 +1,8 @@ #pragma once #include "core/AudioFrame.h" -#include "core/AudioLevel.h" // magToByte — the shared log/dB mapping +#include "core/AudioLevel.h" // magToByte: the shared log/dB mapping +#include "core/math16.h" // ballistic: the per-band meter shape #include // cosf/powf — band math is inherently float (so is the // audioFft seam it feeds); the recognisable DSP choice. @@ -23,7 +24,7 @@ namespace mm { // DC-strips the 24-in-32 samples to floats for the FFT. // - magnitudesToBands: groups the n/2 FFT magnitude bins into 16 log-spaced // bands (pitch is logarithmic — bass gets few bins, treble many) with a plain -// geometric (equal-ratio) bin split, normalises to 0..255, and picks the +// geometric (equal-ratio) bin split, normalizes to 0..255, and picks the // single loudest bin as the dominant peak. // Hann window coefficient at sample `i` of `n`: w(i) = 0.5 - 0.5*cos(2πi/(n-1)). @@ -47,18 +48,199 @@ inline void applyWindow(const int32_t* samples, size_t n, float* out) { } } -// Group `nMag` FFT magnitudes (covering DC..Nyquist over `sampleRate`) into 16 -// log-spaced bands (0..255 each) and report the dominant peak (`peakHz` = its -// frequency, `peakMag` = its 0..255 magnitude). Robust to nMag==0 (all zero). -// -// `noiseFloor` and `gain` condition the bands exactly like the level path -// (AudioLevel.h): each band's scaled magnitude has `noiseFloor` subtracted (so a -// quiet idle spectrum — the mic's own noise — gates to 0 instead of flickering -// the LEDs) and is then multiplied by `gain`/16 (16 = unity) for live brightness -// control. Same knobs, same meaning, both the level and the spectrum. +/// The 17 bin-index edges of the 16 bands, `edge[b]..edge[b+1]` per band. +/// +/// A band is only a band if it OWNS bins. A pure geometric split (`edge[e] = nMag^(e/16)`, equal +/// frequency ratio per band, the textbook mapping of linear bins onto pitch) is scale-free, but the +/// FFT is not: below twice the bin width there are no distinct bins to hand out, so the low edges +/// collide. Measured at the shipped shape (256 bins of 43.1 Hz): bands 0 and 2 owned NO bins and +/// bands 1 and 4 owned one, while band 15 owned 75. A quarter of the display could not respond to +/// anything, and it was the quarter where music has its energy. +/// +/// So the geometric curve is kept, and then made monotonic by construction: every band gets at least +/// one bin, taken from the wide top bands that have bins to spare. The result stays log-shaped where +/// the FFT can afford it and degrades to one-bin-per-band where it cannot, which is the honest +/// answer at the bottom of the range: no split can separate 60 Hz from 80 Hz when they share a bin. +/// +/// Computed once per rate change, never per frame: 17 values, and the summing loop below costs the +/// same `nMag` additions wherever the edges sit. (The function itself is below kLowestAudibleHz, +/// which its first edge depends on.) + +/// The lowest frequency the spectrum shows. Below this is infrasound: mains hum, a microphone's DC +/// drift, footfall and traffic rumble, none of it audible and none of it music. At 22 kHz with a +/// 1024-bin FFT the first band would otherwise cover 11-22 Hz and display that rumble as bass. +inline constexpr float kLowestAudibleHz = 40.0f; + +inline void audioBandEdges(size_t nMag, uint32_t sampleRate, size_t edge[17]) { + if (nMag < 17) { // pathologically small: one bin each, as far as it goes + for (uint8_t e = 0; e <= 16; e++) edge[e] = e < nMag ? e : nMag; + return; + } + // The geometric ideal, in floating point so the collisions are visible before rounding. + for (uint8_t e = 0; e <= 16; e++) { + const float frac = static_cast(e) / 16.0f; + float ix = std::pow(static_cast(nMag), frac); + edge[e] = static_cast(ix); + } + // Start at the lowest audible bin rather than at bin 1: bin 0 is DC and the bins just above it + // are infrasound (see kLowestAudibleHz). Falls back to bin 1 when the rate is unknown. + size_t firstBin = 1; + if (sampleRate > 0) { + const float binHz = static_cast(sampleRate) / (2.0f * static_cast(nMag)); + if (binHz > 0.0f) { + firstBin = static_cast(kLowestAudibleHz / binHz); + if (firstBin < 1) firstBin = 1; + if (firstBin > nMag / 2) firstBin = nMag / 2; // never eat half the spectrum + } + } + edge[0] = firstBin; + edge[16] = nMag; + // Forward pass: push each edge up so every band owns a bin. This is what the geometric split + // could not do, and it costs the top bands a bin each, which they have in abundance. + for (uint8_t e = 1; e <= 16; e++) + if (edge[e] <= edge[e - 1]) edge[e] = edge[e - 1] + 1; + // Backward pass: if the forward pass ran past the end (a small FFT), pull the edges back down. + // Both passes together guarantee strictly increasing edges inside 1..nMag whenever nMag >= 17. + edge[16] = nMag; + for (uint8_t e = 16; e >= 1; e--) + if (edge[e] <= edge[e - 1]) edge[e - 1] = edge[e] - 1; +} + +/// The PPM ballistic over all 16 bands: `smoothed` follows `raw` fast on a rise and slowly on a +/// fall, each band on its own. One call per block, after whichever path produced the raw bands +/// (mic, simulation or a received sync packet), so every consumer sees the same meter. +/// +/// `rise` and `fall` are `smoothFollow` rates. The defaults below are a broadcast meter's shape: +/// a hit arrives in one block, and a bar then takes about forty blocks (a second) to fall to zero, +/// long enough for the eye to read the peak and short enough not to smear the next one. +constexpr uint8_t kBandRise = 200; +constexpr uint8_t kBandFall = 24; +inline void smoothBands(const uint8_t raw[16], uint8_t smoothed[16], + uint8_t rise = kBandRise, uint8_t fall = kBandFall) { + for (uint8_t b = 0; b < 16; b++) smoothed[b] = ballistic(smoothed[b], raw[b], rise, fall); +} + +/// Spectral flux: how much the spectrum ROSE since the last block, 0..255. The standard onset +/// detection function (Bello et al. 2005, Dixon 2006): sum the positive per-band differences and +/// ignore the falls, so a hit reads high, a decay reads zero and a held tone reads zero. Sixteen +/// subtractions on bands already computed, so it costs nothing and lands with the block. +inline uint8_t spectralFlux(const uint8_t prev[16], const uint8_t cur[16]) { + uint32_t sum = 0; + for (uint8_t b = 0; b < 16; b++) if (cur[b] > prev[b]) sum += static_cast(cur[b] - prev[b]); + sum /= 16; // sixteen bands of 255 fold back onto 0..255 + return static_cast(sum > 255 ? 255 : sum); +} + +/// Turns a flux stream into onsets: one per hit, none for a swell. The decision is the textbook +/// one, flux against its own recent MEAN (Dixon 2006) rather than an absolute threshold, so a loud +/// room and a quiet one fire on the same kind of event; a refractory window then makes one hit one +/// onset however many blocks it spans. The mean is an EMA in 8.8 fixed point, advanced AFTER the +/// decision so a hit does not raise the bar it is being judged against. +struct OnsetDetector { + uint16_t mean_ = 0; ///< EMA of the flux, 8.8 fixed point + uint32_t lastMs_ = 0; ///< when the last onset fired + bool fired_ = false; ///< whether one has fired yet (lastMs_ of 0 is a valid time) + + /// Feed one block's flux at time `nowMs`. Returns true on the block an onset is detected. + /// A hit is flux above `num/den` of the mean plus `margin`; `refractoryMs` is the minimum gap. + bool feed(uint8_t flux, uint32_t nowMs, uint8_t num = 3, uint8_t den = 2, + uint8_t margin = 20, uint16_t refractoryMs = 100) { + const uint32_t meanNow = mean_ >> 8; + const bool above = flux > (meanNow * num) / den + margin; + mean_ = static_cast(mean_ - (mean_ >> 4) + (static_cast(flux) << 4)); + if (above && (!fired_ || nowMs - lastMs_ >= refractoryMs)) { + lastMs_ = nowMs; + fired_ = true; + return true; + } + return false; + } +}; + +/// Per-band conditioning in the dB domain: the tier of the audio roadmap's design that learns the +/// RIG rather than chasing the music. +/// +/// Every band carries two learned numbers. Its **floor**, the level it reads with no program +/// material (mic self-noise, mains hum, the room), followed as a running MINIMUM that drifts up +/// slowly so a room that gets noisier is re-learned. Its **peak**, followed with an instant attack +/// and a release of seconds, so it settles to the band's typical loudest level rather than +/// tracking every beat. Between the two is the band's own dynamic range, and the correction maps +/// that range onto the display window instead of leaving the treble at a fourteenth of the bass +/// under spectrally balanced material (measured: a peak-per-band reading of pink noise falls as +/// 1/sqrt(f) across the sixteen bands). +/// +/// The correction is applied with a compressor's `ratio`: N:1 removes (1 - 1/N) of a band's +/// deviation from the window, so 1:1 is off and the music's balance is untouched, 2:1 halves the +/// rig's coloration, and a high ratio flattens it. Slow on purpose: a fast per-band gain is what +/// causes cross-spectral pumping, and every source on multiband dynamics says not to (the audio +/// roadmap, § two tiers). `maxGain` caps the lift so a silent band is never amplified into its own +/// noise. `learning` off freezes both tables, which is the deterministic mode a show wants. +/// +/// State is 32 floats; the work is 16 logs and a handful of multiplies per block, on the audio +/// block path and never per light. +struct BandConditioner { + /// The narrowest dynamic range a band is credited with: the shared follower minimum, so the + /// band and level paths cannot drift apart (kConditionerMinRangeDb, AudioLevel.h). + static constexpr float kMinRangeDb = kConditionerMinRangeDb; + + float floorDb[16]; + float peakDb[16]; + bool primed = false; + + BandConditioner() { for (uint8_t b = 0; b < 16; b++) { floorDb[b] = 0.0f; peakDb[b] = 0.0f; } } + + /// Condition one block. `db` in, `out` the corrected dB for the display window + /// [windowFloor, windowFloor + windowSpan]. `dtMs` is the block interval, for the time + /// constants. `ratioN` is the N of N:1 (1 = off). `learning` false freezes the tables. + /// `gateDb` is the silence threshold: a band below it carries no program material, so it + /// reads zero and is NOT learned from. Both halves matter. Without the gate the lift is + /// dominated by `windowFloor - db`, which relocates a silent band up into the window as + /// eagerly as a quiet instrument, and an empty room is displayed at full scale (measured on a + /// Dig-Next-2: the raw path read flux 0-3 while the learner made 33-68 of it). And learning + /// from silence drags the floor table down to the noise, so the next wobble reads as music. + void process(const float db[16], float out[16], uint32_t dtMs, float windowFloor, + float windowSpan, uint8_t ratioN, float maxGainDb, bool learning, float gateDb, + float floorRiseDbPerS = 1.0f, float peakReleaseDbPerS = 3.0f) { + if (!primed) { + for (uint8_t b = 0; b < 16; b++) { + floorDb[b] = db[b]; + peakDb[b] = db[b] + kMinRangeDb; + } + primed = true; + } + const float dt = static_cast(dtMs) / 1000.0f; + const float amount = ratioN <= 1 ? 0.0f : 1.0f - 1.0f / static_cast(ratioN); + for (uint8_t b = 0; b < 16; b++) { + // Silence: nothing to show and nothing to learn. Held before the followers so the + // tables keep describing the music rather than the room's noise floor. + if (db[b] < gateDb) { out[b] = 0.0f; continue; } + if (learning) { + // Floor: a minimum follower that drifts UP slowly, so it forgets a quiet moment + // over seconds but takes a new low at once. + floorDb[b] = db[b] < floorDb[b] ? db[b] : floorDb[b] + floorRiseDbPerS * dt; + // Peak: instant attack, slow release, so it settles on the band's typical top. + peakDb[b] = db[b] > peakDb[b] ? db[b] : peakDb[b] - peakReleaseDbPerS * dt; + // Bound the stretch so a collapsed follower cannot divide by ~zero. Small on + // purpose: the silence gate above is what keeps a quiet room quiet, and a large + // minimum here would flatten real music instead (see kConditionerMinRangeDb). + if (peakDb[b] < floorDb[b] + kMinRangeDb) peakDb[b] = floorDb[b] + kMinRangeDb; + } + // Where this band's own range would put the value inside the window, and how far + // from the raw value that is; `amount` decides how much of that move is taken. + const float range = peakDb[b] - floorDb[b]; + const float normalized = windowFloor + (db[b] - floorDb[b]) * (windowSpan / range); + float shift = (normalized - db[b]) * amount; + if (shift > maxGainDb) shift = maxGainDb; // never lift a band into its noise + out[b] = db[b] + shift; + } + } +}; + inline void magnitudesToBands(const float* mag, size_t nMag, uint32_t sampleRate, uint16_t noiseFloor, uint16_t gain, - uint8_t bands[16], uint16_t& peakHz, uint16_t& peakMag) { + uint8_t bands[16], uint16_t& peakHz, uint16_t& peakMag, + BandConditioner* cond = nullptr, uint32_t dtMs = 23, + uint8_t ratioN = 1, float maxGainDb = 24.0f, bool learning = true) { for (uint8_t b = 0; b < 16; b++) bands[b] = 0; peakHz = 0; peakMag = 0; @@ -67,17 +249,9 @@ inline void magnitudesToBands(const float* mag, size_t nMag, uint32_t sampleRate // Hz per bin = sampleRate / (2 * nMag). const float binHz = static_cast(sampleRate) / (2.0f * static_cast(nMag)); - // 17 log-spaced bin-index edges: edge[e] = nMag^(e/16), so edge[0]=1 (skip - // DC), edge[16]=nMag, each band spanning the same frequency *ratio* — a plain - // geometric split, the standard way to map linear FFT bins onto pitch. size_t edge[17]; - for (uint8_t e = 0; e <= 16; e++) { - const float frac = static_cast(e) / 16.0f; - size_t ix = static_cast(std::pow(static_cast(nMag), frac)); - if (ix < 1) ix = 1; - if (ix > nMag) ix = nMag; - edge[e] = ix; - } + audioBandEdges(nMag, sampleRate, edge); + float bandDb[16]; // Magnitude → 0..255 on the shared LOGARITHMIC (dB) scale (magToByte, in // AudioLevel.h) — the same mapping the level/VU path uses, so noiseFloor/gain @@ -101,7 +275,29 @@ inline void magnitudesToBands(const float* mag, size_t nMag, uint32_t sampleRate // single tone light ONE band instead of smearing across many. float best = 0.0f; for (size_t i = lo; i < hi; i++) if (mag[i] > best) best = mag[i]; - bands[b] = toByte(best); + bandDb[b] = best <= 1.0f ? 0.0f : 20.0f * std::log10(best); + } + // Per-band conditioning in dB, then the shared window onto bytes. Without a conditioner this + // is exactly magToByte per band, so the two paths cannot disagree. + if (cond) { + float out[16]; + // The gate sits AT the window floor, and the level path's kMuteMarginDb does NOT belong + // here. The two measure different quantities: a band reports the PEAK magnitude of its + // bins while the level reports the block's RMS, and a peak sits far above an RMS for the + // same sound, so the same margin in dB is a much larger concession on a band. Bench, a + // quiet room on a Dig-Next-2: at the window floor the spectrum reads flux 1-2, and a 20 dB + // margin takes it to 49-102 with onsets firing, which is the room's own noise displayed as + // music. A margin large enough to matter would have to be tuned per part, which is what + // the learner exists to avoid. + // + // The cost is real and accepted: a band below the window is primed once and then returns + // early, so its learned floor does not track a room that gets quieter still. That band is + // dark either way, and re-learning starts the moment anything audible arrives. + cond->process(bandDb, out, dtMs, windowFloorDb(noiseFloor), windowSpanDb(gain), ratioN, + maxGainDb, learning, windowFloorDb(noiseFloor)); + for (uint8_t b = 0; b < 16; b++) bands[b] = bandDb[b] <= 0.0f ? 0 : dbToByte(out[b], noiseFloor, gain); + } else { + for (uint8_t b = 0; b < 16; b++) bands[b] = bandDb[b] <= 0.0f ? 0 : dbToByte(bandDb[b], noiseFloor, gain); } if (peakVal > 0.0f) { diff --git a/src/core/AudioFrame.h b/src/core/AudioFrame.h index dfe4d3e9..b268fa90 100644 --- a/src/core/AudioFrame.h +++ b/src/core/AudioFrame.h @@ -33,8 +33,20 @@ struct AudioFrame { // their thresholds sit around 48..144 AFTER a /16. A 0..255 value // arrives below the squelch floor and reads as near-silence. // See services.md (Audio) for the trade and the open decision. - uint8_t bands[16] = {}; // 16 log-spaced frequency-band magnitudes, 0..255 - // (bass = bands[0], treble = bands[15]) + uint8_t bands[16] = {}; // 16 log-spaced frequency-band magnitudes, 0..255, RAW: this + // block's value with no smoothing (bass = bands[0], treble = + // bands[15]). Snaps to a transient; use it to catch a hit. + uint8_t bandsSmoothed[16] = {}; // the same bands with a meter's BALLISTIC: fast rise, slow + // fall (a PPM, IEC 60268-10). Use it for a spectrum display or + // anything that should read as bars rather than twitch. The pair + // mirrors level / levelSmoothed, so raw stays available. + uint8_t flux = 0; // spectral flux this block, 0..255: how much the spectrum ROSE + // since the last block. The onset detection function. + uint8_t onset = 0; // 0, or the flux strength on the ONE block a hit was detected: + // flux well above its own recent mean, at most one per refractory + // window. Reactive, with the block's ~23 ms latency. An effect + // that flashes on this catches the drum; one that wants to be + // ON the beat needs the tempo tracker (backlog, audio roadmap). }; } // namespace mm diff --git a/src/core/AudioLevel.h b/src/core/AudioLevel.h index e489a5da..29eedf41 100644 --- a/src/core/AudioLevel.h +++ b/src/core/AudioLevel.h @@ -19,16 +19,24 @@ namespace mm { // window = a sound fills more of the range: spanDb = (255-gain)/4 + 4. // Human hearing is logarithmic and FFT/RMS magnitudes span a huge range, so a // linear map crushes the quiet or saturates the loud; this is the standard fix. -inline uint8_t magToByte(float m, uint16_t noiseFloor, uint16_t gain) { - if (m <= 1.0f) return 0; - const float floorDb = 60.0f + static_cast(noiseFloor) * 0.5f; - const float spanDb = static_cast(255 - gain) * 0.25f + 4.0f; - const float t = (20.0f * std::log10(m) - floorDb) / spanDb; +/// The display window in dB: where it starts and how wide it is. One home for the two knobs. +inline float windowFloorDb(uint16_t noiseFloor) { return 60.0f + static_cast(noiseFloor) * 0.5f; } +inline float windowSpanDb(uint16_t gain) { return static_cast(255 - gain) * 0.25f + 4.0f; } + +/// A value already in dB onto 0..255 through the window. The band path conditions in dB first +/// (AudioBands.h, BandConditioner) and then comes here, so the window means one thing everywhere. +inline uint8_t dbToByte(float db, uint16_t noiseFloor, uint16_t gain) { + const float t = (db - windowFloorDb(noiseFloor)) / windowSpanDb(gain); if (t <= 0.0f) return 0; if (t >= 1.0f) return 255; return static_cast(t * 255.0f); } +inline uint8_t magToByte(float m, uint16_t noiseFloor, uint16_t gain) { + if (m <= 1.0f) return 0; + return dbToByte(20.0f * std::log10(m), noiseFloor, gain); +} + // DC-blocker: the standard one-pole/one-zero high-pass that removes the constant // (DC) offset and sub-bass rumble from the sample stream before any analysis — // y[n] = x[n] - x[n-1] + R·y[n-1]. R near 1 sets the cutoff: R = 0.99 ≈ 40 Hz at @@ -90,8 +98,69 @@ struct DcBlocker { // through the same log/dB window the bands use (magToByte), so the VU meter and // the spectrum share one scaling and the noiseFloor/gain knobs mean the same // thing for both. Empty/null input yields zero (silence), never a crash. +// +/// The narrowest dynamic range a learned follower credits its input with, shared by the level and +/// the per-band conditioners so the two paths behave alike. It exists only to bound +/// `windowSpan / range`, which a collapsed follower would otherwise drive toward infinity. +/// +/// Deliberately SMALL, because the silence gate is what keeps a quiet room quiet and this is not a +/// second mechanism for the same job. A large value flattens real music instead: at 12 dB a band +/// swinging 6 dB filled only half the display, which reads as "vivid bands, no dynamic range". +/// The gate can tell silence from a quiet passage, which a range clamp fundamentally cannot, so +/// the gate does that work and this stays out of the way. +inline constexpr float kConditionerMinRangeDb = 3.0f; + +/// The level's own minimum, larger than a band's for the same reason its gate is lower: this +/// follows a whole block's RMS, which swings far less than any single band's peak. At the band +/// value the meter stretched that small natural variation to full scale and sat pinned at 255. +inline constexpr float kLevelMinRangeDb = 20.0f; + +/// The manual level window's width at full `gain`. The level is scaled BY gain rather than sized +/// from it: `gain` sizes the band window directly, but a block RMS covers far more dB than a single +/// bin's peak, so feeding one raw number to both left the VU in the bottom third of the meter at +/// the settings that made the spectrum look right. Scaling keeps the knob meaning what it means +/// (higher gain = narrower window = hotter meter) in both paths. 20 dB is the room's measured +/// speech-to-quiet range on the bench parts. +inline constexpr float kLevelWindowSpanDb = 20.0f; + +/// The manual level window's span for a given `gain`: the base at gain 255, widening to twice that +/// as gain falls to 0, so the control spans a useful range either side of its midpoint. +inline float levelWindowSpanDb(uint16_t gain) { + return kLevelWindowSpanDb * (2.0f - static_cast(gain) / 255.0f); +} + +/// How far below the display window a level has to fall before it counts as silence rather than a +/// quiet passage. The window floor is what a manual setup shows as its lowest visible level, so +/// anything at it is audible; the margin is what separates "quiet" from "nothing at all". +inline constexpr float kMuteMarginDb = 20.0f; + +/// The level's own floor and peak, learned the way BandConditioner learns a band's. With `levels` +/// automatic the display window is measured rather than dialed in, so the VU levels itself along +/// with the bands and the manual sliders are genuinely manual-only. Same followers and the same +/// minimum range as the band tables, so the two paths behave alike and cannot disagree. +struct LevelConditioner { + static constexpr float kMinRangeDb = kLevelMinRangeDb; + + float floorDb = 0.0f; + float peakDb = 0.0f; + bool primed = false; + + /// Learn from this block's RMS and return the dB window [floor, floor+span] to display it in. + void observe(float db, uint32_t dtMs, float& windowFloor, float& windowSpan, + float floorRiseDbPerS = 1.0f, float peakReleaseDbPerS = 3.0f) { + if (!primed) { floorDb = db; peakDb = db + kMinRangeDb; primed = true; } + const float dt = static_cast(dtMs) / 1000.0f; + floorDb = db < floorDb ? db : floorDb + floorRiseDbPerS * dt; + peakDb = db > peakDb ? db : peakDb - peakReleaseDbPerS * dt; + if (peakDb < floorDb + kMinRangeDb) peakDb = floorDb + kMinRangeDb; + windowFloor = floorDb; + windowSpan = peakDb - floorDb; + } +}; + inline void computeLevel(const int32_t* samples, size_t n, - uint16_t noiseFloor, uint16_t gain, AudioFrame& frame) { + uint16_t noiseFloor, uint16_t gain, AudioFrame& frame, + LevelConditioner* cond = nullptr, uint32_t dtMs = 23) { if (!samples || n == 0) { frame.level = 0; return; @@ -111,7 +180,32 @@ inline void computeLevel(const int32_t* samples, size_t n, const uint64_t meanSq = sqSum / static_cast(n); const uint64_t rms = isqrt64(meanSq); - frame.level = magToByte(static_cast(rms), noiseFloor, gain); + // Automatic: the window is the level's own learned range, so a quiet room and a loud one both + // fill the meter. Manual: the floor/gain sliders, exactly as before. + if (cond) { + const float db = rms <= 1 ? 0.0f : 20.0f * std::log10(static_cast(rms)); + // Silence for the LEVEL is not the same number as silence for a band, and the caller has + // already halved `floor` for that reason: a band gate reads a single bin's PEAK magnitude + // while this reads the whole block's RMS, which for real music sits well below the + // strongest bin. Gating both at the band threshold left the spectrum lively with the VU + // pinned at zero (measured: flux 32-100 against level 0). The window floor is the level a + // manual setup DISPLAYS, so it is audible by definition; silence is kMuteMarginDb below it. + const float gateDb = windowFloorDb(noiseFloor) - kMuteMarginDb; + if (db < gateDb) { frame.level = 0; return; } + float wFloor = 0.0f, wSpan = 1.0f; + cond->observe(db, dtMs, wFloor, wSpan); + const float t = (db - wFloor) / wSpan; + const float clamped = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); + frame.level = static_cast(clamped * 255.0f + 0.5f); + return; + } + // Manual. `floor` positions the window and `gain` scales its width, both as they do for the + // bands, but from the level's own base span (see levelWindowSpanDb). + const float db = rms <= 1 ? 0.0f : 20.0f * std::log10(static_cast(rms)); + const float wFloor = windowFloorDb(noiseFloor); + const float t = (db - wFloor) / levelWindowSpanDb(gain); + const float clamped = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); + frame.level = rms <= 1 ? 0 : static_cast(clamped * 255.0f + 0.5f); } } // namespace mm diff --git a/src/core/AudioService.h b/src/core/AudioService.h index 97b8cfa7..7782861c 100644 --- a/src/core/AudioService.h +++ b/src/core/AudioService.h @@ -90,6 +90,23 @@ class AudioService : public MoonModule { public: /// Block size = FFT size: a power of two. 512 samples at 22050 Hz is ~23 ms of /// audio per frame, fine resolution (~43 Hz/bin) at a modest per-tick cost. + /// What every producer of raw bands does next, once: the meter ballistic, the spectral flux + /// and the onset decision. Four paths write `frame_.bands` (the mic, two simulations and a + /// received sync packet); routing them all through here is what keeps the four consumers of + /// the frame seeing one definition of "smoothed" and one definition of "a hit". + void finishBands() { + smoothBands(frame_.bands, frame_.bandsSmoothed); + frame_.flux = spectralFlux(prevBands_, frame_.bands); + std::memcpy(prevBands_, frame_.bands, sizeof(prevBands_)); + frame_.onset = onset_.feed(frame_.flux, platform::millis()) ? frame_.flux : 0; + if (frame_.onset) onsetCount_++; + if (frame_.flux > fluxPeak_) fluxPeak_ = frame_.flux; + } + uint8_t prevBands_[16] = {}; ///< last block's raw bands, the flux's reference + BandConditioner cond_; ///< the per-band floor and peak tables, learned live + LevelConditioner levelCond_; ///< the same, for the overall level (VU) in automatic mode + OnsetDetector onset_; ///< the hit decision, with its running mean and refractory + static constexpr size_t kBlock = 512; static constexpr size_t kMag = kBlock / 2; ///< real-FFT magnitude bins @@ -115,6 +132,10 @@ class AudioService : public MoonModule { /// OS capture device (desktop): an index into platform::audioCaptureDevices' list. /// 0 = "default" (order-stable). Changing it re-opens capture live (no reboot). uint8_t device = 0; + /// Which kind of microphone is wired: 0 = I2S (three wires, an INMP441-class PCM part), + /// 1 = PDM (two wires, the one-bit part boards solder on, such as the QuinLED Dig-Next-2's). + /// PDM has no bit clock and no master clock, so those two pins hide when it is selected. + uint8_t micMode = 0; int8_t sckPin = -1; ///< bit clock / BCLK (-1 = unset). Changing it re-creates the I2S channel live (no reboot). int8_t wsPin = -1; ///< word-select / LRCLK (-1 = unset). Changing it re-creates the I2S channel live. int8_t sdPin = -1; ///< serial data in / DOUT (-1 = unset). Changing it re-creates the I2S channel live. @@ -133,8 +154,27 @@ class AudioService : public MoonModule { uint8_t floor = 100; ///< noise floor (dB display floor), bands/level ///< below this read as silence. Raise to keep an ///< ambient room dark, lower for a quiet room. - uint8_t gain = 222; ///< sensitivity, HIGHER = more (a narrower dB window + uint8_t gain = 128; ///< sensitivity, HIGHER = more (a narrower dB window ///< so a given sound fills more of the bar). + /// Per-band conditioning (AudioBands.h, BandConditioner): the learner that levels the RIG, + /// mic response and room, without touching the music's own balance. `agc` picks whether the + /// tables keep learning; `ratio` is a compressor's N:1 (1 = off); `maxGain` caps the lift in + /// dB so a silent band is never amplified into its own noise. + /// Who sets the display window: 0 = manual (the floor and gain sliders), 1 = automatic (the + /// per-band learner). One choice rather than two overlapping mechanisms, so a slider on screen + /// is always a slider that does something. + uint8_t levels = 1; + /// Automatic levelling, fixed rather than exposed. Both act on the LEARNED per-band range, + /// which the conditioner has already normalized per rig, so one value serves every source: a + /// PDM mic, an INMP441 and a line-in all arrive looking the same. What differs between them is + /// the absolute level, and that is `floor`'s job, the one knob automatic mode keeps. + /// + /// 4 = N of N:1, correcting three quarters of a band's deviation: enough to take out the rig's + /// coloration, short of the high ratios that cause cross-spectral pumping. 24 dB caps the lift + /// for a rig whose bands sit far apart; with silence gated it rarely binds, and it is kept as + /// the guard rather than a tuning knob. + static constexpr uint8_t kRatio = 4; + static constexpr uint8_t kMaxGainDb = 24; /// Simulated-audio pattern (only shown, and only used, in Simulate mode, see `mode`). The synthesized /// signal drives audio-reactive effects with no mic or music, for a preview/demo device or a test: /// `music`: a plausible song: multi-sine bands + a swelling volume + a periodic beat + a @@ -201,10 +241,17 @@ class AudioService : public MoonModule { // GPIOs; order follows the I2S datasheet: clocks, data, optional MCLK). On desktop it is // the OS capture device instead. --- if constexpr (platform::hasI2sMic) { - controls_.addPin("sckPin", sckPin); controls_.setHidden(controls_.count() - 1, !localMode); + // A PDM part has neither a bit clock nor a master clock: its two wires are the clock + // the chip drives (wsPin) and the data line (sdPin). Showing the other two would + // invite a user to set pins nothing reads. + static constexpr const char* kMicModeOptions[] = {"I2S", "PDM"}; + controls_.addSelect("micMode", micMode, kMicModeOptions, 2); + controls_.setHidden(controls_.count() - 1, !localMode); + const bool pdm = micMode == 1; + controls_.addPin("sckPin", sckPin); controls_.setHidden(controls_.count() - 1, !localMode || pdm); controls_.addPin("wsPin", wsPin); controls_.setHidden(controls_.count() - 1, !localMode); controls_.addPin("sdPin", sdPin); controls_.setHidden(controls_.count() - 1, !localMode); - controls_.addPin("mclkPin", mclkPin); controls_.setHidden(controls_.count() - 1, !localMode); + controls_.addPin("mclkPin", mclkPin); controls_.setHidden(controls_.count() - 1, !localMode || pdm); } if constexpr (platform::hasAudioCapture) { // The OS capture input: entry 0 "default" follows the system setting; loopback @@ -228,8 +275,24 @@ class AudioService : public MoonModule { controls_.addSelect("sampleRate", sampleRateSel, kRateOptions, kSampleRateCount); controls_.setHidden(controls_.count() - 1, !localMode); // floor/gain condition the local FFT/level mapping. + // ONE decision, then the controls that decision needs. `levels` says who sets the display + // window: a person (the floor and gain sliders) or the learner (which measures each band's + // own floor and typical peak and maps them onto the window for you). Showing both sets at + // once was the confusing part: four sliders for two jobs, with `agc` silently overriding + // what `floor` and `gain` meant while leaving them on screen. + static constexpr const char* kLevelsOptions[] = {"manual", "automatic"}; + controls_.addSelect("levels", levels, kLevelsOptions, 2); + controls_.setHidden(controls_.count() - 1, !localMode); + const bool manual = levels == 0; + // `floor` is shown in BOTH modes because it means one thing in both: below this is not + // signal. Manual maps the display from it; automatic gates silence with it, which is what + // stops the learner amplifying an empty room to full scale. `gain` sets the manual window's + // span and has no automatic counterpart, so it hides with the mode that uses it. controls_.addControl("floor", floor, 0, 255); controls_.setHidden(controls_.count() - 1, !localMode); - controls_.addControl("gain", gain, 1, 255); controls_.setHidden(controls_.count() - 1, !localMode); + controls_.addControl("gain", gain, 1, 255); controls_.setHidden(controls_.count() - 1, !localMode || !manual); + // Automatic exposes no levelling knobs: `strength` and `maxBoost` measurably changed the + // numbers but nothing a viewer could see once silence was gated, and both are + // source-independent (see kRatio), so they are constants. // "send audio": broadcast the locally-analyzed frame. Only meaningful in Local mode. if constexpr (platform::hasNetwork) { controls_.addControl("send audio", send); @@ -259,6 +322,9 @@ class AudioService : public MoonModule { // instantaneous RMS, recomputed every audio block, this read-out is the human-readable // summary of it, not a separate statistic. controls_.addReadOnly("level RMS", levelStr_, sizeof(levelStr_)); + // The onset diagnostic: hits per second and the peak flux, the one row that answers + // "is the detector hearing the beat" without a per-band list (audio roadmap, § The UI). + controls_.addReadOnly("onsets", onsetStr_, sizeof(onsetStr_)); controls_.addReadOnly("peakHz", peakStr_, sizeof(peakStr_)); MoonModule::defineControls(); } @@ -269,9 +335,15 @@ class AudioService : public MoonModule { bool affectsPrepare(const char* name) const override { return std::strcmp(name, "wsPin") == 0 || std::strcmp(name, "sdPin") == 0 || std::strcmp(name, "sckPin") == 0 || std::strcmp(name, "mclkPin") == 0 + // Re-creates the I2S channel in the other mode, AND hides or shows the two clock + // pins PDM does not have. + || std::strcmp(name, "micMode") == 0 || std::strcmp(name, "device") == 0 || std::strcmp(name, "sampleRate") == 0 || std::strcmp(name, "mode") == 0 - || std::strcmp(name, "send audio") == 0 || std::strcmp(name, "syncPort") == 0; + || std::strcmp(name, "send audio") == 0 || std::strcmp(name, "syncPort") == 0 + // `levels` swaps which sliders are shown (manual floor/gain against the learner's + // strength/maxBoost), so it toggles rows exactly as mode and send do. + || std::strcmp(name, "levels") == 0; } /// Pure build (see MoonModule::prepare): claim the frame election (this instance's frame_ drives the @@ -405,7 +477,9 @@ class AudioService : public MoonModule { // with how loud the room is. Uses a gentler floor than the bands (half), // so the VU keeps moving with volume instead of being gated hard like the // per-band display. - computeLevel(samples_, kBlock, static_cast(floor / 2), gain, frame_); + computeLevel(samples_, kBlock, static_cast(floor / 2), gain, frame_, + levels == 1 ? &levelCond_ : nullptr, + static_cast(kBlock * 1000u / sampleRate())); // 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 @@ -419,7 +493,11 @@ class AudioService : public MoonModule { applyWindow(samples_, kBlock, windowed_); platform::audioFft(windowed_, kBlock, mag_); magnitudesToBands(mag_, kMag, sampleRate(), floor, gain, - frame_.bands, peakHz, peakMag); + frame_.bands, peakHz, peakMag, + levels == 1 ? &cond_ : nullptr, + static_cast(kBlock * 1000u / sampleRate()), + kRatio, static_cast(kMaxGainDb), true); + finishBands(); // Peak frequency: the exact-Hz FFT bin, held when there's no real signal so // it doesn't wander in silence. @@ -457,6 +535,7 @@ class AudioService : public MoonModule { 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; + finishBands(); frame_.level = env; frame_.peakHz = static_cast(80 + pos * 700); // bass→~10.6 kHz across the 16 steps frame_.peakMag = env; @@ -478,6 +557,7 @@ class AudioService : public MoonModule { 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; + finishBands(); // 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; @@ -489,7 +569,18 @@ class AudioService : public MoonModule { } void tick1s() MM_NONBLOCKING override { + // The mirror of the LED driver's retry: on the classic ESP32 a PDM microphone and the + // parallel LED bus both need I2S0, so whichever asks second is refused, and the loser would + // otherwise stay silent until the user edited a control. Retry only while Local mode is + // wanted and the mic is not up, and only once the platform says the instance is free, so a + // genuinely bad pin set costs nothing per second. reinit() is the cold path prepare() runs. + if (mode == 0 && !inited_ + && platform::audioMicSharedBusFree(micMode == 1 ? platform::MicMode::Pdm + : platform::MicMode::I2sStd)) reinit(); std::snprintf(levelStr_, sizeof(levelStr_), "%u", static_cast(levelPeak_)); + std::snprintf(onsetStr_, sizeof(onsetStr_), "%u/s, flux %u", + static_cast(onsetCount_), static_cast(fluxPeak_)); + onsetCount_ = 0; fluxPeak_ = 0; std::snprintf(peakStr_, sizeof(peakStr_), "%u Hz", static_cast(frame_.peakHz)); levelPeak_ = 0; // reset for the next window @@ -506,7 +597,9 @@ class AudioService : public MoonModule { && platform::audioCodecType == platform::CodecType::None; if (directMicLive) { if (micSamples1s_ == 0) - setStatus("mic: no samples, check sckPin / wsPin (I2S clocks)", Severity::Warning); + setStatus(micMode == 1 ? "mic: no samples, check wsPin (PDM clock)" + : "mic: no samples, check sckPin / wsPin (I2S clocks)", + Severity::Warning); else if (micNonzero1s_ == 0) setStatus("mic: data line silent, check sdPin (SD/DOUT) + mic power", Severity::Warning); else if (micStatusStale_) @@ -577,6 +670,9 @@ class AudioService : public MoonModule { AudioFrame frame_; char levelStr_[12] = {}; + char onsetStr_[20] = {}; + uint8_t onsetCount_ = 0; ///< onsets in the current 1 s display window (UI only) + uint8_t fluxPeak_ = 0; ///< peak flux in that window (UI only) char peakStr_[12] = {}; uint8_t levelPeak_ = 0; // peak frame_.level across the current 1 s display window (UI only) @@ -634,8 +730,13 @@ class AudioService : public MoonModule { // don't attempt an I2S init: initializing I2S on unset pins is what hung a // mic-less board's boot. GPIO 0 IS a valid mic pin now (the sentinel is -1, // not 0), so the guard tests < 0, not == 0. - if (sckPin < 0 || wsPin < 0 || sdPin < 0) { - setStatus("mic: set sckPin / wsPin / sdPin", Severity::Status); + // A PDM part has two wires, not three: the clock this chip drives (wsPin) and the data + // line (sdPin). Requiring a bit clock there would leave a correctly wired board sitting + // at "set sckPin" forever, with a pin it does not have. + const bool pdm = micMode == 1; + if (wsPin < 0 || sdPin < 0 || (!pdm && sckPin < 0)) { + setStatus(pdm ? "mic: set wsPin (clock) / sdPin (data)" + : "mic: set sckPin / wsPin / sdPin", Severity::Status); return; } // Bring up the I2S channel FIRST. Where MCLK comes from depends on the board: @@ -650,7 +751,8 @@ class AudioService : public MoonModule { ? mclkPin : static_cast(platform::audioCodecPins.mclk); inited_ = platform::audioMicInit(mic_, static_cast(wsPin), static_cast(sdPin), - static_cast(sckPin), mclk, sampleRate()); + static_cast(sckPin), mclk, sampleRate(), + static_cast(micMode)); if (!inited_) { setStatus(kInitFailMsg, Severity::Error); return; @@ -688,6 +790,13 @@ class AudioService : public MoonModule { // and then lost its bus (a failed reinit after a pin edit, or release) // would leave the last real frame frozen on the LEDs instead of going dark. frame_ = AudioFrame{}; + // The ANALYSIS history goes with it, so a restarted source begins from a DEFINED state + // rather than the old source's last block: flux is a difference against the previous + // block, and the onset detector carries a running mean. The zeroed frame above is what + // keeps the first block after a restart silent (measured against zeros it would otherwise + // read as a full-scale rise), and this is what keeps the second one honest. + std::memset(prevBands_, 0, sizeof(prevBands_)); + onset_ = OnsetDetector{}; } // --- WLED audio sync (guarded: only compiled where platform::hasNetwork) --- @@ -800,7 +909,15 @@ class AudioService : public MoonModule { if (n <= 0) break; // -1 = nothing pending AudioFrame rf; if (parseWledAudioSync(pkt, static_cast(n), rf)) { + // The packet carries RAW bands; the ballistic is ours and lives across packets. + // A whole-frame copy would zero it forty times a second, so the smoothed state is + // carried over the copy and then advanced by this packet's bands, exactly as the + // mic path advances it by a block. + uint8_t keep[16]; + std::memcpy(keep, frame_.bandsSmoothed, sizeof(keep)); frame_ = rf; // received audio drives the effects + std::memcpy(frame_.bandsSmoothed, keep, sizeof(keep)); + finishBands(); lastSyncRecv_ = platform::millis(); // Whose audio this is. A receiver with no peer named looks identical to one taking // the wrong source, and on a multi-device rig that is the question being asked. diff --git a/src/core/math16.h b/src/core/math16.h index 8846a7de..68e33a41 100644 --- a/src/core/math16.h +++ b/src/core/math16.h @@ -119,17 +119,23 @@ constexpr int32_t map32(int32_t v, int32_t inLo, int32_t inHi, int32_t outLo, in /// silently freezes. The fix all nine converged on is to accumulate the RAW numerator in 64 bits and /// divide only at the read: which is what this does. /// -/// Usage: one member per animated quantity; call `advance(elapsedMs, rate)` once per frame, then +/// Usage: one member per animated quantity; call `advanceTo(nowMs, rate)` once per frame, then /// read as often as needed. `rate` is BPM-like: the caller's speed control, whatever its units. class BeatPhase { public: /// Accumulate this frame's contribution. Safe to call with a rate of 0 (the phase holds). /// The first call only establishes the time base, so a large `elapsed` at startup cannot jump /// the phase: the same first-tick guard three of the nine effects carried by hand. - void advance(uint32_t elapsedMs, uint32_t rate) { - if (!started_) { started_ = true; lastMs_ = elapsedMs; return; } - const uint32_t dt = elapsedMs - lastMs_; // unsigned: correct across the millis() wrap - lastMs_ = elapsedMs; + /// + /// Takes the CURRENT TIME, not a frame delta: the delta is computed here. Named `advanceTo` + /// for that reason, because `advance(dt)` reads naturally and is wrong, which four of five + /// oscillator callers wrote before the name said otherwise. Passing a delta feeds this the + /// change in frame time, so the phase creeps on a jittery device and stops dead on a steady + /// one; the effects it drove looked frozen while every test stayed green. + void advanceTo(uint32_t nowMs, uint32_t rate) { + if (!started_) { started_ = true; lastMs_ = nowMs; return; } + const uint32_t dt = nowMs - lastMs_; // unsigned: correct across the millis() wrap + lastMs_ = nowMs; num_ += static_cast(dt) * rate; } @@ -327,6 +333,20 @@ constexpr uint8_t smoothFollow(uint8_t current, uint8_t target, uint8_t rate) { return static_cast(current + (delta > 0 ? 1 : -1)); } +/// A meter's ballistic: rise at one rate, fall at another. `smoothFollow` with two time constants. +/// +/// A follower with a single rate is the wrong instrument for anything reactive: it makes the attack +/// as sluggish as the decay and rounds off the transient the effect exists to show. Every meter +/// standard separates the two, and a broadcast peak programme meter (PPM, IEC 60268-10) is the +/// shape wanted here: rise almost at once, fall over a comfortable time so the eye can read the +/// peak. WLED, FastLED and LedFx each arrived at the same asymmetric form independently. +/// +/// `rise` and `fall` are `smoothFollow` rates (0 = frozen, 255 = instant). Equal values reduce to +/// `smoothFollow` exactly, so this is a superset rather than a second mechanism. +constexpr uint8_t ballistic(uint8_t current, uint8_t target, uint8_t rise, uint8_t fall) { + return smoothFollow(current, target, target > current ? rise : fall); +} + /// The falling-peak meter: rise INSTANTLY to a new high, then decay slowly. The asymmetry is the /// whole point: a peak that eased upward would miss transients, and one that dropped instantly /// would show nothing to read. Every VU meter with a floating peak dot is this function. diff --git a/src/core/oscillators.h b/src/core/oscillators.h index 00fbd650..3fd3a1c4 100644 --- a/src/core/oscillators.h +++ b/src/core/oscillators.h @@ -50,7 +50,7 @@ struct Oscillator { /// A fixed-size bank of oscillators. `N` is a compile-time count because an effect knows how many /// quantities it animates, and a fixed member array costs no allocation and no indirection. /// -/// Usage, once per frame: `bank.advance(elapsed())`, then `bank.value(i)` wherever the value is +/// Usage, once per frame: `bank.advanceTo(elapsed())`, then `bank.value(i)` wherever the value is /// needed, including inside a pixel loop. Configure with `set(i, {...})` in prepare() or whenever a /// control changes; a rate change takes effect from that frame without jumping the phase, which is /// what live reconfiguration requires. @@ -68,11 +68,12 @@ class OscillatorBank { /// Read one oscillator's settings, for a caller that adjusts a single field. const Oscillator& get(uint8_t i) const { return osc_[i < N ? i : N - 1]; } - /// Advance every oscillator by this frame's elapsed time. Call once per frame, before reading. + /// Advance every oscillator to the current time. Call once per frame, before reading. Takes + /// the CURRENT TIME rather than a frame delta (BeatPhase::advanceTo owns the why). /// Each phase accumulates its own dt*rate numerator, so a rate of 0 holds and a rate changed /// mid-run continues from where the phase stands (BeatPhase owns the why). - void advance(uint32_t elapsedMs) { - for (uint8_t i = 0; i < N; i++) phase_[i].advance(elapsedMs, osc_[i].rate); + void advanceTo(uint32_t nowMs) { + for (uint8_t i = 0; i < N; i++) phase_[i].advanceTo(nowMs, osc_[i].rate); } /// Oscillator `i`'s current phase as an angle16, offset included. The raw cycle position, for a diff --git a/src/light/drivers/LedPeripheral.h b/src/light/drivers/LedPeripheral.h index eb8b82e0..d579c22e 100644 --- a/src/light/drivers/LedPeripheral.h +++ b/src/light/drivers/LedPeripheral.h @@ -60,6 +60,13 @@ class LedPeripheral { virtual bool powerOfTwoBus() const = 0; /// The status message when bus init fails on this peripheral. virtual const char* initFailMsg() const = 0; + + /// True when a PREVIOUS init failed only because another module held a peripheral this backend + /// needs, and that peripheral is now free: the driver then rebuilds itself, so the loser of a + /// contended claim recovers without the user touching anything. Cheap enough for tick1s (a + /// registry read, never an init). Default false: a backend with nothing to share never retries, + /// which keeps a genuinely bad config from re-attempting once a second forever. + virtual bool busContentionCleared() const { return false; } /// Must the loopback self-test build a full-width bus (true) or can it run on a private 1-lane /// unit (false)? esp_lcd i80 / MoonI80 need the full width; Parlio can do a single lane. virtual bool loopbackFullWidth() const = 0; diff --git a/src/light/drivers/MoonLedDriver.h b/src/light/drivers/MoonLedDriver.h index 262e405b..fa8f9c48 100644 --- a/src/light/drivers/MoonLedDriver.h +++ b/src/light/drivers/MoonLedDriver.h @@ -269,7 +269,7 @@ class MoonI80Peripheral : public LedPeripheral { /// Status text when the bus will not come up, so the cause is on screen rather than in a serial log. /// The two real causes are named: a pin the peripheral cannot route, or no DMA-reachable memory for /// the frame (or the ring's pool). - const char* initFailMsg() const override { return "LCD-MM: bus init failed — check pins / memory"; } + const char* initFailMsg() const override { return "LCD-MM: bus init failed, check pins / memory"; } /// The expander needs a backend that can stream its ×8 frame; LCD_CAM is it, and this backend is /// LCD_CAM-only, so the answer is simply "wherever this backend runs at all". bool supportsPinExpander() const override { return platform::hasLcdCam; } diff --git a/src/light/drivers/MultiPinLedDriver.h b/src/light/drivers/MultiPinLedDriver.h index 4920a51e..749829ab 100644 --- a/src/light/drivers/MultiPinLedDriver.h +++ b/src/light/drivers/MultiPinLedDriver.h @@ -70,37 +70,37 @@ class I80Peripheral : public LedPeripheral { /// - In shift mode this pin is wired to the physical '595 clock line on the expander board. /// Changing it means re-wiring hardware, not just re-configuring. /// - /// **Give it a real, free GPIO — do not set -1.** Bench-proven that nothing on a WS2812 strand reads - /// WR or DC (4096 lights over 16 lanes, and 1440 through a '595, both render with both pins at -1: - /// the peripheral generates the signals internally and the GPIO matrix only carries them off-chip). - /// But -1 does not MEAN "unrouted" here — it means **65535**: the value reaches the platform as - /// `uint16_t`, which slips past IDF's `wr_gpio_num >= 0 && dc_gpio_num >= 0` check - /// (esp_lcd_panel_io_i80.c), where a properly-typed `GPIO_NUM_NC` would be rejected outright. - /// `esp_lcd` then hands 65535 to `esp_rom_gpio_connect_out_signal`, and what happens next is PER-TARGET - /// ROM, not an API contract: the S3 and classic ROMs open with an unsigned bounds compare and return - /// without writing (a silent no-op), but the **ESP32-P4 ROM has no such guard** and computes a store - /// ~0x50120554 — a quarter-megabyte past the GPIO block, in another peripheral's window — plus a - /// >31-bit shift. This backend runs on the P4. IDF's own `esp_rom/patches/esp_rom_gpio.c` is unguarded - /// too, so the S3's check is an implementation detail a patch could remove, not a promise. FastLED's - /// LCD_CAM driver parks both pins on a dummy GPIO for the same reason. To spend no GPIO at all, use - /// MoonI80Peripheral: owning the DMA below esp_lcd, it holds DC at a constant level and routes WR only - /// when a '595 needs it as SRCLK. - /// Per-chip, because 10/11 are free GPIOs on the S3 these were chosen on and are the FLASH bus - /// on a classic ESP32 (6-11): routing the i80 clock onto one wedges the board to a watchdog - /// reset with no panic and no coredump. `i2sLanes > 0` IS "this is the classic-ESP32 i80" (the - /// two backends are mutually exclusive per silicon), the same discriminator dmaBudgetBytes() - /// below keys on, rather than a raw CONFIG_IDF_TARGET that would put chip knowledge outside the - /// platform layer. + /// **Unset (-1) means "no pin", and what that costs depends on the chip.** Nothing on a WS2812 + /// strand reads WR or DC (bench-proven: 4096 lights over 16 lanes and 1440 through a '595 both + /// render with neither line wired), so the only question is what the peripheral is given for + /// the GPIO number it insists on. /// - /// 18/23 rather than the first free numbers: WR and DC are peripheral-fixed signals no WS2812 - /// strand reads, so this default only has to avoid pins a BOARD is likely to have committed. - /// 21/22 look free chip-wise and are the QuinLED Dig-Next-2's relay lines, where claiming them - /// silently switched two power channels off (LEDs dark, no error anywhere). 18/23 are plain - /// GPIOs on every classic package: no strap, no flash, no UART, and unused by the catalog's - /// boards. A board that does wire them overrides the control, and reinit() refuses a reserved - /// pin outright. - int8_t clockPin = platform::i2sLanes > 0 ? 18 : 10; - int8_t dcPin = platform::i2sLanes > 0 ? 23 : 11; + /// **Naming.** WR and DC are the i8080 bus's own signal names (WR = the write strobe that + /// clocks each bus word, DC = the data/command select), which is what the datasheets and + /// `esp_lcd` call them; `clockPin`/`dcPin` are the control names a user sees. Every message + /// about them names both, as "clockPin (WR)" and "dcPin (DC)", so the UI and the datasheet + /// can be read together. + /// + /// **WR can be unset on the classic ESP32; DC cannot, anywhere.** WR reaches its pad through the + /// GPIO matrix, so the platform sinks an unset one onto an input-only pad and no usable GPIO is + /// spent. DC is toggled in SOFTWARE by esp_lcd on every transfer, and that call on a pad with no + /// output driver logs an error from a context where logging aborts, so DC always needs a real + /// pin: 33 on the classic (free on every package here), 11 on the LCD_CAM chips. On the LCD_CAM + /// chips (S3/P4/S31) both need a real pad, + /// because an invalid number reaches the ROM's matrix routine and the P4's writes a quarter + /// megabyte past the GPIO block (the S3 happens to ignore it, which is luck, not a contract); + /// there the default is 10/11, free on the S3 these were chosen on, and MoonI80Peripheral is + /// the way to spend no GPIO at all (owning the DMA below esp_lcd, it holds DC constant and + /// routes WR only when a '595 needs it as SRCLK). + /// + /// A board that needs WR on a real pin (a '595's shift clock) sets it; the platform refuses a + /// pin its package lacks or has wired to flash or PSRAM before the peripheral can touch it. + /// That refusal is what turned the QuinLED Dig-Next-2's old default of 18/23 from a silent + /// watchdog reset (the ESP32-PICO-V3-02 has no such pads) into a status naming the pin. + /// `i2sLanes > 0` IS "this is the classic-ESP32 i80" (the two backends are mutually exclusive + /// per silicon), the same discriminator dmaBudgetBytes() below keys on. + int8_t clockPin = platform::i2sLanes > 0 ? -1 : 10; + int8_t dcPin = platform::i2sLanes > 0 ? 33 : 11; // --- LedPeripheral descriptors --- @@ -143,17 +143,30 @@ class I80Peripheral : public LedPeripheral { if constexpr (platform::i2sLanes > 0) return LedHwBlock::I2s; else return LedHwBlock::LcdCam; } + /// The classic ESP32's i80 IS an I2S peripheral, and this bus always drives from instance 1, + /// leaving instance 0 (the only one with a PDM converter) for audio. Ask the platform whether + /// instance 1 is free now; on the LCD_CAM chips nothing is shared and this is a compile-time + /// false. + bool busContentionCleared() const override { + if constexpr (platform::i2sLanes > 0) return platform::i80Ws2812SharedBusFree(); + else return false; + } + const char* initFailMsg() const override { - // Names the same peripheral the label does, so the error and the dropdown agree. - return (platform::i2sLanes > 0) ? "I2S-IDF: bus init failed — check pins / memory" - : "LCD-IDF: bus init failed — check pins / memory"; + // The backend's own reason when it has one (a peripheral another module holds); else the + // generic line, naming the same peripheral the label does so the error and dropdown agree. + if (const char* why = platform::i80Ws2812LastError()) return why; + return (platform::i2sLanes > 0) ? "I2S-IDF: bus init failed, check pins / memory" + : "LCD-IDF: bus init failed, check pins / memory"; } /// Spare bus lanes (shift mode, when the board has fewer data pins than the bus is wide) park on /// WR: the peripheral already drives it and the board already wires it, so the lane is inert. /// (Overrides the interface default; this is the "ghost pin" the platform layer uses for the same /// reason.) - uint16_t clockPinForBus() const override { return static_cast(clockPin); } + uint16_t clockPinForBus() const override { + return clockPin < 0 ? platform::kBusPinUnset : static_cast(clockPin); + } /// Bind the i80-specific bus controls: the sacrificial WR (clockPin) and DC pins /// the peripheral mandates. @@ -171,23 +184,33 @@ class I80Peripheral : public LedPeripheral { /// distinct control lines — the bus won't init), so it can't be a warn-and-run like a data-lane /// collision (which only corrupts that one lane). null = no fatal condition. const char* validateBusFatal() const override { - // An UNSET clockPin/dcPin (-1) is fatal on i80: the bus mandates a valid WR and DC GPIO, but - // clockPinForBus()/busInit cast the int8_t to uint16_t, so -1 becomes 65535 and slips past - // IDF's own `wr_gpio_num >= 0 && dc_gpio_num >= 0` guard (see the clockPin doc above). Reject it - // here, before that cast, so an unconfigured board idles with a clear status instead of an - // init on a garbage GPIO number. - if (clockPin < 0) return "clockPin (WR) is unset — the i80 bus needs a valid WR GPIO"; - if (dcPin < 0) return "dcPin is unset — the i80 bus needs a valid DC GPIO"; - if (clockPin == dcPin) - return "clockPin (WR) and dcPin are the same GPIO — they must differ"; - // Neither may sit on a pin the chip wired to flash or PSRAM: routing I/O there corrupts the - // device. The driver's own sweep covers the bus LANES, but WR only rides that list when - // there are spare lanes to park it on (a full-width 8- or 16-pin setup has none) and DC - // never does, so these two are checked here, where the pair already lives. - if (platform::gpioCapability(static_cast(clockPin)).reserved) - return "clockPin (WR) is wired to flash/PSRAM on this chip - pick another pin"; - if (platform::gpioCapability(static_cast(dcPin)).reserved) - return "dcPin is wired to flash/PSRAM on this chip - pick another pin"; + // Unset (-1) is fine on the classic ESP32, where the platform sinks the line onto an + // input-only pad (see the clockPin doc), and fatal on the LCD_CAM chips, where the number + // would reach the ROM. The int8 -> uint16 cast in busInit is exactly why this is checked + // here: an unguarded -1 becomes 65535 and slips IDF's own `>= 0` test. + // WR may be unset on the classic ESP32 (the platform sinks it onto an input-only pad, which + // the GPIO matrix drives harmlessly); DC may never be, on any chip, because esp_lcd toggles + // it in software every frame and that call aborts on a pad with no output driver. + if (platform::i2sLanes == 0 && clockPin < 0) + return "clockPin (WR) is unset - the i80 bus needs a write-strobe GPIO on this chip"; + if (dcPin < 0) return "dcPin (DC) is unset - the i80 bus toggles it every frame, so it needs a real GPIO"; + if (clockPin >= 0 && clockPin == dcPin) + return "clockPin (WR) and dcPin (DC) are the same GPIO - they must differ"; + // Neither may sit on a pin the chip wired to flash or PSRAM, nor on one this PACKAGE does + // not have: routing a signal there corrupts the device or wedges its flash cache, and + // both fail silently. The driver's own sweep covers the bus LANES, but WR only rides that + // list when there are spare lanes to park it on and DC never does, so the pair is checked + // here, where it lives. + if (clockPin >= 0) { + const auto cap = platform::gpioCapability(static_cast(clockPin)); + if (!cap.validGpio) return "clockPin (WR) does not exist on this chip package - pick another pin"; + if (cap.reserved) return "clockPin (WR) is wired to flash/PSRAM on this chip - pick another pin"; + } + if (dcPin >= 0) { + const auto cap = platform::gpioCapability(static_cast(dcPin)); + if (!cap.validGpio) return "dcPin (DC) does not exist on this chip package - pick another pin"; + if (cap.reserved) return "dcPin (DC) is wired to flash/PSRAM on this chip - pick another pin"; + } // The '595 latch is a BUS LANE, so it needs its own GPIO: sharing it with WR would make the // pixel clock double as the latch (the '595 would present a byte on every shift cycle), and // sharing it with DC would latch on the command phase. Both are fatal — the bus builds, but @@ -197,7 +220,7 @@ class I80Peripheral : public LedPeripheral { if (owner_->latchPin == clockPin) return "latchPin is on clockPin (WR) — the latch needs its own GPIO"; if (owner_->latchPin == dcPin) - return "latchPin is on dcPin — the latch needs its own GPIO"; + return "latchPin is on dcPin (DC) - the latch needs its own GPIO"; } return nullptr; } @@ -246,9 +269,9 @@ class I80Peripheral : public LedPeripheral { /// slot keeps its wire duration. bool busInit(size_t frameBytes, bool wantSecondBuffer) override { return platform::i80Ws2812Init(i80_, owner_->busPinList(), owner_->busPinCount(), - static_cast(clockPin), - static_cast(dcPin), frameBytes, wantSecondBuffer, - owner_->busClockMultiplier()); + clockPinForBus(), + dcPin < 0 ? platform::kBusPinUnset : static_cast(dcPin), + frameBytes, wantSecondBuffer, owner_->busClockMultiplier()); } /// DMA buffer `i` (0/1) the orchestrator encodes into; buffer 1 is null when the second /// buffer didn't fit (single-buffer mode). Both are the same size (busCapacity). diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h index 8bb3a5ef..0b0ff867 100644 --- a/src/light/drivers/ParallelLedDriver.h +++ b/src/light/drivers/ParallelLedDriver.h @@ -195,7 +195,7 @@ class ParallelLedDriver : public DriverBase { /// **ON is simply the better configuration — the switch exists to A/B it, not because some setups /// should run it OFF.** The one-frame output latency it adds (~8–20 ms) sits inside the perceptual /// audio↔visual sync window and is small next to what the pipeline already spends (FFT window + - /// render + the 8–16 ms WS2812 wire itself), so there is no user class — sound-reactive included — + /// render + the 8–16 ms WS2812 wire itself), so there is no user class (audio-reactive included) /// that should turn it off for latency. Leave it ON and take the fps. /// /// **It is per-driver because the resource is per-driver:** each driver's second DMA buffer lives @@ -547,7 +547,7 @@ class ParallelLedDriver : public DriverBase { /// DOUBLE-BUFFER (doubleBuffer ON — encode N+1 while N clocks out, `max(encode, wire)` per tick, /// +1 frame latency, +1 DMA buffer). Inert off this chip and idle until inited with a source /// buffer + correction. (The double-buffer defaults ON — it overlaps the blocking wire wait and - /// lifted the P4 whole-board rate 48→76 fps; OFF is the sound-reactive 0-latency opt-out and pays + /// lifted the P4 whole-board rate 48→76 fps; OFF is the audio-reactive 0-latency opt-out and pays /// for exactly one buffer — see the doubleBuffer control + docs/history/lessons.md.) // REPORTED AS BLOCKING, deliberately: tickSync()/tickRing() reach busWaitIfBusy(), which // waits for the DMA transfer to finish (bounded by waitBudgetMs, and self-limiting via @@ -712,6 +712,15 @@ class ParallelLedDriver : public DriverBase { /// toward, and it tracks an overclocked slot rate directly. "—" until the first transfer completes. void tick1s() MM_NONBLOCKING override { if (!peripheral_) return; + // A bus that lost a shared peripheral to another module comes back on its own once that + // module lets go. On the classic ESP32 the i80 bus and a PDM microphone both need I2S0, so + // whichever asks second is refused; without this the loser stayed dark until the user + // happened to edit a control, which is a reboot-to-apply in all but name (architecture.md, + // live reconfiguration). Gated tightly, because this runs on the render thread: only while + // the driver WANTS the bus and does not hold it, and only when the backend says the thing + // it was refused is free again (a register read, not an init). The rebuild itself is the + // same reinit() a control edit runs, on the same cold path, at most once per second. + if (!inited_ && laneCount_ > 0 && peripheral_->busContentionCleared()) reinit(); const uint32_t us = peripheral_->busLastTransmitUs(); if (us == 0) std::snprintf(frameTimeStr_, sizeof(frameTimeStr_), "—"); else std::snprintf(frameTimeStr_, sizeof(frameTimeStr_), "%u µs (%u fps max)", @@ -1904,9 +1913,13 @@ class ParallelLedDriver : public DriverBase { for (uint8_t i = 0; i < width && i < kMaxLanes; i++) { const uint16_t pin = bus[i]; if (pin > 48) continue; // unset/NC: nothing routed - if (!platform::gpioCapability(static_cast(pin)).reserved) continue; + const auto cap = platform::gpioCapability(static_cast(pin)); + if (cap.validGpio && !cap.reserved) continue; + // A pin the package lacks fails the same silent way a flash pin does (the + // ESP32-PICO-V3-02 has no GPIO 18/23), so it is refused here for the same reason. std::snprintf(statusBuf_, sizeof(statusBuf_), - "GPIO %u is wired to flash/PSRAM on this chip - pick another pin", + cap.validGpio ? "GPIO %u is wired to flash/PSRAM on this chip - pick another pin" + : "GPIO %u does not exist on this chip package - pick another pin", unsigned(pin)); setStatus(statusBuf_, Severity::Error); deinit(); diff --git a/src/light/drivers/ParlioLedDriver.h b/src/light/drivers/ParlioLedDriver.h index 16159a85..a3581fc7 100644 --- a/src/light/drivers/ParlioLedDriver.h +++ b/src/light/drivers/ParlioLedDriver.h @@ -41,7 +41,7 @@ class ParlioPeripheral : public LedPeripheral { /// Parlio is its own TX peripheral block, distinct from LcdCam/I2S — it coexists with an i80 or /// MoonI80 driver on the same chip (the P4 has both). LedHwBlock hwBlock() const override { return LedHwBlock::Parlio; } - const char* initFailMsg() const override { return "Parlio init failed — check pins / memory"; } + const char* initFailMsg() const override { return "Parlio init failed, check pins / memory"; } // The WS2812 slot rate (375 ns @ 2.67 MHz) — identical to the LCD backend's; // the P4 Parlio's 160 MHz PLL clock divides to it exactly (/60). diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h index 10f9fc61..80498f46 100644 --- a/src/light/drivers/RmtLedDriver.h +++ b/src/light/drivers/RmtLedDriver.h @@ -173,6 +173,10 @@ class RmtLedDriver : public DriverBase { return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0 || std::strcmp(name, "timing") == 0 || std::strcmp(name, "t0hNs") == 0 || std::strcmp(name, "t1hNs") == 0 || std::strcmp(name, "periodNs") == 0 + // Not because it rebuilds anything: defineControls decides whether the loopback pins + // are shown, and only a prepare sweep re-runs it. Without this the checkbox toggles a + // mode whose three controls never appear, so the test cannot be aimed from the UI. + || std::strcmp(name, "loopbackTest") == 0 || isWindowControl(name); } @@ -239,6 +243,20 @@ class RmtLedDriver : public DriverBase { /// only calls this when effectively-enabled and routes to release() (release) otherwise, so the /// channels + buffer free when the driver, or a parent, is disabled. void prepare() override { + // Drain first. resizeSymbols() may free the symbol buffer and reinit() deletes the + // channel, and a prepare arrives from a control change, which can land mid-frame: the + // peripheral is then still reading those symbols. Bounded, because a wedged transfer must + // not block a config change forever; past the deadline the rebuild proceeds, which is the + // pre-existing behavior rather than a new risk. + if (txInFlight_) { + for (uint8_t attempt = 0; attempt < 4 && txInFlight_; attempt++) + txInFlight_ = !waitForPins(); + // Still busy after every attempt: the peripheral is reading symbols_ right now, so + // rebuilding would free the buffer under it, which is the corruption this drain exists + // to prevent. Defer instead. tick() re-waits and the config applies on a later prepare; + // the alternative, rebuilding anyway, trades a delayed config change for a torn frame. + if (txInFlight_) return; + } parseConfig(); resizeSymbols(); reinit(); @@ -284,6 +302,18 @@ class RmtLedDriver : public DriverBase { // Encode within this driver's window only. winLen_ is the slice length; // txLightCount_ (Σ pinCounts_) is what the pins clock out — n is the min, // so a window smaller than the configured pin total never reads past it. + // A frame still on the wire OWNS symbols_: the RMT copy encoder streams straight out of it, + // so re-encoding now rewrites bytes the peripheral is mid-way through clocking. That is not + // a dropped frame, it is a corrupted one, and it shows as a handful of lights in a color + // the effect never drew. Only a timed-out wait leaves this set, so the normal path never + // sees it; when it happens, skipping the tick lets the transfer finish and the next tick + // encodes cleanly. Bench: this is what remained after the memory-block and interrupt + // priority work, and it is independent of light count, which is what ruled those out. + if (txInFlight_) { + txInFlight_ = !waitForPins(); // still busy: leave symbols_ alone for another tick + if (txInFlight_) return; + } + const nrOfLightsType n = txLightCount_ < winLen_ ? txLightCount_ : winLen_; const uint8_t outCh = correction_.outChannels; // Same defensive guard ArtNet uses: skip rather than overrun if the @@ -331,10 +361,22 @@ class RmtLedDriver : public DriverBase { started[i] = platform::rmtWs2812Transmit(rmt_[i], symbols_ + pinOffsets_[i], static_cast(pinLights) * wordsPerLight); } + for (uint8_t i = 0; i < pinCount_; i++) started_[i] = started[i]; + txInFlight_ = !waitForPins(); + if (cfg_.reset_us) platform::delayUs(cfg_.reset_us); + } + + /// Wait on every pin that actually started, and report whether they all finished. A pin whose + /// transmit never started is not waited on: with no done-callback coming, that would spend the + /// full timeout and let one bad pin stall the tick. + bool waitForPins() MM_NONBLOCKING { + bool allDone = true; for (uint8_t i = 0; i < pinCount_; i++) { - if (started[i]) platform::rmtWs2812Wait(rmt_[i], 1000 /* ms */); + if (!started_[i]) continue; + if (platform::rmtWs2812Wait(rmt_[i], 1000 /* ms */)) started_[i] = false; + else allDone = false; } - if (cfg_.reset_us) platform::delayUs(cfg_.reset_us); + return allDone; } /// Test-only accessors. symbolBuffer/symbolCapacity mirror ArtNet's @@ -376,6 +418,8 @@ class RmtLedDriver : public DriverBase { nrOfLightsType winLen_ = 0; // window length (lights), clamped to the buffer uint8_t pinCount_ = 0; // 0 = idle (parse error / no pins) bool inited_ = false; // all-or-nothing across the pins + bool started_[kMaxPins] = {}; // which pins have a transmit still to be waited on + bool txInFlight_ = false; // a frame is still clocking out of symbols_ uint32_t* symbols_ = nullptr; // owned; one word per WS2812 data bit size_t symbolCap_ = 0; // words allocated // Per-light scratch for correction_.apply(): `outChannels` bytes, one light at a time. Heap, sized @@ -513,7 +557,17 @@ class RmtLedDriver : public DriverBase { const size_t need = symbolsFor(n, ch); if (symbols_ && symbolCap_ >= need) return; freeSymbols(); - symbols_ = static_cast(platform::alloc(need * sizeof(uint32_t))); + // INTERNAL RAM, deliberately, on a chip that would otherwise put this in PSRAM. The RMT copy + // encoder runs inside the refill interrupt and reads these symbols straight into the + // peripheral's memory, so on a DMA-less classic ESP32 every refill is a read of this buffer + // under a 40-160 us deadline. From PSRAM that read goes through the 32 KB cache that WiFi + // and the render loop also churn, and one miss is a stall of microseconds: a late refill, + // a few wrong lights, at any light count and on any core. Bench: QuinLED Dig-Next-2 + // (PICO-V3-02, 2 MB PSRAM), 2026-09-05. The buffer is small (24 bytes x 4 per light: 24 KB + // at 256 lights) so internal RAM affords it; a board that cannot falls back to the general + // heap rather than to no output at all. + symbols_ = static_cast(platform::allocInternal(need * sizeof(uint32_t))); + if (!symbols_) symbols_ = static_cast(platform::alloc(need * sizeof(uint32_t))); symbolCap_ = symbols_ ? need : 0; publishHeapBytes(); // the symbol buffer grew — refresh the memory readout } @@ -623,7 +677,7 @@ class RmtLedDriver : public DriverBase { // --- RMT channels (hardware; RMT targets only) --- - static constexpr const char* kInitFailMsg = "RMT init failed — check the pins"; + static constexpr const char* kInitFailMsg = "RMT init failed, check the pins"; // All-or-nothing: a failing pin deinits everything and reports which pin, // so tick()'s guard stays a single bool and the user sees one clear error diff --git a/src/light/effects/AudioSpectrumEffect.h b/src/light/effects/AudioSpectrumEffect.h index 8ffec34d..476b8fa2 100644 --- a/src/light/effects/AudioSpectrumEffect.h +++ b/src/light/effects/AudioSpectrumEffect.h @@ -52,10 +52,11 @@ 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; + // The RAW level, like the spectrum bars above: this effect is the audio test + // instrument, so it shows what the analyzer produces rather than a prettified version + // of it. The smoothed level (levelSmoothed, a one-pole EMA) lags by ~70 ms, which is + // the calm VU look every other effect wants and the wrong thing for judging response. + const uint16_t vu = f->level; const lengthType litW = static_cast( static_cast(vu > 255 ? 255 : vu) * w / 255u); // Green → red across the width, the VU-meter look. D2: write the z=0 slice only; diff --git a/src/light/effects/AuroraEffect.h b/src/light/effects/AuroraEffect.h index 18d683ac..57370a1b 100644 --- a/src/light/effects/AuroraEffect.h +++ b/src/light/effects/AuroraEffect.h @@ -37,7 +37,7 @@ namespace mm { /// Effect: layered noise curtains in polar coordinates, each layer on its own oscillators. class AuroraEffect : public EffectBase { public: - const char* tags() const override { return "💫🖌️"; } // power-function showcase + const char* tags() const override { return "💫🖌️🌫️🎡"; } // power-function showcase Dim dimensions() const override { return Dim::D3; } // volumetric: the curtains have depth static constexpr uint8_t kMaxLayers = 4; @@ -93,7 +93,7 @@ class AuroraEffect : public EffectBase { {.rate = static_cast(rate / 5), .low = -8192, .high = 8192, .phaseOffset = static_cast(i * 20000), .wave = Wave::Sine}); } - bank_.advance(elapsed()); + bank_.advanceTo(elapsed()); const bool table = lut_.ready(); const int32_t cx = w / 2, cy = h / 2, cz = dep / 2; diff --git a/src/light/effects/BeatRipplesEffect.h b/src/light/effects/BeatRipplesEffect.h new file mode 100644 index 00000000..4efe4ee3 --- /dev/null +++ b/src/light/effects/BeatRipplesEffect.h @@ -0,0 +1,188 @@ +#pragma once + +#include "light/effects/EffectBase.h" + +namespace mm { + +// BeatRipples: every beat is a stone dropped in water. +// +// The surface is a real wave simulation, the classic two-buffer scheme (Gomez 2000, the water +// effect the demoscene settled on): each cell's next height is the average of its four neighbors +// doubled, minus its previous height, damped. That is the discrete wave equation, and it gives +// what a hand-drawn expanding circle cannot: ripples that pass THROUGH each other, reflect off the +// walls, and interfere into the standing patterns that make water look like water. +// +// A detected onset drops a stone. The loudest band decides where, so a bass hit lands near the +// center and a treble hit out at the rim, and the strength of the hit sets how deep the stone +// falls. Between beats the surface keeps ringing on its own, which is why this reads as water +// rather than as a flash. +// +// The height field is rendered by SLOPE, not by height: a surface is visible because it bends +// light, so the difference between neighboring cells is what lights a pixel. That is also what +// makes the crests read as bright lines rather than as blobs. +// +// Volumetric: `Layer::extrude` fills a cube with the plane, the same choice Particles and Wave +// make. The wave equation itself is 2D, and a 3D one is a different effect rather than a flag. +// @card BeatRipplesEffect.png +/// Effect: a wave surface where every detected beat drops a stone, rippling and interfering. +class BeatRipplesEffect : public EffectBase { +public: + const char* tags() const override { return "💫🎶🖌️"; } + Dim dimensions() const override { return Dim::D2; } + + uint8_t damping = 200; // how long the water keeps ringing + uint8_t drop = 180; // how deep a beat's stone falls + uint8_t rain = 30; // idle drops when there is no music at all + uint8_t shine = 150; // how strongly the slope lights the surface + + void defineControls() override { + controls_.addControl("damping", damping, 0, 255); + controls_.addControl("drop", drop, 0, 255); + controls_.addControl("rain", rain, 0, 255); + controls_.addControl("shine", shine, 0, 255); + } + + void prepare() override { + const lengthType w = width(), h = height(); + const size_t n = static_cast(w) * h; + cur_.resize(n); prev_.resize(n); + if (cur_) std::memset(cur_.data(), 0, cur_.bytes()); + if (prev_) std::memset(prev_.data(), 0, prev_.bytes()); + onsetSeen_ = false; + started_ = false; + seq_ = 0; + // The rain clock starts full, so the first drop lands on the opening frame. Starting at + // zero leaves the pool empty for up to two seconds, which reads as a broken effect. + carry_ = 2000; + } + + void tick() MM_NONBLOCKING override { + if (!cur_ || !prev_) return; + const draw::Canvas cv = canvas(); + const lengthType w = width(), h = height(); + if (w < 3 || h < 3) return; + const uint32_t now = elapsed(); + const uint32_t dt = started_ ? now - lastMs_ : 0u; + lastMs_ = now; + started_ = true; + + const AudioFrame* f = AudioService::latestFrame(); + const bool onsetNow = f && f->onset != 0; + if (onsetNow && !onsetSeen_) { + // Where the stone lands: the loudest band picks the radius, so bass falls near the + // center and treble out at the rim. The angle walks so successive hits spread out. + uint8_t loudest = 0, best = 0; + for (uint8_t b = 0; b < 16; b++) if (f->bands[b] > best) { best = f->bands[b]; loudest = b; } + const angle16 a = static_cast(hashInt(seq_, 7) << 8); + const int32_t maxR = (w < h ? w : h) / 2 - 2; + const int32_t r = (maxR * (loudest + 1)) / 17; + const lengthType sx = static_cast(w / 2 + (static_cast(cos16(a)) * r) / 32768); + const lengthType sy = static_cast(h / 2 + (static_cast(sin16(a)) * r) / 32768); + // How hard the beat hit scales the stone, so a loud onset makes a bigger wave. The + // floor keeps a weak but real onset visible rather than silent. + const int32_t hit = 96 + (static_cast(f->onset) * 159) / 255; + splash(sx, sy, static_cast(-(static_cast(drop) * kSplashScale / 255) * hit / 255)); + seq_++; + } + onsetSeen_ = onsetNow; + + // Idle rain, so the surface is alive with no music. Time-paced, not per frame. + if (rain > 0) { + carry_ += dt; + const uint32_t every = 2000u - static_cast(rain) * 7u; + if (carry_ >= every) { + carry_ = 0; + const lengthType sx = static_cast(hashInt(seq_, 11) % static_cast(w)); + const lengthType sy = static_cast(hashInt(seq_, 13) % static_cast(h)); + splash(sx, sy, static_cast(-(static_cast(rain) * kSplashScale) / 255)); + seq_++; + } + } + + // The wave equation runs on a FIXED timestep, not once per frame. A wave simulation's + // speed IS its step count, so stepping per frame makes the water ring at the framerate: + // measured 16x faster at 1200 fps than at 60. The accumulator gives the same physics on + // any device, and the cap stops a long stall from spending a second catching up. + stepCarry_ += dt; + constexpr uint32_t kStepMs = 16; // ~60 physics steps a second + uint8_t steps = 0; + while (stepCarry_ >= kStepMs && steps < 4) { stepCarry_ -= kStepMs; steps++; } + if (stepCarry_ > kStepMs * 4) stepCarry_ = 0; + for (uint8_t it = 0; it < steps; it++) waveStep(); + renderSurface(cv, w, h); + } + + /// One step of the wave equation: next = (neighbors / 2) - previous, damped. The one line + /// that makes ripples pass THROUGH each other rather than merely expand. + void waveStep() { + const lengthType w = width(), h = height(); + int16_t* c = cur_.data(); + int16_t* p = prev_.data(); + const int32_t keep = 224 + static_cast(damping) / 8; // 224..255 of 256 + for (lengthType y = 1; y < h - 1; y++) + for (lengthType x = 1; x < w - 1; x++) { + const size_t o = static_cast(y) * w + x; + const int32_t sum = static_cast(c[o - 1]) + c[o + 1] + c[o - w] + c[o + w]; + int32_t v = (sum / 2) - static_cast(p[o]); + v = (v * keep) / 256; + p[o] = static_cast(v < -20000 ? -20000 : (v > 20000 ? 20000 : v)); + } + // The two buffers exchange roles: `prev_` now holds the new surface. + for (size_t i = 0, n = cur_.count(); i < n; i++) { const int16_t tmp = cur_[i]; cur_[i] = prev_[i]; prev_[i] = tmp; } + } + + /// Render by SLOPE: a water surface is visible because it bends light, so the gradient + /// between neighbors is what lights a pixel, not the height itself. + void renderSurface(const draw::Canvas& cv, lengthType w, lengthType h) { + const int16_t* s = cur_.data(); + for (lengthType y = 0; y < h; y++) + for (lengthType x = 0; x < w; x++) { + const size_t o = static_cast(y) * w + x; + const int32_t gx = (x > 0 && x < w - 1) ? static_cast(s[o + 1]) - s[o - 1] : 0; + const int32_t gy = (y > 0 && y < h - 1) ? static_cast(s[o + w]) - s[o - w] : 0; + int32_t mag = (gx < 0 ? -gx : gx) + (gy < 0 ? -gy : gy); + mag = (mag * shine) / 512; + const uint8_t bri = static_cast(mag > 255 ? 255 : mag); + // The palette index follows the HEIGHT, so a crest and a trough differ in color + // while the slope decides how brightly either shows. + const int32_t hgt = static_cast(s[o]); + const uint8_t index = static_cast(128 + (hgt > 4000 ? 127 : (hgt < -4000 ? -128 : (hgt * 127) / 4000))); + draw::pixel(cv, {x, y, 0}, colorFromPalette(*Palettes::active(), index, bri)); + } + } + +private: + /// The height field is int16 and rings up to +/-20000, so a stone is pressed in units of + /// thousands. The 0..255 `drop` and `rain` knobs scale onto that range here: at 255 a beat + /// presses the surface a third of the way to the clamp, which is deep enough to read as a + /// wave and shallow enough that interfering ripples do not saturate. + static constexpr int32_t kSplashScale = 6000; + + /// A stone: a small dish pressed into the surface, so the ripple starts as a real displacement + /// rather than a single spike that the simulation would smear into noise. + void splash(lengthType cx, lengthType cy, int16_t depth) { + const lengthType w = width(), h = height(); + const lengthType rad = static_cast((w < h ? w : h) / 24 + 1); + const int32_t r2 = static_cast(rad) * rad; + for (lengthType dy = -rad; dy <= rad; dy++) + for (lengthType dx = -rad; dx <= rad; dx++) { + const int32_t q = static_cast(dx) * dx + static_cast(dy) * dy; + if (q > r2) continue; + const lengthType x = cx + dx, y = cy + dy; + if (x < 1 || y < 1 || x >= w - 1 || y >= h - 1) continue; + const int32_t fall = ((r2 - q) * 100) / (r2 > 0 ? r2 : 1); + // Accumulated, not assigned: a stone landing on a live ripple adds to it. Writing + // the dish flat would erase whatever wave was already passing through, which is + // the one thing this simulation exists to show. + const size_t o = static_cast(y) * w + x; + const int32_t v = static_cast(cur_[o]) + (depth * fall) / 100; + cur_[o] = static_cast(v < -20000 ? -20000 : (v > 20000 ? 20000 : v)); + } + } + + ScratchBuffer cur_{*this}, prev_{*this}; + bool onsetSeen_ = false, started_ = false; + uint32_t lastMs_ = 0, carry_ = 2000, seq_ = 0, stepCarry_ = 0; +}; + +} // namespace mm diff --git a/src/light/effects/ColorTrailsEffect.h b/src/light/effects/ColorTrailsEffect.h new file mode 100644 index 00000000..703b1ff4 --- /dev/null +++ b/src/light/effects/ColorTrailsEffect.h @@ -0,0 +1,263 @@ +#pragma once + +#include "light/effects/EffectBase.h" + +namespace mm { + +// ColorTrails: emitters pouring color into a flow that is two noise profiles, not a field. +// +// The flow here is SEPARABLE, and that is the whole idea. A velocity field normally costs one +// vector per cell; this one is a single noise value per row and per column, so a WxH grid is +// steered by W+H numbers. Each row shifts horizontally by its own amount and each column shifts +// vertically by its own, and because the two shears compose, the picture swirls and folds as though +// something were solving for it. Nothing is: there is no pressure, no divergence, no iteration. +// +// That makes it the cheap end of the transport family. `FluidEffect` runs a real Stam solver and +// buys interaction between jets and vortices that form out of the flow's own history, at roughly +// twenty passes over the grid; this is two, and on a large panel that is the difference between an +// effect that runs and one that does not. Reach for the solver when the medium itself is the +// subject, and for this when the subject is color being carried. +// +// Four pieces, each a power function: +// +// - `inoise16` sampled along one axis fills the two profiles, so the flow drifts and reverses on +// its own clock rather than blowing steadily in one direction. +// - `draw::advect16` carries the plane along that flow, backward-sampled and bilinear, which is +// what smears a dot into a ribbon instead of teleporting it. +// - `draw::decay16` fades by a half-life, so a trail is the same length in seconds on any device. +// - `OscillatorBank` walks the emitters, so what is poured in keeps moving. +// +// The plane is 16-bit for the reason every transport effect here is: a value multiplied by slightly +// less than one, many times a second, either dies early or never fades at 8 bits. +// +// Credit: Stefan Petrick, whose concept this is, and Jeff (mindful_stone / 4wheeljive), whose +// ColorTrails in AuroraPortal is the composition it follows, by way of MoonLight: +// https://github.com/4wheeljive/AuroraPortal/blob/main/src/programs/colorTrails_detail.hpp +// The separable-noise-advection idea is theirs; the kernels underneath are this project's. +// @card ColorTrailsEffect.png +/// Effect: color emitters carried by a flow made of two noise profiles, one per axis. +class ColorTrailsEffect : public EffectBase { +public: + const char* tags() const override { return "💫🖌️💨🌫️"; } + Dim dimensions() const override { return Dim::D3; } // the profiles steer every slice + + /// Which emitters pour color in. They are compositional rather than exclusive: `All` is the + /// intended picture and the others exist to see one at a time. + enum class Mode : uint8_t { All = 0, Orbital, Lissajous, Border }; + + uint8_t speed = 60; // how fast the emitters travel + uint8_t flow = 128; // how far a row or column is pushed: the shear amount + uint8_t flowSpeed = 128; // how fast the profiles themselves drift + uint8_t scale = 85; // the profiles' spatial frequency: few broad bands or many fine + uint8_t persistence = 60; // how long color survives, as a half-life + uint8_t colorSpeed = 128; // how fast the emitters walk the palette + uint8_t size = 128; // orbit radius, and the Lissajous figure's reach + uint8_t mode = static_cast(Mode::All); + + void defineControls() override { + controls_.addControl("speed", speed, 0, 255); + controls_.addControl("flow", flow, 0, 255); + controls_.addControl("flowSpeed", flowSpeed, 0, 255); + controls_.addControl("scale", scale, 1, 255); + controls_.addControl("persistence", persistence, 0, 255); + controls_.addControl("colorSpeed", colorSpeed, 0, 255); + controls_.addControl("size", size, 0, 255); + controls_.addSelect("mode", mode, kModes, 4); + } + + void prepare() override { + const size_t needed = static_cast(width()) * height() * depth() * 3u; + plane_.resize(needed); + scratch_.resize(needed); + carry_.resize(needed); + // The profiles are the whole velocity field: one value per column, one per row. + xProf_.resize(static_cast(width())); + yProf_.resize(static_cast(height())); + if (plane_) std::memset(plane_.data(), 0, plane_.bytes()); + if (scratch_) std::memset(scratch_.data(), 0, scratch_.bytes()); + // The dither accumulator too: resize KEEPS the old contents at an unchanged size, so a + // re-prepare would carry the previous configuration's error into the first frames. + if (carry_) std::memset(carry_.data(), 0, carry_.bytes()); + started_ = false; + front_ = true; + } + + void tick() MM_NONBLOCKING override { + if (!plane_ || !scratch_ || !xProf_ || !yProf_) return; + const lengthType w = width(), h = height(), d = depth(); + const uint32_t now = elapsed(); + const uint32_t dt = started_ ? now - lastMs_ : 0u; + lastMs_ = now; + started_ = true; + + // Both emitter clocks scale from `speed`, and 0 STOPS them rather than leaving a floor: + // a speed slider whose slowest setting still moves is not a speed slider. The orbit and + // the Lissajous run at different rates so they drift in and out of step instead of + // marching together. + bank_.set(0, {.rate = static_cast(speed / 4), .low = 0, .high = 65535, + .phaseOffset = 0, .wave = Wave::Saw}); + bank_.set(1, {.rate = static_cast((speed * 3u) / 22u), .low = 0, .high = 65535, + .phaseOffset = 16384, .wave = Wave::Saw}); + // advanceTo() takes an ABSOLUTE timestamp and computes its own delta (math16.h BeatPhase): + // passing this frame's dt instead makes every delta a few milliseconds of a clock that + // never advances, so the emitters sit still while the picture flickers between them. + bank_.advanceTo(now); + // The palette walk is on the same clock, advanced here rather than read off the wall time: + // `colorSpeed` sets how fast it moves THROUGH the palette, and `speed` still gates it, so + // stopping the effect stops the color too. + huePhase_ += (dt * static_cast(colorSpeed) * static_cast(speed)) / 24000u; + + sampleProfiles(w, h, now); + + ScratchBuffer& src = front_ ? plane_ : scratch_; + ScratchBuffer& dst = front_ ? scratch_ : plane_; + + // The two shears, applied as one advection: a light's source is offset horizontally by its + // ROW's profile and vertically by its COLUMN's. Doing both in one backward sample is what + // keeps this to a single pass, where the original takes two and a temporary buffer. + const int32_t* xp = xProf_.data(); + const int32_t* yp = yProf_.data(); + draw::advect16(dst.data(), src.data(), w, h, d, + [xp, yp](lengthType x, lengthType y, lengthType, + draw::pos_t& vx, draw::pos_t& vy) { + vx = static_cast(yp[y]); + vy = static_cast(xp[x]); + }, draw::Edge::Wrap); + front_ = !front_; + ScratchBuffer& moved = front_ ? plane_ : scratch_; + + draw::decay16(moved.data(), moved.count(), halfLifeMs(), dt); + emit(moved.data(), w, h, d); + draw::blit16(canvas(), moved.data(), w, h, d, carry_ ? carry_.data() : nullptr); + } + +private: + static constexpr const char* kModes[4] = {"All", "Orbital", "Lissajous", "Border"}; + + uint32_t halfLifeMs() const { + return 20u + static_cast(persistence) * static_cast(persistence) / 16u; + } + + /// Fill the two profiles: one noise value per column and per row, in sub-pixels of shift. + /// + /// This is the effect's entire velocity field, and the reason it is cheap: W+H noise samples a + /// frame rather than W*H. The frequency scales inversely with the count so the flow keeps the + /// same number of bands across it on any grid, rather than turning to static on a large one. + void sampleProfiles(lengthType w, lengthType h, uint32_t now) { + const uint32_t phase = (now * static_cast(flowSpeed)) / 24u; + // The shear scales with the grid: a fixed pixel count is a strong flow on a 16-wide panel + // and an invisible one on a 256-wide, which is the same reasoning `fShift` carries upstream. + const int32_t reach = (static_cast(flow) * (w < h ? w : h)) / 220; + const uint32_t cells = static_cast(scale) * 24u; + for (lengthType x = 0; x < w; x++) { + const int32_t n = static_cast(inoise16(static_cast(x) * cells, phase, 0)) - 32768; + xProf_[static_cast(x)] = (n * reach * draw::kSubOne) / 32768 / 16; + } + for (lengthType y = 0; y < h; y++) { + // A different offset into the field, so the two axes are decoupled: one field read for + // both would rise and fall together and slide the whole picture along a diagonal. + const int32_t n = static_cast(inoise16(static_cast(y) * cells, phase, 32768)) - 32768; + yProf_[static_cast(y)] = (n * reach * draw::kSubOne) / 32768 / 16; + } + } + + /// The emitters: what is poured in, drawn after the transport so this frame's color is sharp + /// and only what came before has been carried. + void emit(uint16_t* p, lengthType w, lengthType h, lengthType d) { + const Mode m = static_cast(mode); + // An emitter writes at FULL brightness, every frame. What depends on elapsed time is + // where it is, not how brightly it burns: a dot moves further between two frames on a slow + // device and less on a fast one, and the trail it leaves is the same either way because + // `decay16` fades in real time. Scaling brightness by dt instead is what made this effect + // nearly black in the preview: at 17k fps the frame delta rounds to zero and the emitters + // wrote nothing, leaving only the border, which covers every frame, visible at all. + const uint8_t hue = static_cast(huePhase_); + const int32_t half = (w < h ? w : h) / 2; + const int32_t radius = (half * static_cast(size)) / 300 + 1; + const lengthType dot = static_cast((w < h ? w : h) / 24 + 1); + + if (m == Mode::All || m == Mode::Orbital) { + // Three circles on one orbit, spaced a third apart, each a third of the palette along. + for (uint8_t i = 0; i < 3; i++) { + const angle16 a = static_cast(bank_.phase(0) + i * 21845); + const lengthType cx = static_cast(w / 2 + (static_cast(cos16(a)) * radius) / 32768); + const lengthType cy = static_cast(h / 2 + (static_cast(sin16(a)) * radius) / 32768); + splat(p, w, h, d, cx, cy, dot, static_cast(hue + i * 85)); + } + } + if (m == Mode::All || m == Mode::Lissajous) { + // A Lissajous point: two sines at a 3:2 ratio, which traces a figure that never closes + // on itself the way a circle does, so the line it lays down keeps finding new ground. + const angle16 a = static_cast(bank_.phase(1)); + const angle16 b = static_cast(bank_.phase(1) * 3u / 2u); + // `size` scales the figure's reach, the same control the orbit radius reads, so the + // two emitters grow and shrink together instead of the Lissajous always spanning the + // whole panel. + const int32_t reachX = ((w / 2 - 1) * static_cast(size)) / 255; + const int32_t reachY = ((h / 2 - 1) * static_cast(size)) / 255; + const lengthType cx = static_cast(w / 2 + (static_cast(sin16(a)) * reachX) / 32768); + const lengthType cy = static_cast(h / 2 + (static_cast(cos16(b)) * reachY) / 32768); + splat(p, w, h, d, cx, cy, dot, static_cast(hue + 128)); + } + if (m == Mode::All || m == Mode::Border) { + // The rim, its hue walking around the perimeter: the flow pulls it inward, so the + // border is a source that feeds the whole picture rather than a frame around it. + for (lengthType x = 0; x < w; x++) { + const uint8_t c = static_cast(hue + (x * 255) / (w > 1 ? w : 1)); + put(p, w, h, d, x, 0, c, 255); + put(p, w, h, d, x, static_cast(h - 1), c, 255); + } + for (lengthType y = 0; y < h; y++) { + const uint8_t c = static_cast(hue + 128 + (y * 255) / (h > 1 ? h : 1)); + put(p, w, h, d, 0, y, c, 255); + put(p, w, h, d, static_cast(w - 1), y, c, 255); + } + } + } + + /// A soft round emitter, brightest at its center. + void splat(uint16_t* p, lengthType w, lengthType h, lengthType d, + lengthType cx, lengthType cy, lengthType r, uint8_t index) { + const int32_t r2 = static_cast(r) * r; + for (lengthType dy = -r; dy <= r; dy++) + for (lengthType dx = -r; dx <= r; dx++) { + const int32_t q = static_cast(dx) * dx + static_cast(dy) * dy; + if (q > r2) continue; + const uint8_t bri = static_cast(255 - (q * 255) / (r2 > 0 ? r2 : 1)); + put(p, w, h, d, static_cast(cx + dx), static_cast(cy + dy), index, bri); + } + } + + /// Write one light of the wide plane, in the palette's color, taking the BRIGHTER of what is + /// there and what is poured in. + /// + /// Not a sum: an emitter that keeps covering the same light (the border does, every frame) + /// accumulates without limit and pins it at white, which no half-life can drain and which gets + /// worse the faster the device runs. A maximum bounds every light by the color actually poured + /// in, so the picture stays the palette's rather than turning to paper, and the trail still + /// reads because decay pulls a light down as soon as the emitter moves off it. + void put(uint16_t* p, lengthType w, lengthType h, lengthType d, + lengthType x, lengthType y, uint8_t index, uint8_t bri) { + if (x < 0 || y < 0 || x >= w || y >= h) return; + const RGB c = colorFromPalette(*Palettes::active(), index, bri); + const uint8_t ch[3] = {c.r, c.g, c.b}; + for (lengthType z = 0; z < d; z++) { + const size_t o = ((static_cast(z) * h + y) * w + x) * 3u; + for (uint8_t k = 0; k < 3; k++) { + const uint16_t v = static_cast(static_cast(ch[k]) << 8); + if (v > p[o + k]) p[o + k] = v; + } + } + } + + ScratchBuffer plane_{*this}; ///< the color itself, three samples per light + ScratchBuffer scratch_{*this}; ///< advect's destination; the two alternate roles + ScratchBuffer carry_{*this}; ///< the dither's per-channel error + ScratchBuffer xProf_{*this}; ///< one shift per column: the vertical flow + ScratchBuffer yProf_{*this}; ///< one shift per row: the horizontal flow + OscillatorBank<2> bank_; + bool front_ = true, started_ = false; + uint32_t lastMs_ = 0, huePhase_ = 0; +}; + +} // namespace mm diff --git a/src/light/effects/DissolveEffect.h b/src/light/effects/DissolveEffect.h index a22c67e1..f0807106 100644 --- a/src/light/effects/DissolveEffect.h +++ b/src/light/effects/DissolveEffect.h @@ -49,7 +49,7 @@ class DissolveEffect : public EffectBase { const draw::Canvas cv = canvas(); const lengthType w = width(), h = height(); - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); const uint32_t raw = phase_.phase(65536); // Each complete sweep is one "generation": the generation number seeds the hash, so every diff --git a/src/light/effects/DistortionWavesEffect.h b/src/light/effects/DistortionWavesEffect.h index 27cb8fb0..f623eb19 100644 --- a/src/light/effects/DistortionWavesEffect.h +++ b/src/light/effects/DistortionWavesEffect.h @@ -43,7 +43,7 @@ class DistortionWavesEffect : public EffectBase { // speed 0 freezes: BeatPhase still tracks the time base, so resuming does not jump by the // pause. Two scales are read from the ONE accumulator (see ty below) — the reason phase() // takes the scale at the read rather than baking it into the accumulate. - phase_.advance(elapsed(), speed); + phase_.advanceTo(elapsed(), speed); const uint8_t t = static_cast(phase_.phase(256)); // ty is the y-axis time phase, running ~1.3× t. Reading the SAME accumulator at a different // scale (not deriving it from the already-wrapped uint8 t) keeps it CONTINUOUS: computing ty diff --git a/src/light/effects/EchoEffect.h b/src/light/effects/EchoEffect.h index 87db9e10..15229899 100644 --- a/src/light/effects/EchoEffect.h +++ b/src/light/effects/EchoEffect.h @@ -61,7 +61,7 @@ class EchoEffect : public EffectBase { const lengthType w = width(), h = height(); if (w < 2 || h < 2 || !history_) return; - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); const angle16 t = static_cast(phase_.phase(65536)); // Feedback is per-frame work driving a per-second look, so every amount below scales by how diff --git a/src/light/effects/FishTankEffect.h b/src/light/effects/FishTankEffect.h index 66bfc5ef..b346c058 100644 --- a/src/light/effects/FishTankEffect.h +++ b/src/light/effects/FishTankEffect.h @@ -121,7 +121,7 @@ class FishTankEffect : public EffectBase { public: static constexpr uint8_t kPool = 24; // the control maxima, summed - const char* tags() const override { return "💫🎶✨👾"; } // audio-reactive when soundReactive is set + const char* tags() const override { return "💫🎶✨👾"; } // audio-reactive when audioReactive is set Dim dimensions() const override { return Dim::D2; } /// How many of each swim, and how fast. @@ -130,7 +130,7 @@ class FishTankEffect : public EffectBase { uint8_t tiny = 5; // the school uint8_t speed = 80; uint8_t spriteSize = 0; // 0 = auto: scale with the grid, as FlyingToasters does - bool soundReactive = false; // move to the music: each sprite on its own band, still in silence + bool audioReactive = false; // move to the music: each sprite on its own band, still in silence void defineControls() override { controls_.addControl("fish", fish, 0, 8); @@ -138,7 +138,7 @@ class FishTankEffect : public EffectBase { controls_.addControl("school", tiny, 0, 8); controls_.addControl("speed", speed, 1, 255); controls_.addControl("spriteSize", spriteSize, 0, 12); - controls_.addControl("soundReactive", soundReactive); + controls_.addControl("audioReactive", audioReactive); } void prepare() override { @@ -179,10 +179,10 @@ class FishTankEffect : public EffectBase { draw::fill(cv, RGB{0, 0, 0}); const uint32_t scale = time_.advance(elapsed()); - if (scale > 0) pool_.stepDriven(scale, soundReactive, wanted()); + if (scale > 0) pool_.stepDriven(scale, audioReactive, wanted()); // One shared tail-beat clock, offset per fish so the tank never pulses in unison. - beat_.advance(elapsed(), 200); + beat_.advanceTo(elapsed(), 200); syncPopulation(); diff --git a/src/light/effects/FluidEffect.h b/src/light/effects/FluidEffect.h index 00acc615..8a8ea826 100644 --- a/src/light/effects/FluidEffect.h +++ b/src/light/effects/FluidEffect.h @@ -26,7 +26,7 @@ namespace mm { /// Effect: dye poured into a simulated fluid, carried by the flow the medium itself works out. class FluidEffect : public EffectBase { public: - const char* tags() const override { return "💫🖌️"; } // power-function showcase + const char* tags() const override { return "💫🖌️🌊💨"; } // power-function showcase Dim dimensions() const override { return Dim::D3; } // a medium per slice, jets wander in z static constexpr uint8_t kMaxJets = 4; @@ -114,7 +114,10 @@ class FluidEffect : public EffectBase { {.rate = static_cast(rate / 4 + 1), .low = 0, .high = 65535, .phaseOffset = static_cast(i * 30000), .wave = Wave::Sine}); } - bank_.advance(dt); + // advanceTo() takes an ABSOLUTE timestamp and computes its own delta (math16.h BeatPhase), + // so passing this frame's dt feeds it the CHANGE in frame time: a few percent of the right + // motion on a jittery device, and none at all where the frame time is steady. + bank_.advanceTo(now); uint16_t* live = front_ ? dyeA_.data() : dyeB_.data(); uint16_t* spare = front_ ? dyeB_.data() : dyeA_.data(); diff --git a/src/light/effects/FlyingToastersEffect.h b/src/light/effects/FlyingToastersEffect.h index cf6e90df..4e93d21f 100644 --- a/src/light/effects/FlyingToastersEffect.h +++ b/src/light/effects/FlyingToastersEffect.h @@ -104,7 +104,7 @@ class FlyingToastersEffect : public EffectBase { public: static constexpr uint8_t kPool = 20; // 12 toasters + 8 toast, the control maxima - const char* tags() const override { return "💫🎶✨👾"; } // audio-reactive when soundReactive is set + const char* tags() const override { return "💫🎶✨👾"; } // audio-reactive when audioReactive is set Dim dimensions() const override { return Dim::D2; } /// How many of each fly, and how fast the flock drifts. @@ -112,14 +112,14 @@ class FlyingToastersEffect : public EffectBase { uint8_t toast = 3; uint8_t speed = 96; uint8_t spriteSize = 0; // 0 = auto: scale with the grid; both toasters and toast use it - bool soundReactive = false; // move to the music: each sprite on its own band, still in silence + bool audioReactive = false; // move to the music: each sprite on its own band, still in silence void defineControls() override { controls_.addControl("toasters", toasters, 1, 12); controls_.addControl("toast", toast, 0, 8); controls_.addControl("speed", speed, 1, 255); controls_.addControl("spriteSize", spriteSize, 0, 12); - controls_.addControl("soundReactive", soundReactive); + controls_.addControl("audioReactive", audioReactive); } void prepare() override { @@ -160,10 +160,10 @@ class FlyingToastersEffect : public EffectBase { draw::fill(cv, RGB{0, 0, 0}); const uint32_t scale = time_.advance(elapsed()); - if (scale > 0) pool_.stepDriven(scale, soundReactive, wanted()); + if (scale > 0) pool_.stepDriven(scale, audioReactive, wanted()); // The wing flap: one shared BeatPhase, offset per toaster so the flock never syncs. - flap_.advance(elapsed(), 180); // ~3 flaps per second across the 4-frame cycle + flap_.advanceTo(elapsed(), 180); // ~3 flaps per second across the 4-frame cycle for (uint16_t i = 0; i < pool_.count; i++) { if (!pool_.ttl[i]) continue; diff --git a/src/light/effects/FreqMatrixEffect.h b/src/light/effects/FreqMatrixEffect.h index 9be0b8bd..bc0d26a3 100644 --- a/src/light/effects/FreqMatrixEffect.h +++ b/src/light/effects/FreqMatrixEffect.h @@ -20,7 +20,7 @@ namespace mm { // 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 +// Prior art: WLED's "Freqmatrix" audio-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 diff --git a/src/light/effects/LavaLampEffect.h b/src/light/effects/LavaLampEffect.h index 9c566030..47a3cdfd 100644 --- a/src/light/effects/LavaLampEffect.h +++ b/src/light/effects/LavaLampEffect.h @@ -48,7 +48,7 @@ class LavaLampEffect : public EffectBase { // Shared accumulator: raw dt·bpm in 64 bits, divided only at the read, so a sub-millisecond // frame does not round to zero and freeze the animation (mm::BeatPhase owns that rule now). - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); const uint8_t t = static_cast(phase_.phase(256)); int16_t bx[NUM_BLOBS] = {}; diff --git a/src/light/effects/MetaballsEffect.h b/src/light/effects/MetaballsEffect.h index 8be49ac3..f5964878 100644 --- a/src/light/effects/MetaballsEffect.h +++ b/src/light/effects/MetaballsEffect.h @@ -46,7 +46,7 @@ class MetaballsEffect : public EffectBase { uint32_t now = elapsed(); // Shared accumulator: raw dt·bpm in 64 bits, divided only at the read, so a sub-millisecond // frame does not round to zero and freeze the animation (mm::BeatPhase owns that rule now). - phase_.advance(now, bpm); + phase_.advanceTo(now, bpm); const uint8_t t = static_cast(phase_.phase(256)); const uint8_t n = count < MAX_BALLS ? count : MAX_BALLS; diff --git a/src/light/effects/MovingHeadEffect.h b/src/light/effects/MovingHeadEffect.h index a47fb949..79c35cf7 100644 --- a/src/light/effects/MovingHeadEffect.h +++ b/src/light/effects/MovingHeadEffect.h @@ -26,7 +26,7 @@ namespace mm { /// @card MovingHeadEffect.gif class MovingHeadEffect : public EffectBase { public: - const char* tags() const override { return "💫🎶🎯"; } // audio-reactive when soundReactive is set + const char* tags() const override { return "💫🎶🎯"; } // audio-reactive when audioReactive is set /// D3: every head is a fixture with its own aim, so the effect places all of them itself. /// /// Declaring D1 would be smaller, but it is a promise the Layer keeps by EXTRUDING: it writes @@ -58,7 +58,7 @@ class MovingHeadEffect : public EffectBase { /// Move and light with the music: the beam swings wider as the room gets louder, each head /// brightens on its own frequency band, and a beat kicks the whole rig. Silence holds it still, /// which is what makes the mode read as reactive rather than merely animated. - bool soundReactive = false; + bool audioReactive = false; void defineControls() override { controls_.addSelect("formation", formation, kFormationNames, kFormationCount); @@ -68,7 +68,7 @@ class MovingHeadEffect : public EffectBase { controls_.addControl("tiltRange", tiltRange, 0, 255); controls_.addControl("panCenter", panCenter, 0, 255); controls_.addControl("tiltCenter", tiltCenter, 0, 255); - controls_.addControl("soundReactive", soundReactive); + controls_.addControl("audioReactive", audioReactive); } void prepare() override { @@ -89,8 +89,8 @@ class MovingHeadEffect : public EffectBase { // Own the background: an effect must not inherit the previous frame's picture. draw::fill(cv, RGB{0, 0, 0}); - pan_.advance(elapsed(), panBpm); - tilt_.advance(elapsed(), tiltBpm); + pan_.advanceTo(elapsed(), panBpm); + tilt_.advanceTo(elapsed(), tiltBpm); // phase(65536) is the angle16 form sin16 takes; truncating to uint16 is the free wrap. const uint16_t panPhase = static_cast(pan_.phase(65536)); @@ -98,7 +98,7 @@ class MovingHeadEffect : public EffectBase { // Audio is read ONCE per frame, not per head: the spectrum is the same for all of them, // and a per-head read would be the same work times the rig size. - const AudioFrame* audio = soundReactive ? AudioService::latestFrame() : nullptr; + const AudioFrame* audio = audioReactive ? AudioService::latestFrame() : nullptr; const bool live = audio && audio->levelSmoothed >= kSilence; // A beat widens the sweep and flares the color, then decays over ~20 frames. Without the @@ -110,9 +110,9 @@ class MovingHeadEffect : public EffectBase { // Loud music opens the beam to its full range, quiet keeps it tight. In silence the rig // HOLDS its aim rather than drifting. const uint16_t loud = live ? audio->levelSmoothed : 0; - const uint8_t swingPan = soundReactive ? scaleToLevel(panRange, loud) : panRange; - const uint8_t swingTilt = soundReactive ? scaleToLevel(tiltRange, loud) : tiltRange; - const bool frozen = soundReactive && !live; + const uint8_t swingPan = audioReactive ? scaleToLevel(panRange, loud) : panRange; + const uint8_t swingTilt = audioReactive ? scaleToLevel(tiltRange, loud) : tiltRange; + const bool frozen = audioReactive && !live; for (nrOfLightsType i = 0; i < n; i++) { const uint32_t hx = i % w, hy = (i / w) % h, hz = i / (w * h); @@ -132,7 +132,7 @@ class MovingHeadEffect : public EffectBase { // band, so the rig ripples with the music instead of pulsing as one block. const uint8_t hue = static_cast((panPhase >> 8) + (f.phase >> 8)); uint8_t bright = 255; - if (soundReactive) { + if (audioReactive) { const uint8_t band = static_cast((static_cast(i) * 16u) / (n ? n : 1)); const uint8_t mag = audio ? audio->bands[band > 15 ? 15 : band] : 0; // A floor keeps a head whose own band is quiet visible rather than black, and the @@ -209,7 +209,7 @@ class MovingHeadEffect : public EffectBase { /// Widen the sweep on a beat, by up to half again. The beat is the loudest thing in the mode, /// so it moves the rig as well as lighting it. uint8_t boost(uint8_t range) const { - if (!soundReactive || beatDecay_ == 0) return range; + if (!audioReactive || beatDecay_ == 0) return range; const uint16_t wider = static_cast(range + (range * beatDecay_) / 512u); return static_cast(wider > 255 ? 255 : wider); } diff --git a/src/light/effects/NebulaEffect.h b/src/light/effects/NebulaEffect.h index 7416564f..92b13ee0 100644 --- a/src/light/effects/NebulaEffect.h +++ b/src/light/effects/NebulaEffect.h @@ -29,7 +29,7 @@ namespace mm { /// Effect: a noise field births light, a curl flow carries it, and the two make a folding cloud. class NebulaEffect : public EffectBase { public: - const char* tags() const override { return "💫🖌️"; } // power-function showcase + const char* tags() const override { return "💫🖌️💨🌫️"; } // power-function showcase Dim dimensions() const override { return Dim::D3; } // the flow and the field both carry z uint8_t speed = 40; // how fast the medium moves, and with it the whole cloud @@ -112,7 +112,10 @@ class NebulaEffect : public EffectBase { bank_.set(0, {.rate = static_cast(4 + speed / 6), .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Saw}); - bank_.advance(dt); + // advanceTo() takes an ABSOLUTE timestamp and computes its own delta (math16.h BeatPhase), + // so passing this frame's dt feeds it the CHANGE in frame time: a few percent of the right + // motion on a jittery device, and none at all where the frame time is steady. + bank_.advanceTo(now); uint16_t* live = front_ ? planeA_.data() : planeB_.data(); uint16_t* spare = front_ ? planeB_.data() : planeA_.data(); diff --git a/src/light/effects/NoiseEffect.h b/src/light/effects/NoiseEffect.h index d39c5f0e..73dce334 100644 --- a/src/light/effects/NoiseEffect.h +++ b/src/light/effects/NoiseEffect.h @@ -24,7 +24,7 @@ namespace mm { /// @card NoiseEffect.gif class NoiseEffect : public EffectBase { public: - const char* tags() const override { return "⚡️💫🌙🐙"; } // FastLED + MoonLight lineage + const char* tags() const override { return "⚡️💫🌙🐙🌫️"; } // FastLED + MoonLight lineage Dim dimensions() const override { return Dim::D3; } static constexpr const char* kMotionOptions[] = {"drift", "morph"}; diff --git a/src/light/effects/NoiseMeterEffect.h b/src/light/effects/NoiseMeterEffect.h index 5d79b645..4b41fe33 100644 --- a/src/light/effects/NoiseMeterEffect.h +++ b/src/light/effects/NoiseMeterEffect.h @@ -14,7 +14,7 @@ namespace mm { // 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 +// Prior art: WLED's "Noisemeter" audio-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 AudioService::latestFrame(); silence → level 0 → @@ -23,7 +23,7 @@ namespace mm { /// Audio-reactive effect: a noise field modulated by sound level. class NoiseMeterEffect : public EffectBase { public: - const char* tags() const override { return "🐙🎵"; } // WLED origin · audio + 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. diff --git a/src/light/effects/PacmanEffect.h b/src/light/effects/PacmanEffect.h index 95c23c11..ef40fe17 100644 --- a/src/light/effects/PacmanEffect.h +++ b/src/light/effects/PacmanEffect.h @@ -123,7 +123,7 @@ class PacmanEffect : public EffectBase { public: static constexpr uint8_t kPool = 12; - const char* tags() const override { return "💫🎶✨👾"; } // audio-reactive when soundReactive is set + const char* tags() const override { return "💫🎶✨👾"; } // audio-reactive when audioReactive is set Dim dimensions() const override { return Dim::D2; } /// How many of each, and how fast they travel. @@ -131,14 +131,14 @@ class PacmanEffect : public EffectBase { uint8_t ghosts = 4; // the arcade cast: Blinky, Pinky, Inky, Clyde uint8_t speed = 96; uint8_t spriteSize = 0; // 0 = auto: scale with the grid - bool soundReactive = false; // move to the music: each sprite on its own band, still in silence + bool audioReactive = false; // move to the music: each sprite on its own band, still in silence void defineControls() override { controls_.addControl("pacmen", pacmen, 0, 4); controls_.addControl("ghosts", ghosts, 0, 8); controls_.addControl("speed", speed, 1, 255); controls_.addControl("spriteSize", spriteSize, 0, 12); - controls_.addControl("soundReactive", soundReactive); + controls_.addControl("audioReactive", audioReactive); } void prepare() override { @@ -176,11 +176,11 @@ class PacmanEffect : public EffectBase { draw::fill(cv, RGB{0, 0, 0}); const uint32_t scale = time_.advance(elapsed()); - if (scale > 0) pool_.stepDriven(scale, soundReactive, wanted()); + if (scale > 0) pool_.stepDriven(scale, audioReactive, wanted()); // One clock for the chomp AND the ghosts' shuffle: in the arcade they run at the same // rate, and a single phase keeps them in step without a second accumulator. - chomp_.advance(elapsed(), 420); + chomp_.advanceTo(elapsed(), 420); syncPopulation(); diff --git a/src/light/effects/PlasmaEffect.h b/src/light/effects/PlasmaEffect.h index 8450767a..939caa5d 100644 --- a/src/light/effects/PlasmaEffect.h +++ b/src/light/effects/PlasmaEffect.h @@ -39,7 +39,7 @@ class PlasmaEffect : public EffectBase { // every fixture. The x256 that used to sit in the accumulate is the read scale here: same // product, and BeatPhase keeps the numerator in 64 bits so a short dt cannot truncate the // sub-unit progress to zero and stall the animation. 256 phase units = one beat's wrap. - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); const uint32_t phase = phase_.phase(256); uint8_t step_x = static_cast(256 / scale_x); diff --git a/src/light/effects/PolarNoiseEffect.h b/src/light/effects/PolarNoiseEffect.h index 96bb8ffd..23829f43 100644 --- a/src/light/effects/PolarNoiseEffect.h +++ b/src/light/effects/PolarNoiseEffect.h @@ -31,7 +31,7 @@ namespace mm { /// Effect: a warped, kaleidoscopic noise field in polar coordinates. class PolarNoiseEffect : public EffectBase { public: - const char* tags() const override { return "💫🖌️"; } // power-function showcase + const char* tags() const override { return "💫🖌️🌫️🎡"; } // power-function showcase Dim dimensions() const override { return Dim::D3; } // volumetric: the field turns through depth uint8_t bpm = 8; // how fast the field drifts @@ -71,7 +71,7 @@ class PolarNoiseEffect : public EffectBase { // The drift is an oscillator: a sawtooth that only ever moves forward, which is what makes // the field breathe outward rather than rock back and forth. drift_.set(0, {.rate = bpm, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Saw}); - drift_.advance(elapsed()); + drift_.advanceTo(elapsed()); const uint32_t t = drift_.unitValue(0); // Build the address table if the grid changed; a rebuild is the only frame that pays for it. diff --git a/src/light/effects/PongEffect.h b/src/light/effects/PongEffect.h index 2f522e82..c08a3b5b 100644 --- a/src/light/effects/PongEffect.h +++ b/src/light/effects/PongEffect.h @@ -23,7 +23,7 @@ namespace mm { /// @card PongEffect.gif class PongEffect : public EffectBase { public: - const char* tags() const override { return "💫🎵👾"; } // audio-reactive when soundReactive is set + const char* tags() const override { return "💫🎵👾"; } // audio-reactive when audioReactive is set Dim dimensions() const override { return Dim::D2; } /// Rally speed, in ball crossings per minute rather than pixels per frame: the court is a @@ -42,7 +42,7 @@ class PongEffect : public EffectBase { bool spriteBall = false; /// Let the music drive the rally: the ball only advances on the beat, so it crosses the court /// in time with the track and stands still in silence. - bool soundReactive = false; + bool audioReactive = false; void defineControls() override { controls_.addControl("rallyBpm", rallyBpm, 5, 200); @@ -50,7 +50,7 @@ class PongEffect : public EffectBase { controls_.addControl("reflex", reflex, 40, 255); controls_.addControl("size", size, 1, 4); controls_.addControl("spriteBall", spriteBall); - controls_.addControl("soundReactive", soundReactive); + controls_.addControl("audioReactive", audioReactive); } void setup() override { @@ -63,7 +63,7 @@ class PongEffect : public EffectBase { if (width() == 0 || height() == 0) return; draw::fill(cv, RGB{0, 0, 0}); - const AudioFrame* audio = soundReactive ? AudioService::latestFrame() : nullptr; + const AudioFrame* audio = audioReactive ? AudioService::latestFrame() : nullptr; // The same beat test the rest of the project uses: level against its own smoothed average. // Reading it once per frame keeps the audio path off the per-pixel work. const bool live = audio && audio->levelSmoothed >= kSilence; @@ -72,18 +72,18 @@ class PongEffect : public EffectBase { // Motion by ELAPSED TIME. `rallyBpm` is crossings per minute, so a frame's share of a // crossing is the same on a 60 fps board and a 1200 fps desktop: the ball travels the court // in the same wall-clock time on both, rather than 20x faster on the quick one. - rally_.advance(elapsed(), rallyBpm); + rally_.advanceTo(elapsed(), rallyBpm); const uint32_t travel = rally_.phase(kCourtScale); // The two modes keep two different clocks: free-running counts elapsed time, reactive // counts beats. Switching between them mid-rally would hand step() a jump between the two // -- the ball teleporting across the court one way, and an unsigned underflow to a // four-billion step the other. Rebase both to the current position instead, so the toggle // is seamless and the ball carries on from where it is. - if (soundReactive != wasReactive_) { - wasReactive_ = soundReactive; + if (audioReactive != wasReactive_) { + wasReactive_ = audioReactive; lastTravel_ = travelAt_; } - if (soundReactive) { + if (audioReactive) { // On the beat the ball JUMPS a slice of the court and then waits. Silence holds it // still, which is what makes the mode read as reactive rather than merely animated. if (beat) travelAt_ += kCourtScale / kBeatSteps; diff --git a/src/light/effects/RadialSpectrumEffect.h b/src/light/effects/RadialSpectrumEffect.h new file mode 100644 index 00000000..e9ccca38 --- /dev/null +++ b/src/light/effects/RadialSpectrumEffect.h @@ -0,0 +1,160 @@ +#pragma once + +#include "light/effects/EffectBase.h" + +namespace mm { + +// RadialSpectrum: the spectrum as ripples. Each band owns a sector around the center, mirrored +// left and right; sound is born at the center and travels outward, so the radius is TIME and a +// ring's length is that band's recent history. A radial spectrogram, the circular visualizer the +// music-video world settled on, and here also the diagnostic the bar analyzer is: every sector is +// one band, so a band that is stuck or pinned shows as a sector that never moves or never dims. +// +// No transport is involved. The effect keeps a short history of band frames (one entry per +// ring) and every light READS it: its angle picks the band, its radius picks the age. That +// is a LUT read and a table read per light, cheaper than drawing bars, and it is what makes the +// effect volumetric for free: under the spherical polar mapping a cube's radius is the distance +// from its center, so the ripples become expanding shells. +// +// `beat` adds the onset detector's hits as a white shockwave born at the center on every hit, +// traveling out with the ripples. `smooth` switches the source between the raw bands and the +// meter ballistic, which is the comparison a person tuning the audio path wants to see. +// @card RadialSpectrumEffect.png +/// Effect: the spectrum as ripples, one sector per band, radius as time; a shockwave per beat. +class RadialSpectrumEffect : public EffectBase { +public: + const char* tags() const override { return "💫🎶🖌️🎡"; } + Dim dimensions() const override { return Dim::D3; } + + static constexpr uint16_t kMaxHistory = 128; ///< rings of history, and the largest radius read + + uint8_t speed = 85; // how fast sound travels outward + uint8_t persistence = 128; // how far out a ripple stays visible + bool smooth = false; // read the meter ballistic rather than the raw bands + bool beat = true; // a white shockwave on every detected onset + PolarLut::Controls polar; // the address, and cylindrical / spherical / radial on a cube + + void defineControls() override { + controls_.addControl("speed", speed, 5, 100); // higher is faster, as everywhere else + controls_.addControl("persistence", persistence, 0, 255); + controls_.addControl("smooth", smooth); + controls_.addControl("beat", beat); + PolarLut::addControls(controls_, polar); + } + + void prepare() override { + lut_.prepareFor(polar, width(), height(), EffectBase::depth()); + std::memset(history_, 0, sizeof(history_)); + std::memset(beats_, 0, sizeof(beats_)); + head_ = 0; + carry_ = 0; + started_ = false; + onsetSeen_ = false; + } + + void tick() MM_NONBLOCKING override { + const draw::Canvas cv = canvas(); + const lengthType w = width(), h = height(), dep = depth(); + const uint32_t now = elapsed(); + const uint32_t dt = started_ ? now - lastMs_ : 0u; + lastMs_ = now; + started_ = true; + + const AudioFrame* f = AudioService::latestFrame(); + const uint8_t* src = f ? (smooth ? f->bandsSmoothed : f->bands) : nullptr; + // A hit is one block of onset != 0 and ticks outrun blocks: edge-detect, and latch it + // until the next ring is born so a hit between rings is not lost. + const bool onsetNow = f && f->onset != 0; + if (onsetNow && !onsetSeen_) pendingBeat_ = 255; + onsetSeen_ = onsetNow; + + // Time-paced history: a ring every `ringMs` whatever the framerate, the remainder + // carried. A stall births a burst of rings, capped so it cannot wipe the history. + // + // The control is a RATE, so turning it up speeds the ripples up, which is what every other + // speed in the tree does. The period it drives is the inverse: 5 gives a slow 105 ms ring, + // 100 a fast 10 ms one. + const uint32_t ringMs = 110u - static_cast(speed); + carry_ += dt; + uint8_t born = 0; + while (carry_ >= ringMs && born < 8) { + carry_ -= ringMs; + head_ = static_cast((head_ + 1) % kMaxHistory); + if (src) std::memcpy(history_[head_], src, 16); + else std::memset(history_[head_], 0, 16); + beats_[head_] = beat ? pendingBeat_ : 0; + pendingBeat_ = 0; + born++; + } + if (born > 0) carry_ %= ringMs; + + // The fade with age, as a per-ring keep fraction in 1/256ths: persistence 255 keeps all, + // 0 shows only the newest ring. Precomputed per ring so the light loop is a lookup. + // The keep fraction per ring, and the range is what makes the default work. A ripple has + // to survive as many rings as the fixture's corner is far: 45 on a 64x64 panel. So the + // scale is centered on that rather than reaching it only at the top, and the DEFAULT of 128 + // gives 61 rings, filling a panel with room to spare. 92%..99.6% per ring: the low end is + // a tight halo around the center, the high end reaches the corner of a large wall. + // (An earlier range started at 50% per ring, which died after 19 rings at ANY setting, so + // the effect could never fill a panel however far the control was pushed.) + // Rebuilt only when `persistence` moves: the table depends on nothing else, and at 128 + // entries per frame it was the effect's largest fixed cost after the light loop itself. + if (persistence != fadeFor_) { + const uint32_t keep = 236u + (static_cast(persistence) * 19u) / 255u; + uint32_t k = 256; + for (uint16_t r = 0; r < kMaxHistory; r++) { fade_[r] = static_cast(k > 255 ? 255 : k); k = (k * keep) >> 8; } + fadeFor_ = persistence; + } + + const bool table = lut_.ready(); + const int32_t cx = w / 2, cy = h / 2, cz = dep / 2; + const auto m = PolarLut::mappingOf(polar); + std::size_t idx = 0; + for (lengthType z = 0; z < dep; z++) + for (lengthType y = 0; y < h; y++) + for (lengthType x = 0; x < w; x++, idx++) { + angle16 a; uint32_t r; + if (table) { a = lut_.angle(idx); r = lut_.radiusPixels(idx); } + else { + const auto ad = PolarLut::addressOf(m, static_cast(x) - cx, + static_cast(y) - cy, + static_cast(z) - cz); + a = ad.angle; r = ad.radius; + } + // The band from the angle, folded so left and right mirror: bass at the top + // and bottom, treble at the sides, the symmetric form the circular visualizer + // uses because a spectrum has no left and right of its own. + const uint32_t folded = a < 32768u ? a : 65535u - a; // 0..32767 + const uint8_t band = static_cast((folded * 16u) >> 15); // 0..15 + // The age from the radius: ring r was born r steps ago. + if (r >= kMaxHistory) { draw::pixel(cv, {x, y, z}, RGB{0, 0, 0}); continue; } + const uint16_t slot = static_cast((head_ + kMaxHistory - r) % kMaxHistory); + const uint32_t v = (static_cast(history_[slot][band]) * fade_[r]) >> 8; + RGB c = colorFromPalette(*Palettes::active(), static_cast(band * 16u), + static_cast(v)); + // The shockwave: a white ring on the beat, fading with the same age. + const uint32_t bw = (static_cast(beats_[slot]) * fade_[r]) >> 8; + if (bw) { + c.r = static_cast(c.r + bw > 255u ? 255u : c.r + bw); + c.g = static_cast(c.g + bw > 255u ? 255u : c.g + bw); + c.b = static_cast(c.b + bw > 255u ? 255u : c.b + bw); + } + draw::pixel(cv, {x, y, z}, c); + } + } + +private: + PolarLut lut_{*this}; + uint8_t history_[kMaxHistory][16] = {}; ///< the rings: one band frame per step + uint8_t beats_[kMaxHistory] = {}; ///< the shockwave strength born with each ring + uint8_t fade_[kMaxHistory] = {}; ///< the keep fraction per ring of age + uint8_t fadeFor_ = 255; ///< the `persistence` fade_[] was built for + uint16_t head_ = 0; ///< the newest ring + uint32_t carry_ = 0; ///< time owed toward the next ring, in ms + uint8_t pendingBeat_ = 0; ///< a hit waiting for the next ring + bool started_ = false; + bool onsetSeen_ = false; + uint32_t lastMs_ = 0; +}; + +} // namespace mm diff --git a/src/light/effects/RaymarchEffect.h b/src/light/effects/RaymarchEffect.h index 4def2c9c..45100ba2 100644 --- a/src/light/effects/RaymarchEffect.h +++ b/src/light/effects/RaymarchEffect.h @@ -51,7 +51,7 @@ class RaymarchEffect : public EffectBase { const draw::Canvas cv = canvas(); if (cv.dims.x < 1 || cv.dims.y < 1) return; - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); // Each oscillator reads the phase at ITS OWN rate and wraps in its own 16-bit angle, so // every term is continuous across the wrap. Scaling one wrapped angle by 1.3 or 0.8 (the diff --git a/src/light/effects/RingsEffect.h b/src/light/effects/RingsEffect.h index 060ff166..29efe105 100644 --- a/src/light/effects/RingsEffect.h +++ b/src/light/effects/RingsEffect.h @@ -14,7 +14,7 @@ namespace mm { /// @card RingsEffect.gif class RingsEffect : public EffectBase { public: - const char* tags() const override { return "💫🦅🖌️"; } // MoonLight origin · David Jupijn / Rising Step + 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; } diff --git a/src/light/effects/SdfShapesEffect.h b/src/light/effects/SdfShapesEffect.h index 4d4170bd..dbc19eb5 100644 --- a/src/light/effects/SdfShapesEffect.h +++ b/src/light/effects/SdfShapesEffect.h @@ -54,7 +54,7 @@ class SdfShapesEffect : public EffectBase { const draw::Canvas cv = canvas(); const lengthType w = width(), h = height(); - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); const angle16 t = static_cast(phase_.phase(65536)); // The short side sets the scale, so the composition looks the same on any aspect ratio. diff --git a/src/light/effects/SineEffect.h b/src/light/effects/SineEffect.h index 283db334..4defe0bf 100644 --- a/src/light/effects/SineEffect.h +++ b/src/light/effects/SineEffect.h @@ -44,7 +44,7 @@ class SineEffect : public EffectBase { const uint32_t now = elapsed(); // Shared accumulator: raw dt·rate in 64 bits, divided only at the read, so a sub-millisecond // frame does not round to zero and freeze the animation (mm::BeatPhase owns that rule now). - phase_.advance(now, bpm); + phase_.advanceTo(now, bpm); // Accumulate dt*bpm and read the high bits as the scroll phase (uint8 angle) — // the same integer accumulator the other effects use so a sub-ms dt isn't lost. const uint8_t t = static_cast(phase_.phase(256)); diff --git a/src/light/effects/SpaceInvadersEffect.h b/src/light/effects/SpaceInvadersEffect.h index 2da6ec97..2b285d80 100644 --- a/src/light/effects/SpaceInvadersEffect.h +++ b/src/light/effects/SpaceInvadersEffect.h @@ -120,7 +120,7 @@ static_assert(sizeof(kCannon) == static_cast(GW) * GH * GF, "cannon: one /// @card SpaceInvadersEffect.gif class SpaceInvadersEffect : public EffectBase { public: - const char* tags() const override { return "💫🎵👾"; } // audio-reactive when soundReactive is set + const char* tags() const override { return "💫🎵👾"; } // audio-reactive when audioReactive is set Dim dimensions() const override { return Dim::D2; } /// Steps per minute at full strength. The arcade had no such control: its tempo WAS the @@ -136,14 +136,14 @@ class SpaceInvadersEffect : public EffectBase { /// the formation on the beat is the natural mapping rather than an imposed one, and the cannon /// fires on a transient. In silence the formation HOLDS: a still invasion is the honest render /// of no music, and it is what makes the mode read as reactive. - bool soundReactive = false; + bool audioReactive = false; void defineControls() override { controls_.addControl("marchBpm", marchBpm, 10, 240); controls_.addControl("stepX", stepX, 1, 8); controls_.addControl("dropY", dropY, 1, 12); controls_.addControl("size", size, 1, 4); - controls_.addControl("soundReactive", soundReactive); + controls_.addControl("audioReactive", audioReactive); } void prepare() override { @@ -156,7 +156,7 @@ class SpaceInvadersEffect : public EffectBase { if (width() == 0 || height() == 0) return; draw::fill(cv, RGB{0, 0, 0}); - const AudioFrame* audio = soundReactive ? AudioService::latestFrame() : nullptr; + const AudioFrame* audio = audioReactive ? AudioService::latestFrame() : nullptr; const bool live = audio && audio->levelSmoothed >= kSilence; // A transient is the cannon's trigger, and it is also what makes a beat-driven march step: // reading `level` against its own smoothed average is the same beat test the moving-head @@ -207,7 +207,7 @@ class SpaceInvadersEffect : public EffectBase { if (!live) return; // silence holds the invasion still if (!beat) return; // the beat is the clock } else { - formation_.advance(elapsed(), stepInterval()); + formation_.advanceTo(elapsed(), stepInterval()); const uint32_t phase = formation_.phase(2); if (phase == lastPhase_) return; lastPhase_ = phase; diff --git a/src/light/effects/SpiralEffect.h b/src/light/effects/SpiralEffect.h index d33b9963..862dcae5 100644 --- a/src/light/effects/SpiralEffect.h +++ b/src/light/effects/SpiralEffect.h @@ -9,7 +9,7 @@ namespace mm { /// @card SpiralEffect.png class SpiralEffect : public EffectBase { public: - const char* tags() const override { return "💫🦅🖌️"; } // MoonLight origin · David Jupijn / Rising Step + 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; } @@ -44,7 +44,7 @@ class SpiralEffect : public EffectBase { uint32_t now = elapsed(); // Shared accumulator: raw dt·rate in 64 bits, divided only at the read, so a sub-millisecond // frame does not round to zero and freeze the animation (mm::BeatPhase owns that rule now). - phase_.advance(now, bpm); + phase_.advanceTo(now, bpm); // 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. diff --git a/src/light/effects/SpriteFountainEffect.h b/src/light/effects/SpriteFountainEffect.h index e2cf481f..3ea252ae 100644 --- a/src/light/effects/SpriteFountainEffect.h +++ b/src/light/effects/SpriteFountainEffect.h @@ -25,7 +25,7 @@ namespace mm { /// @card SpriteFountainEffect.gif class SpriteFountainEffect : public EffectBase { public: - const char* tags() const override { return "💫🎶✨👾"; } // audio-reactive when soundReactive is set + const char* tags() const override { return "💫🎶✨👾"; } // audio-reactive when audioReactive is set Dim dimensions() const override { return Dim::D2; } /// How hard the nozzle throws, and how hard gravity pulls back. Both scale with the grid, so @@ -55,7 +55,7 @@ class SpriteFountainEffect : public EffectBase { /// tiny fish. That is the difference between a fountain that pulses and one you can read the /// music off. In silence nothing is thrown, which is what makes the mode look reactive rather /// than merely animated. - bool soundReactive = false; + bool audioReactive = false; void defineControls() override { controls_.addControl("lift", lift, 20, 200); @@ -63,7 +63,7 @@ class SpriteFountainEffect : public EffectBase { controls_.addControl("rate", rate, 1, 6); controls_.addControl("emitBpm", emitBpm, 10, 240); controls_.addControl("size", size, 1, 4); - controls_.addControl("soundReactive", soundReactive); + controls_.addControl("audioReactive", audioReactive); } void prepare() override { @@ -96,7 +96,7 @@ class SpriteFountainEffect : public EffectBase { // Throw from the middle of the floor, leaning either side of straight up. 47152 is just // under vertical in angle16, the same nozzle the MoonLive fountain script uses. - launch_.advance(elapsed(), 9); + launch_.advanceTo(elapsed(), 9); const angle16 aim = static_cast( 47152 + (sin16(static_cast(launch_.phase(65536))) / 8)); const draw::pos_t speed = static_cast(lift) * height() / 4; @@ -104,11 +104,11 @@ class SpriteFountainEffect : public EffectBase { const draw::pos_t oy = static_cast(height() - 1) * draw::kSubOne; // Audio is read ONCE per frame: the spectrum is the same for every sprite, and a per-sprite // read would be the same work times the rate. - const AudioFrame* audio = soundReactive ? AudioService::latestFrame() : nullptr; + const AudioFrame* audio = audioReactive ? AudioService::latestFrame() : nullptr; // The emit CLOCK, separate from the nozzle's sweep: firing every frame tied the plume's // density to the frame rate, so the same settings looked completely different on two // devices. A beat phase makes `emitBpm` mean launches per minute on any of them. - emit_.advance(elapsed(), emitBpm); + emit_.advanceTo(elapsed(), emitBpm); const uint32_t tick = emit_.phase(2); const bool due = tick != lastEmit_; lastEmit_ = tick; diff --git a/src/light/effects/TrailsEffect.h b/src/light/effects/TrailsEffect.h index 5cee44f3..5abbdeac 100644 --- a/src/light/effects/TrailsEffect.h +++ b/src/light/effects/TrailsEffect.h @@ -33,7 +33,7 @@ namespace mm { /// Effect: bright dots thrown into a flowing medium, leaving tails the flow carries and bends. class TrailsEffect : public EffectBase { public: - const char* tags() const override { return "💫🖌️"; } // power-function showcase + const char* tags() const override { return "💫🖌️💨🌫️"; } // power-function showcase Dim dimensions() const override { return Dim::D3; } // per-slice: each slice gets its own flow static constexpr uint8_t kMaxDots = 8; @@ -101,7 +101,10 @@ class TrailsEffect : public EffectBase { bank_.set(1, {.rate = 7, .low = static_cast(256 - breathe), .high = static_cast(256 + breathe), .phaseOffset = 0, .wave = Wave::Sine}); - bank_.advance(dt); + // advanceTo() takes an ABSOLUTE timestamp and computes its own delta (math16.h BeatPhase), + // so passing this frame's dt feeds it the CHANGE in frame time: a few percent of the right + // motion on a jittery device, and none at all where the frame time is steady. + bank_.advanceTo(now); // Which buffer currently HOLDS the trail alternates: a ScratchBuffer is deliberately fixed // to its module (non-movable, it owns a slot in the module's free list), so the ping-pong diff --git a/src/light/effects/TruchetEffect.h b/src/light/effects/TruchetEffect.h index 45a5ba42..b83dad78 100644 --- a/src/light/effects/TruchetEffect.h +++ b/src/light/effects/TruchetEffect.h @@ -60,7 +60,7 @@ class TruchetEffect : public EffectBase { const draw::Canvas cv = canvas(); if (cv.dims.x < 1 || cv.dims.y < 1) return; - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); const uint32_t t = phase_.phase(65536); // One cell is this many 16.16 units across. `scale` counts tiles over the short side, which diff --git a/src/light/effects/TunnelEffect.h b/src/light/effects/TunnelEffect.h index edf8f37e..687864c4 100644 --- a/src/light/effects/TunnelEffect.h +++ b/src/light/effects/TunnelEffect.h @@ -26,7 +26,7 @@ namespace mm { /// Effect: a texture-mapped tunnel flying toward a vanishing point. class TunnelEffect : public EffectBase { public: - const char* tags() const override { return "💫🖌️"; } // power-function showcase + const char* tags() const override { return "💫🖌️🌫️🎡"; } // power-function showcase Dim dimensions() const override { return Dim::D3; } // volumetric: the wall recedes through depth uint8_t bpm = 20; // how fast the tunnel flies past @@ -63,7 +63,7 @@ class TunnelEffect : public EffectBase { const draw::Canvas cv = canvas(); const lengthType w = width(), h = height(), dep = EffectBase::depth(); - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); const uint32_t t = phase_.phase(65536); const int32_t cx = w / 2, cy = h / 2, cz = dep / 2; diff --git a/src/light/effects/VectorBallsEffect.h b/src/light/effects/VectorBallsEffect.h index 371fd0e9..828ea6ed 100644 --- a/src/light/effects/VectorBallsEffect.h +++ b/src/light/effects/VectorBallsEffect.h @@ -56,7 +56,7 @@ class VectorBallsEffect : public EffectBase { const draw::Canvas cv = canvas(); const lengthType w = width(), h = height(); - phase_.advance(elapsed(), bpm); + phase_.advanceTo(elapsed(), bpm); const uint32_t t = phase_.phase(65536); const angle16 yaw = static_cast(t); const angle16 pitch = static_cast(t * 2 / 3); // a second axis, so it tumbles diff --git a/src/light/effects/VuMetersEffect.h b/src/light/effects/VuMetersEffect.h new file mode 100644 index 00000000..4ba45dd7 --- /dev/null +++ b/src/light/effects/VuMetersEffect.h @@ -0,0 +1,179 @@ +#pragma once + +#include "light/effects/EffectBase.h" + +namespace mm { + +// VuMeters: sixteen needles, one per band, each on a real meter's mechanics. +// +// The thing that makes a VU meter beautiful is not the dial, it is the NEEDLE'S MASS. A physical +// meter is a spring and a damper: the needle accelerates toward the signal, overshoots a peak, +// swings back, and settles. That overshoot is why a mechanical meter reads as alive where a bar +// graph reads as a readout, and it is why the standard (IEC 60268-17) specifies 300 ms to 99% with +// 1 to 1.5% overshoot rather than a smoothing constant. +// +// So each band drives a damped harmonic oscillator, integrated per frame. `damping` sets how much +// the needle overshoots: high is a critically damped studio meter, low is a loose needle that +// swings past and bounces. The bass needles are deliberately heavier than the treble ones, as they +// are on a real multi-meter bridge, so the low end swings and the high end flickers. +// +// Each needle sweeps its own arc in its own column, with a peak marker held at the highest +// reading and falling slowly (a peak-hold, the other half of the standard meter), and a red zone +// past three quarters. +// @card VuMetersEffect.png +/// Effect: sixteen VU needles with real mass, one per band, with peak-hold and a red zone. +class VuMetersEffect : public EffectBase { +public: + const char* tags() const override { return "💫🎶🖌️"; } + Dim dimensions() const override { return Dim::D3; } // a plane; a cube gets one bank per slice + + uint8_t damping = 150; // how much the needle overshoots: low swings, high is critical + uint8_t response = 120; // how quickly it chases the signal at all + uint8_t peakHold = 200; // how long the peak marker stays up + bool smooth = false;// drive from the ballistic rather than the raw band + + void defineControls() override { + controls_.addControl("damping", damping, 0, 255); + controls_.addControl("response", response, 1, 255); + controls_.addControl("peakHold", peakHold, 0, 255); + controls_.addControl("smooth", smooth); + } + + void prepare() override { + for (uint8_t b = 0; b < 16; b++) { pos_[b] = 0; vel_[b] = 0; peak_[b] = 0; } + started_ = false; + } + + void tick() MM_NONBLOCKING override { + const draw::Canvas cv = canvas(); + const lengthType w = width(), h = height(), dep = depth(); + if (w < 2 || h < 2) return; + const uint32_t now = elapsed(); + const uint32_t dt = started_ ? now - lastMs_ : 0u; + lastMs_ = now; + started_ = true; + + const AudioFrame* f = AudioService::latestFrame(); + + // The needles: a damped spring per band, integrated in fixed point. Time-stepped so the + // mechanics are the same at any framerate, and clamped so a stall cannot explode it. + const uint32_t step = dt > 100u ? 100u : dt; + const int32_t k = 4 + static_cast(response) / 4; // spring: how hard it pulls + const int32_t c = 1 + static_cast(damping) / 12; // damper: how much it fights + for (uint8_t b = 0; b < 16; b++) { + const int32_t target = static_cast(f ? (smooth ? f->bandsSmoothed[b] : f->bands[b]) : 0) << 8; + // Heavier at the bass end, as a real meter bridge is: the low needles swing, the high + // ones flicker. A sixteenth of the response per band is enough to read as different. + const int32_t mass = 16 + (15 - b); + const int32_t accel = ((target - pos_[b]) * k) / mass - (vel_[b] * c) / 16; + vel_[b] += (accel * static_cast(step)) / 64; + pos_[b] += (vel_[b] * static_cast(step)) / 64; + if (pos_[b] < 0) { pos_[b] = 0; if (vel_[b] < 0) vel_[b] = -vel_[b] / 3; } // bounce off the pin + const int32_t full = 255 << 8; + if (pos_[b] > full) { pos_[b] = full; if (vel_[b] > 0) vel_[b] = 0; } + const uint8_t reading = static_cast(pos_[b] >> 8); + // Peak-hold: takes a new maximum at once, falls back slowly. + // Peak-hold falls by a half-life, so "how long it stays up" is stated in seconds and + // holds at any framerate, the same rule the trail effects decay by. + // The real elapsed time, not `step`: the cap exists to stop a stall throwing the + // spring, but a half-life is stated in seconds and must count every millisecond that + // passed, or the peak hangs longer than asked for after any hitch. + const uint16_t keep = halfLifeKeep(dt, 200u + static_cast(peakHold) * 12u); + peak_[b] = reading > peak_[b] + ? reading + : static_cast((static_cast(peak_[b]) * keep) >> 16); + } + + // The bank is a GRID of dials, not a row of slits: sixteen meters tiled so each cell is as + // square as the panel allows. A 64x64 panel gives 4x4 cells of 16x16, a 256x64 wall gives + // 8x2 cells of 32x32, and a strip degrades to one row. Each meter then owns a real dial + // rather than a column a few pixels wide. + lengthType cols = 16, rows = 1; + bestTiling(w, h, cols, rows); + const lengthType cellW = w / cols, cellH = h / rows; + draw::fill(cv, RGB{0, 0, 0}); + if (cellW < 3 || cellH < 3) return; // no room for a dial at all + for (lengthType z = 0; z < dep; z++) + for (lengthType i = 0; i < 16; i++) { + const lengthType cxi = i % cols, cyi = i / cols; + if (cyi >= rows) break; // fewer cells than bands: draw what fits + drawMeter(cv, cxi * cellW, cyi * cellH, cellW, cellH, z, + static_cast(i), pos_[i] >> 8, peak_[i]); + } + } + +private: + /// The tiling: sixteen cells, as square as this panel allows. Picks the factor pair of 16 + /// whose cell aspect is closest to 1, so a square panel is 4x4 and a wide one 8x2. + static void bestTiling(lengthType w, lengthType h, lengthType& cols, lengthType& rows) { + int32_t bestNum = -1, bestDen = 1; + for (lengthType c = 1; c <= 16; c++) { + if (16 % c) continue; + const lengthType r = 16 / c; + const int32_t cw = w / c, ch = h / r; + if (cw < 1 || ch < 1) continue; + const int32_t lo = cw < ch ? cw : ch, hi = cw < ch ? ch : cw; + // Compare lo/hi as a fraction, without floating point. + if (bestNum < 0 || static_cast(lo) * bestDen > static_cast(bestNum) * hi) { + bestNum = lo; bestDen = hi; cols = c; rows = r; + } + } + if (bestNum < 0) { cols = 16; rows = 1; } + } + + /// One meter inside its cell: a needle from a pivot at the bottom center, sweeping an arc that + /// fits the cell, plus its peak marker and the red zone. + void drawMeter(const draw::Canvas& cv, lengthType x0, lengthType y0, lengthType colW, + lengthType cellH, lengthType z, uint8_t band, int32_t reading, uint8_t peak) const { + const lengthType px = x0 + colW / 2; // pivot, centered in its own cell + const lengthType py = static_cast(y0 + cellH - 1); + // The needle is as long as the cell is tall, and the SWEEP fits the cell's width: a dial + // is as wide as it is tall when the cell is square, and narrows when it is not. + const lengthType len = static_cast(cellH - 1 < 2 ? 2 : cellH - 1); + // 0..255 onto a 120 degree sweep centered on straight up: 0 points up-LEFT, 128 straight + // up, 255 up-RIGHT. With dx = sin(a) and dy = -cos(a), angle 0 is straight up, so the + // sweep runs -60 to +60 degrees (10922 of 65536 is 60). + // The half-sweep that keeps the tip inside the column: asin(halfWidth / len), and at most + // 60 degrees so a wide column does not splay. Computed from the geometry rather than + // fixed, so one meter on a wide panel sweeps a proper arc and sixteen on a narrow one + // stay in their lanes. + const int32_t halfW = colW / 2; + int32_t half = len > 0 ? (10922 * halfW) / len : 10922; // small-angle: proportional + if (half > 10922) half = 10922; // never more than 60 degrees + if (half < 1000) half = 1000; // and always a visible swing + const auto angleFor = [half](int32_t v) -> angle16 { + const int32_t clamped = v < 0 ? 0 : (v > 255 ? 255 : v); + return static_cast(static_cast(-half + (clamped * 2 * half) / 255)); + }; + const angle16 a = angleFor(reading); + const lengthType nx = static_cast(px + (static_cast(sin16(a)) * len) / 32768); + const lengthType ny = static_cast(py - (static_cast(cos16(a)) * len) / 32768); + // The scale: a faint arc, red past three quarters. + for (int32_t s = 0; s <= 255; s += 8) { + const angle16 sa = angleFor(s); + const lengthType sx = static_cast(px + (static_cast(sin16(sa)) * len) / 32768); + const lengthType sy = static_cast(py - (static_cast(cos16(sa)) * len) / 32768); + constexpr uint8_t base = 24; + const RGB tick = s > 191 ? RGB{static_cast(base + 40), 0, 0} + : RGB{base, base, static_cast(base + 8)}; + draw::pixel(cv, {sx, sy, z}, tick); + } + // The needle, in the band's palette color, and its peak marker above it. + const RGB c = colorFromPalette(*Palettes::active(), static_cast(band * 16u), 255); + draw::line(cv, {px, py, z}, {nx, ny, z}, c); + if (peak > 4) { + const angle16 pa = angleFor(peak); + const lengthType mx = static_cast(px + (static_cast(sin16(pa)) * len) / 32768); + const lengthType my = static_cast(py - (static_cast(cos16(pa)) * len) / 32768); + draw::pixel(cv, {mx, my, z}, RGB{255, 255, 255}); + } + } + + int32_t pos_[16] = {}; ///< needle position, 8.8 fixed point + int32_t vel_[16] = {}; ///< needle velocity: the mass that makes it overshoot + uint8_t peak_[16] = {}; ///< the peak-hold marker + bool started_ = false; + uint32_t lastMs_ = 0; +}; + +} // namespace mm diff --git a/src/light/effects/WaveEffect.h b/src/light/effects/WaveEffect.h index c3bf0ca5..f42698a9 100644 --- a/src/light/effects/WaveEffect.h +++ b/src/light/effects/WaveEffect.h @@ -24,7 +24,7 @@ namespace mm { /// Effect of a travelling wave across the layer. class WaveEffect : public EffectBase { public: - const char* tags() const override { return "💫"; } + const char* tags() const override { return "💫🌫️"; } // D2 — writes the z=0 plane only; Layer::extrude duplicates it across z on a 3D layout. Dim dimensions() const override { return Dim::D2; } @@ -76,7 +76,7 @@ class WaveEffect : public EffectBase { // jumping by the whole device uptime), and the dt·bpm numerator stays in 64 bits until the // read (so a sub-millisecond frame does not round to zero and freeze). const uint32_t now = elapsed(); - phase_.advance(now, bpm); + phase_.advanceTo(now, bpm); const uint8_t t = static_cast(phase_.phase(256)); // uint8 angle (256 = full turn) // Color cycles slowly over time: now/50 indexes the active palette via waveColor. const uint8_t colorIndex = static_cast(now / 50); diff --git a/src/light/particles.h b/src/light/particles.h index efc1a069..cea67ac9 100644 --- a/src/light/particles.h +++ b/src/light/particles.h @@ -170,13 +170,13 @@ inline lengthType spreadLane(uint16_t i, uint16_t slots, lengthType extent) { /// Per-sprite audio drive: how fast sprite `i` of `slots` should move for the sound playing now, /// as a multiplier of FrameTime::kOne (so 0 = frozen, kOne = its normal speed). /// -/// Shared by every sprite effect with a `soundReactive` checkbox (FlyingToasters, FishTank, +/// Shared by every sprite effect with a `audioReactive` checkbox (FlyingToasters, FishTank, /// Pacman): the behavior a viewer expects is identical in all three, so it is written once. /// /// Each sprite gets its OWN band, spread across the 16 the FFT produces, so a scene breathes with /// the music instead of surging as one block: the bass sprites lurch on the kick while the treble /// ones flutter on the hats. The overall level gates it, so SILENCE STANDS THE SCENE STILL - the -/// requirement that makes the mode read as sound-reactive rather than merely speed-varying, since +/// requirement that makes the mode read as audio-reactive rather than merely speed-varying, since /// a per-band value alone still drifts on noise between tracks. /// /// Returns kOne unchanged when there is no audio at all (no microphone, service not running), so @@ -378,19 +378,19 @@ struct Pool { } /// step() with a PER-PARTICLE time scale: `drive(i)` returns particle i's own multiplier of - /// FrameTime::kOne. Sound-reactive sprite effects use it to move each sprite on its own audio + /// FrameTime::kOne. Audio-reactive sprite effects use it to move each sprite on its own audio /// band (see audioDrive) - the whole point being that the sprites do NOT move as one block. /// The frame scale still multiplies in, so speed stays frame-rate independent either way. - /// step(), optionally driven by the music: with `soundReactive` set, each of the `live` + /// step(), optionally driven by the music: with `audioReactive` set, each of the `live` /// sprites moves on its own frequency band and the scene stands still in silence; otherwise - /// the whole pool steps together. The one place the sound-reactive rule lives, so the sprite + /// the whole pool steps together. The one place the audio-reactive rule lives, so the sprite /// effects share it rather than each carrying a copy of the branch. /// /// `live` is the number of sprites actually in play, NOT the pool capacity: the bands are /// spread across the sprites that exist, so passing the capacity would crowd every sprite /// into the low bands and leave the treble driving nothing. - void stepDriven(uint32_t scale, bool soundReactive, uint16_t live) { - if (!soundReactive) { step(scale); return; } + void stepDriven(uint32_t scale, bool audioReactive, uint16_t live) { + if (!audioReactive) { step(scale); return; } const AudioFrame* f = AudioService::latestFrame(); stepEach(scale, [f, live](uint16_t i) { return audioDrive(f, i, live); }); } diff --git a/src/main.cpp b/src/main.cpp index a6c460aa..e9791b7c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -37,6 +37,9 @@ #include "light/effects/LavaLampEffect.h" #include "light/effects/NetworkReceiveEffect.h" #include "light/effects/AudioVolumeEffect.h" +#include "light/effects/RadialSpectrumEffect.h" +#include "light/effects/VuMetersEffect.h" +#include "light/effects/BeatRipplesEffect.h" #include "light/effects/AudioSpectrumEffect.h" #include "light/effects/SineEffect.h" #include "light/effects/DistortionWavesEffect.h" @@ -50,6 +53,7 @@ #include "light/effects/PolarNoiseEffect.h" #include "light/effects/WaterRippleEffect.h" #include "light/effects/TrailsEffect.h" +#include "light/effects/ColorTrailsEffect.h" #include "light/effects/TunnelEffect.h" #include "light/effects/EchoEffect.h" #include "light/effects/DissolveEffect.h" @@ -208,6 +212,9 @@ static void registerModuleTypes() { // alphabetically; keeping this list sorted makes the three orders agree at a glance). mm::ModuleFactory::registerType("AudioSpectrumEffect", "light/effects.md#audiospectrum"); mm::ModuleFactory::registerType("AudioVolumeEffect", "light/effects.md#audiovolume"); + mm::ModuleFactory::registerType("RadialSpectrumEffect", "light/effects.md#radialspectrum"); + mm::ModuleFactory::registerType("VuMetersEffect", "light/effects.md#vumeters"); + mm::ModuleFactory::registerType("BeatRipplesEffect", "light/effects.md#beatripples"); mm::ModuleFactory::registerType("BlurzEffect", "light/effects.md#blurz"); mm::ModuleFactory::registerType("BouncingBallsEffect", "light/effects.md#bouncingballs"); mm::ModuleFactory::registerType("DemoReelEffect", "light/effects.md#demoreel"); @@ -245,6 +252,7 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("FluidEffect", "light/effects.md#fluid"); mm::ModuleFactory::registerType("NebulaEffect", "light/effects.md#nebula"); mm::ModuleFactory::registerType("TrailsEffect", "light/effects.md#trails"); + mm::ModuleFactory::registerType("ColorTrailsEffect", "light/effects.md#colortrails"); mm::ModuleFactory::registerType("TunnelEffect", "light/effects.md#tunnel"); mm::ModuleFactory::registerType("EchoEffect", "light/effects.md#echo"); mm::ModuleFactory::registerType("DissolveEffect", "light/effects.md#dissolve"); diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 729fbaef..2f7ff402 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1930,7 +1930,7 @@ bool rmtWs2812Transmit(RmtWs2812Handle& h, const uint32_t* symbols, if (!h.impl || !symbols || symbolCount == 0) return false; return true; } -void rmtWs2812Wait(RmtWs2812Handle& /*h*/, uint32_t /*timeoutMs*/) {} +bool rmtWs2812Wait(RmtWs2812Handle& /*h*/, uint32_t /*timeoutMs*/) { return true; } void rmtWs2812Deinit(RmtWs2812Handle& h) { delete static_cast(h.impl); h.impl = nullptr; @@ -2007,6 +2007,8 @@ void freeHostBus(void*& impl) { } // namespace +const char* i80Ws2812LastError() { return nullptr; } // the emulated bus never refuses for a cause +bool i80Ws2812SharedBusFree() { return false; } // and shares no peripheral, so never retries bool i80Ws2812Init(I80Ws2812Handle& h, const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, uint16_t /*wrGpio*/, uint16_t /*dcGpio*/, size_t bufferBytes, bool wantSecondBuffer, diff --git a/src/platform/desktop/platform_desktop_audio.cpp b/src/platform/desktop/platform_desktop_audio.cpp index f6018592..88787a51 100644 --- a/src/platform/desktop/platform_desktop_audio.cpp +++ b/src/platform/desktop/platform_desktop_audio.cpp @@ -170,6 +170,10 @@ bool audioCaptureInit(AudioMicHandle& h, uint8_t deviceIndex, uint32_t sampleRat return true; } + +// The desktop captures from an OS device, which shares no peripheral with anything: never retries. +bool audioMicSharedBusFree(MicMode) { return false; } + size_t audioMicRead(AudioMicHandle& /*h*/, int32_t* out, size_t maxSamples) { if (!deviceOpen_ || out == nullptr) return 0; return ring_.pop(out, maxSamples); diff --git a/src/platform/esp32/platform_esp32_gpio.cpp b/src/platform/esp32/platform_esp32_gpio.cpp index 52193e24..fce89c07 100644 --- a/src/platform/esp32/platform_esp32_gpio.cpp +++ b/src/platform/esp32/platform_esp32_gpio.cpp @@ -16,6 +16,8 @@ #include "esp_adc/adc_oneshot.h" // adcRead: the ADC1 oneshot unit #include "esp_adc/adc_cali.h" // adcReadMv: per-chip eFuse correction #include "esp_adc/adc_cali_scheme.h" +#include "esp_efuse.h" // esp_efuse_get_pkg_ver: the classic ESP32's PACKAGE decides its pin table +#include "soc/efuse_defs.h" // EFUSE_RD_CHIP_VER_PKG_*: the package ids #include "esp_heap_caps.h" // heap_caps_get_total_size(MALLOC_CAP_SPIRAM) — detect PSRAM without a new // component dep (the heap component is always linked; esp_psram is not, // and adding it to REQUIRES would switch main to strict mode, hiding the @@ -45,10 +47,27 @@ bool inList(uint8_t gpio, const uint8_t* list, size_t n) { // when esp_psram_is_initialized() is true at runtime. A plain WROOM (e.g. the Olimex ESP32-Gateway) has // no PSRAM, so its 16/17 stay free — flagging them would be a false positive. #if defined(CONFIG_IDF_TARGET_ESP32) -// Classic ESP32: flash 6-11 always; 16/17 are the extra flash/PSRAM bus on WROVER modules only. +// Classic ESP32 in a plain module (WROOM / WROVER, a D0WD die): flash 6-11 always; 16/17 are the +// extra flash/PSRAM bus on WROVER modules only. constexpr uint8_t kReserved[] = {6, 7, 8, 9, 10, 11}; constexpr uint8_t kReservedIfPsram[] = {16, 17}; constexpr uint8_t kStrap[] = {0, 2, 5, 12, 15}; +// The PICO system-in-package parts wire their in-package flash and PSRAM differently, and the SAME +// esp32 firmware runs on all of them, so the package is read from eFuse at runtime rather than from +// the build. ESP32-PICO-V3-02 (the QuinLED Dig-Next-2): flash on 6/11, PSRAM on 9/10, and the pads +// of GPIO 16/17/18/23 are NC on the package (datasheet Table 7), which in practice means "used by +// the in-package parts": muxing a peripheral onto one wedges the flash cache, and the board resets +// with no panic and no coredump. 7/8 ARE free on this package (the Dig-Next-2's microphone sits +// there). ESP32-PICO-D4: flash on 6-11 plus 16/17, PSRAM or not. +constexpr uint8_t kReservedPicoV302[] = {6, 9, 10, 11}; +constexpr uint8_t kAbsentPicoV302[] = {16, 17, 18, 23}; +constexpr uint8_t kReservedPicoD4[] = {6, 7, 8, 9, 10, 11, 16, 17}; + +/// The eFuse package id, read once (it cannot change after boot). +uint32_t packageId() { + static const uint32_t pkg = esp_efuse_get_pkg_ver(); + return pkg; +} #elif defined(CONFIG_IDF_TARGET_ESP32S3) // ESP32-S3: flash 26-32 always; 33-37 are octal-PSRAM's SPIIO4-7 + DQS, reserved only on an octal-PSRAM // module (N16R8/R8). Straps 0,45,46 (GPIO3 is a soft strap). JTAG/UART0/USB are role-conflicts, not @@ -93,6 +112,20 @@ GpioCapability gpioCapability(uint8_t gpio) { c.strap = inList(gpio, kStrap, sizeof(kStrap)); c.reserved = inList(gpio, kReserved, sizeof(kReserved)) || (psramPresent() && inList(gpio, kReservedIfPsram, sizeof(kReservedIfPsram))); +#if defined(CONFIG_IDF_TARGET_ESP32) + // The die's valid-GPIO mask does not know the package; the tables above do (see their note). + switch (packageId()) { + case EFUSE_RD_CHIP_VER_PKG_ESP32PICOV302: + c.reserved = inList(gpio, kReservedPicoV302, sizeof(kReservedPicoV302)); + c.validGpio = c.validGpio && !inList(gpio, kAbsentPicoV302, sizeof(kAbsentPicoV302)); + break; + case EFUSE_RD_CHIP_VER_PKG_ESP32PICOD4: + c.reserved = inList(gpio, kReservedPicoD4, sizeof(kReservedPicoD4)); + break; + default: + break; + } +#endif return c; } diff --git a/src/platform/esp32/platform_esp32_i2s.cpp b/src/platform/esp32/platform_esp32_i2s.cpp index 87cd8de5..dd9f2a49 100644 --- a/src/platform/esp32/platform_esp32_i2s.cpp +++ b/src/platform/esp32/platform_esp32_i2s.cpp @@ -21,6 +21,10 @@ #if SOC_I2S_SUPPORTED #include "driver/i2s_std.h" +#if SOC_I2S_SUPPORTS_PDM_RX +#include "driver/i2s_pdm.h" // the two-wire onboard mics (QuinLED Dig-Next-2 and friends) +#include "esp_private/i2s_platform.h" // the shared-instance probe (see audioMicSharedBusFree) +#endif #include "esp_heap_caps.h" // heap_caps_malloc — the FFT scratch, internal RAM only #include "esp_log.h" #include "dsps_fft2r.h" @@ -37,6 +41,11 @@ const char* I2S_TAG = "mm_i2s"; struct MicState { i2s_chan_handle_t rx = nullptr; + bool pdm = false; ///< PDM reads 16-bit samples; the std path reads 32 + /// Staging for the PDM read, per channel rather than one static: two microphones would + /// otherwise share it, and a static here is also a data race the moment anything but the + /// render loop reads. 256 samples is a comfortable slice of a 512-sample block. + int16_t stage[256] = {}; }; // esp-dsp's float FFT works in place on an interleaved complex array (re, im, @@ -79,15 +88,62 @@ bool ensureFftInit() { } // namespace +namespace { +// Set when audioMicInit failed because another module held the I2S instance, cleared on every +// attempt. Only contention can clear on its own, so only it earns the once-a-second retry: keyed +// on "the mic is down" instead, a board with no microphone wired would re-init forever. +bool s_micRefusedForContention = false; +} + bool audioMicInit(AudioMicHandle& h, uint16_t wsPin, uint16_t sdPin, - uint16_t sckPin, int16_t mclkPin, uint32_t sampleRate) { + uint16_t sckPin, int16_t mclkPin, uint32_t sampleRate, MicMode mode) { + s_micRefusedForContention = false; auto* st = new (std::nothrow) MicState(); if (!st) return false; i2s_chan_config_t chanCfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_AUTO, I2S_ROLE_MASTER); if (i2s_new_channel(&chanCfg, nullptr, &st->rx) != ESP_OK) { + // No free instance: something else (the parallel LED bus) holds it, and that can clear. + s_micRefusedForContention = true; + delete st; + return false; + } + + if (mode == MicMode::Pdm) { +#if SOC_I2S_SUPPORTS_PDM_RX + // A PDM part sends one bit per clock and the peripheral decimates it to PCM, so there are + // only two wires: the clock the ESP32 drives, and the data line. `wsPin` carries the clock + // (it is the pin the board wires to the mic's CLK) and `sdPin` the data; `sckPin` and + // `mclkPin` have no meaning here. + i2s_pdm_rx_config_t pdmCfg = { + .clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG(sampleRate), + .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, + I2S_SLOT_MODE_MONO), + .gpio_cfg = { + .clk = static_cast(wsPin), + .din = static_cast(sdPin), + .invert_flags = { .clk_inv = false }, + }, + }; + if (i2s_channel_init_pdm_rx_mode(st->rx, &pdmCfg) != ESP_OK + || i2s_channel_enable(st->rx) != ESP_OK) { + i2s_del_channel(st->rx); + delete st; + return false; + } + // 16-bit samples, where the std path reads 32. audioMicRead widens them on the way out so + // the domain code sees one sample format whatever the part is. + st->pdm = true; + h.impl = st; + return true; +#else + // The chip has no PDM receiver. Fail rather than quietly configure a standard-mode + // channel on two pins, which would read noise and look like a wiring fault. + ESP_LOGE(I2S_TAG, "PDM microphone requested, but this chip has no PDM receiver"); + i2s_del_channel(st->rx); delete st; return false; +#endif } // Standard (Philips) mode, 32-bit slot / 24-bit data, mono. The INMP441 puts @@ -136,11 +192,66 @@ size_t audioMicRead(AudioMicHandle& h, int32_t* out, size_t maxSamples) { // ready into `out` and reports it in `bytesRead`, so we use that count // regardless of the return code (a timeout with bytesRead>0 is a partial read, // not a failure). + if (st->pdm) { + // A PDM channel delivers 16-bit PCM where the std path delivers 32, so the samples are + // staged here and widened on the way out; the domain code sees one format either way. + // + // A FIXED staging buffer, not a split of the caller's: AudioService reads + // `kBlock - filled_`, which shrinks to 1 as the block fills, and halving that reached 0 + // and returned "no samples" forever, with the block one short of complete. The stage is + // capped instead, and a short read is normal here (the caller accumulates across ticks). + // + // The PDM receiver delivers 16-bit PCM where the standard-mode path delivers 24-bit in a + // 32-bit slot, so the sample is scaled to the same full scale the rest of the chain + // assumes. 65536 is that conversion exactly: int16 full scale maps to int32 full scale, + // and no sample can clip. + // + // A larger gain is tempting and wrong. `magToByte` measures a RAW magnitude in dB with a + // window starting at 60 dB, so a quiet PDM signal reads below the window and the level + // sits at 0 until `floor` is lowered to meet it. That is a WINDOW problem, and scaling up + // to "fix" it costs headroom: at 1048576 the clip point is an int16 of 2047, while this + // part's own quiet-room noise floor already peaks near 3500. The microphone then clipped + // continuously, and clipping is broadband, so every band showed noise and the flux + // detector fired onsets in a silent room (measured: flux 29-42 with the band conditioner + // bypassed entirely, which is how the gain was identified as the source). Set the display + // window with `floor`, never with this constant. + // + // Multiplied rather than shifted: `int16_t` promotes to `int`, and shifting a negative + // value (or into the sign bit) is undefined; the multiply is defined across the range, and + // the widest sample (32767 * 65536) still fits an int32. + constexpr size_t kStage = sizeof(st->stage) / sizeof(st->stage[0]); + const size_t want = maxSamples < kStage ? maxSamples : kStage; + if (want == 0) return 0; + i2s_channel_read(st->rx, st->stage, want * sizeof(int16_t), &bytesRead, 0 /* non-blocking */); + const size_t got = bytesRead / sizeof(int16_t); + constexpr int32_t kPdmGain = 65536; // int16 full scale -> int32 full scale, see above + for (size_t i = 0; i < got; i++) out[i] = static_cast(st->stage[i]) * kPdmGain; + return got; + } i2s_channel_read(st->rx, out, maxSamples * sizeof(int32_t), &bytesRead, 0 /* ms — non-blocking */); return bytesRead / sizeof(int32_t); } +bool audioMicSharedBusFree(MicMode mode) { +#if CONFIG_IDF_TARGET_ESP32 + // Only after a CONTENTION refusal. Without this the probe answers "free" on any board whose + // instance 0 is simply idle, so a mic that is down for its OWN reasons (no part wired, wrong + // pins) would re-init once a second forever, allocating and logging on the render thread. + if (!s_micRefusedForContention) return false; + // Only the classic ESP32 shares: its parallel LED bus IS an I2S peripheral. That bus always + // takes instance 1 (see platform_esp32_i80.cpp), so audio always has instance 0, which is also + // the only instance a PDM microphone can use. This is the mirror of the bus's own probe: it + // matters when something else holds 0, and the mic recovers once that clears. + (void)mode; + if (i2s_platform_acquire_occupation(I2S_CTLR_HP, 0, "mm_mic_probe") != ESP_OK) return false; + i2s_platform_release_occupation(I2S_CTLR_HP, 0); + return true; +#else + return false; // every other chip drives parallel LEDs from LCD_CAM, so nothing contends +#endif +} + void audioMicDeinit(AudioMicHandle& h) { auto* st = static_cast(h.impl); if (!st) return; @@ -179,11 +290,12 @@ void audioFft(const float* windowed, size_t n, float* outMag) { namespace mm::platform { -bool audioMicInit(AudioMicHandle&, uint16_t, uint16_t, uint16_t, int16_t, uint32_t) { +bool audioMicInit(AudioMicHandle&, uint16_t, uint16_t, uint16_t, int16_t, uint32_t, MicMode) { return false; } size_t audioMicRead(AudioMicHandle&, int32_t*, size_t) { return 0; } void audioMicDeinit(AudioMicHandle&) {} +bool audioMicSharedBusFree(MicMode) { return false; } // no I2S: nothing to contend for void audioFft(const float*, size_t, float*) {} diff --git a/src/platform/esp32/platform_esp32_i80.cpp b/src/platform/esp32/platform_esp32_i80.cpp index ded185eb..9d688e3b 100644 --- a/src/platform/esp32/platform_esp32_i80.cpp +++ b/src/platform/esp32/platform_esp32_i80.cpp @@ -45,6 +45,9 @@ #include #include // the transmit callback passed to the shared frame loopback #include // std::nothrow +#if !SOC_LCDCAM_I80_LCD_SUPPORTED +#include "esp_private/i2s_platform.h" // the I2S0 occupancy probe (the same API esp_lcd's I2S backend uses) +#endif namespace mm::platform { @@ -353,10 +356,87 @@ I80State* createState(const uint16_t* dataPins, uint8_t laneCount, } // namespace -bool i80Ws2812Init(I80Ws2812Handle& h, const uint16_t* dataPins, uint8_t laneCount, +namespace { +const char* s_lastError = nullptr; // set by i80Ws2812Init on a refusal it can name; cold path +// Whether that refusal was CONTENTION (another module holds the instance) rather than a config +// fault. Only contention can clear on its own, so only it earns a retry: keying the retry on +// `s_lastError` alone made a bad pin set rebuild the bus once a second forever. +bool s_refusedForContention = false; +} + +const char* i80Ws2812LastError() { return s_lastError; } + +bool i80Ws2812SharedBusFree() { +#if !SOC_LCDCAM_I80_LCD_SUPPORTED + // Only meaningful after THIS backend was refused for contention: otherwise a driver that failed + // for its own reasons (bad pins, no memory) would rebuild once a second forever. Probing is the + // acquire/release pair esp_lcd itself uses, which is why it is safe to call repeatedly. + if (!s_refusedForContention) return false; + if (i2s_platform_acquire_occupation(I2S_CTLR_HP, 1, "mm_i80_probe") != ESP_OK) return false; + i2s_platform_release_occupation(I2S_CTLR_HP, 1); + return true; +#else + return false; // LCD_CAM: the i80 bus shares nothing +#endif +} + +bool i80Ws2812Init(I80Ws2812Handle& h, const uint16_t* dataPinsIn, uint8_t laneCount, uint16_t wrGpio, uint16_t dcGpio, size_t bufferBytes, bool wantSecondBuffer, uint8_t clockMultiplier) { - if (!dataPins || laneCount == 0 || bufferBytes == 0 || clockMultiplier == 0) return false; + s_lastError = nullptr; + s_refusedForContention = false; + if (!dataPinsIn || laneCount == 0 || bufferBytes == 0 || clockMultiplier == 0) return false; + if (laneCount > ESP_LCD_I80_BUS_WIDTH_MAX) return false; + uint16_t dataPins[ESP_LCD_I80_BUS_WIDTH_MAX]; + std::memcpy(dataPins, dataPinsIn, laneCount * sizeof(uint16_t)); +#if !SOC_LCDCAM_I80_LCD_SUPPORTED + // The classic ESP32's i80 IS an I2S peripheral, and the chip has two instances. **The LED bus + // always takes instance 1, and everything else gets 0.** The split is fixed in silicon rather + // than chosen: instance 0 is the only one with the PDM-to-PCM and PCM-to-PDM converters + // (I2S_LL_PDM2PCM_SUPPORTED_PORT_MASK is 1U << 0), so a PDM microphone can ONLY live there, + // while nothing in the chip requires instance 1 for anything. The LED bus is therefore the one + // consumer that can always yield, and hard-coding it to 1 leaves 0 free for every audio source + // (PDM, standard I2S, line-in ADC, codec), with no ordering or boot race to reason about. + // + // esp_lcd picks the first FREE instance rather than taking one by number, so 1 is claimed by + // holding 0 across bus creation and releasing it straight after. + if (i2s_platform_acquire_occupation(I2S_CTLR_HP, 1, "mm_i80_probe") != ESP_OK) { + s_lastError = "I2S1 is in use: on the classic ESP32 the parallel LED bus is an I2S " + "peripheral, and it drives from instance 1"; + s_refusedForContention = true; + return false; + } + i2s_platform_release_occupation(I2S_CTLR_HP, 1); + const bool parked = i2s_platform_acquire_occupation(I2S_CTLR_HP, 0, "mm_i80_park") == ESP_OK; + struct ParkGuard { // release instance 0 on EVERY path out of this function + bool on; + ~ParkGuard() { if (on) i2s_platform_release_occupation(I2S_CTLR_HP, 0); } + } parkGuard{parked}; + // "No pin" for WR (kBusPinUnset), and for the spare bus lanes the driver parks on it: the + // peripheral insists on a GPIO number, a WS2812 strand reads none of these lines, and an + // input-only pad has no output driver. So WR is routed to SENSOR_VP (36), bonded on every + // classic package, where the matrix drives nothing and no usable GPIO is spent. + // + // DC gets NO such treatment, and the asymmetry is load-bearing. WR reaches the pad through the + // GPIO matrix (esp_rom_gpio_connect_out_signal), which is inert on a pad that cannot drive. DC + // is software-toggled: esp_lcd calls gpio_set_level on it for every transfer, and on an + // input-only pad that call fails, logs "GPIO output gpio_num error", and the log call itself + // aborts from that context. So an unset DC is refused by the driver before it reaches here. + constexpr uint16_t kWrSink = 36; + if (wrGpio == kBusPinUnset) wrGpio = kWrSink; + for (uint8_t i = 0; i < laneCount; i++) if (dataPins[i] == kBusPinUnset) dataPins[i] = wrGpio; + if (dcGpio == kBusPinUnset) { + s_lastError = "dcPin (DC) needs a real GPIO: the i80 bus toggles it in software every frame"; + return false; + } +#else + // LCD_CAM (S3 / P4 / S31): both control lines need a real pad. The driver refuses an unset one + // before calling here; this is the backstop that keeps an invalid number away from the ROM. + if (wrGpio == kBusPinUnset || dcGpio == kBusPinUnset) { + s_lastError = "clockPin (WR) and dcPin (DC) need a real GPIO on this chip"; + return false; + } +#endif // The shift-register expander needs the LCD_CAM backend: its ×8 frame (~145 KB) only fits in // PSRAM, and the classic ESP32's I2S-i80 backend cannot DMA from PSRAM at all (see buf[0]) — // it would fall back to internal RAM and fail the allocation, or worse, half-fit. Refuse it @@ -576,6 +656,8 @@ bool i80Ws2812Init(I80Ws2812Handle&, const uint16_t*, uint8_t, uint16_t, uint16_ size_t, bool, uint8_t) { return false; } +const char* i80Ws2812LastError() { return nullptr; } +bool i80Ws2812SharedBusFree() { return false; } uint8_t* i80Ws2812Buffer(const I80Ws2812Handle&, uint8_t) { return nullptr; } size_t i80Ws2812BufferCapacity(const I80Ws2812Handle&) { return 0; } bool i80Ws2812Transmit(I80Ws2812Handle&, uint8_t, size_t) { return false; } diff --git a/src/platform/esp32/platform_esp32_rmt.cpp b/src/platform/esp32/platform_esp32_rmt.cpp index c5d3923a..bb415a8a 100644 --- a/src/platform/esp32/platform_esp32_rmt.cpp +++ b/src/platform/esp32/platform_esp32_rmt.cpp @@ -26,12 +26,130 @@ #include "esp_heap_caps.h" // capture buffer alloc for the shared frame loopback #include "esp_timer.h" // timed first transmit #include "esp_log.h" +#include "esp_cpu.h" +#if CONFIG_IDF_TARGET_ESP32 +// The level-5 refill path (rmt_hi_vector.S): the classic ESP32 has no RMT DMA, so the refill +// interrupt is the whole timing story. These are the pieces that path drives directly. +#include "esp_rom_sys.h" // esp_rom_route_intr_matrix +#include "esp_memory_utils.h" // esp_ptr_internal: the ISR may only read internal RAM +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" // vTaskDelay in the polled wait +#include "hal/rmt_ll.h" +#include "soc/rmt_struct.h" // RMT +#include "soc/gpio_struct.h" // GPIO.func_out_sel_cfg: which channel a pin got +#include "soc/gpio_sig_map.h" // RMT_SIG_OUT0_IDX +#include "soc/interrupts.h" // ETS_RMT_INTR_SOURCE +#include "soc/dport_reg.h" // the interrupt matrix map registers, read back after routing +#endif #include #include #include // the transmit callback the shared frame loopback takes #include // std::nothrow +#if CONFIG_IDF_TARGET_ESP32 +// --------------------------------------------------------------------------------------------- +// Level-5 refill. The IDF driver keeps everything about the channel (GPIO, clock, memory blocks, +// power) and is bypassed for the transmit itself: its interrupt runs at level 1-3, which every +// critical section masks, and that is what let a refill arrive late (see rmt_hi_vector.S). The +// RMT interrupt source on core 1 is rerouted to vector 26 (level 5, refused by esp_intr_alloc as +// "special", so routed by hand), and this code plays each frame ping-pong out of the channel's +// memory, one half-block per threshold interrupt, straight from the driver's symbol buffer. +// +// At file scope, outside every namespace: the assembly bridge calls rmtHiIsr by its C name, and +// RMTMEM is the linker's symbol, so both need external C linkage, which an anonymous namespace +// would silently take away. +// +// Everything the ISR touches lives in internal RAM: the channel state (DRAM), the symbol buffer +// (the driver allocates it internal-first and the transmit refuses anything else), RMT registers +// and RMTMEM (peripheral). So it also runs through a flash-cache-off window, which a flash write +// opens on both cores. +// --------------------------------------------------------------------------------------------- +struct RmtHiChannel { + const uint32_t* cur = nullptr; // next symbol to copy in + const uint32_t* end = nullptr; // one past the last + uint16_t half = 0; // symbols per half-block (the threshold) + uint16_t offset = 0; // where the next half goes: 0 or `half` + volatile bool busy = false; // a frame is on the wire +}; +static RmtHiChannel s_hi[RMT_LL_CHANS_PER_INST]; + +// RMTMEM is a linker-provided address; the IDF types it in a private header, so the same layout +// is declared here: 8 channels of 64 words, contiguous, which is what lets a channel that owns +// several blocks be addressed as one run past its own 64. +struct RmtHiMem { struct { volatile uint32_t data32[SOC_RMT_MEM_WORDS_PER_CHANNEL]; } chan[RMT_LL_CHANS_PER_INST]; }; +extern "C" RmtHiMem RMTMEM; +extern "C" void ld_include_rmt_hi_vector(); // forces the .S object to link (the vector symbol is weak elsewhere) + +// Copy the next half-block for `ch`. Runs at level 5: no RTOS, no logging, no cache-dependent +// memory. A frame shorter than the remaining half ends with a zero symbol, which the peripheral +// treats as end-of-transmission and raises TX_DONE on. +static void IRAM_ATTR rmtHiFill(uint8_t ch) { + RmtHiChannel& c = s_hi[ch]; + volatile uint32_t* dst = &RMTMEM.chan[ch].data32[c.offset]; + uint32_t n = c.half; + while (n && c.cur != c.end) { *dst++ = *c.cur++; n--; } + if (n) *dst = 0; // end marker inside this half + c.offset = static_cast(c.offset ? 0 : c.half); +} + +// The C half of the level-5 handler. Called from rmt_hi_vector.S with the register file saved +// and a private stack; must return promptly and must clear what it handles, the interrupt is +// level-triggered. +extern "C" void IRAM_ATTR rmtHiIsr(void*) { + const uint32_t st = RMT.int_st.val; + for (uint8_t ch = 0; ch < RMT_LL_CHANS_PER_INST; ch++) { + const uint32_t thres = RMT_LL_EVENT_TX_THRES(ch), done = RMT_LL_EVENT_TX_DONE(ch); + if (st & thres) { + rmt_ll_clear_interrupt_status(&RMT, thres); + if (s_hi[ch].busy) rmtHiFill(ch); + } + if (st & done) { + rmt_ll_clear_interrupt_status(&RMT, done); + s_hi[ch].busy = false; + rmt_ll_enable_interrupt(&RMT, thres | done, false); + } + } +} + +// Which peripheral channel the IDF handed this GPIO: the matrix records the output signal, and +// the RMT signals are consecutive from RMT_SIG_OUT0_IDX. The driver keeps the id private. +static uint8_t rmtHiChannelOf(uint8_t gpio) { + const uint32_t sig = GPIO.func_out_sel_cfg[gpio].func_sel; + return (sig >= RMT_SIG_OUT0_IDX && sig < RMT_SIG_OUT0_IDX + RMT_LL_CHANS_PER_INST) + ? static_cast(sig - RMT_SIG_OUT0_IDX) : 0xFF; +} + +// Route the RMT interrupt source on THIS core to vector 26 and enable it. Runs on core 1 (inside +// the init hop) because INTENABLE is per core. After this the IDF driver's own level-1 handler +// on this core never fires again, which is intended: nothing here calls rmt_transmit any more, +// so nothing waits on it. +// +// Called after EVERY channel creation, not once: rmt_new_tx_channel routes the source back to the +// driver's own vector each time (intr_alloc.c), and a config change re-creates the channel. A +// once-only guard here let the second init hand the threshold events to the driver's handler, +// which has no transaction and dereferences null: a boot loop ~10 s in, when the network coming +// up triggered the second prepare sweep. Bench-found on the second Dig-Next-2. +static void rmtHiRouteOnThisCore() { +#if defined(CONFIG_ESP_SYSTEM_CHECK_INT_LEVEL_5) || defined(CONFIG_BTDM_CTRL_HLI) +#error "level 5 is taken on this config (system check or Bluetooth HLI); the RMT refill needs it free" +#endif + (void)&ld_include_rmt_hi_vector; + constexpr uint32_t kVector = 26; // level 5, "special" in the descriptor table, free here + esp_rom_route_intr_matrix(esp_cpu_get_core_id(), ETS_RMT_INTR_SOURCE, kVector); + esp_cpu_intr_enable(1u << kVector); + // Read the routing back: the matrix map for this core's RMT source, and this core's + // INTENABLE. The IDF's esp_intr_enable re-programs the map (intr_alloc.c), so a later call on + // the driver's own handle would silently undo this; the readback is what proves it held. + const uint32_t mapReg = esp_cpu_get_core_id() == 0 + ? DPORT_PRO_RMT_INTR_MAP_REG : DPORT_APP_RMT_INTR_MAP_REG; + ESP_LOGI("rmt", "level-5 refill: RMT source routed to vector %lu on core %d; map reads %lu, INTENABLE 0x%08lx", + static_cast(kVector), static_cast(esp_cpu_get_core_id()), + static_cast(DPORT_REG_READ(mapReg)), + static_cast(esp_cpu_intr_get_enabled_mask())); +} +#endif // CONFIG_IDF_TARGET_ESP32 + namespace mm::platform { namespace { @@ -43,45 +161,106 @@ struct RmtTxState { rmt_channel_handle_t channel = nullptr; rmt_encoder_handle_t encoder = nullptr; uint32_t resolutionHz = 0; +#if CONFIG_IDF_TARGET_ESP32 + uint8_t channelId = 0xFF; // the peripheral channel the IDF gave us, read back from the GPIO matrix + uint16_t blockSymbols = 0; // symbols the channel's memory holds (64 per block) +#endif }; + } // namespace -bool rmtWs2812Init(RmtWs2812Handle& h, uint8_t gpio, uint32_t resolutionHz, bool invert) { - auto* st = new (std::nothrow) RmtTxState(); - if (!st) return false; +// The channel is created on CORE 1, and that is the whole point of the detour below. +// +// An RMT TX channel's refill interrupt is bound to whichever core calls rmt_new_tx_channel +// (esp_intr_alloc pins to the calling core; esp_intr_alloc_info_t has no core field). Init is +// reached from the prepare sweep on the main task, and CONFIG_ESP_MAIN_TASK_AFFINITY_CPU0 puts +// that on core 0, where the WiFi task is also pinned. So the driver TICKED on core 1 while its +// interrupt lived with WiFi on core 0, and every WiFi burst that ran above the RMT's level-3 +// ceiling delayed a refill past the 64-symbol deadline: a DMA-less chip keeps clocking the +// stale block and the strip shows a few wrong lights, at any light count, at any TX power. +// Bench (QuinLED Dig-Next-2, 256 WS2812): more memory blocks softened it, priority 3 did +// nothing (WiFi's ISR is above 3 on the same core), halving the lights changed nothing. +// Espressif's RMT maintainer names this exact fix on esp-idf#5173: create the channel on the +// core WiFi is not on. Core 1 here carries only the encode task, so the refill runs undisturbed. +// +// A pinned one-shot task, not esp_ipc_call_blocking: the IPC task has a 1 KB stack and channel +// creation allocates and installs an interrupt. Deinit needs no counterpart: esp_intr_free hops +// to the allocating core itself (intr_alloc.c, via IPC). Chips with RMT DMA gain nothing from +// the hop but lose nothing either, so it is unconditional. +namespace { +struct RmtInitJob { + RmtTxState* st; + uint8_t gpio; + uint32_t resolutionHz; + bool invert; + bool ok; +}; +void rmtInitOnThisCore(void* arg) { + auto* job = static_cast(arg); + RmtTxState* st = job->st; rmt_tx_channel_config_t txCfg = {}; - txCfg.gpio_num = static_cast(gpio); + txCfg.gpio_num = static_cast(job->gpio); txCfg.clk_src = RMT_CLK_SRC_DEFAULT; - txCfg.resolution_hz = resolutionHz; - // Two memory blocks of symbols ping-pong so the DMA-less channel can refill - // while sending — the classic anti-glitch shape. The per-channel block size - // is a chip fact (64 words classic, 48 on the S3 — a hardcoded 64 makes - // rmt_new_tx_channel reject S3); the copy encoder streams from our buffer - // regardless. - txCfg.mem_block_symbols = SOC_RMT_MEM_WORDS_PER_CHANNEL; + txCfg.resolution_hz = job->resolutionHz; txCfg.trans_queue_depth = 4; - txCfg.flags.invert_out = invert ? 1 : 0; - - if (rmt_new_tx_channel(&txCfg, &st->channel) != ESP_OK) { - delete st; - return false; - } + txCfg.flags.invert_out = job->invert ? 1 : 0; + // One memory block per channel, the chip's own size (64 words classic, 48 on the S3: a + // hardcoded 64 makes rmt_new_tx_channel reject the S3), so all eight RMT channels stay + // available to an eight-pin board. On the classic ESP32 the block is the refill deadline + // (~40 us per half-block), and with the refill at interrupt level 1 that deadline was missed + // under WiFi: four blocks softened it, eight made it worse. With the refill at level 5 + // (below) one block is flicker-free, bench-verified on two Dig-Next-2 boards, so the extra + // blocks bought nothing but lost pins. + txCfg.mem_block_symbols = SOC_RMT_MEM_WORDS_PER_CHANNEL; + if (rmt_new_tx_channel(&txCfg, &st->channel) != ESP_OK) { job->ok = false; return; } rmt_copy_encoder_config_t copyCfg = {}; if (rmt_new_copy_encoder(©Cfg, &st->encoder) != ESP_OK) { - rmt_del_channel(st->channel); - delete st; - return false; + rmt_del_channel(st->channel); st->channel = nullptr; + job->ok = false; return; } - if (rmt_enable(st->channel) != ESP_OK) { - rmt_del_encoder(st->encoder); - rmt_del_channel(st->channel); - delete st; - return false; + rmt_del_encoder(st->encoder); st->encoder = nullptr; + rmt_del_channel(st->channel); st->channel = nullptr; + job->ok = false; return; + } + ESP_LOGI("rmt", "channel on GPIO %u created on core %d (%lu-symbol block)", + static_cast(job->gpio), static_cast(esp_cpu_get_core_id()), + static_cast(txCfg.mem_block_symbols)); +#if CONFIG_IDF_TARGET_ESP32 + st->channelId = rmtHiChannelOf(job->gpio); + st->blockSymbols = static_cast(txCfg.mem_block_symbols); + if (st->channelId != 0xFF) { + rmt_ll_tx_enable_wrap(&RMT, st->channelId, true); // one global bit on this chip + rmt_ll_tx_set_limit(&RMT, st->channelId, st->blockSymbols / 2); + rmtHiRouteOnThisCore(); + ESP_LOGI("rmt", "level-5 refill on channel %u, %u-symbol halves", + static_cast(st->channelId), static_cast(st->blockSymbols / 2)); + } else { + ESP_LOGW("rmt", "could not read the channel back from the GPIO matrix; IDF transmit path"); } +#endif + job->ok = true; +} +} // namespace + +bool rmtWs2812Init(RmtWs2812Handle& h, uint8_t gpio, uint32_t resolutionHz, bool invert) { + auto* st = new (std::nothrow) RmtTxState(); + if (!st) return false; + + RmtInitJob job{st, gpio, resolutionHz, invert, false}; + WorkerTask hop; + // Priority above the render loop so the one-shot runs at once; 8 KB matches the encode + // task. If the spawn fails (single core, no memory) the init runs inline on this core, which + // is the pre-fix behavior rather than no channel at all. + if (spawnPinnedTask(hop, "mmRmtInit", &rmtInitOnThisCore, &job, 8192, 6, 1)) { + stopPinnedTask(hop); // joins: the fn already returned, this only reaps the task + } else { + rmtInitOnThisCore(&job); + } + if (!job.ok) { delete st; return false; } st->resolutionHz = resolutionHz; h.impl = st; @@ -97,6 +276,27 @@ bool rmtWs2812Transmit(RmtWs2812Handle& h, const uint32_t* symbols, size_t symbo auto* st = static_cast(h.impl); if (!st || !symbols || symbolCount == 0) return false; +#if CONFIG_IDF_TARGET_ESP32 + if (st->channelId != 0xFF) { + // The level-5 path. The symbol buffer must be internal RAM: the refill runs with the + // flash cache possibly off, and a PSRAM read there is a fault, not a stall. The driver + // allocates internal-first; this is the guard for the fallback case. + if (!esp_ptr_internal(symbols)) return false; + RmtHiChannel& c = s_hi[st->channelId]; + if (c.busy) return false; + const uint8_t ch = st->channelId; + c.cur = symbols; c.end = symbols + symbolCount; + c.half = st->blockSymbols / 2; c.offset = 0; + c.busy = true; + rmt_ll_tx_reset_pointer(&RMT, ch); + rmt_ll_clear_interrupt_status(&RMT, RMT_LL_EVENT_TX_THRES(ch) | RMT_LL_EVENT_TX_DONE(ch)); + rmtHiFill(ch); // both halves primed before the start + rmtHiFill(ch); + rmt_ll_enable_interrupt(&RMT, RMT_LL_EVENT_TX_THRES(ch) | RMT_LL_EVENT_TX_DONE(ch), true); + rmt_ll_tx_start(&RMT, ch); + return true; + } +#endif rmt_transmit_config_t txCfg = {}; txCfg.loop_count = 0; // single shot, no hardware loop @@ -109,9 +309,9 @@ bool rmtWs2812Transmit(RmtWs2812Handle& h, const uint32_t* symbols, size_t symbo symbolCount * sizeof(uint32_t), &txCfg) == ESP_OK; } -void rmtWs2812Wait(RmtWs2812Handle& h, uint32_t timeoutMs) { +bool rmtWs2812Wait(RmtWs2812Handle& h, uint32_t timeoutMs) { auto* st = static_cast(h.impl); - if (!st) return; + if (!st) return true; // Finite timeout so a wedged DMA can't hang the render tick forever. Even the // longest realistic frame (thousands of pixels) clocks out well under 1 s; a // timeout here means the peripheral is stuck, and the driver re-encodes the @@ -125,12 +325,34 @@ void rmtWs2812Wait(RmtWs2812Handle& h, uint32_t timeoutMs) { // and calls rmt_transmit again; if the channel is still busy, rmt_transmit // returns an error, rmtWs2812Transmit returns false, and RmtLedDriver::tick() // skips waiting on that channel (its started[] guard) — no crash, no corruption. - rmt_tx_wait_all_done(st->channel, timeoutMs); + // The RESULT is what the caller needs: a timeout leaves the frame in flight, and re-encoding + // into symbols_ next tick would rewrite bytes the peripheral is still clocking out. That is a + // silent corruption rather than a dropped frame, and it shows on the strip as a few lights in + // the wrong color, independent of light count. +#if CONFIG_IDF_TARGET_ESP32 + if (st->channelId != 0xFF) { + // TX_DONE clears `busy` from the level-5 handler. Polled with a yield, not a semaphore: + // the handler runs where no RTOS call is allowed, so it cannot signal one. + const int64_t deadline = esp_timer_get_time() + static_cast(timeoutMs) * 1000; + while (s_hi[st->channelId].busy) { + if (esp_timer_get_time() > deadline) return false; + vTaskDelay(1); + } + return true; + } +#endif + return rmt_tx_wait_all_done(st->channel, timeoutMs) == ESP_OK; } void rmtWs2812Deinit(RmtWs2812Handle& h) { auto* st = static_cast(h.impl); if (!st) return; +#if CONFIG_IDF_TARGET_ESP32 + if (st->channelId != 0xFF) { + rmt_ll_enable_interrupt(&RMT, RMT_LL_EVENT_TX_THRES(st->channelId) | RMT_LL_EVENT_TX_DONE(st->channelId), false); + s_hi[st->channelId].busy = false; + } +#endif if (st->channel) { rmt_disable(st->channel); rmt_del_channel(st->channel); diff --git a/src/platform/esp32/rmt_hi_vector.S b/src/platform/esp32/rmt_hi_vector.S new file mode 100644 index 00000000..35ce1fb3 --- /dev/null +++ b/src/platform/esp32/rmt_hi_vector.S @@ -0,0 +1,199 @@ +/* rmt_hi_vector.S: the level-5 interrupt bridge for the classic-ESP32 RMT refill. + * + * Why this file exists, in one paragraph. On a classic ESP32 the RMT has no DMA: the peripheral + * plays from a small on-chip block that an interrupt must refill every few dozen microseconds + * for the whole frame, and a late refill re-clocks stale symbols into the strip (bits shift, + * a red light shows blue). The IDF driver services that interrupt at level 1-3, and on Xtensa + * every critical section raises PS.INTLEVEL to XCHAL_EXCM_LEVEL (3), so a spinlock anywhere on + * the core holds the refill for its whole duration. Only a level-4/5 interrupt runs through a + * critical section, and a level-4/5 handler cannot be written in C: it runs with the register + * window in an undefined state and no stack, so this bridge saves the full register file, + * switches to a private stack, calls one IRAM C function, and restores. It is derived from + * Espressif's own hli_vectors.S (Apache-2.0, components/bt/controller/esp32), moved from level 4 + * to level 5 with the Bluetooth-specific parts removed. The C side is rmtHiIsr in + * platform_esp32_rmt.cpp. + * + * The level-5 vector (interrupt 26) is flagged special in the IDF descriptor table, so + * esp_intr_alloc refuses it for a peripheral source; the C side routes the RMT source to it + * by hand (esp_rom_route_intr_matrix) and enables it on core 1. Nothing else uses level 5 on + * this build: the interrupt watchdog and cache-error check sit on level 4 + * (CONFIG_ESP_SYSTEM_CHECK_INT_LEVEL_4), and Bluetooth is off. Both facts are asserted in C. + */ + +#include "sdkconfig.h" + +#if defined(__XTENSA__) && defined(CONFIG_IDF_TARGET_ESP32) && !defined(CONFIG_BTDM_CTRL_HLI) + +#include +#include +#include +#include "xtensa/config/core-isa.h" +#include "xtensa_context.h" +#include "soc/soc.h" + +#define RMT_HI_LEVEL 5 +#define RMT_HI_INTR_STACK 1024 + +/* Save area: 64 general registers plus 7 specials (WINDOWBASE, WINDOWSTART, SAR, LBEG, LEND, + * LCOUNT, EPC1). Not the standard exception frame: laid out for the save/restore loops below. */ +#define REG_FILE_SIZE (64 * 4) +#define SPECREG_OFFSET REG_FILE_SIZE +#define SPECREG_SIZE (7 * 4) +#define REG_SAVE_AREA_SIZE (SPECREG_OFFSET + SPECREG_SIZE) + +#define sp a1 + + .data + /* 16-byte aligned, because the C handler's stack pointer is derived from the end of this + * buffer and the Xtensa windowed ABI requires a 16-byte aligned SP. `.data` gives no such + * guarantee on its own. The save area is aligned too: it is addressed with l32i/s32i, which + * want 4, and 16 costs nothing here. */ + .align 16 +_rmt_hi_intr_stack: + .space RMT_HI_INTR_STACK + .align 16 +_rmt_hi_save_ctx: + .space REG_SAVE_AREA_SIZE + + .section .iram1,"ax" + .global xt_highint5 + .type xt_highint5,@function + .align 4 + +xt_highint5: + movi a0, _rmt_hi_save_ctx + /* save the four low registers; a0 arrives in EXCSAVE_5 */ + s32i a1, a0, 4 + s32i a2, a0, 8 + s32i a3, a0, 12 + rsr a2, XT_REG_EXCSAVE_5 + s32i a2, a0, 0 + + /* special registers */ + addi a0, a0, SPECREG_OFFSET + rsr a2, XT_REG_WINDOWBASE + s32i a2, a0, 0 + rsr a2, XT_REG_WINDOWSTART + s32i a2, a0, 4 + rsr a2, XT_REG_SAR + s32i a2, a0, 8 + #if XCHAL_HAVE_LOOPS + rsr a2, XT_REG_LBEG + s32i a2, a0, 12 + rsr a2, XT_REG_LEND + s32i a2, a0, 16 + /* save and disable any active zero-overhead loop while C runs */ + movi a2, 0 + xsr a2, XT_REG_LCOUNT + s32i a2, a0, 20 + #endif + rsr a2, EPC1 + s32i a2, a0, 24 + + /* exception mode on, window overflow off, one level above ours */ + movi a0, PS_INTLEVEL(RMT_HI_LEVEL + 1) | PS_EXCM + wsr a0, XT_REG_PS + rsync + + /* the remaining 60 physical registers, 12 per window rotation, 5 rotations */ + movi a1, 5 + movi a3, _rmt_hi_save_ctx + 4 * 4 +1: + s32i a4, a3, 0 + s32i a5, a3, 4 + s32i a6, a3, 8 + s32i a7, a3, 12 + s32i a8, a3, 16 + s32i a9, a3, 20 + s32i a10, a3, 24 + s32i a11, a3, 28 + s32i a12, a3, 32 + s32i a13, a3, 36 + s32i a14, a3, 40 + s32i a15, a3, 44 + addi a13, a1, -1 + addi a15, a3, 48 + beqz a13, 2f + rotw 3 + j 1b +2: + rotw 4 + /* all registers saved: WINDOWSTART = 1 << WINDOWBASE */ + rsr a2, XT_REG_WINDOWBASE + movi a3, 1 + ssl a2 + sll a3, a3 + wsr a3, XT_REG_WINDOWSTART + +_rmt_hi_stack_switch: + movi a0, 0 + movi sp, _rmt_hi_intr_stack + RMT_HI_INTR_STACK - 16 + s32e a0, sp, -12 + s32e a0, sp, -16 + movi a0, _rmt_hi_stack_switch + /* PS for C at our own level: interrupts below us masked, EXCM clear, windowing on */ + movi a6, PS_INTLEVEL(RMT_HI_LEVEL) | PS_UM | PS_WOE + wsr a6, XT_REG_PS + rsync + + mov a6, sp + call4 rmtHiIsr + l32e sp, sp, -12 + + /* back to exception mode for the restore */ + movi a2, PS_INTLEVEL(RMT_HI_LEVEL + 1) | PS_EXCM + wsr a2, XT_REG_PS + rsync + + movi a0, _rmt_hi_save_ctx + SPECREG_OFFSET + l32i a2, a0, 8 + wsr a2, XT_REG_SAR + #if XCHAL_HAVE_LOOPS + l32i a2, a0, 12 + wsr a2, XT_REG_LBEG + l32i a2, a0, 16 + wsr a2, XT_REG_LEND + l32i a2, a0, 20 + wsr a2, XT_REG_LCOUNT + #endif + l32i a2, a0, 24 + wsr a2, EPC1 + + /* restore the 60 registers, the reverse of the save */ + rotw -4 + movi a15, _rmt_hi_save_ctx + 64 * 4 + movi a13, 5 +1: + addi a1, a13, -1 + addi a3, a15, -48 + l32i a4, a3, 0 + l32i a5, a3, 4 + l32i a6, a3, 8 + l32i a7, a3, 12 + l32i a8, a3, 16 + l32i a9, a3, 20 + l32i a10, a3, 24 + l32i a11, a3, 28 + l32i a12, a3, 32 + l32i a13, a3, 36 + l32i a14, a3, 40 + l32i a15, a3, 44 + beqz a1, 2f + rotw -3 + j 1b +2: + movi a0, _rmt_hi_save_ctx + l32i a2, a0, SPECREG_OFFSET + 4 + wsr a2, XT_REG_WINDOWSTART + l32i a1, a0, 4 + l32i a2, a0, 8 + l32i a3, a0, 12 + rsr a0, XT_REG_EXCSAVE_5 + rfi RMT_HI_LEVEL + +/* xt_highint5 is only a weak default elsewhere, so nothing would make the linker keep this + * object. This symbol is referenced from C (and with -u in CMake) to force it in. */ + .global ld_include_rmt_hi_vector +ld_include_rmt_hi_vector: + +#endif /* __XTENSA__ && CONFIG_IDF_TARGET_ESP32 && !CONFIG_BTDM_CTRL_HLI */ diff --git a/src/platform/platform.h b/src/platform/platform.h index 09b61a6a..57d4870e 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -980,9 +980,13 @@ bool rmtWs2812Transmit(RmtWs2812Handle& h, const uint32_t* symbols, size_t symbo // Block until the channel's in-flight transmission finishes, bounded by // `timeoutMs` so a wedged peripheral can't hang the render tick forever: a // timed-out frame is simply dropped and re-encoded next tick (self-heals). With -// N channels waited sequentially the worst case is N×timeoutMs; acceptable for -// the same self-healing reason. -void rmtWs2812Wait(RmtWs2812Handle& h, uint32_t timeoutMs); +// N channels waited sequentially the worst case is N×timeoutMs. A timeout is NOT a dropped frame +// the caller may re-encode over: the peripheral is still reading the symbol buffer, so the driver +// keeps it untouched and waits again on the next tick (RmtLedDriver::waitForPins). +/// Block until this channel's transmit completes. Returns false on TIMEOUT, meaning the frame is +/// STILL CLOCKING OUT: the caller must not touch the symbol buffer it handed over, because the +/// peripheral is still reading it. +bool rmtWs2812Wait(RmtWs2812Handle& h, uint32_t timeoutMs); void rmtWs2812Deinit(RmtWs2812Handle& h); @@ -1061,9 +1065,25 @@ struct I80Ws2812Handle { void* impl = nullptr; }; // error in esp_lcd: it silently rounds the prescale, which would emit a wrong waveform). A // multiplier > 1 is rejected on a backend that cannot DMA the resulting frame from PSRAM (the // classic-ESP32 I2S i80 path), rather than driving a frame the hardware can't sustain. +// +// `kBusPinUnset` for `wrGpio` / `dcGpio` (or a parked data lane) means "no pin": on the classic +// ESP32 the backend sinks that line onto an input-only pad, so the peripheral gets the GPIO number +// it insists on and nothing on the board is driven. The LCD_CAM backends need a real pad for both +// (the P4 ROM writes outside the GPIO block for an invalid number), so there it is an init failure +// the driver reports before ever calling this. +constexpr uint16_t kBusPinUnset = 0xFFFF; bool i80Ws2812Init(I80Ws2812Handle& h, const uint16_t* dataPins, uint8_t laneCount, uint16_t wrGpio, uint16_t dcGpio, size_t bufferBytes, bool wantSecondBuffer, uint8_t clockMultiplier = 1); +// Why the last i80Ws2812Init returned false, when the backend knows more than "it failed" +// (a peripheral another module holds, a pin this package lacks); nullptr when it does not. +// The driver shows it as the status, so the user reads the cause rather than "check pins / memory". +const char* i80Ws2812LastError(); +// Whether the peripheral this backend shares with other modules is free to claim right now. On the +// classic ESP32 the i80 bus is the I2S peripheral and a PDM microphone wants the same instance, so +// the driver polls this to rebuild itself once the microphone lets go (and the microphone's own +// retry does the mirror). Always false where nothing is shared. Cheap: a registry read, no init. +bool i80Ws2812SharedBusFree(); // DMA frame buffer `buffer` (0 or 1) the driver encodes into (zero-copy). // Buffer 0 always exists once init succeeded; buffer 1 is null when the second @@ -1464,12 +1484,28 @@ bool audioCaptureInit(AudioMicHandle& h, uint8_t deviceIndex, uint32_t sampleRat // codec needs the clock to run (the ES8311 won't even answer I2C without it, so // AudioService starts I2S *before* audioCodecInit on a codec board). Returns false // on failure (bad pins, no I2S, out of memory): the module idles with a status error. +/// How the microphone speaks, which decides how many pins it needs and how the peripheral is +/// configured. Two physically different parts, not two settings of one: +/// - `I2sStd`: a PCM part (INMP441 and friends) on three wires, bit clock + word select + data, +/// already-decoded samples in Philips framing. +/// - `Pdm`: a one-bit-stream part on TWO wires, clock + data, decimated to PCM by the +/// peripheral. Boards with a mic soldered on tend to use these because they are cheaper and +/// smaller: the QuinLED Dig-Next-2's onboard mic is one (clock GPIO 8, data GPIO 7). +/// `sckPin` and `mclkPin` are meaningless in PDM mode and ignored. +enum class MicMode : uint8_t { I2sStd = 0, Pdm = 1 }; + bool audioMicInit(AudioMicHandle& h, uint16_t wsPin, uint16_t sdPin, - uint16_t sckPin, int16_t mclkPin, uint32_t sampleRate); + uint16_t sckPin, int16_t mclkPin, uint32_t sampleRate, + MicMode mode = MicMode::I2sStd); // Read up to `maxSamples` 32-bit samples into `out`; returns the count read // (0 if none ready / not initialized). Non-blocking enough for the render tick. size_t audioMicRead(AudioMicHandle& h, int32_t* out, size_t maxSamples); +// Whether the I2S instance a PDM microphone needs is free right now. The mirror of +// i80Ws2812SharedBusFree: on the classic ESP32 the parallel LED bus is that same instance, so a +// microphone refused at claim time polls this to come up once the bus lets go. False where nothing +// is shared (every chip whose i80 is LCD_CAM, and the desktop). Cheap: a registry read, no init. +bool audioMicSharedBusFree(MicMode mode); void audioMicDeinit(AudioMicHandle& h); diff --git a/src/ui/app.js b/src/ui/app.js index 431eb65e..c10e0c45 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -1244,7 +1244,7 @@ function showUpdateOverlay() { // A URL install hears POST /cancel; a file upload cancels by dropping its connection // (MoonBase's single-connection server is busy receiving it), so abort the fetch. if (uploadCtl) uploadCtl.abort(); - fetch("/cancel", { method: "POST" }).catch(() => {}); + fetch("/api/firmware/cancel", { method: "POST" }).catch(() => {}); }); box.append(h, msg, bar, cancel, dismiss); ov.appendChild(box); @@ -1328,7 +1328,7 @@ async function moonbaseUpdateFlow(opts) { const uploadCtl = new AbortController(); ui.setUpload(uploadCtl); try { - const r = await fetch("/install", { + const r = await fetch("/api/firmware/upload", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: opts.file, signal: uploadCtl.signal }); if (!r.ok) throw new Error(await r.text()); @@ -1380,9 +1380,9 @@ async function moonbaseUpdateFlow(opts) { ui.status("The install was interrupted \u2014 retrying\u2026"); try { if (opts.url) { - await fetch("/install-url", { method: "POST", body: opts.url }); + await fetch("/api/firmware/url", { method: "POST", body: opts.url }); } else { - await fetch("/install", { + await fetch("/api/firmware/upload", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: opts.file }); } @@ -1558,11 +1558,11 @@ function createCard(mod, depth) { // Emoji tags (role + curated) shown after the name: same set used by the // type picker's chip filter, so visual identity is consistent across views. - const emoji = emojiTagsForMod(mod); - if (emoji) { + const emojiList = emojiListForMod(mod); + if (emojiList.length) { const emojiEl = document.createElement("span"); emojiEl.className = "card-name-emoji"; - emojiEl.textContent = emoji; + renderEmojiTags(emojiEl, emojiList); title.appendChild(emojiEl); } @@ -2106,10 +2106,25 @@ function docPathForType(moduleType) { // loaded, so two MoonLive effects running different scripts read differently while sharing one // entry in /api/types: the audio one shows 🎶, the moving-head one 🎯. A compiled module sends // nothing here and keeps its type's answer. -function emojiTagsForMod(mod) { - if (!mod) return ""; +/// The same set as an array, for the callers that render one span per emoji. +function emojiListForMod(mod) { + if (!mod) return []; const t = availableTypes.find(t => t.name === mod.type) || {role: mod.role, tags: ""}; - return emojiTagsFor(mod.tags ? {...t, tags: mod.tags} : t).join(""); + return emojiTagsFor(mod.tags ? {...t, tags: mod.tags} : t); +} + +/// Fill `el` with one span per emoji, each carrying its own tooltip. A tooltip belongs to a single +/// character, so the emoji cannot be one joined string. Both the card header and the picker rows +/// render through here, which is what keeps a chip explained the same way wherever it appears. +function renderEmojiTags(el, list) { + el.textContent = ""; + for (const ch of list) { + const one = document.createElement("span"); + one.textContent = ch; + const label = EMOJI_LABEL[ch]; + if (label) one.title = label; + el.appendChild(one); + } } // Whether a control appears in the generic control list: false for controls the module marked @@ -5156,14 +5171,74 @@ const SCRIPTED_EMOJI = "\u{1F4DD}"; // 📝 the module runs a MoonLive scri // Not the gear: that is already the `generic` role's emoji, and reusing it drew the chip twice. const COMPILED_EMOJI = "\u{1F4E6}"; // 📦 chip only: never a tag any module carries +// The POWER-FUNCTION tags: which kernels an effect is built on, rather than where it came from or +// what it listens to. They are ordered LAST and kept together so they read as one group on a card +// and sit adjacent in the picker's chip row, which is what makes "show me the fluid effects" a +// glanceable filter rather than a hunt through a mixed string. A separator character is not used: +// every chip is one grapheme, so a separator would become a chip of its own. +// What an effect LISTENS to. Their own group in the picker: "show me the effects that react to +// music" is the question a chip row is for, and the two notes answer it together. +const AUDIO_EMOJI = ["\u{1F3B5}", "\u{1F3B6}"]; // volume, frequency + +const KERNEL_EMOJI = ["🖌️", "✨", "🌊", "💨", "🌫️", "🎡"]; // same order the legend lists them in + +// What each chip MEANS, as a tooltip. The chips are a filter, so a reader who does not yet know the +// vocabulary has to guess what narrows the list; the title makes each one self-describing without +// spending any screen space. Wording follows the legend that documents them for people reading a +// card: docs/tutorials/how-projectmm-works.md, "The emoji on every card". +const EMOJI_LABEL = { + // what the module IS + "\u{1F525}": "effect", + "\u2638\uFE0F": "driver", + "\u{1F48E}": "modifier", + "\u{1F6A5}": "layout", + "\u{1F95E}": "layer", + "\u{1F6F0}\uFE0F": "service", + "\u2699\uFE0F": "generic", + [SCRIPTED_EMOJI]: "runs a MoonLive script", + [COMPILED_EMOJI]: "compiled into the firmware", + // the shape it works in + "\u{1F4CF}": "1D: a line", + "\u{1F7E6}": "2D: a picture", + "\u{1F9CA}": "3D: a volume", + // where it came from + "\u{1F4AB}": "projectMM / MoonLight", + "\u{1F319}": "MoonModules", + "\u{1F419}": "WLED", + "\u26A1\uFE0F": "FastLED", + "\u{1F985}": "a named contributor", + // what it listens to and what it does + "\u{1F3B5}": "reacts to volume: how loud the room is", + "\u{1F3B6}": "reacts to frequency: which notes are playing", + "\u{1F4E1}": "takes its picture from the network", + "\u{1F3AF}": "aims moving heads", + "\u{1F47E}": "pixel art: games and sprites", + "\u{1F9EC}": "a simulation: cells evolving off their own last frame", + "\u{1F4F9}": "motion-tracking aware", + // the power functions, shown together at the end of a row + "\u{1F58C}\uFE0F": "power function: a shader, every pixel computed from its position", + "\u2728": "power function: particles, born, moved by forces, and dying", + "\u{1F30A}": "power function: a fluid working out its own motion", + "\u{1F4A8}": "power function: transport, light carried and fading rather than redrawn", + "\u{1F32B}\uFE0F": "power function: a noise field, the cloud and smoke family", + "\u{1F3A1}": "power function: polar, composed around a center", +}; + function emojiTagsFor(t) { const out = []; + const kernels = []; const seen = new Set(); - const push = (ch) => { if (ch && !seen.has(ch)) { seen.add(ch); out.push(ch); } }; + const push = (ch) => { + if (!ch || seen.has(ch)) return; + seen.add(ch); + (KERNEL_EMOJI.includes(ch) ? kernels : out).push(ch); + }; push(ROLE_EMOJI[t.role]); push(DIM_EMOJI[t.dim]); for (const ch of graphemes(t.tags || "")) push(ch); - return out; + // Kernel chips in a fixed order, so two effects on the same kernels show the same run. + kernels.sort((a, b) => KERNEL_EMOJI.indexOf(a) - KERNEL_EMOJI.indexOf(b)); + return out.concat(kernels); } // The type picker serves two modes: @@ -5421,6 +5496,8 @@ function openPicker(anchorEl, opts) { Object.values(ROLE_EMOJI), // type Object.values(DIM_EMOJI), // dimension ["\u{1F4AB}", "\u{1F319}", "\u{1F419}", "\u26A1\uFE0F"], // origin + AUDIO_EMOJI, // what it listens to + KERNEL_EMOJI, // which power functions it is built on ]; const groups = CHIP_GROUPS.map(g => present.filter(e => g.includes(e))); const classified = new Set(CHIP_GROUPS.flat()); @@ -5442,6 +5519,8 @@ function openPicker(anchorEl, opts) { const chip = document.createElement("button"); chip.className = "type-picker-chip"; chip.textContent = emoji; + const label = EMOJI_LABEL[emoji]; + if (label) { chip.title = label; chip.setAttribute("aria-label", label); } chip.addEventListener("click", () => { if (activeChips.has(emoji)) { activeChips.delete(emoji); chip.classList.remove("active"); } else { activeChips.add(emoji); chip.classList.add("active"); } @@ -5522,7 +5601,7 @@ function openPicker(anchorEl, opts) { } const emoji = document.createElement("span"); emoji.className = "type-picker-item-emoji"; - emoji.textContent = emojiTagsFor(t).join(""); + renderEmojiTags(emoji, emojiTagsFor(t)); item.appendChild(emoji); // Show the factory-stripped name ("Rainbow") not the typeName // ("RainbowEffect"); the role text on the right already conveys diff --git a/src/ui/migrate.js b/src/ui/migrate.js index d0029c75..773f6254 100644 --- a/src/ui/migrate.js +++ b/src/ui/migrate.js @@ -61,6 +61,14 @@ export const TYPE_RENAMES = { // the bench: a blanket fps → targetFps corrupted NetworkSendDriver's own `fps`). `review` marks // a value-semantics change: the name maps, the value needs the user's eye. export const CONTROL_RENAMES = { + // One name for one thing: the service is AudioService, the frame is AudioFrame, so the control + // that makes a sprite effect follow the music is audioReactive. Scoped to the seven effects that + // declare it, per the rule above. + "soundReactive": { + name: "audioReactive", date: "2026-09-05", + onTypes: ["FishTankEffect", "FlyingToastersEffect", "PacmanEffect", "PongEffect", + "SpaceInvadersEffect", "SpriteFountainEffect", "MovingHeadEffect"], + }, // ControlModule's encoders spell the word out: the interface uses the industry term, the UI // abbreviates it to `enc` for the strip. Scoped, because `enc1` is a plausible name anywhere. "enc1": { name: "encoder1", date: "2026-08-30", onTypes: ["ControlModule"] }, diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index fc626bc5..9ee9487e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -87,6 +87,7 @@ add_executable(mm_tests unit/light/unit_PanelCardDriver_packet.cpp unit/light/unit_WledAudioSyncPacket.cpp unit/light/unit_BlendMap.cpp + unit/light/unit_BeatRipples.cpp unit/light/unit_fluid.cpp unit/light/unit_draw.cpp unit/light/unit_GameOfLifeEffect.cpp diff --git a/test/js/migrate.test.mjs b/test/js/migrate.test.mjs index eb93a4a0..6c687fd5 100644 --- a/test/js/migrate.test.mjs +++ b/test/js/migrate.test.mjs @@ -6,7 +6,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { applyMigrations } from "../../src/ui/migrate.js"; +import { applyMigrations, CONTROL_RENAMES } from "../../src/ui/migrate.js"; test("a user preset that shares a renamed filename is the user's, not migrated", () => { const { files, report } = applyMigrations({ "/.config/presets/Layers.json": '{"x":1}' }); @@ -132,3 +132,49 @@ test("a corrupt config file is restored as-is and flagged for review", () => { assert.equal(files["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/.config/Broken.json"], "{not json"); assert.ok(report.some(r => r.kind === "review" && r.detail.includes("not valid JSON"))); }); + +// Every CONTROL_RENAMES entry is consumed as `cr.name` (renameKeys), so an entry written with any +// other key silently renames nothing: the old key stays, the user's value never reaches the new +// control, and no report line says so. `soundReactive` shipped with `to:` (the shape FILE_RENAMES +// uses) and did exactly that. This checks the table's own shape as well as one worked example, +// because the shape is the part a new entry gets wrong. +test("every control rename declares the key renameKeys reads, and carries its scope", () => { + for (const [from, r] of Object.entries(CONTROL_RENAMES)) { + assert.ok(typeof r.name === "string" && r.name.length > 0, + `${from}: needs name: (found keys ${Object.keys(r).join(", ")})`); + assert.ok(Array.isArray(r.onTypes) && r.onTypes.length > 0, + `${from}: a bare-name rename needs onTypes, per the scoping rule`); + } +}); + +test("a saved soundReactive value comes back under audioReactive", () => { + const files = { + "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/.config/Effects.json": JSON.stringify({ + "type": "Effects", + "0.type": "FishTankEffect", + "0.soundReactive": true, + "0.speed": 42, + }), + }; + const { files: out } = applyMigrations(files); + const cfg = JSON.parse(out["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/.config/Effects.json"]); + assert.equal(cfg["0.audioReactive"], true, "the value must land on the new name"); + assert.ok(!("0.soundReactive" in cfg), "the old key must be gone"); + assert.equal(cfg["0.speed"], 42, "unrelated controls are untouched"); +}); + +// Scoped, per the rule the table documents: the same word on a module that never declared it is +// somebody else's control and must not be rewritten. +test("soundReactive is left alone on a module outside its scope", () => { + const files = { + "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/.config/Effects.json": JSON.stringify({ + "type": "Effects", + "0.type": "NoiseEffect", + "0.soundReactive": true, + }), + }; + const { files: out } = applyMigrations(files); + const cfg = JSON.parse(out["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/.config/Effects.json"]); + assert.equal(cfg["0.soundReactive"], true); + assert.ok(!("0.audioReactive" in cfg)); +}); diff --git a/test/js/ui-emoji-labels.test.mjs b/test/js/ui-emoji-labels.test.mjs new file mode 100644 index 00000000..5bbef43f --- /dev/null +++ b/test/js/ui-emoji-labels.test.mjs @@ -0,0 +1,66 @@ +// Every emoji the interface shows carries a tooltip explaining it. The chips in the type picker +// are a FILTER, so a reader who does not know the vocabulary cannot tell what narrows the list; +// the tooltip is what makes each one self-describing. A label table is written by hand while the +// emoji themselves are declared across ~90 module headers, so the two drift apart silently: what +// this pins is that no emoji reaches a user without an explanation. +// Run: `node --test test/js/ui-emoji-labels.test.mjs`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { execSync } from "node:child_process"; + +const APP = new URL("../../src/ui/app.js", import.meta.url).pathname; +const src = readFileSync(APP, "utf8"); + +/// app.js is a browser script rather than a module, so the vocabularies are lifted out of it by +/// name and evaluated. Reading the source is the point: a test that redeclared the tables would +/// pass while the shipped ones drifted. +function tableFrom(name) { + const i = src.indexOf(`const ${name}`); + assert.ok(i >= 0, `${name} not found in app.js`); + const j = src.indexOf("\n};", i); + return src.slice(i, j + 3); +} +function constFrom(name) { + const i = src.indexOf(`const ${name}`); + assert.ok(i >= 0, `${name} not found in app.js`); + // To the statement's semicolon, not to the end of the line: these declarations carry a + // trailing comment, and cutting at the newline truncates the value itself. + return src.slice(i, src.indexOf(";", i) + 1); +} + +const SCRIPTED_EMOJI = "\u{1F4DD}"; +const COMPILED_EMOJI = "\u{1F4E6}"; +const EMOJI_LABEL = new Function("SCRIPTED_EMOJI", "COMPILED_EMOJI", + tableFrom("EMOJI_LABEL") + "; return EMOJI_LABEL;")(SCRIPTED_EMOJI, COMPILED_EMOJI); + +const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const emojiIn = (s) => [...seg.segment(s)].map(g => g.segment).filter(g => g.trim()); + +test("every emoji the interface generates has a tooltip", () => { + const ROLE_EMOJI = new Function(tableFrom("ROLE_EMOJI") + "; return ROLE_EMOJI;")(); + const DIM_EMOJI = new Function(tableFrom("DIM_EMOJI") + "; return DIM_EMOJI;")(); + const KERNEL_EMOJI = new Function(constFrom("KERNEL_EMOJI") + "; return KERNEL_EMOJI;")(); + + const shown = [...Object.values(ROLE_EMOJI), ...Object.values(DIM_EMOJI), + ...KERNEL_EMOJI, SCRIPTED_EMOJI, COMPILED_EMOJI]; + const missing = shown.filter(e => !EMOJI_LABEL[e]); + assert.deepEqual(missing, [], `emoji shown with no tooltip: ${missing.join(" ")}`); +}); + +test("every emoji a module declares in tags() has a tooltip", () => { + // The modules are the moving half: a new effect adds a tag emoji, and nothing else would + // notice that the picker then shows a character no tooltip explains. + const out = execSync( + `grep -rho 'const char\\* tags() const override { return "[^"]*"' src/ || true`, + { encoding: "utf8", cwd: new URL("../..", import.meta.url).pathname }); + const declared = new Set(); + for (const line of out.split("\n")) { + const m = line.match(/return "([^"]*)"/); + if (m) for (const e of emojiIn(m[1])) declared.add(e); + } + assert.ok(declared.size > 10, `expected the module headers to declare emoji, found ${declared.size}`); + const missing = [...declared].filter(e => !EMOJI_LABEL[e]); + assert.deepEqual(missing, [], `declared in tags() with no tooltip: ${missing.join(" ")}`); +}); diff --git a/test/python/test_repo_health_measured_state.py b/test/python/test_repo_health_measured_state.py new file mode 100644 index 00000000..22e3b608 --- /dev/null +++ b/test/python/test_repo_health_measured_state.py @@ -0,0 +1,67 @@ +"""A snapshot reports what THIS run measured, never what an earlier one did. + +`MEASURED_THIS_RUN` and `MEASURED_DATES` are module-level, so a second `snapshot()` in the same +process would inherit the first's claims unless they are cleared: a target measured once and then +unavailable would keep reporting `Built: yes` and carry the first run's date, which is exactly the +false reassurance the freshness rule exists to prevent, moved one call later. + +The report's own vocabulary is pinned here too, because the labels are what a reader acts on: +`yes` measured now, `carried Nd` not rebuilt and N days old, `carried (age?)` not rebuilt and from +before dates were recorded, and `STALE` past the threshold. +""" + +import datetime as dt +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(ROOT / "moondeck" / "check")) + +import repo_health # noqa: E402 + + +def test_second_snapshot_does_not_inherit_the_first_runs_measurement(monkeypatch): + """A target measured once, then unmeasurable, must stop claiming it was built.""" + calls = {"n": 0} + + def fake_measure_flash(): + # First call measures a target the way the real one does; the second finds nothing + # (the binary is gone, or too old to count) and records neither. + calls["n"] += 1 + if calls["n"] == 1: + repo_health.MEASURED_THIS_RUN.add("esp32-pico") + repo_health.MEASURED_DATES["esp32-pico"] = "2020-01-01" + return {"esp32-pico": 1000} + return {} + + monkeypatch.setattr(repo_health, "measure_flash", fake_measure_flash) + for name in ("measure_loc", "measure_comments", "measure_tests", "measure_docs", + "measure_complexity"): + monkeypatch.setattr(repo_health, name, lambda: {}) + monkeypatch.setattr(repo_health, "_head", lambda: "deadbeef") + + first = repo_health.snapshot() + assert first["measured"]["esp32-pico"] == "2020-01-01" + assert repo_health._built_label("esp32-pico", first["measured"].get("esp32-pico")) == "yes" + + second = repo_health.snapshot() + assert "esp32-pico" not in second["measured"], "the date must not survive into a second run" + assert "esp32-pico" not in repo_health.MEASURED_THIS_RUN + # And the label now describes a carry rather than a measurement. + assert repo_health._built_label("esp32-pico", second["measured"].get("esp32-pico")) != "yes" + + +def test_built_labels_say_measured_carried_or_stale(): + """The four labels a reader acts on, including the undated carry.""" + repo_health.MEASURED_THIS_RUN.clear() + repo_health.MEASURED_THIS_RUN.add("fresh") + recent = (dt.date.today() - dt.timedelta(days=2)).isoformat() + old = (dt.date.today() - dt.timedelta(days=repo_health.STALE_AFTER_DAYS + 1)).isoformat() + + assert repo_health._built_label("fresh", recent) == "yes" + assert repo_health._built_label("other", recent) == "carried 2d" + assert "STALE" in repo_health._built_label("other", old) + # No date at all: honest about the gap rather than guessing one. + assert repo_health._built_label("other", None) == "carried (age?)" + assert repo_health._built_label("other", "not-a-date") == "carried (age?)" + repo_health.MEASURED_THIS_RUN.clear() diff --git a/test/scenario_runner.cpp b/test/scenario_runner.cpp index 93b25ef9..746da23a 100644 --- a/test/scenario_runner.cpp +++ b/test/scenario_runner.cpp @@ -26,6 +26,7 @@ #include "light/effects/FluidEffect.h" #include "light/effects/NebulaEffect.h" #include "light/effects/TrailsEffect.h" +#include "light/effects/ColorTrailsEffect.h" #include "light/effects/PolarNoiseEffect.h" #include "light/effects/SpiralEffect.h" #include "light/effects/RingsEffect.h" @@ -42,6 +43,9 @@ #include "core/SystemModule.h" #include "core/AudioService.h" #include "light/effects/AudioVolumeEffect.h" +#include "light/effects/RadialSpectrumEffect.h" +#include "light/effects/VuMetersEffect.h" +#include "light/effects/BeatRipplesEffect.h" #include "light/effects/AudioSpectrumEffect.h" #include "light/effects/GameOfLifeEffect.h" #include "light/effects/GEQ3DEffect.h" @@ -228,6 +232,7 @@ static void registerScenarioTypes() { mm::ModuleFactory::registerType("FluidEffect"); mm::ModuleFactory::registerType("NebulaEffect"); mm::ModuleFactory::registerType("TrailsEffect"); + mm::ModuleFactory::registerType("ColorTrailsEffect"); mm::ModuleFactory::registerType("PolarNoiseEffect"); mm::ModuleFactory::registerType("SpiralEffect"); mm::ModuleFactory::registerType("RingsEffect"); @@ -244,6 +249,9 @@ static void registerScenarioTypes() { mm::ModuleFactory::registerType("SystemModule"); mm::ModuleFactory::registerType("AudioService"); mm::ModuleFactory::registerType("AudioVolumeEffect"); + mm::ModuleFactory::registerType("RadialSpectrumEffect"); + mm::ModuleFactory::registerType("VuMetersEffect"); + mm::ModuleFactory::registerType("BeatRipplesEffect"); mm::ModuleFactory::registerType("AudioSpectrumEffect"); mm::ModuleFactory::registerType("GameOfLifeEffect"); mm::ModuleFactory::registerType("GEQ3DEffect"); diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index 445858c7..5de41c53 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -117,14 +117,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 134, - "p95": 246, + "p50": 144, + "p95": 248, "min": 117, - "max": 248, + "max": 302, "n": 32, - "samples": [126, 167, 130, 125, 123, 123, 129, 134, 203, 129, 133, 243, 248, 246, 127, 204, 143, 150, 150, 175, 205, 179, 191, 127, 202, 119, 121, 118, 120, 199, 193, 117] + "samples": [203, 129, 133, 243, 248, 246, 127, 204, 143, 150, 150, 175, 205, 179, 191, 127, 202, 119, 121, 118, 120, 199, 193, 117, 144, 302, 129, 118, 246, 120, 133, 122] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth-wifi": { "tick_us": { @@ -299,14 +299,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 132, - "p95": 238, + "p50": 134, + "p95": 247, "min": 117, - "max": 247, + "max": 260, "n": 32, - "samples": [127, 169, 131, 131, 128, 127, 127, 142, 205, 130, 134, 238, 247, 238, 126, 196, 146, 147, 159, 171, 203, 182, 135, 128, 132, 119, 120, 118, 118, 131, 134, 117] + "samples": [205, 130, 134, 238, 247, 238, 126, 196, 146, 147, 159, 171, 203, 182, 135, 128, 132, 119, 120, 118, 118, 131, 134, 117, 133, 135, 146, 121, 260, 123, 118, 120] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth-wifi": { "tick_us": { @@ -481,14 +481,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 130, + "p50": 126, "p95": 239, "min": 116, "max": 240, "n": 32, - "samples": [128, 168, 132, 132, 127, 127, 129, 135, 204, 130, 133, 240, 239, 238, 126, 186, 147, 148, 158, 173, 206, 180, 126, 128, 118, 121, 121, 118, 116, 116, 122, 117] + "samples": [204, 130, 133, 240, 239, 238, 126, 186, 147, 148, 158, 173, 206, 180, 126, 128, 118, 121, 121, 118, 116, 116, 122, 117, 122, 129, 122, 121, 152, 122, 120, 120] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth-wifi": { "tick_us": { @@ -671,14 +671,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 129, + "p50": 127, "p95": 247, "min": 117, "max": 261, "n": 32, - "samples": [128, 168, 130, 131, 128, 127, 127, 139, 205, 129, 131, 261, 247, 241, 127, 186, 148, 148, 157, 174, 192, 182, 127, 128, 120, 119, 120, 118, 118, 120, 123, 117] + "samples": [205, 129, 131, 261, 247, 241, 127, 186, 148, 148, 157, 174, 192, 182, 127, 128, 120, 119, 120, 118, 118, 120, 123, 117, 122, 141, 124, 121, 151, 122, 119, 121] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth-wifi": { "tick_us": { diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json index 5ad49d65..9c67b2ac 100644 --- a/test/scenarios/light/scenario_Audio_mutation.json +++ b/test/scenarios/light/scenario_Audio_mutation.json @@ -105,14 +105,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 21, - "p95": 61, + "p50": 20, + "p95": 46, "min": 16, - "max": 77, + "max": 48, "n": 32, - "samples": [17, 51, 77, 46, 34, 50, 44, 61, 36, 21, 21, 35, 21, 21, 21, 32, 24, 38, 46, 48, 27, 25, 16, 20, 16, 20, 20, 17, 17, 16, 16, 17] + "samples": [21, 35, 21, 21, 21, 32, 24, 38, 46, 48, 27, 25, 16, 20, 16, 20, 20, 17, 17, 16, 16, 17, 17, 17, 19, 26, 16, 17, 25, 19, 20, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -202,14 +202,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 33, - "p95": 202, + "p50": 26, + "p95": 70, "min": 17, - "max": 404, + "max": 137, "n": 32, - "samples": [23, 37, 202, 120, 48, 106, 40, 404, 41, 17, 25, 35, 34, 70, 30, 33, 26, 53, 66, 59, 35, 38, 18, 22, 17, 17, 19, 28, 27, 22, 17, 19] + "samples": [25, 35, 34, 70, 30, 33, 26, 53, 66, 59, 35, 38, 18, 22, 17, 17, 19, 28, 27, 22, 17, 19, 20, 37, 22, 28, 24, 137, 27, 17, 20, 19] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -316,14 +316,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 33, - "p95": 213, + "p50": 26, + "p95": 63, "min": 17, - "max": 413, + "max": 66, "n": 32, - "samples": [24, 33, 413, 58, 53, 66, 47, 213, 86, 17, 18, 32, 40, 66, 28, 33, 28, 57, 60, 63, 45, 44, 37, 20, 17, 18, 20, 19, 25, 18, 18, 19] + "samples": [18, 32, 40, 66, 28, 33, 28, 57, 60, 63, 45, 44, 37, 20, 17, 18, 20, 19, 25, 18, 18, 19, 19, 21, 26, 28, 30, 40, 32, 26, 18, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -413,14 +413,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 31, - "p95": 125, + "p50": 26, + "p95": 68, "min": 19, - "max": 184, + "max": 75, "n": 32, - "samples": [30, 42, 184, 69, 75, 85, 55, 125, 86, 19, 20, 38, 27, 66, 26, 36, 30, 62, 75, 68, 45, 45, 20, 23, 20, 21, 20, 21, 31, 20, 20, 21] + "samples": [20, 38, 27, 66, 26, 36, 30, 62, 75, 68, 45, 45, 20, 23, 20, 21, 20, 21, 31, 20, 20, 21, 19, 22, 24, 28, 31, 44, 29, 29, 21, 24] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -508,14 +508,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 32, - "p95": 82, + "p50": 23, + "p95": 69, "min": 19, - "max": 110, + "max": 80, "n": 32, - "samples": [26, 36, 82, 63, 54, 61, 39, 110, 42, 23, 21, 32, 33, 69, 25, 37, 28, 65, 67, 62, 41, 80, 19, 21, 21, 21, 20, 22, 27, 20, 21, 19] + "samples": [21, 32, 33, 69, 25, 37, 28, 65, 67, 62, 41, 80, 19, 21, 21, 21, 20, 22, 27, 20, 21, 19, 19, 21, 30, 28, 22, 29, 36, 19, 21, 23] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -603,14 +603,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 29, - "p95": 105, + "p50": 20, + "p95": 79, "min": 16, - "max": 142, + "max": 87, "n": 32, - "samples": [19, 34, 75, 105, 57, 83, 39, 142, 39, 17, 26, 29, 38, 87, 23, 32, 25, 52, 79, 57, 29, 33, 18, 26, 20, 19, 19, 17, 17, 17, 16, 17] + "samples": [26, 29, 38, 87, 23, 32, 25, 52, 79, 57, 29, 33, 18, 26, 20, 19, 19, 17, 17, 17, 16, 17, 17, 21, 17, 25, 17, 20, 24, 19, 17, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Aurora_fps.json b/test/scenarios/light/scenario_Aurora_fps.json index bbcd6fca..b848e2a8 100644 --- a/test/scenarios/light/scenario_Aurora_fps.json +++ b/test/scenarios/light/scenario_Aurora_fps.json @@ -84,14 +84,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 841, + "p50": 855, "p95": 1391, "min": 474, - "max": 1391, - "n": 15, - "samples": [478, 474, 650, 786, 803, 855, 835, 843, 890, 827, 841, 871, 845, 856, 1391] + "max": 1527, + "n": 23, + "samples": [478, 474, 650, 786, 803, 855, 835, 843, 890, 827, 841, 871, 845, 856, 1391, 863, 858, 885, 1527, 1317, 853, 859, 861] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -114,14 +114,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 317, - "p95": 355, + "p50": 318, + "p95": 449, "min": 207, - "max": 355, - "n": 15, - "samples": [207, 207, 208, 307, 311, 320, 316, 318, 317, 317, 321, 334, 312, 322, 355] + "max": 495, + "n": 23, + "samples": [207, 207, 208, 307, 311, 320, 316, 318, 317, 317, 321, 334, 312, 322, 355, 335, 322, 495, 395, 449, 317, 318, 317] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -145,13 +145,13 @@ "desktop-macos": { "tick_us": { "p50": 187, - "p95": 254, + "p95": 245, "min": 127, "max": 254, - "n": 15, - "samples": [127, 127, 128, 185, 183, 189, 187, 192, 186, 185, 188, 196, 187, 187, 254] + "n": 23, + "samples": [127, 127, 128, 185, 183, 189, 187, 192, 186, 185, 188, 196, 187, 187, 254, 187, 188, 222, 232, 245, 186, 188, 185] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -174,14 +174,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 544, - "p95": 581, + "p50": 551, + "p95": 702, "min": 303, - "max": 581, - "n": 15, - "samples": [304, 303, 306, 530, 532, 554, 540, 544, 551, 551, 554, 581, 543, 554, 560] + "max": 720, + "n": 23, + "samples": [304, 303, 306, 530, 532, 554, 540, 544, 551, 551, 554, 581, 543, 554, 560, 550, 553, 567, 702, 720, 550, 545, 552] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -204,14 +204,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 937, - "p95": 1236, + "p50": 950, + "p95": 1158, "min": 474, "max": 1236, - "n": 15, - "samples": [474, 474, 480, 921, 919, 974, 933, 936, 938, 945, 964, 997, 937, 956, 1236] + "n": 23, + "samples": [474, 474, 480, 921, 919, 974, 933, 936, 938, 945, 964, 997, 937, 956, 1236, 950, 953, 952, 1158, 1152, 946, 1029, 970] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -234,14 +234,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1455, - "p95": 2116, + "p50": 1472, + "p95": 1871, "min": 785, "max": 2116, - "n": 14, - "samples": [937, 785, 1419, 1423, 1465, 1430, 1562, 1455, 1458, 1483, 2116, 1446, 1497, 1493] + "n": 22, + "samples": [937, 785, 1419, 1423, 1465, 1430, 1562, 1455, 1458, 1483, 2116, 1446, 1497, 1493, 1472, 1467, 1490, 1853, 1666, 1473, 1474, 1871] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -264,14 +264,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1498, - "p95": 1920, + "p50": 1514, + "p95": 1828, "min": 507, "max": 1920, - "n": 15, - "samples": [507, 814, 821, 1496, 1511, 1506, 1491, 1494, 1498, 1506, 1574, 1920, 1494, 1564, 1543] + "n": 23, + "samples": [507, 814, 821, 1496, 1511, 1506, 1491, 1494, 1498, 1506, 1574, 1920, 1494, 1564, 1543, 1622, 1526, 1556, 1761, 1695, 1534, 1514, 1828] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -294,14 +294,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1103, + "p50": 1117, "p95": 1211, "min": 333, - "max": 1211, - "n": 15, - "samples": [333, 643, 658, 1103, 1079, 1108, 1092, 1096, 1104, 1118, 1165, 1211, 1103, 1135, 1128] + "max": 1303, + "n": 23, + "samples": [333, 643, 658, 1103, 1079, 1108, 1092, 1096, 1104, 1118, 1165, 1211, 1103, 1135, 1128, 1137, 1120, 1131, 1303, 1210, 1117, 1118, 1105] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } } diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json index 013c66c4..61617d88 100644 --- a/test/scenarios/light/scenario_Driver_mutation.json +++ b/test/scenarios/light/scenario_Driver_mutation.json @@ -76,14 +76,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 32, - "p95": 590, - "min": 17, - "max": 1843, + "p50": 20, + "p95": 99, + "min": 16, + "max": 147, "n": 32, - "samples": [25, 32, 590, 105, 45, 51, 43, 1843, 56, 18, 99, 32, 36, 147, 25, 37, 25, 51, 73, 88, 61, 43, 17, 21, 17, 20, 20, 17, 19, 20, 19, 18] + "samples": [56, 18, 99, 32, 36, 147, 25, 37, 25, 51, 73, 88, 61, 43, 17, 21, 17, 20, 20, 17, 19, 20, 19, 18, 21, 16, 19, 19, 17, 19, 18, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -173,14 +173,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 32, - "p95": 298, - "min": 17, - "max": 421, + "p50": 20, + "p95": 68, + "min": 16, + "max": 73, "n": 32, - "samples": [25, 47, 421, 126, 48, 54, 39, 298, 68, 19, 25, 37, 51, 73, 26, 32, 25, 47, 65, 59, 59, 45, 17, 22, 17, 20, 20, 17, 27, 20, 20, 20] + "samples": [68, 19, 25, 37, 51, 73, 26, 32, 25, 47, 65, 59, 59, 45, 17, 22, 17, 20, 20, 17, 27, 20, 20, 20, 23, 16, 20, 20, 18, 20, 20, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -270,14 +270,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 32, - "p95": 257, - "min": 18, - "max": 339, + "p50": 21, + "p95": 64, + "min": 16, + "max": 67, "n": 32, - "samples": [29, 32, 257, 339, 46, 53, 38, 222, 67, 25, 24, 36, 37, 61, 25, 32, 25, 48, 64, 63, 52, 46, 21, 18, 20, 20, 20, 20, 24, 20, 20, 21] + "samples": [67, 25, 24, 36, 37, 61, 25, 32, 25, 48, 64, 63, 52, 46, 21, 18, 20, 20, 20, 20, 24, 20, 20, 21, 17, 16, 20, 19, 17, 20, 20, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -365,14 +365,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 33, - "p95": 120, + "p50": 20, + "p95": 83, "min": 17, "max": 173, "n": 32, - "samples": [33, 50, 106, 114, 52, 64, 50, 120, 83, 20, 25, 33, 25, 49, 31, 38, 27, 55, 173, 76, 54, 47, 20, 24, 20, 20, 20, 17, 19, 20, 20, 17] + "samples": [83, 20, 25, 33, 25, 49, 31, 38, 27, 55, 173, 76, 54, 47, 20, 24, 20, 20, 20, 17, 19, 20, 20, 17, 19, 20, 20, 19, 21, 19, 20, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -460,14 +460,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 29, - "p95": 267, + "p50": 20, + "p95": 66, "min": 17, "max": 316, "n": 32, - "samples": [27, 68, 135, 87, 47, 74, 136, 267, 50, 20, 20, 32, 29, 62, 28, 35, 26, 59, 316, 66, 36, 52, 21, 21, 20, 20, 20, 17, 23, 20, 20, 19] + "samples": [50, 20, 20, 32, 29, 62, 28, 35, 26, 59, 316, 66, 36, 52, 21, 21, 20, 20, 20, 17, 23, 20, 20, 19, 20, 20, 20, 19, 17, 19, 19, 19] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_composition.json b/test/scenarios/light/scenario_Effects_composition.json index 37548a4f..d9ecc176 100644 --- a/test/scenarios/light/scenario_Effects_composition.json +++ b/test/scenarios/light/scenario_Effects_composition.json @@ -106,14 +106,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 370, - "p95": 1253, + "p50": 169, + "p95": 768, "min": 142, - "max": 1335, + "max": 1044, "n": 32, - "samples": [253, 499, 1335, 697, 573, 781, 604, 1253, 646, 253, 263, 426, 343, 1044, 366, 485, 370, 731, 711, 768, 496, 497, 248, 248, 146, 142, 146, 152, 145, 146, 148, 144] + "samples": [646, 253, 263, 426, 343, 1044, 366, 485, 370, 731, 711, 768, 496, 497, 248, 248, 146, 142, 146, 152, 145, 146, 148, 144, 145, 147, 148, 164, 169, 144, 147, 144] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Fields_polar_lut.json b/test/scenarios/light/scenario_Fields_polar_lut.json index 1ec112a3..610c6cab 100644 --- a/test/scenarios/light/scenario_Fields_polar_lut.json +++ b/test/scenarios/light/scenario_Fields_polar_lut.json @@ -85,14 +85,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 289, - "p95": 387, + "p50": 290, + "p95": 384, "min": 189, "max": 387, - "n": 16, - "samples": [289, 189, 281, 384, 274, 387, 283, 278, 281, 289, 325, 292, 302, 291, 286, 293] + "n": 24, + "samples": [289, 189, 281, 384, 274, 387, 283, 278, 281, 289, 325, 292, 302, 291, 286, 293, 285, 287, 294, 337, 332, 293, 290, 300] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -115,14 +115,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 281, - "p95": 313, + "p50": 285, + "p95": 320, "min": 183, - "max": 313, - "n": 16, - "samples": [183, 189, 194, 279, 273, 275, 284, 281, 281, 313, 297, 290, 297, 283, 288, 291] + "max": 340, + "n": 24, + "samples": [183, 189, 194, 279, 273, 275, 284, 281, 281, 313, 297, 290, 297, 283, 288, 291, 285, 290, 293, 340, 320, 284, 287, 285] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -145,14 +145,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 282, - "p95": 303, + "p50": 284, + "p95": 317, "min": 168, - "max": 303, - "n": 16, - "samples": [168, 180, 177, 279, 274, 274, 282, 281, 282, 288, 289, 289, 303, 284, 286, 289] + "max": 337, + "n": 24, + "samples": [168, 180, 177, 279, 274, 274, 282, 281, 282, 288, 289, 289, 303, 284, 286, 289, 284, 309, 284, 337, 317, 285, 283, 284] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -175,14 +175,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 281, - "p95": 301, + "p50": 284, + "p95": 339, "min": 168, - "max": 301, - "n": 16, - "samples": [168, 175, 179, 278, 274, 273, 284, 277, 282, 285, 286, 286, 301, 281, 287, 290] + "max": 347, + "n": 24, + "samples": [168, 175, 179, 278, 274, 273, 284, 277, 282, 285, 286, 286, 301, 281, 287, 290, 288, 347, 283, 339, 306, 284, 293, 285] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -205,14 +205,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 140, - "p95": 147, + "p50": 141, + "p95": 155, "min": 83, - "max": 147, - "n": 16, - "samples": [83, 86, 88, 138, 137, 137, 142, 140, 138, 144, 141, 144, 147, 141, 144, 143] + "max": 166, + "n": 24, + "samples": [83, 86, 88, 138, 137, 137, 142, 140, 138, 144, 141, 144, 147, 141, 144, 143, 143, 155, 141, 166, 153, 141, 140, 140] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -235,14 +235,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 420, - "p95": 501, + "p50": 422, + "p95": 503, "min": 250, - "max": 501, - "n": 16, - "samples": [250, 257, 264, 422, 413, 410, 501, 413, 417, 422, 421, 428, 444, 420, 451, 442] + "max": 512, + "n": 24, + "samples": [250, 257, 264, 422, 413, 410, 501, 413, 417, 422, 421, 428, 444, 420, 451, 442, 503, 433, 512, 502, 461, 424, 421, 425] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -271,14 +271,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1218, - "p95": 1311, + "p50": 1234, + "p95": 1402, "min": 960, - "max": 1311, - "n": 16, - "samples": [960, 970, 981, 1200, 1187, 1206, 1234, 1239, 1206, 1218, 1228, 1239, 1298, 1269, 1240, 1311] + "max": 1892, + "n": 24, + "samples": [960, 970, 981, 1200, 1187, 1206, 1234, 1239, 1206, 1218, 1228, 1239, 1298, 1269, 1240, 1311, 1232, 1236, 1892, 1402, 1341, 1261, 1229, 1250] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -302,13 +302,13 @@ "desktop-macos": { "tick_us": { "p50": 480, - "p95": 604, + "p95": 590, "min": 460, "max": 604, - "n": 16, - "samples": [560, 561, 561, 479, 460, 590, 474, 479, 476, 472, 473, 480, 506, 482, 604, 488] + "n": 24, + "samples": [560, 561, 561, 479, 460, 590, 474, 479, 476, 472, 473, 480, 506, 482, 604, 488, 475, 481, 491, 538, 530, 477, 475, 477] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } } diff --git a/test/scenarios/light/scenario_Fluid_solver.json b/test/scenarios/light/scenario_Fluid_solver.json index 75b022f5..16ea02bd 100644 --- a/test/scenarios/light/scenario_Fluid_solver.json +++ b/test/scenarios/light/scenario_Fluid_solver.json @@ -82,10 +82,10 @@ "p95": 73, "min": 29, "max": 73, - "n": 10, - "samples": [30, 30, 30, 31, 30, 31, 29, 31, 73, 30] + "n": 18, + "samples": [30, 30, 30, 31, 30, 31, 29, 31, 73, 30, 29, 29, 30, 33, 32, 29, 29, 29] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -104,10 +104,10 @@ "p95": 37, "min": 19, "max": 37, - "n": 10, - "samples": [20, 20, 20, 19, 19, 19, 20, 19, 37, 19] + "n": 18, + "samples": [20, 20, 20, 19, 19, 19, 20, 19, 37, 19, 19, 19, 20, 21, 21, 19, 19, 19] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -126,10 +126,10 @@ "p95": 101, "min": 66, "max": 101, - "n": 10, - "samples": [69, 69, 68, 70, 68, 68, 66, 68, 101, 68] + "n": 18, + "samples": [69, 69, 68, 70, 68, 68, 66, 68, 101, 68, 67, 67, 70, 76, 75, 67, 67, 67] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -148,10 +148,10 @@ "p95": 37, "min": 29, "max": 37, - "n": 10, - "samples": [30, 31, 30, 30, 29, 29, 29, 29, 37, 29] + "n": 18, + "samples": [30, 31, 30, 30, 29, 29, 29, 29, 37, 29, 29, 29, 30, 33, 33, 29, 29, 29] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -166,14 +166,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 65, + "p50": 64, "p95": 74, - "min": 63, + "min": 62, "max": 74, - "n": 10, - "samples": [64, 65, 65, 66, 66, 64, 64, 63, 74, 66] + "n": 18, + "samples": [64, 65, 65, 66, 66, 64, 64, 63, 74, 66, 63, 62, 66, 71, 70, 63, 62, 62] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -187,14 +187,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 133, - "p95": 138, - "min": 130, - "max": 138, - "n": 10, - "samples": [133, 134, 138, 136, 134, 131, 130, 132, 133, 134] + "p50": 132, + "p95": 145, + "min": 127, + "max": 145, + "n": 18, + "samples": [133, 134, 138, 136, 134, 131, 130, 132, 133, 134, 129, 128, 131, 145, 145, 131, 127, 129] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } }, "description": "And the height, making it 64x64: four times the cells of the 32x32 the pair started from. Reallocating on each axis separately is the shape a UI resize actually takes." @@ -210,14 +210,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 133, - "p95": 140, - "min": 131, - "max": 140, - "n": 10, - "samples": [133, 140, 137, 136, 135, 131, 132, 131, 131, 134] + "p50": 132, + "p95": 145, + "min": 129, + "max": 145, + "n": 18, + "samples": [133, 140, 137, 136, 135, 131, 132, 131, 131, 134, 130, 138, 129, 145, 145, 131, 129, 132] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -232,14 +232,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 134, + "p50": 131, "p95": 147, - "min": 129, + "min": 127, "max": 147, - "n": 10, - "samples": [134, 141, 138, 135, 147, 134, 130, 131, 129, 135] + "n": 18, + "samples": [134, 141, 138, 135, 147, 134, 130, 131, 129, 135, 129, 129, 129, 146, 145, 129, 128, 127] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -258,10 +258,10 @@ "p95": 40, "min": 35, "max": 40, - "n": 9, - "samples": [39, 38, 36, 40, 36, 35, 36, 35, 37] + "n": 17, + "samples": [39, 38, 36, 40, 36, 35, 36, 35, 37, 35, 35, 35, 40, 40, 35, 35, 35] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -277,12 +277,12 @@ "tick_us": { "p50": 11, "p95": 12, - "min": 11, + "min": 10, "max": 12, - "n": 9, - "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11] + "n": 17, + "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 11, 11, 10] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -296,14 +296,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 222, + "p50": 220, "p95": 249, - "min": 219, + "min": 215, "max": 249, - "n": 9, - "samples": [249, 232, 229, 220, 220, 220, 222, 219, 227] + "n": 17, + "samples": [249, 232, 229, 220, 220, 220, 222, 219, 227, 217, 226, 219, 249, 247, 217, 218, 215] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -320,12 +320,12 @@ "tick_us": { "p50": 11, "p95": 12, - "min": 11, + "min": 10, "max": 12, - "n": 9, - "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11] + "n": 17, + "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 12, 12, 11, 11, 11] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -340,14 +340,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 8, + "p50": 9, "p95": 29, "min": 8, "max": 29, - "n": 10, - "samples": [29, 9, 9, 9, 8, 8, 8, 8, 8, 9] + "n": 18, + "samples": [29, 9, 9, 9, 8, 8, 8, 8, 8, 9, 8, 9, 8, 9, 10, 8, 9, 9] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -365,10 +365,10 @@ "p95": 8, "min": 6, "max": 8, - "n": 10, - "samples": [7, 7, 7, 7, 6, 7, 6, 7, 8, 8] + "n": 18, + "samples": [7, 7, 7, 7, 6, 7, 6, 7, 8, 8, 7, 7, 6, 8, 7, 6, 7, 7] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } } diff --git a/test/scenarios/light/scenario_GridBlacks_blackpixel.json b/test/scenarios/light/scenario_GridBlacks_blackpixel.json index 4ead1ffd..6d0258b9 100644 --- a/test/scenarios/light/scenario_GridBlacks_blackpixel.json +++ b/test/scenarios/light/scenario_GridBlacks_blackpixel.json @@ -90,14 +90,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 15, + "p50": 2, + "p95": 12, "min": 1, "max": 27, "n": 32, - "samples": [10, 15, 5, 7, 2, 2, 5, 9, 8, 6, 7, 27, 12, 6, 3, 11, 4, 5, 7, 7, 5, 5, 2, 2, 1, 2, 1, 1, 1, 1, 1, 1] + "samples": [8, 6, 7, 27, 12, 6, 3, 11, 4, 5, 7, 7, 5, 5, 2, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -193,14 +193,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 7, - "p95": 25, + "p50": 2, + "p95": 16, "min": 2, - "max": 73, + "max": 25, "n": 32, - "samples": [73, 10, 7, 10, 3, 3, 7, 14, 10, 8, 10, 15, 16, 8, 5, 25, 5, 5, 10, 10, 7, 7, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2] + "samples": [10, 8, 10, 15, 16, 8, 5, 25, 5, 5, 10, 10, 7, 7, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index f4e9208d..f3d197ac 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -117,14 +117,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 128, + "p50": 127, "p95": 311, "min": 117, "max": 316, "n": 32, - "samples": [170, 130, 126, 220, 127, 128, 129, 126, 157, 244, 128, 127, 241, 280, 306, 311, 167, 316, 183, 292, 249, 240, 128, 126, 120, 118, 120, 128, 117, 119, 122, 117] + "samples": [157, 244, 128, 127, 241, 280, 306, 311, 167, 316, 183, 292, 249, 240, 128, 126, 120, 118, 120, 128, 117, 119, 122, 117, 121, 120, 124, 125, 126, 119, 189, 124] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth-wifi": { "tick_us": { @@ -299,14 +299,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 68, + "p50": 66, "p95": 145, "min": 59, "max": 161, "n": 32, - "samples": [141, 68, 68, 108, 68, 68, 69, 69, 74, 135, 66, 63, 132, 145, 118, 161, 84, 144, 90, 126, 125, 118, 68, 68, 65, 66, 64, 61, 59, 64, 65, 59] + "samples": [74, 135, 66, 63, 132, 145, 118, 161, 84, 144, 90, 126, 125, 118, 68, 68, 65, 66, 64, 61, 59, 64, 65, 59, 65, 65, 66, 62, 63, 64, 60, 64] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth-wifi": { "tick_us": { @@ -481,14 +481,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 129, + "p50": 126, "p95": 298, "min": 116, "max": 309, "n": 32, - "samples": [164, 132, 129, 204, 127, 127, 129, 128, 149, 309, 129, 125, 247, 283, 243, 298, 166, 249, 182, 209, 247, 240, 126, 127, 120, 123, 121, 123, 116, 120, 122, 119] + "samples": [149, 309, 129, 125, 247, 283, 243, 298, 166, 249, 182, 209, 247, 240, 126, 127, 120, 123, 121, 123, 116, 120, 122, 119, 121, 120, 122, 126, 127, 120, 120, 142] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth-wifi": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json index 00ba12f9..dd486fe4 100644 --- a/test/scenarios/light/scenario_Layer_base_pipeline.json +++ b/test/scenarios/light/scenario_Layer_base_pipeline.json @@ -83,14 +83,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 94, - "p95": 211, + "p50": 71, + "p95": 179, "min": 64, - "max": 245, + "max": 197, "n": 32, - "samples": [64, 68, 199, 211, 145, 245, 127, 205, 143, 73, 68, 131, 86, 123, 94, 122, 107, 179, 178, 197, 123, 125, 71, 65, 72, 70, 69, 66, 64, 66, 66, 64] + "samples": [143, 73, 68, 131, 86, 123, 94, 122, 107, 179, 178, 197, 123, 125, 71, 65, 72, 70, 69, 66, 64, 66, 66, 64, 71, 70, 70, 68, 69, 70, 67, 139] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json index 75957708..2c8f4130 100644 --- a/test/scenarios/light/scenario_Layer_memory_1to1.json +++ b/test/scenarios/light/scenario_Layer_memory_1to1.json @@ -80,14 +80,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 10, - "p95": 41, + "p50": 5, + "p95": 40, "min": 5, - "max": 229, + "max": 41, "n": 32, - "samples": [6, 229, 32, 21, 6, 11, 19, 12, 18, 11, 11, 10, 40, 14, 7, 10, 7, 9, 41, 27, 10, 11, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5] + "samples": [18, 11, 11, 10, 40, 14, 7, 10, 7, 9, 41, 27, 10, 11, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index 4f34d667..ca7be306 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -78,14 +78,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 29, - "p95": 79, + "p50": 17, + "p95": 50, "min": 16, - "max": 354, + "max": 61, "n": 32, - "samples": [22, 37, 354, 44, 36, 48, 36, 79, 38, 17, 17, 33, 23, 35, 25, 32, 29, 48, 50, 61, 32, 32, 16, 16, 17, 17, 16, 17, 17, 16, 16, 17] + "samples": [38, 17, 17, 33, 23, 35, 25, 32, 29, 48, 50, 61, 32, 32, 16, 16, 17, 17, 16, 17, 17, 16, 16, 17, 16, 16, 17, 18, 18, 16, 17, 16] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -206,14 +206,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 75, - "p95": 180, + "p50": 49, + "p95": 126, "min": 43, - "max": 633, + "max": 126, "n": 32, - "samples": [51, 93, 633, 140, 100, 126, 85, 180, 105, 51, 71, 90, 61, 103, 70, 87, 75, 123, 126, 126, 86, 86, 48, 47, 48, 44, 46, 49, 45, 43, 48, 44] + "samples": [105, 51, 71, 90, 61, 103, 70, 87, 75, 123, 126, 126, 86, 86, 48, 47, 48, 44, 46, 49, 45, 43, 48, 44, 48, 49, 48, 46, 49, 46, 50, 48] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -329,14 +329,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 156, - "p95": 381, + "p50": 96, + "p95": 248, "min": 88, - "max": 554, + "max": 248, "n": 32, - "samples": [93, 207, 554, 381, 202, 258, 187, 294, 200, 96, 100, 167, 119, 174, 130, 171, 138, 245, 248, 248, 170, 169, 92, 93, 156, 93, 93, 97, 88, 92, 94, 88] + "samples": [200, 96, 100, 167, 119, 174, 130, 171, 138, 245, 248, 248, 170, 169, 92, 93, 156, 93, 93, 97, 88, 92, 94, 88, 121, 93, 93, 96, 95, 93, 95, 96] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -451,14 +451,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 27, - "p95": 68, + "p50": 20, + "p95": 47, "min": 16, - "max": 293, + "max": 48, "n": 32, - "samples": [21, 61, 64, 293, 42, 55, 32, 68, 38, 21, 16, 31, 27, 36, 24, 32, 25, 47, 47, 48, 33, 31, 20, 20, 17, 20, 20, 21, 17, 20, 20, 17] + "samples": [38, 21, 16, 31, 27, 36, 24, 32, 25, 47, 47, 48, 33, 31, 20, 20, 17, 20, 20, 21, 17, 20, 20, 17, 20, 20, 18, 18, 18, 20, 20, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index ac74bc3d..9d527e71 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -88,14 +88,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 71, + "p50": 6, + "p95": 22, "min": 5, - "max": 93, + "max": 28, "n": 32, - "samples": [15, 5, 5, 93, 14, 71, 13, 12, 12, 22, 11, 15, 8, 12, 9, 14, 28, 18, 10, 10, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5] + "samples": [12, 22, 11, 15, 8, 12, 9, 14, 28, 18, 10, 10, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 6, 6, 5, 5, 6, 5, 6, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -206,14 +206,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 25, + "p50": 5, + "p95": 24, "min": 5, "max": 34, "n": 32, - "samples": [12, 5, 8, 20, 9, 25, 12, 21, 14, 34, 13, 18, 9, 12, 9, 16, 24, 17, 11, 10, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [14, 34, 13, 18, 9, 12, 9, 16, 24, 17, 11, 10, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 5, 5, 5, 6, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -316,14 +316,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 28, + "p50": 5, + "p95": 19, "min": 5, - "max": 33, + "max": 28, "n": 32, - "samples": [33, 5, 6, 18, 11, 15, 13, 12, 19, 18, 15, 19, 9, 10, 9, 17, 28, 17, 11, 10, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6] + "samples": [19, 18, 15, 19, 9, 10, 9, 17, 28, 17, 11, 10, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 15, 5, 5, 5, 6, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -426,14 +426,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 10, - "p95": 29, - "min": 5, - "max": 31, + "p50": 5, + "p95": 21, + "min": 4, + "max": 29, "n": 32, - "samples": [31, 5, 5, 20, 16, 13, 23, 18, 21, 15, 15, 14, 9, 10, 15, 15, 29, 19, 16, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [21, 15, 15, 14, 9, 10, 15, 15, 29, 19, 16, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 4, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -529,14 +529,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 11, - "p95": 41, + "p50": 5, + "p95": 20, "min": 5, - "max": 60, + "max": 26, "n": 32, - "samples": [41, 5, 5, 24, 14, 60, 12, 17, 11, 20, 12, 14, 8, 11, 12, 14, 26, 20, 11, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [11, 20, 12, 14, 8, 11, 12, 14, 26, 20, 11, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -632,14 +632,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 10, - "p95": 28, + "p50": 5, + "p95": 24, "min": 5, - "max": 29, + "max": 26, "n": 32, - "samples": [28, 5, 5, 22, 16, 27, 29, 24, 15, 24, 11, 22, 7, 10, 12, 16, 26, 18, 11, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 5, 5] + "samples": [15, 24, 11, 22, 7, 10, 12, 16, 26, 18, 11, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 5, 5, 8, 5, 5, 5, 6, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -736,13 +736,13 @@ "desktop-macos": { "tick_us": { "p50": 5, - "p95": 5, + "p95": 8, "min": 5, - "max": 5, - "n": 3, - "samples": [5, 5, 5] + "max": 8, + "n": 11, + "samples": [5, 5, 5, 8, 5, 5, 7, 5, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -758,13 +758,13 @@ "desktop-macos": { "tick_us": { "p50": 5, - "p95": 5, + "p95": 6, "min": 5, - "max": 5, - "n": 3, - "samples": [5, 5, 5] + "max": 6, + "n": 11, + "samples": [5, 5, 5, 6, 5, 5, 6, 6, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -780,13 +780,13 @@ "desktop-macos": { "tick_us": { "p50": 5, - "p95": 5, + "p95": 6, "min": 5, - "max": 5, - "n": 3, - "samples": [5, 5, 5] + "max": 6, + "n": 11, + "samples": [5, 5, 5, 6, 5, 6, 6, 6, 5, 6, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -799,14 +799,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 24, + "p50": 5, + "p95": 21, "min": 5, - "max": 64, + "max": 22, "n": 32, - "samples": [64, 5, 6, 24, 9, 21, 14, 24, 12, 20, 17, 22, 7, 11, 14, 18, 21, 18, 11, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [12, 20, 17, 22, 7, 11, 14, 18, 21, 18, 11, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -902,14 +902,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 8, + "p50": 5, "p95": 29, "min": 5, "max": 32, "n": 32, - "samples": [27, 6, 5, 17, 8, 12, 15, 29, 12, 32, 13, 24, 8, 10, 15, 15, 29, 18, 10, 13, 5, 5, 6, 5, 5, 5, 5, 6, 5, 5, 5, 5] + "samples": [12, 32, 13, 24, 8, 10, 15, 15, 29, 18, 10, 13, 5, 5, 6, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1005,14 +1005,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 8, + "p50": 5, "p95": 31, "min": 5, "max": 37, "n": 32, - "samples": [12, 7, 5, 14, 8, 15, 11, 29, 16, 37, 10, 31, 7, 9, 15, 14, 21, 22, 12, 10, 5, 6, 5, 5, 6, 5, 6, 6, 5, 5, 5, 5] + "samples": [16, 37, 10, 31, 7, 9, 15, 14, 21, 22, 12, 10, 5, 6, 5, 5, 6, 5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32": { "tick_us": { diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index 0be5f09b..b3c5d08a 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -374,14 +374,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 11, - "p95": 185, + "p50": 5, + "p95": 47, "min": 5, - "max": 267, + "max": 54, "n": 32, - "samples": [6, 267, 185, 26, 70, 5, 5, 46, 54, 25, 10, 25, 19, 20, 13, 30, 7, 12, 47, 17, 14, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [54, 25, 10, 25, 19, 20, 13, 30, 7, 12, 47, 17, 14, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -531,14 +531,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 97, - "min": 4, - "max": 559, + "p50": 6, + "p95": 21, + "min": 5, + "max": 32, "n": 32, - "samples": [6, 32, 559, 17, 97, 5, 4, 25, 18, 19, 11, 19, 20, 15, 12, 32, 7, 9, 14, 21, 10, 9, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5] + "samples": [18, 19, 11, 19, 20, 15, 12, 32, 7, 9, 14, 21, 10, 9, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -682,14 +682,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 10, - "p95": 159, + "p50": 6, + "p95": 20, "min": 5, - "max": 520, + "max": 21, "n": 32, - "samples": [6, 520, 159, 26, 10, 6, 5, 36, 13, 11, 12, 12, 21, 19, 20, 17, 8, 8, 17, 18, 10, 10, 5, 6, 5, 6, 5, 5, 6, 5, 5, 5] + "samples": [13, 11, 12, 12, 21, 19, 20, 17, 8, 8, 17, 18, 10, 10, 5, 6, 5, 6, 5, 5, 6, 5, 5, 5, 6, 5, 6, 5, 5, 5, 6, 8] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -984,14 +984,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 10, - "p95": 34, + "p50": 5, + "p95": 24, "min": 5, - "max": 117, + "max": 25, "n": 32, - "samples": [6, 117, 34, 27, 32, 6, 6, 13, 8, 20, 13, 18, 20, 21, 24, 25, 8, 10, 10, 18, 10, 10, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5] + "samples": [8, 20, 13, 18, 20, 21, 24, 25, 8, 10, 10, 18, 10, 10, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1126,14 +1126,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 10, - "p95": 96, + "p50": 5, + "p95": 24, "min": 5, "max": 397, "n": 32, - "samples": [6, 96, 35, 26, 19, 7, 5, 10, 22, 397, 12, 17, 18, 22, 24, 11, 8, 9, 13, 24, 10, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [22, 397, 12, 17, 18, 22, 24, 11, 8, 9, 13, 24, 10, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json index f777e88b..fafbd037 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json +++ b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json @@ -89,14 +89,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 165, + "p50": 3, + "p95": 21, "min": 2, "max": 241, "n": 32, - "samples": [3, 165, 15, 12, 3, 3, 9, 132, 241, 7, 8, 18, 17, 13, 4, 19, 4, 5, 21, 14, 6, 6, 3, 3, 3, 2, 3, 3, 3, 2, 2, 3] + "samples": [241, 7, 8, 18, 17, 13, 4, 19, 4, 5, 21, 14, 6, 6, 3, 3, 3, 2, 3, 3, 3, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json index 57880f0b..d3d25541 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json +++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json @@ -94,9 +94,9 @@ "min": 117, "max": 302, "n": 32, - "samples": [126, 125, 126, 124, 235, 127, 129, 124, 126, 125, 150, 127, 128, 238, 283, 277, 174, 302, 182, 214, 235, 240, 127, 126, 118, 119, 117, 121, 117, 119, 119, 118] + "samples": [126, 125, 150, 127, 128, 238, 283, 277, 174, 302, 182, 214, 235, 240, 127, 126, 118, 119, 117, 121, 117, 119, 119, 118, 122, 119, 121, 127, 129, 119, 121, 119] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Trails_ladder.json b/test/scenarios/light/scenario_Trails_ladder.json index 8988d142..a1c85024 100644 --- a/test/scenarios/light/scenario_Trails_ladder.json +++ b/test/scenarios/light/scenario_Trails_ladder.json @@ -90,10 +90,10 @@ "p95": 13, "min": 10, "max": 13, - "n": 10, - "samples": [11, 10, 10, 10, 11, 11, 11, 11, 11, 13] + "n": 18, + "samples": [11, 10, 10, 10, 11, 11, 11, 11, 11, 13, 12, 11, 11, 13, 13, 11, 12, 11] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -127,10 +127,10 @@ "p95": 57, "min": 40, "max": 57, - "n": 10, - "samples": [57, 40, 42, 41, 45, 45, 45, 45, 45, 47] + "n": 18, + "samples": [57, 40, 42, 41, 45, 45, 45, 45, 45, 47, 46, 46, 49, 50, 53, 45, 44, 46] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -164,10 +164,10 @@ "p95": 208, "min": 160, "max": 208, - "n": 10, - "samples": [208, 160, 166, 165, 182, 178, 180, 184, 180, 183] + "n": 18, + "samples": [208, 160, 166, 165, 182, 178, 180, 184, 180, 183, 186, 179, 187, 200, 202, 178, 178, 178] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -204,14 +204,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 348, - "p95": 358, + "p50": 349, + "p95": 396, "min": 313, - "max": 358, - "n": 10, - "samples": [323, 313, 331, 324, 358, 348, 353, 357, 352, 357] + "max": 396, + "n": 18, + "samples": [323, 313, 331, 324, 358, 348, 353, 357, 352, 357, 361, 351, 349, 396, 395, 347, 349, 348] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -234,14 +234,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 351, + "p50": 355, "p95": 608, "min": 312, "max": 608, - "n": 10, - "samples": [321, 312, 334, 332, 442, 353, 358, 354, 351, 608] + "n": 18, + "samples": [321, 312, 334, 332, 442, 353, 358, 354, 351, 608, 364, 362, 373, 423, 414, 354, 360, 355] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -265,13 +265,13 @@ "desktop-macos": { "tick_us": { "p50": 351, - "p95": 370, + "p95": 451, "min": 312, - "max": 370, - "n": 10, - "samples": [319, 312, 336, 350, 359, 351, 354, 354, 365, 370] + "max": 451, + "n": 18, + "samples": [319, 312, 336, 350, 359, 351, 354, 354, 365, 370, 365, 351, 348, 451, 413, 350, 350, 349] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } }, @@ -308,14 +308,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 178, - "p95": 189, + "p50": 180, + "p95": 250, "min": 164, - "max": 189, - "n": 10, - "samples": [164, 169, 169, 166, 183, 178, 180, 182, 189, 186] + "max": 250, + "n": 18, + "samples": [164, 169, 169, 166, 183, 178, 180, 182, 189, 186, 185, 180, 183, 250, 210, 177, 177, 181] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" } } } diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json index 18f0c87e..c0275ae3 100644 --- a/test/scenarios/light/scenario_modifier_chain.json +++ b/test/scenarios/light/scenario_modifier_chain.json @@ -101,14 +101,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 16, - "p95": 86, + "p50": 10, + "p95": 32, "min": 8, - "max": 163, + "max": 43, "n": 32, - "samples": [163, 17, 36, 86, 20, 23, 20, 28, 31, 8, 9, 16, 14, 17, 12, 18, 15, 24, 32, 43, 17, 16, 8, 8, 8, 8, 8, 10, 9, 8, 9, 8] + "samples": [31, 8, 9, 16, 14, 17, 12, 18, 15, 24, 32, 43, 17, 16, 8, 8, 8, 8, 8, 10, 9, 8, 9, 8, 12, 10, 10, 24, 10, 8, 10, 10] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -161,14 +161,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 14, - "p95": 49, + "p50": 9, + "p95": 36, "min": 6, - "max": 86, + "max": 56, "n": 32, - "samples": [26, 14, 86, 49, 16, 23, 18, 36, 36, 7, 7, 14, 10, 21, 11, 15, 13, 22, 23, 26, 14, 14, 7, 7, 6, 7, 7, 9, 7, 6, 9, 7] + "samples": [36, 7, 7, 14, 10, 21, 11, 15, 13, 22, 23, 26, 14, 14, 7, 7, 6, 7, 7, 9, 7, 6, 9, 7, 7, 9, 9, 56, 8, 9, 9, 9] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -219,14 +219,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 41, - "p95": 94, + "p50": 26, + "p95": 72, "min": 21, - "max": 143, + "max": 74, "n": 32, - "samples": [29, 46, 94, 143, 50, 59, 59, 83, 74, 27, 23, 48, 30, 65, 33, 48, 41, 63, 72, 69, 43, 46, 26, 22, 24, 22, 22, 28, 21, 21, 24, 21] + "samples": [74, 27, 23, 48, 30, 65, 33, 48, 41, 63, 72, 69, 43, 46, 26, 22, 24, 22, 22, 28, 21, 21, 24, 21, 27, 24, 25, 26, 24, 24, 24, 25] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -252,14 +252,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 72, - "p95": 138, + "p50": 47, + "p95": 123, "min": 37, - "max": 473, + "max": 138, "n": 32, - "samples": [62, 78, 116, 473, 98, 116, 90, 127, 123, 47, 47, 72, 52, 88, 55, 77, 73, 112, 110, 138, 77, 83, 47, 47, 43, 43, 44, 41, 42, 40, 44, 37] + "samples": [123, 47, 47, 72, 52, 88, 55, 77, 73, 112, 110, 138, 77, 83, 47, 47, 43, 43, 44, 41, 42, 40, 44, 37, 39, 44, 43, 48, 43, 45, 44, 44] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index 39fac74d..7da7562a 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -151,14 +151,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 16, - "p95": 87, + "p50": 9, + "p95": 71, "min": 8, "max": 246, "n": 32, - "samples": [8, 22, 37, 87, 20, 22, 44, 57, 26, 9, 8, 17, 11, 33, 12, 17, 49, 25, 246, 71, 16, 35, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8] + "samples": [26, 9, 8, 17, 11, 33, 12, 17, 49, 25, 246, 71, 16, 35, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 12, 9, 9, 10, 8] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth": { "tick_us": { @@ -295,14 +295,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 42, - "p95": 160, + "p50": 24, + "p95": 87, "min": 20, - "max": 321, + "max": 160, "n": 32, - "samples": [23, 43, 80, 321, 63, 75, 135, 85, 64, 24, 22, 42, 31, 46, 33, 43, 61, 65, 160, 87, 45, 74, 25, 25, 21, 21, 20, 21, 21, 20, 22, 21] + "samples": [64, 24, 22, 42, 31, 46, 33, 43, 61, 65, 160, 87, 45, 74, 25, 25, 21, 21, 20, 21, 21, 20, 22, 21, 22, 20, 21, 31, 24, 24, 24, 20] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth": { "tick_us": { @@ -439,14 +439,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 16, - "p95": 76, + "p50": 10, + "p95": 49, "min": 8, - "max": 513, + "max": 106, "n": 32, - "samples": [10, 16, 24, 76, 43, 513, 63, 38, 30, 11, 10, 16, 11, 36, 12, 16, 29, 25, 47, 49, 18, 39, 10, 10, 10, 9, 10, 8, 9, 11, 10, 8] + "samples": [30, 11, 10, 16, 11, 36, 12, 16, 29, 25, 47, 49, 18, 39, 10, 10, 10, 9, 10, 8, 9, 11, 10, 8, 9, 9, 8, 106, 9, 10, 10, 10] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32-eth": { "tick_us": { diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json index 4923bd34..8c8d6481 100644 --- a/test/scenarios/light/scenario_perf_full.json +++ b/test/scenarios/light/scenario_perf_full.json @@ -85,14 +85,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 11, + "p50": 1, + "p95": 9, "min": 1, - "max": 22, + "max": 9, "n": 32, - "samples": [2, 7, 9, 9, 22, 8, 8, 11, 7, 2, 2, 4, 3, 5, 3, 5, 4, 8, 9, 9, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 3, 5, 3, 5, 4, 8, 9, 9, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -205,14 +205,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 15, + "p50": 1, + "p95": 7, "min": 1, - "max": 51, + "max": 14, "n": 32, - "samples": [2, 7, 7, 7, 51, 15, 8, 12, 7, 2, 2, 4, 4, 5, 4, 5, 5, 7, 7, 14, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 4, 5, 4, 5, 5, 7, 7, 14, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -325,14 +325,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 12, + "p50": 1, + "p95": 8, "min": 1, - "max": 28, + "max": 12, "n": 32, - "samples": [2, 5, 10, 7, 28, 10, 6, 9, 7, 2, 2, 4, 3, 5, 3, 5, 4, 7, 8, 12, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 3, 5, 3, 5, 4, 7, 8, 12, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -568,14 +568,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 9, + "p50": 1, + "p95": 8, "min": 1, "max": 31, "n": 32, - "samples": [2, 7, 7, 8, 9, 6, 6, 7, 7, 2, 2, 4, 3, 7, 3, 5, 4, 7, 8, 31, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 3, 7, 3, 5, 4, 7, 8, 31, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -686,14 +686,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 26, + "p50": 2, + "p95": 9, "min": 1, - "max": 70, + "max": 26, "n": 32, - "samples": [2, 5, 7, 10, 70, 8, 6, 16, 7, 2, 2, 4, 3, 6, 3, 5, 4, 7, 26, 9, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 3, 6, 3, 5, 4, 7, 26, 9, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -815,14 +815,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 13, + "p50": 1, + "p95": 8, "min": 1, "max": 13, "n": 32, - "samples": [2, 5, 7, 7, 11, 9, 6, 13, 7, 2, 2, 4, 3, 13, 3, 5, 4, 7, 7, 8, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 3, 13, 3, 5, 4, 7, 7, 8, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -948,14 +948,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 9, + "p50": 1, + "p95": 8, "min": 1, "max": 11, "n": 32, - "samples": [2, 5, 7, 7, 9, 7, 7, 8, 7, 2, 2, 4, 3, 7, 3, 5, 4, 7, 11, 8, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 3, 7, 3, 5, 4, 7, 11, 8, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1055,14 +1055,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 13, + "p50": 1, + "p95": 12, "min": 1, "max": 21, "n": 32, - "samples": [2, 5, 7, 8, 8, 7, 6, 13, 7, 2, 2, 4, 3, 6, 3, 5, 4, 7, 12, 21, 5, 6, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 3, 6, 3, 5, 4, 7, 12, 21, 5, 6, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32p4rev1-eth": { "tick_us": { @@ -1168,14 +1168,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 11, + "p50": 1, + "p95": 9, "min": 1, - "max": 26, + "max": 11, "n": 32, - "samples": [2, 5, 7, 10, 7, 26, 6, 6, 7, 2, 2, 4, 3, 5, 3, 5, 4, 7, 9, 11, 9, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [7, 2, 2, 4, 3, 5, 3, 5, 4, 7, 9, 11, 9, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1292,14 +1292,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 16, - "p95": 136, + "p50": 6, + "p95": 35, "min": 4, - "max": 167, + "max": 38, "n": 32, - "samples": [9, 19, 34, 37, 136, 167, 23, 36, 27, 10, 9, 16, 13, 20, 15, 19, 19, 29, 27, 38, 35, 19, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [27, 10, 9, 16, 13, 20, 15, 19, 19, 29, 27, 38, 35, 19, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1416,14 +1416,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 73, - "p95": 255, + "p50": 23, + "p95": 174, "min": 17, - "max": 346, + "max": 255, "n": 32, - "samples": [41, 87, 138, 153, 207, 346, 100, 169, 102, 41, 41, 73, 58, 137, 62, 82, 74, 123, 255, 174, 113, 82, 41, 40, 17, 17, 17, 18, 18, 17, 18, 18] + "samples": [102, 41, 41, 73, 58, 137, 62, 82, 74, 123, 255, 174, 113, 82, 41, 40, 17, 17, 17, 18, 18, 17, 18, 18, 18, 18, 18, 23, 21, 18, 18, 18] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1540,14 +1540,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 303, + "p50": 113, "p95": 1023, "min": 70, "max": 2016, "n": 32, - "samples": [178, 349, 567, 679, 561, 781, 468, 747, 408, 178, 175, 303, 237, 529, 276, 354, 308, 542, 2016, 1023, 414, 368, 174, 173, 71, 70, 70, 74, 74, 71, 70, 73] + "samples": [408, 178, 175, 303, 237, 529, 276, 354, 308, 542, 2016, 1023, 414, 368, 174, 173, 71, 70, 70, 74, 74, 71, 70, 73, 75, 72, 74, 113, 86, 71, 71, 70] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1672,14 +1672,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 7, - "p95": 18, + "p50": 4, + "p95": 15, "min": 4, - "max": 28, + "max": 16, "n": 32, - "samples": [4, 9, 14, 18, 13, 28, 10, 14, 16, 4, 4, 7, 6, 15, 7, 9, 8, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [16, 4, 4, 7, 6, 15, 7, 9, 8, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1796,14 +1796,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 30, - "p95": 87, + "p50": 17, + "p95": 72, "min": 15, - "max": 108, + "max": 92, "n": 32, - "samples": [18, 35, 86, 60, 58, 87, 47, 108, 44, 18, 17, 30, 23, 39, 27, 35, 30, 59, 72, 57, 35, 37, 17, 17, 15, 15, 15, 16, 16, 16, 16, 16] + "samples": [44, 18, 17, 30, 23, 39, 27, 35, 30, 59, 72, 57, 35, 37, 17, 17, 15, 15, 15, 16, 16, 16, 16, 16, 17, 15, 16, 92, 19, 16, 16, 16] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -1920,14 +1920,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 120, - "p95": 398, + "p50": 69, + "p95": 360, "min": 61, - "max": 662, + "max": 574, "n": 32, - "samples": [74, 141, 357, 261, 226, 662, 172, 398, 203, 71, 71, 122, 92, 191, 107, 140, 120, 298, 360, 297, 144, 165, 69, 69, 62, 62, 61, 66, 65, 64, 63, 64] + "samples": [203, 71, 71, 122, 92, 191, 107, 140, 120, 298, 360, 297, 144, 165, 69, 69, 62, 62, 61, 66, 65, 64, 63, 64, 64, 63, 67, 574, 74, 64, 62, 63] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -2044,14 +2044,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 489, - "p95": 2011, + "p50": 279, + "p95": 1114, "min": 247, "max": 12932, "n": 32, - "samples": [289, 560, 1418, 895, 1286, 2011, 693, 989, 717, 285, 279, 489, 371, 879, 429, 558, 499, 1114, 12932, 1097, 562, 589, 278, 279, 252, 249, 247, 256, 265, 251, 255, 259] + "samples": [717, 285, 279, 489, 371, 879, 429, 558, 499, 1114, 12932, 1097, 562, 589, 278, 279, 252, 249, 247, 256, 265, 251, 255, 259, 267, 257, 264, 402, 298, 250, 259, 252] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -2203,14 +2203,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 2, + "p50": 1, "p95": 8, "min": 1, "max": 13, "n": 32, - "samples": [1, 2, 3, 4, 3, 3, 3, 3, 3, 1, 1, 2, 1, 8, 2, 2, 2, 5, 13, 4, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [3, 1, 1, 2, 1, 8, 2, 2, 2, 5, 13, 4, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32": { "tick_us": { @@ -2327,14 +2327,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 7, - "p95": 20, + "p50": 4, + "p95": 19, "min": 4, - "max": 23, + "max": 20, "n": 32, - "samples": [4, 9, 23, 14, 13, 13, 18, 13, 11, 5, 4, 8, 6, 20, 7, 9, 7, 19, 18, 13, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [11, 5, 4, 8, 6, 20, 7, 9, 7, 19, 18, 13, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32": { "tick_us": { @@ -2451,14 +2451,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 30, + "p50": 18, "p95": 74, "min": 15, "max": 113, "n": 32, - "samples": [17, 39, 62, 54, 51, 68, 49, 56, 41, 19, 18, 31, 24, 65, 27, 35, 30, 74, 113, 52, 35, 35, 18, 17, 16, 15, 15, 16, 16, 15, 16, 16] + "samples": [41, 19, 18, 31, 24, 65, 27, 35, 30, 74, 113, 52, 35, 35, 18, 17, 16, 15, 15, 16, 16, 15, 16, 16, 16, 16, 16, 27, 18, 15, 15, 15] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32": { "tick_us": { @@ -2575,14 +2575,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 120, - "p95": 570, + "p50": 70, + "p95": 370, "min": 62, "max": 574, "n": 32, - "samples": [71, 145, 570, 221, 206, 245, 168, 235, 164, 74, 75, 178, 92, 245, 109, 139, 120, 370, 574, 268, 151, 141, 70, 70, 63, 63, 62, 66, 66, 63, 64, 65] + "samples": [164, 74, 75, 178, 92, 245, 109, 139, 120, 370, 574, 268, 151, 141, 70, 70, 63, 63, 62, 66, 66, 63, 64, 65, 65, 63, 67, 103, 74, 63, 63, 63] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32": { "tick_us": { diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json index 4629cfd6..30cd3289 100644 --- a/test/scenarios/light/scenario_perf_light.json +++ b/test/scenarios/light/scenario_perf_light.json @@ -101,14 +101,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 4, - "p95": 10, + "p50": 2, + "p95": 9, "min": 1, "max": 10, "n": 32, - "samples": [2, 5, 7, 8, 7, 8, 5, 10, 5, 2, 2, 4, 3, 9, 3, 5, 4, 7, 7, 10, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [5, 2, 2, 4, 3, 9, 3, 5, 4, 7, 7, 10, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -449,14 +449,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 2, + "p50": 1, "p95": 4, "min": 1, "max": 4, "n": 32, - "samples": [1, 2, 3, 3, 3, 4, 3, 3, 3, 1, 1, 2, 1, 3, 2, 2, 2, 4, 4, 4, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [3, 1, 1, 2, 1, 3, 2, 2, 2, 4, 4, 4, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -573,14 +573,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 7, - "p95": 23, + "p50": 4, + "p95": 14, "min": 4, "max": 49, "n": 32, - "samples": [4, 9, 23, 16, 11, 13, 11, 19, 10, 4, 4, 7, 6, 10, 7, 9, 7, 14, 49, 14, 9, 10, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [10, 4, 4, 7, 6, 10, 7, 9, 7, 14, 49, 14, 9, 10, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { @@ -697,14 +697,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 30, - "p95": 76, + "p50": 17, + "p95": 61, "min": 15, "max": 78, "n": 32, - "samples": [18, 35, 51, 76, 42, 64, 42, 58, 41, 18, 17, 30, 23, 61, 27, 35, 30, 52, 78, 54, 36, 35, 17, 17, 15, 15, 15, 16, 16, 15, 15, 17] + "samples": [41, 18, 17, 30, 23, 61, 27, 35, 30, 52, 78, 54, 36, 35, 17, 17, 15, 15, 15, 16, 16, 15, 15, 17, 20, 16, 16, 24, 18, 15, 16, 15] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index 4d358efd..efac865c 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -173,14 +173,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 7, + "p50": 4, "p95": 14, "min": 4, - "max": 20, + "max": 14, "n": 32, - "samples": [4, 9, 13, 12, 13, 20, 10, 13, 10, 4, 4, 7, 6, 11, 7, 9, 8, 13, 14, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [10, 4, 4, 7, 6, 11, 7, 9, 8, 13, 14, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 6, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -300,14 +300,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 31, - "p95": 74, + "p50": 18, + "p95": 84, "min": 15, "max": 88, "n": 32, - "samples": [18, 36, 64, 74, 48, 48, 41, 52, 41, 18, 18, 31, 23, 59, 27, 36, 31, 56, 88, 55, 36, 35, 17, 17, 15, 15, 15, 18, 17, 16, 16, 16] + "samples": [41, 18, 18, 31, 23, 59, 27, 36, 31, 56, 88, 55, 36, 35, 17, 17, 15, 15, 15, 18, 17, 16, 16, 16, 84, 17, 16, 37, 19, 16, 16, 16] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -427,14 +427,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 123, + "p50": 71, "p95": 241, "min": 62, - "max": 298, + "max": 296, "n": 32, - "samples": [71, 169, 241, 298, 210, 217, 164, 223, 165, 71, 71, 122, 92, 230, 108, 141, 123, 211, 241, 219, 147, 144, 69, 69, 63, 63, 62, 151, 65, 63, 63, 66] + "samples": [165, 71, 71, 122, 92, 230, 108, 141, 123, 211, 241, 219, 147, 144, 69, 69, 63, 63, 62, 151, 65, 63, 63, 66, 296, 66, 64, 103, 74, 65, 63, 63] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -554,14 +554,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 485, - "p95": 1394, + "p50": 287, + "p95": 938, "min": 247, - "max": 1401, + "max": 1089, "n": 32, - "samples": [285, 586, 1394, 1401, 690, 1207, 666, 947, 667, 283, 287, 482, 369, 938, 460, 580, 485, 881, 908, 1089, 581, 575, 277, 278, 251, 247, 250, 590, 264, 252, 255, 260] + "samples": [667, 283, 287, 482, 369, 938, 460, 580, 485, 881, 908, 1089, 581, 575, 277, 278, 251, 247, 250, 590, 264, 252, 255, 260, 872, 259, 272, 813, 298, 254, 251, 251] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -702,14 +702,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 8, - "p95": 35, + "p50": 4, + "p95": 16, "min": 4, - "max": 36, + "max": 24, "n": 32, - "samples": [4, 36, 17, 35, 10, 16, 10, 19, 12, 4, 4, 8, 6, 10, 7, 9, 11, 24, 14, 14, 11, 9, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4] + "samples": [12, 4, 4, 8, 6, 10, 7, 9, 11, 24, 14, 14, 11, 9, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 7, 4, 4, 16, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -829,14 +829,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 30, - "p95": 98, + "p50": 18, + "p95": 61, "min": 15, "max": 121, "n": 32, - "samples": [17, 51, 72, 65, 41, 98, 42, 70, 48, 18, 18, 30, 23, 47, 27, 36, 33, 121, 55, 56, 36, 35, 17, 17, 15, 16, 16, 17, 16, 15, 15, 16] + "samples": [48, 18, 18, 30, 23, 47, 27, 36, 33, 121, 55, 56, 36, 35, 17, 17, 15, 16, 16, 17, 16, 15, 15, 16, 22, 16, 16, 61, 18, 15, 16, 15] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -956,14 +956,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 121, - "p95": 315, + "p50": 71, + "p95": 228, "min": 61, - "max": 331, + "max": 315, "n": 32, - "samples": [69, 152, 241, 331, 163, 290, 166, 207, 186, 71, 73, 121, 92, 173, 111, 185, 127, 315, 228, 206, 142, 142, 70, 69, 63, 61, 65, 68, 68, 62, 64, 64] + "samples": [186, 71, 73, 121, 92, 173, 111, 185, 127, 315, 228, 206, 142, 142, 70, 69, 63, 61, 65, 68, 68, 62, 64, 64, 83, 64, 63, 125, 74, 62, 63, 64] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -1083,14 +1083,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 481, - "p95": 1281, + "p50": 308, + "p95": 881, "min": 249, - "max": 1396, + "max": 895, "n": 32, - "samples": [280, 571, 944, 1281, 626, 1125, 661, 1396, 739, 286, 285, 481, 369, 640, 430, 749, 509, 863, 881, 895, 560, 564, 279, 279, 253, 249, 310, 316, 263, 254, 254, 259] + "samples": [739, 286, 285, 481, 369, 640, 430, 749, 509, 863, 881, 895, 560, 564, 279, 279, 253, 249, 310, 316, 263, 254, 254, 259, 308, 260, 253, 643, 309, 254, 255, 252] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -1231,14 +1231,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 8, - "p95": 18, + "p50": 4, + "p95": 14, "min": 4, - "max": 53, + "max": 15, "n": 32, - "samples": [4, 9, 15, 13, 9, 18, 10, 53, 10, 4, 4, 8, 6, 9, 7, 9, 8, 13, 15, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [10, 4, 4, 8, 6, 9, 7, 9, 8, 13, 15, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 4, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -1358,14 +1358,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 30, - "p95": 74, + "p50": 17, + "p95": 53, "min": 15, - "max": 138, + "max": 55, "n": 32, - "samples": [17, 35, 51, 59, 35, 74, 41, 138, 41, 18, 18, 30, 23, 35, 27, 35, 31, 53, 53, 55, 36, 35, 17, 17, 16, 15, 15, 16, 16, 16, 15, 16] + "samples": [41, 18, 18, 30, 23, 35, 27, 35, 31, 53, 53, 55, 36, 35, 17, 17, 16, 15, 15, 16, 16, 16, 15, 16, 16, 16, 17, 27, 18, 16, 15, 15] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -1485,14 +1485,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 120, - "p95": 275, + "p50": 71, + "p95": 294, "min": 62, - "max": 294, + "max": 588, "n": 32, - "samples": [70, 141, 225, 217, 148, 275, 165, 265, 294, 72, 71, 120, 92, 152, 107, 141, 123, 209, 232, 222, 140, 140, 69, 70, 62, 62, 62, 67, 66, 64, 63, 64] + "samples": [294, 72, 71, 120, 92, 152, 107, 141, 123, 209, 232, 222, 140, 140, 69, 70, 62, 62, 62, 67, 66, 64, 63, 64, 86, 64, 67, 96, 588, 63, 64, 64] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -1612,14 +1612,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 436, - "p95": 995, + "p50": 286, + "p95": 918, "min": 247, - "max": 1188, + "max": 938, "n": 32, - "samples": [278, 634, 1188, 914, 558, 883, 660, 995, 918, 289, 286, 436, 368, 781, 428, 570, 439, 852, 834, 938, 564, 561, 278, 274, 252, 247, 251, 277, 262, 251, 254, 252] + "samples": [918, 289, 286, 436, 368, 781, 428, 570, 439, 852, 834, 938, 564, 561, 278, 274, 252, 247, 251, 277, 262, 251, 254, 252, 311, 255, 260, 384, 359, 252, 252, 251] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -1760,14 +1760,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 7, - "p95": 14, + "p50": 4, + "p95": 13, "min": 4, - "max": 20, + "max": 14, "n": 32, - "samples": [4, 9, 14, 14, 11, 20, 10, 14, 13, 4, 4, 7, 6, 11, 7, 9, 7, 13, 13, 14, 9, 10, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [13, 4, 4, 7, 6, 11, 7, 9, 7, 13, 13, 14, 9, 10, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -1887,14 +1887,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 27, - "p95": 64, + "p50": 18, + "p95": 52, "min": 15, - "max": 144, + "max": 52, "n": 32, - "samples": [18, 37, 144, 55, 41, 50, 42, 64, 52, 18, 18, 27, 23, 47, 27, 35, 28, 52, 51, 52, 35, 36, 18, 17, 16, 15, 15, 17, 16, 16, 15, 16] + "samples": [52, 18, 18, 27, 23, 47, 27, 35, 28, 52, 51, 52, 35, 36, 18, 17, 16, 15, 15, 17, 16, 16, 15, 16, 17, 15, 15, 25, 21, 15, 15, 15] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -2014,14 +2014,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 109, - "p95": 321, + "p50": 69, + "p95": 208, "min": 61, - "max": 447, + "max": 243, "n": 32, - "samples": [72, 148, 447, 211, 153, 321, 165, 228, 207, 76, 71, 110, 92, 243, 107, 140, 109, 206, 203, 208, 141, 144, 69, 68, 62, 62, 61, 64, 65, 63, 64, 63] + "samples": [207, 76, 71, 110, 92, 243, 107, 140, 109, 206, 203, 208, 141, 144, 69, 68, 62, 62, 61, 64, 65, 63, 64, 63, 69, 64, 62, 117, 87, 64, 63, 63] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { @@ -2141,14 +2141,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 437, - "p95": 1612, + "p50": 281, + "p95": 874, "min": 246, - "max": 1685, + "max": 919, "n": 32, - "samples": [279, 586, 1685, 886, 594, 1612, 850, 899, 784, 287, 285, 484, 368, 874, 429, 568, 437, 919, 811, 847, 581, 606, 279, 281, 251, 248, 246, 258, 271, 249, 255, 253] + "samples": [784, 287, 285, 484, 368, 874, 429, 568, 437, 919, 811, 847, 581, 606, 279, 281, 251, 248, 246, 258, 271, 249, 255, 253, 276, 251, 253, 385, 363, 252, 251, 250] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json index 12568a3b..4db58d05 100644 --- a/test/scenarios/light/scenario_peripheral_switch.json +++ b/test/scenarios/light/scenario_peripheral_switch.json @@ -172,14 +172,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 8, + "p50": 4, "p95": 16, "min": 4, "max": 17, "n": 32, - "samples": [4, 9, 13, 14, 11, 14, 10, 14, 16, 4, 4, 8, 6, 13, 9, 9, 7, 14, 13, 17, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [16, 4, 4, 8, 6, 13, 9, 9, 7, 14, 13, 17, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32p4rev1-eth": { "tick_us": { @@ -293,14 +293,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 7, + "p50": 4, "p95": 16, "min": 4, - "max": 29, + "max": 19, "n": 32, - "samples": [4, 9, 29, 16, 10, 14, 15, 14, 14, 4, 4, 8, 6, 11, 7, 10, 7, 13, 13, 16, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [14, 4, 4, 8, 6, 11, 7, 10, 7, 13, 13, 16, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 19, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32p4rev1-eth": { "tick_us": { @@ -414,14 +414,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 7, - "p95": 17, + "p50": 4, + "p95": 14, "min": 4, - "max": 22, + "max": 15, "n": 32, - "samples": [4, 9, 22, 14, 10, 13, 14, 17, 15, 4, 4, 8, 6, 11, 7, 9, 7, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [15, 4, 4, 8, 6, 11, 7, 9, 7, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32p4rev1-eth": { "tick_us": { @@ -534,14 +534,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 8, - "p95": 17, + "p50": 4, + "p95": 14, "min": 4, "max": 17, "n": 32, - "samples": [4, 9, 13, 14, 10, 14, 17, 16, 17, 4, 4, 8, 6, 9, 9, 9, 7, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4] + "samples": [17, 4, 4, 8, 6, 9, 9, 9, 7, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32p4rev1-eth": { "tick_us": { @@ -655,14 +655,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 8, - "p95": 17, + "p50": 4, + "p95": 15, "min": 4, - "max": 65, + "max": 17, "n": 32, - "samples": [4, 9, 65, 15, 10, 17, 13, 13, 17, 4, 4, 8, 6, 10, 10, 9, 7, 13, 13, 15, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [17, 4, 4, 8, 6, 10, 10, 9, 7, 13, 13, 15, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32p4rev1-eth": { "tick_us": { @@ -792,14 +792,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 8, - "p95": 18, + "p50": 4, + "p95": 16, "min": 4, - "max": 24, + "max": 18, "n": 32, - "samples": [4, 9, 24, 13, 10, 13, 13, 14, 16, 4, 4, 8, 6, 11, 9, 9, 7, 13, 14, 18, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [16, 4, 4, 8, 6, 11, 9, 9, 7, 13, 14, 18, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4] }, - "last_updated": "2026-09-05" + "last_updated": "2026-09-06" }, "esp32p4rev1-eth": { "tick_us": { diff --git a/test/unit/core/unit_AudioService_sync.cpp b/test/unit/core/unit_AudioService_sync.cpp index 3ce4d39e..419ee1e9 100644 --- a/test/unit/core/unit_AudioService_sync.cpp +++ b/test/unit/core/unit_AudioService_sync.cpp @@ -182,6 +182,21 @@ TEST_CASE("AudioService Receive: a localhost WLED packet drives frame_, then hol } CHECK(landed); CHECK(a.audioFrame()->levelSmoothed == 111); + // The ballistic is OURS, not the packet's: the smoothed bands rise toward the received raw + // bands, and they survive the whole-frame copy the next packet makes (a copy that zeroed them + // forty times a second would leave nothing to fall slowly). + CHECK(a.audioFrame()->bandsSmoothed[15] > 80); // peer.bands[15] is 120; one block of rise + AudioFrame quiet; // then the peer goes silent + buildWledAudioSync(pkt, quiet, /*peak=*/false); + REQUIRE(tx.sendTo(pkt, WLED_SYNC_PACKET_SIZE)); + bool fell = false; + for (int i = 0; i < 100 && !fell; i++) { + a.tick(); + fell = a.audioFrame()->level == 0; + if (!fell) platform::delayMs(1); + } + CHECK(fell); + CHECK(a.audioFrame()->bandsSmoothed[15] > 40); // still falling, not reset by the copy // Named, not just "receiving": the packet came from loopback, so the status has to say so. A // receiver that cannot name its source looks identical to one locked onto the wrong device. CHECK(std::strcmp(status(a), "receiving from 127.0.0.1") == 0); diff --git a/test/unit/core/unit_MoonBaseContract.cpp b/test/unit/core/unit_MoonBaseContract.cpp index 3e145fd2..cb816c29 100644 --- a/test/unit/core/unit_MoonBaseContract.cpp +++ b/test/unit/core/unit_MoonBaseContract.cpp @@ -81,3 +81,40 @@ TEST_CASE("NetworkModule.json keeps MoonBase's scraped keys inside its 2048-byte std::filesystem::remove_all(tmpRoot); mm::platform::fsSetRoot("."); } + +// The OTA routes are the OTHER cross-image contract, and the one with two speakers: the browser +// drives an update by talking to the application, which hands over to MoonBase mid-flight, so the +// page keeps calling the same paths against a different image. The two therefore have to agree on +// the names, and nothing else pins that: MoonBase is a standalone project sharing no sources, so a +// route renamed on one side compiles cleanly on both and fails only on a device, halfway through +// an update, with the app already gone. +// +// They diverged once (MoonBase served `/install` and `/install-url` while the app served +// `/api/firmware/upload` and `/api/firmware/url`), which cost a debugging round: the app answers an +// unknown large POST with 413, so pushing to the wrong name reads as "the image is too big" rather +// than "no such route". This test reads both sources and requires the shared vocabulary. +TEST_CASE("the two boot images serve the OTA routes under the same names") { + // Resolved from this file rather than the working directory: ctest runs the binary from the + // build tree, where a relative path finds nothing. + const std::filesystem::path repo = + std::filesystem::path(__FILE__).parent_path().parent_path().parent_path().parent_path(); + const auto read = [&](const char* rel) { + std::ifstream f(repo / rel); + REQUIRE_MESSAGE(f.good(), "cannot open " << rel); + return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + }; + const std::string moonbase = read("moonbase/main/moonbase_main.cpp"); + const std::string app = read("src/core/HttpServerModule.cpp"); + + // Push an image, and install from a URL: the two routes a browser calls across the handover. + for (const char* route : {"/api/firmware/upload", "/api/firmware/url"}) { + CHECK_MESSAGE(moonbase.find(route) != std::string::npos, "MoonBase must serve " << route); + CHECK_MESSAGE(app.find(route) != std::string::npos, "the app must serve " << route); + } + + // And the old names stay gone on both sides: a leftover would be a second way to say one + // thing, which is what this test exists to prevent. + for (const char* gone : {"\"POST /install\"", "\"POST /install-url\"", "'/install'", "'/install-url'"}) { + CHECK_MESSAGE(moonbase.find(gone) == std::string::npos, "MoonBase still references " << gone); + } +} diff --git a/test/unit/core/unit_Oscillators.cpp b/test/unit/core/unit_Oscillators.cpp index 6703b4fc..16a43df8 100644 --- a/test/unit/core/unit_Oscillators.cpp +++ b/test/unit/core/unit_Oscillators.cpp @@ -20,9 +20,9 @@ namespace { /// first-tick guard), and calling this again from 0 would establish it a second time. template uint32_t run(OscillatorBank& bank, uint32_t untilMs, uint32_t dtMs, uint32_t from = 0) { - if (from == 0) bank.advance(0); + if (from == 0) bank.advanceTo(0); uint32_t t = from; - while (t + dtMs <= untilMs) { t += dtMs; bank.advance(t); } + while (t + dtMs <= untilMs) { t += dtMs; bank.advanceTo(t); } return t; } @@ -34,7 +34,7 @@ TEST_CASE("an oscillator stays inside the range the effect asked for") { int32_t lo = 1 << 30, hi = -(1 << 30); uint32_t t = 0; for (uint32_t f = 0; f < 2000; f++) { - bank.advance(t); + bank.advanceTo(t); t += 7; // an irregular frame time, as a real loop has lo = bank.value(0) < lo ? bank.value(0) : lo; hi = bank.value(0) > hi ? bank.value(0) : hi; @@ -64,7 +64,7 @@ TEST_CASE("two oscillators at the same rate hold their phase relationship indefi run(bank, 1600, 16); const int32_t earlyGap = static_cast(bank.phase(1)) - static_cast(bank.phase(0)); uint32_t t = 1600; - for (uint32_t f = 0; f < 200000; f++) { t += 16; bank.advance(t); } // ~an hour of frames + for (uint32_t f = 0; f < 200000; f++) { t += 16; bank.advanceTo(t); } // ~an hour of frames const int32_t lateGap = static_cast(bank.phase(1)) - static_cast(bank.phase(0)); CHECK(earlyGap == lateGap); } @@ -82,14 +82,14 @@ TEST_CASE("changing the rate continues from where the phase stands, without jump OscillatorBank<1> bank; bank.set(0, {.rate = 30, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Saw}); uint32_t t = 0; - for (uint32_t f = 0; f < 50; f++) { bank.advance(t); t += 16; } + for (uint32_t f = 0; f < 50; f++) { bank.advanceTo(t); t += 16; } const uint16_t before = bank.unitValue(0); CHECK(before > 0); // it is genuinely mid-cycle, not at the start Oscillator faster = bank.get(0); faster.rate = 240; bank.set(0, faster); - bank.advance(t); // the very next frame, one 16 ms step + bank.advanceTo(t); // the very next frame, one 16 ms step const uint16_t after = bank.unitValue(0); // One frame at the new rate moves the phase by a frame's worth, not to a new place entirely. @@ -130,7 +130,7 @@ TEST_CASE("a square wave is only ever fully on or fully off") { uint32_t t = 0; bool sawLow = false, sawHigh = false; for (uint32_t f = 0; f < 300; f++) { - bank.advance(t); + bank.advanceTo(t); t += 11; // 3.3 s, about five cycles at 90 BPM const int32_t v = bank.value(0); CHECK((v == 0 || v == 255)); // never anything in between @@ -155,9 +155,9 @@ TEST_CASE("the first frame establishes the time base instead of jumping the phas // An effect enabled after the device has been up for an hour starts at zero, not an hour in. OscillatorBank<1> bank; bank.set(0, {.rate = 60, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Saw}); - bank.advance(3600000u); // first call, an hour on the clock + bank.advanceTo(3600000u); // first call, an hour on the clock CHECK(bank.phase(0) == 0); - bank.advance(3600016u); + bank.advanceTo(3600016u); // One 16 ms frame in, not an hour: at 60 BPM that is 16/1000 of a turn, about 1048. CHECK(bank.phase(0) > 1000); CHECK(bank.phase(0) < 1100); diff --git a/test/unit/core/unit_math16.cpp b/test/unit/core/unit_math16.cpp index 3c1f40da..a57dd13d 100644 --- a/test/unit/core/unit_math16.cpp +++ b/test/unit/core/unit_math16.cpp @@ -96,36 +96,36 @@ TEST_CASE("sin16 and map32 evaluate at compile time") { TEST_CASE("BeatPhase keeps animating when frames are under a millisecond") { BeatPhase p; - p.advance(1000, 120); // first call only sets the time base + p.advanceTo(1000, 120); // first call only sets the time base CHECK(p.phase(256) == 0); // 1000 frames of a sub-millisecond dt: a per-tick divide would round each to zero and freeze. // (Integer ms means some ticks advance 0 and some 1 — the accumulator must survive both.) - for (uint32_t i = 1; i <= 1000; i++) p.advance(1000 + i / 2, 120); + for (uint32_t i = 1; i <= 1000; i++) p.advanceTo(1000 + i / 2, 120); CHECK(p.phase(256) > 0); } TEST_CASE("BeatPhase advances proportionally to elapsed time and rate") { BeatPhase slow, fast; - slow.advance(0, 60); fast.advance(0, 120); - slow.advance(1000, 60); fast.advance(1000, 120); + slow.advanceTo(0, 60); fast.advanceTo(0, 120); + slow.advanceTo(1000, 60); fast.advanceTo(1000, 120); CHECK(fast.numerator() == 2 * slow.numerator()); // double the rate, double the phase BeatPhase p; - p.advance(0, 60); - p.advance(500, 60); + p.advanceTo(0, 60); + p.advanceTo(500, 60); const uint64_t half = p.numerator(); - p.advance(1000, 60); + p.advanceTo(1000, 60); CHECK(p.numerator() == 2 * half); // double the time, double the phase } TEST_CASE("BeatPhase holds still at rate zero and resets to zero") { BeatPhase p; - p.advance(0, 120); - p.advance(1000, 0); + p.advanceTo(0, 120); + p.advanceTo(1000, 0); CHECK(p.numerator() == 0); - p.advance(2000, 120); + p.advanceTo(2000, 120); CHECK(p.numerator() > 0); p.reset(); CHECK(p.numerator() == 0); @@ -135,13 +135,13 @@ TEST_CASE("BeatPhase holds still at rate zero and resets to zero") { // long-running device must not see the phase jump backwards or leap. TEST_CASE("BeatPhase survives the millis wrap") { BeatPhase p; - p.advance(0xFFFFFF00u, 120); - p.advance(0xFFFFFF00u + 100u, 120); // wraps past 2^32 + p.advanceTo(0xFFFFFF00u, 120); + p.advanceTo(0xFFFFFF00u + 100u, 120); // wraps past 2^32 const uint64_t afterWrap = p.numerator(); BeatPhase q; - q.advance(1000, 120); - q.advance(1100, 120); // the same 100 ms, no wrap + q.advanceTo(1000, 120); + q.advanceTo(1100, 120); // the same 100 ms, no wrap CHECK(afterWrap == q.numerator()); } diff --git a/test/unit/light/unit_AudioBands.cpp b/test/unit/light/unit_AudioBands.cpp index 479d7db7..169fa679 100644 --- a/test/unit/light/unit_AudioBands.cpp +++ b/test/unit/light/unit_AudioBands.cpp @@ -6,6 +6,7 @@ #include "platform/platform.h" // platform::audioFft (desktop naive DFT) #include +#include // std::memcpy: clang finds it transitively, GCC does not (CI's sanitizer builds) #include #include @@ -137,3 +138,347 @@ TEST_CASE("AudioBands: zero / degenerate input never crashes") { mm::applyWindow(nullptr, 4, &out1); CHECK(true); } + +// The band SPLIT itself, rather than what lands in a band. A 16-band display is only 16 bands if +// every band owns bins of its own: a band whose edges collapse onto the same bin index can never +// light, whatever the signal, and a band with one bin reads a sixteenth of what a band with 75 does +// under the same energy. Both were true of the geometric split (edge[e] = nMag^(e/16)) at the +// shipped shape, which is what these pin. + +TEST_CASE("every band owns at least one FFT bin, so no band is dark whatever the music") { + // The shipped shape: 512-sample FFT at 22050 Hz, so 256 bins of 43.1 Hz. The geometric split + // gave bands 0 and 2 no bins at all (edges 1-1 and 2-2) and bands 1 and 4 a single bin, which + // is a quarter of the display that cannot respond. + const size_t nMag = 256; + const uint32_t sampleRate = 22050; + size_t edges[17]; + mm::audioBandEdges(nMag, sampleRate, edges); + for (uint8_t b = 0; b < 16; b++) { + INFO("band ", b, " spans bins ", edges[b], "..", edges[b + 1]); + CHECK(edges[b + 1] > edges[b]); + } + CHECK(edges[0] >= 1); // bin 0 is DC, never part of a band + CHECK(edges[16] == nMag); // and the top band reaches the Nyquist end +} + +TEST_CASE("band edges rise with frequency, so a band is a range rather than a reshuffle") { + const size_t nMag = 256; + size_t edges[17]; + mm::audioBandEdges(nMag, 22050, edges); + for (uint8_t e = 1; e <= 16; e++) CHECK(edges[e] > edges[e - 1]); +} + +TEST_CASE("a small FFT still yields sixteen usable bands, because a fixture may run one") { + // 128 bins is the smallest shape worth supporting; a geometric split there is degenerate over + // half its range. Sixteen bands must still each own a bin. + const size_t nMag = 128; + size_t edges[17]; + mm::audioBandEdges(nMag, 22050, edges); + for (uint8_t b = 0; b < 16; b++) { + INFO("band ", b, " spans bins ", edges[b], "..", edges[b + 1]); + CHECK(edges[b + 1] > edges[b]); + } +} + +TEST_CASE("the low bands keep the resolution the FFT can actually deliver") { + // Above the bin width the split is free to place edges anywhere; below it there is nothing to + // place. The lowest band starts at the first non-DC bin and the early bands stay narrow, so the + // bass keeps what resolution exists rather than being folded into one wide band. + const size_t nMag = 256; + const uint32_t sampleRate = 22050; + size_t edges[17]; + mm::audioBandEdges(nMag, sampleRate, edges); + const float binHz = static_cast(sampleRate) / (2.0f * static_cast(nMag)); + CHECK(edges[0] == 1); // starts just above DC + CHECK(edges[4] * binHz < 400.0f); // four bands inside the bass + CHECK(edges[8] * binHz < 2000.0f); // half the display below 2 kHz +} + +// The BALLISTIC of a band. A meter that rises and falls at the same speed is the wrong instrument: +// it makes the attack as sluggish as the decay and rounds off exactly the drum hit an audio effect +// exists to show. Broadcast meters (PPM, IEC 60268-10) rise fast and fall slowly, and WLED, FastLED +// and LedFx each arrived at the same asymmetric form independently. + +TEST_CASE("a band rises to a transient at once and falls back slowly, the PPM ballistic") { + uint8_t v = 0; + // A hit: one block takes it most of the way up, because a drum must not be smoothed away. + v = mm::ballistic(v, 200, /*rise*/ 200, /*fall*/ 24); + CHECK(v > 150); + const uint8_t afterRise = v; + // Silence after it: the fall is gradual, so the bar decays rather than dropping out. + v = mm::ballistic(v, 0, 200, 24); + CHECK(v < afterRise); + CHECK(v > (afterRise * 3) / 4); // one block takes off a tenth: the bar decays, it does not drop + for (int i = 0; i < 60; i++) v = mm::ballistic(v, 0, 200, 24); + CHECK(v == 0); // and it does reach zero rather than sticking just above it +} + +TEST_CASE("the ballistic reaches its target exactly, so a held level does not sit one short") { + uint8_t v = 0; + for (int i = 0; i < 60; i++) v = mm::ballistic(v, 255, 200, 24); + CHECK(v == 255); + for (int i = 0; i < 200; i++) v = mm::ballistic(v, 0, 200, 24); + CHECK(v == 0); +} + +TEST_CASE("equal rise and fall reduce to a symmetric follower, so the ballistic is a superset") { + for (uint8_t rate : {uint8_t(8), uint8_t(64), uint8_t(200)}) { + uint8_t a = 40, b = 40; + for (int i = 0; i < 10; i++) { + a = mm::ballistic(a, 200, rate, rate); + b = mm::smoothFollow(b, 200, rate); + CHECK(a == b); + } + } +} + +TEST_CASE("every band gets its own ballistic, so a hit in the bass does not smooth the treble") { + uint8_t raw[16] = {}, sm[16] = {}; + raw[0] = 255; // a bass hit, treble silent + mm::smoothBands(raw, sm); + CHECK(sm[0] > 150); // the hit shows in one block + for (uint8_t b = 1; b < 16; b++) CHECK(sm[b] == 0); // and touches no other band + raw[0] = 0; // silence + mm::smoothBands(raw, sm); + CHECK(sm[0] > 100); // the bar is still falling, not gone + CHECK(sm[0] < 200); +} + +// Onset detection. The standard onset detection function is SPECTRAL FLUX (Bello 2005, Dixon +// 2006): the sum over bands of the positive change since the last block. A rise across the +// spectrum is a hit; a fall is not, and a steady tone is not. It is 16 subtractions on bands we +// already have, so it costs nothing and lands with the block's own latency. + +TEST_CASE("spectral flux reads a rise, ignores a fall, and is zero on a steady spectrum") { + uint8_t prev[16] = {}, cur[16] = {}; + CHECK(mm::spectralFlux(prev, cur) == 0); // silence to silence + for (uint8_t b = 0; b < 16; b++) cur[b] = 200; + const uint8_t hit = mm::spectralFlux(prev, cur); // everything rose + CHECK(hit > 150); + std::memcpy(prev, cur, 16); + CHECK(mm::spectralFlux(prev, cur) == 0); // held: no flux + for (uint8_t b = 0; b < 16; b++) cur[b] = 0; + CHECK(mm::spectralFlux(prev, cur) == 0); // a fall is not an onset +} + +TEST_CASE("an onset fires once per hit, not once per block the hit lasts, and not on a swell") { + // A hit is flux well above its own recent average; a refractory window makes one hit one + // onset. A slow swell raises the average with it and never exceeds it enough to fire. + mm::OnsetDetector d; + int onsets = 0; + for (int block = 0; block < 200; block++) { + const bool hitBlock = (block % 20 == 0); // a hit every 20 blocks (~half a second) + const uint8_t flux = hitBlock ? 200 : 5; // background flux between hits + if (d.feed(flux, static_cast(block) * 23u)) onsets++; + } + CHECK(onsets == 10); // ten hits, ten onsets + mm::OnsetDetector s; + int swell = 0; + for (int block = 0; block < 200; block++) // a slow linear swell + if (s.feed(static_cast(block / 2), static_cast(block) * 23u)) swell++; + CHECK(swell <= 1); // the first block may fire; nothing after +} + +// Per-band conditioning: the learner. Each band learns its own floor and peak in dB; `ratio` +// decides how much of the rig's coloration is removed. Fed directly with dB so the tests say +// what they mean. + +namespace { +void feedBlocks(mm::BandConditioner& c, const float db[16], float out[16], int blocks, uint8_t ratio, + float maxGain = 24.0f, bool learning = true) { + // gate 0: these cases test the conditioner's mapping, so nothing is gated as silence. The + // gate has its own case below. + for (int i = 0; i < blocks; i++) c.process(db, out, 23, 60.0f, 40.0f, ratio, maxGain, learning, 0.0f); +} +} + +TEST_CASE("at ratio 1:1 the conditioner changes nothing, so the music's own balance is untouched") { + mm::BandConditioner c; float db[16], out[16]; + for (uint8_t b = 0; b < 16; b++) db[b] = 80.0f - b * 1.5f; + feedBlocks(c, db, out, 100, 1); + for (uint8_t b = 0; b < 16; b++) CHECK(out[b] == doctest::Approx(db[b])); +} + +TEST_CASE("a spectrally tilted rig reads flat at a high ratio, once the learner has settled") { + // Pink noise through a peak-per-band reading tilts 1/sqrt(f): the treble far below the bass. + // Each band has the same DYNAMICS (a 20 dB swing), only its level differs. After settling, + // the conditioned tops line up within a couple of dB, so a balanced signal shows as balanced. + mm::BandConditioner c; float lo[16], hi[16], out[16]; + for (uint8_t b = 0; b < 16; b++) { hi[b] = 90.0f - b * 1.5f; lo[b] = hi[b] - 20.0f; } + // The cap is not under test here (it has its own case below), so it sits above the tilt. + for (int i = 0; i < 400; i++) { feedBlocks(c, hi, out, 1, 20, 40.0f); feedBlocks(c, lo, out, 1, 20, 40.0f); } + feedBlocks(c, hi, out, 1, 20, 40.0f); + float mn = 1e9f, mx = -1e9f; + for (uint8_t b = 0; b < 16; b++) { if (out[b] < mn) mn = out[b]; if (out[b] > mx) mx = out[b]; } + CHECK(mx - mn < 2.0f); + CHECK(out[15] == doctest::Approx(100.0f).epsilon(0.03)); // the top lands at the window's top +} + +TEST_CASE("each band learns its own floor, so a hum in one band does not raise the others") { + mm::BandConditioner c; float silence[16], out[16]; + for (uint8_t b = 0; b < 16; b++) silence[b] = 30.0f; + silence[1] = 55.0f; // mains hum in band 1 + feedBlocks(c, silence, out, 200, 20); + CHECK(c.floorDb[1] == doctest::Approx(55.0f).epsilon(0.05)); + CHECK(c.floorDb[0] == doctest::Approx(30.0f).epsilon(0.05)); + CHECK(c.floorDb[8] == doctest::Approx(30.0f).epsilon(0.05)); +} + +TEST_CASE("maxGain caps the lift, so a silent band is never amplified into its own noise") { + mm::BandConditioner c; float db[16], out[16]; + for (uint8_t b = 0; b < 16; b++) db[b] = 80.0f; + db[15] = 40.0f; // one band forty dB down + feedBlocks(c, db, out, 300, 20, /*maxGain*/ 6.0f); + CHECK(out[15] <= 40.0f + 6.0f + 0.01f); +} + +TEST_CASE("learning off freezes the tables, the deterministic mode a show wants") { + mm::BandConditioner c; float a[16], b2[16], out[16]; + for (uint8_t b = 0; b < 16; b++) { a[b] = 70.0f; b2[b] = 90.0f; } + feedBlocks(c, a, out, 100, 20); + const float peakBefore = c.peakDb[3], floorBefore = c.floorDb[3]; + feedBlocks(c, b2, out, 100, 20, 24.0f, /*learning*/ false); + CHECK(c.peakDb[3] == peakBefore); + CHECK(c.floorDb[3] == floorBefore); +} + +TEST_CASE("the peak releases over seconds, not blocks, so one loud bar does not re-level the display") { + mm::BandConditioner c; float loud[16], quiet[16], out[16]; + for (uint8_t b = 0; b < 16; b++) { loud[b] = 90.0f; quiet[b] = 60.0f; } + feedBlocks(c, loud, out, 10, 20); + feedBlocks(c, quiet, out, 10, 20); // a quarter of a second later + CHECK(c.peakDb[0] > 85.0f); // still remembers the loud bar + feedBlocks(c, quiet, out, 400, 20); // ten seconds later + // It has let go: the peak sits at the band's floor plus the minimum range, rather than + // anywhere near the loud bar it was holding. + // Converging on the minimum range: the peak falls while the floor drifts up to meet it. + CHECK(c.peakDb[0] - c.floorDb[0] < mm::BandConditioner::kMinRangeDb + 2.0f); +} + +// A quiet passage is not silence, and must keep its dynamics. The range clamp used to be the +// anti-noise mechanism and was set high enough (12 dB) to squash real music: a band swinging 6 dB +// filled only half the display, which reads as vivid bands with no dynamic range. The silence gate +// took that job over, so a band with real swing now uses the whole window. +TEST_CASE("a quietly played band still fills the display, so soft passages keep their dynamics") { + mm::BandConditioner c; float soft[16], loud[16], out[16]; + for (uint8_t b = 0; b < 16; b++) { soft[b] = 70.0f; loud[b] = 76.0f; } // a 6 dB swing + + // Settle on that swing, above the gate throughout: this is music, not a silent room. + for (int i = 0; i < 200; i++) { + c.process(soft, out, 23, 60.0f, 40.0f, 20, 40.0f, true, 65.0f); + c.process(loud, out, 23, 60.0f, 40.0f, 20, 40.0f, true, 65.0f); + } + const float atLoud = out[0]; + c.process(soft, out, 23, 60.0f, 40.0f, 20, 40.0f, true, 65.0f); + const float atSoft = out[0]; + + // The 6 dB swing is stretched across most of the 40 dB window, not left as 6 dB of it. + CHECK(atLoud - atSoft > 20.0f); +} + +// The level path levels itself in automatic mode, the other half of the one `levels` decision: +// the learner measures the VU's window the way it measures each band's, so the manual floor/gain +// sliders are genuinely manual-only rather than still shaping the picture from behind a hidden row. +TEST_CASE("in automatic mode a quiet room and a loud one both fill the level meter") { + const size_t n = 512; + int32_t quiet[n], loud[n]; + for (size_t i = 0; i < n; i++) { + const float ph = static_cast(i) * 0.1f; + // Both above the silence gate (60 dB at floor 0), a hundred times apart: the test is + // that each fills its OWN window, not that one of them is silent. + quiet[i] = static_cast(std::sin(ph) * 20000000.0f); // a quiet room + loud[i] = static_cast(std::sin(ph) * 2000000000.0f); // a hundred times louder + } + + // Music, not a test tone: the level has to VARY for a learned window to mean anything, so + // each room alternates a soft passage with a loud one. A steady tone correctly reads zero + // once the floor follower catches up to it, which is what "nothing is changing" looks like. + int32_t quietSoft[n], loudSoft[n]; + for (size_t i = 0; i < n; i++) { quietSoft[i] = quiet[i] / 2; loudSoft[i] = loud[i] / 2; } + + mm::AudioFrame f{}; + mm::LevelConditioner a, b; + for (int i = 0; i < 100; i++) { + mm::computeLevel(quietSoft, n, 0, 128, f, &a, 23); + mm::computeLevel(quiet, n, 0, 128, f, &a, 23); + } + const uint16_t quietLevel = f.level; + for (int i = 0; i < 100; i++) { + mm::computeLevel(loudSoft, n, 0, 128, f, &b, 23); + mm::computeLevel(loud, n, 0, 128, f, &b, 23); + } + const uint16_t loudLevel = f.level; + + // The two rooms read the SAME, though one is a hundred times louder: each is mapped onto its + // own learned window, which is the whole point of levelling the VU automatically. Both sit + // above the silence gate (floor 0 here), so this measures the levelling, not the gate. + CHECK(quietLevel > 0); + CHECK(quietLevel == loudLevel); + + // And manual mode still maps absolutely: the loud room reads higher than the quiet one. + mm::computeLevel(quiet, n, 50, 128, f, nullptr, 23); + const uint16_t quietManual = f.level; + mm::computeLevel(loud, n, 50, 128, f, nullptr, 23); + CHECK(f.level > quietManual); +} + +// The silence gate, the fix for a learner that levelled an empty room up to full scale. Measured on +// a Dig-Next-2: the raw path read flux 0-3 in a quiet room while the conditioner made 33-68 of it, +// because the lift is dominated by relocating a quiet band up into the display window and silence +// was relocated as eagerly as music. +TEST_CASE("a room below the floor reads silent, however hard the learner is asked to level") { + mm::BandConditioner c; float quiet[16], out[16]; + for (uint8_t b = 0; b < 16; b++) quiet[b] = 55.0f; // below a gate of 60 + + // Settle, then ask for the most aggressive levelling available. + for (int i = 0; i < 400; i++) c.process(quiet, out, 23, 60.0f, 40.0f, 20, 40.0f, true, 60.0f); + for (uint8_t b = 0; b < 16; b++) CHECK(out[b] == 0.0f); + + // And the tables were not dragged down to the room's noise: real music still reads. + float music[16]; + for (uint8_t b = 0; b < 16; b++) music[b] = 80.0f; + c.process(music, out, 23, 60.0f, 40.0f, 20, 40.0f, true, 60.0f); + for (uint8_t b = 0; b < 16; b++) CHECK(out[b] > 0.0f); +} + +// Flux is a difference against the PREVIOUS block and the onset detector carries a running mean, +// so a source that stops and starts must not measure its first new block against the last block of +// the old one: that reports a hit nobody played. AudioService::deinit clears both with the frame. +TEST_CASE("a restarted source reports no onset from the block that preceded it") { + // The history the old source left behind: a loud spectrum. + uint8_t prev[16], now[16]; + for (uint8_t b = 0; b < 16; b++) { prev[b] = 200; now[b] = 200; } + CHECK(mm::spectralFlux(prev, now) == 0); // steady: no flux, by definition + + // Cleared history (what deinit leaves) reads the same block as a full-scale RISE, which is why + // deinit also publishes a silent frame: the first block after a restart is what that silences. + // What clearing buys is a DEFINED reference for the block after it, rather than a spectrum the + // old source left behind. + uint8_t cleared[16] = {}; + const uint16_t againstStale = mm::spectralFlux(prev, now); + const uint16_t againstCleared = mm::spectralFlux(cleared, now); + CHECK(againstStale == 0); + CHECK(againstCleared > againstStale); // which is why the frame is published silent too +} + +// The gate that ships, exercised through magnitudesToBands rather than process() directly: every +// conditioner test above hands `process` a hand-picked gateDb, so none covers the value the caller +// actually passes. It sits AT the display window's floor, deliberately: a band reports its bins' +// PEAK while the level path reports an RMS, so the level's 20 dB silence margin is a far larger +// concession here. Measured on a Dig-Next-2, a 20 dB margin took a quiet room from flux 1-2 to +// 49-102 with onsets firing. +TEST_CASE("a room below the display window shows nothing on the spectrum") { + const size_t nMag = 256; + const uint32_t rate = 22050; + float room[nMag]; + // 100 dB: below the window floor (110 dB at `floor` 100), above where a 20 dB margin would sit. + for (size_t i = 0; i < nMag; i++) room[i] = 100000.0f; + + mm::BandConditioner cond; + uint8_t bands[16]; uint16_t peakHz = 0, peakMag = 0; + for (int i = 0; i < 20; i++) + mm::magnitudesToBands(room, nMag, rate, /*noiseFloor*/ 100, /*gain*/ 128, + bands, peakHz, peakMag, &cond, 23, 4, 24.0f, true); + + for (uint8_t b = 0; b < 16; b++) CHECK(bands[b] == 0); +} diff --git a/test/unit/light/unit_AudioLevel.cpp b/test/unit/light/unit_AudioLevel.cpp index d10cfa77..34a68791 100644 --- a/test/unit/light/unit_AudioLevel.cpp +++ b/test/unit/light/unit_AudioLevel.cpp @@ -127,6 +127,10 @@ TEST_CASE("AudioLevel: a high noiseFloor (dB floor) gates a modest signal to zer CHECK(hi.level == 0); } +// `gain` reads the same way on both paths (higher = narrower window = hotter), but scales the +// level's OWN base span rather than being used raw: a block RMS covers far more dB than a single +// bin's peak, and sharing the raw number left the VU in the bottom third of the meter at the +// settings that made the spectrum look right (measured on a Dig-Next-2: RMS 39-83 of 255). TEST_CASE("AudioLevel: higher gain (narrower dB window) reads a higher level") { auto s = sine(512, 8, 1 << 14); mm::AudioFrame lo, hi; diff --git a/test/unit/light/unit_BeatRipples.cpp b/test/unit/light/unit_BeatRipples.cpp new file mode 100644 index 00000000..5e1785a8 --- /dev/null +++ b/test/unit/light/unit_BeatRipples.cpp @@ -0,0 +1,46 @@ +// @module BeatRipplesEffect +// @also golden_frame + +// The wave surface. A golden hash pins which pixels light, but it passes just as happily on a +// frame that is entirely black, which is how this effect once shipped rendering nothing at all: +// the stone pressed into the surface was scaled in units of tens while the render reads a slope +// in units of thousands, so every ripple fell below the threshold that lights a pixel. What is +// pinned here is that water is VISIBLE and that it MOVES. + +#include "doctest.h" +#include "golden_frame.h" +#include "light/effects/BeatRipplesEffect.h" + +using namespace mm; + +namespace { +/// Brightest byte anywhere in the frame: how strongly the surface catches the light. +int brightestLight(const Layer& layer) { + const auto& b = layer.buffer(); + int peak = 0; + for (size_t i = 0; i < b.bytes(); i++) if (b.data()[i] > peak) peak = b.data()[i]; + return peak; +} +} // namespace + +TEST_CASE("still water shows its ripples, and they spread") { + golden::ScopedTestClock clock(1000); + Layouts layouts; GridLayout grid; Layer layer; BeatRipplesEffect e; + grid.width = 32; grid.height = 32; grid.depth = 1; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&e); + layer.applyState(); + + // With no audio at all the idle rain is the only source, and the first drop lands on the + // opening frame rather than after a silent interval of black water. + for (int f = 0; f < 40; f++) { platform::setTestNowMs(1000 + f * 20u); layer.tick(); } + const int early = brightestLight(layer); + CHECK_MESSAGE(early > 16, "idle rain must light the surface, not leave it black"); + + // And the surface keeps ringing rather than settling immediately: a wave that vanished in a + // frame would satisfy the check above while still looking like nothing. + for (int f = 40; f < 200; f++) { platform::setTestNowMs(1000 + f * 20u); layer.tick(); } + CHECK_MESSAGE(brightestLight(layer) > 16, "the water keeps moving while the rain falls"); +} diff --git a/test/unit/light/unit_Effects_framerate.cpp b/test/unit/light/unit_Effects_framerate.cpp index cc382d58..9d694bac 100644 --- a/test/unit/light/unit_Effects_framerate.cpp +++ b/test/unit/light/unit_Effects_framerate.cpp @@ -119,11 +119,25 @@ TEST_CASE("every effect behaves the same at any framerate") { // // BouncingBalls: its MOTION was always rate-independent (ball height comes from absolute // wall-clock time); its trail now comes from the Layer and its golden is unchanged. + // NebulaEffect (1.36): the same shape as FluidEffect below, for the same reason: its + // births are placed by an oscillator and paced by a time budget, so a batched frame + // seeds along a different path than a spread one. + // + // FluidEffect (1.36): its jets are POSITIONED by an oscillator and poured on a + // time-paced budget with catch-up. At a low framerate several pours land in one frame, + // all reading the jet's position at that instant, where a fast device spreads the same + // pours across the arc. So the dye is laid down along a slightly different path, which + // is a property of pouring a moving source in batches rather than a rate bug: the pour + // COUNT is already rate-correct. Surfaced when the oscillator was fixed to advance on + // absolute time; before that the jets never moved and the paths could not differ. const std::string en(name); const double band = (en == "BlurzEffect") ? 3.70 + : (en == "FluidEffect") ? 1.45 + : (en == "NebulaEffect") ? 1.45 : (en == "RandomEffect") ? 2.40 : (en == "StarFieldEffect") ? 1.50 : (en == "BouncingBallsEffect") ? 1.40 : 1.35; + if (!(ratio < band)) MESSAGE("FRAMERATE " << en << " ratio=" << ratio << " band=" << band); CHECK(ratio < band); audited++; }); diff --git a/test/unit/light/unit_Effects_golden.cpp b/test/unit/light/unit_Effects_golden.cpp index 6c1e3d56..f766707d 100644 --- a/test/unit/light/unit_Effects_golden.cpp +++ b/test/unit/light/unit_Effects_golden.cpp @@ -14,7 +14,7 @@ // @also NebulaEffect, NoiseEffect, PacmanEffect, PlasmaEffect, PolarNoiseEffect, PraxisEffect, // @also RainbowEffect, RingsEffect, RubiksCubeEffect, SdfShapesEffect, SineEffect, SolidEffect, // @also SphereMoveEffect, SpiralEffect, StarFieldEffect, StarSkyEffect, TetrixEffect, TextEffect, -// @also TrailsEffect, TruchetEffect, TunnelEffect, WaterRippleEffect, WaveEffect +// @also RadialSpectrumEffect, VuMetersEffect, TrailsEffect, TruchetEffect, TunnelEffect, WaterRippleEffect, WaveEffect // Pins the EXACT rendered output of the time-driven effects, so the power-function migration's // "renders exactly the same" claim is proved rather than asserted. @@ -74,6 +74,8 @@ #include "light/effects/WaterRippleEffect.h" #include "light/effects/FluidEffect.h" #include "light/effects/NebulaEffect.h" +#include "light/effects/RadialSpectrumEffect.h" +#include "light/effects/VuMetersEffect.h" #include "light/effects/TrailsEffect.h" #include "light/effects/TunnelEffect.h" #include "light/effects/EchoEffect.h" @@ -128,9 +130,11 @@ TEST_CASE("time-driven effects render byte-identical frames (migration guard)") SUBCASE("two SDF shapes orbit and melt together, with a soft edge") { SdfShapesEffect e; golden::checkGolden("SdfShapesEffect", golden::renderHash(e, 16, 16, 1), 0xbcfb74b4836606a3ull); } SUBCASE("a warped noise field folded into a kaleidoscope") { PolarNoiseEffect e; golden::checkGolden("PolarNoiseEffect", golden::renderHash(e, 16, 16, 1), 0x8d48e0d1e0180610ull); } SUBCASE("heat rises, cools and colors through the palette") { FireEffect e; golden::checkGolden("FireEffect", golden::renderHash(e, 16, 16, 1), 0x1cadbabb59bc489bull); } - SUBCASE("dye poured into a simulated medium, carried by the flow it works out") { FluidEffect e; golden::checkGolden("FluidEffect", golden::renderHash(e, 16, 16, 1), 0xdef67ab1f131e137ull); } - SUBCASE("a field births light and a curl flow carries it into a cloud") { NebulaEffect e; golden::checkGolden("NebulaEffect", golden::renderHash(e, 16, 16, 1), 0xc42116cc9f9cc33full); } - SUBCASE("dots thrown into a flow, leaving tails it carries and bends") { TrailsEffect e; golden::checkGolden("TrailsEffect", golden::renderHash(e, 16, 16, 1), 0x77993423ebe2f58cull); } + SUBCASE("dye poured into a simulated medium, carried by the flow it works out") { FluidEffect e; golden::checkGolden("FluidEffect", golden::renderHash(e, 16, 16, 1), 0x46f503452c0435efull); } + SUBCASE("a field births light and a curl flow carries it into a cloud") { NebulaEffect e; golden::checkGolden("NebulaEffect", golden::renderHash(e, 16, 16, 1), 0x6cefd6a2f242aeafull); } + SUBCASE("sixteen VU needles with mass, one per band") { VuMetersEffect e; golden::checkGolden("VuMetersEffect", golden::renderHash(e, 16, 16, 1), 0x4c9ddf61e3f3bf78ull); } + SUBCASE("the spectrum as ripples, one sector per band, radius as time") { RadialSpectrumEffect e; golden::checkGolden("RadialSpectrumEffect", golden::renderHash(e, 16, 16, 1), 0xf76a40a372582783ull); } + SUBCASE("dots thrown into a flow, leaving tails it carries and bends") { TrailsEffect e; golden::checkGolden("TrailsEffect", golden::renderHash(e, 16, 16, 1), 0x2f9b1de2ed8382beull); } SUBCASE("layered noise curtains, each drifting on its own clock") { AuroraEffect e; golden::checkGolden("AuroraEffect", golden::renderHash(e, 16, 16, 1), 0xfb4329a20959443dull); } SUBCASE("drops ripple, reflect off the edges and interfere") { WaterRippleEffect e; golden::checkGolden("WaterRippleEffect", golden::renderHash(e, 16, 16, 1), 0xa11f9c4f27cba8d5ull); } SUBCASE("a texture-mapped tunnel flying toward a vanishing point") { TunnelEffect e; golden::checkGolden("TunnelEffect", golden::renderHash(e, 16, 16, 1), 0x7b4d4451a3de3887ull); } diff --git a/test/unit/light/unit_MultiPinLedDriver.cpp b/test/unit/light/unit_MultiPinLedDriver.cpp index 6c64a275..f3216209 100644 --- a/test/unit/light/unit_MultiPinLedDriver.cpp +++ b/test/unit/light/unit_MultiPinLedDriver.cpp @@ -256,6 +256,73 @@ TEST_CASE("MultiPinLedDriver drives any pin count; the bus rounds up around it") // (0 pins → idles: covered by "MultiPinLedDriver with the empty default pins idles cleanly" above.) } +// WR and DC are lines the LEDs never read, so what an UNSET one costs is the chip's business: +// the classic ESP32 sinks it onto an input-only pad (no GPIO spent), the LCD_CAM chips need a real +// pad because an invalid number reaches the ROM's matrix routine. The desktop emulates the LCD_CAM +// backend, so here an unset WR idles the driver with a status that names the chip's rule. +TEST_CASE("MultiPinLedDriver idles with a named status when DC is unset, on every chip") { + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + mm::Buffer src; + mm::Correction corr; + d.setPeripheralForTest(&peripheral); + REQUIRE(src.allocate(64, 3)); + mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", 20); + mm::test::setControlValue(d, "dcPin", -1); + std::strcpy(d.pins, "1,2,4"); + wire(d, peripheral, src, corr, 64); + CHECK(d.severity() == mm::MoonModule::Severity::Error); + // DC is toggled in software every frame (esp_lcd calls gpio_set_level on it), which a pad with + // no output driver cannot do: the call fails, logs, and the log aborts. So unlike WR it can + // never be sunk, and the driver says why. + CHECK(std::strstr(d.status() ? d.status() : "", "needs a real GPIO") != nullptr); +} + +TEST_CASE("MultiPinLedDriver on an LCD_CAM chip idles with a named status when WR is unset") { + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + mm::Buffer src; + mm::Correction corr; + d.setPeripheralForTest(&peripheral); + REQUIRE(src.allocate(64, 3)); + mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", -1); + mm::test::setControlValue(d, "dcPin", 21); + std::strcpy(d.pins, "1,2,4"); + wire(d, peripheral, src, corr, 64); + CHECK(d.severity() == mm::MoonModule::Severity::Error); + CHECK(std::strstr(d.status() ? d.status() : "", "needs a write-strobe GPIO") != nullptr); +} + +// A pin the PACKAGE lacks fails the same silent way a flash pin does: the ESP32-PICO-V3-02 has no +// GPIO 18/23, and routing the i80 clock there wedged its flash cache with no panic. The platform +// knows the package; the driver must refuse by name before the peripheral touches the pad. +TEST_CASE("MultiPinLedDriver refuses a WR/DC pin this chip package does not have") { + mm::platform::GpioCapability absent; + absent.validGpio = false; + mm::platform::setTestGpioCapability(20, absent); + { + mm::I80Peripheral peripheral; + mm::ParallelLedDriver d; + mm::Buffer src; + mm::Correction corr; + d.setPeripheralForTest(&peripheral); + REQUIRE(src.allocate(64, 3)); + mm::test::rebuildFromPreset(corr, 255, mm::test::PresetOrder::GRB); + d.defineControls(); + mm::test::setControlValue(d, "clockPin", 20); + mm::test::setControlValue(d, "dcPin", 21); + std::strcpy(d.pins, "1,2,4"); + wire(d, peripheral, src, corr, 64); + CHECK(d.severity() == mm::MoonModule::Severity::Error); + CHECK(std::strstr(d.status() ? d.status() : "", "does not exist on this chip package") != nullptr); + } + mm::platform::clearTestGpioCapability(); +} + // A data lane on the same GPIO as the WR (clockPin) or DC pin is a WARNING, not a // blocker: that lane carries the clock/DC waveform instead of pixel data, but on a // board that wires all 8/16 lanes yet drives fewer strands, parking WR/DC on an diff --git a/test/unit/light/unit_Particles.cpp b/test/unit/light/unit_Particles.cpp index c1beeff2..c2d65694 100644 --- a/test/unit/light/unit_Particles.cpp +++ b/test/unit/light/unit_Particles.cpp @@ -718,7 +718,7 @@ TEST_CASE("spreadLane interleaves rather than striding in order") { } } -// Sound-reactive sprites: the behavior a viewer judges is "it moves with the music, and it stops +// audio-reactive sprites: the behavior a viewer judges is "it moves with the music, and it stops // when the music stops". Both halves are pinned here because both were explicit requirements. TEST_CASE("Silence stands the sprites still") { mm::AudioFrame quiet; // levelSmoothed 0: no music playing @@ -773,7 +773,7 @@ TEST_CASE("The spectrum is spread over the live sprites, not the pool capacity") } // Pool::stepDriven is the shared entry point the sprite effects use, so the rules ride on it too. -TEST_CASE("Sound-reactive stepping moves sprites by their own band, and not at all in silence") { +TEST_CASE("audio-reactive stepping moves sprites by their own band, and not at all in silence") { draw::pos_t x[4] = {0, 0, 0, 0}, y[4] = {0, 0, 0, 0}; draw::pos_t vx[4] = {256, 256, 256, 256}, vy[4] = {0, 0, 0, 0}; uint16_t ttl[4] = {1, 1, 1, 1};