Skip to main content

st12_1/
frame.rs

1//! The LTC codeword — SMPTE ST 12-1:2014 §9. See `st12-1/docs/st12-1.md` for
2//! the curated spec transcription this module implements field-for-field.
3
4use broadcast_common::{Parse, Serialize};
5
6use crate::error::{Error, Result};
7
8// ---------------------------------------------------------------------------
9// Named constants (no magic numbers) — ST 12-1 §9.2/Tables 2-5
10// ---------------------------------------------------------------------------
11
12/// Length of the LTC codeword: 80 bits (§9.1) packed 8 bits/byte.
13pub const FRAME_LEN: usize = 10;
14
15/// Maximum hour value — "24-hour clock ... to 23 hours" (§5.2/§6.2/§7.2).
16pub const MAX_HOURS: u8 = 23;
17/// Maximum minute/second value — "... 59 minutes, and 59 seconds".
18pub const MAX_MINUTES_SECONDS: u8 = 59;
19/// Maximum frame value across all supported frame rates: 30-frame counting
20/// (drop or non-drop, §5.2.1/§5.2.2) numbers frames `00` through `29`, the
21/// widest of the three per-rate bounds (25-frame: `00`-`24` per §6.2;
22/// 24-frame: `00`-`23` per §7.2). The 80-bit codeword carries no
23/// self-describing frame-rate field, so this crate validates against the
24/// widest bound and leaves the tighter per-rate bound to a caller that knows
25/// its stream's frame rate (see `docs/st12-1.md` §8.2).
26pub const MAX_FRAMES: u8 = 29;
27/// Maximum value of one 4-bit binary group ("user bits") nibble (§8.1/Table 4).
28pub const MAX_BINARY_GROUP: u8 = 0x0F;
29
30/// The fixed synchronization word (§9.2.5, Table 5), as the two bytes it
31/// occupies under this crate's bit-to-byte packing (`docs/st12-1.md`'s "Byte
32/// packing convention"): byte 8 holds bits 64-71, byte 9 holds bits 72-79.
33/// This is the well-known LTC sync-word byte pair.
34pub const SYNC_WORD: [u8; 2] = [0xFC, 0xBF];
35
36// ---------------------------------------------------------------------------
37// FrameRate — ST 12-1 Table 3's three counting-mode columns
38// ---------------------------------------------------------------------------
39
40/// Which of ST 12-1 Table 3's three flag-bit-position columns applies.
41///
42/// The drop-frame flag (bit 10) and color-frame flag (bit 11) sit at fixed
43/// bit positions regardless of frame rate, but the polarity-correction/BGF0/
44/// BGF1/BGF2 bits move: 30-frame and 24-frame share one mapping, while
45/// 25-frame swaps bit 27 and bit 59's meaning (see `docs/st12-1.md`'s
46/// "Judgment call" note on Table 3). The 80-bit codeword itself carries no
47/// self-describing frame-rate field, so the caller supplies it.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50#[non_exhaustive]
51pub enum FrameRate {
52    /// 30-frame counting (NTSC-related, drop or non-drop, §5.2).
53    Fps30,
54    /// 25-frame counting (PAL-related, §6.2).
55    Fps25,
56    /// 24-frame counting (film-related, §7.2).
57    Fps24,
58}
59
60impl FrameRate {
61    /// The spec's own label for this counting mode (Table 3's column header).
62    #[must_use]
63    pub fn name(&self) -> &'static str {
64        match self {
65            Self::Fps30 => "30-frame",
66            Self::Fps25 => "25-frame",
67            Self::Fps24 => "24-frame",
68        }
69    }
70}
71
72broadcast_common::impl_spec_display!(FrameRate);
73
74// ---------------------------------------------------------------------------
75// BinaryGroupUsage — ST 12-1 Table 1
76// ---------------------------------------------------------------------------
77
78/// The meaning of the eight binary groups ("user bits"), per the three
79/// binary group flag bits BGF2/BGF1/BGF0 (§8.3.3, Table 1).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82#[non_exhaustive]
83pub enum BinaryGroupUsage {
84    /// `000` — time address reference unspecified, binary group content
85    /// unspecified (§8.4.1).
86    UnspecifiedUnspecified,
87    /// `001` — time address reference unspecified, binary groups hold an
88    /// eight-bit character set (§8.4.2).
89    UnspecifiedEightBitCodes,
90    /// `010` — time address referenced to clock time, binary group content
91    /// unspecified (§8.4.3, §8.5).
92    ClockTimeUnspecified,
93    /// `011` — reserved for future definition by SMPTE (§8.4.4): "shall not
94    /// be used".
95    Reserved,
96    /// `100` — time address reference unspecified, binary groups hold date
97    /// and time zone data (§8.4.5).
98    UnspecifiedDateTimeZone,
99    /// `101` — time address reference unspecified, binary groups hold
100    /// page/line multiplex data (§8.4.6).
101    UnspecifiedPageLine,
102    /// `110` — time address referenced to clock time, binary groups hold
103    /// date and time zone data (§8.4.7, §8.5).
104    ClockTimeDateTimeZone,
105    /// `111` — time address referenced to clock time, binary groups hold
106    /// page/line multiplex data (§8.4.8, §8.5).
107    ClockTimePageLine,
108}
109
110impl BinaryGroupUsage {
111    /// The spec's own compound label (Table 1's "Time address reference" /
112    /// "Binary group" column pair).
113    #[must_use]
114    pub fn name(&self) -> &'static str {
115        match self {
116            Self::UnspecifiedUnspecified => "unspecified time address, unspecified binary group",
117            Self::UnspecifiedEightBitCodes => "unspecified time address, 8-bit codes",
118            Self::ClockTimeUnspecified => "clock time, unspecified binary group",
119            Self::Reserved => "reserved time address, reserved binary group",
120            Self::UnspecifiedDateTimeZone => "unspecified time address, date and time zone",
121            Self::UnspecifiedPageLine => "unspecified time address, page/line",
122            Self::ClockTimeDateTimeZone => "clock time, date and time zone",
123            Self::ClockTimePageLine => "clock time, page/line",
124        }
125    }
126
127    /// Look up the Table 1 row for a given BGF2/BGF1/BGF0 combination.
128    #[must_use]
129    pub fn from_flags(bgf2: bool, bgf1: bool, bgf0: bool) -> Self {
130        match (bgf2, bgf1, bgf0) {
131            (false, false, false) => Self::UnspecifiedUnspecified,
132            (false, false, true) => Self::UnspecifiedEightBitCodes,
133            (false, true, false) => Self::ClockTimeUnspecified,
134            (false, true, true) => Self::Reserved,
135            (true, false, false) => Self::UnspecifiedDateTimeZone,
136            (true, false, true) => Self::UnspecifiedPageLine,
137            (true, true, false) => Self::ClockTimeDateTimeZone,
138            (true, true, true) => Self::ClockTimePageLine,
139        }
140    }
141}
142
143broadcast_common::impl_spec_display!(BinaryGroupUsage);
144
145/// The three binary group flag bits (§8.3.3), resolved from an [`LtcFrame`]
146/// against a chosen [`FrameRate`] (see [`LtcFrame::binary_group_flags`]).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct BinaryGroupFlags {
150    /// BGF2.
151    pub bgf2: bool,
152    /// BGF1.
153    pub bgf1: bool,
154    /// BGF0.
155    pub bgf0: bool,
156}
157
158impl BinaryGroupFlags {
159    /// Table 1's binary-group-usage classification for this flag combination.
160    #[must_use]
161    pub fn usage(&self) -> BinaryGroupUsage {
162        BinaryGroupUsage::from_flags(self.bgf2, self.bgf1, self.bgf0)
163    }
164}
165
166// ---------------------------------------------------------------------------
167// LtcFrame — the 80-bit logical LTC codeword
168// ---------------------------------------------------------------------------
169
170/// A parsed (or to-be-serialized) 80-bit LTC codeword (§9.2): the BCD time
171/// address, drop/color frame flags, the four rate-dependent flag bits
172/// (polarity correction / BGF0 / BGF1 / BGF2 — see [`FrameRate`]), the eight
173/// 4-bit binary groups ("user bits", §8.1), and the fixed synchronization
174/// word (always [`SYNC_WORD`] on serialize; validated on parse).
175///
176/// This models only the logical codeword content — not the §9.3 biphase-mark
177/// modulation that carries it as an analog/digital audio signal (see
178/// `docs/st12-1.md`'s "Scope" section).
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181pub struct LtcFrame {
182    /// Hours, `00`-`23` (§9.2.1, Table 2 bits 48-51/56-57).
183    pub hours: u8,
184    /// Minutes, `00`-`59` (Table 2 bits 32-35/40-42).
185    pub minutes: u8,
186    /// Seconds, `00`-`59` (Table 2 bits 16-19/24-26).
187    pub seconds: u8,
188    /// Frames, `00`-`29` (Table 2 bits 0-3/8-9); the caller applies the
189    /// tighter per-`FrameRate` bound if it needs one (see [`MAX_FRAMES`]).
190    pub frames: u8,
191    /// Drop frame flag, bit 10 (§8.3.1): set when NTSC drop-frame
192    /// compensation (§5.2.2) is in effect. Fixed bit position across all
193    /// frame rates; reserved-zero where not applicable (§9.2.2 note).
194    pub drop_frame_flag: bool,
195    /// Color frame flag, bit 11 (§8.3.2). Fixed bit position across all
196    /// frame rates; reserved-zero where not applicable (§9.2.2 note).
197    pub color_frame_flag: bool,
198    /// The wire bit at position 27 (Table 3): 30-frame/24-frame's polarity
199    /// correction bit, or 25-frame's BGF0 — see [`LtcFrame::polarity_correction`]
200    /// / [`LtcFrame::binary_group_flags`] to resolve its meaning for a given
201    /// [`FrameRate`].
202    pub flag_bit_27: bool,
203    /// The wire bit at position 43 (Table 3): 30-frame/24-frame's BGF0, or
204    /// 25-frame's BGF2.
205    pub flag_bit_43: bool,
206    /// The wire bit at position 58 (Table 3): BGF1 in all three frame rates.
207    pub flag_bit_58: bool,
208    /// The wire bit at position 59 (Table 3): 30-frame/24-frame's BGF2, or
209    /// 25-frame's polarity correction bit.
210    pub flag_bit_59: bool,
211    /// The eight 4-bit binary groups ("user bits"), each `0x0`-`0xF`, in
212    /// first..eighth order (Table 4 bits 4-7/12-15/20-23/28-31/36-39/44-47/
213    /// 52-55/60-63). Their collective meaning is given by
214    /// [`LtcFrame::binary_group_flags`]`.usage()`.
215    pub user_bits: [u8; 8],
216}
217
218impl LtcFrame {
219    /// Resolve the §9.2.3 biphase-mark polarity-correction bit for `rate`
220    /// (Table 3: bit 27 for 30-frame/24-frame, bit 59 for 25-frame).
221    #[must_use]
222    pub fn polarity_correction(&self, rate: FrameRate) -> bool {
223        match rate {
224            FrameRate::Fps25 => self.flag_bit_59,
225            FrameRate::Fps30 | FrameRate::Fps24 => self.flag_bit_27,
226        }
227    }
228
229    /// Resolve the §8.3.3 binary group flags (BGF2/BGF1/BGF0) for `rate`
230    /// (Table 3's per-rate bit-position mapping).
231    #[must_use]
232    pub fn binary_group_flags(&self, rate: FrameRate) -> BinaryGroupFlags {
233        let (bgf0, bgf2) = match rate {
234            FrameRate::Fps25 => (self.flag_bit_27, self.flag_bit_43),
235            FrameRate::Fps30 | FrameRate::Fps24 => (self.flag_bit_43, self.flag_bit_59),
236        };
237        BinaryGroupFlags {
238            bgf2,
239            bgf1: self.flag_bit_58,
240            bgf0,
241        }
242    }
243
244    /// Validate all typed fields against their wire/semantic bounds. Called
245    /// by both `parse` (on decoded values) and `serialize_into` (on
246    /// caller-constructed values), so a hand-built out-of-range `LtcFrame`
247    /// cannot silently round-trip.
248    fn validate(&self) -> Result<()> {
249        if self.hours > MAX_HOURS {
250            return Err(Error::InvalidValue {
251                field: "hours",
252                value: self.hours,
253                reason: "exceeds the 24-hour-clock maximum (23)",
254            });
255        }
256        if self.minutes > MAX_MINUTES_SECONDS {
257            return Err(Error::InvalidValue {
258                field: "minutes",
259                value: self.minutes,
260                reason: "exceeds the maximum (59)",
261            });
262        }
263        if self.seconds > MAX_MINUTES_SECONDS {
264            return Err(Error::InvalidValue {
265                field: "seconds",
266                value: self.seconds,
267                reason: "exceeds the maximum (59)",
268            });
269        }
270        if self.frames > MAX_FRAMES {
271            return Err(Error::InvalidValue {
272                field: "frames",
273                value: self.frames,
274                reason: "exceeds the widest supported frame-rate maximum (29)",
275            });
276        }
277        for (index, &value) in self.user_bits.iter().enumerate() {
278            if value > MAX_BINARY_GROUP {
279                return Err(Error::InvalidBinaryGroup { index, value });
280            }
281        }
282        Ok(())
283    }
284}
285
286impl<'a> Parse<'a> for LtcFrame {
287    type Error = Error;
288
289    fn parse(bytes: &'a [u8]) -> Result<Self> {
290        if bytes.len() < FRAME_LEN {
291            return Err(Error::BufferTooShort {
292                need: FRAME_LEN,
293                have: bytes.len(),
294                what: "LTC codeword",
295            });
296        }
297
298        if [bytes[8], bytes[9]] != SYNC_WORD {
299            return Err(Error::SyncWordMismatch {
300                expected: SYNC_WORD,
301                found: [bytes[8], bytes[9]],
302            });
303        }
304
305        let frame_units = bytes[0] & 0x0F;
306        let user_bits_1 = bytes[0] >> 4;
307
308        let frame_tens = bytes[1] & 0x03;
309        let drop_frame_flag = bytes[1] & 0x04 != 0;
310        let color_frame_flag = bytes[1] & 0x08 != 0;
311        let user_bits_2 = bytes[1] >> 4;
312
313        let seconds_units = bytes[2] & 0x0F;
314        let user_bits_3 = bytes[2] >> 4;
315
316        let seconds_tens = bytes[3] & 0x07;
317        let flag_bit_27 = bytes[3] & 0x08 != 0;
318        let user_bits_4 = bytes[3] >> 4;
319
320        let minutes_units = bytes[4] & 0x0F;
321        let user_bits_5 = bytes[4] >> 4;
322
323        let minutes_tens = bytes[5] & 0x07;
324        let flag_bit_43 = bytes[5] & 0x08 != 0;
325        let user_bits_6 = bytes[5] >> 4;
326
327        let hours_units = bytes[6] & 0x0F;
328        let user_bits_7 = bytes[6] >> 4;
329
330        let hours_tens = bytes[7] & 0x03;
331        let flag_bit_58 = bytes[7] & 0x04 != 0;
332        let flag_bit_59 = bytes[7] & 0x08 != 0;
333        let user_bits_8 = bytes[7] >> 4;
334
335        let frame = Self {
336            hours: hours_tens * 10 + hours_units,
337            minutes: minutes_tens * 10 + minutes_units,
338            seconds: seconds_tens * 10 + seconds_units,
339            frames: frame_tens * 10 + frame_units,
340            drop_frame_flag,
341            color_frame_flag,
342            flag_bit_27,
343            flag_bit_43,
344            flag_bit_58,
345            flag_bit_59,
346            user_bits: [
347                user_bits_1,
348                user_bits_2,
349                user_bits_3,
350                user_bits_4,
351                user_bits_5,
352                user_bits_6,
353                user_bits_7,
354                user_bits_8,
355            ],
356        };
357        frame.validate()?;
358        Ok(frame)
359    }
360}
361
362impl Serialize for LtcFrame {
363    type Error = Error;
364
365    fn serialized_len(&self) -> usize {
366        FRAME_LEN
367    }
368
369    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
370        if buf.len() < FRAME_LEN {
371            return Err(Error::BufferTooShort {
372                need: FRAME_LEN,
373                have: buf.len(),
374                what: "LTC codeword serialize output",
375            });
376        }
377        self.validate()?;
378
379        let frame_units = self.frames % 10;
380        let frame_tens = self.frames / 10;
381        let seconds_units = self.seconds % 10;
382        let seconds_tens = self.seconds / 10;
383        let minutes_units = self.minutes % 10;
384        let minutes_tens = self.minutes / 10;
385        let hours_units = self.hours % 10;
386        let hours_tens = self.hours / 10;
387
388        buf[0] = frame_units | (self.user_bits[0] << 4);
389        buf[1] = frame_tens
390            | (u8::from(self.drop_frame_flag) << 2)
391            | (u8::from(self.color_frame_flag) << 3)
392            | (self.user_bits[1] << 4);
393        buf[2] = seconds_units | (self.user_bits[2] << 4);
394        buf[3] = seconds_tens | (u8::from(self.flag_bit_27) << 3) | (self.user_bits[3] << 4);
395        buf[4] = minutes_units | (self.user_bits[4] << 4);
396        buf[5] = minutes_tens | (u8::from(self.flag_bit_43) << 3) | (self.user_bits[5] << 4);
397        buf[6] = hours_units | (self.user_bits[6] << 4);
398        buf[7] = hours_tens
399            | (u8::from(self.flag_bit_58) << 2)
400            | (u8::from(self.flag_bit_59) << 3)
401            | (self.user_bits[7] << 4);
402        buf[8] = SYNC_WORD[0];
403        buf[9] = SYNC_WORD[1];
404
405        Ok(FRAME_LEN)
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn sample_frame() -> LtcFrame {
414        LtcFrame {
415            hours: 1,
416            minutes: 23,
417            seconds: 45,
418            frames: 13,
419            drop_frame_flag: false,
420            color_frame_flag: true,
421            flag_bit_27: true,
422            flag_bit_43: true,
423            flag_bit_58: false,
424            flag_bit_59: true,
425            user_bits: [1, 2, 3, 4, 5, 6, 7, 8],
426        }
427    }
428
429    #[test]
430    fn round_trip() {
431        let f = sample_frame();
432        let mut out = [0u8; FRAME_LEN];
433        f.serialize_into(&mut out).unwrap();
434        assert_eq!(LtcFrame::parse(&out).unwrap(), f);
435    }
436
437    #[test]
438    fn serialize_then_parse_matches_spec_vector() {
439        // See docs/st12-1.md's "Worked vector" section.
440        let f = sample_frame();
441        let mut out = [0u8; FRAME_LEN];
442        f.serialize_into(&mut out).unwrap();
443        assert_eq!(
444            out,
445            [0x13, 0x29, 0x35, 0x4C, 0x53, 0x6A, 0x71, 0x88, 0xFC, 0xBF]
446        );
447    }
448
449    #[test]
450    fn rejects_sync_word_mismatch() {
451        let mut bytes = [0x13, 0x29, 0x35, 0x4C, 0x53, 0x6A, 0x71, 0x88, 0xFC, 0xBF];
452        bytes[9] = 0x00;
453        assert!(matches!(
454            LtcFrame::parse(&bytes),
455            Err(Error::SyncWordMismatch { .. })
456        ));
457    }
458
459    #[test]
460    fn rejects_buffer_too_short() {
461        let bytes = [0u8; 9];
462        assert!(matches!(
463            LtcFrame::parse(&bytes),
464            Err(Error::BufferTooShort {
465                need: 10,
466                have: 9,
467                ..
468            })
469        ));
470    }
471
472    #[test]
473    fn rejects_hours_over_23() {
474        let mut f = sample_frame();
475        f.hours = 24;
476        let mut out = [0u8; FRAME_LEN];
477        assert!(matches!(
478            f.serialize_into(&mut out),
479            Err(Error::InvalidValue { field: "hours", .. })
480        ));
481    }
482
483    #[test]
484    fn rejects_frames_over_29() {
485        let mut f = sample_frame();
486        f.frames = 30;
487        let mut out = [0u8; FRAME_LEN];
488        assert!(matches!(
489            f.serialize_into(&mut out),
490            Err(Error::InvalidValue {
491                field: "frames",
492                ..
493            })
494        ));
495    }
496
497    #[test]
498    fn rejects_user_bit_over_15() {
499        let mut f = sample_frame();
500        f.user_bits[3] = 0x10;
501        let mut out = [0u8; FRAME_LEN];
502        assert!(matches!(
503            f.serialize_into(&mut out),
504            Err(Error::InvalidBinaryGroup {
505                index: 3,
506                value: 0x10
507            })
508        ));
509    }
510
511    #[test]
512    fn polarity_correction_and_bgf_swap_between_25_and_other_rates() {
513        let f = sample_frame(); // flag_bit_27=1, flag_bit_43=1, flag_bit_58=0, flag_bit_59=1
514        assert!(f.polarity_correction(FrameRate::Fps30));
515        assert!(f.polarity_correction(FrameRate::Fps24));
516        assert!(f.polarity_correction(FrameRate::Fps25));
517
518        let bg30 = f.binary_group_flags(FrameRate::Fps30);
519        assert_eq!(
520            bg30,
521            BinaryGroupFlags {
522                bgf2: true,
523                bgf1: false,
524                bgf0: true
525            }
526        );
527        assert_eq!(bg30.usage(), BinaryGroupUsage::UnspecifiedPageLine);
528
529        // With distinct bit-27/bit-43 values, the 25-frame <-> 30/24-frame
530        // swap (Table 3) is directly observable: bit 27 feeds BGF0 for
531        // 25-frame but polarity-correction for 30/24-frame, and vice versa
532        // for bit 59 vs. BGF2.
533        let mut g = f;
534        g.flag_bit_27 = true; // 30/24-frame: polarity=1; 25-frame: BGF0=1
535        g.flag_bit_43 = false; // 30/24-frame: BGF0=0;    25-frame: BGF2=0
536        g.flag_bit_58 = true; // BGF1=1 in every rate
537        g.flag_bit_59 = false; // 30/24-frame: BGF2=0;    25-frame: polarity=0
538
539        assert!(g.polarity_correction(FrameRate::Fps30));
540        assert!(g.polarity_correction(FrameRate::Fps24));
541        assert!(!g.polarity_correction(FrameRate::Fps25));
542
543        let bg30 = g.binary_group_flags(FrameRate::Fps30);
544        assert_eq!(
545            bg30,
546            BinaryGroupFlags {
547                bgf2: false,
548                bgf1: true,
549                bgf0: false
550            }
551        );
552        let bg25 = g.binary_group_flags(FrameRate::Fps25);
553        assert_eq!(
554            bg25,
555            BinaryGroupFlags {
556                bgf2: false,
557                bgf1: true,
558                bgf0: true
559            }
560        );
561        assert_ne!(
562            bg30, bg25,
563            "25-frame's BGF0 must differ from 30/24-frame's here"
564        );
565    }
566
567    #[test]
568    fn binary_group_usage_covers_all_eight_combinations() {
569        for bgf2 in [false, true] {
570            for bgf1 in [false, true] {
571                for bgf0 in [false, true] {
572                    // Must not panic for any of the 8 possible combinations.
573                    let _ = BinaryGroupUsage::from_flags(bgf2, bgf1, bgf0);
574                }
575            }
576        }
577    }
578
579    #[test]
580    fn field_mutation_changes_bytes() {
581        let a = sample_frame();
582        let mut b = a;
583        b.seconds = a.seconds.wrapping_add(1);
584        let mut oa = [0u8; FRAME_LEN];
585        let mut ob = [0u8; FRAME_LEN];
586        a.serialize_into(&mut oa).unwrap();
587        b.serialize_into(&mut ob).unwrap();
588        assert_ne!(oa, ob);
589    }
590}