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::from_raw_fields(
221            hours, minutes, seconds, frames, self.fps, drop_frame, user_bits,
222        );
223
224        Ok(LtcFrame {
225            timecode,
226            user_bits,
227            drop_frame,
228            color_frame,
229            biphase_polarity,
230            bit_offset: offset,
231        })
232    }
233
234    /// Return `true` if the 16 bits at `offset` match the LTC sync word.
235    pub fn check_sync(&self, bits: &[LtcBit], offset: usize) -> bool {
236        if offset + 16 > bits.len() {
237            return false;
238        }
239        Self::SYNC_BITS
240            .iter()
241            .enumerate()
242            .all(|(i, &expected)| bits[offset + i].as_u8() == expected)
243    }
244
245    /// Encode a `Timecode` into an 80-bit LTC word (returned as `Vec<LtcBit>`).
246    ///
247    /// This is the inverse of `decode_frame` and is useful for round-trip tests.
248    pub fn encode_frame(&self, tc: &Timecode) -> Vec<LtcBit> {
249        let mut word = vec![LtcBit::Zero; 80];
250
251        let set_bit = |word: &mut Vec<LtcBit>, pos: usize, val: u8| {
252            word[pos] = LtcBit::from(val);
253        };
254
255        // Frame units / tens
256        let fu = tc.frames % 10;
257        let ft = tc.frames / 10;
258        for i in 0..4 {
259            set_bit(&mut word, i, (fu >> i) & 1);
260        }
261        set_bit(&mut word, 8, ft & 1);
262        set_bit(&mut word, 9, (ft >> 1) & 1);
263        set_bit(&mut word, 10, tc.frame_rate.drop_frame as u8);
264
265        // Seconds
266        let su = tc.seconds % 10;
267        let st = tc.seconds / 10;
268        for i in 0..4 {
269            set_bit(&mut word, 16 + i, (su >> i) & 1);
270        }
271        for i in 0..3 {
272            set_bit(&mut word, 24 + i, (st >> i) & 1);
273        }
274
275        // Minutes
276        let mu = tc.minutes % 10;
277        let mt = tc.minutes / 10;
278        for i in 0..4 {
279            set_bit(&mut word, 32 + i, (mu >> i) & 1);
280        }
281        for i in 0..3 {
282            set_bit(&mut word, 40 + i, (mt >> i) & 1);
283        }
284
285        // Hours
286        let hu = tc.hours % 10;
287        let ht = tc.hours / 10;
288        for i in 0..4 {
289            set_bit(&mut word, 48 + i, (hu >> i) & 1);
290        }
291        for i in 0..2 {
292            set_bit(&mut word, 56 + i, (ht >> i) & 1);
293        }
294
295        // Sync word
296        for (i, &b) in Self::SYNC_BITS.iter().enumerate() {
297            set_bit(&mut word, 64 + i, b);
298        }
299
300        word
301    }
302}
303
304/// Helper: build an 80-bit LTC word from raw field values (for test construction).
305pub fn build_ltc_word(
306    hours: u8,
307    minutes: u8,
308    seconds: u8,
309    frames: u8,
310    drop_frame: bool,
311    fps: u8,
312) -> Vec<LtcBit> {
313    let tc = Timecode::from_raw_fields(hours, minutes, seconds, frames, fps, drop_frame, 0);
314    let parser = LtcParser::new(fps, drop_frame);
315    parser.encode_frame(&tc)
316}
317
318// ── Tests ─────────────────────────────────────────────────────────────────────
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn make_parser() -> LtcParser {
325        LtcParser::new(25, false)
326    }
327
328    #[test]
329    fn test_ltcbit_from_bool() {
330        assert_eq!(LtcBit::from(true), LtcBit::One);
331        assert_eq!(LtcBit::from(false), LtcBit::Zero);
332    }
333
334    #[test]
335    fn test_ltcbit_from_u8() {
336        assert_eq!(LtcBit::from(1u8), LtcBit::One);
337        assert_eq!(LtcBit::from(0u8), LtcBit::Zero);
338        assert_eq!(LtcBit::from(255u8), LtcBit::One);
339    }
340
341    #[test]
342    fn test_ltcbit_as_u8() {
343        assert_eq!(LtcBit::One.as_u8(), 1);
344        assert_eq!(LtcBit::Zero.as_u8(), 0);
345    }
346
347    #[test]
348    fn test_check_sync_valid() {
349        let mut bits = vec![LtcBit::Zero; 80];
350        for (i, &b) in LtcParser::SYNC_BITS.iter().enumerate() {
351            bits[64 + i] = LtcBit::from(b);
352        }
353        assert!(make_parser().check_sync(&bits, 64));
354    }
355
356    #[test]
357    fn test_check_sync_invalid() {
358        let bits = vec![LtcBit::Zero; 80];
359        assert!(!make_parser().check_sync(&bits, 64));
360    }
361
362    #[test]
363    fn test_check_sync_too_short() {
364        let bits = vec![LtcBit::Zero; 10];
365        assert!(!make_parser().check_sync(&bits, 0));
366    }
367
368    #[test]
369    fn test_encode_decode_roundtrip_simple() {
370        let parser = make_parser();
371        let tc = Timecode::from_raw_fields(1, 2, 3, 4, 25, false, 0);
372        let encoded = parser.encode_frame(&tc);
373        assert_eq!(encoded.len(), 80);
374        let decoded = parser
375            .decode_frame(&encoded, 0)
376            .expect("decode should succeed");
377        assert_eq!(decoded.timecode.hours, 1);
378        assert_eq!(decoded.timecode.minutes, 2);
379        assert_eq!(decoded.timecode.seconds, 3);
380        assert_eq!(decoded.timecode.frames, 4);
381    }
382
383    #[test]
384    fn test_encode_decode_midnight() {
385        let parser = make_parser();
386        let tc = Timecode::from_raw_fields(0, 0, 0, 0, 25, false, 0);
387        let encoded = parser.encode_frame(&tc);
388        let decoded = parser
389            .decode_frame(&encoded, 0)
390            .expect("decode should succeed");
391        assert_eq!(decoded.timecode.hours, 0);
392        assert_eq!(decoded.timecode.seconds, 0);
393    }
394
395    #[test]
396    fn test_decode_bits_finds_one_frame() {
397        let parser = make_parser();
398        let tc = Timecode::from_raw_fields(0, 1, 2, 3, 25, false, 0);
399        let bits = parser.encode_frame(&tc);
400        let frames = parser.decode_bits(&bits);
401        assert_eq!(frames.len(), 1);
402    }
403
404    #[test]
405    fn test_decode_bits_empty() {
406        assert!(make_parser().decode_bits(&[]).is_empty());
407    }
408
409    #[test]
410    fn test_decode_bits_too_short() {
411        let bits = vec![LtcBit::Zero; 40];
412        assert!(make_parser().decode_bits(&bits).is_empty());
413    }
414
415    #[test]
416    fn test_decode_frame_buffer_too_small() {
417        let bits = vec![LtcBit::Zero; 79];
418        let err = make_parser().decode_frame(&bits, 0);
419        assert_eq!(err, Err(TimecodeError::BufferTooSmall));
420    }
421
422    #[test]
423    fn test_decode_drop_frame_flag() {
424        let parser = LtcParser::new(30, true);
425        let tc = Timecode::from_raw_fields(0, 0, 5, 0, 30, true, 0);
426        let encoded = parser.encode_frame(&tc);
427        let decoded = parser
428            .decode_frame(&encoded, 0)
429            .expect("decode should succeed");
430        assert!(decoded.drop_frame);
431    }
432
433    #[test]
434    fn test_build_ltc_word_length() {
435        let word = build_ltc_word(1, 2, 3, 4, false, 25);
436        assert_eq!(word.len(), 80);
437    }
438
439    #[test]
440    fn test_decode_frame_bit_offset() {
441        let parser = make_parser();
442        let tc = Timecode::from_raw_fields(0, 0, 0, 0, 25, false, 0);
443        let bits = parser.encode_frame(&tc);
444        let decoded = parser
445            .decode_frame(&bits, 0)
446            .expect("decode should succeed");
447        assert_eq!(decoded.bit_offset, 0);
448    }
449
450    #[test]
451    fn test_ltcframe_color_frame_default_false() {
452        let parser = make_parser();
453        let tc = Timecode::from_raw_fields(0, 0, 0, 0, 25, false, 0);
454        let encoded = parser.encode_frame(&tc);
455        let decoded = parser
456            .decode_frame(&encoded, 0)
457            .expect("decode should succeed");
458        assert!(!decoded.color_frame);
459    }
460}