From 4db9557a8016f8e9f8a3528c7153585f70fa22af Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 27 Oct 2026 02:00:53 +0100 Subject: [PATCH] Add DMX scenes: drag presets onto the events of the master deck The DMX Scenes panel of the page assigns the presets of a DMX controller to events. Each preset is a MIDI note. The user drags a preset onto a row. - The rows are in four groups: Phrases, Rhythm, Transport and Mixer. - The phrase rows use six buckets of the rekordbox phrases: Intro, Groove, Build, Drop, Break and Outro. - A row with more presets steps through them. It steps on each trigger or after a number of bars, in the order up, ping-pong or random. - A row sends a pulse, holds the note, or sends a flash with a decay in milliseconds or in beats. - The page blinks the title of a row when its event occurs. It shows the cell of the current phrase and the last preset of each row. - Extra notes outside the preset range are available. - The setting "One preset at a time" (default on) is for a controller that shows one preset at a time. With it, no note off comes after a newer note on, and the held scene comes back after an overlay. The shared crate autoblink-core-midi holds the engine (scenes.rs). The host writes the scenes to scenes.json next to the config file. The firmware keeps them in NVS. The new MIDI setting "Note Off Velocity 0" (index 33, default on) sends each note off as a note on with velocity 0. A DMX controller ignored the 0x80 note off and kept the light on. The page names the phrases below the waveforms with the buckets. The firmware accepts a page from localhost, so that a page change needs no flash. --- AGENTS.md | 26 +- Cargo.lock | 11 +- Cargo.toml | 2 +- crates/autoblink-core-midi/src/config.rs | 4 + crates/autoblink-core-midi/src/lib.rs | 21 +- crates/autoblink-core-midi/src/scenes.rs | 1274 ++++++++++++++++++++ crates/autoblink-core-midi/src/settings.rs | 9 +- esp32/Cargo.lock | 8 +- esp32/Cargo.toml | 2 +- esp32/src/main.rs | 6 +- esp32/src/midi.rs | 60 +- esp32/src/settings.rs | 73 +- esp32/src/webui.rs | 71 +- host/Cargo.toml | 1 + host/src/app.rs | 3 + host/src/config.rs | 41 +- host/src/midi/activity.rs | 3 + host/src/midi/mapper.rs | 247 +++- host/src/settings.rs | 16 + host/src/web/mod.rs | 92 +- host/src/web/status.rs | 3 + webui/index.html | 532 +++++++- 22 files changed, 2438 insertions(+), 67 deletions(-) create mode 100644 crates/autoblink-core-midi/src/scenes.rs diff --git a/AGENTS.md b/AGENTS.md index b89de1b..8d04728 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ cp .github/vendor-patches/link_bindings.rs vendor/rusty_link/link_bindings.rs | `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) | | `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 | `clock.rs`, `mapper.rs`, `settings.rs` (host setting indices) | +| `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) | | `esp32-emulator` | Native emulator | `main.rs`, `verify_stopped.rs` | ### Host library exports (`host/src/lib.rs`) @@ -94,6 +94,7 @@ Plus `pub fn interface_priority()` for network interface ranking. - `prolink/metadata.rs` — the dbserver client. One TCP session gets the metadata, the PSSI phrases, the PWV4 waveform preview, the extended cue list (`0x2b04`) and the PQTZ beat grid. The beat grid puts the phrases on the time axis. The web route `GET /waveform/{deck}` serves the waveform and the cues. - `web/status.rs` — status snapshot; takes each lock alone, never two at once - `settings.rs` — the settings table and the validated changes +- DMX scenes: the engine is `autoblink_core_midi::scenes`. `midi/mapper.rs` gives it the beats and the master state. The page sends the full settings as `scenes_set`. The host writes them to `scenes.json` in the folder of the config file (`config::save_scenes`, on a blocking thread). The TOML config has no scenes. - `webui/index.html` (repo root) — the one page that the host, the emulator and the ESP32 serve - `runtime/mod.rs` — `supervise` restarts each long-running task after a panic. The MIDI clock, the MTC, the mapper and the MIDI output worker run on the `midi-rt` thread, a separate single-thread runtime. @@ -163,6 +164,28 @@ The output is `esp32/target/xtensa-esp32-espidf/release/autoblink-esp32`. - 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 %. - 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 + +- The firmware serves the page from the app image. A change to `webui/index.html` therefore needs a flash, or an OTA update and a restart. +- To work on the page, serve it from the computer: `python3 -m http.server 8000 -d webui`. +- Open http://localhost:8000/index.html?box=autoblink.local. The page then uses the box for the WebSocket and the waveforms. A reload shows each change. +- The firmware accepts a page from `localhost` or `127.0.0.1` (`dev_origin` in `webui.rs`). The waveform route sends `Access-Control-Allow-Origin: *`. +- Flash the box only when the page is complete. + +### DMX scenes + +- The firmware keeps the scenes as JSON in NVS (key `scenes`). Like the MIDI settings, it writes them 2 s after the last change (`Settings::save`). +- The webui parses `scenes_set` straight into `ScenesConfig`, not into a `serde_json::Value`. `MAX_COMMAND_LEN` is 3072 bytes for this command. +- The page sends `scene_test`. The webui puts the preset into `State::scene_test`, and the `midi` thread sends the note. +- The `midi` thread has 6 KB of stack since 1.23.0 (5 KB before). Measure it with `/mem`. +- Do not use an `f32` field in a struct that serde reads on the firmware. LLVM for Xtensa stops with "Cannot select: XtensaISD::PCREL_WRAPPER" (`scenes::Decay` uses `f64`). +- The firmware has no mixer data. The page hides the Mixer rows of the scenes without the `mixer` capability. +- The setting "Note Off Velocity 0" (index 33, default on) sends each note off as a note on with velocity 0. It applies to the MIDI notes and the DMX scenes. A DMX controller of the user kept the light on after a 0x80 note off. The test of a preset (`scene_test`) always uses 0x80. +- 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. +- 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`. + ### Debug logs - Run `curl http://autoblink.local/log` to read the log over WiFi. The page shows no log. @@ -257,6 +280,7 @@ Do not connect a load to IO12 at power-on. If IO12 is high, the ESP32 sets the f - 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 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` - `host/src/state.rs` and `host/src/main.rs` are the highest-coupling files in the host crate diff --git a/Cargo.lock b/Cargo.lock index d8c9f0f..b892964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,11 +108,11 @@ dependencies = [ [[package]] name = "autoblink-core-dbserver" -version = "1.22.2" +version = "1.23.0" [[package]] name = "autoblink-core-midi" -version = "1.22.2" +version = "1.23.0" dependencies = [ "serde", "serde_json", @@ -120,11 +120,11 @@ dependencies = [ [[package]] name = "autoblink-core-prolink" -version = "1.22.2" +version = "1.23.0" [[package]] name = "autoblink-esp32-emulator" -version = "1.22.2" +version = "1.23.0" dependencies = [ "anyhow", "autoblink-core-prolink", @@ -138,9 +138,10 @@ dependencies = [ [[package]] name = "autoblink-host" -version = "1.22.2" +version = "1.23.0" dependencies = [ "anyhow", + "autoblink-core-midi", "autoblink-core-prolink", "axum", "clap 4.6.0", diff --git a/Cargo.toml b/Cargo.toml index 30d8f8b..8296445 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ exclude = ["esp32"] [workspace.package] -version = "1.22.2" +version = "1.23.0" edition = "2021" license = "Proprietary" authors = ["AUTOBLINK team"] diff --git a/crates/autoblink-core-midi/src/config.rs b/crates/autoblink-core-midi/src/config.rs index cee93fd..efed272 100644 --- a/crates/autoblink-core-midi/src/config.rs +++ b/crates/autoblink-core-midi/src/config.rs @@ -37,6 +37,9 @@ pub struct MidiConfig { /// The first note of the phrase kinds. The note is this value plus the kind code (0 to 11). pub phrase_base_note: u8, pub send_phrase_kind: bool, + /// Sends each note off as a note on with velocity 0, not as 0x80. Some DMX controllers + /// ignore the 0x80 note off. Host index 33. + pub off_vel0: bool, pub cc_enabled: bool, /// 0-based. The page shows 1 to 16. pub cc_channel: u8, @@ -76,6 +79,7 @@ impl Default for MidiConfig { send_phrase_change: true, phrase_base_note: 40, send_phrase_kind: true, + off_vel0: true, cc_enabled: true, cc_channel: 0, ccs: default_ccs(), diff --git a/crates/autoblink-core-midi/src/lib.rs b/crates/autoblink-core-midi/src/lib.rs index 27b88e0..a2c2ae1 100644 --- a/crates/autoblink-core-midi/src/lib.rs +++ b/crates/autoblink-core-midi/src/lib.rs @@ -8,6 +8,7 @@ pub mod clock; pub mod config; pub mod mapper; +pub mod scenes; pub mod settings; pub use clock::Clock; @@ -29,13 +30,19 @@ pub enum Out { SongPosition(u16), /// A note on, followed at once by its note off. The channel is 0-based. Note { ch: u8, note: u8, vel: u8 }, + /// A note on without its note off. The channel is 0-based. + NoteOn { ch: u8, note: u8, vel: u8 }, + /// A note off. The channel is 0-based. + NoteOff { ch: u8, note: u8 }, /// A control change. The channel is 0-based. Cc { ch: u8, num: u8, val: u8 }, } impl Out { - /// Writes the bytes of the message into `buf` and returns their number. - pub fn encode(self, buf: &mut [u8; 6]) -> usize { + /// Writes the bytes of the message into `buf` and returns their number. With `off_vel0`, a + /// note off is a note on with velocity 0 (`MidiConfig::off_vel0`). + pub fn encode(self, buf: &mut [u8; 6], off_vel0: bool) -> usize { + let off = if off_vel0 { 0x90 } else { 0x80 }; match self { Out::Realtime(b) => { buf[0] = b; @@ -47,9 +54,17 @@ impl Out { } Out::Note { ch, note, vel } => { let (ch, note) = (ch & 0x0f, note & 0x7f); - *buf = [0x90 | ch, note, vel & 0x7f, 0x80 | ch, note, 0]; + *buf = [0x90 | ch, note, vel & 0x7f, off | ch, note, 0]; 6 } + Out::NoteOn { ch, note, vel } => { + *buf = [0x90 | (ch & 0x0f), note & 0x7f, vel & 0x7f, 0, 0, 0]; + 3 + } + Out::NoteOff { ch, note } => { + *buf = [off | (ch & 0x0f), note & 0x7f, 0, 0, 0, 0]; + 3 + } Out::Cc { ch, num, val } => { *buf = [0xB0 | (ch & 0x0f), num & 0x7f, val & 0x7f, 0, 0, 0]; 3 diff --git a/crates/autoblink-core-midi/src/scenes.rs b/crates/autoblink-core-midi/src/scenes.rs new file mode 100644 index 0000000..ebdcc32 --- /dev/null +++ b/crates/autoblink-core-midi/src/scenes.rs @@ -0,0 +1,1274 @@ +//! The DMX scenes. The user maps the presets of a DMX controller to events of the master deck. +//! A preset is one MIDI note: the first note plus the preset index. +//! +//! The rows of the Phrases, Transport and Mixer groups set the current scene. The rows of the +//! Rhythm group (beat, downbeat, phrase change, fill-in) are overlays. An overlay does not +//! change the current scene. +//! +//! The beat count comes from the CDJ status, up to 200 ms after the beat. Thus, on a beat, the +//! engine predicts a phrase start and a fill-in start from the last status. The phrase rows fire +//! when the status shows the new phrase. + +use serde::{Deserialize, Serialize}; + +use crate::Out; + +/// The maximum number of presets. +pub const MAX_PRESETS: u8 = 32; +/// A row entry of this value and up is an extra note: `EXTRA_BASE` is `extra[0]`. +pub const EXTRA_BASE: u8 = 32; +/// The maximum number of extra notes. +const MAX_EXTRA: usize = 16; +/// The maximum number of presets in one row. +const MAX_ROW_PRESETS: usize = 16; +/// The maximum number of characters in a preset label. +const MAX_LABEL: usize = 12; +const MAX_DECAY_MS: u16 = 10_000; +const MAX_DECAY_BEATS: f64 = 32.0; +/// The permitted values of `Row::advance`. 0 steps at each trigger. +pub const ADVANCE_BARS: [u8; 7] = [0, 1, 2, 4, 8, 16, 32]; + +/// An event of the master deck. Each event has one row on the page. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Event { + Intro, + Groove, + Build, + Drop, + Break, + Outro, + Beat, + Downbeat, + PhraseChange, + Fill, + Stop, + Play, + TrackLoad, + MasterChange, + BassCut, + BassReturn, +} + +const EVENT_COUNT: usize = 16; + +impl Event { + pub const ALL: [Event; EVENT_COUNT] = [ + Event::Intro, + Event::Groove, + Event::Build, + Event::Drop, + Event::Break, + Event::Outro, + Event::Beat, + Event::Downbeat, + Event::PhraseChange, + Event::Fill, + Event::Stop, + Event::Play, + Event::TrackLoad, + Event::MasterChange, + Event::BassCut, + Event::BassReturn, + ]; + + /// Returns the bucket of a phrase kind code (Intro 0, Verse 1 to 6, Bridge 7, Chorus 8, + /// Up 9, Down 10, Outro 11). + pub fn bucket(code: u8) -> Option { + match code { + 0 => Some(Event::Intro), + 1..=6 => Some(Event::Groove), + 7 | 9 => Some(Event::Build), + 8 => Some(Event::Drop), + 10 => Some(Event::Break), + 11 => Some(Event::Outro), + _ => None, + } + } + + /// Returns true for the rows of the Rhythm group. + pub fn is_overlay(self) -> bool { + matches!( + self, + Event::Beat | Event::Downbeat | Event::PhraseChange | Event::Fill + ) + } +} + +/// The order of the steps in a row with more than one preset. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Order { + #[default] + Up, + PingPong, + /// The next preset is random. The same preset does not come two times in sequence. + Random, +} + +/// How long the note of a preset stays on. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Gate { + /// The note on and the note off go out together. + #[default] + Pulse, + /// The note stays on until the next scene fires. + Hold, + /// The note goes off after the decay. + Flash, +} + +/// The decay of a flash, in milliseconds or in beats of the master. +/// +/// The beats are `f64`. LLVM for Xtensa cannot compile the serde code of an `f32` here +/// ("Cannot select: XtensaISD::PCREL_WRAPPER"). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Decay { + Ms(u16), + Beats(f64), +} + +impl Default for Decay { + fn default() -> Self { + Decay::Ms(250) + } +} + +/// The presets of one event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Row { + pub event: Event, + /// The 0-based preset indices, in the order of the steps. An index of `EXTRA_BASE` and up + /// is an extra note. + pub presets: Vec, + /// 0 gives the next preset at each trigger. N gives the next preset every N bars. + #[serde(default)] + pub advance: u8, + #[serde(default)] + pub order: Order, + #[serde(default)] + pub gate: Gate, + #[serde(default)] + pub decay: Decay, +} + +/// A note outside the range of the presets, with a name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Extra { + pub note: u8, + #[serde(default)] + pub label: String, +} + +/// The scene settings. The page sends the full settings after each change. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ScenesConfig { + pub enabled: bool, + /// 0-based. The page shows 1 to 16. + pub channel: u8, + /// The note of preset 1. + pub first_note: u8, + /// The number of presets. + pub count: u8, + /// The controller shows one preset at a time. Its note off can then end a preset with a + /// different note. When true, the engine sends no note off after a newer note on, and it + /// sends the held note again after an overlay goes off. + pub one_at_a_time: bool, + /// The names of the presets. An empty name or a missing name shows no name. + pub labels: Vec, + /// The notes outside the range of the presets. + pub extra: Vec, + /// Only the rows with presets. Each event has one row at most. + pub rows: Vec, +} + +impl Default for ScenesConfig { + fn default() -> Self { + Self { + enabled: false, + channel: 0, + first_note: 0, + count: 16, + one_at_a_time: true, + labels: Vec::new(), + extra: Vec::new(), + rows: Vec::new(), + } + } +} + +impl ScenesConfig { + /// Makes the settings valid. It clamps the numbers and cuts the labels. It removes the + /// presets out of range, the empty rows and a second row of an event. + pub fn sanitize(&mut self) { + self.channel = self.channel.min(15); + self.count = self.count.clamp(1, MAX_PRESETS); + self.first_note = self.first_note.min(128 - self.count); + self.labels.truncate(usize::from(self.count)); + let clean = |label: &str| { + label + .chars() + .filter(|c| !c.is_control()) + .take(MAX_LABEL) + .collect() + }; + for label in &mut self.labels { + *label = clean(label); + } + self.extra.truncate(MAX_EXTRA); + for x in &mut self.extra { + x.note = x.note.min(127); + x.label = clean(&x.label); + } + let count = self.count; + 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) + }); + row.presets.truncate(MAX_ROW_PRESETS); + if !ADVANCE_BARS.contains(&row.advance) { + row.advance = 0; + } + row.decay = match row.decay { + Decay::Ms(ms) => Decay::Ms(ms.clamp(1, MAX_DECAY_MS)), + Decay::Beats(b) if b.is_finite() => Decay::Beats(b.clamp(0.0625, MAX_DECAY_BEATS)), + Decay::Beats(_) => Decay::default(), + }; + let keep = !row.presets.is_empty() && !seen.contains(&row.event); + seen.push(row.event); + keep + }); + } + + /// Returns the row of `event`, when it has presets. + pub fn row(&self, event: Event) -> Option<&Row> { + self.rows + .iter() + .find(|r| r.event == event && !r.presets.is_empty()) + } + + /// Returns the note of a 0-based preset index, or of an extra note. + pub fn note(&self, preset: u8) -> u8 { + match preset.checked_sub(EXTRA_BASE) { + Some(k) => self.extra.get(usize::from(k)).map_or(0, |x| x.note), + None => self.first_note.saturating_add(preset).min(127), + } + } +} + +/// The phrase of the master deck, for the scenes. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct ScenePhrase { + /// The index of the phrase in the song structure. + pub idx: usize, + /// The kind code. `None` for a kind that has no code. + pub code: Option, + /// The beats until the next phrase starts. 1 on the last beat of the phrase. + pub beats_left: u32, + /// The beats until the fill-in starts. The value is 0 in the fill-in, and `None` for a + /// phrase without a fill-in. + pub fill_beats_left: Option, +} + +/// The state of the master deck, for the scenes. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct SceneInput { + /// The player number of the master. 0 means no master. + pub device: u8, + /// The rekordbox ID of the master track. 0 means no track. + pub track: u32, + pub playing: bool, + pub bpm: f64, + 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 current scene, for the page. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct SceneStatus { + /// The row of the current scene. + pub row: Option, + /// The position in the presets of the row that is on now. + pub step: Option, + /// The position of the next preset. `None` for a random order. + pub next: Option, + /// The bars until the next step, for a row that steps every N bars. + pub bars_left: Option, + /// The phrase bucket of the master deck now. + pub phrase: Option, + /// 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. + pub events: [u8; EVENT_COUNT], + /// The position of the preset that each row sent last, in the order of `Event::ALL`. + pub last: [Option; EVENT_COUNT], +} + +#[derive(Debug, Clone, Copy, Default)] +struct Cursor { + /// The position of the preset that fires next. + pos: u8, + /// True while a ping-pong row goes down. + back: bool, + /// The downbeats since the last trigger or step. + bars: u8, + /// The time of the last trigger. + fired_ms: u64, +} + +/// The scene engine has no clock and no I/O, like the mapper. +#[derive(Debug, Clone)] +pub struct Scenes { + cursors: [Cursor; EVENT_COUNT], + rng: u32, + current: Option, + current_step: u8, + /// The note of a hold row that is on: (channel, note). + held: Option<(u8, u8)>, + /// The flash notes that are on: (note off time, channel, note). + flashes: Vec<(u64, u8, u8)>, + prev: SceneInput, + /// The (device, track, phrase) that the phrase row fired for. + phrase_key: Option<(u8, u32, usize)>, + /// The (device, track, phrase) that the fill-in row fired for. + fill_key: Option<(u8, u32, usize)>, + /// 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. + status_overlay_ms: Option, + ms_per_beat: f64, + events: [u8; EVENT_COUNT], + last: [Option; EVENT_COUNT], +} + +impl Default for Scenes { + fn default() -> Self { + Self { + cursors: [Cursor::default(); EVENT_COUNT], + rng: 0x2545_f491, + current: None, + current_step: 0, + held: None, + flashes: Vec::new(), + prev: SceneInput::default(), + phrase_key: None, + fill_key: None, + predicted_idx: None, + status_overlay_ms: None, + ms_per_beat: 500.0, + events: [0; EVENT_COUNT], + last: [None; EVENT_COUNT], + } + } +} + +fn index(e: Event) -> usize { + e as usize +} + +impl Scenes { + /// Call on each beat of the master. It fires one overlay: the fill-in, the phrase change, the + /// downbeat or the beat, in this order of priority. Only a row with presets counts. On beat 1, + /// it also steps the rows that step every N bars. + pub fn beat( + &mut self, + cfg: &ScenesConfig, + now_ms: u64, + beat_in_bar: u8, + out: &mut impl FnMut(Out), + ) { + let key = self + .prev + .phrase + .map(|p| (self.prev.device, self.prev.track, p.idx)); + let p = self.prev.phrase; + // An overlay from the status in the last half beat belongs to this beat. + let from_status = self + .status_overlay_ms + .take() + .is_some_and(|t| now_ms.saturating_sub(t) < (self.ms_per_beat / 2.0) as u64); + let fill_next = p.is_some_and(|p| p.fill_beats_left == Some(1)) && self.fill_key != key; + let change_next = p.is_some_and(|p| p.beats_left == 1) && self.predicted_idx.is_none(); + + self.count(Event::Beat); + if beat_in_bar == 1 { + self.count(Event::Downbeat); + } + if fill_next { + self.fill_key = key; + self.count(Event::Fill); + } + if change_next { + self.predicted_idx = p.map(|p| p.idx + 1); + self.count(Event::PhraseChange); + } + + let has = |e| cfg.row(e).is_some(); + let overlay = if from_status { + None + } else if fill_next && has(Event::Fill) { + Some(Event::Fill) + } else if change_next && has(Event::PhraseChange) { + Some(Event::PhraseChange) + } else if beat_in_bar == 1 && has(Event::Downbeat) { + Some(Event::Downbeat) + } else { + Some(Event::Beat) + }; + + if beat_in_bar == 1 && cfg.enabled { + self.step_bars(cfg, now_ms, out); + } + if let Some(e) = overlay { + self.fire(cfg, e, now_ms, out); + } + } + + /// Call after each status change and at least every 10 ms. It fires the rows of the + /// phrases, the transport and the mixer. It sends the note off of each flash that ended. + /// The engine counts the events also while the scenes are off. + pub fn update( + &mut self, + cfg: &ScenesConfig, + now_ms: u64, + s: &SceneInput, + out: &mut impl FnMut(Out), + ) { + self.tick(cfg, now_ms, out); + if !cfg.enabled { + self.release_all(out); + self.current = None; + } + if s.bpm > 0.0 { + self.ms_per_beat = 60_000.0 / s.bpm; + } + let p = self.prev; + + if s.device != 0 && p.device != 0 && s.device != p.device { + self.event(cfg, Event::MasterChange, now_ms, out); + } else if s.track != 0 && s.track != p.track && s.device == p.device { + self.event(cfg, Event::TrackLoad, now_ms, out); + } + if s.device != 0 && s.playing && !p.playing { + self.event(cfg, Event::Play, now_ms, out); + if cfg.row(Event::Play).is_none() { + // Without a play row, the phrase row fires again below. + self.phrase_key = None; + } + } else if !s.playing && p.playing { + self.event(cfg, Event::Stop, now_ms, out); + } + if let (Some(was), Some(cut)) = (p.bass_cut, s.bass_cut) { + if was != cut { + let e = if cut { + Event::BassCut + } else { + Event::BassReturn + }; + self.event(cfg, e, now_ms, out); + } + } + + if let (Some(a), Some(b)) = (p.phrase, s.phrase) { + if a.idx != b.idx { + if self.predicted_idx != Some(b.idx) { + self.count(Event::PhraseChange); + if cfg.row(Event::PhraseChange).is_some() { + self.fire(cfg, Event::PhraseChange, now_ms, out); + self.status_overlay_ms = Some(now_ms); + } + } + self.predicted_idx = None; + } + } + match s.phrase { + Some(ph) => { + let key = Some((s.device, s.track, ph.idx)); + if self.phrase_key != key { + self.phrase_key = key; + if let Some(e) = ph.code.and_then(Event::bucket) { + self.event(cfg, e, now_ms, out); + } + } + if ph.fill_beats_left == Some(0) && self.fill_key != key { + self.fill_key = key; + self.count(Event::Fill); + if cfg.row(Event::Fill).is_some() { + self.fire(cfg, Event::Fill, now_ms, out); + self.status_overlay_ms = Some(now_ms); + } + } + } + None => self.phrase_key = None, + } + self.prev = *s; + } + + /// 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() + } + + /// Returns the current scene for the page. + pub fn status(&self, cfg: &ScenesConfig) -> SceneStatus { + let phrase = self + .prev + .phrase + .and_then(|p| p.code) + .and_then(Event::bucket); + let Some((e, row)) = self.current.and_then(|e| Some((e, cfg.row(e)?))) else { + return SceneStatus { + phrase, + events: self.events, + last: self.last, + ..SceneStatus::default() + }; + }; + let len = row.presets.len() as u8; + let c = self.cursors[index(e)]; + let next = match row.order { + Order::Random => None, + _ if row.advance == 0 => Some(c.pos % len), + order => Some(peek(c, order, len)), + }; + SceneStatus { + row: Some(e), + step: Some(self.current_step.min(len - 1)), + next, + bars_left: (row.advance > 0).then(|| row.advance.saturating_sub(c.bars)), + phrase, + events: self.events, + last: self.last, + } + } + + fn count(&mut self, e: Event) { + let n = &mut self.events[index(e)]; + *n = n.wrapping_add(1); + } + + /// Counts the event and fires its row. + fn event(&mut self, cfg: &ScenesConfig, e: Event, now_ms: u64, out: &mut impl FnMut(Out)) { + self.count(e); + self.fire(cfg, e, now_ms, out); + } + + /// 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 { + return; + } + let Some(row) = cfg.row(e) else { return }; + let len = row.presets.len() as u8; + let c = &mut self.cursors[index(e)]; + let step = c.pos % len; + c.bars = 0; + c.fired_ms = now_ms; + if row.advance == 0 { + advance(c, row.order, len, &mut self.rng); + } + self.send(cfg, row, step, now_ms, !e.is_overlay(), out); + if !e.is_overlay() { + self.current = Some(e); + self.current_step = step; + } + } + + /// Counts a bar in each row that steps every N bars. The current scene sends its new preset. + fn step_bars(&mut self, cfg: &ScenesConfig, now_ms: u64, out: &mut impl FnMut(Out)) { + let half_beat = (self.ms_per_beat / 2.0) as u64; + for row in cfg + .rows + .iter() + .filter(|r| r.advance > 0 && !r.presets.is_empty()) + { + let len = row.presets.len() as u8; + let c = &mut self.cursors[index(row.event)]; + // The bar of the trigger itself does not count. + if c.fired_ms != 0 && now_ms.saturating_sub(c.fired_ms) < half_beat { + continue; + } + c.bars += 1; + if c.bars < row.advance { + continue; + } + c.bars = 0; + advance(c, row.order, len, &mut self.rng); + let step = c.pos % len; + if self.current == Some(row.event) { + self.current_step = step; + self.send(cfg, row, step, now_ms, true, out); + } + } + } + + fn send( + &mut self, + cfg: &ScenesConfig, + row: &Row, + step: u8, + now_ms: u64, + scene: bool, + out: &mut impl FnMut(Out), + ) { + self.last[index(row.event)] = Some(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. + // With one preset at a time, a flash of a different note ends before the new note starts. + let held = self.held; + self.flashes.retain(|&(_, c, n)| { + if (c, n) == (ch, note) { + return false; + } + if !cfg.one_at_a_time { + return true; + } + if held != Some((c, n)) { + out(Out::NoteOff { ch: c, note: n }); + } + false + }); + let prev = if scene || row.gate == Gate::Hold { + self.held.take() + } else { + None + }; + // A note off before a note on of the same note makes the light go out for a moment. + if let Some(p) = prev.filter(|&p| p != (ch, note)) { + out(Out::NoteOff { ch: p.0, note: p.1 }); + } + out(Out::NoteOn { ch, note, vel: 127 }); + match row.gate { + Gate::Pulse => { + out(Out::NoteOff { ch, note }); + self.restore_held(cfg, out); + } + Gate::Hold => self.held = Some((ch, note)), + Gate::Flash => { + let ms = match row.decay { + Decay::Ms(ms) => f64::from(ms), + Decay::Beats(b) => b * self.ms_per_beat, + }; + self.flashes.push((now_ms + ms.max(1.0) as u64, ch, note)); + } + } + } + + /// 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; + let mut ended = false; + self.flashes.retain(|&(t, ch, note)| { + if t > now_ms { + return true; + } + if held != Some((ch, note)) { + out(Out::NoteOff { ch, note }); + ended = true; + } + false + }); + if ended { + self.restore_held(cfg, out); + } + } + + /// Sends the held note again after an overlay went off. Only with one preset at a time. + fn restore_held(&self, cfg: &ScenesConfig, out: &mut impl FnMut(Out)) { + if !cfg.one_at_a_time { + return; + } + if let Some((ch, note)) = self.held { + out(Out::NoteOn { ch, note, vel: 127 }); + } + } + + fn release_all(&mut self, out: &mut impl FnMut(Out)) { + for (_, ch, note) in self.flashes.drain(..) { + out(Out::NoteOff { ch, note }); + } + if let Some((ch, note)) = self.held.take() { + out(Out::NoteOff { ch, note }); + } + } +} + +/// Returns the next position without a change to the cursor. Not for a random order. +fn peek(c: Cursor, order: Order, len: u8) -> u8 { + let mut c = c; + advance(&mut c, order, len, &mut 1); + c.pos % len +} + +fn advance(c: &mut Cursor, order: Order, len: u8, rng: &mut u32) { + let pos = c.pos % len; + if len <= 1 { + c.pos = 0; + return; + } + c.pos = match order { + Order::Up => (pos + 1) % len, + Order::PingPong => { + if pos == 0 { + c.back = false; + } else if pos == len - 1 { + c.back = true; + } + if c.back { + pos - 1 + } else { + pos + 1 + } + } + Order::Random => { + // xorshift32 + *rng ^= *rng << 13; + *rng ^= *rng >> 17; + *rng ^= *rng << 5; + let r = (*rng % u32::from(len - 1)) as u8; + if r >= pos { + r + 1 + } else { + r + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(event: Event, presets: &[u8]) -> Row { + Row { + event, + presets: presets.to_vec(), + advance: 0, + order: Order::Up, + gate: Gate::Pulse, + decay: Decay::default(), + } + } + + fn cfg(rows: Vec) -> ScenesConfig { + ScenesConfig { + enabled: true, + channel: 9, + rows, + ..ScenesConfig::default() + } + } + + fn notes(out: &[Out]) -> Vec { + out.iter() + .filter_map(|o| match *o { + Out::Note { note, .. } | Out::NoteOn { note, .. } => Some(note), + _ => None, + }) + .collect() + } + + fn input(idx: usize, code: u8, beats_left: u32) -> SceneInput { + SceneInput { + device: 1, + track: 7, + playing: true, + bpm: 120.0, + phrase: Some(ScenePhrase { + idx, + code: Some(code), + beats_left, + fill_beats_left: None, + }), + bass_cut: None, + } + } + + #[test] + fn buckets_fold_the_rekordbox_kinds() { + let kinds: Vec<_> = (0..=12).map(Event::bucket).collect(); + use Event::*; + assert_eq!( + kinds, + [ + Some(Intro), + Some(Groove), + Some(Groove), + Some(Groove), + Some(Groove), + Some(Groove), + Some(Groove), + Some(Build), + Some(Drop), + Some(Build), + Some(Break), + Some(Outro), + None + ] + ); + } + + #[test] + fn the_orders_step_through_the_presets() { + let mut rng = 1; + let run = |order, rng: &mut u32| { + let mut c = Cursor::default(); + (0..7) + .map(|_| { + let p = c.pos; + advance(&mut c, order, 4, rng); + p + }) + .collect::>() + }; + assert_eq!(run(Order::Up, &mut rng), [0, 1, 2, 3, 0, 1, 2]); + assert_eq!(run(Order::PingPong, &mut rng), [0, 1, 2, 3, 2, 1, 0]); + let r = run(Order::Random, &mut rng); + assert!(r.windows(2).all(|w| w[0] != w[1] && w[1] < 4)); + } + + #[test] + fn a_phrase_bucket_fires_once_and_steps_on_each_trigger() { + let cfg = cfg(vec![row(Event::Drop, &[4, 5])]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.update(&cfg, 10, &input(0, 8, 32), &mut |o| out.push(o)); + s.update(&cfg, 20, &input(0, 8, 31), &mut |o| out.push(o)); + assert_eq!( + out, + [ + Out::NoteOn { + ch: 9, + note: 4, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 4 } + ] + ); + s.update(&cfg, 30, &input(1, 0, 32), &mut |_| {}); + s.update(&cfg, 40, &input(2, 8, 32), &mut |o| out.push(o)); + assert_eq!(notes(&out), [4, 5]); + assert_eq!(s.status(&cfg).row, Some(Event::Drop)); + } + + #[test] + fn a_hold_scene_goes_off_when_the_next_scene_fires() { + let mut held = row(Event::Intro, &[0]); + held.gate = Gate::Hold; + let cfg = cfg(vec![held, row(Event::Drop, &[3])]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.update(&cfg, 10, &input(0, 0, 32), &mut |o| out.push(o)); + s.update(&cfg, 20, &input(1, 8, 32), &mut |o| out.push(o)); + assert_eq!( + out, + [ + Out::NoteOn { + ch: 9, + note: 0, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 0 }, + Out::NoteOn { + ch: 9, + note: 3, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 3 }, + ] + ); + } + + #[test] + fn a_hold_on_each_beat_sends_the_note_off_before_the_next_note_on() { + let mut beat = row(Event::Beat, &[1, 2]); + beat.gate = Gate::Hold; + let cfg = cfg(vec![beat]); + let mut s = Scenes::default(); + s.update(&cfg, 0, &input(0, 0, 32), &mut |_| {}); + let mut out = Vec::new(); + s.beat(&cfg, 500, 2, &mut |o| out.push(o)); + s.beat(&cfg, 1000, 3, &mut |o| out.push(o)); + assert_eq!( + out, + [ + Out::NoteOn { + ch: 9, + note: 1, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 1 }, + Out::NoteOn { + ch: 9, + note: 2, + vel: 127 + }, + ] + ); + } + + #[test] + fn a_long_flash_goes_off_before_the_next_note_and_never_after_it() { + let mut beat = row(Event::Beat, &[1, 2]); + beat.gate = Gate::Flash; + beat.decay = Decay::Beats(2.0); + let cfg = cfg(vec![beat]); + let mut s = Scenes::default(); + s.update(&cfg, 0, &input(0, 0, 32), &mut |_| {}); + let mut out = Vec::new(); + s.beat(&cfg, 500, 2, &mut |o| out.push(o)); + s.beat(&cfg, 1000, 3, &mut |o| out.push(o)); + s.update(&cfg, 1999, &input(0, 0, 30), &mut |o| out.push(o)); + assert_eq!( + out, + [ + Out::NoteOn { + ch: 9, + note: 1, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 1 }, + Out::NoteOn { + ch: 9, + note: 2, + vel: 127 + }, + ] + ); + out.clear(); + // Two beats at 120 BPM are 1000 ms. + s.update(&cfg, 2000, &input(0, 0, 30), &mut |o| out.push(o)); + assert_eq!(out, [Out::NoteOff { ch: 9, note: 2 }]); + } + + #[test] + fn with_layers_a_long_flash_keeps_its_decay_and_the_held_note_is_not_sent_again() { + let mut held = row(Event::Intro, &[0]); + held.gate = Gate::Hold; + let mut beat = row(Event::Beat, &[1, 2]); + beat.gate = Gate::Flash; + beat.decay = Decay::Beats(2.0); + let mut cfg = cfg(vec![held, beat]); + cfg.one_at_a_time = false; + let mut s = Scenes::default(); + s.update(&cfg, 0, &input(0, 0, 32), &mut |_| {}); + let mut out = Vec::new(); + s.beat(&cfg, 500, 2, &mut |o| out.push(o)); + s.beat(&cfg, 1000, 3, &mut |o| out.push(o)); + s.update(&cfg, 1500, &input(0, 0, 30), &mut |o| out.push(o)); + assert_eq!( + out, + [ + Out::NoteOn { + ch: 9, + note: 1, + vel: 127 + }, + Out::NoteOn { + ch: 9, + note: 2, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 1 }, + ] + ); + } + + #[test] + fn the_held_scene_comes_back_after_an_overlay() { + let mut held = row(Event::Intro, &[0]); + held.gate = Gate::Hold; + let mut beat = row(Event::Beat, &[5]); + beat.gate = Gate::Flash; + beat.decay = Decay::Ms(100); + let cfg = cfg(vec![held, row(Event::Downbeat, &[6]), beat]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.update(&cfg, 0, &input(0, 0, 32), &mut |_| {}); + s.beat(&cfg, 500, 2, &mut |o| out.push(o)); + s.update(&cfg, 600, &input(0, 0, 31), &mut |o| out.push(o)); + s.beat(&cfg, 1000, 1, &mut |o| out.push(o)); + assert_eq!( + out, + [ + Out::NoteOn { + ch: 9, + note: 5, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 5 }, + Out::NoteOn { + ch: 9, + note: 0, + vel: 127 + }, + Out::NoteOn { + ch: 9, + note: 6, + vel: 127 + }, + Out::NoteOff { ch: 9, note: 6 }, + Out::NoteOn { + ch: 9, + note: 0, + vel: 127 + }, + ] + ); + } + + #[test] + fn the_same_note_on_each_beat_never_goes_off() { + let mut hold = row(Event::Beat, &[1]); + hold.gate = Gate::Hold; + let mut flash = row(Event::Downbeat, &[2]); + flash.gate = Gate::Flash; + flash.decay = Decay::Ms(5000); + let cfg = cfg(vec![hold, flash]); + let mut s = Scenes::default(); + s.update(&cfg, 0, &input(0, 0, 32), &mut |_| {}); + let mut out = Vec::new(); + s.beat(&cfg, 500, 2, &mut |o| out.push(o)); + s.beat(&cfg, 1000, 3, &mut |o| out.push(o)); + s.beat(&cfg, 2000, 1, &mut |o| out.push(o)); + s.beat(&cfg, 4000, 1, &mut |o| out.push(o)); + assert!(!out.contains(&Out::NoteOff { ch: 9, note: 1 })); + assert!(!out.contains(&Out::NoteOff { ch: 9, note: 2 })); + assert_eq!(s.next_off_ms(), Some(9000)); + } + + #[test] + fn a_flash_goes_off_after_its_decay() { + let mut beat = row(Event::Beat, &[11]); + beat.gate = Gate::Flash; + beat.decay = Decay::Beats(0.5); + let cfg = cfg(vec![beat]); + let mut s = Scenes::default(); + s.update(&cfg, 0, &input(0, 0, 32), &mut |_| {}); + let mut out = Vec::new(); + s.beat(&cfg, 1000, 2, &mut |o| out.push(o)); + assert_eq!( + out, + [Out::NoteOn { + ch: 9, + note: 11, + vel: 127 + }] + ); + // Half a beat at 120 BPM is 250 ms. + assert_eq!(s.next_off_ms(), Some(1250)); + out.clear(); + s.update(&cfg, 1249, &input(0, 0, 31), &mut |o| out.push(o)); + assert!(out.is_empty()); + s.update(&cfg, 1250, &input(0, 0, 31), &mut |o| out.push(o)); + assert_eq!(out, [Out::NoteOff { ch: 9, note: 11 }]); + } + + #[test] + fn the_most_specific_overlay_wins_on_a_beat() { + let cfg = cfg(vec![ + row(Event::Beat, &[1]), + row(Event::Downbeat, &[2]), + row(Event::PhraseChange, &[3]), + ]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.update(&cfg, 0, &input(0, 0, 5), &mut |_| {}); + s.beat(&cfg, 500, 2, &mut |o| out.push(o)); + s.beat(&cfg, 1000, 1, &mut |o| out.push(o)); + assert_eq!(notes(&out), [1, 2]); + // The status shows the last beat of the phrase. The next beat starts the next phrase. + out.clear(); + s.update(&cfg, 1100, &input(0, 0, 1), &mut |_| {}); + s.beat(&cfg, 1500, 1, &mut |o| out.push(o)); + assert_eq!(notes(&out), [3]); + // The status then shows the new phrase. The overlay does not fire two times. + out.clear(); + s.update(&cfg, 1600, &input(1, 0, 32), &mut |o| out.push(o)); + assert!(out.is_empty()); + } + + #[test] + fn a_phrase_change_from_the_status_suppresses_the_downbeat_of_the_same_beat() { + let cfg = cfg(vec![ + row(Event::Downbeat, &[2]), + row(Event::PhraseChange, &[3]), + ]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.update(&cfg, 0, &input(0, 0, 9), &mut |_| {}); + s.update(&cfg, 990, &input(1, 0, 32), &mut |o| out.push(o)); + s.beat(&cfg, 1000, 1, &mut |o| out.push(o)); + assert_eq!(notes(&out), [3]); + // The next downbeat fires again. + s.beat(&cfg, 3000, 1, &mut |o| out.push(o)); + assert_eq!(notes(&out), [3, 2]); + } + + #[test] + fn a_row_steps_every_n_bars_while_it_is_the_scene() { + let mut drop = row(Event::Drop, &[4, 5, 6]); + drop.advance = 2; + let cfg = cfg(vec![drop]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + // The phrase starts on a downbeat. That bar does not count. + s.update(&cfg, 1000, &input(0, 8, 32), &mut |o| out.push(o)); + s.beat(&cfg, 1050, 1, &mut |o| out.push(o)); + assert_eq!(s.status(&cfg).bars_left, Some(2)); + for bar in 1..=4 { + s.beat(&cfg, 1000 + bar * 2000, 1, &mut |o| out.push(o)); + } + assert_eq!(notes(&out), [4, 5, 6]); + let st = s.status(&cfg); + assert_eq!( + (st.row, st.step, st.next, st.bars_left), + (Some(Event::Drop), Some(2), Some(0), Some(2)) + ); + assert_eq!(st.last[index(Event::Drop)], Some(2)); + assert_eq!(st.phrase, Some(Event::Drop)); + } + + #[test] + fn the_fill_in_fires_on_its_beat_or_from_the_status() { + let cfg = cfg(vec![row(Event::Fill, &[8]), row(Event::Beat, &[1])]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + let fill = |left| SceneInput { + phrase: Some(ScenePhrase { + fill_beats_left: Some(left), + ..input(0, 0, 8).phrase.unwrap() + }), + ..input(0, 0, 8) + }; + s.update(&cfg, 0, &fill(1), &mut |_| {}); + s.beat(&cfg, 500, 1, &mut |o| out.push(o)); + s.update(&cfg, 600, &fill(0), &mut |o| out.push(o)); + assert_eq!(notes(&out), [8]); + } + + #[test] + fn transport_and_mixer_edges_fire_their_rows() { + let cfg = cfg(vec![ + row(Event::Stop, &[14]), + row(Event::BassCut, &[9]), + row(Event::TrackLoad, &[3]), + ]); + let mut s = Scenes::default(); + let mut out = Vec::new(); + let base = SceneInput { + device: 1, + track: 7, + playing: true, + bpm: 120.0, + phrase: None, + bass_cut: Some(false), + }; + s.update(&cfg, 0, &base, &mut |_| {}); + s.update( + &cfg, + 10, + &SceneInput { + bass_cut: Some(true), + ..base + }, + &mut |o| out.push(o), + ); + s.update( + &cfg, + 20, + &SceneInput { + playing: false, + bass_cut: Some(true), + ..base + }, + &mut |o| out.push(o), + ); + s.update( + &cfg, + 30, + &SceneInput { + playing: false, + track: 8, + bass_cut: Some(true), + ..base + }, + &mut |o| out.push(o), + ); + assert_eq!(notes(&out), [9, 14, 3]); + } + + #[test] + fn each_event_counts_also_without_presets_and_while_off() { + let mut cfg = cfg(vec![]); + cfg.enabled = false; + let mut s = Scenes::default(); + let mut out = Vec::new(); + s.update(&cfg, 0, &input(0, 8, 1), &mut |o| out.push(o)); + s.beat(&cfg, 500, 1, &mut |o| out.push(o)); + s.update(&cfg, 600, &input(1, 10, 32), &mut |o| out.push(o)); + s.beat(&cfg, 1000, 2, &mut |o| out.push(o)); + assert!(out.is_empty()); + let st = s.status(&cfg); + let n = |e| st.events[index(e)]; + // The phrase change is predicted on the beat, and the status does not count it again. + assert_eq!( + ( + n(Event::Beat), + n(Event::Downbeat), + n(Event::PhraseChange), + n(Event::Drop), + n(Event::Break), + n(Event::Play) + ), + (2, 1, 1, 1, 1, 1) + ); + assert_eq!(st.phrase, Some(Event::Break)); + } + + #[test] + fn switching_off_releases_the_notes() { + let mut held = row(Event::Intro, &[0]); + held.gate = Gate::Hold; + let mut cfg = cfg(vec![held]); + let mut s = Scenes::default(); + s.update(&cfg, 0, &input(0, 0, 32), &mut |_| {}); + cfg.enabled = false; + let mut out = Vec::new(); + s.update(&cfg, 10, &input(0, 0, 31), &mut |o| out.push(o)); + assert_eq!(out, [Out::NoteOff { ch: 9, note: 0 }]); + } + + #[test] + fn sanitize_drops_bad_data_and_parses_the_page_json() { + let json = r#"{"enabled":true,"channel":20,"first_note":120,"count":15, + "labels":["Blue wash and more text","Red"], + "extra":[{"note":200,"label":"Smoke"}], + "rows":[{"event":"drop","presets":[4,5,99,32,33],"advance":3,"order":"ping_pong","gate":"flash","decay":{"beats":0.25}}, + {"event":"drop","presets":[1]}, + {"event":"intro","presets":[]}]}"#; + let mut c: ScenesConfig = serde_json::from_str(json).unwrap(); + c.sanitize(); + assert_eq!((c.channel, c.first_note, c.count), (15, 113, 15)); + assert_eq!(c.labels, ["Blue wash an", "Red"]); + assert_eq!(c.rows.len(), 1); + let r = &c.rows[0]; + assert_eq!( + (r.presets.as_slice(), r.advance, r.order, r.gate), + (&[4u8, 5, 32][..], 0, Order::PingPong, Gate::Flash) + ); + assert_eq!((c.note(4), c.note(32), c.note(33)), (117, 127, 0)); + assert_eq!(r.decay, Decay::Beats(0.25)); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ScenesConfig::default() + ); + } +} diff --git a/crates/autoblink-core-midi/src/settings.rs b/crates/autoblink-core-midi/src/settings.rs index be15ab9..b6d6e7d 100644 --- a/crates/autoblink-core-midi/src/settings.rs +++ b/crates/autoblink-core-midi/src/settings.rs @@ -1,6 +1,9 @@ //! The MIDI rows of the settings message of protocol v1. The indices, labels, values and ranges //! are the same as in `host/src/settings.rs`, so the page shows the same rows and help texts. //! +//! Index 33 ("Note Off Velocity 0") comes after the OSC rows of the host. It is in the section +//! "MIDI Notes", and the page puts it into that group. +//! //! The firmware leaves out the rows that need data that it does not receive: the No Bass note //! (14, from the USB MIDI of the XDJ-AZ), MTC and OSC. @@ -25,8 +28,8 @@ const CC_LABELS: [&str; CC_COUNT] = [ ]; /// The host indices that the firmware supports. -pub const INDICES: [usize; 24] = [ - 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, +pub const INDICES: [usize; 25] = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 33, ]; /// One row of the settings message. @@ -120,6 +123,7 @@ fn item(cfg: &MidiConfig, idx: usize) -> SettingItem { ), 15 => toggle("CC Enabled", Some("MIDI CC"), cfg.cc_enabled), 16 => plain("CC Channel", (cfg.cc_channel + 1).to_string(), None), + 33 => toggle("Note Off Velocity 0", Some("MIDI Notes"), cfg.off_vel0), _ => { let cc = cfg.ccs[idx - CC_FIRST]; plain(CC_LABELS[idx - CC_FIRST], cc.num.to_string(), Some(cc.on)) @@ -168,6 +172,7 @@ pub fn cycle(cfg: &mut MidiConfig, idx: usize, dir: i8) -> Result<(), String> { 12 => cfg.send_phrase_change = !cfg.send_phrase_change, 13 => cfg.send_phrase_kind = !cfg.send_phrase_kind, 15 => cfg.cc_enabled = !cfg.cc_enabled, + 33 => cfg.off_vel0 = !cfg.off_vel0, i if (CC_FIRST..CC_FIRST + CC_COUNT).contains(&i) => { let cc = &mut cfg.ccs[i - CC_FIRST]; cc.on = !cc.on; diff --git a/esp32/Cargo.lock b/esp32/Cargo.lock index 000790c..43c817b 100644 --- a/esp32/Cargo.lock +++ b/esp32/Cargo.lock @@ -58,22 +58,22 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autoblink-core-dbserver" -version = "1.22.2" +version = "1.23.0" [[package]] name = "autoblink-core-midi" -version = "1.22.2" +version = "1.23.0" dependencies = [ "serde", ] [[package]] name = "autoblink-core-prolink" -version = "1.22.2" +version = "1.23.0" [[package]] name = "autoblink-esp32" -version = "1.22.2" +version = "1.23.0" dependencies = [ "anyhow", "autoblink-core-dbserver", diff --git a/esp32/Cargo.toml b/esp32/Cargo.toml index 737974d..18d0daa 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.22.2" +version = "1.23.0" edition = "2021" license = "Proprietary" authors = ["AUTOBLINK team"] diff --git a/esp32/src/main.rs b/esp32/src/main.rs index 8f5856e..4cc0465 100644 --- a/esp32/src/main.rs +++ b/esp32/src/main.rs @@ -65,6 +65,8 @@ pub struct State { pub reset_reason: &'static str, /// The state of the firmware update for the page. 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, start: Instant, } @@ -183,6 +185,7 @@ fn run() -> anyhow::Result<()> { error: ota_error, ..Default::default() }), + scene_test: AtomicU8::new(u8::MAX), start: Instant::now(), }); let midi_beats = midi::start( @@ -191,6 +194,7 @@ fn run() -> anyhow::Result<()> { p.pins.gpio35, state.clone(), settings.midi.clone(), + settings.scenes.clone(), )?; let device_number = settings.device_number.clone(); let settings = Arc::new(Mutex::new(settings)); @@ -260,7 +264,7 @@ fn run() -> anyhow::Result<()> { .lock() .unwrap() .retain(|n, _| decks.contains(n)); - settings.lock().unwrap().save_midi(); + settings.lock().unwrap().save(); // SAFETY: the heap calls only read counters. let (free, min, block) = unsafe { ( diff --git a/esp32/src/midi.rs b/esp32/src/midi.rs index 4235453..976dff5 100644 --- a/esp32/src/midi.rs +++ b/esp32/src/midi.rs @@ -1,6 +1,6 @@ //! MIDI output on UART2, TX on IO17, at 31250 baud. //! -//! One thread runs the clock and the note and CC mapper of `autoblink-core-midi`. The receive thread +//! One thread runs the clock, the note and CC mapper and the DMX scenes of `autoblink-core-midi`. The receive thread //! sends it each master beat through a FreeRTOS queue. The thread sleeps until 1 ms before the //! next clock pulse, then waits in a loop, so the pulse goes out on time. //! @@ -18,11 +18,13 @@ //! between the bytes of the other messages. use std::collections::VecDeque; +use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use autoblink_core_dbserver::{current_phrase_idx, phrase_info}; use autoblink_core_midi::clock::Master; +use autoblink_core_midi::scenes::{SceneInput, ScenePhrase, SceneStatus, Scenes, ScenesConfig}; use autoblink_core_midi::{Clock, Mapper, MidiConfig, Out, Phrase, Smoother, Snapshot, CLOCK}; use esp_idf_svc::hal::cpu::Core; use esp_idf_svc::hal::delay::TickType; @@ -48,6 +50,8 @@ pub struct MidiStatus { pub clock_state: &'static str, pub out16: u8, pub delta_ms: Option, + /// The current DMX scene. + pub scene: SceneStatus, } /// A master beat, as the receive thread saw it. @@ -69,6 +73,7 @@ pub fn start( rx: Gpio35<'static>, state: Arc, cfg: Arc>, + scenes_cfg: Arc>, ) -> anyhow::Result>> { let uart_cfg = config::Config::new().baudrate(Hertz(31_250)); let uart = UartDriver::new(uart, tx, rx, None::, None::, &uart_cfg)?; @@ -76,13 +81,15 @@ pub fn start( let beat_rx = beats.clone(); // Priority 10 is above the HTTP server task (5). Core 1 does not run the WiFi task. - crate::spawn_rt(c"midi", 5 * 1024, 10, Some(Core::Core1), move || { + // ponytail: 6 KB is an estimate for the scenes (5 KB before). Measure with /mem on a box. + crate::spawn_rt(c"midi", 6 * 1024, 10, Some(Core::Core1), move || { // SAFETY: a null handle subscribes the calling task. if let Err(e) = sys::esp!(unsafe { sys::esp_task_wdt_add(std::ptr::null_mut()) }) { log::warn!("The task watchdog does not watch the MIDI thread: {e}"); } let mut clock = Clock::default(); let mut mapper = Mapper::default(); + let mut scenes = Scenes::default(); let mut smoother = Smoother::default(); let mut smoothed_bpm = 0.0; let mut last_master = 0u8; @@ -116,13 +123,13 @@ pub fn start( let mut status = MidiStatus::default(); let mut send = |o: Out| { let mut buf = [0u8; 6]; - let n = o.encode(&mut buf); + let n = o.encode(&mut buf, cfg.off_vel0); if let Err(e) = uart.write(&buf[..n]) { log::warn!("MIDI message not sent: {e}"); } match o { Out::Realtime(CLOCK) => status.pulses += 1, - Out::Note { note, .. } => { + Out::Note { note, .. } | Out::NoteOn { note, .. } => { status.notes += 1; status.recent_notes.push_front((note, Instant::now())); } @@ -149,9 +156,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 now_ms = state.now_ms(); + let mut scene_in = SceneInput::default(); let (mut snap, track) = { let tracker = state.tracker.lock().unwrap(); tracker.master().map_or((Snapshot::default(), None), |m| { @@ -168,6 +182,14 @@ pub fn start( playing: m.is_playing(now_ms), phrase: None, }; + scene_in = SceneInput { + device: m.num, + track: m.track.rekordbox_id, + playing: snap.playing, + bpm: snap.bpm, + phrase: None, + bass_cut: None, + }; (snap, m.beat_count.map(|bc| (m.track.rekordbox_id, bc))) }) }; @@ -181,6 +203,15 @@ pub fn start( .and_then(|ss| { let idx = current_phrase_idx(ss, beat_count)?; let info = phrase_info(ss, idx, beat_count)?; + let entry = &ss.phrases[idx]; + scene_in.phrase = Some(ScenePhrase { + idx, + code: info.kind.code(), + beats_left: info.beats_left, + fill_beats_left: entry + .has_fill + .then(|| u32::from(entry.fill_beat).saturating_sub(beat_count)), + }); Some(Phrase { rekordbox_id: rbid, idx, @@ -200,6 +231,26 @@ pub fn start( mapper.update(&cfg, &snap, &mut send); next_cc = Instant::now() + CC_INTERVAL; } + let scene = { + let scenes_cfg = scenes_cfg.lock().unwrap(); + let test = state.scene_test.swap(u8::MAX, Ordering::Relaxed); + if test != u8::MAX { + // The test always uses the 0x80 note off, as on the host. A DMX controller that + // ignores 0x80 then keeps the preset on, so that the user sees it. + let mut buf = [0u8; 6]; + let o = Out::Note { + ch: scenes_cfg.channel, + note: scenes_cfg.note(test), + vel: 127, + }; + let n = o.encode(&mut buf, false); + if let Err(e) = uart.write(&buf[..n]) { + log::warn!("MIDI message not sent: {e}"); + } + } + scenes.update(&scenes_cfg, state.now_ms(), &scene_in, &mut send); + scenes.status(&scenes_cfg) + }; let mut s = state.midi.lock().unwrap(); s.pulses += status.pulses; @@ -216,6 +267,7 @@ pub fn start( s.clock_state = clock.state_label(); s.out16 = clock.out16(); s.delta_ms = clock.last_error_ms().map(f64::abs); + s.scene = scene; } })?; log::info!("MIDI output is on IO17"); diff --git a/esp32/src/settings.rs b/esp32/src/settings.rs index 57e451d..5129336 100644 --- a/esp32/src/settings.rs +++ b/esp32/src/settings.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use autoblink_core_midi::scenes::ScenesConfig; use autoblink_core_midi::{settings as midi_settings, MidiConfig, SettingItem}; use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault}; @@ -19,7 +20,7 @@ const IDX_PASSWORD: usize = 101; const IDX_JOIN_SSID: usize = 102; const IDX_JOIN_PASSWORD: usize = 103; const DEFAULT_PASSWORD: &str = "autoblink123"; -/// The MIDI configuration goes to NVS when it has not changed for this time. +/// The MIDI configuration and the scenes go to NVS when they have not changed for this time. const SAVE_DELAY: Duration = Duration::from_secs(2); pub struct Settings { @@ -31,18 +32,30 @@ pub struct Settings { pub device_number: Arc, /// The time of the last MIDI change that is not in NVS yet. midi_changed: Option, + /// The DMX scenes. The MIDI thread reads them. + pub scenes: Arc>, + /// The time of the last scene change that is not in NVS yet. + scenes_changed: Option, } impl Settings { pub fn new(partition: EspDefaultNvsPartition, default_ssid: String) -> anyhow::Result { let nvs = EspNvs::new(partition, "xdj", true)?; - let mut buf = vec![0u8; 2048]; + // The scenes need more than the MIDI configuration: up to about 3 KB. + let mut buf = vec![0u8; 4096]; let midi = nvs .get_str("midi", &mut buf) .ok() .flatten() .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); + let mut scenes: ScenesConfig = nvs + .get_str("scenes", &mut buf) + .ok() + .flatten() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + scenes.sanitize(); let device = nvs .get_u8("devnum") .ok() @@ -54,6 +67,8 @@ impl Settings { midi: Arc::new(Mutex::new(midi)), device_number: Arc::new(AtomicU8::new(device.unwrap_or(DEFAULT_DEVICE))), midi_changed: None, + scenes: Arc::new(Mutex::new(scenes)), + scenes_changed: None, }) } @@ -208,20 +223,54 @@ impl Settings { Ok(()) } - /// Writes the MIDI configuration to NVS when it has not changed for `SAVE_DELAY`. The main - /// loop calls this each second. - pub fn save_midi(&mut self) { - if !self.midi_changed.is_some_and(|t| t.elapsed() >= SAVE_DELAY) { - return; + /// Builds the protocol v1 scenes message as JSON text. + pub fn scenes_message(&self) -> String { + #[derive(serde::Serialize)] + struct Message<'a> { + #[serde(rename = "type")] + kind: &'static str, + #[serde(flatten)] + scenes: &'a ScenesConfig, } - self.midi_changed = None; - let json = serde_json::to_string(&*self.midi.lock().unwrap()); + serde_json::to_string(&Message { + kind: "scenes", + scenes: &self.scenes.lock().unwrap(), + }) + .unwrap_or_default() + } + + /// Makes the scenes valid and uses them. `save` writes them to NVS later. + pub fn set_scenes(&mut self, mut scenes: ScenesConfig) { + scenes.sanitize(); + *self.scenes.lock().unwrap() = scenes; + self.scenes_changed = Some(Instant::now()); + } + + /// Writes the MIDI configuration and the scenes to NVS when they have not changed for + /// `SAVE_DELAY`. The main loop calls this each second. + pub fn save(&mut self) { + if self.midi_changed.is_some_and(|t| t.elapsed() >= SAVE_DELAY) { + self.midi_changed = None; + let json = serde_json::to_string(&*self.midi.lock().unwrap()); + self.save_json("midi", json, "MIDI settings"); + } + if self + .scenes_changed + .is_some_and(|t| t.elapsed() >= SAVE_DELAY) + { + self.scenes_changed = None; + let json = serde_json::to_string(&*self.scenes.lock().unwrap()); + self.save_json("scenes", json, "scenes"); + } + } + + fn save_json(&self, key: &str, json: serde_json::Result, what: &str) { match json .map_err(anyhow::Error::from) - .and_then(|j| Ok(self.nvs.set_str("midi", &j)?)) + .and_then(|j| Ok(self.nvs.set_str(key, &j)?)) { - Ok(()) => log::info!("The MIDI settings are saved"), - Err(e) => log::warn!("Cannot save the MIDI settings: {e}"), + Ok(()) => log::info!("The {what} are saved"), + Err(e) => log::warn!("Cannot save the {what}: {e}"), } } diff --git a/esp32/src/webui.rs b/esp32/src/webui.rs index fc65427..3498fb1 100644 --- a/esp32/src/webui.rs +++ b/esp32/src/webui.rs @@ -1,7 +1,8 @@ //! Serves `webui/index.html` and speaks WebSocket protocol v1 (see `host/src/web/mod.rs`). //! -//! The firmware sends the capabilities `beat`, `decks`, `output`, `settings`, `waveform` and -//! `ota`. The page hides the sections of the other capabilities: `mixer`, `ports` and `logs`. +//! The firmware sends the capabilities `beat`, `decks`, `output`, `settings`, `waveform`, `ota` +//! and `scenes`. The page hides the sections of the other capabilities: `mixer`, `ports` and +//! `logs`. Without `mixer`, the page also hides the mixer rows of the scenes. //! 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. //! @@ -30,13 +31,15 @@ use crate::settings::Settings; use crate::tracks::TrackData; use crate::State; use autoblink_core_dbserver::{phrase_status, PhraseKind, TrackOverview}; +use autoblink_core_midi::scenes::{SceneStatus, ScenesConfig}; use autoblink_core_prolink::{Deck, PlayState}; /// 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. -const MAX_COMMAND_LEN: usize = 512; +/// The scenes command is the longest command, about 1.5 KB with 16 rows. +const MAX_COMMAND_LEN: usize = 3072; /// The page gets the status this often. const STATUS_INTERVAL: Duration = Duration::from_millis(100); @@ -130,8 +133,12 @@ pub fn start( }); match overview { Some(o) => { - let mut resp = - req.into_response(200, None, &[("Content-Type", "application/json")])?; + // A page from the computer of the developer reads the waveform too (see `dev_origin`). + let headers = [ + ("Content-Type", "application/json"), + ("Access-Control-Allow-Origin", "*"), + ]; + let mut resp = req.into_response(200, None, &headers)?; write_waveform(&mut resp, &o) } None => req.into_status_response(404)?.write_all(b"no waveform"), @@ -180,7 +187,7 @@ pub fn start( "proto": 1, "version": env!("CARGO_PKG_VERSION"), "device": "esp32", - "caps": ["beat", "decks", "output", "settings", "waveform", "ota"], + "caps": ["beat", "decks", "output", "settings", "waveform", "ota", "scenes"], }); 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. @@ -189,6 +196,8 @@ pub fn start( } ws_active.set(ws.create_detached_sender()?); 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()); } if ws.is_closed() { @@ -208,6 +217,24 @@ pub fn start( // For a text frame, esp-idf-svc adds 1 to the length and puts a NUL byte at the end. // serde_json refuses that byte, so remove it. let text = buf.strip_suffix(&[0]).unwrap_or(&buf); + // A `Value` tree has a heap block for each field. The scenes are large, so they go + // into their struct at once. + #[derive(serde::Deserialize)] + struct Kind<'a> { + #[serde(rename = "type", borrow)] + kind: Cow<'a, str>, + } + if serde_json::from_slice::(text).is_ok_and(|k| k.kind == "scenes_set") { + let reply = match serde_json::from_slice::(text) { + Ok(scenes) => { + let mut settings = settings.lock().unwrap(); + settings.set_scenes(scenes); + settings.scenes_message() + } + Err(e) => error(&format!("The scenes are not valid: {e}")), + }; + return ws.send(FrameType::Text(false), reply.as_bytes()); + } let Ok(cmd) = serde_json::from_slice::(text) else { return ws.send( FrameType::Text(false), @@ -217,6 +244,8 @@ pub fn start( if cmd["type"] == "take_over" { take_over(ws, &ws_active)?; 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); @@ -389,6 +418,16 @@ fn command(cmd: &Value, settings: &Mutex, state: &Arc) -> Strin Err(text) => error(&text), } } + Some("scene_test") => match cmd["preset"].as_u64().and_then(|p| u8::try_from(p).ok()) { + // The MIDI thread sends the note. The reply is empty. + Some(p) if p < u8::MAX => { + state + .scene_test + .store(p, std::sync::atomic::Ordering::Relaxed); + r#"{"type":"scene_test"}"#.into() + } + _ => error("The command needs a preset"), + }, Some("reboot") => match crate::restart_soon() { Ok(()) => { log::info!("The page asked for a restart"); @@ -503,6 +542,7 @@ struct Status<'a> { link_active: bool, clock: ClockJson, output: OutputJson, + scene: SceneStatus, mixer: Option<()>, ota: OtaStatus, system: SystemJson, @@ -642,7 +682,7 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { } }) .collect(); - let (clock, output) = { + let (clock, output, scene) = { let midi = state.midi.lock().unwrap(); let clock = ClockJson { out16: midi.out16, @@ -672,7 +712,7 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { mtc_qf: 0, mtc_full: 0, }; - (clock, output) + (clock, output, midi.scene) }; let mut ota = state.ota.lock().unwrap().clone(); if ota.state.is_empty() { @@ -698,6 +738,7 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { link_active: false, clock, output, + scene, mixer: None, ota, // SAFETY: the two heap calls only read counters. @@ -749,10 +790,24 @@ fn header(req: *mut sys::httpd_req_t, name: &str) -> Option { /// The same rule as the host: no Origin passes. Otherwise the Origin must name the Host, and the /// Host must be an IP address, `localhost` or a `.local` name. A public name can resolve to this /// device (DNS rebinding), so it does not pass. +/// Returns true for a page from `localhost` or `127.0.0.1`. A developer opens `webui/index.html` +/// from the computer with `?box=
`, and the page then needs no flash for a change. Only +/// a program on the computer of the user can serve such a page. +fn dev_origin(origin: &str) -> bool { + origin + .strip_prefix("http://") + .map(|rest| rest.trim_end_matches('/')) + .map(|rest| rest.rsplit_once(':').map_or(rest, |(name, _)| name)) + .is_some_and(|name| name.eq_ignore_ascii_case("localhost") || name == "127.0.0.1") +} + fn origin_allowed(origin: Option<&str>, host: Option<&str>) -> bool { let Some(origin) = origin else { return true; }; + if dev_origin(origin) { + return true; + } let Some(host) = host else { return false; }; diff --git a/host/Cargo.toml b/host/Cargo.toml index 6e98a7e..2ee8f60 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -25,6 +25,7 @@ axum = { version = "0.8", default-features = false, features = ["http1", "tokio" serde_json = "1" rusty_link = "0.2" +autoblink-core-midi = { path = "../crates/autoblink-core-midi" } autoblink-core-prolink = { path = "../crates/autoblink-core-prolink" } [dev-dependencies] diff --git a/host/src/app.rs b/host/src/app.rs index b5f5b55..d3f1fcb 100644 --- a/host/src/app.rs +++ b/host/src/app.rs @@ -209,6 +209,9 @@ pub fn init() -> anyhow::Result { // ── Config ─────────────────────────────────────────────────────────────── let mut initial_cfg = config::load(&cli.config)?; + let scenes_path = config::scenes_path(&cli.config); + initial_cfg.scenes = config::load_scenes(&scenes_path); + initial_cfg.scenes_path = Some(scenes_path); if let Some(iface) = &cli.interface { initial_cfg.interface = iface.clone(); } diff --git a/host/src/config.rs b/host/src/config.rs index 01535f4..6f667e2 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -1,8 +1,9 @@ use std::sync::Arc; +use autoblink_core_midi::scenes::ScenesConfig; use parking_lot::RwLock; use serde::Deserialize; -use std::path::Path; +use std::path::{Path, PathBuf}; // ── Beat source selector ────────────────────────────────────────────────────── @@ -32,6 +33,12 @@ pub struct Config { pub link: LinkConfig, pub web: WebConfig, pub osc: OscConfig, + /// 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, } pub type SharedConfig = Arc>; @@ -51,6 +58,8 @@ impl Default for Config { link: LinkConfig::default(), web: WebConfig::default(), osc: OscConfig::default(), + scenes: ScenesConfig::default(), + scenes_path: None, } } } @@ -129,6 +138,9 @@ pub struct NoteConfig { pub no_bass: u8, /// The switch of each note. A note that is off is not sent. pub send: NoteSend, + /// Sends each note off as a note on with velocity 0, not as 0x80. Some DMX controllers + /// ignore the 0x80 note off. This applies to the MIDI notes and the DMX scenes. + pub off_vel0: bool, } /// One switch for each note of [`NoteConfig`]. @@ -165,6 +177,7 @@ impl Default for NoteConfig { phrase_base: 40, no_bass: 39, send: NoteSend::default(), + off_vel0: true, } } } @@ -392,6 +405,32 @@ pub fn load(path: &Path) -> anyhow::Result { Ok(cfg) } +/// Returns the path of the scenes file: `scenes.json` in the folder of the config file. +pub fn scenes_path(config_path: &Path) -> PathBuf { + config_path.with_file_name("scenes.json") +} + +/// Reads the scenes file. A missing or bad file gives the default scenes. +pub fn load_scenes(path: &Path) -> ScenesConfig { + let mut scenes = match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str(&text).unwrap_or_else(|e| { + tracing::warn!("The scenes file {path:?} is not valid, using defaults: {e}"); + ScenesConfig::default() + }), + Err(_) => ScenesConfig::default(), + }; + scenes.sanitize(); + scenes +} + +/// Writes the scenes file. This is a blocking call. +pub fn save_scenes(path: &Path, scenes: &ScenesConfig) -> anyhow::Result<()> { + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(scenes)?)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + pub(crate) fn validate(cfg: &Config) -> anyhow::Result<()> { anyhow::ensure!( cfg.device_number >= 1 && cfg.device_number <= 15, diff --git a/host/src/midi/activity.rs b/host/src/midi/activity.rs index d271e05..53d1bf4 100644 --- a/host/src/midi/activity.rs +++ b/host/src/midi/activity.rs @@ -44,6 +44,9 @@ pub struct MidiActivity { /// Latest beat timing delta in milliseconds. pub clock_timing_delta_ms: Option, + + /// The current DMX scene, for the page. + pub scene: autoblink_core_midi::scenes::SceneStatus, } #[cfg(test)] diff --git a/host/src/midi/mapper.rs b/host/src/midi/mapper.rs index 5ec7d4e..ddcce9c 100644 --- a/host/src/midi/mapper.rs +++ b/host/src/midi/mapper.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use parking_lot::Mutex; use tokio::sync::broadcast; @@ -11,6 +11,8 @@ 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::Out; // ── MIDI byte helpers ───────────────────────────────────────────────────────── @@ -22,6 +24,15 @@ fn note_off(ch: u8, note: u8) -> [u8; 3] { [0x80 | (ch & 0x0f), note & 0x7f, 0] } +/// The note off. With `vel0`, it is a note on with velocity 0 (`NoteConfig::off_vel0`). +fn release(ch: u8, note: u8, vel0: bool) -> [u8; 3] { + if vel0 { + note_on(ch, note, 0) + } else { + note_off(ch, note) + } +} + fn cc(ch: u8, num: u8, val: u8) -> [u8; 3] { [0xB0 | (ch & 0x0f), num & 0x7f, val & 0x7f] } @@ -147,7 +158,7 @@ mod tests { let msgs = midi.get_messages(); assert!(msgs.contains(&cc(0, 20, 0).to_vec())); assert!(msgs.contains(¬e_on(9, 40, 127).to_vec())); - assert!(msgs.contains(¬e_off(9, 40).to_vec())); + assert!(msgs.contains(&release(9, 40, true).to_vec())); assert!(msgs.contains(&program_change(0, 0).to_vec())); assert!(msgs.contains(&cc(0, 22, 28).to_vec())); // 33 - 5 beats left @@ -164,6 +175,119 @@ mod tests { handle.abort(); } + #[tokio::test] + async fn master_phrase_fires_its_dmx_scene() { + use crate::state::{PhraseEntry, PhraseKind, SongStructure, TrackMood}; + 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.channel = 3; + c.scenes.rows = vec![Row { + event: Event::Intro, + presets: vec![2], + advance: 0, + order: Order::Up, + gate: Gate::Hold, + decay: Decay::default(), + }]; + } + 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.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, + }], + }, + ); + st.apply_cdj_status(&cdj_at(7, 5)); + } + let (status_tx, status_rx) = broadcast::channel(8); + let (_beat_tx, beat_rx) = broadcast::channel(8); + let handle = tokio::spawn(run_with_midi( + midi.clone(), + state, + beat_rx, + status_rx, + cfg, + Arc::new(Mutex::new(MidiActivity::default())), + )); + let _ = status_tx.send(StatusEvent::Mixer(crate::prolink::packets::MixerStatus { + device_number: 16, + is_master: false, + bpm_raw: 12800, + track_bpm: Some(128.0), + beat_in_bar: 1, + })); + tokio::time::sleep(Duration::from_millis(30)).await; + let msgs = midi.get_messages(); + assert!(msgs.contains(¬e_on(3, 2, 127).to_vec())); + assert!(!msgs.contains(¬e_on(3, 2, 0).to_vec())); + handle.abort(); + } + + #[tokio::test] + async fn a_scene_flash_sends_its_note_off_after_the_decay() { + 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), + }]; + } + 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 handle = tokio::spawn(run_with_midi( + midi.clone(), + state, + beat_rx, + status_rx, + cfg, + Arc::new(Mutex::new(MidiActivity::default())), + )); + 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(30)).await; + let msgs = midi.get_messages(); + assert!(msgs.contains(¬e_on(0, 5, 127).to_vec())); + assert!(!msgs.contains(¬e_on(0, 5, 0).to_vec())); + // No other event wakes the task. The note off comes from the timer alone. + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(midi.get_messages().contains(¬e_on(0, 5, 0).to_vec())); + handle.abort(); + } + #[test] fn test_scale01() { assert_eq!(scale01(0.0), 0); @@ -230,7 +354,11 @@ mod tests { #[test] fn test_fire_beat_notes_regular_and_downbeat() { let midi = MockMidiTransport::new(); - let note_cfg = NoteConfig::default(); + // The 0x80 note off: the setting "Note Off Velocity 0" is off. + let note_cfg = NoteConfig { + off_vel0: false, + ..NoteConfig::default() + }; let activity = Arc::new(Mutex::new(MidiActivity::default())); fire_beat_notes(&midi, ¬e_cfg, 2, &activity); @@ -290,7 +418,7 @@ mod tests { state.write().mixer[0].low = Some(0); wake(); - let pulse = vec![note_on(9, 39, 127).to_vec(), note_off(9, 39).to_vec()]; + let pulse = vec![note_on(9, 39, 127).to_vec(), release(9, 39, true).to_vec()]; tokio::time::sleep(Duration::from_millis(30)).await; assert!(midi.get_messages().windows(2).any(|w| w == pulse)); @@ -598,12 +726,13 @@ fn pulse_note( vel: u8, on: bool, activity: &Arc>, + vel0: bool, ) { if !on { return; } let _ = midi.send_message(¬e_on(ch, note, vel)); - let _ = midi.send_message(¬e_off(ch, note)); + let _ = midi.send_message(&release(ch, note, vel0)); // The UI counters are optional. Do not wait for the lock. if let Some(mut act) = activity.try_lock() { act.notes_sent += 1; @@ -627,10 +756,11 @@ fn fire_beat_notes( vel, n.enabled && n.send.beat, activity, + n.off_vel0, ); if beat_in_bar == 1 { let on = n.enabled && n.send.downbeat; - pulse_note(midi, n.channel, n.downbeat, 127, on, activity); + pulse_note(midi, n.channel, n.downbeat, 127, on, activity, n.off_vel0); } } @@ -642,6 +772,53 @@ fn send_osc_beat(osc: &OscOut, cfg: &SharedConfig, beat_in_bar: u8) { } } +/// Sends one message of the DMX scenes. +fn send_scene(midi: &dyn MidiTransport, o: Out, activity: &Arc>, vel0: bool) { + match o { + Out::Note { ch, note, vel } => pulse_note(midi, ch, note, vel, true, activity, vel0), + Out::NoteOn { ch, note, vel } => { + let _ = midi.send_message(¬e_on(ch, note, vel)); + // The UI counters are optional. Do not wait for the lock. + if let Some(mut act) = activity.try_lock() { + act.notes_sent += 1; + remember(&mut act.recent_notes, (note, Instant::now())); + } + } + Out::NoteOff { ch, note } => { + let _ = midi.send_message(&release(ch, note, vel0)); + } + _ => {} + } +} + +/// Returns the state of the master deck for the DMX scenes. +fn scene_input(state: &SharedState) -> SceneInput { + let st = state.read(); + let dev = st.devices.get(&st.master.device_number); + let phrase = st.master_phrase().zip(dev).map(|(info, d)| { + let entry = d + .song_structure + .as_ref() + .and_then(|ss| ss.phrases.get(info.idx)); + ScenePhrase { + idx: info.idx, + code: info.kind.code(), + beats_left: info.beats_left, + fill_beats_left: entry + .filter(|e| e.has_fill) + .map(|e| u32::from(e.fill_beat).saturating_sub(d.beat_count)), + } + }); + SceneInput { + device: st.master.device_number, + track: dev.map_or(0, |d| d.rekordbox_id), + playing: st.master.is_playing, + bpm: st.master.bpm, + phrase, + bass_cut: Some(st.no_bass()), + } +} + // ── Previous CC values (to suppress duplicate sends) ───────────────────────── #[derive(Default)] @@ -683,10 +860,19 @@ async fn run_with_midi( let mut prev_osc_beats_left = u32::MAX; // (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(); + let start = Instant::now(); + let now_ms = || start.elapsed().as_millis() as u64; tracing::info!("MIDI mapper task started"); loop { + // Wake up for the note off of a scene flash. + let flash_off = scenes + .next_off_ms() + .map(|ms| tokio::time::Instant::from_std(start + Duration::from_millis(ms))); tokio::select! { + _ = async { tokio::time::sleep_until(flash_off.unwrap()).await }, if flash_off.is_some() => {} + evt = beat_rx.recv() => { match evt { Ok(BeatEvent::Beat { packet: bp, .. }) => { @@ -701,6 +887,13 @@ async fn run_with_midi( let note_cfg = cfg.read().midi.notes; fire_beat_notes(midi.as_ref(), ¬e_cfg, bp.beat_in_bar, &activity); + let (scene_cfg, vel0) = { + let c = cfg.read(); + (c.scenes.clone(), c.midi.notes.off_vel0) + }; + scenes.beat(&scene_cfg, now_ms(), bp.beat_in_bar, &mut |o| { + send_scene(midi.as_ref(), o, &activity, vel0) + }); send_osc_beat(&osc, &cfg, bp.beat_in_bar); tracing::debug!( device = bp.device_number, @@ -718,6 +911,13 @@ async fn run_with_midi( } let note_cfg = cfg.read().midi.notes; fire_beat_notes(midi.as_ref(), ¬e_cfg, beat_in_bar, &activity); + let (scene_cfg, vel0) = { + let c = cfg.read(); + (c.scenes.clone(), c.midi.notes.off_vel0) + }; + scenes.beat(&scene_cfg, now_ms(), beat_in_bar, &mut |o| { + send_scene(midi.as_ref(), o, &activity, vel0) + }); send_osc_beat(&osc, &cfg, beat_in_bar); tracing::debug!( beat = beat_in_bar, @@ -894,6 +1094,7 @@ async fn run_with_midi( 127, on, &activity, + n.off_vel0, ); tracing::debug!(note = n.phrase_change, on, "Phrase change"); } @@ -934,7 +1135,15 @@ async fn run_with_midi( let n = cfg_now.notes; let note = n.phrase_base.saturating_add(code); let on = n.enabled && n.send.phrase_kind; - pulse_note(midi.as_ref(), n.channel, note, 127, on, &activity); + pulse_note( + midi.as_ref(), + n.channel, + note, + 127, + on, + &activity, + n.off_vel0, + ); if cfg_now.program.enabled { let _ = midi.send_message(&program_change(cfg_now.program.channel, code)); } @@ -984,10 +1193,32 @@ async fn run_with_midi( prev_no_bass = no_bass; let n = cfg.read().midi.notes; let on = n.enabled && n.send.no_bass; - pulse_note(midi.as_ref(), n.channel, n.no_bass, 127, on, &activity); + pulse_note( + midi.as_ref(), + n.channel, + n.no_bass, + 127, + on, + &activity, + n.off_vel0, + ); osc.send(&cfg, "/no_bass", &[Arg::I(no_bass.into())]); tracing::debug!(no_bass, note = n.no_bass, on, "NO_BASS"); } + + // ── DMX scenes ──────────────────────────────────────────────────────── + let (scene_cfg, vel0) = { + let c = cfg.read(); + (c.scenes.clone(), c.midi.notes.off_vel0) + }; + let input = scene_input(&state); + scenes.update(&scene_cfg, now_ms(), &input, &mut |o| { + send_scene(midi.as_ref(), o, &activity, vel0) + }); + // 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); + } } } diff --git a/host/src/settings.rs b/host/src/settings.rs index 3b46e8f..e91fc97 100644 --- a/host/src/settings.rs +++ b/host/src/settings.rs @@ -200,6 +200,13 @@ pub const SETTINGS: &[SettingDef] = &[ kind: SettingKind::Text, section: None, }, + // Index 33 is after the OSC rows, so that the indices of the other rows stay. The page puts + // it into the group of its section. + SettingDef { + label: "Note Off Velocity 0", + kind: SettingKind::Toggle, + section: Some("MIDI Notes"), + }, ]; /// The index of the first note with a switch. The notes follow in [`note_slot`] order. @@ -353,6 +360,7 @@ pub fn get_value(cfg: &Config, interfaces: &[NetworkIfaceInfo], idx: usize) -> S 30 => tick(cfg.osc.enabled), 31 => cfg.osc.targets.join(", "), 32 => cfg.osc.prefix.clone(), + 33 => tick(cfg.midi.notes.off_vel0), _ => String::new(), } } @@ -445,6 +453,10 @@ pub fn apply_change( cfg.midi.notes.enabled = !cfg.midi.notes.enabled; true } + 33 => { + cfg.midi.notes.off_vel0 = !cfg.midi.notes.off_vel0; + true + } 15 => { cfg.midi.cc.enabled = !cfg.midi.cc.enabled; true @@ -683,6 +695,10 @@ fn log_setting_change(idx: usize, cfg: &Config) { 30 => tracing::info!(enabled = cfg.osc.enabled, "OSC output setting changed"), 31 => tracing::info!(targets = ?cfg.osc.targets, "OSC targets changed"), 32 => tracing::info!(prefix = %cfg.osc.prefix, "OSC prefix changed"), + 33 => tracing::info!( + off_vel0 = cfg.midi.notes.off_vel0, + "Note off velocity 0 setting changed" + ), _ => {} } } diff --git a/host/src/web/mod.rs b/host/src/web/mod.rs index d5a00e7..2b87744 100644 --- a/host/src/web/mod.rs +++ b/host/src/web/mod.rs @@ -11,6 +11,8 @@ //! - `ports`: `{ports, active, connected}`, after each change. //! - `log`: `{lines}`, the new log lines. //! - `error`: `{text}`, when a command fails. +//! - `scenes`: the DMX scene settings (`autoblink_core_midi::scenes::ScenesConfig`), after each +//! change. The `scene` field of the status names the current scene. //! - `busy`: `{}`, only from the ESP32. The ESP32 sends the status to one page at a time, and //! another page has it. The page then shows a button that sends `take_over`. //! @@ -19,6 +21,9 @@ //! with `on`, it flips the switch. //! - `{type: "set", idx, value}` stores a numeric or text setting. //! - `{type: "port", name}` switches the MIDI output port. +//! - `{type: "scenes_set", ...}` stores the full DMX scene settings. The server makes them +//! 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: "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`. @@ -56,6 +61,7 @@ use crate::prolink::beat_listener::BeatEvent; use crate::prolink::discovery::DeviceTable; use crate::settings::{self, NetworkIfaceInfo, SettingKind, SETTINGS}; use crate::state::SharedState; +use autoblink_core_midi::scenes::ScenesConfig; /// The page. The host, the emulator and the ESP32 serve the same file. pub const PAGE: &str = include_str!("../../../webui/index.html"); @@ -89,6 +95,7 @@ struct Shared { status_tx: watch::Sender>, settings_tx: watch::Sender>, ports_tx: watch::Sender>, + scenes_tx: watch::Sender>, rescan: Notify, } @@ -181,7 +188,8 @@ fn start(ctx: WebCtx) -> Arc { ports: RwLock::new(Vec::new()), status_tx: watch::channel(empty.clone()).0, settings_tx: watch::channel(empty.clone()).0, - ports_tx: watch::channel(empty).0, + ports_tx: watch::channel(empty.clone()).0, + scenes_tx: watch::channel(empty).0, rescan: Notify::new(), }); let s = Arc::clone(&shared); @@ -283,6 +291,8 @@ fn produce_once(shared: &Shared) { tagged("settings", &json!({ "items": items })), ); + send_if_changed(&shared.scenes_tx, tagged("scenes", &cfg.scenes)); + let ports = shared.ports.read().clone(); send_if_changed( &shared.ports_tx, @@ -485,6 +495,12 @@ enum Command { index: Option, }, Rescan, + #[serde(rename = "scenes_set")] + ScenesSet(ScenesConfig), + #[serde(rename = "scene_test")] + SceneTest { + preset: u8, + }, } async fn send(socket: &mut WebSocket, msg: &str) -> bool { @@ -500,7 +516,7 @@ async fn client(mut socket: WebSocket, shared: Arc) { "proto": 1, "version": env!("CARGO_PKG_VERSION"), "device": "host", - "caps": ["beat", "decks", "mixer", "output", "settings", "ports", "logs", "waveform"], + "caps": ["beat", "decks", "mixer", "output", "settings", "ports", "logs", "waveform", "scenes"], }); if !send(&mut socket, &hello.to_string()).await { return; @@ -509,12 +525,13 @@ async fn client(mut socket: WebSocket, shared: Arc) { let mut status_rx = shared.status_tx.subscribe(); let mut settings_rx = shared.settings_tx.subscribe(); let mut ports_rx = shared.ports_tx.subscribe(); + let mut scenes_rx = shared.scenes_tx.subscribe(); let mut beat_rx = shared.ctx.beat_tx.subscribe(); let mut log_seq = 0; // The producer skips its work while no client is connected, so build fresh messages now. produce_once(&shared); - for rx in [&mut settings_rx, &mut ports_rx] { + for rx in [&mut settings_rx, &mut ports_rx, &mut scenes_rx] { let msg = rx.borrow_and_update().clone(); if !send(&mut socket, &msg).await { return; @@ -549,6 +566,12 @@ async fn client(mut socket: WebSocket, shared: Arc) { } ports_rx.borrow_and_update().clone() } + r = scenes_rx.changed() => { + if r.is_err() { + return; + } + scenes_rx.borrow_and_update().clone() + } beat = beat_rx.recv() => { let (device, beat) = match beat { Ok(BeatEvent::Beat { packet, .. }) => (packet.device_number, packet.beat_in_bar), @@ -609,6 +632,36 @@ async fn handle_command(shared: &Shared, text: &str) -> Option { shared.rescan.notify_one(); None } + Command::ScenesSet(mut scenes) => { + scenes.sanitize(); + let path = { + let mut cfg = ctx.cfg.write(); + cfg.scenes = scenes.clone(); + cfg.scenes_path.clone() + }; + let path = path?; + // The file write is a blocking call. + let saved = + tokio::task::spawn_blocking(move || crate::config::save_scenes(&path, &scenes)) + .await; + match saved { + Ok(Ok(())) => None, + Ok(Err(e)) => Some(format!("Cannot store the scenes: {e}")), + Err(e) => Some(format!("Cannot store the scenes: {e}")), + } + } + Command::SceneTest { preset } => { + let (ch, note) = { + let cfg = ctx.cfg.read(); + (cfg.scenes.channel, cfg.scenes.note(preset)) + }; + use crate::midi::MidiTransport; + let _ = ctx.midi_out.send_message(&[0x90 | ch, note, 127]); + // 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]); + None + } } } @@ -818,6 +871,39 @@ mod tests { assert_eq!(beat["beat"], 2); } + #[tokio::test(flavor = "multi_thread")] + async fn scenes_set_stores_valid_scenes_in_the_file() { + let ctx = ctx(); + let cfg = Arc::clone(&ctx.cfg); + let dir = std::env::temp_dir().join(format!("xdj-scenes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("scenes.json"); + cfg.write().scenes_path = Some(path.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(&"scenes".into())); + let scenes = next_of_type(&mut ws, "scenes").await; + assert_eq!(scenes["enabled"], false); + + ws.send(WsMessage::Text( + r#"{"type":"scenes_set","enabled":true,"count":15,"labels":["Strobe"], + "rows":[{"event":"drop","presets":[4,40],"gate":"hold"}]}"# + .into(), + )) + .await + .unwrap(); + wait_for(&mut ws, "scenes", |v| v["enabled"] == true).await; + let stored = crate::config::load_scenes(&path); + assert_eq!(stored.rows[0].presets, [4]); + assert_eq!(cfg.read().scenes, stored); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[tokio::test(flavor = "multi_thread")] async fn waveform_route_gives_the_overview_of_a_deck() { let ctx = ctx(); diff --git a/host/src/web/status.rs b/host/src/web/status.rs index 89d1e49..f5c8cd6 100644 --- a/host/src/web/status.rs +++ b/host/src/web/status.rs @@ -32,6 +32,8 @@ pub struct Status { /// The XDJ-AZ mixer. `None` until a mixer control moves. pub mixer: Option>, pub output: Output, + /// The current DMX scene. + pub scene: autoblink_core_midi::scenes::SceneStatus, } #[derive(Debug, Serialize)] @@ -316,6 +318,7 @@ pub fn snapshot( link_peers, link_active, mixer, + scene: act.scene, output: Output { port: cfg.midi.output.clone(), connected: out_link.connected, diff --git a/webui/index.html b/webui/index.html index 8c7d35e..3524f98 100644 --- a/webui/index.html +++ b/webui/index.html @@ -137,6 +137,60 @@ .port{text-align:left;display:flex;gap:8px} .port.active{border-color:var(--play)} .port .mark{width:10px;color:var(--play)} +/* DMX scenes */ +#p-scenes .scfg{display:flex;flex-wrap:wrap;gap:6px 18px;align-items:center;color:var(--muted);margin-bottom:12px} +#p-scenes .scfg label{display:flex;gap:6px;align-items:center} +#p-scenes .hint{color:var(--dim);font-size:11px;margin-left:auto} +#p-scenes select{background:#1c1c2c;color:var(--text);border:1px solid var(--border);border-radius:5px;padding:1px 4px;font:inherit;font-size:12px} +#p-scenes input.num{width:52px} +.palette{display:flex;flex-wrap:wrap;gap:6px;padding:10px;background:#0d0d16;border:1px solid var(--border);border-radius:8px;margin-bottom:12px} +.chip{display:inline-flex;flex-direction:column;align-items:flex-start;min-width:64px;padding:4px 8px 5px;border-radius:6px;background:#10303a;border:1px solid #0f5866;cursor:grab;user-select:none;-webkit-user-select:none;touch-action:none;position:relative} +.chip b{font-size:15px;color:var(--accent);line-height:1.1} +.chip small{font-size:10px;color:var(--text);opacity:.85;white-space:nowrap;min-height:1.2em} +.chip i{position:absolute;top:3px;right:6px;font-style:normal;font-size:9px;color:var(--dim)} +.chip.extra{background:#24163a;border-color:#5b3a8a} +.chip.extra b{color:#c9a0ff} +.addnote{display:inline-flex;gap:4px;align-items:center;padding:4px 6px;border:1px dashed var(--dim);border-radius:6px} +.addnote input.num{width:46px} +.addnote input.name{width:90px;text-align:left} +.addnote .err{color:var(--danger);font-size:11px} +.chip.sel{outline:2px solid var(--flash);outline-offset:1px} +.chip.mini{min-width:0;flex-direction:row;gap:5px;align-items:baseline;padding:3px 7px} +.chip.mini b{font-size:13px} +.chip.mini small{min-height:0} +.chip .x{font-style:normal;color:var(--dim);font-size:12px;margin-left:2px;cursor:pointer;padding:0 2px} +.chip.live{background:var(--flash);border-color:var(--flash)} +.chip.live b,.chip.live small,.chip.live .x{color:#000} +.chip.next{border:1px dashed var(--flash)} +.ghost{position:fixed;z-index:10;pointer-events:none;display:flex;gap:4px;transform:rotate(-4deg);box-shadow:0 6px 18px rgba(0,0,0,.6)} +.slots{display:grid;grid-template-columns:150px minmax(0,1fr) auto;gap:6px 12px;align-items:center} +.grp{grid-column:1/-1;display:flex;align-items:baseline;gap:8px;margin:12px 0 2px;padding-bottom:4px;border-bottom:1px solid var(--border);font-size:11px;font-weight:700;letter-spacing:1.4px;text-transform:uppercase;color:var(--text);cursor:pointer;user-select:none} +.grp:first-child{margin-top:0} +.grp::before{content:'▾';color:var(--dim)} +.grp.closed::before{content:'▸'} +.grp span{font-weight:400;letter-spacing:0;text-transform:none;color:var(--dim)} +.ev{padding-left:14px;font-size:11px;text-transform:uppercase;letter-spacing:1.1px;color:var(--muted);font-weight:600} +.ev small{display:block;font-size:9px;font-weight:400;letter-spacing:.3px;text-transform:none;color:var(--dim);margin-top:1px} +.ev.on{color:var(--flash)} +.ev{border-radius:4px;padding:3px 6px 3px 14px} +.ev.hit{animation:evhit .35s ease-out} +@keyframes evhit{from{color:var(--flash);text-shadow:0 0 8px var(--flash)}} +.ev.cur{background:rgba(255,208,0,.16);color:var(--flash);box-shadow:inset 3px 0 0 var(--flash)} +.ev.cur small{color:var(--muted)} +.drop{display:flex;flex-wrap:wrap;gap:5px;min-height:34px;padding:4px 6px;border:1px solid var(--border);border-radius:6px;background:#0d0d16;align-items:center} +.drop.empty::after{content:'drop a preset';color:var(--dim);font-size:11px} +.drop.active{border-color:var(--flash);box-shadow:0 0 10px rgba(255,208,0,.35)} +.drop.target{border:1px dashed var(--accent);background:#0c1d24} +.drop .info{color:var(--dim);font-size:11px} +.ctl{display:flex;gap:6px;align-items:center;color:var(--muted);font-size:12px;white-space:nowrap} +.ctl.dim{opacity:.4} +.seg{display:inline-flex;border:1px solid var(--border);border-radius:5px;overflow:hidden} +.seg button{border:0;border-radius:0;border-right:1px solid var(--border);color:var(--muted)} +.seg button:last-child{border-right:0} +.seg button.on{background:#0a5a6a;color:var(--text)} +.ctl .sep{width:1px;height:16px;background:var(--border)} +.legend{margin-top:12px;display:flex;flex-wrap:wrap;gap:4px 20px;color:var(--dim);font-size:11px} +.legend b{color:var(--muted);font-weight:500} /* logs */ .log{background:#08080d;border-radius:6px;padding:8px;height:200px;overflow-y:auto;font-size:11px;line-height:1.55;white-space:pre-wrap;word-break:break-all} .log .ERROR{color:var(--danger)}.log .WARN{color:var(--warn)}.log .DEBUG,.log .TRACE{color:var(--dim)} @@ -205,6 +259,28 @@

