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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
# Runs scripts/check.sh before each push.
# Enable it once for each clone: git config core.hooksPath .githooks
# Skip it for one push: git push --no-verify
exec sh scripts/check.sh
45 changes: 39 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
name: CI

# A pull request gets only the quick Linux job: format, clippy and tests.
# Run `sh scripts/check.sh` before a push. It runs the same checks and the firmware build.
on:
push:
pull_request:
paths-ignore: ["**.md", "docs/**"]
push:
branches: [main]
paths-ignore: ["**.md", "docs/**"]
workflow_dispatch:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
RUSTY_LINK_VERSION: "0.2.3"

jobs:
# ── Tests (Linux) ────────────────────────────────────────────────────────────
# ── Lint and tests (Linux) ──────────────────────────────────────────────────
test:
name: Test
name: Lint and test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -54,17 +65,37 @@ jobs:

- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

# Only main writes the cache. A pull request reads the cache of main.
- name: Cache Cargo registry & build artifacts
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}

- name: Check the format
run: cargo fmt --all --check

# The firmware has its own workspace and the esp toolchain. rustfmt reads the module tree
# from main.rs, so the stable toolchain can check the format.
- name: Check the format of the firmware
run: rustfmt --check --edition 2021 esp32/build.rs esp32/src/main.rs

- name: Clippy
run: cargo clippy --locked --all-targets -- -D warnings

- name: Run tests
run: cargo test --locked

# ── Build checks (Windows + macOS) ──────────────────────────────────────────
# This job runs after a merge to main and on a manual start. It does not run for a pull request.
# The release workflow builds the release binaries for all targets.
build:
name: Build (${{ matrix.os }})
name: Check (${{ matrix.os }})
if: github.event_name != 'pull_request'
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -154,6 +185,8 @@ jobs:

- name: Cache Cargo registry & build artifacts
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}

- name: Build
run: cargo build --release --locked
- name: Check
run: cargo check --locked
20 changes: 8 additions & 12 deletions .github/workflows/esp32-ci.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
name: ESP32 CI

# The firmware build takes about 5 minutes. It runs after a merge to main and on a manual start.
# Before a push, `sh scripts/check.sh` builds the firmware with and without the DMX feature.
on:
push:
branches: [main]
paths: ["esp32/**", "crates/**", "webui/**", ".github/workflows/esp32-ci.yml"]
pull_request:
paths: ["esp32/**", "crates/**", "webui/**", ".github/workflows/esp32-ci.yml"]
workflow_dispatch:

concurrency:
group: esp32-ci-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
Expand All @@ -14,6 +19,7 @@ jobs:
esp32-build:
name: Build ESP32 firmware (WT32-ETH01)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -49,16 +55,6 @@ jobs:
esp32/target
key: esp32-${{ hashFiles('esp32/Cargo.lock', 'esp32/.cargo/config.toml', 'esp32/sdkconfig.defaults') }}

- name: Build with the DMX output
working-directory: esp32
run: cargo build --release --locked --features dmx

- name: Build
working-directory: esp32
run: cargo build --release --locked

- name: Upload firmware
uses: actions/upload-artifact@v4
with:
name: esp32-firmware
path: esp32/target/xtensa-esp32-espidf/release/autoblink-esp32
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ cargo run -p autoblink-esp32-emulator
# Tests (all crates)
cargo test --locked

# The checks of CI before a push: format, clippy, tests, and the firmware build when the branch changes it
sh scripts/check.sh # --esp32 always builds the firmware, --no-esp32 never builds it
git config core.hooksPath .githooks # once for each clone: the pre-push hook runs scripts/check.sh

# Single workspace member
cargo test -p autoblink-host
cargo build -p autoblink-esp32-emulator
Expand Down Expand Up @@ -120,7 +124,13 @@ All have `#[cfg(not(windows))]` guards around reuse_port calls.

