Skip to main content

max / audiofiles

31.8 KB · 909 lines History Blame Raw
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.
4 //!
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.
17
18 use std::fs::File;
19 use std::io::{BufWriter, Seek, SeekFrom, Write};
20 use std::path::Path;
21 use std::sync::atomic::{AtomicBool, Ordering};
22
23 use hound::{WavSpec, WavWriter};
24
25 use super::ExportChannels;
26 use super::convert::{RESAMPLE_CHUNK, convert_channels, make_resampler};
27 use super::decode::{OpenedDecoder, decode_next_into, open_decoder};
28 use super::dither::SimpleRng;
29 use super::encode::{DITHER_SEED, sample_format_for, wav_size_check, write_sample};
30 use super::encode_aiff::{aiff_header_bytes, write_aiff_sample};
31 use crate::edit::EditOperation;
32 use crate::edit::fade::FadeCurve;
33 use crate::error::{CoreError, io_err};
34
35 /// Resolve a concrete output channel count from the target spec + source.
36 fn out_channel_count(target: &ExportChannels, src_channels: u16) -> u16 {
37 match target {
38 ExportChannels::Original => src_channels.max(1),
39 ExportChannels::Mono => 1,
40 ExportChannels::Stereo => 2,
41 }
42 }
43
44 // ── FrameStream: chunked decoder ───────────────────────────────────────────
45
46 /// A pull-based reader over a decoded source: format parameters plus a
47 /// `next_block` that decodes one packet's worth of interleaved frames at a time.
48 pub(crate) struct FrameStream {
49 dec: OpenedDecoder,
50 }
51
52 impl FrameStream {
53 pub(crate) fn open(path: &Path) -> Result<Self, CoreError> {
54 Ok(Self {
55 dec: open_decoder(path)?,
56 })
57 }
58
59 pub(crate) fn sample_rate(&self) -> u32 {
60 self.dec.sample_rate
61 }
62 pub(crate) fn channels(&self) -> u16 {
63 self.dec.channels
64 }
65 pub(crate) fn n_frames(&self) -> Option<u64> {
66 self.dec.n_frames
67 }
68
69 /// Decode the next packet's frames into `out` (interleaved, appended).
70 /// Returns `Ok(false)` at end of stream.
71 pub(crate) fn next_block(&mut self, out: &mut Vec<f32>) -> Result<bool, CoreError> {
72 decode_next_into(
73 self.dec.format.as_mut(),
74 self.dec.decoder.as_mut(),
75 self.dec.track_id,
76 out,
77 )
78 }
79 }
80
81 // ── StreamStage: per-frame edit transforms (source layout) ─────────────────
82
83 /// A per-frame transform applied to interleaved source-layout audio before
84 /// channel-convert/resample. `frame_offset` is the absolute frame index of the
85 /// block's first frame, so position-dependent ops (fades) work across blocks.
86 pub(crate) trait StreamStage {
87 fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64);
88 }
89
90 /// Gain in dB. Reuses `edit::gain::apply_gain` per block so streamed gain is
91 /// bit-identical to the buffered edit (same dB→linear scale, same clamp).
92 struct GainStage {
93 db: f64,
94 }
95 impl StreamStage for GainStage {
96 fn process(&mut self, block: &mut [f32], _channels: u16, _frame_offset: u64) {
97 crate::edit::gain::apply_gain(block, self.db);
98 }
99 }
100
101 /// Fade-in over the first `fade_frames` frames; identity afterwards. Matches
102 /// `edit::fade::apply_fade_in`'s `t = i / (fade_frames-1).max(1)`.
103 struct FadeInStage {
104 fade_frames: u64,
105 curve: FadeCurve,
106 }
107 impl StreamStage for FadeInStage {
108 fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64) {
109 if self.fade_frames == 0 {
110 return;
111 }
112 let ch = channels as usize;
113 if ch == 0 {
114 return;
115 }
116 let denom = (self.fade_frames - 1).max(1) as f32;
117 let frames = block.len() / ch;
118 for fr in 0..frames {
119 let abs = frame_offset + fr as u64;
120 if abs >= self.fade_frames {
121 break; // rest of the file is identity
122 }
123 let g = self.curve.gain(abs as f32 / denom);
124 for c in 0..ch {
125 block[fr * ch + c] *= g;
126 }
127 }
128 }
129 }
130
131 /// Fade-out over the last `fade_frames` frames. Matches
132 /// `edit::fade::apply_fade_out`'s `t = 1 - i / (fade_frames-1).max(1)`.
133 struct FadeOutStage {
134 total_frames: u64,
135 fade_frames: u64,
136 curve: FadeCurve,
137 }
138 impl StreamStage for FadeOutStage {
139 fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64) {
140 if self.fade_frames == 0 {
141 return;
142 }
143 let ch = channels as usize;
144 if ch == 0 {
145 return;
146 }
147 let start = self.total_frames - self.fade_frames;
148 let denom = (self.fade_frames - 1).max(1) as f32;
149 let frames = block.len() / ch;
150 for fr in 0..frames {
151 let abs = frame_offset + fr as u64;
152 if abs < start {
153 continue;
154 }
155 let i = abs - start;
156 let g = self.curve.gain(1.0 - (i as f32 / denom));
157 for c in 0..ch {
158 block[fr * ch + c] *= g;
159 }
160 }
161 }
162 }
163
164 // ── StreamSink: incremental encoders ───────────────────────────────────────
165
166 /// An incremental encoder fed one f32 sample at a time (interleaved order).
167 pub(crate) trait StreamSink {
168 fn write_sample(&mut self, sample: f32) -> Result<(), CoreError>;
169 fn finalize(self) -> Result<(), CoreError>;
170 }
171
172 /// Streaming WAV sink. Reuses `encode::write_sample` so output matches the
173 /// buffered `encode_wav` quantisation (and dither) at every bit depth.
174 struct WavSink {
175 writer: WavWriter<BufWriter<File>>,
176 bit_depth: u16,
177 rng: SimpleRng,
178 }
179 impl WavSink {
180 fn create(
181 dest: &Path,
182 channels: u16,
183 sample_rate: u32,
184 bit_depth: u16,
185 out_frames: usize,
186 ) -> Result<Self, CoreError> {
187 wav_size_check(
188 (out_frames as u64).saturating_mul(channels as u64),
189 bit_depth,
190 )?;
191 let spec = WavSpec {
192 channels,
193 sample_rate,
194 bits_per_sample: bit_depth,
195 sample_format: sample_format_for(bit_depth),
196 };
197 let writer =
198 WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
199 Ok(Self {
200 writer,
201 bit_depth,
202 rng: SimpleRng::new(DITHER_SEED),
203 })
204 }
205 }
206 impl StreamSink for WavSink {
207 fn write_sample(&mut self, sample: f32) -> Result<(), CoreError> {
208 write_sample(&mut self.writer, sample, self.bit_depth, &mut self.rng)
209 }
210 fn finalize(self) -> Result<(), CoreError> {
211 self.writer
212 .finalize()
213 .map_err(|e| CoreError::Export(format!("WAV finalize: {e}")))
214 }
215 }
216
217 /// Streaming AIFF sink. Writes the FORM/COMM/SSND header up front (frame count
218 /// known) and reuses `encode_aiff::write_aiff_sample` so output matches the
219 /// buffered `encode_aiff` byte-for-byte.
220 struct AiffSink {
221 writer: BufWriter<File>,
222 channels: u16,
223 sample_rate: u32,
224 bit_depth: u16,
225 rng: SimpleRng,
226 /// Samples (not frames) actually written, so finalize can backpatch the
227 /// header with the true frame count rather than the up-front estimate.
228 samples_written: u64,
229 }
230 impl AiffSink {
231 fn create(
232 dest: &Path,
233 channels: u16,
234 sample_rate: u32,
235 bit_depth: u16,
236 out_frames: usize,
237 ) -> Result<Self, CoreError> {
238 if channels == 0 {
239 return Err(CoreError::Export("AIFF: 0 channels".to_string()));
240 }
241 // Initial header sized to the estimate; backpatched at finalize with the
242 // exact count. The header is a fixed-size block so the rewrite is in place.
243 let (header, _) = aiff_header_bytes(channels, sample_rate, bit_depth, out_frames)?;
244 let file = File::create(dest).map_err(|e| io_err(dest, e))?;
245 let mut writer = BufWriter::new(file);
246 writer.write_all(&header).map_err(|e| io_err(dest, e))?;
247 Ok(Self {
248 writer,
249 channels,
250 sample_rate,
251 bit_depth,
252 rng: SimpleRng::new(DITHER_SEED),
253 samples_written: 0,
254 })
255 }
256 }
257 impl StreamSink for AiffSink {
258 fn write_sample(&mut self, sample: f32) -> Result<(), CoreError> {
259 write_aiff_sample(&mut self.writer, sample, self.bit_depth, &mut self.rng)?;
260 self.samples_written += 1;
261 Ok(())
262 }
263 fn finalize(mut self) -> Result<(), CoreError> {
264 // The resampler can emit fewer frames than the up-front estimate for some
265 // ratios (round(in*ratio) overestimates the true output). Backpatch COMM
266 // numSampleFrames and the SSND/FORM sizes to the frames actually written
267 // so the container never overstates its sample count (strict hardware-
268 // sampler parsers reject that). Matches the buffered path, which sizes its
269 // header from the real output, and WAV streaming, which hound backpatches.
270 let frames = (self.samples_written / self.channels.max(1) as u64) as usize;
271 let (header, needs_pad) =
272 aiff_header_bytes(self.channels, self.sample_rate, self.bit_depth, frames)?;
273 if needs_pad {
274 self.writer
275 .write_all(&[0u8])
276 .map_err(|e| CoreError::Export(format!("AIFF pad: {e}")))?;
277 }
278 self.writer
279 .seek(SeekFrom::Start(0))
280 .map_err(|e| CoreError::Export(format!("AIFF seek: {e}")))?;
281 self.writer
282 .write_all(&header)
283 .map_err(|e| CoreError::Export(format!("AIFF header backpatch: {e}")))?;
284 self.writer
285 .flush()
286 .map_err(|e| CoreError::Export(format!("AIFF flush: {e}")))
287 }
288 }
289
290 // ── Driver ─────────────────────────────────────────────────────────────────
291
292 /// Run the streaming pipeline: decode → stages (source layout) → channel-convert
293 /// → resample → sink. Returns `Ok(false)` if the source has no known frame count
294 /// (the caller should fall back to the buffered pipeline). `make_sink` is given
295 /// the resolved output channel count, rate, and exact frame count.
296 fn process_streaming<S: StreamSink>(
297 mut stream: FrameStream,
298 stages: &mut [&mut dyn StreamStage],
299 target_channels: &ExportChannels,
300 target_rate: Option<u32>,
301 cancel: &AtomicBool,
302 make_sink: impl FnOnce(u16, u32, usize) -> Result<S, CoreError>,
303 ) -> Result<bool, CoreError> {
304 let Some(n_frames) = stream.n_frames() else {
305 return Ok(false);
306 };
307 let n_frames = n_frames as usize;
308
309 let src_rate = stream.sample_rate();
310 let src_channels = stream.channels();
311 let src_ch = src_channels as usize;
312 let out_ch = out_channel_count(target_channels, src_channels) as usize;
313 let dst_rate = target_rate.unwrap_or(src_rate);
314
315 let out_frames = if dst_rate == src_rate || src_rate == 0 {
316 n_frames
317 } else {
318 ((n_frames as f64) * (dst_rate as f64 / src_rate as f64)).round() as usize
319 };
320
321 let mut sink = make_sink(out_ch as u16, dst_rate, out_frames)?;
322 let mut frame_offset: u64 = 0;
323 let mut packet: Vec<f32> = Vec::new();
324
325 // Apply edit stages to a freshly decoded packet (source layout), advancing
326 // the absolute frame offset.
327 let apply_stages =
328 |packet: &mut Vec<f32>, stages: &mut [&mut dyn StreamStage], frame_offset: &mut u64| {
329 let frames = packet.len().checked_div(src_ch).unwrap_or(0);
330 for stage in stages.iter_mut() {
331 stage.process(packet, src_channels, *frame_offset);
332 }
333 *frame_offset += frames as u64;
334 };
335
336 if dst_rate == src_rate {
337 // No resampling: decode → stages → channel-convert → write.
338 loop {
339 if cancel.load(Ordering::Acquire) {
340 return Err(CoreError::Cancelled);
341 }
342 packet.clear();
343 if !stream.next_block(&mut packet)? {
344 break;
345 }
346 apply_stages(&mut packet, stages, &mut frame_offset);
347 let (converted, _) = convert_channels(&packet, src_channels, target_channels);
348 for &s in &converted {
349 sink.write_sample(s)?;
350 }
351 }
352 } else {
353 if src_channels == 0 || src_rate == 0 || dst_rate == 0 {
354 return Err(CoreError::Export(format!(
355 "invalid resample params: channels={src_channels}, src_rate={src_rate}, dst_rate={dst_rate}"
356 )));
357 }
358 let target_total = out_frames;
359 let mut resampler = make_resampler(src_rate, dst_rate, out_ch)?;
360
361 // Per-channel input accumulators (de-interleaved). Full RESAMPLE_CHUNK
362 // blocks are emitted as they fill, so the resampler sees the same
363 // chunk-aligned input sequence as the buffered path.
364 let mut acc: Vec<Vec<f32>> = vec![Vec::new(); out_ch];
365 let mut written = 0usize;
366
367 loop {
368 if cancel.load(Ordering::Acquire) {
369 return Err(CoreError::Cancelled);
370 }
371 packet.clear();
372 if !stream.next_block(&mut packet)? {
373 break;
374 }
375 apply_stages(&mut packet, stages, &mut frame_offset);
376 let (converted, _) = convert_channels(&packet, src_channels, target_channels);
377 let frames = converted.len() / out_ch;
378 for fr in 0..frames {
379 for (c, channel) in acc.iter_mut().enumerate() {
380 channel.push(converted[fr * out_ch + c]);
381 }
382 }
383 while acc[0].len() >= RESAMPLE_CHUNK {
384 let input: Vec<Vec<f32>> =
385 acc.iter().map(|b| b[..RESAMPLE_CHUNK].to_vec()).collect();
386 for b in &mut acc {
387 b.drain(..RESAMPLE_CHUNK);
388 }
389 let out = resampler.process(&input)?;
390 write_block(&mut sink, out, target_total, &mut written)?;
391 }
392 }
393
394 // Final short chunk (the resampler treats the remainder as silence).
395 if !acc[0].is_empty() {
396 let out = resampler.process(&acc)?;
397 write_block(&mut sink, out, target_total, &mut written)?;
398 }
399
400 // Drain the resampler's latency tail until we have target_total frames.
401 while written < target_total {
402 let out = resampler.flush()?;
403 let produced = out.first().map_or(0, std::vec::Vec::len);
404 write_block(&mut sink, out, target_total, &mut written)?;
405 if produced == 0 {
406 break;
407 }
408 }
409 }
410
411 sink.finalize()?;
412 Ok(true)
413 }
414
415 /// Interleave and write one per-channel resampler output block to the sink,
416 /// clamping the total to `target_total` frames (matching the buffered path, which
417 /// drops the extra frames the resampler emits while flushing its latency tail).
418 fn write_block<S: StreamSink>(
419 sink: &mut S,
420 per_channel: &[Vec<f32>],
421 target_total: usize,
422 written: &mut usize,
423 ) -> Result<(), CoreError> {
424 let frames = per_channel.first().map_or(0, std::vec::Vec::len);
425 for f in 0..frames {
426 if *written >= target_total {
427 return Ok(());
428 }
429 for channel in per_channel {
430 sink.write_sample(channel[f])?;
431 }
432 *written += 1;
433 }
434 Ok(())
435 }
436
437 // ── Public entry points ─────────────────────────────────────────────────────
438
439 /// Attempt a streaming WAV export. `Ok(true)` if it streamed, `Ok(false)` if the
440 /// source has no known frame count (caller falls back to buffered).
441 pub(crate) fn try_stream_wav_export(
442 source: &Path,
443 dest: &Path,
444 target_channels: &ExportChannels,
445 target_rate: Option<u32>,
446 bit_depth: u16,
447 cancel: &AtomicBool,
448 ) -> Result<bool, CoreError> {
449 if !matches!(bit_depth, 8 | 16 | 24 | 32) {
450 return Err(CoreError::Export(format!(
451 "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)"
452 )));
453 }
454 let stream = FrameStream::open(source)?;
455 process_streaming(
456 stream,
457 &mut [],
458 target_channels,
459 target_rate,
460 cancel,
461 |ch, rate, frames| WavSink::create(dest, ch, rate, bit_depth, frames),
462 )
463 }
464
465 /// Attempt a streaming AIFF export. `Ok(true)` if it streamed, `Ok(false)` if the
466 /// source has no known frame count (caller falls back to buffered).
467 pub(crate) fn try_stream_aiff_export(
468 source: &Path,
469 dest: &Path,
470 target_channels: &ExportChannels,
471 target_rate: Option<u32>,
472 bit_depth: u16,
473 cancel: &AtomicBool,
474 ) -> Result<bool, CoreError> {
475 if !matches!(bit_depth, 16 | 24) {
476 return Err(CoreError::Export(format!(
477 "AIFF: unsupported bit depth: {bit_depth} (expected 16 or 24)"
478 )));
479 }
480 let stream = FrameStream::open(source)?;
481 process_streaming(
482 stream,
483 &mut [],
484 target_channels,
485 target_rate,
486 cancel,
487 |ch, rate, frames| AiffSink::create(dest, ch, rate, bit_depth, frames),
488 )
489 }
490
491 /// Attempt a streaming edit for the streamable ops (gain, fade in/out): decode →
492 /// stage → write a 24-bit WAV temp, without holding the whole file in RAM.
493 ///
494 /// Returns `Ok(false)` if the op is not streamable (reverse, normalize, trim,
495 /// channel conversion, these need the whole signal) or the source has no known
496 /// frame count; the caller then uses the buffered path.
497 pub(crate) fn try_stream_edit_wav(
498 source: &Path,
499 dest: &Path,
500 operation: &EditOperation,
501 cancel: &AtomicBool,
502 ) -> Result<bool, CoreError> {
503 let stream = FrameStream::open(source)?;
504 let Some(total) = stream.n_frames() else {
505 return Ok(false);
506 };
507
508 let mut stage: Box<dyn StreamStage> = match operation {
509 EditOperation::Gain { db } => Box::new(GainStage { db: *db }),
510 EditOperation::FadeIn { frames, curve } => Box::new(FadeInStage {
511 fade_frames: (*frames as u64).min(total),
512 curve: *curve,
513 }),
514 EditOperation::FadeOut { frames, curve } => Box::new(FadeOutStage {
515 total_frames: total,
516 fade_frames: (*frames as u64).min(total),
517 curve: *curve,
518 }),
519 // Not streamable: needs the whole signal or changes structure.
520 _ => return Ok(false),
521 };
522
523 // Edit keeps the source layout and rate; output is 24-bit WAV (no dither), so
524 // the streamed result is bit-identical to decode → apply_edit → encode_wav(24).
525 process_streaming(
526 stream,
527 &mut [stage.as_mut()],
528 &ExportChannels::Original,
529 None,
530 cancel,
531 |ch, rate, frames| WavSink::create(dest, ch, rate, 24, frames),
532 )
533 }
534
535 #[cfg(test)]
536 mod tests {
537 use super::*;
538 use crate::export::{convert, decode, encode, encode_aiff};
539
540 fn write_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) {
541 let spec = WavSpec {
542 channels,
543 sample_rate,
544 bits_per_sample: 32,
545 sample_format: hound::SampleFormat::Float,
546 };
547 let mut w = WavWriter::create(path, spec).unwrap();
548 for &s in samples {
549 w.write_sample(s).unwrap();
550 }
551 w.finalize().unwrap();
552 }
553
554 fn read_i32(path: &Path) -> (hound::WavSpec, Vec<i32>) {
555 let mut r = hound::WavReader::open(path).unwrap();
556 let spec = r.spec();
557 let s = r.samples::<i32>().map(|x| x.unwrap()).collect();
558 (spec, s)
559 }
560
561 /// The buffered reference: decode → convert → encode_wav.
562 fn buffered_export(
563 source: &Path,
564 dest: &Path,
565 target_channels: &ExportChannels,
566 target_rate: Option<u32>,
567 bit_depth: u16,
568 ) {
569 let decoded = decode::decode_multichannel(source).unwrap();
570 let converted = convert::apply_conversion(
571 &decoded.samples,
572 decoded.channels,
573 decoded.sample_rate,
574 target_channels,
575 target_rate,
576 )
577 .unwrap();
578 encode::encode_wav(&converted, bit_depth, dest).unwrap();
579 }
580
581 fn assert_streaming_matches_buffered(
582 src_channels: u16,
583 src_rate: u32,
584 samples: &[f32],
585 target_channels: &ExportChannels,
586 target_rate: Option<u32>,
587 ) {
588 for bit_depth in [24u16, 16] {
589 assert_streaming_matches_buffered_at(
590 src_channels,
591 src_rate,
592 samples,
593 target_channels,
594 target_rate,
595 bit_depth,
596 );
597 }
598 }
599
600 fn assert_streaming_matches_buffered_at(
601 src_channels: u16,
602 src_rate: u32,
603 samples: &[f32],
604 target_channels: &ExportChannels,
605 target_rate: Option<u32>,
606 bit_depth: u16,
607 ) {
608 let dir = tempfile::tempdir().unwrap();
609 let src = dir.path().join("src.wav");
610 write_wav(&src, src_channels, src_rate, samples);
611
612 let streamed = dir.path().join("streamed.wav");
613 let did = try_stream_wav_export(
614 &src,
615 &streamed,
616 target_channels,
617 target_rate,
618 bit_depth,
619 &AtomicBool::new(false),
620 )
621 .unwrap();
622 assert!(did, "WAV source must be streamable (known frame count)");
623
624 let buffered = dir.path().join("buffered.wav");
625 buffered_export(&src, &buffered, target_channels, target_rate, bit_depth);
626
627 let (spec_s, samp_s) = read_i32(&streamed);
628 let (spec_b, samp_b) = read_i32(&buffered);
629 assert_eq!(spec_s.channels, spec_b.channels, "channel count");
630 assert_eq!(spec_s.sample_rate, spec_b.sample_rate, "sample rate");
631 assert_eq!(
632 samp_s.len(),
633 samp_b.len(),
634 "streamed frame count must match buffered exactly"
635 );
636 assert_eq!(
637 samp_s, samp_b,
638 "streamed samples must be bit-identical to buffered"
639 );
640 }
641
642 #[test]
643 fn streaming_matches_buffered_no_resample_mono() {
644 let samples: Vec<f32> = (0..5000).map(|i| ((i % 73) as f32 / 73.0) - 0.5).collect();
645 assert_streaming_matches_buffered(1, 44100, &samples, &ExportChannels::Original, None);
646 }
647
648 #[test]
649 fn streaming_matches_buffered_no_resample_stereo_to_mono() {
650 let samples: Vec<f32> = (0..6000).map(|i| ((i % 50) as f32 / 50.0) - 0.5).collect();
651 assert_streaming_matches_buffered(2, 48000, &samples, &ExportChannels::Mono, None);
652 }
653
654 #[test]
655 fn streaming_matches_buffered_downsample_mono() {
656 let samples: Vec<f32> = (0..9001).map(|i| ((i % 97) as f32 / 97.0) - 0.5).collect();
657 assert_streaming_matches_buffered(
658 1,
659 48000,
660 &samples,
661 &ExportChannels::Original,
662 Some(24000),
663 );
664 }
665
666 #[test]
667 fn streaming_matches_buffered_upsample_stereo() {
668 let samples: Vec<f32> = (0..8000).map(|i| ((i % 41) as f32 / 41.0) - 0.5).collect();
669 assert_streaming_matches_buffered(
670 2,
671 44100,
672 &samples,
673 &ExportChannels::Original,
674 Some(48000),
675 );
676 }
677
678 /// AIFF: streamed export must be byte-identical to the buffered encoder.
679 fn assert_aiff_streaming_matches_buffered(
680 src_channels: u16,
681 src_rate: u32,
682 samples: &[f32],
683 target_channels: &ExportChannels,
684 target_rate: Option<u32>,
685 bit_depth: u16,
686 ) {
687 let dir = tempfile::tempdir().unwrap();
688 let src = dir.path().join("src.wav");
689 write_wav(&src, src_channels, src_rate, samples);
690
691 let streamed = dir.path().join("streamed.aiff");
692 let did = try_stream_aiff_export(
693 &src,
694 &streamed,
695 target_channels,
696 target_rate,
697 bit_depth,
698 &AtomicBool::new(false),
699 )
700 .unwrap();
701 assert!(did, "WAV source must be streamable");
702
703 // Buffered AIFF reference: decode → convert → encode_aiff.
704 let decoded = decode::decode_multichannel(&src).unwrap();
705 let converted = convert::apply_conversion(
706 &decoded.samples,
707 decoded.channels,
708 decoded.sample_rate,
709 target_channels,
710 target_rate,
711 )
712 .unwrap();
713 let buffered = dir.path().join("buffered.aiff");
714 encode_aiff::encode_aiff(&converted, bit_depth, &buffered).unwrap();
715
716 assert_eq!(
717 std::fs::read(&streamed).unwrap(),
718 std::fs::read(&buffered).unwrap(),
719 "streamed AIFF must be byte-identical to buffered ({bit_depth}-bit)"
720 );
721 }
722
723 #[test]
724 fn aiff_streaming_matches_buffered_no_resample() {
725 // Odd sample count exercises the SSND word-align pad byte.
726 let samples: Vec<f32> = (0..5001).map(|i| ((i % 73) as f32 / 73.0) - 0.5).collect();
727 for bd in [16u16, 24] {
728 assert_aiff_streaming_matches_buffered(
729 1,
730 44100,
731 &samples,
732 &ExportChannels::Original,
733 None,
734 bd,
735 );
736 }
737 }
738
739 #[test]
740 fn aiff_streaming_matches_buffered_resample_stereo() {
741 let samples: Vec<f32> = (0..8000).map(|i| ((i % 41) as f32 / 41.0) - 0.5).collect();
742 for bd in [16u16, 24] {
743 assert_aiff_streaming_matches_buffered(
744 2,
745 44100,
746 &samples,
747 &ExportChannels::Original,
748 Some(48000),
749 bd,
750 );
751 }
752 }
753
754 #[test]
755 fn aiff_streaming_header_frame_count_matches_data() {
756 // The AIFF header is written up front from round(in*ratio); the resampler
757 // can emit fewer frames for some ratios. finalize() must backpatch
758 // numSampleFrames + SSND size to the frames actually written, so the
759 // header never overstates the real sample count. Verify across several
760 // odd-ratio resamples that the declared count equals the bytes present.
761 for (src_rate, dst_rate) in [
762 (44100u32, 48000u32),
763 (48000, 44100),
764 (44100, 22050),
765 (32000, 44100),
766 ] {
767 for bd in [16u16, 24] {
768 let dir = tempfile::tempdir().unwrap();
769 let src = dir.path().join("src.wav");
770 let samples: Vec<f32> = (0..7777).map(|i| ((i % 53) as f32 / 53.0) - 0.5).collect();
771 write_wav(&src, 1, src_rate, &samples);
772
773 let out = dir.path().join("out.aiff");
774 try_stream_aiff_export(
775 &src,
776 &out,
777 &ExportChannels::Original,
778 Some(dst_rate),
779 bd,
780 &AtomicBool::new(false),
781 )
782 .unwrap();
783
784 let bytes = std::fs::read(&out).unwrap();
785 // numSampleFrames: BE u32 at offset 22; SSND chunk size: BE u32 at
786 // offset 42 (= 8 + sample_data_size).
787 let num_frames =
788 u32::from_be_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]) as usize;
789 let ssnd_size =
790 u32::from_be_bytes([bytes[42], bytes[43], bytes[44], bytes[45]]) as usize;
791 let sample_data = ssnd_size - 8;
792 let bytes_per_sample = (bd / 8) as usize;
793 assert_eq!(
794 num_frames * bytes_per_sample,
795 sample_data,
796 "COMM numSampleFrames disagrees with SSND data size ({src_rate}->{dst_rate}, {bd}-bit)"
797 );
798 // And the declared data must actually be present on disk (header is
799 // 54 bytes; a trailing pad byte may follow odd data).
800 let on_disk = bytes.len() - 54;
801 assert!(
802 on_disk == sample_data || on_disk == sample_data + 1,
803 "SSND data size overstates bytes on disk ({src_rate}->{dst_rate}, {bd}-bit): \
804 declared {sample_data}, on disk {on_disk}"
805 );
806 }
807 }
808 }
809
810 /// Edit: streamed gain/fade must equal decode → apply_edit → encode_wav(24).
811 fn assert_edit_streaming_matches_buffered(
812 channels: u16,
813 rate: u32,
814 samples: &[f32],
815 op: &EditOperation,
816 ) {
817 let dir = tempfile::tempdir().unwrap();
818 let src = dir.path().join("src.wav");
819 write_wav(&src, channels, rate, samples);
820
821 let streamed = dir.path().join("streamed.wav");
822 let did = try_stream_edit_wav(&src, &streamed, op, &AtomicBool::new(false)).unwrap();
823 assert!(did, "gain/fade must be streamable");
824
825 // Buffered reference.
826 let mut decoded = decode::decode_multichannel(&src).unwrap();
827 let ch = crate::edit::apply_edit(
828 &mut decoded.samples,
829 decoded.channels,
830 decoded.sample_rate,
831 op,
832 )
833 .unwrap();
834 let converted = convert::ConvertedAudio {
835 samples: decoded.samples,
836 sample_rate: decoded.sample_rate,
837 channels: ch,
838 };
839 let buffered = dir.path().join("buffered.wav");
840 encode::encode_wav(&converted, 24, &buffered).unwrap();
841
842 let (_, samp_s) = read_i32(&streamed);
843 let (_, samp_b) = read_i32(&buffered);
844 assert_eq!(
845 samp_s, samp_b,
846 "streamed edit must be bit-identical to buffered"
847 );
848 }
849
850 #[test]
851 fn edit_streaming_gain_matches_buffered() {
852 let samples: Vec<f32> = (0..4000).map(|i| ((i % 31) as f32 / 31.0) - 0.5).collect();
853 assert_edit_streaming_matches_buffered(
854 2,
855 44100,
856 &samples,
857 &EditOperation::Gain { db: -6.0 },
858 );
859 assert_edit_streaming_matches_buffered(
860 1,
861 44100,
862 &samples,
863 &EditOperation::Gain { db: 3.5 },
864 );
865 }
866
867 #[test]
868 fn edit_streaming_fade_matches_buffered() {
869 let samples: Vec<f32> = (0..4000).map(|i| ((i % 31) as f32 / 31.0) - 0.5).collect();
870 for curve in [FadeCurve::Linear, FadeCurve::Logarithmic, FadeCurve::SCurve] {
871 assert_edit_streaming_matches_buffered(
872 2,
873 44100,
874 &samples,
875 &EditOperation::FadeIn {
876 frames: 1500,
877 curve,
878 },
879 );
880 assert_edit_streaming_matches_buffered(
881 1,
882 44100,
883 &samples,
884 &EditOperation::FadeOut {
885 frames: 1200,
886 curve,
887 },
888 );
889 }
890 }
891
892 #[test]
893 fn edit_streaming_rejects_non_streamable() {
894 let dir = tempfile::tempdir().unwrap();
895 let src = dir.path().join("src.wav");
896 write_wav(&src, 1, 44100, &[0.1, 0.2, 0.3, 0.4]);
897 let dest = dir.path().join("out.wav");
898 // Reverse needs the whole signal, must decline streaming.
899 let did = try_stream_edit_wav(
900 &src,
901 &dest,
902 &EditOperation::Reverse,
903 &AtomicBool::new(false),
904 )
905 .unwrap();
906 assert!(!did, "reverse is not streamable");
907 }
908 }
909