sonora-common-audio 0.1.0

DSP primitives for WebRTC Audio Processing (resamplers, filters, channel buffers)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! High-quality sinc-based audio resampler.
//!
//! Port of WebRTC's `SincResampler` — a Blackman-windowed sinc interpolation
//! resampler with runtime SIMD dispatch for the inner convolution loop.
//!
//! # Architecture
//!
//! The resampler uses a **pull** model: you call [`SincResampler::resample`]
//! requesting N output frames, and it pulls input via the
//! [`SincResamplerCallback`] trait.
//!
//! For a **push** model (provide input, get output), see
//! [`PushSincResampler`](super::push_sinc_resampler).

use std::f64::consts::PI;

use derive_more::Debug;
use sonora_simd::SimdBackend;

/// Callback for providing input data to the resampler.
pub trait SincResamplerCallback {
    /// Write `frames` samples into `destination`.
    /// Zero-pad if not enough data is available.
    fn run(&mut self, frames: usize, destination: &mut [f32]);
}

/// High-quality single-channel sample-rate converter.
#[derive(Debug)]
pub struct SincResampler {
    io_sample_rate_ratio: f64,
    #[debug(skip)]
    virtual_source_idx: f64,
    #[debug(skip)]
    buffer_primed: bool,

    request_frames: usize,
    block_size: usize,

    /// Kernel storage: `KERNEL_OFFSET_COUNT + 1` kernels of `KERNEL_SIZE` each.
    #[debug(skip)]
    kernel_storage: Vec<f32>,
    #[debug(skip)]
    kernel_pre_sinc_storage: Vec<f32>,
    #[debug(skip)]
    kernel_window_storage: Vec<f32>,

    /// Input buffer with region pointers stored as offsets.
    #[debug(skip)]
    input_buffer: Vec<f32>,
    /// Offset into `input_buffer` where new input is written.
    #[debug(skip)]
    r0: usize,
    /// Start of the input region (always 0).
    #[debug(skip)]
    r1: usize,
    /// Half-kernel offset (`KERNEL_SIZE / 2`).
    #[debug(skip)]
    r2: usize,
    #[debug(skip)]
    r3: usize,
    #[debug(skip)]
    r4: usize,

    simd: SimdBackend,
}

// ── Constants ───────────────────────────────────────────────────────

/// Kernel size. Must be a multiple of 32.
pub const KERNEL_SIZE: usize = 32;

/// Default request size in frames.
pub const DEFAULT_REQUEST_SIZE: usize = 512;

/// Number of sub-sample kernel offsets for interpolation.
pub const KERNEL_OFFSET_COUNT: usize = 32;

/// Total kernel storage size.
pub const KERNEL_STORAGE_SIZE: usize = KERNEL_SIZE * (KERNEL_OFFSET_COUNT + 1);

const HALF_KERNEL: usize = KERNEL_SIZE / 2;

// ── Implementation ──────────────────────────────────────────────────

fn sinc_scale_factor(io_ratio: f64) -> f64 {
    let factor = if io_ratio > 1.0 { 1.0 / io_ratio } else { 1.0 };
    // Adjust slightly downward to avoid aliasing at the transition band.
    factor * 0.9
}

impl SincResampler {
    /// Create a new resampler.
    ///
    /// - `io_sample_rate_ratio`: input / output sample rate ratio
    /// - `request_frames`: number of input frames requested per callback (must be > `KERNEL_SIZE`)
    pub fn new(io_sample_rate_ratio: f64, request_frames: usize) -> Self {
        assert!(
            io_sample_rate_ratio > 0.0 && io_sample_rate_ratio.is_finite(),
            "io_sample_rate_ratio must be positive and finite, got {io_sample_rate_ratio}"
        );
        assert!(request_frames > 0, "request_frames must be > 0");
        let simd = sonora_simd::detect_backend();

        let mut resampler = Self {
            io_sample_rate_ratio,
            virtual_source_idx: 0.0,
            buffer_primed: false,
            request_frames,
            block_size: 0,
            kernel_storage: vec![0.0; KERNEL_STORAGE_SIZE],
            kernel_pre_sinc_storage: vec![0.0; KERNEL_STORAGE_SIZE],
            kernel_window_storage: vec![0.0; KERNEL_STORAGE_SIZE],
            input_buffer: vec![0.0; request_frames + KERNEL_SIZE],
            r0: 0,
            r1: 0,
            r2: HALF_KERNEL,
            r3: 0,
            r4: 0,
            simd,
        };

        resampler.update_regions(false);
        assert!(resampler.block_size > KERNEL_SIZE);
        resampler.initialize_kernel();
        resampler
    }

