Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
399db58
Add the design of the DMX planner
strikeout Oct 27, 2026
1b95da2
Build the DMX planner on the DMX scenes
strikeout Oct 27, 2026
0bef8c1
Add the plan for the DMX looks on the box
strikeout Oct 27, 2026
31d1660
Add a favicon to the web page
strikeout Oct 27, 2026
2a55e01
Use the light cone A as the favicon
strikeout Oct 27, 2026
2f153f0
Blink the tab icon on each master beat
strikeout Oct 27, 2026
8a7c29c
Report each fired preset of the DMX scenes
strikeout Oct 27, 2026
f6a519e
Add the show model of the DMX planner
strikeout Oct 27, 2026
ace6110
Render a DMX look into the universe
strikeout Oct 27, 2026
23fd53c
Add the DMX beat clock and the intensity effects
strikeout Oct 27, 2026
bb5fcb5
Align the DMX bar effects and the clock resync count
strikeout Oct 27, 2026
69f3819
Add the Lights engine of the DMX planner
strikeout Oct 27, 2026
4bd6723
Keep the idle look under the DMX layers
strikeout Oct 27, 2026
99acfb5
Add the show partition to the firmware
strikeout Oct 27, 2026
38328e2
Use a named subtype for the show partition
strikeout Oct 27, 2026
9b72f6d
Send the DMX looks of the scenes on IO4
strikeout Oct 27, 2026
c7c1404
Serve the DMX show and the live controls
strikeout Oct 27, 2026
34b85d2
Bound the lists of a DMX show while they are read
strikeout Oct 27, 2026
73864a8
Add the Lights panel for the DMX looks
strikeout Oct 27, 2026
658b8de
Release the strobe hold when the page goes away
strikeout Oct 27, 2026
c2d37bf
Release the strobe when the active page leaves the network
strikeout Oct 27, 2026
f8d09e4
Release the DMX looks as 1.24.0
strikeout Oct 27, 2026
1ebafe1
Fix the stop race, the strobe state and the DMX docs
strikeout Oct 27, 2026
9e60952
Hold each scene preset for its own number of beats
strikeout Oct 28, 2026
8b90d9b
Restore held entries and keep the scenes JSON small
strikeout Oct 28, 2026
4217acc
Merge the Lights panel into the scenes panel, colour the chip corners…
strikeout Oct 28, 2026
1dffced
Release the scene holds and the new Lights layout as 1.25.0
strikeout Oct 28, 2026
77e5a5b
Add the plan for the Open Fixture Library import
strikeout Oct 28, 2026
c019c08
Keep the Open Fixture Library source of a light
strikeout Oct 28, 2026
32ef51c
Fetch fixtures from the Open Fixture Library through the box
strikeout Oct 28, 2026
02fe65c
Add lights from the Open Fixture Library
strikeout Oct 28, 2026
cb95f8e
Size pixel modes of OFL lights correctly
strikeout Oct 28, 2026
f2d67ef
Add the plan for positions and shapes
strikeout Oct 28, 2026
a94e7e5
Refuse a pixel mode of unknown size
strikeout Oct 28, 2026
9d76d3b
Add the plans for the drop guess, Art-Net and the host
strikeout Oct 28, 2026
80d9a3c
Release the Open Fixture Library import as 1.26.0
strikeout Oct 28, 2026
c8cef19
Light moving heads and pixel bars from the library
strikeout Oct 28, 2026
08d2859
Add pan, tilt, positions and shapes to the DMX show
strikeout Oct 28, 2026
c422332
Move heads to positions and in beat shapes
strikeout Oct 28, 2026
22e8035
Aim a head live from the page
strikeout Oct 28, 2026
0102d53
Set positions and shapes for moving heads on the page
strikeout Oct 28, 2026
e0ec31d
Match the pixel keys of OFL and keep both aims
strikeout Oct 28, 2026
4761719
Release positions and shapes as 1.27.0
strikeout Oct 28, 2026
89614c9
Keep heads still unless a look aims them, and align shapes to the bar
strikeout Oct 28, 2026
4319f71
Start beat shapes on the downbeat
strikeout Oct 28, 2026
dfef1ea
Guess the phrase buckets from the waveform energy
strikeout Oct 28, 2026
7f8b828
Keep the beat index without bar information
strikeout Oct 28, 2026
872fb52
Fire guessed buckets on 16-beat block starts
strikeout Oct 28, 2026
c263eb7
Refire the guess after play and show the fired bucket
strikeout Oct 28, 2026
069a827
Prime the guess tests before the first step
strikeout Oct 28, 2026
5769f67
Feed the drop guess on the box and on the host
strikeout Oct 28, 2026
e97c384
Reset the guess hysteresis on a master change
strikeout Oct 28, 2026
09e0d58
Release the drop guess as 1.28.0
strikeout Oct 28, 2026
9e84989
Guess the coming block and log the guess thresholds
strikeout Oct 28, 2026
78646b6
Add the Art-Net codec and the DMX merge
strikeout Oct 28, 2026
af425fb
Refuse Art-Net frames longer than a universe
strikeout Oct 28, 2026
e83bee4
Merge Art-Net from a desk into the DMX output
strikeout Oct 28, 2026
c2c2544
Run the DMX engine on the host and send Art-Net to a node
strikeout Oct 28, 2026
c05c058
Drive the live controls from the host engine
strikeout Oct 28, 2026
dae68c8
Release the host strobe hold when its page goes away
strikeout Oct 28, 2026
d8c3bb1
Edit the box's show from the host page
strikeout Oct 28, 2026
5954f22
Keep the host page on one show endpoint at a time
strikeout Oct 28, 2026
f30bc88
Close the small follow-ups of the DMX plans
strikeout Oct 28, 2026
e4be69c
Release Art-Net and the host engine as 1.29.0
strikeout Oct 28, 2026
ed7cb17
Keep the host copy on load and document the device number rule
strikeout Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@
# The firmware has its own target directory.
/esp32/target/
/esp32/.embuild/

