Skip to main content

max / audiofiles

Perf A+: generalize the streaming pipeline (AIFF + edit gain/fade), cap whole-file decode Replaces the WAV-only streaming export with one driver — decode -> per-frame edit stages -> channel-convert -> resample -> encode — serving three callers: - FrameStream: chunked pull-decoder over a source. - StreamStage: Gain / FadeIn / FadeOut, reusing the buffered DSP (edit::gain, FadeCurve::gain) so streamed edits are bit-identical to buffered. - StreamSink: WAV and AIFF, reusing the buffered quantisers (encode::write_sample, encode_aiff::write_aiff_sample, now shared via aiff_header_bytes/write_aiff_sample) so streamed output is byte-identical. Wires AIFF export and the streamable edit ops (gain/fade) through it, so neither holds the whole file in RAM (previously AIFF always buffered; edit always decoded whole). Ops that genuinely need the whole signal (reverse, normalize, trim, channel conversion) keep the buffered path, now bounded by a 2 GiB resident cap in decode_multichannel (new CoreError::FileTooLarge) so a pathological source fails loudly instead of OOMing. Tests assert streamed == buffered byte-for-byte for WAV, AIFF (incl. resample + SSND pad), and edit gain/fade across all curves; plus the resident-cap boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 22:08 UTC
Commit: 69b70fe6f49adde1c44b29876893bca89edd8371
Parent: 581abad
8 files changed, +496 insertions, -154 deletions
@@ -19,7 +19,10 @@ pub enum FadeCurve {
19 19 impl FadeCurve {
20 20 /// Compute the gain at position `t` (0.0 = start, 1.0 = end of fade region).
21 21 /// Returns a value in [0.0, 1.0].
22 - fn gain(&self, t: f32) -> f32 {
22 + ///
23 + /// `pub(crate)` so the streaming edit pipeline computes the same per-frame gain
24 + /// as the buffered fade, keeping the two paths bit-identical.
25 + pub(crate) fn gain(&self, t: f32) -> f32 {
23 26 match self {
24 27 FadeCurve::Linear => t,
25 28 FadeCurve::Logarithmic => {
@@ -147,7 +147,18 @@ fn process_edit(
147 147 operation: &EditOperation,
148 148 cancel_flag: &AtomicBool,
149 149 ) -> Result<PathBuf, crate::error::CoreError> {
150 - // 1. Decode
150 + let temp_dir = std::env::temp_dir().join("audiofiles_edit");
151 + std::fs::create_dir_all(&temp_dir).map_err(|e| crate::error::io_err(&temp_dir, e))?;
152 + let temp_path = temp_dir.join(format!("edit_{}.wav", uuid_simple()));
153 +
154 + // Streamable ops (gain, fade) never hold the whole file in RAM: decode →
155 + // stage → 24-bit WAV, bit-identical to the buffered path. Other ops (reverse,
156 + // normalize, trim, channel conversion) need the whole signal and fall through.
157 + if crate::export::stream::try_stream_edit_wav(source_path, &temp_path, operation, cancel_flag)? {
158 + return Ok(temp_path);
159 + }
160 +
161 + // 1. Decode (bounded by the resident-memory cap in decode_multichannel).
151 162 let mut decoded = decode_multichannel(source_path)?;
152 163
153 164 // Check cancel between decode and edit
@@ -168,13 +179,7 @@ fn process_edit(
168 179 return Err(crate::error::CoreError::Internal("edit cancelled".to_string()));
169 180 }
170 181
171 - // 3. Write to temp WAV (24-bit to preserve quality)
172 - let temp_dir = std::env::temp_dir().join("audiofiles_edit");
173 - std::fs::create_dir_all(&temp_dir).map_err(|e| crate::error::io_err(&temp_dir, e))?;
174 -
175 - let temp_name = format!("edit_{}.wav", uuid_simple());
176 - let temp_path = temp_dir.join(temp_name);
177 -
182 + // 3. Write to the temp WAV reserved above (24-bit to preserve quality).
178 183 let converted = ConvertedAudio {
179 184 samples: decoded.samples,
180 185 sample_rate: decoded.sample_rate,
@@ -67,6 +67,12 @@ pub enum CoreError {
67 67 #[error("export error: {0}")]
68 68 Export(String),
69 69
70 + /// A whole-file operation (an op that can't stream, e.g. reverse/normalize)
71 + /// would exceed the in-memory decode ceiling. Surfaced to the user rather than
72 + /// risking an OOM. Message is user-facing.
73 + #[error("{0}")]
74 + FileTooLarge(String),
75 +
70 76 /// JSON serialization/deserialization failure.
71 77 #[error("serialization error: {0}")]
72 78 Serialization(String),
@@ -121,12 +121,38 @@ pub(crate) fn decode_next_into(
121 121 }
122 122 }
123 123
124 + /// Ceiling on a whole-file decode held resident in RAM (interleaved f32). Ops
125 + /// that genuinely need the whole signal — reverse, peak/LUFS normalize, forge
126 + /// slice-boundary scan — can't stream, so a pathologically long source must fail
127 + /// loudly here rather than OOM the process. Streamable ops (gain, fade, format
128 + /// convert/export) bypass this via the streaming pipeline and have no such limit.
129 + /// 2 GiB of f32 ≈ 3 h of 48 kHz stereo, well past any real sample.
130 + pub(crate) const MAX_DECODE_RESIDENT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
131 +
132 + fn too_large_err(bytes: u64) -> CoreError {
133 + CoreError::FileTooLarge(format!(
134 + "file is too large to load into memory for this operation \
135 + ({bytes} bytes > {MAX_DECODE_RESIDENT_BYTES} cap); \
136 + this operation needs the whole file at once"
137 + ))
138 + }
139 +
140 + /// Resident byte cost of `frames` interleaved f32 frames at `channels`, saturating.
141 + fn resident_bytes(frames: u64, channels: u16) -> u64 {
142 + frames
143 + .saturating_mul(channels.max(1) as u64)
144 + .saturating_mul(std::mem::size_of::<f32>() as u64)
145 + }
146 +
124 147 /// Decode an audio file preserving its original channel layout.
125 148 ///
126 149 /// `pub(crate)`: full-file decode is reachable only from core worker/runner code
127 150 /// (edit/forge/export workers, [`crate::forge::compute_preview_marks`]), never
128 151 /// from the browser UI, so a synchronous decode can't creep back onto the egui
129 152 /// frame thread (ultra-fuzz Chronic #1).
153 + ///
154 + /// Bounded by [`MAX_DECODE_RESIDENT_BYTES`]: rejected up front when the container
155 + /// reports a frame count over the cap, and incrementally for sources that don't.
130 156 #[instrument(skip_all)]
131 157 pub(crate) fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, CoreError> {
132 158 let OpenedDecoder {
@@ -135,11 +161,25 @@ pub(crate) fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, Co
135 161 track_id,
136 162 sample_rate,
137 163 channels,
138 - ..
164 + n_frames,
139 165 } = open_decoder(path)?;
140 166
167 + // Up-front reject when the size is known (PCM WAV etc.), before allocating.
168 + if let Some(nf) = n_frames {
169 + let bytes = resident_bytes(nf, channels);
170 + if bytes > MAX_DECODE_RESIDENT_BYTES {
171 + return Err(too_large_err(bytes));
172 + }
173 + }
174 +
141 175 let mut all_samples: Vec<f32> = Vec::new();
142 - while decode_next_into(format.as_mut(), decoder.as_mut(), track_id, &mut all_samples)? {}
176 + while decode_next_into(format.as_mut(), decoder.as_mut(), track_id, &mut all_samples)? {
177 + // Incremental guard for sources with no reported frame count.
178 + let bytes = (all_samples.len() as u64).saturating_mul(std::mem::size_of::<f32>() as u64);
179 + if bytes > MAX_DECODE_RESIDENT_BYTES {
180 + return Err(too_large_err(bytes));
181 + }
182 + }
143 183
144 184 if all_samples.is_empty() {
145 185 return Err(AnalysisError::NoAudioData.into());
@@ -217,4 +257,23 @@ mod tests {
217 257 let result = decode_multichannel(&PathBuf::from("/nonexistent/audio.wav"));
218 258 assert!(result.is_err());
219 259 }
260 +
261 + #[test]
262 + fn resident_cap_boundary() {
263 + // The largest in-bounds size must pass; one frame over must trip.
264 + let bytes_per_frame = std::mem::size_of::<f32>() as u64 * 2; // stereo
265 + let max_frames = MAX_DECODE_RESIDENT_BYTES / bytes_per_frame;
266 + assert!(resident_bytes(max_frames, 2) <= MAX_DECODE_RESIDENT_BYTES);
267 + assert!(resident_bytes(max_frames + 1, 2) > MAX_DECODE_RESIDENT_BYTES);
268 + // Saturating: an absurd frame count can't wrap to a small value.
269 + assert_eq!(resident_bytes(u64::MAX, 8), u64::MAX);
270 + }
271 +
272 + #[test]
273 + fn too_large_err_is_file_too_large() {
274 + assert!(matches!(
275 + too_large_err(MAX_DECODE_RESIDENT_BYTES + 1),
276 + CoreError::FileTooLarge(_)
277 + ));
278 + }
220 279 }
@@ -24,29 +24,25 @@ fn aiff_sample_data_size(num_frames: usize, channels: u16, bit_depth: u16) -> Re
24 24 Ok(sample_data_size_u64 as u32)
25 25 }
26 26
27 - /// Encode audio to an AIFF file at the given path.
27 + /// Build the AIFF FORM/COMM/SSND header bytes for a file of `num_frames` frames,
28 + /// returning the header and whether a trailing SSND pad byte is required (true
29 + /// when the sample data is an odd number of bytes).
28 30 ///
29 - /// - 16-bit: applies TPDF dither before quantization (same algorithm as WAV encoder).
30 - /// - 24-bit: direct f32-to-i32 scaling, no dither needed.
31 - pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result<(), CoreError> {
32 - if bit_depth != 16 && bit_depth != 24 {
33 - return Err(CoreError::Export(format!(
34 - "AIFF: unsupported bit depth: {bit_depth}"
35 - )));
36 - }
37 -
38 - let channels = audio.channels;
39 - if channels == 0 {
40 - return Err(CoreError::Export("AIFF: 0 channels".to_string()));
41 - }
42 - let num_frames = audio.samples.len() / channels as usize;
43 -
31 + /// Shared by the buffered [`encode_aiff`] and the streaming AIFF sink so both
32 + /// emit byte-identical containers. The frame count must be known up front (the
33 + /// streaming path gets it from the source's exact frame count + resample ratio).
34 + pub(crate) fn aiff_header_bytes(
35 + channels: u16,
36 + sample_rate: u32,
37 + bit_depth: u16,
38 + num_frames: usize,
39 + ) -> Result<(Vec<u8>, bool), CoreError> {
44 40 let sample_data_size = aiff_sample_data_size(num_frames, channels, bit_depth)?;
45 41
46 42 // SSND chunk: 8 (offset + block_size) + sample data
47 - let ssnd_chunk_size = 8u32.checked_add(sample_data_size).ok_or_else(|| {
48 - CoreError::Export("AIFF: SSND chunk size overflow".to_string())
49 - })?;
43 + let ssnd_chunk_size = 8u32
44 + .checked_add(sample_data_size)
45 + .ok_or_else(|| CoreError::Export("AIFF: SSND chunk size overflow".to_string()))?;
50 46 // COMM chunk: always 18 bytes for standard AIFF
51 47 let comm_chunk_size: u32 = 18;
52 48
@@ -65,59 +61,86 @@ pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Resul
65 61 .and_then(|v| v.checked_add(ssnd_pad))
66 62 .ok_or_else(|| CoreError::Export("AIFF: FORM size overflow".to_string()))?;
67 63
68 - let mut buf = Vec::with_capacity(12 + form_size as usize);
69 -
70 - // FORM header
64 + let mut buf = Vec::with_capacity(54);
71 65 buf.extend_from_slice(b"FORM");
72 66 buf.extend_from_slice(&form_size.to_be_bytes());
73 67 buf.extend_from_slice(b"AIFF");
74 68
75 - // COMM chunk
76 69 buf.extend_from_slice(b"COMM");
77 70 buf.extend_from_slice(&comm_chunk_size.to_be_bytes());
78 71 buf.extend_from_slice(&channels.to_be_bytes()); // numChannels (i16)
79 72 buf.extend_from_slice(&(num_frames as u32).to_be_bytes()); // numSampleFrames (u32)
80 73 buf.extend_from_slice(&bit_depth.to_be_bytes()); // sampleSize (i16)
81 - buf.extend_from_slice(&sample_rate_to_extended(audio.sample_rate)); // 80-bit extended
74 + buf.extend_from_slice(&sample_rate_to_extended(sample_rate)); // 80-bit extended
82 75
83 - // SSND chunk
84 76 buf.extend_from_slice(b"SSND");
85 77 buf.extend_from_slice(&ssnd_chunk_size.to_be_bytes());
86 78 buf.extend_from_slice(&0u32.to_be_bytes()); // offset
87 79 buf.extend_from_slice(&0u32.to_be_bytes()); // blockSize
88 80
89 - // Sample data (big-endian)
81 + Ok((buf, ssnd_pad == 1))
82 + }
83 +
84 + /// Quantize and write one f32 sample as big-endian AIFF sample data. 16-bit
85 + /// applies fixed-seed TPDF dither (matching the WAV encoder); 24-bit scales
86 + /// directly. Shared by the buffered and streaming AIFF paths so both quantize
87 + /// identically.
88 + pub(crate) fn write_aiff_sample<W: Write>(
89 + out: &mut W,
90 + sample: f32,
91 + bit_depth: u16,
92 + rng: &mut SimpleRng,
93 + ) -> Result<(), CoreError> {
90 94 match bit_depth {
91 95 16 => {
92 - // Fixed seed, same as the WAV encoder, so AIFF dithered exports are
93 - // reproducible too. A pointer-derived seed varied run-to-run with the
94 - // allocator address, making AIFF output nondeterministic and any
95 - // byte-parity test flaky by construction.
96 - let mut rng = SimpleRng::new(super::encode::DITHER_SEED);
97 96 let scale = -(i16::MIN as f32); // 32768 = 2^15; see encode::write_sample
98 - for &sample in &audio.samples {
99 - let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
100 - let dithered = (sample + dither) * scale;
101 - let clamped = dithered.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
102 - buf.extend_from_slice(&clamped.to_be_bytes());
103 - }
97 + let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
98 + let clamped = ((sample + dither) * scale)
99 + .round()
100 + .clamp(i16::MIN as f32, i16::MAX as f32) as i16;
101 + out.write_all(&clamped.to_be_bytes())
102 + .map_err(|e| CoreError::Export(format!("AIFF write: {e}")))
104 103 }
105 104 24 => {
106 105 let scale = 8_388_608.0f32; // 2^23
107 - for &sample in &audio.samples {
108 - let scaled = (sample * scale)
109 - .round()
110 - .clamp(-8_388_608.0, 8_388_607.0) as i32;
111 - let bytes = scaled.to_be_bytes();
112 - // Write only the 3 most significant bytes (big-endian 24-bit)
113 - buf.extend_from_slice(&bytes[1..4]);
114 - }
106 + let scaled = (sample * scale).round().clamp(-8_388_608.0, 8_388_607.0) as i32;
107 + let bytes = scaled.to_be_bytes();
108 + out.write_all(&bytes[1..4]) // 3 most significant bytes (big-endian 24-bit)
109 + .map_err(|e| CoreError::Export(format!("AIFF write: {e}")))
115 110 }
116 - _ => unreachable!(), // checked at top of function
111 + other => Err(CoreError::Export(format!("AIFF: unsupported bit depth: {other}"))),
112 + }
113 + }
114 +
115 + /// Encode audio to an AIFF file at the given path.
116 + ///
117 + /// - 16-bit: applies TPDF dither before quantization (same algorithm as WAV encoder).
118 + /// - 24-bit: direct f32-to-i32 scaling, no dither needed.
119 + pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result<(), CoreError> {
120 + if bit_depth != 16 && bit_depth != 24 {
121 + return Err(CoreError::Export(format!(
122 + "AIFF: unsupported bit depth: {bit_depth}"
123 + )));
124 + }
125 +
126 + let channels = audio.channels;
127 + if channels == 0 {
128 + return Err(CoreError::Export("AIFF: 0 channels".to_string()));
129 + }
130 + let num_frames = audio.samples.len() / channels as usize;
131 +
132 + let (header, needs_pad) = aiff_header_bytes(channels, audio.sample_rate, bit_depth, num_frames)?;
133 + let mut buf = header;
134 +
135 + // Fixed seed, same as the WAV encoder, so AIFF dithered exports are
136 + // reproducible too (16-bit only; 24-bit ignores the RNG).
137 + let mut rng = SimpleRng::new(super::encode::DITHER_SEED);
138 + for &sample in &audio.samples {
139 + write_aiff_sample(&mut buf, sample, bit_depth, &mut rng)?;
117 140 }
118 141
119 142 // Word-align the SSND chunk with a trailing pad byte when its data is odd.
120 - if ssnd_pad == 1 {
143 + if needs_pad {
121 144 buf.push(0);
122 145 }
123 146
@@ -8,7 +8,7 @@ pub mod encode_aiff;
8 8 pub mod profile;
9 9 mod resolve;
10 10 mod runner;
11 - mod stream;
11 + pub(crate) mod stream;
12 12 pub mod sanitize;
13 13
14 14 use std::path::PathBuf;
@@ -246,16 +246,32 @@ fn export_single_item(
246 246 })
247 247 }
248 248 ExportFormat::Aiff => {
249 - let decoded = decode::decode_multichannel(source)?;
250 - let converted = convert::apply_conversion(
251 - &decoded.samples,
252 - decoded.channels,
253 - decoded.sample_rate,
254 - &config.channels,
255 - config.sample_rate,
256 - )?;
257 249 let bit_depth = config.bit_depth.unwrap_or(24);
258 - write_atomic(dest, |tmp| encode_aiff::encode_aiff(&converted, bit_depth, tmp))
250 + write_atomic(dest, |tmp| {
251 + // Stream when the source reports an exact frame count: bounded
252 + // memory and byte-identical to the buffered AIFF encoder. Fall back
253 + // to buffered decode+convert+encode otherwise.
254 + let streamed = super::stream::try_stream_aiff_export(
255 + source,
256 + tmp,
257 + &config.channels,
258 + config.sample_rate,
259 + bit_depth,
260 + &std::sync::atomic::AtomicBool::new(false),
261 + )?;
262 + if streamed {
263 + return Ok(());
264 + }
265 + let decoded = decode::decode_multichannel(source)?;
266 + let converted = convert::apply_conversion(
267 + &decoded.samples,
268 + decoded.channels,
269 + decoded.sample_rate,
270 + &config.channels,
271 + config.sample_rate,
272 + )?;
273 + encode_aiff::encode_aiff(&converted, bit_depth, tmp)
274 + })
259 275 }
260 276 }
261 277 }
@@ -1,12 +1,22 @@
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.
1 + //! Streaming audio pipeline: decode → [per-frame edit stages] → channel-convert
2 + //! → resample → encode, in a bounded window, so a multi-GB source never
3 + //! materialises as a 2-4x f32 buffer.
3 4 //!
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.
5 + //! One driver ([`process_streaming`]) serves three callers — WAV export, AIFF
6 + //! export, and the streamable edit ops (gain/fade) — by varying two things: the
7 + //! [`StreamStage`]s applied per frame (none for export) and the [`StreamSink`]
8 + //! that quantises/encodes the output. Sinks reuse the exact buffered quantisation
9 + //! (`encode::write_sample`, `encode_aiff::write_aiff_sample`) and stages reuse the
10 + //! exact buffered DSP (`edit::gain::apply_gain`, `FadeCurve::gain`), so every
11 + //! streamed result is byte-identical to its buffered equivalent.
12 + //!
13 + //! Streaming needs the source's exact frame count (PCM WAV reports it; AIFF needs
14 + //! it for the header up front, the resampler to size its output). Sources without
15 + //! one (e.g. some compressed formats) return `Ok(false)` so the caller falls back
16 + //! to the buffered pipeline.
8 17
9 - use std::io::{Seek, Write};
18 + use std::fs::File;
19 + use std::io::{BufWriter, Write};
10 20 use std::path::Path;
11 21 use std::sync::atomic::{AtomicBool, Ordering};
12 22
@@ -14,10 +24,13 @@ use hound::{WavSpec, WavWriter};
14 24 use rubato::Resampler;
15 25
16 26 use super::convert::{convert_channels, make_resampler, RESAMPLE_CHUNK};
17 - use super::decode::{decode_next_into, open_decoder};
27 + use super::decode::{decode_next_into, open_decoder, OpenedDecoder};
18 28 use super::dither::SimpleRng;
19 - use super::encode::{sample_format_for, write_sample, DITHER_SEED};
29 + use super::encode::{sample_format_for, wav_size_check, write_sample, DITHER_SEED};
30 + use super::encode_aiff::{aiff_header_bytes, write_aiff_sample};
20 31 use super::ExportChannels;
32 + use crate::edit::fade::FadeCurve;
33 + use crate::edit::EditOperation;
21 34 use crate::error::{io_err, CoreError};
22 35
23 36 /// Resolve a concrete output channel count from the target spec + source.
@@ -29,98 +42,284 @@ fn out_channel_count(target: &ExportChannels, src_channels: u16) -> u16 {
29 42 }
30 43 }
31 44
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>],
45 + // ── FrameStream: chunked decoder ───────────────────────────────────────────
46 +
47 + /// A pull-based reader over a decoded source: format parameters plus a
48 + /// `next_block` that decodes one packet's worth of interleaved frames at a time.
49 + pub(crate) struct FrameStream {
50 + dec: OpenedDecoder,
51 + }
52 +
53 + impl FrameStream {
54 + pub(crate) fn open(path: &Path) -> Result<Self, CoreError> {
55 + Ok(Self {
56 + dec: open_decoder(path)?,
57 + })
58 + }
59 +
60 + pub(crate) fn sample_rate(&self) -> u32 {
61 + self.dec.sample_rate
62 + }
63 + pub(crate) fn channels(&self) -> u16 {
64 + self.dec.channels
65 + }
66 + pub(crate) fn n_frames(&self) -> Option<u64> {
67 + self.dec.n_frames
68 + }
69 +
70 + /// Decode the next packet's frames into `out` (interleaved, appended).
71 + /// Returns `Ok(false)` at end of stream.
72 + pub(crate) fn next_block(&mut self, out: &mut Vec<f32>) -> Result<bool, CoreError> {
73 + decode_next_into(
74 + self.dec.format.as_mut(),
75 + self.dec.decoder.as_mut(),
76 + self.dec.track_id,
77 + out,
78 + )
79 + }
80 + }
81 +
82 + // ── StreamStage: per-frame edit transforms (source layout) ─────────────────
83 +
84 + /// A per-frame transform applied to interleaved source-layout audio before
85 + /// channel-convert/resample. `frame_offset` is the absolute frame index of the
86 + /// block's first frame, so position-dependent ops (fades) work across blocks.
87 + pub(crate) trait StreamStage {
88 + fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64);
89 + }
90 +
91 + /// Gain in dB. Reuses `edit::gain::apply_gain` per block so streamed gain is
92 + /// bit-identical to the buffered edit (same dB→linear scale, same clamp).
93 + struct GainStage {
94 + db: f64,
95 + }
96 + impl StreamStage for GainStage {
97 + fn process(&mut self, block: &mut [f32], _channels: u16, _frame_offset: u64) {
98 + crate::edit::gain::apply_gain(block, self.db);
99 + }
100 + }
101 +
102 + /// Fade-in over the first `fade_frames` frames; identity afterwards. Matches
103 + /// `edit::fade::apply_fade_in`'s `t = i / (fade_frames-1).max(1)`.
104 + struct FadeInStage {
105 + fade_frames: u64,
106 + curve: FadeCurve,
107 + }
108 + impl StreamStage for FadeInStage {
109 + fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64) {
110 + if self.fade_frames == 0 {
111 + return;
112 + }
113 + let ch = channels as usize;
114 + if ch == 0 {
115 + return;
116 + }
117 + let denom = (self.fade_frames - 1).max(1) as f32;
118 + let frames = block.len() / ch;
119 + for fr in 0..frames {
120 + let abs = frame_offset + fr as u64;
121 + if abs >= self.fade_frames {
122 + break; // rest of the file is identity
123 + }
124 + let g = self.curve.gain(abs as f32 / denom);
125 + for c in 0..ch {
126 + block[fr * ch + c] *= g;
127 + }
128 + }
129 + }
130 + }
131 +
132 + /// Fade-out over the last `fade_frames` frames. Matches
133 + /// `edit::fade::apply_fade_out`'s `t = 1 - i / (fade_frames-1).max(1)`.
134 + struct FadeOutStage {
135 + total_frames: u64,
136 + fade_frames: u64,
137 + curve: FadeCurve,
138 + }
139 + impl StreamStage for FadeOutStage {
140 + fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64) {
141 + if self.fade_frames == 0 {
142 + return;
143 + }
144 + let ch = channels as usize;
145 + if ch == 0 {
146 + return;
147 + }
148 + let start = self.total_frames - self.fade_frames;
149 + let denom = (self.fade_frames - 1).max(1) as f32;
150 + let frames = block.len() / ch;
151 + for fr in 0..frames {
152 + let abs = frame_offset + fr as u64;
153 + if abs < start {
154 + continue;
155 + }
156 + let i = abs - start;
157 + let g = self.curve.gain(1.0 - (i as f32 / denom));
158 + for c in 0..ch {
159 + block[fr * ch + c] *= g;
160 + }
161 + }
162 + }
163 + }
164 +
165 + // ── StreamSink: incremental encoders ───────────────────────────────────────
166 +
167 + /// An incremental encoder fed one f32 sample at a time (interleaved order).
168 + pub(crate) trait StreamSink {
169 + fn write_sample(&mut self, sample: f32) -> Result<(), CoreError>;
170 + fn finalize(self) -> Result<(), CoreError>;
171 + }
172 +
173 + /// Streaming WAV sink. Reuses `encode::write_sample` so output matches the
174 + /// buffered `encode_wav` quantisation (and dither) at every bit depth.
175 + struct WavSink {
176 + writer: WavWriter<BufWriter<File>>,
38 177 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(());
178 + rng: SimpleRng,
179 + }
180 + impl WavSink {
181 + fn create(
182 + dest: &Path,
183 + channels: u16,
184 + sample_rate: u32,
185 + bit_depth: u16,
186 + out_frames: usize,
187 + ) -> Result<Self, CoreError> {
188 + wav_size_check((out_frames as u64).saturating_mul(channels as u64), bit_depth)?;
189 + let spec = WavSpec {
190 + channels,
191 + sample_rate,
192 + bits_per_sample: bit_depth,
193 + sample_format: sample_format_for(bit_depth),
194 + };
195 + let writer =
196 + WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
197 + Ok(Self {
198 + writer,
199 + bit_depth,
200 + rng: SimpleRng::new(DITHER_SEED),
201 + })
202 + }
203 + }
204 + impl StreamSink for WavSink {
205 + fn write_sample(&mut self, sample: f32) -> Result<(), CoreError> {
206 + write_sample(&mut self.writer, sample, self.bit_depth, &mut self.rng)
207 + }
208 + fn finalize(self) -> Result<(), CoreError> {
209 + self.writer
210 + .finalize()
211 + .map_err(|e| CoreError::Export(format!("WAV finalize: {e}")))
212 + }
213 + }
214 +
215 + /// Streaming AIFF sink. Writes the FORM/COMM/SSND header up front (frame count
216 + /// known) and reuses `encode_aiff::write_aiff_sample` so output matches the
217 + /// buffered `encode_aiff` byte-for-byte.
218 + struct AiffSink {
219 + writer: BufWriter<File>,
220 + bit_depth: u16,
221 + rng: SimpleRng,
222 + needs_pad: bool,
223 + }
224 + impl AiffSink {
225 + fn create(
226 + dest: &Path,
227 + channels: u16,
228 + sample_rate: u32,
229 + bit_depth: u16,
230 + out_frames: usize,
231 + ) -> Result<Self, CoreError> {
232 + if channels == 0 {
233 + return Err(CoreError::Export("AIFF: 0 channels".to_string()));
47 234 }
48 - for channel in per_channel {
49 - write_sample(writer, channel[f], bit_depth, rng)?;
235 + let (header, needs_pad) = aiff_header_bytes(channels, sample_rate, bit_depth, out_frames)?;
236 + let file = File::create(dest).map_err(|e| io_err(dest, e))?;
237 + let mut writer = BufWriter::new(file);
238 + writer.write_all(&header).map_err(|e| io_err(dest, e))?;
239 + Ok(Self {
240 + writer,
241 + bit_depth,
242 + rng: SimpleRng::new(DITHER_SEED),
243 + needs_pad,
244 + })
245 + }
246 + }
247 + impl StreamSink for AiffSink {
248 + fn write_sample(&mut self, sample: f32) -> Result<(), CoreError> {
249 + write_aiff_sample(&mut self.writer, sample, self.bit_depth, &mut self.rng)
250 + }
251 + fn finalize(mut self) -> Result<(), CoreError> {
252 + if self.needs_pad {
253 + self.writer
254 + .write_all(&[0u8])
255 + .map_err(|e| CoreError::Export(format!("AIFF pad: {e}")))?;
50 256 }
51 - *written += 1;
257 + self.writer
258 + .flush()
259 + .map_err(|e| CoreError::Export(format!("AIFF flush: {e}")))
52 260 }
53 - Ok(())
54 261 }
55 262
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,
263 + // ── Driver ─────────────────────────────────────────────────────────────────
264 +
265 + /// Run the streaming pipeline: decode → stages (source layout) → channel-convert
266 + /// → resample → sink. Returns `Ok(false)` if the source has no known frame count
267 + /// (the caller should fall back to the buffered pipeline). `make_sink` is given
268 + /// the resolved output channel count, rate, and exact frame count.
269 + fn process_streaming<S: StreamSink>(
270 + mut stream: FrameStream,
271 + stages: &mut [&mut dyn StreamStage],
62 272 target_channels: &ExportChannels,
63 273 target_rate: Option<u32>,
64 - bit_depth: u16,
65 274 cancel: &AtomicBool,
275 + make_sink: impl FnOnce(u16, u32, usize) -> Result<S, CoreError>,
66 276 ) -> 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 {
277 + let Some(n_frames) = stream.n_frames() else {
76 278 return Ok(false);
77 279 };
78 280 let n_frames = n_frames as usize;
79 281
80 - let src_rate = dec.sample_rate;
81 - let src_channels = dec.channels;
282 + let src_rate = stream.sample_rate();
283 + let src_channels = stream.channels();
284 + let src_ch = src_channels as usize;
82 285 let out_ch = out_channel_count(target_channels, src_channels) as usize;
83 286 let dst_rate = target_rate.unwrap_or(src_rate);
84 287
85 - // Reject a >4 GB output before writing a single byte (the streaming path
86 - // exists for long files, so this overflow is reachable here in a way the
87 - // buffered path rarely hits).
88 288 let out_frames = if dst_rate == src_rate || src_rate == 0 {
89 289 n_frames
90 290 } else {
91 291 ((n_frames as f64) * (dst_rate as f64 / src_rate as f64)).round() as usize
92 292 };
93 - super::encode::wav_size_check((out_frames as u64).saturating_mul(out_ch as u64), bit_depth)?;
94 293
95 - let spec = WavSpec {
96 - channels: out_ch as u16,
97 - sample_rate: dst_rate,
98 - bits_per_sample: bit_depth,
99 - sample_format: sample_format_for(bit_depth),
100 - };
101 - let mut writer =
102 - WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
103 - let mut rng = SimpleRng::new(DITHER_SEED);
294 + let mut sink = make_sink(out_ch as u16, dst_rate, out_frames)?;
295 + let mut frame_offset: u64 = 0;
296 + let mut packet: Vec<f32> = Vec::new();
297 +
298 + // Apply edit stages to a freshly decoded packet (source layout), advancing
299 + // the absolute frame offset.
300 + let apply_stages =
301 + |packet: &mut Vec<f32>, stages: &mut [&mut dyn StreamStage], frame_offset: &mut u64| {
302 + let frames = packet.len().checked_div(src_ch).unwrap_or(0);
303 + for stage in stages.iter_mut() {
304 + stage.process(packet, src_channels, *frame_offset);
305 + }
306 + *frame_offset += frames as u64;
307 + };
104 308
105 309 if dst_rate == src_rate {
106 - // No resampling: decode → channel-convert → write, one packet at a time.
107 - let mut packet: Vec<f32> = Vec::new();
310 + // No resampling: decode → stages → channel-convert → write.
108 311 loop {
109 312 if cancel.load(Ordering::Acquire) {
110 313 return Err(CoreError::Cancelled);
111 314 }
112 315 packet.clear();
113 - if !decode_next_into(
114 - dec.format.as_mut(),
115 - dec.decoder.as_mut(),
116 - dec.track_id,
117 - &mut packet,
118 - )? {
316 + if !stream.next_block(&mut packet)? {
119 317 break;
120 318 }
319 + apply_stages(&mut packet, stages, &mut frame_offset);
121 320 let (converted, _) = convert_channels(&packet, src_channels, target_channels);
122 321 for &s in &converted {
123 - write_sample(&mut writer, s, bit_depth, &mut rng)?;
322 + sink.write_sample(s)?;
124 323 }
125 324 }
126 325 } else {
@@ -129,31 +328,24 @@ pub(crate) fn try_stream_wav_export(
129 328 "invalid resample params: channels={src_channels}, src_rate={src_rate}, dst_rate={dst_rate}"
130 329 )));
131 330 }
132 - let ratio = dst_rate as f64 / src_rate as f64;
133 - let target_total = ((n_frames as f64) * ratio).round() as usize;
331 + let target_total = out_frames;
134 332 let mut resampler = make_resampler(src_rate, dst_rate, out_ch)?;
135 333
136 334 // Per-channel input accumulators (de-interleaved). Full RESAMPLE_CHUNK
137 335 // blocks are emitted as they fill, so the resampler sees the same
138 - // chunk-aligned input sequence as the buffered path regardless of how
139 - // the decoder packetizes the file.
336 + // chunk-aligned input sequence as the buffered path.
140 337 let mut acc: Vec<Vec<f32>> = vec![Vec::new(); out_ch];
141 338 let mut written = 0usize;
142 - let mut packet: Vec<f32> = Vec::new();
143 339
144 340 loop {
145 341 if cancel.load(Ordering::Acquire) {
146 342 return Err(CoreError::Cancelled);
147 343 }
148 344 packet.clear();
149 - if !decode_next_into(
150 - dec.format.as_mut(),
151 - dec.decoder.as_mut(),
152 - dec.track_id,
153 - &mut packet,
154 - )? {
345 + if !stream.next_block(&mut packet)? {
155 346 break;
156 347 }
348 + apply_stages(&mut packet, stages, &mut frame_offset);
157 349 let (converted, _) = convert_channels(&packet, src_channels, target_channels);
158 350 let frames = converted.len() / out_ch;
159 351 for fr in 0..frames {
@@ -170,17 +362,16 @@ pub(crate) fn try_stream_wav_export(
170 362 let out = resampler
171 363 .process(&input, None)
172 364 .map_err(|e| CoreError::Export(format!("resample: {e}")))?;
173 - write_block(&mut writer, &out, bit_depth, &mut rng, target_total, &mut written)?;
365 + write_block(&mut sink, &out, target_total, &mut written)?;
174 366 }
175 367 }
176 368
177 - // Final short chunk (process_partial zero-pads internally), matching the
178 - // buffered path's handling of the last sub-chunk.
369 + // Final short chunk (process_partial zero-pads internally).
179 370 if !acc[0].is_empty() {
180 371 let out = resampler
181 372 .process_partial(Some(acc.as_slice()), None)
182 373 .map_err(|e| CoreError::Export(format!("resample: {e}")))?;
183 - write_block(&mut writer, &out, bit_depth, &mut rng, target_total, &mut written)?;
374 + write_block(&mut sink, &out, target_total, &mut written)?;
184 375 }
185 376
186 377 // Drain the resampler's latency tail until we have target_total frames.
@@ -189,23 +380,141 @@ pub(crate) fn try_stream_wav_export(
189 380 .process_partial::<Vec<f32>>(None, None)
190 381 .map_err(|e| CoreError::Export(format!("resample flush: {e}")))?;
191 382 let produced = out.first().map_or(0, |c| c.len());
192 - write_block(&mut writer, &out, bit_depth, &mut rng, target_total, &mut written)?;
383 + write_block(&mut sink, &out, target_total, &mut written)?;
193 384 if produced == 0 {
194 385 break;
195 386 }
196 387 }
197 388 }
198 389
199 - writer
200 - .finalize()
201 - .map_err(|e| io_err(dest, std::io::Error::other(e)))?;
390 + sink.finalize()?;
202 391 Ok(true)
203 392 }
204 393
394 + /// Interleave and write one per-channel resampler output block to the sink,
395 + /// clamping the total to `target_total` frames (matching the buffered path, which
396 + /// drops the extra frames the resampler emits while flushing its latency tail).
397 + fn write_block<S: StreamSink>(
398 + sink: &mut S,
399 + per_channel: &[Vec<f32>],
400 + target_total: usize,
401 + written: &mut usize,
402 + ) -> Result<(), CoreError> {
403 + let frames = per_channel.first().map_or(0, |c| c.len());
404 + for f in 0..frames {
405 + if *written >= target_total {
406 + return Ok(());
407 + }
408 + for channel in per_channel {
409 + sink.write_sample(channel[f])?;
410 + }
411 + *written += 1;
412 + }
413 + Ok(())
414 + }
415 +
416 + // ── Public entry points ─────────────────────────────────────────────────────
417 +
418 + /// Attempt a streaming WAV export. `Ok(true)` if it streamed, `Ok(false)` if the
419 + /// source has no known frame count (caller falls back to buffered).
420 + pub(crate) fn try_stream_wav_export(
421 + source: &Path,
422 + dest: &Path,
423 + target_channels: &ExportChannels,
424 + target_rate: Option<u32>,
425 + bit_depth: u16,
426 + cancel: &AtomicBool,
427 + ) -> Result<bool, CoreError> {
428 + if !matches!(bit_depth, 8 | 16 | 24 | 32) {
429 + return Err(CoreError::Export(format!(
430 + "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)"
431 + )));
432 + }
433 + let stream = FrameStream::open(source)?;
434 + process_streaming(
Lines truncated