Skip to main content

kithara_decode/gapless/trimmer/
core.rs

1use smallvec::SmallVec;
2
3use crate::{GaplessInfo, PcmChunk, duration_for_frames, gapless::heuristic::SilenceTrimParams};
4
5/// Inline batch of chunks released by one `GaplessTrimmer` operation.
6pub type GaplessOutput = SmallVec<[PcmChunk; 2]>;
7type TailBuffer = SmallVec<[PcmChunk; 4]>;
8
9struct Consts;
10impl Consts {
11    /// Length of the click-suppression fade-in applied after every
12    /// heuristic trim (silence or codec-priming). 3 ms is short
13    /// enough to be inaudible as a transient but long enough to mask
14    /// the level discontinuity at the trim boundary.
15    const FADE_IN_DURATION_MS: u64 = 3;
16
17    /// Length of the click-suppression fade-out applied to the very
18    /// end of the buffered audio after a heuristic trailing-silence
19    /// trim. Mirror of `Consts::FADE_IN_DURATION_MS` for the trailing side;
20    /// same reasoning (mask any sub-sample boundary mismatch left by
21    /// the trim search).
22    const FADE_OUT_DURATION_MS: u64 = 3;
23
24    /// Window length (in milliseconds) used by the trailing silence
25    /// search. Per-sample threshold tests false-positive on zero-
26    /// crossings of any periodic signal — at 800 Hz a sine passes
27    /// below `1e-3` for ~3 frames every cycle, which the old
28    /// algorithm classified as silence and ate into audible content.
29    /// A 10 ms window contains many full cycles of typical audio and
30    /// integrates over them to get a stable energy estimate; it also
31    /// averages out lossy-codec quantisation noise floors (AAC
32    /// commonly sits around -50..-60 dB in quiet regions) so a real
33    /// silent suffix is recognised reliably.
34    const TRAILING_SILENCE_WINDOW_MS: u64 = 10;
35}
36
37/// Stateful PCM trimmer that applies one track's gapless contract.
38#[derive(Debug, Default)]
39pub struct GaplessTrimmer {
40    mode: GaplessMode,
41    tail_buffer: TailBuffer,
42    tail_buffered_frames: u64,
43    /// Tail hold-back size. Reused for two purposes:
44    ///   - in `Fixed` mode it is the metadata-driven trailing trim,
45    ///   - in `Heuristic` mode it is `scan_window_frames` so we always
46    ///     have enough buffered tail for the EOF silence search.
47    ///
48    /// The two roles never collide — only one mode is active per
49    /// trimmer instance — but watch out when reading the buffer
50    /// helpers below: `trailing_frames` does not always mean "frames
51    /// to drop", sometimes it just means "minimum buffered tail".
52    trailing_frames: u64,
53}
54
55#[derive(Debug, Default)]
56enum GaplessMode {
57    #[default]
58    Disabled,
59    Fixed {
60        leading_remaining: u64,
61        /// Click-suppression fade applied to the first `Consts::FADE_IN_DURATION_MS`
62        /// of audio that survives the leading trim. `None` for
63        /// metadata-driven trim — that boundary is sample-exact.
64        fade_in: Option<FadeInState>,
65    },
66    Heuristic(Box<HeuristicState>),
67}
68
69#[derive(Debug)]
70struct HeuristicState {
71    /// Fade-in applied to the first frames after a successful leading
72    /// trim. `None` while we're still buffering or if no trim happened.
73    fade_in: Option<FadeInState>,
74    params: SilenceTrimParams,
75    /// Buffered chunks while we look for the first non-silent frame.
76    /// Once the search ends, the buffer is drained into `tail_buffer`
77    /// (with leading frames trimmed) and never refilled.
78    leading_buffer: TailBuffer,
79    leading_enabled: bool,
80    /// Same `params.trim_trailing`, copied for fast access in the flush
81    /// path so we don't keep matching against the parent enum.
82    trim_trailing: bool,
83    /// Pre-computed linear amplitude floor — recomputing on every
84    /// frame would be wasteful and `params` is immutable for the
85    /// lifetime of the trimmer.
86    silence_threshold_amp: f32,
87    leading_buffered_frames: u64,
88}
89
90impl HeuristicState {
91    fn new(params: SilenceTrimParams) -> Self {
92        let silence_threshold_amp = params.threshold_amplitude();
93        let trim_trailing = params.trim_trailing;
94        Self {
95            params,
96            silence_threshold_amp,
97            trim_trailing,
98            leading_buffer: TailBuffer::new(),
99            leading_buffered_frames: 0,
100            leading_enabled: true,
101            fade_in: None,
102        }
103    }
104}
105
106/// Raised-cosine fade-in tracker.
107///
108/// The state is just a counter of how many frames have already been
109/// shaped; the curve is generated on demand in [`FadeInState::apply`].
110/// The total fade length is in *frames*, not samples — channels are
111/// handled by the apply step itself.
112#[derive(Debug, Clone, Copy)]
113struct FadeInState {
114    applied_frames: u16,
115    total_frames: u16,
116}
117
118impl FadeInState {
119    /// Apply the next slice of the fade to `chunk`, modifying samples
120    /// in place. The chunk may be shorter or longer than the remaining
121    /// fade window; we only touch the prefix that still needs shaping.
122    fn apply(&mut self, chunk: &mut PcmChunk) {
123        if self.is_done() {
124            return;
125        }
126        let frames = chunk_frames(chunk);
127        if frames == 0 {
128            return;
129        }
130        let channels = usize::from(chunk.spec().channels.max(1));
131        let remaining = self.total_frames.saturating_sub(self.applied_frames);
132        let to_shape = remaining.min(u16::try_from(frames).unwrap_or(u16::MAX));
133        let total = f32::from(self.total_frames.max(1));
134        let start_frame = self.applied_frames;
135        let shape_samples = usize::from(to_shape) * channels;
136        let prefix_len = shape_samples.min(chunk.pcm.len());
137        let prefix = &mut chunk.pcm[..prefix_len];
138        for (frame_offset, frame_samples) in prefix.chunks_exact_mut(channels).enumerate() {
139            let frame = start_frame.saturating_add(u16::try_from(frame_offset).unwrap_or(u16::MAX));
140            let position = f32::from(frame) / total;
141            let gain = 0.5 - 0.5 * (std::f32::consts::PI * position).cos();
142            for sample in frame_samples {
143                *sample *= gain;
144            }
145        }
146        self.applied_frames = self.applied_frames.saturating_add(to_shape);
147    }
148
149    fn for_sample_rate(sample_rate: u32) -> Self {
150        let total_frames =
151            u64::from(sample_rate.max(1)).saturating_mul(Consts::FADE_IN_DURATION_MS) / 1000;
152        let total_frames = u16::try_from(total_frames.clamp(1, 65_535)).unwrap_or(u16::MAX);
153        Self {
154            total_frames,
155            applied_frames: 0,
156        }
157    }
158
159    /// Returns true once the fade has finished — caller can drop the state.
160    fn is_done(self) -> bool {
161        self.applied_frames >= self.total_frames
162    }
163}
164
165impl GaplessTrimmer {
166    /// Build a trimmer that drops a fixed number of leading frames
167    /// looked up from a codec table. The boundary is by definition
168    /// approximate, so a short raised-cosine fade-in is applied to
169    /// the first frames of audible output to avoid clicks.
170    ///
171    /// `sample_rate` is needed to size the fade-in in frames.
172    #[must_use]
173    pub fn codec_priming(leading_frames: u64, sample_rate: u32) -> Self {
174        if leading_frames == 0 {
175            return Self::disabled();
176        }
177        Self {
178            mode: GaplessMode::Fixed {
179                leading_remaining: leading_frames,
180                fade_in: Some(FadeInState::for_sample_rate(sample_rate)),
181            },
182            trailing_frames: 0,
183            tail_buffer: TailBuffer::new(),
184            tail_buffered_frames: 0,
185        }
186    }
187
188    #[must_use]
189    pub fn disabled() -> Self {
190        Self::default()
191    }
192
193    #[must_use]
194    pub fn flush(&mut self) -> GaplessOutput {
195        match &mut self.mode {
196            GaplessMode::Disabled => GaplessOutput::new(),
197            GaplessMode::Fixed { .. } => {
198                trim_tail_frames(
199                    &mut self.tail_buffer,
200                    &mut self.tail_buffered_frames,
201                    self.trailing_frames,
202                );
203                drain_tail(&mut self.tail_buffer, &mut self.tail_buffered_frames)
204            }
205            GaplessMode::Heuristic(state) => flush_heuristic(
206                state,
207                &mut self.tail_buffer,
208                &mut self.tail_buffered_frames,
209                self.trailing_frames,
210            ),
211        }
212    }
213
214    /// Drop seek-sensitive state. Both heuristic search and pending
215    /// fade-in are abandoned: after a seek we land mid-track and
216    /// trying to "trim leading silence" or apply a fade-in there
217    /// would corrupt audible content.
218    pub fn notify_seek(&mut self) {
219        match &mut self.mode {
220            GaplessMode::Disabled => {}
221            GaplessMode::Fixed {
222                leading_remaining,
223                fade_in,
224            } => {
225                *leading_remaining = 0;
226                *fade_in = None;
227            }
228            GaplessMode::Heuristic(state) => {
229                state.leading_buffer.clear();
230                state.leading_buffered_frames = 0;
231                state.leading_enabled = false;
232                state.fade_in = None;
233            }
234        }
235        clear_tail_buffer(&mut self.tail_buffer, &mut self.tail_buffered_frames);
236    }
237
238    #[must_use]
239    pub fn push(&mut self, chunk: PcmChunk) -> GaplessOutput {
240        match &mut self.mode {
241            GaplessMode::Disabled => output_with(chunk),
242            GaplessMode::Fixed {
243                leading_remaining,
244                fade_in,
245            } => {
246                let Some(mut chunk) = trim_leading(chunk, leading_remaining) else {
247                    return SmallVec::new();
248                };
249                apply_fade_in(fade_in, &mut chunk);
250
251                buffer_tail(&mut self.tail_buffer, &mut self.tail_buffered_frames, chunk);
252                release_ready_chunks(
253                    &mut self.tail_buffer,
254                    &mut self.tail_buffered_frames,
255                    self.trailing_frames,
256                )
257            }
258            GaplessMode::Heuristic(state) => push_heuristic(
259                state,
260                &mut self.tail_buffer,
261                &mut self.tail_buffered_frames,
262                self.trailing_frames,
263                chunk,
264            ),
265        }
266    }
267
268    /// Build a silence-scan trimmer. Trim boundaries are inferred by
269    /// scanning samples; a fade-in is applied after the boundary is
270    /// found to mask the level jump.
271    #[must_use]
272    pub fn silence_trim(params: SilenceTrimParams) -> Self {
273        Self {
274            trailing_frames: params.scan_window_frames,
275            mode: GaplessMode::Heuristic(Box::new(HeuristicState::new(params))),
276            tail_buffer: TailBuffer::new(),
277            tail_buffered_frames: 0,
278        }
279    }
280}
281
282impl From<GaplessInfo> for GaplessTrimmer {
283    fn from(info: GaplessInfo) -> Self {
284        let enabled = info.leading_frames > 0 || info.trailing_frames > 0;
285        Self {
286            mode: if enabled {
287                GaplessMode::Fixed {
288                    leading_remaining: info.leading_frames,
289                    fade_in: None,
290                }
291            } else {
292                GaplessMode::Disabled
293            },
294            trailing_frames: info.trailing_frames,
295            tail_buffer: TailBuffer::new(),
296            tail_buffered_frames: 0,
297        }
298    }
299}
300
301fn push_heuristic(
302    state: &mut HeuristicState,
303    tail_buffer: &mut TailBuffer,
304    tail_buffered_frames: &mut u64,
305    trailing_frames: u64,
306    chunk: PcmChunk,
307) -> GaplessOutput {
308    if !state.leading_enabled {
309        return forward_post_leading(
310            state,
311            tail_buffer,
312            tail_buffered_frames,
313            trailing_frames,
314            chunk,
315        );
316    }
317
318    state.leading_buffered_frames = state
319        .leading_buffered_frames
320        .saturating_add(chunk_frames(&chunk));
321    state.leading_buffer.push(chunk);
322
323    if let Some(trim_frames) = find_leading_trim_frames(
324        &state.leading_buffer,
325        &state.params,
326        state.silence_threshold_amp,
327    ) {
328        state.leading_enabled = false;
329        if trim_frames > 0 {
330            arm_fade_in(state);
331        }
332        return drain_leading_buffer(
333            state,
334            tail_buffer,
335            tail_buffered_frames,
336            trailing_frames,
337            trim_frames,
338        );
339    }
340
341    if state.leading_buffered_frames >= state.params.scan_window_frames {
342        state.leading_enabled = false;
343        return drain_leading_buffer(state, tail_buffer, tail_buffered_frames, trailing_frames, 0);
344    }
345
346    GaplessOutput::new()
347}
348
349fn forward_post_leading(
350    state: &mut HeuristicState,
351    tail_buffer: &mut TailBuffer,
352    tail_buffered_frames: &mut u64,
353    trailing_frames: u64,
354    mut chunk: PcmChunk,
355) -> GaplessOutput {
356    apply_fade_in(&mut state.fade_in, &mut chunk);
357    buffer_tail(tail_buffer, tail_buffered_frames, chunk);
358    release_ready_chunks(tail_buffer, tail_buffered_frames, trailing_frames)
359}
360
361fn flush_heuristic(
362    state: &mut HeuristicState,
363    tail_buffer: &mut TailBuffer,
364    tail_buffered_frames: &mut u64,
365    trailing_frames: u64,
366) -> GaplessOutput {
367    let mut ready = GaplessOutput::new();
368
369    if state.leading_enabled {
370        let trim_frames = find_leading_trim_frames(
371            &state.leading_buffer,
372            &state.params,
373            state.silence_threshold_amp,
374        )
375        .unwrap_or(0);
376        state.leading_enabled = false;
377        if trim_frames > 0 {
378            arm_fade_in(state);
379        }
380        ready.extend(drain_leading_buffer(
381            state,
382            tail_buffer,
383            tail_buffered_frames,
384            trailing_frames,
385            trim_frames,
386        ));
387    }
388
389    if state.trim_trailing {
390        let silent_suffix = trailing_silent_frames(tail_buffer, state.silence_threshold_amp);
391        if silent_suffix > 0
392            && silent_suffix < *tail_buffered_frames
393            && silent_suffix >= state.params.min_trim_frames
394        {
395            trim_tail_frames(tail_buffer, tail_buffered_frames, silent_suffix);
396            let sample_rate = tail_buffer
397                .last()
398                .map_or(0, |chunk| chunk.spec().sample_rate);
399            apply_trailing_fade_out(tail_buffer, sample_rate);
400        }
401    }
402
403    ready.extend(drain_tail(tail_buffer, tail_buffered_frames));
404    ready
405}
406
407/// Apply a raised-cosine fade-out to the last `Consts::FADE_OUT_DURATION_MS`
408/// of audio buffered in `tail_buffer`. Modifies samples in place; if
409/// fewer frames are buffered than the fade window, the entire tail is
410/// shaped (gain still goes from 1.0 down to ~0.0 across whatever is
411/// available).
412fn apply_trailing_fade_out(tail_buffer: &mut TailBuffer, sample_rate: u32) {
413    if tail_buffer.is_empty() {
414        return;
415    }
416    let total_frames_u64 =
417        u64::from(sample_rate.max(1)).saturating_mul(Consts::FADE_OUT_DURATION_MS) / 1000;
418    let total_frames = usize_from_u64_saturating(total_frames_u64).max(1);
419    let denom = u32::try_from(total_frames.saturating_sub(1).max(1)).unwrap_or(u32::MAX);
420    let denom = f32::from(u16::try_from(denom).unwrap_or(u16::MAX));
421
422    let mut faded_so_far: usize = 0;
423    for chunk in tail_buffer.iter_mut().rev() {
424        if faded_so_far >= total_frames {
425            break;
426        }
427        let channels = usize::from(chunk.spec().channels.max(1));
428        let chunk_total_frames = usize_from_u64_saturating(chunk_frames(chunk));
429        if chunk_total_frames == 0 {
430            continue;
431        }
432        let in_window = (total_frames - faded_so_far).min(chunk_total_frames);
433        let first_to_shape = chunk_total_frames - in_window;
434
435        let pcm_end = (chunk_total_frames * channels).min(chunk.pcm.len());
436        let pcm_start = (first_to_shape * channels).min(pcm_end);
437        let window = &mut chunk.pcm[pcm_start..pcm_end];
438        for (frame_in_chunk, frame_samples) in window.chunks_exact_mut(channels).enumerate() {
439            let frames_to_end = in_window - 1 - frame_in_chunk + faded_so_far;
440            let frame_in_fade = total_frames - 1 - frames_to_end;
441            let position = f32::from(u16::try_from(frame_in_fade).unwrap_or(u16::MAX)) / denom;
442            let gain = 0.5 + 0.5 * (std::f32::consts::PI * position).cos();
443            for sample in frame_samples {
444                *sample *= gain;
445            }
446        }
447
448        faded_so_far += in_window;
449    }
450}
451
452fn arm_fade_in(state: &mut HeuristicState) {
453    let sample_rate = state
454        .leading_buffer
455        .first()
456        .map_or(0, |chunk| chunk.spec().sample_rate);
457    state.fade_in = Some(FadeInState::for_sample_rate(sample_rate));
458}
459
460fn apply_fade_in(fade: &mut Option<FadeInState>, chunk: &mut PcmChunk) {
461    let Some(state) = fade.as_mut() else {
462        return;
463    };
464    state.apply(chunk);
465    if state.is_done() {
466        *fade = None;
467    }
468}
469
470fn trim_leading(mut chunk: PcmChunk, leading_remaining: &mut u64) -> Option<PcmChunk> {
471    if *leading_remaining > 0 {
472        let chunk_frames = chunk_frames(&chunk);
473        if chunk_frames <= *leading_remaining {
474            *leading_remaining -= chunk_frames;
475            return None;
476        }
477
478        let trim_frames = usize_from_u64_saturating(*leading_remaining);
479        *leading_remaining = 0;
480        trim_chunk_start(&mut chunk, trim_frames);
481    }
482
483    (chunk_frames(&chunk) > 0).then_some(chunk)
484}
485
486fn drain_leading_buffer(
487    state: &mut HeuristicState,
488    tail_buffer: &mut TailBuffer,
489    tail_buffered_frames: &mut u64,
490    trailing_frames: u64,
491    trim_frames: u64,
492) -> GaplessOutput {
493    let mut buffer = std::mem::take(&mut state.leading_buffer);
494    state.leading_buffered_frames = 0;
495
496    let mut remaining_trim = trim_frames;
497    for mut chunk in buffer.drain(..) {
498        if remaining_trim > 0 {
499            let chunk_frames = chunk_frames(&chunk);
500            if chunk_frames <= remaining_trim {
501                remaining_trim -= chunk_frames;
502                continue;
503            }
504
505            let trim = usize_from_u64_saturating(remaining_trim);
506            remaining_trim = 0;
507            trim_chunk_start(&mut chunk, trim);
508        }
509
510        apply_fade_in(&mut state.fade_in, &mut chunk);
511        buffer_tail(tail_buffer, tail_buffered_frames, chunk);
512    }
513
514    release_ready_chunks(tail_buffer, tail_buffered_frames, trailing_frames)
515}
516
517fn buffer_tail(tail_buffer: &mut TailBuffer, tail_buffered_frames: &mut u64, chunk: PcmChunk) {
518    *tail_buffered_frames = (*tail_buffered_frames).saturating_add(chunk_frames(&chunk));
519    tail_buffer.push(chunk);
520}
521
522fn release_ready_chunks(
523    tail_buffer: &mut TailBuffer,
524    tail_buffered_frames: &mut u64,
525    trailing_frames: u64,
526) -> GaplessOutput {
527    let mut ready = GaplessOutput::new();
528    while can_release_front(tail_buffer, *tail_buffered_frames, trailing_frames) {
529        if let Some(chunk) = pop_front_chunk(tail_buffer, tail_buffered_frames) {
530            ready.push(chunk);
531        }
532    }
533    ready
534}
535
536fn can_release_front(
537    tail_buffer: &TailBuffer,
538    tail_buffered_frames: u64,
539    trailing_frames: u64,
540) -> bool {
541    let Some(front) = tail_buffer.first() else {
542        return false;
543    };
544
545    tail_buffered_frames.saturating_sub(chunk_frames(front)) >= trailing_frames
546}
547
548fn trim_tail_frames(
549    tail_buffer: &mut TailBuffer,
550    tail_buffered_frames: &mut u64,
551    trim_frames: u64,
552) {
553    let mut drop_frames = trim_frames.min(*tail_buffered_frames);
554    while drop_frames > 0 {
555        let Some(back) = tail_buffer.last_mut() else {
556            break;
557        };
558
559        let back_frames = chunk_frames(back);
560        if back_frames <= drop_frames {
561            drop_frames -= back_frames;
562            *tail_buffered_frames = (*tail_buffered_frames).saturating_sub(back_frames);
563            tail_buffer.pop();
564            continue;
565        }
566
567        trim_chunk_end(back, drop_frames);
568        *tail_buffered_frames = (*tail_buffered_frames).saturating_sub(drop_frames);
569        drop_frames = 0;
570    }
571}
572
573/// Walk frames from the end of `tail_buffer`, group them into
574/// `Consts::TRAILING_SILENCE_WINDOW_MS` windows, and count frames as silent
575/// while window-mean-|sample| stays below `threshold_amp`. Returns the
576/// largest tail length whose energy is still below the floor.
577///
578/// Per-sample testing (the original implementation) misclassifies
579/// zero-crossings of any periodic signal as silence: AAC quantisation
580/// noise around a ZCR can dip below `1e-3` for a handful of frames at
581/// every cycle. Integrating over a few-millisecond window prevents
582/// the search from chewing into audible content via those gaps.
583///
584/// Mean-|sample| (rather than RMS) is used because it is less peak-
585/// sensitive: for an audible sine its mean-abs is ≈0.6 of peak, while
586/// for a noisy quiet region it tracks the average linear amplitude.
587/// This widens the gap between "real" audio and codec quantisation
588/// noise, making the threshold easier to pick.
589fn trailing_silent_frames(tail_buffer: &TailBuffer, threshold_amp: f32) -> u64 {
590    use num_traits::AsPrimitive;
591
592    if tail_buffer.is_empty() {
593        return 0;
594    }
595
596    let sample_rate = tail_buffer
597        .first()
598        .map_or(48_000, |chunk| chunk.spec().sample_rate)
599        .max(1);
600    let window_frames =
601        (u64::from(sample_rate).saturating_mul(Consts::TRAILING_SILENCE_WINDOW_MS) / 1000).max(1);
602    let threshold = f64::from(threshold_amp);
603
604    let mut silent_frames = 0u64;
605    let mut window_sum_abs = 0.0_f64;
606    let mut window_count: u64 = 0;
607
608    for chunk in tail_buffer.iter().rev() {
609        let chunk_total_frames = chunk_frames(chunk);
610        let samples = chunk.samples();
611        let channels = usize::from(chunk.spec().channels.max(1));
612        let channels_f64: f64 = channels.max(1).as_();
613        for frame in (0..chunk_total_frames).rev() {
614            let frame_start = usize_from_u64_saturating(frame).saturating_mul(channels);
615            let frame_end = frame_start.saturating_add(channels).min(samples.len());
616            if frame_end <= frame_start {
617                continue;
618            }
619            let mut frame_sum_abs = 0.0_f64;
620            for &sample in &samples[frame_start..frame_end] {
621                frame_sum_abs += f64::from(sample.abs());
622            }
623            let frame_mean_abs = frame_sum_abs / channels_f64;
624            window_sum_abs += frame_mean_abs;
625            window_count = window_count.saturating_add(1);
626
627            if window_count >= window_frames {
628                let window_count_f64: f64 = window_count.as_();
629                let mean_abs = window_sum_abs / window_count_f64;
630                if mean_abs <= threshold {
631                    silent_frames = silent_frames.saturating_add(window_count);
632                    window_sum_abs = 0.0;
633                    window_count = 0;
634                } else {
635                    return silent_frames;
636                }
637            }
638        }
639    }
640
641    if window_count > 0 {
642        let window_count_f64: f64 = window_count.as_();
643        let mean_abs = window_sum_abs / window_count_f64;
644        if mean_abs <= threshold {
645            silent_frames = silent_frames.saturating_add(window_count);
646        }
647    }
648
649    silent_frames
650}
651
652/// Find the first non-silent frame in the buffered leading audio.
653///
654/// Returns:
655/// - `Some(n)` — the boundary is at frame `n` (counting silent
656///   frames seen so far) AND `n >= params.min_trim_frames`. Caller
657///   can drop `n` frames safely.
658/// - `None` — either no boundary was found within
659///   `params.scan_window_frames` (the audio looks like one long
660///   fade-in and we choose to leave it alone), or a boundary was
661///   found but with too few preceding silent frames to be considered
662///   trim-worthy.
663fn find_leading_trim_frames(
664    buffer: &[PcmChunk],
665    params: &SilenceTrimParams,
666    threshold_amp: f32,
667) -> Option<u64> {
668    let mut scanned_frames = 0_u64;
669    let mut trim_frames = 0_u64;
670
671    for chunk in buffer {
672        let chunk_frames = chunk_frames(chunk);
673        let samples = chunk.samples();
674        let channels = usize::from(chunk.spec().channels.max(1));
675        for frame in 0..chunk_frames {
676            if scanned_frames >= params.scan_window_frames {
677                return None;
678            }
679
680            let frame_start = usize_from_u64_saturating(frame).saturating_mul(channels);
681            let frame_end = frame_start.saturating_add(channels).min(samples.len());
682            if frame_end <= frame_start {
683                scanned_frames = scanned_frames.saturating_add(1);
684                trim_frames = trim_frames.saturating_add(1);
685                continue;
686            }
687
688            if !frame_is_silent(&samples[frame_start..frame_end], threshold_amp) {
689                return (trim_frames >= params.min_trim_frames).then_some(trim_frames);
690            }
691
692            scanned_frames = scanned_frames.saturating_add(1);
693            trim_frames = trim_frames.saturating_add(1);
694        }
695    }
696
697    None
698}
699
700fn drain_tail(tail_buffer: &mut TailBuffer, tail_buffered_frames: &mut u64) -> GaplessOutput {
701    let mut ready = GaplessOutput::new();
702    while let Some(chunk) = pop_front_chunk(tail_buffer, tail_buffered_frames) {
703        ready.push(chunk);
704    }
705    ready
706}
707
708fn clear_tail_buffer(tail_buffer: &mut TailBuffer, tail_buffered_frames: &mut u64) {
709    tail_buffer.clear();
710    *tail_buffered_frames = 0;
711}
712
713fn pop_front_chunk(
714    tail_buffer: &mut TailBuffer,
715    tail_buffered_frames: &mut u64,
716) -> Option<PcmChunk> {
717    if tail_buffer.is_empty() {
718        return None;
719    }
720
721    let chunk = tail_buffer.remove(0);
722    *tail_buffered_frames = tail_buffered_frames.saturating_sub(chunk_frames(&chunk));
723    Some(chunk)
724}
725
726fn frame_is_silent(samples: &[f32], threshold_amp: f32) -> bool {
727    samples.iter().all(|sample| sample.abs() <= threshold_amp)
728}
729
730fn trim_chunk_start(chunk: &mut PcmChunk, trim_frames: usize) {
731    let spec = chunk.spec();
732    let channels = usize::from(spec.channels.max(1));
733    let trim_samples = trim_frames.saturating_mul(channels);
734    let len = chunk.pcm.len();
735    chunk.pcm.copy_within(trim_samples..len, 0);
736    chunk.pcm.truncate(len.saturating_sub(trim_samples));
737    chunk.meta.frame_offset = chunk.meta.frame_offset.saturating_add(trim_frames as u64);
738    chunk.meta.frames = u32::try_from(chunk.pcm.len() / channels.max(1)).unwrap_or(u32::MAX);
739    chunk.meta.timestamp = chunk
740        .meta
741        .timestamp
742        .saturating_add(duration_for_frames(spec.sample_rate, trim_frames as u64));
743}
744
745fn trim_chunk_end(chunk: &mut PcmChunk, trim_frames: u64) {
746    let channels = usize::from(chunk.spec().channels.max(1));
747    let keep_frames = usize_from_u64_saturating(chunk_frames(chunk).saturating_sub(trim_frames));
748    let keep_samples = keep_frames.saturating_mul(channels);
749    chunk.pcm.truncate(keep_samples);
750    chunk.meta.frames = u32::try_from(keep_frames).unwrap_or(u32::MAX);
751}
752
753fn output_with(chunk: PcmChunk) -> GaplessOutput {
754    let mut ready = GaplessOutput::new();
755    ready.push(chunk);
756    ready
757}
758
759fn chunk_frames(chunk: &PcmChunk) -> u64 {
760    u64::try_from(chunk.frames()).unwrap_or(u64::MAX)
761}
762
763fn usize_from_u64_saturating(value: u64) -> usize {
764    usize::try_from(value).unwrap_or(usize::MAX)
765}
766
767#[cfg(test)]
768#[path = "tests.rs"]
769mod tests;