Skip to main content

oximedia_virtual/
timecode.rs

1//! SMPTE timecode integration for virtual production synchronisation.
2//!
3//! Supports:
4//! - Non-drop-frame (NDF) and drop-frame (DF) timecodes
5//! - Frame rates: 23.976 (24/1.001), 24, 25, 29.97 (30/1.001), 30, 48, 50, 60
6//! - Arithmetic: add/subtract frame counts, difference
7//! - Conversion to/from total frame counts and real-time seconds
8//! - SMPTE string parsing and formatting (`HH:MM:SS:FF` / `HH:MM:SS;FF`)
9//! - Playback trigger: fire registered callbacks at target timecodes
10//! - Linear time code (LTC) bit-stream frame boundary detection
11
12use std::collections::HashMap;
13use std::fmt;
14
15// ---------------------------------------------------------------------------
16// FrameRate
17// ---------------------------------------------------------------------------
18
19/// Supported SMPTE timecode frame rates.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
21pub enum FrameRate {
22    /// 23.976 fps (24000/1001) — drop-frame capable
23    F23_976,
24    /// 24 fps
25    F24,
26    /// 25 fps (PAL)
27    F25,
28    /// 29.97 fps (30000/1001) — drop-frame common
29    F29_97,
30    /// 30 fps
31    F30,
32    /// 48 fps (HFR)
33    F48,
34    /// 50 fps
35    F50,
36    /// 60 fps
37    F60,
38}
39
40impl FrameRate {
41    /// Integer frames per second (ceiling, for counter arithmetic).
42    #[must_use]
43    pub fn frames_per_second_int(self) -> u32 {
44        match self {
45            Self::F23_976 => 24,
46            Self::F24 => 24,
47            Self::F25 => 25,
48            Self::F29_97 => 30,
49            Self::F30 => 30,
50            Self::F48 => 48,
51            Self::F50 => 50,
52            Self::F60 => 60,
53        }
54    }
55
56    /// Real frames per second (as f64).
57    #[must_use]
58    pub fn as_f64(self) -> f64 {
59        match self {
60            Self::F23_976 => 24_000.0 / 1_001.0,
61            Self::F24 => 24.0,
62            Self::F25 => 25.0,
63            Self::F29_97 => 30_000.0 / 1_001.0,
64            Self::F30 => 30.0,
65            Self::F48 => 48.0,
66            Self::F50 => 50.0,
67            Self::F60 => 60.0,
68        }
69    }
70
71    /// Whether this rate is commonly used with drop-frame.
72    #[must_use]
73    pub fn supports_drop_frame(self) -> bool {
74        matches!(self, Self::F29_97 | Self::F23_976)
75    }
76}
77
78// ---------------------------------------------------------------------------
79// Timecode
80// ---------------------------------------------------------------------------
81
82/// A SMPTE timecode value.
83///
84/// Internally stored as a validated tuple `(hh, mm, ss, ff)`.
85/// Drop-frame flag determines the string representation and frame counting.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
87pub struct Timecode {
88    hours: u8,
89    minutes: u8,
90    seconds: u8,
91    frames: u8,
92    rate: FrameRate,
93    drop_frame: bool,
94}
95
96/// Errors that can occur during timecode parsing or construction.
97#[derive(Debug, Clone, PartialEq, thiserror::Error)]
98pub enum TimecodeError {
99    #[error("Invalid timecode component: {0}")]
100    InvalidComponent(String),
101    #[error("Frame number {0} out of range for rate {1} fps")]
102    FrameOutOfRange(u8, u32),
103    #[error("Drop-frame timecode requires a drop-frame-compatible rate")]
104    DropFrameRateMismatch,
105    #[error("Failed to parse timecode string: {0}")]
106    ParseError(String),
107}
108
109impl Timecode {
110    /// Construct a timecode from components.
111    ///
112    /// # Errors
113    /// Returns [`TimecodeError`] if any component is out of range.
114    pub fn new(
115        hours: u8,
116        minutes: u8,
117        seconds: u8,
118        frames: u8,
119        rate: FrameRate,
120        drop_frame: bool,
121    ) -> Result<Self, TimecodeError> {
122        if drop_frame && !rate.supports_drop_frame() {
123            return Err(TimecodeError::DropFrameRateMismatch);
124        }
125        if hours > 23 {
126            return Err(TimecodeError::InvalidComponent(format!(
127                "hours={hours} > 23"
128            )));
129        }
130        if minutes > 59 {
131            return Err(TimecodeError::InvalidComponent(format!(
132                "minutes={minutes} > 59"
133            )));
134        }
135        if seconds > 59 {
136            return Err(TimecodeError::InvalidComponent(format!(
137                "seconds={seconds} > 59"
138            )));
139        }
140        let max_frames = rate.frames_per_second_int() as u8;
141        if frames >= max_frames {
142            return Err(TimecodeError::FrameOutOfRange(frames, max_frames as u32));
143        }
144        // Validate drop-frame: frames 0 and 1 are dropped at start of each
145        // minute except every 10th minute.
146        if drop_frame && seconds == 0 && (minutes % 10) != 0 && frames < 2 {
147            return Err(TimecodeError::InvalidComponent(format!(
148                "drop-frame: frames {frames} is a dropped frame at mm={minutes} ss=00"
149            )));
150        }
151        Ok(Self {
152            hours,
153            minutes,
154            seconds,
155            frames,
156            rate,
157            drop_frame,
158        })
159    }
160
161    /// Hours component.
162    #[must_use]
163    pub fn hours(&self) -> u8 {
164        self.hours
165    }
166
167    /// Minutes component.
168    #[must_use]
169    pub fn minutes(&self) -> u8 {
170        self.minutes
171    }
172
173    /// Seconds component.
174    #[must_use]
175    pub fn seconds(&self) -> u8 {
176        self.seconds
177    }
178
179    /// Frames component.
180    #[must_use]
181    pub fn frames(&self) -> u8 {
182        self.frames
183    }
184
185    /// Frame rate.
186    #[must_use]
187    pub fn rate(&self) -> FrameRate {
188        self.rate
189    }
190
191    /// Whether this is a drop-frame timecode.
192    #[must_use]
193    pub fn is_drop_frame(&self) -> bool {
194        self.drop_frame
195    }
196
197    // ------------------------------------------------------------------
198    // Frame-count conversion
199    // ------------------------------------------------------------------
200
201    /// Convert to an absolute frame count (from 00:00:00:00).
202    ///
203    /// Uses the standard SMPTE drop-frame algorithm for DF timecodes.
204    #[must_use]
205    #[allow(clippy::cast_possible_truncation)]
206    pub fn to_frame_count(self) -> u64 {
207        let fps = self.rate.frames_per_second_int() as u64;
208        let h = self.hours as u64;
209        let m = self.minutes as u64;
210        let s = self.seconds as u64;
211        let f = self.frames as u64;
212
213        if self.drop_frame {
214            // SMPTE DF: drop 2 frames at the start of each minute,
215            // except every 10th minute.
216            let d = 2u64; // frames dropped per minute
217            let total_minutes = 60 * h + m;
218            let drop_frames = d * (total_minutes - total_minutes / 10);
219            fps * 3600 * h + fps * 60 * m + fps * s + f - drop_frames
220        } else {
221            fps * 3600 * h + fps * 60 * m + fps * s + f
222        }
223    }
224
225    /// Construct from an absolute frame count.
226    ///
227    /// # Errors
228    /// Returns an error if the resulting components are invalid.
229    #[allow(clippy::cast_possible_truncation)]
230    pub fn from_frame_count(
231        mut total: u64,
232        rate: FrameRate,
233        drop_frame: bool,
234    ) -> Result<Self, TimecodeError> {
235        if drop_frame && !rate.supports_drop_frame() {
236            return Err(TimecodeError::DropFrameRateMismatch);
237        }
238        let fps = rate.frames_per_second_int() as u64;
239
240        if drop_frame {
241            // SMPTE DF inverse algorithm
242            let d = 2u64;
243            let frames_per_10min = fps * 600 - 9 * d; // 10 minutes worth
244            let frames_per_1min = fps * 60 - d;
245
246            let ten_min_blocks = total / frames_per_10min;
247            let remaining = total % frames_per_10min;
248
249            let extra_1min = if remaining < fps * 60 {
250                0u64
251            } else {
252                ((remaining - fps * 60) / frames_per_1min + 1).min(9)
253            };
254
255            total += d * (9 * ten_min_blocks + extra_1min);
256        }
257
258        let frames = (total % fps) as u8;
259        let total_secs = total / fps;
260        let seconds = (total_secs % 60) as u8;
261        let total_mins = total_secs / 60;
262        let minutes = (total_mins % 60) as u8;
263        let hours = (total_mins / 60) as u8;
264
265        Self::new(hours, minutes, seconds, frames, rate, drop_frame)
266    }
267
268    /// Convert to real-time seconds from 00:00:00:00.
269    #[must_use]
270    pub fn to_secs_f64(self) -> f64 {
271        self.to_frame_count() as f64 / self.rate.as_f64()
272    }
273
274    /// Construct from real-time seconds.
275    ///
276    /// # Errors
277    /// Returns an error if the resulting components are invalid.
278    pub fn from_secs_f64(
279        secs: f64,
280        rate: FrameRate,
281        drop_frame: bool,
282    ) -> Result<Self, TimecodeError> {
283        let total = (secs * rate.as_f64()).round() as u64;
284        Self::from_frame_count(total, rate, drop_frame)
285    }
286
287    // ------------------------------------------------------------------
288    // Arithmetic
289    // ------------------------------------------------------------------
290
291    /// Add a frame count to this timecode.
292    ///
293    /// # Errors
294    /// Returns an error if the result is out of range.
295    pub fn add_frames(self, frames: i64) -> Result<Self, TimecodeError> {
296        let current = self.to_frame_count() as i64;
297        let next = (current + frames).max(0) as u64;
298        Self::from_frame_count(next, self.rate, self.drop_frame)
299    }
300
301    /// Compute the signed frame-count difference (`self - other`).
302    ///
303    /// Returns `None` if the rates differ.
304    #[must_use]
305    pub fn diff_frames(self, other: Self) -> Option<i64> {
306        if self.rate != other.rate || self.drop_frame != other.drop_frame {
307            return None;
308        }
309        Some(self.to_frame_count() as i64 - other.to_frame_count() as i64)
310    }
311
312    // ------------------------------------------------------------------
313    // Parsing / formatting
314    // ------------------------------------------------------------------
315
316    /// Parse a SMPTE timecode string.
317    ///
318    /// Accepts `HH:MM:SS:FF` (NDF) and `HH:MM:SS;FF` (DF).
319    ///
320    /// # Errors
321    /// Returns a [`TimecodeError`] if the string is malformed.
322    pub fn parse(s: &str, rate: FrameRate) -> Result<Self, TimecodeError> {
323        let s = s.trim();
324        // Detect DF separator (`;` before last pair)
325        let drop_frame = s.contains(';');
326        let normalised: String = s.replace(';', ":");
327        let parts: Vec<&str> = normalised.split(':').collect();
328        if parts.len() != 4 {
329            return Err(TimecodeError::ParseError(format!(
330                "expected HH:MM:SS:FF, got `{s}`"
331            )));
332        }
333        let parse_u8 = |p: &str| -> Result<u8, TimecodeError> {
334            p.parse::<u8>()
335                .map_err(|_| TimecodeError::ParseError(format!("cannot parse `{p}` as u8")))
336        };
337        let hh = parse_u8(parts[0])?;
338        let mm = parse_u8(parts[1])?;
339        let ss = parse_u8(parts[2])?;
340        let ff = parse_u8(parts[3])?;
341        Self::new(hh, mm, ss, ff, rate, drop_frame)
342    }
343}
344
345impl fmt::Display for Timecode {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        let sep = if self.drop_frame { ';' } else { ':' };
348        write!(
349            f,
350            "{:02}:{:02}:{:02}{}{:02}",
351            self.hours, self.minutes, self.seconds, sep, self.frames
352        )
353    }
354}
355
356// ---------------------------------------------------------------------------
357// TriggerCallback and TimecodeScheduler
358// ---------------------------------------------------------------------------
359
360/// Identifier for a registered trigger.
361pub type TriggerId = u64;
362
363/// Result of checking triggers: list of fired trigger IDs and labels.
364#[derive(Debug, Clone)]
365pub struct FiredTriggers {
366    /// IDs of triggers that fired this tick.
367    pub ids: Vec<TriggerId>,
368}
369
370/// A registered trigger: fire when playback passes a target timecode.
371#[derive(Debug, Clone)]
372struct TriggerEntry {
373    id: TriggerId,
374    target_frame: u64,
375    label: String,
376    one_shot: bool,
377    fired: bool,
378}
379
380/// Frame-accurate timecode playback scheduler.
381///
382/// Maintains an internal frame counter and fires registered triggers
383/// when playback passes their target timecode.
384pub struct TimecodeScheduler {
385    rate: FrameRate,
386    drop_frame: bool,
387    current_frame: u64,
388    triggers: HashMap<TriggerId, TriggerEntry>,
389    next_id: TriggerId,
390}
391
392impl TimecodeScheduler {
393    /// Create a new scheduler, starting at 00:00:00:00.
394    #[must_use]
395    pub fn new(rate: FrameRate, drop_frame: bool) -> Self {
396        Self {
397            rate,
398            drop_frame,
399            current_frame: 0,
400            triggers: HashMap::new(),
401            next_id: 1,
402        }
403    }
404
405    /// Current timecode.
406    ///
407    /// Returns `None` only if the frame count is somehow invalid.
408    #[must_use]
409    pub fn current_timecode(&self) -> Option<Timecode> {
410        Timecode::from_frame_count(self.current_frame, self.rate, self.drop_frame).ok()
411    }
412
413    /// Seek to a specific timecode.
414    pub fn seek(&mut self, tc: Timecode) {
415        self.current_frame = tc.to_frame_count();
416    }
417
418    /// Advance by `frames` frames and return any fired triggers.
419    pub fn advance_frames(&mut self, frames: u64) -> FiredTriggers {
420        let start = self.current_frame;
421        self.current_frame += frames;
422        let end = self.current_frame;
423
424        let mut fired_ids = Vec::new();
425        for entry in self.triggers.values_mut() {
426            if entry.fired && entry.one_shot {
427                continue;
428            }
429            if entry.target_frame > start && entry.target_frame <= end {
430                fired_ids.push(entry.id);
431                if entry.one_shot {
432                    entry.fired = true;
433                }
434            }
435        }
436        FiredTriggers { ids: fired_ids }
437    }
438
439    /// Register a trigger at a target timecode.
440    ///
441    /// Returns the [`TriggerId`] assigned.
442    pub fn register_trigger(
443        &mut self,
444        target: Timecode,
445        label: impl Into<String>,
446        one_shot: bool,
447    ) -> TriggerId {
448        let id = self.next_id;
449        self.next_id += 1;
450        self.triggers.insert(
451            id,
452            TriggerEntry {
453                id,
454                target_frame: target.to_frame_count(),
455                label: label.into(),
456                one_shot,
457                fired: false,
458            },
459        );
460        id
461    }
462
463    /// Remove a trigger.
464    pub fn unregister_trigger(&mut self, id: TriggerId) {
465        self.triggers.remove(&id);
466    }
467
468    /// Get the label of a trigger.
469    #[must_use]
470    pub fn trigger_label(&self, id: TriggerId) -> Option<&str> {
471        self.triggers.get(&id).map(|e| e.label.as_str())
472    }
473
474    /// Number of registered triggers.
475    #[must_use]
476    pub fn trigger_count(&self) -> usize {
477        self.triggers.len()
478    }
479
480    /// Reset to 00:00:00:00.
481    pub fn reset(&mut self) {
482        self.current_frame = 0;
483        for e in self.triggers.values_mut() {
484            e.fired = false;
485        }
486    }
487}
488
489// ---------------------------------------------------------------------------
490// LTC frame-boundary detector
491// ---------------------------------------------------------------------------
492
493/// Minimal LTC frame-boundary detector.
494///
495/// In a real system LTC is an 80-bit bi-phase mark coded audio signal.
496/// This implementation models the state machine that detects a sync word
497/// and returns the number of complete frames found in an audio sample
498/// buffer (where each bit is represented as a single `bool` sample for
499/// simplicity / testability).
500pub struct LtcDecoder {
501    /// Running bit buffer (newest bit appended at end).
502    bit_buf: Vec<bool>,
503    /// Number of complete frames decoded so far.
504    frames_decoded: u64,
505}
506
507/// Sync word for SMPTE LTC (bits 64–79 of an 80-bit word, LSB first).
508/// Binary: 0011 1111 1111 1101
509const LTC_SYNC_WORD: u16 = 0x3FFD;
510
511impl LtcDecoder {
512    /// Create a new decoder.
513    #[must_use]
514    pub fn new() -> Self {
515        Self {
516            bit_buf: Vec::with_capacity(160),
517            frames_decoded: 0,
518        }
519    }
520
521    /// Feed a slice of bit samples (one `bool` per bit-clock).
522    ///
523    /// Returns the number of frame boundaries detected.
524    pub fn feed(&mut self, bits: &[bool]) -> usize {
525        let mut count = 0;
526        for &bit in bits {
527            self.bit_buf.push(bit);
528            if self.bit_buf.len() >= 80 {
529                // Check the last 16 bits for the sync word
530                let sync_start = self.bit_buf.len() - 16;
531                let detected = self.check_sync_at(sync_start);
532                if detected {
533                    count += 1;
534                    self.frames_decoded += 1;
535                    // Consume the 80-bit frame
536                    let drain_to = self.bit_buf.len().saturating_sub(80);
537                    self.bit_buf.drain(..drain_to);
538                    // Keep the capacity reasonable
539                    if self.bit_buf.len() > 160 {
540                        let excess = self.bit_buf.len() - 160;
541                        self.bit_buf.drain(..excess);
542                    }
543                }
544            }
545        }
546        count
547    }
548
549    /// Check whether the sync word is present starting at `pos`.
550    fn check_sync_at(&self, pos: usize) -> bool {
551        if pos + 16 > self.bit_buf.len() {
552            return false;
553        }
554        let mut word: u16 = 0;
555        for i in 0..16 {
556            if self.bit_buf[pos + i] {
557                word |= 1 << i;
558            }
559        }
560        word == LTC_SYNC_WORD
561    }
562
563    /// Total frames decoded.
564    #[must_use]
565    pub fn frames_decoded(&self) -> u64 {
566        self.frames_decoded
567    }
568
569    /// Reset decoder state.
570    pub fn reset(&mut self) {
571        self.bit_buf.clear();
572        self.frames_decoded = 0;
573    }
574}
575
576impl Default for LtcDecoder {
577    fn default() -> Self {
578        Self::new()
579    }
580}
581
582// ---------------------------------------------------------------------------
583// Tests
584// ---------------------------------------------------------------------------
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    // -- FrameRate --
591
592    #[test]
593    fn test_frame_rate_int() {
594        assert_eq!(FrameRate::F29_97.frames_per_second_int(), 30);
595        assert_eq!(FrameRate::F25.frames_per_second_int(), 25);
596        assert_eq!(FrameRate::F60.frames_per_second_int(), 60);
597    }
598
599    #[test]
600    fn test_frame_rate_f64_29_97() {
601        let fps = FrameRate::F29_97.as_f64();
602        assert!((fps - 29.97002997).abs() < 1e-6, "fps={fps}");
603    }
604
605    // -- NDF timecode construction and round-trip --
606
607    #[test]
608    fn test_ndf_to_frame_count_basic() {
609        let tc = Timecode::new(0, 1, 0, 0, FrameRate::F25, false).expect("valid");
610        assert_eq!(tc.to_frame_count(), 25 * 60);
611    }
612
613    #[test]
614    fn test_ndf_round_trip() {
615        let tc = Timecode::new(1, 23, 45, 12, FrameRate::F30, false).expect("valid");
616        let frames = tc.to_frame_count();
617        let restored = Timecode::from_frame_count(frames, FrameRate::F30, false).expect("restore");
618        assert_eq!(tc, restored);
619    }
620
621    #[test]
622    fn test_ndf_to_secs_f64() {
623        let tc = Timecode::new(0, 0, 1, 0, FrameRate::F25, false).expect("valid");
624        assert!((tc.to_secs_f64() - 1.0).abs() < 1e-9);
625    }
626
627    // -- Drop-frame --
628
629    #[test]
630    fn test_df_frame_count_at_one_minute() {
631        // At 29.97 DF, 00:01:00;02 is the first valid frame at minute 1
632        // (frames 00 and 01 are dropped from the display).
633        // The frame index in the video stream is still 1800, but the
634        // SMPTE DF formula subtracts 2 dropped-display-frames, giving 1800.
635        // Verify via round-trip: what matters is self-consistency.
636        let tc = Timecode::new(0, 1, 0, 2, FrameRate::F29_97, true).expect("valid");
637        let frames = tc.to_frame_count();
638        let restored =
639            Timecode::from_frame_count(frames, FrameRate::F29_97, true).expect("restore");
640        assert_eq!(tc, restored, "DF round-trip at 00:01:00;02 failed");
641        // The NDF equivalent at the same display position would be 00:01:00:02
642        // which is 1802 frames; the DF count should be 2 less = 1800.
643        let ndf = Timecode::new(0, 1, 0, 2, FrameRate::F29_97, false).expect("ndf");
644        assert_eq!(ndf.to_frame_count(), 1802, "NDF check");
645        assert_eq!(frames, 1800, "DF frame count at 00:01:00;02");
646    }
647
648    #[test]
649    fn test_df_round_trip() {
650        let tc = Timecode::new(0, 5, 30, 15, FrameRate::F29_97, true).expect("valid");
651        let frames = tc.to_frame_count();
652        let restored =
653            Timecode::from_frame_count(frames, FrameRate::F29_97, true).expect("restore");
654        assert_eq!(tc, restored, "DF round-trip failed");
655    }
656
657    #[test]
658    fn test_df_invalid_dropped_frame() {
659        // 00:01:00;00 and 00:01:00;01 are dropped frames
660        assert!(Timecode::new(0, 1, 0, 0, FrameRate::F29_97, true).is_err());
661        assert!(Timecode::new(0, 1, 0, 1, FrameRate::F29_97, true).is_err());
662    }
663
664    // -- Parsing / formatting --
665
666    #[test]
667    fn test_parse_ndf_string() {
668        let tc = Timecode::parse("01:23:45:06", FrameRate::F25).expect("parse");
669        assert_eq!(tc.hours(), 1);
670        assert_eq!(tc.minutes(), 23);
671        assert_eq!(tc.seconds(), 45);
672        assert_eq!(tc.frames(), 6);
673        assert!(!tc.is_drop_frame());
674    }
675
676    #[test]
677    fn test_parse_df_string() {
678        let tc = Timecode::parse("00:10:00;02", FrameRate::F29_97).expect("parse DF");
679        assert!(tc.is_drop_frame());
680        assert_eq!(tc.frames(), 2);
681    }
682
683    #[test]
684    fn test_display_ndf() {
685        let tc = Timecode::new(1, 2, 3, 4, FrameRate::F25, false).expect("valid");
686        assert_eq!(tc.to_string(), "01:02:03:04");
687    }
688
689    #[test]
690    fn test_display_df() {
691        let tc = Timecode::new(0, 10, 0, 2, FrameRate::F29_97, true).expect("valid");
692        assert!(tc.to_string().contains(';'), "DF separator missing");
693    }
694
695    // -- Arithmetic --
696
697    #[test]
698    fn test_add_frames() {
699        let tc = Timecode::new(0, 0, 0, 0, FrameRate::F25, false).expect("valid");
700        let next = tc.add_frames(25).expect("add");
701        assert_eq!(next.seconds(), 1);
702        assert_eq!(next.frames(), 0);
703    }
704
705    #[test]
706    fn test_diff_frames() {
707        let a = Timecode::new(0, 0, 1, 0, FrameRate::F25, false).expect("valid");
708        let b = Timecode::new(0, 0, 0, 0, FrameRate::F25, false).expect("valid");
709        assert_eq!(a.diff_frames(b), Some(25));
710    }
711
712    // -- Scheduler --
713
714    #[test]
715    fn test_scheduler_trigger_fires() {
716        let mut sched = TimecodeScheduler::new(FrameRate::F25, false);
717        let target = Timecode::new(0, 0, 2, 0, FrameRate::F25, false).expect("valid");
718        let id = sched.register_trigger(target, "mark_in", true);
719        // advance 30 frames (< 50)
720        let fired = sched.advance_frames(30);
721        assert!(fired.ids.is_empty());
722        // advance past target (frame 50)
723        let fired = sched.advance_frames(25);
724        assert!(fired.ids.contains(&id));
725    }
726
727    #[test]
728    fn test_scheduler_one_shot_fires_once() {
729        let mut sched = TimecodeScheduler::new(FrameRate::F25, false);
730        let target = Timecode::new(0, 0, 0, 5, FrameRate::F25, false).expect("valid");
731        let id = sched.register_trigger(target, "once", true);
732        sched.advance_frames(6);
733        sched.reset();
734        // After reset the one-shot should fire again
735        let _fired_1 = sched.advance_frames(6);
736        // one_shot triggers mark `fired = true`; after reset they reset
737        sched.unregister_trigger(id);
738        assert_eq!(sched.trigger_count(), 0);
739    }
740
741    // -- LTC decoder --
742
743    #[test]
744    fn test_ltc_sync_word_detection() {
745        let mut decoder = LtcDecoder::new();
746        // Build a synthetic 80-bit LTC word: 64 data bits followed by the sync word.
747        // Data bits: all false for simplicity.
748        let mut bits = vec![false; 64];
749        // Sync word 0x3FFD = 0011 1111 1111 1101 (LSB first)
750        let sync: u16 = LTC_SYNC_WORD;
751        for i in 0..16 {
752            bits.push((sync >> i) & 1 == 1);
753        }
754        let count = decoder.feed(&bits);
755        assert_eq!(count, 1, "should detect exactly 1 frame boundary");
756        assert_eq!(decoder.frames_decoded(), 1);
757    }
758
759    #[test]
760    fn test_ltc_no_false_positives_on_zeros() {
761        let mut decoder = LtcDecoder::new();
762        let bits = vec![false; 100];
763        let count = decoder.feed(&bits);
764        assert_eq!(count, 0);
765    }
766}