- **`cargo test --locked`** runs tests across all crates
- No dedicated test infrastructure found (no `tests/` directory, no fixtures)
- ESP32 CI (`esp32-ci.yml`) builds the firmware when a change touches `esp32/`, `crates/` or `webui/`. No test runs on the device.
- Do the builds on your computer. GitHub runs only quick checks.
- Before a push, run `sh scripts/check.sh`. It runs the checks of CI. When the branch changes `esp32/`, `crates/` or `webui/`, it also builds the firmware with and without `dmx`.
- `ci.yml` runs for each pull request and each merge to `main`. One Linux job checks the format, runs clippy with `-D warnings` and runs the tests. A change to Markdown or `docs/` only does not start it.
- The Windows and macOS jobs of `ci.yml` run `cargo check` only after a merge to `main` or on a manual start.
- `esp32-ci.yml` builds the firmware without `dmx` only after a merge to `main` or on a manual start. No test runs on the device.
- To start a workflow on a branch, run `gh workflow run ci.yml --ref <branch>` or `gh workflow run esp32-ci.yml --ref <branch>`.
- The release workflows (`release.yml`, `esp32-release.yml`) start from a tag. They build all targets.
- Coverage gaps: packet parsing, state machine transitions, BPM smoothing, MIDI clock transport

## ESP32 Build Requirements
Expand Down Expand Up @@ -257,6 +267,7 @@ Do not connect a load to IO12 at power-on. If IO12 is high, the ESP32 sets the f
## Constraints

- **until 2027-01-11** — Keep the host app alive under every fault. A task must restart after a panic (`runtime::supervise`). A blocking call must not run on a tokio worker. An FFI callback must not capture Rust data, because the vendored `rusty_link` callback setters keep a dangling pointer.
- **until 2027-01-25** — After each merge to `main`, build `main` on the local computer. Run `git checkout main && git pull`, then `sh scripts/check.sh --esp32 && cargo build --release --locked`. GitHub Actions runs only quick checks, because the account has a limit of free Actions minutes.

## graphify

Expand Down
9 changes: 8 additions & 1 deletion crates/autoblink-core-dbserver/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,14 @@ pub fn fetch(
track_type: u8,
rekordbox_id: u32,
) -> Result<Fetched, FetchError> {
fetch_with(&TcpStream::connect_timeout, ip, asking, track_slot, track_type, rekordbox_id)
fetch_with(
&TcpStream::connect_timeout,
ip,
asking,
track_slot,
track_type,
rekordbox_id,
)
}

/// Like [`fetch`], but opens the two TCP connections with `connect`. Use it to select the
Expand Down
11 changes: 6 additions & 5 deletions crates/autoblink-core-dbserver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ pub use parse::{
};
pub use protocol::{
decode_utf16be, describe_args, encode_message, encode_number_field, encode_utf16be, read_field,
read_message, read_message_with, DbMessage, Field, ARG_TAG_BLOB, ARG_TAG_NUMBER, ARG_TAG_STRING, DB_MAGIC,
DB_SERVER_QUERY_PORT, EXT_DAT, EXT_EXT, FIELD_BLOB, FIELD_STRING, FIELD_U16, FIELD_U32,
FIELD_U8, MAX_ARG_COUNT, MAX_FIELD_LEN, MAX_STREAM_LEN, MENU_DATA, MSG_ANLZ_TAG_REQ, MSG_ANLZ_TAG_RESP,
MSG_CUE_LIST_EXT_REQ, MSG_CUE_LIST_EXT_RESP, MSG_MENU_FOOTER, MSG_MENU_HEADER, MSG_MENU_ITEM,
MSG_METADATA_REQ, MSG_RENDER_MENU, MSG_SETUP_REQ, PREVIEW_COLUMNS, TAG_PQTZ, TAG_PSSI, TAG_PWV4,
read_message, read_message_with, DbMessage, Field, ARG_TAG_BLOB, ARG_TAG_NUMBER,
ARG_TAG_STRING, DB_MAGIC, DB_SERVER_QUERY_PORT, EXT_DAT, EXT_EXT, FIELD_BLOB, FIELD_STRING,
FIELD_U16, FIELD_U32, FIELD_U8, MAX_ARG_COUNT, MAX_FIELD_LEN, MAX_STREAM_LEN, MENU_DATA,
MSG_ANLZ_TAG_REQ, MSG_ANLZ_TAG_RESP, MSG_CUE_LIST_EXT_REQ, MSG_CUE_LIST_EXT_RESP,
MSG_MENU_FOOTER, MSG_MENU_HEADER, MSG_MENU_ITEM, MSG_METADATA_REQ, MSG_RENDER_MENU,
MSG_SETUP_REQ, PREVIEW_COLUMNS, TAG_PQTZ, TAG_PSSI, TAG_PWV4,
};
pub use types::{
current_phrase_idx, phrase_info, phrase_status, Cue, PhraseEntry, PhraseInfo, PhraseKind,
Expand Down
98 changes: 80 additions & 18 deletions crates/autoblink-core-midi/src/clock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,22 @@ impl Clock {
return;
}
self.bars = 0;
let Some(error) = self.earliest.take() else { return };
let Some(error) = self.earliest.take() else {
return;
};
self.last_error_ms = Some(error / 1000.0);
if error.abs() < DEADBAND {
return;
}
let duration = (cfg.smoothing_ms as f64 * 1000.0).max(1000.0).max(10.0 * error.abs());
self.slew = Some(Slew { start: now_f, duration, total: error, applied: 0.0 });
let duration = (cfg.smoothing_ms as f64 * 1000.0)
.max(1000.0)
.max(10.0 * error.abs());
self.slew = Some(Slew {
start: now_f,
duration,
total: error,
applied: 0.0,
});
}

