Skip to main content

oximedia_align/
sync_score.rs

1//! Sync quality scoring for multi-stream alignment in `OxiMedia`.
2//!
3//! Provides [`SyncScorer`] which evaluates alignment quality and produces
4//! [`SyncReport`] summaries with per-stream metrics.
5
6#![allow(dead_code)]
7
8/// Quality level of a synchronization result.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub enum SyncQuality {
11    /// Sync is unusable; offset error is too large.
12    Poor,
13    /// Sync is marginal; may cause audible/visible drift.
14    Fair,
15    /// Sync is acceptable for most purposes.
16    Good,
17    /// Sync is excellent; sub-frame accuracy achieved.
18    Excellent,
19}
20
21impl SyncQuality {
22    /// Return a numeric score in the range `[0.0, 1.0]`.
23    #[allow(clippy::cast_precision_loss)]
24    #[must_use]
25    pub fn score(&self) -> f64 {
26        match self {
27            Self::Poor => 0.1,
28            Self::Fair => 0.4,
29            Self::Good => 0.75,
30            Self::Excellent => 1.0,
31        }
32    }
33
34    /// Human-readable label.
35    #[must_use]
36    pub fn label(&self) -> &'static str {
37        match self {
38            Self::Poor => "poor",
39            Self::Fair => "fair",
40            Self::Good => "good",
41            Self::Excellent => "excellent",
42        }
43    }
44}
45
46/// Result of evaluating sync between two streams.
47#[derive(Debug, Clone)]
48pub struct SyncResult {
49    /// Quality classification.
50    pub quality: SyncQuality,
51    /// Measured offset in milliseconds (positive = stream B is ahead).
52    pub offset_ms: f64,
53    /// Cross-correlation confidence `[0.0, 1.0]`.
54    pub confidence: f64,
55    /// Stream identifier string.
56    pub stream_id: String,
57}
58
59impl SyncResult {
60    /// Create a new sync result.
61    #[must_use]
62    pub fn new(quality: SyncQuality, offset_ms: f64, confidence: f64, stream_id: &str) -> Self {
63        Self {
64            quality,
65            offset_ms,
66            confidence,
67            stream_id: stream_id.to_owned(),
68        }
69    }
70
71    /// Return `true` if quality is [`SyncQuality::Good`] or better.
72    #[must_use]
73    pub fn is_good_sync(&self) -> bool {
74        self.quality >= SyncQuality::Good
75    }
76
77    /// Absolute value of the offset.
78    #[must_use]
79    pub fn abs_offset_ms(&self) -> f64 {
80        self.offset_ms.abs()
81    }
82}
83
84/// Configuration for the sync scorer.
85#[derive(Debug, Clone)]
86pub struct SyncScorerConfig {
87    /// Offset threshold in ms below which sync is considered Excellent.
88    pub excellent_threshold_ms: f64,
89    /// Offset threshold in ms below which sync is considered Good.
90    pub good_threshold_ms: f64,
91    /// Offset threshold in ms below which sync is considered Fair.
92    pub fair_threshold_ms: f64,
93    /// Minimum confidence required for Good classification.
94    pub min_confidence_good: f64,
95}
96
97impl Default for SyncScorerConfig {
98    fn default() -> Self {
99        Self {
100            excellent_threshold_ms: 0.5,
101            good_threshold_ms: 5.0,
102            fair_threshold_ms: 33.0,
103            min_confidence_good: 0.70,
104        }
105    }
106}
107
108/// Evaluates stream sync results and produces quality ratings.
109#[derive(Debug)]
110pub struct SyncScorer {
111    config: SyncScorerConfig,
112}
113
114impl SyncScorer {
115    /// Create a new scorer with the given configuration.
116    #[must_use]
117    pub fn new(config: SyncScorerConfig) -> Self {
118        Self { config }
119    }
120
121    /// Create a scorer with default thresholds.
122    #[must_use]
123    pub fn default_scorer() -> Self {
124        Self::new(SyncScorerConfig::default())
125    }
126
127    /// Evaluate a raw offset + confidence measurement for a stream.
128    #[must_use]
129    pub fn evaluate(&self, stream_id: &str, offset_ms: f64, confidence: f64) -> SyncResult {
130        let abs_off = offset_ms.abs();
131        let quality = if confidence < self.config.min_confidence_good {
132            SyncQuality::Poor
133        } else if abs_off <= self.config.excellent_threshold_ms {
134            SyncQuality::Excellent
135        } else if abs_off <= self.config.good_threshold_ms {
136            SyncQuality::Good
137        } else if abs_off <= self.config.fair_threshold_ms {
138            SyncQuality::Fair
139        } else {
140            SyncQuality::Poor
141        };
142        SyncResult::new(quality, offset_ms, confidence, stream_id)
143    }
144}
145
146/// Aggregated report from evaluating multiple streams.
147#[derive(Debug, Default)]
148pub struct SyncReport {
149    /// Individual results, one per stream.
150    pub results: Vec<SyncResult>,
151}
152
153impl SyncReport {
154    /// Create an empty report.
155    #[must_use]
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// Add a result to the report.
161    pub fn add(&mut self, result: SyncResult) {
162        self.results.push(result);
163    }
164
165    /// Return the worst quality across all results.
166    #[must_use]
167    pub fn worst_quality(&self) -> Option<SyncQuality> {
168        self.results.iter().map(|r| r.quality).min()
169    }
170
171    /// Compute the average absolute offset in milliseconds.
172    #[allow(clippy::cast_precision_loss)]
173    #[must_use]
174    pub fn avg_offset_ms(&self) -> f64 {
175        if self.results.is_empty() {
176            return 0.0;
177        }
178        let sum: f64 = self.results.iter().map(SyncResult::abs_offset_ms).sum();
179        sum / self.results.len() as f64
180    }
181
182    /// Return the number of streams with good or better sync.
183    #[must_use]
184    pub fn good_count(&self) -> usize {
185        self.results.iter().filter(|r| r.is_good_sync()).count()
186    }
187
188    /// Return `true` if every stream achieved good or better sync.
189    #[must_use]
190    pub fn all_good(&self) -> bool {
191        !self.results.is_empty() && self.results.iter().all(SyncResult::is_good_sync)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    // ── SyncQuality ──────────────────────────────────────────────────────────
200
201    #[test]
202    fn test_quality_order() {
203        assert!(SyncQuality::Excellent > SyncQuality::Good);
204        assert!(SyncQuality::Good > SyncQuality::Fair);
205        assert!(SyncQuality::Fair > SyncQuality::Poor);
206    }
207
208    #[test]
209    fn test_quality_score_monotone() {
210        assert!(SyncQuality::Excellent.score() > SyncQuality::Good.score());
211        assert!(SyncQuality::Good.score() > SyncQuality::Fair.score());
212        assert!(SyncQuality::Fair.score() > SyncQuality::Poor.score());
213    }
214
215    #[test]
216    fn test_quality_score_range() {
217        for q in [
218            SyncQuality::Poor,
219            SyncQuality::Fair,
220            SyncQuality::Good,
221            SyncQuality::Excellent,
222        ] {
223            let s = q.score();
224            assert!((0.0..=1.0).contains(&s), "score {s} out of [0,1]");
225        }
226    }
227
228    #[test]
229    fn test_quality_labels_non_empty() {
230        for q in [
231            SyncQuality::Poor,
232            SyncQuality::Fair,
233            SyncQuality::Good,
234            SyncQuality::Excellent,
235        ] {
236            assert!(!q.label().is_empty());
237        }
238    }
239
240    // ── SyncResult ───────────────────────────────────────────────────────────
241
242    #[test]
243    fn test_sync_result_is_good() {
244        let good = SyncResult::new(SyncQuality::Good, 2.0, 0.9, "cam1");
245        let poor = SyncResult::new(SyncQuality::Poor, 50.0, 0.3, "cam2");
246        assert!(good.is_good_sync());
247        assert!(!poor.is_good_sync());
248    }
249
250    #[test]
251    fn test_sync_result_excellent_is_good() {
252        let r = SyncResult::new(SyncQuality::Excellent, 0.1, 0.99, "cam1");
253        assert!(r.is_good_sync());
254    }
255
256    #[test]
257    fn test_abs_offset_negative() {
258        let r = SyncResult::new(SyncQuality::Good, -8.0, 0.85, "cam3");
259        assert!((r.abs_offset_ms() - 8.0).abs() < f64::EPSILON);
260    }
261
262    // ── SyncScorer ───────────────────────────────────────────────────────────
263
264    #[test]
265    fn test_scorer_excellent() {
266        let scorer = SyncScorer::default_scorer();
267        let r = scorer.evaluate("cam1", 0.3, 0.95);
268        assert_eq!(r.quality, SyncQuality::Excellent);
269    }
270
271    #[test]
272    fn test_scorer_good() {
273        let scorer = SyncScorer::default_scorer();
274        let r = scorer.evaluate("cam1", 3.0, 0.80);
275        assert_eq!(r.quality, SyncQuality::Good);
276    }
277
278    #[test]
279    fn test_scorer_fair() {
280        let scorer = SyncScorer::default_scorer();
281        let r = scorer.evaluate("cam1", 15.0, 0.75);
282        assert_eq!(r.quality, SyncQuality::Fair);
283    }
284
285    #[test]
286    fn test_scorer_poor_large_offset() {
287        let scorer = SyncScorer::default_scorer();
288        let r = scorer.evaluate("cam1", 100.0, 0.90);
289        assert_eq!(r.quality, SyncQuality::Poor);
290    }
291
292    #[test]
293    fn test_scorer_poor_low_confidence() {
294        let scorer = SyncScorer::default_scorer();
295        let r = scorer.evaluate("cam1", 0.1, 0.2);
296        assert_eq!(r.quality, SyncQuality::Poor);
297    }
298
299    // ── SyncReport ───────────────────────────────────────────────────────────
300
301    #[test]
302    fn test_report_empty() {
303        let report = SyncReport::new();
304        assert!(report.worst_quality().is_none());
305        assert!((report.avg_offset_ms()).abs() < f64::EPSILON);
306        assert!(!report.all_good());
307    }
308
309    #[test]
310    fn test_report_worst_quality() {
311        let scorer = SyncScorer::default_scorer();
312        let mut report = SyncReport::new();
313        report.add(scorer.evaluate("cam1", 0.2, 0.99));
314        report.add(scorer.evaluate("cam2", 20.0, 0.80));
315        assert_eq!(report.worst_quality(), Some(SyncQuality::Fair));
316    }
317
318    #[test]
319    fn test_report_avg_offset() {
320        let mut report = SyncReport::new();
321        report.add(SyncResult::new(SyncQuality::Good, 4.0, 0.9, "a"));
322        report.add(SyncResult::new(SyncQuality::Good, 6.0, 0.9, "b"));
323        assert!((report.avg_offset_ms() - 5.0).abs() < 1e-10);
324    }
325
326    #[test]
327    fn test_report_all_good() {
328        let scorer = SyncScorer::default_scorer();
329        let mut report = SyncReport::new();
330        report.add(scorer.evaluate("cam1", 0.3, 0.95));
331        report.add(scorer.evaluate("cam2", 2.0, 0.80));
332        assert!(report.all_good());
333    }
334
335    #[test]
336    fn test_report_good_count() {
337        let scorer = SyncScorer::default_scorer();
338        let mut report = SyncReport::new();
339        report.add(scorer.evaluate("cam1", 0.3, 0.95)); // Excellent
340        report.add(scorer.evaluate("cam2", 2.0, 0.80)); // Good
341        report.add(scorer.evaluate("cam3", 50.0, 0.8)); // Poor
342        assert_eq!(report.good_count(), 2);
343    }
344}