diff --git a/faderpunk/src/app.rs b/faderpunk/src/app.rs index a7676a941..f6c40f7a3 100644 --- a/faderpunk/src/app.rs +++ b/faderpunk/src/app.rs @@ -23,7 +23,7 @@ use crate::{ events::{EventPubSubChannel, InputEvent}, tasks::{ buttons::{is_channel_button_pressed, is_shift_button_pressed}, - clock::{ClockSubscriber, CLOCK_PUBSUB}, + clock::{ClockSubscriber, CLOCK_PUBSUB, TICK_COUNTER}, global_config::get_global_config, i2c::{I2cLeaderMessage, I2cLeaderSender}, leds::{set_led_mode, LedMode, LedMsg}, @@ -795,6 +795,18 @@ impl App { Clock::new() } + /// Poll the current tick instead of subscribing to the clock. + /// + /// For apps that derive everything from the tick number and never react to + /// Start/Stop/Reset. Unlike [`Self::use_clock`] this costs no subscriber + /// slot, and an app that falls behind cannot back up the shared clock + /// queue. Returns `u64::MAX` before the first tick; the counter going + /// backwards means the clock was restarted or reset. + #[allow(dead_code)] // first users are the WIP app branches + pub fn clock_ticker(&self) -> fn() -> u64 { + ticks + } + pub fn use_quantizer(&self, range: Range, vpo: VoltPerOct, bypass: bool) -> Quantizer { Quantizer::new(range, vpo, bypass) } @@ -870,3 +882,8 @@ pub fn pitch_as_counts(pitch: Pitch, range: Range, vpo: VoltPerOct) -> u16 { pub fn vpo_counts_per_oct(vpo: VoltPerOct) -> i16 { get_global_config().vpo_counts_per_oct(vpo) } + +#[allow(dead_code)] // see App::clock_ticker +fn ticks() -> u64 { + TICK_COUNTER.load(Ordering::Relaxed) +} diff --git a/faderpunk/src/tasks/clock.rs b/faderpunk/src/tasks/clock.rs index aa097ec85..4643e2bf1 100644 --- a/faderpunk/src/tasks/clock.rs +++ b/faderpunk/src/tasks/clock.rs @@ -15,7 +15,7 @@ use embassy_sync::{ use embassy_time::{Duration, Instant, Timer}; use heapless::Deque; use midly::live::SystemRealtime; -use portable_atomic::{AtomicBool, Ordering}; +use portable_atomic::{AtomicBool, AtomicU64, Ordering}; use libfp::{ utils::bpm_to_clock_duration, AuxJackMode, ClockSrc, GlobalConfig, MidiOut, MidiOutConfig, @@ -34,7 +34,7 @@ use crate::{ const CLOCK_PUBSUB_SIZE: usize = 16; // 16 apps + 1 metronome -const CLOCK_PUBSUB_SUBSCRIBERS: usize = 17; +const CLOCK_PUBSUB_SUBSCRIBERS: usize = 16; // Only the gatekeeper publishes to CLOCK_PUBSUB const CLOCK_PUBSUB_PUBLISHERS: usize = 5; // Add a slight delay before the very first tick (to offset it to reset) @@ -46,6 +46,14 @@ const METRONOME_HIGH_MS: u64 = 25; pub static METRONOME_HIGH: AtomicBool = AtomicBool::new(true); +/// Current gatekeeper tick, mirrored for readers that only need the count. +/// +/// A [`CLOCK_PUBSUB`] subscription costs a queue slot and obliges the reader to +/// keep draining it; anything that only wants "which tick is it" can poll this +/// instead and never influence the gatekeeper. `u64::MAX` means "not started" — +/// the first tick after a Start/Reset is 0. +pub static TICK_COUNTER: AtomicU64 = AtomicU64::new(u64::MAX); + type AuxInputs = ( Peri<'static, PIN_1>, Peri<'static, PIN_2>, @@ -275,10 +283,11 @@ async fn send_analog_ticks(spawner: &Spawner, config: &GlobalConfig, counters: & } } if !ports.is_empty() { - MAX_CHANNEL + // try_send for the same reason: a full MAX queue must not stall the + // clock gatekeeper. A dropped analog tick is better than a dead clock. + let _ = MAX_CHANNEL .sender() - .send(MaxCmd::GpoSetHighMany(ports.clone())) - .await; + .try_send(MaxCmd::GpoSetHighMany(ports.clone())); spawner.spawn(analog_tick_release(ports, 5)).ok(); } } @@ -291,10 +300,11 @@ async fn send_analog_reset(spawner: &Spawner, config: &GlobalConfig) { } } if !ports.is_empty() { - MAX_CHANNEL + // try_send for the same reason: a full MAX queue must not stall the + // clock gatekeeper. A dropped analog tick is better than a dead clock. + let _ = MAX_CHANNEL .sender() - .send(MaxCmd::GpoSetHighMany(ports.clone())) - .await; + .try_send(MaxCmd::GpoSetHighMany(ports.clone())); spawner.spawn(analog_tick_release(ports, 10)).ok(); } } @@ -302,33 +312,49 @@ async fn send_analog_reset(spawner: &Spawner, config: &GlobalConfig) { #[embassy_executor::task(pool_size = 4)] async fn analog_tick_release(ports: heapless::Vec, trigger_len: u64) { Timer::after_millis(trigger_len).await; - MAX_CHANNEL - .sender() - .send(MaxCmd::GpoSetLowMany(ports)) - .await; + let _ = MAX_CHANNEL.sender().try_send(MaxCmd::GpoSetLowMany(ports)); } +/// Scene LED beat flash — polls [`TICK_COUNTER`] only. +/// +/// Must never subscribe to [`CLOCK_PUBSUB`]: awaiting a timer while holding a +/// subscriber slot lets the shared queue fill, and a gatekeeper that cannot +/// publish stops the whole device clock. #[embassy_executor::task] async fn metronome() { - let mut sub = CLOCK_PUBSUB.subscriber().unwrap(); + let mut last_seen = TICK_COUNTER.load(Ordering::Relaxed); + let mut last_beat = u64::MAX; + let mut high_left_ms: u16 = 0; loop { - match sub.next_message_pure().await { - ClockEvent::Tick(ticks) => { - // Fire on the first tick of each quarter note (every 24 ppqn ticks). - if ticks.is_multiple_of(24) { - METRONOME_HIGH.store(true, Ordering::Relaxed); - Timer::after_millis(METRONOME_HIGH_MS).await; - METRONOME_HIGH.store(false, Ordering::Relaxed); - } - } - ClockEvent::Start | ClockEvent::Reset => { - METRONOME_HIGH.store(true, Ordering::Relaxed); - } - ClockEvent::Stop => { + Timer::after_millis(1).await; + + if high_left_ms > 0 { + high_left_ms -= 1; + if high_left_ms == 0 { METRONOME_HIGH.store(false, Ordering::Relaxed); } } + + let t = TICK_COUNTER.load(Ordering::Relaxed); + if t == last_seen { + continue; + } + // Counter reset (Start/Reset stores u64::MAX, then ticks from 0). + if t < last_seen { + last_beat = u64::MAX; + METRONOME_HIGH.store(true, Ordering::Relaxed); + high_left_ms = METRONOME_HIGH_MS as u16; + } + last_seen = t; + + // First tick of each quarter note (every 24 PPQN ticks). + let beat = t / 24; + if beat != last_beat { + last_beat = beat; + METRONOME_HIGH.store(true, Ordering::Relaxed); + high_left_ms = METRONOME_HIGH_MS as u16; + } } } @@ -399,9 +425,15 @@ async fn run_clock_gatekeeper() { || matches!(source, ClockSrc::Atom | ClockSrc::Meteor | ClockSrc::Cube) { tick_counter = tick_counter.wrapping_add(1); - clock_publisher - .publish(ClockEvent::Tick(tick_counter)) - .await; + // Publish the count before the pubsub so a poller + // never reads a tick the subscribers already saw. + TICK_COUNTER.store(tick_counter, Ordering::Relaxed); + // Never await on the tick path: a subscriber that + // sleeps while holding its slot fills the queue, + // and a blocked gatekeeper stops the whole device + // clock. Overwriting the oldest tick only costs a + // lagged subscriber a beat. + clock_publisher.publish_immediate(ClockEvent::Tick(tick_counter)); send_analog_ticks(&spawner, &config, &mut analog_tick_counters).await; } } @@ -422,6 +454,7 @@ async fn run_clock_gatekeeper() { // (Re-)start the clock. Full phase reset ClockInEvent::Start(_) => { tick_counter = u64::MAX; + TICK_COUNTER.store(u64::MAX, Ordering::Relaxed); is_running = true; clock_publisher.publish(ClockEvent::Reset).await; clock_publisher.publish(ClockEvent::Start).await; @@ -438,6 +471,7 @@ async fn run_clock_gatekeeper() { // Reset the phase without affecting the run state ClockInEvent::Reset(_) => { tick_counter = u64::MAX; + TICK_COUNTER.store(u64::MAX, Ordering::Relaxed); clock_publisher.publish(ClockEvent::Reset).await; analog_tick_counters = [0; 3]; send_analog_reset(&spawner, &config).await;