@pipedog/resampler-wasm 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,533 @@
1
+ // Copyright (c) 2026, pipedog contributors
2
+ // SPDX-License-Identifier: BSD-2-Clause
3
+ //
4
+ // A rubato sinc resampler behind a tiny C ABI, for wasm32-unknown-unknown.
5
+ //
6
+ // No wasm-bindgen: the JS side talks to these `extern "C"` functions through
7
+ // `WebAssembly.Instance.exports` and reads/writes PCM directly in linear memory.
8
+ //
9
+ // Model: every resampler is a *stream* (one-shot resampling is "process the
10
+ // whole clip, then flush"). A stream owns its staging buffers:
11
+ //
12
+ // ptr = rs_stream_input(h, n) // room for n int16 samples (interleaved)
13
+ // ... JS copies n samples to ptr ...
14
+ // m = rs_stream_process(h, n) // m int16 samples are now at rs_stream_output(h)
15
+ // m = rs_stream_flush(h) // drain the filter tail, then reset
16
+ //
17
+ // The `_f32` variants do the same with float samples in [-1, 1] (unclipped).
18
+ // Internally everything runs in f64, so the arithmetic noise floor stays
19
+ // below the VHQ preset's stopband (f32 accumulation sits near -125 dB).
20
+ //
21
+ // Streams accept any number of samples per call. rubato's `Async` resampler
22
+ // processes fixed-size input chunks, so a call is split into pieces of at most
23
+ // MAX_CHUNK frames and the chunk size is set to each piece's length
24
+ // (`Resizable::set_chunk_size` keeps the filter history), which means no
25
+ // input is held back beyond the filter's own delay. A sample count that is
26
+ // not a multiple of the channel count leaves the remainder buffered for the
27
+ // next call.
28
+ //
29
+ // Output is aligned with the input: the filter's start-up delay is trimmed
30
+ // from the front of the stream, and `flush` emits exactly
31
+ // round(total_input_frames * out_rate / in_rate) frames in total, like
32
+ // libsoxr's `ResampleStream` with `last=True`.
33
+
34
+ use audioadapter_buffers::direct::InterleavedSlice;
35
+ use rubato::{
36
+ Async, FixedAsync, Indexing, Resampler, Resizable, SincInterpolationParameters,
37
+ SincInterpolationType, WindowFunction,
38
+ };
39
+
40
+ /// Largest input chunk handed to rubato in one go, in frames.
41
+ const MAX_CHUNK: usize = 1024;
42
+
43
+ /// Quality presets, in the same order as libsoxr's (QQ=0 .. VHQ=4).
44
+ ///
45
+ /// Designed after libsoxr's quality table: the stopband starts at the
46
+ /// Nyquist frequency of the *lower* of the two rates, whichever way the
47
+ /// conversion goes, and
48
+ ///
49
+ /// | preset | libsoxr: passband end, rejection | here: flat (0.01 dB) to, stopband |
50
+ /// |--------|----------------------------------|-----------------------------|
51
+ /// | QQ | cubic interpolation, no filter | 0.665, >= 75 dB |
52
+ /// | LQ | 0.676, 96 dB | 0.74, >= 112 dB |
53
+ /// | MQ | 0.915, 96 dB | 0.91, >= 111 dB |
54
+ /// | HQ | 0.913, 120 dB | 0.905, >= 138 dB |
55
+ /// | VHQ | 0.911, 169 dB | 0.91, >= 157 dB |
56
+ ///
57
+ /// Lengths and the cutoff are given at the lower rate. rubato measures
58
+ /// `sinc_len` in input samples and only scales the cutoff when downsampling,
59
+ /// so `sinc_params` stretches the filter by the downsampling factor to keep
60
+ /// the same response at every ratio.
61
+ #[derive(Clone, Copy)]
62
+ pub struct Preset {
63
+ /// Filter length in samples of the lower rate.
64
+ pub len: usize,
65
+ /// Sinc table points per sample of the lower rate.
66
+ pub oversampling: usize,
67
+ pub window: WindowFunction,
68
+ pub interpolation: SincInterpolationType,
69
+ /// -6 dB cutoff relative to the lower Nyquist frequency.
70
+ pub cutoff: f32,
71
+ }
72
+
73
+ pub fn preset(quality: u32) -> Option<Preset> {
74
+ use SincInterpolationType::*;
75
+ use WindowFunction::*;
76
+ let p = |len, oversampling, window, interpolation, cutoff| Preset {
77
+ len,
78
+ oversampling,
79
+ window,
80
+ interpolation,
81
+ cutoff,
82
+ };
83
+ Some(match quality {
84
+ 0 => p(32, 32, Blackman, Linear, 0.82), // QQ
85
+ 1 => p(64, 64, BlackmanHarris, Cubic, 0.84), // LQ
86
+ 2 => p(160, 128, BlackmanHarris, Cubic, 0.95), // MQ
87
+ 3 => p(240, 128, BlackmanHarris2, Cubic, 0.945), // HQ
88
+ 4 => p(272, 256, BlackmanHarris2, Cubic, 0.945), // VHQ
89
+ _ => return None,
90
+ })
91
+ }
92
+
93
+ /// The rubato parameters of a preset for a given ratio (out/in):
94
+ /// `(sinc_len, oversampling, f_cutoff)`.
95
+ fn sinc_params(p: &Preset, ratio: f64) -> (usize, usize, f32) {
96
+ let down = ratio.min(1.0);
97
+ let sinc_len = ((p.len as f64 / down).ceil() as usize).next_multiple_of(8);
98
+ // The stretched sinc varies `1/down` times more slowly per input sample,
99
+ // so the table needs proportionally fewer points per input sample.
100
+ let oversampling = ((p.oversampling as f64 * down).ceil() as usize).max(8);
101
+ (sinc_len, oversampling, p.cutoff)
102
+ }
103
+
104
+ pub struct Stream {
105
+ resampler: Async<f64>,
106
+ channels: usize,
107
+ ratio: f64,
108
+ cutoff: f32,
109
+ /// Output frames to drop at the start of every stream (filter delay).
110
+ delay: usize,
111
+ /// Zero frames fed before the first input so that `delay` is a whole
112
+ /// number of output frames (see `alignment`).
113
+ prime: usize,
114
+ /// Whether the priming zeros still have to be fed.
115
+ needs_prime: bool,
116
+ /// Output frames still to drop at the start of this stream.
117
+ delay_left: usize,
118
+ /// Input frames consumed since the stream started (or was last reset).
119
+ total_in: u64,
120
+ /// Output frames emitted since the stream started (or was last reset).
121
+ total_out: u64,
122
+ /// Samples (< channels) left over from the previous call.
123
+ carry: Vec<f64>,
124
+ /// Staging areas JS writes input into.
125
+ input_i16: Vec<i16>,
126
+ input_f32: Vec<f32>,
127
+ /// Interleaved float input (carry + input).
128
+ in_f: Vec<f64>,
129
+ /// One rubato output chunk.
130
+ out_f: Vec<f64>,
131
+ /// The result of the last process/flush call.
132
+ res: Vec<f64>,
133
+ output_f32: Vec<f32>,
134
+ output_i16: Vec<i16>,
135
+ }
136
+
137
+ impl Stream {
138
+ pub fn new(in_rate: u32, out_rate: u32, channels: u32, quality: u32) -> Option<Stream> {
139
+ if in_rate == 0 || out_rate == 0 || channels == 0 || channels > 8 {
140
+ return None;
141
+ }
142
+ let p = preset(quality)?;
143
+ let ratio = out_rate as f64 / in_rate as f64;
144
+ let (sinc_len, oversampling, f_cutoff) = sinc_params(&p, ratio);
145
+ let params = SincInterpolationParameters::new(sinc_len, p.window)
146
+ .oversampling_factor(oversampling)
147
+ .interpolation(p.interpolation)
148
+ .f_cutoff(f_cutoff);
149
+ let resampler = Async::<f64>::new_sinc(
150
+ ratio,
151
+ 1.0,
152
+ &params,
153
+ MAX_CHUNK,
154
+ channels as usize,
155
+ FixedAsync::Input,
156
+ )
157
+ .ok()?;
158
+ let cutoff = resampler.cutoff().unwrap_or(0.0);
159
+ let out_cap = resampler.output_frames_max() * channels as usize;
160
+ let (prime, delay) = alignment(in_rate, out_rate, sinc_len);
161
+ Some(Stream {
162
+ resampler,
163
+ channels: channels as usize,
164
+ ratio,
165
+ cutoff,
166
+ delay,
167
+ prime,
168
+ needs_prime: true,
169
+ delay_left: delay,
170
+ total_in: 0,
171
+ total_out: 0,
172
+ carry: Vec::new(),
173
+ input_i16: Vec::new(),
174
+ input_f32: Vec::new(),
175
+ in_f: Vec::new(),
176
+ out_f: vec![0.0; out_cap],
177
+ res: Vec::new(),
178
+ output_i16: Vec::new(),
179
+ output_f32: Vec::new(),
180
+ })
181
+ }
182
+
183
+ pub fn reset(&mut self) {
184
+ self.resampler.reset();
185
+ self.needs_prime = true;
186
+ self.delay_left = self.delay;
187
+ self.total_in = 0;
188
+ self.total_out = 0;
189
+ self.carry.clear();
190
+ }
191
+
192
+ /// Run one rubato chunk of `frames` input frames starting at frame
193
+ /// `offset` of `in_f` (`silence` feeds a full chunk of zeros instead), and
194
+ /// append the delay-trimmed result to `res`.
195
+ fn run_chunk(&mut self, offset: usize, frames: usize, silence: bool) -> bool {
196
+ let ch = self.channels;
197
+ let chunk = if silence { MAX_CHUNK } else { frames };
198
+ if self.resampler.set_chunk_size(chunk).is_err() {
199
+ return false;
200
+ }
201
+ let (src, src_frames, indexing) = if silence {
202
+ (&[][..], 0, Indexing::new().partial_len(0))
203
+ } else {
204
+ (
205
+ &self.in_f[..],
206
+ self.in_f.len() / ch,
207
+ Indexing::new().input_offset(offset),
208
+ )
209
+ };
210
+ let Ok(input) = InterleavedSlice::new(src, ch, src_frames) else {
211
+ return false;
212
+ };
213
+ let out_frames = self.out_f.len() / ch;
214
+ let Ok(mut output) = InterleavedSlice::new_mut(&mut self.out_f, ch, out_frames) else {
215
+ return false;
216
+ };
217
+ let Ok((_, produced)) =
218
+ self.resampler
219
+ .process_into_buffer(&input, &mut output, Some(&indexing))
220
+ else {
221
+ return false;
222
+ };
223
+ let skip = self.delay_left.min(produced);
224
+ self.delay_left -= skip;
225
+ self.total_out += (produced - skip) as u64;
226
+ self.res
227
+ .extend_from_slice(&self.out_f[skip * ch..produced * ch]);
228
+ true
229
+ }
230
+
231
+ /// Resample `samples` (interleaved, float). The result is left in `res`.
232
+ fn process(&mut self, samples: impl Iterator<Item = f64>) -> bool {
233
+ let ch = self.channels;
234
+ self.res.clear();
235
+ self.in_f.clear();
236
+ if self.needs_prime {
237
+ self.needs_prime = false;
238
+ self.in_f.resize(self.prime * ch, 0.0);
239
+ }
240
+ let primed = self.in_f.len() / ch;
241
+ self.in_f.extend_from_slice(&self.carry);
242
+ self.in_f.extend(samples);
243
+ let frames = self.in_f.len() / ch;
244
+ self.carry.clear();
245
+ self.carry.extend_from_slice(&self.in_f[frames * ch..]);
246
+ self.in_f.truncate(frames * ch);
247
+ self.total_in += (frames - primed) as u64;
248
+
249
+ let mut offset = 0;
250
+ while offset < frames {
251
+ let piece = (frames - offset).min(MAX_CHUNK);
252
+ if !self.run_chunk(offset, piece, false) {
253
+ return false;
254
+ }
255
+ offset += piece;
256
+ }
257
+ true
258
+ }
259
+
260
+ /// Emit the audio held in the filter, then reset. The result is left in `res`.
261
+ fn flush(&mut self) -> bool {
262
+ let ch = self.channels;
263
+ self.res.clear();
264
+ let expected = (self.total_in as f64 * self.ratio).round() as u64;
265
+ // A few silent chunks always cover the filter delay; bound the loop anyway.
266
+ let mut guard = 64;
267
+ while self.total_out < expected && guard > 0 {
268
+ if !self.run_chunk(0, 0, true) {
269
+ return false;
270
+ }
271
+ guard -= 1;
272
+ }
273
+ if self.total_out > expected {
274
+ let extra = (self.total_out - expected) as usize;
275
+ let keep = self.res.len().saturating_sub(extra * ch);
276
+ self.res.truncate(keep);
277
+ }
278
+ self.reset();
279
+ true
280
+ }
281
+
282
+ fn result_i16(&mut self, ok: bool) -> i32 {
283
+ self.output_i16.clear();
284
+ if !ok {
285
+ return -1;
286
+ }
287
+ self.output_i16.extend(self.res.iter().map(|&v| to_i16(v)));
288
+ self.output_i16.len() as i32
289
+ }
290
+
291
+ fn result_f32(&mut self, ok: bool) -> i32 {
292
+ self.output_f32.clear();
293
+ if !ok {
294
+ return -1;
295
+ }
296
+ self.output_f32.extend(self.res.iter().map(|&v| v as f32));
297
+ self.output_f32.len() as i32
298
+ }
299
+ }
300
+
301
+ /// Choose how many zero frames to prime a stream with, and how many output
302
+ /// frames to trim, so the output is sample-aligned with the input.
303
+ ///
304
+ /// rubato's first output frame sits at input position `1/ratio - sinc_len/2`,
305
+ /// i.e. the stream is delayed by `D = sinc_len*ratio/2 - 1` output frames.
306
+ /// `D` is fractional for many ratios (e.g. 48k->16k), and a fractional delay
307
+ /// cannot be trimmed. Priming with `k` zero input frames adds `k*ratio`
308
+ /// output frames of delay; with ratio = p/q in lowest terms some k < 2q makes
309
+ /// the total a whole number. Returns `(k, round(total delay))`.
310
+ fn alignment(in_rate: u32, out_rate: u32, sinc_len: usize) -> (usize, usize) {
311
+ let g = gcd(in_rate as u64, out_rate as u64);
312
+ let (p, q) = ((out_rate as u64 / g) as i128, (in_rate as u64 / g) as i128);
313
+ let l = sinc_len.div_ceil(8) as i128 * 8;
314
+ // total(k) = (l*p + 2*k*p - 2*q) / (2*q) output frames.
315
+ let den = 2 * q;
316
+ let mut best = (0i128, i128::MAX);
317
+ for k in 0..den.min(1 << 16) {
318
+ let num = l * p + 2 * k * p - 2 * q;
319
+ let rem = num.rem_euclid(den).min(den - num.rem_euclid(den));
320
+ if rem < best.1 {
321
+ best = (k, rem);
322
+ if rem == 0 {
323
+ break;
324
+ }
325
+ }
326
+ }
327
+ let k = best.0;
328
+ let num = l * p + 2 * k * p - 2 * q;
329
+ let delay = ((num as f64) / (den as f64)).round().max(0.0) as usize;
330
+ (k as usize, delay)
331
+ }
332
+
333
+ fn gcd(mut a: u64, mut b: u64) -> u64 {
334
+ while b != 0 {
335
+ (a, b) = (b, a % b);
336
+ }
337
+ a.max(1)
338
+ }
339
+
340
+ #[inline]
341
+ fn to_i16(v: f64) -> i16 {
342
+ let s = (v * 32768.0).round();
343
+ if s >= 32767.0 {
344
+ 32767
345
+ } else if s <= -32768.0 {
346
+ -32768
347
+ } else {
348
+ s as i16
349
+ }
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // C ABI
354
+ // ---------------------------------------------------------------------------
355
+
356
+ /// Allocate `len` bytes (8-byte aligned) in linear memory.
357
+ #[no_mangle]
358
+ pub extern "C" fn rs_alloc(len: usize) -> *mut u8 {
359
+ let words = len.div_ceil(8).max(1);
360
+ let mut v: Vec<u64> = Vec::with_capacity(words);
361
+ let ptr = v.as_mut_ptr() as *mut u8;
362
+ core::mem::forget(v);
363
+ ptr
364
+ }
365
+
366
+ /// Free a block returned by `rs_alloc(len)`.
367
+ ///
368
+ /// # Safety
369
+ /// `ptr` must come from `rs_alloc` with the same `len`.
370
+ #[no_mangle]
371
+ pub unsafe extern "C" fn rs_free(ptr: *mut u8, len: usize) {
372
+ if ptr.is_null() {
373
+ return;
374
+ }
375
+ let words = len.div_ceil(8).max(1);
376
+ drop(Vec::from_raw_parts(ptr as *mut u64, 0, words));
377
+ }
378
+
379
+ /// Create a stream. `quality`: 0=QQ, 1=LQ, 2=MQ, 3=HQ, 4=VHQ. Returns 0 on
380
+ /// invalid parameters (zero rate, channels outside 1..=8, unknown quality).
381
+ #[no_mangle]
382
+ pub extern "C" fn rs_stream_new(
383
+ in_rate: u32,
384
+ out_rate: u32,
385
+ channels: u32,
386
+ quality: u32,
387
+ ) -> *mut Stream {
388
+ match Stream::new(in_rate, out_rate, channels, quality) {
389
+ Some(s) => Box::into_raw(Box::new(s)),
390
+ None => core::ptr::null_mut(),
391
+ }
392
+ }
393
+
394
+ /// # Safety
395
+ /// `h` must be a live handle from `rs_stream_new`; it is invalid afterwards.
396
+ #[no_mangle]
397
+ pub unsafe extern "C" fn rs_stream_free(h: *mut Stream) {
398
+ if !h.is_null() {
399
+ drop(Box::from_raw(h));
400
+ }
401
+ }
402
+
403
+ /// Make room for `n` int16 input samples and return where to write them.
404
+ ///
405
+ /// # Safety
406
+ /// `h` must be a live handle from `rs_stream_new`.
407
+ #[no_mangle]
408
+ pub unsafe extern "C" fn rs_stream_input(h: *mut Stream, n: usize) -> *mut i16 {
409
+ let s = &mut *h;
410
+ s.input_i16.resize(n, 0);
411
+ s.input_i16.as_mut_ptr()
412
+ }
413
+
414
+ /// Resample the `n` int16 samples written at `rs_stream_input`. Returns the
415
+ /// number of int16 samples now at `rs_stream_output`, or -1 on error.
416
+ ///
417
+ /// # Safety
418
+ /// `h` must be a live handle from `rs_stream_new`.
419
+ #[no_mangle]
420
+ pub unsafe extern "C" fn rs_stream_process(h: *mut Stream, n: usize) -> i32 {
421
+ let s = &mut *h;
422
+ let input = core::mem::take(&mut s.input_i16);
423
+ let n = n.min(input.len());
424
+ let ok = s.process(input[..n].iter().map(|&v| v as f64 / 32768.0));
425
+ s.input_i16 = input;
426
+ s.result_i16(ok)
427
+ }
428
+
429
+ /// Drain the filter tail and reset. Returns the number of int16 samples now
430
+ /// at `rs_stream_output`, or -1 on error.
431
+ ///
432
+ /// # Safety
433
+ /// `h` must be a live handle from `rs_stream_new`.
434
+ #[no_mangle]
435
+ pub unsafe extern "C" fn rs_stream_flush(h: *mut Stream) -> i32 {
436
+ let s = &mut *h;
437
+ let ok = s.flush();
438
+ s.result_i16(ok)
439
+ }
440
+
441
+ /// Pointer to the int16 output of the last `rs_stream_process`/`rs_stream_flush`.
442
+ ///
443
+ /// # Safety
444
+ /// `h` must be a live handle from `rs_stream_new`.
445
+ #[no_mangle]
446
+ pub unsafe extern "C" fn rs_stream_output(h: *mut Stream) -> *const i16 {
447
+ (*h).output_i16.as_ptr()
448
+ }
449
+
450
+ /// Make room for `n` float input samples and return where to write them.
451
+ ///
452
+ /// # Safety
453
+ /// `h` must be a live handle from `rs_stream_new`.
454
+ #[no_mangle]
455
+ pub unsafe extern "C" fn rs_stream_input_f32(h: *mut Stream, n: usize) -> *mut f32 {
456
+ let s = &mut *h;
457
+ s.input_f32.resize(n, 0.0);
458
+ s.input_f32.as_mut_ptr()
459
+ }
460
+
461
+ /// Resample the `n` float samples written at `rs_stream_input_f32`. Returns
462
+ /// the number of float samples now at `rs_stream_output_f32`, or -1 on error.
463
+ ///
464
+ /// # Safety
465
+ /// `h` must be a live handle from `rs_stream_new`.
466
+ #[no_mangle]
467
+ pub unsafe extern "C" fn rs_stream_process_f32(h: *mut Stream, n: usize) -> i32 {
468
+ let s = &mut *h;
469
+ let input = core::mem::take(&mut s.input_f32);
470
+ let n = n.min(input.len());
471
+ let ok = s.process(input[..n].iter().map(|&v| v as f64));
472
+ s.input_f32 = input;
473
+ s.result_f32(ok)
474
+ }
475
+
476
+ /// Float variant of `rs_stream_flush`; output at `rs_stream_output_f32`.
477
+ ///
478
+ /// # Safety
479
+ /// `h` must be a live handle from `rs_stream_new`.
480
+ #[no_mangle]
481
+ pub unsafe extern "C" fn rs_stream_flush_f32(h: *mut Stream) -> i32 {
482
+ let s = &mut *h;
483
+ let ok = s.flush();
484
+ s.result_f32(ok)
485
+ }
486
+
487
+ /// Pointer to the float output of the last `_f32` process/flush call.
488
+ ///
489
+ /// # Safety
490
+ /// `h` must be a live handle from `rs_stream_new`.
491
+ #[no_mangle]
492
+ pub unsafe extern "C" fn rs_stream_output_f32(h: *mut Stream) -> *const f32 {
493
+ (*h).output_f32.as_ptr()
494
+ }
495
+
496
+ /// Discard all state (filter history, pending samples, counters).
497
+ ///
498
+ /// # Safety
499
+ /// `h` must be a live handle from `rs_stream_new`.
500
+ #[no_mangle]
501
+ pub unsafe extern "C" fn rs_stream_reset(h: *mut Stream) {
502
+ (*h).reset()
503
+ }
504
+
505
+ /// Filter delay in output frames (trimmed from the start of every stream).
506
+ ///
507
+ /// # Safety
508
+ /// `h` must be a live handle from `rs_stream_new`.
509
+ #[no_mangle]
510
+ pub unsafe extern "C" fn rs_stream_delay(h: *mut Stream) -> u32 {
511
+ (*h).delay as u32
512
+ }
513
+
514
+ /// Relative anti-aliasing cutoff (1.0 = input Nyquist; scaled down when downsampling).
515
+ ///
516
+ /// # Safety
517
+ /// `h` must be a live handle from `rs_stream_new`.
518
+ #[no_mangle]
519
+ pub unsafe extern "C" fn rs_stream_cutoff(h: *mut Stream) -> f32 {
520
+ (*h).cutoff
521
+ }
522
+
523
+ /// Sinc filter length of a quality preset, in samples of the lower rate (0 if unknown).
524
+ #[no_mangle]
525
+ pub extern "C" fn rs_quality_sinc_len(quality: u32) -> u32 {
526
+ preset(quality).map(|p| p.len as u32).unwrap_or(0)
527
+ }
528
+
529
+ /// Sinc table points per sample of the lower rate for a quality preset (0 if unknown).
530
+ #[no_mangle]
531
+ pub extern "C" fn rs_quality_oversampling(quality: u32) -> u32 {
532
+ preset(quality).map(|p| p.oversampling as u32).unwrap_or(0)
533
+ }
Binary file