diff --git a/.gitignore b/.gitignore index 1054696..23ba8df 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ # The firmware has its own target directory. /esp32/target/ /esp32/.embuild/ + +# Brainstorm mockups of the visual companion +.superpowers/ diff --git a/AGENTS.md b/AGENTS.md index 8d04728..dfbf2dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,15 +65,16 @@ cp .github/vendor-patches/link_bindings.rs vendor/rusty_link/link_bindings.rs | Crate | Role | Key modules | |---|---|---| -| `host` | Desktop app + shared library | `config`, `midi/*`, `prolink/*`, `state`, `web/*`, `settings`, `link` | -| `esp32` | Firmware for the WT32-ETH01, ESP-IDF with `std` | `net.rs` (Ethernet, WiFi AP, WiFi station), `ota.rs` (update from GitHub), `prolink.rs` (virtual CDJ, UDP receive), `tracks.rs` (dbserver fetch), `midi.rs` (UART MIDI out), `led.rs` (beat LED, status LED), `dmx.rs` (feature `dmx`), `webui.rs` (page, protocol v1, waveform route), `settings.rs` (NVS), `logring.rs` (log that stays through a restart, core dump report) | +| `host` | Desktop app + shared library | `config`, `dmx`, `midi/*`, `prolink/*`, `state`, `web/*`, `settings`, `link` | +| `esp32` | Firmware for the WT32-ETH01, ESP-IDF with `std` | `net.rs` (Ethernet, WiFi AP, WiFi station), `ota.rs` (update from GitHub), `prolink.rs` (virtual CDJ, UDP receive), `tracks.rs` (dbserver fetch), `midi.rs` (UART MIDI out), `led.rs` (beat LED, status LED), `dmx.rs` (feature `dmx`), `show.rs` (the `show` partition), `ofl.rs` (Open Fixture Library fetch), `webui.rs` (page, protocol v1, waveform route), `settings.rs` (NVS), `logring.rs` (log that stays through a restart, core dump report) | | `crates/autoblink-core-dbserver` | Blocking dbserver client of the firmware, host-testable | `client.rs` (fetch), `protocol.rs` (wire format), `parse.rs` (PSSI, PWV4, PQTZ, cues), `types.rs` (song structure, phrase position) | | `crates/autoblink-core-midi` | MIDI logic of the firmware, host-testable. The host uses `scenes.rs` too. | `clock.rs`, `mapper.rs`, `scenes.rs` (DMX scenes), `settings.rs` (host setting indices) | +| `crates/autoblink-core-dmx` | DMX looks of the firmware, host-testable | `show.rs` (show model and limits), `render.rs` (look into universe), `clock.rs` (beat clock), `effect.rs` (intensity and bar effects), `lights.rs` (engine), `artnet.rs` (ArtDmx parse and build), `merge.rs` (HTP and LTP merge) | | `esp32-emulator` | Native emulator | `main.rs`, `verify_stopped.rs` | ### Host library exports (`host/src/lib.rs`) -`pub mod config link logbuf midi prolink runtime settings state web` +`pub mod app config dmx link logbuf midi osc prolink runtime settings state web` Plus `pub fn interface_priority()` for network interface ranking. @@ -126,10 +127,10 @@ All have `#[cfg(not(windows))]` guards around reuse_port calls. - **`cargo test --locked`** runs tests across all crates - No dedicated test infrastructure found (no `tests/` directory, no fixtures) - Do the builds on your computer. GitHub runs only quick checks. -- Before a push, run `sh scripts/check.sh`. It runs the checks of CI. When the branch changes `esp32/`, `crates/` or `webui/`, it also builds the firmware with and without `dmx`. +- Before a push, run `sh scripts/check.sh`. It runs the checks of CI. When the branch changes `esp32/`, `crates/` or `webui/`, it also builds the firmware with and without `dmx`. The feature `dmx` is a default feature, so `--no-default-features` builds without it. - `ci.yml` runs for each pull request and each merge to `main`. One Linux job checks the format, runs clippy with `-D warnings` and runs the tests. A change to Markdown or `docs/` only does not start it. - The Windows and macOS jobs of `ci.yml` run `cargo check` only after a merge to `main` or on a manual start. -- `esp32-ci.yml` builds the firmware without `dmx` only after a merge to `main` or on a manual start. No test runs on the device. +- `esp32-ci.yml` builds the default firmware (with `dmx`) only after a merge to `main` or on a manual start. No test runs on the device. - To start a workflow on a branch, run `gh workflow run ci.yml --ref ` or `gh workflow run esp32-ci.yml --ref `. - The release workflows (`release.yml`, `esp32-release.yml`) start from a tag. They build all targets. - Coverage gaps: packet parsing, state machine transitions, BPM smoothing, MIDI clock transport @@ -158,10 +159,11 @@ The output is `esp32/target/xtensa-esp32-espidf/release/autoblink-esp32`. - A router at 192.168.1.1 is often the DNS server too. The box has that address on its own WiFi network, so the DNS query goes to the box itself. In that case, the `ota` thread sets the DNS server to 1.1.1.1. - `EspOta::get_running_slot` of esp-idf-svc 0.53 fails with `ESP_ERR_INVALID_SIZE` on this firmware. Read the label from `esp_ota_get_running_partition` instead. - The flash writes can make the MIDI clock stutter. The page warns when a deck plays. It does not refuse the update. +- Each show save erases flash sectors. This pauses the DMX frames and can make the MIDI clock stutter, like an OTA write. - The bootloader has rollback (`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`). A new app calls `ota::mark_valid` after the web server and the Pro DJ Link threads start. If it restarts before that call, the bootloader starts the previous app. - Do not use the bootloader of espflash. It has no rollback. The runner and the release workflow give `--bootloader` with the bootloader of the build. - The tag `esp32-vX.Y.Z` must match the version in `esp32/Cargo.toml`. The release workflow stops when they differ. Otherwise the box installs the same firmware after each check. -- An app image must be smaller than the slot (0x1E0000 bytes). Version 1.21.0 uses 74 % of the slot (1 450 080 bytes). Full LTO, the gzip page and the common certificate bundle make it smaller. Version 1.20.0 used 83 %. +- An app image must be smaller than the slot (0x1E0000 bytes). Version 1.21.0 uses 74 % of the slot (1 450 080 bytes). Version 1.29.0 uses 85 % (1 677 280 bytes). Full LTO, the gzip page and the common certificate bundle make it smaller. - The OTA check uses the common certificate bundle (`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN`). The GitHub certificate chains end at DigiCert and USERTrust. If `ota_check` fails with a certificate error, set the full bundle again. ### Web page without a flash @@ -184,7 +186,76 @@ The output is `esp32/target/xtensa-esp32-espidf/release/autoblink-esp32`. - The scene setting `one_at_a_time` (default on) is for a controller that shows one preset at a time. A note off of such a controller can end a preset with a different note. With the setting on, a flash ends before the next note on. The held scene comes back after a flash or a pulse. The engine sends no note off before a note on of the same note. Such a note off turns the light off for a moment. - Index 33 is after the OSC rows, so that the other indices stay. The page puts a row into the group of its section. - A row entry of 32 and up is an extra note (`scenes::EXTRA_BASE`): 32 is `extra[0]`. The page moves the entries when it removes an extra note. +- `Row::holds` gives each entry of a row a hold in beats (0 is no hold). The page sends it only when a row has a hold, so `scenes_set` stays below `MAX_COMMAND_LEN`. The page keeps `holds` aligned with `presets` when it adds, removes or moves an entry. +- An entry with a hold sends its note off after the hold, whatever the gate of the row. During the hold, the triggers of the row wait in one pending trigger. When the hold ends, the next entry fires at once. A scene row drops its pending trigger when another row has become the current scene. +- After a flash or a pulse, `restore_held` sends the note of the newest held entry again (with `one_at_a_time`). The light can then show the entry of an older scene row while the page shows the newer scene. - The status field `scene.events` counts each event in the order of `Event::ALL`, also without presets and while the scenes are off. The page keeps the same order in `SCENE_EVENTS`. +- The drop guess (`autoblink_core_midi::guess::DropGuess`) gives a bucket (Intro, Groove, Build, Drop, Break, Outro) to a track without rekordbox phrases. The engine uses it only when `SceneInput::phrase` is `None`. Phrases always win. +- The profile is the sum of the 6 PWV4 bytes of a column divided by 6. It is folded to at most 600 columns (`u8`) and smoothed with a moving average of 4 columns on each side. The page does not receive it. +- The thresholds come from the profile of the track: `hi` is the 70th percentile, `lo` is the 30th percentile, and `mid` is the median. Before the first column and after the last column at or above `mid`, the bucket is Intro or Outro. Drop is at or above `hi`. Break is at or below `lo`. Build is a column below `hi` where the next 2 % of the track reaches `hi`. The rest is Groove. A Drop or a Break stays while the energy is within 8 of its threshold (hysteresis). When `hi - lo` is below 24, the track gives only Intro, Groove and Outro. A loudly mastered track therefore gives no Drop guess. +- The engine fires the guessed bucket only at a block start: `phrase16 == 1` after a value other than 1. It fires only when the bucket differs from the last bucket that the guess fired for this track. A new track or a new master deck resets it, and the first tick of a new track or master deck also fires when `phrase16` is already 1. Play without a Play row fires the guess again at once. A master change to a deck without phrases keeps the old scene until the next block start, whereas a real phrase fires at once (the Master change row). A fire at a block start decides the coming block. Thus both feeders classify half a block ahead: they add 8 beats (`8 * 60 000 / bpm` ms) to the playhead (`DropGuess::bucket_ahead`). The history of the hysteresis (`prev_guess`) belongs to the pair of master deck and track. Each fetch logs the thresholds (`hi`, `lo`, `mid` and the first and last `mid` column), so that the owner can tune `MIN_SPREAD` and `HYSTERESIS`. +- The look-ahead of 8 beats covers 5 smoothed columns only while `len_ms * bpm` is at most 57.6e6 (about 7.5 minutes at 128 BPM). A longer track fires one block late. The upgrade is an offset of the larger of 8 beats and 5 columns, below 16 beats, or a Drop test on the unsmoothed profile. +- The page shows "(guess)" at the row of the current bucket when the status field `scene.guessed` is true. +- The guess is built once for each track. The box builds it in the `dbserver` thread before it takes the lock (about 4 KB of heap for a short time, `tracks.rs`). The host builds it in `set_overview` (`host/src/state/dj_state.rs`). Without a preview of 32 columns, there is no guess. + +### DMX show + +- The `show` partition holds the DMX show (`show, data, undefined, 0x3F0000, 0x10000` in `esp32/partitions.csv`). Its content is JSON. A u32 length comes first. The firmware writes the length last, so a write that stops early leaves no show. The box then starts with an empty show until the page saves again. +- OTA does not change the partition table. Flash a box once over USB to add the partition. +- Do not use the subtype `0x40` for the partition. It panics espflash 4.6.0. The table uses the named subtype `undefined`. +- `GET /show` serializes the running show. `POST /show` is a CORS simple request with an Origin check. It answers 413 when the body is larger than 60 000 bytes. It answers 503 when the body is larger than half of the largest free heap block. The reader bounds the lists of the show while it reads them. +- The firmware sends the capability `dmx` and the status field `dmx`. The page sends the WebSocket commands `dmx_live`, `dmx_fog` and `dmx_aim`. +- The `dmx` thread has priority 6 on core 1. Its stack is 7 KB since 1.29.0 (6 KB before). This size is an estimate. Measure it with `/mem` on a box. +- Three senders use one queue of depth 32 to the `dmx` thread: `midi`, `prolink-rx` and the HTTP task (`dmx_fog`). A full queue drops a message. A dropped Off keeps its layer on until the next Off of that preset. An On does not remove the layer. A stop only hides it. +- The DMX output starts before WiFi. Thus the OTA maintenance start sends the idle look. +- The strobe hold ends when the active page closes or a send to it fails. A take-over of another page also ends it. +- The HTTP task has 8 KB of stack. Nobody has measured it under `POST /show`. +- A fixture has the channel kinds `pan`, `pan_fine`, `tilt` and `tilt_fine`. The generic types are `moving_head` (pan, tilt, dimmer, red, green, blue) and `moving_head_16` (with the fine channels). A head with only coarse channels gets the high byte of the 16-bit value. +- The show holds up to 8 positions (`MAX_POSITIONS`, names of 16 characters). A position holds aims, and an aim is a 16-bit pan and tilt for one fixture. A head without an aim in the position sits at 32768 on each axis. +- A look row can have `position` (an index) and `shape`. The shapes are `circle`, `figure8` and `sweep`. A shape has `beats` (1 to 32) and `size` (0 to 255). The amplitude is `size` x 64 units of the 16-bit range, so 255 is a quarter of the range. +- The offset of a shape comes from `t = (i + phase) / beats + k / n`, where `i` is the beat index. `i` counts the beats from a bar line: `i = (beat in bar - 1 + 4 * bar number) mod beats`. A period of 4, 8 or 16 beats therefore starts on beat 1. Without bar information, `i` is the beat count mod beats. The effects with `Every::Bar` use the same fallback: the beat in the bar is the count mod 4. Fixture `k` of `n` in the group is `k / n` of a cycle ahead of the first one. Circle is (cos, sin) of 2 pi t. Figure8 is (sin, sin(2a) / 2). Sweep moves pan only, as sin. The sum of position and offset is clamped to 0 to 65535. It does not wrap. Without a beat, the offset is zero. +- The WebSocket command `dmx_aim` (fixture below 32, pan, tilt) holds the pan and tilt of one head for `AIM_MS` (10 s, `crates/autoblink-core-dmx/src/lights.rs`). The aim overrides every layer and the idle look. The queue drops it when full. The page sends about 20 aims each second and always sends the last value. +- A fade between two looks blends the coarse byte and the fine byte alone. A pan across a byte border can jump for a moment. The `ponytail:` comment in `lights.rs` names the fix: blend the 16-bit pairs. + +### Light library (Open Fixture Library) + +- The box is the proxy of the Open Fixture Library (OFL). The phone on the WiFi network of the box often has no internet. The firmware passes the JSON of OFL through and does not parse it (`esp32/src/ofl.rs`). +- `GET /ofl/search?q=` searches. The text has 1 to 64 characters, and the page asks only for 3 or more. A 1-letter search gives a reply of 48 KB, which the firmware refuses (502). +- `GET /ofl/fixture?key=/` gets one light. The key must match `^[a-z0-9-]+/[a-z0-9-]+$`. +- Both routes check the Origin and send `Access-Control-Allow-Origin: *`. The statuses are: + - 400: the value is bad. + - 403: the Origin is wrong. + - 502: OFL fails, the reply is too large, or the box has no internet. + - 503: the heap is low, or another fetch or an OTA check runs. + - 504: OFL does not answer in 10 s. +- A fetch does not start below 80 KB free heap or a 32 KB largest block (`MIN_FREE`, `MIN_BLOCK`). TLS takes about 50 KB. The firmware cuts off a reply larger than 24 KB (`MAX_BODY`). +- The fetch runs on the `ofl` thread (12 KB stack, priority 5, from `crate::spawn`). The HTTP handler joins the thread. Thus a fetch blocks the HTTP task. The page gets no status and no beats for up to tens of seconds. The MIDI clock and the DMX output keep running. +- While `ofl::busy()` is true, the status thread sends no status, as during an OTA check. The page disables the library search while the strobe hold is on. +- The certificate chain of OFL ends at ISRG Root X1. The common certificate bundle has it through the X2 cross-certificate. +- The box needs internet: a joined WiFi network (indices 102 and 103), or an Ethernet network with a router. +- `Fixture.ofl` holds `#`, for example `eurolite/led-bar-6-qcl-rgbw#4-channel`. The core limits it to 96 characters. +- The page maps the OFL channels to the kinds of the show. A pixel channel gets the kind of its template channel, so each pixel shows the same colour. A channel that no template describes becomes a fixed placeholder channel, so that the footprint stays right. A Pan or Tilt channel maps to `pan` or `tilt`, and its fine alias maps to `pan_fine` or `tilt_fine`. A mode with an unknown pixel count, or with more than 32 channels, the page does not add. +- Nobody has measured the heap after many fetches on a box. Run `/mem` after ten fetches. + +### Art-Net in on the box + +- The `artnet` thread (`esp32/src/artnet.rs`) receives Art-Net on UDP port 6454 on all interfaces. It keeps universe 0 only and ignores a frame of another universe. Its stack is 4 KB and its priority is 5 (`crate::spawn`). This size is an estimate. Measure it with `/mem` on a box. +- `main` starts the thread after the network and the Pro DJ Link threads. The OTA maintenance start does not reach that line, so it has no Art-Net. +- The permanent heap for Art-Net is about 8 KB. +- The box answers no ArtPoll. A desk must send to the IP address of the box (unicast) or to the subnet broadcast address. +- Art-Net counts as live for 3 s after its last frame (`ARTNET_TIMEOUT_MS`). Without a live frame, the output is the engine alone. +- The merge rule is in `autoblink_core_dmx::merge`. The channels of kind Dimmer, Red, Green, Blue, White, Amber, Uv and Strobe use HTP (the higher value wins). All other channels use LTP (the source that changed the channel last wins). The channels that no light patches and the Fog channel are LTP too. After a timeout, the first live frame claims every non-zero channel. +- The `dmx` thread reads the frame with `try_lock`, so the `artnet` thread never blocks it. The status field `dmx.artnet` is true while Art-Net is live. + +### DMX engine on the host + +- The `[dmx]` section of the config file has `artnet_target` (an IPv4 address or IP:port, default port 6454) and `universe` (0 to 32767, default 0). Without `artnet_target`, or with a bad value, the engine does not start. +- The engine task (`host/src/dmx.rs`) runs under `runtime::supervise`. It renders about every 25 ms and sends ArtDmx to the target. +- The task is silent while it sees a box. A box is a Pro DJ Link device with the name `autoblink`. Its device number differs from the number of the host. Two hosts with the default device name therefore silence each other. Change `device_name` on one of them. With the same device number as the host, the host does not find the box and keeps sending. The host logs a warning at start when `artnet_target` is set and its device number is the default 7. +- The host keeps a copy of the show in `show.json` in the folder of the config file. `GET /show` and `POST /show` work as on the box, with the same 60 000-byte limit. +- The host takes `dmx_live`, `dmx_fog` and `dmx_aim` from the page. When the page that holds the strobe closes, the host releases the hold (`StrobeGuard` in `web/mod.rs`). +- The status field `box` gives the IP address of the box, or null. The status field `dmx` comes from the engine. +- When a box is on the network, the host page loads and saves the show of the box (`GET` and `POST` to the box). Open the host page at `127.0.0.1` or `localhost`, so that the box accepts the POST (`dev_origin`). The preset names on the host page are the names of the host. The page hides the Preview button. ### Debug logs @@ -193,7 +264,7 @@ The output is `esp32/target/xtensa-esp32-espidf/release/autoblink-esp32`. - Start a thread with `crate::spawn`. It gives the FreeRTOS task the thread name. Without it, `/mem` shows each Rust thread as "pthread". - `crate::spawn` gives priority 5 on the two cores, the same as the HTTP server task. `crate::spawn_rt` sets a priority and a core. The WiFi task runs on core 0. The `midi` thread has priority 10 on core 1. The `prolink-rx` thread has priority 8 on core 1. - `prolink-rx` receives on the three Pro DJ Link sockets with one `poll` loop and one buffer. The task watchdog watches it and the `midi` thread. After a receive error, the thread sleeps for 10 ms, so that the idle task can run. -- Each stack comes from the heap. The sizes in 1.19.0 keep about 1.5 to 2 KB free in each task. Measure with `/mem` again after a change that adds work to a thread. Version 1.21.0 changes `main` to 7 KB and adds `prolink-rx` with 6 KB. Measure these two. +- Each stack comes from the heap. The sizes in 1.19.0 keep about 1.5 to 2 KB free in each task. Measure with `/mem` again after a change that adds work to a thread. Version 1.21.0 changes `main` to 7 KB and adds `prolink-rx` with 6 KB. Measure these two. Measure `main` again after 1.29.0, because the `Box` builds in `dmx::start` can use its stack. - The status message and the settings message are `Serialize` structs. Do not build them as a `serde_json::Value` tree. A tree has a heap block for each field, and the blocks fragment the heap. - The firmware keeps 600 columns of the waveform preview (`autoblink_core_dbserver::PREVIEW_COLUMNS`). The players send 1200. - The dbserver client reads the PWV4 preview and the PQTZ beat grid as a stream (`read_message_with`). It keeps only 600 preview columns and the beats of the phrase starts, the fill-ins and the end. A buffered grid and its parsed copy needed up to 72 KB for one fetch. Without phrases, the client does not request the beat grid. @@ -227,7 +298,7 @@ The output is `esp32/target/xtensa-esp32-espidf/release/autoblink-esp32`. | IO33 | Status LED, with the label 485_EN on the board. While the master deck plays, it pulses for 120 ms on beat 1 and for 25 ms on the other beats. When no beat arrives for 2 s, it shows a blink code (see below). | | IO17 | MIDI out (UART2 TX, 31250 baud). TRS-A: IO17 → 10 Ω → tip, 3V3 → 33 Ω → ring, GND → sleeve. | | IO35 | UART2 RX. It is not used, but the UART driver needs a receive pin. | -| IO4 | DMX out (UART1 TX, 250 kbaud) to an RS-485 module. Only with `--features dmx`. | +| IO4 | DMX out (UART1 TX, 250 kbaud) to an RS-485 module. The `dmx` feature is on by default. | | IO36 | UART1 RX. It is not used, but the UART driver needs a receive pin. | The blink code of IO33 repeats every 2 s. The LED shows the first line that applies: @@ -279,7 +350,7 @@ Do not connect a load to IO12 at power-on. If IO12 is high, the ESP32 sets the f - The ESP32 MIDI output follows the host rules (`autoblink-core-midi`), with the phrase notes and CCs. It has no No Bass note, no MTC and no OSC. - The ESP32 page has no mixer section. The XDJ-AZ sends the EQ and fader positions only as USB MIDI, and the classic ESP32 has no USB host. - The ESP32 sends the Phrase 16 CC as the page help says (0 at each block start). The host never sends it, because its dedupe starts at 0. -- The DMX output (`--features dmx`) flashes channel 1 on each beat. It has no settings yet. +- The DMX output no longer flashes channel 1 on each beat. The `dmx` thread renders the looks of the show (see "DMX show"). - The emulator has no `scenes` capability. The page hides the DMX Scenes panel there. - The emulator uses old setting indices (for example 8 for Note Channel, which the host has at 9). It does not use `autoblink-core-midi` yet. - A refactor plan exists at `.sisyphus/plans/multi-architecture-refactor-plan.md` diff --git a/Cargo.lock b/Cargo.lock index b892964..d08945d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,11 +108,20 @@ dependencies = [ [[package]] name = "autoblink-core-dbserver" -version = "1.23.0" +version = "1.29.0" + +[[package]] +name = "autoblink-core-dmx" +version = "1.29.0" +dependencies = [ + "autoblink-core-midi", + "serde", + "serde_json", +] [[package]] name = "autoblink-core-midi" -version = "1.23.0" +version = "1.29.0" dependencies = [ "serde", "serde_json", @@ -120,11 +129,11 @@ dependencies = [ [[package]] name = "autoblink-core-prolink" -version = "1.23.0" +version = "1.29.0" [[package]] name = "autoblink-esp32-emulator" -version = "1.23.0" +version = "1.29.0" dependencies = [ "anyhow", "autoblink-core-prolink", @@ -138,9 +147,10 @@ dependencies = [ [[package]] name = "autoblink-host" -version = "1.23.0" +version = "1.29.0" dependencies = [ "anyhow", + "autoblink-core-dmx", "autoblink-core-midi", "autoblink-core-prolink", "axum", diff --git a/Cargo.toml b/Cargo.toml index 8296445..60bd445 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "host", "esp32-emulator", "crates/autoblink-core-midi", + "crates/autoblink-core-dmx", "crates/autoblink-core-prolink", "crates/autoblink-core-dbserver", ] @@ -13,7 +14,7 @@ members = [ exclude = ["esp32"] [workspace.package] -version = "1.23.0" +version = "1.29.0" edition = "2021" license = "Proprietary" authors = ["AUTOBLINK team"] diff --git a/crates/autoblink-core-dmx/Cargo.toml b/crates/autoblink-core-dmx/Cargo.toml new file mode 100644 index 0000000..2404f96 --- /dev/null +++ b/crates/autoblink-core-dmx/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "autoblink-core-dmx" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +autoblink-core-midi = { path = "../autoblink-core-midi" } diff --git a/crates/autoblink-core-dmx/src/artnet.rs b/crates/autoblink-core-dmx/src/artnet.rs new file mode 100644 index 0000000..2232599 --- /dev/null +++ b/crates/autoblink-core-dmx/src/artnet.rs @@ -0,0 +1,105 @@ +//! The Art-Net ArtDmx packet: the reader and the writer. + +use crate::render::Universe; +use crate::show::UNIVERSE; + +/// The UDP port of Art-Net. +pub const ARTNET_PORT: u16 = 6454; + +const ID: &[u8; 8] = b"Art-Net\0"; +const OP_DMX: u16 = 0x5000; +const HEADER: usize = 18; + +/// Returns the universe (Net << 8 | SubUni) and the data of an ArtDmx packet, or None. +pub fn parse_artdmx(packet: &[u8]) -> Option<(u16, &[u8])> { + if packet.len() < HEADER || &packet[..8] != ID { + return None; + } + if u16::from_le_bytes([packet[8], packet[9]]) != OP_DMX { + return None; + } + let len = usize::from(u16::from_be_bytes([packet[16], packet[17]])); + if len == 0 || len > UNIVERSE { + return None; + } + let data = packet.get(HEADER..HEADER + len)?; + let universe = u16::from(packet[15] & 0x7f) << 8 | u16::from(packet[14]); + Some((universe, data)) +} + +/// Writes an ArtDmx packet for `universe` with `data` (512 bytes) into `out`. Returns the length. +pub fn build_artdmx(sequence: u8, universe: u16, data: &Universe, out: &mut [u8; 530]) -> usize { + out[..8].copy_from_slice(ID); + out[8..10].copy_from_slice(&OP_DMX.to_le_bytes()); + out[10..12].copy_from_slice(&14u16.to_be_bytes()); + out[12] = sequence; + out[13] = 0; + out[14] = universe as u8; + out[15] = (universe >> 8) as u8 & 0x7f; + out[16..18].copy_from_slice(&(UNIVERSE as u16).to_be_bytes()); + out[HEADER..].copy_from_slice(data); + HEADER + UNIVERSE +} + +#[cfg(test)] +mod tests { + use super::*; + + fn packet(universe: u16, data: &[u8]) -> Vec { + let mut p = b"Art-Net\0".to_vec(); + p.extend([ + 0x00, + 0x50, + 0, + 14, + 0, + 0, + universe as u8, + (universe >> 8) as u8, + ]); + p.extend((data.len() as u16).to_be_bytes()); + p.extend(data); + p + } + + #[test] + fn parse_valid_artdmx() { + let p = packet(0x0102, &[1, 2, 3, 4]); + assert_eq!(parse_artdmx(&p), Some((0x0102, &[1u8, 2, 3, 4][..]))); + } + + #[test] + fn parse_rejects() { + let mut p = packet(0, &[1, 2]); + p[0] = b'X'; + assert!(parse_artdmx(&p).is_none()); + let mut p = packet(0, &[1, 2]); + p[9] = 0x21; + assert!(parse_artdmx(&p).is_none()); + assert!(parse_artdmx(&packet(0, &[1, 2])[..17]).is_none()); + let mut p = packet(0, &[1, 2]); + p[17] = 9; + assert!(parse_artdmx(&p).is_none()); + assert!(parse_artdmx(&packet(0, &[0; 513])).is_none()); + assert!(parse_artdmx(&packet(0, &[])).is_none()); + assert!(parse_artdmx(&packet(0, &[0; 512])).is_some()); + assert!(parse_artdmx(&packet(0, &[1])).is_some()); + } + + #[test] + fn build_round_trip() { + let mut data = [0u8; UNIVERSE]; + data[0] = 7; + data[511] = 9; + let mut out = [0u8; 530]; + let n = build_artdmx(3, 0x0102, &data, &mut out); + assert_eq!(n, 530); + let (u, d) = parse_artdmx(&out[..n]).unwrap(); + assert_eq!(u, 0x0102); + assert_eq!(d, &data[..]); + assert_eq!(out[12], 3); + assert_eq!(out[10..12], [0, 14]); + assert_eq!(out[13], 0); + assert_eq!(out[16..18], [2, 0]); + } +} diff --git a/crates/autoblink-core-dmx/src/clock.rs b/crates/autoblink-core-dmx/src/clock.rs new file mode 100644 index 0000000..0898eaa --- /dev/null +++ b/crates/autoblink-core-dmx/src/clock.rs @@ -0,0 +1,161 @@ +//! The beat clock of the DMX planner. +//! +//! On WiFi, a beat packet arrives late by up to about 300 ms, because the access +//! point holds a broadcast until the next DTIM beacon. A late packet is never +//! early. Thus the clock keeps an anchor, the predicted time of the last beat, +//! and moves it only to an earlier time. + +/// Number of packets that the clock compares to find a constant lateness. +const WINDOW: usize = 8; +/// The lateness in ms above which the whole window moves the anchor. +const LATE_MS: f64 = 20.0; +/// The time in ms after the last packet at which the clock stops. +const STOP_MS: f64 = 2000.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Beat { + pub phase: f64, + pub beat_in_bar: u8, + pub count: u32, + pub ms_per_beat: f64, +} + +#[derive(Default)] +pub struct BeatClock { + anchor_ms: Option, + count: u32, + beat_in_bar: u8, + period_ms: f64, + last_packet_ms: f64, + lateness: Vec, +} + +impl BeatClock { + pub fn beat(&mut self, now_ms: u64, beat_in_bar: u8, bpm: f64) { + let t = now_ms as f64; + let old_period = self.period_ms; + self.period_ms = if bpm > 0.0 { 60000.0 / bpm } else { 500.0 }; + self.last_packet_ms = t; + let Some(anchor) = self.anchor_ms else { + self.anchor_ms = Some(t); + self.count = 0; + self.beat_in_bar = beat_in_bar; + return; + }; + // The prediction uses the period of the last beat, not the new one. + let period = if old_period > 0.0 { + old_period + } else { + self.period_ms + }; + let predicted = anchor + period; + let expected = self.beat_in_bar % 4 + 1; + let bib_ok = self.beat_in_bar == 0 || beat_in_bar == 0 || beat_in_bar == expected; + if bib_ok && t >= predicted - period / 2.0 && t <= predicted + period { + self.count = self.count.wrapping_add(1); + if beat_in_bar != 0 { + self.beat_in_bar = beat_in_bar; + } else if self.beat_in_bar != 0 { + self.beat_in_bar = expected; + } + self.anchor_ms = Some(t.min(predicted)); + self.lateness.push((t - predicted).max(0.0)); + if self.lateness.len() > WINDOW { + self.lateness.remove(0); + } + if self.lateness.len() == WINDOW { + let min = self.lateness.iter().copied().fold(f64::MAX, f64::min); + if min > LATE_MS { + self.anchor_ms = self.anchor_ms.map(|a| a + min); + self.lateness.clear(); + } + } + } else { + // A lost packet leaves a gap. The count goes on by the beats of the gap. + let gap = ((t - anchor) / period).round().max(1.0) as u32; + self.anchor_ms = Some(t); + self.count = self.count.wrapping_add(gap); + self.beat_in_bar = beat_in_bar; + self.lateness.clear(); + } + } + + /// `None` before the first beat, and 2000 ms after the last beat packet. + pub fn now(&self, now_ms: u64) -> Option { + let anchor = self.anchor_ms?; + let t = now_ms as f64; + if t - self.last_packet_ms > STOP_MS { + return None; + } + let n = ((t - anchor) / self.period_ms).floor().max(0.0); + let phase = ((t - anchor) / self.period_ms - n).clamp(0.0, 1.0); + let n = n as u32; + let beat_in_bar = if self.beat_in_bar == 0 { + 0 + } else { + ((u32::from(self.beat_in_bar) - 1 + n) % 4) as u8 + 1 + }; + Some(Beat { + phase, + beat_in_bar, + count: self.count.wrapping_add(n), + ms_per_beat: self.period_ms, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn late_packets_keep_phase() { + let mut c = BeatClock::default(); + for (i, late) in [0u64, 300, 100, 250, 180].iter().enumerate() { + c.beat(i as u64 * 500 + late, (i % 4) as u8 + 1, 120.0); + } + let b = c.now(2000).unwrap(); + assert!(b.phase < 0.02, "{}", b.phase); + assert_eq!(b.count, 4); + } + + #[test] + fn early_packet_moves_anchor() { + let mut c = BeatClock::default(); + for i in 0..4u64 { + c.beat(i * 500, (i % 4) as u8 + 1, 120.0); + } + c.beat(2000 - 40, 1, 120.0); + assert_eq!(c.now(1960).unwrap().phase, 0.0); + } + + #[test] + fn resync_counts_lost_beats() { + let mut c = BeatClock::default(); + c.beat(0, 1, 120.0); + c.beat(500, 2, 120.0); + c.beat(1500, 4, 120.0); + let b = c.now(1500).unwrap(); + assert_eq!(b.count, 3); + assert_eq!(b.beat_in_bar, 4); + } + + #[test] + fn stops_after_2_s() { + let mut c = BeatClock::default(); + assert!(c.now(0).is_none()); + c.beat(1000, 1, 120.0); + assert!(c.now(3000).is_some()); + assert!(c.now(3001).is_none()); + } + + #[test] + fn predicts_between_packets() { + let mut c = BeatClock::default(); + c.beat(0, 4, 120.0); + let b = c.now(750).unwrap(); + assert_eq!(b.count, 1); + assert_eq!(b.beat_in_bar, 1); + assert_eq!(b.phase, 0.5); + } +} diff --git a/crates/autoblink-core-dmx/src/effect.rs b/crates/autoblink-core-dmx/src/effect.rs new file mode 100644 index 0000000..5a2660b --- /dev/null +++ b/crates/autoblink-core-dmx/src/effect.rs @@ -0,0 +1,262 @@ +//! The intensity effects of a row of fixtures. + +use crate::clock::Beat; +use crate::show::{Effect, Every, Shape}; + +/// The factor 0 to 255 of fixture `k` of `n` in a row with `effect`. +/// It is 255 for `None`, `Strobe` and `Fog`, and for every effect without a beat. +pub fn factor(effect: &Effect, beat: Option<&Beat>, k: usize, n: usize) -> u8 { + let ( + Some(b), + Effect::Flash { every } + | Effect::Pulse { every } + | Effect::Chase { every } + | Effect::Random { every }, + ) = (beat, effect) + else { + return 255; + }; + let second_half = b.phase >= 0.5; + let (p, c) = match every { + Every::Beat => (b.phase, b.count), + Every::Half => ( + (2.0 * b.phase).fract(), + b.count.wrapping_mul(2).wrapping_add(u32::from(second_half)), + ), + Every::Bar => { + // Without bar information, the count gives the beat in the bar. + let bib = match b.beat_in_bar { + 0 => b.count % 4, + n => u32::from(n) - 1, + }; + // Count the bar starts, so that a bar steps with beat 1. + ( + (f64::from(bib) + b.phase) / 4.0, + b.count.wrapping_sub(bib) / 4, + ) + } + }; + let lit = |on: bool| if on { 255 } else { 0 }; + match effect { + Effect::Flash { .. } => (255.0 * (1.0 - p).powi(3)).clamp(0.0, 255.0) as u8, + Effect::Pulse { .. } => { + (255.0 * (1.0 + (std::f64::consts::TAU * p).cos()) / 2.0).clamp(0.0, 255.0) as u8 + } + Effect::Chase { .. } if n > 0 => lit(c as usize % n == k), + Effect::Random { .. } if n > 0 => lit(xorshift32(c.wrapping_add(1)) as usize % n == k), + _ => 255, + } +} + +/// The pan and tilt offset of a shape for fixture `k` of `n`, in 16-bit units. +/// A fixture is `k / n` of a cycle ahead of the first one. The cycle starts on a bar line. +/// Without a beat, the offset is zero. +pub fn shape_offset(shape: &Shape, beat: Option<&Beat>, k: usize, n: usize) -> (i32, i32) { + let ( + Some(b), + Shape::Circle { beats, size } + | Shape::Figure8 { beats, size } + | Shape::Sweep { beats, size }, + ) = (beat, shape) + else { + return (0, 0); + }; + let beats = u32::from((*beats).max(1)); + let amp = f64::from(*size) * 64.0; + let shift = if n > 0 { k as f64 / n as f64 } else { 0.0 }; + let bib = u32::from(b.beat_in_bar.saturating_sub(1)); + let i = if b.beat_in_bar == 0 { + // Without bar information, the count gives the index. + b.count % beats + } else { + let bar = b.count.wrapping_sub(bib) / 4; + bib.wrapping_add(bar.wrapping_mul(4)) % beats + }; + let t = ((f64::from(i) + b.phase) / f64::from(beats) + shift).fract(); + let a = std::f64::consts::TAU * t; + let (x, y) = match shape { + Shape::Circle { .. } => (a.cos(), a.sin()), + Shape::Figure8 { .. } => (a.sin(), (2.0 * a).sin() / 2.0), + _ => (a.sin(), 0.0), + }; + ((amp * x).round() as i32, (amp * y).round() as i32) +} + +fn xorshift32(mut x: u32) -> u32 { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x +} + +#[cfg(test)] +mod tests { + use super::*; + + fn beat(phase: f64, beat_in_bar: u8, count: u32) -> Beat { + Beat { + phase, + beat_in_bar, + count, + ms_per_beat: 500.0, + } + } + + const FLASH: Effect = Effect::Flash { every: Every::Beat }; + + #[test] + fn flash_shape() { + assert_eq!(factor(&FLASH, Some(&beat(0.0, 1, 0)), 0, 1), 255); + let v = factor(&FLASH, Some(&beat(0.25, 1, 0)), 0, 1); + assert!(v == 107 || v == 108, "{v}"); + assert_eq!(factor(&FLASH, Some(&beat(0.99, 1, 0)), 0, 1), 0); + } + + #[test] + fn pulse_shape() { + let e = Effect::Pulse { every: Every::Beat }; + assert_eq!(factor(&e, Some(&beat(0.0, 1, 0)), 0, 1), 255); + assert_eq!(factor(&e, Some(&beat(0.5, 1, 0)), 0, 1), 0); + } + + #[test] + fn chase_steps() { + let e = Effect::Chase { every: Every::Beat }; + for (count, lit) in [0, 1, 2, 0, 1, 2].into_iter().enumerate() { + for k in 0..3 { + let v = factor(&e, Some(&beat(0.0, 1, count as u32)), k, 3); + assert_eq!(v, if k == lit { 255 } else { 0 }, "count {count} k {k}"); + } + } + assert_eq!(factor(&e, Some(&beat(0.0, 1, 0)), 0, 0), 255); + } + + #[test] + fn random_lights_one() { + let e = Effect::Random { every: Every::Beat }; + for count in 0..20 { + let lit = (0..4) + .filter(|&k| factor(&e, Some(&beat(0.0, 1, count)), k, 4) == 255) + .count(); + assert_eq!(lit, 1); + } + } + + #[test] + fn half_and_bar() { + let half = Effect::Flash { every: Every::Half }; + assert_eq!(factor(&half, Some(&beat(0.5, 1, 0)), 0, 1), 255); + let bar = Effect::Flash { every: Every::Bar }; + let v = factor(&bar, Some(&beat(0.0, 3, 2)), 0, 1); + assert!(v == 31 || v == 32, "{v}"); + } + + #[test] + fn bar_count_follows_bar_start() { + let e = Effect::Chase { every: Every::Bar }; + let lit = + |count, bib| (0..3).find(|&k| factor(&e, Some(&beat(0.0, bib, count)), k, 3) == 255); + assert_eq!(lit(2, 1), lit(5, 4)); + assert_ne!(lit(2, 1), lit(6, 1)); + } + + #[test] + fn no_beat_is_full() { + assert_eq!(factor(&FLASH, None, 0, 1), 255); + assert_eq!( + factor(&Effect::Fog { ms: 500 }, Some(&beat(0.5, 1, 0)), 0, 1), + 255 + ); + } + + #[test] + fn circle_at_quarter_phase() { + let sh = Shape::Circle { + beats: 4, + size: 255, + }; + let (x, y) = shape_offset(&sh, Some(&beat(0.0, 2, 1)), 0, 1); + assert!(x.abs() <= 1 && (y - 16320).abs() <= 1, "{x} {y}"); + assert_eq!(shape_offset(&sh, None, 0, 1), (0, 0)); + } + + #[test] + fn heads_are_phase_shifted() { + let sh = Shape::Sweep { + beats: 4, + size: 255, + }; + let b = beat(0.5, 1, 0); + assert_ne!( + shape_offset(&sh, Some(&b), 0, 2), + shape_offset(&sh, Some(&b), 1, 2) + ); + } + + #[test] + fn shape_is_aligned_to_the_bar() { + let shape = Shape::Circle { + beats: 4, + size: 100, + }; + let at = |bar_beat: u8, count: u32| { + shape_offset(&shape, Some(&beat(0.25, bar_beat, count)), 0, 1) + }; + // A bar line is beat 1, whatever the count of the clock start was. + assert_eq!(at(1, 4), at(1, 8)); + assert_eq!(at(2, 5), at(2, 9)); + // Beat 2 of the same bar is one beat later than beat 1. + assert_ne!(at(1, 4), at(2, 5)); + } + + #[test] + fn shape_starts_on_beat_one_for_any_clock_start() { + let shape = Shape::Circle { + beats: 4, + size: 100, + }; + let start = shape_offset(&shape, Some(&beat(0.0, 1, 4)), 0, 1); + // The clock first saw beat 2, 3 or 4 of a bar, so the count of beat 1 differs. + for count in [5, 6, 7] { + assert_eq!( + shape_offset(&shape, Some(&beat(0.0, 1, count)), 0, 1), + start + ); + } + } + + #[test] + fn shape_of_eight_beats_restarts_every_second_bar() { + let shape = Shape::Circle { + beats: 8, + size: 100, + }; + let at = |bar_beat: u8, count: u32| { + shape_offset(&shape, Some(&beat(0.0, bar_beat, count)), 0, 1) + }; + assert_eq!(at(1, 0), at(1, 8)); + assert_ne!(at(1, 0), at(1, 4)); + assert_eq!(at(1, 4), at(1, 12)); + // A clock that started on beat 2 gives the same index on beat 1. + assert_eq!(at(1, 3), at(1, 0)); + } + + #[test] + fn unknown_bar_keeps_the_count_index() { + let shape = Shape::Circle { + beats: 8, + size: 100, + }; + let at = |count: u32| shape_offset(&shape, Some(&beat(0.0, 0, count)), 0, 1); + assert_ne!(at(1), at(2)); + assert_eq!(at(1), at(9)); + let e = Effect::Chase { every: Every::Bar }; + let lit = |count| (0..3).find(|&k| factor(&e, Some(&beat(0.0, 0, count)), k, 3) == 255); + // The bar index steps every 4 beats and does not freeze. + assert_eq!(lit(0), lit(3)); + assert_ne!(lit(0), lit(4)); + let f = Effect::Flash { every: Every::Bar }; + let v = |count| factor(&f, Some(&beat(0.5, 0, count)), 0, 1); + assert_ne!(v(0), v(2)); + } +} diff --git a/crates/autoblink-core-dmx/src/lib.rs b/crates/autoblink-core-dmx/src/lib.rs new file mode 100644 index 0000000..16a8aa4 --- /dev/null +++ b/crates/autoblink-core-dmx/src/lib.rs @@ -0,0 +1,20 @@ +//! The DMX planner logic of the AUTOBLINK box. +//! +//! This crate holds the show model: fixtures, groups and looks. It has no clock +//! and no I/O, like `autoblink-core-midi`. Thus the host can test it, and the +//! firmware can use it. + +pub mod show; +pub use show::*; +pub mod render; +pub use render::*; +pub mod clock; +pub use clock::*; +pub mod effect; +pub use effect::*; +pub mod lights; +pub use lights::*; +pub mod artnet; +pub use artnet::*; +pub mod merge; +pub use merge::*; diff --git a/crates/autoblink-core-dmx/src/lights.rs b/crates/autoblink-core-dmx/src/lights.rs new file mode 100644 index 0000000..1d25b90 --- /dev/null +++ b/crates/autoblink-core-dmx/src/lights.rs @@ -0,0 +1,646 @@ +//! The Lights engine: scene notes and fired events become a DMX frame. +//! +//! The firmware calls `frame` about 42 times a second. `frame` allocates no memory. + +use crate::clock::Beat; +use crate::effect::factor; +use crate::render::{fixture_channels, group_fixtures, render, touched, Universe}; +use crate::show::{Channel, Effect, Fade, Show, MAX_FIXTURES, UNIVERSE}; +use autoblink_core_midi::scenes::Event; + +/// The live controls of the page. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Live { + /// 255 is full. + pub master: u8, + pub strobe_hold: bool, +} + +/// A look shows for at least this long, also after a short note. +pub const MIN_ON_MS: u64 = 80; +/// The idle look shows this long after the stop of the playback. +pub const IDLE_AFTER_MS: u64 = 2000; +/// The fog button blows for this long. +pub const FOG_BUTTON_MS: u64 = 3000; +/// An aim from the page holds the pan and tilt of a fixture for this long. +pub const AIM_MS: u64 = 10_000; + +const DEFAULT_MS_PER_BEAT: f64 = 500.0; + +struct Layer { + preset: u8, + on_ms: u64, + manual: bool, + off_at: Option, +} + +/// A `Strobe` or `Fog` row that fired. It forces one channel kind of a group. +struct Burst { + group: u8, + channel: Channel, + value: u8, + until: u64, +} + +/// The pan, the tilt and the end time of the aim of one fixture. +#[derive(Clone, Copy)] +struct AimHold { + pan: u16, + tilt: u16, + until: u64, +} + +#[derive(Default)] +pub struct Lights { + /// The aim of each fixture. A fixed array, so that `frame` allocates nothing. + aims: [Option; MAX_FIXTURES], + layers: Vec, + bursts: Vec, + /// The last fire time of a row: (preset, row index, ms). + fires: Vec<(u8, usize, u64)>, + stopped_at: Option, + last_on_ms: Option, + fog_until: u64, + ms_per_beat: Option, +} + +impl Lights { + /// A scene note on. + pub fn on(&mut self, preset: u8, now_ms: u64) { + self.last_on_ms = Some(now_ms); + if let Some(l) = self.layers.iter_mut().find(|l| l.preset == preset) { + l.off_at = None; + l.manual = false; + return; + } + self.layers.push(Layer { + preset, + on_ms: now_ms, + manual: false, + off_at: None, + }); + } + + /// A scene note off. + pub fn off(&mut self, preset: u8, now_ms: u64) { + let Some(i) = self + .layers + .iter() + .position(|l| l.preset == preset && !l.manual) + else { + return; + }; + let min_end = self.layers[i].on_ms + MIN_ON_MS; + if now_ms < min_end { + self.layers[i].off_at = Some(min_end); + } else { + self.layers.remove(i); + } + } + + /// An event of the scenes fired a preset. + pub fn fired(&mut self, show: &Show, event: Event, preset: u8, now_ms: u64) { + if event.is_phrase_event() { + self.start_bursts(show, preset, now_ms, false); + } + if !event.is_overlay() { + self.layers.retain(|l| !l.manual); + } + } + + /// A click on the page: the preset shows as a manual look. + pub fn test(&mut self, show: &Show, preset: u8, now_ms: u64) { + self.layers.retain(|l| !l.manual); + self.layers.push(Layer { + preset, + on_ms: now_ms, + manual: true, + off_at: None, + }); + self.last_on_ms = Some(now_ms); + self.start_bursts(show, preset, now_ms, true); + } + + /// The fog button. + pub fn fog(&mut self, now_ms: u64) { + self.fog_until = now_ms + FOG_BUTTON_MS; + } + + /// The page aims a fixture. The aim holds the pan and tilt over every layer until `AIM_MS` pass. + pub fn aim(&mut self, fixture: u8, pan: u16, tilt: u16, now_ms: u64) { + if let Some(slot) = self.aims.get_mut(usize::from(fixture)) { + *slot = Some(AimHold { + pan, + tilt, + until: now_ms + AIM_MS, + }); + } + } + + pub fn set_playing(&mut self, playing: bool, now_ms: u64) { + self.stopped_at = if playing { None } else { Some(now_ms) }; + } + + /// The presets that show now, bottom first, for the page. + pub fn layers(&self) -> impl Iterator + '_ { + self.layers.iter().map(|l| l.preset) + } + + fn start_bursts(&mut self, show: &Show, preset: u8, now_ms: u64, ignore_cooldown: bool) { + let Some(look) = show.look(preset) else { + return; + }; + let mpb = self.ms_per_beat.unwrap_or(DEFAULT_MS_PER_BEAT); + for (r, row) in look.rows.iter().enumerate() { + let (channel, value, ms) = match row.effect { + Effect::Strobe { rate, beats } => (Channel::Strobe, rate, f64::from(beats) * mpb), + Effect::Fog { ms } => (Channel::Fog, 255, f64::from(ms)), + _ => continue, + }; + let last = self.fires.iter_mut().find(|f| f.0 == preset && f.1 == r); + if !ignore_cooldown { + if let Some(f) = &last { + if now_ms.saturating_sub(f.2) < u64::from(row.cooldown_s) * 1000 { + continue; + } + } + } + match last { + Some(f) => f.2 = now_ms, + None => self.fires.push((preset, r, now_ms)), + } + self.bursts.push(Burst { + group: row.group, + channel, + value, + until: now_ms + ms as u64, + }); + } + } + + /// Writes the DMX frame for `now_ms` into `out`. + pub fn frame( + &mut self, + show: &Show, + beat: Option<&Beat>, + live: &Live, + now_ms: u64, + out: &mut Universe, + ) { + if let Some(b) = beat { + self.ms_per_beat = Some(b.ms_per_beat); + } + let mpb = beat.map_or(DEFAULT_MS_PER_BEAT, |b| b.ms_per_beat); + self.layers.retain(|l| l.off_at.is_none_or(|t| t > now_ms)); + self.bursts.retain(|b| b.until > now_ms); + out.fill(0); + // A head that no look aims sits at the centre, not at the end stop. + for f in &show.fixtures { + for (i, c) in fixture_channels(f) { + if matches!(c, Channel::Pan | Channel::Tilt) { + out[i] = 0x80; + } + } + } + + let stopped = self + .stopped_at + .is_some_and(|s| now_ms >= s + IDLE_AFTER_MS && self.last_on_ms.is_none_or(|o| o < s)); + let mut scratch = [0u8; UNIVERSE]; + // The idle look is the bottom layer. Thus a layer without a look never blacks out the rig. + if let Some(look) = show.idle.and_then(|p| show.look(p)) { + render( + show, + look, + beat, + |r, k, n| factor(&look.rows[r].effect, beat, k, n), + out, + ); + } + if !stopped { + for layer in &self.layers { + let Some(look) = show.look(layer.preset) else { + continue; + }; + render( + show, + look, + beat, + |r, k, n| factor(&look.rows[r].effect, beat, k, n), + &mut scratch, + ); + let fade_ms = match look.fade { + Fade::Cut => 0.0, + Fade::Beat => mpb, + Fade::Bar => 4.0 * mpb, + }; + let age = now_ms.saturating_sub(layer.on_ms) as f64; + let alpha = if fade_ms > 0.0 { + (age / fade_ms).min(1.0) + } else { + 1.0 + }; + // ponytail: a fade between two positions blends the coarse and the fine byte + // alone. A pan across a byte border can jump for a moment. Blend the 16-bit pairs to fix it. + for ch in touched(show, look) { + let under = f64::from(out[ch]); + let top = f64::from(scratch[ch]); + out[ch] = (under + (top - under) * alpha).round() as u8; + } + } + } + + for (i, slot) in self.aims.iter_mut().enumerate() { + if slot.is_some_and(|a| a.until <= now_ms) { + *slot = None; + } + let (Some(a), Some(f)) = (*slot, show.fixtures.get(i)) else { + continue; + }; + for (ch, c) in fixture_channels(f) { + out[ch] = match c { + Channel::Pan => (a.pan >> 8) as u8, + Channel::PanFine => a.pan as u8, + Channel::Tilt => (a.tilt >> 8) as u8, + Channel::TiltFine => a.tilt as u8, + _ => continue, + }; + } + } + + for b in &self.bursts { + for (_, _, f) in group_fixtures(show, b.group) { + for (i, c) in fixture_channels(f) { + if c == b.channel { + out[i] = b.value; + } + } + } + } + let fog = now_ms < self.fog_until; + if fog || live.strobe_hold { + for f in &show.fixtures { + for (i, c) in fixture_channels(f) { + match c { + Channel::Fog if fog => out[i] = 255, + Channel::Strobe if live.strobe_hold => out[i] = 255, + _ => {} + } + } + } + } + if live.master != 255 { + let m = u32::from(live.master); + for f in &show.fixtures { + let has_dimmer = f.channels.contains(&Channel::Dimmer); + for (i, c) in fixture_channels(f) { + let scaled = match c { + Channel::Dimmer => true, + Channel::Red | Channel::Green | Channel::Blue | Channel::White => { + !has_dimmer + } + _ => false, + }; + if scaled { + out[i] = (u32::from(out[i]) * m / 255) as u8; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::show::{Fixture, Group, Look, LookRow}; + + fn row(group: u8, color: [u8; 4], intensity: u8, effect: Effect, cooldown_s: u16) -> LookRow { + LookRow { + group, + color, + intensity, + effect, + cooldown_s, + position: None, + shape: crate::show::Shape::None, + } + } + + fn par(color: [u8; 4], intensity: u8, fade: Fade) -> Look { + Look { + fade, + rows: vec![row(0, color, intensity, Effect::None, 0)], + } + } + + fn fixture(address: u16, channels: &[Channel]) -> Fixture { + Fixture { + name: "f".into(), + address, + channels: channels.to_vec(), + ofl: None, + } + } + + fn group(f: u8) -> Group { + Group { + name: "g".into(), + fixtures: vec![f], + } + } + + fn show() -> Show { + use Channel::*; + Show { + fixtures: vec![ + fixture(1, &[Dimmer, Red, Green, Blue]), + fixture(10, &[Strobe]), + fixture(20, &[Fog]), + ], + groups: vec![group(0), group(1), group(2)], + looks: vec![ + Some(par([255, 0, 0, 0], 255, Fade::Cut)), + Some(par([0, 0, 255, 0], 255, Fade::Cut)), + Some(Look { + fade: Fade::Cut, + rows: vec![ + row( + 1, + [0; 4], + 255, + Effect::Strobe { + rate: 200, + beats: 2, + }, + 30, + ), + row(2, [0; 4], 255, Effect::Fog { ms: 1000 }, 30), + ], + }), + Some(par([255, 255, 255, 0], 50, Fade::Cut)), + ], + idle: Some(3), + positions: Vec::new(), + } + } + + const LIVE: Live = Live { + master: 255, + strobe_hold: false, + }; + + fn frame(l: &mut Lights, s: &Show, now: u64) -> Universe { + let mut out = [0; UNIVERSE]; + l.frame(s, None, &LIVE, now, &mut out); + out + } + + #[test] + fn layers_stack_and_unstack() { + let s = show(); + let mut l = Lights::default(); + l.on(0, 0); + l.on(1, 100); + let f = frame(&mut l, &s, 200); + assert_eq!((f[1], f[3]), (0, 255)); + l.off(1, 300); + let f = frame(&mut l, &s, 400); + assert_eq!((f[1], f[3]), (255, 0)); + assert_eq!(l.layers().collect::>(), [0]); + } + + #[test] + fn pulse_shows_for_min_on() { + let s = show(); + let mut l = Lights::default(); + l.on(1, 1000); + l.off(1, 1000); + assert_eq!(frame(&mut l, &s, 1040)[3], 255); + let f = frame(&mut l, &s, 1081); + assert_eq!((f[0], f[3]), (50, 255)); + assert_eq!(f[1], 255); + assert_eq!(l.layers().count(), 0); + } + + #[test] + fn fade_beat() { + let mut s = show(); + s.looks[1] = Some(par([0, 0, 255, 0], 255, Fade::Beat)); + let beat = Beat { + phase: 0.0, + beat_in_bar: 1, + count: 0, + ms_per_beat: 500.0, + }; + let mut l = Lights::default(); + l.on(1, 0); + let mut out = [0; UNIVERSE]; + l.frame(&s, Some(&beat), &LIVE, 250, &mut out); + // The idle look (white, dimmer 50) is under the blue look (dimmer 255). + for (ch, want) in [(0, 152), (1, 127), (2, 127), (3, 255)] { + assert!(out[ch].abs_diff(want) <= 2, "{ch}: {}", out[ch]); + } + } + + #[test] + fn layer_without_look_shows_idle() { + let s = show(); + let mut l = Lights::default(); + l.on(9, 0); + assert_eq!(frame(&mut l, &s, 10)[0..4], [50, 255, 255, 255]); + } + + #[test] + fn on_makes_a_manual_layer_scene_held() { + let s = show(); + let mut l = Lights::default(); + l.test(&s, 1, 0); + l.on(1, 10); + l.fired(&s, Event::Drop, 1, 20); + assert_eq!(l.layers().collect::>(), [1]); + assert_eq!(frame(&mut l, &s, 30)[3], 255); + } + + #[test] + fn strobe_only_on_phrase_event() { + let s = show(); + let mut l = Lights::default(); + l.on(2, 0); + l.fired(&s, Event::Beat, 2, 0); + assert_eq!(frame(&mut l, &s, 10)[9], 0); + l.fired(&s, Event::Drop, 2, 100); + assert_eq!(frame(&mut l, &s, 200)[9], 200); + assert_eq!(frame(&mut l, &s, 1099)[9], 200); + assert_eq!(frame(&mut l, &s, 1101)[9], 0); + } + + #[test] + fn strobe_cooldown() { + let s = show(); + let mut l = Lights::default(); + l.on(2, 0); + l.fired(&s, Event::Drop, 2, 0); + assert_eq!(frame(&mut l, &s, 100)[9], 200); + l.fired(&s, Event::Drop, 2, 10_000); + assert_eq!(frame(&mut l, &s, 10_100)[9], 0); + l.fired(&s, Event::Drop, 2, 31_000); + assert_eq!(frame(&mut l, &s, 31_100)[9], 200); + } + + #[test] + fn test_ignores_cooldown() { + let s = show(); + let mut l = Lights::default(); + l.fired(&s, Event::Drop, 2, 0); + l.test(&s, 2, 1000); + assert_eq!(frame(&mut l, &s, 1100)[9], 200); + } + + #[test] + fn manual_layer_ends_on_next_scene() { + let s = show(); + let mut l = Lights::default(); + l.test(&s, 0, 0); + l.fired(&s, Event::Beat, 1, 10); + assert_eq!(l.layers().collect::>(), [0]); + l.fired(&s, Event::Drop, 1, 20); + assert_eq!(l.layers().count(), 0); + } + + #[test] + fn no_layer_shows_idle() { + let s = show(); + let f = frame(&mut Lights::default(), &s, 0); + assert_eq!(f[0..4], [50, 255, 255, 255]); + } + + #[test] + fn stop_without_look_shows_idle_after_2_s() { + let s = show(); + let mut l = Lights::default(); + l.on(0, 0); + l.set_playing(false, 1); + assert_eq!(frame(&mut l, &s, 2000)[1], 255); + assert_eq!(frame(&mut l, &s, 1999)[0], 255); + assert_eq!(frame(&mut l, &s, 2001)[0], 50); + + let mut l = Lights::default(); + l.on(0, 0); + l.set_playing(false, 0); + l.on(1, 500); + assert_eq!(frame(&mut l, &s, 2001)[3], 255); + } + + #[test] + fn stop_look_in_same_ms_stays() { + let s = show(); + let mut l = Lights::default(); + l.set_playing(false, 0); + l.on(1, 0); + assert_eq!(frame(&mut l, &s, 2001)[3], 255); + } + + #[test] + fn master_scales_dimmer() { + let s = show(); + let mut l = Lights::default(); + l.on(0, 0); + let mut out = [0; UNIVERSE]; + let live = Live { + master: 128, + strobe_hold: false, + }; + l.frame(&s, None, &live, 10, &mut out); + assert!((127..=128).contains(&out[0]), "{}", out[0]); + } + + #[test] + fn strobe_hold_and_fog_button() { + let s = show(); + let mut l = Lights::default(); + let mut out = [0; UNIVERSE]; + let live = Live { + master: 255, + strobe_hold: true, + }; + l.frame(&s, None, &live, 0, &mut out); + assert_eq!(out[9], 255); + l.fog(0); + assert_eq!(frame(&mut l, &s, 2999)[19], 255); + assert_eq!(frame(&mut l, &s, 3001)[19], 0); + } + + #[test] + fn aim_override_and_timeout() { + use Channel::*; + let mut s = show(); + s.fixtures + .push(fixture(30, &[Pan, PanFine, Tilt, TiltFine])); + let mut l = Lights::default(); + l.aim(3, 0x1234, 0xABCD, 0); + assert_eq!(frame(&mut l, &s, 9_999)[29..33], [0x12, 0x34, 0xAB, 0xCD]); + assert_eq!(frame(&mut l, &s, 10_001)[29..33], [0x80, 0, 0x80, 0]); + } + + #[test] + fn aim_wins_over_layer_and_idle_position() { + use Channel::*; + let mut s = show(); + s.fixtures + .push(fixture(30, &[Dimmer, Pan, PanFine, Tilt, TiltFine])); + s.groups.push(group(3)); + s.positions = vec![crate::show::Position { + name: "a".into(), + aims: vec![crate::show::Aim { + fixture: 3, + pan: 0x2000, + tilt: 0x3000, + }], + }]; + let mut idle = par([1, 2, 3, 0], 255, Fade::Cut); + idle.rows = vec![row(3, [1, 2, 3, 0], 255, Effect::None, 0)]; + idle.rows[0].position = Some(0); + s.looks[3] = Some(idle); + let mut l = Lights::default(); + l.on(0, 0); + assert_eq!(frame(&mut l, &s, 10)[30..34], [0x20, 0, 0x30, 0]); + l.aim(3, 0x1234, 0xABCD, 20); + assert_eq!(frame(&mut l, &s, 30)[30..34], [0x12, 0x34, 0xAB, 0xCD]); + } + + #[test] + fn layer_without_aim_keeps_idle_position() { + use Channel::*; + let mut s = show(); + s.fixtures + .push(fixture(30, &[Dimmer, Pan, PanFine, Tilt, TiltFine])); + s.groups.push(group(3)); + s.positions = vec![crate::show::Position { + name: "a".into(), + aims: vec![crate::show::Aim { + fixture: 3, + pan: 0x2000, + tilt: 0x3000, + }], + }]; + let mut idle = par([1, 2, 3, 0], 255, Fade::Cut); + idle.rows = vec![row(3, [1, 2, 3, 0], 255, Effect::None, 0)]; + idle.rows[0].position = Some(0); + s.looks[3] = Some(idle); + let mut flash = par([255, 0, 0, 0], 255, Fade::Cut); + flash.rows = vec![row(3, [255, 0, 0, 0], 255, Effect::None, 0)]; + s.looks[0] = Some(flash); + let mut l = Lights::default(); + l.on(0, 0); + assert_eq!(frame(&mut l, &s, 10)[30..34], [0x20, 0, 0x30, 0]); + let mut empty = show(); + empty + .fixtures + .push(fixture(30, &[Pan, PanFine, Tilt, TiltFine])); + assert_eq!( + frame(&mut Lights::default(), &empty, 10)[29..33], + [0x80, 0, 0x80, 0] + ); + } +} diff --git a/crates/autoblink-core-dmx/src/merge.rs b/crates/autoblink-core-dmx/src/merge.rs new file mode 100644 index 0000000..24fb957 --- /dev/null +++ b/crates/autoblink-core-dmx/src/merge.rs @@ -0,0 +1,182 @@ +//! The merge of the engine frame and the Art-Net frame. +//! +//! After a timeout, the first live frame claims every non-zero channel. A channel that the desk +//! holds at 0 stays with the engine. +//! +//! HTP (the higher value wins) applies to the intensity and colour channels. +//! LTP (the source that changed the channel last wins) applies to all other channels. + +use crate::render::{fixture_channels, Universe}; +use crate::show::{Channel, Show, UNIVERSE}; + +/// Art-Net counts as live for this time after its last frame. +pub const ARTNET_TIMEOUT_MS: u64 = 3_000; + +pub struct Merge { + /// True when Art-Net owns the channel (LTP). + art_owns: [bool; UNIVERSE], + prev_engine: Universe, + prev_artnet: Universe, +} + +impl Default for Merge { + fn default() -> Self { + Self { + art_owns: [false; UNIVERSE], + prev_engine: [0; UNIVERSE], + prev_artnet: [0; UNIVERSE], + } + } +} + +fn is_htp(c: Channel) -> bool { + use Channel::*; + matches!(c, Dimmer | Red | Green | Blue | White | Amber | Uv | Strobe) +} + +impl Merge { + /// Merges `artnet` (the last frame and its time) into `engine` in place, by the kinds of `show`. + pub fn apply( + &mut self, + show: &Show, + artnet: Option<(&Universe, u64)>, + now_ms: u64, + engine: &mut Universe, + ) { + let live = artnet.filter(|&(_, t)| now_ms.saturating_sub(t) <= ARTNET_TIMEOUT_MS); + let Some((art, _)) = live else { + self.art_owns = [false; UNIVERSE]; + self.prev_engine = *engine; + self.prev_artnet = [0; UNIVERSE]; + return; + }; + let mut htp = [false; UNIVERSE]; + for f in &show.fixtures { + for (i, c) in fixture_channels(f) { + htp[i] = is_htp(c); + } + } + for i in 0..UNIVERSE { + if engine[i] != self.prev_engine[i] { + self.art_owns[i] = false; + } + if art[i] != self.prev_artnet[i] { + self.art_owns[i] = true; + } + } + self.prev_engine = *engine; + self.prev_artnet = *art; + for i in 0..UNIVERSE { + if htp[i] { + engine[i] = engine[i].max(art[i]); + } else if self.art_owns[i] { + engine[i] = art[i]; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::show::Fixture; + + fn show(channels: &[Channel]) -> Show { + Show { + fixtures: vec![Fixture { + name: String::new(), + address: 1, + channels: channels.to_vec(), + ofl: None, + }], + ..Show::default() + } + } + + fn frame(v: &[u8]) -> Universe { + let mut u = [0; UNIVERSE]; + u[..v.len()].copy_from_slice(v); + u + } + + #[test] + fn htp_on_dimmer() { + let s = show(&[Channel::Dimmer]); + let mut m = Merge::default(); + let a = frame(&[200]); + let mut e = frame(&[100]); + m.apply(&s, Some((&a, 0)), 10, &mut e); + assert_eq!(e[0], 200); + let mut e = frame(&[220]); + m.apply(&s, Some((&a, 0)), 20, &mut e); + assert_eq!(e[0], 220); + } + + #[test] + fn ltp_on_pan() { + let s = show(&[Channel::Pan]); + let mut m = Merge::default(); + let mut e = frame(&[10]); + m.apply(&s, None, 0, &mut e); + let a = frame(&[50]); + let mut e = frame(&[10]); + m.apply(&s, Some((&a, 10)), 10, &mut e); + assert_eq!(e[0], 50); + let mut e = frame(&[60]); + m.apply(&s, Some((&a, 10)), 20, &mut e); + assert_eq!(e[0], 60); + } + + #[test] + fn unpatched_channel_is_ltp() { + let s = show(&[Channel::Dimmer]); + let mut m = Merge::default(); + let mut e = frame(&[0, 0, 90]); + m.apply(&s, None, 0, &mut e); + let a = frame(&[0, 0, 40]); + let mut e = frame(&[0, 0, 90]); + m.apply(&s, Some((&a, 5)), 5, &mut e); + assert_eq!(e[2], 40); + } + + #[test] + fn fog_is_ltp() { + let s = show(&[Channel::Fog]); + let mut m = Merge::default(); + let mut e = frame(&[200]); + m.apply(&s, None, 0, &mut e); + let a = frame(&[50]); + let mut e = frame(&[200]); + m.apply(&s, Some((&a, 5)), 5, &mut e); + assert_eq!(e[0], 50); + } + + #[test] + fn same_frame_change_gives_artnet() { + let s = show(&[Channel::Pan]); + let mut m = Merge::default(); + let mut e = frame(&[10]); + m.apply(&s, None, 0, &mut e); + let a = frame(&[50]); + let mut e = frame(&[60]); + m.apply(&s, Some((&a, 5)), 5, &mut e); + assert_eq!(e[0], 50); + } + + #[test] + fn artnet_times_out() { + let s = show(&[Channel::Pan]); + let mut m = Merge::default(); + let a = frame(&[50]); + let mut e = frame(&[10]); + m.apply(&s, Some((&a, 0)), 100, &mut e); + assert_eq!(e[0], 50); + let mut e = frame(&[10]); + m.apply(&s, Some((&a, 0)), 3_001, &mut e); + assert_eq!(e[0], 10); + assert!(!m.art_owns[0]); + let mut e = frame(&[10]); + m.apply(&s, Some((&a, 3_100)), 3_100, &mut e); + assert_eq!(e[0], 50); + } +} diff --git a/crates/autoblink-core-dmx/src/render.rs b/crates/autoblink-core-dmx/src/render.rs new file mode 100644 index 0000000..e84fa3a --- /dev/null +++ b/crates/autoblink-core-dmx/src/render.rs @@ -0,0 +1,304 @@ +//! Render a look into the DMX universe. + +use crate::clock::Beat; +use crate::effect::shape_offset; +use crate::show::{Channel, Fixture, Look, LookRow, Shape, Show, UNIVERSE}; + +/// One value for each channel of the universe. Index 0 is DMX channel 1. +pub type Universe = [u8; UNIVERSE]; + +/// The fixtures of a group, with the position of each fixture in the group. +/// A wrong index gives no fixture, because only an unsanitized show has one. +pub(crate) fn group_fixtures( + show: &Show, + group: u8, +) -> impl Iterator + '_ { + let ids = show + .groups + .get(usize::from(group)) + .map_or(&[][..], |g| &g.fixtures[..]); + ids.iter().enumerate().filter_map(move |(pos, &i)| { + show.fixtures + .get(usize::from(i)) + .map(|f| (pos, ids.len(), f)) + }) +} + +/// The channel indices of a fixture. The universe end cuts the list. +pub(crate) fn fixture_channels(f: &Fixture) -> impl Iterator + '_ { + let start = usize::from(f.address).saturating_sub(1); + f.channels + .iter() + .enumerate() + .map(move |(n, &c)| (start + n, c)) + .take_while(|&(i, _)| i < UNIVERSE) +} + +/// Writes the static values of `look` into `out`. `level(row, fixture_pos, fixture_count)` +/// gives the effect factor 0 to 255 of a fixture in a row; 255 is no change. +/// `beat` moves the pan and tilt of a row with a shape. +pub fn render( + show: &Show, + look: &Look, + beat: Option<&Beat>, + level: impl Fn(usize, usize, usize) -> u8, + out: &mut Universe, +) { + for (r, row) in look.rows.iter().enumerate() { + for (pos, count, f) in group_fixtures(show, row.group) { + let lvl = u32::from(level(r, pos, count)); + let intensity = u32::from(row.intensity); + let has_dimmer = f.channels.contains(&Channel::Dimmer); + let colour = |v: u8| { + if has_dimmer { + v + } else { + (u32::from(v) * intensity * lvl / (255 * 255)) as u8 + } + }; + let fixture_index = show.fixtures.iter().position(|x| std::ptr::eq(x, f)); + let base = row + .position + .and_then(|p| show.positions.get(usize::from(p))) + .and_then(|p| { + p.aims + .iter() + .find(|a| Some(usize::from(a.fixture)) == fixture_index) + }) + .map_or((32768, 32768), |a| (i32::from(a.pan), i32::from(a.tilt))); + let (dx, dy) = shape_offset(&row.shape, beat, pos, count); + let pan = (base.0 + dx).clamp(0, 65535) as u16; + let tilt = (base.1 + dy).clamp(0, 65535) as u16; + for (i, c) in fixture_channels(f) { + if !aims_heads(row) && is_pan_tilt(c) { + continue; + } + out[i] = match c { + Channel::Dimmer => (intensity * lvl / 255) as u8, + Channel::Red => colour(row.color[0]), + Channel::Green => colour(row.color[1]), + Channel::Blue => colour(row.color[2]), + Channel::White => colour(row.color[3]), + Channel::Amber | Channel::Uv | Channel::Strobe | Channel::Fog => 0, + Channel::Pan => (pan >> 8) as u8, + Channel::PanFine => pan as u8, + Channel::Tilt => (tilt >> 8) as u8, + Channel::TiltFine => tilt as u8, + Channel::Fixed(v) => v, + }; + } + } + } +} + +/// A row without a position and without a shape leaves the pan and tilt of its heads alone. +fn aims_heads(row: &LookRow) -> bool { + row.position.is_some() || row.shape != Shape::None +} + +fn is_pan_tilt(c: Channel) -> bool { + matches!( + c, + Channel::Pan | Channel::PanFine | Channel::Tilt | Channel::TiltFine + ) +} + +/// Returns the set of channel indices that `look` touches (the channels of the fixtures of its groups). +/// A channel can appear more than once. A row that aims no head does not touch pan and tilt. +pub fn touched<'a>(show: &'a Show, look: &'a Look) -> impl Iterator + 'a { + look.rows.iter().flat_map(move |row| { + group_fixtures(show, row.group).flat_map(move |(_, _, f)| { + fixture_channels(f) + .filter(move |&(_, c)| aims_heads(row) || !is_pan_tilt(c)) + .map(|(i, _)| i) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::show::{Effect, Fade, Group, LookRow}; + use std::collections::BTreeSet; + + fn fx(address: u16, channels: &[Channel]) -> Fixture { + Fixture { + name: "f".into(), + address, + channels: channels.to_vec(), + ofl: None, + } + } + + fn one(fixtures: Vec, color: [u8; 4], intensity: u8) -> (Show, Look) { + let show = Show { + groups: vec![Group { + name: "g".into(), + fixtures: (0..fixtures.len() as u8).collect(), + }], + fixtures, + ..Show::default() + }; + let look = Look { + fade: Fade::Cut, + rows: vec![LookRow { + group: 0, + color, + intensity, + effect: Effect::None, + cooldown_s: 0, + position: None, + shape: crate::show::Shape::None, + }], + }; + (show, look) + } + + fn run(show: &Show, look: &Look, level: u8) -> Universe { + let mut out = [0; UNIVERSE]; + render(show, look, None, |_, _, _| level, &mut out); + out + } + + use Channel::*; + + #[test] + fn rgb_par_without_dimmer_scales_colour() { + let (s, l) = one(vec![fx(1, &[Red, Green, Blue])], [255, 128, 0, 0], 128); + assert_eq!(run(&s, &l, 255)[0..3], [128, 64, 0]); + } + + #[test] + fn dimmer_takes_intensity() { + let (s, l) = one( + vec![fx(1, &[Dimmer, Red, Green, Blue])], + [10, 20, 30, 0], + 200, + ); + assert_eq!(run(&s, &l, 255)[0..4], [200, 10, 20, 30]); + } + + #[test] + fn level_scales_dimmer() { + let (s, l) = one( + vec![fx(1, &[Dimmer, Red, Green, Blue])], + [10, 20, 30, 0], + 200, + ); + assert_eq!(run(&s, &l, 128)[0], 100); + } + + #[test] + fn fixed_channel_keeps_value() { + let (s, l) = one(vec![fx(1, &[Fixed(7), Dimmer])], [0; 4], 255); + assert_eq!(run(&s, &l, 255)[0], 7); + } + + #[test] + fn fixture_past_512_is_cut() { + let (s, l) = one(vec![fx(511, &[Fixed(9); 4])], [0; 4], 255); + let out = run(&s, &l, 255); + let set: Vec = (0..UNIVERSE).filter(|&i| out[i] != 0).collect(); + assert_eq!(set, [510, 511]); + assert_eq!(touched(&s, &l).collect::>(), [510, 511]); + } + + #[test] + fn touched_lists_group_channels() { + let (s, l) = one( + vec![fx(1, &[Red, Green, Blue]), fx(10, &[Red, Green, Blue])], + [0; 4], + 255, + ); + let set: BTreeSet = touched(&s, &l).collect(); + assert_eq!(set, BTreeSet::from([0, 1, 2, 9, 10, 11])); + } + + #[test] + fn wrong_group_is_skipped() { + let (s, mut l) = one(vec![fx(1, &[Dimmer])], [0; 4], 255); + l.rows[0].group = 5; + assert_eq!(run(&s, &l, 255)[0], 0); + assert_eq!(touched(&s, &l).count(), 0); + } + + #[test] + fn head_with_shape_and_no_position_is_centred() { + let (s, mut l) = one(vec![fx(1, &[Pan, PanFine, Tilt, TiltFine])], [0; 4], 255); + l.rows[0].shape = Shape::Sweep { beats: 4, size: 0 }; + assert_eq!(run(&s, &l, 255)[0..4], [0x80, 0, 0x80, 0]); + } + + #[test] + fn row_without_aim_leaves_pan_tilt() { + let (s, mut l) = one( + vec![fx(1, &[Dimmer, Pan, PanFine, Tilt, TiltFine])], + [0; 4], + 255, + ); + l.rows[0].position = None; + l.rows[0].shape = Shape::None; + assert_eq!(run(&s, &l, 255)[1..5], [0, 0, 0, 0]); + assert_eq!( + touched(&s, &l).collect::>(), + BTreeSet::from([0]) + ); + } + + #[test] + fn position_sets_pan_tilt() { + let (mut s, mut l) = one(vec![fx(1, &[Pan, PanFine, Tilt, TiltFine])], [0; 4], 255); + s.positions = vec![crate::show::Position { + name: "p".into(), + aims: vec![crate::show::Aim { + fixture: 0, + pan: 0x1234, + tilt: 0xABCD, + }], + }]; + l.rows[0].position = Some(0); + assert_eq!(run(&s, &l, 255)[0..4], [0x12, 0x34, 0xAB, 0xCD]); + } + + #[test] + fn coarse_only_head() { + let (mut s, mut l) = one(vec![fx(1, &[Pan])], [0; 4], 255); + s.positions = vec![crate::show::Position { + name: "p".into(), + aims: vec![crate::show::Aim { + fixture: 0, + pan: 0x1234, + tilt: 0, + }], + }]; + l.rows[0].position = Some(0); + assert_eq!(run(&s, &l, 255)[0], 0x12); + } + + #[test] + fn shape_clamps() { + let (mut s, mut l) = one(vec![fx(1, &[Pan, PanFine])], [0; 4], 255); + s.positions = vec![crate::show::Position { + name: "p".into(), + aims: vec![crate::show::Aim { + fixture: 0, + pan: 65000, + tilt: 0, + }], + }]; + l.rows[0].position = Some(0); + l.rows[0].shape = crate::show::Shape::Sweep { + beats: 4, + size: 255, + }; + let b = Beat { + phase: 0.0, + beat_in_bar: 2, + count: 1, + ms_per_beat: 500.0, + }; + let mut out = [0; UNIVERSE]; + render(&s, &l, Some(&b), |_, _, _| 255, &mut out); + assert_eq!(out[0..2], [255, 255]); + } +} diff --git a/crates/autoblink-core-dmx/src/show.rs b/crates/autoblink-core-dmx/src/show.rs new file mode 100644 index 0000000..26e9ddf --- /dev/null +++ b/crates/autoblink-core-dmx/src/show.rs @@ -0,0 +1,664 @@ +//! The show model: the fixtures, the groups and the looks of one box. +//! +//! The firmware keeps a show as JSON. `Show::from_json` always returns a valid show. +//! No type here has an `f32` field, because LLVM for Xtensa cannot compile one. + +use std::fmt; +use std::marker::PhantomData; + +use serde::de::{IgnoredAny, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// The number of channels in one DMX universe. +pub const UNIVERSE: usize = 512; +pub const MAX_FIXTURES: usize = 32; +pub const MAX_CHANNELS: usize = 32; +pub const MAX_GROUPS: usize = 8; +pub const MAX_LOOKS: usize = 32; +pub const MAX_NAME: usize = 16; +/// The most positions of a show. +pub const MAX_POSITIONS: usize = 8; +/// The longest OFL source of a fixture, in characters. +pub const MAX_OFL: usize = 96; +/// The most rows of one look. A row sets one group, so more rows than groups give nothing. +pub const MAX_ROWS: usize = MAX_GROUPS; +/// The longest cooldown of a look row, in seconds. +const MAX_COOLDOWN_S: u16 = 600; + +/// The function of one DMX channel of a fixture. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Channel { + Dimmer, + Red, + Green, + Blue, + White, + Amber, + Uv, + Strobe, + Fog, + Pan, + PanFine, + Tilt, + TiltFine, + /// A channel that always sends this value. + Fixed(u8), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Fixture { + pub name: String, + /// The DMX start channel, 1 to 512. + pub address: u16, + #[serde(deserialize_with = "bounded::<_, _, MAX_CHANNELS>")] + pub channels: Vec, + /// The Open Fixture Library source: `/#`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ofl: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Group { + pub name: String, + /// Indices into `Show::fixtures`. + #[serde(deserialize_with = "bounded::<_, _, MAX_FIXTURES>")] + pub fixtures: Vec, +} + +/// How a look changes the light when it starts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Fade { + #[default] + Cut, + Beat, + Bar, +} + +/// How often an effect repeats. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Every { + #[default] + Beat, + Half, + Bar, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Effect { + #[default] + None, + Flash { + every: Every, + }, + Pulse { + every: Every, + }, + Chase { + every: Every, + }, + Random { + every: Every, + }, + Strobe { + rate: u8, + beats: u8, + }, + Fog { + ms: u16, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LookRow { + /// Index into `Show::groups`. + pub group: u8, + /// Red, green, blue and white. + pub color: [u8; 4], + pub intensity: u8, + #[serde(default)] + pub effect: Effect, + #[serde(default)] + pub cooldown_s: u16, + /// Index into `Show::positions`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, + #[serde(default, skip_serializing_if = "is_none_shape")] + pub shape: Shape, +} + +/// The pan and tilt of one fixture in a position, as 16-bit values. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Aim { + /// Index into `Show::fixtures`. + pub fixture: u8, + pub pan: u16, + pub tilt: u16, +} + +/// A named set of aims. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Position { + pub name: String, + #[serde(deserialize_with = "bounded::<_, _, MAX_FIXTURES>")] + pub aims: Vec, +} + +/// A movement around the position of a row. `beats` is the period (1 to 32). `size` 255 is a +/// quarter of the full pan and tilt range. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Shape { + #[default] + None, + Circle { + beats: u8, + size: u8, + }, + Figure8 { + beats: u8, + size: u8, + }, + Sweep { + beats: u8, + size: u8, + }, +} + +fn is_none_shape(s: &Shape) -> bool { + *s == Shape::None +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Look { + #[serde(default)] + pub fade: Fade, + #[serde(deserialize_with = "bounded::<_, _, MAX_ROWS>")] + pub rows: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Show { + #[serde(deserialize_with = "bounded::<_, _, MAX_FIXTURES>")] + pub fixtures: Vec, + #[serde(deserialize_with = "bounded::<_, _, MAX_GROUPS>")] + pub groups: Vec, + /// `looks[i]` is the look of preset `i`. + #[serde(deserialize_with = "bounded::<_, _, MAX_LOOKS>")] + pub looks: Vec>, + /// The preset that plays when no other look plays. + pub idle: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(deserialize_with = "bounded::<_, _, MAX_POSITIONS>")] + pub positions: Vec, +} + +/// Reads a JSON list and keeps only its first `N` entries. It reads the other entries as +/// `IgnoredAny`, so they use no memory. Without this limit, a long list can use all of the heap +/// of the firmware before `Show::sanitize` runs. +fn bounded<'de, D, T, const N: usize>(d: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct Bounded(PhantomData); + + impl<'de, T: Deserialize<'de>, const N: usize> Visitor<'de> for Bounded { + type Value = Vec; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a list") + } + + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut v = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(N)); + while v.len() < N { + match seq.next_element()? { + Some(x) => v.push(x), + None => return Ok(v), + } + } + while seq.next_element::()?.is_some() {} + Ok(v) + } + } + + d.deserialize_seq(Bounded::(PhantomData)) +} + +fn clean_to(name: &mut String, max: usize) { + *name = name.chars().filter(|c| !c.is_control()).take(max).collect(); +} + +fn clean(name: &mut String) { + clean_to(name, MAX_NAME); +} + +impl Show { + /// Cut the show to the limits and remove each wrong reference. + pub fn sanitize(&mut self) { + self.fixtures.truncate(MAX_FIXTURES); + for f in &mut self.fixtures { + clean(&mut f.name); + if let Some(o) = &mut f.ofl { + clean_to(o, MAX_OFL); + if o.is_empty() { + f.ofl = None; + } + } + f.address = f.address.clamp(1, UNIVERSE as u16); + f.channels.truncate(MAX_CHANNELS); + } + self.groups.truncate(MAX_GROUPS); + let fixtures = self.fixtures.len(); + for g in &mut self.groups { + clean(&mut g.name); + g.fixtures.retain(|&i| usize::from(i) < fixtures); + } + self.positions.truncate(MAX_POSITIONS); + for p in &mut self.positions { + clean(&mut p.name); + p.aims.retain(|a| usize::from(a.fixture) < fixtures); + let mut seen = [false; MAX_FIXTURES]; + p.aims + .retain(|a| !std::mem::replace(&mut seen[usize::from(a.fixture)], true)); + } + let positions = self.positions.len(); + self.looks.truncate(MAX_LOOKS); + let groups = self.groups.len(); + for look in self.looks.iter_mut().flatten() { + look.rows.retain(|r| usize::from(r.group) < groups); + look.rows.truncate(MAX_ROWS); + for r in &mut look.rows { + r.cooldown_s = r.cooldown_s.min(MAX_COOLDOWN_S); + if r.position.is_some_and(|p| usize::from(p) >= positions) { + r.position = None; + } + if let Shape::Circle { beats, .. } + | Shape::Figure8 { beats, .. } + | Shape::Sweep { beats, .. } = &mut r.shape + { + *beats = (*beats).clamp(1, 32); + } + match &mut r.effect { + Effect::Strobe { beats, .. } => *beats = (*beats).clamp(1, 16), + Effect::Fog { ms } => *ms = (*ms).clamp(100, 10000), + _ => {} + } + } + } + if self.idle.is_some_and(|i| self.look(i).is_none()) { + self.idle = None; + } + } + + /// The look of a preset, or `None` when the preset has no look. + pub fn look(&self, preset: u8) -> Option<&Look> { + self.looks.get(usize::from(preset))?.as_ref() + } + + /// Read a show from JSON. A bad document gives the empty show. + pub fn from_json(bytes: &[u8]) -> Show { + let mut show: Show = serde_json::from_slice(bytes).unwrap_or_default(); + show.sanitize(); + show + } +} + +#[cfg(test)] +mod tests { + // The tests build a show field by field, as the page does. + #![allow(clippy::field_reassign_with_default)] + use super::*; + + fn fixture(n: usize) -> Fixture { + Fixture { + name: format!("f{n}"), + address: 1, + channels: vec![Channel::Dimmer], + ofl: None, + } + } + + fn row(group: u8, effect: Effect) -> LookRow { + LookRow { + group, + color: [1, 2, 3, 4], + intensity: 255, + effect, + cooldown_s: 0, + position: None, + shape: Shape::None, + } + } + + #[test] + fn sanitize_cuts_limits() { + let mut s = Show::default(); + s.fixtures = (0..40).map(fixture).collect(); + s.fixtures[0].name = "x".repeat(30); + s.fixtures[0].channels = vec![Channel::Red; 40]; + s.groups = (0..10) + .map(|_| Group { + name: "g".into(), + fixtures: vec![], + }) + .collect(); + s.looks = (0..40).map(|_| None).collect(); + s.sanitize(); + assert_eq!(s.fixtures.len(), 32); + assert_eq!(s.fixtures[0].name.chars().count(), 16); + assert_eq!(s.fixtures[0].channels.len(), 32); + assert_eq!(s.groups.len(), 8); + assert_eq!(s.looks.len(), 32); + } + + #[test] + fn sanitize_drops_bad_refs() { + let mut s = Show::default(); + s.fixtures = vec![fixture(0)]; + s.groups = vec![Group { + name: "a".into(), + fixtures: vec![0, 99], + }]; + s.looks = vec![ + Some(Look { + fade: Fade::Cut, + rows: vec![row(0, Effect::None), row(9, Effect::None)], + }), + None, + None, + None, + None, + None, + ]; + s.idle = Some(5); + s.sanitize(); + assert_eq!(s.groups[0].fixtures, [0]); + assert_eq!(s.looks[0].as_ref().unwrap().rows.len(), 1); + assert_eq!(s.idle, None); + s.idle = Some(0); + s.sanitize(); + assert_eq!(s.idle, Some(0)); + } + + #[test] + fn sanitize_clamps_values() { + let mut s = Show::default(); + s.fixtures = vec![fixture(0), fixture(1)]; + s.fixtures[0].address = 0; + s.fixtures[1].address = 600; + s.groups = vec![Group { + name: "a".into(), + fixtures: vec![0], + }]; + let mut rows = vec![ + row(0, Effect::Strobe { rate: 5, beats: 0 }), + row(0, Effect::Strobe { rate: 5, beats: 99 }), + row(0, Effect::Fog { ms: 5 }), + row(0, Effect::Fog { ms: 60000 }), + row(0, Effect::None), + ]; + rows[4].cooldown_s = 5000; + s.looks = vec![Some(Look { + fade: Fade::Cut, + rows, + })]; + s.sanitize(); + assert_eq!((s.fixtures[0].address, s.fixtures[1].address), (1, 512)); + let r = &s.looks[0].as_ref().unwrap().rows; + assert_eq!(r[0].effect, Effect::Strobe { rate: 5, beats: 1 }); + assert_eq!(r[1].effect, Effect::Strobe { rate: 5, beats: 16 }); + assert_eq!(r[2].effect, Effect::Fog { ms: 100 }); + assert_eq!(r[3].effect, Effect::Fog { ms: 10000 }); + assert_eq!(r[4].cooldown_s, 600); + } + + #[test] + fn ofl_round_trip() { + let mut s = Show::default(); + let mut f = fixture(0); + f.ofl = Some("eurolite/led-bar-6-qcl-rgbw#4-channel".into()); + s.fixtures = vec![f, fixture(1)]; + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(json.matches("\"ofl\"").count(), 1); + assert_eq!(Show::from_json(json.as_bytes()), s); + } + + #[test] + fn ofl_sanitize() { + let mut s = Show::default(); + let mut f = fixture(0); + f.ofl = Some("a".repeat(200)); + let mut g = fixture(1); + g.ofl = Some("ab\ncd".into()); + let mut e = fixture(2); + e.ofl = Some("\n\t".into()); + s.fixtures = vec![f, g, e]; + s.sanitize(); + assert_eq!(s.fixtures[2].ofl, None); + assert_eq!(s.fixtures[0].ofl.as_ref().unwrap().chars().count(), 96); + assert_eq!(s.fixtures[1].ofl.as_deref(), Some("abcd")); + } + + #[test] + fn json_round_trip() { + let mut s = Show::default(); + s.fixtures = vec![Fixture { + name: "Par".into(), + address: 10, + channels: vec![ + Channel::Dimmer, + Channel::Red, + Channel::Green, + Channel::Blue, + Channel::White, + Channel::Amber, + Channel::Uv, + Channel::Strobe, + Channel::Fog, + Channel::Fixed(5), + ], + ofl: None, + }]; + s.groups = vec![Group { + name: "all".into(), + fixtures: vec![0], + }]; + let effects = [ + Effect::None, + Effect::Flash { every: Every::Beat }, + Effect::Pulse { every: Every::Half }, + Effect::Chase { every: Every::Bar }, + Effect::Random { every: Every::Beat }, + Effect::Strobe { rate: 8, beats: 2 }, + Effect::Fog { ms: 500 }, + ]; + s.looks = vec![Some(Look { + fade: Fade::Bar, + rows: effects.into_iter().map(|e| row(0, e)).collect(), + })]; + s.idle = Some(0); + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains(r#"{"fixed":5}"#)); + assert!(json.contains(r#""dimmer""#)); + assert_eq!(Show::from_json(json.as_bytes()), s); + } + + /// A JSON list with `n` copies of `item`. + fn list(item: &str, n: usize) -> String { + format!("[{}]", vec![item; n].join(",")) + } + + #[test] + fn read_cuts_looks_without_sanitize() { + let json = format!(r#"{{"looks":{}}}"#, list("null", 40)); + let s: Show = serde_json::from_str(&json).unwrap(); + assert_eq!(s.looks.len(), MAX_LOOKS); + } + + #[test] + fn read_cuts_rows_without_sanitize() { + let row = r#"{"group":0,"color":[0,0,0,0],"intensity":0}"#; + let json = format!(r#"{{"looks":[{{"rows":{}}}]}}"#, list(row, 20)); + let s: Show = serde_json::from_str(&json).unwrap(); + assert_eq!(s.looks[0].as_ref().unwrap().rows.len(), MAX_ROWS); + } + + #[test] + fn read_continues_after_a_cut_list() { + let fixture = format!( + r#"{{"name":"a","channels":{},"address":7}}"#, + list(r#"{"fixed":1}"#, 40) + ); + let group = format!(r#"{{"fixtures":{},"name":"g"}}"#, list("0", 40)); + let json = format!( + r#"{{"fixtures":{},"groups":{},"looks":{},"idle":3}}"#, + list(&fixture, 40), + list(&group, 10), + list("null", 40) + ); + let s: Show = serde_json::from_str(&json).unwrap(); + assert_eq!(s.fixtures.len(), MAX_FIXTURES); + assert_eq!(s.fixtures[31].channels.len(), MAX_CHANNELS); + assert_eq!(s.fixtures[31].address, 7); + assert_eq!(s.groups.len(), MAX_GROUPS); + assert_eq!(s.groups[7].fixtures.len(), MAX_FIXTURES); + assert_eq!(s.groups[7].name, "g"); + assert_eq!(s.idle, Some(3)); + } + + #[test] + fn sanitize_cuts_rows() { + let mut s = Show::default(); + s.fixtures = vec![fixture(0)]; + s.groups = vec![Group { + name: "a".into(), + fixtures: vec![0], + }]; + s.looks = vec![Some(Look { + fade: Fade::Cut, + rows: vec![row(0, Effect::None); 20], + })]; + s.sanitize(); + assert_eq!(s.looks[0].as_ref().unwrap().rows.len(), MAX_ROWS); + } + + #[test] + fn load_bad_json_gives_default() { + assert_eq!(Show::from_json(b"\xff\xff"), Show::default()); + assert_eq!(Show::from_json(b"{\"fixtures\":7}"), Show::default()); + } +} + +#[cfg(test)] +mod position_tests { + #![allow(clippy::field_reassign_with_default)] + use super::*; + + fn aim(fixture: u8) -> Aim { + Aim { + fixture, + pan: 1, + tilt: 2, + } + } + + #[test] + fn old_show_loads_without_positions() { + let json = r#"{"fixtures":[{"name":"a","address":1,"channels":["dimmer",{"fixed":5}]}],"groups":[{"name":"g","fixtures":[0]}],"looks":[{"fade":"cut","rows":[{"group":0,"color":[1,2,3,4],"intensity":255,"effect":"none","cooldown_s":0}]}],"idle":0}"#; + let s = Show::from_json(json.as_bytes()); + assert!(s.positions.is_empty()); + assert_eq!(serde_json::to_string(&s).unwrap(), json); + } + + #[test] + fn positions_sanitize() { + let mut s = Show::default(); + s.fixtures = vec![Fixture { + name: "a".into(), + address: 1, + channels: vec![Channel::Pan], + ofl: None, + }]; + s.groups = vec![Group { + name: "g".into(), + fixtures: vec![0], + }]; + s.positions = (0..10) + .map(|i| Position { + name: format!("position number {i}"), + aims: vec![aim(0), aim(40), aim(0)], + }) + .collect(); + let row = |shape| LookRow { + group: 0, + color: [0; 4], + intensity: 0, + effect: Effect::None, + cooldown_s: 0, + position: Some(9), + shape, + }; + s.looks = vec![Some(Look { + fade: Fade::Cut, + rows: vec![ + row(Shape::Circle { beats: 0, size: 9 }), + row(Shape::Sweep { beats: 99, size: 9 }), + ], + })]; + s.sanitize(); + assert_eq!(s.positions.len(), MAX_POSITIONS); + assert_eq!(s.positions[0].name.chars().count(), MAX_NAME); + assert_eq!(s.positions[0].aims, vec![aim(0)]); + let rows = &s.looks[0].as_ref().unwrap().rows; + assert_eq!(rows[0].position, None); + assert_eq!(rows[0].shape, Shape::Circle { beats: 1, size: 9 }); + assert_eq!(rows[1].shape, Shape::Sweep { beats: 32, size: 9 }); + } + + #[test] + fn pan_tilt_kinds_json() { + let c = [ + Channel::Pan, + Channel::PanFine, + Channel::Tilt, + Channel::TiltFine, + ]; + let json = serde_json::to_string(&c).unwrap(); + assert_eq!(json, r#"["pan","pan_fine","tilt","tilt_fine"]"#); + assert_eq!(serde_json::from_str::<[Channel; 4]>(&json).unwrap(), c); + } + + #[test] + fn shape_wire_format() { + for (shape, json) in [ + (Shape::None, r#""none""#), + ( + Shape::Circle { + beats: 4, + size: 100, + }, + r#"{"circle":{"beats":4,"size":100}}"#, + ), + ( + Shape::Figure8 { beats: 8, size: 50 }, + r#"{"figure8":{"beats":8,"size":50}}"#, + ), + ( + Shape::Sweep { + beats: 2, + size: 255, + }, + r#"{"sweep":{"beats":2,"size":255}}"#, + ), + ] { + assert_eq!(serde_json::to_string(&shape).unwrap(), json); + assert_eq!(serde_json::from_str::(json).unwrap(), shape); + } + } +} diff --git a/crates/autoblink-core-midi/src/guess.rs b/crates/autoblink-core-midi/src/guess.rs new file mode 100644 index 0000000..2c28fe4 --- /dev/null +++ b/crates/autoblink-core-midi/src/guess.rs @@ -0,0 +1,237 @@ +//! A guess of the phrase buckets from the energy of the waveform preview. +//! +//! The classifier is for a track without rekordbox phrases. It reads the PWV4 preview once and +//! keeps one byte of energy for each column. It then gives a bucket for each position of the +//! track. The thresholds come from the distribution of the track itself. + +use crate::scenes::Event; + +/// The largest number of values in the profile. +pub const PROFILE_LEN: usize = 600; +/// The columns that the moving average takes on each side of a column. +const SMOOTH: usize = 4; +/// The preview needs at least this number of columns. +const MIN_COLUMNS: usize = 32; +/// The energy distance from a threshold in which the previous bucket stays. +const HYSTERESIS: u8 = 8; +/// Below this distance of `hi` and `lo`, the track has no loud and no quiet parts. +const MIN_SPREAD: u8 = 24; + +/// The energy profile of one track and the thresholds of its buckets. +#[derive(Debug, Clone, PartialEq)] +pub struct DropGuess { + profile: Vec, + hi: u8, + lo: u8, + /// The median energy. + mid: u8, + /// The first and the last column with an energy of at least the median. + first_mid: usize, + last_mid: usize, +} + +impl DropGuess { + /// Builds the profile from PWV4 preview bytes (6 bytes for each column). It returns `None` + /// for a preview with fewer than 32 columns. + pub fn from_preview(preview: &[u8]) -> Option { + let cols = preview.len() / 6; + if cols < MIN_COLUMNS { + return None; + } + let energy = |c: usize| { + preview[c * 6..c * 6 + 6] + .iter() + .map(|&b| u32::from(b)) + .sum::() + / 6 + }; + // Fold the columns down to at most `PROFILE_LEN` values. + let n = cols.min(PROFILE_LEN); + let folded: Vec = (0..n) + .map(|i| { + let (a, b) = (i * cols / n, (i + 1) * cols / n); + (a..b).map(energy).sum::() / (b - a) as u32 + }) + .collect(); + let profile: Vec = (0..n) + .map(|i| { + let w = &folded[i.saturating_sub(SMOOTH)..(i + SMOOTH + 1).min(n)]; + (w.iter().sum::() / w.len() as u32) as u8 + }) + .collect(); + let mut sorted = profile.clone(); + sorted.sort_unstable(); + let (hi, lo, mid) = (sorted[n * 70 / 100], sorted[n * 30 / 100], sorted[n / 2]); + let first_mid = profile.iter().position(|&e| e >= mid)?; + let last_mid = profile.iter().rposition(|&e| e >= mid)?; + Some(DropGuess { + profile, + hi, + lo, + mid, + first_mid, + last_mid, + }) + } + + /// The thresholds as `(hi, lo, mid)`. Use them to tune `MIN_SPREAD` and `HYSTERESIS`. + pub fn thresholds(&self) -> (u8, u8, u8) { + (self.hi, self.lo, self.mid) + } + + /// The first and the last profile column with an energy of at least `mid`. + pub fn mid_columns(&self) -> (usize, usize) { + (self.first_mid, self.last_mid) + } + + /// The bucket for the block that starts half a block (8 beats) after the playhead. A fire + /// at a block start decides the look of the coming block, so the classifier looks ahead. + /// A `bpm` of 0 or less gives no offset. A `len_ms` of 0 gives `None`. + pub fn bucket_ahead( + &self, + playhead_ms: u32, + len_ms: u32, + bpm: f64, + prev: Option, + ) -> Option { + if len_ms == 0 { + return None; + } + // ponytail: 8 beats cover 5 smoothed columns only while len_ms * bpm <= 57.6e6 (about + // 7.5 min at 128 BPM). A longer track fires one block late. Upgrade: use max(8 beats, + // 5 columns), capped below 16 beats, or test Drop on the unsmoothed profile. + let ahead_ms = if bpm > 0.0 { 8.0 * 60_000.0 / bpm } else { 0.0 }; + let frac = (f64::from(playhead_ms) + ahead_ms) / f64::from(len_ms); + Some(self.bucket_at(frac.clamp(0.0, 1.0), prev)) + } + + /// The bucket at the position `frac` (0.0 to 1.0) of the track. `prev` is the bucket that + /// the guess gave last. The classifier keeps a Drop or a Break while the energy stays + /// within a small band of the threshold that started it. + pub fn bucket_at(&self, frac: f64, prev: Option) -> Event { + let n = self.profile.len(); + let i = ((frac.clamp(0.0, 1.0) * n as f64) as usize).min(n - 1); + if i < self.first_mid { + return Event::Intro; + } + if i > self.last_mid { + return Event::Outro; + } + if self.hi.saturating_sub(self.lo) < MIN_SPREAD { + return Event::Groove; + } + let e = self.profile[i]; + if e >= self.hi || (prev == Some(Event::Drop) && e >= self.hi.saturating_sub(HYSTERESIS)) { + return Event::Drop; + } + if e <= self.lo || (prev == Some(Event::Break) && e <= self.lo.saturating_add(HYSTERESIS)) { + return Event::Break; + } + let ahead = (n / 50).max(1); + let peak = self.profile[i..(i + 1 + ahead).min(n)] + .iter() + .copied() + .max() + .unwrap_or(0); + if peak >= self.hi { + Event::Build + } else { + Event::Groove + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A preview with 6 equal bytes for each column. `runs` is (columns, value). + fn preview(runs: &[(usize, u8)]) -> Vec { + runs.iter() + .flat_map(|&(n, v)| std::iter::repeat_n(v, n * 6)) + .collect() + } + + fn guess(runs: &[(usize, u8)]) -> DropGuess { + DropGuess::from_preview(&preview(runs)).unwrap() + } + + #[test] + fn flat_track_is_groove() { + let g = guess(&[(600, 120)]); + for f in [0.0, 0.3, 0.5, 0.99, 1.0] { + assert_eq!(g.bucket_at(f, None), Event::Groove, "{f}"); + } + } + + #[test] + fn drop_and_break() { + let g = guess(&[(50, 20), (150, 200), (150, 20), (200, 200), (50, 20)]); + let at = |f| g.bucket_at(f, None); + assert_eq!(at(0.02), Event::Intro); + assert_eq!(at(0.2), Event::Drop); + assert_eq!(at(0.5), Event::Break); + assert_eq!(at(0.7), Event::Drop); + assert_eq!(at(0.98), Event::Outro); + } + + #[test] + fn build_before_drop() { + let g = guess(&[(200, 100), (200, 120), (200, 200)]); + let at = |f| g.bucket_at(f, None); + assert_eq!(at(0.5), Event::Groove); + assert_eq!(at(0.6), Event::Groove); + assert_eq!(at(0.665), Event::Build); + assert_eq!(at(0.8), Event::Drop); + } + + #[test] + fn hysteresis_holds_drop() { + let g = guess(&[(100, 20), (150, 200), (50, 194), (200, 200), (100, 20)]); + let f = 275.0 / 600.0; + assert_eq!(g.bucket_at(f, None), Event::Groove); + assert_eq!(g.bucket_at(f, Some(Event::Drop)), Event::Drop); + } + + /// A step from 20 to 200 at column 400 of 600. The block that starts at the step is a Drop. + /// The smoothing straddles the step, so the position of the step itself is not yet a Drop. + /// The look half a block (8 beats) ahead is a Drop at the step and half a block after it. + #[test] + fn classifies_half_a_block_ahead_of_the_step() { + let g = guess(&[(50, 20), (350, 20), (200, 200)]); + // 600 columns on a 600 000 ms track: 1000 ms for each column. At 120 BPM half a block + // is 4000 ms. + let (len, bpm) = (600_000, 120.0); + assert_ne!(g.bucket_at(400.0 / 600.0, None), Event::Drop); + assert_eq!(g.bucket_ahead(400_000, len, bpm, None), Some(Event::Drop)); + assert_eq!(g.bucket_ahead(404_000, len, bpm, None), Some(Event::Drop)); + // Without an offset the step itself is not yet a Drop. + assert_ne!(g.bucket_ahead(400_000, len, 0.0, None), Some(Event::Drop)); + assert_eq!(g.bucket_ahead(0, 0, bpm, None), None); + // A step down gives Break the same way. + let b = guess(&[(200, 200), (250, 20), (150, 200)]); + assert_ne!(b.bucket_at(200.0 / 600.0, None), Event::Break); + assert_eq!(b.bucket_ahead(200_000, len, bpm, None), Some(Event::Break)); + } + + #[test] + fn thresholds_are_exposed() { + let g = guess(&[(300, 20), (300, 200)]); + let (hi, lo, mid) = g.thresholds(); + assert!(lo <= mid && mid <= hi); + assert_eq!(hi, 200); + } + + #[test] + fn short_preview_is_none() { + assert!(DropGuess::from_preview(&preview(&[(31, 100)])).is_none()); + assert!(DropGuess::from_preview(&preview(&[(32, 100)])).is_some()); + } + + #[test] + fn folds_1200_to_600() { + let g = guess(&[(1200, 100)]); + assert_eq!(g.profile.len(), PROFILE_LEN); + assert!(g.profile.iter().all(|&e| e == 100)); + } +} diff --git a/crates/autoblink-core-midi/src/lib.rs b/crates/autoblink-core-midi/src/lib.rs index a2c2ae1..0c2e5d1 100644 --- a/crates/autoblink-core-midi/src/lib.rs +++ b/crates/autoblink-core-midi/src/lib.rs @@ -7,6 +7,7 @@ pub mod clock; pub mod config; +pub mod guess; pub mod mapper; pub mod scenes; pub mod settings; diff --git a/crates/autoblink-core-midi/src/scenes.rs b/crates/autoblink-core-midi/src/scenes.rs index ebdcc32..77d9284 100644 --- a/crates/autoblink-core-midi/src/scenes.rs +++ b/crates/autoblink-core-midi/src/scenes.rs @@ -86,6 +86,21 @@ impl Event { } } + /// Returns true for the events of the phrase structure. + pub fn is_phrase_event(self) -> bool { + matches!( + self, + Event::Intro + | Event::Groove + | Event::Build + | Event::Drop + | Event::Break + | Event::Outro + | Event::PhraseChange + | Event::Fill + ) + } + /// Returns true for the rows of the Rhythm group. pub fn is_overlay(self) -> bool { matches!( @@ -152,6 +167,15 @@ pub struct Row { pub gate: Gate, #[serde(default)] pub decay: Decay, + /// The hold of each entry of `presets`, in beats of the master. 0 or a missing entry means no + /// hold. The beats are `f64` because LLVM for Xtensa cannot compile serde code for an `f32`. + /// A row without holds leaves the key out, so that the JSON stays small. + #[serde(default, skip_serializing_if = "no_holds")] + pub holds: Vec, +} + +fn no_holds(holds: &[f64]) -> bool { + holds.iter().all(|&h| h == 0.0) } /// A note outside the range of the presets, with a name. @@ -227,10 +251,22 @@ impl ScenesConfig { let extras = self.extra.len(); let mut seen = Vec::new(); self.rows.retain_mut(|row| { - row.presets.retain(|&p| { - p < count || (p >= EXTRA_BASE && usize::from(p - EXTRA_BASE) < extras) - }); + // A hold stays with its preset, so the lists are cut together. + row.holds.resize(row.presets.len(), 0.0); + let valid = + |p: u8| p < count || (p >= EXTRA_BASE && usize::from(p - EXTRA_BASE) < extras); + let mut keep_entry = row.presets.iter().map(|&p| valid(p)); + row.holds.retain(|_| keep_entry.next().unwrap_or(false)); + row.presets.retain(|&p| valid(p)); row.presets.truncate(MAX_ROW_PRESETS); + row.holds.truncate(row.presets.len()); + for h in &mut row.holds { + *h = if h.is_finite() && *h > 0.0 { + h.clamp(0.0625, MAX_DECAY_BEATS) + } else { + 0.0 + }; + } if !ADVANCE_BARS.contains(&row.advance) { row.advance = 0; } @@ -287,6 +323,10 @@ pub struct SceneInput { pub phrase: Option, /// True while the bass is cut on all live mixer channels. It is `None` without mixer data. pub bass_cut: Option, + /// The bucket that the drop guess gives now. The engine uses it only without a phrase. + pub guess: Option, + /// The beat in the 16-beat block, 1 to 16. 0 means unknown. + pub phrase16: u8, } /// The current scene, for the page. @@ -302,6 +342,8 @@ pub struct SceneStatus { pub bars_left: Option, /// The phrase bucket of the master deck now. pub phrase: Option, + /// True when `phrase` comes from the guess, not from a phrase of the player. + pub guessed: bool, /// The number of times that each event occurred, in the order of `Event::ALL`. Each event /// counts, also without presets in its row. The count wraps at 256. The page flashes the /// title of a row when its count changes. @@ -320,6 +362,12 @@ struct Cursor { bars: u8, /// The time of the last trigger. fired_ms: u64, + /// The hold that keeps the row busy: (end time, channel, note). + hold: Option<(u64, u8, u8)>, + /// A trigger came while the row was busy. It fires when the hold ends. + pending: bool, + /// A bar step is among the pending triggers. The row advances before it fires. + pending_step: bool, } /// The scene engine has no clock and no I/O, like the mapper. @@ -338,6 +386,10 @@ pub struct Scenes { phrase_key: Option<(u8, u32, usize)>, /// The (device, track, phrase) that the fill-in row fired for. fill_key: Option<(u8, u32, usize)>, + /// The (device, track) that `guess_last` belongs to. + guess_key: Option<(u8, u32)>, + /// The last bucket that the guess fired for `guess_key`. + guess_last: Option, /// The phrase index that a beat predicted. The status change to it fires no overlay. predicted_idx: Option, /// The time of the last phrase change or fill-in overlay from the status. @@ -345,6 +397,8 @@ pub struct Scenes { ms_per_beat: f64, events: [u8; EVENT_COUNT], last: [Option; EVENT_COUNT], + /// The presets that rows sent since the last `drain_fired`: (event, preset entry). + fired: Vec<(Event, u8)>, } impl Default for Scenes { @@ -359,15 +413,21 @@ impl Default for Scenes { prev: SceneInput::default(), phrase_key: None, fill_key: None, + guess_key: None, + guess_last: None, predicted_idx: None, status_overlay_ms: None, ms_per_beat: 500.0, events: [0; EVENT_COUNT], last: [None; EVENT_COUNT], + fired: Vec::with_capacity(8), } } } +/// The most entries that `Scenes::fired` keeps. +const FIRED_MAX: usize = 16; + fn index(e: Event) -> usize { e as usize } @@ -460,6 +520,12 @@ impl Scenes { if cfg.row(Event::Play).is_none() { // Without a play row, the phrase row fires again below. self.phrase_key = None; + if let (None, Some(e)) = (s.phrase, s.guess) { + // The guess acts like a phrase: it fires again after a pause. + self.guess_key = Some((s.device, s.track)); + self.guess_last = Some(e); + self.event(cfg, e, now_ms, out); + } } } else if !s.playing && p.playing { self.event(cfg, Event::Stop, now_ms, out); @@ -505,26 +571,58 @@ impl Scenes { } } } - None => self.phrase_key = None, + None => { + self.phrase_key = None; + self.guess_update(cfg, s, now_ms, out); + } } self.prev = *s; } + /// Fires the guessed bucket at the start of a 16-beat block. It fires only when the guess + /// differs from the last bucket that the guess fired for this track. + fn guess_update( + &mut self, + cfg: &ScenesConfig, + s: &SceneInput, + now_ms: u64, + out: &mut impl FnMut(Out), + ) { + let key = Some((s.device, s.track)); + let key_changed = self.guess_key != key; + if key_changed { + self.guess_key = key; + self.guess_last = None; + } + let block_start = s.phrase16 == 1 && (self.prev.phrase16 != 1 || key_changed); + if let Some(e) = s.guess.filter(|_| block_start) { + if self.guess_last != Some(e) { + self.guess_last = Some(e); + self.event(cfg, e, now_ms, out); + } + } + } + /// Returns the time of the next flash note off, so that the caller can wake up for it. pub fn next_off_ms(&self) -> Option { - self.flashes.iter().map(|f| f.0).min() + let holds = self.cursors.iter().filter_map(|c| c.hold.map(|h| h.0)); + self.flashes.iter().map(|f| f.0).chain(holds).min() } /// Returns the current scene for the page. pub fn status(&self, cfg: &ScenesConfig) -> SceneStatus { - let phrase = self + let real = self .prev .phrase .and_then(|p| p.code) .and_then(Event::bucket); + let fired_guess = self.guess_last.filter(|_| self.prev.phrase.is_none()); + let guessed = fired_guess.is_some(); + let phrase = fired_guess.or(real); let Some((e, row)) = self.current.and_then(|e| Some((e, cfg.row(e)?))) else { return SceneStatus { phrase, + guessed, events: self.events, last: self.last, ..SceneStatus::default() @@ -543,6 +641,7 @@ impl Scenes { next, bars_left: (row.advance > 0).then(|| row.advance.saturating_sub(c.bars)), phrase, + guessed, events: self.events, last: self.last, } @@ -561,9 +660,19 @@ impl Scenes { /// Fires the current preset of a row, and moves the row to its next preset. fn fire(&mut self, cfg: &ScenesConfig, e: Event, now_ms: u64, out: &mut impl FnMut(Out)) { - if !cfg.enabled { + if !cfg.enabled || cfg.row(e).is_none() { return; } + let c = &mut self.cursors[index(e)]; + if c.hold.is_some() { + c.pending = true; + return; + } + self.fire_now(cfg, e, now_ms, out); + } + + /// Fires the row at once, also when it is busy. + fn fire_now(&mut self, cfg: &ScenesConfig, e: Event, now_ms: u64, out: &mut impl FnMut(Out)) { let Some(row) = cfg.row(e) else { return }; let len = row.presets.len() as u8; let c = &mut self.cursors[index(e)]; @@ -599,6 +708,11 @@ impl Scenes { continue; } c.bars = 0; + if c.hold.is_some() && self.current == Some(row.event) { + c.pending = true; + c.pending_step = true; + continue; + } advance(c, row.order, len, &mut self.rng); let step = c.pos % len; if self.current == Some(row.event) { @@ -618,6 +732,10 @@ impl Scenes { out: &mut impl FnMut(Out), ) { self.last[index(row.event)] = Some(step); + if self.fired.len() >= FIRED_MAX { + self.fired.remove(0); + } + self.fired.push((row.event, row.presets[usize::from(step)])); let ch = cfg.channel; let note = cfg.note(row.presets[usize::from(step)]); // A flash of the same note stays on. The new flash replaces its off time. @@ -645,6 +763,12 @@ impl Scenes { out(Out::NoteOff { ch: p.0, note: p.1 }); } out(Out::NoteOn { ch, note, vel: 127 }); + let hold = row.holds.get(usize::from(step)).copied().unwrap_or(0.0); + if hold > 0.0 { + let end = now_ms + (hold * self.ms_per_beat).max(1.0) as u64; + self.cursors[index(row.event)].hold = Some((end, ch, note)); + return; + } match row.gate { Gate::Pulse => { out(Out::NoteOff { ch, note }); @@ -661,6 +785,14 @@ impl Scenes { } } + /// Calls `f(event, preset)` for each preset that a row sent since the last call, in order. + /// The list keeps the newest 16 entries. + pub fn drain_fired(&mut self, mut f: impl FnMut(Event, u8)) { + for (e, p) in self.fired.drain(..) { + f(e, p); + } + } + /// Sends the note off of each flash that ended. A note that a hold row keeps on stays on. fn tick(&mut self, cfg: &ScenesConfig, now_ms: u64, out: &mut impl FnMut(Out)) { let held = self.held; @@ -678,6 +810,30 @@ impl Scenes { if ended { self.restore_held(cfg, out); } + for (i, e) in Event::ALL.into_iter().enumerate() { + let c = &mut self.cursors[i]; + let Some((end, ch, note)) = c.hold else { + continue; + }; + if end > now_ms { + continue; + } + c.hold = None; + let pending = std::mem::take(&mut c.pending); + let step = std::mem::take(&mut c.pending_step); + if held != Some((ch, note)) { + out(Out::NoteOff { ch, note }); + } + self.restore_held(cfg, out); + // A scene row that is no longer the current scene drops its queued fire. + if pending && cfg.enabled && (e.is_overlay() || self.current == Some(e)) { + if let Some(row) = cfg.row(e).filter(|r| step && r.advance > 0) { + let c = &mut self.cursors[i]; + advance(c, row.order, row.presets.len() as u8, &mut self.rng); + } + self.fire_now(cfg, e, now_ms, out); + } + } } /// Sends the held note again after an overlay went off. Only with one preset at a time. @@ -685,7 +841,16 @@ impl Scenes { if !cfg.one_at_a_time { return; } - if let Some((ch, note)) = self.held { + // A hold entry of a scene row is on too, and it shows the preset. Any newer held note + // took its place in `held` when it fired. + let hold = self + .cursors + .iter() + .zip(Event::ALL) + .filter(|(c, e)| c.hold.is_some() && !e.is_overlay()) + .max_by_key(|(c, _)| c.fired_ms) + .and_then(|(c, _)| c.hold.map(|h| (h.1, h.2))); + if let Some((ch, note)) = self.held.or(hold) { out(Out::NoteOn { ch, note, vel: 127 }); } } @@ -697,6 +862,13 @@ impl Scenes { if let Some((ch, note)) = self.held.take() { out(Out::NoteOff { ch, note }); } + for c in &mut self.cursors { + if let Some((_, ch, note)) = c.hold.take() { + out(Out::NoteOff { ch, note }); + } + c.pending = false; + c.pending_step = false; + } } } @@ -754,6 +926,7 @@ mod tests { order: Order::Up, gate: Gate::Pulse, decay: Decay::default(), + holds: Vec::new(), } } @@ -788,7 +961,223 @@ mod tests { fill_beats_left: None, }), bass_cut: None, + guess: None, + phrase16: 0, + } + } + + fn guess_in(track: u32, guess: Event, phrase16: u8) -> SceneInput { + SceneInput { + track, + phrase: None, + guess: Some(guess), + phrase16, + ..input(0, 0, 8) + } + } + + fn drops(s: &mut Scenes, c: &ScenesConfig, now: &mut u64, seq: &[(u32, Event, u8)]) -> usize { + let mut n = 0; + // Prime the engine at `now`, so that the Play edge of the first step fires nothing. + let prime = SceneInput { + guess: None, + phrase16: 0, + ..guess_in(seq[0].0, Event::Drop, 0) + }; + s.update(c, *now, &prime, &mut |_| {}); + for &(t, g, p) in seq { + *now += 500; + s.update(c, *now, &guess_in(t, g, p), &mut |_| {}); + s.drain_fired(|e, _| n += usize::from(e == Event::Drop)); + } + n + } + + #[test] + fn guess_fires_on_block_start() { + let c = cfg(vec![row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let mut now = 0; + assert_eq!(drops(&mut s, &c, &mut now, &[(7, Event::Drop, 16)]), 0); + assert_eq!(drops(&mut s, &c, &mut now, &[(7, Event::Drop, 1)]), 1); + let st = s.status(&c); + assert!(st.guessed); + assert_eq!(st.phrase, Some(Event::Drop)); + } + + #[test] + fn guess_waits_for_block_start() { + let c = cfg(vec![row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let mut now = 0; + let seq = [ + (7, Event::Intro, 6), + (7, Event::Drop, 7), + (7, Event::Drop, 8), + ]; + assert_eq!(drops(&mut s, &c, &mut now, &seq), 0); + assert_eq!( + drops( + &mut s, + &c, + &mut now, + &[(7, Event::Drop, 16), (7, Event::Drop, 1)] + ), + 1 + ); + } + + #[test] + fn same_guess_does_not_refire() { + let c = cfg(vec![row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let mut now = 0; + assert_eq!(drops(&mut s, &c, &mut now, &[(7, Event::Drop, 16)]), 0); + let seq = [ + (7, Event::Drop, 1), + (7, Event::Drop, 16), + (7, Event::Drop, 1), + ]; + assert_eq!(drops(&mut s, &c, &mut now, &seq), 1); + } + + #[test] + fn phrases_win() { + let c = cfg(vec![row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let with_phrase = |p16| SceneInput { + guess: Some(Event::Drop), + phrase16: p16, + ..input(0, 1, 8) + }; + s.update(&c, 0, &with_phrase(16), &mut |_| {}); + s.update(&c, 10, &with_phrase(1), &mut |_| {}); + let mut n = 0; + s.drain_fired(|e, _| n += usize::from(e == Event::Drop)); + assert_eq!(n, 0); + assert!(!s.status(&c).guessed); + } + + #[test] + fn play_without_row_refires_guess() { + let c = cfg(vec![row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let mut now = 0; + let seq = [(7, Event::Drop, 16), (7, Event::Drop, 1)]; + assert_eq!(drops(&mut s, &c, &mut now, &seq), 1); + let stopped = SceneInput { + playing: false, + ..guess_in(7, Event::Drop, 5) + }; + s.update(&c, 5000, &stopped, &mut |_| {}); + s.update(&c, 5100, &guess_in(7, Event::Drop, 5), &mut |_| {}); + let mut n = 0; + s.drain_fired(|e, _| n += usize::from(e == Event::Drop)); + assert_eq!(n, 1); + } + + #[test] + fn block_start_after_reset_on_beat_one() { + let c = cfg(vec![row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let mut now = 0; + let seq = [(7, Event::Drop, 1), (8, Event::Drop, 1)]; + assert_eq!(drops(&mut s, &c, &mut now, &seq), 2); + } + + #[test] + fn status_shows_the_fired_guess() { + let c = cfg(vec![row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let mut now = 0; + let idle = SceneInput { + playing: false, + ..guess_in(7, Event::Groove, 5) + }; + s.update(&c, 0, &idle, &mut |_| {}); + let st = s.status(&c); + assert!(!st.guessed); + assert_eq!(st.phrase, None); + let none = SceneInput { + guess: None, + ..idle + }; + s.update(&c, 5, &none, &mut |_| {}); + assert!(!s.status(&c).guessed); + drops( + &mut s, + &c, + &mut now, + &[(7, Event::Drop, 16), (7, Event::Drop, 1)], + ); + s.update(&c, 9000, &guess_in(7, Event::Break, 5), &mut |_| {}); + let st = s.status(&c); + assert!(st.guessed); + assert_eq!(st.phrase, Some(Event::Drop)); + } + + #[test] + fn track_change_resets_guess() { + let c = cfg(vec![row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let mut now = 0; + let seq = [ + (7, Event::Drop, 16), + (7, Event::Drop, 1), + (8, Event::Drop, 16), + (8, Event::Drop, 1), + ]; + assert_eq!(drops(&mut s, &c, &mut now, &seq), 2); + } + + #[test] + fn drain_fired_reports_event_and_preset() { + let c = cfg(vec![row(Event::Drop, &[3, 4])]); + let mut s = Scenes::default(); + s.update(&c, 1000, &input(0, 8, 16), &mut |_| {}); + let mut got = Vec::new(); + s.drain_fired(|e, p| got.push((e, p))); + assert_eq!(got, vec![(Event::Drop, 3)]); + let mut again = Vec::new(); + s.drain_fired(|e, p| again.push((e, p))); + assert!(again.is_empty()); + } + + #[test] + fn drain_fired_reports_bar_steps() { + let mut drop = row(Event::Drop, &[0, 1]); + drop.advance = 1; + let c = cfg(vec![drop]); + let mut s = Scenes::default(); + s.update(&c, 1000, &input(0, 8, 32), &mut |_| {}); + s.beat(&c, 3000, 1, &mut |_| {}); + let mut got = Vec::new(); + s.drain_fired(|e, p| got.push((e, p))); + assert_eq!(got, vec![(Event::Drop, 0), (Event::Drop, 1)]); + } + + #[test] + fn drain_fired_keeps_16_and_skips_disabled() { + let mut c = cfg(vec![row(Event::Beat, &[1])]); + let mut s = Scenes::default(); + for i in 0..20 { + s.beat(&c, 1000 + i * 500, 2, &mut |_| {}); } + let mut n = 0; + s.drain_fired(|_, _| n += 1); + assert_eq!(n, 16); + c.enabled = false; + s.beat(&c, 20000, 2, &mut |_| {}); + s.drain_fired(|_, _| n += 1); + assert_eq!(n, 16); + } + + #[test] + fn phrase_events() { + assert!(Event::Drop.is_phrase_event()); + assert!(Event::Fill.is_phrase_event()); + assert!(!Event::Beat.is_phrase_event()); + assert!(!Event::Stop.is_phrase_event()); } #[test] @@ -1170,6 +1559,8 @@ mod tests { bpm: 120.0, phrase: None, bass_cut: Some(false), + guess: None, + phrase16: 0, }; s.update(&cfg, 0, &base, &mut |_| {}); s.update( @@ -1271,4 +1662,187 @@ mod tests { ScenesConfig::default() ); } + + fn hold_cfg(holds: &[f64]) -> ScenesConfig { + let mut r = row(Event::Beat, &[0, 1]); + r.holds = holds.to_vec(); + cfg(vec![r]) + } + + #[test] + fn hold_blocks_and_queues() { + let c = hold_cfg(&[2.0, 0.0]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.beat(&c, 0, 2, &mut |o| out.push(o)); + assert_eq!( + out[0], + Out::NoteOn { + ch: 9, + note: 0, + vel: 127 + } + ); + out.clear(); + s.beat(&c, 500, 3, &mut |o| out.push(o)); + s.update(&c, 999, &input(0, 0, 30), &mut |o| out.push(o)); + assert!(out.is_empty()); + s.update(&c, 1000, &input(0, 0, 29), &mut |o| out.push(o)); + assert_eq!(out[0], Out::NoteOff { ch: 9, note: 0 }); + assert_eq!( + out[1], + Out::NoteOn { + ch: 9, + note: 1, + vel: 127 + } + ); + let mut fired = Vec::new(); + s.drain_fired(|e, p| fired.push((e, p))); + assert_eq!(fired, [(Event::Beat, 0), (Event::Beat, 1)]); + } + + #[test] + fn hold_without_pending_just_ends() { + let mut r = row(Event::Beat, &[0]); + r.holds = vec![1.0]; + let c = cfg(vec![r]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.beat(&c, 0, 2, &mut |o| out.push(o)); + out.clear(); + s.update(&c, 499, &input(0, 0, 30), &mut |o| out.push(o)); + assert!(out.is_empty()); + s.update(&c, 500, &input(0, 0, 30), &mut |o| out.push(o)); + assert_eq!(out, [Out::NoteOff { ch: 9, note: 0 }]); + assert_eq!(s.next_off_ms(), None); + } + + #[test] + fn holds_sanitize() { + let mut c = ScenesConfig { + count: 16, + rows: vec![Row { + holds: vec![1.0], + ..row(Event::Beat, &[0, 1, 2]) + }], + ..ScenesConfig::default() + }; + c.sanitize(); + assert_eq!(c.rows[0].holds, [1.0, 0.0, 0.0]); + c.rows[0].holds = vec![99.0, -1.0, f64::NAN, 5.0]; + c.sanitize(); + assert_eq!(c.rows[0].holds, [MAX_DECAY_BEATS, 0.0, 0.0]); + c.rows[0].holds = vec![0.01, 0.0, 1.0]; + c.sanitize(); + assert_eq!(c.rows[0].holds, [0.0625, 0.0, 1.0]); + } + + #[test] + fn next_off_includes_hold() { + let c = hold_cfg(&[2.0, 0.0]); + let mut s = Scenes::default(); + s.beat(&c, 0, 2, &mut |_| {}); + assert_eq!(s.next_off_ms(), Some(1000)); + } + + #[test] + fn old_json_without_holds_parses() { + let r: Row = serde_json::from_str(r#"{"event":"beat","presets":[1]}"#).unwrap(); + assert!(r.holds.is_empty()); + let c = cfg(vec![r]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.beat(&c, 0, 2, &mut |o| out.push(o)); + s.beat(&c, 500, 3, &mut |o| out.push(o)); + assert_eq!(out.len(), 4); + } + + #[test] + fn switching_off_clears_holds() { + let mut c = hold_cfg(&[2.0, 0.0]); + let mut s = Scenes::default(); + s.beat(&c, 0, 2, &mut |_| {}); + s.beat(&c, 500, 3, &mut |_| {}); + c.enabled = false; + let mut out = Vec::new(); + s.update(&c, 600, &input(0, 0, 30), &mut |o| out.push(o)); + assert_eq!(out, [Out::NoteOff { ch: 9, note: 0 }]); + assert_eq!(s.next_off_ms(), None); + c.enabled = true; + out.clear(); + s.update(&c, 1000, &input(0, 0, 29), &mut |o| out.push(o)); + assert!(out.is_empty()); + } + + #[test] + fn two_triggers_in_one_hold_give_one_queued_fire() { + let c = hold_cfg(&[2.0, 0.0]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.beat(&c, 0, 2, &mut |o| out.push(o)); + s.beat(&c, 500, 3, &mut |o| out.push(o)); + s.beat(&c, 750, 4, &mut |o| out.push(o)); + out.clear(); + s.update(&c, 1000, &input(0, 0, 29), &mut |o| out.push(o)); + assert_eq!(notes(&out), [1]); + out.clear(); + s.update(&c, 1100, &input(0, 0, 28), &mut |o| out.push(o)); + assert!(out.is_empty()); + } + + #[test] + fn a_pulse_overlay_restores_the_hold_note() { + let mut intro = row(Event::Intro, &[3]); + intro.holds = vec![8.0]; + let c = cfg(vec![intro, row(Event::Downbeat, &[7])]); + let mut s = Scenes::default(); + s.update(&c, 0, &input(0, 0, 32), &mut |_| {}); + let mut out = Vec::new(); + s.beat(&c, 1000, 1, &mut |o| out.push(o)); + assert_eq!( + out, + [ + Out::NoteOn { + ch: 9, + note: 7, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 7 }, + Out::NoteOn { + ch: 9, + note: 3, + vel: 127 + }, + ] + ); + } + + #[test] + fn a_queued_fire_of_a_replaced_scene_is_dropped() { + let mut intro = row(Event::Intro, &[1, 2]); + intro.holds = vec![32.0, 0.0]; + let c = cfg(vec![intro, row(Event::Groove, &[5])]); + let mut s = Scenes::default(); + s.update(&c, 0, &input(0, 3, 32), &mut |_| {}); + let mut out = Vec::new(); + s.event(&c, Event::Intro, 0, &mut |o| out.push(o)); + s.event(&c, Event::Groove, 100, &mut |o| out.push(o)); + s.event(&c, Event::Intro, 200, &mut |o| out.push(o)); + out.clear(); + s.update(&c, 16_000, &input(0, 3, 10), &mut |o| out.push(o)); + assert_eq!(notes(&out), Vec::::new()); + assert_eq!(s.current, Some(Event::Groove)); + } + + #[test] + fn rows_without_holds_serialize_without_the_key() { + let json = serde_json::to_string(&row(Event::Beat, &[1])).unwrap(); + assert!(!json.contains("holds")); + let mut r = row(Event::Beat, &[1]); + r.holds = vec![2.0]; + assert!(serde_json::to_string(&r) + .unwrap() + .contains("\"holds\":[2.0]")); + } } diff --git a/docs/superpowers/plans/2026-10-27-dmx-planner-1-looks.md b/docs/superpowers/plans/2026-10-27-dmx-planner-1-looks.md new file mode 100644 index 0000000..63eb356 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-dmx-planner-1-looks.md @@ -0,0 +1,389 @@ +# DMX Planner 1: Looks on the Box — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A preset of the DMX scenes can be a look. The ESP32 sends the look on its own DMX output (IO4), in time with the beat. + +**Architecture:** A new crate `autoblink-core-dmx` holds the show model, the look renderer, the beat effects, a beat clock and the `Lights` engine. The scene engine stays as it is. The `midi` thread forwards the scene notes and the fired rows to the `dmx` thread through a FreeRTOS queue. The `dmx` thread renders one frame about every 24 ms. The show is JSON in a new flash partition `show`. The page edits it with `GET /show` and `POST /show`. + +**Tech Stack:** Rust 2021, serde and serde_json, ESP-IDF 5.5.3 through esp-idf-svc 0.53, vanilla JS in `webui/index.html`. + +**Spec:** `docs/superpowers/specs/2026-10-27-dmx-planner-design.md` + +## Plan series + +This plan is the first of five. Each plan gives working software. The owner wants one release, so all five plans go on one branch before the merge. + +1. Looks on the box (this plan): generic fixtures, groups, looks, intensity effects, strobe and fog bursts, idle look, live strip. +2. OFL import: the box proxies the Open Fixture Library, the browser parses it. +3. Positions and shapes: pan and tilt channels, named positions, circle, figure 8 and sweep. +4. Drop guess: bucket events from the waveform energy for tracks without phrase analysis. +5. Art-Net and host: Art-Net input with the HTP/LTP merge, the host Lights panel, host Art-Net output to a node. + +## Global Constraints + +- Do not use `f32` in a type that serde reads on the firmware. Use `u8`, `u16` or `f64`. +- Do not use `std::sync::mpsc` toward a thread with a higher priority. Use `esp_idf_svc::hal::task::queue::Queue`. +- Do not use `std::io::copy` in a firmware thread. +- Build a message to the page as a `Serialize` struct, never as a `serde_json::Value` tree. +- The `dmx` thread has priority 6 on core 1. The `midi` thread has 10, `prolink-rx` has 8. +- The app image must stay below 0x1E0000 bytes. +- The show limits: 32 fixtures, 32 channels for each fixture, 8 groups, 32 looks (`scenes::MAX_PRESETS`), names of 16 characters. +- Comments, log lines and error strings follow ASD-STE100, as the rest of the repo. +- Bump the version to `1.24.0` in `Cargo.toml` (`[workspace.package]`) and in `esp32/Cargo.toml` in the last task. + +## Review Focus + +1. A show from an older or a damaged partition (erased flash, bad JSON, wrong limits) must give the default show, never a panic. Test: `show::tests::load_bad_json_gives_default` (Task 2) and the firmware load path (Task 6). +2. A fixture with an address near 512 must not write past the universe. Test: `render::tests::fixture_past_512_is_cut` (Task 3). +3. A pulse row sends note on and note off at once. The look must still show. Test: `lights::tests::pulse_shows_for_min_on` (Task 5). +4. The rig must not go dark when the master stops and the Stop row has no look. Test: `lights::tests::stop_without_look_shows_idle_after_2_s` (Task 5). +5. A late WiFi beat packet must not shift the flash. Test: `clock::tests::late_packets_keep_phase` (Task 4). + +--- + +### Task 1: The scene engine reports each fired preset + +**Files:** +- Modify: `crates/autoblink-core-midi/src/scenes.rs` (struct `Scenes`, `fire`, `step_bars`, tests) + +**Interfaces:** +- Produces: `Scenes::drain_fired(&mut self, f: impl FnMut(Event, u8))`. It calls `f(event, preset)` for each preset that a row sent since the last call, in order, and clears the list. `preset` is the entry of `Row::presets` (0-based preset index, or `EXTRA_BASE` and up). Also `pub fn is_phrase_event(e: Event) -> bool` on `Event`: true for Intro, Groove, Build, Drop, Break, Outro, PhraseChange and Fill. + +- [ ] **Step 1: Write the failing tests** in the `tests` module of `scenes.rs`: + +```rust +#[test] +fn drain_fired_reports_event_and_preset() { + let mut c = cfg(vec![row(Event::Drop, &[3, 4])]); + c.enabled = true; + let mut s = Scenes::default(); + let input = SceneInput { device: 1, track: 9, playing: true, bpm: 120.0, + phrase: Some(ScenePhrase { idx: 0, code: Some(8), beats_left: 16, fill_beats_left: None }), + bass_cut: None }; + s.update(&c, 1000, &input, &mut |_| {}); + let mut got = Vec::new(); + s.drain_fired(|e, p| got.push((e, p))); + assert_eq!(got, vec![(Event::Drop, 3)]); + let mut again = Vec::new(); + s.drain_fired(|e, p| again.push((e, p))); + assert!(again.is_empty()); +} + +#[test] +fn phrase_events() { + assert!(Event::Drop.is_phrase_event()); + assert!(Event::Fill.is_phrase_event()); + assert!(!Event::Beat.is_phrase_event()); + assert!(!Event::Stop.is_phrase_event()); +} +``` + +Add a test `drain_fired_reports_bar_steps`: a Drop row with `advance: 1` and presets `[0, 1]`. After the trigger and one downbeat beat later than half a beat, the drain gives `(Drop, 0)` and then `(Drop, 1)`. + +- [ ] **Step 2: Run the tests.** Run: `cargo test -p autoblink-core-midi scenes`. Expected: FAIL, `drain_fired` and `is_phrase_event` do not exist. +- [ ] **Step 3: Implement.** Add `fired: Vec<(Event, u8)>` to `Scenes` (capacity 8 in `Default`). Push in `send` with `row.event` and `row.presets[step]`. Do not push while `cfg.enabled` is false. Cap the list at 16 entries; drop the oldest. +- [ ] **Step 4: Run the tests.** Run: `cargo test -p autoblink-core-midi`. Expected: PASS, the old scene tests too. +- [ ] **Step 5: Commit.** `git commit -m "Report each fired preset of the DMX scenes"` + +### Task 2: The show model + +**Files:** +- Create: `crates/autoblink-core-dmx/Cargo.toml`, `crates/autoblink-core-dmx/src/lib.rs`, `crates/autoblink-core-dmx/src/show.rs` +- Modify: `Cargo.toml` (add the member `crates/autoblink-core-dmx`) + +**Interfaces:** +- Consumes: nothing. +- Produces, in `show.rs` (re-exported from `lib.rs`), all `Debug, Clone, PartialEq, Serialize, Deserialize`, enums `#[serde(rename_all = "snake_case")]`: + +```rust +pub const UNIVERSE: usize = 512; +pub const MAX_FIXTURES: usize = 32; +pub const MAX_CHANNELS: usize = 32; +pub const MAX_GROUPS: usize = 8; +pub const MAX_LOOKS: usize = 32; +pub const MAX_NAME: usize = 16; + +pub enum Channel { Dimmer, Red, Green, Blue, White, Amber, Uv, Strobe, Fog, Fixed(u8) } +pub struct Fixture { pub name: String, pub address: u16, pub channels: Vec } +pub struct Group { pub name: String, pub fixtures: Vec } +pub enum Fade { Cut, Beat, Bar } // Default: Cut +pub enum Every { Beat, Half, Bar } // Default: Beat +pub enum Effect { None, Flash { every: Every }, Pulse { every: Every }, Chase { every: Every }, + Random { every: Every }, Strobe { rate: u8, beats: u8 }, Fog { ms: u16 } } // Default: None +pub struct LookRow { pub group: u8, pub color: [u8; 4], pub intensity: u8, + #[serde(default)] pub effect: Effect, #[serde(default)] pub cooldown_s: u16 } +pub struct Look { #[serde(default)] pub fade: Fade, pub rows: Vec } +#[serde(default)] +pub struct Show { pub fixtures: Vec, pub groups: Vec, + pub looks: Vec>, pub idle: Option } // Default: all empty + +impl Show { + pub fn sanitize(&mut self); + pub fn look(&self, preset: u8) -> Option<&Look>; + pub fn from_json(bytes: &[u8]) -> Show; // bad JSON gives Show::default(); always sanitized +} +``` + +`color` is red, green, blue, white. `address` is the DMX start channel, 1 to 512. `looks[i]` is the look of preset `i`. + +- [ ] **Step 1: Write the failing tests** in `show.rs`: + - `sanitize_cuts_limits`: 40 fixtures, a name of 30 characters, 40 channels, 10 groups, 40 looks → 32, 16, 32, 8, 32. + - `sanitize_drops_bad_refs`: a group entry 99, a row with group 9, `idle: Some(5)` with `looks[5] == None` → the entry and the row are gone, `idle` is `None`. + - `sanitize_clamps_values`: address 0 → 1, address 600 → 512, `Strobe { beats: 0 }` → 1, `Strobe { beats: 99 }` → 16, `Fog { ms: 5 }` → 100, `Fog { ms: 60000 }` → 10000, `cooldown_s: 5000` → 600. + - `json_round_trip`: a show with each `Channel` variant and each `Effect` variant survives `to_string` then `from_json`. The JSON of `Channel::Fixed(5)` is `{"fixed":5}`, of `Channel::Dimmer` is `"dimmer"`. + - `load_bad_json_gives_default`: `from_json(b"\xff\xff")` and `from_json(b"{\"fixtures\":7}")` give `Show::default()`. +- [ ] **Step 2: Run the tests.** Run: `cargo test -p autoblink-core-dmx`. Expected: FAIL to compile. +- [ ] **Step 3: Implement** the types and the three methods. The crate depends on `serde` and on `autoblink-core-midi` (path), with `serde_json` as a normal dependency (`from_json` needs it). +- [ ] **Step 4: Run the tests.** Expected: PASS. +- [ ] **Step 5: Commit.** `git commit -m "Add the show model of the DMX planner"` + +### Task 3: Render a look into the universe + +**Files:** +- Create: `crates/autoblink-core-dmx/src/render.rs` + +**Interfaces:** +- Consumes: `Show`, `Look`, `Channel` (Task 2). +- Produces: + +```rust +/// One value for each channel of the universe. Index 0 is DMX channel 1. +pub type Universe = [u8; UNIVERSE]; +/// Writes the static values of `look` into `out`. `level(row, fixture_pos, fixture_count)` +/// gives the effect factor 0 to 255 of a fixture in a row; 255 is no change. +pub fn render(show: &Show, look: &Look, level: impl Fn(usize, usize, usize) -> u8, out: &mut Universe); +/// Returns the set of channel indices that `look` touches (the channels of the fixtures of its groups). +pub fn touched(show: &Show, look: &Look) -> impl Iterator + '_; +``` + +The channel rules, for each fixture of the group of a row: +- `Dimmer` = intensity × level / 255. +- `Red`, `Green`, `Blue`, `White` = the colour value. If the fixture has no `Dimmer`, also × intensity × level / 255². +- `Amber`, `Uv`, `Strobe`, `Fog` = 0. `Fixed(v)` = v. + +- [ ] **Step 1: Write the failing tests:** + - `rgb_par_without_dimmer_scales_colour`: fixture at address 1 with `[Red, Green, Blue]`, row colour `[255, 128, 0, 0]`, intensity 128, level 255 → `out[0..3] == [128, 64, 0]`. + - `dimmer_takes_intensity`: `[Dimmer, Red, Green, Blue]`, intensity 200, colour `[10, 20, 30, 0]` → `[200, 10, 20, 30]`. + - `level_scales_dimmer`: same fixture, level 128 → dimmer 100. + - `fixed_channel_keeps_value`: `[Fixed(7), Dimmer]` → `out[0] == 7`. + - `fixture_past_512_is_cut`: address 511 with 4 channels → only `out[510]` and `out[511]` change; no panic. + - `touched_lists_group_channels`: two fixtures in the group, 3 channels each at 1 and 10 → `{0,1,2,9,10,11}`. +- [ ] **Step 2: Run** `cargo test -p autoblink-core-dmx render`. Expected: FAIL. +- [ ] **Step 3: Implement** `render` and `touched`. +- [ ] **Step 4: Run** the tests. Expected: PASS. +- [ ] **Step 5: Commit.** `git commit -m "Render a DMX look into the universe"` + +### Task 4: The beat clock and the intensity effects + +**Files:** +- Create: `crates/autoblink-core-dmx/src/clock.rs`, `crates/autoblink-core-dmx/src/effect.rs` + +**Interfaces:** +- Produces: + +```rust +// clock.rs +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Beat { pub phase: f64, pub beat_in_bar: u8, pub count: u32, pub ms_per_beat: f64 } +#[derive(Default)] +pub struct BeatClock { /* private */ } +impl BeatClock { + pub fn beat(&mut self, now_ms: u64, beat_in_bar: u8, bpm: f64); + /// `None` before the first beat, and 2000 ms after the last beat packet. + pub fn now(&self, now_ms: u64) -> Option; +} +// effect.rs +/// The factor 0 to 255 of fixture `k` of `n` in a row with `effect`. 255 for `None`, `Strobe` and `Fog`. +pub fn factor(effect: &Effect, beat: Option<&Beat>, k: usize, n: usize) -> u8; +``` + +The clock rule: the clock keeps an anchor, the predicted time of the last beat. A packet earlier than the prediction moves the anchor to the packet. A late packet does not move it. If the earliest of the last 8 packets is still late by more than 20 ms, the anchor moves by that amount. The clock expects the next packet to have the next `beat_in_bar`. A packet with another `beat_in_bar`, a packet more than 1 beat late, or a packet more than half a beat early sets the anchor to the packet. WiFi packets can be 300 ms late, which is more than half a beat at 120 BPM. `now` predicts the beats after the anchor: `count` and `beat_in_bar` go on, and `phase` stays in 0 to 1. + +The effect formulas, with `p` the phase of the period (`Every::Beat`: the beat phase; `Half`: `frac(2 × phase)`; `Bar`: `((beat_in_bar − 1) + phase) / 4`) and `c` the period count (`count`, `2 × count + (phase ≥ 0.5)`, `count / 4`): +- Flash: `255 × (1 − p)³`. +- Pulse: `255 × (1 + cos 2πp) / 2`. +- Chase: 255 when `c mod n == k`, else 0. +- Random: 255 when `xorshift32(c + 1) mod n == k`, else 0. +- Without a beat (`None`), each effect gives 255. + +- [ ] **Step 1: Write the failing tests:** + - `clock::late_packets_keep_phase`: beats at 120 BPM, true times 0, 500, 1000, 1500, 2000 ms, packets late by 0, 300, 100, 250, 180 ms. At 2000 ms, `now(2000).phase < 0.02`, `count == 4`. + - `clock::early_packet_moves_anchor`: steady 500 ms beats, then a packet 40 ms early → the phase at that packet time is 0. + - `clock::stops_after_2_s`: `now(last + 2001)` is `None`. + - `clock::predicts_between_packets`: one beat at 0 with `beat_in_bar` 4, 120 BPM. `now(750)` gives `count == 1`, `beat_in_bar == 1`, `phase == 0.5`. + - `effect::flash_shape`: phase 0 → 255, phase 0.25 → 107 or 108, phase 0.99 → 0. + - `effect::pulse_shape`: phase 0 → 255, phase 0.5 → 0. + - `effect::chase_steps`: n 3, count 0..6 → lit k = 0,1,2,0,1,2. + - `effect::half_and_bar`: Flash Half at phase 0.5 → 255; Flash Bar at beat_in_bar 3, phase 0 → `255 × 0.5³` = 31 or 32. + - `effect::no_beat_is_full`: `factor(&Flash, None, 0, 1) == 255`. +- [ ] **Step 2: Run** `cargo test -p autoblink-core-dmx`. Expected: FAIL. +- [ ] **Step 3: Implement** `BeatClock` and `factor`. +- [ ] **Step 4: Run** the tests. Expected: PASS. +- [ ] **Step 5: Commit.** `git commit -m "Add the DMX beat clock and the intensity effects"` + +### Task 5: The Lights engine + +**Files:** +- Create: `crates/autoblink-core-dmx/src/lights.rs` + +**Interfaces:** +- Consumes: Tasks 2 to 4; `autoblink_core_midi::scenes::Event`. +- Produces: + +```rust +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Live { pub master: u8, pub strobe_hold: bool } // master 255 is full +pub const MIN_ON_MS: u64 = 80; +pub const IDLE_AFTER_MS: u64 = 2000; +pub const FOG_BUTTON_MS: u64 = 3000; +#[derive(Default)] +pub struct Lights { /* private */ } +impl Lights { + pub fn on(&mut self, preset: u8, now_ms: u64); // a scene note on + pub fn off(&mut self, preset: u8, now_ms: u64); // a scene note off + pub fn fired(&mut self, show: &Show, event: Event, preset: u8, now_ms: u64); + pub fn test(&mut self, show: &Show, preset: u8, now_ms: u64); // page click: a manual look + pub fn fog(&mut self, now_ms: u64); // the fog button + pub fn set_playing(&mut self, playing: bool, now_ms: u64); + pub fn frame(&mut self, show: &Show, beat: Option<&Beat>, live: &Live, now_ms: u64, out: &mut Universe); + /// The presets that show now, bottom first, for the page. + pub fn layers(&self) -> impl Iterator + '_; +} +``` + +The rules: +- The engine keeps a list of layers in the order of their `on`. An `on` of a layer that shows already does nothing. +- `frame` starts at 0 for each channel. It renders each layer with `render`, in order. A layer writes only its `touched` channels. A layer fades in from the values under it: `Cut` 0 ms, `Beat` one beat, `Bar` four beats (500 ms for each beat without a clock). +- An `off` less than `MIN_ON_MS` after the `on` takes effect at `on + MIN_ON_MS`. +- `fired` with a phrase event (`Event::is_phrase_event`) starts the `Strobe` and `Fog` rows of the look, if the cooldown of the row has passed. A `Strobe` burst sets the `Strobe` channels of the group to `rate` for `beats` beats. A `Fog` burst sets the `Fog` channels to 255 for `ms`. `fired` with any event that is not an overlay (`!is_overlay()`) removes the manual layers. +- `test` removes the manual layers, adds `preset` as a manual layer and starts its bursts without the cooldown. +- With no layer, the idle look shows. After `set_playing(false)`, the idle look shows when `IDLE_AFTER_MS` passed and no `on` came after the stop. +- `master` scales each `Dimmer` channel, and the colour channels of a fixture without `Dimmer`. `strobe_hold` sets each `Strobe` channel to 255. `fog` sets each `Fog` channel to 255 for `FOG_BUTTON_MS`. +- A look that the show does not have (`show.look(preset) == None`) is still a layer, but it renders nothing. + +- [ ] **Step 1: Write the failing tests** (a helper show: fixture 1 `[Dimmer, Red, Green, Blue]` at 1, fixture 2 `[Strobe]` at 10, fixture 3 `[Fog]` at 20; groups "par" `[0]`, "strobe" `[1]`, "fog" `[2]`; look 0 red par, look 1 blue par, look 2 a strobe row `Strobe { rate: 200, beats: 2 }` with `cooldown_s: 30` plus a fog row `Fog { ms: 1000 }`, idle look 3 white par at intensity 50): + - `layers_stack_and_unstack`: on 0, on 1 → blue; off 1 → red. + - `pulse_shows_for_min_on`: on 1, off 1 at the same ms → blue at +40 ms, the idle look at +81 ms. + - `fade_beat`: look 1 with `Fade::Beat`, a clock at 120 BPM → at +250 ms the blue channel is about half (127 ± 2). + - `strobe_only_on_phrase_event`: on 2 then `fired(Beat, 2)` → strobe channel 0; `fired(Drop, 2)` → 200, for 2 beats. + - `strobe_cooldown`: `fired(Drop, 2)` at 0 and at 10 000 ms → second burst gives 0; at 31 000 ms → 200. + - `test_ignores_cooldown`: after a Drop burst at 0, `test(2)` at 1000 ms → strobe 200. + - `manual_layer_ends_on_next_scene`: `test(0)`; `fired(Beat, 1)` keeps it; `fired(Drop, 1)` removes it. + - `no_layer_shows_idle`: no layers → par dimmer 50, colour white. + - `stop_without_look_shows_idle_after_2_s`: on 0, `set_playing(false)` at 0 → red at 1999 ms, idle at 2001 ms; an `on 1` at 500 ms → blue at 2001 ms. + - `master_scales_dimmer`: master 128 → red look dimmer 127 or 128. + - `strobe_hold_and_fog_button`: `strobe_hold` → channel 10 is 255; `fog(0)` → channel 20 is 255 at 2999 ms and 0 at 3001 ms. +- [ ] **Step 2: Run** `cargo test -p autoblink-core-dmx lights`. Expected: FAIL. +- [ ] **Step 3: Implement** `Lights`. +- [ ] **Step 4: Run** `cargo test --locked`. Expected: PASS for the whole workspace. +- [ ] **Step 5: Commit.** `git commit -m "Add the Lights engine of the DMX planner"` + +### Task 6: The show partition on the firmware + +**Files:** +- Modify: `esp32/partitions.csv` (add `show, data, 0x40, 0x3F0000, 0x10000`), `esp32/Cargo.toml` (add `autoblink-core-dmx`, make `dmx` a default feature: `default = ["dmx"]`) +- Create: `esp32/src/show.rs` + +**Interfaces:** +- Produces: + +```rust +pub struct ShowStore { /* EspPartition "show" */ } +impl ShowStore { + /// `None` when the partition table has no "show" partition (a box flashed before 1.24.0). + pub fn open() -> Option; + /// Reads the show. Erased flash or bad data gives `Show::default()`. + pub fn load(&mut self) -> Show; + /// Writes the show as JSON. Erases only the sectors that the new data needs. + pub fn save(&mut self, show: &Show) -> anyhow::Result<()>; + /// Streams the stored JSON to `w` in chunks of 1 KB. + pub fn write_json(&mut self, w: &mut impl FnMut(&[u8]) -> anyhow::Result<()>) -> anyhow::Result<()>; +} +``` + +The format: bytes 0 to 3 are the JSON length, little-endian `u32`. The JSON follows. A length of `0xFFFF_FFFF` or more than `0x10000 − 4` means "no show". `save` writes the JSON through a writer with a 256-byte buffer (`serde_json::to_writer`), and writes the length last. A power loss during a save thus leaves "no show", not half a show. `load` parses with `serde_json::from_reader` over a reader with a 256-byte buffer, then calls `sanitize`. + +- [ ] **Step 1: Implement** `show.rs`. Use `esp_idf_svc::partition::EspPartition::new("show")`. +- [ ] **Step 2: Build.** Run: `. ~/export-esp.sh && cd esp32 && cargo build --release`. Expected: the build passes. Check the size: `espflash save-image --chip esp32 target/xtensa-esp32-espidf/release/autoblink-esp32 /tmp/app.bin && stat -f%z /tmp/app.bin`. Expected: below 1966080 (0x1E0000). +- [ ] **Step 3: Commit.** `git commit -m "Add the show partition to the firmware"` + +### Task 7: The DMX thread renders the looks + +**Files:** +- Modify: `esp32/src/dmx.rs` (replace the beat flash), `esp32/src/main.rs` (start order, `State` fields), `esp32/src/midi.rs` (forward the scene notes) + +**Interfaces:** +- Consumes: `Lights`, `BeatClock`, `Show` (Tasks 2 to 5); `Scenes::drain_fired` (Task 1); `ShowStore` (Task 6). +- Produces: + +```rust +// dmx.rs +#[derive(Clone, Copy)] +pub enum DmxMsg { On(u8), Off(u8), Fired(Event, u8), Test(u8), Fog, Beat { beat_in_bar: u8, bpm: f64, at_ms: u64 } } +pub struct DmxLive { pub master: AtomicU8, pub strobe_hold: AtomicBool } // master starts at 255 +#[derive(Default, Clone, Serialize)] +pub struct DmxStatus { pub layers: Vec, pub master: u8, pub strobe_hold: bool } +pub fn start(uart: UART1<'static>, tx: Gpio4<'static>, rx: Gpio36<'static>, state: Arc, + show: Arc>) -> anyhow::Result>>; +// main.rs State: pub dmx_live: DmxLive, pub dmx_status: Mutex, pub show: Arc> +``` + +- [ ] **Step 1: Start the DMX output before the network.** In `run`, open the `ShowStore` and load the show right after `Settings::new`. Move the creation of `State` up to the same place; it needs nothing from the network. Start `dmx::start` before `net::start_wifi`, so that the maintenance start of an OTA update also sends the idle look. +- [ ] **Step 2: Write the frame loop.** Thread `crate::spawn_rt(c"dmx", 6 * 1024, 6, Some(Core::Core1), ..)`. Each pass: take the messages from the queue (wait until the next frame time), give them to `Lights` and `BeatClock`, call `lights.set_playing` from `state.tracker` master `is_playing`, call `lights.frame`, write the frame with `write_with_break` as today, update `dmx_status` without waiting for its lock (`try_lock`). +- [ ] **Step 3: Send the beats.** In the `prolink::spawn` beat closure, replace `dmx.beat()` with `dmx_q.send_back(DmxMsg::Beat { .. }, 0)`. A full queue drops the beat. +- [ ] **Step 4: Forward the scene notes.** In `midi.rs`, wrap the `send` closure that `scenes.beat` and `scenes.update` get. For `Out::NoteOn` and `Out::NoteOff` on `scenes_cfg.channel` with a note in `first_note .. first_note + count`, also queue `On(note − first_note)` or `Off(..)`. After each `scenes.beat` and `scenes.update`, call `drain_fired` and queue `Fired`. For a `scene_test`, also queue `Test(preset)`. Use `send_back(msg, 0)` everywhere; the `midi` thread never waits. +- [ ] **Step 5: Build and flash.** Run: `cd esp32 && cargo run --release`. The partition table changes, so this needs USB. Expected: the log shows "DMX output is on IO4" before the WiFi lines. +- [ ] **Step 6: Check the stacks.** Run: `curl http://autoblink.local/mem`. Expected: `dmx` and `midi` keep at least 1.5 KB free. Raise the stack size if not, and note the measured value in the `spawn_rt` comment. +- [ ] **Step 7: Commit.** `git commit -m "Send the DMX looks of the scenes on IO4"` + +### Task 8: The show and the live controls over the web + +**Files:** +- Modify: `esp32/src/webui.rs` + +**Interfaces:** +- Consumes: `ShowStore`, `DmxLive`, `DmxStatus` (Tasks 6, 7). +- Produces: + - `GET /show`: the stored JSON, `Content-Type: application/json`, `Access-Control-Allow-Origin: *`. An empty store gives the JSON of `Show::default()`. + - `POST /show`: refuses a failed `origin_allowed` check with 403, and a body over 60 000 bytes with 413. Parses the body with `serde_json::from_reader` into `Show`, never into a `Value`. Calls `sanitize`, `ShowStore::save`, then replaces `State::show`. Replies 204. A parse error replies 400 with the serde message. + - WebSocket commands: `{"type":"dmx_live","master":0..255,"strobe":bool}` sets `DmxLive`; `{"type":"dmx_fog"}` queues `DmxMsg::Fog`. Both reply with no message. + - The hello caps get `"dmx"`. The status gets `"dmx": DmxStatus`. + +- [ ] **Step 1: Implement** the two routes, the two commands, the cap and the status field. +- [ ] **Step 2: Build and flash** as in Task 7. +- [ ] **Step 3: Check the routes.** Run: `curl -s http://autoblink.local/show` → `{"fixtures":[],...}`. Run: `curl -s -X POST -H 'Origin: http://evil.example' --data '{}' -o /dev/null -w '%{http_code}' http://autoblink.local/show` → `403`. Run the same POST without the `Origin` header and with a one-fixture show → `204`, and a new `GET` returns it. Restart the box; the `GET` still returns it. +- [ ] **Step 4: Commit.** `git commit -m "Serve the DMX show and the live controls"` + +### Task 9: The Lights panel + +**Files:** +- Modify: `webui/index.html` + +**Interfaces:** +- Consumes: `GET /show`, `POST /show`, `dmx_live`, `dmx_fog`, `scene_test`, status `dmx` (Task 8); the scene palette functions `sceneChip` and `renderScenes`. +- Produces: a panel `
` after `p-scenes`, with these parts, top to bottom: + 1. Live strip: master slider (sends `dmx_live`), "Strobe" button that holds while pressed (`pointerdown` → `strobe: true`, `pointerup` and `pointercancel` → `false`), "Fog" button (`dmx_fog`), and the names of the layers from `status.dmx.layers`. + 2. Patch: one line for each fixture: name, type, address. Add and remove buttons. The types come from `const FIXTURE_TYPES = { dimmer: ['dimmer'], rgb: ['red','green','blue'], rgb_dimmer: ['dimmer','red','green','blue'], rgbw: ['red','green','blue','white'], rgbw_dimmer: ['dimmer','red','green','blue','white'], strobe: ['dimmer','strobe'], fog: ['fog'] }`. The page shows the next free address as the default, and marks an overlap in red. + 3. Groups: name, and one toggle for each fixture. + 4. Look editor: a select of the presets 1 to `scenes.count`, with their labels. For the selected preset: fade (cut, 1 beat, 1 bar); one row for each group with a colour (8 swatches and an ``), a white slider for groups with a white channel, intensity (0 to 255), effect (none, flash, pulse, chase, random, strobe, fog), "every" (beat, ½ beat, bar) for the first four, rate and beats for strobe, ms for fog, cooldown in seconds for strobe and fog. A "Clear look" button sets `looks[i]` to `null`. + 5. Idle look: a select of the presets that have a look, or "none". +- The palette of the DMX scenes marks a preset that has a look with the class `look` (a small coloured corner in the colour of its first row). +- The page loads the show with `fetch(BASE + '/show')` after a hello with the `dmx` cap. Each edit saves the full show with `POST` 1 s after the last change. A failed save shows a toast with the status text. +- Each label and `title` of the panel is plain STE text, like the scene panel. + +- [ ] **Step 1: Implement** the panel. Work on it without a flash: `python3 -m http.server 8000 -d webui`, then open `http://localhost:8000/index.html?box=autoblink.local` (see AGENTS.md "Web page without a flash"). +- [ ] **Step 2: Check** in the browser: add an RGB par at 1 and a fog machine at 20, make the groups, give preset 1 a red look and preset 2 a blue flash look, drop them on the Drop and Beat rows of the scenes. Play a track with phrases. Expected: a DMX monitor shows red at the Chorus and blue flashes on the beat; the layers in the live strip follow. +- [ ] **Step 3: Screenshot** the panel to `.context/lights-panel.png`. +- [ ] **Step 4: Commit.** `git commit -m "Add the Lights panel for the DMX looks"` + +### Task 10: Version, docs and the box test + +**Files:** +- Modify: `Cargo.toml` and `esp32/Cargo.toml` (`1.24.0`), `AGENTS.md` + +- [ ] **Step 1: Bump** both versions to `1.24.0`. Run `cargo build` so that `Cargo.lock` and `esp32/Cargo.lock` follow. +- [ ] **Step 2: Update AGENTS.md:** the crate table (`crates/autoblink-core-dmx`), the `dmx` thread (priority 6, core 1, measured stack), the `show` partition and its format, the need for one USB flash, `GET /show` and `POST /show`, the removal of the beat flash on channel 1, and the `dmx` default feature. Run `sh ~/.claude/templates/ste-check.sh AGENTS.md`. +- [ ] **Step 3: Run** `cargo test --locked`. Expected: PASS. Build the firmware. Expected: the image is below 0x1E0000. +- [ ] **Step 4: Box test** (spec Verification 3, 4, 7 and 8): flash over USB; play a track with phrases → looks change at the phrase starts and the flash looks are on the beat; stop the deck and pull the Ethernet cable → the idle look after 2 s; restart the box → the idle look shows before the network starts; `curl http://autoblink.local/mem` with the page open → each task keeps its margin. +- [ ] **Step 5: Commit.** `git commit -m "Release the DMX looks as 1.24.0"` diff --git a/docs/superpowers/plans/2026-10-28-dmx-planner-2-ofl.md b/docs/superpowers/plans/2026-10-28-dmx-planner-2-ofl.md new file mode 100644 index 0000000..f812edb --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-dmx-planner-2-ofl.md @@ -0,0 +1,110 @@ +# DMX Planner 2: Open Fixture Library Import — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The owner adds a light by its model name. The box gets the definition from the Open Fixture Library (OFL), and the page turns the chosen mode into the channel list of the show. + +**Architecture:** The phone on the box's WiFi has no internet, so the box is the proxy. Two firmware routes fetch OFL over HTTPS on a short-lived thread with a 12 KB stack and pass the JSON through to the page. The page parses the JSON and maps each channel of the chosen mode to a `Channel` of the show. The show keeps the OFL key and the mode name of each light, so the light stays usable without internet. + +**Tech Stack:** Rust 2021, esp-idf-svc 0.53 HTTP client with the certificate bundle, vanilla JS. + +**Spec:** `docs/superpowers/specs/2026-10-27-dmx-planner-design.md` (Patch and fixtures; "OFL on the ESP32"; "OFL without internet"). + +## OFL facts (checked 2026-10-28) + +- Search: `POST https://open-fixture-library.org/api/v1/get-search-results` with the JSON body `{"searchQuery":"","manufacturersQuery":[],"categoriesQuery":[]}`. The reply is a JSON list of `{"key":"/","name":"","color":"#rrggbb"}`. The query "par" gives 108 results, about 10 KB. +- Fixture: `GET https://open-fixture-library.org//.json`. The reply is about 5 KB for a simple light. `modes[].name` and `modes[].channels` (a list of channel names, or `null` for an unused channel) give the modes. `availableChannels.` has `capability` (one) or `capabilities` (a list); each has a `type`, for example `Intensity`, `ColorIntensity` (with `color`: Red, Green, Blue, White, Amber, UV, Cyan, Magenta, Yellow, Lime, Indigo, Warm White, Cold White), `ShutterStrobe`, `Pan`, `Tilt`, `Fog`, `FogOutput`, `NoFunction`. A channel can have `defaultValue` and `fineChannelAliases`. + +## Global Constraints + +- The box has about 100 KB free heap with one track and the page open. TLS to an HTTPS server takes about 50 KB. An OFL request does not start below 80 KB free heap or a 32 KB largest block; the route then answers 503. +- An OFL reply body larger than 24 KB is cut off; the route answers 502 with a short text. +- TLS needs more stack than the HTTP task has. The fetch runs on a thread with a 12 KB stack (`crate::spawn`), and the handler waits for it. +- While a fetch runs, the status thread sends no status, as during an OTA check (the heap is short). +- Do not use `std::io::copy` in a firmware thread. Do not build a `serde_json::Value` on the firmware: the firmware passes the OFL bytes through and does not parse them. +- A key must match `^[a-z0-9-]+/[a-z0-9-]+$`. The firmware refuses any other key with 400. +- No f32 in a serde type that the firmware reads. +- Commits carry no attribution lines. Comments, log lines, error strings and docs follow ASD-STE100. +- Bump the version to `1.26.0` (workspace and `esp32/Cargo.toml`) in the last task. + +## Review Focus + +1. A box without internet (only its own WiFi): both routes answer 502 or 504 quickly with a clear text, and the page offers the generic types. Test: Task 3 stub with a 502 reply; box session. +2. A light with a matrix or a fine channel (16-bit pan): the mapping gives one `Channel` for each DMX channel of the mode, never fewer, so the addresses of the next lights stay right. Test: `oflChannels` unit cases in Task 3. +3. A search typed fast on a phone: the page sends at most one search at a time and drops stale results. Test: Task 3 stub. +4. Heap after ten fetches in a row: no leak. Test: box session `/mem`. +5. A reply that is not JSON (an OFL error page): the page shows the error, adds nothing. Test: Task 3 stub. + +--- + +### Task 1: The show keeps the OFL source of a light + +**Files:** Modify `crates/autoblink-core-dmx/src/show.rs`. + +**Interfaces:** +- Produces: `Fixture` gets `#[serde(default, skip_serializing_if = "Option::is_none")] pub ofl: Option` — `"/#"`. `sanitize` cuts it to 96 characters and drops control characters. `Channel` stays as it is (pan and tilt come in plan 3; the page maps them to `Fixed` for now). + +- [ ] **Step 1:** Write the tests `ofl_round_trip` (a fixture with `ofl: Some("eurolite/led-bar-6-qcl-rgbw#4-channel")` survives `to_string` + `from_json`; a fixture without it serializes without the key) and `ofl_sanitize` (200 characters → 96; a `\n` is dropped). +- [ ] **Step 2:** Run `cargo test -p autoblink-core-dmx`. Expected: FAIL. +- [ ] **Step 3:** Implement the field and the sanitize rule. +- [ ] **Step 4:** Run `cargo test --locked`, fmt, clippy. Expected: PASS. +- [ ] **Step 5:** Commit "Keep the Open Fixture Library source of a light". + +### Task 2: The box fetches from OFL + +**Files:** Create `esp32/src/ofl.rs`. Modify `esp32/src/ota.rs` (share the HTTPS client set-up and the DNS fix; no behaviour change for OTA), `esp32/src/webui.rs` (two routes, the status pause), `esp32/src/main.rs` (`mod ofl;`). + +**Interfaces:** +- Produces in `ofl.rs`: + +```rust +pub const MAX_BODY: usize = 24 * 1024; +pub const MIN_FREE: u32 = 80 * 1024; +pub const MIN_BLOCK: u32 = 32 * 1024; +/// True while a fetch runs. The status thread sends no status then. +pub fn busy() -> bool; +/// Searches OFL. Returns the reply body, or an HTTP status and a short text. +pub fn search(query: &str) -> Result, (u16, String)>; +/// Gets the JSON of one fixture. `key` is "/". +pub fn fixture(key: &str) -> Result, (u16, String)>; +``` + +- Routes: `GET /ofl/search?q=` (q is URL-decoded, 1 to 64 characters; JSON-escape it into the POST body) and `GET /ofl/fixture?key=/`. Both: Origin check as `POST /show` (403); `Access-Control-Allow-Origin: *`; 200 with `Content-Type: application/json` and the OFL body; 400 for a bad query or key; 503 below `MIN_FREE` / `MIN_BLOCK` or while another fetch or an OTA check runs; 502 when OFL answers an error, the body is too large, or the box has no route to the internet; 504 after a 10 s timeout. +- Errors keep the box alive: every failure is a status and a text; nothing panics. + +- [ ] **Step 1:** Move the HTTPS client set-up (`get` in ota.rs with the certificate bundle, and `fix_dns`) into a small shared helper that both `ota.rs` and `ofl.rs` call. Add a POST variant for the search. Keep OTA behaviour identical. +- [ ] **Step 2:** Implement `ofl.rs`: heap guard, busy flag, a `crate::spawn(c"ofl", 12 * 1024, ..)` thread that does the request and reads the body into a `Vec` with a read loop (no `io::copy`), cut at `MAX_BODY`, joined by the caller. +- [ ] **Step 3:** Add the two routes and make the status thread skip a status while `ofl::busy()` (as for `ota::busy()`). +- [ ] **Step 4:** Build both firmware configurations (`cargo build --release --locked`, `--no-default-features`), `rustfmt --check --edition 2021` on the touched files, and report the image size. +- [ ] **Step 5:** Commit "Fetch fixtures from the Open Fixture Library through the box". + +### Task 3: "Add from library" in the Lights setup + +**Files:** Modify `webui/index.html`. + +**Interfaces:** +- Consumes: `GET BASE + '/ofl/search?q='`, `GET BASE + '/ofl/fixture?key='` (Task 2); `Fixture.ofl` (Task 1). +- Produces: `function oflChannels(fixture, modeName) -> { channels: Channel[], notes: string[] }` — one `Channel` for each entry of the mode's `channels`: + - `Intensity` → `"dimmer"`. + - `ColorIntensity` with Red, Green, Blue, White, Amber, UV → `"red"`, `"green"`, `"blue"`, `"white"`, `"amber"`, `"uv"`. Warm White and Cold White → `"white"`. Other colours → `{"fixed":0}`. + - `ShutterStrobe` → `"strobe"`. `Fog`, `FogOutput` → `"fog"`. + - A fine channel (named in `fineChannelAliases` of another channel), `Pan`, `Tilt`, anything else, and `null` → `{"fixed": defaultValue or 0}`. + - When a channel has several capabilities, use the type of the capability that covers DMX value 255; else the first. + - A mode entry that names a matrix or a template channel → `{"fixed":0}` and a note "pixel channels are not supported yet". +- UI: in "Lights setup", next to "+ light", a "+ from library" button opens a small search box. Typing (debounced 300 ms, at most one request in flight, stale replies dropped) lists up to 20 results (name). A pick fetches the fixture, shows the modes as pills with their channel count and the mapped channel list (as text), and an "Add" button that adds the light at the next free address with `name` (cut to 16 characters), `channels`, and `ofl: "#"`. Errors (503, 502, 504, not JSON) show a toast with the text and keep the generic types available. +- A light with `ofl` shows the OFL name in its card. + +- [ ] **Step 1:** Write `oflChannels` and check it in a scratch file under `.context/` (never committed) against three real OFL fixtures saved from the site: an RGBW bar with a 4-channel mode, a light with a fine channel, and a fog machine. Paste the outputs into the report. +- [ ] **Step 2:** Build the UI. Check it against a local stub with fake `/ofl/*` replies, including a 502 and a reply that is not JSON. Console clean. Screenshots to `.context/ofl-*.png`. +- [ ] **Step 3:** Build the firmware (the page is in the image). +- [ ] **Step 4:** Commit "Add lights from the Open Fixture Library". + +### Task 4: Version, docs and checks + +**Files:** `Cargo.toml`, `esp32/Cargo.toml`, both lock files, `AGENTS.md`. + +- [ ] **Step 1:** Bump to 1.26.0; update the lock files. +- [ ] **Step 2:** AGENTS.md, section "DMX show": the two routes, the heap guards, the 12 KB `ofl` thread, the status pause, the 24 KB cut, the need for internet on a joined WiFi network or Ethernet, the `ofl` field. Run `sh ~/.claude/templates/ste-check.sh AGENTS.md` on the new lines. +- [ ] **Step 3:** Run `sh scripts/check.sh --esp32`. Expected: all checks pass. +- [ ] **Step 4:** Commit "Release the Open Fixture Library import as 1.26.0". +- [ ] **Step 5 (box session):** flash over USB; with the box on a WiFi network with internet: search "par", add one light, `/mem` after ten fetches; without internet: both routes answer 502/504 within 10 s. diff --git a/docs/superpowers/plans/2026-10-28-dmx-planner-3-positions.md b/docs/superpowers/plans/2026-10-28-dmx-planner-3-positions.md new file mode 100644 index 0000000..187810f --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-dmx-planner-3-positions.md @@ -0,0 +1,114 @@ +# DMX Planner 3: Positions and Shapes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Moving heads and scanners point to named positions, and a look moves them in beat-synced shapes: circle, figure 8 and sweep. + +**Architecture:** The show gets pan and tilt channel kinds (coarse and fine) and a list of named positions. A position holds the 16-bit pan and tilt of each head. A look row names a position and a shape. The renderer writes pan and tilt from the position plus the shape offset at the beat phase. The page aims a head live: the firmware holds an aim override for a short time while the owner moves the sliders. + +**Tech Stack:** Rust 2021 (`autoblink-core-dmx`), ESP-IDF firmware, vanilla JS. + +**Spec:** `docs/superpowers/specs/2026-10-27-dmx-planner-design.md` (Positions; Effect layer: shapes "move around the position of the row. Its period is in beats or bars."). + +## Global Constraints + +- No f32 in a serde type that the firmware reads. Shape math uses f64 locals only. +- `frame` allocates nothing on the heap. +- New limits: 8 positions (`MAX_POSITIONS`), position names 16 characters, shape period 1 to 32 beats, shape size 0 to 255 (255 is a quarter of the full pan and tilt range). +- Old shows (without positions, pan/tilt kinds or shapes) load unchanged; new fields use `#[serde(default)]` and skip serialization when empty or default. +- The firmware keeps its rules (FreeRTOS queue to the `dmx` thread, `send_back(.., 0)`, no blocking). +- Commits carry no attribution lines. Comments, log lines and docs follow ASD-STE100. +- Bump the version to `1.27.0` in the last task. + +## Review Focus + +1. A head that is in no position: pan and tilt sit at the centre (32768), not at 0. Test: Task 2. +2. A shape near the edge of the range: the value clamps to 0..65535 and never wraps. Test: Task 2. +3. A light with only a coarse pan channel: the renderer writes the high byte only. Test: Task 2. +4. An aim override that the page never ends: it ends by itself after `AIM_MS`. Test: Task 2. +5. Old OFL lights that were added with `{"fixed":…}` for pan and tilt keep working; a new add maps pan and tilt to the new kinds. Test: Task 4 Node check. + +--- + +### Task 1: The show model for pan, tilt, positions and shapes + +**Files:** Modify `crates/autoblink-core-dmx/src/show.rs` (and the struct literals in `render.rs` and `lights.rs` tests). + +**Interfaces:** + +```rust +pub const MAX_POSITIONS: usize = 8; +// Channel gets: Pan, PanFine, Tilt, TiltFine (serde: "pan", "pan_fine", "tilt", "tilt_fine") +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Aim { pub fixture: u8, pub pan: u16, pub tilt: u16 } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Position { pub name: String, #[serde(deserialize_with = bounded MAX_FIXTURES)] pub aims: Vec } +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Shape { #[default] None, Circle { beats: u8, size: u8 }, Figure8 { beats: u8, size: u8 }, Sweep { beats: u8, size: u8 } } +// LookRow gets: #[serde(default, skip_serializing_if = "Option::is_none")] pub position: Option, +// #[serde(default, skip_serializing_if = "is_none_shape")] pub shape: Shape +// Show gets: #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = bounded MAX_POSITIONS)] pub positions: Vec +``` + +`sanitize`: cut positions to 8 and names to 16 characters; drop an aim whose fixture does not exist; keep one aim for each fixture (the first); set `position` to `None` when it points past the list; clamp `beats` to 1..=32. + +- [ ] **Step 1:** Write the tests `old_show_loads_without_positions` (the plan-1 JSON shape round-trips byte-identical), `positions_sanitize` (10 positions → 8; an aim for fixture 40 is dropped; a duplicate aim is dropped; `position: Some(9)` → `None`; `beats: 0` → 1 and `99` → 32), `pan_tilt_kinds_json` (`"pan_fine"` round-trips). +- [ ] **Step 2:** Run them. Expected: FAIL. +- [ ] **Step 3:** Implement. +- [ ] **Step 4:** `cargo test --locked`, fmt, clippy, firmware build. Expected: PASS. +- [ ] **Step 5:** Commit "Add pan, tilt, positions and shapes to the DMX show". + +### Task 2: Render pan and tilt, shapes and the aim override + +**Files:** Modify `crates/autoblink-core-dmx/src/effect.rs` (shape math), `render.rs` (pan and tilt), `lights.rs` (aim override). + +**Interfaces:** + +```rust +// effect.rs +/// The pan and tilt offset of a shape for fixture k of n, in 16-bit units. +pub fn shape_offset(shape: &Shape, beat: Option<&Beat>, k: usize, n: usize) -> (i32, i32); +// render.rs — render gets the beat for the shapes: +pub fn render(show: &Show, look: &Look, beat: Option<&Beat>, level: impl Fn(usize, usize, usize) -> u8, out: &mut Universe); +// lights.rs +pub const AIM_MS: u64 = 10_000; +impl Lights { pub fn aim(&mut self, fixture: u8, pan: u16, tilt: u16, now_ms: u64); } +``` + +The rules: +- Base pan and tilt of a fixture in a row: the aim of that fixture in `show.positions[row.position]`, else 32768 / 32768. +- Shape offset with `t` = the phase over `beats` beats (use `count % beats` plus `phase`), `A = size × 64` (255 → 16320, a quarter of 65535), and a per-fixture phase shift of `k / n` of a cycle so that heads in a group do not move in unison: circle (A·cos 2πt, A·sin 2πt); figure 8 (A·sin 2πt, A·sin 4πt / 2); sweep (A·sin 2πt, 0). Without a beat, the offset is (0, 0). +- Value = clamp(base + offset, 0, 65535). `Pan`/`Tilt` get the high byte, `PanFine`/`TiltFine` the low byte. +- Layer blending in `Lights::frame` stays per channel. A fade between two positions blends the coarse and fine bytes separately — accept it, with a `ponytail:` comment that names the glitch and the upgrade (blend the 16-bit pairs). +- `aim`: until `now + AIM_MS`, the pan and tilt channels of that fixture show the aim value over every layer (and over idle). A new `aim` of the same fixture replaces it. The override ends by itself. + +- [ ] **Step 1:** Write the tests: `head_without_position_is_centred` (pan 0x80, fine 0x00), `position_sets_pan_tilt` (aim 0x1234 / 0xABCD → coarse/fine bytes), `coarse_only_head` (a fixture with only `Pan` gets 0x12), `circle_at_quarter_phase` (size 255, beats 4, phase at count 1 → offset (0, +16320) ±1 for k = 0), `shape_clamps` (aim 65000 + offset → 65535, no wrap), `heads_are_phase_shifted` (k = 0 and k = 1 of 2 differ), `aim_override_and_timeout` (aim shows at +9 999 ms and ends at +10 001 ms). +- [ ] **Step 2:** Run them. Expected: FAIL. +- [ ] **Step 3:** Implement and update the callers of `render`. +- [ ] **Step 4:** `cargo test --locked`, fmt, clippy, firmware build. Expected: PASS. +- [ ] **Step 5:** Commit "Move heads to positions and in beat shapes". + +### Task 3: The firmware takes aim commands + +**Files:** Modify `esp32/src/dmx.rs` (`DmxMsg::Aim { fixture: u8, pan: u16, tilt: u16 }` → `lights.aim`), `esp32/src/webui.rs` (WebSocket command `{"type":"dmx_aim","fixture":n,"pan":0..65535,"tilt":0..65535}` → `send_back(.., 0)`, no reply; clamp and validate). + +- [ ] **Step 1:** Implement. +- [ ] **Step 2:** Build both firmware configurations; rustfmt. +- [ ] **Step 3:** Commit "Aim a head live from the page". + +### Task 4: Positions and shapes on the page + +**Files:** Modify `webui/index.html`. + +- OFL mapping (`oflChannels`): `Pan` → `"pan"`, `Tilt` → `"tilt"`; a fine alias of a Pan channel → `"pan_fine"`, of a Tilt channel → `"tilt_fine"`. Other fine aliases stay `{"fixed":…}`. Node check with the moving-head sample in `.context/ofl-samples/`. +- Generic type list gets `moving_head: ['pan', 'tilt', 'dimmer', 'red', 'green', 'blue']` and `moving_head_16: ['pan', 'pan_fine', 'tilt', 'tilt_fine', 'dimmer', 'red', 'green', 'blue']`. +- Lights setup gets a "Positions" section: position cards (name, + position, remove). In a card, one row for each head (a light with a pan or tilt channel) with two sliders (pan, tilt, 0..65535) and a "Center" button. Moving a slider sends `dmx_aim` (throttled to 20 per second) and stores the aim; the aim saves through the debounced POST. +- Look editor card of a group with heads: a position pill list (none + the names) and shape pills (none, circle, figure 8, sweep) with beats (1, 2, 4, 8, 16) and size (slider). +- Check against a stub (console clean), screenshots to `.context/positions-*.png`, firmware build. +- Commit "Set positions and shapes for moving heads on the page". + +### Task 5: Version, docs and checks + +- Bump to 1.27.0 with the lock files; AGENTS.md "DMX show": pan/tilt kinds, positions, shapes (formulas in one line each), the aim override and `AIM_MS`, the coarse/fine fade glitch; run `sh scripts/check.sh --esp32`; commit "Release positions and shapes as 1.27.0". +- Box session (deferred while the box is offline): one moving head, aim two positions, a look with a circle every 4 beats; check the movement on the beat and the 10 s aim timeout. diff --git a/docs/superpowers/plans/2026-10-28-dmx-planner-4-drop-guess.md b/docs/superpowers/plans/2026-10-28-dmx-planner-4-drop-guess.md new file mode 100644 index 0000000..1672216 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-dmx-planner-4-drop-guess.md @@ -0,0 +1,98 @@ +# DMX Planner 4: Drop Guess — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A track without phrase analysis still changes the scenes. The engine guesses Intro, Groove, Build, Drop, Break and Outro from the waveform energy and fires the bucket rows on 16-beat block starts. + +**Architecture:** A new module `guess.rs` in `autoblink-core-midi` turns the PWV4 colour preview of a track into a smoothed energy profile and classifies a playhead position into a phrase bucket. `SceneInput` gets the guessed bucket and the beat in the 16-beat block. The scene engine fires a bucket row at a block start when the guess changes and the track has no phrases. The firmware and the host both feed the guess. + +**Tech Stack:** Rust 2021 (`autoblink-core-midi`), ESP-IDF firmware, the host (tokio, axum). + +**Spec:** `docs/superpowers/specs/2026-10-27-dmx-planner-design.md` (Triggers and gates, rule 2: "If the track has no phrase analysis, the planner finds the drops from the waveform energy. It sends the bucket events Groove, Build, Drop and Break on 16-beat blocks."). + +## Facts + +- The PWV4 preview has 6 bytes for each column. The firmware keeps 600 columns (`autoblink_core_dbserver::PREVIEW_COLUMNS`); the host keeps the player's columns (usually 1200). The guess uses the sum of the 6 bytes of a column as its energy. The builder may refine this after looking at real data (left to the builder). +- The firmware has the playhead (`Deck::playhead_ms`) and the track length (`Deck::track_len_ms`) from the absolute position packets, and `phrase16` (1 to 16, 0 unknown) from the beat counting. The host has `playhead_ms` on the device and the track length from the metadata. +- Without phrases, the firmware does not request the beat grid. The guess therefore uses `phrase16` for the block starts. + +## Global Constraints + +- The guess never overrides real phrases: with `SceneInput::phrase` set, the engine ignores the guess. +- The guess fires a bucket row only at a block start (`phrase16 == 1` after a value other than 1) and only when the guessed bucket differs from the last bucket that the guess fired for this track. A track change or a master change resets it. +- The profile has at most 600 values (`u8`), so the firmware keeps about 600 bytes for each deck. No allocation at playback time. +- No f32 in serde types; the profile is not serialized. +- The page shows a guessed bucket with "(guess)" so that the owner can tell it from rekordbox phrases. +- Commits carry no attribution lines. Comments and docs follow ASD-STE100. +- Bump the version to `1.28.0` in the last task. + +## Review Focus + +1. A track with a flat energy (ambient, a talk): no bucket flicker — the guess holds Groove, and the hysteresis keeps it. Test: Task 1. +2. A preview of fewer than 32 columns, or none: no guess (`None`), no panic. Test: Task 1. +3. Loops and cue jumps: the guess follows the playhead, and it fires only at block starts. Test: Task 2. +4. A track with phrases on a deck that becomes master: the real phrases win at once. Test: Task 2. +5. The firmware heap after 20 track loads: no growth. Box session. + +--- + +### Task 1: The energy profile and the bucket classifier + +**Files:** Create `crates/autoblink-core-midi/src/guess.rs`; export it from `lib.rs`. + +**Interfaces:** + +```rust +pub const PROFILE_LEN: usize = 600; +#[derive(Debug, Clone, PartialEq)] +pub struct DropGuess { /* profile: Vec of at most PROFILE_LEN, plus thresholds */ } +impl DropGuess { + /// Builds the profile from PWV4 preview bytes (6 bytes for each column). None below 32 columns. + pub fn from_preview(preview: &[u8]) -> Option; + /// The bucket at the position `frac` (0.0 to 1.0) of the track. `prev` is the bucket that the + /// guess gave last; the classifier keeps it while the energy stays inside a hysteresis band. + pub fn bucket_at(&self, frac: f64, prev: Option) -> Event; +} +``` + +The classifier (the builder may tune the numbers; keep the structure): +- Energy of a column = the sum of its 6 bytes, scaled to 0..255; fold the columns down to at most 600; smooth with a moving average over ±4 columns. +- Thresholds from the track's own distribution: `hi` = the 70th percentile, `lo` = the 30th percentile, `mid` = the median. +- Intro: before the first column with energy ≥ `mid`. Outro: after the last column with energy ≥ `mid`. +- Drop: energy ≥ `hi`. Break: energy ≤ `lo`. Build: energy < `hi`, and the maximum over the next 2 % of the track is ≥ `hi`. Groove: the rest. +- Hysteresis: keep `prev` (Drop or Break) while the energy stays within 8 of the threshold that started it. + +- [ ] **Step 1:** Write the tests with synthetic previews (6 equal bytes for each column): `flat_track_is_groove` (all 120 → Groove everywhere after the intro rule), `drop_and_break` (a low intro, a high middle, a low dip, a high end, a low tail → Intro, Drop, Break, Drop, Outro at the expected fractions), `build_before_drop` (a rising ramp into a high part → Build in the last 2 % before it), `hysteresis_holds_drop` (a small dip below `hi` keeps Drop), `short_preview_is_none` (31 columns → None), `folds_1200_to_600`. +- [ ] **Step 2:** Run them. Expected: FAIL. +- [ ] **Step 3:** Implement. +- [ ] **Step 4:** `cargo test --locked -p autoblink-core-midi`, fmt, clippy. Expected: PASS. +- [ ] **Step 5:** Commit "Guess the phrase buckets from the waveform energy". + +### Task 2: The scene engine fires guessed buckets on block starts + +**Files:** Modify `crates/autoblink-core-midi/src/scenes.rs`. + +**Interfaces:** +- `SceneInput` gets `pub guess: Option` (the guessed bucket now) and `pub phrase16: u8` (1 to 16, 0 unknown). Both default to `None` / 0. +- `SceneStatus` gets `pub guessed: bool` — true when `phrase` (the bucket that the status shows) comes from the guess. +- The rules in Global Constraints. The guess uses the same `event()` path as a real bucket (counts, fires, `drain_fired` reports it, `current` changes). + +- [ ] **Step 1:** Write the tests: `guess_fires_on_block_start` (no phrase, guess Drop, phrase16 goes 16 → 1 → fires Drop once), `guess_waits_for_block_start` (guess changes at phrase16 7 → nothing until the next 1), `same_guess_does_not_refire` (Drop at two block starts → one fire), `phrases_win` (a phrase present → the guess is ignored), `track_change_resets_guess` (the same guess fires again after a new track). +- [ ] **Step 2:** Run them. Expected: FAIL. +- [ ] **Step 3:** Implement, and fill the new fields at the existing callers with the defaults (host `scene_input`, firmware `midi.rs`) so that everything builds. +- [ ] **Step 4:** `cargo test --locked`, fmt, clippy, firmware build. Expected: PASS. +- [ ] **Step 5:** Commit "Fire guessed buckets on 16-beat block starts". + +### Task 3: The firmware and the host feed the guess + +**Files:** Modify `esp32/src/tracks.rs` (build a `DropGuess` when the overview arrives; keep it in `TrackData`), `esp32/src/midi.rs` (fill `guess` from the master's playhead / track length and `phrase16` from the tracker), the host: the device or song-structure state that holds the preview (build and keep a `DropGuess` next to it), `host/src/midi/mapper.rs` `scene_input` (fill `guess` and `phrase16`), and `webui/index.html` (show "(guess)" next to the phrase bucket of the scenes panel when `scene.guessed`; also in the master panel bucket label if it shows the scene bucket). + +- [ ] **Step 1:** Implement the firmware side. Build both configurations; rustfmt. +- [ ] **Step 2:** Implement the host side. `cargo test --locked`, fmt, clippy. +- [ ] **Step 3:** Page label; check with a stub; screenshot `.context/guess-*.png`. +- [ ] **Step 4:** Commit "Feed the drop guess on the box and on the host". + +### Task 4: Version, docs and checks + +- Bump to 1.28.0 with the lock files; AGENTS.md (DMX scenes): the guess, its block-start rule, "(guess)" on the page, the thresholds in one line; run `sh scripts/check.sh --esp32`; commit "Release the drop guess as 1.28.0". +- Box session (deferred while the box is offline): play a track without phrase analysis; the bucket rows change at block starts near the audible drops; `/mem` after 20 track loads. diff --git a/docs/superpowers/plans/2026-10-28-dmx-planner-5-artnet-host.md b/docs/superpowers/plans/2026-10-28-dmx-planner-5-artnet-host.md new file mode 100644 index 0000000..9b58c82 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-dmx-planner-5-artnet-host.md @@ -0,0 +1,99 @@ +# DMX Planner 5: Art-Net and the Host — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The box merges Art-Net from a different desk into its own DMX output. The host page edits the box's show over the network. Without a box, the host runs the same engine and sends Art-Net to a DMX node. + +**Architecture:** `autoblink-core-dmx` gets an Art-Net codec (ArtDmx only) and a merge (HTP for intensity channels, LTP for the rest). The firmware receives Art-Net on UDP 6454 in a small thread and merges the last frame in the `dmx` thread. The host finds the box among the Pro DJ Link devices, and its page edits the box's `/show` directly (the box already allows a page from `127.0.0.1`/`localhost`). The host keeps a copy of the show; with `[dmx] artnet_target` set and no box on the network, a supervised host task runs `Lights` and sends ArtDmx frames. + +**Tech Stack:** Rust 2021, ESP-IDF (`std::net::UdpSocket`), the host (tokio `UdpSocket`, axum), vanilla JS. + +**Spec:** `docs/superpowers/specs/2026-10-27-dmx-planner-design.md` (Where it runs; Merge rule; Host and box). + +## Facts + +- Art-Net 4 ArtDmx: `"Art-Net\0"`, OpCode 0x5000 little-endian, ProtVer 14 big-endian, Sequence, Physical, SubUni, Net, Length big-endian (2..512, even), then the data. UDP port 6454. +- The box and the host both use the Pro DJ Link name `autoblink` (`esp32/src/prolink.rs` `NAME`, `host/src/config.rs` default `device_name`). The host finds the box as a device with that name and a device number that is not its own. +- The box's `origin_allowed` accepts a page from `localhost` and `127.0.0.1` (`dev_origin`), `GET /show` sends `Access-Control-Allow-Origin: *`, and `POST /show` is a CORS simple request. The host page on `127.0.0.1:11111` can therefore read and write the box's show directly. + +## Global Constraints + +- Merge rule (spec): HTP for the channels of kind Dimmer, Red, Green, Blue, White, Amber, Uv and Strobe; LTP for every other channel, including the channels that no light patches. LTP: the source that changed a channel last owns it. +- Art-Net counts as live for 3 s after its last frame (`ARTNET_TIMEOUT_MS`). Without it, the output is the engine alone. +- The firmware listens on universe 0 (Net 0, SubUni 0) only. A frame of another universe is ignored. +- The firmware receive thread never blocks the `dmx` thread: it writes the last frame under a short lock (`try_lock` on the `dmx` side). +- The host must stay alive under every fault (`runtime::supervise`; no blocking call on a tokio worker). +- The host does not send Art-Net to the box. When it finds a box, its own engine stops sending. +- No f32 in serde types on the firmware. Commits carry no attribution lines. Comments and docs follow ASD-STE100. +- Bump the version to `1.29.0` in the last task. + +## Review Focus + +1. A desk that sends Art-Net and then stops: after 3 s the box output is the engine alone; no frozen channel. Test: Task 1 merge test; box session. +2. Pan/tilt from a desk while the engine moves the head: LTP gives the desk the channel while it changes, then the engine takes it back when the engine changes. Test: Task 1. +3. A malformed or short UDP packet: ignored, no panic. Test: Task 1 parse tests. +4. The host page with a box and without one: it edits the right show and says which. Test: Task 4 stub. +5. A host without `artnet_target`: no task, no socket. Test: Task 3. + +--- + +### Task 1: The Art-Net codec and the merge + +**Files:** Create `crates/autoblink-core-dmx/src/artnet.rs` and `merge.rs`; export them. + +**Interfaces:** + +```rust +// artnet.rs +pub const ARTNET_PORT: u16 = 6454; +/// Returns the universe (Net << 8 | SubUni) and the data of an ArtDmx packet, or None. +pub fn parse_artdmx(packet: &[u8]) -> Option<(u16, &[u8])>; +/// Writes an ArtDmx packet for `universe` with `data` (512 bytes) into `out`. Returns the length. +pub fn build_artdmx(sequence: u8, universe: u16, data: &Universe, out: &mut [u8; 530]) -> usize; +// merge.rs +pub const ARTNET_TIMEOUT_MS: u64 = 3_000; +#[derive(Default)] +pub struct Merge { /* owner per channel, the previous engine and Art-Net frames, last Art-Net time */ } +impl Merge { + /// Merges `artnet` (the last frame and its time) into `engine` in place, by the kinds of `show`. + pub fn apply(&mut self, show: &Show, artnet: Option<(&Universe, u64)>, now_ms: u64, engine: &mut Universe); +} +``` + +- [ ] **Step 1:** Write the tests: `parse_valid_artdmx` (universe 0x0102, 4 data bytes), `parse_rejects` (wrong id, wrong opcode, short, length > data), `build_round_trip`, `htp_on_dimmer` (engine 100, Art-Net 200 → 200; engine 220 → 220), `ltp_on_pan` (engine 10 steady, Art-Net changes 50 → 50; the engine then changes to 60 → 60), `unpatched_channel_is_ltp`, `artnet_times_out` (3 001 ms after the last frame → engine values; an owner reset to the engine). +- [ ] **Step 2:** Run them. Expected: FAIL. +- [ ] **Step 3:** Implement (no allocation in `apply`). +- [ ] **Step 4:** `cargo test --locked`, fmt, clippy, firmware build. +- [ ] **Step 5:** Commit "Add the Art-Net codec and the DMX merge". + +### Task 2: The box receives Art-Net + +**Files:** Create `esp32/src/artnet.rs`. Modify `esp32/src/main.rs` (start the thread after the network starts), `esp32/src/dmx.rs` (call `Merge::apply` after `lights.frame`), `esp32/src/webui.rs` (status `dmx.artnet`: `true` while Art-Net is live). + +- A `crate::spawn(c"artnet", 4 * 1024, ..)` thread binds `0.0.0.0:6454`, reads with a 1 s timeout, parses with `parse_artdmx`, keeps universe 0 only, and stores `(frame, now_ms)` in an `Arc>`. A bind error logs one warning and ends the thread (the box keeps working). +- The `dmx` thread reads it with `try_lock` each frame. +- Measure nothing on the box now (offline); note the stack as an estimate. +- [ ] Build both configurations; rustfmt; commit "Merge Art-Net from a desk into the DMX output". + +### Task 3: The host engine and show copy + +**Files:** Modify `host/src/config.rs` (`[dmx]` section: `artnet_target: Option` (an IP or `ip:port`), `universe: u16` default 0), add `host/src/dmx.rs` (the task), `host/src/runtime/` (start it under `supervise` when `artnet_target` is set), `host/src/midi/mapper.rs` (forward the scene notes and fired presets to the task through a bounded tokio channel, never awaiting a full channel), `host/src/web/mod.rs` (`GET /show`, `POST /show` on the host: the show copy in `show.json` next to the config file, written on a blocking thread; cap `dmx` when `artnet_target` is set; status `box`: the IP of the box when one is on the network). + +- The task runs `Lights` and `BeatClock` like the firmware `dmx` thread, at about 40 Hz, and sends ArtDmx with `build_artdmx` through a tokio `UdpSocket`. While the host sees a box, it sends nothing. +- [ ] Tests: config parse with and without `[dmx]`; the `/show` routes (round trip, bounded parse via `Show::from_json`); box detection from a device list. `cargo test --locked`, fmt, clippy. +- [ ] Commit "Run the DMX engine on the host and send Art-Net to a node". + +### Task 4: The host page edits the box's show + +**Files:** Modify `webui/index.html`. + +- When `status.box` is set, the Lights setup and the look editor read and write `http:///show` and use `http:///ofl/...`; a line at the top of the setup says "Editing the show of the box at ". After each successful save to the box, the page also posts the same show to the host's `/show` (the host keeps the copy). +- Without a box and with the `dmx` cap, the page edits the host's own `/show` ("Editing the show of this computer (Art-Net to )"). +- The live strip shows only with a box connection or the `dmx` cap; on the host page with a box, the live controls stay on the box's page (hide them, with a link "Open the box page"). +- Check against a stub; screenshots `.context/host-*.png`. +- [ ] Commit "Edit the box's show from the host page". + +### Task 5: Version, docs and checks + +- Bump to 1.29.0 with the lock files; AGENTS.md: Art-Net in (port, universe 0, timeout, merge rule), the `artnet` thread, the host `[dmx]` config, the host engine, box detection by name, the show copy; run `sh scripts/check.sh --esp32`; commit "Release Art-Net and the host engine as 1.29.0". +- Box session (deferred while the box is offline): QLC+ sends universe 0 to the box → HTP on a par, LTP on a head; stop QLC+ → engine after 3 s; the host page edits the box's show; the host with `artnet_target` and the box off → a node gets the frames. diff --git a/docs/superpowers/specs/2026-10-27-dmx-planner-design.md b/docs/superpowers/specs/2026-10-27-dmx-planner-design.md new file mode 100644 index 0000000..afd1129 --- /dev/null +++ b/docs/superpowers/specs/2026-10-27-dmx-planner-design.md @@ -0,0 +1,152 @@ +# DMX planner: design + +## Context + +The owner has a Disco 240 DMX desk. The desk has no MIDI input. It has only two DMX outputs and a mains inlet. Thus the MIDI mapping of AUTOBLINK cannot control the desk. + +The DMX planner replaces the desk as the light source. The desk stays as a spare. + +The planner extends the DMX scenes (`scenes.rs` in `autoblink-core-midi`). The DMX scenes map events of the master deck to presets of an external DMX controller. A preset of the DMX scenes is one MIDI note. In the DMX planner, a preset can also be a look that the box sends on its own DMX output. The events, the rows, the overlays, the steps and the gates of the DMX scenes stay the same. + +The DMX scenes must be on `main` before the work on the DMX planner starts. + +The owner chose layout B of the brainstorm: a look library with a trigger table. The preset palette of the DMX scenes is the look library. The event rows of the DMX scenes are the trigger table. + +## Goals + +- The light follows the song structure. A phrase bucket selects a look. +- The light follows the beat. An effect layer flashes, pulses, chases and moves the heads in time. +- The setup before a gig is fast. The owner starts from presets and changes single fixtures only when necessary. +- The owner can take control live from a phone or a laptop. + +## Non-goals + +- More than one DMX universe. +- Pixel mapping, a 3D visualiser and a timeline editor for each track. +- More than one saved show. +- A MIDI controller as a live surface. This comes in a later project. + +## Anti-goals + +The design fails, even when it works technically, in these cases: + +- The light is out of time with the beat. +- The strobe or the fog fires too often, or at the wrong moment. +- The setup before a gig is slow and complex. +- The rig goes dark when the box restarts, or when the box loses Pro DJ Link. + +## The model + +### Show + +There is one show. The owner edits it for each venue. The show holds the patch, the groups, the positions and the looks. The triggers stay in the scene settings (`ScenesConfig`). + +### Patch and fixtures + +- The rig has 8 to 24 fixtures on one universe. The types are RGB and RGBW pars, LED bars, moving heads, scanners, strobes, dimmers and fog machines. +- The owner adds a fixture by its model name. The box joins the venue WiFi and gets the definition from the Open Fixture Library (OFL). +- The owner selects the DMX mode and the start address of each fixture. +- The owner puts fixtures into groups, for example "Pars", "Heads", "Strobe" and "Fog". + +### Positions + +The owner aims each moving head once at each venue. The owner uses pan and tilt sliders, and saves the result as a named position, for example "crowd", "DJ" or "ceiling". + +### Look + +A look is a preset of the DMX scenes. Its target is the DMX output of the box, not a MIDI note. The preset palette shows a look with its own colour, next to the MIDI presets and the extra notes. + +A look has one row for each group. A row holds the colour, the intensity and the position. A row can also hold a beat effect: + +- Flash, pulse, chase and random on the intensity of the group. +- A strobe or a fog burst, with a cooldown. +- A shape for the moving heads: circle, figure 8 or sweep. The shape moves around the position of the row. Its period is in beats or bars. + +The owner builds a look from presets: a colour palette, an intensity, a named position and an effect. The owner can open one fixture and change its values. A look also has a fade-in time: cut, 1 beat or 1 bar. + +### Triggers and gates + +The DMX scenes give the triggers. The DMX planner changes none of these rules: + +1. The six phrase buckets set the current scene. The buckets are Intro, Groove (Verse 1 to 6), Build (Up, Bridge), Drop (Chorus), Break (Down) and Outro. +2. The Rhythm rows are overlays: Beat, Downbeat, Phrase change and Fill-in. An overlay does not change the current scene. +3. The Transport rows and the Mixer rows also set the current scene. These rows are Stop, Play, Track load, Master change, Bass cut and Bass return. Only the host has the mixer data for the Mixer rows. +4. A row with more than one preset steps through them: up, ping-pong or random. It steps at each trigger or every N bars. + +The gate of a row decides how a look acts: + +- A hold look is the base look. It stays until the next scene fires. +- A pulse look or a flash look goes on top of the base look. A flash ends after its decay, in milliseconds or in beats. +- When a flash ends, the base look shows again. + +The DMX planner adds these rules: + +1. The strobe and the fog of a look fire only from a phrase event. A phrase event is a phrase bucket row, the Phrase change row or the Fill-in row. The cooldown of the look applies to each automatic burst. The live buttons fire them at any time. +2. If the track has no phrase analysis, the planner finds the drops from the waveform energy. It sends the bucket events Groove, Build, Drop and Break on 16-beat blocks. +3. If no deck plays, or the box loses Pro DJ Link, the Stop row fires after 2 s. If the Stop row has no look, the idle look of the show shows. The rig never goes dark. + +### Live controls + +The page has a live strip with these controls: + +- A look picker (previous, next, select). +- A master dimmer. +- A strobe hold. +- A fog button. + +## Where it runs + +- The ESP32 drives the DMX cable on IO4 through an RS-485 module (`esp32/src/dmx.rs`). +- The show lives on the box. The "Lights" panel of the host page edits the show of the box over the network. +- The ESP32 accepts Art-Net from a different desk, for example a laptop with QLC+. It merges that Art-Net with its own engine (HTP). +- The host runs the same engine only when there is no box. Then it sends Art-Net or sACN to a DMX node. +- The owner wants all parts in the first release. The ESP32 and the host get the planner at the same time. + +## Decisions by the agent (the owner did not decide these) + +- **Shared crate.** The DMX engine is a new crate `crates/autoblink-core-dmx`. The crate holds the show model, the looks, the effects, the merge and the energy guess. It has host tests. The scene engine stays in `autoblink-core-midi`. +- **Scene output.** The scene engine reports a preset on and a preset off. The MIDI sink turns a preset into a note, as today. The DMX sink turns a look into channel values. +- **Merge rule.** HTP applies only to the intensity channels: dimmer, strobe intensity and colour channels. The pan, the tilt and the other channels use the last source that changed them (LTP). An HTP merge of pan and tilt makes the heads point to wrong places. +- **Timing.** The effects use the predicted beat phase (BPM and the last beat), not the arrival time of a packet. On WiFi, a beat packet can arrive up to 300 ms late. The MIDI clock already corrects for this. The DMX engine uses the same phase. +- **Storage.** The show goes into a new partition `show` of 64 KB at 0x3F0000, after `coredump`. The show is stored as JSON. A box needs one USB flash to get the partition, because OTA does not change the partition table. +- **Page.** The planner is a new "Lights" panel in `webui/index.html`. The ESP32 and the host serve this page. The show goes over the WebSocket in its own messages, not as setting indices. +- **OFL on the ESP32.** The box streams the OFL data to the browser, and the browser parses it. The browser sends back only the channel layout of the chosen mode. Thus the ESP32 does not hold a large JSON document in its heap. +- **Feature flag.** The `dmx` feature becomes a default feature of the firmware. +- **Manual look.** A look that the owner picks by hand stays until the next scene fires. The test click of a preset in the palette uses the same path. +- **Host and box.** The host does not send Art-Net to the box. The host keeps the last show that it read from the box. It uses that copy only when it drives a DMX node without a box. +- **Never dark at a restart.** The firmware starts the DMX thread before the network. The thread sends the idle look of the stored show until the first scene fires. The OTA maintenance start also sends the idle look. +- **OFL without internet.** Each fixture definition stays in the show after the first fetch. The patch also has a small set of generic types: dimmer, RGB, RGBW and fog. The owner can use these when the venue has no internet. + +## Constraints from the code + +- The ESP32 has about 100 KB free heap with one track and the page open. TLS to GitHub uses about 50 KB. The OFL fetch uses the same heap checks as `ota_check`: 60 KB free and a 24 KB largest block. +- The `dmx` thread has a lower priority than `midi` (10) and `prolink-rx` (8). It starts with `crate::spawn`. +- The show messages are `Serialize` structs. Do not build a `serde_json::Value` tree. +- Do not use `std::sync::mpsc` from `prolink-rx` to a thread with a higher priority. Use the FreeRTOS queue, as `midi.rs` does. +- Do not use `std::io::copy` in a firmware thread. +- The app image must stay below the slot size (0x1E0000). Version 1.21.0 uses 74 %. +- The firmware keeps 600 preview columns of 6 bytes for each track. The drop guess uses these columns. +- Without phrases, the firmware does not request the beat grid. The drop guess therefore counts the live beats in 16-beat blocks. +- The host must stay alive under every fault (`runtime::supervise`). The Art-Net sender must not block a tokio worker. + +## Left to the builder + +- The exact binary format of the show. +- The effect curves and the cooldown defaults. +- The colour mixing for RGB and RGBW fixtures, and the colour wheel mapping. +- The algorithm of the drop guess. +- The choice between Art-Net and sACN as the first host protocol. +- The layout of the look editor. The palette and the event rows stay as the DMX scenes have them. + +## Verification + +1. Run `cargo test --locked`. The tests of `autoblink-core-dmx` cover the looks, the effect timing, the cooldowns, the merge and the drop guess. +2. Build the firmware with `cd esp32 && cargo build --release`. Make sure that the image is below the slot size. +3. Flash the box over USB. Connect a fixture, or a USB-DMX interface with a DMX monitor. +4. Play a track with phrase analysis on the XDJ-AZ. Make sure that the look changes at each phrase start and that the flash looks are on the beat. +5. Put a MIDI preset and a look in one row. Make sure that the external controller and the DMX output both follow the row. +6. Play a track without phrase analysis. Make sure that the drop guess changes the look on 16-beat blocks. +7. Stop the deck, and pull the Ethernet cable. Make sure that the idle look starts after 2 s. +8. Read `curl http://autoblink.local/mem` after a fetch from OFL and with the page open. Make sure that each task keeps its stack margin. +9. Send Art-Net from a different desk (QLC+) to the box. Make sure that the merge gives the expected channel values. +10. Run the host without a box, with Art-Net to a DMX node. Make sure that the node gets the frames of the host engine. diff --git a/esp32/Cargo.lock b/esp32/Cargo.lock index 43c817b..b2fa18d 100644 --- a/esp32/Cargo.lock +++ b/esp32/Cargo.lock @@ -58,25 +58,35 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autoblink-core-dbserver" -version = "1.23.0" +version = "1.29.0" + +[[package]] +name = "autoblink-core-dmx" +version = "1.29.0" +dependencies = [ + "autoblink-core-midi", + "serde", + "serde_json", +] [[package]] name = "autoblink-core-midi" -version = "1.23.0" +version = "1.29.0" dependencies = [ "serde", ] [[package]] name = "autoblink-core-prolink" -version = "1.23.0" +version = "1.29.0" [[package]] name = "autoblink-esp32" -version = "1.23.0" +version = "1.29.0" dependencies = [ "anyhow", "autoblink-core-dbserver", + "autoblink-core-dmx", "autoblink-core-midi", "autoblink-core-prolink", "embuild", diff --git a/esp32/Cargo.toml b/esp32/Cargo.toml index 18d0daa..2dc7f2b 100644 --- a/esp32/Cargo.toml +++ b/esp32/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "autoblink-esp32" # The firmware is not in the workspace. Keep this version the same as the workspace version. -version = "1.23.0" +version = "1.29.0" edition = "2021" license = "Proprietary" authors = ["AUTOBLINK team"] @@ -23,7 +23,8 @@ debug = true opt-level = "z" [features] -# DMX512 output on IO4. It is off until the DMX behaviour is decided. +# The DMX512 output on IO4 (dmx.rs). It is on by default. +default = ["dmx"] dmx = [] [dependencies] @@ -33,6 +34,7 @@ log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" autoblink-core-dbserver = { path = "../crates/autoblink-core-dbserver" } +autoblink-core-dmx = { path = "../crates/autoblink-core-dmx" } autoblink-core-midi = { path = "../crates/autoblink-core-midi" } autoblink-core-prolink = { path = "../crates/autoblink-core-prolink" } diff --git a/esp32/partitions.csv b/esp32/partitions.csv index 29942ab..008f9d7 100644 --- a/esp32/partitions.csv +++ b/esp32/partitions.csv @@ -8,3 +8,5 @@ ota_0, app, ota_0, 0x20000, 0x1E0000 ota_1, app, ota_1, 0x200000, 0x1E0000 # The panic handler writes a core dump here. The firmware logs it after the restart (logring.rs). coredump, data, coredump, 0x3E0000, 0x10000 +# The DMX show is here as JSON (show.rs). OTA does not change this table, so flash a box over USB to add it. +show, data, undefined, 0x3F0000, 0x10000 diff --git a/esp32/sdkconfig.defaults b/esp32/sdkconfig.defaults index 1785768..e787a29 100644 --- a/esp32/sdkconfig.defaults +++ b/esp32/sdkconfig.defaults @@ -60,9 +60,9 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y # A 1 ms tick lets the MIDI thread sleep until 1 ms before the next clock pulse. CONFIG_FREERTOS_HZ=1000 -# The web server has 6 sockets. The firmware also needs 3 UDP sockets for Pro DJ Link, 1 TCP -# socket for the dbserver, 1 for the OTA download and 2 for the web server itself. The default -# is 10. +# The web server has 6 sockets. The firmware also needs 3 UDP sockets for Pro DJ Link, 1 UDP +# socket for Art-Net, 1 TCP socket for the dbserver, 1 for the OTA download and 2 for the web +# server itself. The default is 10. CONFIG_LWIP_MAX_SOCKETS=20 # The MIDI thread resets the task watchdog on each loop pass. When the thread or an idle task diff --git a/esp32/src/artnet.rs b/esp32/src/artnet.rs new file mode 100644 index 0000000..2e8f8aa --- /dev/null +++ b/esp32/src/artnet.rs @@ -0,0 +1,85 @@ +//! The `artnet` thread receives Art-Net from a lighting desk. The desk sends ArtDmx for universe 0 +//! to UDP port 6454. +//! +//! The box answers no ArtPoll. A desk must send to the IP address of the box (unicast) or to the +//! subnet broadcast address. +//! +//! The thread receives on all interfaces: the own WiFi network, the joined WiFi network and the +//! Ethernet port. It keeps the last frame of universe 0 in `State::artnet`. The `dmx` thread +//! merges that frame into its output (`autoblink_core_dmx::Merge`). + +use std::io::ErrorKind; +use std::net::UdpSocket; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use autoblink_core_dmx::{parse_artdmx, Universe, ARTNET_PORT, UNIVERSE}; + +use crate::State; + +/// The Art-Net header (18 bytes) and a full universe. The receive cuts a longer datagram, and +/// the parser then refuses it when its length field is too large. +const PACKET_LEN: usize = 18 + UNIVERSE; + +/// The last Art-Net frame of universe 0. +pub struct ArtIn { + /// The data is on the heap, so that `State` stays small. + pub data: Box, + /// The time of the frame, with the clock of `State::now_ms`. `None` before the first frame. + pub at_ms: Option, +} + +impl Default for ArtIn { + fn default() -> Self { + Self { + data: Box::new([0; UNIVERSE]), + at_ms: None, + } + } +} + +/// Starts the `artnet` thread. When the thread does not start, the firmware logs a warning and +/// continues without Art-Net. +pub fn start(state: Arc) { + // ponytail: 4 KB is an estimate, not a measurement. The thread function has a 672-byte frame + // with the 530-byte buffer. Its deepest direct call path is 2.6 KB (the bind). Measure with + // /mem on a box. + let started = crate::spawn(c"artnet", 4 * 1024, move || { + let sock = match UdpSocket::bind(("0.0.0.0", ARTNET_PORT)) { + Ok(s) => s, + Err(e) => { + log::warn!("Art-Net is off: port {ARTNET_PORT} is not available: {e}"); + return; + } + }; + let _ = sock.set_read_timeout(Some(Duration::from_secs(1))); + log::info!("Art-Net: the firmware receives universe 0 on port {ARTNET_PORT}"); + let mut buf = [0u8; PACKET_LEN]; + loop { + let len = match sock.recv(&mut buf) { + Ok(len) => len, + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { + continue + } + // The sleep lets the lower-priority tasks run after a receive error. + Err(_) => { + thread::sleep(Duration::from_millis(10)); + continue; + } + }; + let Some((0, data)) = parse_artdmx(&buf[..len]) else { + continue; + }; + let now_ms = state.now_ms(); + // The lock is short. The `dmx` thread does not wait for it. + let mut slot = state.artnet.lock().unwrap(); + slot.data[..data.len()].copy_from_slice(data); + slot.data[data.len()..].fill(0); + slot.at_ms = Some(now_ms); + } + }); + if let Err(e) = started { + log::warn!("Art-Net is off: the thread did not start: {e}"); + } +} diff --git a/esp32/src/dmx.rs b/esp32/src/dmx.rs index 291bdee..19aaf44 100644 --- a/esp32/src/dmx.rs +++ b/esp32/src/dmx.rs @@ -1,21 +1,29 @@ -//! DMX512 output on UART1, TX on IO4. Build with `--features dmx`. +//! DMX512 output on UART1, TX on IO4. The `dmx` feature is on by default. //! -//! An RS-485 module (MAX3485 or SP3485) turns the UART signal into DMX. Channel 1 flashes to -//! full on each master beat and fades out in `FLASH_MS`. The other channels stay at 0. -//! -//! ponytail: one fixed effect on channel 1. Settings for channels and effects come when the DMX -//! behaviour is decided. +//! An RS-485 module (MAX3485 or SP3485) turns the UART signal into DMX. The `dmx` thread runs +//! the `Lights` engine of `autoblink-core-dmx` and sends one frame after the other. The `midi` +//! thread queues the scene notes, the fired events and the preset tests. The receive thread +//! queues the master beats. The show in the `show` partition sets the looks. Without a stored +//! show and without Art-Net, all channels stay at 0. The thread merges the last Art-Net frame +//! into each frame (`artnet.rs`). +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; +use autoblink_core_dmx::{ + BeatClock, Lights, Live, Merge, Show, Universe, ARTNET_TIMEOUT_MS, UNIVERSE, +}; +use autoblink_core_midi::scenes::Event; +use esp_idf_svc::hal::cpu::Core; use esp_idf_svc::hal::gpio::{Gpio36, Gpio4}; +use esp_idf_svc::hal::task::queue::Queue; use esp_idf_svc::hal::uart::{config, UartDriver, UART1}; use esp_idf_svc::hal::units::Hertz; +use serde::Serialize; -/// The fade time of the beat flash. -const FLASH_MS: f32 = 200.0; +use crate::State; /// The idle time between two frames. The UART sends one frame (break, start code and 512 /// channels) in about 23 ms, so the rate is about 42 Hz. @@ -25,48 +33,162 @@ const FRAME_GAP: Duration = Duration::from_millis(1); /// DMX512 needs at least 88 µs. const BREAK_BITS: i32 = 25; -#[derive(Clone, Default)] -pub struct Dmx { - last_beat: Arc>>, +/// The number of messages that the queue to the `dmx` thread holds. A full queue drops a message. +const QUEUE_LEN: usize = 32; + +/// A message to the `dmx` thread. The FreeRTOS queue copies it, so it must be `Copy`. +#[derive(Clone, Copy)] +pub enum DmxMsg { + /// The note on of a preset. The value is the preset index. + On(u8), + /// The note off of a preset. + Off(u8), + /// An event of the scenes fired a preset. + Fired(Event, u8), + /// The page tests a preset. + Test(u8), + /// The fog button of the page. + Fog, + /// The page aims a head by hand. + Aim { fixture: u8, pan: u16, tilt: u16 }, + /// A master beat. `at_ms` uses the clock of `State::now_ms`. + Beat { + beat_in_bar: u8, + bpm: f64, + at_ms: u64, + }, } -impl Dmx { - /// Starts the frame thread. `rx` is not used. The UART driver of ESP-IDF 5.5 does not install - /// without a receive buffer, so the driver gets the input-only pin IO36. - pub fn start( - uart: UART1<'static>, - tx: Gpio4<'static>, - rx: Gpio36<'static>, - ) -> anyhow::Result { - let cfg = config::Config::new() - .baudrate(Hertz(250_000)) - .stop_bits(config::StopBits::STOP2); - let uart = UartDriver::new(uart, tx, rx, None::, None::, &cfg)?; - let dmx = Self::default(); - let last_beat = dmx.last_beat.clone(); - crate::spawn(c"dmx", 4 * 1024, move || { - // Byte 0 is the start code (0 for dimmer data). Bytes 1 to 512 are the channels. - let mut frame = [0u8; 513]; - loop { - let level = last_beat - .lock() - .unwrap() - .map_or(0.0, |t| 1.0 - t.elapsed().as_secs_f32() * 1000.0 / FLASH_MS); - frame[1] = (level.clamp(0.0, 1.0) * 255.0) as u8; - // The UART sends the frame, then the break. The break before the next frame - // and the idle time after it make the reset sequence of DMX512. - if let Err(e) = uart.write_with_break(&frame, BREAK_BITS) { - log::warn!("DMX frame not sent: {e}"); - } - let _ = uart.wait_tx_done(100); - thread::sleep(FRAME_GAP); - } - })?; - log::info!("DMX output is on IO4"); - Ok(dmx) - } +/// The live controls of the page. The `dmx` thread reads them for each frame. +pub struct DmxLive { + /// 255 is full. + pub master: AtomicU8, + pub strobe_hold: AtomicBool, +} - pub fn beat(&self) { - *self.last_beat.lock().unwrap() = Some(Instant::now()); +impl Default for DmxLive { + fn default() -> Self { + Self { + master: AtomicU8::new(255), + strobe_hold: AtomicBool::new(false), + } } } + +/// The DMX part of the status for the page. +#[derive(Default, Clone, Serialize)] +pub struct DmxStatus { + /// The presets that show now, bottom first. + pub layers: Vec, + pub master: u8, + pub strobe_hold: bool, + /// `true` while Art-Net is live: the last frame is not older than `ARTNET_TIMEOUT_MS`. + pub artnet: bool, +} + +/// Starts the `dmx` thread. Returns the queue for the messages to the thread. +/// +/// `rx` is not used. The UART driver of ESP-IDF 5.5 does not install without a receive buffer, +/// so the driver gets the input-only pin IO36. +pub fn start( + uart: UART1<'static>, + tx: Gpio4<'static>, + rx: Gpio36<'static>, + state: Arc, + show: Arc>, +) -> anyhow::Result>> { + let cfg = config::Config::new() + .baudrate(Hertz(250_000)) + .stop_bits(config::StopBits::STOP2); + let uart = UartDriver::new(uart, tx, rx, None::, None::, &cfg)?; + let queue = Arc::new(Queue::::new(QUEUE_LEN)); + let msgs = queue.clone(); + // The merge state (about 1.5 KB) and the copy of the Art-Net frame are on the heap, not on + // the stack of the thread. + let mut merge = Box::::default(); + let mut art = Box::new([0u8; UNIVERSE]); + + // Priority 6 is above the HTTP server task (5) and below the `prolink-rx` (8) and `midi` (10) + // threads on core 1. + // ponytail: 7 KB is an estimate, not a measurement. With the Art-Net merge, the thread + // function has a 2880-byte frame (2496 bytes before). Its deepest direct call path is 4.3 KB, + // without the log calls. Measure with /mem on a box, and set the size from the low point. + crate::spawn_rt(c"dmx", 7 * 1024, 6, Some(Core::Core1), move || { + // Byte 0 is the start code (0 for dimmer data). Bytes 1 to 512 are the channels. + let mut frame = [0u8; 513]; + let mut lights = Lights::default(); + let mut clock = BeatClock::default(); + let mut playing = None; + // The time of the Art-Net frame in `art`. The thread keeps the old copy when the lock + // is busy. + let mut art_at: Option = None; + loop { + let now_ms = state.now_ms(); + let is_playing = state + .tracker + .lock() + .unwrap() + .master() + .is_some_and(|m| m.is_playing(now_ms)); + // `set_playing` keeps the time of the stop. Thus the thread calls it only on a change. + if playing != Some(is_playing) { + lights.set_playing(is_playing, now_ms); + playing = Some(is_playing); + } + let live = Live { + master: state.dmx_live.master.load(Ordering::Relaxed), + strobe_hold: state.dmx_live.strobe_hold.load(Ordering::Relaxed), + }; + // The thread does not wait for the `artnet` thread. It copies the frame and releases + // the lock before the merge. + if let Ok(slot) = state.artnet.try_lock() { + art.copy_from_slice(&slot.data[..]); + art_at = slot.at_ms; + } + { + let show = show.lock().unwrap(); + while let Some((msg, _)) = msgs.recv_front(0) { + match msg { + DmxMsg::On(p) => lights.on(p, now_ms), + DmxMsg::Off(p) => lights.off(p, now_ms), + DmxMsg::Fired(e, p) => lights.fired(&show, e, p, now_ms), + DmxMsg::Test(p) => lights.test(&show, p, now_ms), + DmxMsg::Fog => lights.fog(now_ms), + DmxMsg::Aim { fixture, pan, tilt } => { + lights.aim(fixture, pan, tilt, now_ms) + } + DmxMsg::Beat { + beat_in_bar, + bpm, + at_ms, + } => clock.beat(at_ms, beat_in_bar, bpm), + } + } + let universe: &mut Universe = (&mut frame[1..]).try_into().unwrap(); + let beat = clock.now(now_ms); + lights.frame(&show, beat.as_ref(), &live, now_ms, universe); + merge.apply(&show, art_at.map(|t| (&*art, t)), now_ms, universe); + } + // The page reads the status. The thread does not wait for the lock, and it writes + // the layers only after a change. The next frame tries again. + if let Ok(mut s) = state.dmx_status.try_lock() { + if !s.layers.iter().copied().eq(lights.layers()) { + s.layers.clear(); + s.layers.extend(lights.layers()); + } + s.master = live.master; + s.strobe_hold = live.strobe_hold; + s.artnet = art_at.is_some_and(|t| now_ms.saturating_sub(t) <= ARTNET_TIMEOUT_MS); + } + // The UART sends the frame, then the break. The break before the next frame + // and the idle time after it make the reset sequence of DMX512. + if let Err(e) = uart.write_with_break(&frame, BREAK_BITS) { + log::warn!("DMX frame not sent: {e}"); + } + let _ = uart.wait_tx_done(100); + thread::sleep(FRAME_GAP); + } + })?; + log::info!("DMX output is on IO4"); + Ok(queue) +} diff --git a/esp32/src/main.rs b/esp32/src/main.rs index 4cc0465..2d988f5 100644 --- a/esp32/src/main.rs +++ b/esp32/src/main.rs @@ -8,6 +8,8 @@ //! the page is also on that network, at the address from its DHCP server and at //! http://autoblink.local. +#[cfg(feature = "dmx")] +mod artnet; #[cfg(feature = "dmx")] mod dmx; mod led; @@ -15,9 +17,13 @@ mod logring; mod mem; mod midi; mod net; +#[cfg_attr(not(feature = "dmx"), allow(dead_code))] +mod ofl; mod ota; mod prolink; mod settings; +#[cfg(feature = "dmx")] +mod show; mod tracks; mod webui; @@ -67,6 +73,21 @@ pub struct State { pub ota: Mutex, /// A preset that the page asked to test. The MIDI thread sends its note. `u8::MAX` is none. pub scene_test: AtomicU8, + /// The live controls of the DMX output. + #[cfg(feature = "dmx")] + pub dmx_live: dmx::DmxLive, + /// The DMX part of the status. The `dmx` thread writes it. + #[cfg(feature = "dmx")] + pub dmx_status: Mutex, + /// The DMX show. The `dmx` thread reads it for each frame. + #[cfg(feature = "dmx")] + pub show: Arc>, + /// The flash partition of the show. `None` when the partition table has no show partition. + #[cfg(feature = "dmx")] + pub show_store: Mutex>, + /// The last Art-Net frame. The `artnet` thread writes it, and the `dmx` thread reads it. + #[cfg(feature = "dmx")] + pub artnet: Mutex, start: Instant, } @@ -116,6 +137,46 @@ fn run() -> anyhow::Result<()> { })?; let default_ssid = format!("autoblink-{:02X}{:02X}", mac[4], mac[5]); let settings = Settings::new(nvs, default_ssid)?; + + // The state needs nothing from the network. Thus the DMX output can start before the network. + #[cfg(feature = "dmx")] + let mut show_store = show::ShowStore::open(); + #[cfg(feature = "dmx")] + let show = Arc::new(Mutex::new( + show_store.as_mut().map(|s| s.load()).unwrap_or_default(), + )); + let state = Arc::new(State { + tracker: Mutex::new(Tracker::new()), + iface: Mutex::new("Ethernet: no address".into()), + midi: Mutex::new(MidiStatus::default()), + tracks: Tracks::default(), + link: Mutex::new(None), + conflict: AtomicU8::new(0), + own_device: AtomicU8::new(0), + wifi_join_down: AtomicBool::new(false), + reset_reason, + ota: Mutex::new(OtaStatus { + state: if ota_error.is_some() { "error" } else { "" }, + error: ota_error, + ..Default::default() + }), + scene_test: AtomicU8::new(u8::MAX), + #[cfg(feature = "dmx")] + dmx_live: dmx::DmxLive::default(), + #[cfg(feature = "dmx")] + dmx_status: Mutex::new(dmx::DmxStatus::default()), + #[cfg(feature = "dmx")] + show: show.clone(), + #[cfg(feature = "dmx")] + show_store: Mutex::new(show_store), + #[cfg(feature = "dmx")] + artnet: Mutex::new(artnet::ArtIn::default()), + start: Instant::now(), + }); + // The maintenance start of a firmware update also sends the idle look. + #[cfg(feature = "dmx")] + let dmx_q = dmx::start(p.uart1, p.pins.gpio4, p.pins.gpio36, state.clone(), show)?; + let ssid = settings.ssid(); let join = settings.join(); let join_ssid = join.as_ref().map(|(s, _)| s.clone()); @@ -170,24 +231,6 @@ fn run() -> anyhow::Result<()> { unsafe { sys::esp_restart() }; } - let state = Arc::new(State { - tracker: Mutex::new(Tracker::new()), - iface: Mutex::new("Ethernet: no address".into()), - midi: Mutex::new(MidiStatus::default()), - tracks: Tracks::default(), - link: Mutex::new(None), - conflict: AtomicU8::new(0), - own_device: AtomicU8::new(0), - wifi_join_down: AtomicBool::new(false), - reset_reason, - ota: Mutex::new(OtaStatus { - state: if ota_error.is_some() { "error" } else { "" }, - error: ota_error, - ..Default::default() - }), - scene_test: AtomicU8::new(u8::MAX), - start: Instant::now(), - }); let midi_beats = midi::start( p.uart2, p.pins.gpio17, @@ -195,25 +238,37 @@ fn run() -> anyhow::Result<()> { state.clone(), settings.midi.clone(), settings.scenes.clone(), + #[cfg(feature = "dmx")] + dmx_q.clone(), )?; let device_number = settings.device_number.clone(); let settings = Arc::new(Mutex::new(settings)); - let (_server, web_beats) = webui::start(state.clone(), settings.clone())?; + let (_server, web_beats) = webui::start( + state.clone(), + settings.clone(), + #[cfg(feature = "dmx")] + dmx_q.clone(), + )?; let track_changes = tracks::start(state.clone())?; let beat_tx = led::start(PinDriver::output(p.pins.gpio32)?, status_led, state.clone())?; - #[cfg(feature = "dmx")] - let dmx = dmx::Dmx::start(p.uart1, p.pins.gpio4, p.pins.gpio36)?; - prolink::spawn( state.clone(), device_number, move |beat| { // A full queue drops the LED pulse. The receive thread does not wait for the LEDs. let _ = beat_tx.try_send(beat.beat_in_bar); + // A full queue drops the beat. The receive thread does not wait for the DMX thread. #[cfg(feature = "dmx")] - dmx.beat(); + let _ = dmx_q.send_back( + dmx::DmxMsg::Beat { + beat_in_bar: beat.beat_in_bar, + bpm: beat.bpm, + at_ms: beat.at_us / 1000, + }, + 0, + ); let page_beat = (beat.device, beat.beat_in_bar); // A full queue drops the beat. The receive thread does not wait for the MIDI thread. let _ = midi_beats.send_back(beat, 0); @@ -225,6 +280,10 @@ fn run() -> anyhow::Result<()> { }, )?; + // Art-Net needs the network. The maintenance start does not get to this line. + #[cfg(feature = "dmx")] + artnet::start(state.clone()); + // The web server and the receive threads run. A restart before this line makes the // bootloader start the previous app. ota::mark_valid(); diff --git a/esp32/src/midi.rs b/esp32/src/midi.rs index 976dff5..979b170 100644 --- a/esp32/src/midi.rs +++ b/esp32/src/midi.rs @@ -34,6 +34,8 @@ use esp_idf_svc::hal::uart::{config, UartDriver, UART2}; use esp_idf_svc::hal::units::Hertz; use esp_idf_svc::sys; +#[cfg(feature = "dmx")] +use crate::dmx::DmxMsg; use crate::State; /// The CCs go out at most this often. @@ -74,6 +76,7 @@ pub fn start( state: Arc, cfg: Arc>, scenes_cfg: Arc>, + #[cfg(feature = "dmx")] dmx: Arc>, ) -> anyhow::Result>> { let uart_cfg = config::Config::new().baudrate(Hertz(31_250)); let uart = UartDriver::new(uart, tx, rx, None::, None::, &uart_cfg)?; @@ -93,6 +96,10 @@ pub fn start( let mut smoother = Smoother::default(); let mut smoothed_bpm = 0.0; let mut last_master = 0u8; + // The last guess of the loop. The guess keeps a Drop or a Break near its threshold. + let mut prev_guess = None; + // The master deck and the track of `prev_guess`. + let mut guess_key = (0u8, 0u32); let mut next_pulse: Option = None; let mut next_cc = Instant::now(); @@ -156,12 +163,16 @@ pub fn start( &mut send, ); mapper.beat(&cfg, b.beat_in_bar, &mut send); - scenes.beat( - &scenes_cfg.lock().unwrap(), - state.now_ms(), - b.beat_in_bar, - &mut send, - ); + let scenes_cfg = scenes_cfg.lock().unwrap(); + scenes.beat(&scenes_cfg, state.now_ms(), b.beat_in_bar, &mut |o| { + #[cfg(feature = "dmx")] + to_dmx(&dmx, &scenes_cfg, o); + send(o); + }); + #[cfg(feature = "dmx")] + scenes.drain_fired(|e, p| { + let _ = dmx.send_back(DmxMsg::Fired(e, p), 0); + }); } let now_ms = state.now_ms(); @@ -189,18 +200,31 @@ pub fn start( bpm: snap.bpm, phrase: None, bass_cut: None, + guess: None, + phrase16: m.phrase16, }; - (snap, m.beat_count.map(|bc| (m.track.rekordbox_id, bc))) + // The playhead and the length of the track, for the drop guess. + let pos = m.playhead_ms.zip(m.track_len_ms); + (snap, Some((m.track.rekordbox_id, m.beat_count, pos))) }) }; // Lock the tracks only after the tracker is free. - if let Some((rbid, beat_count)) = track { + if let Some((rbid, beat_count, pos)) = track { + // A new master deck or a new track starts without a previous guess. + if (snap.device, rbid) != guess_key { + guess_key = (snap.device, rbid); + prev_guess = None; + } let tracks = state.tracks.0.lock().unwrap(); - snap.phrase = tracks - .get(&snap.device) - .filter(|t| t.rekordbox_id == rbid) - .and_then(|t| t.structure.as_ref()?.as_ref().ok()) - .and_then(|ss| { + let data = tracks.get(&snap.device).filter(|t| t.rekordbox_id == rbid); + // Only a track without phrases has a guess. + scene_in.guess = data.and_then(|t| { + let (p, l) = pos?; + t.guess.as_ref()?.bucket_ahead(p, l, snap.bpm, prev_guess) + }); + snap.phrase = beat_count + .zip(data.and_then(|t| t.structure.as_ref()?.as_ref().ok())) + .and_then(|(beat_count, ss)| { let idx = current_phrase_idx(ss, beat_count)?; let info = phrase_info(ss, idx, beat_count)?; let entry = &ss.phrases[idx]; @@ -221,6 +245,7 @@ pub fn start( }) }); } + prev_guess = scene_in.guess; let master = (snap.device != 0).then_some(Master { device: snap.device, playing: snap.playing, @@ -247,8 +272,18 @@ pub fn start( if let Err(e) = uart.write(&buf[..n]) { log::warn!("MIDI message not sent: {e}"); } + #[cfg(feature = "dmx")] + let _ = dmx.send_back(DmxMsg::Test(test), 0); } - scenes.update(&scenes_cfg, state.now_ms(), &scene_in, &mut send); + scenes.update(&scenes_cfg, state.now_ms(), &scene_in, &mut |o| { + #[cfg(feature = "dmx")] + to_dmx(&dmx, &scenes_cfg, o); + send(o); + }); + #[cfg(feature = "dmx")] + scenes.drain_fired(|e, p| { + let _ = dmx.send_back(DmxMsg::Fired(e, p), 0); + }); scenes.status(&scenes_cfg) }; @@ -273,3 +308,21 @@ pub fn start( log::info!("MIDI output is on IO17"); Ok(beats) } + +/// Queues a note of a preset for the `dmx` thread. Other messages and the extra notes go only to +/// MIDI. A full queue drops the message, so that the MIDI thread does not wait. +#[cfg(feature = "dmx")] +fn to_dmx(dmx: &Queue, c: &ScenesConfig, o: Out) { + let preset = |ch: u8, note: u8| { + note.checked_sub(c.first_note) + .filter(|p| ch == c.channel && *p < c.count) + }; + let msg = match o { + Out::NoteOn { ch, note, .. } => preset(ch, note).map(DmxMsg::On), + Out::NoteOff { ch, note } => preset(ch, note).map(DmxMsg::Off), + _ => None, + }; + if let Some(m) = msg { + let _ = dmx.send_back(m, 0); + } +} diff --git a/esp32/src/ofl.rs b/esp32/src/ofl.rs new file mode 100644 index 0000000..f3d79fd --- /dev/null +++ b/esp32/src/ofl.rs @@ -0,0 +1,207 @@ +//! Gets light definitions from the Open Fixture Library (OFL) for the page. +//! +//! The phone on the WiFi network of the box often has no internet, so the box is the proxy. The +//! firmware passes the JSON of OFL through to the page. It does not parse the JSON. +//! +//! A fetch runs on the `ofl` thread with a 12 KB stack, because the TLS handshake needs a large +//! stack. The HTTP handler waits for the thread. TLS takes about 50 KB of heap. Thus a fetch does +//! not start when the heap is low, and the page gets no status while a fetch runs. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use esp_idf_svc::http::Method; +use esp_idf_svc::sys::{self, EspError}; + +use crate::ota; + +const BASE_URL: &str = "https://open-fixture-library.org"; + +/// The firmware cuts off a longer OFL reply, and the route answers 502. +pub const MAX_BODY: usize = 24 * 1024; +/// A fetch does not start below this free heap. +pub const MIN_FREE: u32 = 80 * 1024; +/// A fetch does not start below this largest free block. The TLS record buffer has 16 KB. +pub const MIN_BLOCK: u32 = 32 * 1024; + +/// The longest wait for the connection and for each read. +const TIMEOUT: Duration = Duration::from_secs(10); +/// The search text has 1 to this many characters. +const MAX_QUERY: usize = 64; +/// The reply buffer grows in steps of this size. +const CHUNK: usize = 4096; + +/// `true` while a fetch runs. +static BUSY: AtomicBool = AtomicBool::new(false); + +/// True while a fetch runs. The status thread sends no status then. +pub fn busy() -> bool { + BUSY.load(Ordering::Acquire) +} + +/// Searches OFL. Returns the reply body, or an HTTP status and a short text. +pub fn search(query: &str) -> Result, (u16, String)> { + if !(1..=MAX_QUERY).contains(&query.chars().count()) { + return Err(( + 400, + format!("The search text must have 1 to {MAX_QUERY} characters."), + )); + } + // A `str` serializes as one JSON string with escapes. The firmware builds no `Value` tree. + let text = serde_json::to_string(query).map_err(|e| (500, e.to_string()))?; + let body = format!(r#"{{"searchQuery":{text},"manufacturersQuery":[],"categoriesQuery":[]}}"#); + fetch( + Method::Post, + format!("{BASE_URL}/api/v1/get-search-results"), + body.into_bytes(), + ) +} + +/// Gets the JSON of one fixture. `key` is "/". +pub fn fixture(key: &str) -> Result, (u16, String)> { + if !valid_key(key) { + return Err(( + 400, + "The key must be / with only a-z, 0-9 and -.".into(), + )); + } + fetch(Method::Get, format!("{BASE_URL}/{key}.json"), Vec::new()) +} + +/// Returns `true` when `key` matches `^[a-z0-9-]+/[a-z0-9-]+$`. +fn valid_key(key: &str) -> bool { + let part = |p: &str| { + !p.is_empty() + && p.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }; + key.split_once('/').is_some_and(|(m, f)| part(m) && part(f)) +} + +/// Runs one request on the `ofl` thread and waits for it. +fn fetch(method: Method, url: String, body: Vec) -> Result, (u16, String)> { + // The HTTP server task runs this function, so a page cannot start an OTA check during a fetch. + // An OTA check that runs already uses the heap for TLS. + if ota::busy() { + return Err((503, "A firmware update check runs. Try again.".into())); + } + if BUSY.swap(true, Ordering::AcqRel) { + return Err((503, "Another library request runs. Try again.".into())); + } + let result = spawn_and_join(method, url, body); + BUSY.store(false, Ordering::Release); + result +} + +fn spawn_and_join(method: Method, url: String, body: Vec) -> Result, (u16, String)> { + // SAFETY: the heap calls only read counters. + let (free, block) = unsafe { + ( + sys::esp_get_free_heap_size(), + sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT), + ) + }; + if free < MIN_FREE || block < MIN_BLOCK as usize { + log::warn!("OFL request refused: {free} bytes free, largest block {block} bytes"); + return Err(( + 503, + format!( + "Not enough memory for the library request: {free} bytes free, largest block \ + {block} bytes. Close other pages of the box, then try again." + ), + )); + } + // The TLS handshake of mbedTLS needs a large stack. + let thread = + crate::spawn(c"ofl", 12 * 1024, move || request(method, &url, &body)).map_err(|e| { + ( + 503, + format!("The library request cannot start a thread: {e}"), + ) + })?; + thread + .join() + .unwrap_or_else(|_| Err((500, "The library request stopped after a panic.".into()))) +} + +/// Sends the request and reads the reply body. Runs on the `ofl` thread. +fn request(method: Method, url: &str, body: &[u8]) -> Result, (u16, String)> { + let start = Instant::now(); + let len = body.len().to_string(); + let headers = [ + ("Content-Type", "application/json"), + ("Content-Length", len.as_str()), + ]; + // A GET request has no body and thus no body headers. + let headers = if body.is_empty() { + &[][..] + } else { + &headers[..] + }; + let mut conn = ota::request(method, url, headers, body, TIMEOUT) + .map_err(|e| failed(e.downcast_ref::(), &format!("{e:#}"), start))?; + let status = conn.status(); + if status != 200 { + log::warn!("OFL request: {url} gave the status {status}"); + return Err(( + 502, + format!("The library answered with the status {status}."), + )); + } + // The reply has no Content-Length (chunked encoding). The buffer grows in small steps, so + // that it needs no block larger than `MAX_BODY` + 1. + let mut out = Vec::new(); + loop { + let len = out.len(); + // A failed allocation must not stop the firmware, so the reserve can fail. + if len == out.capacity() + && out + .try_reserve_exact(CHUNK.min(MAX_BODY + 1 - len)) + .is_err() + { + log::warn!("OFL request: no memory for the reply of {url} after {len} bytes"); + return Err((503, "Not enough memory for the library reply.".into())); + } + out.resize(out.capacity().min(MAX_BODY + 1), 0); + let n = match conn.read(&mut out[len..]) { + Ok(n) => n, + Err(e) => return Err(failed(Some(&e), &e.to_string(), start)), + }; + out.truncate(len + n); + if n == 0 { + log::info!("OFL request: {url} gave {} bytes", out.len()); + return Ok(out); + } + if out.len() > MAX_BODY { + log::warn!("OFL request: the reply of {url} is larger than {MAX_BODY} bytes"); + return Err(( + 502, + format!("The library reply is larger than {MAX_BODY} bytes."), + )); + } + } +} + +/// Gives the status and the text for an error of the HTTPS client. +/// +/// A timeout gives 504. Each other error gives 502, for example a DNS error or no route to the +/// internet. +fn failed(e: Option<&EspError>, text: &str, start: Instant) -> (u16, String) { + log::warn!("OFL request: {text}"); + // The HTTP client gives some errors as negative codes. + let code = e.map_or(0, |e| e.code().abs()); + // A failed connection gives the same code for a timeout and for a refusal. Only the time + // shows a timeout. + let timeout = matches!( + code, + sys::ESP_ERR_HTTP_EAGAIN | sys::ESP_ERR_HTTP_READ_TIMEOUT | sys::ESP_ERR_HTTP_CONNECTING + ) || start.elapsed() >= TIMEOUT; + if timeout { + (504, "The library did not answer in time.".to_string()) + } else { + ( + 502, + format!("The library request failed. The box needs a network with internet. {text}"), + ) + } +} diff --git a/esp32/src/ota.rs b/esp32/src/ota.rs index 5a32c98..f0ff368 100644 --- a/esp32/src/ota.rs +++ b/esp32/src/ota.rs @@ -275,24 +275,42 @@ fn install(state: &Mutex) -> anyhow::Result<()> { /// Sends a GET request. Returns the connection after a 200 response. fn get(url: &str) -> anyhow::Result { + let conn = request(Method::Get, url, &[], &[], Duration::from_secs(15))?; + match conn.status() { + 200 => Ok(conn), + status => bail!("GET {url} gave the status {status}"), + } +} + +/// Sends an HTTPS request with `body` and reads the response headers. Returns the connection +/// for each status. `ofl.rs` uses this function too. +/// +/// The server certificate must chain to a root of the common certificate bundle. `timeout` is the +/// longest wait for the connection and for each read. For a body, give a `Content-Length` header. +/// Without it, the client sends a POST body in chunks. +pub fn request( + method: Method, + url: &str, + headers: &[(&str, &str)], + body: &[u8], + timeout: Duration, +) -> anyhow::Result { fix_dns(); let mut conn = EspHttpConnection::new(&Configuration { // GitHub redirects a release file to a URL with more than 500 characters. buffer_size: Some(4096), buffer_size_tx: Some(2048), - timeout: Some(Duration::from_secs(15)), + timeout: Some(timeout), crt_bundle_attach: Some(sys::esp_crt_bundle_attach), ..Default::default() })?; - conn.initiate_request(Method::Get, url, &[]) + conn.initiate_request(method, url, headers) .with_context(|| { format!("No connection to {url}. The box needs a network with internet.") })?; + conn.write_all(body)?; conn.initiate_response()?; - match conn.status() { - 200 => Ok(conn), - status => bail!("GET {url} gave the status {status}"), - } + Ok(conn) } /// The DNS server for a joined network in the subnet of the own WiFi network. diff --git a/esp32/src/show.rs b/esp32/src/show.rs new file mode 100644 index 0000000..3df80b9 --- /dev/null +++ b/esp32/src/show.rs @@ -0,0 +1,195 @@ +//! The DMX show in the flash partition `show`. +//! +//! Bytes 0 to 3 hold the length of the JSON as a little-endian `u32`. The JSON follows. An +//! erased length (0xFFFF_FFFF) or a length that is too large for the partition means "no show". +//! `save` writes the length last. Thus a power loss during a save gives "no show", not half a +//! show. + +use std::io; + +use autoblink_core_dmx::Show; +use esp_idf_svc::partition::EspPartition; + +/// The bytes of the length field before the JSON. +const HEADER: usize = 4; +/// The size of the read buffer and of the write buffer. The buffers are on the stack. +const BUF: usize = 256; + +pub struct ShowStore { + part: EspPartition, +} + +impl ShowStore { + /// `None` when the partition table has no "show" partition (a box flashed before 1.24.0). + pub fn open() -> Option { + // SAFETY: only this function opens the show partition, and `main` calls it one time. + match unsafe { EspPartition::new("show") } { + Ok(Some(part)) => Some(ShowStore { part }), + Ok(None) => { + log::warn!( + "The partition table has no show partition. Flash the box over USB to add it." + ); + None + } + Err(e) => { + log::warn!("The firmware cannot open the show partition: {e}"); + None + } + } + } + + /// Reads the show. Erased flash or bad data gives `Show::default()`. + pub fn load(&mut self) -> Show { + match self.read_show() { + Ok(Some(mut show)) => { + show.sanitize(); + show + } + Ok(None) => Show::default(), + Err(e) => { + log::warn!("The firmware cannot read the show. It uses an empty show: {e:#}"); + Show::default() + } + } + } + + /// Writes the show as JSON. Erases only the sectors that the new data needs. + pub fn save(&mut self, show: &Show) -> anyhow::Result<()> { + // The first pass counts the bytes. Thus a show that is too large does not erase the + // stored show. + let mut count = Counter(0); + serde_json::to_writer(&mut count, show)?; + let len = count.0; + let max = self.part.size() - HEADER; + anyhow::ensure!( + len <= max, + "The show has {len} bytes of JSON. The show partition holds {max} bytes." + ); + let sector = self.part.erase_size(); + let end = HEADER + len; + self.part.erase(0, end.div_ceil(sector) * sector)?; + let mut w = Writer { + part: &mut self.part, + pos: HEADER, + end, + buf: [0; BUF], + len: 0, + }; + serde_json::to_writer(&mut w, show)?; + io::Write::flush(&mut w)?; + self.part.write(0, &(len as u32).to_le_bytes())?; + Ok(()) + } + + /// Gives the length of the stored JSON, or `None` when the partition has no show. + fn stored_len(&mut self) -> anyhow::Result> { + let mut header = [0u8; HEADER]; + self.part.read(0, &mut header)?; + let len = u32::from_le_bytes(header); + if len == u32::MAX || len as usize > self.part.size() - HEADER { + return Ok(None); + } + Ok(Some(len as usize)) + } + + fn read_show(&mut self) -> anyhow::Result> { + let Some(len) = self.stored_len()? else { + return Ok(None); + }; + let reader = Reader { + part: &mut self.part, + pos: HEADER, + end: HEADER + len, + buf: [0; BUF], + start: 0, + filled: 0, + }; + Ok(Some(serde_json::from_reader(reader)?)) + } +} + +/// Counts the bytes of the JSON and keeps none of them. +struct Counter(usize); + +impl io::Write for Counter { + fn write(&mut self, data: &[u8]) -> io::Result { + self.0 += data.len(); + Ok(data.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Writes the JSON to the erased flash in blocks of `BUF` bytes. +struct Writer<'a> { + part: &'a mut EspPartition, + /// The partition offset of the first byte in `buf`. + pos: usize, + /// The end of the erased area. The writer does not write after it. + end: usize, + buf: [u8; BUF], + len: usize, +} + +impl io::Write for Writer<'_> { + fn write(&mut self, data: &[u8]) -> io::Result { + if self.len == BUF { + self.flush()?; + } + let n = data.len().min(BUF - self.len); + self.buf[self.len..self.len + n].copy_from_slice(&data[..n]); + self.len += n; + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + if self.len == 0 { + return Ok(()); + } + if self.pos + self.len > self.end { + return Err(io::Error::other( + "The show JSON is longer than the erased area.", + )); + } + self.part + .write(self.pos, &self.buf[..self.len]) + .map_err(io::Error::other)?; + self.pos += self.len; + self.len = 0; + Ok(()) + } +} + +/// Reads the stored JSON in blocks of `BUF` bytes, and stops at the stored length. +struct Reader<'a> { + part: &'a mut EspPartition, + /// The partition offset of the next block. + pos: usize, + end: usize, + buf: [u8; BUF], + start: usize, + filled: usize, +} + +impl io::Read for Reader<'_> { + fn read(&mut self, out: &mut [u8]) -> io::Result { + if self.start == self.filled { + let n = BUF.min(self.end - self.pos); + if n == 0 { + return Ok(0); + } + self.part + .read(self.pos, &mut self.buf[..n]) + .map_err(io::Error::other)?; + self.pos += n; + self.start = 0; + self.filled = n; + } + let n = out.len().min(self.filled - self.start); + out[..n].copy_from_slice(&self.buf[self.start..self.start + n]); + self.start += n; + Ok(n) + } +} diff --git a/esp32/src/tracks.rs b/esp32/src/tracks.rs index 1a99888..618aa7b 100644 --- a/esp32/src/tracks.rs +++ b/esp32/src/tracks.rs @@ -16,6 +16,7 @@ use std::time::{Duration, Instant}; use autoblink_core_dbserver::{ asking_player, fetch_with, SongStructure, TrackMetadata, TrackOverview, }; +use autoblink_core_midi::guess::DropGuess; use autoblink_core_prolink::TrackRef; use esp_idf_svc::sys; @@ -40,6 +41,8 @@ pub struct TrackData { /// `None` while the fetch runs. `Err` holds the reason that no song structure is available. pub structure: Option>, pub overview: Option>, + /// The drop guess from the preview. Only a track without phrases has one. + pub guess: Option, } /// The tracks by deck number. @@ -159,6 +162,16 @@ fn fetch_one(state: &State, track: TrackRef) -> bool { .set_track_length(track.device, track.rekordbox_id, secs); } } + // Build the guess before the lock. It takes about 4 KB of heap for a short time. + let guess = result + .as_ref() + .ok() + .filter(|f| { + !f.song_structure + .as_ref() + .is_ok_and(|ss| !ss.phrases.is_empty()) + }) + .and_then(|f| DropGuess::from_preview(&f.overview.as_ref()?.preview)); let mut tracks = state.tracks.0.lock().unwrap(); // A newer track on the deck makes this result stale. let Some(data) = tracks @@ -178,9 +191,18 @@ fn fetch_one(state: &State, track: TrackRef) -> bool { f.metadata.artist, f.metadata.title ); + if let Some(g) = &guess { + let (hi, lo, mid) = g.thresholds(); + let (first, last) = g.mid_columns(); + log::info!( + "Deck {}: guess thresholds hi {hi}, lo {lo}, mid {mid}, columns {first} to {last}.", + track.device + ); + } data.metadata = Some(f.metadata); data.structure = Some(f.song_structure); data.overview = f.overview.map(Arc::new); + data.guess = guess; true } Err(e) => { diff --git a/esp32/src/webui.rs b/esp32/src/webui.rs index 3498fb1..5f00ab1 100644 --- a/esp32/src/webui.rs +++ b/esp32/src/webui.rs @@ -6,6 +6,13 @@ //! With `ota`, the page shows the System panel: the firmware update, the restart button and the //! WiFi settings. The page sends `reboot` for the restart button. //! +//! With the `dmx` feature, the firmware also sends the capability `dmx` and the status field +//! `dmx`. `GET /show` gives the DMX show as JSON, and `POST /show` replaces it. The commands +//! `dmx_live`, `dmx_fog` and `dmx_aim` set the live controls. They get no reply. +//! +//! `GET /ofl/search?q=` and `GET /ofl/fixture?key=/` pass the JSON of +//! the Open Fixture Library through to the page (see `ofl.rs`). +//! //! The firmware sends the status and the beats to one page at a time, the active page. Another //! page gets `busy` and shows a button. The button sends `take_over`, and the firmware closes the //! connection of the active page. That page then connects again and gets `busy`. @@ -26,6 +33,9 @@ use esp_idf_svc::ws::FrameType; use serde::{Serialize, Serializer}; use serde_json::{json, Value}; +#[cfg(feature = "dmx")] +use crate::dmx::{DmxMsg, DmxStatus}; +use crate::ofl; use crate::ota::{self, OtaCmd, OtaStatus}; use crate::settings::Settings; use crate::tracks::TrackData; @@ -33,14 +43,35 @@ use crate::State; use autoblink_core_dbserver::{phrase_status, PhraseKind, TrackOverview}; use autoblink_core_midi::scenes::{SceneStatus, ScenesConfig}; use autoblink_core_prolink::{Deck, PlayState}; +#[cfg(feature = "dmx")] +use esp_idf_svc::hal::task::queue::Queue; +#[cfg(feature = "dmx")] +use esp_idf_svc::http::server::{EspHttpConnection, Request}; /// The page, compressed with gzip by `build.rs`. const INDEX_HTML_GZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/index.html.gz")); /// The firmware ignores a command that is longer than this. -/// The scenes command is the longest command, about 1.5 KB with 16 rows. +/// The scenes command is the longest command, about 1.5 KB with 16 rows. A row without holds leaves out the `holds` key. const MAX_COMMAND_LEN: usize = 3072; +/// The capabilities in the hello message. +const CAPS: &[&str] = &[ + "beat", + "decks", + "output", + "settings", + "waveform", + "ota", + "scenes", + #[cfg(feature = "dmx")] + "dmx", +]; + +/// `POST /show` refuses a longer body. +#[cfg(feature = "dmx")] +const MAX_SHOW_LEN: usize = 60_000; + /// The page gets the status this often. const STATUS_INTERVAL: Duration = Duration::from_millis(100); @@ -54,18 +85,21 @@ const STATUS_INTERVAL: Duration = Duration::from_millis(100); struct Active(Mutex<(u32, Option)>); impl Active { - fn send(&self, text: &[u8]) { + /// Returns true when the send failed and removed the active page. + fn send(&self, text: &[u8]) -> bool { // A send waits for the HTTP server task, and that task takes this lock when a connection // closes. Send without the lock, so that the two tasks cannot block each other. let (generation, Some(mut sender)) = self.0.lock().unwrap().clone() else { - return; + return false; }; if sender.send(FrameType::Text(false), text).is_err() { let mut active = self.0.lock().unwrap(); if active.0 == generation { active.1 = None; + return true; } } + false } /// Makes `sender` the active connection. Returns the old active connection. @@ -77,11 +111,14 @@ impl Active { /// Removes the connection `session` if it is the active connection. Call it only on the HTTP /// server task, where the socket number of a closed connection is not yet free. - fn remove(&self, session: i32) { + /// Returns true when the session was the active page. + fn remove(&self, session: i32) -> bool { let mut active = self.0.lock().unwrap(); - if active.1.as_ref().is_some_and(|a| a.session() == session) { + let was_active = active.1.as_ref().is_some_and(|a| a.session() == session); + if was_active { active.1 = None; } + was_active } fn is_some(&self) -> bool { @@ -97,6 +134,7 @@ impl Active { pub fn start( state: Arc, settings: Arc>, + #[cfg(feature = "dmx")] dmx_q: Arc>, ) -> anyhow::Result<(EspHttpServer<'static>, SyncSender<(u8, u8)>)> { // When all sockets are open, a new connection closes the socket that received data least // recently. The active WebSocket receives no data, so it is that socket. The page loads the @@ -155,6 +193,11 @@ pub fn start( .write_all(crate::mem::report().as_bytes()) })?; + #[cfg(feature = "dmx")] + show_routes(&mut server, &state)?; + #[cfg(feature = "dmx")] + ofl_routes(&mut server)?; + for path in ["/", "/index.html"] { server.fn_handler(path, Method::Get, |req| { req.into_response( @@ -187,7 +230,7 @@ pub fn start( "proto": 1, "version": env!("CARGO_PKG_VERSION"), "device": "esp32", - "caps": ["beat", "decks", "output", "settings", "waveform", "ota", "scenes"], + "caps": CAPS, }); ws.send(FrameType::Text(false), hello.to_string().as_bytes())?; // Only the HTTP server task sets the active page, so the check and the set cannot race. @@ -201,7 +244,10 @@ pub fn start( return ws.send(FrameType::Text(false), msg.as_bytes()); } if ws.is_closed() { - ws_active.remove(ws.session()); + // The active page can leave the network while it holds the strobe. It then does not send the release. + if ws_active.remove(ws.session()) { + release_strobe(&ws_state); + } return Ok(()); } @@ -242,13 +288,23 @@ pub fn start( ); }; if cmd["type"] == "take_over" { - take_over(ws, &ws_active)?; + take_over(ws, &ws_active, &ws_state)?; let msg = settings.lock().unwrap().message(); ws.send(FrameType::Text(false), msg.as_bytes())?; let msg = settings.lock().unwrap().scenes_message(); return ws.send(FrameType::Text(false), msg.as_bytes()); } - let reply = command(&cmd, &settings, &ws_state); + let reply = command( + &cmd, + &settings, + &ws_state, + #[cfg(feature = "dmx")] + &dmx_q, + ); + // A command without a reply gives an empty text. + if reply.is_empty() { + return Ok(()); + } ws.send(FrameType::Text(false), reply.as_bytes()) }; server.ws_handler("/ws", None, move |ws| -> Result<(), EspError> { @@ -274,19 +330,23 @@ pub fn start( beat_msg, r#"{{"type":"beat","device":{device},"beat":{beat}}}"# ); - active.send(beat_msg.as_bytes()); + if active.send(beat_msg.as_bytes()) { + release_strobe(&state); + } } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => return, } if Instant::now() >= next_status { next_status = Instant::now() + STATUS_INTERVAL; - // The OTA check takes about 50 KB of heap for TLS. The status message can then - // fail to allocate, so the page gets no status until the check ends. - if active.is_some() && !ota::busy() { + // The OTA check and an OFL fetch take about 50 KB of heap for TLS. The status + // message can then fail to allocate, so the page gets no status until they end. + if active.is_some() && !ota::busy() && !ofl::busy() { let clock_enabled = midi_cfg.lock().unwrap().clock_enabled; status_message(&state, clock_enabled, &mut status); - active.send(&status); + if active.send(&status) { + release_strobe(&state); + } } } } @@ -295,6 +355,215 @@ pub fn start( Ok((server, beat_tx)) } +/// Releases the strobe hold. The active page can leave the network while it holds the strobe, +/// and it cannot send the release. +fn release_strobe(_state: &State) { + #[cfg(feature = "dmx")] + _state + .dmx_live + .strobe_hold + .store(false, std::sync::atomic::Ordering::Relaxed); +} + +/// Adds `GET /ofl/search?q=` and `GET /ofl/fixture?key=/`. +/// +/// The handler waits for the fetch of `ofl.rs`. Each step of the fetch can wait up to 10 s. +/// Meanwhile the HTTP server task serves no other request, and the page gets no beat and no +/// status. A page from the computer of the developer uses the two routes too (see `dev_origin`). +/// Thus each reply has `Access-Control-Allow-Origin: *`. +#[cfg(feature = "dmx")] +fn ofl_routes(server: &mut EspHttpServer<'static>) -> anyhow::Result<()> { + type Fetch = fn(&str) -> Result, (u16, String)>; + let routes: [(&str, &str, Fetch); 2] = [ + ("/ofl/search", "q", ofl::search), + ("/ofl/fixture", "key", ofl::fixture), + ]; + for (path, name, fetch) in routes { + server.fn_handler(path, Method::Get, move |req| -> anyhow::Result<()> { + let (origin, host) = (req.header("Origin"), req.header("Host")); + let reply = if !origin_allowed(origin, host) { + log::warn!("GET {path} refused: Origin {origin:?} does not match Host {host:?}"); + Err(( + 403, + "The Origin of the page does not match the Host.".to_string(), + )) + } else { + match query_value(req.uri(), name) { + Some(value) => fetch(&value), + None => Err((400, format!("The request has no valid value for {name}."))), + } + }; + let cors = ("Access-Control-Allow-Origin", "*"); + match reply { + Ok(body) => req + .into_response(200, None, &[("Content-Type", "application/json"), cors])? + .write_all(&body)?, + Err((status, text)) => req + .into_response( + status, + None, + &[("Content-Type", "text/plain; charset=utf-8"), cors], + )? + .write_all(text.as_bytes())?, + } + Ok(()) + })?; + } + Ok(()) +} + +/// Returns the URL-decoded value of the query parameter `name` in `uri`. A `+` is a space. +/// +/// Returns `None` when `uri` has no such parameter, when an escape is not `%` and two hex +/// digits, or when the text is not UTF-8. +#[cfg_attr(not(feature = "dmx"), allow(dead_code))] +fn query_value(uri: &str, name: &str) -> Option { + let (_, query) = uri.split_once('?')?; + let raw = query + .split('&') + .find_map(|p| p.strip_prefix(name)?.strip_prefix('='))?; + let mut out = Vec::with_capacity(raw.len()); + let mut bytes = raw.bytes(); + while let Some(b) = bytes.next() { + out.push(match b { + b'+' => b' ', + b'%' => { + let hi = char::from(bytes.next()?).to_digit(16)?; + let lo = char::from(bytes.next()?).to_digit(16)?; + (hi * 16 + lo) as u8 + } + b => b, + }); + } + String::from_utf8(out).ok() +} + +/// Adds `GET /show` and `POST /show`. +/// +/// A page from the computer of the developer uses the two routes too (see `dev_origin`). The page +/// sends the POST body as `text/plain`, so the browser sends no preflight request. Thus each +/// reply has `Access-Control-Allow-Origin: *`, and the route accepts each content type. +#[cfg(feature = "dmx")] +fn show_routes(server: &mut EspHttpServer<'static>, state: &Arc) -> anyhow::Result<()> { + const CORS: (&str, &str) = ("Access-Control-Allow-Origin", "*"); + + let get_state = state.clone(); + server.fn_handler("/show", Method::Get, move |req| -> anyhow::Result<()> { + // The `dmx` thread locks the show for each frame. A clone keeps the lock short. + let show = get_state.show.lock().unwrap().clone(); + let mut resp = + req.into_response(200, None, &[("Content-Type", "application/json"), CORS])?; + // The buffer sends the JSON in chunks of 512 bytes. The full JSON is not on the heap. + let mut w = std::io::BufWriter::with_capacity(512, StdWrite(&mut resp)); + serde_json::to_writer(&mut w, &show)?; + std::io::Write::flush(&mut w)?; + Ok(()) + })?; + + let post_state = state.clone(); + server.fn_handler( + "/show", + Method::Post, + move |mut req| -> anyhow::Result<()> { + let (status, text) = post_show(&mut req, &post_state); + let headers = [("Content-Type", "text/plain; charset=utf-8"), CORS]; + req.into_response(status, None, &headers)? + .write_all(text.as_bytes())?; + Ok(()) + }, + )?; + Ok(()) +} + +/// Reads, checks and stores the show of `POST /show`. Returns the status and the text of the reply. +#[cfg(feature = "dmx")] +fn post_show(req: &mut Request<&mut EspHttpConnection>, state: &State) -> (u16, String) { + let (origin, host) = (req.header("Origin"), req.header("Host")); + if !origin_allowed(origin, host) { + log::warn!("POST /show refused: Origin {origin:?} does not match Host {host:?}"); + return ( + 403, + "The Origin of the page does not match the Host.".into(), + ); + } + let len = req + .header("Content-Length") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + if len > MAX_SHOW_LEN { + return ( + 413, + format!("The show has {len} bytes. The limit is {MAX_SHOW_LEN} bytes."), + ); + } + // The parsed show and the scratch buffer of serde_json for a long string need heap. A body + // larger than half of the largest free block can fail to allocate, and the box restarts. + // SAFETY: the heap call only reads counters. + let block = unsafe { sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT) }; + if len > block / 2 { + log::warn!("POST /show refused: {len} bytes, largest free block {block} bytes"); + return (503, "Not enough memory for the show. Try again.".into()); + } + // serde_json reads the body in small blocks. A full copy of the body is not on the heap. + let body = std::io::BufReader::with_capacity(512, StdRead(&mut *req)); + let mut show: autoblink_core_dmx::Show = match serde_json::from_reader(body) { + Ok(show) => show, + Err(e) => return (400, format!("The show is not valid: {e}")), + }; + show.sanitize(); + // The flash erase and write take time. The firmware holds only the store lock for them, so + // the `dmx` thread does not stop. + let saved = match state.show_store.lock().unwrap().as_mut() { + Some(store) => store.save(&show), + None => { + return ( + 500, + "The box has no show partition. Flash the box over USB to add it.".into(), + ) + } + }; + if let Err(e) = saved { + log::warn!("The firmware cannot save the show: {e:#}"); + return (500, format!("The firmware cannot save the show: {e:#}")); + } + *state.show.lock().unwrap() = show; + log::info!("The page saved a new DMX show"); + (204, String::new()) +} + +/// Gives an `esp_idf_svc::io::Read` to serde_json as a `std::io::Read`. +#[cfg(feature = "dmx")] +struct StdRead(R); + +#[cfg(feature = "dmx")] +impl std::io::Read for StdRead +where + R::Error: std::error::Error + Send + Sync + 'static, +{ + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf).map_err(std::io::Error::other) + } +} + +/// Gives an `esp_idf_svc::io::Write` to serde_json as a `std::io::Write`. +#[cfg(feature = "dmx")] +struct StdWrite(W); + +#[cfg(feature = "dmx")] +impl std::io::Write for StdWrite +where + W::Error: std::error::Error + Send + Sync + 'static, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.write_all(buf).map_err(std::io::Error::other)?; + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + /// Writes the waveform route body in the host format (`host/src/web/mod.rs`). /// /// The preview has about 3600 bytes. As a `serde_json::Value`, it needs a large heap block. The @@ -355,7 +624,7 @@ fn write_waveform(w: &mut W, o: &TrackOverview) -> Result<(), W::Error /// Makes the page that sends this command the active page. Closes the connection of the old /// active page. -fn take_over(ws: &mut EspHttpWsConnection, active: &Active) -> Result<(), EspError> { +fn take_over(ws: &mut EspHttpWsConnection, active: &Active, state: &State) -> Result<(), EspError> { let EspHttpWsConnection::Receiving(sd, _, _) = ws else { return Ok(()); }; @@ -367,6 +636,8 @@ fn take_over(ws: &mut EspHttpWsConnection, active: &Active) -> Result<(), EspErr // SAFETY: `sd` is the live server handle of this handler call. A closed `old` socket // gives only an error code. unsafe { sys::httpd_sess_trigger_close(sd, old) }; + // The page that loses control also loses its strobe hold. + release_strobe(state); } Ok(()) } @@ -377,7 +648,13 @@ fn error(text: &str) -> String { } /// Runs one command from the page. Returns the reply as JSON text: the new settings, or an error. -fn command(cmd: &Value, settings: &Mutex, state: &Arc) -> String { +/// Returns an empty text for a command without a reply. +fn command( + cmd: &Value, + settings: &Mutex, + state: &Arc, + #[cfg(feature = "dmx")] dmx_q: &Queue, +) -> String { match cmd["type"].as_str() { Some(kind @ ("set" | "cycle")) => { let Some(idx) = cmd["idx"].as_u64().map(|i| i as usize) else { @@ -435,6 +712,39 @@ fn command(cmd: &Value, settings: &Mutex, state: &Arc) -> Strin } Err(e) => error(&format!("The firmware cannot restart: {e}")), }, + // The `dmx` thread reads the live controls for each frame. + #[cfg(feature = "dmx")] + Some("dmx_live") => match (cmd["master"].as_f64(), cmd["strobe"].as_bool()) { + (Some(master), Some(strobe)) => { + use std::sync::atomic::Ordering::Relaxed; + let live = &state.dmx_live; + live.master.store(master.clamp(0.0, 255.0) as u8, Relaxed); + live.strobe_hold.store(strobe, Relaxed); + String::new() + } + _ => error("The command needs master and strobe"), + }, + // A full queue drops the fog. The page can send it again. + #[cfg(feature = "dmx")] + Some("dmx_fog") => { + let _ = dmx_q.send_back(DmxMsg::Fog, 0); + String::new() + } + // A full queue drops the aim. The page sends the next one soon. + #[cfg(feature = "dmx")] + Some("dmx_aim") => match cmd["fixture"].as_u64().filter(|f| *f < 32) { + Some(fixture) => { + let axis = |key: &str| cmd[key].as_u64().unwrap_or(0).min(65535) as u16; + let msg = DmxMsg::Aim { + fixture: fixture as u8, + pan: axis("pan"), + tilt: axis("tilt"), + }; + let _ = dmx_q.send_back(msg, 0); + String::new() + } + None => error("The command needs a fixture below 32"), + }, Some(other) => error(&format!("This device does not support the command {other}")), None => error("The command has no type"), } @@ -546,6 +856,10 @@ struct Status<'a> { mixer: Option<()>, ota: OtaStatus, system: SystemJson, + /// `None` while the `dmx` thread holds the lock. The page then keeps the last value. + #[cfg(feature = "dmx")] + #[serde(skip_serializing_if = "Option::is_none")] + dmx: Option, } #[derive(Serialize)] @@ -718,6 +1032,9 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { if ota.state.is_empty() { ota.state = "idle"; } + // The `dmx` thread does not wait for this lock, so the status does not wait either. + #[cfg(feature = "dmx")] + let dmx = state.dmx_status.try_lock().ok().map(|s| s.clone()); let status = Status { kind: "status", source: master.map(|_| "ProLink (network)"), @@ -748,6 +1065,8 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { heap_free: unsafe { sys::esp_get_free_heap_size() }, heap_min: unsafe { sys::esp_get_minimum_free_heap_size() }, }, + #[cfg(feature = "dmx")] + dmx, }; out.clear(); // Serialization to a Vec fails only when the heap is full. diff --git a/host/Cargo.toml b/host/Cargo.toml index 2ee8f60..187ccda 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -26,6 +26,7 @@ serde_json = "1" rusty_link = "0.2" autoblink-core-midi = { path = "../crates/autoblink-core-midi" } +autoblink-core-dmx = { path = "../crates/autoblink-core-dmx" } autoblink-core-prolink = { path = "../crates/autoblink-core-prolink" } [dev-dependencies] diff --git a/host/src/app.rs b/host/src/app.rs index d3f1fcb..f610347 100644 --- a/host/src/app.rs +++ b/host/src/app.rs @@ -112,6 +112,8 @@ pub struct AppContext { // track change pub track_change_tx: mpsc::Sender, pub track_change_rx: mpsc::Receiver, + /// The DMX show of the host, from `show.json` next to the config file. + pub show: crate::dmx::SharedShow, } /// Initialize logging, load config, detect interface, open MIDI, build state. @@ -212,6 +214,9 @@ pub fn init() -> anyhow::Result { let scenes_path = config::scenes_path(&cli.config); initial_cfg.scenes = config::load_scenes(&scenes_path); initial_cfg.scenes_path = Some(scenes_path); + let show_path = config::show_path(&cli.config); + let show = Arc::new(parking_lot::RwLock::new(config::load_show(&show_path))); + initial_cfg.show_path = Some(show_path); if let Some(iface) = &cli.interface { initial_cfg.interface = iface.clone(); } @@ -291,6 +296,7 @@ pub fn init() -> anyhow::Result { vcdjready_tx, track_change_tx, track_change_rx, + show, }) } diff --git a/host/src/config.rs b/host/src/config.rs index 6f667e2..251b1fe 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -1,8 +1,10 @@ use std::sync::Arc; +use autoblink_core_dmx::{Show, ARTNET_PORT}; use autoblink_core_midi::scenes::ScenesConfig; use parking_lot::RwLock; use serde::Deserialize; +use std::net::{Ipv4Addr, SocketAddrV4}; use std::path::{Path, PathBuf}; // ── Beat source selector ────────────────────────────────────────────────────── @@ -33,12 +35,16 @@ pub struct Config { pub link: LinkConfig, pub web: WebConfig, pub osc: OscConfig, + pub dmx: DmxConfig, /// The DMX scenes. The page changes them, and the app stores them in `scenes_path`. #[serde(skip)] pub scenes: ScenesConfig, /// The JSON file of the scenes, next to the config file. `None` stores nothing. #[serde(skip)] pub scenes_path: Option, + /// The JSON file of the DMX show, next to the config file. `None` stores nothing. + #[serde(skip)] + pub show_path: Option, } pub type SharedConfig = Arc>; @@ -58,8 +64,10 @@ impl Default for Config { link: LinkConfig::default(), web: WebConfig::default(), osc: OscConfig::default(), + dmx: DmxConfig::default(), scenes: ScenesConfig::default(), scenes_path: None, + show_path: None, } } } @@ -392,6 +400,61 @@ impl Default for OscConfig { } } +// ── DMX output config ───────────────────────────────────────────────────────── + +/// The DMX engine of the host. It sends Art-Net to a DMX node while no AUTOBLINK box is on the +/// network. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct DmxConfig { + /// The Art-Net node as "IP" or "IP:port". The default port is 6454. A broadcast address is + /// permitted. Without a target, the host starts no DMX engine. + /// + /// The engine stops while a device with the name "autoblink" and another device number is on + /// the network. Two hosts with the default `device_name` thus see each other as a box, and + /// neither host sends Art-Net. + pub artnet_target: Option, + /// The Art-Net universe (Net << 8 | SubUni), 0 to 32767. + pub universe: u16, +} + +/// The Art-Net universe cannot be higher than this value, because the Net field has 7 bits. +const MAX_UNIVERSE: u16 = 0x7fff; + +impl DmxConfig { + /// Returns the address of the Art-Net node, or `None` when the target is not set or not valid. + pub fn target(&self) -> Option { + let t = self.artnet_target.as_deref()?.trim(); + t.parse::() + .ok() + .or_else(|| { + t.parse::() + .ok() + .map(|ip| SocketAddrV4::new(ip, ARTNET_PORT)) + }) + .filter(|a| a.port() != 0 && !a.ip().is_unspecified()) + } + + /// Removes a target that is not valid. The app then starts without the DMX engine. + fn check(&mut self) { + let Some(t) = &self.artnet_target else { + return; + }; + if self.target().is_none() { + tracing::warn!( + "dmx.artnet_target {t:?} is not an IPv4 address or IP:port. The DMX engine is off." + ); + self.artnet_target = None; + } else if self.universe > MAX_UNIVERSE { + tracing::warn!( + "dmx.universe must be 0 to {MAX_UNIVERSE}, got {}. The DMX engine is off.", + self.universe + ); + self.artnet_target = None; + } + } +} + // ── Loader ──────────────────────────────────────────────────────────────────── pub fn load(path: &Path) -> anyhow::Result { @@ -400,7 +463,13 @@ pub fn load(path: &Path) -> anyhow::Result { return Ok(Config::default()); } let text = std::fs::read_to_string(path)?; - let cfg: Config = toml::from_str(&text)?; + parse(&text) +} + +/// Reads a config from TOML text and checks it. +pub fn parse(text: &str) -> anyhow::Result { + let mut cfg: Config = toml::from_str(text)?; + cfg.dmx.check(); validate(&cfg)?; Ok(cfg) } @@ -431,6 +500,27 @@ pub fn save_scenes(path: &Path, scenes: &ScenesConfig) -> anyhow::Result<()> { Ok(()) } +/// Returns the path of the show file: `show.json` in the folder of the config file. +pub fn show_path(config_path: &Path) -> PathBuf { + config_path.with_file_name("show.json") +} + +/// Reads the show file. A missing or bad file gives the empty show. +pub fn load_show(path: &Path) -> Show { + match std::fs::read(path) { + Ok(bytes) => Show::from_json(&bytes), + Err(_) => Show::default(), + } +} + +/// Writes the show file. This is a blocking call. +pub fn save_show(path: &Path, show: &Show) -> anyhow::Result<()> { + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec(show)?)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + pub(crate) fn validate(cfg: &Config) -> anyhow::Result<()> { anyhow::ensure!( cfg.device_number >= 1 && cfg.device_number <= 15, @@ -485,3 +575,71 @@ pub(crate) fn validate(cfg: &Config) -> anyhow::Result<()> { ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, SocketAddrV4}; + + #[test] + fn a_config_without_dmx_has_no_artnet_target() { + let cfg = parse("device_number = 7\n").unwrap(); + assert_eq!(cfg.dmx.artnet_target, None); + assert_eq!(cfg.dmx.universe, 0); + assert_eq!(cfg.dmx.target(), None); + } + + #[test] + fn dmx_section_gives_the_artnet_target() { + let cfg = parse("[dmx]\nartnet_target = \"10.0.0.5\"\nuniverse = 3\n").unwrap(); + assert_eq!( + cfg.dmx.target(), + Some(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 5), 6454)) + ); + assert_eq!(cfg.dmx.universe, 3); + + let cfg = parse("[dmx]\nartnet_target = \"2.255.255.255:6455\"\n").unwrap(); + assert_eq!( + cfg.dmx.target(), + Some(SocketAddrV4::new(Ipv4Addr::new(2, 255, 255, 255), 6455)) + ); + } + + #[test] + fn a_bad_artnet_target_disables_the_engine() { + for bad in [ + "[dmx]\nartnet_target = \"node.example\"\n", + "[dmx]\nartnet_target = \"10.0.0.5:0\"\n", + "[dmx]\nartnet_target = \"10.0.0.5\"\nuniverse = 40000\n", + ] { + let cfg = parse(bad).unwrap(); + assert_eq!(cfg.dmx.artnet_target, None, "{bad}"); + assert_eq!(cfg.dmx.target(), None, "{bad}"); + } + } + + #[test] + fn show_file_round_trip() { + let dir = std::env::temp_dir().join(format!("xdj-show-cfg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = show_path(&dir.join("config.toml")); + assert_eq!(path, dir.join("show.json")); + assert_eq!(load_show(&path), Show::default()); + + let show = Show { + fixtures: vec![autoblink_core_dmx::Fixture { + name: "par".into(), + address: 10, + channels: vec![autoblink_core_dmx::Channel::Dimmer], + ofl: None, + }], + ..Show::default() + }; + save_show(&path, &show).unwrap(); + assert_eq!(load_show(&path), show); + + std::fs::write(&path, b"not json").unwrap(); + assert_eq!(load_show(&path), Show::default()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/host/src/dmx.rs b/host/src/dmx.rs new file mode 100644 index 0000000..7717318 --- /dev/null +++ b/host/src/dmx.rs @@ -0,0 +1,475 @@ +//! The DMX engine of the host. It sends Art-Net to a DMX node. +//! +//! The task runs the `Lights` engine of `autoblink-core-dmx`, like the `dmx` thread of the +//! firmware, and sends one ArtDmx frame each `FRAME_INTERVAL`. The MIDI mapper sends the scene +//! notes, the fired presets and the master beats through a bounded channel. The mapper never +//! waits for the channel: a full channel drops the message. +//! +//! The page sets the live controls (`DmxLive`) and sends the fog, the aims and the preset tests. +//! The task writes the layers and the live controls into `DmxStatus` for the status of the page. +//! +//! The host starts the task only when `[dmx] artnet_target` is set. While an AUTOBLINK box is on +//! the Pro DJ Link network, the box drives the lights, and the task sends nothing. + +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use autoblink_core_dmx::{build_artdmx, BeatClock, Lights, Live, Show, Universe, UNIVERSE}; +use autoblink_core_midi::scenes::Event; +use parking_lot::{Mutex, RwLock}; +use serde::Serialize; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +use crate::config::{DmxConfig, SharedConfig}; +use crate::prolink::discovery::{Device, DeviceTable}; +use crate::state::SharedState; + +/// The show of the host. The page writes it with `POST /show`, and the engine reads it. +pub type SharedShow = Arc>; + +/// The sender of the messages to the engine. +pub type DmxTx = mpsc::Sender; + +/// The Pro DJ Link name of the box (`NAME` in `esp32/src/prolink.rs`). +pub const BOX_NAME: &str = "autoblink"; + +/// The time between two Art-Net frames (40 frames per second). +const FRAME_INTERVAL: Duration = Duration::from_millis(25); + +/// The number of messages that the channel to the engine holds. +const QUEUE_LEN: usize = 64; + +/// A message to the engine. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DmxMsg { + /// The note on of a preset. The value is the preset index. + On(u8), + /// The note off of a preset. + Off(u8), + /// An event of the scenes fired a preset. + Fired(Event, u8), + /// The page tests a preset. + Test(u8), + /// The fog button of the page. + Fog, + /// The page aims a head by hand. + Aim { fixture: u8, pan: u16, tilt: u16 }, + /// A beat of the master. + Beat { + beat_in_bar: u8, + bpm: f64, + at: Instant, + }, +} + +/// The live controls of the page. The task reads them for each frame. +pub struct DmxLive { + /// 255 is full. + pub master: AtomicU8, + pub strobe_hold: AtomicBool, +} + +impl Default for DmxLive { + fn default() -> Self { + Self { + master: AtomicU8::new(255), + strobe_hold: AtomicBool::new(false), + } + } +} + +/// The DMX part of the status for the page. The box sends the same fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct DmxStatus { + /// The presets that show now, bottom first. + pub layers: Vec, + pub master: u8, + pub strobe_hold: bool, +} + +/// The handles of a running engine. +#[derive(Clone)] +pub struct DmxHandle { + pub tx: DmxTx, + pub live: Arc, + pub status: Arc>, +} + +/// Returns the IP address of an AUTOBLINK box in `devices`, or `None`. A box has the name +/// [`BOX_NAME`] and a device number that is not `own`. With more than one box, the box with the +/// lowest device number wins. +pub fn find_box<'a>(devices: impl IntoIterator, own: u8) -> Option { + devices + .into_iter() + .filter(|d| d.name == BOX_NAME && d.device_number != own) + .min_by_key(|d| d.device_number) + .map(|d| Ipv4Addr::from(d.ip)) +} + +/// Starts the engine under `runtime::supervise` when `dmx.artnet_target` is set. Returns the +/// handles for the mapper and the page, or `None` when the engine does not start. +pub fn start( + dmx: &DmxConfig, + show: SharedShow, + dj_state: SharedState, + cfg: SharedConfig, + devices: DeviceTable, +) -> Option { + let target = dmx.target()?; + let universe = dmx.universe; + if cfg.read().device_number == 7 { + tracing::warn!( + "The device number is the default 7. The host cannot find a box with number 7." + ); + } + let (tx, rx) = mpsc::channel(QUEUE_LEN); + // A restart of the task gets the same receiver, so that the mapper keeps its sender. + let rx = Arc::new(tokio::sync::Mutex::new(rx)); + let handle = DmxHandle { + tx, + live: Arc::default(), + status: Arc::new(Mutex::new(DmxStatus { + master: 255, + ..DmxStatus::default() + })), + }; + let task = Task { + target, + universe, + rx, + page: Arc::clone(&handle.live), + status: Arc::clone(&handle.status), + show, + dj_state, + cfg, + devices, + }; + crate::runtime::spawn_supervised("DMX engine", move || run(task.clone())); + tracing::info!(%target, universe, "The DMX engine sends Art-Net"); + Some(handle) +} + +/// The inputs of the engine task. A restart of the task gets a clone. +#[derive(Clone)] +struct Task { + target: SocketAddrV4, + universe: u16, + rx: Arc>>, + page: Arc, + status: Arc>, + show: SharedShow, + dj_state: SharedState, + cfg: SharedConfig, + devices: DeviceTable, +} + +async fn run(task: Task) { + let Task { + target, + universe, + rx, + page, + status, + show, + dj_state, + cfg, + devices, + } = task; + let socket = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %e, "The DMX engine cannot open a UDP socket"); + return; + } + }; + // A broadcast target needs this option. + if let Err(e) = socket.set_broadcast(true) { + tracing::warn!(error = %e, "The DMX engine cannot enable broadcast"); + } + let mut rx = rx.lock().await; + let start = Instant::now(); + let ms = |t: Instant| t.saturating_duration_since(start).as_millis() as u64; + let mut lights = Lights::default(); + let mut clock = BeatClock::default(); + let mut playing = None; + let mut frame: Universe = [0; UNIVERSE]; + let mut packet = [0u8; 530]; + // Art-Net sequence 0 turns the sequence check of the node off. The task uses 1 to 255. + let mut seq: u8 = 0; + let mut box_ip: Option = None; + let mut send_failed = false; + let mut tick = tokio::time::interval(FRAME_INTERVAL); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + let now_ms = ms(Instant::now()); + let live = Live { + master: page.master.load(Ordering::Relaxed), + strobe_hold: page.strobe_hold.load(Ordering::Relaxed), + }; + let is_playing = dj_state.read().master.is_playing; + // `set_playing` keeps the time of the stop. Thus the task calls it only on a change. + if playing != Some(is_playing) { + lights.set_playing(is_playing, now_ms); + playing = Some(is_playing); + } + let own = cfg.read().device_number; + let found = find_box(devices.lock().values(), own); + if found != box_ip { + match found { + Some(ip) => { + tracing::info!(%ip, "An AUTOBLINK box is on the network. The host stops its Art-Net output.") + } + None => tracing::info!( + "The AUTOBLINK box left the network. The host sends Art-Net again." + ), + } + box_ip = found; + } + { + // The block drops the guard before the next await. + let show = show.read(); + while let Ok(msg) = rx.try_recv() { + match msg { + DmxMsg::On(p) => lights.on(p, now_ms), + DmxMsg::Off(p) => lights.off(p, now_ms), + DmxMsg::Fired(e, p) => lights.fired(&show, e, p, now_ms), + DmxMsg::Test(p) => lights.test(&show, p, now_ms), + DmxMsg::Fog => lights.fog(now_ms), + DmxMsg::Aim { fixture, pan, tilt } => lights.aim(fixture, pan, tilt, now_ms), + DmxMsg::Beat { + beat_in_bar, + bpm, + at, + } => clock.beat(ms(at), beat_in_bar, bpm), + } + } + let beat = clock.now(now_ms); + lights.frame(&show, beat.as_ref(), &live, now_ms, &mut frame); + } + // The task does not wait for the status lock, and it writes only a change. Thus a frame + // without a change allocates nothing. The next frame tries again. + if let Some(mut s) = status.try_lock() { + if !s.layers.iter().copied().eq(lights.layers()) { + s.layers.clear(); + s.layers.extend(lights.layers()); + } + s.master = live.master; + s.strobe_hold = live.strobe_hold; + } + if box_ip.is_some() { + continue; + } + seq = seq.checked_add(1).unwrap_or(1); + let n = build_artdmx(seq, universe, &frame, &mut packet); + match socket.send_to(&packet[..n], target).await { + Ok(_) => send_failed = false, + Err(e) => { + // The task logs only the first error after a good send, not 40 lines each second. + if !send_failed { + tracing::warn!(%target, error = %e, "The DMX engine cannot send Art-Net"); + } + send_failed = true; + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::net::Ipv4Addr; + use std::sync::atomic::Ordering; + use std::time::{Duration, Instant}; + + use parking_lot::Mutex; + use tokio::net::UdpSocket; + + use super::*; + use crate::config::{new_shared, Config, DmxConfig}; + use crate::prolink::discovery::Device; + + fn device(num: u8, name: &str, ip: [u8; 4]) -> Device { + Device { + device_number: num, + device_type: 1, + name: name.into(), + mac: [0; 6], + ip, + last_seen: Instant::now(), + } + } + + #[test] + fn find_box_takes_an_autoblink_with_another_number() { + let own = 7; + let devs = [ + device(1, "XDJ-AZ", [10, 0, 0, 2]), + device(7, "autoblink", [10, 0, 0, 3]), + ]; + assert_eq!(find_box(&devs, own), None, "the host itself is not a box"); + + let devs = [ + device(1, "XDJ-AZ", [10, 0, 0, 2]), + device(7, "autoblink", [10, 0, 0, 3]), + device(9, "autoblink", [10, 0, 0, 9]), + device(8, "autoblink", [10, 0, 0, 8]), + ]; + assert_eq!(find_box(&devs, own), Some(Ipv4Addr::new(10, 0, 0, 8))); + } + + fn table() -> DeviceTable { + Arc::new(Mutex::new(HashMap::new())) + } + + #[tokio::test] + async fn no_artnet_target_starts_no_task() { + let dmx = start( + &DmxConfig::default(), + SharedShow::default(), + crate::state::new_shared(0), + new_shared(Config::default()), + table(), + ); + assert!(dmx.is_none()); + } + + async fn recv_frame(node: &UdpSocket, wait: Duration) -> Option<(u16, Vec)> { + let mut buf = [0u8; 600]; + let n = tokio::time::timeout(wait, node.recv(&mut buf)) + .await + .ok()? + .unwrap(); + let (u, data) = autoblink_core_dmx::parse_artdmx(&buf[..n])?; + Some((u, data.to_vec())) + } + + #[tokio::test(flavor = "multi_thread")] + async fn engine_sends_artdmx_until_a_box_shows() { + let node = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let port = node.local_addr().unwrap().port(); + let devices = table(); + let cfg = DmxConfig { + artnet_target: Some(format!("127.0.0.1:{port}")), + universe: 2, + }; + let dmx = start( + &cfg, + SharedShow::default(), + crate::state::new_shared(0), + new_shared(Config::default()), + Arc::clone(&devices), + ); + assert!(dmx.is_some()); + + let (universe, data) = recv_frame(&node, Duration::from_secs(2)) + .await + .expect("an ArtDmx frame"); + assert_eq!(universe, 2); + assert_eq!(data.len(), 512); + + devices + .lock() + .insert(8, device(8, "autoblink", [127, 0, 0, 1])); + // A frame can be on the way when the box shows. Read the frames for 100 ms. + let until = tokio::time::Instant::now() + Duration::from_millis(100); + while tokio::time::Instant::now() < until { + let _ = recv_frame(&node, Duration::from_millis(10)).await; + } + assert!( + recv_frame(&node, Duration::from_millis(300)) + .await + .is_none(), + "the host sends nothing while it sees a box" + ); + + devices.lock().clear(); + assert!(recv_frame(&node, Duration::from_secs(2)).await.is_some()); + } + + /// Starts an engine that sends to a UDP socket of the test. + async fn engine(show: serde_json::Value) -> (UdpSocket, DmxHandle) { + let node = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let port = node.local_addr().unwrap().port(); + let cfg = DmxConfig { + artnet_target: Some(format!("127.0.0.1:{port}")), + universe: 0, + }; + let show = Show::from_json(show.to_string().as_bytes()); + let dmx = start( + &cfg, + Arc::new(RwLock::new(show)), + crate::state::new_shared(0), + new_shared(Config::default()), + table(), + ) + .unwrap(); + (node, dmx) + } + + /// Reads frames until one passes `pred`. + async fn wait_frame(node: &UdpSocket, what: &str, pred: impl Fn(&[u8]) -> bool) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + loop { + let (_, data) = recv_frame(node, Duration::from_secs(2)).await.unwrap(); + if pred(&data) { + return; + } + assert!(tokio::time::Instant::now() < deadline, "{what}"); + } + } + + /// The show has one par on channel 1. Preset 0 sets it to full. + fn par_show() -> serde_json::Value { + serde_json::json!({ + "fixtures": [{"name": "par", "address": 1, "channels": ["dimmer"]}], + "groups": [{"name": "all", "fixtures": [0]}], + "looks": [{"rows": [{"group": 0, "color": [255, 255, 255, 255], "intensity": 255}]}], + }) + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_scene_note_and_the_live_master_drive_the_frame() { + let (node, dmx) = engine(par_show()).await; + dmx.tx.try_send(DmxMsg::On(0)).unwrap(); + wait_frame(&node, "the look shows", |d| d[0] == 255).await; + assert_eq!(dmx.status.lock().layers, [0]); + assert_eq!(dmx.status.lock().master, 255); + + dmx.live.master.store(0, Ordering::Relaxed); + wait_frame(&node, "the master is 0", |d| d[0] == 0).await; + assert_eq!(dmx.status.lock().master, 0); + + dmx.live.strobe_hold.store(true, Ordering::Relaxed); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(dmx.status.lock().strobe_hold); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_fog_and_aim_reach_the_frame() { + let (node, dmx) = engine(par_show()).await; + dmx.tx.try_send(DmxMsg::Test(0)).unwrap(); + wait_frame(&node, "the test shows the look", |d| d[0] == 255).await; + + let (node, dmx) = engine(serde_json::json!({ + "fixtures": [ + {"name": "fog", "address": 1, "channels": ["fog"]}, + {"name": "head", "address": 2, "channels": ["pan", "tilt"]}, + ], + })) + .await; + dmx.tx.try_send(DmxMsg::Fog).unwrap(); + wait_frame(&node, "the fog blows", |d| d[0] > 0).await; + dmx.tx + .try_send(DmxMsg::Aim { + fixture: 1, + pan: 0xffff, + tilt: 0, + }) + .unwrap(); + wait_frame(&node, "the head aims", |d| d[1] == 255 && d[2] == 0).await; + } +} diff --git a/host/src/lib.rs b/host/src/lib.rs index c6f5900..e040d2b 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1,5 +1,6 @@ pub mod app; pub mod config; +pub mod dmx; pub mod link; pub mod logbuf; pub mod midi; diff --git a/host/src/main.rs b/host/src/main.rs index 0fd9579..d3bb85b 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -2,6 +2,7 @@ mod app; mod config; +mod dmx; mod link; mod logbuf; mod midi; diff --git a/host/src/midi/mapper.rs b/host/src/midi/mapper.rs index ddcce9c..8cd70e5 100644 --- a/host/src/midi/mapper.rs +++ b/host/src/midi/mapper.rs @@ -5,13 +5,14 @@ use parking_lot::Mutex; use tokio::sync::broadcast; use crate::config::{CcConfig, NoteConfig, SharedConfig}; +use crate::dmx::{DmxMsg, DmxTx}; use crate::midi::activity::{remember, MidiActivity}; use crate::midi::MidiTransport; use crate::osc::{Arg, OscOut}; use crate::prolink::beat_listener::BeatEvent; use crate::prolink::status_listener::StatusEvent; use crate::state::{BeatSource, SharedState}; -use autoblink_core_midi::scenes::{SceneInput, ScenePhrase, Scenes}; +use autoblink_core_midi::scenes::{Event, SceneInput, ScenePhrase, Scenes, ScenesConfig}; use autoblink_core_midi::Out; // ── MIDI byte helpers ───────────────────────────────────────────────────────── @@ -142,6 +143,7 @@ mod tests { status_rx, cfg, activity, + None, )); let wake = || { let _ = status_tx.send(StatusEvent::Mixer(crate::prolink::packets::MixerStatus { @@ -193,6 +195,7 @@ mod tests { order: Order::Up, gate: Gate::Hold, decay: Decay::default(), + holds: Vec::new(), }]; } let state = new_shared_state(30); @@ -226,6 +229,7 @@ mod tests { status_rx, cfg, Arc::new(Mutex::new(MidiActivity::default())), + None, )); let _ = status_tx.send(StatusEvent::Mixer(crate::prolink::packets::MixerStatus { device_number: 16, @@ -241,6 +245,60 @@ mod tests { handle.abort(); } + #[test] + fn scene_input_guesses_only_without_phrases() { + use crate::state::{PhraseEntry, PhraseKind, SongStructure, TrackMood, TrackOverview}; + + let state = new_shared_state(30); + { + let mut st = state.write(); + st.master.device_number = 1; + st.apply_cdj_status(&cdj_at(7, 5)); + st.master.bpm = 128.0; + st.master.phrase_16_beat = 0; + // A quiet first half and a loud second half, 1200 columns. + let mut preview = vec![20u8; 600 * 6]; + preview.extend(vec![200u8; 600 * 6]); + st.set_overview( + 1, + TrackOverview { + rekordbox_id: 7, + preview, + ..Default::default() + }, + ); + let d = st.devices.get_mut(&1).unwrap(); + d.playhead_ms = Some(150_000); + d.track_length_ms = Some(200_000); + } + let input = scene_input(&state, (0, 0, None)); + assert_eq!(input.guess, Some(Event::Drop)); + assert_eq!(input.phrase16, 1); + + // Without a playhead, the guess has no position. + state.write().devices.get_mut(&1).unwrap().playhead_ms = None; + assert_eq!(scene_input(&state, (0, 0, None)).guess, None); + state.write().devices.get_mut(&1).unwrap().playhead_ms = Some(150_000); + + // The phrases of the player replace the guess. + state.write().set_song_structure( + 1, + 7, + SongStructure { + mood: TrackMood::Mid, + end_beat: 65, + phrases: vec![PhraseEntry { + index: 1, + beat: 1, + kind: PhraseKind::Intro, + has_fill: false, + fill_beat: 0, + }], + }, + ); + assert_eq!(scene_input(&state, (0, 0, None)).guess, None); + } + #[tokio::test] async fn a_scene_flash_sends_its_note_off_after_the_decay() { use autoblink_core_midi::scenes::{Decay, Event, Gate, Order, Row}; @@ -257,6 +315,7 @@ mod tests { order: Order::Up, gate: Gate::Flash, decay: Decay::Ms(80), + holds: Vec::new(), }]; } let state = new_shared_state(30); @@ -270,6 +329,7 @@ mod tests { status_rx, cfg, Arc::new(Mutex::new(MidiActivity::default())), + None, )); let _ = beat_tx.send(BeatEvent::LinkBeat { bpm: 120.0, @@ -288,6 +348,62 @@ mod tests { handle.abort(); } + #[tokio::test] + async fn scene_notes_and_fired_presets_go_to_the_dmx_engine() { + use crate::dmx::DmxMsg; + use autoblink_core_midi::scenes::{Decay, Event, Gate, Order, Row}; + + let midi = Arc::new(MockMidiTransport::new()); + let cfg = new_shared(Config::default()); + { + let mut c = cfg.write(); + c.scenes.enabled = true; + c.scenes.rows = vec![Row { + event: Event::Beat, + presets: vec![5], + advance: 0, + order: Order::Up, + gate: Gate::Flash, + decay: Decay::Ms(80), + holds: Vec::new(), + }]; + } + let state = new_shared_state(30); + state.write().master.source = Some(BeatSource::AbletonLink); + let (_status_tx, status_rx) = broadcast::channel(8); + let (beat_tx, beat_rx) = broadcast::channel(8); + let (dmx_tx, mut dmx_rx) = tokio::sync::mpsc::channel(64); + let handle = tokio::spawn(run_with_midi( + midi.clone(), + state, + beat_rx, + status_rx, + cfg, + Arc::new(Mutex::new(MidiActivity::default())), + Some(dmx_tx), + )); + let _ = beat_tx.send(BeatEvent::LinkBeat { + bpm: 120.0, + beat_in_bar: 2, + bar_phase: 0.25, + beat_phase: 0.0, + received_at: Instant::now(), + }); + tokio::time::sleep(Duration::from_millis(150)).await; + let mut got = Vec::new(); + while let Ok(m) = dmx_rx.try_recv() { + got.push(m); + } + assert!( + matches!(got.first(), Some(DmxMsg::Beat { beat_in_bar: 2, bpm, .. }) if *bpm == 120.0), + "{got:?}" + ); + assert!(got.contains(&DmxMsg::On(5)), "{got:?}"); + assert!(got.contains(&DmxMsg::Fired(Event::Beat, 5)), "{got:?}"); + assert!(got.contains(&DmxMsg::Off(5)), "{got:?}"); + handle.abort(); + } + #[test] fn test_scale01() { assert_eq!(scale01(0.0), 0); @@ -405,6 +521,7 @@ mod tests { status_rx, new_shared(Config::default()), activity, + None, )); let wake = || { let _ = status_tx.send(StatusEvent::Mixer(crate::prolink::packets::MixerStatus { @@ -449,6 +566,7 @@ mod tests { status_rx, cfg.clone(), activity, + None, )); let _ = beat_tx.send(BeatEvent::LinkBeat { @@ -503,6 +621,7 @@ mod tests { status_rx, cfg, Arc::new(Mutex::new(MidiActivity::default())), + None, )); let _ = beat_tx.send(BeatEvent::LinkBeat { @@ -553,6 +672,7 @@ mod tests { status_rx, cfg.clone(), activity, + None, )); let _ = status_tx.send(StatusEvent::Mixer(crate::prolink::packets::MixerStatus { @@ -607,6 +727,7 @@ mod tests { status_rx, cfg.clone(), activity, + None, )); let downbeat = || { let _ = beat_tx.send(BeatEvent::LinkBeat { @@ -657,6 +778,7 @@ mod tests { status_rx, cfg.clone(), activity, + None, )); let wake = || { let _ = status_tx.send(StatusEvent::Mixer(crate::prolink::packets::MixerStatus { @@ -791,10 +913,61 @@ fn send_scene(midi: &dyn MidiTransport, o: Out, activity: &Arc SceneInput { +/// Sends a message to the DMX engine. A full channel drops the message, so that the mapper does +/// not wait. +fn dmx_send(dmx: Option<&DmxTx>, msg: DmxMsg) { + if let Some(tx) = dmx { + let _ = tx.try_send(msg); + } +} + +/// Sends the note of a preset to the DMX engine, as the firmware does. The other messages and the +/// extra notes go only to MIDI. +fn scene_to_dmx(dmx: Option<&DmxTx>, c: &ScenesConfig, o: Out) { + let preset = |ch: u8, note: u8| { + note.checked_sub(c.first_note) + .filter(|p| ch == c.channel && *p < c.count) + }; + let msg = match o { + Out::NoteOn { ch, note, .. } => preset(ch, note).map(DmxMsg::On), + Out::NoteOff { ch, note } => preset(ch, note).map(DmxMsg::Off), + _ => None, + }; + if let Some(m) = msg { + dmx_send(dmx, m); + } +} + +/// Sends the presets that the scenes fired to the DMX engine. +fn fired_to_dmx(scenes: &mut Scenes, dmx: Option<&DmxTx>) { + scenes.drain_fired(|e, p| dmx_send(dmx, DmxMsg::Fired(e, p))); +} + +/// Returns the state of the master deck for the DMX scenes. `prev_guess` is the master deck, the +/// track and the guess of the previous call. A new master deck or a new track starts without a +/// previous guess. +fn scene_input(state: &SharedState, prev_guess: (u8, u32, Option)) -> SceneInput { let st = state.read(); let dev = st.devices.get(&st.master.device_number); + let prev_guess = (prev_guess.0 == st.master.device_number + && Some(prev_guess.1) == dev.map(|d| d.rekordbox_id)) + .then_some(prev_guess.2) + .flatten(); + // The guess is only for a track without phrases. + let guess = dev + .filter(|d| { + d.song_structure + .as_ref() + .is_none_or(|ss| ss.phrases.is_empty()) + }) + .and_then(|d| { + d.guess.as_ref()?.bucket_ahead( + d.playhead_ms?, + d.track_length_ms?, + st.master.bpm, + prev_guess, + ) + }); let phrase = st.master_phrase().zip(dev).map(|(info, d)| { let entry = d .song_structure @@ -816,6 +989,13 @@ fn scene_input(state: &SharedState) -> SceneInput { bpm: st.master.bpm, phrase, bass_cut: Some(st.no_bass()), + guess, + // DjState stores 0..=15. The web status uses the same rule. + phrase16: if st.master.bpm > 0.0 { + st.master.phrase_16_beat.min(15) + 1 + } else { + 0 + }, } } @@ -844,7 +1024,9 @@ async fn run_with_midi( mut status_rx: broadcast::Receiver, cfg: SharedConfig, activity: Arc>, + dmx: Option, ) { + let dmx = dmx.as_ref(); let mut prev = PrevCc::default(); let mut prev_phrase_idx: Option = None; // (device, rekordbox_id, phrase index) of the last phrase kind that was sent. @@ -861,6 +1043,9 @@ async fn run_with_midi( // (device, rekordbox_id, title) of the last track that was sent over OSC. let mut prev_osc_track: Option<(u8, u32, String)> = None; let mut scenes = Scenes::default(); + // The master deck and the last guess of `scene_input`. The guess keeps a Drop or a Break near + // its threshold. + let mut prev_guess: (u8, u32, Option) = (0, 0, None); let start = Instant::now(); let now_ms = || start.elapsed().as_millis() as u64; tracing::info!("MIDI mapper task started"); @@ -875,7 +1060,7 @@ async fn run_with_midi( evt = beat_rx.recv() => { match evt { - Ok(BeatEvent::Beat { packet: bp, .. }) => { + Ok(BeatEvent::Beat { packet: bp, received_at }) => { // Only trigger notes / CCs for the master deck of the active source. let (master_num, master_source) = { let st = state.read(); @@ -891,9 +1076,16 @@ async fn run_with_midi( let c = cfg.read(); (c.scenes.clone(), c.midi.notes.off_vel0) }; + dmx_send(dmx, DmxMsg::Beat { + beat_in_bar: bp.beat_in_bar, + bpm: bp.effective_bpm, + at: received_at, + }); scenes.beat(&scene_cfg, now_ms(), bp.beat_in_bar, &mut |o| { - send_scene(midi.as_ref(), o, &activity, vel0) + send_scene(midi.as_ref(), o, &activity, vel0); + scene_to_dmx(dmx, &scene_cfg, o); }); + fired_to_dmx(&mut scenes, dmx); send_osc_beat(&osc, &cfg, bp.beat_in_bar); tracing::debug!( device = bp.device_number, @@ -905,7 +1097,7 @@ async fn run_with_midi( Ok(BeatEvent::AbsPosition { .. }) => { // Phase updates handled via shared state CCs below. } - Ok(BeatEvent::LinkBeat { bpm, beat_in_bar, .. }) => { + Ok(BeatEvent::LinkBeat { bpm, beat_in_bar, received_at, .. }) => { if !link_beat_allowed(state.read().master.source) { continue; } @@ -915,9 +1107,12 @@ async fn run_with_midi( let c = cfg.read(); (c.scenes.clone(), c.midi.notes.off_vel0) }; + dmx_send(dmx, DmxMsg::Beat { beat_in_bar, bpm, at: received_at }); scenes.beat(&scene_cfg, now_ms(), beat_in_bar, &mut |o| { - send_scene(midi.as_ref(), o, &activity, vel0) + send_scene(midi.as_ref(), o, &activity, vel0); + scene_to_dmx(dmx, &scene_cfg, o); }); + fired_to_dmx(&mut scenes, dmx); send_osc_beat(&osc, &cfg, beat_in_bar); tracing::debug!( beat = beat_in_bar, @@ -1211,10 +1406,13 @@ async fn run_with_midi( let c = cfg.read(); (c.scenes.clone(), c.midi.notes.off_vel0) }; - let input = scene_input(&state); + let input = scene_input(&state, prev_guess); + prev_guess = (input.device, input.track, input.guess); scenes.update(&scene_cfg, now_ms(), &input, &mut |o| { - send_scene(midi.as_ref(), o, &activity, vel0) + send_scene(midi.as_ref(), o, &activity, vel0); + scene_to_dmx(dmx, &scene_cfg, o); }); + fired_to_dmx(&mut scenes, dmx); // The UI counters are optional. Do not wait for the lock. if let Some(mut act) = activity.try_lock() { act.scene = scenes.status(&scene_cfg); @@ -1229,6 +1427,7 @@ pub async fn run( status_rx: broadcast::Receiver, cfg: SharedConfig, activity: Arc>, + dmx: Option, ) { - run_with_midi(midi, state, beat_rx, status_rx, cfg, activity).await; + run_with_midi(midi, state, beat_rx, status_rx, cfg, activity, dmx).await; } diff --git a/host/src/runtime/midi.rs b/host/src/runtime/midi.rs index 1dc421e..3544e72 100644 --- a/host/src/runtime/midi.rs +++ b/host/src/runtime/midi.rs @@ -18,6 +18,7 @@ pub fn spawn(ctx: TaskContext, cfg_change_rx: watch::Receiver<()>, rt: &Handle) c.status_tx.subscribe(), Arc::clone(&c.cfg), Arc::clone(&c.midi_activity), + c.dmx_tx.clone(), ) })); @@ -201,6 +202,7 @@ mod tests { vcdjready_tx, midi_out, midi_activity: Arc::clone(&midi_activity), + dmx_tx: None, }; spawn(ctx, cfg_change_rx, &Handle::current()); diff --git a/host/src/runtime/mod.rs b/host/src/runtime/mod.rs index 2a2b222..3490705 100644 --- a/host/src/runtime/mod.rs +++ b/host/src/runtime/mod.rs @@ -31,6 +31,8 @@ pub struct TaskContext { pub vcdjready_tx: broadcast::Sender, pub midi_out: crate::midi::MidiOutHandle, pub midi_activity: Arc>, + /// The channel to the DMX engine, or `None` when the engine does not run. + pub dmx_tx: Option, } /// Runs the task that `factory` makes, and starts it again when it panics or @@ -123,6 +125,7 @@ pub async fn run(ctx: AppContext) -> anyhow::Result<()> { vcdjready_tx, track_change_tx, track_change_rx, + show, } = ctx; let midi_activity: Arc> = Arc::new(Mutex::new(MidiActivity::default())); @@ -142,6 +145,15 @@ pub async fn run(ctx: AppContext) -> anyhow::Result<()> { crate::midi::MidiOutHandle::start(2048, initial_conn) }; + // The engine starts only with `[dmx] artnet_target`. It restarts after a panic. + let dmx = crate::dmx::start( + &startup_cfg.dmx, + Arc::clone(&show), + Arc::clone(&dj_state), + Arc::clone(&cfg), + device_table.clone(), + ); + let task_ctx = TaskContext { dj_state: Arc::clone(&dj_state), cfg: Arc::clone(&cfg), @@ -152,6 +164,7 @@ pub async fn run(ctx: AppContext) -> anyhow::Result<()> { vcdjready_tx: vcdjready_tx.clone(), midi_out: midi_out.clone(), midi_activity: Arc::clone(&midi_activity), + dmx_tx: dmx.as_ref().map(|d| d.tx.clone()), }; dj_state.write().set_source_mode(startup_cfg.source.clone()); @@ -224,6 +237,8 @@ pub async fn run(ctx: AppContext) -> anyhow::Result<()> { beat_tx: beat_tx.clone(), midi_out: midi_out.clone(), cfg_change_tx: cfg_change_tx.clone(), + show, + dmx, }, web_addr, ) diff --git a/host/src/state/device.rs b/host/src/state/device.rs index 124ed6e..cb46a42 100644 --- a/host/src/state/device.rs +++ b/host/src/state/device.rs @@ -2,6 +2,8 @@ use std::collections::VecDeque; use std::sync::Arc; use std::time::Instant; +use autoblink_core_midi::guess::DropGuess; + use crate::prolink::packets::PlayState; use super::beat_source::BeatSource; @@ -66,6 +68,9 @@ pub struct DeviceState { pub phrase_error: Option, /// The waveform preview and the cues. An `Arc`, because the web status clones each device. pub overview: Option>, + /// The drop guess from the preview of `overview`. The scenes use it for a track without + /// phrases. + pub guess: Option>, pub current_phrase_idx: Option, pub prev_phrase_idx: Option, } @@ -102,6 +107,7 @@ impl DeviceState { song_structure: None, phrase_error: None, overview: None, + guess: None, current_phrase_idx: None, prev_phrase_idx: None, } diff --git a/host/src/state/dj_state.rs b/host/src/state/dj_state.rs index 3897b92..6de163d 100644 --- a/host/src/state/dj_state.rs +++ b/host/src/state/dj_state.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; +use autoblink_core_midi::guess::DropGuess; use parking_lot::RwLock; use crate::config::Source; @@ -165,6 +166,7 @@ impl DjState { dev.song_structure = None; dev.phrase_error = None; dev.overview = None; + dev.guess = None; dev.track_length_ms = None; dev.current_phrase_idx = None; dev.prev_phrase_idx = None; @@ -654,6 +656,20 @@ impl DjState { pub fn set_overview(&mut self, device_number: u8, overview: TrackOverview) { if let Some(dev) = self.devices.get_mut(&device_number) { if dev.rekordbox_id == overview.rekordbox_id { + dev.guess = DropGuess::from_preview(&overview.preview).map(Arc::new); + if let Some(g) = &dev.guess { + let (hi, lo, mid) = g.thresholds(); + let (first, last) = g.mid_columns(); + tracing::info!( + device = device_number, + hi, + lo, + mid, + first, + last, + "Guess thresholds of the track." + ); + } dev.overview = Some(Arc::new(overview)); } } diff --git a/host/src/web/mod.rs b/host/src/web/mod.rs index 2b87744..e38a553 100644 --- a/host/src/web/mod.rs +++ b/host/src/web/mod.rs @@ -25,6 +25,11 @@ //! valid first (`ScenesConfig::sanitize`). //! - `{type: "scene_test", preset}` sends the note of a preset (0-based) one time. //! - `{type: "rescan"}` lists the MIDI ports and network interfaces again. +//! - `{type: "dmx_live", master, strobe}` sets the live master (0 to 255) and the strobe hold of +//! the DMX engine. `{type: "dmx_fog"}` starts the fog. `{type: "dmx_aim", fixture, pan, tilt}` +//! aims a head (fixture 0 to 31). These commands get no reply. Without a DMX engine, they give +//! an `error`. `scene_test` also shows the look of the preset on the engine. When a page that +//! set the strobe hold closes its socket, the server releases the hold. //! - `{type: "take_over"}`, only to the ESP32: this page gets the status. The ESP32 closes the //! connection of the page that had it, and replies with `settings`. //! @@ -34,6 +39,16 @@ //! `{hot, start, end}` in ms; `hot` 0 is a memory cue, and `end` is set for a loop. Each //! phrase is `{kind, start, end, fill}` in ms; `fill` is the start of the fill-in, or null. //! The status field `wave` of a deck names the rekordbox ID that this route gives. +//! +//! HTTP, for the DMX show of the host: +//! - `GET /show` gives the show as JSON (`autoblink_core_dmx::Show`). +//! - `POST /show` replaces the show and writes it to `show.json` next to the config file. The +//! body is JSON, with any content type, so that the page can send a CORS simple request. The +//! reply is 204, 400 for a bad show, 403 for a foreign `Origin`, or 413 above `MAX_SHOW_LEN`. +//! The server keeps only the first entries of a long list (`Show::sanitize`). +//! - The two replies have `Access-Control-Allow-Origin: *`, as on the box. +//! - The `hello` has the capability `dmx` only when the DMX engine runs (`[dmx] artnet_target`). +//! - The status field `box` gives the IP address of an AUTOBLINK box on the network, or null. pub mod status; @@ -41,8 +56,9 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; +use axum::body::Bytes; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::{Path, State}; +use axum::extract::{DefaultBodyLimit, Path, State}; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; @@ -86,6 +102,10 @@ pub struct WebCtx { pub beat_tx: broadcast::Sender, pub midi_out: MidiOutHandle, pub cfg_change_tx: watch::Sender<()>, + /// The DMX show of the host. The DMX engine reads it. + pub show: crate::dmx::SharedShow, + /// The DMX engine, or `None` when it does not run. + pub dmx: Option, } struct Shared { @@ -205,6 +225,12 @@ fn router(shared: Arc) -> Router { .route("/index.html", get(|| async { Html(PAGE) })) .route("/ws", get(ws_handler)) .route("/waveform/{deck}", get(waveform_handler)) + .route( + "/show", + get(show_get) + .post(show_post) + .layer(DefaultBodyLimit::max(MAX_SHOW_LEN)), + ) .with_state(shared) } @@ -261,6 +287,7 @@ fn produce_once(shared: &Shared) { connected: ctx.midi_out.is_connected(), dropped: ctx.midi_out.dropped_messages(), }, + ctx.dmx.as_ref().map(|d| &*d.status), ); shared.status_tx.send_replace(tagged("status", &st)); @@ -412,6 +439,81 @@ async fn waveform_handler(Path(deck): Path, State(shared): State } } +/// `POST /show` refuses a longer body. The box uses the same limit. +const MAX_SHOW_LEN: usize = 60_000; + +/// Lets a page from another origin read the replies of `/show`, as on the box. +const CORS: (header::HeaderName, &str) = (header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + +/// Gives the DMX show of the host. See the module documentation. +async fn show_get(State(shared): State>) -> Response { + // Clone the show under the lock. Serialize the clone after the statement releases the lock. + let show = shared.ctx.show.read().clone(); + match serde_json::to_string(&show) { + Ok(json) => ([(header::CONTENT_TYPE, "application/json"), CORS], json).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +/// Replaces and stores the DMX show of the host. See the module documentation. +async fn show_post( + headers: HeaderMap, + State(shared): State>, + body: Result, +) -> Response { + let reply = |status: StatusCode, text: String| (status, [CORS], text).into_response(); + if !origin_allowed(&headers) { + tracing::warn!( + origin = ?headers.get(header::ORIGIN), + "Web UI: refused a POST /show from a foreign origin" + ); + return reply( + StatusCode::FORBIDDEN, + "The Origin of the page does not match the Host.".into(), + ); + } + // The limit layer rejects a longer body. The reply of the layer has no CORS header. + let Ok(body) = body else { + return reply( + StatusCode::PAYLOAD_TOO_LARGE, + format!("The show is longer than {MAX_SHOW_LEN} bytes."), + ); + }; + // The show has bounded lists, so a long list uses no memory before `sanitize` cuts it. + let mut show: autoblink_core_dmx::Show = match serde_json::from_slice(&body) { + Ok(show) => show, + Err(e) => { + return reply( + StatusCode::BAD_REQUEST, + format!("The show is not valid: {e}"), + ) + } + }; + show.sanitize(); + let path = shared.ctx.cfg.read().show_path.clone(); + if let Some(path) = path { + let copy = show.clone(); + // The file write is a blocking call. + let saved = + tokio::task::spawn_blocking(move || crate::config::save_show(&path, ©)).await; + let err = match saved { + Ok(Ok(())) => None, + Ok(Err(e)) => Some(e.to_string()), + Err(e) => Some(e.to_string()), + }; + if let Some(e) = err { + tracing::warn!("The host cannot store the show: {e}"); + return reply( + StatusCode::INTERNAL_SERVER_ERROR, + format!("The host cannot store the show: {e}"), + ); + } + } + *shared.ctx.show.write() = show; + tracing::info!("The page saved a new DMX show"); + reply(StatusCode::NO_CONTENT, String::new()) +} + /// Returns true when the request may open the socket. /// /// Browsers do not apply CORS to WebSockets. Without this check, any page in a browser on the @@ -501,6 +603,21 @@ enum Command { SceneTest { preset: u8, }, + #[serde(rename = "dmx_live")] + DmxLive { + master: f64, + strobe: bool, + }, + #[serde(rename = "dmx_fog")] + DmxFog, + #[serde(rename = "dmx_aim")] + DmxAim { + fixture: u8, + #[serde(default)] + pan: u64, + #[serde(default)] + tilt: u64, + }, } async fn send(socket: &mut WebSocket, msg: &str) -> bool { @@ -510,13 +627,45 @@ async fn send(socket: &mut WebSocket, msg: &str) -> bool { ) } +/// Releases the strobe hold of the DMX engine when the page that set it goes away. +/// +/// A phone that locks during a hold sends no release. Without this guard, the whole rig strobes +/// until another page releases the hold. +struct StrobeGuard { + live: Option>, + /// True while the last `dmx_live` of this page set the strobe hold. + held: bool, +} + +impl Drop for StrobeGuard { + fn drop(&mut self) { + if let (true, Some(live)) = (self.held, &self.live) { + live.strobe_hold + .store(false, std::sync::atomic::Ordering::Relaxed); + tracing::info!("A page with a strobe hold closed. The host releases the hold."); + } + } +} + async fn client(mut socket: WebSocket, shared: Arc) { + // Each return below drops the guard. + let mut strobe = StrobeGuard { + live: shared.ctx.dmx.as_ref().map(|d| Arc::clone(&d.live)), + held: false, + }; + let mut caps = vec![ + "beat", "decks", "mixer", "output", "settings", "ports", "logs", "waveform", "scenes", + ]; + // The page shows the DMX show editor only when the host has a DMX engine. + if shared.ctx.dmx.is_some() { + caps.push("dmx"); + } let hello = json!({ "type": "hello", "proto": 1, "version": env!("CARGO_PKG_VERSION"), "device": "host", - "caps": ["beat", "decks", "mixer", "output", "settings", "ports", "logs", "waveform", "scenes"], + "caps": caps, }); if !send(&mut socket, &hello.to_string()).await { return; @@ -584,7 +733,7 @@ async fn client(mut socket: WebSocket, shared: Arc) { incoming = socket.recv() => { match incoming { Some(Ok(Message::Text(text))) => { - if let Some(err) = handle_command(&shared, text.as_str()).await { + if let Some(err) = handle_command(&shared, text.as_str(), &mut strobe.held).await { json!({ "type": "error", "text": err }).to_string().into() } else { continue; @@ -602,7 +751,8 @@ async fn client(mut socket: WebSocket, shared: Arc) { } /// Runs one client command. Returns an error text for the page, or `None` on success. -async fn handle_command(shared: &Shared, text: &str) -> Option { +/// `strobe_held` records whether this page holds the strobe of the DMX engine. +async fn handle_command(shared: &Shared, text: &str, strobe_held: &mut bool) -> Option { let ctx = &shared.ctx; let cmd = match serde_json::from_str::(text) { Ok(cmd) => cmd, @@ -660,11 +810,53 @@ async fn handle_command(shared: &Shared, text: &str) -> Option { // The test uses the 0x80 note off, not the velocity 0 of the scenes. A DMX controller // that ignores 0x80 then keeps the preset on, so that the user sees it. let _ = ctx.midi_out.send_message(&[0x80 | ch, note, 0]); + // A full channel drops the test. The page can send it again. + if let Some(dmx) = &ctx.dmx { + let _ = dmx.tx.try_send(crate::dmx::DmxMsg::Test(preset)); + } + None + } + Command::DmxLive { master, strobe } => { + let Some(dmx) = &ctx.dmx else { + return Some(NO_DMX.into()); + }; + use std::sync::atomic::Ordering::Relaxed; + dmx.live + .master + .store(master.clamp(0.0, 255.0) as u8, Relaxed); + dmx.live.strobe_hold.store(strobe, Relaxed); + *strobe_held = strobe; + None + } + // A full channel drops the fog. The page can send it again. + Command::DmxFog => { + let Some(dmx) = &ctx.dmx else { + return Some(NO_DMX.into()); + }; + let _ = dmx.tx.try_send(crate::dmx::DmxMsg::Fog); + None + } + // A full channel drops the aim. The page sends the next aim soon. + Command::DmxAim { fixture, pan, tilt } => { + let Some(dmx) = &ctx.dmx else { + return Some(NO_DMX.into()); + }; + if usize::from(fixture) >= autoblink_core_dmx::MAX_FIXTURES { + return Some("The command needs a fixture below 32".into()); + } + let _ = dmx.tx.try_send(crate::dmx::DmxMsg::Aim { + fixture, + pan: pan.min(65535) as u16, + tilt: tilt.min(65535) as u16, + }); None } } } +/// The error of a DMX command when the host has no DMX engine. +const NO_DMX: &str = "The host has no DMX engine. Set [dmx] artnet_target in the config."; + async fn switch_port( ctx: &WebCtx, name: String, @@ -717,9 +909,31 @@ mod tests { beat_tx: broadcast::channel(16).0, midi_out: MidiOutHandle::start(16, None), cfg_change_tx: watch::channel(()).0, + show: crate::dmx::SharedShow::default(), + dmx: None, } } + /// Sends one HTTP/1.1 request and returns the full response. + async fn http(addr: SocketAddr, head: &str, body: &[u8]) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut s = tokio::net::TcpStream::connect(addr).await.unwrap(); + s.write_all( + format!( + "{head}\r\nHost: {addr}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + // The server can refuse the request before it reads the body. + let _ = s.write_all(body).await; + let mut out = Vec::new(); + let _ = s.read_to_end(&mut out).await; + String::from_utf8_lossy(&out).into_owned() + } + type Ws = tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >; @@ -970,4 +1184,261 @@ mod tests { let err = tokio_tungstenite::connect_async(req).await.unwrap_err(); assert!(err.to_string().contains("403"), "{err}"); } + + #[tokio::test(flavor = "multi_thread")] + async fn show_routes_store_a_bounded_show() { + let ctx = ctx(); + let shared_show = ctx.show.clone(); + let cfg = Arc::clone(&ctx.cfg); + let dir = std::env::temp_dir().join(format!("xdj-show-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("show.json"); + cfg.write().show_path = Some(path.clone()); + let addr = spawn(ctx, SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + + let resp = http(addr, "GET /show HTTP/1.1", b"").await; + assert!(resp.starts_with("HTTP/1.1 200"), "{resp}"); + assert!( + resp.to_ascii_lowercase() + .contains("access-control-allow-origin: *"), + "{resp}" + ); + + // 40 fixtures: the server keeps the first 32 (MAX_FIXTURES). + let fixtures: Vec<_> = (0..40) + .map(|i| json!({"name": format!("f{i}"), "address": 1, "channels": ["dimmer"]})) + .collect(); + let body = json!({ "fixtures": fixtures }).to_string(); + let resp = http(addr, "POST /show HTTP/1.1", body.as_bytes()).await; + assert!(resp.starts_with("HTTP/1.1 204"), "{resp}"); + assert_eq!(shared_show.read().fixtures.len(), 32); + assert_eq!(crate::config::load_show(&path), *shared_show.read()); + + let resp = http(addr, "GET /show HTTP/1.1", b"").await; + let json: serde_json::Value = + serde_json::from_str(resp.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(json["fixtures"].as_array().unwrap().len(), 32); + assert_eq!(json["fixtures"][31]["name"], "f31"); + + // A bad body changes nothing. + let resp = http(addr, "POST /show HTTP/1.1", b"{not json").await; + assert!(resp.starts_with("HTTP/1.1 400"), "{resp}"); + assert_eq!(shared_show.read().fixtures.len(), 32); + + let big = vec![b' '; MAX_SHOW_LEN + 1]; + let resp = http(addr, "POST /show HTTP/1.1", &big).await; + assert!(resp.starts_with("HTTP/1.1 413"), "{resp}"); + + let resp = http( + addr, + "POST /show HTTP/1.1\r\nOrigin: https://evil.example", + b"{}", + ) + .await; + assert!(resp.starts_with("HTTP/1.1 403"), "{resp}"); + assert_eq!(shared_show.read().fixtures.len(), 32); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Starts a DMX engine for `ctx`. The engine sends to the returned socket. + async fn with_engine(ctx: &mut WebCtx) -> tokio::net::UdpSocket { + let node = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let dmx_cfg = crate::config::DmxConfig { + artnet_target: Some(node.local_addr().unwrap().to_string()), + universe: 0, + }; + ctx.cfg.write().dmx = dmx_cfg.clone(); + ctx.dmx = crate::dmx::start( + &dmx_cfg, + ctx.show.clone(), + Arc::clone(&ctx.dj_state), + Arc::clone(&ctx.cfg), + ctx.device_table.clone(), + ); + node + } + + #[tokio::test(flavor = "multi_thread")] + async fn hello_has_dmx_only_with_an_engine() { + for engine in [false, true] { + let mut ctx = ctx(); + let _node = if engine { + Some(with_engine(&mut ctx).await) + } else { + None + }; + let addr = spawn(ctx, SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .unwrap(); + let hello = next_of_type(&mut ws, "hello").await; + assert_eq!( + hello["caps"].as_array().unwrap().contains(&"dmx".into()), + engine + ); + } + } + + /// Waits up to 1 s until the strobe hold of the engine is `want`. + async fn strobe_becomes(live: &crate::dmx::DmxLive, want: bool) { + use std::sync::atomic::Ordering::Relaxed; + for _ in 0..100 { + if live.strobe_hold.load(Relaxed) == want { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("the strobe hold did not become {want}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_closed_page_releases_only_its_own_strobe_hold() { + let mut ctx = ctx(); + let _node = with_engine(&mut ctx).await; + let live = Arc::clone(&ctx.dmx.as_ref().unwrap().live); + let addr = spawn(ctx, SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let open = || async { + let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .unwrap(); + next_of_type(&mut ws, "hello").await; + ws + }; + let hold = r#"{"type":"dmx_live","master":255,"strobe":true}"#; + + // The page that holds the strobe goes away without a release. + let mut a = open().await; + a.send(WsMessage::Text(hold.into())).await.unwrap(); + strobe_becomes(&live, true).await; + drop(a); + strobe_becomes(&live, false).await; + + // A page that never held the strobe does not release the hold of another page. + let mut a = open().await; + a.send(WsMessage::Text(hold.into())).await.unwrap(); + strobe_becomes(&live, true).await; + let b = open().await; + drop(b); + tokio::time::sleep(Duration::from_millis(200)).await; + strobe_becomes(&live, true).await; + drop(a); + strobe_becomes(&live, false).await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn status_names_the_box() { + let ctx = ctx(); + let table = ctx.device_table.clone(); + let addr = spawn(ctx, SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .unwrap(); + let status = next_of_type(&mut ws, "status").await; + assert!(status["box"].is_null()); + table.lock().insert( + 9, + crate::prolink::discovery::Device { + device_number: 9, + device_type: 1, + name: "autoblink".into(), + mac: [0; 6], + ip: [192, 168, 2, 50], + last_seen: std::time::Instant::now(), + }, + ); + wait_for(&mut ws, "status", |v| v["box"] == "192.168.2.50").await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn dmx_commands_drive_the_engine() { + let node = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let mut ctx = ctx(); + let dmx_cfg = crate::config::DmxConfig { + artnet_target: Some(node.local_addr().unwrap().to_string()), + universe: 0, + }; + ctx.cfg.write().dmx = dmx_cfg.clone(); + *ctx.show.write() = autoblink_core_dmx::Show::from_json( + json!({ + "fixtures": [{"name": "par", "address": 1, "channels": ["dimmer"]}], + "groups": [{"name": "all", "fixtures": [0]}], + "looks": [{"rows": [{"group": 0, "color": [255, 255, 255, 255], "intensity": 255}]}], + }) + .to_string() + .as_bytes(), + ); + let dmx = crate::dmx::start( + &dmx_cfg, + ctx.show.clone(), + Arc::clone(&ctx.dj_state), + Arc::clone(&ctx.cfg), + ctx.device_table.clone(), + ) + .unwrap(); + ctx.dmx = Some(dmx.clone()); + let addr = spawn(ctx, SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .unwrap(); + let hello = next_of_type(&mut ws, "hello").await; + assert!(hello["caps"].as_array().unwrap().contains(&"dmx".into())); + let status = next_of_type(&mut ws, "status").await; + assert_eq!(status["dmx"]["master"], 255); + + ws.send(WsMessage::Text( + r#"{"type":"dmx_live","master":40,"strobe":true}"#.into(), + )) + .await + .unwrap(); + wait_for(&mut ws, "status", |v| { + v["dmx"]["master"] == 40 && v["dmx"]["strobe_hold"] == true + }) + .await; + + // The test of a preset shows its look on the engine. + ws.send(WsMessage::Text( + r#"{"type":"scene_test","preset":0}"#.into(), + )) + .await + .unwrap(); + wait_for(&mut ws, "status", |v| v["dmx"]["layers"] == json!([0])).await; + + ws.send(WsMessage::Text(r#"{"type":"dmx_fog"}"#.into())) + .await + .unwrap(); + ws.send(WsMessage::Text( + r#"{"type":"dmx_aim","fixture":40,"pan":1,"tilt":2}"#.into(), + )) + .await + .unwrap(); + let err = next_of_type(&mut ws, "error").await; + assert!(err["text"].as_str().unwrap().contains("below 32"), "{err}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn dmx_commands_without_an_engine_give_an_error() { + let addr = spawn(ctx(), SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .unwrap(); + let status = next_of_type(&mut ws, "status").await; + assert!(status["dmx"].is_null()); + ws.send(WsMessage::Text(r#"{"type":"dmx_fog"}"#.into())) + .await + .unwrap(); + let err = next_of_type(&mut ws, "error").await; + assert!(err["text"].as_str().unwrap().contains("DMX"), "{err}"); + } } diff --git a/host/src/web/status.rs b/host/src/web/status.rs index f5c8cd6..a629fc3 100644 --- a/host/src/web/status.rs +++ b/host/src/web/status.rs @@ -34,6 +34,11 @@ pub struct Status { pub output: Output, /// The current DMX scene. pub scene: autoblink_core_midi::scenes::SceneStatus, + /// The IP address of an AUTOBLINK box on the Pro DJ Link network, or `None`. + #[serde(rename = "box")] + pub box_ip: Option, + /// The layers and the live controls of the DMX engine, or `None` when the engine does not run. + pub dmx: Option, } #[derive(Debug, Serialize)] @@ -151,6 +156,7 @@ pub fn snapshot( activity: &Mutex, interfaces: &[NetworkIfaceInfo], out_link: OutputLink, + dmx: Option<&Mutex>, ) -> Status { let cfg = cfg.read().clone(); let (master, mixer, link_peers, mut dev_states) = { @@ -163,12 +169,18 @@ pub fn snapshot( ) }; let act = activity.lock().clone(); - let mut devices: Vec<_> = device_table - .lock() - .values() - .filter(|d| d.device_number != cfg.device_number) - .cloned() - .collect(); + let dmx = dmx.map(|d| d.lock().clone()); + let (mut devices, box_ip): (Vec<_>, _) = { + let table = device_table.lock(); + ( + table + .values() + .filter(|d| d.device_number != cfg.device_number) + .cloned() + .collect(), + crate::dmx::find_box(table.values(), cfg.device_number), + ) + }; devices.sort_by_key(|d| d.device_number); let link_active = master.source == Some(BeatSource::AbletonLink); @@ -319,6 +331,8 @@ pub fn snapshot( link_active, mixer, scene: act.scene, + box_ip: box_ip.map(|ip| ip.to_string()), + dmx, output: Output { port: cfg.midi.output.clone(), connected: out_link.connected, @@ -432,6 +446,7 @@ mod tests { connected: false, dropped: 0, }, + None, ); let nums: Vec = st.decks.iter().map(|d| d.num).collect(); @@ -465,6 +480,7 @@ mod tests { connected: true, dropped: 0, }, + None, ); assert!(st.no_devices); assert!(st.mixer.is_none()); diff --git a/scripts/check.sh b/scripts/check.sh index da6f868..911029c 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -50,10 +50,10 @@ if [ "$esp32" = yes ]; then fi # export-esp.sh sets PATH and LIBCLANG_PATH for the esp toolchain. . "$HOME/export-esp.sh" - step "Firmware" - (cd esp32 && cargo build --release --locked) + step "Firmware without the DMX output" + (cd esp32 && cargo build --release --locked --no-default-features) step "Firmware with the DMX output" - (cd esp32 && cargo build --release --locked --features dmx) + (cd esp32 && cargo build --release --locked) else step "Firmware: skipped. The branch does not change esp32/, crates/ or webui/." fi diff --git a/webui/index.html b/webui/index.html index 3524f98..322dd5b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,6 +4,8 @@ AUTOBLINK + +