    /// Number of input frames requested per callback.
    pub fn request_frames(&self) -> usize {
        self.request_frames
    }

    /// Maximum output frames that results in a single callback.
    pub fn chunk_size(&self) -> usize {
        (self.block_size as f64 / self.io_sample_rate_ratio) as usize
    }

    /// Flush all state and reset.
    pub fn flush(&mut self) {
        self.virtual_source_idx = 0.0;
        self.buffer_primed = false;
        self.input_buffer.fill(0.0);
        self.update_regions(false);
    }

    /// Update the sample rate ratio (reconstructs kernels).
    ///
    /// # Panics
    ///
    /// Panics if the ratio is not positive and finite.
    pub fn set_ratio(&mut self, io_sample_rate_ratio: f64) {
        assert!(
            io_sample_rate_ratio > 0.0 && io_sample_rate_ratio.is_finite(),
            "io_sample_rate_ratio must be positive and finite, got {io_sample_rate_ratio}"
        );
        if (self.io_sample_rate_ratio - io_sample_rate_ratio).abs() < f64::EPSILON {
            return;
        }
        self.io_sample_rate_ratio = io_sample_rate_ratio;

        // Re-use pre-computed sinc and window values.
        let scale = sinc_scale_factor(io_sample_rate_ratio);
        for offset_idx in 0..=KERNEL_OFFSET_COUNT {
            for i in 0..KERNEL_SIZE {
                let idx = i + offset_idx * KERNEL_SIZE;
                let window = self.kernel_window_storage[idx];
                let pre_sinc = self.kernel_pre_sinc_storage[idx];
                self.kernel_storage[idx] = (window as f64
                    * if pre_sinc == 0.0 {
                        scale
                    } else {
                        (scale * pre_sinc as f64).sin() / pre_sinc as f64
                    }) as f32;
            }
        }
    }

    /// Resample `frames` output samples, pulling input via `callback`.
    pub fn resample(
        &mut self,
        frames: usize,
        destination: &mut [f32],
        callback: &mut dyn SincResamplerCallback,
    ) {
        assert!(
            destination.len() >= frames,
            "destination too short: {} < {frames}",
            destination.len()
        );
        let mut remaining = frames;
        let mut dest_idx = 0;

        // Prime the buffer on first use.
        if !self.buffer_primed && remaining > 0 {
            let r0 = self.r0;
            callback.run(
                self.request_frames,
                &mut self.input_buffer[r0..r0 + self.request_frames],
            );
            self.buffer_primed = true;
        }

        let current_io_ratio = self.io_sample_rate_ratio;

        while remaining > 0 {
            let iterations = ((self.block_size as f64 - self.virtual_source_idx) / current_io_ratio)
                .ceil() as isize;

            for _ in (1..=iterations).rev() {
                debug_assert!((self.virtual_source_idx as usize) < self.block_size);

                let source_idx = self.virtual_source_idx as usize;
                let subsample_remainder = self.virtual_source_idx - source_idx as f64;

                let virtual_offset_idx = subsample_remainder * KERNEL_OFFSET_COUNT as f64;
                let offset_idx = virtual_offset_idx as usize;

                let k1_start = offset_idx * KERNEL_SIZE;
                let k2_start = k1_start + KERNEL_SIZE;

                let input_start = self.r1 + source_idx;

                let kernel_interpolation_factor = virtual_offset_idx - offset_idx as f64;

                destination[dest_idx] =
                    self.convolve(input_start, k1_start, k2_start, kernel_interpolation_factor);
                dest_idx += 1;

                self.virtual_source_idx += current_io_ratio;

                remaining -= 1;
                if remaining == 0 {
                    return;
                }
            }

            // Wrap back around.
            self.virtual_source_idx -= self.block_size as f64;

            // Copy r3_,r4_ to r1_,r2_ (wrap tail to head).
            self.input_buffer
                .copy_within(self.r3..self.r3 + KERNEL_SIZE, self.r1);

            // Reinitialize regions if necessary.
            if self.r0 == self.r2 {
                self.update_regions(true);
            }

            // Refresh with more input.
            let r0 = self.r0;
            callback.run(
                self.request_frames,
                &mut self.input_buffer[r0..r0 + self.request_frames],
            );
        }
    }

