Skip to main content

oximedia_timecode/
ltc_parser.rs

1//! LTC (Linear Timecode) bit-level parser.
2//!
3//! Parses the raw 80-bit LTC word into a `LtcFrame` containing a decoded
4//! `Timecode`.  The parser operates on a slice of `LtcBit` values
5//! (i.e. clock-qualified biphase-mark decoded bits) and locates the sync
6//! word, then reconstructs the timecode fields.
7//!
8//! # SMPTE 12M LTC word layout (80 bits, LSB first per group)
9//! - Bits 0-3:   frame units
10//! - Bit 4:      user bit 1
11//! - Bit 5:      user bit 2  (actually these are user bit nibbles interleaved)
12//! - Bits 4,6,10,12,18,20,26,28: user-bit nibble pairs
13//! - Bits 8-9:   frame tens + drop-frame flag (bit 10) + color-frame (bit 11)
14//! - Bits 16-19: seconds units
15//! - Bits 24-26: seconds tens
16//! - Bits 32-35: minutes units
17//! - Bits 40-42: minutes tens
18//! - Bits 48-51: hours units
19//! - Bits 56-57: hours tens
20//! - Bits 64-79: sync word 0xBFFC (0011111111111101 in LS→MS order)
21
22#![allow(dead_code)]
23#![allow(clippy::cast_precision_loss)]
24
25use crate::{FrameRateInfo, Timecode, TimecodeError};
26
27// ── LtcBit ────────────────────────────────────────────────────────────────────
28
29/// A single logical bit in an LTC data stream after biphase-mark decoding.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LtcBit {
32    /// Logical zero.
33    Zero,
34    /// Logical one.
35    One,
36}
37
38impl LtcBit {
39    /// Convert to `u8` (0 or 1).
40    pub fn as_u8(self) -> u8 {
41        match self {
42            Self::Zero => 0,
43            Self::One => 1,
44        }
45    }
46}
47
48impl From<bool> for LtcBit {
49    fn from(b: bool) -> Self {
50        if b {
51            Self::One
52        } else {
53            Self::Zero
54        }
55    }
56}
57
58impl From<u8> for LtcBit {
59    fn from(v: u8) -> Self {
60        if v != 0 {
61            Self::One
62        } else {
63            Self::Zero
64        }
65    }
66}
67
68// ── LtcFrame ─────────────────────────────────────────────────────────────────
69
70/// A fully decoded LTC frame.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct LtcFrame {
73    /// The decoded timecode.
74    pub timecode: Timecode,
75    /// 32-bit user bits extracted from the LTC word.
76    pub user_bits: u32,
77    /// Drop-frame flag as encoded in the bitstream.
78    pub drop_frame: bool,
79    /// Color-frame flag.
80    pub color_frame: bool,
81    /// Biphase-mark polarity correction bit.
82    pub biphase_polarity: bool,
83    /// Byte position (bit 0 offset) within the source buffer where this frame began.
84    pub bit_offset: usize,
85}
86
87// ── LtcParser ────────────────────────────────────────────────────────────────
88
89/// Parses raw LTC bit streams into `LtcFrame` values.
90///
91/// # Example
92/// ```
93/// use oximedia_timecode::ltc_parser::{LtcBit, LtcParser};
94///
95/// let parser = LtcParser::new(30, false);
96/// // Build a minimal 80-bit all-zero LTC word (plus sync word)
97/// let mut bits = vec![LtcBit::Zero; 64];
98/// // Append sync word: 0011 1111 1111 1101  (LS bit first)
99/// let sync: [u8; 16] = [0,0,1,1, 1,1,1,1, 1,1,1,1, 1,1,0,1];
100/// bits.extend(sync.iter().map(|&b| LtcBit::from(b)));
101/// let frames = parser.decode_bits(&bits);
102/// assert_eq!(frames.len(), 1);
103/// ```
104#[derive(Debug, Clone)]
105pub struct LtcParser {
106    /// Nominal frames-per-second (30 for NTSC, 25 for PAL, etc.)
107    pub fps: u8,
108    /// Whether drop-frame mode should be assumed when not encoded.
109    pub default_drop_frame: bool,
110}
111
112impl LtcParser {
113    /// The 16-bit LTC sync word value (bit 64..79 of each LTC word).
114    /// In bit order from bit-64 to bit-79 (LSB first): 0011 1111 1111 1101
115    const SYNC_BITS: [u8; 16] = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1];
116
117    /// Create a new parser.
118    ///
119    /// `fps` should be 24, 25, or 30.  `default_drop_frame` sets the drop-frame
120    /// assumption for streams that do not encode the flag.
121    pub fn new(fps: u8, default_drop_frame: bool) -> Self {
122        Self {
123            fps,
124            default_drop_frame,
125        }
126    }
127
128    /// Scan a slice of bits for valid LTC frames and return all decoded frames.
129    ///
130    /// The function searches for the 16-bit sync word at the end of each
131    /// candidate 80-bit window.
132    pub fn decode_bits(&self, bits: &[LtcBit]) -> Vec<LtcFrame> {
133        if bits.len() < 80 {
134            return Vec::new();
135        }
136        let mut frames = Vec::new();
137        let mut i = 0;
138        while i + 80 <= bits.len() {
139            // Check sync word at bits [i+64 .. i+80]
140            if self.check_sync(bits, i + 64) {
141                if let Ok(frame) = self.decode_frame(bits, i) {
142                    frames.push(frame);
143                    i += 80;
144                    continue;
145                }
146            }
147            i += 1;
148        }
149        frames
150    }
151
152    /// Decode a single 80-bit LTC word starting at `offset`.
153    pub fn decode_frame(&self, bits: &[LtcBit], offset: usize) -> Result<LtcFrame, TimecodeError> {
154        if offset + 80 > bits.len() {
155            return Err(TimecodeError::BufferTooSmall);
156        }
157
158        let word = &bits[offset..offset + 80];
159
160        // Helper: extract a nibble (4 bits) from positions in `word`
161        let nibble = |positions: [usize; 4]| -> u8 {
162            positions
163                .iter()
164                .enumerate()
165                .map(|(shift, &pos)| word[pos].as_u8() << shift)
166                .sum()
167        };
168
169        // Frame units (bits 0-3)
170        let frame_units = nibble([0, 1, 2, 3]);
171        // Frame tens (bits 8-9)
172        let frame_tens = nibble([8, 9, 0, 0]) & 0x03;
173        let drop_frame = word[10].as_u8() != 0;
174        let color_frame = word[11].as_u8() != 0;
175
176        // Seconds units (bits 16-19)
177        let sec_units = nibble([16, 17, 18, 19]);
178        // Seconds tens (bits 24-26)
179        let sec_tens = nibble([24, 25, 26, 0]) & 0x07;
180
181        // Minutes units (bits 32-35)
182        let min_units = nibble([32, 33, 34, 35]);
183        // Minutes tens (bits 40-42)
184        let min_tens = nibble([40, 41, 42, 0]) & 0x07;
185
186        // Hours units (bits 48-51)
187        let hr_units = nibble([48, 49, 50, 51]);
188        // Hours tens (bits 56-57)
189        let hr_tens = nibble([56, 57, 0, 0]) & 0x03;
190        let biphase_polarity = word[58].as_u8() != 0;
191
192        let frames = frame_tens * 10 + frame_units;
193        let seconds = sec_tens * 10 + sec_units;
194        let minutes = min_tens * 10 + min_units;
195        let hours = hr_tens * 10 + hr_units;
196
197        // User bits (8 nibbles spread across even bit positions 4,6,12,14,20,22,28,30,36,38...)
198        // Simplified: extract the 8 user-bit nibble positions
199        let ub_positions: [[usize; 4]; 8] = [
200            [4, 5, 0, 0], // UB1 (2 bits only in some specs; use 4 for simplicity)
201            [6, 7, 0, 0],
202            [12, 13, 0, 0],
203            [14, 15, 0, 0],
204            [22, 23, 0, 0],
205            [28, 29, 0, 0], // crossing into next byte area
206            [36, 37, 0, 0],
207            [44, 45, 0, 0],
208        ];
209        let mut user_bits: u32 = 0;
210        for (idx, pos) in ub_positions.iter().enumerate() {
211            let nibval = (word[pos[0]].as_u8() | (word[pos[1]].as_u8() << 1)) as u32;
212            user_bits |= nibval << (idx * 2);
213        }
214
215        let frame_rate_info = FrameRateInfo {
216            fps: self.fps,
217            drop_frame,
218        };
219
220        let timecode = Timecode {
221            hours,
222            minutes,
223            seconds,
224            frames,
225            frame_rate: frame_rate_info,
226            user_bits,
227        };
228
229        Ok(LtcFrame {
230            timecode,
231            user_bits,
232            drop_frame,
233            color_frame,
234            biphase_polarity,
235            bit_offset: offset,
236        })
237    }
238
239    /// Return `true` if the 16 bits at `offset` match the LTC sync word.
240    pub fn check_sync(&self, bits: &[LtcBit], offset: usize) -> bool {
241        if offset + 16 > bits.len() {
242            return false;
243        }
244        Self::SYNC_BITS
245            .iter()
246            .enumerate()
247            .all(|(i, &expected)| bits[offset + i].as_u8() == expected)
248    }
249
250    /// Encode a `Timecode` into an 80-bit LTC word (returned as `Vec<LtcBit>`).
251    ///
252    /// This is the inverse of `decode_frame` and is useful for round-trip tests.
253    pub fn encode_frame(&self, tc: &Timecode) -> Vec<LtcBit> {
254        let mut word = vec![LtcBit::Zero; 80];
255
256        let set_bit = |word: &mut Vec<LtcBit>, pos: usize, val: u8| {
257            word[pos] = LtcBit::from(val);
258        };
259
260        // Frame units / tens
261        let fu = tc.frames % 10;
262        let ft = tc.frames / 10;
263        for i in 0..4 {
264            set_bit(&mut word, i, (fu >> i) & 1);
265        }
266        set_bit(&mut word, 8, ft & 1);
267        set_bit(&mut word, 9, (ft >> 1) & 1);
268        set_bit(&mut word, 10, tc.frame_rate.drop_frame as u8);
269
270        // Seconds
271        let su = tc.seconds % 10;
272        let st = tc.seconds / 10;
273        for i in 0..4 {
274            set_bit(&mut word, 16 + i, (su >> i) & 1);
275        }
276        for i in 0..3 {
277            set_bit(&mut word, 24 + i, (st >> i) & 1);
278        }
279
280        // Minutes
281        let mu = tc.minutes % 10;
282        let mt = tc.minutes / 10;
283        for i in 0..4 {
284            set_bit(&mut word, 32 + i, (mu >> i) & 1);
285        }
286        for i in 0..3 {
287            set_bit(&mut word, 40 + i, (mt >> i) & 1);
288        }
289
290        // Hours
291        let hu = tc.hours % 10;
292        let ht = tc.hours / 10;
293        for i in 0..4 {
294            set_bit(&mut word, 48 + i, (hu >> i) & 1);
295        }
296        for i in 0..2 {
297            set_bit(&mut word, 56 + i, (ht >> i) & 1);
298        }
299
300        // Sync word
301        for (i, &b) in Self::SYNC_BITS.iter().enumerate() {
302            set_bit(&mut word, 64 + i, b);
303        }
304
305        word
306    }
307}
308
309/// Helper: build an 80-bit LTC word from raw field values (for test construction).
310pub fn build_ltc_word(
311    hours: u8,
312    minutes: u8,
313    seconds: u8,
314    frames: u8,
315    drop_frame: bool,
316    fps: u8,
317) -> Vec<LtcBit> {
318    use crate::FrameRateInfo;
319    let tc = Timecode {
320        hours,
321        minutes,
322        seconds,
323        frames,
324        frame_rate: FrameRateInfo { fps, drop_frame },
325        user_bits: 0,
326    };
327    let parser = LtcParser::new(fps, drop_frame);
328    parser.encode_frame(&tc)
329}
330
331// ── Tests ─────────────────────────────────────────────────────────────────────
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn make_parser() -> LtcParser {
338        LtcParser::new(25, false)
339    }
340
341    #[test]
342    fn test_ltcbit_from_bool() {
343        assert_eq!(LtcBit::from(true), LtcBit::One);
344        assert_eq!(LtcBit::from(false), LtcBit::Zero);
345    }
346
347    #[test]
348    fn test_ltcbit_from_u8() {
349        assert_eq!(LtcBit::from(1u8), LtcBit::One);
350        assert_eq!(LtcBit::from(0u8), LtcBit::Zero);
351        assert_eq!(LtcBit::from(255u8), LtcBit::One);
352    }
353
354    #[test]
355    fn test_ltcbit_as_u8() {
356        assert_eq!(LtcBit::One.as_u8(), 1);
357        assert_eq!(LtcBit::Zero.as_u8(), 0);
358    }
359
360    #[test]
361    fn test_check_sync_valid() {
362        let mut bits = vec![LtcBit::Zero; 80];
363        for (i, &b) in LtcParser::SYNC_BITS.iter().enumerate() {
364            bits[64 + i] = LtcBit::from(b);
365        }
366        assert!(make_parser().check_sync(&bits, 64));
367    }
368
369    #[test]
370    fn test_check_sync_invalid() {
371        let bits = vec![LtcBit::Zero; 80];
372        assert!(!make_parser().check_sync(&bits, 64));
373    }
374
375    #[test]
376    fn test_check_sync_too_short() {
377        let bits = vec![LtcBit::Zero; 10];
378        assert!(!make_parser().check_sync(&bits, 0));
379    }
380
381    #[test]
382    fn test_encode_decode_roundtrip_simple() {
383        let parser = make_parser();
384        let tc = Timecode {
385            hours: 1,
386            minutes: 2,
387            seconds: 3,
388            frames: 4,
389            frame_rate: crate::FrameRateInfo {
390                fps: 25,
391                drop_frame: false,
392            },
393            user_bits: 0,
394        };
395        let encoded = parser.encode_frame(&tc);
396        assert_eq!(encoded.len(), 80);
397        let decoded = parser.decode_frame(&encoded, 0).unwrap();
398        assert_eq!(decoded.timecode.hours, 1);
399        assert_eq!(decoded.timecode.minutes, 2);
400        assert_eq!(decoded.timecode.seconds, 3);
401        assert_eq!(decoded.timecode.frames, 4);
402    }
403
404    #[test]
405    fn test_encode_decode_midnight() {
406        let parser = make_parser();
407        let tc = Timecode {
408            hours: 0,
409            minutes: 0,
410            seconds: 0,
411            frames: 0,
412            frame_rate: crate::FrameRateInfo {
413                fps: 25,
414                drop_frame: false,
415            },
416            user_bits: 0,
417        };
418        let encoded = parser.encode_frame(&tc);
419        let decoded = parser.decode_frame(&encoded, 0).unwrap();
420        assert_eq!(decoded.timecode.hours, 0);
421        assert_eq!(decoded.timecode.seconds, 0);
422    }
423
424    #[test]
425    fn test_decode_bits_finds_one_frame() {
426        let parser = make_parser();
427        let tc = Timecode {
428            hours: 0,
429            minutes: 1,
430            seconds: 2,
431            frames: 3,
432            frame_rate: crate::FrameRateInfo {
433                fps: 25,
434                drop_frame: false,
435            },
436            user_bits: 0,
437        };
438        let bits = parser.encode_frame(&tc);
439        let frames = parser.decode_bits(&bits);
440        assert_eq!(frames.len(), 1);
441    }
442
443    #[test]
444    fn test_decode_bits_empty() {
445        assert!(make_parser().decode_bits(&[]).is_empty());
446    }
447
448    #[test]
449    fn test_decode_bits_too_short() {
450        let bits = vec![LtcBit::Zero; 40];
451        assert!(make_parser().decode_bits(&bits).is_empty());
452    }
453
454    #[test]
455    fn test_decode_frame_buffer_too_small() {
456        let bits = vec![LtcBit::Zero; 79];
457        let err = make_parser().decode_frame(&bits, 0);
458        assert_eq!(err, Err(TimecodeError::BufferTooSmall));
459    }
460
461    #[test]
462    fn test_decode_drop_frame_flag() {
463        let parser = LtcParser::new(30, true);
464        let tc = Timecode {
465            hours: 0,
466            minutes: 0,
467            seconds: 5,
468            frames: 0,
469            frame_rate: crate::FrameRateInfo {
470                fps: 30,
471                drop_frame: true,
472            },
473            user_bits: 0,
474        };
475        let encoded = parser.encode_frame(&tc);
476        let decoded = parser.decode_frame(&encoded, 0).unwrap();
477        assert!(decoded.drop_frame);
478    }
479
480    #[test]
481    fn test_build_ltc_word_length() {
482        let word = build_ltc_word(1, 2, 3, 4, false, 25);
483        assert_eq!(word.len(), 80);
484    }
485
486    #[test]
487    fn test_decode_frame_bit_offset() {
488        let parser = make_parser();
489        let tc = Timecode {
490            hours: 0,
491            minutes: 0,
492            seconds: 0,
493            frames: 0,
494            frame_rate: crate::FrameRateInfo {
495                fps: 25,
496                drop_frame: false,
497            },
498            user_bits: 0,
499        };
500        let bits = parser.encode_frame(&tc);
501        let decoded = parser.decode_frame(&bits, 0).unwrap();
502        assert_eq!(decoded.bit_offset, 0);
503    }
504
505    #[test]
506    fn test_ltcframe_color_frame_default_false() {
507        let parser = make_parser();
508        let tc = Timecode {
509            hours: 0,
510            minutes: 0,
511            seconds: 0,
512            frames: 0,
513            frame_rate: crate::FrameRateInfo {
514                fps: 25,
515                drop_frame: false,
516            },
517            user_bits: 0,
518        };
519        let encoded = parser.encode_frame(&tc);
520        let decoded = parser.decode_frame(&encoded, 0).unwrap();
521        assert!(!decoded.color_frame);
522    }
523}