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