Skip to main content

ff_analysis/analysis/
silence_detector.rs

1//! Silence detection for audio files.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use crate::AnalysisError;
7
8/// A detected silent interval in an audio stream.
9///
10/// Both timestamps are measured from the beginning of the file.
11#[derive(Debug, Clone, PartialEq)]
12pub struct SilenceRange {
13    /// Start of the silent interval.
14    pub start: Duration,
15    /// End of the silent interval.
16    pub end: Duration,
17}
18
19/// Detects silent intervals in an audio file and returns their time ranges.
20///
21/// Uses `FFmpeg`'s `silencedetect` filter to identify audio segments whose
22/// amplitude stays below `threshold_db` for at least `min_duration`.  Only
23/// complete intervals (silence start **and** end detected) are reported; a
24/// trailing silence that runs to end-of-file without an explicit end marker is
25/// not included.
26///
27/// # Examples
28///
29/// ```ignore
30/// use ff_analysis::SilenceDetector;
31/// use std::time::Duration;
32///
33/// let ranges = SilenceDetector::new("audio.mp3")
34///     .threshold_db(-40.0)
35///     .min_duration(Duration::from_millis(500))
36///     .run()?;
37///
38/// for r in &ranges {
39///     println!("Silence {:?}–{:?}", r.start, r.end);
40/// }
41/// ```
42pub struct SilenceDetector {
43    input: PathBuf,
44    threshold_db: f32,
45    min_duration: Duration,
46}
47
48impl SilenceDetector {
49    /// Creates a new detector for the given audio file.
50    ///
51    /// Defaults: `threshold_db = -40.0`, `min_duration = 500 ms`.
52    pub fn new(input: impl AsRef<Path>) -> Self {
53        Self {
54            input: input.as_ref().to_path_buf(),
55            threshold_db: -40.0,
56            min_duration: Duration::from_millis(500),
57        }
58    }
59
60    /// Sets the amplitude threshold in dBFS.
61    ///
62    /// Audio samples below this level are considered silent.  The value should
63    /// be negative (e.g. `-40.0` for −40 dBFS).
64    ///
65    /// Default: `-40.0` dB.
66    #[must_use]
67    pub fn threshold_db(self, db: f32) -> Self {
68        Self {
69            threshold_db: db,
70            ..self
71        }
72    }
73
74    /// Sets the minimum duration a silent segment must last to be reported.
75    ///
76    /// Silence shorter than this value is ignored.
77    ///
78    /// Default: 500 ms.
79    #[must_use]
80    pub fn min_duration(self, d: Duration) -> Self {
81        Self {
82            min_duration: d,
83            ..self
84        }
85    }
86
87    /// Runs silence detection and returns all detected [`SilenceRange`] values.
88    ///
89    /// # Errors
90    ///
91    /// - [`AnalysisError::Failed`] — input file not found or an internal
92    ///   filter-graph error occurs.
93    pub fn run(self) -> Result<Vec<SilenceRange>, AnalysisError> {
94        if !self.input.exists() {
95            return Err(AnalysisError::Failed {
96                reason: format!("file not found: {}", self.input.display()),
97            });
98        }
99        super::analysis_inner::detect_silence(&self.input, self.threshold_db, self.min_duration)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn silence_detector_missing_file_should_return_analysis_failed() {
109        let result = SilenceDetector::new("does_not_exist_99999.mp3").run();
110        assert!(
111            matches!(result, Err(AnalysisError::Failed { .. })),
112            "expected Failed for missing file, got {result:?}"
113        );
114    }
115
116    #[test]
117    fn silence_detector_default_threshold_should_be_minus_40_db() {
118        // Verify the default is -40 dB by round-tripping through threshold_db().
119        // Setting the same value should not change behaviour.
120        let result = SilenceDetector::new("does_not_exist_99999.mp3")
121            .threshold_db(-40.0)
122            .run();
123        assert!(
124            matches!(result, Err(AnalysisError::Failed { .. })),
125            "expected Failed (missing file) when threshold_db=-40, got {result:?}"
126        );
127    }
128}