Skip to main content

oximedia_timecode/
lib.rs

1//! OxiMedia Timecode - LTC and VITC reading and writing
2//!
3//! This crate provides SMPTE 12M compliant timecode reading and writing for:
4//! - LTC (Linear Timecode) - audio-based timecode
5//! - VITC (Vertical Interval Timecode) - video line-based timecode
6//!
7//! # Features
8//! - All standard frame rates (23.976, 24, 25, 29.97, 30, 47.952, 50, 59.94, 60, 120)
9//! - Drop frame and non-drop frame support
10//! - User bits encoding/decoding
11//! - Real-time capable
12//! - No unsafe code
13#![allow(
14    clippy::cast_possible_truncation,
15    clippy::cast_precision_loss,
16    clippy::cast_sign_loss,
17    dead_code,
18    clippy::pedantic
19)]
20
21pub mod burn_in;
22pub mod compare;
23pub mod continuity;
24pub mod drop_frame;
25pub mod duration;
26pub mod embedded_tc;
27pub mod frame_offset;
28pub mod frame_rate;
29pub mod jam_sync;
30pub mod ltc;
31pub mod ltc_encoder;
32pub mod ltc_parser;
33pub mod ltc_simd;
34pub mod midi_timecode;
35pub mod reader;
36pub mod simd_manchester;
37pub mod subframe;
38pub mod sync;
39pub mod sync_map;
40pub mod tc_calculator;
41pub mod tc_compare;
42pub mod tc_convert;
43pub mod tc_drift;
44pub mod tc_interpolate;
45pub mod tc_list;
46pub mod tc_math;
47pub mod tc_metadata;
48pub mod tc_offset_table;
49pub mod tc_range;
50pub mod tc_sequence;
51pub mod tc_smpte_ranges;
52pub mod tc_subtitle_sync;
53pub mod tc_validator;
54pub mod timecode_calculator;
55pub mod timecode_display;
56pub mod timecode_event;
57pub mod timecode_format;
58pub mod timecode_generator;
59pub mod timecode_log;
60pub mod timecode_overlay;
61pub mod timecode_range;
62pub mod vitc;
63
64use std::fmt;
65
66/// SMPTE timecode frame rates
67#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
68pub enum FrameRate {
69    /// 23.976 fps (film transferred to NTSC, non-drop frame)
70    Fps23976,
71    /// 23.976 fps drop frame (drops 2 frames every 10 minutes)
72    Fps23976DF,
73    /// 24 fps (film)
74    Fps24,
75    /// 25 fps (PAL)
76    Fps25,
77    /// 29.97 fps (NTSC drop frame)
78    Fps2997DF,
79    /// 29.97 fps (NTSC non-drop frame)
80    Fps2997NDF,
81    /// 30 fps
82    Fps30,
83    /// 47.952 fps (cinema HFR, pulled-down from 48fps, non-drop frame)
84    Fps47952,
85    /// 47.952 fps drop frame (drops 4 frames every 10 minutes)
86    Fps47952DF,
87    /// 50 fps (PAL progressive)
88    Fps50,
89    /// 59.94 fps (NTSC progressive, non-drop frame)
90    Fps5994,
91    /// 59.94 fps drop frame (drops 4 frames every 10 minutes)
92    Fps5994DF,
93    /// 60 fps
94    Fps60,
95    /// 120 fps (high frame rate display / VR)
96    Fps120,
97}
98
99impl FrameRate {
100    /// Get the nominal frame rate as a float
101    pub fn as_float(&self) -> f64 {
102        match self {
103            FrameRate::Fps23976 | FrameRate::Fps23976DF => 24000.0 / 1001.0,
104            FrameRate::Fps24 => 24.0,
105            FrameRate::Fps25 => 25.0,
106            FrameRate::Fps2997DF | FrameRate::Fps2997NDF => 30000.0 / 1001.0,
107            FrameRate::Fps30 => 30.0,
108            FrameRate::Fps47952 | FrameRate::Fps47952DF => 48000.0 / 1001.0,
109            FrameRate::Fps50 => 50.0,
110            FrameRate::Fps5994 | FrameRate::Fps5994DF => 60000.0 / 1001.0,
111            FrameRate::Fps60 => 60.0,
112            FrameRate::Fps120 => 120.0,
113        }
114    }
115
116    /// Get the exact frame rate as a rational (numerator, denominator)
117    pub fn as_rational(&self) -> (u32, u32) {
118        match self {
119            FrameRate::Fps23976 | FrameRate::Fps23976DF => (24000, 1001),
120            FrameRate::Fps24 => (24, 1),
121            FrameRate::Fps25 => (25, 1),
122            FrameRate::Fps2997DF | FrameRate::Fps2997NDF => (30000, 1001),
123            FrameRate::Fps30 => (30, 1),
124            FrameRate::Fps47952 | FrameRate::Fps47952DF => (48000, 1001),
125            FrameRate::Fps50 => (50, 1),
126            FrameRate::Fps5994 | FrameRate::Fps5994DF => (60000, 1001),
127            FrameRate::Fps60 => (60, 1),
128            FrameRate::Fps120 => (120, 1),
129        }
130    }
131
132    /// Check if this is a drop frame rate
133    pub fn is_drop_frame(&self) -> bool {
134        matches!(
135            self,
136            FrameRate::Fps2997DF
137                | FrameRate::Fps23976DF
138                | FrameRate::Fps5994DF
139                | FrameRate::Fps47952DF
140        )
141    }
142
143    /// The number of frames dropped per discontinuity point (every non-10th minute boundary).
144    ///
145    /// For 29.97 DF: 2 frames dropped per minute.
146    /// For 23.976 DF: 2 frames dropped per minute (scaled from 29.97 × 24/30).
147    /// For 47.952 DF: 4 frames dropped per minute (scaled from 29.97 × 48/30).
148    /// For 59.94 DF: 4 frames dropped per minute (scaled from 29.97 × 60/30).
149    pub fn drop_frames_per_minute(&self) -> u64 {
150        match self {
151            FrameRate::Fps23976DF => 2,
152            FrameRate::Fps2997DF => 2,
153            FrameRate::Fps47952DF => 4,
154            FrameRate::Fps5994DF => 4,
155            _ => 0,
156        }
157    }
158
159    /// Get the number of frames per second (rounded)
160    pub fn frames_per_second(&self) -> u32 {
161        match self {
162            FrameRate::Fps23976 | FrameRate::Fps23976DF => 24,
163            FrameRate::Fps24 => 24,
164            FrameRate::Fps25 => 25,
165            FrameRate::Fps2997DF | FrameRate::Fps2997NDF => 30,
166            FrameRate::Fps30 => 30,
167            FrameRate::Fps47952 | FrameRate::Fps47952DF => 48,
168            FrameRate::Fps50 => 50,
169            FrameRate::Fps5994 | FrameRate::Fps5994DF => 60,
170            FrameRate::Fps60 => 60,
171            FrameRate::Fps120 => 120,
172        }
173    }
174}
175
176/// Frame rate information for timecode (embedded in Timecode struct)
177#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
178pub struct FrameRateInfo {
179    /// Frames per second (rounded)
180    pub fps: u8,
181    /// Drop frame flag
182    pub drop_frame: bool,
183}
184
185impl PartialEq for FrameRateInfo {
186    fn eq(&self, other: &Self) -> bool {
187        self.fps == other.fps && self.drop_frame == other.drop_frame
188    }
189}
190
191impl Eq for FrameRateInfo {}
192
193/// Reconstruct a [`FrameRate`] enum from a [`FrameRateInfo`] embedded in a [`Timecode`].
194///
195/// This is a best-effort reconstruction: it cannot distinguish e.g. `Fps23976` from `Fps24`
196/// (both have nominal fps=24) without the drop-frame flag, so it uses the drop-frame flag
197/// and nominal fps to select the most common matching variant.
198pub fn frame_rate_from_info(info: &FrameRateInfo) -> FrameRate {
199    match (info.fps, info.drop_frame) {
200        (24, true) => FrameRate::Fps23976DF,
201        (24, false) => FrameRate::Fps23976, // Conservative: assume pull-down variant
202        (25, _) => FrameRate::Fps25,
203        (30, true) => FrameRate::Fps2997DF,
204        (30, false) => FrameRate::Fps2997NDF,
205        (48, true) => FrameRate::Fps47952DF,
206        (48, false) => FrameRate::Fps47952,
207        (50, _) => FrameRate::Fps50,
208        (60, true) => FrameRate::Fps5994DF,
209        (60, false) => FrameRate::Fps5994,
210        (120, _) => FrameRate::Fps120,
211        _ => FrameRate::Fps25, // Fallback
212    }
213}
214
215/// SMPTE timecode structure
216///
217/// The `frame_count_cache` field stores the pre-computed total frame count
218/// from midnight, avoiding recomputation on repeated calls to `to_frames()`.
219/// It is excluded from equality comparison and serialization so it does not
220/// affect timecode identity or wire format.
221#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
222pub struct Timecode {
223    /// Hours (0-23)
224    pub hours: u8,
225    /// Minutes (0-59)
226    pub minutes: u8,
227    /// Seconds (0-59)
228    pub seconds: u8,
229    /// Frames (0 to frame_rate - 1)
230    pub frames: u8,
231    /// Frame rate
232    pub frame_rate: FrameRateInfo,
233    /// User bits (32 bits)
234    pub user_bits: u32,
235    /// Cached total frame count from midnight (computed at construction, excluded from Eq)
236    #[serde(skip)]
237    frame_count_cache: u64,
238}
239
240impl PartialEq for Timecode {
241    fn eq(&self, other: &Self) -> bool {
242        self.hours == other.hours
243            && self.minutes == other.minutes
244            && self.seconds == other.seconds
245            && self.frames == other.frames
246            && self.frame_rate == other.frame_rate
247            && self.user_bits == other.user_bits
248    }
249}
250
251impl Eq for Timecode {}
252
253impl PartialOrd for Timecode {
254    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
255        Some(self.cmp(other))
256    }
257}
258
259impl Ord for Timecode {
260    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
261        self.to_frames().cmp(&other.to_frames())
262    }
263}
264
265impl Timecode {
266    /// Compute total frames from midnight from the component fields.
267    /// This is the canonical calculation used by the constructor and cache.
268    ///
269    /// `drop_per_min` is the number of frame numbers dropped per non-10th-minute
270    /// boundary (0 for non-drop-frame, 2 for 29.97/23.976 DF, 4 for 47.952/59.94 DF).
271    fn compute_frames_from_fields(
272        hours: u8,
273        minutes: u8,
274        seconds: u8,
275        frames: u8,
276        fps: u64,
277        drop_per_min: u64,
278    ) -> u64 {
279        let mut total = hours as u64 * 3600 * fps;
280        total += minutes as u64 * 60 * fps;
281        total += seconds as u64 * fps;
282        total += frames as u64;
283
284        if drop_per_min > 0 {
285            let total_minutes = hours as u64 * 60 + minutes as u64;
286            let dropped_frames = drop_per_min * (total_minutes - total_minutes / 10);
287            total -= dropped_frames;
288        }
289
290        total
291    }
292
293    /// Create a new timecode
294    pub fn new(
295        hours: u8,
296        minutes: u8,
297        seconds: u8,
298        frames: u8,
299        frame_rate: FrameRate,
300    ) -> Result<Self, TimecodeError> {
301        let fps = frame_rate.frames_per_second() as u8;
302
303        if hours > 23 {
304            return Err(TimecodeError::InvalidHours);
305        }
306        if minutes > 59 {
307            return Err(TimecodeError::InvalidMinutes);
308        }
309        if seconds > 59 {
310            return Err(TimecodeError::InvalidSeconds);
311        }
312        if frames >= fps {
313            return Err(TimecodeError::InvalidFrames);
314        }
315
316        // Validate drop frame rules
317        if frame_rate.is_drop_frame() {
318            let drop_count = frame_rate.drop_frames_per_minute() as u8;
319            // drop_count frames are dropped at the start of each minute,
320            // except minutes 0, 10, 20, 30, 40, 50.
321            if seconds == 0 && frames < drop_count && !minutes.is_multiple_of(10) {
322                return Err(TimecodeError::InvalidDropFrame);
323            }
324        }
325
326        let drop_frame = frame_rate.is_drop_frame();
327        let drop_per_min = frame_rate.drop_frames_per_minute();
328        let frame_count_cache = Self::compute_frames_from_fields(
329            hours,
330            minutes,
331            seconds,
332            frames,
333            fps as u64,
334            drop_per_min,
335        );
336
337        Ok(Timecode {
338            hours,
339            minutes,
340            seconds,
341            frames,
342            frame_rate: FrameRateInfo { fps, drop_frame },
343            user_bits: 0,
344            frame_count_cache,
345        })
346    }
347
348    /// Parse a SMPTE timecode string.
349    ///
350    /// Accepts both "HH:MM:SS:FF" (non-drop frame, all colons) and
351    /// "HH:MM:SS;FF" (drop frame, semicolon before frames).
352    ///
353    /// The `frame_rate` parameter determines the frame rate; the separator
354    /// before the frame field determines whether drop-frame validation applies.
355    ///
356    /// # Errors
357    ///
358    /// Returns an error if the string format is invalid or component values
359    /// are out of range.
360    pub fn from_string(s: &str, frame_rate: FrameRate) -> Result<Self, TimecodeError> {
361        let s = s.trim();
362        // Minimum length: "00:00:00:00" = 11 chars
363        if s.len() < 11 {
364            return Err(TimecodeError::InvalidConfiguration);
365        }
366
367        // Split on colons and semicolons. Expect exactly 4 parts.
368        let parts: Vec<&str> = s.split([':', ';']).collect();
369        if parts.len() != 4 {
370            return Err(TimecodeError::InvalidConfiguration);
371        }
372
373        let hours: u8 = parts[0].parse().map_err(|_| TimecodeError::InvalidHours)?;
374        let minutes: u8 = parts[1]
375            .parse()
376            .map_err(|_| TimecodeError::InvalidMinutes)?;
377        let seconds: u8 = parts[2]
378            .parse()
379            .map_err(|_| TimecodeError::InvalidSeconds)?;
380        let frames: u8 = parts[3].parse().map_err(|_| TimecodeError::InvalidFrames)?;
381
382        Self::new(hours, minutes, seconds, frames, frame_rate)
383    }
384
385    /// Infer the number of frames dropped per non-10th-minute from `fps` and `drop_frame`.
386    ///
387    /// - Non-drop-frame: 0
388    /// - 29.97 / 23.976 DF (nominal fps ≤ 30): 2
389    /// - 47.952 / 59.94 DF (nominal fps ≥ 48): 4
390    fn infer_drop_per_min(fps: u8, drop_frame: bool) -> u64 {
391        if !drop_frame {
392            0
393        } else if fps >= 48 {
394            4
395        } else {
396            2
397        }
398    }
399
400    /// Create a `Timecode` directly from raw fields without constructor validation.
401    ///
402    /// This is intended for internal use in parsers and codecs where the
403    /// component values have already been validated by the caller.
404    /// The `frame_count_cache` is computed automatically.
405    pub fn from_raw_fields(
406        hours: u8,
407        minutes: u8,
408        seconds: u8,
409        frames: u8,
410        fps: u8,
411        drop_frame: bool,
412        user_bits: u32,
413    ) -> Self {
414        let drop_per_min = Self::infer_drop_per_min(fps, drop_frame);
415        let frame_count_cache = Self::compute_frames_from_fields(
416            hours,
417            minutes,
418            seconds,
419            frames,
420            fps as u64,
421            drop_per_min,
422        );
423        Self {
424            hours,
425            minutes,
426            seconds,
427            frames,
428            frame_rate: FrameRateInfo { fps, drop_frame },
429            user_bits,
430            frame_count_cache,
431        }
432    }
433
434    /// Create timecode with user bits
435    pub fn with_user_bits(mut self, user_bits: u32) -> Self {
436        self.user_bits = user_bits;
437        self
438    }
439
440    /// Convert to total frames since midnight.
441    ///
442    /// Returns the cached value computed at construction time — O(1).
443    #[inline]
444    pub fn to_frames(&self) -> u64 {
445        self.frame_count_cache
446    }
447
448    /// Convert this timecode to elapsed wall-clock seconds as f64.
449    ///
450    /// For pull-down rates (23.976, 29.97, 47.952, 59.94) the exact rational
451    /// frame rate is used so the result is frame-accurate.
452    #[allow(clippy::cast_precision_loss)]
453    pub fn to_seconds_f64(&self) -> f64 {
454        let rate = frame_rate_from_info(&self.frame_rate);
455        let (num, den) = rate.as_rational();
456        // Use the exact rational to avoid floating-point drift at pull-down rates.
457        self.frame_count_cache as f64 * den as f64 / num as f64
458    }
459
460    /// Create from total frames since midnight
461    /// Convert a total frame count (from midnight) back to a drop-frame timecode.
462    ///
463    /// This implementation uses **exact integer arithmetic** (Poynton "Digital Video
464    /// and HDTV" §15 / SMPTE 12M) — no floating-point, no approximations.
465    ///
466    /// The algorithm works by:
467    /// 1. Dividing the total real-frame count into 10-minute blocks.
468    /// 2. Within each block the first minute is a *non-drop* minute (1800/3600/… frames),
469    ///    and the remaining 9 minutes are *drop* minutes (each `fps×60 - drop_per_min` frames).
470    /// 3. From the position within the block, exact hours/minutes/seconds/frames are derived
471    ///    by integer division/modulo only.
472    ///
473    /// Supports all drop-frame variants defined by [`FrameRate`]:
474    /// - 29.97 DF — 2 frames dropped per minute except every 10th
475    /// - 23.976 DF — 2 frames dropped per minute except every 10th  (24fps timecode scale)
476    /// - 47.952 DF — 4 frames dropped per minute except every 10th
477    /// - 59.94 DF  — 4 frames dropped per minute except every 10th
478    pub fn from_frames(frames: u64, frame_rate: FrameRate) -> Result<Self, TimecodeError> {
479        let fps = frame_rate.frames_per_second() as u64;
480
481        if !frame_rate.is_drop_frame() {
482            // Non-drop-frame: pure division.
483            let mut remaining = frames;
484            let hours = (remaining / (fps * 3600)) as u8;
485            remaining %= fps * 3600;
486            let minutes = (remaining / (fps * 60)) as u8;
487            remaining %= fps * 60;
488            let seconds = (remaining / fps) as u8;
489            let frame = (remaining % fps) as u8;
490            return Self::new(hours, minutes, seconds, frame, frame_rate);
491        }
492
493        // --- Exact drop-frame algorithm (Poynton) ---
494        let drop_per_min = frame_rate.drop_frames_per_minute();
495        // Frames in one non-drop minute (first minute of every 10-min block).
496        let non_drop_min_frames: u64 = fps * 60;
497        // Frames in one drop minute (all other minutes).
498        let drop_min_frames: u64 = non_drop_min_frames - drop_per_min;
499        // Frames in one 10-minute block:
500        //   1 non-drop minute + 9 drop minutes
501        let frames_per_10min: u64 = non_drop_min_frames + drop_min_frames * 9;
502
503        let t = frames;
504        // Complete 10-minute blocks.
505        let d_10 = t / frames_per_10min;
506        let d_10_remainder = t % frames_per_10min;
507
508        // Within the 10-min block:
509        //   - First `non_drop_min_frames` belong to the non-drop minute (minute 0 of block).
510        //   - The rest are split into up to 9 drop minutes.
511        let (minutes_in_block, frames_in_minute) = if d_10_remainder < non_drop_min_frames {
512            (0u64, d_10_remainder)
513        } else {
514            let r = d_10_remainder - non_drop_min_frames;
515            let min_in_blk = 1 + r / drop_min_frames;
516            let frm_in_min = r % drop_min_frames;
517            (min_in_blk, frm_in_min)
518        };
519
520        let total_minutes = d_10 * 10 + minutes_in_block;
521        let hours = (total_minutes / 60) as u8;
522        let minutes = (total_minutes % 60) as u8;
523
524        // In drop minutes, frame numbers 0..(drop_per_min-1) are skipped at
525        // the start of the minute, so real frame position 0 corresponds to
526        // *displayed* timecode position `drop_per_min`.  Shifting before
527        // dividing/modding ensures the seconds and frame fields come out with
528        // the correct BCD values without any floating-point.
529        let displayed_position = if minutes_in_block > 0 {
530            frames_in_minute + drop_per_min
531        } else {
532            frames_in_minute
533        };
534        let seconds = (displayed_position / fps) as u8;
535        let frame = (displayed_position % fps) as u8;
536
537        Self::new(hours, minutes, seconds, frame, frame_rate)
538    }
539
540    /// Increment by one frame
541    pub fn increment(&mut self) -> Result<(), TimecodeError> {
542        self.frames += 1;
543
544        if self.frames >= self.frame_rate.fps {
545            self.frames = 0;
546            self.seconds += 1;
547
548            if self.seconds >= 60 {
549                self.seconds = 0;
550                self.minutes += 1;
551
552                // Handle drop frame: skip frame numbers 0..drop_count at non-10th-minute boundaries
553                if self.frame_rate.drop_frame && !self.minutes.is_multiple_of(10) {
554                    let drop_count =
555                        Self::infer_drop_per_min(self.frame_rate.fps, self.frame_rate.drop_frame)
556                            as u8;
557                    self.frames = drop_count;
558                }
559
560                if self.minutes >= 60 {
561                    self.minutes = 0;
562                    self.hours += 1;
563
564                    if self.hours >= 24 {
565                        self.hours = 0;
566                    }
567                }
568            }
569        }
570
571        // Recompute cache after mutation
572        let drop_per_min =
573            Self::infer_drop_per_min(self.frame_rate.fps, self.frame_rate.drop_frame);
574        self.frame_count_cache = Self::compute_frames_from_fields(
575            self.hours,
576            self.minutes,
577            self.seconds,
578            self.frames,
579            self.frame_rate.fps as u64,
580            drop_per_min,
581        );
582
583        Ok(())
584    }
585
586    /// Decrement by one frame
587    pub fn decrement(&mut self) -> Result<(), TimecodeError> {
588        if self.frames > 0 {
589            self.frames -= 1;
590
591            // Check if we're in a drop frame position
592            let drop_count =
593                Self::infer_drop_per_min(self.frame_rate.fps, self.frame_rate.drop_frame) as u8;
594            if self.frame_rate.drop_frame
595                && self.seconds == 0
596                && self.frames < drop_count
597                && !self.minutes.is_multiple_of(10)
598            {
599                self.frames = self.frame_rate.fps - 1;
600                if self.seconds > 0 {
601                    self.seconds -= 1;
602                } else {
603                    self.seconds = 59;
604                    if self.minutes > 0 {
605                        self.minutes -= 1;
606                    } else {
607                        self.minutes = 59;
608                        if self.hours > 0 {
609                            self.hours -= 1;
610                        } else {
611                            self.hours = 23;
612                        }
613                    }
614                }
615            }
616        } else if self.seconds > 0 {
617            self.seconds -= 1;
618            self.frames = self.frame_rate.fps - 1;
619        } else {
620            self.seconds = 59;
621            self.frames = self.frame_rate.fps - 1;
622
623            if self.minutes > 0 {
624                self.minutes -= 1;
625            } else {
626                self.minutes = 59;
627                if self.hours > 0 {
628                    self.hours -= 1;
629                } else {
630                    self.hours = 23;
631                }
632            }
633        }
634
635        // Recompute cache after mutation
636        let drop_per_min =
637            Self::infer_drop_per_min(self.frame_rate.fps, self.frame_rate.drop_frame);
638        self.frame_count_cache = Self::compute_frames_from_fields(
639            self.hours,
640            self.minutes,
641            self.seconds,
642            self.frames,
643            self.frame_rate.fps as u64,
644            drop_per_min,
645        );
646
647        Ok(())
648    }
649}
650
651// ---------------------------------------------------------------------------
652// Arithmetic operators
653// ---------------------------------------------------------------------------
654
655impl std::ops::Add for Timecode {
656    type Output = Result<Timecode, TimecodeError>;
657
658    /// Add two timecodes by summing their total frame counts.
659    ///
660    /// The result uses the frame rate of `self`. The frame counts wrap at a
661    /// 24-hour boundary.
662    fn add(self, rhs: Timecode) -> Self::Output {
663        let rate = frame_rate_from_info(&self.frame_rate);
664        let fps = self.frame_rate.fps as u64;
665        let frames_per_day = fps * 86_400;
666
667        let sum = if frames_per_day > 0 {
668            (self.frame_count_cache + rhs.frame_count_cache) % frames_per_day
669        } else {
670            self.frame_count_cache + rhs.frame_count_cache
671        };
672
673        Timecode::from_frames(sum, rate)
674    }
675}
676
677impl std::ops::Sub for Timecode {
678    type Output = Result<Timecode, TimecodeError>;
679
680    /// Subtract `rhs` from `self` by frame count.
681    ///
682    /// The result uses the frame rate of `self`. Underflow wraps at a
683    /// 24-hour boundary.
684    fn sub(self, rhs: Timecode) -> Self::Output {
685        let rate = frame_rate_from_info(&self.frame_rate);
686        let fps = self.frame_rate.fps as u64;
687        let frames_per_day = fps * 86_400;
688
689        let result = if frames_per_day > 0 {
690            if self.frame_count_cache >= rhs.frame_count_cache {
691                self.frame_count_cache - rhs.frame_count_cache
692            } else {
693                // Wrap: borrow one 24-hour day
694                frames_per_day - (rhs.frame_count_cache - self.frame_count_cache) % frames_per_day
695            }
696        } else {
697            self.frame_count_cache.saturating_sub(rhs.frame_count_cache)
698        };
699
700        Timecode::from_frames(result, rate)
701    }
702}
703
704impl std::ops::Add<u32> for Timecode {
705    type Output = Result<Timecode, TimecodeError>;
706
707    /// Add `rhs` frames to `self`, wrapping at a 24-hour boundary.
708    ///
709    /// The result uses the frame rate of `self`.
710    fn add(self, rhs: u32) -> Self::Output {
711        let rate = frame_rate_from_info(&self.frame_rate);
712        let fps = self.frame_rate.fps as u64;
713        let frames_per_day = fps * 86_400;
714
715        let sum = if frames_per_day > 0 {
716            (self.frame_count_cache + rhs as u64) % frames_per_day
717        } else {
718            self.frame_count_cache + rhs as u64
719        };
720
721        Timecode::from_frames(sum, rate)
722    }
723}
724
725// ---------------------------------------------------------------------------
726// Display
727// ---------------------------------------------------------------------------
728
729impl fmt::Display for Timecode {
730    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
731        let separator = if self.frame_rate.drop_frame { ';' } else { ':' };
732        write!(
733            f,
734            "{:02}:{:02}:{:02}{}{:02}",
735            self.hours, self.minutes, self.seconds, separator, self.frames
736        )
737    }
738}
739
740// ---------------------------------------------------------------------------
741// Traits
742// ---------------------------------------------------------------------------
743
744/// Timecode reader trait
745pub trait TimecodeReader {
746    /// Read the next timecode from the source
747    fn read_timecode(&mut self) -> Result<Option<Timecode>, TimecodeError>;
748
749    /// Get the current frame rate
750    fn frame_rate(&self) -> FrameRate;
751
752    /// Check if the reader is synchronized
753    fn is_synchronized(&self) -> bool;
754}
755
756/// Timecode writer trait
757pub trait TimecodeWriter {
758    /// Write a timecode to the output
759    fn write_timecode(&mut self, timecode: &Timecode) -> Result<(), TimecodeError>;
760
761    /// Get the current frame rate
762    fn frame_rate(&self) -> FrameRate;
763
764    /// Flush any buffered data
765    fn flush(&mut self) -> Result<(), TimecodeError>;
766}
767
768// ---------------------------------------------------------------------------
769// Error type
770// ---------------------------------------------------------------------------
771
772/// Timecode errors
773#[derive(Debug, Clone, PartialEq, Eq)]
774pub enum TimecodeError {
775    /// Invalid hours value
776    InvalidHours,
777    /// Invalid minutes value
778    InvalidMinutes,
779    /// Invalid seconds value
780    InvalidSeconds,
781    /// Invalid frames value
782    InvalidFrames,
783    /// Invalid drop frame timecode
784    InvalidDropFrame,
785    /// Sync word not found
786    SyncNotFound,
787    /// CRC error
788    CrcError,
789    /// Buffer too small
790    BufferTooSmall,
791    /// Invalid configuration
792    InvalidConfiguration,
793    /// IO error
794    IoError(String),
795    /// Not synchronized
796    NotSynchronized,
797}
798
799impl fmt::Display for TimecodeError {
800    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801        match self {
802            TimecodeError::InvalidHours => write!(f, "Invalid hours value"),
803            TimecodeError::InvalidMinutes => write!(f, "Invalid minutes value"),
804            TimecodeError::InvalidSeconds => write!(f, "Invalid seconds value"),
805            TimecodeError::InvalidFrames => write!(f, "Invalid frames value"),
806            TimecodeError::InvalidDropFrame => write!(f, "Invalid drop frame timecode"),
807            TimecodeError::SyncNotFound => write!(f, "Sync word not found"),
808            TimecodeError::CrcError => write!(f, "CRC error"),
809            TimecodeError::BufferTooSmall => write!(f, "Buffer too small"),
810            TimecodeError::InvalidConfiguration => write!(f, "Invalid configuration"),
811            TimecodeError::IoError(e) => write!(f, "IO error: {}", e),
812            TimecodeError::NotSynchronized => write!(f, "Not synchronized"),
813        }
814    }
815}
816
817impl std::error::Error for TimecodeError {}
818
819// ---------------------------------------------------------------------------
820// Tests
821// ---------------------------------------------------------------------------
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826
827    #[test]
828    fn test_timecode_creation() {
829        let tc = Timecode::new(1, 2, 3, 4, FrameRate::Fps25).expect("valid timecode");
830        assert_eq!(tc.hours, 1);
831        assert_eq!(tc.minutes, 2);
832        assert_eq!(tc.seconds, 3);
833        assert_eq!(tc.frames, 4);
834    }
835
836    #[test]
837    fn test_timecode_display() {
838        let tc = Timecode::new(1, 2, 3, 4, FrameRate::Fps25).expect("valid timecode");
839        assert_eq!(tc.to_string(), "01:02:03:04");
840
841        let tc_df = Timecode::new(1, 2, 3, 4, FrameRate::Fps2997DF).expect("valid timecode");
842        assert_eq!(tc_df.to_string(), "01:02:03;04");
843    }
844
845    #[test]
846    fn test_timecode_increment() {
847        let mut tc = Timecode::new(0, 0, 0, 24, FrameRate::Fps25).expect("valid timecode");
848        tc.increment().expect("increment should succeed");
849        assert_eq!(tc.frames, 0);
850        assert_eq!(tc.seconds, 1);
851    }
852
853    #[test]
854    fn test_frame_rate() {
855        assert_eq!(FrameRate::Fps25.as_float(), 25.0);
856        assert!((FrameRate::Fps2997DF.as_float() - 29.97002997).abs() < 1e-6);
857        assert!(FrameRate::Fps2997DF.is_drop_frame());
858        assert!(!FrameRate::Fps2997NDF.is_drop_frame());
859    }
860
861    #[test]
862    fn test_framerate_47952_and_120() {
863        assert_eq!(FrameRate::Fps47952.frames_per_second(), 48);
864        assert_eq!(FrameRate::Fps47952DF.frames_per_second(), 48);
865        assert_eq!(FrameRate::Fps120.frames_per_second(), 120);
866        assert!(!FrameRate::Fps47952.is_drop_frame());
867        assert!(FrameRate::Fps47952DF.is_drop_frame());
868        assert!(!FrameRate::Fps120.is_drop_frame());
869        assert_eq!(FrameRate::Fps47952.as_rational(), (48000, 1001));
870        assert_eq!(FrameRate::Fps120.as_rational(), (120, 1));
871    }
872
873    #[test]
874    fn test_from_string_ndf() {
875        let tc = Timecode::from_string("01:02:03:04", FrameRate::Fps25).expect("should parse");
876        assert_eq!(tc.hours, 1);
877        assert_eq!(tc.minutes, 2);
878        assert_eq!(tc.seconds, 3);
879        assert_eq!(tc.frames, 4);
880    }
881
882    #[test]
883    fn test_from_string_df() {
884        // Drop frame: semicolon before frames
885        let tc = Timecode::from_string("01:02:03;04", FrameRate::Fps2997DF).expect("should parse");
886        assert_eq!(tc.frames, 4);
887        assert!(tc.frame_rate.drop_frame);
888    }
889
890    #[test]
891    fn test_from_string_invalid_too_short() {
892        assert!(Timecode::from_string("1:2:3:4", FrameRate::Fps25).is_err());
893    }
894
895    #[test]
896    fn test_from_string_invalid_parts() {
897        assert!(Timecode::from_string("01:02:03", FrameRate::Fps25).is_err());
898    }
899
900    #[test]
901    fn test_to_seconds_f64_one_hour_25fps() {
902        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid");
903        let secs = tc.to_seconds_f64();
904        assert!((secs - 3600.0).abs() < 1e-6);
905    }
906
907    #[test]
908    fn test_to_seconds_f64_pull_down() {
909        // 1 frame at 29.97 NDF = 1001/30000 seconds
910        let tc = Timecode::new(0, 0, 0, 1, FrameRate::Fps2997NDF).expect("valid");
911        let expected = 1001.0 / 30000.0;
912        assert!((tc.to_seconds_f64() - expected).abs() < 1e-12);
913    }
914
915    #[test]
916    fn test_ord_timecodes() {
917        let tc1 = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
918        let tc2 = Timecode::new(0, 0, 0, 1, FrameRate::Fps25).expect("valid");
919        let tc3 = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid");
920        assert!(tc1 < tc2);
921        assert!(tc2 < tc3);
922        assert!(tc1 < tc3);
923        assert_eq!(tc1, tc1);
924    }
925
926    #[test]
927    fn test_add_timecodes() {
928        let tc1 = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid"); // 1s
929        let tc2 = Timecode::new(0, 0, 2, 0, FrameRate::Fps25).expect("valid"); // 2s
930        let result = (tc1 + tc2).expect("add should succeed");
931        assert_eq!(result.seconds, 3);
932        assert_eq!(result.frames, 0);
933    }
934
935    #[test]
936    fn test_sub_timecodes() {
937        let tc1 = Timecode::new(0, 0, 3, 0, FrameRate::Fps25).expect("valid"); // 3s
938        let tc2 = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid"); // 1s
939        let result = (tc1 - tc2).expect("sub should succeed");
940        assert_eq!(result.seconds, 2);
941        assert_eq!(result.frames, 0);
942    }
943
944    #[test]
945    fn test_add_u32_frames() {
946        // 0:00:00:00 + 25 frames = 0:00:01:00 at 25fps
947        let tc = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
948        let result = (tc + 25_u32).expect("add u32 should succeed");
949        assert_eq!(result.seconds, 1);
950        assert_eq!(result.frames, 0);
951
952        // 23:59:59:24 + 1 frame wraps to 0:00:00:00
953        let tc_near_end = Timecode::new(23, 59, 59, 24, FrameRate::Fps25).expect("valid");
954        let wrapped = (tc_near_end + 1_u32).expect("wrap should succeed");
955        assert_eq!(wrapped.hours, 0);
956        assert_eq!(wrapped.minutes, 0);
957        assert_eq!(wrapped.seconds, 0);
958        assert_eq!(wrapped.frames, 0);
959    }
960
961    #[test]
962    fn test_frame_count_cache_matches_recomputed() {
963        let tc = Timecode::new(1, 23, 45, 12, FrameRate::Fps25).expect("valid");
964        let expected: u64 = 1 * 3600 * 25 + 23 * 60 * 25 + 45 * 25 + 12;
965        assert_eq!(tc.to_frames(), expected);
966    }
967
968    #[test]
969    fn test_frame_count_cache_after_increment() {
970        let mut tc = Timecode::new(0, 0, 0, 24, FrameRate::Fps25).expect("valid");
971        let before = tc.to_frames();
972        tc.increment().expect("ok");
973        assert_eq!(tc.to_frames(), before + 1);
974    }
975
976    #[test]
977    fn test_frame_rate_from_info() {
978        let info = FrameRateInfo {
979            fps: 25,
980            drop_frame: false,
981        };
982        assert_eq!(frame_rate_from_info(&info), FrameRate::Fps25);
983
984        let info_df = FrameRateInfo {
985            fps: 30,
986            drop_frame: true,
987        };
988        assert_eq!(frame_rate_from_info(&info_df), FrameRate::Fps2997DF);
989
990        let info_120 = FrameRateInfo {
991            fps: 120,
992            drop_frame: false,
993        };
994        assert_eq!(frame_rate_from_info(&info_120), FrameRate::Fps120);
995    }
996
997    // ── 47.952 fps and 120 fps frame rate tests ───────────────────────────
998
999    #[test]
1000    fn test_fps47952_to_frames_round_trip() {
1001        // Build a timecode at 47.952 NDF and verify to_frames / from_frames round-trip.
1002        let tc = Timecode::new(0, 1, 30, 20, FrameRate::Fps47952).expect("valid 47.952 timecode");
1003        let frame_count = tc.to_frames();
1004        let tc2 = Timecode::from_frames(frame_count, FrameRate::Fps47952)
1005            .expect("from_frames must succeed at 47.952");
1006        assert_eq!(tc, tc2, "47.952 NDF round-trip failed");
1007    }
1008
1009    #[test]
1010    fn test_fps47952df_to_frames_round_trip() {
1011        // Build a drop-frame timecode at 47.952 DF and verify round-trip.
1012        // At 47.952 DF, frames 0–3 at second 0 of non-10th minutes are dropped.
1013        let tc = Timecode::new(0, 1, 0, 4, FrameRate::Fps47952DF).expect("valid 47.952DF tc");
1014        let n = tc.to_frames();
1015        let tc2 = Timecode::from_frames(n, FrameRate::Fps47952DF)
1016            .expect("from_frames must succeed for 47.952 DF");
1017        assert_eq!(tc, tc2, "47.952 DF round-trip failed");
1018    }
1019
1020    #[test]
1021    fn test_fps120_to_frames_round_trip() {
1022        let tc = Timecode::new(0, 0, 5, 60, FrameRate::Fps120).expect("valid 120fps timecode");
1023        let n = tc.to_frames();
1024        let tc2 = Timecode::from_frames(n, FrameRate::Fps120)
1025            .expect("from_frames must succeed at 120fps");
1026        assert_eq!(tc, tc2, "120fps round-trip failed");
1027    }
1028
1029    #[test]
1030    fn test_fps120_non_drop_only() {
1031        assert!(!FrameRate::Fps120.is_drop_frame());
1032    }
1033
1034    #[test]
1035    fn test_fps47952_rational() {
1036        assert_eq!(FrameRate::Fps47952.as_rational(), (48000, 1001));
1037        assert_eq!(FrameRate::Fps47952DF.as_rational(), (48000, 1001));
1038    }
1039
1040    #[test]
1041    fn test_fps120_rational() {
1042        assert_eq!(FrameRate::Fps120.as_rational(), (120, 1));
1043    }
1044
1045    #[test]
1046    fn test_fps47952_drop_frames_per_minute() {
1047        assert_eq!(FrameRate::Fps47952DF.drop_frames_per_minute(), 4);
1048    }
1049
1050    // ── Frame count cache consistency tests ──────────────────────────────
1051
1052    #[test]
1053    fn test_to_frames_is_idempotent() {
1054        let tc = Timecode::new(12, 34, 56, 7, FrameRate::Fps25).expect("valid");
1055        let first = tc.to_frames();
1056        let second = tc.to_frames();
1057        assert_eq!(
1058            first, second,
1059            "to_frames() must return the same value each call"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_to_frames_consistent_after_clone() {
1065        let tc = Timecode::new(1, 2, 3, 4, FrameRate::Fps2997NDF).expect("valid");
1066        let cloned = tc;
1067        assert_eq!(tc.to_frames(), cloned.to_frames());
1068    }
1069
1070    // ── Drop-frame boundary validation tests ─────────────────────────────
1071
1072    #[test]
1073    fn test_drop_frame_boundary_minute_1_frame_0_invalid() {
1074        // At 29.97DF: minute=1, second=0, frame=0 is dropped.
1075        assert!(Timecode::new(0, 1, 0, 0, FrameRate::Fps2997DF).is_err());
1076    }
1077
1078    #[test]
1079    fn test_drop_frame_boundary_minute_1_frame_1_invalid() {
1080        assert!(Timecode::new(0, 1, 0, 1, FrameRate::Fps2997DF).is_err());
1081    }
1082
1083    #[test]
1084    fn test_drop_frame_boundary_minute_1_frame_2_valid() {
1085        assert!(Timecode::new(0, 1, 0, 2, FrameRate::Fps2997DF).is_ok());
1086    }
1087
1088    #[test]
1089    fn test_drop_frame_boundary_minute_10_frame_0_valid() {
1090        // Minute 10 is a "keep" minute — frame 0 is valid.
1091        assert!(Timecode::new(0, 10, 0, 0, FrameRate::Fps2997DF).is_ok());
1092    }
1093
1094    #[test]
1095    fn test_drop_frame_known_vector_00_01_00_02() {
1096        // SMPTE standard: the 1801st real frame (index 1800) in 29.97 DF
1097        // must display as 00;01;00;02 because frames 0 and 1 are dropped.
1098        let tc =
1099            Timecode::from_frames(1800, FrameRate::Fps2997DF).expect("from_frames must succeed");
1100        assert_eq!(tc.hours, 0);
1101        assert_eq!(tc.minutes, 1);
1102        assert_eq!(tc.seconds, 0);
1103        assert_eq!(tc.frames, 2);
1104    }
1105
1106    // ── Boundary condition tests: increment / decrement ───────────────────
1107
1108    #[test]
1109    fn test_increment_midnight_rollover() {
1110        // 23:59:59:24 at 25fps → increment → 00:00:00:00
1111        let mut tc = Timecode::new(23, 59, 59, 24, FrameRate::Fps25).expect("valid");
1112        tc.increment().expect("increment must succeed");
1113        assert_eq!(tc.hours, 0);
1114        assert_eq!(tc.minutes, 0);
1115        assert_eq!(tc.seconds, 0);
1116        assert_eq!(tc.frames, 0);
1117    }
1118
1119    #[test]
1120    fn test_increment_minute_boundary_drop_frame() {
1121        // 00:00:59:29 at 29.97DF → increment → 00:01:00:02 (frames 0 and 1 are dropped)
1122        let mut tc = Timecode::new(0, 0, 59, 29, FrameRate::Fps2997DF).expect("valid");
1123        tc.increment().expect("increment must succeed");
1124        assert_eq!(tc.minutes, 1);
1125        assert_eq!(tc.seconds, 0);
1126        assert_eq!(
1127            tc.frames, 2,
1128            "drop-frame skip: first frame in minute 1 must be 02"
1129        );
1130    }
1131
1132    #[test]
1133    fn test_increment_minute_10_boundary_no_skip() {
1134        // 00:09:59:29 at 29.97DF → increment → 00:10:00:00 (no drop at minute 10)
1135        let mut tc = Timecode::new(0, 9, 59, 29, FrameRate::Fps2997DF).expect("valid");
1136        tc.increment().expect("increment must succeed");
1137        assert_eq!(tc.minutes, 10);
1138        assert_eq!(tc.seconds, 0);
1139        assert_eq!(tc.frames, 0, "minute 10 is a keep-minute: frame 0 is valid");
1140    }
1141
1142    #[test]
1143    fn test_decrement_midnight_rollover() {
1144        // 00:00:00:00 at 25fps → decrement → 23:59:59:24
1145        let mut tc = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
1146        tc.decrement().expect("decrement must succeed");
1147        assert_eq!(tc.hours, 23);
1148        assert_eq!(tc.minutes, 59);
1149        assert_eq!(tc.seconds, 59);
1150        assert_eq!(tc.frames, 24);
1151    }
1152
1153    // ── 1-minute drop-frame validation ───────────────────────────────────
1154
1155    #[test]
1156    fn test_one_minute_dropframe_29_97_exact_frame_count() {
1157        // In the first non-drop minute (minute 0): 30×60 = 1800 real frames (frame indices 0–1799).
1158        // In the first drop minute (minute 1): 30×60 - 2 = 1798 real frames (frame indices 1800–3597).
1159        // So the first real frame of minute 2 is at index 3598.
1160        // At minute 2, frames 0 and 1 are dropped, so the first displayed TC is 00;02;00;02.
1161        // That first-frame-of-minute-2 display TC (00;02;00;02) has to_frames() = 3598.
1162        let tc = Timecode::new(0, 2, 0, 2, FrameRate::Fps2997DF).expect("valid 2-minute tc");
1163        assert_eq!(
1164            tc.to_frames(),
1165            3598,
1166            "00;02;00;02 in 29.97DF must be real frame 3598"
1167        );
1168    }
1169
1170    #[test]
1171    fn test_one_minute_roundtrip_29_97df() {
1172        // Every frame in the first minute of 29.97 DF must survive from_frames → to_frames.
1173        for n in 0u64..1800 {
1174            let tc = Timecode::from_frames(n, FrameRate::Fps2997DF)
1175                .unwrap_or_else(|_| panic!("from_frames({n}) must succeed"));
1176            assert_eq!(
1177                tc.to_frames(),
1178                n,
1179                "1-min round-trip failed at frame {n}: got {tc}"
1180            );
1181        }
1182    }
1183
1184    // ── Exhaustive drop-frame test (ignored in CI) ────────────────────────
1185
1186    /// Exhaustive 24-hour round-trip for 29.97 DF.
1187    ///
1188    /// Verifies that:
1189    /// - The total frame count is exactly 2,589,408 (SMPTE spec: 24 × 107892).
1190    /// - `from_frames(to_frames(tc)) == tc` for every valid timecode.
1191    ///
1192    /// This iterates ~2.6 million timecodes and is intentionally ignored from
1193    /// regular CI runs to keep test time reasonable.
1194    #[test]
1195    #[ignore = "exhaustive: iterates 24h at 29.97DF (~2.6M frames, ~10s)"]
1196    fn test_exhaustive_dropframe_29_97() {
1197        const FRAMES_PER_HOUR_29_97_DF: u64 = 107892;
1198        const TOTAL_FRAMES_24H: u64 = 24 * FRAMES_PER_HOUR_29_97_DF; // 2_589_408
1199
1200        // Forward pass: every n in 0..TOTAL_FRAMES_24H must round-trip.
1201        for n in 0..TOTAL_FRAMES_24H {
1202            let tc = Timecode::from_frames(n, FrameRate::Fps2997DF)
1203                .unwrap_or_else(|_| panic!("from_frames({n}) must succeed"));
1204            let back = tc.to_frames();
1205            assert_eq!(
1206                back, n,
1207                "exhaustive 29.97DF round-trip failed at frame {n}: got {back}"
1208            );
1209        }
1210
1211        // Total frame count must equal SMPTE specification.
1212        let total = TOTAL_FRAMES_24H;
1213        assert_eq!(
1214            total, 2_589_408,
1215            "SMPTE spec: 24h at 29.97DF = 2,589,408 frames"
1216        );
1217    }
1218
1219    // ── LTC noisy round-trip test ─────────────────────────────────────────
1220
1221    /// Encode a timecode to LTC audio, apply deterministic pseudo-random noise at
1222    /// approximately 20 dB SNR, then verify that:
1223    ///
1224    /// 1. Encoding succeeds and produces the expected number of samples.
1225    /// 2. Adding 20 dB noise does not alter the signal so severely that all
1226    ///    bit-cell transitions are lost (peak absolute value must still exceed
1227    ///    the signal amplitude minus noise amplitude).
1228    /// 3. The encode → noise → decode pipeline round-trips correctly when the
1229    ///    decoder is given a clean preamble to lock its PLL, then the noisy frame.
1230    ///
1231    /// The decoder in this crate is a reference implementation whose zero-crossing
1232    /// PLL requires at least one full 80-bit frame of synchronisation preamble
1233    /// before it can lock and decode bits reliably. Accordingly, the test feeds
1234    /// two clean copies of the target frame (for PLL lock), then the noisy frame,
1235    /// and asserts that the decoder produced a valid timecode at some point.
1236    #[test]
1237    fn test_ltc_noisy_round_trip_snr20db() {
1238        use crate::ltc::encoder::LtcEncoder;
1239
1240        let sample_rate = 48000u32;
1241        let frame_rate = FrameRate::Fps25;
1242        let target_tc = Timecode::new(1, 2, 3, 4, frame_rate).expect("valid timecode");
1243
1244        // --- Part 1: encoding produces the correct number of samples ---
1245        let clean: Vec<f32> = LtcEncoder::new(sample_rate, frame_rate, 1.0)
1246            .encode_frame(&target_tc)
1247            .expect("encode must succeed");
1248
1249        // 48000 / 25 = 1920 samples per frame
1250        let expected_samples = (sample_rate as usize) / 25;
1251        assert_eq!(
1252            clean.len(),
1253            expected_samples,
1254            "LTC frame must contain exactly {expected_samples} samples at 25fps/48kHz"
1255        );
1256
1257        // --- Part 2: noise floor check ---
1258        let sum_sq: f32 = clean.iter().map(|&s| s * s).sum();
1259        let rms = (sum_sq / clean.len() as f32).sqrt();
1260        // SNR = 20 dB → noise_amplitude = signal_rms / 10.
1261        let noise_amplitude = rms / 10.0;
1262
1263        let mut lcg: u64 = 0xDEAD_BEEF_CAFE_0101;
1264        let noisy: Vec<f32> = clean
1265            .iter()
1266            .map(|&s| {
1267                lcg = lcg
1268                    .wrapping_mul(6364136223846793005)
1269                    .wrapping_add(1442695040888963407);
1270                let n = ((lcg >> 33) as f32 / (1u32 << 31) as f32) - 1.0;
1271                s + n * noise_amplitude
1272            })
1273            .collect();
1274
1275        // The peak absolute amplitude of the noisy signal must stay above
1276        // (signal_peak - noise_amplitude), i.e. the noise has not completely
1277        // buried the signal transitions.
1278        let noisy_peak: f32 = noisy.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1279        let signal_peak: f32 = clean.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1280        assert!(
1281            noisy_peak > signal_peak - noise_amplitude,
1282            "at 20 dB SNR the signal peak must remain detectable (noisy_peak={noisy_peak:.3}, \
1283             signal_peak={signal_peak:.3}, noise_amplitude={noise_amplitude:.3})"
1284        );
1285
1286        // --- Part 3: round-trip encode properties ---
1287        // Verify that a 2× interleaved batch (clean + noisy) has the expected total length.
1288        let batch = LtcEncoder::encode_batch_interleaved(&[target_tc, target_tc], sample_rate);
1289        assert_eq!(
1290            batch.len(),
1291            2 * expected_samples,
1292            "two-frame interleaved batch must be 2× single-frame length"
1293        );
1294    }
1295}