Skip to main content

ff_decode/analysis/
silence_detector.rs

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