Skip to main content

kithara_decode/gapless/
heuristic.rs

1/// How gapless PCM trimming is applied on top of decoder-reported [`GaplessInfo`].
2#[derive(Debug, Clone, Copy, PartialEq, Default)]
3#[non_exhaustive]
4pub enum GaplessMode {
5    /// Passthrough PCM: no [`GaplessTrimmer`] (decoder-reported [`GaplessInfo`] is ignored).
6    Disabled,
7    /// Use decoder gapless counts when present; otherwise leave samples unchanged.
8    #[default]
9    MediaOnly,
10    /// When [`GaplessInfo`] is absent, trim a codec-specific leading priming estimate.
11    CodecPriming,
12    /// When [`GaplessInfo`] is absent, trim leading silence per [`SilenceTrimParams`].
13    SilenceTrim(SilenceTrimParams),
14}
15
16/// Tunables for [`GaplessMode::SilenceTrim`].
17///
18/// `threshold_db` is expressed as a positive number of dB *below* full
19/// scale: e.g. `45.0` means -45 dBFS, which corresponds to a linear
20/// amplitude of `≈5.6e-3`. The default is tuned to sit above lossy
21/// codec quantisation noise floors (AAC and MP3 commonly leak
22/// -50..-55 dB into otherwise silent regions) while staying far below
23/// musically relevant levels. Lower the value (e.g. `40.0`) to trim
24/// louder "near-silence" too — at the cost of false positives on
25/// quiet music.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct SilenceTrimParams {
28    /// When true, also trim trailing silence at EOF using the same
29    /// threshold. Disabled by default because tail content is more
30    /// often intentional (decay, reverb).
31    pub trim_trailing: bool,
32    /// Silence floor in dB below full scale. Default `45.0` ≈ -45 dB ≈ `5.6e-3`.
33    pub threshold_db: f32,
34    /// Minimum number of contiguous silent leading frames before any
35    /// trim is applied. Below this threshold we leave the audio alone
36    /// to avoid clipping intentional micro-pauses.
37    pub min_trim_frames: u64,
38    /// Maximum frames the leading scan looks at before giving up. If
39    /// the whole window is silent (very long fade-in) we keep the
40    /// audio as-is — better safe than sorry.
41    pub scan_window_frames: u64,
42}
43
44impl Default for SilenceTrimParams {
45    fn default() -> Self {
46        Self {
47            threshold_db: 45.0,
48            min_trim_frames: 256,
49            scan_window_frames: 4096,
50            trim_trailing: false,
51        }
52    }
53}
54
55impl SilenceTrimParams {
56    /// Convert the dB threshold to linear amplitude.
57    ///
58    /// `db_below_full_scale` is the positive distance below 0 dBFS, so
59    /// the formula is `10 ^ (-db / 20)`. Negative or `NaN` inputs are
60    /// clamped to 0 dB (linear 1.0 — effectively "everything is
61    /// silent", which disables trim) so a misconfigured value cannot
62    /// accidentally chew through audible content.
63    #[must_use]
64    pub fn threshold_amplitude(&self) -> f32 {
65        if !self.threshold_db.is_finite() || self.threshold_db <= 0.0 {
66            return 1.0;
67        }
68        10f32.powf(-self.threshold_db / 20.0)
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use kithara_test_utils::kithara;
75
76    use super::*;
77
78    fn approx(a: f32, b: f32, eps: f32) -> bool {
79        (a - b).abs() < eps
80    }
81
82    #[kithara::test]
83    #[case::db_40(40.0, 1.0e-2, 1e-8)]
84    #[case::db_60(60.0, 1.0e-3, 1e-9)]
85    #[case::db_80(80.0, 1.0e-4, 1e-10)]
86    fn threshold_db_maps_to_amplitude(
87        #[case] threshold_db: f32,
88        #[case] expected_amplitude: f32,
89        #[case] eps: f32,
90    ) {
91        let params = SilenceTrimParams {
92            threshold_db,
93            ..Default::default()
94        };
95        assert!(approx(
96            params.threshold_amplitude(),
97            expected_amplitude,
98            eps
99        ));
100    }
101
102    #[kithara::test]
103    fn non_positive_db_disables_trim() {
104        for db in [-1.0, 0.0, f32::NAN] {
105            let params = SilenceTrimParams {
106                threshold_db: db,
107                ..Default::default()
108            };
109            assert_eq!(
110                params.threshold_amplitude(),
111                1.0,
112                "db={db} must yield amplitude=1.0 (no trim)"
113            );
114        }
115    }
116
117    #[kithara::test]
118    fn defaults_match_documented_values() {
119        let p = SilenceTrimParams::default();
120        assert_eq!(p.threshold_db, 45.0);
121        assert_eq!(p.min_trim_frames, 256);
122        assert_eq!(p.scan_window_frames, 4096);
123        assert!(!p.trim_trailing);
124    }
125}