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}