# Brainstorm mockups of the visual companion
.superpowers/
89 changes: 80 additions & 9 deletions AGENTS.md

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"host",
"esp32-emulator",
"crates/autoblink-core-midi",
"crates/autoblink-core-dmx",
"crates/autoblink-core-prolink",
"crates/autoblink-core-dbserver",
]
Expand All @@ -13,7 +14,7 @@ members = [
exclude = ["esp32"]

[workspace.package]
version = "1.23.0"
version = "1.29.0"
edition = "2021"
license = "Proprietary"
authors = ["AUTOBLINK team"]
Expand Down
11 changes: 11 additions & 0 deletions crates/autoblink-core-dmx/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "autoblink-core-dmx"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
autoblink-core-midi = { path = "../autoblink-core-midi" }
105 changes: 105 additions & 0 deletions crates/autoblink-core-dmx/src/artnet.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! The Art-Net ArtDmx packet: the reader and the writer.

use crate::render::Universe;
use crate::show::UNIVERSE;

/// The UDP port of Art-Net.
pub const ARTNET_PORT: u16 = 6454;

const ID: &[u8; 8] = b"Art-Net\0";
const OP_DMX: u16 = 0x5000;
const HEADER: usize = 18;

/// Returns the universe (Net << 8 | SubUni) and the data of an ArtDmx packet, or None.
pub fn parse_artdmx(packet: &[u8]) -> Option<(u16, &[u8])> {
if packet.len() < HEADER || &packet[..8] != ID {
return None;
}
if u16::from_le_bytes([packet[8], packet[9]]) != OP_DMX {
return None;
}
let len = usize::from(u16::from_be_bytes([packet[16], packet[17]]));
if len == 0 || len > UNIVERSE {
return None;
}
let data = packet.get(HEADER..HEADER + len)?;
let universe = u16::from(packet[15] & 0x7f) << 8 | u16::from(packet[14]);
Some((universe, data))
}

/// Writes an ArtDmx packet for `universe` with `data` (512 bytes) into `out`. Returns the length.
pub fn build_artdmx(sequence: u8, universe: u16, data: &Universe, out: &mut [u8; 530]) -> usize {
out[..8].copy_from_slice(ID);
out[8..10].copy_from_slice(&OP_DMX.to_le_bytes());
out[10..12].copy_from_slice(&14u16.to_be_bytes());
out[12] = sequence;
out[13] = 0;
out[14] = universe as u8;
out[15] = (universe >> 8) as u8 & 0x7f;
out[16..18].copy_from_slice(&(UNIVERSE as u16).to_be_bytes());
out[HEADER..].copy_from_slice(data);
HEADER + UNIVERSE
}

#[cfg(test)]
mod tests {
use super::*;

fn packet(universe: u16, data: &[u8]) -> Vec<u8> {
let mut p = b"Art-Net\0".to_vec();
p.extend([
0x00,
0x50,
0,
14,
0,
0,
universe as u8,
(universe >> 8) as u8,
]);
p.extend((data.len() as u16).to_be_bytes());
p.extend(data);
p
}

#[test]
fn parse_valid_artdmx() {
let p = packet(0x0102, &[1, 2, 3, 4]);
assert_eq!(parse_artdmx(&p), Some((0x0102, &[1u8, 2, 3, 4][..])));
}

#[test]
fn parse_rejects() {
let mut p = packet(0, &[1, 2]);
p[0] = b'X';
assert!(parse_artdmx(&p).is_none());
let mut p = packet(0, &[1, 2]);
p[9] = 0x21;
assert!(parse_artdmx(&p).is_none());
assert!(parse_artdmx(&packet(0, &[1, 2])[..17]).is_none());
let mut p = packet(0, &[1, 2]);
p[17] = 9;
assert!(parse_artdmx(&p).is_none());
assert!(parse_artdmx(&packet(0, &[0; 513])).is_none());
assert!(parse_artdmx(&packet(0, &[])).is_none());
assert!(parse_artdmx(&packet(0, &[0; 512])).is_some());
assert!(parse_artdmx(&packet(0, &[1])).is_some());
}

#[test]
fn build_round_trip() {
let mut data = [0u8; UNIVERSE];
data[0] = 7;
data[511] = 9;
let mut out = [0u8; 530];
let n = build_artdmx(3, 0x0102, &data, &mut out);
assert_eq!(n, 530);
let (u, d) = parse_artdmx(&out[..n]).unwrap();
assert_eq!(u, 0x0102);
assert_eq!(d, &data[..]);
assert_eq!(out[12], 3);
assert_eq!(out[10..12], [0, 14]);
assert_eq!(out[13], 0);
assert_eq!(out[16..18], [2, 0]);
}
}
161 changes: 161 additions & 0 deletions crates/autoblink-core-dmx/src/clock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! The beat clock of the DMX planner.
//!
//! On WiFi, a beat packet arrives late by up to about 300 ms, because the access
//! point holds a broadcast until the next DTIM beacon. A late packet is never
//! early. Thus the clock keeps an anchor, the predicted time of the last beat,
//! and moves it only to an earlier time.

/// Number of packets that the clock compares to find a constant lateness.
const WINDOW: usize = 8;
/// The lateness in ms above which the whole window moves the anchor.
const LATE_MS: f64 = 20.0;
/// The time in ms after the last packet at which the clock stops.
const STOP_MS: f64 = 2000.0;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Beat {
pub phase: f64,
pub beat_in_bar: u8,
pub count: u32,
pub ms_per_beat: f64,
}

#[derive(Default)]
pub struct BeatClock {
anchor_ms: Option<f64>,
count: u32,
beat_in_bar: u8,
period_ms: f64,
last_packet_ms: f64,
lateness: Vec<f64>,
}

impl BeatClock {
pub fn beat(&mut self, now_ms: u64, beat_in_bar: u8, bpm: f64) {
let t = now_ms as f64;
let old_period = self.period_ms;
self.period_ms = if bpm > 0.0 { 60000.0 / bpm } else { 500.0 };
self.last_packet_ms = t;
let Some(anchor) = self.anchor_ms else {
self.anchor_ms = Some(t);
self.count = 0;
self.beat_in_bar = beat_in_bar;
return;
};
// The prediction uses the period of the last beat, not the new one.
let period = if old_period > 0.0 {
old_period
} else {
self.period_ms
};
let predicted = anchor + period;
let expected = self.beat_in_bar % 4 + 1;
let bib_ok = self.beat_in_bar == 0 || beat_in_bar == 0 || beat_in_bar == expected;
if bib_ok && t >= predicted - period / 2.0 && t <= predicted + period {
self.count = self.count.wrapping_add(1);
if beat_in_bar != 0 {
self.beat_in_bar = beat_in_bar;
} else if self.beat_in_bar != 0 {
self.beat_in_bar = expected;
}
self.anchor_ms = Some(t.min(predicted));
self.lateness.push((t - predicted).max(0.0));
if self.lateness.len() > WINDOW {
self.lateness.remove(0);
}
if self.lateness.len() == WINDOW {
let min = self.lateness.iter().copied().fold(f64::MAX, f64::min);
if min > LATE_MS {
self.anchor_ms = self.anchor_ms.map(|a| a + min);
self.lateness.clear();
}
}
} else {
// A lost packet leaves a gap. The count goes on by the beats of the gap.
let gap = ((t - anchor) / period).round().max(1.0) as u32;
self.anchor_ms = Some(t);
self.count = self.count.wrapping_add(gap);
self.beat_in_bar = beat_in_bar;
self.lateness.clear();
}
}

/// `None` before the first beat, and 2000 ms after the last beat packet.
pub fn now(&self, now_ms: u64) -> Option<Beat> {
let anchor = self.anchor_ms?;
let t = now_ms as f64;
if t - self.last_packet_ms > STOP_MS {
return None;
}
let n = ((t - anchor) / self.period_ms).floor().max(0.0);
let phase = ((t - anchor) / self.period_ms - n).clamp(0.0, 1.0);
let n = n as u32;
let beat_in_bar = if self.beat_in_bar == 0 {
0
} else {
((u32::from(self.beat_in_bar) - 1 + n) % 4) as u8 + 1
};
Some(Beat {
phase,
beat_in_bar,
count: self.count.wrapping_add(n),
ms_per_beat: self.period_ms,
})
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn late_packets_keep_phase() {
let mut c = BeatClock::default();
for (i, late) in [0u64, 300, 100, 250, 180].iter().enumerate() {
c.beat(i as u64 * 500 + late, (i % 4) as u8 + 1, 120.0);
}
let b = c.now(2000).unwrap();
assert!(b.phase < 0.02, "{}", b.phase);
assert_eq!(b.count, 4);
}

#[test]
fn early_packet_moves_anchor() {
let mut c = BeatClock::default();
for i in 0..4u64 {
c.beat(i * 500, (i % 4) as u8 + 1, 120.0);
}
c.beat(2000 - 40, 1, 120.0);
assert_eq!(c.now(1960).unwrap().phase, 0.0);
}

#[test]
fn resync_counts_lost_beats() {
let mut c = BeatClock::default();
c.beat(0, 1, 120.0);
c.beat(500, 2, 120.0);
c.beat(1500, 4, 120.0);
let b = c.now(1500).unwrap();
assert_eq!(b.count, 3);
assert_eq!(b.beat_in_bar, 4);
}

#[test]
fn stops_after_2_s() {
let mut c = BeatClock::default();
assert!(c.now(0).is_none());
c.beat(1000, 1, 120.0);
assert!(c.now(3000).is_some());
assert!(c.now(3001).is_none());
}

#[test]
fn predicts_between_packets() {
let mut c = BeatClock::default();
c.beat(0, 4, 120.0);
let b = c.now(750).unwrap();
assert_eq!(b.count, 1);
assert_eq!(b.beat_in_bar, 1);
assert_eq!(b.phase, 0.5);
}
}
Loading
Loading