Skip to main content

oximedia_timecode/
subframe.rs

1// Copyright 2025 OxiMedia Contributors
2// Licensed under the Apache License, Version 2.0
3
4//! Sub-frame timecode: extend a SMPTE timecode with an audio-sample offset.
5//!
6//! `SubframeTimestamp` combines a [`Timecode`] with an audio-sample index and
7//! sample rate to give nanosecond-accurate timestamps that go beyond the
8//! one-frame resolution of SMPTE timecode alone.
9//!
10//! # Example
11//!
12//! ```rust,ignore
13//! use oximedia_timecode::{Timecode, FrameRate};
14//! use oximedia_timecode::subframe::SubframeTimestamp;
15//!
16//! let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).unwrap();
17//! let sub = SubframeTimestamp::new(tc, 441, 44100);
18//! // 1 second + 441/44100 s = 1.01 s = 1_010_000_000 ns
19//! assert_eq!(sub.to_nanos(), 1_010_000_000);
20//! ```
21
22use crate::{Timecode, TimecodeError};
23
24/// A timecode extended with a sub-frame audio sample offset.
25#[derive(Debug, Clone, Copy)]
26pub struct SubframeTimestamp {
27    /// The integer-frame SMPTE timecode.
28    pub timecode: Timecode,
29    /// Zero-based sample offset within the current frame.
30    pub sample: u32,
31    /// Audio sample rate in Hz (e.g. 48000, 44100).
32    pub sample_rate: u32,
33}
34
35impl SubframeTimestamp {
36    /// Create a new `SubframeTimestamp`.
37    ///
38    /// `sample` is the zero-based sample index within the frame identified by
39    /// `tc`.  `sample_rate` must be > 0.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`TimecodeError::InvalidConfiguration`] if `sample_rate` is 0.
44    pub fn new(tc: Timecode, sample: u32, sample_rate: u32) -> Self {
45        SubframeTimestamp {
46            timecode: tc,
47            sample,
48            sample_rate: sample_rate.max(1),
49        }
50    }
51
52    /// Convert to an absolute nanosecond offset from timecode midnight (00:00:00:00).
53    ///
54    /// The calculation is:
55    ///
56    /// ```text
57    /// tc_nanos   = timecode.to_frames() * 1_000_000_000 / frame_rate
58    /// sub_nanos  = sample * 1_000_000_000 / sample_rate
59    /// total_nanos = tc_nanos + sub_nanos
60    /// ```
61    ///
62    /// Integer arithmetic is used throughout to avoid floating-point error.
63    /// For drop-frame rates, `timecode.to_frames()` already accounts for the
64    /// frame drops.
65    pub fn to_nanos(&self) -> u64 {
66        let fps_info = self.timecode.frame_rate;
67        let fps = crate::frame_rate_from_info(&fps_info);
68        let (num, den) = fps.as_rational();
69
70        // nanoseconds per frame = 1_000_000_000 * den / num
71        // Use 128-bit intermediates to avoid overflow at 120 fps / high frame counts.
72        let total_frames = self.timecode.to_frames() as u128;
73        let ns_per_frame_num = 1_000_000_000_u128 * den as u128;
74        let ns_per_frame_den = num as u128;
75
76        let tc_nanos = total_frames * ns_per_frame_num / ns_per_frame_den;
77
78        // sub-frame: sample / sample_rate seconds → nanoseconds
79        let sub_nanos = self.sample as u128 * 1_000_000_000 / self.sample_rate as u128;
80
81        (tc_nanos + sub_nanos) as u64
82    }
83
84    /// Return the sub-frame offset as a fraction in `[0.0, 1.0)` of one frame.
85    ///
86    /// ```text
87    /// fraction = sample / (sample_rate / frame_rate)
88    ///          = sample * frame_rate / sample_rate
89    /// ```
90    pub fn subframe_fraction(&self) -> f64 {
91        let fps_info = self.timecode.frame_rate;
92        let fps = crate::frame_rate_from_info(&fps_info);
93        let frame_rate = fps.as_float();
94        let samples_per_frame = self.sample_rate as f64 / frame_rate;
95        if samples_per_frame <= 0.0 {
96            return 0.0;
97        }
98        (self.sample as f64 / samples_per_frame).clamp(0.0, 1.0)
99    }
100
101    /// Convert back to floating-point seconds from midnight.
102    pub fn to_seconds_f64(&self) -> f64 {
103        self.to_nanos() as f64 / 1_000_000_000.0
104    }
105
106    /// Advance by `samples` audio samples, wrapping timecode frames as needed.
107    ///
108    /// Returns `Err` if timecode increment fails (e.g. wrapping past 23:59:59:xx).
109    pub fn advance_samples(&self, samples: u32) -> Result<Self, TimecodeError> {
110        let fps_info = self.timecode.frame_rate;
111        let fps = crate::frame_rate_from_info(&fps_info);
112        let samples_per_frame = (self.sample_rate as f64 / fps.as_float()).round() as u32;
113
114        let total_samples = self.sample + samples;
115        let extra_frames = total_samples / samples_per_frame.max(1);
116        let new_sample = total_samples % samples_per_frame.max(1);
117
118        let mut new_tc = self.timecode;
119        for _ in 0..extra_frames {
120            new_tc.increment()?;
121        }
122
123        Ok(SubframeTimestamp::new(new_tc, new_sample, self.sample_rate))
124    }
125}
126
127impl PartialEq for SubframeTimestamp {
128    fn eq(&self, other: &Self) -> bool {
129        self.to_nanos() == other.to_nanos()
130    }
131}
132
133impl Eq for SubframeTimestamp {}
134
135impl PartialOrd for SubframeTimestamp {
136    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
137        Some(self.cmp(other))
138    }
139}
140
141impl Ord for SubframeTimestamp {
142    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
143        self.to_nanos().cmp(&other.to_nanos())
144    }
145}
146
147impl std::fmt::Display for SubframeTimestamp {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(
150            f,
151            "{} +{}/{} samples",
152            self.timecode, self.sample, self.sample_rate
153        )
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::FrameRate;
161
162    fn tc(h: u8, m: u8, s: u8, fr: u8, rate: FrameRate) -> Timecode {
163        Timecode::new(h, m, s, fr, rate).expect("valid timecode")
164    }
165
166    #[test]
167    fn zero_subframe_matches_tc_nanos() {
168        let t = tc(0, 0, 1, 0, FrameRate::Fps25);
169        let sub = SubframeTimestamp::new(t, 0, 48000);
170        // 1 second = 1_000_000_000 ns
171        assert_eq!(sub.to_nanos(), 1_000_000_000);
172    }
173
174    #[test]
175    fn half_frame_offset_at_25fps() {
176        // frame 0 at 00:00:01:00 @ 25fps = 1s = 1_000_000_000 ns
177        // + 24000/48000 s = 0.5 s = 500_000_000 ns
178        let t = tc(0, 0, 1, 0, FrameRate::Fps25);
179        let sub = SubframeTimestamp::new(t, 24000, 48000);
180        assert_eq!(sub.to_nanos(), 1_500_000_000);
181    }
182
183    #[test]
184    fn subframe_fraction_zero() {
185        let t = tc(0, 0, 0, 0, FrameRate::Fps25);
186        let sub = SubframeTimestamp::new(t, 0, 48000);
187        assert!((sub.subframe_fraction() - 0.0).abs() < 1e-9);
188    }
189
190    #[test]
191    fn to_seconds_f64_is_consistent() {
192        let t = tc(0, 0, 2, 0, FrameRate::Fps25);
193        let sub = SubframeTimestamp::new(t, 0, 48000);
194        assert!((sub.to_seconds_f64() - 2.0).abs() < 1e-6);
195    }
196
197    #[test]
198    fn ordering() {
199        let t0 = tc(0, 0, 0, 0, FrameRate::Fps25);
200        let t1 = tc(0, 0, 0, 1, FrameRate::Fps25);
201        let sub0 = SubframeTimestamp::new(t0, 0, 48000);
202        let sub1 = SubframeTimestamp::new(t1, 0, 48000);
203        assert!(sub0 < sub1);
204    }
205
206    #[test]
207    fn sample_rate_zero_clamped() {
208        let t = tc(0, 0, 0, 0, FrameRate::Fps25);
209        let sub = SubframeTimestamp::new(t, 0, 0);
210        assert_eq!(sub.sample_rate, 1);
211    }
212}