Skip to main content

forge_capture/
convert.rs

1//! What the device delivers, turned into the mono model-rate signal.
2
3use rubato::Resampler as _;
4use rubato::audioadapter_buffers::direct::{SequentialSliceOfSlices, SequentialSliceOfVecs};
5
6use crate::Error;
7use crate::SAMPLE_RATE;
8
9/// Mean of one interleaved frame, so every channel contributes. Discarding
10/// a capsule silently halves the signal on hardware where the speaker sits
11/// nearer one of them.
12fn channel_mean(frame: &[f32]) -> f32 {
13    // Channel counts are single digits; the cast cannot lose anything.
14    let scale = f32::from(u16::try_from(frame.len()).unwrap_or(u16::MAX));
15    frame.iter().sum::<f32>() / scale
16}
17
18/// The loudest absolute sample of a raw block, before any downmix: a meter
19/// should show a channel clipping even where the mono mix halves it.
20pub(crate) fn raw_peak(block: &[f32]) -> f32 {
21    block.iter().fold(0.0f32, |loudest, sample| loudest.max(sample.abs()))
22}
23
24/// Resampler input per call, in device frames. 10 ms at 48 kHz; any
25/// block size the device delivers is staged up to it.
26const RESAMPLE_CHUNK: usize = 480;
27
28/// The windowed-sinc filter both resampling paths use: `f_cutoff` is
29/// relative to the lower of the two Nyquists, so speech below it passes and
30/// everything that would alias is in the stopband.
31fn sinc_parameters() -> rubato::SincInterpolationParameters {
32    // The documented starting point.
33    rubato::SincInterpolationParameters {
34        sinc_len: 256,
35        f_cutoff: Some(0.95),
36        oversampling_factor: 256,
37        interpolation: rubato::SincInterpolationType::Cubic,
38        window: rubato::WindowFunction::BlackmanHarris2,
39    }
40}
41
42/// A buffer of mono audio at `from`, converted to `to` with the input
43/// path's own filter. The same rate hands the buffer straight back.
44pub fn resample_mono(pcm: &[f32], from: u32, to: u32) -> Result<Vec<f32>, Error> {
45    const CHUNK: usize = 1024;
46    if from == to {
47        return Ok(pcm.to_vec());
48    }
49    if from == 0 || to == 0 {
50        return Err(Error::Capture {
51            message: "a sample rate of zero cannot be converted".to_owned(),
52        });
53    }
54    if pcm.is_empty() {
55        return Ok(Vec::new());
56    }
57
58    let mut resampler = rubato::Async::<f32>::new_sinc(
59        f64::from(to) / f64::from(from),
60        1.0,
61        &sinc_parameters(),
62        CHUNK,
63        1,
64        rubato::FixedAsync::Input,
65    )
66    .map_err(|error| Error::Capture { message: error.to_string() })?;
67    let from_frames = usize::try_from(from).unwrap_or(usize::MAX);
68    let to_frames = usize::try_from(to).unwrap_or(usize::MAX);
69    let mut converted = Vec::with_capacity(
70        pcm.len().saturating_mul(to_frames).saturating_div(from_frames).saturating_add(CHUNK),
71    );
72    let mut buffer = vec![vec![0.0f32; resampler.output_frames_max()]];
73    let frames_out = buffer[0].len();
74
75    let mut position = 0;
76    while position < pcm.len() {
77        let input = &pcm[position..position + CHUNK.min(pcm.len() - position)];
78        let input_buffer =
79            SequentialSliceOfSlices::new(std::slice::from_ref(&input), 1, input.len())
80                .map_err(|error| Error::Capture { message: error.to_string() })?;
81        let mut output_buffer = SequentialSliceOfVecs::new_mut(&mut buffer, 1, frames_out)
82            .map_err(|error| Error::Capture { message: error.to_string() })?;
83        let indexing = rubato::Indexing::new().partial_len(input.len());
84        let produced = resampler
85            .process_into_buffer(&input_buffer, &mut output_buffer, Some(&indexing))
86            .map_err(|error| Error::Capture { message: error.to_string() })?
87            .1;
88        converted.extend_from_slice(&buffer[0][..produced]);
89        position += input.len();
90    }
91    // The filter's own delay: pad once more so the tail comes out rather
92    // than being cut off by the delay line, then cut the padding response
93    // off at the buffer's own duration.
94    let empty: [&[f32]; 1] = [&[]];
95    let input_buffer = SequentialSliceOfSlices::new(&empty, 1, 0)
96        .map_err(|error| Error::Capture { message: error.to_string() })?;
97    let mut output_buffer = SequentialSliceOfVecs::new_mut(&mut buffer, 1, frames_out)
98        .map_err(|error| Error::Capture { message: error.to_string() })?;
99    let indexing = rubato::Indexing::new().partial_len(0);
100    let flushed = resampler
101        .process_into_buffer(&input_buffer, &mut output_buffer, Some(&indexing))
102        .map_err(|error| Error::Capture { message: error.to_string() })?
103        .1;
104    converted.extend_from_slice(&buffer[0][..flushed]);
105    converted.truncate(pcm.len().saturating_mul(to_frames) / from_frames);
106    Ok(converted)
107}
108
109/// Turns what the device delivers into the mono [`SAMPLE_RATE`] signal
110/// everything downstream reads. Pass-through when the device natively
111/// offers the model rate; otherwise downmix to mono, then resample
112/// with a windowed-sinc filter. Decimating without the filter would
113/// fold everything above the new Nyquist into the speech band as
114/// confident wrong words.
115///
116/// Built on the recorder thread and moved into the stream callback,
117/// which is `FnMut`, so `push` is the whole realtime path: it extends
118/// preallocated buffers and hands the samples on as they are ready.
119pub(crate) struct InputConverter {
120    channels: usize,
121    resampling: Option<Resampling>,
122}
123
124impl InputConverter {
125    /// `channels` is what the stream will deliver, `device_rate` the
126    /// rate it will deliver at; a `None` rate is the pass-through.
127    pub(crate) fn new(channels: usize, device_rate: Option<u32>) -> Result<Self, Error> {
128        let resampling = device_rate.map(Resampling::new).transpose()?;
129        Ok(Self { channels, resampling })
130    }
131
132    /// Fold one callback's worth of device audio in, handing the mono
133    /// model-rate samples and the raw block's own peak to `out`.
134    pub(crate) fn push(&mut self, block: &[f32], out: &mut impl FnMut(&[f32], f32)) {
135        let peak = raw_peak(block);
136        match &mut self.resampling {
137            None => {
138                if self.channels <= 1 {
139                    out(block, peak);
140                } else {
141                    let mono: Vec<f32> =
142                        block.chunks_exact(self.channels).map(channel_mean).collect();
143                    out(&mono, peak);
144                }
145            }
146            Some(resampling) => resampling.push(block, self.channels, peak, out),
147        }
148    }
149}
150
151struct Resampling {
152    resampler: rubato::Async<f32>,
153    staging: Vec<f32>,
154    input: Vec<Vec<f32>>,
155    output: Vec<Vec<f32>>,
156    /// The loudest raw peak of the blocks staged into the next chunk.
157    /// Blocks are gathered here before they are resampled, so the peak
158    /// is folded the same way: a meter must not see only the block that
159    /// happened to complete a chunk.
160    pending_peak: f32,
161}
162
163impl Resampling {
164    fn new(device_rate: u32) -> Result<Self, Error> {
165        let resampler = rubato::Async::<f32>::new_sinc(
166            f64::from(SAMPLE_RATE) / f64::from(device_rate),
167            1.0,
168            &sinc_parameters(),
169            RESAMPLE_CHUNK,
170            1,
171            rubato::FixedAsync::Input,
172        )
173        .map_err(|error| Error::Capture { message: error.to_string() })?;
174        let output = vec![vec![0.0f32; resampler.output_frames_max()]];
175        // Sized for the largest block a callback could bring, so the
176        // extends below stay inside the allocation. Overflow past it
177        // would grow once and then hold - the same first-malloc trade
178        // the recorder makes.
179        let staging = Vec::with_capacity(RESAMPLE_CHUNK * 4);
180        let input = vec![vec![0.0; RESAMPLE_CHUNK]];
181        Ok(Self { resampler, staging, input, output, pending_peak: 0.0 })
182    }
183
184    fn push(
185        &mut self,
186        block: &[f32],
187        channels: usize,
188        peak: f32,
189        out: &mut impl FnMut(&[f32], f32),
190    ) {
191        self.pending_peak = self.pending_peak.max(peak);
192        if channels <= 1 {
193            self.staging.extend_from_slice(block);
194        } else {
195            self.staging.extend(block.chunks_exact(channels).map(channel_mean));
196        }
197        while self.staging.len() >= RESAMPLE_CHUNK {
198            self.input[0].copy_from_slice(&self.staging[..RESAMPLE_CHUNK]);
199            // The only failure is a buffer-size mismatch, and both
200            // buffers are sized to the resampler at construction; a
201            // warn beats panicking the audio thread.
202            let frames_out = self.output[0].len();
203            let input = SequentialSliceOfVecs::new(&self.input, 1, RESAMPLE_CHUNK);
204            let output = SequentialSliceOfVecs::new_mut(&mut self.output, 1, frames_out);
205            let processed = match (input, output) {
206                (Ok(input), Ok(mut output)) => self
207                    .resampler
208                    .process_into_buffer(&input, &mut output, None)
209                    .map(|(_, produced)| produced)
210                    .map_err(|error| error.to_string()),
211                (Err(error), _) | (_, Err(error)) => Err(error.to_string()),
212            };
213            let produced = match processed {
214                Ok(produced) => produced,
215                Err(error) => {
216                    // Defensive: a persistent rejection must not
217                    // grow staging without bound.
218                    self.staging.clear();
219                    tracing::warn!(
220                        event_name = "resampler_block_rejected",
221                        %error,
222                        "resampler rejected a block; dropping it"
223                    );
224                    return;
225                }
226            };
227            self.staging.drain(..RESAMPLE_CHUNK);
228            out(&self.output[0][..produced], std::mem::take(&mut self.pending_peak));
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    /// The model rate as a float. The cases here work in whole seconds at
238    /// this rate, so a sample count and its seconds are the same number.
239    const RATE: f32 = 16_000.0;
240
241    /// One second at the model rate.
242    const SECOND: usize = 16_000;
243
244    /// Feed a stereo take through the converter in CoreAudio-sized
245    /// blocks, interleaved the way the F32 callback delivers.
246    fn feed_stereo(converter: &mut InputConverter, left: &[f32], right: &[f32]) -> Vec<f32> {
247        let mut mono = Vec::new();
248        for (l, r) in left.chunks(512).zip(right.chunks(512)) {
249            let block: Vec<f32> = l.iter().zip(r.iter()).flat_map(|(l, r)| [*l, *r]).collect();
250            converter.push(&block, &mut |samples, _| mono.extend_from_slice(samples));
251        }
252        mono
253    }
254
255    /// Magnitude of `freq` over `pcm`, taken as one second at the model
256    /// rate: whole, so the hertz bins land exactly on the DFT grid and a
257    /// tone's level reads without windowing leakage.
258    fn magnitude_at(pcm: &[f32], freq: f32) -> f32 {
259        let omega = std::f32::consts::TAU * freq / RATE;
260        let coeff = 2.0 * omega.cos();
261        let (mut s1, mut s2) = (0.0f32, 0.0f32);
262        for &sample in pcm {
263            let s = sample + coeff * s1 - s2;
264            s2 = s1;
265            s1 = s;
266        }
267        (s1 * s1 + s2 * s2 - coeff * s1 * s2).sqrt() / (RATE / 2.0)
268    }
269
270    /// The tail of a converted take, as its last whole second: past the
271    /// head, so the filter's warmup is not in the window.
272    fn interior_second(pcm: &[f32]) -> Vec<f32> {
273        pcm[pcm.len() - SECOND..].to_vec()
274    }
275
276    /// `gain * sin(freq)` at `rate`, over `seconds`.
277    fn sine(freq: f32, gain: f32, rate: u32, seconds: u32) -> Vec<f32> {
278        let rate_f = f32::from(u16::try_from(rate).unwrap_or(u16::MAX));
279        let step = std::f32::consts::TAU * freq / rate_f;
280        let mut phase = 0.0f32;
281        (0..seconds * rate)
282            .map(|_| {
283                let sample = gain * phase.sin();
284                phase += step;
285                sample
286            })
287            .collect()
288    }
289
290    #[test]
291    fn a_48k_stereo_take_converts_to_mono_16k_keeping_its_tone_and_dropping_what_would_alias() {
292        // In band, at different levels per channel: only the average
293        // (0.2) survives the downmix, so its level pins that the
294        // channels were averaged rather than one carried through.
295        let mut converter = InputConverter::new(2, Some(48_000)).expect("the resampler must build");
296        let pcm =
297            feed_stereo(&mut converter, &sine(440.0, 0.3, 48_000, 2), &sine(440.0, 0.1, 48_000, 2));
298        assert!(
299            (31_600..=32_400).contains(&pcm.len()),
300            "two seconds at 48 kHz must land as two seconds at 16 kHz, got {} samples",
301            pcm.len()
302        );
303        let pcm = interior_second(&pcm);
304        let kept = magnitude_at(&pcm, 440.0);
305        assert!(
306            (kept - 0.2).abs() < 0.02,
307            "the tone must come through at the downmixed level 0.2, got {kept}"
308        );
309        assert!(
310            magnitude_at(&pcm, 540.0) < 0.02,
311            "the energy must stay at the tone's own frequency: a wrong rate would move it"
312        );
313
314        // Above the new Nyquist. Naive every-third-sample decimation
315        // delivers a 12 kHz tone as a full-size 4 kHz tone - speech-band
316        // content that was never spoken - which is the failure a real
317        // filter exists to prevent.
318        let mut converter = InputConverter::new(2, Some(48_000)).expect("the resampler must build");
319        let pcm = feed_stereo(
320            &mut converter,
321            &sine(12_000.0, 0.25, 48_000, 2),
322            &sine(12_000.0, 0.25, 48_000, 2),
323        );
324        let aliased = magnitude_at(&interior_second(&pcm), 4_000.0);
325        assert!(
326            aliased < 0.005,
327            "a 12 kHz tone must be filtered away, not aliased into the speech band at 4 kHz, got {aliased}"
328        );
329    }
330
331    #[test]
332    fn a_native_16k_path_forwards_its_samples_through_todays_downmix() {
333        let mut converter =
334            InputConverter::new(2, None).expect("pass-through has nothing to build");
335        let mut out = Vec::new();
336        converter.push(&[1.0, 0.0, 0.5, -0.5], &mut |samples, _| out.extend_from_slice(samples));
337        assert_eq!(
338            out,
339            vec![0.5, 0.0],
340            "a stereo block on the native path must flow through today's exact downmix"
341        );
342    }
343
344    #[test]
345    fn one_callback_larger_than_a_resampler_chunk_is_consumed_in_full() {
346        let mut converter = InputConverter::new(2, Some(48_000)).expect("the resampler must build");
347        // 960 frames is exactly two chunks: the one push must run the
348        // resampler for both, not stop after the first.
349        let mut produced = 0;
350        converter.push(&vec![0.0; 2 * 480 * 2], &mut |samples, _| produced += samples.len());
351        assert!(
352            (200..=320).contains(&produced),
353            "both chunks of a 960-frame callback must be resampled (116 then 160 frames), got {produced}"
354        );
355    }
356
357    /// A buffer converted in one call keeps its tone at the new rate's own
358    /// duration, and a tone above the new Nyquist is filtered rather than
359    /// folded into the speech band.
360    #[test]
361    fn a_one_shot_resample_keeps_a_tone_and_filters_what_would_alias() {
362        let pcm = sine(440.0, 0.3, 24_000, 2);
363        let out = resample_mono(&pcm, 24_000, 16_000).expect("the resampler must build");
364        assert_eq!(out.len(), 32_000, "two seconds at 24 kHz is two seconds at 16 kHz");
365        let kept = magnitude_at(&interior_second(&out), 440.0);
366        assert!((kept - 0.3).abs() < 0.02, "the tone must come through at its level, got {kept}");
367
368        let pcm = sine(10_000.0, 0.25, 24_000, 2);
369        let out = resample_mono(&pcm, 24_000, 16_000).expect("the resampler must build");
370        let aliased = magnitude_at(&interior_second(&out), 6_000.0);
371        assert!(
372            aliased < 0.005,
373            "a 10 kHz tone must be filtered away, not aliased into the speech band at 6 kHz, got {aliased}"
374        );
375    }
376
377    /// The same rate is the same samples: a caller may hand a buffer in
378    /// whatever rate it has without special-casing the model's own.
379    #[test]
380    fn a_one_shot_resample_at_the_same_rate_returns_the_buffer() {
381        let pcm = vec![0.1, -0.2, 0.3];
382        assert_eq!(resample_mono(&pcm, 16_000, 16_000).unwrap(), pcm);
383        assert!(resample_mono(&[], 24_000, 16_000).unwrap().is_empty());
384    }
385
386    #[test]
387    fn the_raw_peak_reaches_the_caller_from_a_clipping_channel() {
388        let mut converter = InputConverter::new(2, Some(48_000)).expect("the resampler must build");
389        // One channel at full scale, the other silent, across a whole
390        // chunk: the downmixed mean is -6 dBFS, and a meter must still
391        // report the channel that is actually clipping.
392        let mut block = vec![0.0f32; 2 * RESAMPLE_CHUNK];
393        for frame in block.as_chunks_mut::<2>().0 {
394            frame[0] = 1.0;
395        }
396        let mut peak = 0.0f32;
397        converter.push(&block, &mut |_, block_peak| peak = block_peak);
398        assert!(
399            (peak - 1.0).abs() < f32::EPSILON,
400            "the peak must be the raw channel's, not the mix's, got {peak}"
401        );
402    }
403}