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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion faderpunk/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -795,6 +795,18 @@ impl<const N: usize> App<N> {
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)
}
Expand Down Expand Up @@ -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)
}
92 changes: 63 additions & 29 deletions faderpunk/src/tasks/clock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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>,
Expand Down Expand Up @@ -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();
}
}
Expand All @@ -291,44 +300,61 @@ 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();
}
}

#[embassy_executor::task(pool_size = 4)]
async fn analog_tick_release(ports: heapless::Vec<Port, 4>, 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;
}
}
}

Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
Expand All @@ -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;
Expand Down