Skip to main content

subx_cli/services/vad/
resample.rs

1//! Audio resampling utilities using the rubato crate.
2//!
3//! Provides i16 ↔ f32 conversion and synchronous resampling of mono PCM
4//! audio via [`rubato::Fft`] with the [`rubato::FixedSync::Input`]
5//! configuration. The rubato 2.0 API uses
6//! [`audioadapter`]-based buffers, so this module wraps the input slice
7//! with [`SequentialSlice`] and the output buffer with
8//! [`SequentialSliceOfVecs`] before delegating to
9//! [`Resampler::process_all_into_buffer`].
10
11use audioadapter_buffers::direct::{SequentialSlice, SequentialSliceOfVecs};
12use log::{debug, trace};
13use rubato::{Fft, FixedSync, Resampler};
14use std::error::Error;
15use std::time::Instant;
16
17/// Number of input frames per processing chunk handed to the resampler.
18///
19/// Tuned to balance throughput and intermediate buffer footprint; the
20/// rubato resampler handles arbitrary input lengths through
21/// `process_all_into_buffer`, so this value only influences the FFT
22/// sub-chunking — the caller-visible behavior is unaffected.
23const CHUNK_SIZE: usize = 8192;
24
25/// Resample i16 mono audio to the target sample rate (returns `Vec<i16>`).
26pub fn resample_to_target_rate(
27    input_samples: &[i16],
28    input_sample_rate: u32,
29    output_sample_rate: u32,
30) -> Result<Vec<i16>, Box<dyn Error>> {
31    let total_start = Instant::now();
32    debug!(
33        "[resample] input_samples: {} input_sample_rate: {} output_sample_rate: {}",
34        input_samples.len(),
35        input_sample_rate,
36        output_sample_rate
37    );
38
39    if input_sample_rate == output_sample_rate {
40        debug!("[resample] sample rate unchanged, fast path");
41        return Ok(input_samples.to_vec());
42    }
43
44    if input_samples.is_empty() {
45        debug!("[resample] empty input, returning empty output");
46        return Ok(Vec::new());
47    }
48
49    let t_convert = Instant::now();
50    let input_f32: Vec<f32> = input_samples.iter().map(|&s| s as f32 / 32768.0).collect();
51    debug!(
52        "[resample] i16->f32 conversion done in {:.3?}",
53        t_convert.elapsed()
54    );
55
56    let input_len = input_f32.len();
57    let channels = 1;
58
59    // Construct a fresh resampler per call. The rubato FFT plan caches
60    // its trigonometric tables internally, so re-creation cost is
61    // dominated by a few small allocations rather than transform setup.
62    let t_init = Instant::now();
63    let mut resampler = Fft::<f32>::new(
64        input_sample_rate as usize,
65        output_sample_rate as usize,
66        CHUNK_SIZE,
67        1,
68        channels,
69        FixedSync::Input,
70    )?;
71    debug!("[resample] Fft ready in {:.3?}", t_init.elapsed());
72
73    // Pre-size the output buffer using rubato's own length oracle so
74    // `process_all_into_buffer` never has to truncate.
75    let needed_out = resampler.process_all_needed_output_len(input_len);
76    let mut output_f32: Vec<Vec<f32>> = vec![vec![0.0f32; needed_out]];
77
78    let in_adapter = SequentialSlice::new(&input_f32, channels, input_len)
79        .map_err(|e| format!("input adapter construction failed: {e}"))?;
80    let mut out_adapter = SequentialSliceOfVecs::new_mut(&mut output_f32, channels, needed_out)
81        .map_err(|e| format!("output adapter construction failed: {e}"))?;
82
83    let t_resample = Instant::now();
84    let (in_used, out_produced) =
85        resampler.process_all_into_buffer(&in_adapter, &mut out_adapter, input_len, None)?;
86    trace!(
87        "[resample] process_all_into_buffer consumed {} frames, produced {} frames",
88        in_used, out_produced
89    );
90    debug!("[resample] resampling done in {:.3?}", t_resample.elapsed());
91
92    let t_i16 = Instant::now();
93    let resample_ratio = output_sample_rate as f64 / input_sample_rate as f64;
94    let expected_len = ((input_samples.len() as f64) * resample_ratio).round() as usize;
95    let mut output_i16: Vec<i16> = output_f32[0]
96        .iter()
97        .take(out_produced)
98        .map(|&s| (s.clamp(-1.0, 1.0) * 32767.0) as i16)
99        .collect();
100    if output_i16.len() > expected_len {
101        output_i16.truncate(expected_len);
102    }
103    debug!(
104        "[resample] f32->i16 conversion done in {:.3?}",
105        t_i16.elapsed()
106    );
107    debug!(
108        "[resample] total elapsed: {:.3?} (input {} -> output {} samples)",
109        total_start.elapsed(),
110        input_samples.len(),
111        output_i16.len()
112    );
113    Ok(output_i16)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    /// Identity case: equal sample rates SHALL bypass the resampler.
121    #[test]
122    fn identical_sample_rates_returns_input_unchanged() {
123        let input: Vec<i16> = (0..16_000).map(|i| (i % 1024) as i16).collect();
124        let out = resample_to_target_rate(&input, 16_000, 16_000).expect("resample succeeds");
125        assert_eq!(out, input);
126    }
127
128    /// Empty input SHALL produce an empty output without panicking.
129    #[test]
130    fn empty_input_returns_empty() {
131        let out = resample_to_target_rate(&[], 44_100, 16_000).expect("resample succeeds");
132        assert!(out.is_empty());
133    }
134
135    /// Downsampling 1 second of audio from 44.1 kHz to 16 kHz SHALL
136    /// produce ~16 000 frames (within a tight tolerance because rubato
137    /// `process_all_into_buffer` already trims the resampler delay).
138    #[test]
139    fn downsample_44100_to_16000_length_matches_ratio() {
140        let input: Vec<i16> = (0..44_100).map(|i| (i % 1024) as i16).collect();
141        let out = resample_to_target_rate(&input, 44_100, 16_000).expect("resample succeeds");
142        let ratio = 16_000.0 / 44_100.0;
143        let expected = (input.len() as f64 * ratio).round() as usize;
144        assert!(
145            out.len().abs_diff(expected) <= 8,
146            "expected ~{expected} frames, got {}",
147            out.len()
148        );
149    }
150
151    /// Upsampling SHALL also respect the ratio.
152    #[test]
153    fn upsample_16000_to_48000_length_matches_ratio() {
154        let input: Vec<i16> = (0..16_000).map(|i| (i % 1024) as i16).collect();
155        let out = resample_to_target_rate(&input, 16_000, 48_000).expect("resample succeeds");
156        let ratio = 48_000.0 / 16_000.0;
157        let expected = (input.len() as f64 * ratio).round() as usize;
158        assert!(
159            out.len().abs_diff(expected) <= 8,
160            "expected ~{expected} frames, got {}",
161            out.len()
162        );
163    }
164}