diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..e7f3905 --- /dev/null +++ b/.githooks/pre-push @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d60ba06..e5570a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: @@ -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 diff --git a/.github/workflows/esp32-ci.yml b/.github/workflows/esp32-ci.yml index af5322f..1a9ac50 100644 --- a/.github/workflows/esp32-ci.yml +++ b/.github/workflows/esp32-ci.yml @@ -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 @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index ff3a40b..b89de1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 ` or `gh workflow run esp32-ci.yml --ref `. +- The release workflows (`release.yml`, `esp32-release.yml`) start from a tag. They build all targets. - Coverage gaps: packet parsing, state machine transitions, BPM smoothing, MIDI clock transport ## ESP32 Build Requirements @@ -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 diff --git a/crates/autoblink-core-dbserver/src/client.rs b/crates/autoblink-core-dbserver/src/client.rs index 6cf5394..d089e7a 100644 --- a/crates/autoblink-core-dbserver/src/client.rs +++ b/crates/autoblink-core-dbserver/src/client.rs @@ -105,7 +105,14 @@ pub fn fetch( track_type: u8, rekordbox_id: u32, ) -> Result { - 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 diff --git a/crates/autoblink-core-dbserver/src/lib.rs b/crates/autoblink-core-dbserver/src/lib.rs index 5a63592..2127e8f 100644 --- a/crates/autoblink-core-dbserver/src/lib.rs +++ b/crates/autoblink-core-dbserver/src/lib.rs @@ -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, diff --git a/crates/autoblink-core-midi/src/clock.rs b/crates/autoblink-core-midi/src/clock.rs index 688529b..19b844e 100644 --- a/crates/autoblink-core-midi/src/clock.rs +++ b/crates/autoblink-core-midi/src/clock.rs @@ -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 @@ -291,7 +300,11 @@ mod tests { const BEAT: u64 = 500_000; // 120 BPM fn master(playing: bool) -> Option { - 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 { @@ -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); @@ -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); @@ -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; @@ -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]); @@ -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); @@ -426,11 +477,20 @@ 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] @@ -438,7 +498,9 @@ mod tests { 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"); } diff --git a/crates/autoblink-core-midi/src/mapper.rs b/crates/autoblink-core-midi/src/mapper.rs index 6ad3a3b..cf98847 100644 --- a/crates/autoblink-core-midi/src/mapper.rs +++ b/crates/autoblink-core-midi/src/mapper.rs @@ -61,7 +61,13 @@ fn cc_value(i: usize, s: &Snapshot) -> u8 { 3 if s.phrase16 > 0 => scale01(((s.phrase16 - 1) as f64 + s.beat_phase) / 16.0), 3 => 0, 4 => scale01(s.beat_phase), - 5 => if s.playing { 127 } else { 0 }, + 5 => { + if s.playing { + 127 + } else { + 0 + } + } _ => s.device.min(127), } } @@ -79,7 +85,12 @@ pub struct Mapper { impl Default for Mapper { fn default() -> Self { - Self { prev: [0; CC_COUNT], prev_phrase16: 0, prev_phrase_idx: None, kind_sent_for: None } + Self { + prev: [0; CC_COUNT], + prev_phrase16: 0, + prev_phrase_idx: None, + kind_sent_for: None, + } } } @@ -91,10 +102,18 @@ impl Mapper { } let ch = cfg.note_channel; if cfg.send_beat { - out(Out::Note { ch, note: cfg.beat_note, vel: beat_velocity(beat_in_bar) }); + out(Out::Note { + ch, + note: cfg.beat_note, + vel: beat_velocity(beat_in_bar), + }); } if beat_in_bar == 1 && cfg.send_downbeat { - out(Out::Note { ch, note: cfg.downbeat_note, vel: 127 }); + out(Out::Note { + ch, + note: cfg.downbeat_note, + vel: 127, + }); } } @@ -110,14 +129,22 @@ impl Mapper { let val = cc_value(i, s); if val != self.prev[i] { self.prev[i] = val; - out(Out::Cc { ch, num: cc.num, val }); + out(Out::Cc { + ch, + num: cc.num, + val, + }); } } // The page help says: "Sends 0 at the start of each 16-beat block". The host never // sends it, because its dedupe starts at 0. The firmware sends it as the help says. let cc = cfg.ccs[PHRASE_16]; if cfg.cc_enabled && cc.on && s.phrase16 == 1 && self.prev_phrase16 != 1 { - out(Out::Cc { ch, num: cc.num, val: 0 }); + out(Out::Cc { + ch, + num: cc.num, + val: 0, + }); } self.prev_phrase16 = s.phrase16; self.update_phrase(cfg, s, out); @@ -132,7 +159,11 @@ impl Mapper { let idx = s.phrase.map(|p| p.idx); if let (Some(prev), Some(cur)) = (self.prev_phrase_idx, idx) { if prev != cur && cfg.notes_enabled && cfg.send_phrase_change { - out(Out::Note { ch: cfg.note_channel, note: cfg.phrase_change_note, vel: 127 }); + out(Out::Note { + ch: cfg.note_channel, + note: cfg.phrase_change_note, + vel: 127, + }); } } self.prev_phrase_idx = idx; @@ -143,20 +174,35 @@ impl Mapper { self.kind_sent_for = Some(key); let cc = cfg.ccs[PHRASE_KIND]; if cfg.cc_enabled && cc.on { - out(Out::Cc { ch: cfg.cc_channel, num: cc.num, val: (code * 10).min(127) }); + out(Out::Cc { + ch: cfg.cc_channel, + num: cc.num, + val: (code * 10).min(127), + }); } if cfg.notes_enabled && cfg.send_phrase_kind { let note = cfg.phrase_base_note.saturating_add(code).min(127); - out(Out::Note { ch: cfg.note_channel, note, vel: 127 }); + out(Out::Note { + ch: cfg.note_channel, + note, + vel: 127, + }); } } - for (i, val) in [(PHRASE_PROGRESS, scale01(p.progress)), (BEATS_LEFT, p.beats_left.min(127) as u8)] { + for (i, val) in [ + (PHRASE_PROGRESS, scale01(p.progress)), + (BEATS_LEFT, p.beats_left.min(127) as u8), + ] { let cc = cfg.ccs[i]; if !cfg.cc_enabled || !cc.on { self.prev[i] = 255; } else if val != self.prev[i] { self.prev[i] = val; - out(Out::Cc { ch: cfg.cc_channel, num: cc.num, val }); + out(Out::Cc { + ch: cfg.cc_channel, + num: cc.num, + val, + }); } } } @@ -195,7 +241,15 @@ mod tests { } fn snap() -> Snapshot { - Snapshot { device: 2, bpm: 128.5, pitch: 2.0, phrase16: 5, beat_phase: 0.5, playing: true, phrase: None } + Snapshot { + device: 2, + bpm: 128.5, + pitch: 2.0, + phrase16: 5, + beat_phase: 0.5, + playing: true, + phrase: None, + } } #[test] @@ -203,9 +257,30 @@ mod tests { let cfg = MidiConfig::default(); let m = Mapper::default(); let out = collect(|o| m.beat(&cfg, 1, &mut |x| o(x))); - assert_eq!(out, [Out::Note { ch: 9, note: 36, vel: 127 }, Out::Note { ch: 9, note: 37, vel: 127 }]); + assert_eq!( + out, + [ + Out::Note { + ch: 9, + note: 36, + vel: 127 + }, + Out::Note { + ch: 9, + note: 37, + vel: 127 + } + ] + ); let out = collect(|o| m.beat(&cfg, 3, &mut |x| o(x))); - assert_eq!(out, [Out::Note { ch: 9, note: 36, vel: 64 }]); + assert_eq!( + out, + [Out::Note { + ch: 9, + note: 36, + vel: 64 + }] + ); } #[test] @@ -216,7 +291,15 @@ mod tests { let cc = |num, val| Out::Cc { ch: 0, num, val }; assert_eq!( out, - [cc(1, 68), cc(33, 64), cc(2, 76), cc(3, 36), cc(4, 64), cc(5, 127), cc(6, 2)] + [ + cc(1, 68), + cc(33, 64), + cc(2, 76), + cc(3, 36), + cc(4, 64), + cc(5, 127), + cc(6, 2) + ] ); assert!(collect(|o| m.update(&cfg, &snap(), &mut |x| o(x))).is_empty()); } @@ -230,47 +313,117 @@ mod tests { m.update(&cfg, &snap(), &mut |_| {}); cfg.ccs[5].on = true; let out = collect(|o| m.update(&cfg, &snap(), &mut |x| o(x))); - assert_eq!(out, [Out::Cc { ch: 0, num: 5, val: 127 }]); + assert_eq!( + out, + [Out::Cc { + ch: 0, + num: 5, + val: 127 + }] + ); } #[test] fn phrase16_sends_0_at_each_block_start() { let cfg = MidiConfig::default(); let mut m = Mapper::default(); - let at = |p| Snapshot { phrase16: p, ..snap() }; + let at = |p| Snapshot { + phrase16: p, + ..snap() + }; m.update(&cfg, &at(16), &mut |_| {}); let out = collect(|o| m.update(&cfg, &at(1), &mut |x| o(x))); - assert!(out.contains(&Out::Cc { ch: 0, num: 7, val: 0 })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 7, + val: 0 + })); let out = collect(|o| m.update(&cfg, &at(1), &mut |x| o(x))); - assert!(!out.contains(&Out::Cc { ch: 0, num: 7, val: 0 })); + assert!(!out.contains(&Out::Cc { + ch: 0, + num: 7, + val: 0 + })); } #[test] fn phrase_messages_follow_the_host_rules() { let cfg = MidiConfig::default(); let mut m = Mapper::default(); - let phrase = |idx, code, beats_left| Phrase { rekordbox_id: 207, idx, code: Some(code), progress: 0.5, beats_left }; - let at = |p| Snapshot { phrase: Some(p), ..snap() }; + let phrase = |idx, code, beats_left| Phrase { + rekordbox_id: 207, + idx, + code: Some(code), + progress: 0.5, + beats_left, + }; + let at = |p| Snapshot { + phrase: Some(p), + ..snap() + }; let out = collect(|o| m.update(&cfg, &at(phrase(0, 1, 16)), &mut |x| o(x))); // The first phrase: kind CC 10, kind note 40 + 1, progress 64, beats left 16. No change note. - assert!(out.contains(&Out::Cc { ch: 0, num: 20, val: 10 })); - assert!(out.contains(&Out::Note { ch: 9, note: 41, vel: 127 })); - assert!(out.contains(&Out::Cc { ch: 0, num: 21, val: 64 })); - assert!(out.contains(&Out::Cc { ch: 0, num: 22, val: 16 })); - assert!(!out.contains(&Out::Note { ch: 9, note: 38, vel: 127 })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 20, + val: 10 + })); + assert!(out.contains(&Out::Note { + ch: 9, + note: 41, + vel: 127 + })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 21, + val: 64 + })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 22, + val: 16 + })); + assert!(!out.contains(&Out::Note { + ch: 9, + note: 38, + vel: 127 + })); // The same phrase again sends nothing new. let out = collect(|o| m.update(&cfg, &at(phrase(0, 1, 16)), &mut |x| o(x))); assert!(out.is_empty()); // The next phrase: the change note and the new kind. let out = collect(|o| m.update(&cfg, &at(phrase(1, 8, 32)), &mut |x| o(x))); - assert!(out.contains(&Out::Note { ch: 9, note: 38, vel: 127 })); - assert!(out.contains(&Out::Cc { ch: 0, num: 20, val: 80 })); - assert!(out.contains(&Out::Note { ch: 9, note: 48, vel: 127 })); + assert!(out.contains(&Out::Note { + ch: 9, + note: 38, + vel: 127 + })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 20, + val: 80 + })); + assert!(out.contains(&Out::Note { + ch: 9, + note: 48, + vel: 127 + })); // A phrase with an unknown kind still sends the change note and the progress CCs. - let unknown = Phrase { code: None, ..phrase(2, 0, 8) }; + let unknown = Phrase { + code: None, + ..phrase(2, 0, 8) + }; let out = collect(|o| m.update(&cfg, &at(unknown), &mut |x| o(x))); - assert!(out.contains(&Out::Note { ch: 9, note: 38, vel: 127 })); - assert!(out.contains(&Out::Cc { ch: 0, num: 22, val: 8 })); + assert!(out.contains(&Out::Note { + ch: 9, + note: 38, + vel: 127 + })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 22, + val: 8 + })); assert!(!out.iter().any(|o| matches!(o, Out::Cc { num: 20, .. }))); } diff --git a/crates/autoblink-core-midi/src/settings.rs b/crates/autoblink-core-midi/src/settings.rs index b9d89dc..be15ab9 100644 --- a/crates/autoblink-core-midi/src/settings.rs +++ b/crates/autoblink-core-midi/src/settings.rs @@ -25,8 +25,9 @@ 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; 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, +]; /// One row of the settings message. #[derive(Debug, Clone, PartialEq, Serialize)] @@ -78,15 +79,45 @@ fn item(cfg: &MidiConfig, idx: usize) -> SettingItem { match idx { 3 => toggle("Clock Enabled", Some("MIDI Clock"), cfg.clock_enabled), 4 => toggle("Clock Loop Sync", None, cfg.clock_loop_sync), - 5 => number("BPM Smoothing", None, format!("{} ms", cfg.smoothing_ms), cfg.smoothing_ms.to_string(), None), - 6 => number("Latency Comp", None, format!("{} ms", cfg.latency_ms), cfg.latency_ms.to_string(), None), - 7 => number("Phrase Lock Stable", None, format!("{} bars", cfg.phrase_lock_bars), cfg.phrase_lock_bars.to_string(), None), + 5 => number( + "BPM Smoothing", + None, + format!("{} ms", cfg.smoothing_ms), + cfg.smoothing_ms.to_string(), + None, + ), + 6 => number( + "Latency Comp", + None, + format!("{} ms", cfg.latency_ms), + cfg.latency_ms.to_string(), + None, + ), + 7 => number( + "Phrase Lock Stable", + None, + format!("{} bars", cfg.phrase_lock_bars), + cfg.phrase_lock_bars.to_string(), + None, + ), 8 => toggle("Notes Enabled", Some("MIDI Notes"), cfg.notes_enabled), 9 => plain("Note Channel", (cfg.note_channel + 1).to_string(), None), 10 => plain("Beat Note", cfg.beat_note.to_string(), Some(cfg.send_beat)), - 11 => plain("Downbeat Note", cfg.downbeat_note.to_string(), Some(cfg.send_downbeat)), - 12 => plain("Phrase Change Note", cfg.phrase_change_note.to_string(), Some(cfg.send_phrase_change)), - 13 => plain("Phrase Kind Base Note", cfg.phrase_base_note.to_string(), Some(cfg.send_phrase_kind)), + 11 => plain( + "Downbeat Note", + cfg.downbeat_note.to_string(), + Some(cfg.send_downbeat), + ), + 12 => plain( + "Phrase Change Note", + cfg.phrase_change_note.to_string(), + Some(cfg.send_phrase_change), + ), + 13 => plain( + "Phrase Kind Base Note", + cfg.phrase_base_note.to_string(), + Some(cfg.send_phrase_kind), + ), 15 => toggle("CC Enabled", Some("MIDI CC"), cfg.cc_enabled), 16 => plain("CC Channel", (cfg.cc_channel + 1).to_string(), None), _ => { @@ -127,7 +158,10 @@ pub fn cycle(cfg: &mut MidiConfig, idx: usize, dir: i8) -> Result<(), String> { 3 => cfg.clock_enabled = !cfg.clock_enabled, 4 => cfg.clock_loop_sync = !cfg.clock_loop_sync, 6 => cfg.latency_ms = (cfg.latency_ms + if dir < 0 { -5 } else { 5 }).clamp(-1000, 1000), - 7 => cfg.phrase_lock_bars = (cfg.phrase_lock_bars as i16 + dir.signum() as i16).clamp(1, 32) as u8, + 7 => { + cfg.phrase_lock_bars = + (cfg.phrase_lock_bars as i16 + dir.signum() as i16).clamp(1, 32) as u8 + } 8 => cfg.notes_enabled = !cfg.notes_enabled, 10 => cfg.send_beat = !cfg.send_beat, 11 => cfg.send_downbeat = !cfg.send_downbeat, @@ -191,7 +225,10 @@ mod tests { v["ccs"].as_array_mut().unwrap().truncate(8); v["ccs"][0]["num"] = 99.into(); let cfg: MidiConfig = serde_json::from_value(v).unwrap(); - assert_eq!((cfg.ccs[0].num, cfg.ccs[8].num, cfg.ccs[10].num), (99, 20, 22)); + assert_eq!( + (cfg.ccs[0].num, cfg.ccs[8].num, cfg.ccs[10].num), + (99, 20, 22) + ); } #[test] diff --git a/crates/autoblink-core-prolink/src/parse.rs b/crates/autoblink-core-prolink/src/parse.rs index 382e49d..077902e 100644 --- a/crates/autoblink-core-prolink/src/parse.rs +++ b/crates/autoblink-core-prolink/src/parse.rs @@ -83,40 +83,6 @@ pub fn parse_beat(data: &[u8]) -> Option { }) } -#[cfg(test)] -mod tests { - use super::*; - use crate::{BEAT_NONE, PITCH_NORMAL}; - - fn magic_pkt(kind: u8, len: usize) -> alloc::vec::Vec { - let mut v = alloc::vec![0u8; len]; - v[..10].copy_from_slice(&MAGIC); - v[0x0a] = kind; - v - } - - #[test] - fn beat_timings_scale_by_pitch_and_preserve_sentinel() { - let mut p = magic_pkt(PKT_BEAT, 0x60); - p[0x21] = 1; - p[0x5a] = 0x2e; // 120.00 BPM raw hi - p[0x5b] = 0xe0; // 120.00 BPM raw lo - - let pitch_plus_8 = ((PITCH_NORMAL as f64) * 1.08).round() as u32; - p[0x54..0x58].copy_from_slice(&pitch_plus_8.to_be_bytes()); - - p[0x24..0x28].copy_from_slice(&500u32.to_be_bytes()); - p[0x28..0x2c].copy_from_slice(&1000u32.to_be_bytes()); - p[0x2c..0x30].copy_from_slice(&BEAT_NONE.to_be_bytes()); - p[0x5c] = 1; - - let bp = parse_beat(&p).expect("beat packet should parse"); - assert_eq!(bp.next_beat_ms, 463); - assert_eq!(bp.second_beat_ms, 926); - assert_eq!(bp.next_bar_ms, BEAT_NONE); - } -} - pub fn parse_abs_position(data: &[u8]) -> Option { if data.len() < 0x3c { return None; @@ -211,3 +177,37 @@ pub fn parse_mixer_status(data: &[u8]) -> Option { beat_in_bar: data[0x37], }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BEAT_NONE, PITCH_NORMAL}; + + fn magic_pkt(kind: u8, len: usize) -> alloc::vec::Vec { + let mut v = alloc::vec![0u8; len]; + v[..10].copy_from_slice(&MAGIC); + v[0x0a] = kind; + v + } + + #[test] + fn beat_timings_scale_by_pitch_and_preserve_sentinel() { + let mut p = magic_pkt(PKT_BEAT, 0x60); + p[0x21] = 1; + p[0x5a] = 0x2e; // 120.00 BPM raw hi + p[0x5b] = 0xe0; // 120.00 BPM raw lo + + let pitch_plus_8 = ((PITCH_NORMAL as f64) * 1.08).round() as u32; + p[0x54..0x58].copy_from_slice(&pitch_plus_8.to_be_bytes()); + + p[0x24..0x28].copy_from_slice(&500u32.to_be_bytes()); + p[0x28..0x2c].copy_from_slice(&1000u32.to_be_bytes()); + p[0x2c..0x30].copy_from_slice(&BEAT_NONE.to_be_bytes()); + p[0x5c] = 1; + + let bp = parse_beat(&p).expect("beat packet should parse"); + assert_eq!(bp.next_beat_ms, 463); + assert_eq!(bp.second_beat_ms, 926); + assert_eq!(bp.next_bar_ms, BEAT_NONE); + } +} diff --git a/crates/autoblink-core-prolink/src/tracker.rs b/crates/autoblink-core-prolink/src/tracker.rs index b5a3378..effabc3 100644 --- a/crates/autoblink-core-prolink/src/tracker.rs +++ b/crates/autoblink-core-prolink/src/tracker.rs @@ -85,7 +85,11 @@ impl Deck { } fn period_ms(&self) -> f64 { - if self.bpm > 0.0 { 60_000.0 / self.bpm } else { 1_000.0 } + if self.bpm > 0.0 { + 60_000.0 / self.bpm + } else { + 1_000.0 + } } } @@ -113,7 +117,13 @@ pub struct Tracker { impl Tracker { pub const fn new() -> Self { - Self { decks: Vec::new(), peers: Vec::new(), beat_master: None, flag_master: None, own: 0 } + Self { + decks: Vec::new(), + peers: Vec::new(), + beat_master: None, + flag_master: None, + own: 0, + } } /// Sets the device number of this device, so that the tracker ignores its own packets. @@ -131,7 +141,12 @@ impl Tracker { return; } if ka.device_type != DEV_CDJ || !self.is_player(ka.device_number) { - let peer = Peer { num: ka.device_number, name: ka.name.clone(), ip: ka.ip, last_seen_ms: now_ms }; + let peer = Peer { + num: ka.device_number, + name: ka.name.clone(), + ip: ka.ip, + last_seen_ms: now_ms, + }; match self.peers.iter_mut().find(|p| p.num == ka.device_number) { Some(p) => *p = peer, None => self.peers.push(peer), @@ -205,7 +220,8 @@ impl Tracker { if deck.status_playing { deck.pitch = st.pitch_pct; } - deck.beat_count = (st.beat_count != u32::MAX && st.beat_count != 0).then_some(st.beat_count); + deck.beat_count = + (st.beat_count != u32::MAX && st.beat_count != 0).then_some(st.beat_count); if let Some(count) = deck.beat_count { // Like the host: the phrase position follows the beat number of the track. deck.phrase16 = ((count - 1) % 16 + 1) as u8; @@ -252,7 +268,11 @@ impl Tracker { if secs == 0 { return; } - if let Some(d) = self.decks.iter_mut().find(|d| d.num == device && d.track.rekordbox_id == rekordbox_id) { + if let Some(d) = self + .decks + .iter_mut() + .find(|d| d.num == device && d.track.rekordbox_id == rekordbox_id) + { d.track_len_ms = Some(secs.saturating_mul(1000)); } } @@ -263,7 +283,8 @@ impl Tracker { .retain(|d| now_ms.saturating_sub(d.last_seen_ms) <= DECK_TIMEOUT_MS); self.peers .retain(|p| now_ms.saturating_sub(p.last_seen_ms) <= DECK_TIMEOUT_MS); - let present = |m: Option, decks: &[Deck]| m.filter(|n| decks.iter().any(|d| d.num == *n)); + let present = + |m: Option, decks: &[Deck]| m.filter(|n| decks.iter().any(|d| d.num == *n)); self.beat_master = present(self.beat_master, &self.decks); self.flag_master = present(self.flag_master, &self.decks); } @@ -278,9 +299,12 @@ impl Tracker { /// The name and the IP address of any device on the network: a deck or another device. pub fn device_address(&self, num: u8) -> Option<(&str, [u8; 4])> { - self.deck(num) - .map(|d| (d.name.as_str(), d.ip)) - .or_else(|| self.peers.iter().find(|p| p.num == num).map(|p| (p.name.as_str(), p.ip))) + self.deck(num).map(|d| (d.name.as_str(), d.ip)).or_else(|| { + self.peers + .iter() + .find(|p| p.num == num) + .map(|p| (p.name.as_str(), p.ip)) + }) } pub fn master(&self) -> Option<&Deck> { @@ -300,7 +324,10 @@ impl Tracker { let i = match self.decks.iter().position(|d| d.num == num) { Some(i) => i, None => { - self.decks.push(Deck { num, ..Deck::default() }); + self.decks.push(Deck { + num, + ..Deck::default() + }); self.decks.sort_by_key(|d| d.num); self.decks.iter().position(|d| d.num == num).unwrap() } @@ -414,8 +441,21 @@ mod tests { fn keepalive_names_the_deck_and_skips_other_devices() { let mut t = Tracker::new(); t.on_keepalive(&keepalive(3, "XDJ-AZ"), 0); - t.on_keepalive(&KeepAlive { device_type: 1, ..keepalive(0x21, "DJM") }, 0); - t.on_keepalive(&KeepAlive { device_type: 3, ip: [10, 0, 0, 9], ..keepalive(17, "rekordbox") }, 0); + t.on_keepalive( + &KeepAlive { + device_type: 1, + ..keepalive(0x21, "DJM") + }, + 0, + ); + t.on_keepalive( + &KeepAlive { + device_type: 3, + ip: [10, 0, 0, 9], + ..keepalive(17, "rekordbox") + }, + 0, + ); assert_eq!(t.decks().len(), 1); assert_eq!(t.device_address(17), Some(("rekordbox", [10, 0, 0, 9]))); assert_eq!(t.decks()[0].name, "XDJ-AZ"); @@ -449,10 +489,19 @@ mod tests { #[test] fn status_reports_a_track_change_once() { let mut t = Tracker::new(); - let tr = t.on_cdj_status(&status(2, false, PlayState::Playing, 207), 0).unwrap(); - assert_eq!((tr.device, tr.slot, tr.track_type, tr.rekordbox_id), (2, 3, 1, 207)); - assert!(t.on_cdj_status(&status(2, false, PlayState::Playing, 207), 200).is_none()); - let eject = t.on_cdj_status(&status(2, false, PlayState::NoTrack, 0), 400).unwrap(); + let tr = t + .on_cdj_status(&status(2, false, PlayState::Playing, 207), 0) + .unwrap(); + assert_eq!( + (tr.device, tr.slot, tr.track_type, tr.rekordbox_id), + (2, 3, 1, 207) + ); + assert!(t + .on_cdj_status(&status(2, false, PlayState::Playing, 207), 200) + .is_none()); + let eject = t + .on_cdj_status(&status(2, false, PlayState::NoTrack, 0), 400) + .unwrap(); assert_eq!(eject.rekordbox_id, 0); } @@ -484,7 +533,10 @@ mod tests { }; t.on_abs_position(&p, 0); let d = t.deck(3).unwrap(); - assert_eq!((d.playhead_ms, d.track_len_ms), (Some(12_345), Some(300_000))); + assert_eq!( + (d.playhead_ms, d.track_len_ms), + (Some(12_345), Some(300_000)) + ); t.set_track_length(3, 0, 299); assert_eq!(t.deck(3).unwrap().track_len_ms, Some(299_000)); } diff --git a/esp32-emulator/src/main.rs b/esp32-emulator/src/main.rs index 6e06d2e..e41d82b 100644 --- a/esp32-emulator/src/main.rs +++ b/esp32-emulator/src/main.rs @@ -172,7 +172,11 @@ impl EmulatorState { state.add_log(0, "INFO", "MIDI: GPIO1(IN) GPIO3(OUT) @ 31250"); state.add_log(0, "INFO", "Pro DJ Link virtual CDJ #5"); state.add_log(0, "INFO", "Listening on UDP:50000,50001,50002"); - state.add_log(0, "INFO", "Proactive participation enabled (keep-alive + status templates)"); + state.add_log( + 0, + "INFO", + "Proactive participation enabled (keep-alive + status templates)", + ); state.add_log(0, "INFO", "Ready for Pro DJ Link packets..."); state } @@ -195,30 +199,51 @@ impl EmulatorState { fn refresh_master(&mut self) { let prev_master = self.master_idx; - + // 1. Priority: Explicit master flag from a deck - let explicit_master = self.decks.iter().position(|d| d.is_master && d.device_number != 0); - + let explicit_master = self + .decks + .iter() + .position(|d| d.is_master && d.device_number != 0); + if let Some(idx) = explicit_master { if self.master_idx != idx { let deck = &self.decks[idx]; - self.add_log(0, "INFO", format!("Master handoff: #{} ({}) is now MASTER", deck.device_number, deck.name)); + self.add_log( + 0, + "INFO", + format!( + "Master handoff: #{} ({}) is now MASTER", + deck.device_number, deck.name + ), + ); } self.master_idx = idx; return; } // 2. Fallback: First playing deck (if no explicit master) - let playing_deck = self.decks.iter().position(|d| d.is_playing && d.device_number != 0); + let playing_deck = self + .decks + .iter() + .position(|d| d.is_playing && d.device_number != 0); if let Some(idx) = playing_deck { if self.master_idx != idx { let deck = &self.decks[idx]; - self.add_log(0, "INFO", format!("Master fallback: #{} ({}) is PLAYING", deck.device_number, deck.name)); + self.add_log( + 0, + "INFO", + format!( + "Master fallback: #{} ({}) is PLAYING", + deck.device_number, deck.name + ), + ); } self.master_idx = idx; } else { // 3. Last resort: Keep current master if it still exists, otherwise reset to 0 - if self.master_idx >= self.decks.len() || self.decks[self.master_idx].device_number == 0 { + if self.master_idx >= self.decks.len() || self.decks[self.master_idx].device_number == 0 + { if prev_master != 0 { self.add_log(0, "WARN", "Master lost: no active or playing decks"); } @@ -232,14 +257,16 @@ impl EmulatorState { return; } - if &data[..10] != autoblink_core_prolink::MAGIC { + if data[..10] != autoblink_core_prolink::MAGIC { return; } let pkt_type = data[10]; match port { - autoblink_core_prolink::PORT_DISCOVERY => self.handle_discovery_packet(addr, pkt_type, data), + autoblink_core_prolink::PORT_DISCOVERY => { + self.handle_discovery_packet(addr, pkt_type, data) + } autoblink_core_prolink::PORT_BEAT => self.handle_beat_packet(addr, pkt_type, data), autoblink_core_prolink::PORT_STATUS => self.handle_status_packet(addr, pkt_type, data), _ => {} @@ -272,7 +299,14 @@ impl EmulatorState { if self.decks[idx].name.is_empty() { self.decks[idx].device_type = ka.device_type; self.decks[idx].name = ka.name.clone(); - self.add_log(0, "INFO", format!("Identified: {} #{} ({})", ka.name, ka.device_number, type_name)); + self.add_log( + 0, + "INFO", + format!( + "Identified: {} #{} ({})", + ka.name, ka.device_number, type_name + ), + ); } } else if let Some(slot) = self.find_empty_slot() { self.decks[slot].device_number = ka.device_number; @@ -280,7 +314,14 @@ impl EmulatorState { self.decks[slot].name = ka.name.clone(); self.decks[slot].ip = Some(ka.ip); self.decks[slot].last_seen = std::time::Instant::now(); - self.add_log(0, "INFO", format!("Discovered: {} #{} ({})", ka.name, ka.device_number, type_name)); + self.add_log( + 0, + "INFO", + format!( + "Discovered: {} #{} ({})", + ka.name, ka.device_number, type_name + ), + ); } } } @@ -293,7 +334,11 @@ impl EmulatorState { 0x00 | 0x02 | 0x04 => { if data.len() >= 12 { let device_num = data[11]; - self.add_log(2, "DEBUG", format!("Claim {}: device #{}", pkt_type, device_num)); + self.add_log( + 2, + "DEBUG", + format!("Claim {}: device #{}", pkt_type, device_num), + ); } } 0x08 => { @@ -311,7 +356,11 @@ impl EmulatorState { if let Some(idx) = self.find_deck(beat.device_number) { self.decks[idx].bpm = beat.effective_bpm; self.decks[idx].pitch = beat.pitch_pct; - self.decks[idx].beat = if beat.beat_in_bar == 0 { 4 } else { beat.beat_in_bar }; + self.decks[idx].beat = if beat.beat_in_bar == 0 { + 4 + } else { + beat.beat_in_bar + }; self.decks[idx].last_seen = std::time::Instant::now(); let old_beat = self.decks[idx].beat; @@ -323,19 +372,28 @@ impl EmulatorState { self.decks[idx].is_playing = true; } - if self.decks.get(self.master_idx).map(|d| d.device_number) == Some(beat.device_number) { + if self.decks.get(self.master_idx).map(|d| d.device_number) + == Some(beat.device_number) + { self.midi_note_count += 1; self.midi_cc_count += 1; self.last_note = self.midi_config.beat_note; self.last_note_ch = self.midi_config.note_channel; self.last_cc = self.midi_config.beat_phase_cc; - self.last_cc_val = (beat.beat_in_bar * 31) as u8; + self.last_cc_val = beat.beat_in_bar * 31; self.last_cc_ch = self.midi_config.cc_channel; self.last_note_at = Some(Instant::now()); self.last_cc_at = Some(Instant::now()); } - self.add_log(2, "DEBUG", format!("Beat #{} bpm={:.2} beat={}/4", beat.device_number, beat.effective_bpm, beat.beat_in_bar)); + self.add_log( + 2, + "DEBUG", + format!( + "Beat #{} bpm={:.2} beat={}/4", + beat.device_number, beat.effective_bpm, beat.beat_in_bar + ), + ); } } autoblink_core_prolink::PKT_ABS_POSITION => { @@ -345,14 +403,23 @@ impl EmulatorState { self.decks[idx].pitch = abs.pitch_pct; self.decks[idx].last_seen = std::time::Instant::now(); } - if self.decks.get(self.master_idx).map(|d| d.device_number) == Some(abs.device_number) { + if self.decks.get(self.master_idx).map(|d| d.device_number) + == Some(abs.device_number) + { self.midi_cc_count += 1; self.last_cc = self.midi_config.bar_phase_cc; self.last_cc_val = 64; self.last_cc_ch = self.midi_config.cc_channel; self.last_cc_at = Some(Instant::now()); } - self.add_log(2, "DEBUG", format!("AbsPosition #{} pos={}ms bpm={:.2}", abs.device_number, abs.playhead_ms, abs.effective_bpm)); + self.add_log( + 2, + "DEBUG", + format!( + "AbsPosition #{} pos={}ms bpm={:.2}", + abs.device_number, abs.playhead_ms, abs.effective_bpm + ), + ); } } _ => {} @@ -370,7 +437,7 @@ impl EmulatorState { self.decks[idx].is_on_air = s.is_on_air; self.decks[idx].pitch = s.pitch_pct; self.decks[idx].last_seen = std::time::Instant::now(); - + if s.is_master { // Clear master flag from all other decks if this one claims it for i in 0..self.decks.len() { @@ -379,15 +446,30 @@ impl EmulatorState { } } } - + self.decks[idx].is_master = s.is_master; self.decks[idx].bpm = s.effective_bpm; self.decks[idx].beat = s.beat_in_bar; } - let playing_str = if s.play_state.is_playing() || s.is_playing_flag { "playing" } else { "stopped" }; + let playing_str = if s.play_state.is_playing() || s.is_playing_flag { + "playing" + } else { + "stopped" + }; let master_str = if s.is_master { " MASTER" } else { "" }; - self.add_log(0, "INFO", format!("Status #{} {} bpm={:.2}{} (beat={})", s.device_number, playing_str, s.effective_bpm, master_str, s.beat_in_bar)); + self.add_log( + 0, + "INFO", + format!( + "Status #{} {} bpm={:.2}{} (beat={})", + s.device_number, + playing_str, + s.effective_bpm, + master_str, + s.beat_in_bar + ), + ); } } autoblink_core_prolink::PKT_MIXER_STATUS => { @@ -406,7 +488,16 @@ impl EmulatorState { self.decks[idx].bpm = bpm; } } - self.add_log(0, "DEBUG", format!("Mixer #{} is {} (bpm={:?})", s.device_number, if s.is_master { "master" } else { "slave" }, s.track_bpm)); + self.add_log( + 0, + "DEBUG", + format!( + "Mixer #{} is {} (bpm={:?})", + s.device_number, + if s.is_master { "master" } else { "slave" }, + s.track_bpm + ), + ); } } _ => {} @@ -415,18 +506,22 @@ impl EmulatorState { fn find_empty_slot(&self) -> Option { for (i, d) in self.decks.iter().enumerate() { - if d.device_number == 0 { return Some(i); } + if d.device_number == 0 { + return Some(i); + } } None } fn find_deck(&self, device_num: u8) -> Option { - self.decks.iter().position(|d| d.device_number == device_num) + self.decks + .iter() + .position(|d| d.device_number == device_num) } fn tick(&mut self) { self.tick += 1; - + let now = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(2); @@ -445,25 +540,34 @@ impl EmulatorState { self.refresh_master(); let master_idx = self.master_idx; - let master_bpm = if master_idx < self.decks.len() { self.decks[master_idx].bpm } else { 0.0 }; - let master_playing = if master_idx < self.decks.len() { self.decks[master_idx].is_playing } else { false }; - + let master_bpm = if master_idx < self.decks.len() { + self.decks[master_idx].bpm + } else { + 0.0 + }; + let master_playing = if master_idx < self.decks.len() { + self.decks[master_idx].is_playing + } else { + false + }; + if master_bpm > 0.0 && master_playing { let clocks_to_add = ((24.0 * master_bpm) / 600.0).round() as u64; if clocks_to_add > 0 { self.midi_clock_count += clocks_to_add; - } else if self.tick % 5 == 0 { + } else if self.tick.is_multiple_of(5) { self.midi_clock_count += 1; } } - if self.tick % 500 == 0 { + if self.tick.is_multiple_of(500) { self.add_log(0, "INFO", "MIDI clock: 24PPQ running"); } } fn visible_decks(&self) -> Vec<&DeckState> { - self.decks.iter() + self.decks + .iter() .filter(|d| d.device_number != 0 && d.device_number != self.virtual_device_num) .collect() } @@ -587,8 +691,16 @@ impl EmulatorState { let note = |v: &str| v.parse::().ok().filter(|v| *v <= 127); let channel = |v: &str| v.parse::().ok().filter(|v| (1..=16).contains(v)); let ok = match idx { - 5 => value.parse::().ok().filter(|v| *v <= 1000).map(|v| c.smoothing_ms = v), - 6 => value.parse::().ok().filter(|v| (-1000..=1000).contains(v)).map(|v| c.latency_ms = v), + 5 => value + .parse::() + .ok() + .filter(|v| *v <= 1000) + .map(|v| c.smoothing_ms = v), + 6 => value + .parse::() + .ok() + .filter(|v| (-1000..=1000).contains(v)) + .map(|v| c.latency_ms = v), 8 => channel(value).map(|v| c.note_channel = v), 9 => note(value).map(|v| c.beat_note = v), 10 => note(value).map(|v| c.downbeat_note = v), @@ -614,7 +726,8 @@ impl EmulatorState { 3 => self.midi_config.clock_enabled = !self.midi_config.clock_enabled, 6 => { let step = if dir < 0 { -5 } else { 5 }; - self.midi_config.latency_ms = (self.midi_config.latency_ms + step).clamp(-1000, 1000); + self.midi_config.latency_ms = + (self.midi_config.latency_ms + step).clamp(-1000, 1000); } _ => return false, } @@ -627,7 +740,11 @@ impl EmulatorState { let first = self.log_total - self.logs.len() as u64; let skip = seq.saturating_sub(first) as usize; ( - self.logs.iter().skip(skip).map(|l| l.message.clone()).collect(), + self.logs + .iter() + .skip(skip) + .map(|l| l.message.clone()) + .collect(), self.log_total, ) } @@ -667,7 +784,10 @@ pub async fn run_emulator() -> Result<()> { let mut buf = [0u8; 4096]; loop { if let Ok((len, addr)) = sock_50000_recv.recv_from(&mut buf).await { - state_net.write().await.handle_packet(addr, 50000, &buf[..len]); + state_net + .write() + .await + .handle_packet(addr, 50000, &buf[..len]); } } }); @@ -696,7 +816,10 @@ pub async fn run_emulator() -> Result<()> { let mut buf = [0u8; 4096]; loop { if let Ok((len, addr)) = sock.recv_from(&mut buf).await { - state_net3.write().await.handle_packet(addr, 50002, &buf[..len]); + state_net3 + .write() + .await + .handle_packet(addr, 50002, &buf[..len]); } } }); @@ -709,12 +832,12 @@ pub async fn run_emulator() -> Result<()> { let mut interval = interval(Duration::from_millis(1500)); let mac = [0x02, 0xAB, 0xCD, 0xEF, 0x01, 0x02]; let name = build::pad_name("autoblink-emu"); - + loop { interval.tick().await; let s = state_part.read().await; let dev_num = s.virtual_device_num; - + // 1. Broadcast keep-alive // We use 10.10.10.50 as our IP in the packet for the emulator let my_ip = [10, 10, 10, 50]; @@ -722,10 +845,14 @@ pub async fn run_emulator() -> Result<()> { if let Err(e) = sock_part.send_to(&ka_pkt, "255.255.255.255:50000").await { eprintln!("Failed to send keep-alive: {}", e); } - + // 2. Unicast status template to all discovered decks on port 50002 let status_pkt = build::build_status_packet(&name, dev_num); - for deck in s.decks.iter().filter(|d| d.device_number != 0 && d.ip.is_some()) { + for deck in s + .decks + .iter() + .filter(|d| d.device_number != 0 && d.ip.is_some()) + { if let Some(ip) = deck.ip { let addr = SocketAddr::from((ip, 50002)); if let Err(e) = sock_part.send_to(&status_pkt, addr).await { @@ -808,10 +935,16 @@ fn origin_allowed(headers: &HeaderMap) -> bool { host.rsplit_once(':').map_or(host, |(name, _)| name) } .to_ascii_lowercase(); - same && (name.parse::().is_ok() || name == "localhost" || name.ends_with(".local")) + same && (name.parse::().is_ok() + || name == "localhost" + || name.ends_with(".local")) } -async fn ws_handler(ws: WebSocketUpgrade, headers: HeaderMap, State(sh): State) -> Response { +async fn ws_handler( + ws: WebSocketUpgrade, + headers: HeaderMap, + State(sh): State, +) -> Response { if !origin_allowed(&headers) { return StatusCode::FORBIDDEN.into_response(); } @@ -888,7 +1021,11 @@ async fn handle_command(sh: &Shared, text: &str) -> Option { let mut s = sh.state.write().await; match cmd["type"].as_str() { Some("cycle") => { - let dir = if cmd["dir"].as_i64().unwrap_or(1) < 0 { -1 } else { 1 }; + let dir = if cmd["dir"].as_i64().unwrap_or(1) < 0 { + -1 + } else { + 1 + }; s.cycle_setting(idx, dir); None } diff --git a/esp32-emulator/src/verify_stopped.rs b/esp32-emulator/src/verify_stopped.rs index dd9056f..dba53dc 100644 --- a/esp32-emulator/src/verify_stopped.rs +++ b/esp32-emulator/src/verify_stopped.rs @@ -1,6 +1,6 @@ +use autoblink_core_prolink::{MAGIC, PKT_ABS_POSITION, PKT_MIXER_STATUS}; use std::net::UdpSocket; use std::time::Duration; -use autoblink_core_prolink::{MAGIC, PKT_ABS_POSITION, PKT_MIXER_STATUS}; fn main() -> anyhow::Result<()> { let sock = UdpSocket::bind("0.0.0.0:0")?; diff --git a/esp32/src/dmx.rs b/esp32/src/dmx.rs index 67ae580..291bdee 100644 --- a/esp32/src/dmx.rs +++ b/esp32/src/dmx.rs @@ -33,7 +33,11 @@ pub struct Dmx { impl Dmx { /// Starts the frame thread. `rx` is not used. The UART driver of ESP-IDF 5.5 does not install /// without a receive buffer, so the driver gets the input-only pin IO36. - pub fn start(uart: UART1<'static>, tx: Gpio4<'static>, rx: Gpio36<'static>) -> anyhow::Result { + pub fn start( + uart: UART1<'static>, + tx: Gpio4<'static>, + rx: Gpio36<'static>, + ) -> anyhow::Result { let cfg = config::Config::new() .baudrate(Hertz(250_000)) .stop_bits(config::StopBits::STOP2); @@ -44,9 +48,10 @@ impl Dmx { // Byte 0 is the start code (0 for dimmer data). Bytes 1 to 512 are the channels. let mut frame = [0u8; 513]; loop { - let level = last_beat.lock().unwrap().map_or(0.0, |t| { - 1.0 - t.elapsed().as_secs_f32() * 1000.0 / FLASH_MS - }); + let level = last_beat + .lock() + .unwrap() + .map_or(0.0, |t| 1.0 - t.elapsed().as_secs_f32() * 1000.0 / FLASH_MS); frame[1] = (level.clamp(0.0, 1.0) * 255.0) as u8; // The UART sends the frame, then the break. The break before the next frame // and the idle time after it make the reset sequence of DMX512. diff --git a/esp32/src/logring.rs b/esp32/src/logring.rs index 2a659b3..e310c23 100644 --- a/esp32/src/logring.rs +++ b/esp32/src/logring.rs @@ -56,7 +56,13 @@ impl Log for Logger { let mut line = String::new(); // SAFETY: esp_log_timestamp has no preconditions. let ms = unsafe { sys::esp_log_timestamp() }; - let _ = write!(line, "{ms} {} {}: {}", &record.level().as_str()[..1], record.target(), record.args()); + let _ = write!( + line, + "{ms} {} {}: {}", + &record.level().as_str()[..1], + record.target(), + record.args() + ); if line.len() > MAX_LINE { let mut end = MAX_LINE; while !line.is_char_boundary(end) { @@ -111,7 +117,11 @@ pub fn init() { // SAFETY: this function holds LOCK. The volatile read accepts the random data after a power-on. let valid = unsafe { addr_of!((*r).magic).read_volatile() } == MAGIC; // SAFETY: a valid magic number shows that the previous start wrote the ring. - let previous = if valid { contents(unsafe { &*r }) } else { String::new() }; + let previous = if valid { + contents(unsafe { &*r }) + } else { + String::new() + }; // SAFETY: this function holds LOCK. unsafe { addr_of_mut!((*r).magic).write(MAGIC); @@ -137,9 +147,17 @@ pub fn text() -> String { contents(unsafe { &*ring() }) }; let previous = PREVIOUS.get().map_or("", String::as_str); - let previous = if previous.is_empty() { "(no lines: the box started from power-on)\n" } else { previous }; + let previous = if previous.is_empty() { + "(no lines: the box started from power-on)\n" + } else { + previous + }; let crash = CRASH.lock().unwrap_or_else(|e| e.into_inner()); - let crash = if crash.is_empty() { "(no core dump)\n" } else { crash.as_str() }; + let crash = if crash.is_empty() { + "(no core dump)\n" + } else { + crash.as_str() + }; format!("=== The last crash ===\n{crash}=== The previous start ===\n{previous}=== This start ===\n{current}") } @@ -160,8 +178,12 @@ pub fn report_core_dump() { } } let mut reason = [0u8; 200]; - if sys::esp_core_dump_get_panic_reason(reason.as_mut_ptr().cast(), reason.len()) == sys::ESP_OK { - let reason = CStr::from_bytes_until_nul(&reason).map(CStr::to_string_lossy).unwrap_or_default(); + if sys::esp_core_dump_get_panic_reason(reason.as_mut_ptr().cast(), reason.len()) + == sys::ESP_OK + { + let reason = CStr::from_bytes_until_nul(&reason) + .map(CStr::to_string_lossy) + .unwrap_or_default(); crash_line(format!("The last crash: {reason}")); } let mut s: sys::esp_core_dump_summary_t = std::mem::zeroed(); @@ -169,13 +191,20 @@ pub fn report_core_dump() { let task = CStr::from_ptr(s.exc_task.as_ptr()).to_string_lossy(); let bt = &s.exc_bt_info; let depth = (bt.depth as usize).min(bt.bt.len()); - let addrs: Vec = bt.bt[..depth].iter().map(|a| format!("{a:#010x}")).collect(); + let addrs: Vec = bt.bt[..depth] + .iter() + .map(|a| format!("{a:#010x}")) + .collect(); crash_line(format!( "The last crash: task {task}, PC {:#010x}, cause {}, address {:#010x}{}", s.exc_pc, s.ex_info.exc_cause, s.ex_info.exc_vaddr, - if bt.corrupted { ", the backtrace is corrupt" } else { "" } + if bt.corrupted { + ", the backtrace is corrupt" + } else { + "" + } )); crash_line(format!("The last crash: backtrace {}", addrs.join(" "))); } diff --git a/esp32/src/main.rs b/esp32/src/main.rs index 499ca2d..8f5856e 100644 --- a/esp32/src/main.rs +++ b/esp32/src/main.rs @@ -28,15 +28,15 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; +use autoblink_core_prolink::Tracker; use esp_idf_svc::eventloop::EspSystemEventLoop; use esp_idf_svc::hal::cpu::Core; use esp_idf_svc::hal::gpio::PinDriver; -use esp_idf_svc::hal::task::thread::ThreadSpawnConfiguration; use esp_idf_svc::hal::peripherals::Peripherals; +use esp_idf_svc::hal::task::thread::ThreadSpawnConfiguration; use esp_idf_svc::mdns::EspMdns; use esp_idf_svc::nvs::EspDefaultNvsPartition; use esp_idf_svc::sys; -use autoblink_core_prolink::Tracker; use crate::midi::MidiStatus; use crate::net::{EthPins, AP_IP}; @@ -94,7 +94,10 @@ fn main() { fn run() -> anyhow::Result<()> { // SAFETY: esp_reset_reason has no preconditions. let reset_reason = reset_reason(unsafe { sys::esp_reset_reason() }); - log::info!("AUTOBLINK {} starts. Reason for the restart: {reset_reason}", env!("CARGO_PKG_VERSION")); + log::info!( + "AUTOBLINK {} starts. Reason for the restart: {reset_reason}", + env!("CARGO_PKG_VERSION") + ); logring::report_core_dump(); let p = Peripherals::take()?; let sysloop = EspSystemEventLoop::take()?; @@ -106,7 +109,9 @@ fn run() -> anyhow::Result<()> { let mut mac = [0u8; 6]; // SAFETY: the buffer has the 6 bytes that a MAC address needs. - sys::esp!(unsafe { sys::esp_read_mac(mac.as_mut_ptr(), sys::esp_mac_type_t_ESP_MAC_WIFI_SOFTAP) })?; + sys::esp!(unsafe { + sys::esp_read_mac(mac.as_mut_ptr(), sys::esp_mac_type_t_ESP_MAC_WIFI_SOFTAP) + })?; let default_ssid = format!("autoblink-{:02X}{:02X}", mac[4], mac[5]); let settings = Settings::new(nvs, default_ssid)?; let ssid = settings.ssid(); @@ -118,7 +123,9 @@ fn run() -> anyhow::Result<()> { log::info!("WiFi \"{join}\": the firmware joins this network"); } // mDNS answers on all networks. Without it, the page is still at its IP addresses. - let _mdns = start_mdns().inspect_err(|e| log::warn!("mDNS is off: {e}")).ok(); + let _mdns = start_mdns() + .inspect_err(|e| log::warn!("mDNS is off: {e}")) + .ok(); let (eth, _osc) = net::start_ethernet( EthPins { @@ -142,8 +149,14 @@ fn run() -> anyhow::Result<()> { log::info!("Firmware update: the firmware starts only the network for the download"); let deadline = Instant::now() + Duration::from_secs(30); while Instant::now() < deadline { - let sta = wifi.sta_netif().get_ip_info().is_ok_and(|i| !i.ip.is_unspecified()); - let eth_ip = eth.netif().get_ip_info().is_ok_and(|i| !i.ip.is_unspecified() && !i.ip.is_link_local()); + let sta = wifi + .sta_netif() + .get_ip_info() + .is_ok_and(|i| !i.ip.is_unspecified()); + let eth_ip = eth + .netif() + .get_ip_info() + .is_ok_and(|i| !i.ip.is_unspecified() && !i.ip.is_link_local()); if sta || eth_ip { break; } @@ -172,7 +185,13 @@ fn run() -> anyhow::Result<()> { }), start: Instant::now(), }); - let midi_beats = midi::start(p.uart2, p.pins.gpio17, p.pins.gpio35, state.clone(), settings.midi.clone())?; + let midi_beats = midi::start( + p.uart2, + p.pins.gpio17, + p.pins.gpio35, + state.clone(), + settings.midi.clone(), + )?; let device_number = settings.device_number.clone(); let settings = Arc::new(Mutex::new(settings)); let (_server, web_beats) = webui::start(state.clone(), settings.clone())?; @@ -227,8 +246,20 @@ fn run() -> anyhow::Result<()> { loop { state.tracker.lock().unwrap().expire(state.now_ms()); // Free the track data of a deck that left the network. - let decks: Vec = state.tracker.lock().unwrap().decks().iter().map(|d| d.num).collect(); - state.tracks.0.lock().unwrap().retain(|n, _| decks.contains(n)); + let decks: Vec = state + .tracker + .lock() + .unwrap() + .decks() + .iter() + .map(|d| d.num) + .collect(); + state + .tracks + .0 + .lock() + .unwrap() + .retain(|n, _| decks.contains(n)); settings.lock().unwrap().save_midi(); // SAFETY: the heap calls only read counters. let (free, min, block) = unsafe { @@ -241,7 +272,9 @@ fn run() -> anyhow::Result<()> { // The time of this line in GET /log shows the event that used the heap in the last second. if min.saturating_add(HEAP_LOW_STEP) <= heap_low { heap_low = min; - log::warn!("Heap low point: {min} bytes. Now {free} bytes free, largest block {block} bytes"); + log::warn!( + "Heap low point: {min} bytes. Now {free} bytes free, largest block {block} bytes" + ); } if Instant::now() >= next_heap_log { next_heap_log += Duration::from_secs(60); @@ -258,7 +291,10 @@ fn run() -> anyhow::Result<()> { join_retry = JOIN_RETRY; } else { join_retry = (join_retry * 2).min(JOIN_RETRY_MAX); - log::info!("WiFi \"{name}\": the firmware tries to join. The next try is in {} s", join_retry.as_secs()); + log::info!( + "WiFi \"{name}\": the firmware tries to join. The next try is in {} s", + join_retry.as_secs() + ); if let Err(e) = wifi.connect() { log::warn!("WiFi: the connection did not start: {e}"); } @@ -266,7 +302,11 @@ fn run() -> anyhow::Result<()> { next_join = Instant::now() + join_retry; } - let info = eth.netif().get_ip_info().ok().filter(|i| !i.ip.is_unspecified()); + let info = eth + .netif() + .get_ip_info() + .ok() + .filter(|i| !i.ip.is_unspecified()); let eth_ip = info.map(|i| (i.ip, i.subnet.mask.0)); let sta_info = join_ssid .as_ref() @@ -288,8 +328,15 @@ fn run() -> anyhow::Result<()> { quiet_since = None; dwell = SWITCH_AFTER; } else if quiet_since.get_or_insert_with(Instant::now).elapsed() >= dwell { - let (from, to) = if use_sta { (sta_name, eth_name) } else { (eth_name, sta_name) }; - log::info!("No player on \"{from}\" for {} s. Pro DJ Link tries \"{to}\".", dwell.as_secs()); + let (from, to) = if use_sta { + (sta_name, eth_name) + } else { + (eth_name, sta_name) + }; + log::info!( + "No player on \"{from}\" for {} s. Pro DJ Link tries \"{to}\".", + dwell.as_secs() + ); use_sta = !use_sta; quiet_since = None; // Without a player on the two interfaces, the changes and their log lines become rare. @@ -312,7 +359,9 @@ fn run() -> anyhow::Result<()> { broadcast: broadcast(i.ip, i.subnet.mask.0), mac, }); - state.wifi_join_down.store(join_ssid.is_some() && sta_ip.is_none(), Ordering::Relaxed); + state + .wifi_join_down + .store(join_ssid.is_some() && sta_ip.is_none(), Ordering::Relaxed); let now = (eth_ip.map(|(ip, _)| ip), sta_ip); if now != last { if now.0 != last.0 { @@ -353,7 +402,9 @@ fn run() -> anyhow::Result<()> { None => log::info!("WiFi \"{name}\" is not connected"), } } - let mut iface = now.0.map_or("Ethernet: no address".into(), |ip| format!("Ethernet {ip}")); + let mut iface = now + .0 + .map_or("Ethernet: no address".into(), |ip| format!("Ethernet {ip}")); if let Some(ip) = now.1 { iface.push_str(&format!(" · WiFi {ip}")); } @@ -379,7 +430,11 @@ pub fn restart_soon() -> std::io::Result<()> { /// Without the spawn configuration, each Rust thread has the task name "pthread". The thread has /// priority 5 (CONFIG_PTHREAD_TASK_PRIO_DEFAULT), the priority of the HTTP server task. It runs on /// the two cores. -pub fn spawn(name: &'static CStr, stack_size: usize, f: F) -> std::io::Result> +pub fn spawn( + name: &'static CStr, + stack_size: usize, + f: F, +) -> std::io::Result> where F: FnOnce() -> T + Send + 'static, T: Send + 'static, @@ -404,7 +459,13 @@ where { // The configuration applies to the threads that the calling thread starts. Reset it after // the start, so that no other thread gets this name. - let _ = ThreadSpawnConfiguration { name: Some(name), priority, pin_to_core: core, ..Default::default() }.set(); + let _ = ThreadSpawnConfiguration { + name: Some(name), + priority, + pin_to_core: core, + ..Default::default() + } + .set(); let handle = thread::Builder::new() .name(name.to_string_lossy().into_owned()) .stack_size(stack_size) @@ -434,7 +495,9 @@ fn start_mdns() -> anyhow::Result { /// Returns `true` when `a` and `b` are in one subnet with a prefix of `prefix` bits. fn same_subnet(a: Ipv4Addr, b: Ipv4Addr, prefix: u8) -> bool { - let mask = u32::MAX.checked_shl(32 - u32::from(prefix.min(32))).unwrap_or(0); + let mask = u32::MAX + .checked_shl(32 - u32::from(prefix.min(32))) + .unwrap_or(0); u32::from(a) & mask == u32::from(b) & mask } diff --git a/esp32/src/mem.rs b/esp32/src/mem.rs index 7e4d29e..6a9caa7 100644 --- a/esp32/src/mem.rs +++ b/esp32/src/mem.rs @@ -35,10 +35,15 @@ pub fn report() -> String { // Tasks can start between the two calls, so leave space for some more. let mut tasks = vec![sys::TaskStatus_t::default(); count + 4]; // SAFETY: the array has `tasks.len()` entries. The total run time can be null. - let n = unsafe { sys::uxTaskGetSystemState(tasks.as_mut_ptr(), tasks.len() as u32, std::ptr::null_mut()) }; + let n = unsafe { + sys::uxTaskGetSystemState(tasks.as_mut_ptr(), tasks.len() as u32, std::ptr::null_mut()) + }; tasks.truncate(n as usize); tasks.sort_by_key(|t| std::cmp::Reverse(t.usStackHighWaterMark)); - let _ = writeln!(out, "\nFree stack of each task (low point since the start, in bytes):"); + let _ = writeln!( + out, + "\nFree stack of each task (low point since the start, in bytes):" + ); for t in &tasks { // SAFETY: FreeRTOS keeps the name of a live task in its control block. let name = unsafe { CStr::from_ptr(t.pcTaskName) }.to_string_lossy(); diff --git a/esp32/src/midi.rs b/esp32/src/midi.rs index 4f97e4e..4235453 100644 --- a/esp32/src/midi.rs +++ b/esp32/src/midi.rs @@ -21,6 +21,9 @@ use std::collections::VecDeque; 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::{Clock, Mapper, MidiConfig, Out, Phrase, Smoother, Snapshot, CLOCK}; use esp_idf_svc::hal::cpu::Core; use esp_idf_svc::hal::delay::TickType; use esp_idf_svc::hal::gpio::{Gpio17, Gpio35}; @@ -28,9 +31,6 @@ use esp_idf_svc::hal::task::queue::Queue; use esp_idf_svc::hal::uart::{config, UartDriver, UART2}; use esp_idf_svc::hal::units::Hertz; use esp_idf_svc::sys; -use autoblink_core_midi::clock::Master; -use autoblink_core_dbserver::{current_phrase_idx, phrase_info}; -use autoblink_core_midi::{Clock, Mapper, MidiConfig, Out, Phrase, Smoother, Snapshot, CLOCK}; use crate::State; @@ -140,7 +140,14 @@ pub fn start( last_master = b.device; } smoothed_bpm = smoother.push(b.bpm, cfg.smoothing_ms); - clock.beat(&cfg, b.at_us, b.device, b.beat_in_bar, smoothed_bpm, &mut send); + clock.beat( + &cfg, + b.at_us, + b.device, + b.beat_in_bar, + smoothed_bpm, + &mut send, + ); mapper.beat(&cfg, b.beat_in_bar, &mut send); } @@ -150,7 +157,11 @@ pub fn start( tracker.master().map_or((Snapshot::default(), None), |m| { let snap = Snapshot { device: m.num, - bpm: if m.num == last_master && smoothed_bpm > 0.0 { smoothed_bpm } else { m.bpm }, + bpm: if m.num == last_master && smoothed_bpm > 0.0 { + smoothed_bpm + } else { + m.bpm + }, pitch: m.pitch, phrase16: m.phrase16, beat_phase: m.beat_phase(now_ms), diff --git a/esp32/src/net.rs b/esp32/src/net.rs index f789e16..792e180 100644 --- a/esp32/src/net.rs +++ b/esp32/src/net.rs @@ -8,7 +8,10 @@ use std::time::Duration; use esp_idf_svc::eth::{EspEth, EthDriver, RmiiClockConfig, RmiiEth, RmiiEthChipset}; use esp_idf_svc::eventloop::EspSystemEventLoop; -use esp_idf_svc::hal::gpio::{self, Gpio0, Gpio16, Gpio18, Gpio19, Gpio21, Gpio22, Gpio23, Gpio25, Gpio26, Gpio27, Output, PinDriver}; +use esp_idf_svc::hal::gpio::{ + self, Gpio0, Gpio16, Gpio18, Gpio19, Gpio21, Gpio22, Gpio23, Gpio25, Gpio26, Gpio27, Output, + PinDriver, +}; use esp_idf_svc::hal::mac::MAC; use esp_idf_svc::hal::modem::Modem; use esp_idf_svc::ipv4::{self, Mask, RouterConfiguration, Subnet}; @@ -28,7 +31,11 @@ pub const MDNS_NAME: &str = "autoblink"; /// `netif` removes the binding. pub fn bind_device(fd: RawFd, netif: &str) -> io::Result<()> { let mut req = sys::ifreq::default(); - for (dst, src) in req.ifr_name.iter_mut().zip(netif.bytes().take(sys::IFNAMSIZ as usize - 1)) { + for (dst, src) in req + .ifr_name + .iter_mut() + .zip(netif.bytes().take(sys::IFNAMSIZ as usize - 1)) + { *dst = src as _; } // SAFETY: `req` is a valid `ifreq`, and the length is its size. @@ -41,7 +48,11 @@ pub fn bind_device(fd: RawFd, netif: &str) -> io::Result<()> { std::mem::size_of::() as _, ) }; - if r == 0 { Ok(()) } else { Err(io::Error::last_os_error()) } + if r == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } } /// Opens a TCP connection to `addr` on the network interface `netif`. An empty `netif` is for @@ -66,7 +77,9 @@ pub fn connect_on(netif: &str, addr: &SocketAddr, timeout: Duration) -> io::Resu sin_len: std::mem::size_of::() as _, sin_family: sys::AF_INET as _, sin_port: v4.port().to_be(), - sin_addr: sys::in_addr { s_addr: u32::from_ne_bytes(v4.ip().octets()) }, + sin_addr: sys::in_addr { + s_addr: u32::from_ne_bytes(v4.ip().octets()), + }, sin_zero: Default::default(), }; // SAFETY: `sa` is a valid `sockaddr_in`, and the length is its size. @@ -82,7 +95,11 @@ pub fn connect_on(netif: &str, addr: &SocketAddr, timeout: Duration) -> io::Resu if e.raw_os_error() != Some(sys::EINPROGRESS as i32) { return Err(e); } - let mut p = sys::pollfd { fd, events: sys::POLLOUT as _, revents: 0 }; + let mut p = sys::pollfd { + fd, + events: sys::POLLOUT as _, + revents: 0, + }; // SAFETY: `p` is one valid `pollfd`. match unsafe { sys::poll(&mut p, 1, timeout.as_millis() as _) } { 0 => return Err(io::ErrorKind::TimedOut.into()), @@ -164,7 +181,10 @@ pub fn start_wifi( let driver = WifiDriver::new(modem, sysloop, None)?; let ap_netif = EspNetif::new_with_conf(&NetifConfiguration { ip_configuration: Some(ipv4::Configuration::Router(RouterConfiguration { - subnet: Subnet { gateway: AP_IP, mask: Mask(24) }, + subnet: Subnet { + gateway: AP_IP, + mask: Mask(24), + }, dhcp_enabled: true, dns: None, secondary_dns: None, @@ -173,8 +193,12 @@ pub fn start_wifi( })?; let mut wifi = EspWifi::wrap_all(driver, EspNetif::new(NetifStack::Sta)?, ap_netif)?; let ap = AccessPointConfiguration { - ssid: ssid.try_into().map_err(|_| anyhow::anyhow!("The WiFi name is too long"))?, - password: password.try_into().map_err(|_| anyhow::anyhow!("The WiFi password is too long"))?, + ssid: ssid + .try_into() + .map_err(|_| anyhow::anyhow!("The WiFi name is too long"))?, + password: password + .try_into() + .map_err(|_| anyhow::anyhow!("The WiFi password is too long"))?, auth_method: AuthMethod::WPA2Personal, channel: 6, max_connections: 4, @@ -186,12 +210,19 @@ pub fn start_wifi( return Ok(wifi); }; let client = ClientConfiguration { - ssid: join_ssid.as_str().try_into().map_err(|_| anyhow::anyhow!("The join WiFi name is too long"))?, + ssid: join_ssid + .as_str() + .try_into() + .map_err(|_| anyhow::anyhow!("The join WiFi name is too long"))?, password: join_password .as_str() .try_into() .map_err(|_| anyhow::anyhow!("The join WiFi password is too long"))?, - auth_method: if join_password.is_empty() { AuthMethod::None } else { AuthMethod::WPA2Personal }, + auth_method: if join_password.is_empty() { + AuthMethod::None + } else { + AuthMethod::WPA2Personal + }, ..Default::default() }; wifi.set_configuration(&Configuration::Mixed(client, ap))?; diff --git a/esp32/src/ota.rs b/esp32/src/ota.rs index d53b620..5a32c98 100644 --- a/esp32/src/ota.rs +++ b/esp32/src/ota.rs @@ -66,7 +66,6 @@ pub struct OtaStatus { pub error: Option, } - /// `true` while an update thread runs. static BUSY: AtomicBool = AtomicBool::new(false); @@ -120,8 +119,12 @@ pub fn run(state: Arc, cmd: OtaCmd) -> Result<(), String> { fn start_check(state: Arc) -> Result<(), String> { // SAFETY: the heap calls only read counters. - let (free, block) = - unsafe { (sys::esp_get_free_heap_size(), sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT)) }; + let (free, block) = unsafe { + ( + sys::esp_get_free_heap_size(), + sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT), + ) + }; if free < CHECK_MIN_FREE || block < CHECK_MIN_BLOCK { return Err(format!( "Not enough memory for the check: {free} bytes free, largest block {block} bytes. \ @@ -138,7 +141,9 @@ fn start_check(state: Arc) -> Result<(), String> { } BUSY.store(false, Ordering::Release); }); - spawned.map(|_| ()).map_err(|e| format!("The firmware update cannot start a thread: {e}")) + spawned + .map(|_| ()) + .map_err(|e| format!("The firmware update cannot start a thread: {e}")) } fn request_install(state: &State) -> Result<(), String> { @@ -182,7 +187,11 @@ pub fn mark_valid() { // not fit into its strings. Thus read the label of the partition directly. // SAFETY: the running partition is a static entry of the partition table, or null. let label = unsafe { sys::esp_ota_get_running_partition().as_ref() } - .map(|p| unsafe { std::ffi::CStr::from_ptr(p.label.as_ptr()) }.to_string_lossy().into_owned()) + .map(|p| { + unsafe { std::ffi::CStr::from_ptr(p.label.as_ptr()) } + .to_string_lossy() + .into_owned() + }) .unwrap_or_default(); match EspOta::new().and_then(|mut ota| ota.mark_running_slot_valid()) { Ok(()) => log::info!("The app in slot {label} is marked valid"), @@ -207,7 +216,12 @@ fn check(state: &Mutex) -> anyhow::Result { let newer = newer(&latest, env!("CARGO_PKG_VERSION")) .ok_or_else(|| anyhow!("The release has the version {latest:?}. It is not x.y.z."))?; log::info!("Firmware update: the release has the version {latest}"); - set(state, if newer { "available" } else { "up-to-date" }, Some(latest), 0); + set( + state, + if newer { "available" } else { "up-to-date" }, + Some(latest), + 0, + ); Ok(newer) } @@ -232,7 +246,8 @@ fn install(state: &Mutex) -> anyhow::Result<()> { let mut ota = EspOta::new()?; // Erase each sector before the write to it. One erase of the full slot stops the other // tasks for some seconds. - let mut update = ota.initiate_update_with_known_size(sys::OTA_WITH_SEQUENTIAL_WRITES as usize)?; + let mut update = + ota.initiate_update_with_known_size(sys::OTA_WITH_SEQUENTIAL_WRITES as usize)?; let mut buf = vec![0u8; 4096]; let mut done = 0; loop { @@ -249,7 +264,10 @@ fn install(state: &Mutex) -> anyhow::Result<()> { } // esp_ota_end examines the image. It refuses an image for another chip or with a bad checksum. update.complete()?; - log::info!("Firmware update: {} is written. The firmware restarts.", latest.as_deref().unwrap_or("?")); + log::info!( + "Firmware update: {} is written. The firmware restarts.", + latest.as_deref().unwrap_or("?") + ); thread::sleep(Duration::from_secs(1)); // SAFETY: esp_restart has no preconditions. unsafe { sys::esp_restart() } @@ -267,7 +285,9 @@ fn get(url: &str) -> anyhow::Result { ..Default::default() })?; conn.initiate_request(Method::Get, url, &[]) - .with_context(|| format!("No connection to {url}. The box needs a network with internet."))?; + .with_context(|| { + format!("No connection to {url}. The box needs a network with internet.") + })?; conn.initiate_response()?; match conn.status() { 200 => Ok(conn), @@ -291,7 +311,10 @@ fn fix_dns() { } let mut dns = sys::esp_netif_dns_info_t::default(); // SAFETY: `sta` is a live netif, and `dns` is a valid output buffer. - if unsafe { sys::esp_netif_get_dns_info(sta, sys::esp_netif_dns_type_t_ESP_NETIF_DNS_MAIN, &mut dns) } != sys::ESP_OK { + if unsafe { + sys::esp_netif_get_dns_info(sta, sys::esp_netif_dns_type_t_ESP_NETIF_DNS_MAIN, &mut dns) + } != sys::ESP_OK + { return; } // SAFETY: the ip4 member is valid for each address type, because it is at the start of the union. @@ -303,11 +326,18 @@ fn fix_dns() { dns.ip.u_addr.ip4.addr = u32::from_ne_bytes(PUBLIC_DNS.octets()); dns.ip.type_ = sys::ESP_IPADDR_TYPE_V4 as u8; // SAFETY: `sta` is a live netif, and `dns` holds an IPv4 address. - unsafe { sys::esp_netif_set_dns_info(sta, sys::esp_netif_dns_type_t_ESP_NETIF_DNS_MAIN, &mut dns) }; + unsafe { + sys::esp_netif_set_dns_info(sta, sys::esp_netif_dns_type_t_ESP_NETIF_DNS_MAIN, &mut dns) + }; } fn set(state: &Mutex, name: &'static str, latest: Option, progress: u8) { - *state.lock().unwrap() = OtaStatus { state: name, latest, progress, error: None }; + *state.lock().unwrap() = OtaStatus { + state: name, + latest, + progress, + error: None, + }; } /// Returns `Some(true)` when the version `a` is newer than `b`. Returns `None` when `a` or `b` is diff --git a/esp32/src/prolink.rs b/esp32/src/prolink.rs index 516ccd6..1afcf6f 100644 --- a/esp32/src/prolink.rs +++ b/esp32/src/prolink.rs @@ -25,13 +25,13 @@ use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; -use esp_idf_svc::hal::cpu::Core; -use esp_idf_svc::sys; use autoblink_core_prolink::{ build_announce, build_claim1, build_claim2, build_claim_final, build_keepalive, build_status_packet, has_magic, pad_name, parse_abs_position, parse_beat, parse_cdj_status, parse_keepalive, TrackRef, PKT_CONFLICT, PORT_BEAT, PORT_DISCOVERY, PORT_STATUS, }; +use esp_idf_svc::hal::cpu::Core; +use esp_idf_svc::sys; use crate::midi::Beat; use crate::State; @@ -82,7 +82,11 @@ pub fn spawn( } // A CDJ-3000 class status packet has more than 0x1c0 bytes. let mut buf = [0u8; 1500]; - let mut polls = fds.map(|fd| sys::pollfd { fd, events: sys::POLLIN as _, revents: 0 }); + let mut polls = fds.map(|fd| sys::pollfd { + fd, + events: sys::POLLIN as _, + revents: 0, + }); loop { // SAFETY: the task subscribed above. A reset of an unsubscribed task only gives an // error code. @@ -115,7 +119,9 @@ pub fn spawn( } })?; - crate::spawn(c"virtual-cdj", 4608, move || virtual_cdj(&sender, &state, &device_number, &fds))?; + crate::spawn(c"virtual-cdj", 4608, move || { + virtual_cdj(&sender, &state, &device_number, &fds) + })?; Ok(()) } @@ -141,7 +147,11 @@ fn on_beat_port(st: &State, data: &[u8], on_master_beat: &impl Fn(Beat)) { let is_master = st.tracker.lock().unwrap().on_beat(&beat, now); if is_master { if beat.beat_in_bar == 1 { - log::debug!("Bar: deck {} at {:.2} BPM", beat.device_number, beat.effective_bpm); + log::debug!( + "Bar: deck {} at {:.2} BPM", + beat.device_number, + beat.effective_bpm + ); } on_master_beat(Beat { device: beat.device_number, @@ -154,13 +164,18 @@ fn on_beat_port(st: &State, data: &[u8], on_master_beat: &impl Fn(Beat)) { /// Handles a packet on port 50002: the CDJ status of a player. fn on_status(st: &State, data: &[u8], on_track: &impl Fn(TrackRef)) { - let Some(cdj) = parse_cdj_status(data) else { return }; + let Some(cdj) = parse_cdj_status(data) else { + return; + }; let now = st.now_ms(); let change = st.tracker.lock().unwrap().on_cdj_status(&cdj, now); if let Some(track) = change { log::info!( "Deck {} loaded track {} (slot {}, player {})", - track.device, track.rekordbox_id, track.slot, track.source_player + track.device, + track.rekordbox_id, + track.slot, + track.source_player ); on_track(track); } @@ -212,7 +227,14 @@ fn virtual_cdj(sock: &UdpSocket, state: &State, wanted: &AtomicU8, fds: &[RawFd] errors = 0; } // The players send their status only to a device that sends its own status to them. - let ips: Vec<[u8; 4]> = state.tracker.lock().unwrap().decks().iter().map(|d| d.ip).collect(); + let ips: Vec<[u8; 4]> = state + .tracker + .lock() + .unwrap() + .decks() + .iter() + .map(|d| d.ip) + .collect(); for ip in ips.into_iter().filter(|ip| *ip != [0; 4]) { let _ = sock.send_to(&status, SocketAddrV4::new(ip.into(), PORT_STATUS)); } @@ -242,7 +264,13 @@ fn bind_netif(fds: &[RawFd], netif: &str) -> bool { /// Sends the announce and claim packets. Returns the claimed device number. When another device /// reports a conflict, the next number is tried. -fn join(sock: &UdpSocket, state: &State, name: &[u8; 20], eth: &LinkInfo, want: u8) -> anyhow::Result { +fn join( + sock: &UdpSocket, + state: &State, + name: &[u8; 20], + eth: &LinkInfo, + want: u8, +) -> anyhow::Result { let bcast = SocketAddrV4::new(eth.broadcast, PORT_DISCOVERY); let ip = eth.ip.octets(); let send = |pkt: &[u8]| -> anyhow::Result<()> { @@ -265,7 +293,10 @@ fn join(sock: &UdpSocket, state: &State, name: &[u8; 20], eth: &LinkInfo, want: let deadline = Instant::now() + CLAIM_INTERVAL; while Instant::now() < deadline { if state.conflict.swap(0, Ordering::Relaxed) == claimed { - log::warn!("Device number {claimed} is in use. The virtual CDJ tries {}.", claimed + 1); + log::warn!( + "Device number {claimed} is in use. The virtual CDJ tries {}.", + claimed + 1 + ); claimed += 1; anyhow::ensure!(claimed <= 15, "No free device number is available"); continue 'claim; diff --git a/esp32/src/settings.rs b/esp32/src/settings.rs index 85e7c58..57e451d 100644 --- a/esp32/src/settings.rs +++ b/esp32/src/settings.rs @@ -8,8 +8,8 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault}; use autoblink_core_midi::{settings as midi_settings, MidiConfig, SettingItem}; +use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault}; /// The host index of "Device Number": the number that the virtual CDJ asks for. const IDX_DEVICE: usize = 2; @@ -43,7 +43,11 @@ impl Settings { .flatten() .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); - let device = nvs.get_u8("devnum").ok().flatten().filter(|n| (1..=15).contains(n)); + let device = nvs + .get_u8("devnum") + .ok() + .flatten() + .filter(|n| (1..=15).contains(n)); Ok(Self { nvs, default_ssid, @@ -54,7 +58,8 @@ impl Settings { } pub fn ssid(&self) -> String { - self.get("ssid").unwrap_or_else(|| self.default_ssid.clone()) + self.get("ssid") + .unwrap_or_else(|| self.default_ssid.clone()) } pub fn password(&self) -> String { @@ -93,15 +98,53 @@ impl Settings { let join = self.join().map(|(s, _)| s).unwrap_or_default(); let mut items = vec![SettingItem { section: None, - ..row(IDX_DEVICE, "Device Number", "number", device.clone(), Some(device)) + ..row( + IDX_DEVICE, + "Device Number", + "number", + device.clone(), + Some(device), + ) }]; items.extend(midi_settings::items(&self.midi.lock().unwrap())); - items.push(row(IDX_SSID, "WiFi name (applies after restart)", "text", ssid.clone(), Some(ssid))); - items.push(row(IDX_PASSWORD, "WiFi password (applies after restart)", "text", hidden(), None)); - let shown = if join.is_empty() { "off".to_string() } else { join.clone() }; - items.push(row(IDX_JOIN_SSID, "Join WiFi name (applies after restart)", "text", shown, Some(join))); - items.push(row(IDX_JOIN_PASSWORD, "Join WiFi password (applies after restart)", "text", hidden(), None)); - serde_json::to_string(&Message { kind: "settings", items }).unwrap_or_default() + items.push(row( + IDX_SSID, + "WiFi name (applies after restart)", + "text", + ssid.clone(), + Some(ssid), + )); + items.push(row( + IDX_PASSWORD, + "WiFi password (applies after restart)", + "text", + hidden(), + None, + )); + let shown = if join.is_empty() { + "off".to_string() + } else { + join.clone() + }; + items.push(row( + IDX_JOIN_SSID, + "Join WiFi name (applies after restart)", + "text", + shown, + Some(join), + )); + items.push(row( + IDX_JOIN_PASSWORD, + "Join WiFi password (applies after restart)", + "text", + hidden(), + None, + )); + serde_json::to_string(&Message { + kind: "settings", + items, + }) + .unwrap_or_default() } /// Stores a typed value. Returns the error text for the page when the value is not valid. @@ -112,26 +155,37 @@ impl Settings { .parse::() .ok() .filter(|n| (1..=15).contains(n)) - .ok_or_else(|| format!("Value \"{value}\" is out of range for setting {idx}"))?; - self.nvs.set_u8("devnum", n).map_err(|e| format!("Cannot save the setting: {e}"))?; + .ok_or_else(|| { + format!("Value \"{value}\" is out of range for setting {idx}") + })?; + self.nvs + .set_u8("devnum", n) + .map_err(|e| format!("Cannot save the setting: {e}"))?; self.device_number.store(n, Ordering::Relaxed); log::info!("Device number changed to {n}. The virtual CDJ joins again."); Ok(()) } IDX_SSID if (1..=32).contains(&value.len()) => self.put("ssid", value), IDX_SSID => Err("The WiFi name must have 1 to 32 bytes".into()), - IDX_PASSWORD if (8..=63).contains(&value.len()) && value.is_ascii() => self.put("pass", value), + IDX_PASSWORD if (8..=63).contains(&value.len()) && value.is_ascii() => { + self.put("pass", value) + } IDX_PASSWORD => Err("The WiFi password must have 8 to 63 ASCII characters".into()), // An empty name stops the join. The page cannot send an empty password, so the // password goes too. Then the next name can be an open network. - IDX_JOIN_SSID if value.is_empty() => self.remove("jpass").and_then(|()| self.remove("jssid")), + IDX_JOIN_SSID if value.is_empty() => { + self.remove("jpass").and_then(|()| self.remove("jssid")) + } IDX_JOIN_SSID if value.len() <= 32 => self.put("jssid", value), IDX_JOIN_SSID => Err("The WiFi name must have 0 to 32 bytes".into()), IDX_JOIN_PASSWORD if value.is_empty() => self.remove("jpass"), - IDX_JOIN_PASSWORD if (8..=63).contains(&value.len()) && value.is_ascii() => self.put("jpass", value), - IDX_JOIN_PASSWORD => { - Err("The WiFi password must be empty (open network) or have 8 to 63 ASCII characters".into()) + IDX_JOIN_PASSWORD if (8..=63).contains(&value.len()) && value.is_ascii() => { + self.put("jpass", value) } + IDX_JOIN_PASSWORD => Err( + "The WiFi password must be empty (open network) or have 8 to 63 ASCII characters" + .into(), + ), _ => self.change_midi(|cfg| midi_settings::set(cfg, idx, value)), } } @@ -145,7 +199,10 @@ impl Settings { /// /// An NVS write stops the flash cache of both cores for up to some 100 ms, and the MIDI clock /// stops with it. Many fast clicks on the page then give many stops. The delay gives one. - fn change_midi(&mut self, f: impl FnOnce(&mut MidiConfig) -> Result<(), String>) -> Result<(), String> { + fn change_midi( + &mut self, + f: impl FnOnce(&mut MidiConfig) -> Result<(), String>, + ) -> Result<(), String> { f(&mut self.midi.lock().unwrap())?; self.midi_changed = Some(Instant::now()); Ok(()) @@ -159,26 +216,37 @@ impl Settings { } self.midi_changed = None; let json = serde_json::to_string(&*self.midi.lock().unwrap()); - match json.map_err(anyhow::Error::from).and_then(|j| Ok(self.nvs.set_str("midi", &j)?)) { + match json + .map_err(anyhow::Error::from) + .and_then(|j| Ok(self.nvs.set_str("midi", &j)?)) + { Ok(()) => log::info!("The MIDI settings are saved"), Err(e) => log::warn!("Cannot save the MIDI settings: {e}"), } } fn put(&self, key: &str, value: &str) -> Result<(), String> { - self.nvs.set_str(key, value).map_err(|e| format!("Cannot save the setting: {e}"))?; + self.nvs + .set_str(key, value) + .map_err(|e| format!("Cannot save the setting: {e}"))?; log::info!("Setting {key} changed. It applies after a restart."); Ok(()) } fn remove(&self, key: &str) -> Result<(), String> { - self.nvs.remove(key).map_err(|e| format!("Cannot save the setting: {e}"))?; + self.nvs + .remove(key) + .map_err(|e| format!("Cannot save the setting: {e}"))?; log::info!("Setting {key} removed. It applies after a restart."); Ok(()) } fn get(&self, key: &str) -> Option { let mut buf = [0u8; 65]; - self.nvs.get_str(key, &mut buf).ok().flatten().map(str::to_owned) + self.nvs + .get_str(key, &mut buf) + .ok() + .flatten() + .map(str::to_owned) } } diff --git a/esp32/src/tracks.rs b/esp32/src/tracks.rs index 13329b3..1a99888 100644 --- a/esp32/src/tracks.rs +++ b/esp32/src/tracks.rs @@ -13,7 +13,9 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use autoblink_core_dbserver::{asking_player, fetch_with, SongStructure, TrackMetadata, TrackOverview}; +use autoblink_core_dbserver::{ + asking_player, fetch_with, SongStructure, TrackMetadata, TrackOverview, +}; use autoblink_core_prolink::TrackRef; use esp_idf_svc::sys; @@ -61,7 +63,13 @@ pub fn start(state: Arc) -> anyhow::Result> { tracks.remove(&track.device); continue; } - tracks.insert(track.device, TrackData { rekordbox_id: track.rekordbox_id, ..Default::default() }); + tracks.insert( + track.device, + TrackData { + rekordbox_id: track.rekordbox_id, + ..Default::default() + }, + ); drop(tracks); if fetch_one(&state, track) { continue; @@ -73,9 +81,18 @@ pub fn start(state: Arc) -> anyhow::Result> { while crate::ota::busy() && Instant::now() < until { thread::sleep(Duration::from_secs(1)); } - let loaded = state.tracker.lock().unwrap().deck(track.device).map(|d| d.track.rekordbox_id); + let loaded = state + .tracker + .lock() + .unwrap() + .deck(track.device) + .map(|d| d.track.rekordbox_id); if loaded == Some(track.rekordbox_id) { - log::info!("Deck {} track {}: the fetch starts again", track.device, track.rekordbox_id); + log::info!( + "Deck {} track {}: the fetch starts again", + track.device, + track.rekordbox_id + ); fetch_one(&state, track); } } @@ -89,39 +106,65 @@ fn fetch_one(state: &State, track: TrackRef) -> bool { let (source, players) = { let tracker = state.tracker.lock().unwrap(); // A track from rekordbox has a source player above 15, which is not a deck. - let source = tracker.device_address(track.source_player).map(|(name, ip)| (ip, name.to_owned())); - let players: Vec<(u8, u8)> = - tracker.decks().iter().map(|d| (d.num, d.track.source_player)).collect(); + let source = tracker + .device_address(track.source_player) + .map(|(name, ip)| (ip, name.to_owned())); + let players: Vec<(u8, u8)> = tracker + .decks() + .iter() + .map(|d| (d.num, d.track.source_player)) + .collect(); (source, players) }; // SAFETY: the two heap calls only read counters. - let (free, block) = - unsafe { (sys::esp_get_free_heap_size(), sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT)) }; + let (free, block) = unsafe { + ( + sys::esp_get_free_heap_size(), + sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT), + ) + }; let result = match source { - _ if free < MIN_FREE_HEAP || block < MIN_HEAP_BLOCK => { - Err(format!("not enough memory for the track data ({free} bytes free, largest block {block} bytes)")) - } + _ if free < MIN_FREE_HEAP || block < MIN_HEAP_BLOCK => Err(format!( + "not enough memory for the track data ({free} bytes free, largest block {block} bytes)" + )), Some((ip, name)) if ip != [0; 4] => { let own = state.own_device.load(Ordering::Relaxed); let asking = asking_player(own, track.source_player, &name, players); // Connect on the interface of the virtual CDJ. let netif = state.link.lock().unwrap().map_or("", |l| l.netif); let connect = |addr: &_, timeout| crate::net::connect_on(netif, addr, timeout); - fetch_with(&connect, ip.into(), asking, track.slot, track.track_type, track.rekordbox_id) - .map_err(|e| e.to_string()) + fetch_with( + &connect, + ip.into(), + asking, + track.slot, + track.track_type, + track.rekordbox_id, + ) + .map_err(|e| e.to_string()) } - _ => Err(format!("player {} is not on the network", track.source_player)), + _ => Err(format!( + "player {} is not on the network", + track.source_player + )), }; // The tracker checks the track ID itself. Lock it before the tracks, never inside them. if let Ok(f) = &result { if let Some(secs) = f.metadata.duration_s { - state.tracker.lock().unwrap().set_track_length(track.device, track.rekordbox_id, secs); + state + .tracker + .lock() + .unwrap() + .set_track_length(track.device, track.rekordbox_id, secs); } } let mut tracks = state.tracks.0.lock().unwrap(); // A newer track on the deck makes this result stale. - let Some(data) = tracks.get_mut(&track.device).filter(|d| d.rekordbox_id == track.rekordbox_id) else { + let Some(data) = tracks + .get_mut(&track.device) + .filter(|d| d.rekordbox_id == track.rekordbox_id) + else { return true; }; match result { @@ -129,14 +172,23 @@ fn fetch_one(state: &State, track: TrackRef) -> bool { for w in &f.warnings { log::warn!("Deck {} track {}: {w}", track.device, track.rekordbox_id); } - log::info!("Deck {}: {} - {}", track.device, f.metadata.artist, f.metadata.title); + log::info!( + "Deck {}: {} - {}", + track.device, + f.metadata.artist, + f.metadata.title + ); data.metadata = Some(f.metadata); data.structure = Some(f.song_structure); data.overview = f.overview.map(Arc::new); true } Err(e) => { - log::warn!("Deck {} track {}: the fetch failed: {e}", track.device, track.rekordbox_id); + log::warn!( + "Deck {} track {}: the fetch failed: {e}", + track.device, + track.rekordbox_id + ); data.structure = Some(Err(e)); false } diff --git a/esp32/src/webui.rs b/esp32/src/webui.rs index c06af57..fc65427 100644 --- a/esp32/src/webui.rs +++ b/esp32/src/webui.rs @@ -54,7 +54,9 @@ impl Active { fn send(&self, text: &[u8]) { // A send waits for the HTTP server task, and that task takes this lock when a connection // closes. Send without the lock, so that the two tasks cannot block each other. - let (generation, Some(mut sender)) = self.0.lock().unwrap().clone() else { return }; + let (generation, Some(mut sender)) = self.0.lock().unwrap().clone() else { + return; + }; if sender.send(FrameType::Text(false), text).is_err() { let mut active = self.0.lock().unwrap(); if active.0 == generation { @@ -106,21 +108,30 @@ pub fn start( stack_size: 8 * 1024, uri_match_wildcard: true, max_open_sockets: 6, - keep_alive: Some(KeepAlive { idle_secs: 5, interval_secs: 5, probe_count: 3 }), + keep_alive: Some(KeepAlive { + idle_secs: 5, + interval_secs: 5, + probe_count: 3, + }), ..Default::default() })?; let active = Arc::new(Active::default()); let wave_state = state.clone(); server.fn_handler("/waveform/*", Method::Get, move |req| { - let deck = req.uri().rsplit('/').next().and_then(|n| n.parse::().ok()); + let deck = req + .uri() + .rsplit('/') + .next() + .and_then(|n| n.parse::().ok()); let overview = deck.and_then(|n| { let tracks = wave_state.tracks.0.lock().unwrap(); tracks.get(&n).and_then(|t| t.overview.clone()) }); match overview { Some(o) => { - let mut resp = req.into_response(200, None, &[("Content-Type", "application/json")])?; + let mut resp = + req.into_response(200, None, &[("Content-Type", "application/json")])?; write_waveform(&mut resp, &o) } None => req.into_status_response(404)?.write_all(b"no waveform"), @@ -142,7 +153,10 @@ pub fn start( req.into_response( 200, None, - &[("Content-Type", "text/html; charset=utf-8"), ("Content-Encoding", "gzip")], + &[ + ("Content-Type", "text/html; charset=utf-8"), + ("Content-Encoding", "gzip"), + ], )? .write_all(INDEX_HTML_GZ) })?; @@ -195,7 +209,10 @@ pub fn start( // serde_json refuses that byte, so remove it. let text = buf.strip_suffix(&[0]).unwrap_or(&buf); let Ok(cmd) = serde_json::from_slice::(text) else { - return ws.send(FrameType::Text(false), error("The command is not valid JSON").as_bytes()); + return ws.send( + FrameType::Text(false), + error("The command is not valid JSON").as_bytes(), + ); }; if cmd["type"] == "take_over" { take_over(ws, &ws_active)?; @@ -224,7 +241,10 @@ pub fn start( match beat_rx.recv_timeout(next_status.saturating_duration_since(Instant::now())) { Ok((device, beat)) => { beat_msg.clear(); - let _ = write!(beat_msg, r#"{{"type":"beat","device":{device},"beat":{beat}}}"#); + let _ = write!( + beat_msg, + r#"{{"type":"beat","device":{device},"beat":{beat}}}"# + ); active.send(beat_msg.as_bytes()); } Err(RecvTimeoutError::Timeout) => {} @@ -277,12 +297,24 @@ fn write_waveform(w: &mut W, o: &TrackOverview) -> Result<(), W::Error text.clear(); } // The response is not a `std::io::Write`, so serde_json writes the tail into a buffer first. - let cues: Vec = - o.cues.iter().map(|c| CueJson { hot: c.hot_cue, start: c.start_ms, end: c.end_ms }).collect(); + let cues: Vec = o + .cues + .iter() + .map(|c| CueJson { + hot: c.hot_cue, + start: c.start_ms, + end: c.end_ms, + }) + .collect(); let phrases: Vec = o .phrases .iter() - .map(|p| SpanJson { kind: Disp(p.kind), start: p.start_ms, end: p.end_ms, fill: p.fill_ms }) + .map(|p| SpanJson { + kind: Disp(p.kind), + start: p.start_ms, + end: p.end_ms, + fill: p.fill_ms, + }) .collect(); let mut tail = br#"],"cues":"#.to_vec(); let _ = serde_json::to_writer(&mut tail, &cues); @@ -329,7 +361,14 @@ fn command(cmd: &Value, settings: &Mutex, state: &Arc) -> Strin None => Err("A set command needs a value".into()), } } else { - settings.cycle(idx, if cmd["dir"].as_i64() == Some(-1) { -1 } else { 1 }) + settings.cycle( + idx, + if cmd["dir"].as_i64() == Some(-1) { + -1 + } else { + 1 + }, + ) }; match result { Ok(()) => settings.message(), @@ -338,7 +377,11 @@ fn command(cmd: &Value, settings: &Mutex, state: &Arc) -> Strin } // The update thread does the work. The page sees the result in the "ota" field of the status. Some(kind @ ("ota_check" | "ota_install")) => { - let cmd = if kind == "ota_check" { OtaCmd::Check } else { OtaCmd::Install }; + let cmd = if kind == "ota_check" { + OtaCmd::Check + } else { + OtaCmd::Install + }; // The reply is small on purpose. The TLS handshake of the OTA thread takes about // 40 KB of heap, and the full settings message can then fail to allocate. match ota::run(state.clone(), cmd) { @@ -385,7 +428,10 @@ struct Src { impl Serialize for Src { fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(&format_args!("deck#{} {} (rb#{})", self.player, self.slot, self.id)) + serializer.collect_str(&format_args!( + "deck#{} {} (rb#{})", + self.player, self.slot, self.id + )) } } @@ -399,10 +445,14 @@ struct PhraseJson { /// Builds the track fields like `host/src/web/status.rs`. fn track_fields(d: &Deck, t: Option<&TrackData>) -> TrackFields { - let Some(t) = t.filter(|t| t.rekordbox_id == d.track.rekordbox_id && t.rekordbox_id != 0) else { + let Some(t) = t.filter(|t| t.rekordbox_id == d.track.rekordbox_id && t.rekordbox_id != 0) + else { return TrackFields::default(); }; - let mut f = TrackFields { wave: t.overview.as_ref().map(|o| o.rekordbox_id), ..Default::default() }; + let mut f = TrackFields { + wave: t.overview.as_ref().map(|o| o.rekordbox_id), + ..Default::default() + }; if let Some(m) = &t.metadata { let mut parts: Vec = [&m.artist, &m.title, &m.key] .into_iter() @@ -418,12 +468,15 @@ fn track_fields(d: &Deck, t: Option<&TrackData>) -> TrackFields { } match &t.structure { Some(Ok(ss)) => { - f.phrase = d.beat_count.and_then(|bc| phrase_status(ss, bc)).map(|p| PhraseJson { - kind: Disp(p.kind), - progress: p.progress, - fill: p.fill, - beats_left: p.beats_left, - }); + f.phrase = d + .beat_count + .and_then(|bc| phrase_status(ss, bc)) + .map(|p| PhraseJson { + kind: Disp(p.kind), + progress: p.progress, + fill: p.fill, + beats_left: p.beats_left, + }); } Some(Err(reason)) => f.phrase_note = Some(format!("phrase info unavailable: {reason}")), // The fetch is still in progress. @@ -534,11 +587,18 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { let now = state.now_ms(); let (decks, master_num, master_virtual) = { let tracker = state.tracker.lock().unwrap(); - (tracker.decks().to_vec(), tracker.master().map(|m| m.num), tracker.master_is_virtual()) + ( + tracker.decks().to_vec(), + tracker.master().map(|m| m.num), + tracker.master_is_virtual(), + ) }; let fields: Vec = { let tracks = state.tracks.0.lock().unwrap(); - decks.iter().map(|d| track_fields(d, tracks.get(&d.num))).collect() + decks + .iter() + .map(|d| track_fields(d, tracks.get(&d.num))) + .collect() }; let master = master_num.and_then(|n| decks.iter().find(|d| d.num == n)); let deck_json: Vec = decks @@ -555,7 +615,11 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { }; DeckJson { num: d.num, - name: if d.name.is_empty() { Cow::Owned(format!("Deck {}", d.num)) } else { Cow::Borrowed(&d.name) }, + name: if d.name.is_empty() { + Cow::Owned(format!("Deck {}", d.num)) + } else { + Cow::Borrowed(&d.name) + }, playing: d.is_playing(now), master: is_master, virtual_master: is_master && master_virtual, @@ -580,7 +644,11 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { .collect(); let (clock, output) = { let midi = state.midi.lock().unwrap(); - let clock = ClockJson { out16: midi.out16, state: midi.clock_state, delta_ms: midi.delta_ms }; + let clock = ClockJson { + out16: midi.out16, + state: midi.clock_state, + delta_ms: midi.delta_ms, + }; let output = OutputJson { port: "UART2 TX (IO17)", connected: true, @@ -590,8 +658,16 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { notes: midi.notes, ccs: midi.ccs, // Like the host: the history keeps an entry for 3 s, newest first. - notes_recent: midi.recent_notes.iter().filter_map(|&(n, t)| Some((n, age_ms(t)?))).collect(), - ccs_recent: midi.recent_ccs.iter().filter_map(|&(n, v, t)| Some((n, v, age_ms(t)?))).collect(), + notes_recent: midi + .recent_notes + .iter() + .filter_map(|&(n, t)| Some((n, age_ms(t)?))) + .collect(), + ccs_recent: midi + .recent_ccs + .iter() + .filter_map(|&(n, v, t)| Some((n, v, age_ms(t)?))) + .collect(), mtc: None, mtc_qf: 0, mtc_full: 0, @@ -689,5 +765,7 @@ fn origin_allowed(origin: Option<&str>, host: Option<&str>) -> bool { host.rsplit_once(':').map_or(host, |(name, _)| name) } .to_ascii_lowercase(); - same && (name.parse::().is_ok() || name == "localhost" || name.ends_with(".local")) + same && (name.parse::().is_ok() + || name == "localhost" + || name.ends_with(".local")) } diff --git a/host/src/app.rs b/host/src/app.rs index 715f521..b5f5b55 100644 --- a/host/src/app.rs +++ b/host/src/app.rs @@ -590,8 +590,8 @@ pub fn list_interfaces() -> anyhow::Result<()> { println!("Detected network interfaces:"); println!( - "{:<6} {:<30} {:<18} {:<18} {}", - "Prio", "Name", "IPv4", "Broadcast", "MAC" + "{:<6} {:<30} {:<18} {:<18} MAC", + "Prio", "Name", "IPv4", "Broadcast" ); println!("{}", "-".repeat(95)); diff --git a/host/src/config.rs b/host/src/config.rs index fda6838..01535f4 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -268,24 +268,20 @@ pub struct ProgramConfig { /// MTC frame rate variants. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum MtcFrameRate { /// 24 fps (film/cinema). #[serde(alias = "24")] Fps24, /// 25 fps (PAL video). #[serde(alias = "25")] + #[default] Fps25, /// 30 fps non-drop (NTSC). #[serde(alias = "30")] Fps30, } -impl Default for MtcFrameRate { - fn default() -> Self { - Self::Fps25 - } -} - impl MtcFrameRate { /// Frames per second as an integer. pub fn fps(self) -> u8 { @@ -326,6 +322,7 @@ impl MtcFrameRate { #[derive(Debug, Clone, Deserialize)] #[serde(default)] +#[derive(Default)] pub struct MtcConfig { /// Enable MIDI Timecode output. pub enabled: bool, @@ -333,15 +330,6 @@ pub struct MtcConfig { pub frame_rate: MtcFrameRate, } -impl Default for MtcConfig { - fn default() -> Self { - Self { - enabled: false, - frame_rate: MtcFrameRate::default(), - } - } -} - // ── Ableton Link config ─────────────────────────────────────────────────────── #[derive(Debug, Clone, Deserialize)] diff --git a/host/src/midi/clock.rs b/host/src/midi/clock.rs index cba6227..c38f791 100644 --- a/host/src/midi/clock.rs +++ b/host/src/midi/clock.rs @@ -535,7 +535,7 @@ fn handle_beat_event( a.clock_wait_beats_seen = cs.wait_beats_seen; a.clock_phrase_beat = cs.beat_count; a.clock_pulse_index = cs.pulse_index; - a.clock_timing_delta_ms = beat_timing_delta_ms(&cs, Instant::now()); + a.clock_timing_delta_ms = beat_timing_delta_ms(cs, Instant::now()); } } @@ -566,7 +566,7 @@ fn handle_beat_event( a.clock_wait_beats_seen = 0; a.clock_phrase_beat = cs.beat_count; a.clock_pulse_index = cs.pulse_index; - a.clock_timing_delta_ms = beat_timing_delta_ms(&cs, Instant::now()); + a.clock_timing_delta_ms = beat_timing_delta_ms(cs, Instant::now()); } } cs.pulse_index = 0; @@ -617,7 +617,7 @@ fn handle_beat_event( a.clock_wait_beats_seen = cs.wait_beats_seen; a.clock_phrase_beat = cs.beat_count; a.clock_pulse_index = cs.pulse_index; - a.clock_timing_delta_ms = beat_timing_delta_ms(&cs, Instant::now()); + a.clock_timing_delta_ms = beat_timing_delta_ms(cs, Instant::now()); } } @@ -649,7 +649,7 @@ fn handle_beat_event( a.clock_wait_beats_seen = 0; a.clock_phrase_beat = cs.beat_count; a.clock_pulse_index = cs.pulse_index; - a.clock_timing_delta_ms = beat_timing_delta_ms(&cs, Instant::now()); + a.clock_timing_delta_ms = beat_timing_delta_ms(cs, Instant::now()); } } cs.pulse_index = 0; @@ -682,19 +682,16 @@ fn drain_beat_events( smoothing_ms: u64, beat_rx: &mut broadcast::Receiver, ) { - loop { - match beat_rx.try_recv() { - Ok(evt) => handle_beat_event( - cs, - state, - midi, - activity, - resync_interval_bars, - smoothing_ms, - evt, - ), - Err(_) => break, - } + while let Ok(evt) = beat_rx.try_recv() { + handle_beat_event( + cs, + state, + midi, + activity, + resync_interval_bars, + smoothing_ms, + evt, + ); } } @@ -940,7 +937,7 @@ pub async fn run( let mut cached_is_playing = false; let mut last_master_key = { let st = state.read(); - (st.master.source.clone(), st.master.device_number) + (st.master.source, st.master.device_number) }; let mut last_connected = midi.is_connected(); @@ -993,7 +990,7 @@ pub async fn run( let current_master_key = { let st = state.read(); - (st.master.source.clone(), st.master.device_number) + (st.master.source, st.master.device_number) }; if clock_enabled && current_master_key != last_master_key { diff --git a/host/src/midi/mapper.rs b/host/src/midi/mapper.rs index 1c4e145..5ec7d4e 100644 --- a/host/src/midi/mapper.rs +++ b/host/src/midi/mapper.rs @@ -866,7 +866,10 @@ async fn run_with_midi( // The UI counters are optional. Do not wait for the lock. if let Some(mut act) = activity.try_lock() { act.cc_sent += 1; - remember(&mut act.recent_ccs, (cc_cfg.phrase_16, new_phrase_16, Instant::now())); + remember( + &mut act.recent_ccs, + (cc_cfg.phrase_16, new_phrase_16, Instant::now()), + ); } } } @@ -922,7 +925,10 @@ async fn run_with_midi( // The UI counters are optional. Do not wait for the lock. if let Some(mut act) = activity.try_lock() { act.cc_sent += 1; - remember(&mut act.recent_ccs, (c.phrase_kind, kind_val, Instant::now())); + remember( + &mut act.recent_ccs, + (c.phrase_kind, kind_val, Instant::now()), + ); } } let n = cfg_now.notes; diff --git a/host/src/midi/soak.rs b/host/src/midi/soak.rs index 8f61703..313bfcf 100644 --- a/host/src/midi/soak.rs +++ b/host/src/midi/soak.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use clap::ValueEnum; @@ -59,7 +59,7 @@ fn compute_percentiles_ms(mut samples: Vec) -> Percentiles { } } -fn ensure_parent_dir(path: &PathBuf) -> anyhow::Result<()> { +fn ensure_parent_dir(path: &Path) -> anyhow::Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; diff --git a/host/src/midi/timecode.rs b/host/src/midi/timecode.rs index 4304747..de8eb15 100644 --- a/host/src/midi/timecode.rs +++ b/host/src/midi/timecode.rs @@ -354,12 +354,13 @@ impl MtcScheduler { // Preferred: fresh, master-scoped AbsPosition measurement. let is_master_scoped = measurement.device_number == Some(master_device); - if is_master_scoped - && measurement.kind == MeasurementKind::ProLinkAbsPositionPacket - && measurement.playhead_ms.is_some() - && *age <= PLAYHEAD_MAX_AGE - { - let pos = measurement.playhead_ms.unwrap() as i64; + let fresh_playhead = measurement.playhead_ms.filter(|_| { + is_master_scoped + && measurement.kind == MeasurementKind::ProLinkAbsPositionPacket + && *age <= PLAYHEAD_MAX_AGE + }); + if let Some(playhead_ms) = fresh_playhead { + let pos = playhead_ms as i64; let speed = speed_factor_from_master_pitch_pct(master_pitch_pct); match &mut self.osc { Some(osc) => osc.retime(now, pos, speed), @@ -380,6 +381,7 @@ impl MtcScheduler { Some((pos, false)) } + #[allow(clippy::too_many_arguments)] fn on_wake( &mut self, now: Instant, @@ -519,11 +521,7 @@ impl MtcScheduler { let frame = tc.total_frames(frame_rate.fps()); if let Some(prev) = self.last_cycle_frame { - let diff = if frame >= prev { - frame - prev - } else { - prev - frame - }; + let diff = frame.abs_diff(prev); if diff > DISCONTINUITY_THRESHOLD_FRAMES { // Exactly one full-frame resync, then restart QF cycle. out.push(MtcOut::FullFrame(full_frame_sysex(&tc, &frame_rate))); diff --git a/host/src/prolink/metadata.rs b/host/src/prolink/metadata.rs index a948c59..5b374fb 100644 --- a/host/src/prolink/metadata.rs +++ b/host/src/prolink/metadata.rs @@ -223,7 +223,11 @@ pub async fn run( map.insert(key, ()); } - let session = Arc::clone(sessions.entry(ip).or_insert_with(|| Arc::new(Semaphore::new(1)))); + let session = Arc::clone( + sessions + .entry(ip) + .or_insert_with(|| Arc::new(Semaphore::new(1))), + ); let inflight2 = Arc::clone(&inflight); let state2 = Arc::clone(&state); let warned2 = Arc::clone(&warned); @@ -948,8 +952,8 @@ fn parse_pssi_tag_inner(data: &[u8]) -> anyhow::Result { }, TrackMood::Low => match kind_id { 1 => PhraseKind::Intro, - 2 | 3 | 4 => PhraseKind::Verse1, - 5 | 6 | 7 => PhraseKind::Verse2, + 2..=4 => PhraseKind::Verse1, + 5..=7 => PhraseKind::Verse2, 8 => PhraseKind::Bridge, 9 => PhraseKind::Chorus, 10 => PhraseKind::Outro, @@ -1112,7 +1116,9 @@ async fn read_field(stream: &mut TcpStream) -> anyhow::Result { /// Decode UTF-16 big-endian bytes to a Rust String, stripping null terminators. fn decode_utf16be(data: &[u8]) -> String { let u16s: Vec = data - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|c| u16::from_be_bytes([c[0], c[1]])) .collect(); String::from_utf16_lossy(&u16s) diff --git a/host/src/runtime/midi.rs b/host/src/runtime/midi.rs index c94ae9e..1dc421e 100644 --- a/host/src/runtime/midi.rs +++ b/host/src/runtime/midi.rs @@ -75,7 +75,9 @@ pub async fn reconnect_loop( if midi_out.is_connected() { warned = false; let wanted = cfg.read().midi.output.clone(); - let ports = tokio::task::spawn_blocking(list.clone()).await.unwrap_or_default(); + let ports = tokio::task::spawn_blocking(list.clone()) + .await + .unwrap_or_default(); if port_present(&wanted, &ports) { continue; } diff --git a/host/src/runtime/prolink.rs b/host/src/runtime/prolink.rs index a648e85..a89cf7b 100644 --- a/host/src/runtime/prolink.rs +++ b/host/src/runtime/prolink.rs @@ -51,8 +51,15 @@ pub fn spawn( mac, ); - let reason = - watch_group(&mut group, &ctx, &hint, device_number, bind_ip, &device_table).await; + let reason = watch_group( + &mut group, + &ctx, + &hint, + device_number, + bind_ip, + &device_table, + ) + .await; tracing::warn!(%reason, "Rebuilding the Pro DJ Link network tasks"); group.shutdown().await; tokio::time::sleep(CHECK_INTERVAL).await; @@ -140,10 +147,9 @@ fn network_changed(hint: &str, bind_ip: Ipv4Addr, players: bool) -> Option Some(format!( - "a better interface is up: {} ({})", - best.name, best.ip - )), + Some(best) if hint == "auto" && !players && best.priority < current.priority => Some( + format!("a better interface is up: {} ({})", best.name, best.ip), + ), _ => None, } } diff --git a/host/src/state/dj_state.rs b/host/src/state/dj_state.rs index e249094..3897b92 100644 --- a/host/src/state/dj_state.rs +++ b/host/src/state/dj_state.rs @@ -169,7 +169,7 @@ impl DjState { dev.current_phrase_idx = None; dev.prev_phrase_idx = None; // An eject has rekordbox_id 0. It clears the metadata but loads no track. - (s.rekordbox_id != 0).then(|| TrackChange { + (s.rekordbox_id != 0).then_some(TrackChange { device_number: s.device_number, track_source_player: s.track_source_player, track_slot: s.track_slot, @@ -492,6 +492,7 @@ impl DjState { } } + #[allow(clippy::too_many_arguments)] pub fn apply_link_state( &mut self, bpm: f64, diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..da6f868 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Runs the checks of CI on this machine, and the firmware build that CI does not run for a pull request. +# +# Usage: sh scripts/check.sh [--esp32 | --no-esp32] +# +# The script builds the firmware when the branch changes esp32/, crates/ or webui/. +# --esp32 always builds the firmware. --no-esp32 never builds it. +# The firmware build needs the esp toolchain and ~/export-esp.sh (see AGENTS.md). +set -eu + +cd "$(git rev-parse --show-toplevel)" + +esp32=auto +case "${1:-}" in + --esp32) esp32=yes ;; + --no-esp32) esp32=no ;; + "") ;; + *) echo "usage: sh scripts/check.sh [--esp32 | --no-esp32]" >&2; exit 2 ;; +esac + +if [ ! -f vendor/rusty_link/Cargo.toml ]; then + echo "vendor/rusty_link is missing. Do the steps in AGENTS.md, section \"Vendor Setup\"." >&2 + exit 1 +fi + +step() { printf '\n==> %s\n' "$*"; } + +step "Format" +cargo fmt --all --check +rustfmt --check --edition 2021 esp32/build.rs esp32/src/main.rs + +step "Clippy" +cargo clippy --locked --all-targets -- -D warnings + +step "Tests" +cargo test --locked + +if [ "$esp32" = auto ]; then + esp32=no + base=$(git merge-base HEAD origin/main 2>/dev/null || true) + if [ -n "$base" ] && git diff --name-only "$base" HEAD -- esp32 crates webui | grep -q .; then + esp32=yes + fi +fi + +if [ "$esp32" = yes ]; then + if [ ! -f "$HOME/export-esp.sh" ]; then + echo "The branch changes the firmware, but ~/export-esp.sh is missing. Install the esp toolchain (see AGENTS.md)." >&2 + exit 1 + fi + # export-esp.sh sets PATH and LIBCLANG_PATH for the esp toolchain. + . "$HOME/export-esp.sh" + step "Firmware" + (cd esp32 && cargo build --release --locked) + step "Firmware with the DMX output" + (cd esp32 && cargo build --release --locked --features dmx) +else + step "Firmware: skipped. The branch does not change esp32/, crates/ or webui/." +fi + +step "All checks passed"