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}