/// The time from the nearest beat of the grid to `now`, in µs. The part of the slew that the
Expand Down Expand Up @@ -291,7 +300,11 @@ mod tests {
const BEAT: u64 = 500_000; // 120 BPM

fn master(playing: bool) -> Option<Master> {
Some(Master { device: 1, playing, bpm: 120.0 })
Some(Master {
device: 1,
playing,
bpm: 120.0,
})
}

fn run(f: impl FnOnce(&mut dyn FnMut(Out))) -> Vec<Out> {
Expand Down Expand Up @@ -332,7 +345,9 @@ mod tests {
let mut now = 0;
while now < BEAT {
now += 1000;
n += pulses(&run(|o| { c.poll(&cfg, now, master(true), &mut |x| o(x)); }));
n += pulses(&run(|o| {
c.poll(&cfg, now, master(true), &mut |x| o(x));
}));
}
// The pulse at time 0 came with Start, so one beat later 24 more are out.
assert_eq!(n, 24);
Expand All @@ -341,7 +356,10 @@ mod tests {

#[test]
fn latency_moves_the_send_time() {
let cfg = MidiConfig { latency_ms: 5, ..MidiConfig::default() };
let cfg = MidiConfig {
latency_ms: 5,
..MidiConfig::default()
};
let mut c = started(&cfg);
let next = c.poll(&cfg, 0, master(true), &mut |_| {}).unwrap();
assert_eq!(next, BEAT / 24 + 5_000);
Expand All @@ -351,30 +369,57 @@ mod tests {
fn loop_sync_stops_when_the_master_stops() {
let cfg = MidiConfig::default();
let mut c = started(&cfg);
let out = run(|o| { c.poll(&cfg, 10, master(false), &mut |x| o(x)); });
let out = run(|o| {
c.poll(&cfg, 10, master(false), &mut |x| o(x));
});
assert_eq!(out, [Out::Realtime(STOP)]);
assert_eq!(c.state_label(), "wait");
}

#[test]
fn without_loop_sync_a_restart_continues_at_the_beat() {
let cfg = MidiConfig { clock_loop_sync: false, ..MidiConfig::default() };
let cfg = MidiConfig {
clock_loop_sync: false,
..MidiConfig::default()
};
let mut c = Clock::default();
c.poll(&cfg, 0, master(true), &mut |_| {});
let out = run(|o| c.beat(&cfg, 0, 1, 3, 120.0, &mut |x| o(x)));
assert_eq!(out, [Out::Realtime(START), Out::Realtime(CLOCK)]);
// A pause does not stop the clock.
assert!(run(|o| { c.poll(&cfg, 10, master(false), &mut |x| o(x)); }).is_empty());
assert!(run(|o| {
c.poll(&cfg, 10, master(false), &mut |x| o(x));
})
.is_empty());
// A new master stops it. Its first beat continues from the song position.
let other = Some(Master { device: 2, playing: true, bpm: 120.0 });
assert_eq!(run(|o| { c.poll(&cfg, 20, other, &mut |x| o(x)); }), [Out::Realtime(STOP)]);
let other = Some(Master {
device: 2,
playing: true,
bpm: 120.0,
});
assert_eq!(
run(|o| {
c.poll(&cfg, 20, other, &mut |x| o(x));
}),
[Out::Realtime(STOP)]
);
let out = run(|o| c.beat(&cfg, 30, 2, 2, 120.0, &mut |x| o(x)));
assert_eq!(out, [Out::SongPosition(4), Out::Realtime(CONTINUE), Out::Realtime(CLOCK)]);
assert_eq!(
out,
[
Out::SongPosition(4),
Out::Realtime(CONTINUE),
Out::Realtime(CLOCK)
]
);
}

#[test]
fn a_late_downbeat_slews_the_grid() {
let cfg = MidiConfig { phrase_lock_bars: 1, ..MidiConfig::default() };
let cfg = MidiConfig {
phrase_lock_bars: 1,
..MidiConfig::default()
};
let mut c = started(&cfg);
// The next downbeat comes 4 beats plus 10 ms later than the grid expects.
let at = 4 * BEAT + 10_000;
Expand Down Expand Up @@ -405,7 +450,10 @@ mod tests {

#[test]
fn the_correction_uses_the_earliest_beat() {
let cfg = MidiConfig { phrase_lock_bars: 2, ..MidiConfig::default() };
let cfg = MidiConfig {
phrase_lock_bars: 2,
..MidiConfig::default()
};
let mut c = started(&cfg);
bar(&mut c, &cfg, BEAT, [150_000, 40_000, 180_000, 90_000]);
bar(&mut c, &cfg, 5 * BEAT, [120_000, 30_000, 200_000, 160_000]);
Expand All @@ -415,7 +463,10 @@ mod tests {

#[test]
fn a_small_error_does_not_move_the_grid() {
let cfg = MidiConfig { phrase_lock_bars: 1, ..MidiConfig::default() };
let cfg = MidiConfig {
phrase_lock_bars: 1,
..MidiConfig::default()
};
let mut c = started(&cfg);
bar(&mut c, &cfg, BEAT, [4_000, 3_000, 4_000, 4_000]);
assert!((c.last_error_ms().unwrap() - 3.0).abs() < 0.1);
Expand All @@ -426,19 +477,30 @@ mod tests {
fn a_long_stall_resyncs_on_the_next_downbeat() {
let cfg = MidiConfig::default();
let mut c = started(&cfg);
let out = run(|o| { c.poll(&cfg, 2 * BEAT + 1000, master(true), &mut |x| o(x)); });
let out = run(|o| {
c.poll(&cfg, 2 * BEAT + 1000, master(true), &mut |x| o(x));
});
// 48 pulses were due. The clock sends only 48 % 24 = 0 of them.
assert_eq!(pulses(&out), 0);
let out = run(|o| c.beat(&cfg, 4 * BEAT, 1, 1, 120.0, &mut |x| o(x)));
assert_eq!(out, [Out::Realtime(STOP), Out::Realtime(START), Out::Realtime(CLOCK)]);
assert_eq!(
out,
[
Out::Realtime(STOP),
Out::Realtime(START),
Out::Realtime(CLOCK)
]
);
}

#[test]
fn disabling_the_clock_sends_stop() {
let mut cfg = MidiConfig::default();
let mut c = started(&cfg);
cfg.clock_enabled = false;
let out = run(|o| { c.poll(&cfg, 10, master(true), &mut |x| o(x)); });
let out = run(|o| {
c.poll(&cfg, 10, master(true), &mut |x| o(x));
});
assert_eq!(out, [Out::Realtime(STOP)]);
assert_eq!(c.state_label(), "idle");
}
Expand Down
Loading
Loading