Skip to main content

max / audiofiles

Stream WAV export to bound peak memory (ultra-fuzz Performance) The WAV export path decoded the whole file to f32, resampled into more full buffers, then encoded — ~2-4x the file size resident, an OOM risk on large sources. Add a streaming path: decode → channel-convert → resample → encode in a bounded window, feeding the resampler the same 1024-frame-aligned chunks the buffered path uses and clamping output to the exact target frame count. Used only when the container reports n_frames (PCM WAV), so the streamed result is bit-identical to buffered; other formats fall back to the buffered pipeline. - decode.rs: factor open_decoder + decode_next_into, shared by buffered and streaming; decode_multichannel now builds on them. - encode.rs: factor write_sample (per-sample quantize/dither) + sample_format_for; encode_wav reuses them. - convert.rs: factor make_resampler + RESAMPLE_CHUNK so both paths use identical interpolation parameters (the bit-exactness prerequisite). - export/stream.rs: the streaming pipeline. - runner.rs: Wav export tries streaming, falls back to buffered. Tests: streamed output is byte-identical to buffered across no-resample mono, stereo→mono, downsample, and upsample (24-bit, deterministic). Also fixes three pre-existing clippy lints in audiofiles-bench so the workspace gate is clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 21:55 UTC
Commit: 7cc74399b8ab5687db7e8aebda55d9777c14d778
Parent: 0626ba8
7 files changed, +495 insertions, -116 deletions
@@ -174,11 +174,10 @@ fn collect_audio_files(dir: &Path, limit: Option<usize>) -> Vec<PathBuf> {
174 174 return files;
175 175 }
176 176 for entry in walkdir(dir) {
177 - if let Some(lim) = limit {
178 - if files.len() >= lim {
177 + if let Some(lim) = limit
178 + && files.len() >= lim {
179 179 break;
180 180 }
181 - }
182 181 files.push(entry);
183 182 }
184 183 files
@@ -302,7 +301,7 @@ fn main() {
302 301 durations.push(*dur);
303 302 }
304 303
305 - fn stats_line(name: &str, vals: &mut Vec<f64>) {
304 + fn stats_line(name: &str, vals: &mut [f64]) {
306 305 let mean = vals.iter().sum::<f64>() / vals.len() as f64;
307 306 let p50 = percentile(vals, 50.0);
308 307 let p95 = percentile(vals, 95.0);
@@ -629,7 +628,7 @@ fn main() {
629 628 *non_drum_classes.entry(r.predicted.as_str()).or_default() += 1;
630 629 }
631 630 let mut sorted: Vec<_> = non_drum_classes.into_iter().collect();
632 - sorted.sort_by(|a, b| b.1.cmp(&a.1));
631 + sorted.sort_by_key(|a| std::cmp::Reverse(a.1));
633 632 for (class, count) in &sorted {
634 633 println!(" {} → {}", count, class);
635 634 }
@@ -68,6 +68,30 @@ pub fn convert_channels(
68 68 }
69 69 }
70 70
71 + /// Resampler input chunk size in frames. The streaming export path must feed the
72 + /// resampler in identical chunks for its output to match the buffered path.
73 + pub(crate) const RESAMPLE_CHUNK: usize = 1024;
74 +
75 + /// Build the export resampler. Centralized so the buffered [`resample`] and the
76 + /// streaming export path use byte-identical interpolation parameters — a
77 + /// prerequisite for the streaming path being bit-exact with the buffered one.
78 + pub(crate) fn make_resampler(
79 + src_rate: u32,
80 + dst_rate: u32,
81 + channels: usize,
82 + ) -> Result<SincFixedIn<f32>, CoreError> {
83 + let params = SincInterpolationParameters {
84 + sinc_len: 256,
85 + f_cutoff: 0.95,
86 + interpolation: SincInterpolationType::Linear,
87 + oversampling_factor: 256,
88 + window: WindowFunction::BlackmanHarris2,
89 + };
90 + let ratio = dst_rate as f64 / src_rate as f64;
91 + SincFixedIn::<f32>::new(ratio, 2.0, params, RESAMPLE_CHUNK, channels)
92 + .map_err(|e| CoreError::Export(format!("resampler init: {e}")))
93 + }
94 +
71 95 /// Resample interleaved audio from src_rate to dst_rate using rubato.
72 96 /// Returns samples unchanged if rates match.
73 97 #[instrument(skip_all)]
@@ -97,19 +121,10 @@ pub fn resample(
97 121 }
98 122 }
99 123
100 - let params = SincInterpolationParameters {
101 - sinc_len: 256,
102 - f_cutoff: 0.95,
103 - interpolation: SincInterpolationType::Linear,
104 - oversampling_factor: 256,
105 - window: WindowFunction::BlackmanHarris2,
106 - };
107 -
108 124 let ratio = dst_rate as f64 / src_rate as f64;
109 - let chunk_size = 1024;
125 + let chunk_size = RESAMPLE_CHUNK;
110 126
111 - let mut resampler = SincFixedIn::<f32>::new(ratio, 2.0, params, chunk_size, ch)
112 - .map_err(|e| CoreError::Export(format!("resampler init: {e}")))?;
127 + let mut resampler = make_resampler(src_rate, dst_rate, ch)?;
113 128
114 129 let target_total = (num_frames as f64 * ratio).round() as usize;
115 130
@@ -6,8 +6,8 @@
6 6 use std::path::Path;
7 7
8 8 use symphonia::core::audio::SampleBuffer;
9 - use symphonia::core::codecs::DecoderOptions;
10 - use symphonia::core::formats::FormatOptions;
9 + use symphonia::core::codecs::{Decoder, DecoderOptions};
10 + use symphonia::core::formats::{FormatOptions, FormatReader};
11 11 use symphonia::core::io::MediaSourceStream;
12 12 use symphonia::core::meta::MetadataOptions;
13 13 use symphonia::core::probe::Hint;
@@ -26,14 +26,21 @@ pub struct DecodedMultichannel {
26 26 pub channels: u16,
27 27 }
28 28
29 - /// Decode an audio file preserving its original channel layout.
30 - ///
31 - /// `pub(crate)`: full-file decode is reachable only from core worker/runner code
32 - /// (edit/forge/export workers, [`crate::forge::compute_preview_marks`]), never
33 - /// from the browser UI, so a synchronous decode can't creep back onto the egui
34 - /// frame thread (ultra-fuzz Chronic #1).
35 - #[instrument(skip_all)]
36 - pub(crate) fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, CoreError> {
29 + /// An opened decoder plus the source's format parameters. `n_frames` is the
30 + /// exact total frame count when the container reports it (e.g. PCM WAV) — the
31 + /// streaming export path needs it to size the resampler output exactly.
32 + pub(crate) struct OpenedDecoder {
33 + pub format: Box<dyn FormatReader>,
34 + pub decoder: Box<dyn Decoder>,
35 + pub track_id: u32,
36 + pub sample_rate: u32,
37 + pub channels: u16,
38 + pub n_frames: Option<u64>,
39 + }
40 +
41 + /// Open a media file and build its decoder, reading the format parameters.
42 + /// Shared by the buffered [`decode_multichannel`] and the streaming export path.
43 + pub(crate) fn open_decoder(path: &Path) -> Result<OpenedDecoder, CoreError> {
37 44 let file = std::fs::File::open(path).map_err(|e| io_err(path, e))?;
38 45 let mss = MediaSourceStream::new(Box::new(file), Default::default());
39 46
@@ -51,55 +58,88 @@ pub(crate) fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, Co
51 58 )
52 59 .map_err(|e| AnalysisError::ProbeFailed(e.to_string()))?;
53 60
54 - let mut format = probed.format;
55 -
56 - let track = format
57 - .default_track()
58 - .ok_or(AnalysisError::NoAudioTrack)?;
59 -
61 + let format = probed.format;
62 + let track = format.default_track().ok_or(AnalysisError::NoAudioTrack)?;
60 63 let track_id = track.id;
61 - let source_sample_rate = track
64 + let sample_rate = track
62 65 .codec_params
63 66 .sample_rate
64 67 .ok_or(AnalysisError::ProbeFailed("missing sample rate".to_string()))?;
65 - let source_channels = track
68 + let channels = track
66 69 .codec_params
67 70 .channels
68 71 .map(|c| c.count() as u16)
69 72 .ok_or(AnalysisError::ProbeFailed("missing channel count".to_string()))?;
73 + let n_frames = track.codec_params.n_frames;
70 74
71 - let mut decoder = symphonia::default::get_codecs()
75 + let decoder = symphonia::default::get_codecs()
72 76 .make(&track.codec_params, &DecoderOptions::default())
73 77 .map_err(|e| AnalysisError::DecoderFailed(e.to_string()))?;
74 78
75 - let mut all_samples: Vec<f32> = Vec::new();
79 + Ok(OpenedDecoder {
80 + format,
81 + decoder,
82 + track_id,
83 + sample_rate,
84 + channels,
85 + n_frames,
86 + })
87 + }
76 88
89 + /// Decode the next packet's frames into `out` (interleaved, appended). Returns
90 + /// `Ok(true)` on a decoded packet, `Ok(false)` at end of stream. Shared packet
91 + /// loop used by the buffered and streaming paths.
92 + pub(crate) fn decode_next_into(
93 + format: &mut dyn FormatReader,
94 + decoder: &mut dyn Decoder,
95 + track_id: u32,
96 + out: &mut Vec<f32>,
97 + ) -> Result<bool, CoreError> {
77 98 loop {
78 99 let packet = match format.next_packet() {
79 100 Ok(p) => p,
80 101 Err(symphonia::core::errors::Error::IoError(ref e))
81 102 if e.kind() == std::io::ErrorKind::UnexpectedEof =>
82 103 {
83 - break;
104 + return Ok(false);
84 105 }
85 106 Err(e) => return Err(AnalysisError::PacketError(e.to_string()).into()),
86 107 };
87 -
88 108 if packet.track_id() != track_id {
89 109 continue;
90 110 }
91 -
92 111 let decoded = match decoder.decode(&packet) {
93 112 Ok(d) => d,
94 113 Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
95 114 Err(e) => return Err(AnalysisError::DecodeError(e.to_string()).into()),
96 115 };
97 -
98 116 let num_frames = decoded.frames();
99 117 let mut sample_buf = SampleBuffer::<f32>::new(num_frames as u64, *decoded.spec());
100 118 sample_buf.copy_interleaved_ref(decoded);
101 - all_samples.extend_from_slice(sample_buf.samples());
119 + out.extend_from_slice(sample_buf.samples());
120 + return Ok(true);
102 121 }
122 + }
123 +
124 + /// Decode an audio file preserving its original channel layout.
125 + ///
126 + /// `pub(crate)`: full-file decode is reachable only from core worker/runner code
127 + /// (edit/forge/export workers, [`crate::forge::compute_preview_marks`]), never
128 + /// from the browser UI, so a synchronous decode can't creep back onto the egui
129 + /// frame thread (ultra-fuzz Chronic #1).
130 + #[instrument(skip_all)]
131 + pub(crate) fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, CoreError> {
132 + let OpenedDecoder {
133 + mut format,
134 + mut decoder,
135 + track_id,
136 + sample_rate,
137 + channels,
138 + ..
139 + } = open_decoder(path)?;
140 +
141 + let mut all_samples: Vec<f32> = Vec::new();
142 + while decode_next_into(format.as_mut(), decoder.as_mut(), track_id, &mut all_samples)? {}
103 143
104 144 if all_samples.is_empty() {
105 145 return Err(AnalysisError::NoAudioData.into());
@@ -107,8 +147,8 @@ pub(crate) fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, Co
107 147
108 148 Ok(DecodedMultichannel {
109 149 samples: all_samples,
110 - sample_rate: source_sample_rate,
111 - channels: source_channels,
150 + sample_rate,
151 + channels,
112 152 })
113 153 }
114 154
@@ -1,5 +1,6 @@
1 1 //! WAV encoding via hound: writes f32 audio data to 16-bit or 24-bit integer WAV files.
2 2
3 + use std::io::{Seek, Write};
3 4 use std::path::Path;
4 5
5 6 use hound::{SampleFormat, WavSpec, WavWriter};
@@ -9,6 +10,62 @@ use super::dither::SimpleRng;
9 10 use crate::error::{io_err, CoreError};
10 11 use tracing::instrument;
11 12
13 + /// The hound sample format for a given bit depth (Float for 32-bit, else Int).
14 + pub(crate) fn sample_format_for(bit_depth: u16) -> SampleFormat {
15 + if bit_depth == 32 {
16 + SampleFormat::Float
17 + } else {
18 + SampleFormat::Int
19 + }
20 + }
21 +
22 + /// Quantize and write a single f32 sample at the given bit depth. Shared by the
23 + /// buffered [`encode_wav`] and the streaming export path so both quantize
24 + /// identically. `rng` supplies TPDF dither for 8/16-bit; 24/32-bit ignore it.
25 + pub(crate) fn write_sample<W: Write + Seek>(
26 + writer: &mut WavWriter<W>,
27 + sample: f32,
28 + bit_depth: u16,
29 + rng: &mut SimpleRng,
30 + ) -> Result<(), CoreError> {
31 + match bit_depth {
32 + 8 => {
33 + let scale = i8::MAX as f32;
34 + let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
35 + let clamped = ((sample + dither) * scale)
36 + .round()
37 + .clamp(i8::MIN as f32, i8::MAX as f32) as i8;
38 + writer.write_sample(clamped)
39 + }
40 + 16 => {
41 + let scale = i16::MAX as f32;
42 + let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
43 + let clamped = ((sample + dither) * scale)
44 + .round()
45 + .clamp(i16::MIN as f32, i16::MAX as f32) as i16;
46 + writer.write_sample(clamped)
47 + }
48 + 24 => {
49 + let scale = 8_388_607.0f32; // 2^23 - 1
50 + let scaled = (sample * scale).round().clamp(-8_388_608.0, 8_388_607.0) as i32;
51 + writer.write_sample(scaled)
52 + }
53 + 32 => writer.write_sample(sample),
54 + other => {
55 + return Err(CoreError::Export(format!(
56 + "unsupported bit depth: {other} (expected 8, 16, 24, or 32)"
57 + )));
58 + }
59 + }
60 + .map_err(|e| CoreError::Export(format!("WAV write: {e}")))
61 + }
62 +
63 + /// Seed a dither RNG from a sample count (the buffered path seeds off the buffer
64 + /// pointer too, but dither is intentionally non-deterministic either way).
65 + pub(crate) fn dither_seed(sample_count: usize) -> u64 {
66 + 0x9E3779B97F4A7C15u64 ^ sample_count as u64
67 + }
68 +
12 69 /// Encode audio to a WAV file at the given path.
13 70 ///
14 71 /// - 8-bit: unsigned PCM (the WAV spec stores 8-bit as offset-128 unsigned),
@@ -19,79 +76,25 @@ use tracing::instrument;
19 76 /// - 32-bit: IEEE float, written verbatim (lossless passthrough of the f32 data).
20 77 #[instrument(skip_all)]
21 78 pub fn encode_wav(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result<(), CoreError> {
22 - let sample_format = if bit_depth == 32 {
23 - SampleFormat::Float
24 - } else {
25 - SampleFormat::Int
26 - };
79 + if !matches!(bit_depth, 8 | 16 | 24 | 32) {
80 + return Err(CoreError::Export(format!(
81 + "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)"
82 + )));
83 + }
27 84 let spec = WavSpec {
28 85 channels: audio.channels,
29 86 sample_rate: audio.sample_rate,
30 87 bits_per_sample: bit_depth,
31 - sample_format,
88 + sample_format: sample_format_for(bit_depth),
32 89 };
33 90
34 91 let mut writer =
35 92 WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
36 93
37 - match bit_depth {
38 - 8 => {
39 - // 8-bit WAV is unsigned: midpoint 128, range 0..=255. hound writes
40 - // i8 for 8-bit Int specs, so we map the signed quantization into i8
41 - // (which hound serializes as the unsigned byte). TPDF dither at the
42 - // 8-bit LSB masks quantization distortion on this coarse grid.
43 - let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64;
44 - let mut rng = SimpleRng::new(seed);
45 - let scale = i8::MAX as f32;
46 - for &sample in &audio.samples {
47 - let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
48 - let dithered = (sample + dither) * scale;
49 - let clamped = dithered.round().clamp(i8::MIN as f32, i8::MAX as f32) as i8;
50 - writer
51 - .write_sample(clamped)
52 - .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
53 - }
54 - }
55 - 16 => {
56 - // Seed from data pointer so each export gets a different dither pattern
57 - let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64;
58 - let mut rng = SimpleRng::new(seed);
59 - let scale = i16::MAX as f32;
60 - for &sample in &audio.samples {
61 - // TPDF dither: two uniform random values summed
62 - let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
63 - let dithered = (sample + dither) * scale;
64 - let clamped = dithered.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
65 - writer
66 - .write_sample(clamped)
67 - .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
68 - }
69 - }
70 - 24 => {
71 - let scale = 8_388_607.0f32; // 2^23 - 1
72 - for &sample in &audio.samples {
73 - let scaled = (sample * scale)
74 - .round()
75 - .clamp(-8_388_608.0, 8_388_607.0) as i32;
76 - writer
77 - .write_sample(scaled)
78 - .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
79 - }
80 - }
81 - 32 => {
82 - // IEEE float: the in-memory representation is already f32, so this is
83 - // a lossless passthrough — no scaling, dither, or clamping needed.
84 - for &sample in &audio.samples {
85 - writer
86 - .write_sample(sample)
87 - .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
88 - }
89 - }
90 - _ => {
91 - return Err(CoreError::Export(format!(
92 - "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)"
93 - )));
94 - }
94 + // Dither (8/16-bit) is seeded once per file; 24/32-bit ignore the RNG.
95 + let mut rng = SimpleRng::new(dither_seed(audio.samples.len()));
96 + for &sample in &audio.samples {
97 + write_sample(&mut writer, sample, bit_depth, &mut rng)?;
95 98 }
96 99
97 100 writer
@@ -8,6 +8,7 @@ pub mod encode_aiff;
8 8 pub mod profile;
9 9 mod resolve;
10 10 mod runner;
11 + mod stream;
11 12 pub mod sanitize;
12 13
13 14 use std::path::PathBuf;
@@ -214,16 +214,32 @@ fn export_single_item(
214 214 })
215 215 }
216 216 ExportFormat::Wav => {
217 - let decoded = decode::decode_multichannel(source)?;
218 - let converted = convert::apply_conversion(
219 - &decoded.samples,
220 - decoded.channels,
221 - decoded.sample_rate,
222 - &config.channels,
223 - config.sample_rate,
224 - )?;
225 217 let bit_depth = config.bit_depth.unwrap_or(24);
226 - write_atomic(dest, |tmp| encode::encode_wav(&converted, bit_depth, tmp))
218 + write_atomic(dest, |tmp| {
219 + // Stream when the source reports an exact frame count (PCM WAV):
220 + // bounded memory and bit-identical to the buffered path. Fall back
221 + // to buffered decode+convert+encode otherwise.
222 + let streamed = super::stream::try_stream_wav_export(
223 + source,
224 + tmp,
225 + &config.channels,
226 + config.sample_rate,
227 + bit_depth,
228 + &std::sync::atomic::AtomicBool::new(false),
229 + )?;
230 + if streamed {
231 + return Ok(());
232 + }
233 + let decoded = decode::decode_multichannel(source)?;
234 + let converted = convert::apply_conversion(
235 + &decoded.samples,
236 + decoded.channels,
237 + decoded.sample_rate,
238 + &config.channels,
239 + config.sample_rate,
240 + )?;
241 + encode::encode_wav(&converted, bit_depth, tmp)
242 + })
227 243 }
228 244 ExportFormat::Aiff => {
229 245 let decoded = decode::decode_multichannel(source)?;
@@ -0,0 +1,305 @@
1 + //! Streaming WAV export: decode → channel-convert → resample → encode in a
2 + //! bounded window, so a multi-GB source never materializes as a 2-4x f32 buffer.
3 + //!
4 + //! Used only when the container reports an exact frame count (PCM WAV does),
5 + //! which lets the resampler output be sized — and clamped — exactly as the
6 + //! buffered path does, so the streamed result is bit-identical. Sources without
7 + //! a known frame count fall back to the buffered pipeline.
8 +
9 + use std::io::{Seek, Write};
10 + use std::path::Path;
11 + use std::sync::atomic::{AtomicBool, Ordering};
12 +
13 + use hound::{WavSpec, WavWriter};
14 + use rubato::Resampler;
15 +
16 + use super::convert::{convert_channels, make_resampler, RESAMPLE_CHUNK};
17 + use super::decode::{decode_next_into, open_decoder};
18 + use super::dither::SimpleRng;
19 + use super::encode::{dither_seed, sample_format_for, write_sample};
20 + use super::ExportChannels;
21 + use crate::error::{io_err, CoreError};
22 +
23 + /// Resolve a concrete output channel count from the target spec + source.
24 + fn out_channel_count(target: &ExportChannels, src_channels: u16) -> u16 {
25 + match target {
26 + ExportChannels::Original => src_channels.max(1),
27 + ExportChannels::Mono => 1,
28 + ExportChannels::Stereo => 2,
29 + }
30 + }
31 +
32 + /// Interleave and write one per-channel resampler output block, clamping the
33 + /// total to `target_total` frames (matching the buffered path, which drops the
34 + /// extra frames the resampler emits while flushing its latency tail).
35 + fn write_block<W: Write + Seek>(
36 + writer: &mut WavWriter<W>,
37 + per_channel: &[Vec<f32>],
38 + bit_depth: u16,
39 + rng: &mut SimpleRng,
40 + target_total: usize,
41 + written: &mut usize,
42 + ) -> Result<(), CoreError> {
43 + let frames = per_channel.first().map_or(0, |c| c.len());
44 + for f in 0..frames {
45 + if *written >= target_total {
46 + return Ok(());
47 + }
48 + for channel in per_channel {
49 + write_sample(writer, channel[f], bit_depth, rng)?;
50 + }
51 + *written += 1;
52 + }
53 + Ok(())
54 + }
55 +
56 + /// Attempt a streaming WAV export. Returns `Ok(true)` if it streamed the file,
57 + /// `Ok(false)` if the source has no known frame count and the caller should fall
58 + /// back to the buffered pipeline.
59 + pub(crate) fn try_stream_wav_export(
60 + source: &Path,
61 + dest: &Path,
62 + target_channels: &ExportChannels,
63 + target_rate: Option<u32>,
64 + bit_depth: u16,
65 + cancel: &AtomicBool,
66 + ) -> Result<bool, CoreError> {
67 + if !matches!(bit_depth, 8 | 16 | 24 | 32) {
68 + return Err(CoreError::Export(format!(
69 + "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)"
70 + )));
71 + }
72 +
73 + let mut dec = open_decoder(source)?;
74 + // Exact streaming needs the total frame count to size the resampler output.
75 + let Some(n_frames) = dec.n_frames else {
76 + return Ok(false);
77 + };
78 + let n_frames = n_frames as usize;
79 +
80 + let src_rate = dec.sample_rate;
81 + let src_channels = dec.channels;
82 + let out_ch = out_channel_count(target_channels, src_channels) as usize;
83 + let dst_rate = target_rate.unwrap_or(src_rate);
84 +
85 + let spec = WavSpec {
86 + channels: out_ch as u16,
87 + sample_rate: dst_rate,
88 + bits_per_sample: bit_depth,
89 + sample_format: sample_format_for(bit_depth),
90 + };
91 + let mut writer =
92 + WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
93 + let mut rng = SimpleRng::new(dither_seed(n_frames));
94 +
95 + if dst_rate == src_rate {
96 + // No resampling: decode → channel-convert → write, one packet at a time.
97 + let mut packet: Vec<f32> = Vec::new();
98 + loop {
99 + if cancel.load(Ordering::Acquire) {
100 + return Err(CoreError::Cancelled);
101 + }
102 + packet.clear();
103 + if !decode_next_into(
104 + dec.format.as_mut(),
105 + dec.decoder.as_mut(),
106 + dec.track_id,
107 + &mut packet,
108 + )? {
109 + break;
110 + }
111 + let (converted, _) = convert_channels(&packet, src_channels, target_channels);
112 + for &s in &converted {
113 + write_sample(&mut writer, s, bit_depth, &mut rng)?;
114 + }
115 + }
116 + } else {
117 + if src_channels == 0 || src_rate == 0 || dst_rate == 0 {
118 + return Err(CoreError::Export(format!(
119 + "invalid resample params: channels={src_channels}, src_rate={src_rate}, dst_rate={dst_rate}"
120 + )));
121 + }
122 + let ratio = dst_rate as f64 / src_rate as f64;
123 + let target_total = ((n_frames as f64) * ratio).round() as usize;
124 + let mut resampler = make_resampler(src_rate, dst_rate, out_ch)?;
125 +
126 + // Per-channel input accumulators (de-interleaved). Full RESAMPLE_CHUNK
127 + // blocks are emitted as they fill, so the resampler sees the same
128 + // chunk-aligned input sequence as the buffered path regardless of how
129 + // the decoder packetizes the file.
130 + let mut acc: Vec<Vec<f32>> = vec![Vec::new(); out_ch];
131 + let mut written = 0usize;
132 + let mut packet: Vec<f32> = Vec::new();
133 +
134 + loop {
135 + if cancel.load(Ordering::Acquire) {
136 + return Err(CoreError::Cancelled);
137 + }
138 + packet.clear();
139 + if !decode_next_into(
140 + dec.format.as_mut(),
141 + dec.decoder.as_mut(),
142 + dec.track_id,
143 + &mut packet,
144 + )? {
145 + break;
146 + }
147 + let (converted, _) = convert_channels(&packet, src_channels, target_channels);
148 + let frames = converted.len() / out_ch;
149 + for fr in 0..frames {
150 + for (c, channel) in acc.iter_mut().enumerate() {
151 + channel.push(converted[fr * out_ch + c]);
152 + }
153 + }
154 + while acc[0].len() >= RESAMPLE_CHUNK {
155 + let input: Vec<Vec<f32>> =
156 + acc.iter().map(|b| b[..RESAMPLE_CHUNK].to_vec()).collect();
157 + for b in acc.iter_mut() {
158 + b.drain(..RESAMPLE_CHUNK);
159 + }
160 + let out = resampler
161 + .process(&input, None)
162 + .map_err(|e| CoreError::Export(format!("resample: {e}")))?;
163 + write_block(&mut writer, &out, bit_depth, &mut rng, target_total, &mut written)?;
164 + }
165 + }
166 +
167 + // Final short chunk (process_partial zero-pads internally), matching the
168 + // buffered path's handling of the last sub-chunk.
169 + if !acc[0].is_empty() {
170 + let out = resampler
171 + .process_partial(Some(acc.as_slice()), None)
172 + .map_err(|e| CoreError::Export(format!("resample: {e}")))?;
173 + write_block(&mut writer, &out, bit_depth, &mut rng, target_total, &mut written)?;
174 + }
175 +
176 + // Drain the resampler's latency tail until we have target_total frames.
177 + while written < target_total {
178 + let out = resampler
179 + .process_partial::<Vec<f32>>(None, None)
180 + .map_err(|e| CoreError::Export(format!("resample flush: {e}")))?;
181 + let produced = out.first().map_or(0, |c| c.len());
182 + write_block(&mut writer, &out, bit_depth, &mut rng, target_total, &mut written)?;
183 + if produced == 0 {
184 + break;
185 + }
186 + }
187 + }
188 +
189 + writer
190 + .finalize()
191 + .map_err(|e| io_err(dest, std::io::Error::other(e)))?;
192 + Ok(true)
193 + }
194 +
195 + #[cfg(test)]
196 + mod tests {
197 + use super::*;
198 + use crate::export::{convert, decode, encode};
199 +
200 + fn write_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) {
201 + let spec = WavSpec {
202 + channels,
203 + sample_rate,
204 + bits_per_sample: 32,
205 + sample_format: hound::SampleFormat::Float,
206 + };
207 + let mut w = WavWriter::create(path, spec).unwrap();
208 + for &s in samples {
209 + w.write_sample(s).unwrap();
210 + }
211 + w.finalize().unwrap();
212 + }
213 +
214 + fn read_i32(path: &Path) -> (hound::WavSpec, Vec<i32>) {
215 + let mut r = hound::WavReader::open(path).unwrap();
216 + let spec = r.spec();
217 + let s = r.samples::<i32>().map(|x| x.unwrap()).collect();
218 + (spec, s)
219 + }
220 +
221 + /// The buffered reference: decode → convert → encode_wav.
222 + fn buffered_export(
223 + source: &Path,
224 + dest: &Path,
225 + target_channels: &ExportChannels,
226 + target_rate: Option<u32>,
227 + bit_depth: u16,
228 + ) {
229 + let decoded = decode::decode_multichannel(source).unwrap();
230 + let converted = convert::apply_conversion(
231 + &decoded.samples,
232 + decoded.channels,
233 + decoded.sample_rate,
234 + target_channels,
235 + target_rate,
236 + )
237 + .unwrap();
238 + encode::encode_wav(&converted, bit_depth, dest).unwrap();
239 + }
240 +
241 + fn assert_streaming_matches_buffered(
242 + src_channels: u16,
243 + src_rate: u32,
244 + samples: &[f32],
245 + target_channels: ExportChannels,
246 + target_rate: Option<u32>,
247 + ) {
248 + let dir = tempfile::tempdir().unwrap();
249 + let src = dir.path().join("src.wav");
250 + write_wav(&src, src_channels, src_rate, samples);
251 +
252 + // 24-bit: deterministic (no dither), so the two paths must be byte-equal.
253 + let streamed = dir.path().join("streamed.wav");
254 + let did = try_stream_wav_export(
255 + &src,
256 + &streamed,
257 + &target_channels,
258 + target_rate,
259 + 24,
260 + &AtomicBool::new(false),
261 + )
262 + .unwrap();
263 + assert!(did, "WAV source must be streamable (known frame count)");
264 +
265 + let buffered = dir.path().join("buffered.wav");
266 + buffered_export(&src, &buffered, &target_channels, target_rate, 24);
267 +
268 + let (spec_s, samp_s) = read_i32(&streamed);
269 + let (spec_b, samp_b) = read_i32(&buffered);
270 + assert_eq!(spec_s.channels, spec_b.channels, "channel count");
271 + assert_eq!(spec_s.sample_rate, spec_b.sample_rate, "sample rate");
272 + assert_eq!(
273 + samp_s.len(),
274 + samp_b.len(),
275 + "streamed frame count must match buffered exactly"
276 + );
277 + assert_eq!(samp_s, samp_b, "streamed samples must be bit-identical to buffered");
278 + }
279 +
280 + #[test]
281 + fn streaming_matches_buffered_no_resample_mono() {
282 + let samples: Vec<f32> = (0..5000).map(|i| ((i % 73) as f32 / 73.0) - 0.5).collect();
283 + assert_streaming_matches_buffered(1, 44100, &samples, ExportChannels::Original, None);
284 + }
285 +
286 + #[test]
287 + fn streaming_matches_buffered_no_resample_stereo_to_mono() {
288 + let samples: Vec<f32> = (0..6000).map(|i| ((i % 50) as f32 / 50.0) - 0.5).collect();
289 + assert_streaming_matches_buffered(2, 48000, &samples, ExportChannels::Mono, None);
290 + }
291 +
292 + #[test]
293 + fn streaming_matches_buffered_downsample_mono() {
294 + // Many full chunks plus a partial, so the chunk/flush boundaries exercise.
295 + let samples: Vec<f32> = (0..9001).map(|i| ((i % 97) as f32 / 97.0) - 0.5).collect();
296 + assert_streaming_matches_buffered(1, 48000, &samples, ExportChannels::Original, Some(24000));
297 + }
298 +
299 + #[test]
300 + fn streaming_matches_buffered_upsample_stereo() {
301 + let samples: Vec<f32> = (0..8000).map(|i| ((i % 41) as f32 / 41.0) - 0.5).collect();
302 + assert_streaming_matches_buffered(2, 44100, &samples, ExportChannels::Original, Some(48000));
303 + }
304 + }
305 +