    fn update_regions(&mut self, second_load: bool) {
        self.r0 = if second_load {
            KERNEL_SIZE
        } else {
            HALF_KERNEL
        };
        self.r3 = self.r0 + self.request_frames - KERNEL_SIZE;
        self.r4 = self.r0 + self.request_frames - HALF_KERNEL;
        self.block_size = self.r4 - self.r2;
    }

    fn initialize_kernel(&mut self) {
        // Blackman window parameters.
        let k_alpha = 0.16_f64;
        let k_a0 = 0.5 * (1.0 - k_alpha);
        let k_a1 = 0.5_f64;
        let k_a2 = 0.5 * k_alpha;

        let scale = sinc_scale_factor(self.io_sample_rate_ratio);

        for offset_idx in 0..=KERNEL_OFFSET_COUNT {
            // C++ computes subsample_offset as float:
            //   const float subsample_offset =
            //       static_cast<float>(offset_idx) / kKernelOffsetCount;
            let subsample_offset = offset_idx as f32 / KERNEL_OFFSET_COUNT as f32;

            for i in 0..KERNEL_SIZE {
                let idx = i + offset_idx * KERNEL_SIZE;

                // C++: pi * (int(i) - int(kKernelSize/2) - subsample_offset)
                // subsample_offset is float, subtraction in float, then
                // promoted to double for the pi multiply.
                let pre_sinc = PI * (i as f32 - HALF_KERNEL as f32 - subsample_offset) as f64;
                self.kernel_pre_sinc_storage[idx] = pre_sinc as f32;

                // C++: (i - subsample_offset) / kKernelSize — all float
                let x = (i as f32 - subsample_offset) / KERNEL_SIZE as f32;

                // C++ window uses double constants, trig in double, cast to float
                let window =
                    k_a0 - k_a1 * (2.0 * PI * x as f64).cos() + k_a2 * (4.0 * PI * x as f64).cos();
                self.kernel_window_storage[idx] = window as f32;

                // Windowed sinc
                let sinc_val = if pre_sinc == 0.0 {
                    scale
                } else {
                    (scale * pre_sinc).sin() / pre_sinc
                };
                self.kernel_storage[idx] = (window * sinc_val) as f32;
            }
        }
    }

