Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).)
Expand All @@ -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

Expand All @@ -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 <fw>` |
Expand All @@ -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).
Expand Down Expand Up @@ -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 <fw>` | `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 <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.

Expand Down
49 changes: 49 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file added docs/assets/light/effects/BeatRipplesEffect.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/effects/BeatRipplesEffect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/effects/ColorTrailsEffect.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/effects/ColorTrailsEffect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/effects/VuMetersEffect.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/effects/VuMetersEffect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 15 additions & 2 deletions docs/backlog/audio-dsp-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions docs/backlog/backlog-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading