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, FrameRateInfo};
173///
174/// let params = LtcSignalParams::default_25fps();
175/// let mut encoder = LtcAudioEncoder::new(params);
176/// let tc = Timecode {
177///     hours: 1, minutes: 0, seconds: 0, frames: 0,
178///     frame_rate: FrameRateInfo { fps: 25, drop_frame: false },
179///     user_bits: 0,
180/// };
181/// let bits = LtcBitEncoder::encode(&tc);
182/// let samples = encoder.encode_frame(&bits);
183/// assert!(!samples.is_empty());
184/// ```
185#[derive(Debug, Clone)]
186pub struct LtcAudioEncoder {
187    /// Signal parameters.
188    params: LtcSignalParams,
189    /// Current polarity (+1 or -1).
190    polarity: f32,
191}
192
193impl LtcAudioEncoder {
194    /// Create a new audio encoder.
195    pub fn new(params: LtcSignalParams) -> Self {
196        Self {
197            params,
198            polarity: 1.0,
199        }
200    }
201
202    /// Encode a single 80-bit LTC word into audio samples.
203    ///
204    /// Returns a `Vec<f32>` of biphase-mark modulated audio.
205    pub fn encode_frame(&mut self, bits: &[u8; 80]) -> Vec<f32> {
206        let spb = self.params.samples_per_bit();
207        let amplitude = self.params.amplitude;
208        let mut samples = Vec::with_capacity(self.params.samples_per_frame() as usize);
209
210        for &bit in bits.iter() {
211            let num_samples = spb.round() as usize;
212            let half = num_samples / 2;
213
214            if bit == 1 {
215                // '1' bit: transition at start and mid-cell
216                for _ in 0..half {
217                    samples.push(self.polarity * amplitude);
218                }
219                self.polarity = -self.polarity;
220                for _ in half..num_samples {
221                    samples.push(self.polarity * amplitude);
222                }
223                self.polarity = -self.polarity;
224            } else {
225                // '0' bit: transition at start only
226                for _ in 0..num_samples {
227                    samples.push(self.polarity * amplitude);
228                }
229                self.polarity = -self.polarity;
230            }
231        }
232
233        samples
234    }
235
236    /// Encode multiple consecutive timecodes into a continuous audio stream.
237    pub fn encode_sequence(&mut self, timecodes: &[Timecode]) -> Vec<f32> {
238        let mut all_samples = Vec::new();
239        for tc in timecodes {
240            let bits = LtcBitEncoder::encode(tc);
241            let frame_samples = self.encode_frame(&bits);
242            all_samples.extend_from_slice(&frame_samples);
243        }
244        all_samples
245    }
246
247    /// Return the current polarity state.
248    pub fn polarity(&self) -> f32 {
249        self.polarity
250    }
251
252    /// Reset the polarity to +1.
253    pub fn reset_polarity(&mut self) {
254        self.polarity = 1.0;
255    }
256
257    /// Return the signal parameters.
258    pub fn params(&self) -> &LtcSignalParams {
259        &self.params
260    }
261}
262
263// -- Tests -------------------------------------------------------------------
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::FrameRateInfo;
269
270    fn make_tc(h: u8, m: u8, s: u8, f: u8, fps: u8, df: bool) -> Timecode {
271        Timecode {
272            hours: h,
273            minutes: m,
274            seconds: s,
275            frames: f,
276            frame_rate: FrameRateInfo {
277                fps,
278                drop_frame: df,
279            },
280            user_bits: 0,
281        }
282    }
283
284    fn default_params() -> LtcSignalParams {
285        LtcSignalParams::default_25fps()
286    }
287
288    #[test]
289    fn test_signal_params_samples_per_bit_25fps() {
290        let p = default_params();
291        let spb = p.samples_per_bit();
292        // 48000 / (25 * 80) = 24.0
293        assert!((spb - 24.0).abs() < 1e-6);
294    }
295
296    #[test]
297    fn test_signal_params_samples_per_frame_25fps() {
298        let p = default_params();
299        assert_eq!(p.samples_per_frame(), 1920); // 48000 / 25
300    }
301
302    #[test]
303    fn test_signal_params_30fps() {
304        let p = LtcSignalParams::default_30fps();
305        assert_eq!(p.fps, 30);
306        assert_eq!(p.samples_per_frame(), 1600); // 48000 / 30
307    }
308
309    #[test]
310    fn test_bit_encoder_roundtrip() {
311        let tc = make_tc(12, 34, 56, 7, 25, false);
312        let bits = LtcBitEncoder::encode(&tc);
313        let (h, m, s, f, df) = LtcBitEncoder::decode(&bits);
314        assert_eq!(h, 12);
315        assert_eq!(m, 34);
316        assert_eq!(s, 56);
317        assert_eq!(f, 7);
318        assert!(!df);
319    }
320
321    #[test]
322    fn test_bit_encoder_drop_frame_flag() {
323        let tc = make_tc(0, 0, 0, 2, 30, true);
324        let bits = LtcBitEncoder::encode(&tc);
325        let (_, _, _, _, df) = LtcBitEncoder::decode(&bits);
326        assert!(df);
327    }
328
329    #[test]
330    fn test_bit_encoder_midnight() {
331        let tc = make_tc(0, 0, 0, 0, 25, false);
332        let bits = LtcBitEncoder::encode(&tc);
333        let (h, m, s, f, _) = LtcBitEncoder::decode(&bits);
334        assert_eq!((h, m, s, f), (0, 0, 0, 0));
335    }
336
337    #[test]
338    fn test_bit_encoder_max_values() {
339        let tc = make_tc(23, 59, 59, 24, 25, false);
340        let bits = LtcBitEncoder::encode(&tc);
341        let (h, m, s, f, _) = LtcBitEncoder::decode(&bits);
342        assert_eq!(h, 23);
343        assert_eq!(m, 59);
344        assert_eq!(s, 59);
345        assert_eq!(f, 24);
346    }
347
348    #[test]
349    fn test_bit_encoder_sync_word_present() {
350        let tc = make_tc(0, 0, 0, 0, 25, false);
351        let bits = LtcBitEncoder::encode(&tc);
352        assert_eq!(&bits[64..80], &LtcBitEncoder::SYNC_WORD);
353    }
354
355    #[test]
356    fn test_audio_encoder_output_length() {
357        let params = default_params();
358        let mut enc = LtcAudioEncoder::new(params);
359        let tc = make_tc(0, 0, 0, 0, 25, false);
360        let bits = LtcBitEncoder::encode(&tc);
361        let samples = enc.encode_frame(&bits);
362        // Each bit produces ~24 samples at 48kHz/25fps → ~1920 total
363        assert!(!samples.is_empty());
364        assert!(samples.len() >= 1900 && samples.len() <= 1940);
365    }
366
367    #[test]
368    fn test_audio_encoder_amplitude_bounds() {
369        let params = LtcSignalParams {
370            sample_rate: 48000,
371            amplitude: 0.8,
372            fps: 25,
373        };
374        let mut enc = LtcAudioEncoder::new(params);
375        let tc = make_tc(1, 0, 0, 0, 25, false);
376        let bits = LtcBitEncoder::encode(&tc);
377        let samples = enc.encode_frame(&bits);
378        for &s in &samples {
379            assert!(s.abs() <= 0.8 + 1e-6);
380        }
381    }
382
383    #[test]
384    fn test_audio_encoder_polarity_reset() {
385        let mut enc = LtcAudioEncoder::new(default_params());
386        assert!((enc.polarity() - 1.0).abs() < 1e-6);
387        enc.polarity = -1.0;
388        enc.reset_polarity();
389        assert!((enc.polarity() - 1.0).abs() < 1e-6);
390    }
391
392    #[test]
393    fn test_audio_encoder_sequence() {
394        let mut enc = LtcAudioEncoder::new(default_params());
395        let tcs = vec![
396            make_tc(0, 0, 0, 0, 25, false),
397            make_tc(0, 0, 0, 1, 25, false),
398        ];
399        let samples = enc.encode_sequence(&tcs);
400        // Should be approximately 2 * 1920 samples
401        assert!(samples.len() >= 3800);
402    }
403
404    #[test]
405    fn test_audio_encoder_params_accessor() {
406        let params = default_params();
407        let enc = LtcAudioEncoder::new(params);
408        assert_eq!(enc.params().fps, 25);
409        assert_eq!(enc.params().sample_rate, 48000);
410    }
411}