Skip to main content

oximedia_timecode/
ltc_encoder.rs

1//! LTC audio signal encoder.
2//!
3//! Encodes timecode values into audio-rate biphase-mark modulated samples
4//! suitable for embedding in an audio track. The encoder produces `f32`
5//! samples at a configurable sample rate and can generate a continuous
6//! stream of LTC audio across multiple frames.
7
8#![allow(dead_code)]
9#![allow(clippy::cast_precision_loss)]
10#![allow(clippy::cast_possible_truncation)]
11#![allow(clippy::cast_sign_loss)]
12
13use crate::Timecode;
14
15// -- LtcSignalParams ---------------------------------------------------------
16
17/// Parameters controlling the LTC audio signal generation.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct LtcSignalParams {
20    /// Audio sample rate in Hz (e.g. 48000).
21    pub sample_rate: u32,
22    /// Peak amplitude of the generated signal (0.0 .. 1.0).
23    pub amplitude: f32,
24    /// Frame rate (fps) of the timecode being encoded.
25    pub fps: u8,
26}
27
28impl LtcSignalParams {
29    /// Create default params: 48 kHz, amplitude 0.5, 25 fps.
30    pub fn default_25fps() -> Self {
31        Self {
32            sample_rate: 48000,
33            amplitude: 0.5,
34            fps: 25,
35        }
36    }
37
38    /// Create params for 30fps NTSC at 48 kHz.
39    pub fn default_30fps() -> Self {
40        Self {
41            sample_rate: 48000,
42            amplitude: 0.5,
43            fps: 30,
44        }
45    }
46
47    /// Samples per LTC bit at this sample rate and frame rate.
48    ///
49    /// LTC has 80 bits per frame, so samples_per_frame / 80.
50    pub fn samples_per_bit(&self) -> f64 {
51        self.sample_rate as f64 / (self.fps as f64 * 80.0)
52    }
53
54    /// Total audio samples per timecode frame.
55    pub fn samples_per_frame(&self) -> u32 {
56        (self.sample_rate as f64 / self.fps as f64).round() as u32
57    }
58}
59
60// -- LtcBitEncoder -----------------------------------------------------------
61
62/// Converts timecode fields into an 80-bit LTC word (as a `[u8; 80]` of 0/1).
63///
64/// This mirrors the SMPTE 12M standard bit layout.
65#[derive(Debug, Clone)]
66pub struct LtcBitEncoder;
67
68impl LtcBitEncoder {
69    /// The 16-bit sync word (bits 64..79 in an LTC word, LS bit first).
70    const SYNC_WORD: [u8; 16] = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1];
71
72    /// Encode a `Timecode` into an 80-element array of logical bits (0 or 1).
73    pub fn encode(tc: &Timecode) -> [u8; 80] {
74        let mut word = [0u8; 80];
75
76        // Frame units (bits 0-3)
77        let fu = tc.frames % 10;
78        for i in 0..4u8 {
79            word[i as usize] = (fu >> i) & 1;
80        }
81
82        // Frame tens (bits 8-9), drop-frame (bit 10)
83        let ft = tc.frames / 10;
84        word[8] = ft & 1;
85        word[9] = (ft >> 1) & 1;
86        word[10] = u8::from(tc.frame_rate.drop_frame);
87
88        // Seconds units (bits 16-19)
89        let su = tc.seconds % 10;
90        for i in 0..4u8 {
91            word[16 + i as usize] = (su >> i) & 1;
92        }
93
94        // Seconds tens (bits 24-26)
95        let st = tc.seconds / 10;
96        for i in 0..3u8 {
97            word[24 + i as usize] = (st >> i) & 1;
98        }
99
100        // Minutes units (bits 32-35)
101        let mu = tc.minutes % 10;
102        for i in 0..4u8 {
103            word[32 + i as usize] = (mu >> i) & 1;
104        }
105
106        // Minutes tens (bits 40-42)
107        let mt = tc.minutes / 10;
108        for i in 0..3u8 {
109            word[40 + i as usize] = (mt >> i) & 1;
110        }
111
112        // Hours units (bits 48-51)
113        let hu = tc.hours % 10;
114        for i in 0..4u8 {
115            word[48 + i as usize] = (hu >> i) & 1;
116        }
117
118        // Hours tens (bits 56-57)
119        let ht = tc.hours / 10;
120        for i in 0..2u8 {
121            word[56 + i as usize] = (ht >> i) & 1;
122        }
123
124        // Sync word (bits 64-79)
125        word[64..80].copy_from_slice(&Self::SYNC_WORD);
126
127        word
128    }
129
130    /// Decode an 80-bit word back to (hours, minutes, seconds, frames, drop_frame).
131    pub fn decode(word: &[u8; 80]) -> (u8, u8, u8, u8, bool) {
132        let nibble = |positions: &[usize]| -> u8 {
133            positions
134                .iter()
135                .enumerate()
136                .map(|(shift, &pos)| word[pos] << shift)
137                .sum()
138        };
139
140        let frame_units = nibble(&[0, 1, 2, 3]);
141        let frame_tens = nibble(&[8, 9]) & 0x03;
142        let drop_frame = word[10] != 0;
143
144        let sec_units = nibble(&[16, 17, 18, 19]);
145        let sec_tens = nibble(&[24, 25, 26]) & 0x07;
146
147        let min_units = nibble(&[32, 33, 34, 35]);
148        let min_tens = nibble(&[40, 41, 42]) & 0x07;
149
150        let hr_units = nibble(&[48, 49, 50, 51]);
151        let hr_tens = nibble(&[56, 57]) & 0x03;
152
153        let frames = frame_tens * 10 + frame_units;
154        let seconds = sec_tens * 10 + sec_units;
155        let minutes = min_tens * 10 + min_units;
156        let hours = hr_tens * 10 + hr_units;
157
158        (hours, minutes, seconds, frames, drop_frame)
159    }
160}
161
162// -- LtcAudioEncoder ---------------------------------------------------------
163
164/// Generates biphase-mark modulated audio samples from LTC bit words.
165///
166/// Biphase-mark encoding: every bit cell starts with a transition.
167/// A '1' bit has an additional mid-cell transition; a '0' bit does not.
168///
169/// # Example
170/// ```
171/// use oximedia_timecode::ltc_encoder::{LtcAudioEncoder, LtcSignalParams, LtcBitEncoder};
172/// use oximedia_timecode::{Timecode, FrameRate};
173///
174/// let params = LtcSignalParams::default_25fps();
175/// let mut encoder = LtcAudioEncoder::new(params);
176/// let tc = Timecode::from_raw_fields(1, 0, 0, 0, 25, false, 0);
177/// let bits = LtcBitEncoder::encode(&tc);
178/// let samples = encoder.encode_frame(&bits);
179/// assert!(!samples.is_empty());
180/// ```
181#[derive(Debug, Clone)]
182pub struct LtcAudioEncoder {
183    /// Signal parameters.
184    params: LtcSignalParams,
185    /// Current polarity (+1 or -1).
186    polarity: f32,
187}
188
189impl LtcAudioEncoder {
190    /// Create a new audio encoder.
191    pub fn new(params: LtcSignalParams) -> Self {
192        Self {
193            params,
194            polarity: 1.0,
195        }
196    }
197
198    /// Encode a single 80-bit LTC word into audio samples.
199    ///
200    /// Returns a `Vec<f32>` of biphase-mark modulated audio.
201    pub fn encode_frame(&mut self, bits: &[u8; 80]) -> Vec<f32> {
202        let spb = self.params.samples_per_bit();
203        let amplitude = self.params.amplitude;
204        let mut samples = Vec::with_capacity(self.params.samples_per_frame() as usize);
205
206        for &bit in bits.iter() {
207            let num_samples = spb.round() as usize;
208            let half = num_samples / 2;
209
210            if bit == 1 {
211                // '1' bit: transition at start and mid-cell
212                for _ in 0..half {
213                    samples.push(self.polarity * amplitude);
214                }
215                self.polarity = -self.polarity;
216                for _ in half..num_samples {
217                    samples.push(self.polarity * amplitude);
218                }
219                self.polarity = -self.polarity;
220            } else {
221                // '0' bit: transition at start only
222                for _ in 0..num_samples {
223                    samples.push(self.polarity * amplitude);
224                }
225                self.polarity = -self.polarity;
226            }
227        }
228
229        samples
230    }
231
232    /// Encode multiple consecutive timecodes into a continuous audio stream.
233    pub fn encode_sequence(&mut self, timecodes: &[Timecode]) -> Vec<f32> {
234        let mut all_samples = Vec::new();
235        for tc in timecodes {
236            let bits = LtcBitEncoder::encode(tc);
237            let frame_samples = self.encode_frame(&bits);
238            all_samples.extend_from_slice(&frame_samples);
239        }
240        all_samples
241    }
242
243    /// Return the current polarity state.
244    pub fn polarity(&self) -> f32 {
245        self.polarity
246    }
247
248    /// Reset the polarity to +1.
249    pub fn reset_polarity(&mut self) {
250        self.polarity = 1.0;
251    }
252
253    /// Return the signal parameters.
254    pub fn params(&self) -> &LtcSignalParams {
255        &self.params
256    }
257}
258
259// -- Tests -------------------------------------------------------------------
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn make_tc(h: u8, m: u8, s: u8, f: u8, fps: u8, df: bool) -> Timecode {
266        Timecode::from_raw_fields(h, m, s, f, fps, df, 0)
267    }
268
269    fn default_params() -> LtcSignalParams {
270        LtcSignalParams::default_25fps()
271    }
272
273    #[test]
274    fn test_signal_params_samples_per_bit_25fps() {
275        let p = default_params();
276        let spb = p.samples_per_bit();
277        // 48000 / (25 * 80) = 24.0
278        assert!((spb - 24.0).abs() < 1e-6);
279    }
280
281    #[test]
282    fn test_signal_params_samples_per_frame_25fps() {
283        let p = default_params();
284        assert_eq!(p.samples_per_frame(), 1920); // 48000 / 25
285    }
286
287    #[test]
288    fn test_signal_params_30fps() {
289        let p = LtcSignalParams::default_30fps();
290        assert_eq!(p.fps, 30);
291        assert_eq!(p.samples_per_frame(), 1600); // 48000 / 30
292    }
293
294    #[test]
295    fn test_bit_encoder_roundtrip() {
296        let tc = make_tc(12, 34, 56, 7, 25, false);
297        let bits = LtcBitEncoder::encode(&tc);
298        let (h, m, s, f, df) = LtcBitEncoder::decode(&bits);
299        assert_eq!(h, 12);
300        assert_eq!(m, 34);
301        assert_eq!(s, 56);
302        assert_eq!(f, 7);
303        assert!(!df);
304    }
305
306    #[test]
307    fn test_bit_encoder_drop_frame_flag() {
308        let tc = make_tc(0, 0, 0, 2, 30, true);
309        let bits = LtcBitEncoder::encode(&tc);
310        let (_, _, _, _, df) = LtcBitEncoder::decode(&bits);
311        assert!(df);
312    }
313
314    #[test]
315    fn test_bit_encoder_midnight() {
316        let tc = make_tc(0, 0, 0, 0, 25, false);
317        let bits = LtcBitEncoder::encode(&tc);
318        let (h, m, s, f, _) = LtcBitEncoder::decode(&bits);
319        assert_eq!((h, m, s, f), (0, 0, 0, 0));
320    }
321
322    #[test]
323    fn test_bit_encoder_max_values() {
324        let tc = make_tc(23, 59, 59, 24, 25, false);
325        let bits = LtcBitEncoder::encode(&tc);
326        let (h, m, s, f, _) = LtcBitEncoder::decode(&bits);
327        assert_eq!(h, 23);
328        assert_eq!(m, 59);
329        assert_eq!(s, 59);
330        assert_eq!(f, 24);
331    }
332
333    #[test]
334    fn test_bit_encoder_sync_word_present() {
335        let tc = make_tc(0, 0, 0, 0, 25, false);
336        let bits = LtcBitEncoder::encode(&tc);
337        assert_eq!(&bits[64..80], &LtcBitEncoder::SYNC_WORD);
338    }
339
340    #[test]
341    fn test_audio_encoder_output_length() {
342        let params = default_params();
343        let mut enc = LtcAudioEncoder::new(params);
344        let tc = make_tc(0, 0, 0, 0, 25, false);
345        let bits = LtcBitEncoder::encode(&tc);
346        let samples = enc.encode_frame(&bits);
347        // Each bit produces ~24 samples at 48kHz/25fps → ~1920 total
348        assert!(!samples.is_empty());
349        assert!(samples.len() >= 1900 && samples.len() <= 1940);
350    }
351
352    #[test]
353    fn test_audio_encoder_amplitude_bounds() {
354        let params = LtcSignalParams {
355            sample_rate: 48000,
356            amplitude: 0.8,
357            fps: 25,
358        };
359        let mut enc = LtcAudioEncoder::new(params);
360        let tc = make_tc(1, 0, 0, 0, 25, false);
361        let bits = LtcBitEncoder::encode(&tc);
362        let samples = enc.encode_frame(&bits);
363        for &s in &samples {
364            assert!(s.abs() <= 0.8 + 1e-6);
365        }
366    }
367
368    #[test]
369    fn test_audio_encoder_polarity_reset() {
370        let mut enc = LtcAudioEncoder::new(default_params());
371        assert!((enc.polarity() - 1.0).abs() < 1e-6);
372        enc.polarity = -1.0;
373        enc.reset_polarity();
374        assert!((enc.polarity() - 1.0).abs() < 1e-6);
375    }
376
377    #[test]
378    fn test_audio_encoder_sequence() {
379        let mut enc = LtcAudioEncoder::new(default_params());
380        let tcs = vec![
381            make_tc(0, 0, 0, 0, 25, false),
382            make_tc(0, 0, 0, 1, 25, false),
383        ];
384        let samples = enc.encode_sequence(&tcs);
385        // Should be approximately 2 * 1920 samples
386        assert!(samples.len() >= 3800);
387    }
388
389    #[test]
390    fn test_audio_encoder_params_accessor() {
391        let params = default_params();
392        let enc = LtcAudioEncoder::new(params);
393        assert_eq!(enc.params().fps, 25);
394        assert_eq!(enc.params().sample_rate, 48000);
395    }
396}