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}