//! Streaming audio pipeline: decode → [per-frame edit stages] → channel-convert //! → resample → encode, in a bounded window, so a multi-GB source never //! materialises as a 2-4x f32 buffer. //! //! One driver ([`process_streaming`]) serves three callers, WAV export, AIFF //! export, and the streamable edit ops (gain/fade), by varying two things: the //! [`StreamStage`]s applied per frame (none for export) and the [`StreamSink`] //! that quantises/encodes the output. Sinks reuse the exact buffered quantisation //! (`encode::write_sample`, `encode_aiff::write_aiff_sample`) and stages reuse the //! exact buffered DSP (`edit::gain::apply_gain`, `FadeCurve::gain`), so every //! streamed result is byte-identical to its buffered equivalent. //! //! Streaming needs the source's exact frame count (PCM WAV reports it; AIFF needs //! it for the header up front, the resampler to size its output). Sources without //! one (e.g. some compressed formats) return `Ok(false)` so the caller falls back //! to the buffered pipeline. use std::fs::File; use std::io::{BufWriter, Seek, SeekFrom, Write}; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use hound::{WavSpec, WavWriter}; use super::ExportChannels; use super::convert::{RESAMPLE_CHUNK, convert_channels, make_resampler}; use super::decode::{OpenedDecoder, decode_next_into, open_decoder}; use super::dither::SimpleRng; use super::encode::{DITHER_SEED, sample_format_for, wav_size_check, write_sample}; use super::encode_aiff::{aiff_header_bytes, write_aiff_sample}; use crate::edit::EditOperation; use crate::edit::fade::FadeCurve; use crate::error::{CoreError, io_err}; /// Resolve a concrete output channel count from the target spec + source. fn out_channel_count(target: &ExportChannels, src_channels: u16) -> u16 { match target { ExportChannels::Original => src_channels.max(1), ExportChannels::Mono => 1, ExportChannels::Stereo => 2, } } // ── FrameStream: chunked decoder ─────────────────────────────────────────── /// A pull-based reader over a decoded source: format parameters plus a /// `next_block` that decodes one packet's worth of interleaved frames at a time. pub(crate) struct FrameStream { dec: OpenedDecoder, } impl FrameStream { pub(crate) fn open(path: &Path) -> Result { Ok(Self { dec: open_decoder(path)?, }) } pub(crate) fn sample_rate(&self) -> u32 { self.dec.sample_rate } pub(crate) fn channels(&self) -> u16 { self.dec.channels } pub(crate) fn n_frames(&self) -> Option { self.dec.n_frames } /// Decode the next packet's frames into `out` (interleaved, appended). /// Returns `Ok(false)` at end of stream. pub(crate) fn next_block(&mut self, out: &mut Vec) -> Result { decode_next_into( self.dec.format.as_mut(), self.dec.decoder.as_mut(), self.dec.track_id, out, ) } } // ── StreamStage: per-frame edit transforms (source layout) ───────────────── /// A per-frame transform applied to interleaved source-layout audio before /// channel-convert/resample. `frame_offset` is the absolute frame index of the /// block's first frame, so position-dependent ops (fades) work across blocks. pub(crate) trait StreamStage { fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64); } /// Gain in dB. Reuses `edit::gain::apply_gain` per block so streamed gain is /// bit-identical to the buffered edit (same dB→linear scale, same clamp). struct GainStage { db: f64, } impl StreamStage for GainStage { fn process(&mut self, block: &mut [f32], _channels: u16, _frame_offset: u64) { crate::edit::gain::apply_gain(block, self.db); } } /// Fade-in over the first `fade_frames` frames; identity afterwards. Matches /// `edit::fade::apply_fade_in`'s `t = i / (fade_frames-1).max(1)`. struct FadeInStage { fade_frames: u64, curve: FadeCurve, } impl StreamStage for FadeInStage { fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64) { if self.fade_frames == 0 { return; } let ch = channels as usize; if ch == 0 { return; } let denom = (self.fade_frames - 1).max(1) as f32; let frames = block.len() / ch; for fr in 0..frames { let abs = frame_offset + fr as u64; if abs >= self.fade_frames { break; // rest of the file is identity } let g = self.curve.gain(abs as f32 / denom); for c in 0..ch { block[fr * ch + c] *= g; } } } } /// Fade-out over the last `fade_frames` frames. Matches /// `edit::fade::apply_fade_out`'s `t = 1 - i / (fade_frames-1).max(1)`. struct FadeOutStage { total_frames: u64, fade_frames: u64, curve: FadeCurve, } impl StreamStage for FadeOutStage { fn process(&mut self, block: &mut [f32], channels: u16, frame_offset: u64) { if self.fade_frames == 0 { return; } let ch = channels as usize; if ch == 0 { return; } let start = self.total_frames - self.fade_frames; let denom = (self.fade_frames - 1).max(1) as f32; let frames = block.len() / ch; for fr in 0..frames { let abs = frame_offset + fr as u64; if abs < start { continue; } let i = abs - start; let g = self.curve.gain(1.0 - (i as f32 / denom)); for c in 0..ch { block[fr * ch + c] *= g; } } } } // ── StreamSink: incremental encoders ─────────────────────────────────────── /// An incremental encoder fed one f32 sample at a time (interleaved order). pub(crate) trait StreamSink { fn write_sample(&mut self, sample: f32) -> Result<(), CoreError>; fn finalize(self) -> Result<(), CoreError>; } /// Streaming WAV sink. Reuses `encode::write_sample` so output matches the /// buffered `encode_wav` quantisation (and dither) at every bit depth. struct WavSink { writer: WavWriter>, bit_depth: u16, rng: SimpleRng, } impl WavSink { fn create( dest: &Path, channels: u16, sample_rate: u32, bit_depth: u16, out_frames: usize, ) -> Result { wav_size_check( (out_frames as u64).saturating_mul(channels as u64), bit_depth, )?; let spec = WavSpec { channels, sample_rate, bits_per_sample: bit_depth, sample_format: sample_format_for(bit_depth), }; let writer = WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?; Ok(Self { writer, bit_depth, rng: SimpleRng::new(DITHER_SEED), }) } } impl StreamSink for WavSink { fn write_sample(&mut self, sample: f32) -> Result<(), CoreError> { write_sample(&mut self.writer, sample, self.bit_depth, &mut self.rng) } fn finalize(self) -> Result<(), CoreError> { self.writer .finalize() .map_err(|e| CoreError::Export(format!("WAV finalize: {e}"))) } } /// Streaming AIFF sink. Writes the FORM/COMM/SSND header up front (frame count /// known) and reuses `encode_aiff::write_aiff_sample` so output matches the /// buffered `encode_aiff` byte-for-byte. struct AiffSink { writer: BufWriter, channels: u16, sample_rate: u32, bit_depth: u16, rng: SimpleRng, /// Samples (not frames) actually written, so finalize can backpatch the /// header with the true frame count rather than the up-front estimate. samples_written: u64, } impl AiffSink { fn create( dest: &Path, channels: u16, sample_rate: u32, bit_depth: u16, out_frames: usize, ) -> Result { if channels == 0 { return Err(CoreError::Export("AIFF: 0 channels".to_string())); } // Initial header sized to the estimate; backpatched at finalize with the // exact count. The header is a fixed-size block so the rewrite is in place. let (header, _) = aiff_header_bytes(channels, sample_rate, bit_depth, out_frames)?; let file = File::create(dest).map_err(|e| io_err(dest, e))?; let mut writer = BufWriter::new(file); writer.write_all(&header).map_err(|e| io_err(dest, e))?; Ok(Self { writer, channels, sample_rate, bit_depth, rng: SimpleRng::new(DITHER_SEED), samples_written: 0, }) } } impl StreamSink for AiffSink { fn write_sample(&mut self, sample: f32) -> Result<(), CoreError> { write_aiff_sample(&mut self.writer, sample, self.bit_depth, &mut self.rng)?; self.samples_written += 1; Ok(()) } fn finalize(mut self) -> Result<(), CoreError> { // The resampler can emit fewer frames than the up-front estimate for some // ratios (round(in*ratio) overestimates the true output). Backpatch COMM // numSampleFrames and the SSND/FORM sizes to the frames actually written // so the container never overstates its sample count (strict hardware- // sampler parsers reject that). Matches the buffered path, which sizes its // header from the real output, and WAV streaming, which hound backpatches. let frames = (self.samples_written / self.channels.max(1) as u64) as usize; let (header, needs_pad) = aiff_header_bytes(self.channels, self.sample_rate, self.bit_depth, frames)?; if needs_pad { self.writer .write_all(&[0u8]) .map_err(|e| CoreError::Export(format!("AIFF pad: {e}")))?; } self.writer .seek(SeekFrom::Start(0)) .map_err(|e| CoreError::Export(format!("AIFF seek: {e}")))?; self.writer .write_all(&header) .map_err(|e| CoreError::Export(format!("AIFF header backpatch: {e}")))?; self.writer .flush() .map_err(|e| CoreError::Export(format!("AIFF flush: {e}"))) } } // ── Driver ───────────────────────────────────────────────────────────────── /// Run the streaming pipeline: decode → stages (source layout) → channel-convert /// → resample → sink. Returns `Ok(false)` if the source has no known frame count /// (the caller should fall back to the buffered pipeline). `make_sink` is given /// the resolved output channel count, rate, and exact frame count. fn process_streaming( mut stream: FrameStream, stages: &mut [&mut dyn StreamStage], target_channels: &ExportChannels, target_rate: Option, cancel: &AtomicBool, make_sink: impl FnOnce(u16, u32, usize) -> Result, ) -> Result { let Some(n_frames) = stream.n_frames() else { return Ok(false); }; let n_frames = n_frames as usize; let src_rate = stream.sample_rate(); let src_channels = stream.channels(); let src_ch = src_channels as usize; let out_ch = out_channel_count(target_channels, src_channels) as usize; let dst_rate = target_rate.unwrap_or(src_rate); let out_frames = if dst_rate == src_rate || src_rate == 0 { n_frames } else { ((n_frames as f64) * (dst_rate as f64 / src_rate as f64)).round() as usize }; let mut sink = make_sink(out_ch as u16, dst_rate, out_frames)?; let mut frame_offset: u64 = 0; let mut packet: Vec = Vec::new(); // Apply edit stages to a freshly decoded packet (source layout), advancing // the absolute frame offset. let apply_stages = |packet: &mut Vec, stages: &mut [&mut dyn StreamStage], frame_offset: &mut u64| { let frames = packet.len().checked_div(src_ch).unwrap_or(0); for stage in stages.iter_mut() { stage.process(packet, src_channels, *frame_offset); } *frame_offset += frames as u64; }; if dst_rate == src_rate { // No resampling: decode → stages → channel-convert → write. loop { if cancel.load(Ordering::Acquire) { return Err(CoreError::Cancelled); } packet.clear(); if !stream.next_block(&mut packet)? { break; } apply_stages(&mut packet, stages, &mut frame_offset); let (converted, _) = convert_channels(&packet, src_channels, target_channels); for &s in &converted { sink.write_sample(s)?; } } } else { if src_channels == 0 || src_rate == 0 || dst_rate == 0 { return Err(CoreError::Export(format!( "invalid resample params: channels={src_channels}, src_rate={src_rate}, dst_rate={dst_rate}" ))); } let target_total = out_frames; let mut resampler = make_resampler(src_rate, dst_rate, out_ch)?; // Per-channel input accumulators (de-interleaved). Full RESAMPLE_CHUNK // blocks are emitted as they fill, so the resampler sees the same // chunk-aligned input sequence as the buffered path. let mut acc: Vec> = vec![Vec::new(); out_ch]; let mut written = 0usize; loop { if cancel.load(Ordering::Acquire) { return Err(CoreError::Cancelled); } packet.clear(); if !stream.next_block(&mut packet)? { break; } apply_stages(&mut packet, stages, &mut frame_offset); let (converted, _) = convert_channels(&packet, src_channels, target_channels); let frames = converted.len() / out_ch; for fr in 0..frames { for (c, channel) in acc.iter_mut().enumerate() { channel.push(converted[fr * out_ch + c]); } } while acc[0].len() >= RESAMPLE_CHUNK { let input: Vec> = acc.iter().map(|b| b[..RESAMPLE_CHUNK].to_vec()).collect(); for b in &mut acc { b.drain(..RESAMPLE_CHUNK); } let out = resampler.process(&input)?; write_block(&mut sink, out, target_total, &mut written)?; } } // Final short chunk (the resampler treats the remainder as silence). if !acc[0].is_empty() { let out = resampler.process(&acc)?; write_block(&mut sink, out, target_total, &mut written)?; } // Drain the resampler's latency tail until we have target_total frames. while written < target_total { let out = resampler.flush()?; let produced = out.first().map_or(0, std::vec::Vec::len); write_block(&mut sink, out, target_total, &mut written)?; if produced == 0 { break; } } } sink.finalize()?; Ok(true) } /// Interleave and write one per-channel resampler output block to the sink, /// clamping the total to `target_total` frames (matching the buffered path, which /// drops the extra frames the resampler emits while flushing its latency tail). fn write_block( sink: &mut S, per_channel: &[Vec], target_total: usize, written: &mut usize, ) -> Result<(), CoreError> { let frames = per_channel.first().map_or(0, std::vec::Vec::len); for f in 0..frames { if *written >= target_total { return Ok(()); } for channel in per_channel { sink.write_sample(channel[f])?; } *written += 1; } Ok(()) } // ── Public entry points ───────────────────────────────────────────────────── /// Attempt a streaming WAV export. `Ok(true)` if it streamed, `Ok(false)` if the /// source has no known frame count (caller falls back to buffered). pub(crate) fn try_stream_wav_export( source: &Path, dest: &Path, target_channels: &ExportChannels, target_rate: Option, bit_depth: u16, cancel: &AtomicBool, ) -> Result { if !matches!(bit_depth, 8 | 16 | 24 | 32) { return Err(CoreError::Export(format!( "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)" ))); } let stream = FrameStream::open(source)?; process_streaming( stream, &mut [], target_channels, target_rate, cancel, |ch, rate, frames| WavSink::create(dest, ch, rate, bit_depth, frames), ) } /// Attempt a streaming AIFF export. `Ok(true)` if it streamed, `Ok(false)` if the /// source has no known frame count (caller falls back to buffered). pub(crate) fn try_stream_aiff_export( source: &Path, dest: &Path, target_channels: &ExportChannels, target_rate: Option, bit_depth: u16, cancel: &AtomicBool, ) -> Result { if !matches!(bit_depth, 16 | 24) { return Err(CoreError::Export(format!( "AIFF: unsupported bit depth: {bit_depth} (expected 16 or 24)" ))); } let stream = FrameStream::open(source)?; process_streaming( stream, &mut [], target_channels, target_rate, cancel, |ch, rate, frames| AiffSink::create(dest, ch, rate, bit_depth, frames), ) } /// Attempt a streaming edit for the streamable ops (gain, fade in/out): decode → /// stage → write a 24-bit WAV temp, without holding the whole file in RAM. /// /// Returns `Ok(false)` if the op is not streamable (reverse, normalize, trim, /// channel conversion, these need the whole signal) or the source has no known /// frame count; the caller then uses the buffered path. pub(crate) fn try_stream_edit_wav( source: &Path, dest: &Path, operation: &EditOperation, cancel: &AtomicBool, ) -> Result { let stream = FrameStream::open(source)?; let Some(total) = stream.n_frames() else { return Ok(false); }; let mut stage: Box = match operation { EditOperation::Gain { db } => Box::new(GainStage { db: *db }), EditOperation::FadeIn { frames, curve } => Box::new(FadeInStage { fade_frames: (*frames as u64).min(total), curve: *curve, }), EditOperation::FadeOut { frames, curve } => Box::new(FadeOutStage { total_frames: total, fade_frames: (*frames as u64).min(total), curve: *curve, }), // Not streamable: needs the whole signal or changes structure. _ => return Ok(false), }; // Edit keeps the source layout and rate; output is 24-bit WAV (no dither), so // the streamed result is bit-identical to decode → apply_edit → encode_wav(24). process_streaming( stream, &mut [stage.as_mut()], &ExportChannels::Original, None, cancel, |ch, rate, frames| WavSink::create(dest, ch, rate, 24, frames), ) } #[cfg(test)] mod tests { use super::*; use crate::export::{convert, decode, encode, encode_aiff}; fn write_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { let spec = WavSpec { channels, sample_rate, bits_per_sample: 32, sample_format: hound::SampleFormat::Float, }; let mut w = WavWriter::create(path, spec).unwrap(); for &s in samples { w.write_sample(s).unwrap(); } w.finalize().unwrap(); } fn read_i32(path: &Path) -> (hound::WavSpec, Vec) { let mut r = hound::WavReader::open(path).unwrap(); let spec = r.spec(); let s = r.samples::().map(|x| x.unwrap()).collect(); (spec, s) } /// The buffered reference: decode → convert → encode_wav. fn buffered_export( source: &Path, dest: &Path, target_channels: &ExportChannels, target_rate: Option, bit_depth: u16, ) { let decoded = decode::decode_multichannel(source).unwrap(); let converted = convert::apply_conversion( &decoded.samples, decoded.channels, decoded.sample_rate, target_channels, target_rate, ) .unwrap(); encode::encode_wav(&converted, bit_depth, dest).unwrap(); } fn assert_streaming_matches_buffered( src_channels: u16, src_rate: u32, samples: &[f32], target_channels: &ExportChannels, target_rate: Option, ) { for bit_depth in [24u16, 16] { assert_streaming_matches_buffered_at( src_channels, src_rate, samples, target_channels, target_rate, bit_depth, ); } } fn assert_streaming_matches_buffered_at( src_channels: u16, src_rate: u32, samples: &[f32], target_channels: &ExportChannels, target_rate: Option, bit_depth: u16, ) { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.wav"); write_wav(&src, src_channels, src_rate, samples); let streamed = dir.path().join("streamed.wav"); let did = try_stream_wav_export( &src, &streamed, target_channels, target_rate, bit_depth, &AtomicBool::new(false), ) .unwrap(); assert!(did, "WAV source must be streamable (known frame count)"); let buffered = dir.path().join("buffered.wav"); buffered_export(&src, &buffered, target_channels, target_rate, bit_depth); let (spec_s, samp_s) = read_i32(&streamed); let (spec_b, samp_b) = read_i32(&buffered); assert_eq!(spec_s.channels, spec_b.channels, "channel count"); assert_eq!(spec_s.sample_rate, spec_b.sample_rate, "sample rate"); assert_eq!( samp_s.len(), samp_b.len(), "streamed frame count must match buffered exactly" ); assert_eq!( samp_s, samp_b, "streamed samples must be bit-identical to buffered" ); } #[test] fn streaming_matches_buffered_no_resample_mono() { let samples: Vec = (0..5000).map(|i| ((i % 73) as f32 / 73.0) - 0.5).collect(); assert_streaming_matches_buffered(1, 44100, &samples, &ExportChannels::Original, None); } #[test] fn streaming_matches_buffered_no_resample_stereo_to_mono() { let samples: Vec = (0..6000).map(|i| ((i % 50) as f32 / 50.0) - 0.5).collect(); assert_streaming_matches_buffered(2, 48000, &samples, &ExportChannels::Mono, None); } #[test] fn streaming_matches_buffered_downsample_mono() { let samples: Vec = (0..9001).map(|i| ((i % 97) as f32 / 97.0) - 0.5).collect(); assert_streaming_matches_buffered( 1, 48000, &samples, &ExportChannels::Original, Some(24000), ); } #[test] fn streaming_matches_buffered_upsample_stereo() { let samples: Vec = (0..8000).map(|i| ((i % 41) as f32 / 41.0) - 0.5).collect(); assert_streaming_matches_buffered( 2, 44100, &samples, &ExportChannels::Original, Some(48000), ); } /// AIFF: streamed export must be byte-identical to the buffered encoder. fn assert_aiff_streaming_matches_buffered( src_channels: u16, src_rate: u32, samples: &[f32], target_channels: &ExportChannels, target_rate: Option, bit_depth: u16, ) { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.wav"); write_wav(&src, src_channels, src_rate, samples); let streamed = dir.path().join("streamed.aiff"); let did = try_stream_aiff_export( &src, &streamed, target_channels, target_rate, bit_depth, &AtomicBool::new(false), ) .unwrap(); assert!(did, "WAV source must be streamable"); // Buffered AIFF reference: decode → convert → encode_aiff. let decoded = decode::decode_multichannel(&src).unwrap(); let converted = convert::apply_conversion( &decoded.samples, decoded.channels, decoded.sample_rate, target_channels, target_rate, ) .unwrap(); let buffered = dir.path().join("buffered.aiff"); encode_aiff::encode_aiff(&converted, bit_depth, &buffered).unwrap(); assert_eq!( std::fs::read(&streamed).unwrap(), std::fs::read(&buffered).unwrap(), "streamed AIFF must be byte-identical to buffered ({bit_depth}-bit)" ); } #[test] fn aiff_streaming_matches_buffered_no_resample() { // Odd sample count exercises the SSND word-align pad byte. let samples: Vec = (0..5001).map(|i| ((i % 73) as f32 / 73.0) - 0.5).collect(); for bd in [16u16, 24] { assert_aiff_streaming_matches_buffered( 1, 44100, &samples, &ExportChannels::Original, None, bd, ); } } #[test] fn aiff_streaming_matches_buffered_resample_stereo() { let samples: Vec = (0..8000).map(|i| ((i % 41) as f32 / 41.0) - 0.5).collect(); for bd in [16u16, 24] { assert_aiff_streaming_matches_buffered( 2, 44100, &samples, &ExportChannels::Original, Some(48000), bd, ); } } #[test] fn aiff_streaming_header_frame_count_matches_data() { // The AIFF header is written up front from round(in*ratio); the resampler // can emit fewer frames for some ratios. finalize() must backpatch // numSampleFrames + SSND size to the frames actually written, so the // header never overstates the real sample count. Verify across several // odd-ratio resamples that the declared count equals the bytes present. for (src_rate, dst_rate) in [ (44100u32, 48000u32), (48000, 44100), (44100, 22050), (32000, 44100), ] { for bd in [16u16, 24] { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.wav"); let samples: Vec = (0..7777).map(|i| ((i % 53) as f32 / 53.0) - 0.5).collect(); write_wav(&src, 1, src_rate, &samples); let out = dir.path().join("out.aiff"); try_stream_aiff_export( &src, &out, &ExportChannels::Original, Some(dst_rate), bd, &AtomicBool::new(false), ) .unwrap(); let bytes = std::fs::read(&out).unwrap(); // numSampleFrames: BE u32 at offset 22; SSND chunk size: BE u32 at // offset 42 (= 8 + sample_data_size). let num_frames = u32::from_be_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]) as usize; let ssnd_size = u32::from_be_bytes([bytes[42], bytes[43], bytes[44], bytes[45]]) as usize; let sample_data = ssnd_size - 8; let bytes_per_sample = (bd / 8) as usize; assert_eq!( num_frames * bytes_per_sample, sample_data, "COMM numSampleFrames disagrees with SSND data size ({src_rate}->{dst_rate}, {bd}-bit)" ); // And the declared data must actually be present on disk (header is // 54 bytes; a trailing pad byte may follow odd data). let on_disk = bytes.len() - 54; assert!( on_disk == sample_data || on_disk == sample_data + 1, "SSND data size overstates bytes on disk ({src_rate}->{dst_rate}, {bd}-bit): \ declared {sample_data}, on disk {on_disk}" ); } } } /// Edit: streamed gain/fade must equal decode → apply_edit → encode_wav(24). fn assert_edit_streaming_matches_buffered( channels: u16, rate: u32, samples: &[f32], op: &EditOperation, ) { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.wav"); write_wav(&src, channels, rate, samples); let streamed = dir.path().join("streamed.wav"); let did = try_stream_edit_wav(&src, &streamed, op, &AtomicBool::new(false)).unwrap(); assert!(did, "gain/fade must be streamable"); // Buffered reference. let mut decoded = decode::decode_multichannel(&src).unwrap(); let ch = crate::edit::apply_edit( &mut decoded.samples, decoded.channels, decoded.sample_rate, op, ) .unwrap(); let converted = convert::ConvertedAudio { samples: decoded.samples, sample_rate: decoded.sample_rate, channels: ch, }; let buffered = dir.path().join("buffered.wav"); encode::encode_wav(&converted, 24, &buffered).unwrap(); let (_, samp_s) = read_i32(&streamed); let (_, samp_b) = read_i32(&buffered); assert_eq!( samp_s, samp_b, "streamed edit must be bit-identical to buffered" ); } #[test] fn edit_streaming_gain_matches_buffered() { let samples: Vec = (0..4000).map(|i| ((i % 31) as f32 / 31.0) - 0.5).collect(); assert_edit_streaming_matches_buffered( 2, 44100, &samples, &EditOperation::Gain { db: -6.0 }, ); assert_edit_streaming_matches_buffered( 1, 44100, &samples, &EditOperation::Gain { db: 3.5 }, ); } #[test] fn edit_streaming_fade_matches_buffered() { let samples: Vec = (0..4000).map(|i| ((i % 31) as f32 / 31.0) - 0.5).collect(); for curve in [FadeCurve::Linear, FadeCurve::Logarithmic, FadeCurve::SCurve] { assert_edit_streaming_matches_buffered( 2, 44100, &samples, &EditOperation::FadeIn { frames: 1500, curve, }, ); assert_edit_streaming_matches_buffered( 1, 44100, &samples, &EditOperation::FadeOut { frames: 1200, curve, }, ); } } #[test] fn edit_streaming_rejects_non_streamable() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.wav"); write_wav(&src, 1, 44100, &[0.1, 0.2, 0.3, 0.4]); let dest = dir.path().join("out.wav"); // Reverse needs the whole signal, must decline streaming. let did = try_stream_edit_wav( &src, &dest, &EditOperation::Reverse, &AtomicBool::new(false), ) .unwrap(); assert!(!did, "reverse is not streamable"); } }