From 5d305d15ec120ba4459f731506e60561c34f8493 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 14 Jan 2026 21:46:00 +0100 Subject: [PATCH 01/10] fix: correct Source trait semantics and span tracking bugs Clarify Source::current_span_len() returns total span length (not remaining), while size_hint() returns remaining samples. Fix multiple bugs in span boundary detection, seeking, and iterator implementations. Opportunistic fixes: - fix division by zero, off-by-one error, and zero case handling - prevent counter overflows - optimize vector allocations Mid-span seeking: best-effort tracking after try_seek() to detect span boundaries when seeking lands mid-span. Fixes #691 --- CHANGELOG.md | 15 +- src/math.rs | 14 ++ src/mixer.rs | 52 +++++-- src/queue.rs | 25 +-- src/source/blt.rs | 23 ++- src/source/buffered.rs | 20 +-- src/source/chirp.rs | 21 ++- src/source/delay.rs | 2 + src/source/dither.rs | 35 +++-- src/source/done.rs | 2 + src/source/empty_callback.rs | 11 +- src/source/from_iter.rs | 27 +--- src/source/limit.rs | 277 +++++++++++++++++++-------------- src/source/mod.rs | 14 +- src/source/noise.rs | 52 +++++++ src/source/pausable.rs | 6 +- src/source/periodic.rs | 13 +- src/source/position.rs | 92 ++++++----- src/source/repeat.rs | 2 +- src/source/sawtooth.rs | 5 + src/source/signal_generator.rs | 5 + src/source/sine.rs | 5 + src/source/skip.rs | 11 +- src/source/skippable.rs | 2 + src/source/square.rs | 5 + src/source/stoppable.rs | 2 + src/source/take.rs | 91 +++++++++-- src/source/triangle.rs | 5 + src/static_buffer.rs | 2 + src/wav_output.rs | 5 + 30 files changed, 556 insertions(+), 285 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e12f9d..9fa920ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `Chirp` and `Empty` now implement `Iterator::size_hint` and `ExactSizeIterator`. -- `SamplesBuffer` now implements `ExactSizeIterator`. +- All sources now implement `Iterator::size_hint()`. +- All sources now implement `ExactSizeIterator` when their inner source does. - `Zero` now implements `try_seek`, `total_duration` and `Copy`. - Added `Source::is_exhausted()` helper method to check if a source has no more samples. - Added `Red` noise generator that is more practical than `Brownian` noise. @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `64bit` feature to opt-in to 64-bit sample precision (`f64`). ### Fixed + - docs.rs will now document all features, including those that are optional. - `Chirp::next` now returns `None` when the total duration has been reached, and will work correctly for a number of samples greater than 2^24. @@ -41,9 +42,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed channel misalignment in queue with non-power-of-2 channel counts (e.g., 6 channels) by ensuring frame-aligned span lengths. - Fixed channel misalignment when sources end before their promised span length by padding with silence to complete frames. - Fixed `Empty` source to properly report exhaustion. -- Fixed `Zero::current_span_len` returning remaining samples instead of span length. +- Fixed `Source::current_span_len()` to consistently return total span length. +- Fixed `Source::size_hint()` to consistently report actual bounds based on current sources. +- Fixed `Pausable::size_hint()` to correctly account for paused samples. +- Fixed `Limit`, `TakeDuration` and `TrackPosition` to handle mid-span seeks. +- Fixed `MixerSource` to prevent overflow with very long playback. +- Fixed `PeriodicAccess` to prevent overflow with very long periods. ### Changed + - Breaking: _Sink_ terms are replaced with _Player_ and _Stream_ terms replaced with _Sink_. This is a simple rename, functionality is identical. - `OutputStream` is now `MixerDeviceSink` (in anticipation of future @@ -60,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Gaussian` noise generator has standard deviation of 0.6 for perceptual equivalence. - `Velvet` noise generator takes density in Hz as `usize` instead of `f32`. - Upgraded `cpal` to v0.17. -- Clarified `Source::current_span_len()` contract documentation. +- Clarified `Source::current_span_len()` documentation to specify it returns total span length. - Improved queue, mixer and sample rate conversion performance. ## Version [0.21.1] (2025-07-14) diff --git a/src/math.rs b/src/math.rs index fffe1de1..dbc56ec9 100644 --- a/src/math.rs +++ b/src/math.rs @@ -126,6 +126,20 @@ pub(crate) fn duration_to_float(duration: Duration) -> Float { } } +/// Convert Float to Duration with appropriate precision for the Sample type. +#[inline] +#[must_use] +pub(crate) fn duration_from_secs(secs: Float) -> Duration { + #[cfg(not(feature = "64bit"))] + { + Duration::from_secs_f32(secs) + } + #[cfg(feature = "64bit")] + { + Duration::from_secs_f64(secs) + } +} + /// Utility macro for getting a `NonZero` from a literal. Especially /// useful for passing in `ChannelCount` and `Samplerate`. /// Equivalent to: `const { core::num::NonZero::new($n).unwrap() }` diff --git a/src/mixer.rs b/src/mixer.rs index 1e5e5fea..b231f99f 100644 --- a/src/mixer.rs +++ b/src/mixer.rs @@ -32,10 +32,10 @@ pub fn mixer(channels: ChannelCount, sample_rate: SampleRate) -> (Mixer, MixerSo })); let output = MixerSource { - current_sources: Vec::with_capacity(16), + current_sources: Vec::new(), input: input.clone(), - sample_count: 0, - still_pending: vec![], + current_channel: 0, + still_pending: Vec::new(), pending_rx: rx, }; @@ -74,8 +74,8 @@ pub struct MixerSource { // The pending sounds. input: Mixer, - // The number of samples produced so far. - sample_count: usize, + // Current channel position within the frame. + current_channel: u16, // A temporary vec used in start_pending_sources. still_pending: Vec>, @@ -120,10 +120,14 @@ impl Iterator for MixerSource { fn next(&mut self) -> Option { self.start_pending_sources(); - self.sample_count += 1; - let sum = self.sum_current_sources(); + // Advance frame position (wraps at channel count, never overflows) + self.current_channel += 1; + if self.current_channel >= self.input.0.channels.get() { + self.current_channel = 0; + } + if self.current_sources.is_empty() { None } else { @@ -133,7 +137,33 @@ impl Iterator for MixerSource { #[inline] fn size_hint(&self) -> (usize, Option) { - (0, None) + if self.current_sources.is_empty() { + return (0, Some(0)); + } + + // The mixer continues as long as ANY source is playing, so bounds are + // determined by the longest source, not the shortest. + let mut min = 0; + let mut max: Option = Some(0); + + for source in &self.current_sources { + let (source_min, source_max) = source.size_hint(); + // Lower bound: guaranteed to produce at least until longest source's lower bound + min = min.max(source_min); + + match (max, source_max) { + (Some(current_max), Some(source_max_val)) => { + // Upper bound: might produce up to longest source's upper bound + max = Some(current_max.max(source_max_val)); + } + _ => { + // If any source is unbounded, the mixer is unbounded + max = None; + } + } + } + + (min, max) } } @@ -144,9 +174,9 @@ impl MixerSource { // sound will play on the wrong channels, e.g. left / right will be reversed. fn start_pending_sources(&mut self) { while let Ok(source) = self.pending_rx.try_recv() { - let in_step = self - .sample_count - .is_multiple_of(source.channels().get() as usize); + // Only start sources at frame boundaries (when current_channel == 0) + // to ensure correct channel alignment + let in_step = self.current_channel == 0; if in_step { self.current_sources.push(source); diff --git a/src/queue.rs b/src/queue.rs index 182e8f32..b0d51a7f 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -135,37 +135,16 @@ fn threshold(channels: ChannelCount) -> usize { impl Source for SourcesQueueOutput { #[inline] fn current_span_len(&self) -> Option { - // This function is non-trivial because the boundary between two sounds in the queue should - // be a span boundary as well. - // - // The current sound is free to return `None` for `current_span_len()`, in which case - // we *should* return the number of samples remaining the current sound. - // This can be estimated with `size_hint()`. - // - // If the `size_hint` is `None` as well, we are in the worst case scenario. To handle this - // situation we force a span to have a maximum number of samples indicate by this - // constant. - - // Try the current `current_span_len`. if !self.current.is_exhausted() { return self.current.current_span_len(); } else if self.input.keep_alive_if_empty.load(Ordering::Acquire) && self.input.next_sounds.lock().unwrap().is_empty() { - // The next source will be a filler silence which will have a frame-aligned length + // Return what that Zero's current_span_len() will be: Some(threshold(channels)). return Some(threshold(self.current.channels())); } - // Try the size hint. - let (lower_bound, _) = self.current.size_hint(); - // The iterator default implementation just returns 0. - // That's a problematic value, so skip it. - if lower_bound > 0 { - return Some(lower_bound); - } - - // Otherwise we use a frame-aligned threshold value. - Some(threshold(self.current.channels())) + None } #[inline] diff --git a/src/source/blt.rs b/src/source/blt.rs index 5d06779b..355d38cc 100644 --- a/src/source/blt.rs +++ b/src/source/blt.rs @@ -116,10 +116,10 @@ where #[inline] fn next(&mut self) -> Option { - let last_in_span = self.input.current_span_len() == Some(1); + let current_sample_rate = self.input.sample_rate(); if self.applier.is_none() { - self.applier = Some(self.formula.to_applier(self.input.sample_rate().get())); + self.applier = Some(self.formula.to_applier(current_sample_rate.get())); } let sample = self.input.next()?; @@ -134,7 +134,14 @@ where self.y_n1 = result; self.x_n1 = sample; - if last_in_span { + // Check if sample rate changed after getting the next sample. + // Only check when span is finite and not exhausted. + let sample_rate_changed = self + .input + .current_span_len() + .is_some_and(|len| len > 0 && current_sample_rate != self.input.sample_rate()); + + if sample_rate_changed { self.applier = None; } @@ -175,7 +182,15 @@ where #[inline] fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { - self.input.try_seek(pos) + self.input.try_seek(pos)?; + + // Reset filter state to avoid artifacts from previous position + self.x_n1 = 0.0; + self.x_n2 = 0.0; + self.y_n1 = 0.0; + self.y_n2 = 0.0; + + Ok(()) } } diff --git a/src/source/buffered.rs b/src/source/buffered.rs index 974389dc..e45ea2ff 100644 --- a/src/source/buffered.rs +++ b/src/source/buffered.rs @@ -1,4 +1,3 @@ -use std::cmp; use std::mem; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -59,7 +58,7 @@ struct SpanData where I: Source, { - data: Vec, + data: Box<[I::Item]>, channels: ChannelCount, rate: SampleRate, next: Mutex>>, @@ -107,10 +106,12 @@ where let channels = input.channels(); let rate = input.sample_rate(); - let data: Vec = input + let max_samples = span_len.unwrap_or(32768); + let data: Box<[I::Item]> = input .by_ref() - .take(cmp::min(span_len.unwrap_or(32768), 32768)) - .collect(); + .take(max_samples) + .collect::>() + .into_boxed_slice(); if data.is_empty() { return Arc::new(Span::End); @@ -204,11 +205,12 @@ where { #[inline] fn current_span_len(&self) -> Option { - match &*self.current_span { - Span::Data(SpanData { data, .. }) => Some(data.len() - self.position_in_span), - Span::End => Some(0), + let len = match &*self.current_span { + Span::Data(SpanData { data, .. }) => data.len(), + Span::End => 0, Span::Input(_) => unreachable!(), - } + }; + Some(len) } #[inline] diff --git a/src/source/chirp.rs b/src/source/chirp.rs index ce5c0bf3..88c5bc0e 100644 --- a/src/source/chirp.rs +++ b/src/source/chirp.rs @@ -46,17 +46,6 @@ impl Chirp { elapsed_samples: 0, } } - - #[allow(dead_code)] - fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { - let mut target = (pos.as_secs_f64() * self.sample_rate.get() as f64) as u64; - if target >= self.total_samples { - target = self.total_samples; - } - - self.elapsed_samples = target; - Ok(()) - } } impl Iterator for Chirp { @@ -101,4 +90,14 @@ impl Source for Chirp { let secs = self.total_samples as f64 / self.sample_rate.get() as f64; Some(Duration::from_secs_f64(secs)) } + + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + let mut target = (pos.as_secs_f64() * self.sample_rate.get() as f64) as u64; + if target >= self.total_samples { + target = self.total_samples; + } + + self.elapsed_samples = target; + Ok(()) + } } diff --git a/src/source/delay.rs b/src/source/delay.rs index 0f55aa32..5c835dc5 100644 --- a/src/source/delay.rs +++ b/src/source/delay.rs @@ -84,6 +84,8 @@ where } } +impl ExactSizeIterator for Delay where I: Iterator + Source + ExactSizeIterator {} + impl Source for Delay where I: Iterator + Source, diff --git a/src/source/dither.rs b/src/source/dither.rs index adc8b436..95cff7d4 100644 --- a/src/source/dither.rs +++ b/src/source/dither.rs @@ -161,7 +161,8 @@ pub struct Dither { input: I, noise: NoiseGenerator, current_channel: usize, - remaining_in_span: Option, + last_sample_rate: SampleRate, + last_channels: ChannelCount, lsb_amplitude: Float, } @@ -179,13 +180,13 @@ where let sample_rate = input.sample_rate(); let channels = input.channels(); - let active_span_len = input.current_span_len(); Self { input, noise: NoiseGenerator::new(algorithm, sample_rate, channels), current_channel: 0, - remaining_in_span: active_span_len, + last_sample_rate: sample_rate, + last_channels: channels, lsb_amplitude, } } @@ -213,23 +214,25 @@ where #[inline] fn next(&mut self) -> Option { - if let Some(ref mut remaining) = self.remaining_in_span { - *remaining = remaining.saturating_sub(1); - } - - // Consume next input sample *after* decrementing span position and *before* checking for - // span boundary crossing. This ensures that the source has its parameters updated - // correctly before we generate noise for the next sample. let input_sample = self.input.next()?; - let num_channels = self.input.channels(); - if self.remaining_in_span == Some(0) { - self.noise - .update_parameters(self.input.sample_rate(), num_channels); - self.current_channel = 0; - self.remaining_in_span = self.input.current_span_len(); + if self.input.current_span_len().is_some_and(|len| len > 0) { + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + let parameters_changed = current_sample_rate != self.last_sample_rate + || current_channels != self.last_channels; + + if parameters_changed { + self.noise + .update_parameters(current_sample_rate, current_channels); + self.current_channel = 0; + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + } } + let num_channels = self.input.channels(); + let noise_sample = self .noise .next(self.current_channel) diff --git a/src/source/done.rs b/src/source/done.rs index 5bc922b7..8e486cdb 100644 --- a/src/source/done.rs +++ b/src/source/done.rs @@ -66,6 +66,8 @@ where } } +impl ExactSizeIterator for Done where I: Source + ExactSizeIterator {} + impl Source for Done where I: Source, diff --git a/src/source/empty_callback.rs b/src/source/empty_callback.rs index 4ae62437..d240102b 100644 --- a/src/source/empty_callback.rs +++ b/src/source/empty_callback.rs @@ -29,12 +29,19 @@ impl Iterator for EmptyCallback { (self.callback)(); None } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (0, Some(0)) + } } +impl ExactSizeIterator for EmptyCallback {} + impl Source for EmptyCallback { #[inline] fn current_span_len(&self) -> Option { - None + Some(0) } #[inline] @@ -49,7 +56,7 @@ impl Source for EmptyCallback { #[inline] fn total_duration(&self) -> Option { - Some(Duration::new(0, 0)) + Some(Duration::ZERO) } #[inline] diff --git a/src/source/from_iter.rs b/src/source/from_iter.rs index c8c14062..e1252016 100644 --- a/src/source/from_iter.rs +++ b/src/source/from_iter.rs @@ -66,7 +66,7 @@ where if let Some(cur) = &self.current_source { (cur.size_hint().0, None) } else { - (0, None) + (0, Some(0)) } } } @@ -78,36 +78,13 @@ where { #[inline] fn current_span_len(&self) -> Option { - // This function is non-trivial because the boundary between the current source and the - // next must be a span boundary as well. - // - // The current sound is free to return `None` for `current_span_len()`, in which case - // we *should* return the number of samples remaining the current sound. - // This can be estimated with `size_hint()`. - // - // If the `size_hint` is `None` as well, we are in the worst case scenario. To handle this - // situation we force a span to have a maximum number of samples indicate by this - // constant. - const THRESHOLD: usize = 10240; - - // Try the current `current_span_len`. if let Some(src) = &self.current_source { if !src.is_exhausted() { return src.current_span_len(); } } - // Try the size hint. - if let Some(src) = &self.current_source { - if let Some(val) = src.size_hint().1 { - if val < THRESHOLD && val != 0 { - return Some(val); - } - } - } - - // Otherwise we use the constant value. - Some(THRESHOLD) + None } #[inline] diff --git a/src/source/limit.rs b/src/source/limit.rs index a16139ae..4277558a 100644 --- a/src/source/limit.rs +++ b/src/source/limit.rs @@ -67,6 +67,68 @@ use crate::{ Float, Source, }; +/// Creates a limiter that processes the input audio source. +/// +/// This function applies the specified limiting settings to control audio peaks. +/// The limiter uses feedforward processing with configurable attack/release times +/// and soft-knee characteristics for natural-sounding dynamic range control. +/// +/// # Arguments +/// +/// * `input` - Audio source to process +/// * `settings` - Limiter configuration (threshold, knee, timing) +/// +/// # Returns +/// +/// A [`Limit`] source that applies the limiting to the input audio. +/// +/// # Example +/// +/// ```rust +/// use rodio::source::{SineWave, Source, LimitSettings}; +/// +/// let source = SineWave::new(440.0).amplify(2.0); +/// let settings = LimitSettings::default().with_threshold(-6.0); +/// let limited = source.limit(settings); +/// ``` +pub(crate) fn limit(input: I, settings: LimitSettings) -> Limit { + let sample_rate = input.sample_rate(); + let attack = duration_to_coefficient(settings.attack, sample_rate); + let release = duration_to_coefficient(settings.release, sample_rate); + let channels = input.channels(); + let channels_count = channels.get() as usize; + + let base = LimitBase::new(settings.threshold, settings.knee_width, attack, release); + + let inner = match channels_count { + 1 => LimitInner::Mono(LimitMono { + input, + base, + limiter_integrator: 0.0, + limiter_peak: 0.0, + }), + 2 => LimitInner::Stereo(LimitStereo { + input, + base, + limiter_integrators: [0.0; 2], + limiter_peaks: [0.0; 2], + is_right_channel: false, + }), + n => LimitInner::MultiChannel(LimitMulti { + input, + base, + limiter_integrators: vec![0.0; n].into_boxed_slice(), + limiter_peaks: vec![0.0; n].into_boxed_slice(), + position: 0, + }), + }; + + Limit { + inner: Some(inner), + last_channels: channels, + } +} + /// Configuration settings for audio limiting. /// /// This struct defines how the limiter behaves, including when to start limiting @@ -126,8 +188,6 @@ use crate::{ /// .with_attack(Duration::from_millis(3)) // Faster attack /// .with_release(Duration::from_millis(50)); // Faster release /// ``` -#[derive(Debug, Clone)] -/// Configuration settings for audio limiting. /// /// # dB vs. dBFS Reference /// @@ -146,6 +206,7 @@ use crate::{ /// - **-6 dBFS**: Generous headroom (gentle limiting) /// - **-12 dBFS**: Conservative level (preserves significant dynamics) /// - **-20 dBFS**: Very quiet level (background/ambient sounds) +#[derive(Debug, Clone)] pub struct LimitSettings { /// Level where limiting begins (dBFS, must be negative). /// @@ -454,64 +515,6 @@ impl LimitSettings { } } -/// Creates a limiter that processes the input audio source. -/// -/// This function applies the specified limiting settings to control audio peaks. -/// The limiter uses feedforward processing with configurable attack/release times -/// and soft-knee characteristics for natural-sounding dynamic range control. -/// -/// # Arguments -/// -/// * `input` - Audio source to process -/// * `settings` - Limiter configuration (threshold, knee, timing) -/// -/// # Returns -/// -/// A [`Limit`] source that applies the limiting to the input audio. -/// -/// # Example -/// -/// ```rust -/// use rodio::source::{SineWave, Source, LimitSettings}; -/// -/// let source = SineWave::new(440.0).amplify(2.0); -/// let settings = LimitSettings::default().with_threshold(-6.0); -/// let limited = source.limit(settings); -/// ``` -pub(crate) fn limit(input: I, settings: LimitSettings) -> Limit { - let sample_rate = input.sample_rate(); - let attack = duration_to_coefficient(settings.attack, sample_rate); - let release = duration_to_coefficient(settings.release, sample_rate); - let channels = input.channels().get() as usize; - - let base = LimitBase::new(settings.threshold, settings.knee_width, attack, release); - - let inner = match channels { - 1 => LimitInner::Mono(LimitMono { - input, - base, - limiter_integrator: 0.0, - limiter_peak: 0.0, - }), - 2 => LimitInner::Stereo(LimitStereo { - input, - base, - limiter_integrators: [0.0; 2], - limiter_peaks: [0.0; 2], - position: 0, - }), - n => LimitInner::MultiChannel(LimitMulti { - input, - base, - limiter_integrators: vec![0.0; n], - limiter_peaks: vec![0.0; n], - position: 0, - }), - }; - - Limit(inner) -} - /// A source filter that applies audio limiting to prevent peaks from exceeding a threshold. /// /// This filter reduces the amplitude of audio signals that exceed the configured threshold @@ -554,23 +557,17 @@ pub(crate) fn limit(input: I, settings: LimitSettings) -> Limit { /// - **Stereo**: Two-channel optimized with interleaved processing /// - **Multi-channel**: Generic implementation for 3+ channels /// -/// # Channel Count Stability -/// -/// **Important**: The limiter is optimized for sources with fixed channel counts. -/// Most audio files (music, podcasts, etc.) maintain constant channel counts, -/// making this optimization safe and beneficial. -/// -/// If the underlying source changes channel count mid-stream (rare), the limiter -/// will continue to function but performance may be degraded. For such cases, -/// recreate the limiter when the channel count changes. -/// /// # Type Parameters /// /// * `I` - The input audio source type that implements [`Source`] #[derive(Clone, Debug)] -pub struct Limit(LimitInner) +pub struct Limit where - I: Source; + I: Source, +{ + inner: Option>, + last_channels: ChannelCount, +} impl Source for Limit where @@ -578,27 +575,27 @@ where { #[inline] fn current_span_len(&self) -> Option { - self.0.current_span_len() + self.inner.as_ref().unwrap().current_span_len() } #[inline] fn sample_rate(&self) -> SampleRate { - self.0.sample_rate() + self.inner.as_ref().unwrap().sample_rate() } #[inline] fn channels(&self) -> ChannelCount { - self.0.channels() + self.inner.as_ref().unwrap().channels() } #[inline] fn total_duration(&self) -> Option { - self.0.total_duration() + self.inner.as_ref().unwrap().total_duration() } #[inline] fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { - self.0.try_seek(position) + self.inner.as_mut().unwrap().try_seek(position) } } @@ -615,7 +612,7 @@ where /// Useful for inspecting source properties without consuming the filter. #[inline] pub fn inner(&self) -> &I { - self.0.inner() + self.inner.as_ref().unwrap().inner() } /// Returns a mutable reference to the inner audio source. @@ -625,7 +622,7 @@ where /// underlying source. #[inline] pub fn inner_mut(&mut self) -> &mut I { - self.0.inner_mut() + self.inner.as_mut().unwrap().inner_mut() } /// Consumes the limiter and returns the inner audio source. @@ -635,7 +632,7 @@ where /// Useful when limiting is no longer needed but the source should continue. #[inline] pub fn into_inner(self) -> I { - self.0.into_inner() + self.inner.unwrap().into_inner() } } @@ -648,16 +645,76 @@ where /// Provides the next limited sample. #[inline] fn next(&mut self) -> Option { - self.0.next() + let sample = self.inner.as_mut().unwrap().next()?; + + if self + .inner + .as_ref() + .unwrap() + .current_span_len() + .is_some_and(|len| len > 0) + { + let new_channels = self.inner.as_ref().unwrap().channels(); + + if new_channels != self.last_channels { + self.last_channels = new_channels; + let new_channels_count = new_channels.get() as usize; + + let parameters_changed = match self.inner.as_ref().unwrap() { + LimitInner::Mono(_) => new_channels_count != 1, + LimitInner::Stereo(_) => new_channels_count != 2, + LimitInner::MultiChannel(multi) => { + new_channels_count != multi.limiter_integrators.len() + } + }; + + if parameters_changed { + let old_inner = self.inner.take().unwrap(); + + let (input, base) = match old_inner { + LimitInner::Mono(mono) => (mono.input, mono.base), + LimitInner::Stereo(stereo) => (stereo.input, stereo.base), + LimitInner::MultiChannel(multi) => (multi.input, multi.base), + }; + + self.inner = Some(match new_channels_count { + 1 => LimitInner::Mono(LimitMono { + input, + base, + limiter_integrator: 0.0, + limiter_peak: 0.0, + }), + 2 => LimitInner::Stereo(LimitStereo { + input, + base, + limiter_integrators: [0.0; 2], + limiter_peaks: [0.0; 2], + is_right_channel: false, + }), + n => LimitInner::MultiChannel(LimitMulti { + input, + base, + limiter_integrators: vec![0.0; n].into_boxed_slice(), + limiter_peaks: vec![0.0; n].into_boxed_slice(), + position: 0, + }), + }); + } + } + } + + Some(sample) } /// Provides size hints from the inner limiter. #[inline] fn size_hint(&self) -> (usize, Option) { - self.0.size_hint() + self.inner.as_ref().unwrap().size_hint() } } +impl ExactSizeIterator for Limit where I: Source + ExactSizeIterator {} + /// Internal limiter implementation that adapts to different channel configurations. /// /// This enum is private and automatically selects the most efficient implementation @@ -716,13 +773,8 @@ struct LimitBase { /// This variant is automatically selected by [`Limit`] for mono audio sources. /// It uses minimal state (single integrator and peak detector) for optimal /// performance with single-channel audio. -/// -/// # Internal Use -/// -/// This struct is used internally by [`LimitInner::Mono`] and is not intended -/// for direct construction. Use [`Source::limit()`] instead. #[derive(Clone, Debug)] -pub struct LimitMono { +struct LimitMono { /// Input audio source input: I, /// Common limiter parameters @@ -744,13 +796,8 @@ pub struct LimitMono { /// The fixed arrays and channel position tracking provide optimal performance /// for interleaved stereo sample processing, avoiding the dynamic allocation /// overhead of the multi-channel variant. -/// -/// # Internal Use -/// -/// This struct is used internally by [`LimitInner::Stereo`] and is not intended -/// for direct construction. Use [`Source::limit()`] instead. #[derive(Clone, Debug)] -pub struct LimitStereo { +struct LimitStereo { /// Input audio source input: I, /// Common limiter parameters @@ -759,8 +806,8 @@ pub struct LimitStereo { limiter_integrators: [Float; 2], /// Peak detection states for left and right channels limiter_peaks: [Float; 2], - /// Current channel position (0 = left, 1 = right) - position: u8, + /// Current channel: true = right, false = left + is_right_channel: bool, } /// Generic multi-channel limiter for surround sound or other configurations. @@ -775,21 +822,16 @@ pub struct LimitStereo { /// While this variant has slightly more overhead than the mono/stereo variants /// due to vector allocation and dynamic indexing, it provides the flexibility /// needed for complex audio setups while maintaining good performance. -/// -/// # Internal Use -/// -/// This struct is used internally by [`LimitInner::MultiChannel`] and is not -/// intended for direct construction. Use [`Source::limit()`] instead. #[derive(Clone, Debug)] -pub struct LimitMulti { +struct LimitMulti { /// Input audio source input: I, /// Common limiter parameters base: LimitBase, /// Peak detector integrator states (one per channel) - limiter_integrators: Vec, + limiter_integrators: Box<[Float]>, /// Peak detector states (one per channel) - limiter_peaks: Vec, + limiter_peaks: Box<[Float]>, /// Current channel position (0 to channels-1) position: usize, } @@ -908,8 +950,8 @@ where /// updates. #[inline] fn process_next(&mut self, sample: I::Item) -> I::Item { - let channel = self.position as usize; - self.position ^= 1; + let channel = self.is_right_channel as usize; + self.is_right_channel = !self.is_right_channel; let processed = self.base.process_channel( sample, @@ -1131,7 +1173,7 @@ mod tests { use std::time::Duration; fn create_test_buffer( - samples: Vec, + samples: &[Sample], channels: ChannelCount, sample_rate: SampleRate, ) -> SamplesBuffer { @@ -1141,26 +1183,35 @@ mod tests { #[test] fn test_limiter_creation() { // Test mono - let buffer = create_test_buffer(vec![0.5, 0.8, 1.0, 0.3], nz!(1), nz!(44100)); + let buffer = create_test_buffer(&[0.5, 0.8, 1.0, 0.3], nz!(1), nz!(44100)); let limiter = limit(buffer, LimitSettings::default()); assert_eq!(limiter.channels(), nz!(1)); assert_eq!(limiter.sample_rate(), nz!(44100)); - matches!(limiter.0, LimitInner::Mono(_)); + assert!(matches!( + limiter.inner.as_ref().unwrap(), + LimitInner::Mono(_) + )); // Test stereo let buffer = create_test_buffer( - vec![0.5, 0.8, 1.0, 0.3, 0.2, 0.6, 0.9, 0.4], + &[0.5, 0.8, 1.0, 0.3, 0.2, 0.6, 0.9, 0.4], nz!(2), nz!(44100), ); let limiter = limit(buffer, LimitSettings::default()); assert_eq!(limiter.channels(), nz!(2)); - matches!(limiter.0, LimitInner::Stereo(_)); + assert!(matches!( + limiter.inner.as_ref().unwrap(), + LimitInner::Stereo(_) + )); // Test multichannel - let buffer = create_test_buffer(vec![0.5; 12], nz!(3), nz!(44100)); + let buffer = create_test_buffer(&[0.5; 12], nz!(3), nz!(44100)); let limiter = limit(buffer, LimitSettings::default()); assert_eq!(limiter.channels(), nz!(3)); - matches!(limiter.0, LimitInner::MultiChannel(_)); + assert!(matches!( + limiter.inner.as_ref().unwrap(), + LimitInner::MultiChannel(_) + )); } } diff --git a/src/source/mod.rs b/src/source/mod.rs index b458d13a..3309ffd4 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -164,17 +164,21 @@ pub use self::noise::{Pink, WhiteUniform}; /// `sample_rate` too frequently. /// /// In order to properly handle this situation, the `current_span_len()` method should return -/// the number of samples that remain in the iterator before the samples rate and number of -/// channels can potentially change. +/// the total number of samples in the current span (i.e., before the sample rate and number of +/// channels can potentially change). /// pub trait Source: Iterator { - /// Returns the number of samples before the current span ends. + /// Returns the total length of the current span in samples. + /// + /// A span is a contiguous block of samples with unchanging channel count and sample rate. + /// This method returns the total number of samples in the current span, not the number + /// of samples remaining to be read. /// /// `None` means "infinite" or "until the sound ends". Sources that return `Some(x)` should /// return `Some(0)` if and only if when there's no more data. /// - /// After the engine has finished reading the specified number of samples, it will check - /// whether the value of `channels()` and/or `sample_rate()` have changed. + /// After the engine has finished reading the number of samples returned by this method, + /// it will check whether the value of `channels()` and/or `sample_rate()` have changed. /// /// # Frame Alignment /// diff --git a/src/source/noise.rs b/src/source/noise.rs index 32cea2d9..b2712549 100644 --- a/src/source/noise.rs +++ b/src/source/noise.rs @@ -166,6 +166,11 @@ impl Iterator for WhiteUniform { fn next(&mut self) -> Option { Some(self.sampler.sample()) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl_noise_source!(WhiteUniform); @@ -221,6 +226,11 @@ impl Iterator for WhiteTriangular { fn next(&mut self) -> Option { Some(self.sampler.sample()) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl_noise_source!(WhiteTriangular); @@ -319,6 +329,11 @@ impl Iterator for Velvet { Some(output) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl_noise_source!(Velvet); @@ -393,6 +408,11 @@ impl Iterator for WhiteGaussian { fn next(&mut self) -> Option { Some(self.sampler.sample()) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl_noise_source!(WhiteGaussian); @@ -492,6 +512,11 @@ impl Iterator for Pink { // Normalize by number of generators to keep output in reasonable range Some(sum / PINK_NOISE_GENERATORS as Sample) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.white_noise.size_hint() + } } impl_noise_source!(Pink); @@ -555,6 +580,11 @@ impl Iterator for Blue { self.prev_white = white; Some(blue) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.white_noise.size_hint() + } } impl_noise_source!(Blue); @@ -618,6 +648,11 @@ impl Iterator for Violet { self.prev = blue; Some(violet) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.blue_noise.size_hint() + } } impl_noise_source!(Violet); @@ -673,6 +708,11 @@ impl> Iterator for IntegratedNoise { self.accumulator = self.accumulator * self.leak_factor + white; Some(self.accumulator * self.scale) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.white_noise.size_hint() + } } /// Brownian noise generator - true stochastic Brownian motion with Gaussian increments. @@ -724,6 +764,11 @@ impl Iterator for Brownian { fn next(&mut self) -> Option { self.inner.next() } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } } impl Source for Brownian { @@ -746,6 +791,7 @@ impl Source for Brownian { fn try_seek(&mut self, _pos: Duration) -> Result<(), crate::source::SeekError> { // Stateless noise generators can seek to any position since all positions // are equally random and don't depend on previous state + self.inner.accumulator = 0.0; Ok(()) } } @@ -801,6 +847,11 @@ impl Iterator for Red { fn next(&mut self) -> Option { self.inner.next() } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } } impl Source for Red { @@ -823,6 +874,7 @@ impl Source for Red { fn try_seek(&mut self, _pos: Duration) -> Result<(), crate::source::SeekError> { // Stateless noise generators can seek to any position since all positions // are equally random and don't depend on previous state + self.inner.accumulator = 0.0; Ok(()) } } diff --git a/src/source/pausable.rs b/src/source/pausable.rs index 5143b622..1b05ab0d 100644 --- a/src/source/pausable.rs +++ b/src/source/pausable.rs @@ -98,10 +98,14 @@ where #[inline] fn size_hint(&self) -> (usize, Option) { - self.input.size_hint() + let (lower, upper) = self.input.size_hint(); + let paused_samples = self.remaining_paused_samples as usize; + (lower + paused_samples, upper.map(|u| u + paused_samples)) } } +impl ExactSizeIterator for Pausable where I: Source + ExactSizeIterator {} + impl Source for Pausable where I: Source, diff --git a/src/source/periodic.rs b/src/source/periodic.rs index 09fc5477..b251ea31 100644 --- a/src/source/periodic.rs +++ b/src/source/periodic.rs @@ -14,7 +14,7 @@ where // TODO: handle the fact that the samples rate can change let update_frequency = (period.as_secs_f32() * (source.sample_rate().get() as f32) - * (source.channels().get() as f32)) as u32; + * (source.channels().get() as f32)) as usize; PeriodicAccess { input: source, @@ -35,10 +35,10 @@ pub struct PeriodicAccess { modifier: F, // The frequency with which local_volume should be updated by remote_volume - update_frequency: u32, + update_frequency: usize, // How many samples remain until it is time to update local_volume with remote_volume. - samples_until_update: u32, + samples_until_update: usize, } impl PeriodicAccess @@ -91,6 +91,13 @@ where } } +impl ExactSizeIterator for PeriodicAccess +where + I: Source + ExactSizeIterator, + F: FnMut(&mut I), +{ +} + impl Source for PeriodicAccess where I: Source, diff --git a/src/source/position.rs b/src/source/position.rs index 1788625b..8bd1a716 100644 --- a/src/source/position.rs +++ b/src/source/position.rs @@ -1,19 +1,24 @@ use std::time::Duration; use super::SeekError; -use crate::common::{ChannelCount, SampleRate}; -use crate::math::nz; +use crate::common::{ChannelCount, Float, SampleRate}; +use crate::math::{duration_from_secs, duration_to_float}; use crate::Source; /// Internal function that builds a `TrackPosition` object. See trait docs for /// details -pub fn track_position(source: I) -> TrackPosition { +pub fn track_position(source: I) -> TrackPosition +where + I: Source, +{ + let channels = source.channels(); + let sample_rate = source.sample_rate(); TrackPosition { input: source, samples_counted: 0, offset_duration: 0.0, - current_span_sample_rate: nz!(1), - current_span_channels: nz!(1), + current_span_sample_rate: sample_rate, + current_span_channels: channels, current_span_len: None, } } @@ -23,7 +28,7 @@ pub fn track_position(source: I) -> TrackPosition { pub struct TrackPosition { input: I, samples_counted: usize, - offset_duration: f64, + offset_duration: Float, current_span_sample_rate: SampleRate, current_span_channels: ChannelCount, current_span_len: Option, @@ -65,15 +70,16 @@ where /// track_position after speedup's and delay's. #[inline] pub fn get_pos(&self) -> Duration { - let seconds = self.samples_counted as f64 - / self.input.sample_rate().get() as f64 - / self.input.channels().get() as f64 + let seconds = self.samples_counted as Float + / self.input.sample_rate().get() as Float + / self.input.channels().get() as Float + self.offset_duration; - Duration::from_secs_f64(seconds) + duration_from_secs(seconds) } #[inline] - fn set_current_span(&mut self) { + fn reset_current_span(&mut self) { + self.samples_counted = 0; self.current_span_len = self.current_span_len(); self.current_span_sample_rate = self.sample_rate(); self.current_span_channels = self.channels(); @@ -88,28 +94,34 @@ where #[inline] fn next(&mut self) -> Option { - // This should only be executed once at the first call to next. if self.current_span_len.is_none() { - self.set_current_span(); + self.current_span_len = self.input.current_span_len(); } - let item = self.input.next(); - if item.is_some() { - self.samples_counted += 1; - - // At the end of a span add the duration of this span to - // offset_duration and start collecting samples again. - if Some(self.samples_counted) == self.current_span_len() { - self.offset_duration += self.samples_counted as f64 - / self.current_span_sample_rate.get() as f64 - / self.current_span_channels.get() as f64; - - // Reset. - self.samples_counted = 0; - self.set_current_span(); - }; - }; - item + let item = self.input.next()?; + self.samples_counted += 1; + + // Detect span boundaries by TWO mechanisms: + // 1. Reached end of span (by length) - handles same-parameter consecutive spans + // 2. Parameters changed - handles mid-stream parameter changes + let new_channels = self.input.channels(); + let new_sample_rate = self.input.sample_rate(); + + let length_boundary = self + .current_span_len + .is_some_and(|len| self.samples_counted >= len); + let parameter_boundary = new_channels != self.current_span_channels + || new_sample_rate != self.current_span_sample_rate; + + if length_boundary || parameter_boundary { + // At span boundary - accumulate duration using OLD parameters + self.offset_duration += self.samples_counted as Float + / self.current_span_sample_rate.get() as Float + / self.current_span_channels.get() as Float; + self.reset_current_span(); + } + + Some(item) } #[inline] @@ -118,6 +130,8 @@ where } } +impl ExactSizeIterator for TrackPosition where I: Source + ExactSizeIterator {} + impl Source for TrackPosition where I: Source, @@ -144,15 +158,15 @@ where #[inline] fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { - let result = self.input.try_seek(pos); - if result.is_ok() { - self.offset_duration = pos.as_secs_f64(); - // This assumes that the seek implementation of the codec always - // starts again at the beginning of a span. Which is the case with - // symphonia. - self.samples_counted = 0; - } - result + self.input.try_seek(pos)?; + self.offset_duration = duration_to_float(pos); + // Set current_span_len to None after seeking because we may have landed mid-span. + // We don't know how many samples remain in the current span, so we disable + // length-based boundary detection until the next parameter change, which will + // put us at the start of a fresh span where we can re-enable it. + self.reset_current_span(); + self.current_span_len = None; + Ok(()) } } diff --git a/src/source/repeat.rs b/src/source/repeat.rs index ef29968e..601b679c 100644 --- a/src/source/repeat.rs +++ b/src/source/repeat.rs @@ -46,7 +46,7 @@ where #[inline] fn size_hint(&self) -> (usize, Option) { // infinite - (0, None) + (usize::MAX, None) } } diff --git a/src/source/sawtooth.rs b/src/source/sawtooth.rs index 2bfc2309..e5bc97f1 100644 --- a/src/source/sawtooth.rs +++ b/src/source/sawtooth.rs @@ -36,6 +36,11 @@ impl Iterator for SawtoothWave { fn next(&mut self) -> Option { self.test_saw.next() } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl Source for SawtoothWave { diff --git a/src/source/signal_generator.rs b/src/source/signal_generator.rs index 34ffa443..58b29941 100644 --- a/src/source/signal_generator.rs +++ b/src/source/signal_generator.rs @@ -133,6 +133,11 @@ impl Iterator for SignalGenerator { self.phase = (self.phase + self.phase_step).rem_euclid(1.0); val } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl Source for SignalGenerator { diff --git a/src/source/sine.rs b/src/source/sine.rs index 1481d6b2..17c97008 100644 --- a/src/source/sine.rs +++ b/src/source/sine.rs @@ -36,6 +36,11 @@ impl Iterator for SineWave { fn next(&mut self) -> Option { self.test_sine.next() } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl Source for SineWave { diff --git a/src/source/skip.rs b/src/source/skip.rs index dfae5b92..8d80bf8e 100644 --- a/src/source/skip.rs +++ b/src/source/skip.rs @@ -22,7 +22,7 @@ fn do_skip_duration(input: &mut I, mut duration: Duration) where I: Source, { - while duration > Duration::new(0, 0) { + while duration > Duration::ZERO { if input.current_span_len().is_none() { // Sample rate and the amount of channels will be the same till the end. do_skip_duration_unchecked(input, duration); @@ -129,6 +129,8 @@ where } } +impl ExactSizeIterator for SkipDuration where I: Source + ExactSizeIterator {} + impl Source for SkipDuration where I: Source, @@ -150,10 +152,9 @@ where #[inline] fn total_duration(&self) -> Option { - self.input.total_duration().map(|val| { - val.checked_sub(self.skipped_duration) - .unwrap_or_else(|| Duration::from_secs(0)) - }) + self.input + .total_duration() + .map(|val| val.saturating_sub(self.skipped_duration)) } #[inline] diff --git a/src/source/skippable.rs b/src/source/skippable.rs index 0fc46560..24fa02bb 100644 --- a/src/source/skippable.rs +++ b/src/source/skippable.rs @@ -70,6 +70,8 @@ where } } +impl ExactSizeIterator for Skippable where I: Source + ExactSizeIterator {} + impl Source for Skippable where I: Source, diff --git a/src/source/square.rs b/src/source/square.rs index d4acb321..ebb16e82 100644 --- a/src/source/square.rs +++ b/src/source/square.rs @@ -36,6 +36,11 @@ impl Iterator for SquareWave { fn next(&mut self) -> Option { self.test_square.next() } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl Source for SquareWave { diff --git a/src/source/stoppable.rs b/src/source/stoppable.rs index 57cc7dae..44f21940 100644 --- a/src/source/stoppable.rs +++ b/src/source/stoppable.rs @@ -66,6 +66,8 @@ where } } +impl ExactSizeIterator for Stoppable where I: Source + ExactSizeIterator {} + impl Source for Stoppable where I: Source, diff --git a/src/source/take.rs b/src/source/take.rs index d9957e6f..bdaaa15a 100644 --- a/src/source/take.rs +++ b/src/source/take.rs @@ -10,13 +10,16 @@ pub fn take_duration(input: I, duration: Duration) -> TakeDuration where I: Source, { + let sample_rate = input.sample_rate(); + let channels = input.channels(); TakeDuration { - current_span_len: input.current_span_len(), duration_per_sample: TakeDuration::get_duration_per_sample(&input), input, remaining_duration: duration, requested_duration: duration, filter: None, + last_sample_rate: sample_rate, + last_channels: channels, } } @@ -44,10 +47,10 @@ pub struct TakeDuration { remaining_duration: Duration, requested_duration: Duration, filter: Option, - // Remaining samples in current span. - current_span_len: Option, - // Only updated when the current span len is exhausted. + // Cached duration per sample, updated when sample rate or channels change. duration_per_sample: Duration, + last_sample_rate: SampleRate, + last_channels: ChannelCount, } impl TakeDuration @@ -99,17 +102,18 @@ where type Item = ::Item; fn next(&mut self) -> Option<::Item> { - if let Some(span_len) = self.current_span_len.take() { - if span_len > 0 { - self.current_span_len = Some(span_len - 1); - } else { - self.current_span_len = self.input.current_span_len(); - // Sample rate might have changed + // Check if sample rate or channels changed (only if span is finite and not exhausted) + if self.input.current_span_len().is_some_and(|len| len > 0) { + let new_sample_rate = self.input.sample_rate(); + let new_channels = self.input.channels(); + if new_sample_rate != self.last_sample_rate || new_channels != self.last_channels { + self.last_sample_rate = new_sample_rate; + self.last_channels = new_channels; self.duration_per_sample = Self::get_duration_per_sample(&self.input); } } - if self.remaining_duration <= self.duration_per_sample { + if self.remaining_duration < self.duration_per_sample { None } else if let Some(sample) = self.input.next() { let sample = match &self.filter { @@ -125,9 +129,31 @@ where } } - // TODO: size_hint + #[inline] + fn size_hint(&self) -> (usize, Option) { + let remaining_nanos = self.remaining_duration.as_secs() * 1_000_000_000 + + self.remaining_duration.subsec_nanos() as u64; + let nanos_per_sample = self.duration_per_sample.as_secs() * 1_000_000_000 + + self.duration_per_sample.subsec_nanos() as u64; + + if nanos_per_sample == 0 || remaining_nanos == 0 { + return (0, Some(0)); + } + + let remaining_samples = (remaining_nanos / nanos_per_sample) as usize; + + let (inner_lower, inner_upper) = self.input.size_hint(); + let lower = inner_lower.min(remaining_samples); + let upper = inner_upper + .map(|u| u.min(remaining_samples)) + .or(Some(remaining_samples)); + + (lower, upper) + } } +impl ExactSizeIterator for TakeDuration where I: Source + ExactSizeIterator {} + impl Source for TakeDuration where I: Iterator + Source, @@ -138,6 +164,11 @@ where + self.remaining_duration.subsec_nanos() as u64; let nanos_per_sample = self.duration_per_sample.as_secs() * NANOS_PER_SEC + self.duration_per_sample.subsec_nanos() as u64; + + if nanos_per_sample == 0 || remaining_nanos == 0 { + return Some(0); + } + let remaining_samples = (remaining_nanos / nanos_per_sample) as usize; self.input @@ -171,6 +202,40 @@ where #[inline] fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { - self.input.try_seek(pos) + let result = self.input.try_seek(pos); + if result.is_ok() { + // Recalculate remaining duration after seek + self.remaining_duration = self.requested_duration.saturating_sub(pos); + // Don't update last_sample_rate or last_channels here - let next() detect the change + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::SineWave; + + #[test] + fn test_size_hint_with_zero_remaining() { + let source = SineWave::new(440.0).take_duration(Duration::ZERO); + assert_eq!(source.size_hint(), (0, Some(0))); + } + + #[test] + fn test_exact_duration_boundary() { + use crate::source::SineWave; + + let sample_rate = 48000; + let nanos_per_sample = (1_000_000_000 as Float / sample_rate as Float) as usize; + + let n_samples = 10; + let exact_duration = Duration::from_nanos((nanos_per_sample * n_samples) as u64); + + let source = SineWave::new(440.0).take_duration(exact_duration); + + let count = source.count(); + assert_eq!(count, n_samples); } } diff --git a/src/source/triangle.rs b/src/source/triangle.rs index 6cafc911..86a0c970 100644 --- a/src/source/triangle.rs +++ b/src/source/triangle.rs @@ -36,6 +36,11 @@ impl Iterator for TriangleWave { fn next(&mut self) -> Option { self.test_tri.next() } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, None) + } } impl Source for TriangleWave { diff --git a/src/static_buffer.rs b/src/static_buffer.rs index 44ff5998..d5b77ae5 100644 --- a/src/static_buffer.rs +++ b/src/static_buffer.rs @@ -114,6 +114,8 @@ impl Iterator for StaticSamplesBuffer { } } +impl ExactSizeIterator for StaticSamplesBuffer {} + #[cfg(test)] mod tests { use crate::math::nz; diff --git a/src/wav_output.rs b/src/wav_output.rs index f9234169..5f8f5ced 100644 --- a/src/wav_output.rs +++ b/src/wav_output.rs @@ -126,6 +126,11 @@ impl> Iterator for WholeFrames { self.pos += 1; Some(to_yield) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.source.size_hint() + } } #[cfg(test)] From 75389712142bd88c7812634e326e6241934797cf Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 22 Jan 2026 22:29:54 +0100 Subject: [PATCH 02/10] refactor: SourcesQueueOutput silence and span logic --- src/queue.rs | 181 ++++++++++++++++++++++++++------------------------- 1 file changed, 93 insertions(+), 88 deletions(-) diff --git a/src/queue.rs b/src/queue.rs index b0d51a7f..560d30eb 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -5,7 +5,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use crate::source::{Empty, SeekError, Source, Zero}; +use dasp_sample::Sample as _; + +use crate::source::{Empty, SeekError, Source}; use crate::Sample; use crate::common::{ChannelCount, SampleRate}; @@ -36,7 +38,7 @@ pub fn queue(keep_alive_if_empty: bool) -> (Arc, SourcesQueue signal_after_end: None, input: input.clone(), samples_consumed_in_span: 0, - padding_samples_remaining: 0, + silence_samples_remaining: 0, }; (input, output) @@ -72,7 +74,8 @@ impl SourcesQueueInput { /// /// The `Receiver` will be signalled when the sound has finished playing. /// - /// Enable the feature flag `crossbeam-channel` in rodio to use a `crossbeam_channel::Receiver` instead. + /// Enable the feature flag `crossbeam-channel` in rodio to use a `crossbeam_channel::Receiver` + /// instead. #[inline] pub fn append_with_signal(&self, source: T) -> Receiver<()> where @@ -94,6 +97,11 @@ impl SourcesQueueInput { .store(keep_alive_if_empty, Ordering::Release); } + /// Returns whether the queue stays alive if there's no more sound to play. + pub fn keep_alive_if_empty(&self) -> bool { + self.keep_alive_if_empty.load(Ordering::Acquire) + } + /// Removes all the sounds from the queue. Returns the number of sounds cleared. pub fn clear(&self) -> usize { let mut sounds = self.next_sounds.lock().unwrap(); @@ -102,6 +110,7 @@ impl SourcesQueueInput { len } } + /// The output of the queue. Implements `Source`. pub struct SourcesQueueOutput { // The current iterator that produces samples. @@ -116,72 +125,74 @@ pub struct SourcesQueueOutput { // Track samples consumed in the current span to detect mid-span endings. samples_consumed_in_span: usize, - // When a source ends mid-frame, this counts how many silence samples to inject - // to complete the frame before transitioning to the next source. - padding_samples_remaining: usize, -} - -/// Returns a threshold span length that ensures frame alignment. -/// -/// Spans must end on frame boundaries (multiples of channel count) to prevent -/// channel misalignment. Returns ~512 samples rounded to the nearest frame. -#[inline] -fn threshold(channels: ChannelCount) -> usize { - const BASE_SAMPLES: usize = 512; - let ch = channels.get() as usize; - BASE_SAMPLES.div_ceil(ch) * ch + // This counts how many silence samples to inject when a source ends. + silence_samples_remaining: usize, } impl Source for SourcesQueueOutput { #[inline] fn current_span_len(&self) -> Option { - if !self.current.is_exhausted() { - return self.current.current_span_len(); - } else if self.input.keep_alive_if_empty.load(Ordering::Acquire) - && self.input.next_sounds.lock().unwrap().is_empty() + let len = match self.current.current_span_len() { + Some(len) if len == 0 && self.silence_samples_remaining > 0 => { + // - Current source ended mid-frame, and we're injecting silence to frame-align it. + self.silence_samples_remaining + } + Some(len) if len > 0 || !self.input.keep_alive_if_empty() => { + // - Current source is not exhausted, and is reporting some span length, or + // - Current source is exhausted, and won't output silence after it: end of queue. + len + } + _ => { + // - Current source is not exhausted, and is reporting no span length, or + // - Current source is exhausted, and will output silence after it. + self.current.channels().get() as usize + } + }; + + // Special case: if the current source is `Empty` and there are queued sounds after it. + if len == 0 + && self + .current + .total_duration() + .is_some_and(|duration| duration.is_zero()) { - // Return what that Zero's current_span_len() will be: Some(threshold(channels)). - return Some(threshold(self.current.channels())); + if let Some((next, _)) = self.input.next_sounds.lock().unwrap().front() { + return next + .current_span_len() + .or_else(|| Some(next.channels().get() as usize)); + } } - None + // A queue must never return None: that could cause downstream sources to assume sample + // rate or channel count would never change from one queue item to the next. + Some(len) } #[inline] fn channels(&self) -> ChannelCount { - if !self.current.is_exhausted() { - // Current source is active (producing samples) - // - Initially: never (Empty is exhausted immediately) - // - After append: the appended source while playing - // - With keep_alive: Zero (silence) while playing - self.current.channels() - } else if let Some((next, _)) = self.input.next_sounds.lock().unwrap().front() { - // Current source exhausted, peek at next queued source - // This is critical: UniformSourceIterator queries metadata during append, - // before any samples are pulled. We must report the next source's metadata. - next.channels() - } else { - // Queue is empty, no sources queued - // - Initially: Empty - // - With keep_alive: exhausted Zero between silence chunks (matches Empty) - // - Without keep_alive: Empty (will end on next()) - self.current.channels() + if self.current.is_exhausted() && self.silence_samples_remaining == 0 { + if let Some((next, _)) = self.input.next_sounds.lock().unwrap().front() { + // Current source exhausted, peek at next queued source + // This is critical: UniformSourceIterator queries metadata during append, + // before any samples are pulled. We must report the next source's metadata. + return next.channels(); + } } + + self.current.channels() } #[inline] fn sample_rate(&self) -> SampleRate { - if !self.current.is_exhausted() { - // Current source is active (producing samples) - self.current.sample_rate() - } else if let Some((next, _)) = self.input.next_sounds.lock().unwrap().front() { - // Current source exhausted, peek at next queued source - // This prevents wrong resampling setup in UniformSourceIterator - next.sample_rate() - } else { - // Queue is empty, no sources queued - self.current.sample_rate() + if self.current.is_exhausted() && self.silence_samples_remaining == 0 { + if let Some((next, _)) = self.input.next_sounds.lock().unwrap().front() { + // Current source exhausted, peek at next queued source + // This prevents wrong resampling setup in UniformSourceIterator + return next.sample_rate(); + } } + + self.current.sample_rate() } #[inline] @@ -211,34 +222,45 @@ impl Iterator for SourcesQueueOutput { fn next(&mut self) -> Option { loop { // If we're padding to complete a frame, return silence. - if self.padding_samples_remaining > 0 { - self.padding_samples_remaining -= 1; - return Some(0.0); + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); } // Basic situation that will happen most of the time. if let Some(sample) = self.current.next() { - self.samples_consumed_in_span += 1; + self.samples_consumed_in_span = self + .samples_consumed_in_span + .checked_add(1) + .unwrap_or_else(|| { + self.samples_consumed_in_span % self.current.channels().get() as usize + 1 + }); return Some(sample); } - // Source ended - check if we ended mid-frame and need padding. - let channels = self.current.channels().get() as usize; - let incomplete_frame_samples = self.samples_consumed_in_span % channels; - if incomplete_frame_samples > 0 { - // We're mid-frame - need to pad with silence to complete it. - self.padding_samples_remaining = channels - incomplete_frame_samples; - // Reset counter now since we're transitioning to a new span. - self.samples_consumed_in_span = 0; - // Continue loop - next iteration will inject silence. - continue; + // Current source is exhausted - check if we ended mid-frame and need padding. + if self.samples_consumed_in_span > 0 { + let channels = self.current.channels().get() as usize; + let incomplete_frame_samples = self.samples_consumed_in_span % channels; + if incomplete_frame_samples > 0 { + // We're mid-frame - need to pad with silence to complete it. + self.silence_samples_remaining = channels - incomplete_frame_samples; + // Reset counter now since we're transitioning to a new span. + self.samples_consumed_in_span = 0; + // Continue loop - next iterations will inject silence. + continue; + } } - // Reset counter and move to next sound. - // In order to avoid inlining this expensive operation, the code is in another function. - self.samples_consumed_in_span = 0; + // Move to next sound, play silence, or end. + // In order to avoid inlining that expensive operation, the code is in another function. if self.go_next().is_err() { - return None; + if self.input.keep_alive_if_empty() { + self.silence_samples_remaining = self.current.channels().get() as usize; + continue; + } else { + return None; + } } } } @@ -251,7 +273,7 @@ impl Iterator for SourcesQueueOutput { impl SourcesQueueOutput { // Called when `current` is empty, and we must jump to the next element. - // Returns `Ok` if the sound should continue playing, or an error if it should stop. + // Returns `Ok` if there is another sound should continue playing, or `Err` when there is not. // // This method is separate so that it is not inlined. fn go_next(&mut self) -> Result<(), ()> { @@ -261,23 +283,7 @@ impl SourcesQueueOutput { let (next, signal_after_end) = { let mut next = self.input.next_sounds.lock().unwrap(); - - if let Some(next) = next.pop_front() { - next - } else { - let channels = self.current.channels(); - let silence = Box::new(Zero::new_samples( - channels, - self.current.sample_rate(), - threshold(channels), - )) as Box<_>; - if self.input.keep_alive_if_empty.load(Ordering::Acquire) { - // Play a short silence in order to avoid spinlocking. - (silence, None) - } else { - return Err(()); - } - } + next.pop_front().ok_or(())? }; self.current = next; @@ -350,7 +356,6 @@ mod tests { } #[test] - #[ignore] // TODO: not yet implemented fn no_delay_when_added() { let (tx, mut rx) = queue::queue(true); From 9088afe28baf06d18c62cb47409e94dcc6fdba7a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sat, 24 Jan 2026 17:20:20 +0100 Subject: [PATCH 03/10] fix: detect stream parameter changes at span boundaries Parameter updates (channels/sample_rate) occur only at span boundaries or during post-seek detection. Reset counters and enter a detection mode on try_seek (Duration::ZERO is treated as start-of-span). --- src/queue.rs | 9 ++---- src/source/dither.rs | 63 +++++++++++++++++++++++++++++++++-------- src/source/limit.rs | 67 +++++++++++++++++++++++++++++++++++--------- src/source/take.rs | 61 ++++++++++++++++++++++++++++++++-------- 4 files changed, 156 insertions(+), 44 deletions(-) diff --git a/src/queue.rs b/src/queue.rs index 560d30eb..274f907f 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -229,12 +229,8 @@ impl Iterator for SourcesQueueOutput { // Basic situation that will happen most of the time. if let Some(sample) = self.current.next() { - self.samples_consumed_in_span = self - .samples_consumed_in_span - .checked_add(1) - .unwrap_or_else(|| { - self.samples_consumed_in_span % self.current.channels().get() as usize + 1 - }); + let channels = self.current.channels().get() as usize; + self.samples_consumed_in_span = (self.samples_consumed_in_span + 1) % channels; return Some(sample); } @@ -288,6 +284,7 @@ impl SourcesQueueOutput { self.current = next; self.signal_after_end = signal_after_end; + self.samples_consumed_in_span = 0; Ok(()) } } diff --git a/src/source/dither.rs b/src/source/dither.rs index 95cff7d4..3813d6ea 100644 --- a/src/source/dither.rs +++ b/src/source/dither.rs @@ -29,7 +29,10 @@ use rand::{rngs::SmallRng, Rng}; use std::time::Duration; use crate::{ - source::noise::{Blue, WhiteGaussian, WhiteTriangular, WhiteUniform}, + source::{ + noise::{Blue, WhiteGaussian, WhiteTriangular, WhiteUniform}, + SeekError, + }, BitDepth, ChannelCount, Float, Sample, SampleRate, Source, }; @@ -164,6 +167,8 @@ pub struct Dither { last_sample_rate: SampleRate, last_channels: ChannelCount, lsb_amplitude: Float, + samples_counted: usize, + current_span_len: Option, } impl Dither @@ -188,6 +193,8 @@ where last_sample_rate: sample_rate, last_channels: channels, lsb_amplitude, + samples_counted: 0, + current_span_len: None, } } @@ -215,23 +222,41 @@ where #[inline] fn next(&mut self) -> Option { let input_sample = self.input.next()?; + self.samples_counted = self.samples_counted.saturating_add(1); + + let input_span_len = self.input.current_span_len(); + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + + // If input reports no span length, then by contract parameters are stable. + let mut parameters_changed = false; + let at_boundary = input_span_len.is_some_and(|_| { + let known_boundary = self + .current_span_len + .map(|cached_len| self.samples_counted >= cached_len); + + // In span-counting mode, the only way parameters can change is at a span boundary. + // In detection mode after try_seek, we check every sample until we detect a boundary. + if known_boundary.is_none_or(|at_boundary| at_boundary) { + parameters_changed = current_channels != self.last_channels + || current_sample_rate != self.last_sample_rate; + } - if self.input.current_span_len().is_some_and(|len| len > 0) { - let current_sample_rate = self.input.sample_rate(); - let current_channels = self.input.channels(); - let parameters_changed = current_sample_rate != self.last_sample_rate - || current_channels != self.last_channels; + known_boundary.unwrap_or(parameters_changed) + }); + if at_boundary { if parameters_changed { self.noise .update_parameters(current_sample_rate, current_channels); - self.current_channel = 0; self.last_sample_rate = current_sample_rate; self.last_channels = current_channels; } - } - let num_channels = self.input.channels(); + self.samples_counted = 0; + self.current_channel = 0; + self.current_span_len = input_span_len; + } let noise_sample = self .noise @@ -239,7 +264,7 @@ where .expect("Noise generator should always produce samples"); // Advance to next channel (wrapping around) - self.current_channel = (self.current_channel + 1) % num_channels.get() as usize; + self.current_channel = (self.current_channel + 1) % self.input.channels().get() as usize; // Apply subtractive dithering at the target quantization level Some(input_sample - noise_sample * self.lsb_amplitude) @@ -278,8 +303,22 @@ where } #[inline] - fn try_seek(&mut self, pos: Duration) -> Result<(), crate::source::SeekError> { - self.input.try_seek(pos) + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + self.input.try_seek(pos)?; + self.samples_counted = 0; + self.current_channel = 0; + + // After seeking, we may be mid-span at an unknown position. + // Special case: seeking to Duration::ZERO means we're at the start of the first span. + // Otherwise, enter detection mode (current_span_len = None) to check parameters + // every sample until we detect a span boundary by parameter change. + if pos == Duration::ZERO { + self.current_span_len = self.input.current_span_len(); + } else { + self.current_span_len = None; + } + + Ok(()) } } diff --git a/src/source/limit.rs b/src/source/limit.rs index 4277558a..83023c24 100644 --- a/src/source/limit.rs +++ b/src/source/limit.rs @@ -126,6 +126,9 @@ pub(crate) fn limit(input: I, settings: LimitSettings) -> Limit { Limit { inner: Some(inner), last_channels: channels, + last_sample_rate: sample_rate, + samples_counted: 0, + current_span_len: None, } } @@ -567,6 +570,9 @@ where { inner: Option>, last_channels: ChannelCount, + last_sample_rate: SampleRate, + samples_counted: usize, + current_span_len: Option, } impl Source for Limit @@ -595,7 +601,22 @@ where #[inline] fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { - self.inner.as_mut().unwrap().try_seek(position) + let result = self.inner.as_mut().unwrap().try_seek(position); + if result.is_ok() { + // Reset span tracking + self.samples_counted = 0; + + // After seeking, we may be mid-span at an unknown position. + // Special case: seeking to Duration::ZERO means we're at the start of the first span. + // Otherwise, enter detection mode (current_span_len = None) to check parameters + // every sample until we detect a span boundary by parameter change. + if position == Duration::ZERO { + self.current_span_len = self.inner.as_ref().unwrap().current_span_len(); + } else { + self.current_span_len = None; + } + } + result } } @@ -646,21 +667,36 @@ where #[inline] fn next(&mut self) -> Option { let sample = self.inner.as_mut().unwrap().next()?; + self.samples_counted = self.samples_counted.saturating_add(1); + + let input_span_len = self.inner.as_ref().unwrap().current_span_len(); + let current_channels = self.inner.as_ref().unwrap().channels(); + let current_sample_rate = self.inner.as_ref().unwrap().sample_rate(); + + // If input reports no span length, then by contract parameters are stable. + let mut parameters_changed = false; + let at_boundary = input_span_len.is_some_and(|_| { + let known_boundary = self + .current_span_len + .map(|cached_len| self.samples_counted >= cached_len); + + // In span-counting mode, the only way parameters can change is at a span boundary. + // In detection mode after try_seek, we check every sample until we detect a boundary. + if known_boundary.is_none_or(|at_boundary| at_boundary) { + parameters_changed = current_channels != self.last_channels + || current_sample_rate != self.last_sample_rate; + } - if self - .inner - .as_ref() - .unwrap() - .current_span_len() - .is_some_and(|len| len > 0) - { - let new_channels = self.inner.as_ref().unwrap().channels(); + known_boundary.unwrap_or(parameters_changed) + }); - if new_channels != self.last_channels { - self.last_channels = new_channels; - let new_channels_count = new_channels.get() as usize; + if at_boundary { + if parameters_changed { + self.last_channels = current_channels; + self.last_sample_rate = current_sample_rate; + let new_channels_count = current_channels.get() as usize; - let parameters_changed = match self.inner.as_ref().unwrap() { + let needs_reconstruction = match self.inner.as_ref().unwrap() { LimitInner::Mono(_) => new_channels_count != 1, LimitInner::Stereo(_) => new_channels_count != 2, LimitInner::MultiChannel(multi) => { @@ -668,7 +704,7 @@ where } }; - if parameters_changed { + if needs_reconstruction { let old_inner = self.inner.take().unwrap(); let (input, base) = match old_inner { @@ -701,6 +737,9 @@ where }); } } + + self.samples_counted = 0; + self.current_span_len = input_span_len; } Some(sample) diff --git a/src/source/take.rs b/src/source/take.rs index bdaaa15a..106b3c30 100644 --- a/src/source/take.rs +++ b/src/source/take.rs @@ -20,6 +20,8 @@ where filter: None, last_sample_rate: sample_rate, last_channels: channels, + samples_counted: 0, + current_span_len: None, } } @@ -51,6 +53,8 @@ pub struct TakeDuration { duration_per_sample: Duration, last_sample_rate: SampleRate, last_channels: ChannelCount, + samples_counted: usize, + current_span_len: Option, } impl TakeDuration @@ -102,20 +106,43 @@ where type Item = ::Item; fn next(&mut self) -> Option<::Item> { - // Check if sample rate or channels changed (only if span is finite and not exhausted) - if self.input.current_span_len().is_some_and(|len| len > 0) { - let new_sample_rate = self.input.sample_rate(); - let new_channels = self.input.channels(); - if new_sample_rate != self.last_sample_rate || new_channels != self.last_channels { - self.last_sample_rate = new_sample_rate; - self.last_channels = new_channels; - self.duration_per_sample = Self::get_duration_per_sample(&self.input); - } - } - if self.remaining_duration < self.duration_per_sample { None } else if let Some(sample) = self.input.next() { + self.samples_counted = self.samples_counted.saturating_add(1); + + let input_span_len = self.input.current_span_len(); + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + + // If input reports no span length, then by contract parameters are stable. + let mut parameters_changed = false; + let at_boundary = input_span_len.is_some_and(|_| { + let known_boundary = self + .current_span_len + .map(|cached_len| self.samples_counted >= cached_len); + + // In span-counting mode, the only way parameters can change is at a span boundary. + // In detection mode after try_seek, we check every sample until we detect a boundary. + if known_boundary.is_none_or(|at_boundary| at_boundary) { + parameters_changed = current_channels != self.last_channels + || current_sample_rate != self.last_sample_rate; + } + + known_boundary.unwrap_or(parameters_changed) + }); + + if at_boundary { + if parameters_changed { + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + self.duration_per_sample = Self::get_duration_per_sample(&self.input); + } + + self.samples_counted = 0; + self.current_span_len = input_span_len; + } + let sample = match &self.filter { Some(filter) => filter.apply(sample, self), None => sample, @@ -206,7 +233,17 @@ where if result.is_ok() { // Recalculate remaining duration after seek self.remaining_duration = self.requested_duration.saturating_sub(pos); - // Don't update last_sample_rate or last_channels here - let next() detect the change + self.samples_counted = 0; + + // After seeking, we may be mid-span at an unknown position. + // Special case: seeking to Duration::ZERO means we're at the start of the first span. + // Otherwise, enter detection mode (current_span_len = None) to check parameters + // every sample until we detect a span boundary by parameter change. + if pos == Duration::ZERO { + self.current_span_len = self.input.current_span_len(); + } else { + self.current_span_len = None; + } } result } From b16d16419ffea9554a06f3563bd9d188c6a2e95e Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sat, 24 Jan 2026 19:10:33 +0100 Subject: [PATCH 04/10] refactor: extract span tracking helpers --- src/source/dither.rs | 54 ++++++--------- src/source/limit.rs | 145 ++++++++++++++++++----------------------- src/source/mod.rs | 55 ++++++++++++++++ src/source/position.rs | 80 ++++++++++++----------- src/source/take.rs | 65 +++++++----------- 5 files changed, 200 insertions(+), 199 deletions(-) diff --git a/src/source/dither.rs b/src/source/dither.rs index 3813d6ea..4a009883 100644 --- a/src/source/dither.rs +++ b/src/source/dither.rs @@ -30,8 +30,9 @@ use std::time::Duration; use crate::{ source::{ + detect_span_boundary, noise::{Blue, WhiteGaussian, WhiteTriangular, WhiteUniform}, - SeekError, + reset_seek_span_tracking, SeekError, }, BitDepth, ChannelCount, Float, Sample, SampleRate, Source, }; @@ -168,7 +169,7 @@ pub struct Dither { last_channels: ChannelCount, lsb_amplitude: Float, samples_counted: usize, - current_span_len: Option, + cached_span_len: Option, } impl Dither @@ -194,7 +195,7 @@ where last_channels: channels, lsb_amplitude, samples_counted: 0, - current_span_len: None, + cached_span_len: None, } } @@ -222,28 +223,20 @@ where #[inline] fn next(&mut self) -> Option { let input_sample = self.input.next()?; - self.samples_counted = self.samples_counted.saturating_add(1); let input_span_len = self.input.current_span_len(); let current_sample_rate = self.input.sample_rate(); let current_channels = self.input.channels(); - // If input reports no span length, then by contract parameters are stable. - let mut parameters_changed = false; - let at_boundary = input_span_len.is_some_and(|_| { - let known_boundary = self - .current_span_len - .map(|cached_len| self.samples_counted >= cached_len); - - // In span-counting mode, the only way parameters can change is at a span boundary. - // In detection mode after try_seek, we check every sample until we detect a boundary. - if known_boundary.is_none_or(|at_boundary| at_boundary) { - parameters_changed = current_channels != self.last_channels - || current_sample_rate != self.last_sample_rate; - } - - known_boundary.unwrap_or(parameters_changed) - }); + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); if at_boundary { if parameters_changed { @@ -252,10 +245,7 @@ where self.last_sample_rate = current_sample_rate; self.last_channels = current_channels; } - - self.samples_counted = 0; self.current_channel = 0; - self.current_span_len = input_span_len; } let noise_sample = self @@ -305,19 +295,13 @@ where #[inline] fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { self.input.try_seek(pos)?; - self.samples_counted = 0; self.current_channel = 0; - - // After seeking, we may be mid-span at an unknown position. - // Special case: seeking to Duration::ZERO means we're at the start of the first span. - // Otherwise, enter detection mode (current_span_len = None) to check parameters - // every sample until we detect a span boundary by parameter change. - if pos == Duration::ZERO { - self.current_span_len = self.input.current_span_len(); - } else { - self.current_span_len = None; - } - + reset_seek_span_tracking( + &mut self.samples_counted, + &mut self.cached_span_len, + pos, + self.input.current_span_len(), + ); Ok(()) } } diff --git a/src/source/limit.rs b/src/source/limit.rs index 83023c24..a87ba671 100644 --- a/src/source/limit.rs +++ b/src/source/limit.rs @@ -60,7 +60,7 @@ use std::time::Duration; -use super::SeekError; +use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; use crate::{ common::{ChannelCount, Sample, SampleRate}, math::{self, duration_to_coefficient}, @@ -128,7 +128,7 @@ pub(crate) fn limit(input: I, settings: LimitSettings) -> Limit { last_channels: channels, last_sample_rate: sample_rate, samples_counted: 0, - current_span_len: None, + cached_span_len: None, } } @@ -572,7 +572,7 @@ where last_channels: ChannelCount, last_sample_rate: SampleRate, samples_counted: usize, - current_span_len: Option, + cached_span_len: Option, } impl Source for Limit @@ -601,22 +601,14 @@ where #[inline] fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { - let result = self.inner.as_mut().unwrap().try_seek(position); - if result.is_ok() { - // Reset span tracking - self.samples_counted = 0; - - // After seeking, we may be mid-span at an unknown position. - // Special case: seeking to Duration::ZERO means we're at the start of the first span. - // Otherwise, enter detection mode (current_span_len = None) to check parameters - // every sample until we detect a span boundary by parameter change. - if position == Duration::ZERO { - self.current_span_len = self.inner.as_ref().unwrap().current_span_len(); - } else { - self.current_span_len = None; - } - } - result + self.inner.as_mut().unwrap().try_seek(position)?; + reset_seek_span_tracking( + &mut self.samples_counted, + &mut self.cached_span_len, + position, + self.inner.as_ref().unwrap().current_span_len(), + ); + Ok(()) } } @@ -667,79 +659,66 @@ where #[inline] fn next(&mut self) -> Option { let sample = self.inner.as_mut().unwrap().next()?; - self.samples_counted = self.samples_counted.saturating_add(1); let input_span_len = self.inner.as_ref().unwrap().current_span_len(); let current_channels = self.inner.as_ref().unwrap().channels(); let current_sample_rate = self.inner.as_ref().unwrap().sample_rate(); - // If input reports no span length, then by contract parameters are stable. - let mut parameters_changed = false; - let at_boundary = input_span_len.is_some_and(|_| { - let known_boundary = self - .current_span_len - .map(|cached_len| self.samples_counted >= cached_len); - - // In span-counting mode, the only way parameters can change is at a span boundary. - // In detection mode after try_seek, we check every sample until we detect a boundary. - if known_boundary.is_none_or(|at_boundary| at_boundary) { - parameters_changed = current_channels != self.last_channels - || current_sample_rate != self.last_sample_rate; - } + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); - known_boundary.unwrap_or(parameters_changed) - }); - - if at_boundary { - if parameters_changed { - self.last_channels = current_channels; - self.last_sample_rate = current_sample_rate; - let new_channels_count = current_channels.get() as usize; - - let needs_reconstruction = match self.inner.as_ref().unwrap() { - LimitInner::Mono(_) => new_channels_count != 1, - LimitInner::Stereo(_) => new_channels_count != 2, - LimitInner::MultiChannel(multi) => { - new_channels_count != multi.limiter_integrators.len() - } - }; + if at_boundary && parameters_changed { + self.last_channels = current_channels; + self.last_sample_rate = current_sample_rate; + let new_channels_count = current_channels.get() as usize; - if needs_reconstruction { - let old_inner = self.inner.take().unwrap(); - - let (input, base) = match old_inner { - LimitInner::Mono(mono) => (mono.input, mono.base), - LimitInner::Stereo(stereo) => (stereo.input, stereo.base), - LimitInner::MultiChannel(multi) => (multi.input, multi.base), - }; - - self.inner = Some(match new_channels_count { - 1 => LimitInner::Mono(LimitMono { - input, - base, - limiter_integrator: 0.0, - limiter_peak: 0.0, - }), - 2 => LimitInner::Stereo(LimitStereo { - input, - base, - limiter_integrators: [0.0; 2], - limiter_peaks: [0.0; 2], - is_right_channel: false, - }), - n => LimitInner::MultiChannel(LimitMulti { - input, - base, - limiter_integrators: vec![0.0; n].into_boxed_slice(), - limiter_peaks: vec![0.0; n].into_boxed_slice(), - position: 0, - }), - }); + let needs_reconstruction = match self.inner.as_ref().unwrap() { + LimitInner::Mono(_) => new_channels_count != 1, + LimitInner::Stereo(_) => new_channels_count != 2, + LimitInner::MultiChannel(multi) => { + new_channels_count != multi.limiter_integrators.len() } - } + }; + + if needs_reconstruction { + let old_inner = self.inner.take().unwrap(); - self.samples_counted = 0; - self.current_span_len = input_span_len; + let (input, base) = match old_inner { + LimitInner::Mono(mono) => (mono.input, mono.base), + LimitInner::Stereo(stereo) => (stereo.input, stereo.base), + LimitInner::MultiChannel(multi) => (multi.input, multi.base), + }; + + self.inner = Some(match new_channels_count { + 1 => LimitInner::Mono(LimitMono { + input, + base, + limiter_integrator: 0.0, + limiter_peak: 0.0, + }), + 2 => LimitInner::Stereo(LimitStereo { + input, + base, + limiter_integrators: [0.0; 2], + limiter_peaks: [0.0; 2], + is_right_channel: false, + }), + n => LimitInner::MultiChannel(LimitMulti { + input, + base, + limiter_integrators: vec![0.0; n].into_boxed_slice(), + limiter_peaks: vec![0.0; n].into_boxed_slice(), + position: 0, + }), + }); + } } Some(sample) diff --git a/src/source/mod.rs b/src/source/mod.rs index 3309ffd4..425f82e9 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -840,3 +840,58 @@ source_pointer_impl!(Source for Box); source_pointer_impl!(Source for Box); source_pointer_impl!(<'a, Src> Source for &'a mut Src where Src: Source,); + +/// Detects if we're at a span boundary using dual-mode tracking. +/// Returns a tuple indicating whether we're at a span boundary and if parameters changed. +#[inline] +pub(crate) fn detect_span_boundary( + samples_counted: &mut usize, + cached_span_len: &mut Option, + input_span_len: Option, + current_sample_rate: SampleRate, + last_sample_rate: SampleRate, + current_channels: ChannelCount, + last_channels: ChannelCount, +) -> (bool, bool) { + *samples_counted = samples_counted.saturating_add(1); + + // If input reports no span length, then by contract parameters are stable. + let mut parameters_changed = false; + let at_boundary = input_span_len.is_some_and(|_| { + let known_boundary = cached_span_len.map(|cached_len| *samples_counted >= cached_len); + + // In span-counting mode, the only moment that parameters can change is at a span boundary. + // In detection mode after try_seek, we check every sample until we detect a boundary. + if known_boundary.is_none_or(|at_boundary| at_boundary) { + parameters_changed = + current_channels != last_channels || current_sample_rate != last_sample_rate; + } + + known_boundary.unwrap_or(parameters_changed) + }); + + if at_boundary { + *samples_counted = 0; + *cached_span_len = input_span_len; + } + + (at_boundary, parameters_changed) +} + +/// Resets span tracking state after a seek operation. +#[inline] +pub(crate) fn reset_seek_span_tracking( + samples_counted: &mut usize, + cached_span_len: &mut Option, + pos: Duration, + input_span_len: Option, +) { + *samples_counted = 0; + if pos == Duration::ZERO { + // Set span-counting mode when seeking to start + *cached_span_len = input_span_len; + } else { + // Set detection mode for arbitrary positions + *cached_span_len = None; + } +} diff --git a/src/source/position.rs b/src/source/position.rs index 8bd1a716..52821bb7 100644 --- a/src/source/position.rs +++ b/src/source/position.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use super::SeekError; +use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; use crate::common::{ChannelCount, Float, SampleRate}; use crate::math::{duration_from_secs, duration_to_float}; use crate::Source; @@ -19,7 +19,7 @@ where offset_duration: 0.0, current_span_sample_rate: sample_rate, current_span_channels: channels, - current_span_len: None, + cached_span_len: None, } } @@ -31,7 +31,7 @@ pub struct TrackPosition { offset_duration: Float, current_span_sample_rate: SampleRate, current_span_channels: ChannelCount, - current_span_len: Option, + cached_span_len: Option, } impl TrackPosition { @@ -76,14 +76,6 @@ where + self.offset_duration; duration_from_secs(seconds) } - - #[inline] - fn reset_current_span(&mut self) { - self.samples_counted = 0; - self.current_span_len = self.current_span_len(); - self.current_span_sample_rate = self.sample_rate(); - self.current_span_channels = self.channels(); - } } impl Iterator for TrackPosition @@ -94,31 +86,39 @@ where #[inline] fn next(&mut self) -> Option { - if self.current_span_len.is_none() { - self.current_span_len = self.input.current_span_len(); - } - let item = self.input.next()?; - self.samples_counted += 1; - - // Detect span boundaries by TWO mechanisms: - // 1. Reached end of span (by length) - handles same-parameter consecutive spans - // 2. Parameters changed - handles mid-stream parameter changes - let new_channels = self.input.channels(); - let new_sample_rate = self.input.sample_rate(); - - let length_boundary = self - .current_span_len - .is_some_and(|len| self.samples_counted >= len); - let parameter_boundary = new_channels != self.current_span_channels - || new_sample_rate != self.current_span_sample_rate; - - if length_boundary || parameter_boundary { - // At span boundary - accumulate duration using OLD parameters - self.offset_duration += self.samples_counted as Float + + let input_span_len = self.input.current_span_len(); + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + + // Capture samples_counted before detect_span_boundary resets it + let samples_before_boundary = self.samples_counted; + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.current_span_sample_rate, + current_channels, + self.current_span_channels, + ); + + if at_boundary { + // At span boundary - accumulate duration using OLD parameters and the sample + // count from before the boundary (detect_span_boundary increments first, then + // resets at boundary, so samples_before_boundary + 1 gives us the completed count) + let completed_samples = samples_before_boundary.saturating_add(1); + + self.offset_duration += completed_samples as Float / self.current_span_sample_rate.get() as Float / self.current_span_channels.get() as Float; - self.reset_current_span(); + + if parameters_changed { + self.current_span_sample_rate = current_sample_rate; + self.current_span_channels = current_channels; + } } Some(item) @@ -160,12 +160,14 @@ where fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { self.input.try_seek(pos)?; self.offset_duration = duration_to_float(pos); - // Set current_span_len to None after seeking because we may have landed mid-span. - // We don't know how many samples remain in the current span, so we disable - // length-based boundary detection until the next parameter change, which will - // put us at the start of a fresh span where we can re-enable it. - self.reset_current_span(); - self.current_span_len = None; + reset_seek_span_tracking( + &mut self.samples_counted, + &mut self.cached_span_len, + pos, + self.input.current_span_len(), + ); + self.current_span_sample_rate = self.input.sample_rate(); + self.current_span_channels = self.input.channels(); Ok(()) } } diff --git a/src/source/take.rs b/src/source/take.rs index 106b3c30..ad953cc9 100644 --- a/src/source/take.rs +++ b/src/source/take.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use super::SeekError; +use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; use crate::common::{ChannelCount, SampleRate}; use crate::math::NANOS_PER_SEC; use crate::{Float, Sample, Source}; @@ -21,7 +21,7 @@ where last_sample_rate: sample_rate, last_channels: channels, samples_counted: 0, - current_span_len: None, + cached_span_len: None, } } @@ -54,7 +54,7 @@ pub struct TakeDuration { last_sample_rate: SampleRate, last_channels: ChannelCount, samples_counted: usize, - current_span_len: Option, + cached_span_len: Option, } impl TakeDuration @@ -109,38 +109,24 @@ where if self.remaining_duration < self.duration_per_sample { None } else if let Some(sample) = self.input.next() { - self.samples_counted = self.samples_counted.saturating_add(1); - let input_span_len = self.input.current_span_len(); let current_sample_rate = self.input.sample_rate(); let current_channels = self.input.channels(); - // If input reports no span length, then by contract parameters are stable. - let mut parameters_changed = false; - let at_boundary = input_span_len.is_some_and(|_| { - let known_boundary = self - .current_span_len - .map(|cached_len| self.samples_counted >= cached_len); - - // In span-counting mode, the only way parameters can change is at a span boundary. - // In detection mode after try_seek, we check every sample until we detect a boundary. - if known_boundary.is_none_or(|at_boundary| at_boundary) { - parameters_changed = current_channels != self.last_channels - || current_sample_rate != self.last_sample_rate; - } - - known_boundary.unwrap_or(parameters_changed) - }); - - if at_boundary { - if parameters_changed { - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; - self.duration_per_sample = Self::get_duration_per_sample(&self.input); - } - - self.samples_counted = 0; - self.current_span_len = input_span_len; + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); + + if at_boundary && parameters_changed { + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + self.duration_per_sample = Self::get_duration_per_sample(&self.input); } let sample = match &self.filter { @@ -233,17 +219,12 @@ where if result.is_ok() { // Recalculate remaining duration after seek self.remaining_duration = self.requested_duration.saturating_sub(pos); - self.samples_counted = 0; - - // After seeking, we may be mid-span at an unknown position. - // Special case: seeking to Duration::ZERO means we're at the start of the first span. - // Otherwise, enter detection mode (current_span_len = None) to check parameters - // every sample until we detect a span boundary by parameter change. - if pos == Duration::ZERO { - self.current_span_len = self.input.current_span_len(); - } else { - self.current_span_len = None; - } + reset_seek_span_tracking( + &mut self.samples_counted, + &mut self.cached_span_len, + pos, + self.input.current_span_len(), + ); } result } From 354b0165c5ce9e9649e9fdd09e5dbfb10158db2b Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sat, 24 Jan 2026 22:16:47 +0100 Subject: [PATCH 05/10] fix: BLT filter stereo and multi-channel audio Use detect_span_boundary and reset_seek_span_tracking to detect span boundaries and parameter changes. --- src/source/blt.rs | 470 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 398 insertions(+), 72 deletions(-) diff --git a/src/source/blt.rs b/src/source/blt.rs index 355d38cc..7816b347 100644 --- a/src/source/blt.rs +++ b/src/source/blt.rs @@ -3,11 +3,10 @@ use crate::math::PI; use crate::{Sample, Source}; use std::time::Duration; -use super::SeekError; +// Implemented following https://webaudio.github.io/Audio-EQ-Cookbook/audio-eq-cookbook.html +use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; -// Implemented following http://www.musicdsp.org/files/Audio-EQ-Cookbook.txt - -/// Internal function that builds a `BltFilter` object. +/// Builds a `BltFilter` object with a low-pass filter. pub fn low_pass(input: I, freq: u32) -> BltFilter where I: Source, @@ -15,6 +14,7 @@ where low_pass_with_q(input, freq, 0.5) } +/// Builds a `BltFilter` object with a high-pass filter. pub fn high_pass(input: I, freq: u32) -> BltFilter where I: Source, @@ -27,15 +27,7 @@ pub fn low_pass_with_q(input: I, freq: u32, q: Float) -> BltFilter where I: Source, { - BltFilter { - input, - formula: BltFormula::LowPass { freq, q }, - applier: None, - x_n1: 0.0, - x_n2: 0.0, - y_n1: 0.0, - y_n2: 0.0, - } + blt_filter(input, BltFormula::LowPass { freq, q }) } /// Same as high_pass but allows the q value (bandwidth) to be changed @@ -43,30 +35,40 @@ pub fn high_pass_with_q(input: I, freq: u32, q: Float) -> BltFilter where I: Source, { + blt_filter(input, BltFormula::HighPass { freq, q }) +} + +/// Common constructor for BLT filters +fn blt_filter(input: I, formula: BltFormula) -> BltFilter +where + I: Source, +{ + let sample_rate = input.sample_rate(); + let channels = input.channels(); + BltFilter { - input, - formula: BltFormula::HighPass { freq, q }, - applier: None, - x_n1: 0.0, - x_n2: 0.0, - y_n1: 0.0, - y_n2: 0.0, + inner: Some(BltInner::new(input, formula, channels)), + last_sample_rate: sample_rate, + last_channels: channels, + samples_counted: 0, + cached_span_len: None, } } /// This applies an audio filter, it can be a high or low pass filter. #[derive(Clone, Debug)] pub struct BltFilter { - input: I, - formula: BltFormula, - applier: Option, - x_n1: Float, - x_n2: Float, - y_n1: Float, - y_n2: Float, + inner: Option>, + last_sample_rate: SampleRate, + last_channels: ChannelCount, + samples_counted: usize, + cached_span_len: Option, } -impl BltFilter { +impl BltFilter +where + I: Source, +{ /// Modifies this filter so that it becomes a low-pass filter. pub fn to_low_pass(&mut self, freq: u32) { self.to_low_pass_with_q(freq, 0.5); @@ -79,32 +81,36 @@ impl BltFilter { /// Same as to_low_pass but allows the q value (bandwidth) to be changed pub fn to_low_pass_with_q(&mut self, freq: u32, q: Float) { - self.formula = BltFormula::LowPass { freq, q }; - self.applier = None; + self.inner + .as_mut() + .unwrap() + .set_formula(BltFormula::LowPass { freq, q }); } /// Same as to_high_pass but allows the q value (bandwidth) to be changed pub fn to_high_pass_with_q(&mut self, freq: u32, q: Float) { - self.formula = BltFormula::HighPass { freq, q }; - self.applier = None; + self.inner + .as_mut() + .unwrap() + .set_formula(BltFormula::HighPass { freq, q }); } /// Returns a reference to the inner source. #[inline] pub fn inner(&self) -> &I { - &self.input + self.inner.as_ref().unwrap().inner() } /// Returns a mutable reference to the inner source. #[inline] pub fn inner_mut(&mut self) -> &mut I { - &mut self.input + self.inner.as_mut().unwrap().inner_mut() } /// Returns the inner source. #[inline] pub fn into_inner(self) -> I { - self.input + self.inner.unwrap().into_inner() } } @@ -116,81 +122,401 @@ where #[inline] fn next(&mut self) -> Option { - let current_sample_rate = self.input.sample_rate(); + let sample = self.inner.as_mut().unwrap().next()?; + + let input_span_len = self.inner.as_ref().unwrap().current_span_len(); + let current_sample_rate = self.inner.as_ref().unwrap().sample_rate(); + let current_channels = self.inner.as_ref().unwrap().channels(); + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); + + if at_boundary && parameters_changed { + let sample_rate_changed = current_sample_rate != self.last_sample_rate; + let channels_changed = current_channels != self.last_channels; + + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + + // If channel count changed, reconstruct with new variant (this also recreates applier) + // Otherwise, just recreate applier if sample rate changed + if channels_changed { + let old_inner = self.inner.take().unwrap(); + let (input, formula) = old_inner.into_parts(); + self.inner = Some(BltInner::new(input, formula, current_channels)); + } else if sample_rate_changed { + self.inner + .as_mut() + .unwrap() + .recreate_applier(current_sample_rate); + } + } + + Some(sample) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.as_ref().unwrap().size_hint() + } +} + +impl ExactSizeIterator for BltFilter where I: Source + ExactSizeIterator {} + +impl Source for BltFilter +where + I: Source, +{ + #[inline] + fn current_span_len(&self) -> Option { + self.inner.as_ref().unwrap().current_span_len() + } + + #[inline] + fn channels(&self) -> ChannelCount { + self.inner.as_ref().unwrap().channels() + } + + #[inline] + fn sample_rate(&self) -> SampleRate { + self.inner.as_ref().unwrap().sample_rate() + } + + #[inline] + fn total_duration(&self) -> Option { + self.inner.as_ref().unwrap().total_duration() + } - if self.applier.is_none() { - self.applier = Some(self.formula.to_applier(current_sample_rate.get())); + #[inline] + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + self.inner.as_mut().unwrap().try_seek(pos)?; + + reset_seek_span_tracking( + &mut self.samples_counted, + &mut self.cached_span_len, + pos, + self.inner.as_ref().unwrap().current_span_len(), + ); + + Ok(()) + } +} + +#[derive(Clone, Debug)] +enum BltInner { + Mono(BltMono), + Stereo(BltStereo), + Multi(BltMulti), +} + +impl BltInner +where + I: Source, +{ + fn new(input: I, formula: BltFormula, channels: ChannelCount) -> Self { + let channels_count = channels.get() as usize; + + let sample_rate = input.sample_rate(); + let applier = formula.to_applier(sample_rate.get()); + + match channels_count { + 1 => BltInner::Mono(BltMono { + input, + formula, + applier, + x_n1: 0.0, + x_n2: 0.0, + y_n1: 0.0, + y_n2: 0.0, + }), + 2 => BltInner::Stereo(BltStereo { + input, + formula, + applier, + x_n1: [0.0; 2], + x_n2: [0.0; 2], + y_n1: [0.0; 2], + y_n2: [0.0; 2], + is_right_channel: false, + }), + n => BltInner::Multi(BltMulti { + input, + formula, + applier, + x_n1: vec![0.0; n].into_boxed_slice(), + x_n2: vec![0.0; n].into_boxed_slice(), + y_n1: vec![0.0; n].into_boxed_slice(), + y_n2: vec![0.0; n].into_boxed_slice(), + position: 0, + }), } + } - let sample = self.input.next()?; - let result = self - .applier - .as_ref() - .unwrap() - .apply(sample, self.x_n1, self.x_n2, self.y_n1, self.y_n2); + fn set_formula(&mut self, formula: BltFormula) { + let sample_rate = self.inner().sample_rate(); + let applier = formula.to_applier(sample_rate.get()); - self.y_n2 = self.y_n1; - self.x_n2 = self.x_n1; - self.y_n1 = result; - self.x_n1 = sample; + match self { + BltInner::Mono(mono) => { + mono.formula = formula; + mono.applier = applier; + } + BltInner::Stereo(stereo) => { + stereo.formula = formula; + stereo.applier = applier; + } + BltInner::Multi(multi) => { + multi.formula = formula; + multi.applier = applier; + } + } + } - // Check if sample rate changed after getting the next sample. - // Only check when span is finite and not exhausted. - let sample_rate_changed = self - .input - .current_span_len() - .is_some_and(|len| len > 0 && current_sample_rate != self.input.sample_rate()); + fn recreate_applier(&mut self, sample_rate: SampleRate) { + match self { + BltInner::Mono(mono) => { + mono.applier = mono.formula.to_applier(sample_rate.get()); + } + BltInner::Stereo(stereo) => { + stereo.applier = stereo.formula.to_applier(sample_rate.get()); + } + BltInner::Multi(multi) => { + multi.applier = multi.formula.to_applier(sample_rate.get()); + } + } + } - if sample_rate_changed { - self.applier = None; + fn into_parts(self) -> (I, BltFormula) { + match self { + BltInner::Mono(mono) => (mono.input, mono.formula), + BltInner::Stereo(stereo) => (stereo.input, stereo.formula), + BltInner::Multi(multi) => (multi.input, multi.formula), } + } - Some(result) + #[inline] + fn inner(&self) -> &I { + match self { + BltInner::Mono(mono) => &mono.input, + BltInner::Stereo(stereo) => &stereo.input, + BltInner::Multi(multi) => &multi.input, + } } #[inline] - fn size_hint(&self) -> (usize, Option) { - self.input.size_hint() + fn inner_mut(&mut self) -> &mut I { + match self { + BltInner::Mono(mono) => &mut mono.input, + BltInner::Stereo(stereo) => &mut stereo.input, + BltInner::Multi(multi) => &mut multi.input, + } + } + + #[inline] + fn into_inner(self) -> I { + match self { + BltInner::Mono(mono) => mono.input, + BltInner::Stereo(stereo) => stereo.input, + BltInner::Multi(multi) => multi.input, + } } } -impl ExactSizeIterator for BltFilter where I: Source + ExactSizeIterator {} +impl Iterator for BltInner +where + I: Source, +{ + type Item = Sample; -impl Source for BltFilter + #[inline] + fn next(&mut self) -> Option { + match self { + BltInner::Mono(mono) => mono.process_next(), + BltInner::Stereo(stereo) => stereo.process_next(), + BltInner::Multi(multi) => multi.process_next(), + } + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner().size_hint() + } +} + +impl Source for BltInner where I: Source, { #[inline] fn current_span_len(&self) -> Option { - self.input.current_span_len() + self.inner().current_span_len() } #[inline] fn channels(&self) -> ChannelCount { - self.input.channels() + self.inner().channels() } #[inline] fn sample_rate(&self) -> SampleRate { - self.input.sample_rate() + self.inner().sample_rate() } #[inline] fn total_duration(&self) -> Option { - self.input.total_duration() + self.inner().total_duration() } #[inline] fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { - self.input.try_seek(pos)?; + match self { + BltInner::Mono(mono) => { + mono.input.try_seek(pos)?; + mono.x_n1 = 0.0; + mono.x_n2 = 0.0; + mono.y_n1 = 0.0; + mono.y_n2 = 0.0; + } + BltInner::Stereo(stereo) => { + stereo.input.try_seek(pos)?; + stereo.x_n1 = [0.0; 2]; + stereo.x_n2 = [0.0; 2]; + stereo.y_n1 = [0.0; 2]; + stereo.y_n2 = [0.0; 2]; + stereo.is_right_channel = false; + } + BltInner::Multi(multi) => { + multi.input.try_seek(pos)?; + multi.x_n1.fill(0.0); + multi.x_n2.fill(0.0); + multi.y_n1.fill(0.0); + multi.y_n2.fill(0.0); + multi.position = 0; + } + } + Ok(()) + } +} + +/// Mono channel BLT filter optimized for single-channel processing. +#[derive(Clone, Debug)] +struct BltMono { + input: I, + formula: BltFormula, + applier: BltApplier, + x_n1: Float, + x_n2: Float, + y_n1: Float, + y_n2: Float, +} - // Reset filter state to avoid artifacts from previous position - self.x_n1 = 0.0; - self.x_n2 = 0.0; - self.y_n1 = 0.0; - self.y_n2 = 0.0; +impl BltMono +where + I: Source, +{ + #[inline] + fn process_next(&mut self) -> Option { + let sample = self.input.next()?; - Ok(()) + let result = self + .applier + .apply(sample, self.x_n1, self.x_n2, self.y_n1, self.y_n2); + + self.y_n2 = self.y_n1; + self.x_n2 = self.x_n1; + self.y_n1 = result; + self.x_n1 = sample; + + Some(result) + } +} + +/// Stereo channel BLT filter with optimized two-channel processing. +#[derive(Clone, Debug)] +struct BltStereo { + input: I, + formula: BltFormula, + applier: BltApplier, + x_n1: [Float; 2], + x_n2: [Float; 2], + y_n1: [Float; 2], + y_n2: [Float; 2], + is_right_channel: bool, +} + +impl BltStereo +where + I: Source, +{ + #[inline] + fn process_next(&mut self) -> Option { + let sample = self.input.next()?; + + let channel = self.is_right_channel as usize; + self.is_right_channel = !self.is_right_channel; + + let result = self.applier.apply( + sample, + self.x_n1[channel], + self.x_n2[channel], + self.y_n1[channel], + self.y_n2[channel], + ); + + self.y_n2[channel] = self.y_n1[channel]; + self.x_n2[channel] = self.x_n1[channel]; + self.y_n1[channel] = result; + self.x_n1[channel] = sample; + + Some(result) + } +} + +/// Generic multi-channel BLT filter for surround sound or other configurations. +#[derive(Clone, Debug)] +struct BltMulti { + input: I, + formula: BltFormula, + applier: BltApplier, + x_n1: Box<[Float]>, + x_n2: Box<[Float]>, + y_n1: Box<[Float]>, + y_n2: Box<[Float]>, + position: usize, +} + +impl BltMulti +where + I: Source, +{ + #[inline] + fn process_next(&mut self) -> Option { + let sample = self.input.next()?; + + let channel = self.position; + self.position = (self.position + 1) % self.x_n1.len(); + + let result = self.applier.apply( + sample, + self.x_n1[channel], + self.x_n2[channel], + self.y_n1[channel], + self.y_n2[channel], + ); + + self.y_n2[channel] = self.y_n1[channel]; + self.x_n2[channel] = self.x_n1[channel]; + self.y_n1[channel] = result; + self.x_n1[channel] = sample; + + Some(result) } } From b5490acfd4c836550a3dca0a2c17b57979206ad5 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sat, 24 Jan 2026 22:17:56 +0100 Subject: [PATCH 06/10] fix: handle incomplete frames in ChannelVolume --- src/source/channel_volume.rs | 160 +++++++++++++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 5 deletions(-) diff --git a/src/source/channel_volume.rs b/src/source/channel_volume.rs index 4b5b2e87..2ed6a892 100644 --- a/src/source/channel_volume.rs +++ b/src/source/channel_volume.rs @@ -1,5 +1,7 @@ use std::time::Duration; +use dasp_sample::Sample as _; + use super::SeekError; use crate::common::{ChannelCount, SampleRate}; use crate::{Float, Sample, Source}; @@ -67,18 +69,33 @@ where #[inline] fn next(&mut self) -> Option { - // TODO Need a test for this if self.current_channel >= self.channel_volumes.len() { self.current_channel = 0; self.current_sample = None; - let num_channels = self.input.channels(); - for _ in 0..num_channels.get() { + + let mut samples_read = 0; + for _ in 0..self.input.channels().get() { if let Some(s) = self.input.next() { - self.current_sample = Some(self.current_sample.unwrap_or(0.0) + s); + self.current_sample = + Some(self.current_sample.unwrap_or(Sample::EQUILIBRIUM) + s); + samples_read += 1; + } else { + // Input ended mid-frame. This shouldn't happen per the Source contract, + // but handle it defensively: average only the samples we actually got. + break; } } - self.current_sample.map(|s| s / num_channels.get() as Float); + + // Divide by actual samples read, not the expected channel count. + // This handles the case where the input stream ends mid-frame. + if samples_read > 0 { + self.current_sample = self.current_sample.map(|s| s / samples_read as Float); + } else { + // No samples were read - input is exhausted + return None; + } } + let result = self .current_sample .map(|s| s * self.channel_volumes[self.current_channel]); @@ -124,3 +141,136 @@ where self.input.try_seek(pos) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::nz; + + /// Test helper source that allows setting false span length to simulate + /// sources that end before their promised span length. + #[derive(Debug, Clone)] + struct TestSource { + samples: Vec, + pos: usize, + channels: ChannelCount, + sample_rate: SampleRate, + total_span_len: Option, + } + + impl TestSource { + fn new(samples: &[Sample]) -> Self { + let samples = samples.to_vec(); + Self { + total_span_len: Some(samples.len()), + pos: 0, + channels: nz!(1), + sample_rate: nz!(44100), + samples, + } + } + + fn with_channels(mut self, count: ChannelCount) -> Self { + self.channels = count; + self + } + + fn with_false_span_len(mut self, total_len: Option) -> Self { + self.total_span_len = total_len; + self + } + } + + impl Iterator for TestSource { + type Item = Sample; + + fn next(&mut self) -> Option { + let res = self.samples.get(self.pos).copied(); + self.pos += 1; + res + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.samples.len().saturating_sub(self.pos); + (remaining, Some(remaining)) + } + } + + impl Source for TestSource { + fn current_span_len(&self) -> Option { + self.total_span_len + } + + fn channels(&self) -> ChannelCount { + self.channels + } + + fn sample_rate(&self) -> SampleRate { + self.sample_rate + } + + fn total_duration(&self) -> Option { + None + } + + fn try_seek(&mut self, _: Duration) -> Result<(), SeekError> { + Err(SeekError::NotSupported { + underlying_source: std::any::type_name::(), + }) + } + } + + #[test] + fn test_mono_to_stereo() { + let input = TestSource::new(&[1.0, 2.0, 3.0]).with_channels(nz!(1)); + let mut channel_vol = ChannelVolume::new(input, vec![0.5, 0.8]); + assert_eq!(channel_vol.next(), Some(1.0 * 0.5)); + assert_eq!(channel_vol.next(), Some(1.0 * 0.8)); + assert_eq!(channel_vol.next(), Some(2.0 * 0.5)); + assert_eq!(channel_vol.next(), Some(2.0 * 0.8)); + assert_eq!(channel_vol.next(), Some(3.0 * 0.5)); + assert_eq!(channel_vol.next(), Some(3.0 * 0.8)); + assert_eq!(channel_vol.next(), None); + } + + #[test] + fn test_stereo_to_mono() { + let input = TestSource::new(&[1.0, 2.0, 3.0, 4.0]).with_channels(nz!(2)); + let mut channel_vol = ChannelVolume::new(input, vec![1.0]); + assert_eq!(channel_vol.next(), Some(1.5)); + assert_eq!(channel_vol.next(), Some(3.5)); + assert_eq!(channel_vol.next(), None); + } + + #[test] + fn test_stereo_to_stereo_with_mixing() { + let input = TestSource::new(&[1.0, 3.0, 2.0, 4.0]).with_channels(nz!(2)); + let mut channel_vol = ChannelVolume::new(input, vec![0.5, 2.0]); + assert_eq!(channel_vol.next(), Some(2.0 * 0.5)); // 1.0 + assert_eq!(channel_vol.next(), Some(2.0 * 2.0)); // 4.0 + assert_eq!(channel_vol.next(), Some(3.0 * 0.5)); // 1.5 + assert_eq!(channel_vol.next(), Some(3.0 * 2.0)); // 6.0 + assert_eq!(channel_vol.next(), None); + } + + #[test] + fn test_stream_ends_mid_frame() { + let input = TestSource::new(&[1.0, 2.0, 3.0, 4.0, 5.0]) + .with_channels(nz!(2)) + .with_false_span_len(Some(6)); // Promises 6 but only delivers 5 + + let mut channel_vol = ChannelVolume::new(input, vec![1.0, 1.0]); + + assert_eq!(channel_vol.next(), Some(1.5)); + assert_eq!(channel_vol.next(), Some(1.5)); + + assert_eq!(channel_vol.next(), Some(3.5)); + assert_eq!(channel_vol.next(), Some(3.5)); + + // Third partial frame: only got 5.0, divide by 1 (actual count) not 2 + assert_eq!(channel_vol.next(), Some(5.0)); + assert_eq!(channel_vol.next(), Some(5.0)); + + assert_eq!(channel_vol.next(), None); + } +} From 5c667c211038026aeed9a3aee92859426abb3307 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 25 Jan 2026 15:47:40 +0100 Subject: [PATCH 07/10] refactor: use shared padding_samples_needed and TestSource helpers --- src/queue.rs | 94 +++++------------------------------------------ src/source/mod.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 84 deletions(-) diff --git a/src/queue.rs b/src/queue.rs index 274f907f..e55c3300 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -7,7 +7,7 @@ use std::time::Duration; use dasp_sample::Sample as _; -use crate::source::{Empty, SeekError, Source}; +use crate::source::{padding_samples_needed, Empty, SeekError, Source}; use crate::Sample; use crate::common::{ChannelCount, SampleRate}; @@ -236,11 +236,11 @@ impl Iterator for SourcesQueueOutput { // Current source is exhausted - check if we ended mid-frame and need padding. if self.samples_consumed_in_span > 0 { - let channels = self.current.channels().get() as usize; - let incomplete_frame_samples = self.samples_consumed_in_span % channels; - if incomplete_frame_samples > 0 { + let channels = self.current.channels(); + self.silence_samples_remaining = + padding_samples_needed(self.samples_consumed_in_span, channels); + if self.silence_samples_remaining > 0 { // We're mid-frame - need to pad with silence to complete it. - self.silence_samples_remaining = channels - incomplete_frame_samples; // Reset counter now since we're transitioning to a new span. self.samples_consumed_in_span = 0; // Continue loop - next iterations will inject silence. @@ -293,9 +293,9 @@ impl SourcesQueueOutput { mod tests { use crate::buffer::SamplesBuffer; use crate::math::nz; - use crate::source::{SeekError, Source}; - use crate::{queue, ChannelCount, Sample, SampleRate}; - use std::time::Duration; + use crate::queue; + use crate::source::test_utils::TestSource; + use crate::source::Source; #[test] fn basic() { @@ -407,10 +407,9 @@ mod tests { #[test] fn span_ending_mid_frame() { - let mut test_source1 = TestSource::new(&[0.1, 0.2, 0.1, 0.2, 0.1]) - .with_channels(nz!(2)) + let mut test_source1 = TestSource::new(&[0.1, 0.2, 0.1, 0.2, 0.1], nz!(2), nz!(44100)) .with_false_span_len(Some(6)); - let mut test_source2 = TestSource::new(&[0.3, 0.4, 0.3, 0.4]).with_channels(nz!(2)); + let mut test_source2 = TestSource::new(&[0.3, 0.4, 0.3, 0.4], nz!(2), nz!(44100)); let (controls, mut source) = queue::queue(true); controls.append(test_source1.clone()); @@ -436,77 +435,4 @@ mod tests { assert_eq!(source.next(), test_source2.next()); assert_eq!(source.next(), test_source2.next()); } - - /// Test helper source that allows setting false span length to simulate - /// sources that end before their promised span length. - #[derive(Debug, Clone)] - struct TestSource { - samples: Vec, - pos: usize, - channels: ChannelCount, - sample_rate: SampleRate, - total_span_len: Option, - } - - impl TestSource { - fn new(samples: &[Sample]) -> Self { - let samples = samples.to_vec(); - Self { - total_span_len: Some(samples.len()), - pos: 0, - channels: nz!(1), - sample_rate: nz!(44100), - samples, - } - } - - fn with_channels(mut self, count: ChannelCount) -> Self { - self.channels = count; - self - } - - fn with_false_span_len(mut self, total_len: Option) -> Self { - self.total_span_len = total_len; - self - } - } - - impl Iterator for TestSource { - type Item = Sample; - - fn next(&mut self) -> Option { - let res = self.samples.get(self.pos).copied(); - self.pos += 1; - res - } - - fn size_hint(&self) -> (usize, Option) { - let remaining = self.samples.len().saturating_sub(self.pos); - (remaining, Some(remaining)) - } - } - - impl Source for TestSource { - fn current_span_len(&self) -> Option { - self.total_span_len - } - - fn channels(&self) -> ChannelCount { - self.channels - } - - fn sample_rate(&self) -> SampleRate { - self.sample_rate - } - - fn total_duration(&self) -> Option { - None - } - - fn try_seek(&mut self, _: Duration) -> Result<(), SeekError> { - Err(SeekError::NotSupported { - underlying_source: std::any::type_name::(), - }) - } - } } diff --git a/src/source/mod.rs b/src/source/mod.rs index 425f82e9..747a318e 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -895,3 +895,92 @@ pub(crate) fn reset_seek_span_tracking( *cached_span_len = None; } } + +/// Helper to check if we're mid-frame and need padding when input exhausts. +#[inline] +pub(crate) fn padding_samples_needed( + samples_in_current_frame: usize, + channels: ChannelCount, +) -> usize { + if samples_in_current_frame > 0 { + channels.get() as usize - samples_in_current_frame + } else { + 0 + } +} + +#[cfg(test)] +pub(crate) mod test_utils { + use super::*; + + /// Test helper source that can end mid-frame for testing incomplete frame handling. + /// + /// This provides a simple way to create test sources with a specific number of samples + /// and channels, which is useful for testing frame alignment logic. + #[derive(Debug, Clone)] + pub struct TestSource { + samples: Vec, + pos: usize, + channels: ChannelCount, + sample_rate: SampleRate, + total_span_len: Option, + } + + impl TestSource { + /// Creates a new test source with the given samples and channel configuration. + pub fn new(samples: &[Sample], channels: ChannelCount, sample_rate: SampleRate) -> Self { + Self { + samples: samples.to_vec(), + pos: 0, + channels, + sample_rate, + total_span_len: Some(samples.len()), + } + } + + /// Overrides the reported span length for testing early exhaustion. + pub fn with_false_span_len(mut self, total_len: Option) -> Self { + self.total_span_len = total_len; + self + } + } + + impl Iterator for TestSource { + type Item = Sample; + + fn next(&mut self) -> Option { + let res = self.samples.get(self.pos).copied(); + self.pos += 1; + res + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.samples.len().saturating_sub(self.pos); + (remaining, Some(remaining)) + } + } + + impl Source for TestSource { + fn current_span_len(&self) -> Option { + self.total_span_len + } + + fn channels(&self) -> ChannelCount { + self.channels + } + + fn sample_rate(&self) -> SampleRate { + self.sample_rate + } + + fn total_duration(&self) -> Option { + None + } + + fn try_seek(&mut self, _pos: Duration) -> Result<(), SeekError> { + Err(SeekError::NotSupported { + underlying_source: std::any::type_name::(), + }) + } + } +} From 9565d944d5022e8e60718f92b1f9eb85578750ae Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 25 Jan 2026 20:06:21 +0100 Subject: [PATCH 08/10] fix: pad partial frames and track span parameters --- src/source/agc.rs | 183 +++++++++++++++++++++++++++++++---- src/source/blt.rs | 120 ++++++++++++++++------- src/source/channel_volume.rs | 83 +--------------- src/source/dither.rs | 107 ++++++++++++++------ src/source/linear_ramp.rs | 130 +++++++++++++++++++++---- src/source/take.rs | 139 ++++++++++++++++++++------ 6 files changed, 548 insertions(+), 214 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 4903385d..63e00626 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -13,28 +13,30 @@ // Crafted with love. Enjoy! :) // -use super::SeekError; -use crate::math::duration_to_coefficient; -use crate::{Float, Sample, Source}; +use super::{detect_span_boundary, padding_samples_needed, reset_seek_span_tracking, SeekError}; +use crate::{math::duration_to_coefficient, ChannelCount, Float, Sample, SampleRate, Source}; +use dasp_sample::Sample as _; +use std::time::Duration; + +#[cfg(feature = "tracing")] +use tracing; + +#[cfg(feature = "experimental")] +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + #[cfg(all(feature = "experimental", not(feature = "64bit")))] use atomic_float::AtomicF32; #[cfg(all(feature = "experimental", feature = "64bit"))] use atomic_float::AtomicF64; -#[cfg(feature = "experimental")] -use std::sync::atomic::{AtomicBool, Ordering}; -#[cfg(feature = "experimental")] -use std::sync::Arc; -use std::time::Duration; #[cfg(all(feature = "experimental", not(feature = "64bit")))] type AtomicFloat = AtomicF32; #[cfg(all(feature = "experimental", feature = "64bit"))] type AtomicFloat = AtomicF64; -use crate::common::{ChannelCount, SampleRate}; -#[cfg(feature = "tracing")] -use tracing; - /// Ensures `RMS_WINDOW_SIZE` is a power of two const fn power_of_two(n: usize) -> usize { assert!( @@ -90,12 +92,20 @@ pub struct AutomaticGainControl { target_level: Arc, floor: Float, absolute_max_gain: Arc, + attack_time: Duration, + release_time: Duration, current_gain: Float, attack_coeff: Arc, release_coeff: Arc, peak_level: Float, rms_window: CircularBuffer, is_enabled: Arc, + samples_counted: usize, + cached_span_len: Option, + last_sample_rate: SampleRate, + last_channels: ChannelCount, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } #[cfg(not(feature = "experimental"))] @@ -109,12 +119,20 @@ pub struct AutomaticGainControl { target_level: Float, floor: Float, absolute_max_gain: Float, + attack_time: Duration, + release_time: Duration, current_gain: Float, attack_coeff: Float, release_coeff: Float, peak_level: Float, rms_window: CircularBuffer, is_enabled: bool, + samples_counted: usize, + cached_span_len: Option, + last_sample_rate: SampleRate, + last_channels: ChannelCount, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } /// A circular buffer for efficient RMS calculation over a sliding window. @@ -188,33 +206,51 @@ where #[cfg(feature = "experimental")] { + let channels = input.channels(); AutomaticGainControl { input, target_level: Arc::new(AtomicFloat::new(target_level)), floor: 0.0, absolute_max_gain: Arc::new(AtomicFloat::new(absolute_max_gain)), + attack_time, + release_time, current_gain: 1.0, attack_coeff: Arc::new(AtomicFloat::new(attack_coeff)), release_coeff: Arc::new(AtomicFloat::new(release_coeff)), peak_level: 0.0, rms_window: CircularBuffer::new(), is_enabled: Arc::new(AtomicBool::new(true)), + samples_counted: 0, + cached_span_len: None, + last_sample_rate: sample_rate, + last_channels: channels, + samples_in_current_frame: 0, + silence_samples_remaining: 0, } } #[cfg(not(feature = "experimental"))] { + let channels = input.channels(); AutomaticGainControl { input, target_level, floor: 0.0, absolute_max_gain, + attack_time, + release_time, current_gain: 1.0, attack_coeff, release_coeff, peak_level: 0.0, rms_window: CircularBuffer::new(), is_enabled: true, + samples_counted: 0, + cached_span_len: None, + last_sample_rate: sample_rate, + last_channels: channels, + samples_in_current_frame: 0, + silence_samples_remaining: 0, } } } @@ -312,6 +348,9 @@ where /// Use this to dynamically modify how quickly the AGC responds to level increases. /// Smaller values result in faster response, larger values in slower response. /// Adjust during runtime to fine-tune AGC behavior for different audio content. + /// + /// Note: if the sample rate or channel count changes, any value set through this handle will + /// be overwritten with the attack time that this AGC was constructed with. #[inline] pub fn get_attack_coeff(&self) -> Arc { Arc::clone(&self.attack_coeff) @@ -324,6 +363,9 @@ where /// Use this to dynamically modify how quickly the AGC responds to level decreases. /// Smaller values result in faster response, larger values in slower response. /// Adjust during runtime to optimize AGC behavior for varying audio dynamics. + /// + /// Note: if the sample rate or channel count changes, any value set through this handle will + /// be overwritten with the release time that this AGC was constructed with. #[inline] pub fn get_release_coeff(&self) -> Arc { Arc::clone(&self.release_coeff) @@ -505,13 +547,80 @@ where #[inline] fn next(&mut self) -> Option { - self.input.next().map(|sample| { - if self.is_enabled() { - self.process_sample(sample) - } else { - sample + loop { + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); + } + + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + let input_span_len = self.input.current_span_len(); + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); + + if at_boundary && parameters_changed { + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + + // Recalculate coefficients for new sample rate + #[cfg(feature = "experimental")] + { + let attack_coeff = + duration_to_coefficient(self.attack_time, current_sample_rate); + let release_coeff = + duration_to_coefficient(self.release_time, current_sample_rate); + self.attack_coeff.store(attack_coeff, Ordering::Relaxed); + self.release_coeff.store(release_coeff, Ordering::Relaxed); + } + #[cfg(not(feature = "experimental"))] + { + self.attack_coeff = + duration_to_coefficient(self.attack_time, current_sample_rate); + self.release_coeff = + duration_to_coefficient(self.release_time, current_sample_rate); + } + + // Reset RMS window to avoid mixing samples from different parameter sets + self.rms_window = CircularBuffer::new(); + self.peak_level = 0.0; + self.current_gain = 1.0; + + self.samples_in_current_frame = 0; } - }) + + match self.input.next() { + Some(sample) => { + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % current_channels.get() as usize; + + let output = if self.is_enabled() { + self.process_sample(sample) + } else { + sample + }; + return Some(output); + } + None => { + // Input exhausted - check if we're mid-frame + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, current_channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; // Loop will inject silence samples + } + return None; + } + } + } } #[inline] @@ -548,6 +657,42 @@ where #[inline] fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { - self.input.try_seek(pos) + self.input.try_seek(pos)?; + reset_seek_span_tracking( + &mut self.samples_counted, + &mut self.cached_span_len, + pos, + self.input.current_span_len(), + ); + self.samples_in_current_frame = 0; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::nz; + use crate::source::test_utils::TestSource; + + #[test] + fn test_incomplete_frame_padding() { + let samples = vec![0.1, 0.2, 0.3, 0.4, 0.5]; + let source = TestSource::new(&samples, nz!(2), nz!(44100)); + + let agc = automatic_gain_control( + source, + 1.0, + Duration::from_millis(100), + Duration::from_millis(50), + 2.0, + ); + let output: Vec = agc.collect(); + + assert_eq!( + output.get(5), + Some(&Sample::EQUILIBRIUM), + "6th sample should be silence for frame padding" + ); } } diff --git a/src/source/blt.rs b/src/source/blt.rs index 7816b347..52354a91 100644 --- a/src/source/blt.rs +++ b/src/source/blt.rs @@ -1,10 +1,11 @@ use crate::common::{ChannelCount, Float, SampleRate}; use crate::math::PI; use crate::{Sample, Source}; +use dasp_sample::Sample as _; use std::time::Duration; // Implemented following https://webaudio.github.io/Audio-EQ-Cookbook/audio-eq-cookbook.html -use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; +use super::{detect_span_boundary, padding_samples_needed, reset_seek_span_tracking, SeekError}; /// Builds a `BltFilter` object with a low-pass filter. pub fn low_pass(input: I, freq: u32) -> BltFilter @@ -52,6 +53,8 @@ where last_channels: channels, samples_counted: 0, cached_span_len: None, + samples_in_current_frame: 0, + silence_samples_remaining: 0, } } @@ -63,6 +66,8 @@ pub struct BltFilter { last_channels: ChannelCount, samples_counted: usize, cached_span_len: Option, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } impl BltFilter @@ -122,44 +127,65 @@ where #[inline] fn next(&mut self) -> Option { - let sample = self.inner.as_mut().unwrap().next()?; - - let input_span_len = self.inner.as_ref().unwrap().current_span_len(); - let current_sample_rate = self.inner.as_ref().unwrap().sample_rate(); - let current_channels = self.inner.as_ref().unwrap().channels(); + loop { + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); + } - let (at_boundary, parameters_changed) = detect_span_boundary( - &mut self.samples_counted, - &mut self.cached_span_len, - input_span_len, - current_sample_rate, - self.last_sample_rate, - current_channels, - self.last_channels, - ); + let sample = match self.inner.as_mut().unwrap().next() { + Some(s) => s, + None => { + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, self.last_channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; + } + return None; + } + }; + + let input_span_len = self.inner.as_ref().unwrap().current_span_len(); + let current_sample_rate = self.inner.as_ref().unwrap().sample_rate(); + let current_channels = self.inner.as_ref().unwrap().channels(); + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); + + if at_boundary && parameters_changed { + let sample_rate_changed = current_sample_rate != self.last_sample_rate; + let channels_changed = current_channels != self.last_channels; + + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + + if channels_changed { + let old_inner = self.inner.take().unwrap(); + let (input, formula) = old_inner.into_parts(); + self.inner = Some(BltInner::new(input, formula, current_channels)); + } else if sample_rate_changed { + self.inner + .as_mut() + .unwrap() + .recreate_applier(current_sample_rate); + } - if at_boundary && parameters_changed { - let sample_rate_changed = current_sample_rate != self.last_sample_rate; - let channels_changed = current_channels != self.last_channels; - - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; - - // If channel count changed, reconstruct with new variant (this also recreates applier) - // Otherwise, just recreate applier if sample rate changed - if channels_changed { - let old_inner = self.inner.take().unwrap(); - let (input, formula) = old_inner.into_parts(); - self.inner = Some(BltInner::new(input, formula, current_channels)); - } else if sample_rate_changed { - self.inner - .as_mut() - .unwrap() - .recreate_applier(current_sample_rate); + self.samples_in_current_frame = 0; } - } - Some(sample) + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % current_channels.get() as usize; + + return Some(sample); + } } #[inline] @@ -205,6 +231,8 @@ where self.inner.as_ref().unwrap().current_span_len(), ); + self.samples_in_current_frame = 0; + Ok(()) } } @@ -587,3 +615,25 @@ impl BltApplier { self.b0 * x_n + self.b1 * x_n1 + self.b2 * x_n2 - self.a1 * y_n1 - self.a2 * y_n2 } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::nz; + use crate::source::test_utils::TestSource; + + #[test] + fn test_incomplete_frame_padding() { + let samples = vec![0.1, 0.2, 0.3, 0.4, 0.5]; + let source = TestSource::new(&samples, nz!(2), nz!(44100)); + + let filter = low_pass(source, 1000); + let output: Vec = filter.collect(); + + assert_eq!( + output.get(5), + Some(&Sample::EQUILIBRIUM), + "6th sample should be silence for frame padding" + ); + } +} diff --git a/src/source/channel_volume.rs b/src/source/channel_volume.rs index 2ed6a892..030bc1a9 100644 --- a/src/source/channel_volume.rs +++ b/src/source/channel_volume.rs @@ -146,83 +146,11 @@ where mod tests { use super::*; use crate::math::nz; - - /// Test helper source that allows setting false span length to simulate - /// sources that end before their promised span length. - #[derive(Debug, Clone)] - struct TestSource { - samples: Vec, - pos: usize, - channels: ChannelCount, - sample_rate: SampleRate, - total_span_len: Option, - } - - impl TestSource { - fn new(samples: &[Sample]) -> Self { - let samples = samples.to_vec(); - Self { - total_span_len: Some(samples.len()), - pos: 0, - channels: nz!(1), - sample_rate: nz!(44100), - samples, - } - } - - fn with_channels(mut self, count: ChannelCount) -> Self { - self.channels = count; - self - } - - fn with_false_span_len(mut self, total_len: Option) -> Self { - self.total_span_len = total_len; - self - } - } - - impl Iterator for TestSource { - type Item = Sample; - - fn next(&mut self) -> Option { - let res = self.samples.get(self.pos).copied(); - self.pos += 1; - res - } - - fn size_hint(&self) -> (usize, Option) { - let remaining = self.samples.len().saturating_sub(self.pos); - (remaining, Some(remaining)) - } - } - - impl Source for TestSource { - fn current_span_len(&self) -> Option { - self.total_span_len - } - - fn channels(&self) -> ChannelCount { - self.channels - } - - fn sample_rate(&self) -> SampleRate { - self.sample_rate - } - - fn total_duration(&self) -> Option { - None - } - - fn try_seek(&mut self, _: Duration) -> Result<(), SeekError> { - Err(SeekError::NotSupported { - underlying_source: std::any::type_name::(), - }) - } - } + use crate::source::test_utils::TestSource; #[test] fn test_mono_to_stereo() { - let input = TestSource::new(&[1.0, 2.0, 3.0]).with_channels(nz!(1)); + let input = TestSource::new(&[1.0, 2.0, 3.0], nz!(1), nz!(44100)); let mut channel_vol = ChannelVolume::new(input, vec![0.5, 0.8]); assert_eq!(channel_vol.next(), Some(1.0 * 0.5)); assert_eq!(channel_vol.next(), Some(1.0 * 0.8)); @@ -235,7 +163,7 @@ mod tests { #[test] fn test_stereo_to_mono() { - let input = TestSource::new(&[1.0, 2.0, 3.0, 4.0]).with_channels(nz!(2)); + let input = TestSource::new(&[1.0, 2.0, 3.0, 4.0], nz!(2), nz!(44100)); let mut channel_vol = ChannelVolume::new(input, vec![1.0]); assert_eq!(channel_vol.next(), Some(1.5)); assert_eq!(channel_vol.next(), Some(3.5)); @@ -244,7 +172,7 @@ mod tests { #[test] fn test_stereo_to_stereo_with_mixing() { - let input = TestSource::new(&[1.0, 3.0, 2.0, 4.0]).with_channels(nz!(2)); + let input = TestSource::new(&[1.0, 3.0, 2.0, 4.0], nz!(2), nz!(44100)); let mut channel_vol = ChannelVolume::new(input, vec![0.5, 2.0]); assert_eq!(channel_vol.next(), Some(2.0 * 0.5)); // 1.0 assert_eq!(channel_vol.next(), Some(2.0 * 2.0)); // 4.0 @@ -255,8 +183,7 @@ mod tests { #[test] fn test_stream_ends_mid_frame() { - let input = TestSource::new(&[1.0, 2.0, 3.0, 4.0, 5.0]) - .with_channels(nz!(2)) + let input = TestSource::new(&[1.0, 2.0, 3.0, 4.0, 5.0], nz!(2), nz!(44100)) .with_false_span_len(Some(6)); // Promises 6 but only delivers 5 let mut channel_vol = ChannelVolume::new(input, vec![1.0, 1.0]); diff --git a/src/source/dither.rs b/src/source/dither.rs index 4a009883..0104d948 100644 --- a/src/source/dither.rs +++ b/src/source/dither.rs @@ -25,6 +25,7 @@ //! When you later change volume (e.g., with `Player::set_volume()`), both the signal //! and dither noise scale together, maintaining proper dithering behavior. +use dasp_sample::Sample as _; use rand::{rngs::SmallRng, Rng}; use std::time::Duration; @@ -170,6 +171,7 @@ pub struct Dither { lsb_amplitude: Float, samples_counted: usize, cached_span_len: Option, + silence_samples_remaining: usize, } impl Dither @@ -196,6 +198,7 @@ where lsb_amplitude, samples_counted: 0, cached_span_len: None, + silence_samples_remaining: 0, } } @@ -222,42 +225,68 @@ where #[inline] fn next(&mut self) -> Option { - let input_sample = self.input.next()?; - - let input_span_len = self.input.current_span_len(); - let current_sample_rate = self.input.sample_rate(); - let current_channels = self.input.channels(); + loop { + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + + let noise_sample = self + .noise + .next(self.current_channel) + .expect("Noise generator should always produce samples"); + + self.current_channel = + (self.current_channel + 1) % self.last_channels.get() as usize; + return Some(Sample::EQUILIBRIUM - noise_sample * self.lsb_amplitude); + } - let (at_boundary, parameters_changed) = detect_span_boundary( - &mut self.samples_counted, - &mut self.cached_span_len, - input_span_len, - current_sample_rate, - self.last_sample_rate, - current_channels, - self.last_channels, - ); + let input_sample = match self.input.next() { + Some(s) => s, + None => { + if self.current_channel > 0 { + let channels = self.last_channels.get() as usize; + self.silence_samples_remaining = channels - self.current_channel; + continue; // Loop will inject dithered silence samples + } + return None; + } + }; + + let input_span_len = self.input.current_span_len(); + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); - if at_boundary { - if parameters_changed { - self.noise - .update_parameters(current_sample_rate, current_channels); - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; + if at_boundary { + if parameters_changed { + self.noise + .update_parameters(current_sample_rate, current_channels); + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + } + self.current_channel = 0; } - self.current_channel = 0; - } - let noise_sample = self - .noise - .next(self.current_channel) - .expect("Noise generator should always produce samples"); + let noise_sample = self + .noise + .next(self.current_channel) + .expect("Noise generator should always produce samples"); - // Advance to next channel (wrapping around) - self.current_channel = (self.current_channel + 1) % self.input.channels().get() as usize; + // Advance to next channel (wrapping around) + self.current_channel = + (self.current_channel + 1) % self.input.channels().get() as usize; - // Apply subtractive dithering at the target quantization level - Some(input_sample - noise_sample * self.lsb_amplitude) + // Apply subtractive dithering at the target quantization level + return Some(input_sample - noise_sample * self.lsb_amplitude); + } } #[inline] @@ -309,6 +338,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::source::test_utils::TestSource; use crate::source::{SineWave, Source}; use crate::{nz, BitDepth, SampleRate}; @@ -409,4 +439,21 @@ mod tests { cross_corr ); } + + #[test] + fn test_incomplete_frame_padding_stereo() { + let samples = vec![0.1, 0.2, 0.3, 0.4, 0.5]; + let source = TestSource::new(&samples, nz!(2), TEST_SAMPLE_RATE); + + let dithered = Dither::new(source, TEST_BIT_DEPTH, Algorithm::TPDF); + let output: Vec = dithered.collect(); + + // The last sample should be dithered silence (small non-zero value from dither noise) + let lsb = 1.0 / (1_i64 << (TEST_BIT_DEPTH.get() - 1)) as Float; + let max_dither_amplitude = lsb * 2.0; // Max TPDF dither amplitude + assert!( + output.get(5).map(|i| i.abs()) <= Some(max_dither_amplitude), + "6th sample should be dithered silence (small noise)" + ); + } } diff --git a/src/source/linear_ramp.rs b/src/source/linear_ramp.rs index 453f5855..3abc62e8 100644 --- a/src/source/linear_ramp.rs +++ b/src/source/linear_ramp.rs @@ -1,9 +1,10 @@ use std::time::Duration; -use super::SeekError; +use super::{detect_span_boundary, padding_samples_needed, reset_seek_span_tracking, SeekError}; use crate::common::{ChannelCount, SampleRate}; use crate::math::{duration_to_float, NANOS_PER_SEC}; use crate::{Float, Source}; +use dasp_sample::Sample as _; /// Internal function that builds a `LinearRamp` object. pub fn linear_gain_ramp( @@ -18,6 +19,9 @@ where { assert!(!duration.is_zero(), "duration must be greater than zero"); + let sample_rate = input.sample_rate(); + let channels = input.channels(); + LinearGainRamp { input, elapsed: Duration::ZERO, @@ -25,7 +29,13 @@ where start_gain, end_gain, clamp_end, - sample_idx: 0u64, + sample_idx: 0, + samples_counted: 0, + cached_span_len: None, + last_sample_rate: sample_rate, + last_channels: channels, + samples_in_current_frame: 0, + silence_samples_remaining: 0, } } @@ -39,6 +49,12 @@ pub struct LinearGainRamp { end_gain: Float, clamp_end: bool, sample_idx: u64, + samples_counted: usize, + cached_span_len: Option, + last_sample_rate: SampleRate, + last_channels: ChannelCount, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } impl LinearGainRamp @@ -72,30 +88,78 @@ where #[inline] fn next(&mut self) -> Option { - let factor: Float; + loop { + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(crate::Sample::EQUILIBRIUM); + } - if self.elapsed >= self.total { - if self.clamp_end { - factor = self.end_gain; - } else { - factor = 1.0; + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + let input_span_len = self.input.current_span_len(); + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); + + if at_boundary && parameters_changed { + // Parameters changed - need to handle elapsed time carefully + // The elapsed time was accumulated using the OLD sample rate + // We keep elapsed as-is since it represents real time passed + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + self.samples_in_current_frame = 0; } - } else { - self.sample_idx += 1; - // Calculate progress (0.0 to 1.0) using appropriate precision for Float type - let p = duration_to_float(self.elapsed) / duration_to_float(self.total); + let factor: Float; - factor = self.start_gain * (1.0 - p) + self.end_gain * p; - } + if self.elapsed >= self.total { + if self.clamp_end { + factor = self.end_gain; + } else { + factor = 1.0; + } + } else { + self.sample_idx += 1; - if self.sample_idx.is_multiple_of(self.channels().get() as u64) { - let sample_duration = - Duration::from_nanos(NANOS_PER_SEC / self.input.sample_rate().get() as u64); - self.elapsed += sample_duration; - } + // Calculate progress (0.0 to 1.0) using appropriate precision for Float type + let p = duration_to_float(self.elapsed) / duration_to_float(self.total); + + factor = self.start_gain * (1.0 - p) + self.end_gain * p; + } + + if self + .sample_idx + .is_multiple_of(current_channels.get() as u64) + { + let sample_duration = + Duration::from_nanos(NANOS_PER_SEC / current_sample_rate.get() as u64); + self.elapsed += sample_duration; + } - self.input.next().map(|value| value * factor) + match self.input.next() { + Some(value) => { + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % current_channels.get() as usize; + return Some(value * factor); + } + None => { + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, current_channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; + } + return None; + } + } + } } #[inline] @@ -133,7 +197,15 @@ where #[inline] fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { self.elapsed = pos; - self.input.try_seek(pos) + self.input.try_seek(pos)?; + reset_seek_span_tracking( + &mut self.samples_counted, + &mut self.cached_span_len, + pos, + self.input.current_span_len(), + ); + self.samples_in_current_frame = 0; + Ok(()) } } @@ -144,6 +216,7 @@ mod tests { use super::*; use crate::buffer::SamplesBuffer; use crate::math::nz; + use crate::source::test_utils::TestSource; use crate::Sample; /// Create a SamplesBuffer of identical samples with value `value`. @@ -232,4 +305,19 @@ mod tests { panic!("try_seek() failed!"); } } + + #[test] + fn test_incomplete_frame_padding() { + let samples = vec![1.0, 1.0, 1.0, 1.0, 1.0]; + let source = TestSource::new(&samples, nz!(2), nz!(44100)); + + let mut ramp = linear_gain_ramp(source, Duration::from_secs(10), 0.0, 1.0, true); + let output: Vec = ramp.by_ref().collect(); + + assert_eq!( + output.get(5), + Some(&Sample::EQUILIBRIUM), + "6th sample should be silence for frame padding" + ); + } } diff --git a/src/source/take.rs b/src/source/take.rs index ad953cc9..61be1140 100644 --- a/src/source/take.rs +++ b/src/source/take.rs @@ -1,9 +1,10 @@ use std::time::Duration; -use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; +use super::{detect_span_boundary, padding_samples_needed, reset_seek_span_tracking, SeekError}; use crate::common::{ChannelCount, SampleRate}; use crate::math::NANOS_PER_SEC; use crate::{Float, Sample, Source}; +use dasp_sample::Sample as _; /// Internal function that builds a `TakeDuration` object. pub fn take_duration(input: I, duration: Duration) -> TakeDuration @@ -22,6 +23,8 @@ where last_channels: channels, samples_counted: 0, cached_span_len: None, + samples_in_current_frame: 0, + silence_samples_remaining: 0, } } @@ -55,6 +58,8 @@ pub struct TakeDuration { last_channels: ChannelCount, samples_counted: usize, cached_span_len: Option, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } impl TakeDuration @@ -106,39 +111,71 @@ where type Item = ::Item; fn next(&mut self) -> Option<::Item> { - if self.remaining_duration < self.duration_per_sample { - None - } else if let Some(sample) = self.input.next() { - let input_span_len = self.input.current_span_len(); - let current_sample_rate = self.input.sample_rate(); - let current_channels = self.input.channels(); - - let (at_boundary, parameters_changed) = detect_span_boundary( - &mut self.samples_counted, - &mut self.cached_span_len, - input_span_len, - current_sample_rate, - self.last_sample_rate, - current_channels, - self.last_channels, - ); - - if at_boundary && parameters_changed { - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; - self.duration_per_sample = Self::get_duration_per_sample(&self.input); + loop { + // If we're padding to complete a frame, return silence. + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); } - let sample = match &self.filter { - Some(filter) => filter.apply(sample, self), - None => sample, - }; - - self.remaining_duration -= self.duration_per_sample; + // Check if duration has expired. + if self.remaining_duration < self.duration_per_sample { + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, self.last_channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; + } + return None; + } - Some(sample) - } else { - None + // Try to get the next sample from the input. + match self.input.next() { + Some(sample) => { + let input_span_len = self.input.current_span_len(); + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); + + if at_boundary && parameters_changed { + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + self.duration_per_sample = Self::get_duration_per_sample(&self.input); + self.samples_in_current_frame = 0; + } + + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % current_channels.get() as usize; + + let sample = match &self.filter { + Some(filter) => filter.apply(sample, self), + None => sample, + }; + + self.remaining_duration -= self.duration_per_sample; + + return Some(sample); + } + None => { + // Input exhausted - check if we ended mid-frame and need padding. + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, self.last_channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; + } + return None; + } + } } } @@ -225,6 +262,7 @@ where pos, self.input.current_span_len(), ); + self.samples_in_current_frame = 0; } result } @@ -233,6 +271,8 @@ where #[cfg(test)] mod tests { use super::*; + use crate::math::nz; + use crate::source::test_utils::TestSource; use crate::source::SineWave; #[test] @@ -256,4 +296,41 @@ mod tests { let count = source.count(); assert_eq!(count, n_samples); } + + #[test] + fn test_take_duration_expires_mid_frame() { + let samples = vec![1.0; 10]; + let source = TestSource::new(&samples, nz!(2), nz!(44100)); + + let sample_rate = 44100; + let nanos_per_sample = 1_000_000_000 / (sample_rate * source.channels().get() as u64); + let duration = Duration::from_nanos((nanos_per_sample * 5) as u64); + + let taken = take_duration(source, duration); + let output: Vec = taken.collect(); + + assert_eq!( + output.get(5), + Some(&Sample::EQUILIBRIUM), + "6th sample should be silence" + ); + } + + #[test] + fn test_take_input_exhausts_mid_frame() { + let samples = vec![1.0; 5]; + let source = TestSource::new(&samples, nz!(2), nz!(44100)); + + // Take duration is longer than the source + let duration = Duration::from_secs(10); + + let taken = take_duration(source, duration); + let output: Vec = taken.collect(); + + assert_eq!( + output.get(5), + Some(&Sample::EQUILIBRIUM), + "6th sample should be silence" + ); + } } From 7f9f2bc761ca68a1dc0c802093230e33533c319a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 25 Jan 2026 22:09:53 +0100 Subject: [PATCH 09/10] refactor: enforce frame-aligned Source contract Document that Sources must emit complete frames and pad with silence when ending mid-frame, and ensure that decoders do so. --- src/decoder/flac.rs | 25 +++++- src/decoder/mp3.rs | 49 +++++++++--- src/decoder/symphonia.rs | 103 +++++++++++++++++------- src/decoder/vorbis.rs | 62 +++++++++++---- src/decoder/wav.rs | 149 +++++++++++++++++++++-------------- src/queue.rs | 60 +------------- src/source/agc.rs | 149 +++++++++++------------------------ src/source/blt.rs | 114 ++++++++------------------- src/source/channel_volume.rs | 46 ++--------- src/source/dither.rs | 106 +++++++------------------ src/source/linear_ramp.rs | 128 ++++++++++-------------------- src/source/mod.rs | 16 ++-- src/source/take.rs | 96 ++++++++-------------- src/source/zero.rs | 12 ++- tests/channel_volume.rs | 2 + tests/total_duration.rs | 10 +-- 16 files changed, 491 insertions(+), 636 deletions(-) diff --git a/src/decoder/flac.rs b/src/decoder/flac.rs index be93d6bc..91288ed7 100644 --- a/src/decoder/flac.rs +++ b/src/decoder/flac.rs @@ -2,7 +2,7 @@ use std::io::{Read, Seek, SeekFrom}; use std::mem; use std::time::Duration; -use crate::source::SeekError; +use crate::source::{padding_samples_needed, SeekError}; use crate::Source; use crate::common::{ChannelCount, Sample, SampleRate}; @@ -24,6 +24,8 @@ where sample_rate: SampleRate, channels: ChannelCount, total_duration: Option, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } impl FlacDecoder @@ -69,6 +71,8 @@ where ) .expect("flac should never have zero channels"), total_duration, + samples_in_current_frame: 0, + silence_samples_remaining: 0, }) } @@ -119,6 +123,12 @@ where #[inline] fn next(&mut self) -> Option { loop { + // If padding to complete a frame, return silence + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); + } + if self.current_block_off < self.current_block.len() { // Read from current block. let real_offset = (self.current_block_off % self.channels.get() as usize) @@ -142,6 +152,8 @@ where (raw_val << (32 - bits)).to_sample() } }; + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % self.channels.get() as usize; return Some(real_val); } @@ -153,7 +165,16 @@ where self.current_block_channel_len = (block.len() / block.channels()) as usize; self.current_block = block.into_buffer(); } - _ => return None, + _ => { + // Input exhausted - check if mid-frame + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, self.channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; // Loop will inject silence + } + return None; + } } } } diff --git a/src/decoder/mp3.rs b/src/decoder/mp3.rs index a84026db..6138112f 100644 --- a/src/decoder/mp3.rs +++ b/src/decoder/mp3.rs @@ -3,7 +3,7 @@ use std::num::NonZero; use std::time::Duration; use crate::common::{ChannelCount, Sample, SampleRate}; -use crate::source::SeekError; +use crate::source::{padding_samples_needed, SeekError}; use crate::Source; use dasp_sample::Sample as _; @@ -21,6 +21,8 @@ where // what minimp3 calls frames rodio calls spans current_span: Frame, current_span_offset: usize, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } impl Mp3Decoder @@ -43,6 +45,8 @@ where decoder, current_span, current_span_offset: 0, + samples_in_current_frame: 0, + silence_samples_remaining: 0, }) } @@ -98,21 +102,40 @@ where type Item = Sample; fn next(&mut self) -> Option { - let current_span_len = self.current_span_len()?; - if self.current_span_offset == current_span_len { - if let Ok(span) = self.decoder.next_frame() { - // if let Ok(span) = self.decoder.decode_frame() { - self.current_span = span; - self.current_span_offset = 0; - } else { - return None; + loop { + // If padding to complete a frame, return silence + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); + } + + let current_span_len = self.current_span_len()?; + if self.current_span_offset == current_span_len { + if let Ok(span) = self.decoder.next_frame() { + self.current_span = span; + self.current_span_offset = 0; + } else { + // Input exhausted - check if mid-frame + let channels = self.channels(); + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; // Loop will inject silence + } + return None; + } } - } - let v = self.current_span.data[self.current_span_offset]; - self.current_span_offset += 1; + let v = self.current_span.data[self.current_span_offset]; + self.current_span_offset += 1; - Some(v.to_sample()) + let channels = self.channels(); + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % channels.get() as usize; + + return Some(v.to_sample()); + } } } diff --git a/src/decoder/symphonia.rs b/src/decoder/symphonia.rs index 4154850f..a8ea09d8 100644 --- a/src/decoder/symphonia.rs +++ b/src/decoder/symphonia.rs @@ -17,8 +17,10 @@ use symphonia::{ use super::{DecoderError, Settings}; use crate::{ common::{assert_error_traits, ChannelCount, Sample, SampleRate}, - source, Source, + source::{self, padding_samples_needed}, + Source, }; +use dasp_sample::Sample as _; pub(crate) struct SymphoniaDecoder { decoder: Box, @@ -28,6 +30,8 @@ pub(crate) struct SymphoniaDecoder { buffer: SampleBuffer, spec: SignalSpec, seek_mode: SeekMode, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } impl SymphoniaDecoder { @@ -145,6 +149,8 @@ impl SymphoniaDecoder { buffer, spec, seek_mode, + samples_in_current_frame: 0, + silence_samples_remaining: 0, })) } @@ -297,38 +303,81 @@ impl Iterator for SymphoniaDecoder { type Item = Sample; fn next(&mut self) -> Option { - if self.current_span_offset >= self.buffer.len() { - let decoded = loop { - let packet = self.format.next_packet().ok()?; - let decoded = match self.decoder.decode(&packet) { - Ok(decoded) => decoded, - Err(Error::DecodeError(_)) => { - // Skip over packets that cannot be decoded. This ensures the iterator - // continues processing subsequent packets instead of terminating due to - // non-critical decode errors. - continue; + loop { + // If padding to complete a frame, return silence + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); + } + + if self.current_span_offset >= self.buffer.len() { + let decoded = loop { + let packet = match self.format.next_packet() { + Ok(packet) => packet, + Err(_) => { + // Input exhausted - check if mid-frame + let channels = self.channels(); + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + break None; + } + return None; + } + }; + let decoded = match self.decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(Error::DecodeError(_)) => { + // Skip over packets that cannot be decoded. This ensures the iterator + // continues processing subsequent packets instead of terminating due to + // non-critical decode errors. + continue; + } + Err(_) => { + // Input exhausted - check if mid-frame + let channels = self.channels(); + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + break None; + } + return None; + } + }; + + // Loop until we get a packet with audio frames. This is necessary because some + // formats can have packets with only metadata, particularly when rewinding, in + // which case the iterator would otherwise end with `None`. + // Note: checking `decoded.frames()` is more reliable than `packet.dur()`, which + // can resturn non-zero durations for packets without audio frames. + if decoded.frames() > 0 { + break Some(decoded); } - Err(_) => return None, }; - // Loop until we get a packet with audio frames. This is necessary because some - // formats can have packets with only metadata, particularly when rewinding, in - // which case the iterator would otherwise end with `None`. - // Note: checking `decoded.frames()` is more reliable than `packet.dur()`, which - // can resturn non-zero durations for packets without audio frames. - if decoded.frames() > 0 { - break decoded; + match decoded { + Some(decoded) => { + decoded.spec().clone_into(&mut self.spec); + self.buffer = SymphoniaDecoder::get_buffer(decoded, &self.spec); + self.current_span_offset = 0; + } + None => { + // Break out happened due to exhaustion, continue to emit padding + continue; + } } - }; + } - decoded.spec().clone_into(&mut self.spec); - self.buffer = SymphoniaDecoder::get_buffer(decoded, &self.spec); - self.current_span_offset = 0; - } + let sample = *self.buffer.samples().get(self.current_span_offset)?; + self.current_span_offset += 1; - let sample = *self.buffer.samples().get(self.current_span_offset)?; - self.current_span_offset += 1; + let channels = self.channels(); + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % channels.get() as usize; - Some(sample) + return Some(sample); + } } } diff --git a/src/decoder/vorbis.rs b/src/decoder/vorbis.rs index 8d81fbf5..d7b7c61b 100644 --- a/src/decoder/vorbis.rs +++ b/src/decoder/vorbis.rs @@ -1,10 +1,11 @@ use std::io::{Read, Seek, SeekFrom}; use std::time::Duration; -use crate::source::SeekError; +use crate::source::{padding_samples_needed, SeekError}; use crate::Source; use crate::common::{ChannelCount, Sample, SampleRate}; +use dasp_sample::Sample as _; use lewton::inside_ogg::OggStreamReader; use lewton::samples::InterleavedSamples; @@ -16,6 +17,8 @@ where stream_reader: OggStreamReader, current_data: Vec, next: usize, + samples_in_current_frame: usize, + silence_samples_remaining: usize, } impl VorbisDecoder @@ -49,6 +52,8 @@ where stream_reader, current_data: data, next: 0, + samples_in_current_frame: 0, + silence_samples_remaining: 0, } } @@ -109,9 +114,29 @@ where #[inline] fn next(&mut self) -> Option { - if let Some(sample) = self.current_data.get(self.next).copied() { - self.next += 1; - if self.current_data.is_empty() { + loop { + // If padding to complete a frame, return silence + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); + } + + if let Some(sample) = self.current_data.get(self.next).copied() { + self.next += 1; + if self.current_data.is_empty() { + if let Ok(Some(data)) = self + .stream_reader + .read_dec_packet_generic::>() + { + self.current_data = data.samples; + self.next = 0; + } + } + let channels = self.channels(); + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % channels.get() as usize; + return Some(sample); + } else { if let Ok(Some(data)) = self .stream_reader .read_dec_packet_generic::>() @@ -119,19 +144,24 @@ where self.current_data = data.samples; self.next = 0; } + if let Some(sample) = self.current_data.get(self.next).copied() { + self.next += 1; + let channels = self.channels(); + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % channels.get() as usize; + return Some(sample); + } else { + // Input exhausted - check if mid-frame + let channels = self.channels(); + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; // Loop will inject silence + } + return None; + } } - Some(sample) - } else { - if let Ok(Some(data)) = self - .stream_reader - .read_dec_packet_generic::>() - { - self.current_data = data.samples; - self.next = 0; - } - let sample = self.current_data.get(self.next).copied(); - self.next += 1; - sample } } diff --git a/src/decoder/wav.rs b/src/decoder/wav.rs index 5fbd08c3..f7348208 100644 --- a/src/decoder/wav.rs +++ b/src/decoder/wav.rs @@ -2,7 +2,7 @@ use std::io::{Read, Seek, SeekFrom}; use std::sync::Arc; use std::time::Duration; -use crate::source::SeekError; +use crate::source::{padding_samples_needed, SeekError}; use crate::{Sample, Source}; use crate::common::{ChannelCount, SampleRate}; @@ -35,28 +35,35 @@ where let reader = WavReader::new(data).expect("should still be wav"); let spec = reader.spec(); let len = reader.len() as u64; + let sample_rate = spec.sample_rate; + + let Some(channels) = ChannelCount::new(spec.channels) else { + return Err(reader.into_inner()); + }; + let Some(sample_rate) = SampleRate::new(sample_rate) else { + return Err(reader.into_inner()); + }; + let reader = SamplesIterator { reader, samples_read: 0, + samples_in_current_frame: 0, + silence_samples_remaining: 0, + channels, }; - let sample_rate = spec.sample_rate; - let channels = spec.channels; - assert!(channels > 0); - let total_duration = { - let data_rate = sample_rate as u64 * channels as u64; + let data_rate = sample_rate.get() as u64 * channels.get() as u64; let secs = len / data_rate; let nanos = ((len % data_rate) * 1_000_000_000) / data_rate; Duration::new(secs, nanos as u32) }; - Ok(WavDecoder { + Ok(Self { reader, total_duration, - sample_rate: SampleRate::new(sample_rate) - .expect("wav should have a sample rate higher then zero"), - channels: ChannelCount::new(channels).expect("wav should have a least one channel"), + sample_rate, + channels, }) } @@ -72,6 +79,9 @@ where { reader: WavReader, samples_read: u32, // wav header is u32 so this suffices + samples_in_current_frame: usize, + silence_samples_remaining: usize, + channels: ChannelCount, } impl Iterator for SamplesIterator @@ -82,58 +92,83 @@ where #[inline] fn next(&mut self) -> Option { - self.samples_read += 1; - let spec = self.reader.spec(); - let next_sample: Option = - match (spec.sample_format, spec.bits_per_sample as u32) { - (SampleFormat::Float, bits) => { - if bits == 32 { - let next_f32: Option> = self.reader.samples().next(); - next_f32.and_then(|value| value.ok()) - } else { - #[cfg(feature = "tracing")] - tracing::error!("Unsupported WAV float bit depth: {}", bits); - #[cfg(not(feature = "tracing"))] - eprintln!("Unsupported WAV float bit depth: {}", bits); - None + loop { + // If padding to complete a frame, return silence + if self.silence_samples_remaining > 0 { + self.silence_samples_remaining -= 1; + return Some(Sample::EQUILIBRIUM); + } + + self.samples_read += 1; + let spec = self.reader.spec(); + let next_sample: Option = + match (spec.sample_format, spec.bits_per_sample as u32) { + (SampleFormat::Float, bits) => { + if bits == 32 { + let next_f32: Option> = self.reader.samples().next(); + next_f32.and_then(|value| value.ok()) + } else { + #[cfg(feature = "tracing")] + tracing::error!("Unsupported WAV float bit depth: {}", bits); + #[cfg(not(feature = "tracing"))] + eprintln!("Unsupported WAV float bit depth: {}", bits); + None + } } - } - (SampleFormat::Int, 8) => { - let next_i8: Option> = self.reader.samples().next(); - next_i8.and_then(|value| value.ok().map(|value| value.to_sample())) - } - (SampleFormat::Int, 16) => { - let next_i16: Option> = self.reader.samples().next(); - next_i16.and_then(|value| value.ok().map(|value| value.to_sample())) - } - (SampleFormat::Int, 24) => { - let next_i24_in_i32: Option> = self.reader.samples().next(); - next_i24_in_i32.and_then(|value| { - value.ok().and_then(I24::new).map(|value| value.to_sample()) - }) - } - (SampleFormat::Int, 32) => { - let next_i32: Option> = self.reader.samples().next(); - next_i32.and_then(|value| value.ok().map(|value| value.to_sample())) - } - (SampleFormat::Int, bits) => { - // Unofficial WAV integer bit depth, try to handle it anyway - let next_i32: Option> = self.reader.samples().next(); - if bits <= 32 { - next_i32.and_then(|value| { - value.ok().map(|value| (value << (32 - bits)).to_sample()) + (SampleFormat::Int, 8) => { + let next_i8: Option> = self.reader.samples().next(); + next_i8.and_then(|value| value.ok().map(|value| value.to_sample())) + } + (SampleFormat::Int, 16) => { + let next_i16: Option> = self.reader.samples().next(); + next_i16.and_then(|value| value.ok().map(|value| value.to_sample())) + } + (SampleFormat::Int, 24) => { + let next_i24_in_i32: Option> = self.reader.samples().next(); + next_i24_in_i32.and_then(|value| { + value.ok().and_then(I24::new).map(|value| value.to_sample()) }) - } else { - #[cfg(feature = "tracing")] - tracing::error!("Unsupported WAV integer bit depth: {}", bits); - #[cfg(not(feature = "tracing"))] - eprintln!("Unsupported WAV integer bit depth: {}", bits); - None } + (SampleFormat::Int, 32) => { + let next_i32: Option> = self.reader.samples().next(); + next_i32.and_then(|value| value.ok().map(|value| value.to_sample())) + } + (SampleFormat::Int, bits) => { + // Unofficial WAV integer bit depth, try to handle it anyway + let next_i32: Option> = self.reader.samples().next(); + if bits <= 32 { + next_i32.and_then(|value| { + value.ok().map(|value| (value << (32 - bits)).to_sample()) + }) + } else { + #[cfg(feature = "tracing")] + tracing::error!("Unsupported WAV integer bit depth: {}", bits); + #[cfg(not(feature = "tracing"))] + eprintln!("Unsupported WAV integer bit depth: {}", bits); + None + } + } + }; + + match next_sample { + Some(sample) => { + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % self.channels.get() as usize; + return Some(sample); } - }; - next_sample + None => { + // Input exhausted - check if mid-frame + self.silence_samples_remaining = + padding_samples_needed(self.samples_in_current_frame, self.channels); + if self.silence_samples_remaining > 0 { + self.samples_in_current_frame = 0; + continue; // Loop will inject silence + } + return None; + } + } + } } #[inline] diff --git a/src/queue.rs b/src/queue.rs index e55c3300..927ebeea 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -7,7 +7,7 @@ use std::time::Duration; use dasp_sample::Sample as _; -use crate::source::{padding_samples_needed, Empty, SeekError, Source}; +use crate::source::{Empty, SeekError, Source}; use crate::Sample; use crate::common::{ChannelCount, SampleRate}; @@ -37,7 +37,6 @@ pub fn queue(keep_alive_if_empty: bool) -> (Arc, SourcesQueue current: Box::new(Empty::new()) as Box<_>, signal_after_end: None, input: input.clone(), - samples_consumed_in_span: 0, silence_samples_remaining: 0, }; @@ -122,10 +121,7 @@ pub struct SourcesQueueOutput { // The next sounds. input: Arc, - // Track samples consumed in the current span to detect mid-span endings. - samples_consumed_in_span: usize, - - // This counts how many silence samples to inject when a source ends. + // This counts how many silence samples to inject for keep-alive behavior. silence_samples_remaining: usize, } @@ -221,7 +217,7 @@ impl Iterator for SourcesQueueOutput { #[inline] fn next(&mut self) -> Option { loop { - // If we're padding to complete a frame, return silence. + // If we're playing silence for keep-alive, return silence. if self.silence_samples_remaining > 0 { self.silence_samples_remaining -= 1; return Some(Sample::EQUILIBRIUM); @@ -229,26 +225,10 @@ impl Iterator for SourcesQueueOutput { // Basic situation that will happen most of the time. if let Some(sample) = self.current.next() { - let channels = self.current.channels().get() as usize; - self.samples_consumed_in_span = (self.samples_consumed_in_span + 1) % channels; return Some(sample); } - // Current source is exhausted - check if we ended mid-frame and need padding. - if self.samples_consumed_in_span > 0 { - let channels = self.current.channels(); - self.silence_samples_remaining = - padding_samples_needed(self.samples_consumed_in_span, channels); - if self.silence_samples_remaining > 0 { - // We're mid-frame - need to pad with silence to complete it. - // Reset counter now since we're transitioning to a new span. - self.samples_consumed_in_span = 0; - // Continue loop - next iterations will inject silence. - continue; - } - } - - // Move to next sound, play silence, or end. + // Current source is exhausted. Move to next sound, play silence, or end. // In order to avoid inlining that expensive operation, the code is in another function. if self.go_next().is_err() { if self.input.keep_alive_if_empty() { @@ -284,7 +264,6 @@ impl SourcesQueueOutput { self.current = next; self.signal_after_end = signal_after_end; - self.samples_consumed_in_span = 0; Ok(()) } } @@ -404,35 +383,4 @@ mod tests { ); } } - - #[test] - fn span_ending_mid_frame() { - let mut test_source1 = TestSource::new(&[0.1, 0.2, 0.1, 0.2, 0.1], nz!(2), nz!(44100)) - .with_false_span_len(Some(6)); - let mut test_source2 = TestSource::new(&[0.3, 0.4, 0.3, 0.4], nz!(2), nz!(44100)); - - let (controls, mut source) = queue::queue(true); - controls.append(test_source1.clone()); - controls.append(test_source2.clone()); - - assert_eq!(source.next(), test_source1.next()); - assert_eq!(source.next(), test_source1.next()); - assert_eq!(source.next(), test_source1.next()); - assert_eq!(source.next(), test_source1.next()); - assert_eq!(source.next(), test_source1.next()); - assert_eq!(None, test_source1.next()); - - // Source promised span of 6 but only delivered 5 samples. - // With 2 channels, that's 2.5 frames. Queue should pad with silence. - assert_eq!( - source.next(), - Some(0.0), - "Expected silence to complete frame" - ); - - assert_eq!(source.next(), test_source2.next()); - assert_eq!(source.next(), test_source2.next()); - assert_eq!(source.next(), test_source2.next()); - assert_eq!(source.next(), test_source2.next()); - } } diff --git a/src/source/agc.rs b/src/source/agc.rs index 63e00626..d487dd6c 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -13,11 +13,11 @@ // Crafted with love. Enjoy! :) // -use super::{detect_span_boundary, padding_samples_needed, reset_seek_span_tracking, SeekError}; -use crate::{math::duration_to_coefficient, ChannelCount, Float, Sample, SampleRate, Source}; -use dasp_sample::Sample as _; use std::time::Duration; +use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; +use crate::{math::duration_to_coefficient, ChannelCount, Float, Sample, SampleRate, Source}; + #[cfg(feature = "tracing")] use tracing; @@ -104,8 +104,6 @@ pub struct AutomaticGainControl { cached_span_len: Option, last_sample_rate: SampleRate, last_channels: ChannelCount, - samples_in_current_frame: usize, - silence_samples_remaining: usize, } #[cfg(not(feature = "experimental"))] @@ -131,8 +129,6 @@ pub struct AutomaticGainControl { cached_span_len: Option, last_sample_rate: SampleRate, last_channels: ChannelCount, - samples_in_current_frame: usize, - silence_samples_remaining: usize, } /// A circular buffer for efficient RMS calculation over a sliding window. @@ -224,8 +220,6 @@ where cached_span_len: None, last_sample_rate: sample_rate, last_channels: channels, - samples_in_current_frame: 0, - silence_samples_remaining: 0, } } @@ -249,8 +243,6 @@ where cached_span_len: None, last_sample_rate: sample_rate, last_channels: channels, - samples_in_current_frame: 0, - silence_samples_remaining: 0, } } } @@ -547,80 +539,53 @@ where #[inline] fn next(&mut self) -> Option { - loop { - if self.silence_samples_remaining > 0 { - self.silence_samples_remaining -= 1; - return Some(Sample::EQUILIBRIUM); - } + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + let input_span_len = self.input.current_span_len(); - let current_sample_rate = self.input.sample_rate(); - let current_channels = self.input.channels(); - let input_span_len = self.input.current_span_len(); - - let (at_boundary, parameters_changed) = detect_span_boundary( - &mut self.samples_counted, - &mut self.cached_span_len, - input_span_len, - current_sample_rate, - self.last_sample_rate, - current_channels, - self.last_channels, - ); - - if at_boundary && parameters_changed { - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; - - // Recalculate coefficients for new sample rate - #[cfg(feature = "experimental")] - { - let attack_coeff = - duration_to_coefficient(self.attack_time, current_sample_rate); - let release_coeff = - duration_to_coefficient(self.release_time, current_sample_rate); - self.attack_coeff.store(attack_coeff, Ordering::Relaxed); - self.release_coeff.store(release_coeff, Ordering::Relaxed); - } - #[cfg(not(feature = "experimental"))] - { - self.attack_coeff = - duration_to_coefficient(self.attack_time, current_sample_rate); - self.release_coeff = - duration_to_coefficient(self.release_time, current_sample_rate); - } - - // Reset RMS window to avoid mixing samples from different parameter sets - self.rms_window = CircularBuffer::new(); - self.peak_level = 0.0; - self.current_gain = 1.0; - - self.samples_in_current_frame = 0; - } + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); - match self.input.next() { - Some(sample) => { - self.samples_in_current_frame = - (self.samples_in_current_frame + 1) % current_channels.get() as usize; - - let output = if self.is_enabled() { - self.process_sample(sample) - } else { - sample - }; - return Some(output); - } - None => { - // Input exhausted - check if we're mid-frame - self.silence_samples_remaining = - padding_samples_needed(self.samples_in_current_frame, current_channels); - if self.silence_samples_remaining > 0 { - self.samples_in_current_frame = 0; - continue; // Loop will inject silence samples - } - return None; - } + if at_boundary && parameters_changed { + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + + // Recalculate coefficients for new sample rate + #[cfg(feature = "experimental")] + { + let attack_coeff = duration_to_coefficient(self.attack_time, current_sample_rate); + let release_coeff = duration_to_coefficient(self.release_time, current_sample_rate); + self.attack_coeff.store(attack_coeff, Ordering::Relaxed); + self.release_coeff.store(release_coeff, Ordering::Relaxed); } + #[cfg(not(feature = "experimental"))] + { + self.attack_coeff = duration_to_coefficient(self.attack_time, current_sample_rate); + self.release_coeff = + duration_to_coefficient(self.release_time, current_sample_rate); + } + + // Reset RMS window to avoid mixing samples from different parameter sets + self.rms_window = CircularBuffer::new(); + self.peak_level = 0.0; + self.current_gain = 1.0; } + + let sample = self.input.next()?; + + let output = if self.is_enabled() { + self.process_sample(sample) + } else { + sample + }; + Some(output) } #[inline] @@ -664,7 +629,6 @@ where pos, self.input.current_span_len(), ); - self.samples_in_current_frame = 0; Ok(()) } } @@ -674,25 +638,4 @@ mod tests { use super::*; use crate::math::nz; use crate::source::test_utils::TestSource; - - #[test] - fn test_incomplete_frame_padding() { - let samples = vec![0.1, 0.2, 0.3, 0.4, 0.5]; - let source = TestSource::new(&samples, nz!(2), nz!(44100)); - - let agc = automatic_gain_control( - source, - 1.0, - Duration::from_millis(100), - Duration::from_millis(50), - 2.0, - ); - let output: Vec = agc.collect(); - - assert_eq!( - output.get(5), - Some(&Sample::EQUILIBRIUM), - "6th sample should be silence for frame padding" - ); - } } diff --git a/src/source/blt.rs b/src/source/blt.rs index 52354a91..5d27b132 100644 --- a/src/source/blt.rs +++ b/src/source/blt.rs @@ -1,11 +1,11 @@ +use std::time::Duration; + use crate::common::{ChannelCount, Float, SampleRate}; use crate::math::PI; use crate::{Sample, Source}; -use dasp_sample::Sample as _; -use std::time::Duration; // Implemented following https://webaudio.github.io/Audio-EQ-Cookbook/audio-eq-cookbook.html -use super::{detect_span_boundary, padding_samples_needed, reset_seek_span_tracking, SeekError}; +use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; /// Builds a `BltFilter` object with a low-pass filter. pub fn low_pass(input: I, freq: u32) -> BltFilter @@ -53,8 +53,6 @@ where last_channels: channels, samples_counted: 0, cached_span_len: None, - samples_in_current_frame: 0, - silence_samples_remaining: 0, } } @@ -66,8 +64,6 @@ pub struct BltFilter { last_channels: ChannelCount, samples_counted: usize, cached_span_len: Option, - samples_in_current_frame: usize, - silence_samples_remaining: usize, } impl BltFilter @@ -127,65 +123,42 @@ where #[inline] fn next(&mut self) -> Option { - loop { - if self.silence_samples_remaining > 0 { - self.silence_samples_remaining -= 1; - return Some(Sample::EQUILIBRIUM); - } - - let sample = match self.inner.as_mut().unwrap().next() { - Some(s) => s, - None => { - self.silence_samples_remaining = - padding_samples_needed(self.samples_in_current_frame, self.last_channels); - if self.silence_samples_remaining > 0 { - self.samples_in_current_frame = 0; - continue; - } - return None; - } - }; - - let input_span_len = self.inner.as_ref().unwrap().current_span_len(); - let current_sample_rate = self.inner.as_ref().unwrap().sample_rate(); - let current_channels = self.inner.as_ref().unwrap().channels(); - - let (at_boundary, parameters_changed) = detect_span_boundary( - &mut self.samples_counted, - &mut self.cached_span_len, - input_span_len, - current_sample_rate, - self.last_sample_rate, - current_channels, - self.last_channels, - ); - - if at_boundary && parameters_changed { - let sample_rate_changed = current_sample_rate != self.last_sample_rate; - let channels_changed = current_channels != self.last_channels; - - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; - - if channels_changed { - let old_inner = self.inner.take().unwrap(); - let (input, formula) = old_inner.into_parts(); - self.inner = Some(BltInner::new(input, formula, current_channels)); - } else if sample_rate_changed { - self.inner - .as_mut() - .unwrap() - .recreate_applier(current_sample_rate); - } + let sample = self.inner.as_mut().unwrap().next()?; - self.samples_in_current_frame = 0; - } + let input_span_len = self.inner.as_ref().unwrap().current_span_len(); + let current_sample_rate = self.inner.as_ref().unwrap().sample_rate(); + let current_channels = self.inner.as_ref().unwrap().channels(); - self.samples_in_current_frame = - (self.samples_in_current_frame + 1) % current_channels.get() as usize; + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); - return Some(sample); + if at_boundary && parameters_changed { + let sample_rate_changed = current_sample_rate != self.last_sample_rate; + let channels_changed = current_channels != self.last_channels; + + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + + if channels_changed { + let old_inner = self.inner.take().unwrap(); + let (input, formula) = old_inner.into_parts(); + self.inner = Some(BltInner::new(input, formula, current_channels)); + } else if sample_rate_changed { + self.inner + .as_mut() + .unwrap() + .recreate_applier(current_sample_rate); + } } + + Some(sample) } #[inline] @@ -231,8 +204,6 @@ where self.inner.as_ref().unwrap().current_span_len(), ); - self.samples_in_current_frame = 0; - Ok(()) } } @@ -621,19 +592,4 @@ mod tests { use super::*; use crate::math::nz; use crate::source::test_utils::TestSource; - - #[test] - fn test_incomplete_frame_padding() { - let samples = vec![0.1, 0.2, 0.3, 0.4, 0.5]; - let source = TestSource::new(&samples, nz!(2), nz!(44100)); - - let filter = low_pass(source, 1000); - let output: Vec = filter.collect(); - - assert_eq!( - output.get(5), - Some(&Sample::EQUILIBRIUM), - "6th sample should be silence for frame padding" - ); - } } diff --git a/src/source/channel_volume.rs b/src/source/channel_volume.rs index 030bc1a9..4398e6bf 100644 --- a/src/source/channel_volume.rs +++ b/src/source/channel_volume.rs @@ -72,30 +72,14 @@ where if self.current_channel >= self.channel_volumes.len() { self.current_channel = 0; self.current_sample = None; - - let mut samples_read = 0; for _ in 0..self.input.channels().get() { - if let Some(s) = self.input.next() { - self.current_sample = - Some(self.current_sample.unwrap_or(Sample::EQUILIBRIUM) + s); - samples_read += 1; - } else { - // Input ended mid-frame. This shouldn't happen per the Source contract, - // but handle it defensively: average only the samples we actually got. - break; - } - } - - // Divide by actual samples read, not the expected channel count. - // This handles the case where the input stream ends mid-frame. - if samples_read > 0 { - self.current_sample = self.current_sample.map(|s| s / samples_read as Float); - } else { - // No samples were read - input is exhausted - return None; + let s = self.input.next()?; + self.current_sample = Some(self.current_sample.unwrap_or(Sample::EQUILIBRIUM) + s); } + self.current_sample = self + .current_sample + .map(|s| s / self.input.channels().get() as Float); } - let result = self .current_sample .map(|s| s * self.channel_volumes[self.current_channel]); @@ -180,24 +164,4 @@ mod tests { assert_eq!(channel_vol.next(), Some(3.0 * 2.0)); // 6.0 assert_eq!(channel_vol.next(), None); } - - #[test] - fn test_stream_ends_mid_frame() { - let input = TestSource::new(&[1.0, 2.0, 3.0, 4.0, 5.0], nz!(2), nz!(44100)) - .with_false_span_len(Some(6)); // Promises 6 but only delivers 5 - - let mut channel_vol = ChannelVolume::new(input, vec![1.0, 1.0]); - - assert_eq!(channel_vol.next(), Some(1.5)); - assert_eq!(channel_vol.next(), Some(1.5)); - - assert_eq!(channel_vol.next(), Some(3.5)); - assert_eq!(channel_vol.next(), Some(3.5)); - - // Third partial frame: only got 5.0, divide by 1 (actual count) not 2 - assert_eq!(channel_vol.next(), Some(5.0)); - assert_eq!(channel_vol.next(), Some(5.0)); - - assert_eq!(channel_vol.next(), None); - } } diff --git a/src/source/dither.rs b/src/source/dither.rs index 0104d948..3bae2112 100644 --- a/src/source/dither.rs +++ b/src/source/dither.rs @@ -25,7 +25,6 @@ //! When you later change volume (e.g., with `Player::set_volume()`), both the signal //! and dither noise scale together, maintaining proper dithering behavior. -use dasp_sample::Sample as _; use rand::{rngs::SmallRng, Rng}; use std::time::Duration; @@ -171,7 +170,6 @@ pub struct Dither { lsb_amplitude: Float, samples_counted: usize, cached_span_len: Option, - silence_samples_remaining: usize, } impl Dither @@ -198,7 +196,6 @@ where lsb_amplitude, samples_counted: 0, cached_span_len: None, - silence_samples_remaining: 0, } } @@ -225,68 +222,42 @@ where #[inline] fn next(&mut self) -> Option { - loop { - if self.silence_samples_remaining > 0 { - self.silence_samples_remaining -= 1; - - let noise_sample = self - .noise - .next(self.current_channel) - .expect("Noise generator should always produce samples"); - - self.current_channel = - (self.current_channel + 1) % self.last_channels.get() as usize; - return Some(Sample::EQUILIBRIUM - noise_sample * self.lsb_amplitude); - } + let input_sample = self.input.next()?; - let input_sample = match self.input.next() { - Some(s) => s, - None => { - if self.current_channel > 0 { - let channels = self.last_channels.get() as usize; - self.silence_samples_remaining = channels - self.current_channel; - continue; // Loop will inject dithered silence samples - } - return None; - } - }; - - let input_span_len = self.input.current_span_len(); - let current_sample_rate = self.input.sample_rate(); - let current_channels = self.input.channels(); - - let (at_boundary, parameters_changed) = detect_span_boundary( - &mut self.samples_counted, - &mut self.cached_span_len, - input_span_len, - current_sample_rate, - self.last_sample_rate, - current_channels, - self.last_channels, - ); + let input_span_len = self.input.current_span_len(); + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); - if at_boundary { - if parameters_changed { - self.noise - .update_parameters(current_sample_rate, current_channels); - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; - } - self.current_channel = 0; + if at_boundary { + if parameters_changed { + self.noise + .update_parameters(current_sample_rate, current_channels); + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; } + self.current_channel = 0; + } - let noise_sample = self - .noise - .next(self.current_channel) - .expect("Noise generator should always produce samples"); + let noise_sample = self + .noise + .next(self.current_channel) + .expect("Noise generator should always produce samples"); - // Advance to next channel (wrapping around) - self.current_channel = - (self.current_channel + 1) % self.input.channels().get() as usize; + // Advance to next channel (wrapping around) + self.current_channel = (self.current_channel + 1) % self.input.channels().get() as usize; - // Apply subtractive dithering at the target quantization level - return Some(input_sample - noise_sample * self.lsb_amplitude); - } + // Apply subtractive dithering at the target quantization level + Some(input_sample - noise_sample * self.lsb_amplitude) } #[inline] @@ -439,21 +410,4 @@ mod tests { cross_corr ); } - - #[test] - fn test_incomplete_frame_padding_stereo() { - let samples = vec![0.1, 0.2, 0.3, 0.4, 0.5]; - let source = TestSource::new(&samples, nz!(2), TEST_SAMPLE_RATE); - - let dithered = Dither::new(source, TEST_BIT_DEPTH, Algorithm::TPDF); - let output: Vec = dithered.collect(); - - // The last sample should be dithered silence (small non-zero value from dither noise) - let lsb = 1.0 / (1_i64 << (TEST_BIT_DEPTH.get() - 1)) as Float; - let max_dither_amplitude = lsb * 2.0; // Max TPDF dither amplitude - assert!( - output.get(5).map(|i| i.abs()) <= Some(max_dither_amplitude), - "6th sample should be dithered silence (small noise)" - ); - } } diff --git a/src/source/linear_ramp.rs b/src/source/linear_ramp.rs index 3abc62e8..12a64920 100644 --- a/src/source/linear_ramp.rs +++ b/src/source/linear_ramp.rs @@ -1,10 +1,9 @@ use std::time::Duration; -use super::{detect_span_boundary, padding_samples_needed, reset_seek_span_tracking, SeekError}; +use super::{detect_span_boundary, reset_seek_span_tracking, SeekError}; use crate::common::{ChannelCount, SampleRate}; use crate::math::{duration_to_float, NANOS_PER_SEC}; use crate::{Float, Source}; -use dasp_sample::Sample as _; /// Internal function that builds a `LinearRamp` object. pub fn linear_gain_ramp( @@ -34,8 +33,6 @@ where cached_span_len: None, last_sample_rate: sample_rate, last_channels: channels, - samples_in_current_frame: 0, - silence_samples_remaining: 0, } } @@ -53,8 +50,6 @@ pub struct LinearGainRamp { cached_span_len: Option, last_sample_rate: SampleRate, last_channels: ChannelCount, - samples_in_current_frame: usize, - silence_samples_remaining: usize, } impl LinearGainRamp @@ -88,78 +83,51 @@ where #[inline] fn next(&mut self) -> Option { - loop { - if self.silence_samples_remaining > 0 { - self.silence_samples_remaining -= 1; - return Some(crate::Sample::EQUILIBRIUM); - } + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + let input_span_len = self.input.current_span_len(); - let current_sample_rate = self.input.sample_rate(); - let current_channels = self.input.channels(); - let input_span_len = self.input.current_span_len(); - - let (at_boundary, parameters_changed) = detect_span_boundary( - &mut self.samples_counted, - &mut self.cached_span_len, - input_span_len, - current_sample_rate, - self.last_sample_rate, - current_channels, - self.last_channels, - ); - - if at_boundary && parameters_changed { - // Parameters changed - need to handle elapsed time carefully - // The elapsed time was accumulated using the OLD sample rate - // We keep elapsed as-is since it represents real time passed - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; - self.samples_in_current_frame = 0; - } + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); - let factor: Float; + if at_boundary && parameters_changed { + // Parameters changed - need to handle elapsed time carefully + // The elapsed time was accumulated using the OLD sample rate + // We keep elapsed as-is since it represents real time passed + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + } - if self.elapsed >= self.total { - if self.clamp_end { - factor = self.end_gain; - } else { - factor = 1.0; - } + let factor = if self.elapsed >= self.total { + if self.clamp_end { + self.end_gain } else { - self.sample_idx += 1; - - // Calculate progress (0.0 to 1.0) using appropriate precision for Float type - let p = duration_to_float(self.elapsed) / duration_to_float(self.total); - - factor = self.start_gain * (1.0 - p) + self.end_gain * p; - } - - if self - .sample_idx - .is_multiple_of(current_channels.get() as u64) - { - let sample_duration = - Duration::from_nanos(NANOS_PER_SEC / current_sample_rate.get() as u64); - self.elapsed += sample_duration; - } - - match self.input.next() { - Some(value) => { - self.samples_in_current_frame = - (self.samples_in_current_frame + 1) % current_channels.get() as usize; - return Some(value * factor); - } - None => { - self.silence_samples_remaining = - padding_samples_needed(self.samples_in_current_frame, current_channels); - if self.silence_samples_remaining > 0 { - self.samples_in_current_frame = 0; - continue; - } - return None; - } + 1.0 } + } else { + self.sample_idx = self.sample_idx.wrapping_add(1); + let p = duration_to_float(self.elapsed) / duration_to_float(self.total); + self.start_gain * (1.0 - p) + self.end_gain * p + }; + + if self + .sample_idx + .is_multiple_of(current_channels.get() as u64) + { + let sample_duration = + Duration::from_nanos(NANOS_PER_SEC / current_sample_rate.get() as u64); + self.elapsed += sample_duration; } + + let value = self.input.next()?; + Some(value * factor) } #[inline] @@ -204,7 +172,6 @@ where pos, self.input.current_span_len(), ); - self.samples_in_current_frame = 0; Ok(()) } } @@ -305,19 +272,4 @@ mod tests { panic!("try_seek() failed!"); } } - - #[test] - fn test_incomplete_frame_padding() { - let samples = vec![1.0, 1.0, 1.0, 1.0, 1.0]; - let source = TestSource::new(&samples, nz!(2), nz!(44100)); - - let mut ramp = linear_gain_ramp(source, Duration::from_secs(10), 0.0, 1.0, true); - let output: Vec = ramp.by_ref().collect(); - - assert_eq!( - output.get(5), - Some(&Sample::EQUILIBRIUM), - "6th sample should be silence for frame padding" - ); - } } diff --git a/src/source/mod.rs b/src/source/mod.rs index 747a318e..64d94a6d 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -167,6 +167,16 @@ pub use self::noise::{Pink, WhiteUniform}; /// the total number of samples in the current span (i.e., before the sample rate and number of /// channels can potentially change). /// +/// # Frame Alignment Contract +/// +/// All `Source` implementors MUST ensure that when the iterator returns `None`, all previously +/// emitted samples form complete frames. That is, the total number of samples emitted must be a +/// multiple of `channels()`. +/// +/// A "frame" is one sample for each channel in the audio stream. For example, stereo audio (2 +/// channels) must emit samples in pairs: left, right, left, right, etc. Should a `Source` find +/// itself in a situation where it would need to emit an incomplete final frame, it MUST pad +/// the remaining samples with silence before returning `None`. pub trait Source: Iterator { /// Returns the total length of the current span in samples. /// @@ -937,12 +947,6 @@ pub(crate) mod test_utils { total_span_len: Some(samples.len()), } } - - /// Overrides the reported span length for testing early exhaustion. - pub fn with_false_span_len(mut self, total_len: Option) -> Self { - self.total_span_len = total_len; - self - } } impl Iterator for TestSource { diff --git a/src/source/take.rs b/src/source/take.rs index 61be1140..5a9dfac8 100644 --- a/src/source/take.rs +++ b/src/source/take.rs @@ -130,52 +130,40 @@ where } // Try to get the next sample from the input. - match self.input.next() { - Some(sample) => { - let input_span_len = self.input.current_span_len(); - let current_sample_rate = self.input.sample_rate(); - let current_channels = self.input.channels(); - - let (at_boundary, parameters_changed) = detect_span_boundary( - &mut self.samples_counted, - &mut self.cached_span_len, - input_span_len, - current_sample_rate, - self.last_sample_rate, - current_channels, - self.last_channels, - ); - - if at_boundary && parameters_changed { - self.last_sample_rate = current_sample_rate; - self.last_channels = current_channels; - self.duration_per_sample = Self::get_duration_per_sample(&self.input); - self.samples_in_current_frame = 0; - } - - self.samples_in_current_frame = - (self.samples_in_current_frame + 1) % current_channels.get() as usize; - - let sample = match &self.filter { - Some(filter) => filter.apply(sample, self), - None => sample, - }; - - self.remaining_duration -= self.duration_per_sample; - - return Some(sample); - } - None => { - // Input exhausted - check if we ended mid-frame and need padding. - self.silence_samples_remaining = - padding_samples_needed(self.samples_in_current_frame, self.last_channels); - if self.silence_samples_remaining > 0 { - self.samples_in_current_frame = 0; - continue; - } - return None; - } + let sample = self.input.next()?; + + let input_span_len = self.input.current_span_len(); + let current_sample_rate = self.input.sample_rate(); + let current_channels = self.input.channels(); + + let (at_boundary, parameters_changed) = detect_span_boundary( + &mut self.samples_counted, + &mut self.cached_span_len, + input_span_len, + current_sample_rate, + self.last_sample_rate, + current_channels, + self.last_channels, + ); + + if at_boundary && parameters_changed { + self.last_sample_rate = current_sample_rate; + self.last_channels = current_channels; + self.duration_per_sample = Self::get_duration_per_sample(&self.input); + self.samples_in_current_frame = 0; } + + self.samples_in_current_frame = + (self.samples_in_current_frame + 1) % current_channels.get() as usize; + + let sample = match &self.filter { + Some(filter) => filter.apply(sample, self), + None => sample, + }; + + self.remaining_duration -= self.duration_per_sample; + + return Some(sample); } } @@ -315,22 +303,4 @@ mod tests { "6th sample should be silence" ); } - - #[test] - fn test_take_input_exhausts_mid_frame() { - let samples = vec![1.0; 5]; - let source = TestSource::new(&samples, nz!(2), nz!(44100)); - - // Take duration is longer than the source - let duration = Duration::from_secs(10); - - let taken = take_duration(source, duration); - let output: Vec = taken.collect(); - - assert_eq!( - output.get(5), - Some(&Sample::EQUILIBRIUM), - "6th sample should be silence" - ); - } } diff --git a/src/source/zero.rs b/src/source/zero.rs index bb54b7d7..9d2d7b21 100644 --- a/src/source/zero.rs +++ b/src/source/zero.rs @@ -29,13 +29,23 @@ impl Zero { } } - /// Create a new source that never ends and produces total silence. + /// Create a new source that produces a finite amount of silence samples. + /// + /// # Panics + /// + /// Panics if `num_samples` is not a multiple of `channels`. #[inline] pub fn new_samples( channels: ChannelCount, sample_rate: SampleRate, num_samples: usize, ) -> Self { + assert_eq!( + num_samples % channels.get() as usize, + 0, + "Zero sample count must be a multiple of channel count", + ); + Self { channels, sample_rate, diff --git a/tests/channel_volume.rs b/tests/channel_volume.rs index 0c8b39ed..cfeb0294 100644 --- a/tests/channel_volume.rs +++ b/tests/channel_volume.rs @@ -1,3 +1,5 @@ +#![cfg(any(feature = "symphonia-mp3", feature = "minimp3"))] + use std::fs; use std::io::BufReader; diff --git a/tests/total_duration.rs b/tests/total_duration.rs index 8d56a8c4..5c9f85df 100644 --- a/tests/total_duration.rs +++ b/tests/total_duration.rs @@ -12,7 +12,6 @@ use rstest_reuse::{self, *}; #[cfg(any( feature = "claxon", - feature = "minimp3", feature = "symphonia-aac", feature = "symphonia-flac", feature = "symphonia-mp3", @@ -27,10 +26,6 @@ use rstest_reuse::{self, *}; feature = "symphonia-vorbis", case("ogg", Duration::from_secs_f64(69.328979591), "symphonia") )] -#[cfg_attr( - all(feature = "minimp3", not(feature = "symphonia-mp3")), - case("mp3", Duration::ZERO, "minimp3") -)] #[cfg_attr( all(feature = "hound", not(feature = "symphonia-wav")), case("wav", Duration::from_secs_f64(10.143469387), "hound") @@ -55,7 +50,7 @@ use rstest_reuse::{self, *}; feature = "symphonia-flac", case("flac", Duration::from_secs_f64(10.152380952), "symphonia flac") )] -fn all_decoders( +fn supported_decoders( #[case] format: &'static str, #[case] correct_duration: Duration, #[case] decoder_name: &'static str, @@ -77,7 +72,6 @@ fn get_music(format: &str) -> Decoder { #[cfg(any( feature = "claxon", - feature = "minimp3", feature = "symphonia-flac", feature = "symphonia-mp3", feature = "symphonia-isomp4", @@ -85,7 +79,7 @@ fn get_music(format: &str) -> Decoder { feature = "symphonia-wav", feature = "hound", ))] -#[apply(all_decoders)] +#[apply(supported_decoders)] #[trace] fn decoder_returns_total_duration( #[case] format: &'static str, From 0fbddd8d4c4a8a9d8fe1fb38f722d45d044f5564 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 25 Jan 2026 23:05:48 +0100 Subject: [PATCH 10/10] docs: update changelog --- CHANGELOG.md | 16 +++++++++++----- src/source/mod.rs | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa920ae..e19d4464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DitherAlgorithm` enum for algorithm selection - `Source::dither()` function for applying dithering - Added `64bit` feature to opt-in to 64-bit sample precision (`f64`). +- `Chirp` now implements `try_seek`. ### Fixed @@ -39,15 +40,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed audio distortion when queueing sources with different sample rates/channel counts or transitioning from empty queue. - Fixed `SamplesBuffer` to correctly report exhaustion and remaining samples. - Improved precision in `SkipDuration` to avoid off-by-a-few-samples errors. -- Fixed channel misalignment in queue with non-power-of-2 channel counts (e.g., 6 channels) by ensuring frame-aligned span lengths. -- Fixed channel misalignment when sources end before their promised span length by padding with silence to complete frames. - Fixed `Empty` source to properly report exhaustion. - Fixed `Source::current_span_len()` to consistently return total span length. - Fixed `Source::size_hint()` to consistently report actual bounds based on current sources. - Fixed `Pausable::size_hint()` to correctly account for paused samples. -- Fixed `Limit`, `TakeDuration` and `TrackPosition` to handle mid-span seeks. -- Fixed `MixerSource` to prevent overflow with very long playback. +- Fixed `MixerSource` and `LinearRamp` to prevent overflow with very long playback. - Fixed `PeriodicAccess` to prevent overflow with very long periods. +- Fixed `BltFilter` to work correctly with stereo and multi-channel audio. +- Fixed `ChannelVolume` to work correclty with stereo and multi-channel audio. +- Fixed `Brownian` and `Red` noise generators to reset after seeking. +- Fixed sources to correctly handle sample rate and channel count changes at span boundaries. +- Fixed sources to detect parameter updates after mid-span seeks. ### Changed @@ -68,7 +71,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Velvet` noise generator takes density in Hz as `usize` instead of `f32`. - Upgraded `cpal` to v0.17. - Clarified `Source::current_span_len()` documentation to specify it returns total span length. -- Improved queue, mixer and sample rate conversion performance. +- Explicitly document the requirement for sources to return complete frames. +- Ensured decoders to always return complete frames, as well as `TakeDuration` when expired. +- `Zero::new_samples()` now panics when it is not a multiple of the channel count. +- Improved queue, buffer, mixer and sample rate conversion performance. ## Version [0.21.1] (2025-07-14) diff --git a/src/source/mod.rs b/src/source/mod.rs index 64d94a6d..dd587b2d 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -167,7 +167,7 @@ pub use self::noise::{Pink, WhiteUniform}; /// the total number of samples in the current span (i.e., before the sample rate and number of /// channels can potentially change). /// -/// # Frame Alignment Contract +/// # Frame alignment requirement /// /// All `Source` implementors MUST ensure that when the iterator returns `None`, all previously /// emitted samples form complete frames. That is, the total number of samples emitted must be a