Skip to main content

audio_resample_bsd/
rubato_impl.rs

1//! RT-safe [`Resampler`] standard implementation backed by the rubato `Fft`
2//! synchronous resampler.
3//!
4//! [`RubatoResampler`] operates at a fixed input ratio (`FixedSync::Input`). To
5//! keep the processing path ([`Resampler::process_into_buffer`]) **zero-copy and
6//! allocation-free**, the caller-provided planar-flat slice is wrapped directly
7//! with a [`rubato::audioadapter_buffers::direct::SequentialSlice`] adapter and
8//! handed to rubato. The "sequential" layout of `SequentialSlice`
9//! (`[ch0_frames..., ch1_frames...]`) matches the planar-flat layout of the
10//! [`Resampler`] contract exactly, so no deinterleave/re-interleave copy is
11//! needed.
12//!
13//! # Real-time safety
14//!
15//! `process_into_buffer` may be called directly from the RT thread:
16//! - **No heap allocation** — rubato `Fft` documents an allocation-free
17//!   processing path.
18//! - **No copy** — `SequentialSlice` wraps the caller's slice by reference.
19//! - **No panic** — all length validation returns a [`ResampleError`].
20//!
21//! [`Resampler::set_rate`] rebuilds the inner `Fft`, so it **allocates** and is
22//! not RT-safe (call only between audio cycles).
23
24use rubato::{
25    audioadapter_buffers::direct::SequentialSlice, Fft, FixedSync,
26    Resampler as RubatoResamplerTrait,
27};
28
29use crate::{ResampleError, Resampler};
30
31/// Upper bound for sample rates. It far exceeds any real audio sample rate
32/// (up to ~384 kHz) and guards the `f64` -> `usize` conversion against loss.
33const MAX_RATE: f64 = 4_000_000.0;
34
35/// RT-safe resampler backed by the rubato `Fft` synchronous resampler.
36///
37/// It operates at a fixed input ratio (`FixedSync::Input`). On the processing
38/// path ([`process_into_buffer`][Resampler::process_into_buffer]) it wraps the
39/// caller-provided planar-flat slice directly with a [`SequentialSlice`] adapter
40/// and hands it to rubato **zero-copy and allocation-free**.
41///
42/// # Panics
43///
44/// No method on this type panics. Every error on the processing path is
45/// returned as a [`ResampleError`].
46#[derive(Debug)]
47pub struct RubatoResampler {
48    inner: Fft<f32>,
49    channels: usize,
50    chunk_size: usize,
51    input_rate: f64,
52    output_rate: f64,
53}
54
55impl RubatoResampler {
56    /// Creates a new resampler. **Call this before entering the RT thread** so
57    /// that all internal state is prepared.
58    ///
59    /// `input_rate`/`output_rate` must be positive finite values (>= 1.0 Hz,
60    /// <= `MAX_RATE`, representable as integer Hz without loss). `channels >= 1`
61    /// and `chunk_size >= 1` are required.
62    ///
63    /// # Errors
64    ///
65    /// - rate <= 0, non-finite, or > `MAX_RATE` ->
66    ///   [`ResampleError::InvalidSampleRate`].
67    /// - `channels == 0` -> [`ResampleError::InvalidChannelCount`].
68    /// - `chunk_size == 0` -> [`ResampleError::InvalidChunkSize`].
69    /// - rubato `Fft` construction failure -> [`ResampleError::ResampleFailed`].
70    pub fn new(
71        input_rate: f64,
72        output_rate: f64,
73        channels: usize,
74        chunk_size: usize,
75    ) -> Result<Self, ResampleError> {
76        if !is_valid_rate(input_rate) {
77            return Err(ResampleError::InvalidSampleRate { rate: input_rate });
78        }
79        if !is_valid_rate(output_rate) {
80            return Err(ResampleError::InvalidSampleRate { rate: output_rate });
81        }
82        if channels == 0 {
83            return Err(ResampleError::InvalidChannelCount(channels));
84        }
85        if chunk_size == 0 {
86            return Err(ResampleError::InvalidChunkSize(chunk_size));
87        }
88        let rate_in = rate_to_usize(input_rate)
89            .ok_or(ResampleError::InvalidSampleRate { rate: input_rate })?;
90        let rate_out = rate_to_usize(output_rate)
91            .ok_or(ResampleError::InvalidSampleRate { rate: output_rate })?;
92        let inner = Fft::<f32>::new(rate_in, rate_out, chunk_size, channels, FixedSync::Input)
93            .map_err(|e| ResampleError::ResampleFailed(e.to_string()))?;
94        Ok(Self {
95            inner,
96            channels,
97            chunk_size,
98            input_rate,
99            output_rate,
100        })
101    }
102
103    /// Returns the fixed number of input frames (per channel).
104    #[must_use]
105    pub fn input_chunk_size(&self) -> usize {
106        self.chunk_size
107    }
108
109    /// Returns the upper bound on output frames (per channel). The RT caller
110    /// must pre-allocate the output buffer to at least this size.
111    #[must_use]
112    pub fn output_frames_max(&self) -> usize {
113        self.inner.output_frames_max()
114    }
115
116    /// Returns the channel count.
117    #[must_use]
118    pub fn channels(&self) -> usize {
119        self.channels
120    }
121
122    /// Returns the input sample rate (Hz).
123    #[must_use]
124    pub fn input_rate(&self) -> f64 {
125        self.input_rate
126    }
127
128    /// Returns the output sample rate (Hz).
129    #[must_use]
130    pub fn output_rate(&self) -> f64 {
131        self.output_rate
132    }
133
134    /// Returns the resampler's group delay, in output frames.
135    ///
136    /// rubato `Fft` introduces a **fixed delay** equal to half the internal FFT
137    /// block size, expressed in output frames. For offline / full-clip
138    /// processing the caller may trim this many leading output frames to align
139    /// output with input. For RT streaming the caller absorbs the delay through
140    /// buffering.
141    ///
142    /// This is a read-only query, so it is safe on the RT path (no allocation,
143    /// no locking).
144    #[must_use]
145    pub fn output_delay(&self) -> usize {
146        self.inner.output_delay()
147    }
148}
149
150impl Resampler for RubatoResampler {
151    fn process_into_buffer(
152        &mut self,
153        input: &[f32],
154        output: &mut [f32],
155    ) -> Result<usize, ResampleError> {
156        // 1. Validate input length (fixed input = channels × chunk_size).
157        let expected = self.channels * self.chunk_size;
158        if input.len() != expected {
159            return Err(ResampleError::InputLengthMismatch {
160                expected,
161                actual: input.len(),
162            });
163        }
164        // 2. Query this call's output frame count + validate the output buffer.
165        let out_n = self.inner.output_frames_next();
166        let needed = self.channels * out_n;
167        if output.len() < needed {
168            return Err(ResampleError::BufferTooSmall {
169                needed,
170                have: output.len(),
171            });
172        }
173        // 3. Wrap the planar-flat slice zero-copy with SequentialSlice and run
174        //    rubato. The SequentialSlice layout ([ch0.., ch1..]) matches this
175        //    contract, so no copy/deinterleave is needed. RT path: no
176        //    allocation, no copy, no panic.
177        let input_adapter = SequentialSlice::new(input, self.channels, self.chunk_size)
178            .map_err(|e| ResampleError::ResampleFailed(e.to_string()))?;
179        let mut output_adapter = SequentialSlice::new_mut(output, self.channels, out_n)
180            .map_err(|e| ResampleError::ResampleFailed(e.to_string()))?;
181        let (_frames_read, frames_written) = self
182            .inner
183            .process_into_buffer(&input_adapter, &mut output_adapter, None)
184            .map_err(|e| ResampleError::ResampleFailed(e.to_string()))?;
185        Ok(frames_written)
186    }
187
188    fn set_rate(&mut self, input_rate: f64, output_rate: f64) {
189        // Non-RT path: rebuilds the inner Fft (allocates). Invalid rates are
190        // silently ignored.
191        if !is_valid_rate(input_rate) || !is_valid_rate(output_rate) {
192            return;
193        }
194        let Some(rate_in) = rate_to_usize(input_rate) else {
195            return;
196        };
197        let Some(rate_out) = rate_to_usize(output_rate) else {
198            return;
199        };
200        let Ok(new_inner) = Fft::<f32>::new(
201            rate_in,
202            rate_out,
203            self.chunk_size,
204            self.channels,
205            FixedSync::Input,
206        ) else {
207            return;
208        };
209        self.inner = new_inner;
210        self.input_rate = input_rate;
211        self.output_rate = output_rate;
212    }
213}
214
215/// Checks that the sample rate is a valid positive finite value (>= 1.0).
216const fn is_valid_rate(rate: f64) -> bool {
217    rate.is_finite() && rate >= 1.0
218}
219
220/// Converts a validated sample rate (`f64`) to `usize`.
221///
222/// Assumes `is_valid_rate` has passed. Returns `None` if the value is outside
223/// `[1.0, MAX_RATE]`. In-range values are stored in `usize` without loss of
224/// magnitude or sign.
225fn rate_to_usize(rate: f64) -> Option<usize> {
226    if rate > MAX_RATE {
227        return None;
228    }
229    // Guard passed: rate in [1.0, 4_000_000.0] -> lossless, positive usize.
230    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
231    let value = rate as usize;
232    Some(value)
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    /// Floating-point approximate equality (test helper).
240    fn approx_eq(a: f64, b: f64) -> bool {
241        (a - b).abs() < 1e-9
242    }
243
244    /// Generates a mono sine wave of `frames` samples (given freq/rate).
245    /// `phase_offset` is a frame offset for producing consecutive chunks.
246    #[allow(
247        clippy::cast_precision_loss,
248        clippy::cast_possible_truncation,
249        clippy::cast_sign_loss
250    )]
251    fn sine_chunk(freq: f64, rate: f64, frames: usize, phase_offset: usize) -> Vec<f32> {
252        let omega = 2.0_f64 * std::f64::consts::TAU * freq / rate;
253        (0..frames)
254            .map(|i| {
255                let t = (i + phase_offset) as f64;
256                (omega * t).sin() as f32
257            })
258            .collect()
259    }
260
261    #[test]
262    fn new_rejects_zero_sample_rate() {
263        let err = RubatoResampler::new(0.0, 48000.0, 1, 256).unwrap_err();
264        assert_eq!(err, ResampleError::InvalidSampleRate { rate: 0.0 });
265        let err = RubatoResampler::new(44100.0, 0.0, 1, 256).unwrap_err();
266        assert_eq!(err, ResampleError::InvalidSampleRate { rate: 0.0 });
267    }
268
269    #[test]
270    fn new_rejects_non_finite_sample_rate() {
271        let err = RubatoResampler::new(f64::NAN, 48000.0, 1, 256).unwrap_err();
272        assert!(err.to_string().contains("invalid sample rate"));
273    }
274
275    #[test]
276    fn new_rejects_zero_channels() {
277        let err = RubatoResampler::new(44100.0, 48000.0, 0, 256).unwrap_err();
278        assert_eq!(err, ResampleError::InvalidChannelCount(0));
279    }
280
281    #[test]
282    fn new_rejects_zero_chunk_size() {
283        let err = RubatoResampler::new(44100.0, 48000.0, 1, 0).unwrap_err();
284        assert_eq!(err, ResampleError::InvalidChunkSize(0));
285    }
286
287    #[test]
288    fn process_into_buffer_rejects_wrong_input_length() {
289        let mut rs = RubatoResampler::new(44100.0, 48000.0, 1, 256).unwrap();
290        let input = vec![0.0_f32; 128]; // expects 256, got 128
291        let mut output = vec![0.0_f32; rs.output_frames_max()];
292        let err = rs.process_into_buffer(&input, &mut output).unwrap_err();
293        assert_eq!(
294            err,
295            ResampleError::InputLengthMismatch {
296                expected: 256,
297                actual: 128,
298            }
299        );
300    }
301
302    #[test]
303    fn process_into_buffer_rejects_small_output() {
304        let mut rs = RubatoResampler::new(44100.0, 48000.0, 1, 256).unwrap();
305        let input = vec![0.0_f32; 256];
306        // Due to its group delay, rubato Fft may report an output frame count of
307        // 0 on early calls (out_n==0 -> needed==0 -> any buffer suffices). Once
308        // it starts producing output (out_n>0), a too-small buffer triggers
309        // BufferTooSmall. Verify it triggers within a bounded number of calls.
310        for _ in 0..256 {
311            let mut tiny = vec![0.0_f32; 1];
312            match rs.process_into_buffer(&input, &mut tiny) {
313                Ok(_) => {} // out_n still 0 (or 1); keep priming.
314                Err(ResampleError::BufferTooSmall { needed, have }) => {
315                    assert_eq!(have, 1);
316                    assert!(needed > 1, "out_n must exceed 1");
317                    return;
318                }
319                Err(other) => panic!("unexpected error: {other:?}"),
320            }
321        }
322        panic!("BufferTooSmall did not trigger within 256 iterations");
323    }
324
325    #[test]
326    fn process_into_buffer_writes_something_for_sine() {
327        // rubato Fft has a group delay (half the internal FFT block), so a
328        // single chunk may yield all-zero output. Feed several chunks to flush
329        // the delay, then assert the output is non-zero.
330        let chunk = 1024;
331        let mut rs = RubatoResampler::new(44100.0, 48000.0, 1, chunk).unwrap();
332        let mut max_abs = 0.0_f32;
333        for blk in 0..16_usize {
334            let input = sine_chunk(1000.0, 44100.0, chunk, blk * chunk);
335            let mut output = vec![0.0_f32; rs.output_frames_max()];
336            let written = rs.process_into_buffer(&input, &mut output).unwrap();
337            for s in &output[..written] {
338                let abs = s.abs();
339                if abs > max_abs {
340                    max_abs = abs;
341                }
342            }
343        }
344        assert!(
345            max_abs > 0.0,
346            "output must not be all zero (max_abs={max_abs})"
347        );
348    }
349
350    #[test]
351    fn set_rate_changes_output_rate() {
352        let mut rs = RubatoResampler::new(44100.0, 48000.0, 1, 256).unwrap();
353        rs.set_rate(48000.0, 96000.0);
354        assert!(approx_eq(rs.input_rate(), 48000.0));
355        assert!(approx_eq(rs.output_rate(), 96000.0));
356        // Confirm processing still works after the change.
357        let input = sine_chunk(1000.0, 48000.0, 256, 0);
358        let mut output = vec![0.0_f32; rs.output_frames_max()];
359        let written = rs.process_into_buffer(&input, &mut output).unwrap();
360        assert!(written > 0);
361    }
362
363    #[test]
364    fn set_rate_ignores_invalid_rate() {
365        let mut rs = RubatoResampler::new(44100.0, 48000.0, 1, 256).unwrap();
366        rs.set_rate(0.0, 96000.0);
367        assert!(approx_eq(rs.input_rate(), 44100.0));
368        assert!(approx_eq(rs.output_rate(), 48000.0));
369        rs.set_rate(-1.0, 96000.0);
370        assert!(approx_eq(rs.input_rate(), 44100.0));
371    }
372
373    #[test]
374    fn stereo_two_channels_processes_both() {
375        let chunk = 512;
376        let ch = 2;
377        let mut rs = RubatoResampler::new(44100.0, 48000.0, ch, chunk).unwrap();
378        // Channel 0 = 1 kHz, channel 1 = 2 kHz (distinct signals).
379        let mut max0 = 0.0_f32;
380        let mut max1 = 0.0_f32;
381        for blk in 0..16_usize {
382            let c0 = sine_chunk(1000.0, 44100.0, chunk, blk * chunk);
383            let c1 = sine_chunk(2000.0, 44100.0, chunk, blk * chunk);
384            let mut input = Vec::with_capacity(ch * chunk);
385            input.extend_from_slice(&c0);
386            input.extend_from_slice(&c1);
387            let mut output = vec![0.0_f32; ch * rs.output_frames_max()];
388            let written = rs.process_into_buffer(&input, &mut output).unwrap();
389            // planar: output = [ch0(0..written), ch1(0..written)]
390            for s in &output[..written] {
391                let abs = s.abs();
392                if abs > max0 {
393                    max0 = abs;
394                }
395            }
396            for s in &output[written..2 * written] {
397                let abs = s.abs();
398                if abs > max1 {
399                    max1 = abs;
400                }
401            }
402        }
403        assert!(max0 > 0.0, "channel 0 processed (max0={max0})");
404        assert!(max1 > 0.0, "channel 1 processed (max1={max1})");
405    }
406
407    #[test]
408    fn accessors_return_construction_values() {
409        let rs = RubatoResampler::new(44100.0, 48000.0, 2, 256).unwrap();
410        assert_eq!(rs.channels(), 2);
411        assert_eq!(rs.input_chunk_size(), 256);
412        assert!(approx_eq(rs.input_rate(), 44100.0));
413        assert!(approx_eq(rs.output_rate(), 48000.0));
414        // output_frames_max is decided by rubato; only positivity is guaranteed.
415        assert!(rs.output_frames_max() > 0);
416    }
417}