Skip to main content

rill_core/time/
tick.rs

1//! # Clock tick - the heartbeat of signal processing
2//!
3//! A `ClockTick` represents a single moment in signal time, containing
4//! information about sample position, block size, and tempo.
5
6use std::fmt;
7
8/// A tick of the system clock
9///
10/// Sent to nodes on every signal block to provide timing information
11/// and synchronize processing. This is the fundamental timing primitive
12/// in Rill.
13///
14/// # Fields
15///
16/// * `sample_pos` - Absolute sample position since start
17/// * `samples_since_last` - Number of samples since the last tick
18/// * `is_new_block` - Whether this is the start of a new block
19/// * `sample_rate` - Current sample rate in Hz
20/// * `tempo` - Current tempo in BPM (if available)
21/// * `source` - Which backend produced this tick (e.g. "alsa:default")
22///
23/// I/O access is handled directly through [`IoCapture`](crate::io::IoCapture)
24/// and [`IoPlayback`](crate::io::IoPlayback) traits — the tick carries
25/// only timing metadata.
26///
27/// # Example
28///
29/// ```
30/// use rill_core::time::ClockTick;
31///
32/// let tick = ClockTick::new(44100, 64, 44100.0, "test".into());
33/// assert_eq!(tick.absolute_seconds(), 1.0);
34/// assert_eq!(tick.delta_seconds(), 64.0 / 44100.0);
35/// ```
36#[derive(Clone)]
37pub struct ClockTick {
38    /// Absolute sample position since start
39    pub sample_pos: u64,
40
41    /// Number of samples since the last tick
42    pub samples_since_last: u32,
43
44    /// Whether this is the start of a new block
45    pub is_new_block: bool,
46
47    /// Current sample rate in Hz
48    pub sample_rate: f32,
49
50    /// Current tempo in BPM (if available)
51    pub tempo: Option<f32>,
52
53    /// Which backend produced this tick (e.g. "alsa:default", "pipewire:0").
54    pub source: String,
55
56    /// Hardware clock correction factor: `configured_rate / actual_rate`.
57    ///
58    /// `1.0` = nominal (rates match). `< 1.0` = hardware runs faster.
59    /// `> 1.0` = hardware runs slower.  Set by the backend when the
60    /// negotiated hardware rate differs from the graph's configured rate.
61    pub speed_ratio: f64,
62
63    /// Whether this tick should trigger a ClockTick dispatch to modules.
64    ///
65    /// `true` by default.  Chunking backends (PipeWire) set this to `false`
66    /// for intermediate chunks and `true` only for the final chunk of the
67    /// DMA buffer — avoiding 48 ClockTick dispatches per PW callback.
68    pub is_final: bool,
69
70    /// Number of frames the I/O backend processes per callback (its quantum).
71    ///
72    /// The graph processes one `block_size` chunk per pass, but a backend may
73    /// batch many chunks into a single I/O callback (e.g. PipeWire delivers a
74    /// 12288-frame buffer = 48 × 256 chunks). Tick-driven control producers
75    /// (sequencers/servos) that run asynchronously only see the results of a
76    /// callback *after* it returns, so a change reacting to a tick in the
77    /// current callback can only be rendered in the next one. Producers use
78    /// `io_quantum` as the sample look-ahead when stamping
79    /// [`SetParameter::sample_pos`](crate::queues::SetParameter) so the change
80    /// lands on the correct block of the next callback instead of collapsing
81    /// onto block 0. Defaults to `samples_since_last` (one chunk per callback).
82    pub io_quantum: u32,
83}
84
85impl PartialEq for ClockTick {
86    fn eq(&self, other: &Self) -> bool {
87        self.sample_pos == other.sample_pos
88            && self.samples_since_last == other.samples_since_last
89            && self.is_new_block == other.is_new_block
90            && self.sample_rate == other.sample_rate
91            && self.tempo == other.tempo
92            && self.source == other.source
93    }
94}
95
96impl fmt::Debug for ClockTick {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.debug_struct("ClockTick")
99            .field("sample_pos", &self.sample_pos)
100            .field("samples_since_last", &self.samples_since_last)
101            .field("is_new_block", &self.is_new_block)
102            .field("sample_rate", &self.sample_rate)
103            .field("tempo", &self.tempo)
104            .field("source", &self.source)
105            .finish()
106    }
107}
108
109impl ClockTick {
110    /// Create a new clock tick
111    ///
112    /// # Arguments
113    /// * `sample_pos` - Absolute sample position
114    /// * `samples_since_last` - Samples since last tick
115    /// * `sample_rate` - Sample rate in Hz
116    /// * `source` - Backend source name
117    ///
118    /// # Returns
119    /// A new `ClockTick` with `is_new_block = true` and `tempo = None`
120    pub fn new(sample_pos: u64, samples_since_last: u32, sample_rate: f32, source: String) -> Self {
121        Self {
122            sample_pos,
123            samples_since_last,
124            is_new_block: true,
125            sample_rate,
126            tempo: None,
127            source,
128            speed_ratio: 1.0,
129            is_final: true,
130            io_quantum: samples_since_last,
131        }
132    }
133
134    /// Create a new clock tick with tempo information
135    ///
136    /// # Arguments
137    /// * `sample_pos` - Absolute sample position
138    /// * `samples_since_last` - Samples since last tick
139    /// * `sample_rate` - Sample rate in Hz
140    /// * `tempo` - Tempo in BPM
141    /// * `source` - Backend source name
142    pub fn with_tempo(
143        sample_pos: u64,
144        samples_since_last: u32,
145        sample_rate: f32,
146        tempo: f32,
147        source: String,
148    ) -> Self {
149        Self {
150            sample_pos,
151            samples_since_last,
152            is_new_block: true,
153            sample_rate,
154            tempo: Some(tempo),
155            source,
156            speed_ratio: 1.0,
157            is_final: true,
158            io_quantum: samples_since_last,
159        }
160    }
161
162    /// Set the I/O backend's per-callback quantum (frames processed per callback).
163    ///
164    /// Backends that batch multiple `block_size` chunks into one callback set
165    /// this so asynchronous control producers know how far ahead to schedule
166    /// sample-accurate parameter changes. See [`io_quantum`](Self::io_quantum).
167    pub fn with_io_quantum(mut self, io_quantum: u32) -> Self {
168        self.io_quantum = io_quantum;
169        self
170    }
171
172    /// Get the time since the last tick in seconds
173    ///
174    /// # Returns
175    /// Time in seconds since the previous tick
176    #[inline(always)]
177    pub fn delta_seconds(&self) -> f32 {
178        self.samples_since_last as f32 / self.sample_rate
179    }
180
181    /// Get the absolute time in seconds since start
182    ///
183    /// # Returns
184    /// Absolute time in seconds
185    #[inline(always)]
186    pub fn absolute_seconds(&self) -> f64 {
187        self.sample_pos as f64 / self.sample_rate as f64
188    }
189
190    /// Get the current beat position (if tempo is available)
191    ///
192    /// # Returns
193    /// * `Some(beat)` - Current beat position (fractional)
194    /// * `None` - No tempo information available
195    #[inline(always)]
196    pub fn beat_position(&self) -> Option<f64> {
197        self.tempo.map(|bpm| {
198            let seconds_per_beat = 60.0 / bpm as f64;
199            self.absolute_seconds() / seconds_per_beat
200        })
201    }
202
203    /// Get the current bar-beat-sixteenth position (if tempo is available)
204    ///
205    /// # Returns
206    /// * `Some((bar, beat, sixteenth))` - Musical position
207    /// * `None` - No tempo information available
208    pub fn musical_position(&self) -> Option<(u32, u8, u8)> {
209        self.tempo.map(|bpm| {
210            let seconds_per_beat = 60.0 / bpm as f64;
211            let total_beats = self.absolute_seconds() / seconds_per_beat;
212
213            let bar = (total_beats / 4.0).floor() as u32;
214            let beat_in_bar = (total_beats % 4.0) as u8;
215            let sixteenth = ((total_beats.fract() * 4.0) as u8) % 4;
216
217            (bar, beat_in_bar, sixteenth)
218        })
219    }
220
221    /// Advance to the next tick
222    ///
223    /// # Arguments
224    /// * `samples` - Number of samples to advance
225    pub fn advance(&mut self, samples: u32) {
226        self.sample_pos += samples as u64;
227        self.samples_since_last = samples;
228        self.is_new_block = true;
229    }
230
231    /// Check if this tick is at the start of a new bar
232    ///
233    /// # Returns
234    /// `true` if this is the first beat of a bar
235    pub fn is_new_bar(&self) -> bool {
236        if let Some((_, beat, sixteenth)) = self.musical_position() {
237            beat == 0 && sixteenth == 0
238        } else {
239            false
240        }
241    }
242
243    /// Check if this tick is at the start of a new beat
244    ///
245    /// # Returns
246    /// `true` if this is the start of a beat
247    pub fn is_new_beat(&self) -> bool {
248        if let Some((_, _, sixteenth)) = self.musical_position() {
249            sixteenth == 0
250        } else {
251            false
252        }
253    }
254}
255
256impl Default for ClockTick {
257    fn default() -> Self {
258        Self {
259            sample_pos: 0,
260            samples_since_last: 0,
261            is_new_block: false,
262            sample_rate: 44100.0,
263            tempo: None,
264            source: String::new(),
265            speed_ratio: 1.0,
266            is_final: true,
267            io_quantum: 0,
268        }
269    }
270}
271
272impl fmt::Display for ClockTick {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        write!(
275            f,
276            "ClockTick(pos={}, delta={}ms, rate={}Hz, source={}",
277            self.sample_pos,
278            self.delta_seconds() * 1000.0,
279            self.sample_rate,
280            self.source,
281        )?;
282
283        if let Some(tempo) = self.tempo {
284            write!(f, ", tempo={}BPM", tempo)?;
285        }
286
287        write!(f, ")")
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_clock_tick_creation() {
297        let tick = ClockTick::new(44100, 44100, 44100.0, "test".into());
298        assert_eq!(tick.sample_pos, 44100);
299        assert_eq!(tick.samples_since_last, 44100);
300        assert!(tick.is_new_block);
301        assert_eq!(tick.sample_rate, 44100.0);
302        assert_eq!(tick.tempo, None);
303        assert_eq!(tick.source, "test");
304    }
305
306    #[test]
307    fn test_clock_tick_with_tempo() {
308        let tick = ClockTick::with_tempo(44100, 44100, 44100.0, 120.0, "test".into());
309        assert_eq!(tick.tempo, Some(120.0));
310    }
311
312    #[test]
313    fn test_delta_seconds() {
314        let tick = ClockTick::new(0, 44100, 44100.0, "test".into());
315        assert_eq!(tick.delta_seconds(), 1.0);
316
317        let tick = ClockTick::new(0, 22050, 44100.0, "test".into());
318        assert_eq!(tick.delta_seconds(), 0.5);
319    }
320
321    #[test]
322    fn test_absolute_seconds() {
323        let tick = ClockTick::new(44100, 44100, 44100.0, "test".into());
324        assert_eq!(tick.absolute_seconds(), 1.0);
325
326        let tick = ClockTick::new(88200, 44100, 44100.0, "test".into());
327        assert_eq!(tick.absolute_seconds(), 2.0);
328    }
329
330    #[test]
331    fn test_beat_position() {
332        let tick = ClockTick::with_tempo(44100, 44100, 44100.0, 120.0, "test".into());
333        // At 120 BPM, one beat = 0.5 seconds
334        // 1 second = 2 beats
335        assert_eq!(tick.beat_position(), Some(2.0));
336    }
337
338    #[test]
339    fn test_musical_position() {
340        let tick = ClockTick::with_tempo(44100 * 2, 44100, 44100.0, 120.0, "test".into());
341        // 2 seconds at 120 BPM = 4 beats
342        // 4 beats = 1 bar
343        let pos = tick.musical_position();
344        assert_eq!(pos, Some((1, 0, 0)));
345
346        let tick = ClockTick::with_tempo(44100 * 3, 44100, 44100.0, 120.0, "test".into());
347        // 3 seconds = 6 beats = 1.5 bars
348        let pos = tick.musical_position();
349        assert_eq!(pos, Some((1, 2, 0)));
350    }
351
352    #[test]
353    fn test_advance() {
354        let mut tick = ClockTick::new(0, 0, 44100.0, "test".into());
355        tick.advance(64);
356        assert_eq!(tick.sample_pos, 64);
357        assert_eq!(tick.samples_since_last, 64);
358        assert!(tick.is_new_block);
359    }
360
361    #[test]
362    fn test_is_new_bar() {
363        let tick = ClockTick::with_tempo(0, 0, 44100.0, 120.0, "test".into());
364        assert!(tick.is_new_bar());
365
366        let tick = ClockTick::with_tempo(22050, 22050, 44100.0, 120.0, "test".into());
367        // 0.5 seconds = 1 beat, not new bar
368        assert!(!tick.is_new_bar());
369    }
370
371    #[test]
372    fn test_is_new_beat() {
373        let tick = ClockTick::with_tempo(0, 0, 44100.0, 120.0, "test".into());
374        assert!(tick.is_new_beat());
375
376        let tick = ClockTick::with_tempo(11025, 11025, 44100.0, 120.0, "test".into());
377        // 0.25 seconds = half beat, not new beat
378        assert!(!tick.is_new_beat());
379    }
380
381    #[test]
382    fn test_default() {
383        let tick = ClockTick::default();
384        assert_eq!(tick.sample_pos, 0);
385        assert_eq!(tick.samples_since_last, 0);
386        assert!(!tick.is_new_block);
387        assert_eq!(tick.sample_rate, 44100.0);
388        assert_eq!(tick.tempo, None);
389        assert_eq!(tick.source, "");
390    }
391
392    #[test]
393    fn test_display() {
394        let tick = ClockTick::new(44100, 44100, 44100.0, "test".into());
395        let display = format!("{}", tick);
396        assert!(display.contains("pos=44100"));
397        assert!(display.contains("delta=1000ms"));
398        assert!(display.contains("source=test"));
399
400        let tick = ClockTick::with_tempo(44100, 44100, 44100.0, 120.0, "test".into());
401        let display = format!("{}", tick);
402        assert!(display.contains("tempo=120BPM"));
403    }
404}