max / audiofiles
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 | + |