Skip to main content

oximedia_align/
temporal_align.rs

1//! Temporal stream alignment utilities for `OxiMedia`.
2//!
3//! Provides [`StreamAligner`] which applies measured offsets to bring multiple
4//! streams into a common time-base and reports the resulting alignment quality.
5
6#![allow(dead_code)]
7
8/// A signed time offset to apply to a stream's presentation timestamps.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct AlignmentOffset {
11    /// Stream identifier index.
12    pub stream_index: usize,
13    /// Offset in milliseconds to add to every PTS in this stream.
14    pub offset_ms: f64,
15    /// Confidence score `[0.0, 1.0]` from the sync detection step.
16    pub confidence: f64,
17}
18
19impl AlignmentOffset {
20    /// Create a new alignment offset.
21    #[must_use]
22    pub fn new(stream_index: usize, offset_ms: f64, confidence: f64) -> Self {
23        Self {
24            stream_index,
25            offset_ms,
26            confidence,
27        }
28    }
29
30    /// Apply this offset to a raw PTS value (in ms) and return the adjusted PTS.
31    #[must_use]
32    pub fn apply_to_pts(&self, raw_pts_ms: f64) -> f64 {
33        raw_pts_ms + self.offset_ms
34    }
35
36    /// Return `true` when the offset magnitude is within `tolerance_ms`.
37    #[must_use]
38    pub fn is_within_tolerance(&self, tolerance_ms: f64) -> bool {
39        self.offset_ms.abs() <= tolerance_ms
40    }
41}
42
43/// Represents a single stream's temporal synchronisation state.
44#[derive(Debug, Clone)]
45pub struct TemporalAlignment {
46    /// Stream index in the session.
47    pub stream_index: usize,
48    /// Applied offset in milliseconds.
49    pub applied_offset_ms: f64,
50    /// Residual drift per second (ms/s); ideally zero.
51    pub drift_rate_ms_per_s: f64,
52    /// Whether alignment was applied successfully.
53    pub aligned: bool,
54}
55
56impl TemporalAlignment {
57    /// Create a new temporal alignment record.
58    #[must_use]
59    pub fn new(
60        stream_index: usize,
61        applied_offset_ms: f64,
62        drift_rate_ms_per_s: f64,
63        aligned: bool,
64    ) -> Self {
65        Self {
66            stream_index,
67            applied_offset_ms,
68            drift_rate_ms_per_s,
69            aligned,
70        }
71    }
72
73    /// Return `true` when the stream is aligned and residual drift is negligible.
74    ///
75    /// "Negligible" is defined as < 0.1 ms/s.
76    #[must_use]
77    pub fn is_synchronized(&self) -> bool {
78        self.aligned && self.drift_rate_ms_per_s.abs() < 0.1
79    }
80
81    /// Compute the predicted PTS drift after `duration_s` seconds.
82    #[must_use]
83    pub fn predicted_drift_ms(&self, duration_s: f64) -> f64 {
84        self.drift_rate_ms_per_s * duration_s
85    }
86}
87
88/// Configuration for the stream aligner.
89#[derive(Debug, Clone)]
90pub struct StreamAlignerConfig {
91    /// Tolerance in ms; offsets beyond this are flagged.
92    pub tolerance_ms: f64,
93    /// Maximum allowed drift rate in ms/s before marking as unsynced.
94    pub max_drift_ms_per_s: f64,
95    /// Minimum confidence required to apply an offset.
96    pub min_confidence: f64,
97}
98
99impl Default for StreamAlignerConfig {
100    fn default() -> Self {
101        Self {
102            tolerance_ms: 10.0,
103            max_drift_ms_per_s: 0.5,
104            min_confidence: 0.60,
105        }
106    }
107}
108
109/// Aligns multiple media streams to a common time-base.
110#[derive(Debug)]
111pub struct StreamAligner {
112    config: StreamAlignerConfig,
113    alignments: Vec<TemporalAlignment>,
114}
115
116impl StreamAligner {
117    /// Create a new aligner with the given configuration.
118    #[must_use]
119    pub fn new(config: StreamAlignerConfig) -> Self {
120        Self {
121            config,
122            alignments: Vec::new(),
123        }
124    }
125
126    /// Create an aligner with default configuration.
127    #[must_use]
128    pub fn default_aligner() -> Self {
129        Self::new(StreamAlignerConfig::default())
130    }
131
132    /// Apply a set of offsets, producing [`TemporalAlignment`] records.
133    ///
134    /// Offsets with insufficient confidence are recorded as unaligned.
135    pub fn align_streams(&mut self, offsets: &[AlignmentOffset]) -> &[TemporalAlignment] {
136        self.alignments.clear();
137        for off in offsets {
138            let aligned = off.confidence >= self.config.min_confidence;
139            let applied = if aligned { off.offset_ms } else { 0.0 };
140            // Estimate drift as zero; real implementations would interpolate.
141            let drift = 0.0_f64;
142            self.alignments.push(TemporalAlignment::new(
143                off.stream_index,
144                applied,
145                drift,
146                aligned,
147            ));
148        }
149        &self.alignments
150    }
151
152    /// Return the maximum absolute offset applied across all streams (ms).
153    #[must_use]
154    pub fn max_offset_ms(&self) -> f64 {
155        self.alignments
156            .iter()
157            .map(|a| a.applied_offset_ms.abs())
158            .fold(0.0_f64, f64::max)
159    }
160
161    /// Count how many streams are fully synchronized.
162    #[must_use]
163    pub fn synchronized_count(&self) -> usize {
164        self.alignments
165            .iter()
166            .filter(|a| a.is_synchronized())
167            .count()
168    }
169
170    /// Return `true` if every stream achieved synchronisation.
171    #[must_use]
172    pub fn all_synchronized(&self) -> bool {
173        !self.alignments.is_empty()
174            && self
175                .alignments
176                .iter()
177                .all(TemporalAlignment::is_synchronized)
178    }
179
180    /// Look up the alignment record for a given stream index.
181    #[must_use]
182    pub fn get_alignment(&self, stream_index: usize) -> Option<&TemporalAlignment> {
183        self.alignments
184            .iter()
185            .find(|a| a.stream_index == stream_index)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    // ── AlignmentOffset ──────────────────────────────────────────────────────
194
195    #[test]
196    fn test_apply_to_pts_positive() {
197        let off = AlignmentOffset::new(0, 50.0, 0.9);
198        assert!((off.apply_to_pts(1000.0) - 1050.0).abs() < f64::EPSILON);
199    }
200
201    #[test]
202    fn test_apply_to_pts_negative() {
203        let off = AlignmentOffset::new(0, -30.0, 0.9);
204        assert!((off.apply_to_pts(1000.0) - 970.0).abs() < f64::EPSILON);
205    }
206
207    #[test]
208    fn test_apply_to_pts_zero() {
209        let off = AlignmentOffset::new(0, 0.0, 1.0);
210        assert!((off.apply_to_pts(500.0) - 500.0).abs() < f64::EPSILON);
211    }
212
213    #[test]
214    fn test_within_tolerance_true() {
215        let off = AlignmentOffset::new(0, 5.0, 0.9);
216        assert!(off.is_within_tolerance(10.0));
217    }
218
219    #[test]
220    fn test_within_tolerance_false() {
221        let off = AlignmentOffset::new(0, 20.0, 0.9);
222        assert!(!off.is_within_tolerance(10.0));
223    }
224
225    // ── TemporalAlignment ────────────────────────────────────────────────────
226
227    #[test]
228    fn test_is_synchronized_true() {
229        let ta = TemporalAlignment::new(0, 5.0, 0.01, true);
230        assert!(ta.is_synchronized());
231    }
232
233    #[test]
234    fn test_is_synchronized_not_aligned() {
235        let ta = TemporalAlignment::new(0, 5.0, 0.01, false);
236        assert!(!ta.is_synchronized());
237    }
238
239    #[test]
240    fn test_is_synchronized_high_drift() {
241        let ta = TemporalAlignment::new(0, 5.0, 0.5, true);
242        assert!(!ta.is_synchronized());
243    }
244
245    #[test]
246    fn test_predicted_drift() {
247        let ta = TemporalAlignment::new(0, 0.0, 0.05, true);
248        assert!((ta.predicted_drift_ms(100.0) - 5.0).abs() < f64::EPSILON);
249    }
250
251    // ── StreamAligner ────────────────────────────────────────────────────────
252
253    #[test]
254    fn test_aligner_empty() {
255        let mut aligner = StreamAligner::default_aligner();
256        aligner.align_streams(&[]);
257        assert!((aligner.max_offset_ms()).abs() < f64::EPSILON);
258        assert!(!aligner.all_synchronized());
259    }
260
261    #[test]
262    fn test_aligner_applies_confident_offset() {
263        let mut aligner = StreamAligner::default_aligner();
264        let offsets = [AlignmentOffset::new(0, 8.0, 0.95)];
265        aligner.align_streams(&offsets);
266        let al = aligner.get_alignment(0).expect("al should be valid");
267        assert!((al.applied_offset_ms - 8.0).abs() < f64::EPSILON);
268        assert!(al.aligned);
269    }
270
271    #[test]
272    fn test_aligner_skips_low_confidence() {
273        let mut aligner = StreamAligner::default_aligner();
274        let offsets = [AlignmentOffset::new(0, 15.0, 0.2)];
275        aligner.align_streams(&offsets);
276        let al = aligner.get_alignment(0).expect("al should be valid");
277        assert!((al.applied_offset_ms).abs() < f64::EPSILON);
278        assert!(!al.aligned);
279    }
280
281    #[test]
282    fn test_aligner_max_offset() {
283        let mut aligner = StreamAligner::default_aligner();
284        let offsets = [
285            AlignmentOffset::new(0, 3.0, 0.9),
286            AlignmentOffset::new(1, 7.0, 0.9),
287            AlignmentOffset::new(2, 1.5, 0.9),
288        ];
289        aligner.align_streams(&offsets);
290        assert!((aligner.max_offset_ms() - 7.0).abs() < f64::EPSILON);
291    }
292
293    #[test]
294    fn test_aligner_all_synchronized() {
295        let mut aligner = StreamAligner::default_aligner();
296        let offsets = [
297            AlignmentOffset::new(0, 2.0, 0.95),
298            AlignmentOffset::new(1, 4.0, 0.85),
299        ];
300        aligner.align_streams(&offsets);
301        assert!(aligner.all_synchronized());
302    }
303
304    #[test]
305    fn test_aligner_synchronized_count() {
306        let mut aligner = StreamAligner::default_aligner();
307        let offsets = [
308            AlignmentOffset::new(0, 2.0, 0.95),  // aligned
309            AlignmentOffset::new(1, 50.0, 0.10), // not aligned (low conf)
310        ];
311        aligner.align_streams(&offsets);
312        assert_eq!(aligner.synchronized_count(), 1);
313    }
314
315    #[test]
316    fn test_aligner_get_alignment_missing() {
317        let aligner = StreamAligner::default_aligner();
318        assert!(aligner.get_alignment(99).is_none());
319    }
320}