Skip to main content

max / audiofiles

Upgrade rubato to 4.0 rubato 4 replaces SincFixedIn with Async + FixedAsync::Input, moves all I/O through audioadapter views instead of Vec<Vec<f32>>, and folds process_partial into Indexing::partial_len. Both export paths are written against per-channel vectors and must feed the resampler identically to stay byte-identical, so the new API lives behind one ExportResampler with process/flush and the buffered and streaming callers are unchanged in shape. The behavioural change that matters: 0.14 time-aligned output to input internally, 4.0 reports the sinc group delay through output_delay and expects the caller to trim it. Left alone, every rate-converted export came out shifted late by that delay (63 frames on the 48k->24k impulse test) and ran long by the same amount. ExportResampler now discards those leading frames across however many blocks they span.
Author: Max Johnson <me@maxj.phd> · 2026-07-22 03:57 UTC
Commit: fa9d633823d4aac1f3b64884509f06152e8f5b61
Parent: 2f92bed
4 files changed, +193 insertions, -37 deletions
M Cargo.lock +63 -2
@@ -342,6 +342,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
342 342 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
343 343
344 344 [[package]]
345 + name = "audio-core"
346 + version = "0.2.1"
347 + source = "registry+https://github.com/rust-lang/crates.io-index"
348 + checksum = "f93ebbf82d06013f4c41fe71303feb980cddd78496d904d06be627972de51a24"
349 +
350 + [[package]]
351 + name = "audioadapter"
352 + version = "4.0.0"
353 + source = "registry+https://github.com/rust-lang/crates.io-index"
354 + checksum = "c75c3943c6c7279bb25a449a8d1727480730ab2efd7b6fd5d6ca51927096e6e4"
355 + dependencies = [
356 + "audio-core",
357 + "num-traits",
358 + ]
359 +
360 + [[package]]
361 + name = "audioadapter-buffers"
362 + version = "4.0.0"
363 + source = "registry+https://github.com/rust-lang/crates.io-index"
364 + checksum = "ece3390b6eb40379094843a1da5aaccc34bc0d85a8cbf68d09fe092fee6de29e"
365 + dependencies = [
366 + "audioadapter",
367 + "audioadapter-sample",
368 + "num-traits",
369 + ]
370 +
371 + [[package]]
372 + name = "audioadapter-sample"
373 + version = "4.0.0"
374 + source = "registry+https://github.com/rust-lang/crates.io-index"
375 + checksum = "1592f90413568e259413c21a41a3d571feb1774255c209e7966d98f9db708c90"
376 + dependencies = [
377 + "audio-core",
378 + "num-traits",
379 + ]
380 +
381 + [[package]]
345 382 name = "audiofiles-app"
346 383 version = "0.5.0"
347 384 dependencies = [
@@ -4344,14 +4381,18 @@ dependencies = [
4344 4381
4345 4382 [[package]]
4346 4383 name = "rubato"
4347 - version = "0.14.1"
4384 + version = "4.0.0"
4348 4385 source = "registry+https://github.com/rust-lang/crates.io-index"
4349 - checksum = "e6dd52e80cfc21894deadf554a5673002938ae4625f7a283e536f9cf7c17b0d5"
4386 + checksum = "f57c655d11e929f05a8663b323ff553f8d9773be05dfdc087795955bedeb8d92"
4350 4387 dependencies = [
4388 + "audioadapter",
4389 + "audioadapter-buffers",
4351 4390 "num-complex",
4352 4391 "num-integer",
4353 4392 "num-traits",
4354 4393 "realfft",
4394 + "visibility",
4395 + "windowfunctions",
4355 4396 ]
4356 4397
4357 4398 [[package]]
@@ -5962,6 +6003,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5962 6003 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
5963 6004
5964 6005 [[package]]
6006 + name = "visibility"
6007 + version = "0.1.1"
6008 + source = "registry+https://github.com/rust-lang/crates.io-index"
6009 + checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
6010 + dependencies = [
6011 + "proc-macro2",
6012 + "quote",
6013 + "syn 2.0.119",
6014 + ]
6015 +
6016 + [[package]]
5965 6017 name = "walkdir"
5966 6018 version = "2.5.0"
5967 6019 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6385,6 +6437,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
6385 6437 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
6386 6438
6387 6439 [[package]]
6440 + name = "windowfunctions"
6441 + version = "0.1.1"
6442 + source = "registry+https://github.com/rust-lang/crates.io-index"
6443 + checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a"
6444 + dependencies = [
6445 + "num-traits",
6446 + ]
6447 +
6448 + [[package]]
6388 6449 name = "windows"
6389 6450 version = "0.62.2"
6390 6451 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +1 -1
@@ -31,7 +31,7 @@ rfd = "0.17"
31 31 pollster = "1.0"
32 32 serde = { version = "1.0.228", features = ["derive"] }
33 33 serde_json = "1.0.149"
34 - rubato = "0.14"
34 + rubato = "4.0"
35 35 hound = "3.5"
36 36 tracing = "0.1.44"
37 37 tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
@@ -1,7 +1,9 @@
1 1 //! Audio conversion pipeline: channel count and sample rate conversion for export.
2 2
3 + use rubato::audioadapter_buffers::direct::SequentialSliceOfVecs;
3 4 use rubato::{
4 - Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction,
5 + Async, FixedAsync, Indexing, Resampler, SincInterpolationParameters, SincInterpolationType,
6 + WindowFunction,
5 7 };
6 8
7 9 use super::ExportChannels;
@@ -86,17 +88,123 @@ pub(crate) fn make_resampler(
86 88 src_rate: u32,
87 89 dst_rate: u32,
88 90 channels: usize,
89 - ) -> Result<SincFixedIn<f32>, CoreError> {
91 + ) -> Result<ExportResampler, CoreError> {
90 92 let params = SincInterpolationParameters {
91 93 sinc_len: 256,
92 - f_cutoff: 0.95,
94 + f_cutoff: Some(0.95),
93 95 interpolation: SincInterpolationType::Linear,
94 96 oversampling_factor: 256,
95 97 window: WindowFunction::BlackmanHarris2,
96 98 };
97 99 let ratio = dst_rate as f64 / src_rate as f64;
98 - SincFixedIn::<f32>::new(ratio, 2.0, params, RESAMPLE_CHUNK, channels)
99 - .map_err(|e| CoreError::Export(format!("resampler init: {e}")))
100 + // `FixedAsync::Input` is rubato 4's spelling of the old `SincFixedIn`: a
101 + // fixed-size input chunk with a variable-length output block.
102 + let inner = Async::<f32>::new_sinc(
103 + ratio,
104 + 2.0,
105 + &params,
106 + RESAMPLE_CHUNK,
107 + channels,
108 + FixedAsync::Input,
109 + )
110 + .map_err(|e| CoreError::Export(format!("resampler init: {e}")))?;
111 + let delay_remaining = inner.output_delay();
112 + Ok(ExportResampler {
113 + inner,
114 + channels,
115 + delay_remaining,
116 + input: vec![Vec::new(); channels],
117 + output: vec![Vec::new(); channels],
118 + })
119 + }
120 +
121 + /// The export resampler, wrapping rubato's `Async` in the per-channel
122 + /// `Vec<Vec<f32>>` interface both export paths are written against.
123 + ///
124 + /// rubato 4 replaced `process`/`process_partial` with a single
125 + /// `process_into_buffer` over `audioadapter` views, and moved short-chunk
126 + /// handling into `Indexing::partial_len`. Keeping that behind one method is what
127 + /// lets the buffered and streaming paths stay byte-identical: they hand the same
128 + /// chunks to the same call, and neither has to reason about padding or output
129 + /// sizing.
130 + pub(crate) struct ExportResampler {
131 + inner: Async<f32>,
132 + channels: usize,
133 + /// Leading output frames still to be discarded to undo the sinc filter's
134 + /// group delay. rubato 0.14 time-aligned output to input for us; rubato 4
135 + /// reports the delay through `output_delay` and leaves the trim to the
136 + /// caller. Without it every rate-converted export is shifted late by half
137 + /// the sinc length (scaled by the ratio) and runs long by the same amount.
138 + delay_remaining: usize,
139 + /// Scratch input, always padded out to the resampler's required chunk size.
140 + input: Vec<Vec<f32>>,
141 + /// Scratch output, always sized to the resampler's maximum output block.
142 + output: Vec<Vec<f32>>,
143 + }
144 +
145 + impl ExportResampler {
146 + /// Push one chunk of per-channel input and return the frames it produced.
147 + ///
148 + /// `chunk` may be shorter than [`RESAMPLE_CHUNK`] (the final partial chunk)
149 + /// or empty (flushing the latency tail); in both cases the remainder is
150 + /// treated as silence, which is what `process_partial` did in rubato 0.14.
151 + fn push(&mut self, chunk: &[Vec<f32>]) -> Result<&[Vec<f32>], CoreError> {
152 + let needed = self.inner.input_frames_next();
153 + let valid = chunk.first().map_or(0, |c| c.len()).min(needed);
154 +
155 + // rubato requires a full-size input buffer even for a partial chunk: the
156 + // frames past `valid` are declared silent via `partial_len`, but must
157 + // still exist for the adapter to wrap.
158 + for (c, buf) in self.input.iter_mut().enumerate() {
159 + buf.clear();
160 + if let Some(src) = chunk.get(c) {
161 + buf.extend_from_slice(&src[..valid.min(src.len())]);
162 + }
163 + buf.resize(needed, 0.0);
164 + }
165 +
166 + let out_frames = self.inner.output_frames_next();
167 + for buf in self.output.iter_mut() {
168 + buf.clear();
169 + buf.resize(out_frames, 0.0);
170 + }
171 +
172 + let indexing = Indexing {
173 + partial_len: (valid < needed).then_some(valid),
174 + ..Default::default()
175 + };
176 +
177 + let input = SequentialSliceOfVecs::new(&self.input[..], self.channels, needed)
178 + .map_err(|e| CoreError::Export(format!("resample input buffer: {e}")))?;
179 + let mut output =
180 + SequentialSliceOfVecs::new_mut(&mut self.output[..], self.channels, out_frames)
181 + .map_err(|e| CoreError::Export(format!("resample output buffer: {e}")))?;
182 +
183 + let (_, produced) = self
184 + .inner
185 + .process_into_buffer(&input, &mut output, Some(&indexing))
186 + .map_err(|e| CoreError::Export(format!("resample: {e}")))?;
187 +
188 + // Trim the group delay off the head of the stream, spending it across
189 + // however many blocks it takes.
190 + let skip = self.delay_remaining.min(produced);
191 + self.delay_remaining -= skip;
192 + for buf in self.output.iter_mut() {
193 + buf.truncate(produced);
194 + buf.drain(..skip);
195 + }
196 + Ok(&self.output)
197 + }
198 +
199 + /// Process one full or short chunk of real input.
200 + pub(crate) fn process(&mut self, chunk: &[Vec<f32>]) -> Result<&[Vec<f32>], CoreError> {
201 + self.push(chunk)
202 + }
203 +
204 + /// Drive the resampler with silence to drain its latency tail.
205 + pub(crate) fn flush(&mut self) -> Result<&[Vec<f32>], CoreError> {
206 + self.push(&[])
207 + }
100 208 }
101 209
102 210 /// Resample interleaved audio from src_rate to dst_rate using rubato.
@@ -147,17 +255,13 @@ pub fn resample(
147 255 .map(|buf| buf[pos..end].to_vec())
148 256 .collect();
149 257
150 - // Full chunks go through `process`; the short final chunk through
151 - // `process_partial`, which zero-pads internally to a full chunk.
152 - let output_chunk = if actual_len == chunk_size {
153 - resampler.process(&input_chunk, None)
154 - } else {
155 - resampler.process_partial(Some(input_chunk.as_slice()), None)
156 - }
157 - .map_err(|e| CoreError::Export(format!("resample: {e}")))?;
258 + // Full and short chunks take the same call; the resampler treats
259 + // anything past the chunk's real length as silence.
260 + let _ = actual_len;
261 + let output_chunk = resampler.process(&input_chunk)?;
158 262
159 - for (c, chunk) in output_chunk.into_iter().enumerate() {
160 - output_channels[c].extend_from_slice(&chunk);
263 + for (c, chunk) in output_chunk.iter().enumerate() {
264 + output_channels[c].extend_from_slice(chunk);
161 265 }
162 266
163 267 pos += chunk_size;
@@ -169,12 +273,10 @@ pub fn resample(
169 273 // its tail. (The resampler time-aligns output to input internally, so no
170 274 // leading-delay trim is needed.)
171 275 while output_channels[0].len() < target_total {
172 - let flushed = resampler
173 - .process_partial::<Vec<f32>>(None, None)
174 - .map_err(|e| CoreError::Export(format!("resample flush: {e}")))?;
276 + let flushed = resampler.flush()?;
175 277 let produced = flushed.first().map_or(0, |c| c.len());
176 - for (c, chunk) in flushed.into_iter().enumerate() {
177 - output_channels[c].extend_from_slice(&chunk);
278 + for (c, chunk) in flushed.iter().enumerate() {
279 + output_channels[c].extend_from_slice(chunk);
178 280 }
179 281 if produced == 0 {
180 282 break; // fully drained
@@ -21,7 +21,6 @@ use std::path::Path;
21 21 use std::sync::atomic::{AtomicBool, Ordering};
22 22
23 23 use hound::{WavSpec, WavWriter};
24 - use rubato::Resampler;
25 24
26 25 use super::ExportChannels;
27 26 use super::convert::{RESAMPLE_CHUNK, convert_channels, make_resampler};
@@ -387,28 +386,22 @@ fn process_streaming<S: StreamSink>(
387 386 for b in acc.iter_mut() {
388 387 b.drain(..RESAMPLE_CHUNK);
389 388 }
390 - let out = resampler
391 - .process(&input, None)
392 - .map_err(|e| CoreError::Export(format!("resample: {e}")))?;
393 - write_block(&mut sink, &out, target_total, &mut written)?;
389 + let out = resampler.process(&input)?;
390 + write_block(&mut sink, out, target_total, &mut written)?;
394 391 }
395 392 }
396 393
397 - // Final short chunk (process_partial zero-pads internally).
394 + // Final short chunk (the resampler treats the remainder as silence).
398 395 if !acc[0].is_empty() {
399 - let out = resampler
400 - .process_partial(Some(acc.as_slice()), None)
401 - .map_err(|e| CoreError::Export(format!("resample: {e}")))?;
402 - write_block(&mut sink, &out, target_total, &mut written)?;
396 + let out = resampler.process(&acc)?;
397 + write_block(&mut sink, out, target_total, &mut written)?;
403 398 }
404 399
405 400 // Drain the resampler's latency tail until we have target_total frames.
406 401 while written < target_total {
407 - let out = resampler
408 - .process_partial::<Vec<f32>>(None, None)
409 - .map_err(|e| CoreError::Export(format!("resample flush: {e}")))?;
402 + let out = resampler.flush()?;
410 403 let produced = out.first().map_or(0, |c| c.len());
411 - write_block(&mut sink, &out, target_total, &mut written)?;
404 + write_block(&mut sink, out, target_total, &mut written)?;
412 405 if produced == 0 {
413 406 break;
414 407 }