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
398            .decode_frame(&encoded, 0)
399            .expect("decode should succeed");
400        assert_eq!(decoded.timecode.hours, 1);
401        assert_eq!(decoded.timecode.minutes, 2);
402        assert_eq!(decoded.timecode.seconds, 3);
403        assert_eq!(decoded.timecode.frames, 4);
404    }
405
406    #[test]
407    fn test_encode_decode_midnight() {
408        let parser = make_parser();
409        let tc = Timecode {
410            hours: 0,
411            minutes: 0,
412            seconds: 0,
413            frames: 0,
414            frame_rate: crate::FrameRateInfo {
415                fps: 25,
416                drop_frame: false,
417            },
418            user_bits: 0,
419        };
420        let encoded = parser.encode_frame(&tc);
421        let decoded = parser
422            .decode_frame(&encoded, 0)
423            .expect("decode should succeed");
424        assert_eq!(decoded.timecode.hours, 0);
425        assert_eq!(decoded.timecode.seconds, 0);
426    }
427
428    #[test]
429    fn test_decode_bits_finds_one_frame() {
430        let parser = make_parser();
431        let tc = Timecode {
432            hours: 0,
433            minutes: 1,
434            seconds: 2,
435            frames: 3,
436            frame_rate: crate::FrameRateInfo {
437                fps: 25,
438                drop_frame: false,
439            },
440            user_bits: 0,
441        };
442        let bits = parser.encode_frame(&tc);
443        let frames = parser.decode_bits(&bits);
444        assert_eq!(frames.len(), 1);
445    }
446
447    #[test]
448    fn test_decode_bits_empty() {
449        assert!(make_parser().decode_bits(&[]).is_empty());
450    }
451
452    #[test]
453    fn test_decode_bits_too_short() {
454        let bits = vec![LtcBit::Zero; 40];
455        assert!(make_parser().decode_bits(&bits).is_empty());
456    }
457
458    #[test]
459    fn test_decode_frame_buffer_too_small() {
460        let bits = vec![LtcBit::Zero; 79];
461        let err = make_parser().decode_frame(&bits, 0);
462        assert_eq!(err, Err(TimecodeError::BufferTooSmall));
463    }
464
465    #[test]
466    fn test_decode_drop_frame_flag() {
467        let parser = LtcParser::new(30, true);
468        let tc = Timecode {
469            hours: 0,
470            minutes: 0,
471            seconds: 5,
472            frames: 0,
473            frame_rate: crate::FrameRateInfo {
474                fps: 30,
475                drop_frame: true,
476            },
477            user_bits: 0,
478        };
479        let encoded = parser.encode_frame(&tc);
480        let decoded = parser
481            .decode_frame(&encoded, 0)
482            .expect("decode should succeed");
483        assert!(decoded.drop_frame);
484    }
485
486    #[test]
487    fn test_build_ltc_word_length() {
488        let word = build_ltc_word(1, 2, 3, 4, false, 25);
489        assert_eq!(word.len(), 80);
490    }
491
492    #[test]
493    fn test_decode_frame_bit_offset() {
494        let parser = make_parser();
495        let tc = Timecode {
496            hours: 0,
497            minutes: 0,
498            seconds: 0,
499            frames: 0,
500            frame_rate: crate::FrameRateInfo {
501                fps: 25,
502                drop_frame: false,
503            },
504            user_bits: 0,
505        };
506        let bits = parser.encode_frame(&tc);
507        let decoded = parser
508            .decode_frame(&bits, 0)
509            .expect("decode should succeed");
510        assert_eq!(decoded.bit_offset, 0);
511    }
512
513    #[test]
514    fn test_ltcframe_color_frame_default_false() {
515        let parser = make_parser();
516        let tc = Timecode {
517            hours: 0,
518            minutes: 0,
519            seconds: 0,
520            frames: 0,
521            frame_rate: crate::FrameRateInfo {
522                fps: 25,
523                drop_frame: false,
524            },
525            user_bits: 0,
526        };
527        let encoded = parser.encode_frame(&tc);
528        let decoded = parser
529            .decode_frame(&encoded, 0)
530            .expect("decode should succeed");
531        assert!(!decoded.color_frame);
532    }
533}