Deck Information

+
+

DMX Scenes

+
+ + + + + + Drag a preset onto an event · Shift-click for a range · Click a preset to test it · Double-click to rename · Add a note outside the range at the end of the presets +
+
+
+
+ on trigger next preset each time the event fires + every N bars next preset every N bars while the scene is on + ↑ ⇄ 🎲 up · ping-pong · random + pulse note on and off at once + hold note stays on until the next scene + flash note goes off after the decay +
+
+

Settings

@@ -253,6 +329,11 @@

System

const set = (id, text) => { const e = $(id); if (e.textContent !== text) e.textContent = text; }; const pad2 = (n) => String(n).padStart(2, '0'); +// A page from the computer of a developer shows a box with "?box=
". A change to this +// file then needs only a reload, not a flash. +const BOX = new URLSearchParams(location.search).get('box'); +const BASE = BOX ? 'http://' + BOX : ''; + let ws = null, backoff = 500, caps = null, status = null, beatCap = false, lastBeat = 0, toastTimer = 0; function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } @@ -302,6 +383,7 @@

System

} if (s.output) renderOutput(s.output); + if (scenes) renderSceneLive(s.scene); if (s.decks) renderDecks(s); if (s.ota) renderOta(s.ota); const up = s.system && s.system.uptime_s; @@ -364,7 +446,7 @@

