Skip to main content

jugar_probar/audio_quality/
types.rs

1//! Types for audio quality verification.
2//!
3//! Provides report structures for audio level analysis, clipping detection,
4//! and silence verification.
5
6use serde::{Deserialize, Serialize};
7
8/// Audio quality report for a rendered video.
9#[derive(Clone, Debug, Serialize)]
10pub struct AudioQualityReport {
11    /// Source file path
12    pub source: String,
13    /// Overall verdict
14    pub verdict: AudioVerdict,
15    /// Audio level metrics
16    pub levels: AudioLevels,
17    /// Clipping analysis
18    pub clipping: ClippingReport,
19    /// Silence analysis
20    pub silence: SilenceReport,
21    /// Duration in seconds
22    pub duration_secs: f64,
23    /// Sample rate
24    pub sample_rate: u32,
25    /// Total sample count
26    pub sample_count: usize,
27}
28
29/// Audio level metrics.
30#[derive(Clone, Debug, Serialize)]
31pub struct AudioLevels {
32    /// Peak amplitude (0.0-1.0)
33    pub peak: f64,
34    /// Peak amplitude in dBFS
35    pub peak_dbfs: f64,
36    /// RMS amplitude (0.0-1.0)
37    pub rms: f64,
38    /// RMS amplitude in dBFS
39    pub rms_dbfs: f64,
40    /// Dynamic range in dB (peak - noise floor)
41    pub dynamic_range_db: f64,
42    /// Whether levels are within acceptable range
43    pub passed: bool,
44}
45
46/// Clipping detection results.
47#[derive(Clone, Debug, Serialize)]
48pub struct ClippingReport {
49    /// Number of clipped samples (at +/- 1.0)
50    pub clipped_samples: usize,
51    /// Percentage of clipped samples
52    pub clipped_pct: f64,
53    /// Whether clipping test passed (no clipping)
54    pub passed: bool,
55}
56
57/// Silence detection results.
58#[derive(Clone, Debug, Serialize)]
59pub struct SilenceReport {
60    /// Detected silence regions
61    pub regions: Vec<SilenceRegion>,
62    /// Total silence duration in seconds
63    pub total_silence_secs: f64,
64    /// Percentage of audio that is silence
65    pub silence_pct: f64,
66    /// Whether silence check passed
67    pub passed: bool,
68}
69
70/// A region of silence in the audio.
71#[derive(Clone, Debug, Serialize)]
72pub struct SilenceRegion {
73    /// Start time in seconds
74    pub start_secs: f64,
75    /// End time in seconds
76    pub end_secs: f64,
77    /// Duration in seconds
78    pub duration_secs: f64,
79}
80
81/// Overall audio quality verdict.
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83pub enum AudioVerdict {
84    /// All checks passed
85    Pass,
86    /// One or more checks failed
87    Fail,
88    /// No audio found
89    NoAudio,
90}
91
92impl std::fmt::Display for AudioVerdict {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Self::Pass => write!(f, "PASS"),
96            Self::Fail => write!(f, "FAIL"),
97            Self::NoAudio => write!(f, "NO AUDIO"),
98        }
99    }
100}
101
102/// Configuration for audio quality checks.
103#[derive(Clone, Debug)]
104pub struct AudioQualityConfig {
105    /// Minimum acceptable RMS in dBFS (default: -40.0)
106    pub min_rms_dbfs: f64,
107    /// Maximum acceptable peak in dBFS (default: -0.1, just below clipping)
108    pub max_peak_dbfs: f64,
109    /// Whether to fail on any clipping (default: true)
110    pub no_clipping: bool,
111    /// Silence threshold in dBFS (default: -60.0)
112    pub silence_threshold_dbfs: f64,
113    /// Minimum silence duration to report in seconds (default: 0.5)
114    pub min_silence_duration_secs: f64,
115    /// Maximum acceptable silence percentage (default: 80.0)
116    pub max_silence_pct: f64,
117}
118
119impl Default for AudioQualityConfig {
120    fn default() -> Self {
121        Self {
122            min_rms_dbfs: -40.0,
123            max_peak_dbfs: -0.1,
124            no_clipping: true,
125            silence_threshold_dbfs: -60.0,
126            min_silence_duration_secs: 0.5,
127            max_silence_pct: 80.0,
128        }
129    }
130}
131
132impl AudioQualityConfig {
133    /// Set minimum RMS level.
134    #[must_use]
135    pub fn with_min_rms_dbfs(mut self, dbfs: f64) -> Self {
136        self.min_rms_dbfs = dbfs;
137        self
138    }
139
140    /// Set maximum peak level.
141    #[must_use]
142    pub fn with_max_peak_dbfs(mut self, dbfs: f64) -> Self {
143        self.max_peak_dbfs = dbfs;
144        self
145    }
146
147    /// Set clipping policy.
148    #[must_use]
149    pub const fn with_no_clipping(mut self, no_clipping: bool) -> Self {
150        self.no_clipping = no_clipping;
151        self
152    }
153
154    /// Set silence threshold.
155    #[must_use]
156    pub fn with_silence_threshold_dbfs(mut self, dbfs: f64) -> Self {
157        self.silence_threshold_dbfs = dbfs;
158        self
159    }
160}
161
162#[cfg(test)]
163#[allow(clippy::unwrap_used)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_audio_verdict_display() {
169        assert_eq!(AudioVerdict::Pass.to_string(), "PASS");
170        assert_eq!(AudioVerdict::Fail.to_string(), "FAIL");
171        assert_eq!(AudioVerdict::NoAudio.to_string(), "NO AUDIO");
172    }
173
174    #[test]
175    fn test_audio_verdict_equality() {
176        assert_eq!(AudioVerdict::Pass, AudioVerdict::Pass);
177        assert_ne!(AudioVerdict::Pass, AudioVerdict::Fail);
178    }
179
180    #[test]
181    fn test_config_defaults() {
182        let config = AudioQualityConfig::default();
183        assert!((config.min_rms_dbfs - (-40.0)).abs() < f64::EPSILON);
184        assert!((config.max_peak_dbfs - (-0.1)).abs() < f64::EPSILON);
185        assert!(config.no_clipping);
186        assert!((config.silence_threshold_dbfs - (-60.0)).abs() < f64::EPSILON);
187    }
188
189    #[test]
190    fn test_config_builders() {
191        let config = AudioQualityConfig::default()
192            .with_min_rms_dbfs(-30.0)
193            .with_max_peak_dbfs(-1.0)
194            .with_no_clipping(false)
195            .with_silence_threshold_dbfs(-50.0);
196        assert!((config.min_rms_dbfs - (-30.0)).abs() < f64::EPSILON);
197        assert!((config.max_peak_dbfs - (-1.0)).abs() < f64::EPSILON);
198        assert!(!config.no_clipping);
199        assert!((config.silence_threshold_dbfs - (-50.0)).abs() < f64::EPSILON);
200    }
201
202    #[test]
203    fn test_silence_region() {
204        let region = SilenceRegion {
205            start_secs: 1.0,
206            end_secs: 2.5,
207            duration_secs: 1.5,
208        };
209        assert!((region.duration_secs - 1.5).abs() < f64::EPSILON);
210    }
211
212    #[test]
213    fn test_audio_levels_serialization() {
214        let levels = AudioLevels {
215            peak: 0.95,
216            peak_dbfs: -0.45,
217            rms: 0.3,
218            rms_dbfs: -10.46,
219            dynamic_range_db: 50.0,
220            passed: true,
221        };
222        let json = serde_json::to_string(&levels).unwrap();
223        assert!(json.contains("\"peak\":0.95"));
224    }
225
226    #[test]
227    fn test_clipping_report() {
228        let report = ClippingReport {
229            clipped_samples: 0,
230            clipped_pct: 0.0,
231            passed: true,
232        };
233        assert!(report.passed);
234    }
235
236    #[test]
237    fn test_silence_report() {
238        let report = SilenceReport {
239            regions: vec![],
240            total_silence_secs: 0.0,
241            silence_pct: 0.0,
242            passed: true,
243        };
244        assert!(report.passed);
245        assert!(report.regions.is_empty());
246    }
247
248    #[test]
249    fn test_audio_quality_report_serialization() {
250        let report = AudioQualityReport {
251            source: "test.mp4".to_string(),
252            verdict: AudioVerdict::Pass,
253            levels: AudioLevels {
254                peak: 0.5,
255                peak_dbfs: -6.0,
256                rms: 0.2,
257                rms_dbfs: -14.0,
258                dynamic_range_db: 40.0,
259                passed: true,
260            },
261            clipping: ClippingReport {
262                clipped_samples: 0,
263                clipped_pct: 0.0,
264                passed: true,
265            },
266            silence: SilenceReport {
267                regions: vec![],
268                total_silence_secs: 0.0,
269                silence_pct: 0.0,
270                passed: true,
271            },
272            duration_secs: 10.0,
273            sample_rate: 48000,
274            sample_count: 480_000,
275        };
276        let json = serde_json::to_string(&report).unwrap();
277        assert!(json.contains("\"verdict\":\"Pass\""));
278    }
279}