diff --git a/rust_backend/crates/core/src/media/hires/evidence.rs b/rust_backend/crates/core/src/media/hires/evidence.rs index 26e16f7d..252eacaa 100644 --- a/rust_backend/crates/core/src/media/hires/evidence.rs +++ b/rust_backend/crates/core/src/media/hires/evidence.rs @@ -14,6 +14,9 @@ use super::fft::Radix2Fft; +mod streaming; +pub(super) use streaming::{IntegerEvidence, StftAccumulator, StftPlan}; + pub const CONFIDENCE_CERTAIN: &str = "certain"; pub const CONFIDENCE_LIKELY: &str = "likely"; @@ -83,6 +86,7 @@ pub struct StftStats { /// Averaged channel spectrum with a periodic Hann window and hop n_fft/4. /// Only complete frames are used: padding a cropped segment with zeros /// introduces an artificial discontinuity and broadband spectral leakage. +#[cfg(test)] pub fn analyze_stft( y: &[f32], n_fft: usize, @@ -337,6 +341,7 @@ pub fn detect_imaging( /// every sample repeated (sample-and-hold) or the in-between samples on a /// straight line (linear interpolation). `samples` is one channel, /// right-justified; `or_bits` is the OR over the whole window. +#[cfg(test)] pub fn detect_integer_upsampling(samples: &[i32], or_bits: u32, sample_rate: u32) -> &'static str { if or_bits == 0 || samples.len() < 4 { return ""; @@ -362,6 +367,7 @@ pub fn detect_integer_upsampling(samples: &[i32], or_bits: u32, sample_rate: u32 "" } +#[cfg(test)] fn integer_upsampling_at_phase( samples: &[i32], unused: u32, diff --git a/rust_backend/crates/core/src/media/hires/evidence/streaming.rs b/rust_backend/crates/core/src/media/hires/evidence/streaming.rs new file mode 100644 index 00000000..247df41a --- /dev/null +++ b/rust_backend/crates/core/src/media/hires/evidence/streaming.rs @@ -0,0 +1,385 @@ +//! Exact evidence with one circular STFT frame per channel. Only scalar +//! frame levels survive the FFT, for the existing floor/band percentiles. + +use super::*; + +pub(in crate::media::hires) struct StftPlan { + n: usize, + window: Vec, + window_power: f64, + band_low: usize, + band_high: usize, + music_bands: Vec<(usize, usize)>, + fft: Radix2Fft, + re: Vec, + im: Vec, + band_power: Vec, +} + +impl StftPlan { + pub(in crate::media::hires) fn new(n: usize, sample_rate: u32) -> Self { + let window: Vec = (0..n) + .map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n as f64).cos()) + .collect(); + let window_power = window.iter().map(|w| w * w).sum(); + let bin_hz = f64::from(sample_rate) / n as f64; + let band_low = (FLOOR_BAND_LOW_HZ / bin_hz).ceil() as usize; + let band_high = ((FLOOR_BAND_HIGH_HZ / bin_hz) as usize).min(n / 2); + let nyquist = f64::from(sample_rate) / 2.0; + let mut music_bands = Vec::new(); + if nyquist > f64::from(SOURCE_RATES[1]) / 2.0 { + let mut lo = MUSIC_BAND_START_HZ; + while lo + MUSIC_BAND_WIDTH_HZ <= nyquist { + let first = (lo / bin_hz).ceil() as usize; + let last = (((lo + MUSIC_BAND_WIDTH_HZ) / bin_hz).ceil() as usize).min(n / 2 + 1); + if last > first { + music_bands.push((first, last)); + } + lo += MUSIC_BAND_WIDTH_HZ; + } + } + Self { + n, + window, + window_power, + band_low, + band_high, + music_bands, + fft: Radix2Fft::new(n), + re: vec![0.0; n], + im: vec![0.0; n], + band_power: Vec::with_capacity(band_high.saturating_sub(band_low) + 1), + } + } +} + +pub(in crate::media::hires) struct StftAccumulator { + signal: Vec, + cursor: usize, + until_frame: usize, + frame_count: usize, + stats: StftStats, + frames: Vec<(f64, f64)>, + music_levels: Vec>, +} + +impl StftAccumulator { + pub(in crate::media::hires) fn new(plan: &StftPlan) -> Self { + Self { + signal: vec![0.0; plan.n], + cursor: 0, + until_frame: plan.n, + frame_count: 0, + stats: StftStats { + avg_magnitude: vec![0.0; plan.n / 2 + 1], + quiet_floor_var: f64::NAN, + music_band_spreads: Vec::new(), + }, + frames: Vec::new(), + music_levels: vec![Vec::new(); plan.music_bands.len()], + } + } + + pub(in crate::media::hires) fn push( + &mut self, + value: f32, + plan: &mut StftPlan, + check: &dyn Fn() -> Result<(), String>, + ) -> Result<(), String> { + self.signal[self.cursor] = value; + self.cursor += 1; + if self.cursor == plan.n { + self.cursor = 0; + } + self.until_frame -= 1; + if self.until_frame != 0 { + return Ok(()); + } + if self.frame_count.is_multiple_of(256) { + check()?; + } + self.frame_count += 1; + self.until_frame = plan.n / 4; + if self.signal.iter().all(|sample| *sample == 0.0) { + return Ok(()); + } + let ordered = self.signal[self.cursor..] + .iter() + .chain(&self.signal[..self.cursor]); + for ((r, w), sample) in plan.re.iter_mut().zip(&plan.window).zip(ordered) { + *r = f64::from(*sample) * w; + } + plan.im.fill(0.0); + plan.fft.transform(&mut plan.re, &mut plan.im); + for (k, avg) in self.stats.avg_magnitude.iter_mut().enumerate() { + *avg += plan.re[k].hypot(plan.im[k]); + } + if plan.band_high <= plan.band_low { + return Ok(()); + } + plan.band_power.clear(); + let mut sum = 0.0; + for k in plan.band_low..=plan.band_high { + let power = plan.re[k] * plan.re[k] + plan.im[k] * plan.im[k]; + plan.band_power.push(power); + sum += power; + } + if sum == 0.0 { + return Ok(()); + } + self.frames.push(( + sum / plan.band_power.len() as f64, + median_in_place(&mut plan.band_power), + )); + for (levels, &(first, last)) in self.music_levels.iter_mut().zip(&plan.music_bands) { + let power = (first..last) + .map(|k| plan.re[k] * plan.re[k] + plan.im[k] * plan.im[k]) + .sum::() + / (last - first) as f64; + levels.push(10.0 * power.max(1e-30).log10()); + } + Ok(()) + } + + pub(in crate::media::hires) fn finish(mut self, plan: &StftPlan) -> StftStats { + if self.frame_count == 0 { + return self.stats; + } + for avg in &mut self.stats.avg_magnitude { + *avg /= self.frame_count as f64; + } + if !self.frames.is_empty() { + self.frames.sort_by(|a, b| a.0.total_cmp(&b.0)); + let count = ((self.frames.len() as f64 * QUIET_FRAME_FRACTION) as usize).max(1); + let mut estimates: Vec = self.frames[..count] + .iter() + .map(|&(_, median)| median / std::f64::consts::LN_2 / plan.window_power) + .collect(); + self.stats.quiet_floor_var = median_in_place(&mut estimates); + } + for mut levels in self.music_levels { + if levels.len() < 2 { + break; + } + levels.sort_by(f64::total_cmp); + self.stats + .music_band_spreads + .push(percentile_sorted(&levels, 0.95) - percentile_sorted(&levels, 0.05)); + } + self.stats + } +} + +struct Position { + count: usize, + moving: usize, + // Bucket floor(log2(abs(d2) - 1)). Then abs(d2) > 2 << unused + // exactly when its bucket is at least unused + 1. Samples are all + // divisible by 1 << unused, once the final OR establishes that shift. + second_derivatives: [usize; 33], +} + +impl Default for Position { + fn default() -> Self { + Self { + count: 0, + moving: 0, + second_derivatives: [0; 33], + } + } +} + +pub(in crate::media::hires) struct IntegerEvidence { + pub(in crate::media::hires) or_bits: u32, + count: usize, + previous: [i32; 2], + ratios: Vec>, +} + +impl IntegerEvidence { + pub(in crate::media::hires) fn new(sample_rate: u32) -> Self { + let ratios = SOURCE_RATES + .into_iter() + .filter_map(|rate| { + let ratio = (sample_rate / rate) as usize; + (sample_rate.is_multiple_of(rate) && (2..=8).contains(&ratio)) + .then(|| (0..ratio).map(|_| Position::default()).collect()) + }) + .collect(); + Self { + or_bits: 0, + count: 0, + previous: [0; 2], + ratios, + } + } + + pub(in crate::media::hires) fn push(&mut self, value: i32) { + self.or_bits |= value as u32; + if self.count >= 2 { + let d1 = i64::from(self.previous[1]) - i64::from(self.previous[0]); + let d2 = (i64::from(self.previous[0]) - 2 * i64::from(self.previous[1]) + + i64::from(value)) + .unsigned_abs(); + let bucket = d2.saturating_sub(1).checked_ilog2(); + for positions in &mut self.ratios { + let position = (self.count - 1) % positions.len(); + let state = &mut positions[position]; + state.count += 1; + state.moving += usize::from(d1 != 0); + if let Some(bucket) = bucket { + state.second_derivatives[bucket as usize] += 1; + } + } + } + self.previous = [self.previous[1], value]; + self.count += 1; + } + + pub(in crate::media::hires) fn artifact(&self) -> &'static str { + if self.or_bits == 0 || self.count < 4 { + return ""; + } + let first_bucket = self.or_bits.trailing_zeros() as usize + 1; + for positions in &self.ratios { + let inner_total: usize = positions.iter().map(|state| state.count).sum(); + let hold_total: usize = positions.iter().map(|state| state.moving).sum(); + let lines: Vec = positions + .iter() + .map(|state| state.second_derivatives[first_bucket..].iter().sum()) + .collect(); + let line_total: usize = lines.iter().sum(); + for (phase, anchor) in positions.iter().enumerate() { + let maximum = (inner_total - anchor.count) as f64 * ARTIFACT_MAX_VIOLATION_RATE; + if anchor.moving >= ARTIFACT_MIN_MOVING_ANCHORS + && (hold_total - anchor.moving) as f64 <= maximum + { + return ARTIFACT_SAMPLE_HOLD; + } + if lines[phase] >= ARTIFACT_MIN_MOVING_ANCHORS + && (line_total - lines[phase]) as f64 <= maximum + { + return ARTIFACT_INTERPOLATION; + } + } + } + "" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(in crate::media::hires) fn assert_stats_equal(actual: &StftStats, expected: &StftStats) { + assert_eq!(actual.avg_magnitude, expected.avg_magnitude); + assert_eq!( + actual.quiet_floor_var.to_bits(), + expected.quiet_floor_var.to_bits() + ); + assert_eq!(actual.music_band_spreads, expected.music_band_spreads); + } + + #[test] + fn circular_stft_matches_buffered_reference_exactly() { + for rate in [44_100, 96_000, 192_000] { + for n in [256, 1024] { + let mut plan = StftPlan::new(n, rate); + for length in [n - 1, n, n + n / 4 - 1, 7 * n + 13] { + let signals: Vec> = (0..3) + .map(|channel| { + (0..length) + .map(|i| match channel { + 0 => 0.0, + 1 if i < 2 * n => 0.0, + 1 => ((i * 7919 % 65537) as f32 - 32768.0) / 65536.0, + _ => -((i * 7919 % 65537) as f32 - 32768.0) / 65536.0, + }) + .collect() + }) + .collect(); + let mut channels: Vec<_> = signals + .iter() + .map(|_| StftAccumulator::new(&plan)) + .collect(); + for i in 0..length { + for (channel, signal) in channels.iter_mut().zip(&signals) { + channel.push(signal[i], &mut plan, &|| Ok(())).unwrap(); + } + } + for (channel, signal) in channels.into_iter().zip(signals) { + assert_stats_equal( + &channel.finish(&plan), + &analyze_stft(&signal, n, rate, &|| Ok(())).unwrap(), + ); + } + } + } + } + } + + fn assert_integer_equal(samples: &[i32], rate: u32) { + let mut evidence = IntegerEvidence::new(rate); + for &sample in samples { + evidence.push(sample); + } + let orbit = samples.iter().fold(0, |bits, sample| bits | *sample as u32); + assert_eq!(evidence.or_bits, orbit); + assert_eq!( + evidence.artifact(), + detect_integer_upsampling(samples, orbit, rate) + ); + } + + #[test] + fn integer_histograms_match_every_ratio_phase_padding_and_violation_boundary() { + for base_rate in SOURCE_RATES { + for ratio in 2..=8 { + for phase in 0..ratio { + for shift in [0, 8, 16] { + let anchors: Vec = + (0..1300).map(|i| (i * 137 % 1021 - 510) * 16).collect(); + let hold: Vec = anchors + .iter() + .flat_map(|value| std::iter::repeat_n(*value << shift, ratio)) + .skip(phase) + .collect(); + let line: Vec = anchors + .windows(2) + .flat_map(|pair| { + (0..ratio).map(move |i| { + (pair[0] + (pair[1] - pair[0]) * i as i32 / ratio as i32) + << shift + }) + }) + .skip(phase) + .collect(); + let rate = base_rate * ratio as u32; + for signal in [hold, line] { + assert_integer_equal(&signal, rate); + for violations in [1, ratio, ratio * 2] { + let mut altered = signal.clone(); + for i in 0..violations { + altered[10 + i * 17] += 37 << shift; + } + assert_integer_equal(&altered, rate); + } + // The final value changes the unused-bit shift for all earlier derivatives. + let mut altered = signal; + altered.push(1); + assert_integer_equal(&altered, rate); + } + } + } + } + } + for samples in [ + vec![], + vec![0; 10], + vec![i32::MIN, i32::MAX, i32::MIN, 1], + vec![i32::MIN; 5000], + ] { + assert_integer_equal(&samples, 96_000); + } + } +} diff --git a/rust_backend/crates/core/src/media/hires/mod.rs b/rust_backend/crates/core/src/media/hires/mod.rs index eab9bc6a..0ac6bea7 100644 --- a/rust_backend/crates/core/src/media/hires/mod.rs +++ b/rust_backend/crates/core/src/media/hires/mod.rs @@ -161,8 +161,8 @@ impl HiResCheckResult { } } -/// One channel's decoded segment. Channels are analyzed separately to avoid -/// phase cancellation, keeping memory bounded to one channel at a time. +/// Reference decoder retained for streaming parity tests. +#[cfg(test)] #[derive(Default)] struct Window { signal: Vec, @@ -173,6 +173,36 @@ struct Window { samples: Vec, } +struct ChannelAnalysis { + stft: StftAccumulator, + integers: IntegerEvidence, + count: usize, + non_silent: bool, +} + +impl ChannelAnalysis { + fn push( + &mut self, + sample: f32, + integer: Option, + plan: &mut StftPlan, + check: &dyn Fn() -> Result<(), String>, + ) -> Result<(), String> { + self.count += 1; + self.non_silent |= f64::from(sample.abs()) > 1e-9; + if let Some(integer) = integer { + self.integers.push(integer); + } + self.stft.push(sample, plan, check) + } +} + +struct WindowAnalysis { + channels: Vec, + #[cfg(test)] + decoded_blocks: usize, +} + /// Analyses one file and returns a populated result. Only a short segment from /// the middle of the track is decoded, never the whole file. pub fn check_file( @@ -195,7 +225,7 @@ pub fn check_file( } check()?; - let source = Source::open(&mut file)?; + let mut source = Source::open(&mut file)?; let sr = source.sample_rate(); if sr == 0 { return Err("invalid declared sample rate 0".to_string().into()); @@ -213,8 +243,6 @@ pub fn check_file( let window_frames = (analyzed_duration * f64::from(sr)) as u64; let declared_bits = source.declared_bits(); - let channels = source.channel_count(); - drop(source); let mut result = HiResCheckResult { file_path: file_path.to_string(), @@ -239,27 +267,26 @@ pub fn check_file( let mut or_bits = 0; let mut common_artifact: Option<&str> = None; let mut all_floors_at_16bit = true; - for channel in 0..channels { + let mut plan = StftPlan::new(n_fft, sr); + let analysis = source + .analyze_window(start_frame, window_frames, &mut plan, check) + .map_err(|e| HiResCheckError::Failed(format!("could not decode audio: {e}")))?; + for channel in analysis.channels { check()?; - file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?; - let mut source = Source::open(&mut file)?; - let window = source - .read_window(start_frame, window_frames, channel, check) - .map_err(|e| HiResCheckError::Failed(format!("could not decode audio: {e}")))?; - if window.signal.is_empty() { + if channel.count == 0 { return Err("decoded audio segment is empty".to_string().into()); } - or_bits |= window.or_bits; + or_bits |= channel.integers.or_bits; result.analyzed_duration_s = result .analyzed_duration_s - .min(window.signal.len() as f64 / f64::from(sr)); - if !window.signal.iter().any(|v| f64::from(v.abs()) > 1e-9) { + .min(channel.count as f64 / f64::from(sr)); + if !channel.non_silent { continue; } - let stats = analyze_stft(&window.signal, n_fft, sr, check)?; + let stats = channel.stft.finish(&plan); all_floors_at_16bit &= classify_noise_floor(stats.quiet_floor_var, 1).0 == FLOOR_AT_16BIT; if claims_by_rate { - let artifact = detect_integer_upsampling(&window.samples, window.or_bits, sr); + let artifact = channel.integers.artifact(); // A pattern in one channel must not implicate independent, // full-resolution content in another channel. common_artifact = Some(match common_artifact { @@ -472,6 +499,7 @@ impl<'a> Source<'a> { } } + #[cfg(test)] fn read_window( &mut self, start_frame: u64, @@ -484,12 +512,85 @@ impl<'a> Source<'a> { Source::Wav(wav) => wav.read_window(start_frame, frames, channel, check), } } + + fn analyze_window( + &mut self, + start_frame: u64, + frames: u64, + plan: &mut StftPlan, + check: &dyn Fn() -> Result<(), String>, + ) -> Result { + let sample_rate = self.sample_rate(); + let mut analysis = WindowAnalysis { + channels: (0..self.channel_count()) + .map(|_| ChannelAnalysis { + stft: StftAccumulator::new(plan), + integers: IntegerEvidence::new(sample_rate), + count: 0, + non_silent: false, + }) + .collect(), + #[cfg(test)] + decoded_blocks: 0, + }; + match self { + Source::Flac(reader) => { + let scale = 1.0 / 2f64.powi(reader.streaminfo().bits_per_sample as i32 - 1); + let end = start_frame + frames; + let mut blocks = reader.blocks(); + let mut buffer = Vec::new(); + let mut decoded = 0_u64; + while analysis + .channels + .first() + .is_some_and(|channel| (channel.count as u64) < frames) + { + if decoded.is_multiple_of(64) { + check()?; + } + decoded += 1; + let block = match blocks.read_next_or_eof(buffer) { + Ok(Some(block)) => block, + Ok(None) => break, + Err(_) if analysis.channels[0].count > 0 => break, + Err(error) => return Err(error.to_string()), + }; + #[cfg(test)] + { + analysis.decoded_blocks += 1; + } + let first = block.time(); + let n = u64::from(block.duration()); + if first + n > start_frame { + let from = start_frame.saturating_sub(first) as usize; + let to = n.min(end.saturating_sub(first)) as usize; + for (index, channel) in analysis.channels.iter_mut().enumerate() { + for &value in &block.channel(index as u32)[from..to] { + channel.push( + (f64::from(value) * scale) as f32, + Some(value), + plan, + check, + )?; + } + } + } + buffer = block.into_buffer(); + } + } + Source::Wav(wav) => { + wav.analyze_window(start_frame, frames, &mut analysis, plan, check)? + } + } + Ok(analysis) + } } /// Decodes [start_frame, start_frame + frames). claxon hands back samples /// right-justified with wasted bits already shifted back in and inter-channel /// decorrelation undone, so each value is the stored sample. It cannot seek, /// so the frames before the window are decoded and skipped. +#[cfg(test)] fn read_flac_window( reader: &mut claxon::FlacReader>, start_frame: u64, @@ -637,6 +738,7 @@ impl<'a> WavSource<'a> { u64::from(self.channels * self.container_bits / 8) } + #[cfg(test)] fn read_window( &mut self, start_frame: u64, @@ -699,4 +801,57 @@ impl<'a> WavSource<'a> { } Ok(out) } + + fn analyze_window( + &mut self, + start_frame: u64, + frames: u64, + analysis: &mut WindowAnalysis, + plan: &mut StftPlan, + check: &dyn Fn() -> Result<(), String>, + ) -> Result<(), String> { + let total = self.data_size / self.frame_bytes(); + let frames = frames.min(total.saturating_sub(start_frame)); + let frame_bytes = self.frame_bytes() as usize; + self.file + .seek(SeekFrom::Start( + self.data_offset + start_frame * frame_bytes as u64, + )) + .map_err(|e| e.to_string())?; + let bytes_per_sample = (self.container_bits / 8) as usize; + let scale = 1.0 / 2f64.powi(self.container_bits as i32 - 1); + let mut reader = BufReader::with_capacity(1 << 16, &mut *self.file); + let mut frame = vec![0; frame_bytes]; + for index in 0..frames { + if index.is_multiple_of(65_536) { + check()?; + } + if reader.read_exact(&mut frame).is_err() { + break; + } + for (channel, bytes) in analysis + .channels + .iter_mut() + .zip(frame.chunks_exact(bytes_per_sample)) + { + if self.is_float { + let value = if self.container_bits == 32 { + f64::from(f32::from_le_bytes(bytes.try_into().expect("32-bit float"))) + } else { + f64::from_le_bytes(bytes.try_into().expect("64-bit float")) + }; + channel.push(value as f32, None, plan, check)?; + } else { + let value = match self.container_bits { + 8 => i32::from(bytes[0]) - 128, + 16 => i32::from(i16::from_le_bytes(bytes.try_into().expect("16-bit PCM"))), + 24 => i32::from_le_bytes([0, bytes[0], bytes[1], bytes[2]]) >> 8, + _ => i32::from_le_bytes(bytes.try_into().expect("32-bit PCM")), + }; + channel.push((f64::from(value) * scale) as f32, Some(value), plan, check)?; + } + } + } + Ok(()) + } } diff --git a/rust_backend/crates/core/src/media/hires/tests.rs b/rust_backend/crates/core/src/media/hires/tests.rs index 8731e670..9e0040e8 100644 --- a/rust_backend/crates/core/src/media/hires/tests.rs +++ b/rust_backend/crates/core/src/media/hires/tests.rs @@ -439,6 +439,115 @@ fn write_stereo_wav(path: &Path, left: &[i32], right: &[i32], sr: u32) { std::fs::write(path, bytes).expect("stereo header"); } +fn assert_streamed_window_matches_reference(path: &Path, start: u64, frames: u64) -> usize { + let mut file = File::open(path).unwrap(); + let mut source = Source::open(&mut file).unwrap(); + let rate = source.sample_rate(); + let mut plan = StftPlan::new(1024, rate); + let streamed = source + .analyze_window(start, frames, &mut plan, &|| Ok(())) + .unwrap(); + for (index, channel) in streamed.channels.into_iter().enumerate() { + let mut file = File::open(path).unwrap(); + let mut reference = Source::open(&mut file).unwrap(); + let window = reference + .read_window(start, frames, index as u32, &|| Ok(())) + .unwrap(); + assert_eq!(channel.count, window.signal.len()); + assert_eq!( + channel.non_silent, + window + .signal + .iter() + .any(|sample| f64::from(sample.abs()) > 1e-9) + ); + assert_eq!(channel.integers.or_bits, window.or_bits); + assert_eq!( + channel.integers.artifact(), + evidence::detect_integer_upsampling(&window.samples, window.or_bits, rate) + ); + let expected = evidence::analyze_stft(&window.signal, 1024, rate, &|| Ok(())).unwrap(); + let actual = channel.stft.finish(&plan); + assert_eq!(actual.avg_magnitude, expected.avg_magnitude); + assert_eq!( + actual.quiet_floor_var.to_bits(), + expected.quiet_floor_var.to_bits() + ); + assert_eq!(actual.music_band_spreads, expected.music_band_spreads); + } + streamed.decoded_blocks +} + +#[test] +fn one_flac_decode_pass_serves_all_eight_channels_with_exact_window_evidence() { + let dir = TempDir::new("streamed-flac"); + let path = dir.file("eight-channels.flac"); + let samples = pcm_using_bits(16, 24, 4096 * 6, 29); + write_flac(&path, &samples, 96_000, 24, 8); + // Three prefix blocks plus the end of the sampled window: four decoded + // blocks total, rather than four separately for every channel. + assert_eq!( + assert_streamed_window_matches_reference(&path, 8500, 5000), + 4 + ); + assert_eq!(assert_streamed_window_matches_reference(&path, 0, 700), 1); + assert_eq!( + assert_streamed_window_matches_reference(&path, 20_000, 8000), + 6 + ); +} + +#[test] +fn streaming_wav_preserves_independent_antiphase_channels_and_partial_windows() { + let dir = TempDir::new("streamed-wav"); + let left = quantize(&full_band_noise(20_003, 23), 24); + let right: Vec = left.iter().map(|sample| -*sample).collect(); + let path = dir.file("antiphase.wav"); + write_stereo_wav(&path, &left, &right, 96_000); + assert_eq!( + assert_streamed_window_matches_reference(&path, 17, 13_013), + 0 + ); + assert_eq!( + assert_streamed_window_matches_reference(&path, 18_000, 6000), + 0 + ); +} + +#[test] +fn streaming_wav_matches_all_integer_and_float_sample_encodings() { + let dir = TempDir::new("streamed-encodings"); + for bits in [8, 16, 24, 32] { + let path = dir.file(&format!("integer-{bits}.wav")); + let samples: Vec = (0..4099).map(|i| (i * 37 % 127) - 63).collect(); + write_wav(&path, &samples, 96_000, bits); + assert_streamed_window_matches_reference(&path, 31, 3500); + } + for bits in [32, 64] { + let path = dir.file(&format!("float-{bits}.wav")); + write_wav(&path, &[0], 96_000, 32); + let mut bytes = std::fs::read(&path).unwrap(); + bytes.truncate(44); + bytes[20..22].copy_from_slice(&3u16.to_le_bytes()); + bytes[28..32].copy_from_slice(&(96_000_u32 * bits / 8).to_le_bytes()); + bytes[32..34].copy_from_slice(&((bits / 8) as u16).to_le_bytes()); + bytes[34..36].copy_from_slice(&(bits as u16).to_le_bytes()); + for i in 0..4099 { + let value = ((i * 37 % 127) as f64 - 63.0) / 128.0; + if bits == 32 { + bytes.extend_from_slice(&(value as f32).to_le_bytes()); + } else { + bytes.extend_from_slice(&value.to_le_bytes()); + } + } + let data_size = (bytes.len() - 44) as u32; + bytes[4..8].copy_from_slice(&(36 + data_size).to_le_bytes()); + bytes[40..44].copy_from_slice(&data_size.to_le_bytes()); + std::fs::write(&path, bytes).unwrap(); + assert_streamed_window_matches_reference(&path, 31, 3500); + } +} + #[test] fn antiphase_channels_do_not_cancel_spectral_evidence() { let dir = TempDir::new("antiphase");