System

const m = s.master; const d = (s.decks || []).find((x) => x.num === m.device); const p = d && d.phrase; - set('pkind', p ? p.kind : (d && d.phrase_note) || (m.device ? 'no phrase info' : '—')); + set('pkind', p ? bucket(p.kind) : (d && d.phrase_note) || (m.device ? 'no phrase info' : '—')); $('pkind').classList.toggle('none', !p); $('pfill').hidden = !(p && p.fill); $('pprog').style.width = (p ? p.progress * 100 : 0) + '%'; @@ -441,7 +523,7 @@

System

let phrase = null; if (d.phrase) { const bar = h('i'); bar.style.width = (d.phrase.progress * 100) + '%'; - phrase = h('div', 'phrase', bar, h('span', 'num', d.phrase.kind + ' ' + Math.floor(d.phrase.progress * 100) + '%' + (d.phrase.fill ? ' [FILL]' : ''))); + phrase = h('div', 'phrase', bar, h('span', 'num', bucket(d.phrase.kind) + ' ' + Math.floor(d.phrase.progress * 100) + '%' + (d.phrase.fill ? ' [FILL]' : ''))); } const w = caps && caps.has('waveform') && (d.wave != null || d.track_len_ms != null) ? waveFor(d) : null; return h('div', 'deck' + (d.master ? ' master' : d.virtual_master ? ' vmaster' : ''), @@ -472,8 +554,11 @@

System

// The waveform of each deck: {canvas, id, img, cues, phrases}. `id` is the rekordbox ID of `img`. const waves = new Map(); -// The phrase lane colours, by the first word of the phrase kind. -const PHRASE_COLOURS = { INTRO: '#3f6ea8', VERSE: '#7357a8', BRIDGE: '#a8853a', CHORUS: '#b5425a', UP: '#38996d', DOWN: '#357fa3', OUTRO: '#3f6ea8' }; +// The page shows the rekordbox phrase kinds as the buckets of the DMX scenes (`Event::bucket`). +const BUCKETS = { INTRO: 'INTRO', VERSE: 'GROOVE', BRIDGE: 'BUILD', UP: 'BUILD', CHORUS: 'DROP', DOWN: 'BREAK', OUTRO: 'OUTRO' }; +const bucket = (kind) => BUCKETS[kind.split(' ')[0]] || kind; +// The phrase lane colours, by bucket. +const PHRASE_COLOURS = { INTRO: '#3f6ea8', GROOVE: '#7357a8', BUILD: '#a8853a', DROP: '#b5425a', BREAK: '#357fa3', OUTRO: '#3f6ea8' }; function waveFor(d) { let w = waves.get(d.num); @@ -483,7 +568,7 @@

System

w.canvas.classList.remove('lane'); if (w.id != null) { const want = w.id; - fetch('/waveform/' + d.num).then((r) => (r.ok ? r.json() : null)).then((j) => { + fetch(BASE + '/waveform/' + d.num).then((r) => (r.ok ? r.json() : null)).then((j) => { if (!j || j.rekordbox_id !== want || w.id !== want) return; w.img = j.preview && j.preview.length ? renderPreview(j.preview) : null; w.cues = j.cues || []; @@ -526,11 +611,11 @@

System

const x0 = x(p.start), x1 = x(p.end), wd = Math.max(x1 - x0 - dpr, 1); const now = playhead != null && playhead >= p.start && playhead < p.end; g.globalAlpha = now ? 1 : 0.6; - g.fillStyle = PHRASE_COLOURS[p.kind.split(' ')[0]] || '#555'; + const label = bucket(p.kind); + g.fillStyle = PHRASE_COLOURS[label] || '#555'; g.fillRect(x0, y + dpr, wd, hgt - dpr); if (p.fill != null) { g.fillStyle = 'rgba(255,255,255,.25)'; g.fillRect(x(p.fill), y + dpr, Math.max(x1 - x(p.fill) - dpr, 1), hgt - dpr); } g.globalAlpha = 1; - const label = p.kind.replace('VERSE ', 'V'); if (g.measureText(label).width + 6 * dpr < wd) { g.fillStyle = '#fff'; g.fillText(label, x0 + 3 * dpr, y + hgt / 2 + dpr / 2); } } } @@ -662,6 +747,7 @@

System

'Phrase Kind CC': 'The phrase kind of the master deck times 10: Intro 0, Verse 10–60, Bridge 70, Chorus 80, Up 90, Down 100, Outro 110.', 'Phrase Progress CC': 'The position in the current phrase of the master deck: 0 at the start, 127 at the end.', 'Beats Left CC': 'The number of beats to the end of the current phrase of the master deck. The maximum is 127.', + 'Note Off Velocity 0': 'Sends each note off as a note on with velocity 0. MIDI makes the two the same, but some DMX controllers ignore the 0x80 note off. This applies to the MIDI notes and the DMX scenes. The test click of a DMX preset always uses 0x80.', 'MTC Enabled': 'Sends MIDI Timecode on the output port.', 'MTC Frame Rate': 'The MTC frame rate: 24, 25 or 30 fps. The receiver must use the same rate.', 'OSC Enabled': 'Sends OSC messages to the targets below.', @@ -674,6 +760,9 @@

System

// Settings rows are built once per index, then updated in place, so that an input keeps its focus. // Each section is a group that the columns of #sgrid do not split. const setRows = new Map(); +// A row with a section goes into the group of that section. Thus a row that the server adds at +// the end of its table (index 33) can join an earlier group. +const setGroups = new Map(); let group = null, groupName = null; function onSettings(m) { for (const it of m.items) { @@ -685,10 +774,14 @@

System

else if (it.idx >= MIDI_SETTINGS_START) { if (!group || (it.section && it.section !== groupName)) { groupName = it.section || 'MIDI'; - const head = h('div', 'section', groupName); - head.title = HELP[groupName] || ''; - group = h('div', 'group', head); - $('sgrid').append(group); + group = setGroups.get(groupName); + if (!group) { + const head = h('div', 'section', groupName); + head.title = HELP[groupName] || ''; + group = h('div', 'group', head); + setGroups.set(groupName, group); + $('sgrid').append(group); + } } parent = group; } @@ -804,11 +897,424 @@

System

set('conn', 'waiting'); } -const handlers = { hello: onHello, status: onStatus, settings: onSettings, ports: onPorts, log: onLog, beat: onBeat, busy: onBusy, error: (m) => { otaDone(); toast(m.text); } }; +// ── DMX scenes ────────────────────────────────────────────────────────────── +// The server sends the scene settings as "scenes" and the current scene in the "scene" field of +// the status. Each edit sends the full settings back as "scenes_set". The server drops the rows +// without presets, so the page keeps the settings of an empty row itself. +const SCENE_GROUPS = [ + ['Phrases', 'rekordbox phrase analysis in 6 buckets · fires at the phrase start', null, [ + ['intro', 'Intro', 'rekordbox Intro'], ['groove', 'Groove', 'Verse 1–6'], ['build', 'Build', 'Up, Bridge'], + ['drop', 'Drop', 'Chorus'], ['break', 'Break', 'Down'], ['outro', 'Outro', 'rekordbox Outro']]], + ['Rhythm', 'beat grid of the master deck · one per beat: Fill-in › Phrase change › Downbeat › Beat', null, [ + ['beat', 'Beat', 'every beat'], ['downbeat', 'Downbeat', 'beat 1 of each bar'], + ['phrase_change', 'Phrase change', 'any new phrase'], ['fill', 'Fill-in', 'end of a phrase']]], + ['Transport', 'master deck state', null, [ + ['stop', 'Stop', 'master stops'], ['play', 'Play', 'master starts'], + ['track_load', 'Track load', 'new track on master'], ['master_change', 'Master change', 'other deck is master']]], + ['Mixer', 'XDJ-AZ mixer, USB MIDI', 'mixer', [ + ['bass_cut', 'Bass cut', 'low EQ down on all live channels'], ['bass_return', 'Bass return', 'low EQ back up']]], +]; +const ADVANCE = [1, 2, 4, 8, 16, 32]; +// The order of the "events" counters in the status (`Event::ALL` in autoblink-core-midi). +// The first 6 are the phrase buckets. They do not blink: the row of the current phrase is lit. +const SCENE_EVENTS = ['intro', 'groove', 'build', 'drop', 'break', 'outro', 'beat', 'downbeat', 'phrase_change', + 'fill', 'stop', 'play', 'track_load', 'master_change', 'bass_cut', 'bass_return']; +let sceneEvents = null; +const DECAY_BEATS = [[0.25, '¼'], [0.5, '½'], [1, '1'], [2, '2'], [4, '4'], [8, '8']]; +const ROW_MAX = 16; +const EXTRA_BASE = 32; // a row entry of 32 and up is scenes.extra[entry - 32] +let scenes = null, sceneSel = new Set(), sceneAnchor = null, scenePending = false; +const sceneDraft = {}; // the settings of the rows without presets +const sceneRowEls = new Map(); + +const SCENE_RHYTHM = ['beat', 'downbeat', 'phrase_change', 'fill']; + +/** The defaults of a new row: a phrase row steps every 2 bars, a rhythm row holds its note. */ +function sceneRow(ev) { + const phrase = SCENE_EVENTS.indexOf(ev) < 6; + return scenes.rows.find((r) => r.event === ev) + || sceneDraft[ev] || (sceneDraft[ev] = { + event: ev, presets: [], advance: phrase ? 2 : 0, order: 'up', + gate: SCENE_RHYTHM.includes(ev) ? 'hold' : 'pulse', decay: { beats: 0.5 } }); +} + +function sceneSave() { + scenes.rows = SCENE_GROUPS.flatMap((g) => g[3]).map(([ev]) => sceneRow(ev)).filter((r) => r.presets.length); + send(Object.assign({ type: 'scenes_set' }, scenes)); + renderScenes(); +} + +/** Changes a row. A row that gets presets moves from the draft into the settings. */ +function sceneEdit(ev, f) { + const r = sceneRow(ev); + f(r); + r.presets = r.presets.slice(0, ROW_MAX); + if (r.presets.length && !scenes.rows.includes(r)) { scenes.rows.push(r); delete sceneDraft[ev]; } + if (!r.presets.length) { scenes.rows = scenes.rows.filter((x) => x !== r); sceneDraft[ev] = r; } + sceneSave(); +} + +function onScenes(m) { + delete m.type; + scenes = m; + // Do not rebuild under an input that has the focus. + if ($('p-scenes').contains(document.activeElement) && document.activeElement.tagName === 'INPUT') scenePending = true; + else renderScenes(); +} + +function sceneChip(i, mini) { + const x = i >= EXTRA_BASE ? scenes.extra[i - EXTRA_BASE] : null; + const note = x ? x.note : scenes.first_note + i; + const label = x ? x.label : (scenes.labels[i] || ''); + return h('span', 'chip' + (mini ? ' mini' : '') + (x ? ' extra' : ''), + !mini && h('i', null, 'n' + note), h('b', null, x ? '♪' + note : String(i + 1)), h('small', null, label)); +} + +/** The form at the end of the palette: a note outside the range, with an optional name. */ +function sceneAddForm() { + const note = h('input', 'num'); + note.inputMode = 'numeric'; + note.placeholder = 'note'; + note.title = 'A MIDI note from 0 to 127.'; + const name = h('input', 'num name'); + name.placeholder = 'name'; + name.maxLength = 12; + const err = h('span', 'err'); + const add = h('button', null, '+ Note'); + add.title = 'Add a MIDI note outside the range of the presets.'; + const commit = () => { + const v = note.value.trim(); + const n = /^\d{1,3}$/.test(v) ? +v : -1; + if (n < 0 || n > 127) { note.classList.add('bad'); err.textContent = 'note 0–127'; note.focus(); return; } + if (scenes.extra.length >= 16) { err.textContent = '16 notes is the maximum'; return; } + scenes.extra.push({ note: n, label: name.value.trim().slice(0, 12) }); + note.blur(); name.blur(); + sceneSave(); + }; + add.addEventListener('click', commit); + for (const i of [note, name]) { + i.addEventListener('keydown', (e) => { if (e.key === 'Enter') commit(); }); + i.addEventListener('input', () => { note.classList.remove('bad'); err.textContent = ''; }); + } + return h('span', 'addnote', note, name, add, err); +} + +/** Removes an extra note, and moves the row entries of the extra notes after it. */ +function sceneRemoveExtra(k) { + const gone = EXTRA_BASE + k; + for (const r of [...scenes.rows, ...Object.values(sceneDraft)]) { + r.presets = r.presets.filter((p) => p !== gone).map((p) => (p > gone ? p - 1 : p)); + } + scenes.extra.splice(k, 1); + sceneSave(); +} + +function seg(options, value, onPick) { + return h('span', 'seg', ...options.map(([v, text, title]) => { + const b = h('button', v === value ? 'on' : null, text); + if (title) b.title = title; + b.addEventListener('click', () => onPick(v)); + return b; + })); +} + +function renderScenes() { + if (!scenes) return; + scenePending = false; + set('scon', scenes.enabled ? 'ON' : 'OFF'); + $('scon').style.color = scenes.enabled ? 'var(--play)' : 'var(--danger)'; + set('scone', scenes.one_at_a_time ? 'ON' : 'OFF'); + $('scone').style.color = scenes.one_at_a_time ? 'var(--play)' : 'var(--muted)'; + for (const [id, v] of [['scch', scenes.channel + 1], ['scfirst', scenes.first_note], ['sccount', scenes.count]]) { + if (document.activeElement !== $(id)) $(id).value = v; + } + scenes.extra = scenes.extra || []; + for (const i of [...sceneSel]) if (i >= scenes.count && i - EXTRA_BASE >= scenes.extra.length) sceneSel.delete(i); + + const chips = []; + for (let i = 0; i < scenes.count; i++) { + const c = sceneChip(i, false); + c.classList.toggle('sel', sceneSel.has(i)); + c.title = 'Note ' + (scenes.first_note + i) + '. Click to test it.'; + sceneDrag(c, { from: 'palette', idx: i }); + chips.push(c); + } + scenes.extra.forEach((x, k) => { + const c = sceneChip(EXTRA_BASE + k, false); + c.classList.toggle('sel', sceneSel.has(EXTRA_BASE + k)); + c.title = 'Note ' + x.note + '. Click to test it.'; + const del = h('em', 'x', '×'); + del.title = 'Remove this note'; + del.style.position = 'absolute'; del.style.bottom = '2px'; del.style.right = '4px'; + del.addEventListener('pointerdown', (e) => e.stopPropagation()); + del.addEventListener('click', () => sceneRemoveExtra(k)); + c.append(del); + sceneDrag(c, { from: 'palette', idx: EXTRA_BASE + k }); + chips.push(c); + }); + chips.push(sceneAddForm()); + $('palette').replaceChildren(...chips); + + const closed = sceneClosed(); + const out = []; + sceneRowEls.clear(); + for (const [name, note, cap, events] of SCENE_GROUPS) { + if (cap && caps && !caps.has(cap)) continue; + const head = h('div', 'grp' + (closed.has(name) ? ' closed' : ''), name, h('span', null, note)); + head.addEventListener('click', () => { + closed.has(name) ? closed.delete(name) : closed.add(name); + try { localStorage.setItem('autoblink.scenes.closed', JSON.stringify([...closed])); } catch { /* ignore */ } + renderScenes(); + }); + out.push(head); + if (closed.has(name)) continue; + for (const [ev, label, sub] of events) out.push(...sceneRowView(ev, label, sub)); + } + $('slots').replaceChildren(...out); + if (status) renderSceneLive(status.scene); +} + +function sceneClosed() { + try { + const saved = localStorage.getItem('autoblink.scenes.closed'); + if (saved) return new Set(JSON.parse(saved)); + } catch { /* storage blocked */ } + return new Set(['Mixer']); +} + +function sceneRowView(ev, label, sub) { + const r = sceneRow(ev); + const name = h('div', 'ev', label, h('small', null, sub)); + const drop = h('div', 'drop' + (r.presets.length ? '' : ' empty')); + drop.dataset.ev = ev; + r.presets.forEach((p, pos) => { + const c = sceneChip(p, true); + const x = h('em', 'x', '×'); + x.title = 'Remove'; + x.addEventListener('pointerdown', (e) => e.stopPropagation()); + x.addEventListener('click', () => sceneEdit(ev, (r) => r.presets.splice(pos, 1))); + c.append(x); + sceneDrag(c, { from: 'row', ev, pos }); + drop.append(c); + }); + const info = h('span', 'info'); + drop.append(info); + // Tap fallback: with a selection in the palette, a click on a row adds it. + drop.addEventListener('click', (e) => { + if (e.target !== drop || !sceneSel.size) return; + const add = [...sceneSel].sort((a, b) => a - b); + sceneSel.clear(); + sceneEdit(ev, (r) => r.presets.push(...add)); + }); + + const parts = [seg([[false, 'on trigger', 'The next preset each time the event fires.'], [true, 'every', 'The next preset every N bars.']], + r.advance > 0, (every) => sceneEdit(ev, (r) => { r.advance = every ? (r.advance || 4) : 0; }))]; + if (r.advance > 0) { + const sel = h('select', null, ...ADVANCE.map((n) => h('option', null, n + (n === 1 ? ' bar' : ' bars')))); + sel.selectedIndex = Math.max(0, ADVANCE.indexOf(r.advance)); + sel.addEventListener('change', () => sceneEdit(ev, (r) => { r.advance = ADVANCE[sel.selectedIndex]; })); + parts.push(sel); + } + if (r.presets.length > 1) { + parts.push(seg([['up', '↑', 'Up'], ['ping_pong', '⇄', 'Ping-pong'], ['random', '🎲', 'Random']], r.order, + (o) => sceneEdit(ev, (r) => { r.order = o; }))); + } + parts.push(h('span', 'sep')); + const gates = SCENE_RHYTHM.includes(ev) ? [['hold', 'hold'], ['pulse', 'pulse'], ['flash', 'flash']] + : [['pulse', 'pulse'], ['hold', 'hold'], ['flash', 'flash']]; + parts.push(seg(gates, r.gate, (g) => sceneEdit(ev, (r) => { + r.gate = g; + // A flash starts with half a beat. The old default of 250 ms changes too. + if (g === 'flash' && r.decay.ms === 250) r.decay = { beats: 0.5 }; + }))); + if (r.gate === 'flash') parts.push(...sceneDecay(ev, r)); + const ctl = h('div', 'ctl' + (r.presets.length ? '' : ' dim'), ...parts); + sceneRowEls.set(ev, { name, drop, info }); + return [name, drop, ctl]; +} + +/** The decay of a flash: milliseconds in an input, or beats in a list. */ +function sceneDecay(ev, r) { + const beats = 'beats' in r.decay; + const unit = h('select', null, h('option', null, 'ms'), h('option', null, 'beat')); + unit.selectedIndex = beats ? 1 : 0; + unit.addEventListener('change', () => sceneEdit(ev, (r) => { r.decay = unit.selectedIndex ? { beats: 0.5 } : { ms: 250 }; })); + if (beats) { + const v = h('select', null, ...DECAY_BEATS.map(([, t]) => h('option', null, t))); + v.selectedIndex = Math.max(0, DECAY_BEATS.findIndex(([b]) => b === r.decay.beats)); + v.addEventListener('change', () => sceneEdit(ev, (r) => { r.decay = { beats: DECAY_BEATS[v.selectedIndex][0] }; })); + return [v, unit]; + } + const v = h('input', 'num'); + v.inputMode = 'numeric'; + v.value = r.decay.ms; + v.addEventListener('change', () => { + const ms = parseInt(v.value, 10); + if (ms > 0) sceneEdit(ev, (r) => { r.decay = { ms }; }); + }); + return [v, unit]; +} + +/** Shows the current scene: the row, the preset that is on and the next preset. */ +function renderSceneLive(sc) { + // The title of a row blinks when its event occurs, with or without presets in the row. + const events = sc && sc.events; + if (events && sceneEvents) { + SCENE_EVENTS.forEach((ev, i) => { + const el = sceneRowEls.get(ev); + if (i < 6 || !el || events[i] === sceneEvents[i]) return; + el.name.classList.remove('hit'); + void el.name.offsetWidth; // restart the animation + el.name.classList.add('hit'); + }); + } + sceneEvents = events || null; + for (const ev of SCENE_EVENTS.slice(0, 6)) { + const el = sceneRowEls.get(ev); + if (el) el.name.classList.toggle('cur', !!sc && sc.phrase === ev); + } + for (const [ev, el] of sceneRowEls) { + const on = !!sc && sc.row === ev && scenes.enabled; + el.name.classList.toggle('on', on); + el.drop.classList.toggle('active', on); + // Each row shows the preset that it sent last. The current scene also shows the next one. + const last = sc && sc.last ? sc.last[SCENE_EVENTS.indexOf(ev)] : null; + [...el.drop.querySelectorAll('.chip')].forEach((c, i) => { + c.classList.toggle('live', scenes.enabled && (on ? sc.step === i : last === i)); + c.classList.toggle('next', on && sc.next === i && sc.next !== sc.step); + }); + const r = sceneRow(ev); + const text = on && r.presets.length > 1 + ? '↻ step ' + (sc.step + 1) + '/' + r.presets.length + (sc.bars_left != null ? ' · next in ' + sc.bars_left + (sc.bars_left === 1 ? ' bar' : ' bars') : '') + : ''; + if (el.info.textContent !== text) el.info.textContent = text; + } +} + +/** Drag with pointer events, so that a touch screen works too. A click without a move is a click. */ +function sceneDrag(chip, src) { + chip.addEventListener('pointerdown', (down) => { + if (down.button !== 0) return; + let ghost = null, target = null; + const move = (e) => { + if (!ghost && Math.hypot(e.clientX - down.clientX, e.clientY - down.clientY) < 6) return; + if (!ghost) { + const list = src.from === 'palette' + ? (sceneSel.has(src.idx) ? [...sceneSel].sort((a, b) => a - b) : [src.idx]) + : [sceneRow(src.ev).presets[src.pos]]; + src.presets = list; + ghost = h('div', 'ghost', ...list.map((i) => { const c = sceneChip(i, true); c.classList.add('sel'); return c; })); + document.body.append(ghost); + } + ghost.style.left = (e.clientX + 8) + 'px'; + ghost.style.top = (e.clientY + 8) + 'px'; + const t = document.elementFromPoint(e.clientX, e.clientY); + const d = t && t.closest('.drop'); + if (target !== d) { if (target) target.classList.remove('target'); target = d; if (d) d.classList.add('target'); } + }; + const up = (e) => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + window.removeEventListener('pointercancel', up); + if (target) target.classList.remove('target'); + if (!ghost) { if (e.type === 'pointerup') sceneClick(src, e); return; } + ghost.remove(); + sceneDrop(src, target, e); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + window.addEventListener('pointercancel', up); + }); +} + +function sceneRename(i) { + const x = i >= EXTRA_BASE ? scenes.extra[i - EXTRA_BASE] : null; + const name = prompt(x ? 'Name of note ' + x.note : 'Name of preset ' + (i + 1), x ? x.label : scenes.labels[i] || ''); + if (name === null) return; + if (x) { x.label = name.trim().slice(0, 12); sceneSave(); return; } + while (scenes.labels.length <= i) scenes.labels.push(''); + scenes.labels[i] = name.trim().slice(0, 12); + sceneSave(); +} + +// A click rebuilds the palette, so the browser sees no "dblclick". The page finds it itself. +let sceneLastClick = null; + +function sceneClick(src, e) { + if (src.from !== 'palette') return; + const i = src.idx; + const now = Date.now(); + if (!e.shiftKey && sceneLastClick && sceneLastClick.i === i && now - sceneLastClick.t < 400) { + sceneLastClick = null; + sceneRename(i); + return; + } + sceneLastClick = { i, t: now }; + if (e.shiftKey && sceneAnchor !== null && (sceneAnchor < EXTRA_BASE) === (i < EXTRA_BASE)) { + sceneSel.clear(); + for (let k = Math.min(sceneAnchor, i); k <= Math.max(sceneAnchor, i); k++) sceneSel.add(k); + } else if (e.metaKey || e.ctrlKey) { + sceneSel.has(i) ? sceneSel.delete(i) : sceneSel.add(i); + sceneAnchor = i; + } else { + sceneSel = new Set(sceneSel.size === 1 && sceneSel.has(i) ? [] : [i]); + sceneAnchor = i; + send({ type: 'scene_test', preset: i }); + } + renderScenes(); +} + +function sceneDrop(src, target, e) { + const to = target && target.dataset.ev; + if (src.from === 'palette') { + if (!to) return; + sceneSel.clear(); + sceneEdit(to, (r) => r.presets.push(...src.presets)); + return; + } + const preset = sceneRow(src.ev).presets[src.pos]; + if (to === src.ev) { + // Reorder: put the chip before the chip under the pointer, or at the end. + const under = document.elementFromPoint(e.clientX, e.clientY); + const chip = under && under.closest('.drop .chip'); + const chips = [...target.querySelectorAll('.chip')]; + sceneEdit(to, (r) => { + r.presets.splice(src.pos, 1); + let at = chip ? chips.indexOf(chip) : r.presets.length; + if (chip && at > src.pos) at -= 1; + r.presets.splice(at, 0, preset); + }); + return; + } + // A chip that leaves its row moves to the other row, or goes away when it lands outside a row. + sceneEdit(src.ev, (r) => r.presets.splice(src.pos, 1)); + if (to) sceneEdit(to, (r) => r.presets.push(preset)); +} + +$('scon').addEventListener('click', () => { if (scenes) { scenes.enabled = !scenes.enabled; sceneSave(); } }); +$('scone').addEventListener('click', () => { if (scenes) { scenes.one_at_a_time = !scenes.one_at_a_time; sceneSave(); } }); +for (const [id, apply] of [ + ['scch', (v) => { scenes.channel = v - 1; }], + ['scfirst', (v) => { scenes.first_note = v; }], + ['sccount', (v) => { scenes.count = v; }], +]) { + const input = $(id); + const commit = () => { + const v = parseInt(input.value, 10); + if (!scenes || Number.isNaN(v)) return; + apply(v); + sceneSave(); + }; + input.addEventListener('change', commit); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') input.blur(); }); +} +$('p-scenes').addEventListener('focusout', () => setTimeout(() => { if (scenePending) renderScenes(); })); + +const handlers = { scenes: onScenes, hello: onHello, status: onStatus, settings: onSettings, ports: onPorts, log: onLog, beat: onBeat, busy: onBusy, error: (m) => { otaDone(); toast(m.text); } }; function connect() { const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'; - ws = new WebSocket(proto + location.host + '/ws'); + ws = new WebSocket(BOX ? 'ws://' + BOX + '/ws' : proto + location.host + '/ws'); ws.onopen = () => { backoff = 500; $('dot').classList.add('on');