    /// Inner convolution: dot-product of input with two interpolated kernels.
    ///
    /// Uses `convolve_sinc` which performs interpolation on SIMD vectors
    /// *before* horizontal reduction, matching C++ `SincResampler::Convolve_*`
    /// rounding behavior exactly.
    #[inline]
    fn convolve(
        &self,
        input_offset: usize,
        k1_offset: usize,
        k2_offset: usize,
        kernel_interpolation_factor: f64,
    ) -> f32 {
        let input = &self.input_buffer[input_offset..input_offset + KERNEL_SIZE];
        let k1 = &self.kernel_storage[k1_offset..k1_offset + KERNEL_SIZE];
        let k2 = &self.kernel_storage[k2_offset..k2_offset + KERNEL_SIZE];

        self.simd
            .convolve_sinc(input, k1, k2, kernel_interpolation_factor)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Callback that provides a constant value.
    struct ConstCallback(f32);
    impl SincResamplerCallback for ConstCallback {
        fn run(&mut self, frames: usize, destination: &mut [f32]) {
            for d in destination.iter_mut().take(frames) {
                *d = self.0;
            }
        }
    }

    /// Callback that provides a sine wave.
    struct SineCallback {
        phase: f64,
        phase_increment: f64,
    }
    impl SineCallback {
        fn new(freq_hz: f64, sample_rate: f64) -> Self {
            Self {
                phase: 0.0,
                phase_increment: 2.0 * PI * freq_hz / sample_rate,
            }
        }
    }
    impl SincResamplerCallback for SineCallback {
        fn run(&mut self, frames: usize, destination: &mut [f32]) {
            for d in destination.iter_mut().take(frames) {
                *d = self.phase.sin() as f32;
                self.phase += self.phase_increment;
            }
        }
    }

    #[test]
    fn construction() {
        let r = SincResampler::new(1.0, DEFAULT_REQUEST_SIZE);
        assert_eq!(r.request_frames(), DEFAULT_REQUEST_SIZE);
    }

    #[test]
    fn identity_passthrough() {
        // 1:1 ratio should pass through (approximately)
        let mut r = SincResampler::new(1.0, DEFAULT_REQUEST_SIZE);
        let mut cb = ConstCallback(1.0);
        let chunk = r.chunk_size();
        let mut output = vec![0.0_f32; chunk * 2];
        r.resample(chunk, &mut output, &mut cb);

        // After priming, the constant value should appear.
        // Allow some ramp-up time, then check the tail is close to 1.0.
        let tail = &output[chunk / 2..chunk];
        for &v in tail {
            assert!((v - 1.0).abs() < 0.01, "expected ~1.0, got {v}");
        }
    }

    #[test]
    fn downsample_2x() {
        // 48kHz -> 24kHz (ratio = 2.0)
        let source_frames = 480; // 10ms at 48kHz
        let dest_frames = 240; // 10ms at 24kHz
        let ratio = source_frames as f64 / dest_frames as f64;
        let mut r = SincResampler::new(ratio, source_frames);
        let mut cb = SineCallback::new(1000.0, 48000.0);

        let mut output = vec![0.0_f32; dest_frames];
        // Prime
        r.resample(r.chunk_size(), &mut vec![0.0; r.chunk_size()], &mut cb);
        // Resample
        r.resample(dest_frames, &mut output, &mut cb);

        // Output should contain non-trivial values
        let energy: f32 = output.iter().map(|v| v * v).sum();
        assert!(
            energy > 0.1,
            "output should have signal energy, got {energy}"
        );
    }

    #[test]
    fn upsample_2x() {
        // 24kHz -> 48kHz (ratio = 0.5)
        let source_frames = 240;
        let dest_frames = 480;
        let ratio = source_frames as f64 / dest_frames as f64;
        let mut r = SincResampler::new(ratio, source_frames);
        let mut cb = SineCallback::new(1000.0, 24000.0);

        let mut output = vec![0.0_f32; dest_frames];
        r.resample(r.chunk_size(), &mut vec![0.0; r.chunk_size()], &mut cb);
        r.resample(dest_frames, &mut output, &mut cb);

        let energy: f32 = output.iter().map(|v| v * v).sum();
        assert!(
            energy > 0.1,
            "output should have signal energy, got {energy}"
        );
    }

    #[test]
    fn flush_resets_state() {
        let mut r = SincResampler::new(1.0, DEFAULT_REQUEST_SIZE);
        let mut cb = ConstCallback(1.0);
        let chunk = r.chunk_size();
        let mut output = vec![0.0; chunk];
        r.resample(chunk, &mut output, &mut cb);

        r.flush();
        // After flush, buffer should be unprimed again.
        let mut output2 = vec![0.0; chunk];
        r.resample(chunk, &mut output2, &mut cb);

        // Both runs should produce similar output (the filter re-primes).
        assert!((output[chunk - 1] - output2[chunk - 1]).abs() < 0.01);
    }

    #[test]
    fn set_ratio_changes_output() {
        let mut r = SincResampler::new(2.0, 480);
        let mut cb = SineCallback::new(1000.0, 48000.0);

        let mut out1 = vec![0.0; 240];
        r.resample(r.chunk_size(), &mut vec![0.0; r.chunk_size()], &mut cb);
        r.resample(240, &mut out1, &mut cb);

        // Change ratio
        r.set_ratio(1.0);
        r.flush();
        let mut cb2 = SineCallback::new(1000.0, 48000.0);
        let mut out2 = vec![0.0; 480];
        r.resample(r.chunk_size(), &mut vec![0.0; r.chunk_size()], &mut cb2);
        r.resample(480, &mut out2, &mut cb2);

        // Different number of output samples validates the ratio change worked
        assert_eq!(out1.len(), 240);
        assert_eq!(out2.len(), 480);
    }
}