Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- `Chirp` now implements `Iterator::size_hint` and `ExactSizeIterator`.
- `SamplesBuffer` now implements `ExactSizeIterator`.
- 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.
- Added `std_dev()` to `WhiteUniform` and `WhiteTriangular`.
- Added a macro `nz!` which facilitates creating NonZero's for `SampleRate` and
Expand All @@ -31,13 +33,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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.
- `PeriodicAccess` is slightly more accurate for 44.1 kHz sample rate families.
- 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.

### Changed
- `output_to_wav` renamed to `wav_to_file` and now takes ownership of the `Source`.
- `Blue` noise generator uses uniform instead of Gaussian noise for better performance.
- `Gaussian` noise generator has standard deviation of 0.6 for perceptual equivalence.
- `Velvet` noise generator takes density in Hz as `usize` instead of `f32`.
- Upgrade `cpal` to v0.17.
- Clarified `Source::current_span_len()` contract documentation.

## Version [0.21.1] (2025-07-14)

Expand Down
11 changes: 9 additions & 2 deletions src/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ impl SamplesBuffer {
impl Source for SamplesBuffer {
#[inline]
fn current_span_len(&self) -> Option<usize> {
None
if self.pos >= self.data.len() {
Some(0)
} else {
Some(self.data.len())
}
}

#[inline]
Expand Down Expand Up @@ -126,10 +130,13 @@ impl Iterator for SamplesBuffer {

#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
(self.data.len(), Some(self.data.len()))
let remaining = self.data.len() - self.pos;
(remaining, Some(remaining))
}
}

impl ExactSizeIterator for SamplesBuffer {}

#[cfg(test)]
mod tests {
use crate::buffer::SamplesBuffer;
Expand Down
202 changes: 183 additions & 19 deletions src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub fn queue(keep_alive_if_empty: bool) -> (Arc<SourcesQueueInput>, SourcesQueue
current: Box::new(Empty::new()) as Box<_>,
signal_after_end: None,
input: input.clone(),
samples_consumed_in_span: 0,
padding_samples_remaining: 0,
};

(input, output)
Expand Down Expand Up @@ -110,9 +112,28 @@ pub struct SourcesQueueOutput {

// The next sounds.
input: Arc<SourcesQueueInput>,

// 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,
}

const THRESHOLD: usize = 512;
const SILENCE_SAMPLE_RATE: SampleRate = nz!(44100);
const SILENCE_CHANNELS: ChannelCount = nz!(1);

/// 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
}

impl Source for SourcesQueueOutput {
#[inline]
Expand All @@ -129,15 +150,13 @@ impl Source for SourcesQueueOutput {
// constant.

// Try the current `current_span_len`.
if let Some(val) = self.current.current_span_len() {
if val != 0 {
return Some(val);
} 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 the length of `THRESHOLD`
return Some(THRESHOLD);
}
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 Some(threshold(self.current.channels()));
}

// Try the size hint.
Expand All @@ -148,18 +167,34 @@ impl Source for SourcesQueueOutput {
return Some(lower_bound);
}

// Otherwise we use the constant value.
Some(THRESHOLD)
// Otherwise we use a frame-aligned threshold value.
Some(threshold(self.current.channels()))
}

#[inline]
fn channels(&self) -> ChannelCount {
self.current.channels()
// When current source is exhausted, peek at the next source's metadata
if !self.current.is_exhausted() {
self.current.channels()
} else if let Some((next, _)) = self.input.next_sounds.lock().unwrap().first() {
next.channels()
} else {
// Queue is empty - return silence metadata
SILENCE_CHANNELS
}
}

#[inline]
fn sample_rate(&self) -> SampleRate {
self.current.sample_rate()
// When current source is exhausted, peek at the next source's metadata
if !self.current.is_exhausted() {
self.current.sample_rate()
} else if let Some((next, _)) = self.input.next_sounds.lock().unwrap().first() {
next.sample_rate()
} else {
// Queue is empty - return silence metadata
SILENCE_SAMPLE_RATE
}
}

#[inline]
Expand Down Expand Up @@ -188,13 +223,33 @@ impl Iterator for SourcesQueueOutput {
#[inline]
fn next(&mut self) -> Option<Self::Item> {
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);
}

// Basic situation that will happen most of the time.
if let Some(sample) = self.current.next() {
self.samples_consumed_in_span += 1;
return Some(sample);
}

// Since `self.current` has finished, we need to pick the next sound.
// 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;
}

// 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;
if self.go_next().is_err() {
return None;
}
Expand All @@ -221,7 +276,11 @@ impl SourcesQueueOutput {
let mut next = self.input.next_sounds.lock().unwrap();

if next.is_empty() {
let silence = Box::new(Zero::new_samples(nz!(1), nz!(44100), THRESHOLD)) as Box<_>;
let silence = Box::new(Zero::new_samples(
SILENCE_CHANNELS,
SILENCE_SAMPLE_RATE,
threshold(SILENCE_CHANNELS),
)) as Box<_>;
if self.input.keep_alive_if_empty.load(Ordering::Acquire) {
// Play a short silence in order to avoid spinlocking.
(silence, None)
Expand All @@ -243,11 +302,11 @@ impl SourcesQueueOutput {
mod tests {
use crate::buffer::SamplesBuffer;
use crate::math::nz;
use crate::queue;
use crate::source::Source;
use crate::source::{SeekError, Source};
use crate::{queue, ChannelCount, Sample, SampleRate};
use std::time::Duration;

#[test]
#[ignore] // FIXME: samples rate and channel not updated immediately after transition
fn basic() {
let (tx, mut rx) = queue::queue(false);

Expand Down Expand Up @@ -321,4 +380,109 @@ mod tests {
assert_eq!(rx.next(), Some(10.0));
assert_eq!(rx.next(), Some(-10.0));
}

#[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))
.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 (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());
}

/// 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<Sample>,
pos: usize,
channels: ChannelCount,
sample_rate: SampleRate,
total_span_len: Option<usize>,
}

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<usize>) -> Self {
self.total_span_len = total_len;
self
}
}

impl Iterator for TestSource {
type Item = Sample;

fn next(&mut self) -> Option<Self::Item> {
let res = self.samples.get(self.pos).copied();
self.pos += 1;
res
}

fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.samples.len().saturating_sub(self.pos);
(remaining, Some(remaining))
}
}

impl Source for TestSource {
fn current_span_len(&self) -> Option<usize> {
self.total_span_len
}

fn channels(&self) -> ChannelCount {
self.channels
}

fn sample_rate(&self) -> SampleRate {
self.sample_rate
}

fn total_duration(&self) -> Option<Duration> {
None
}

fn try_seek(&mut self, _: Duration) -> Result<(), SeekError> {
Err(SeekError::NotSupported {
underlying_source: std::any::type_name::<Self>(),
})
}
}
}
6 changes: 2 additions & 4 deletions src/source/from_iter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,8 @@ where

// Try the current `current_span_len`.
if let Some(src) = &self.current_source {
if let Some(val) = src.current_span_len() {
if val != 0 {
return Some(val);
}
if !src.is_exhausted() {
return src.current_span_len();
}
}

Expand Down
22 changes: 19 additions & 3 deletions src/source/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,30 @@ pub use self::noise::{Pink, WhiteUniform};
/// channels can potentially change.
///
pub trait Source: Iterator<Item = Sample> {
/// Returns the number of samples before the current span ends. `None` means "infinite" or
/// "until the sound ends".
/// Should never return 0 unless there's no more data.
/// Returns the number of samples before the current span ends.
///
/// `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.
///
/// # Frame Alignment
///
/// Span lengths must be multiples of the channel count to ensure spans end on frame
/// boundaries. A "frame" is one sample for each channel. Returning a span length
/// that is not a multiple of `channels()` will cause channel misalignment issues.
///
/// Note: This returns the total span size, not the remaining samples. Use `Iterator::size_hint`
/// to determine how many samples remain in the iterator.
fn current_span_len(&self) -> Option<usize>;

/// Returns true if the source is exhausted (has no more samples available).
#[inline]
fn is_exhausted(&self) -> bool {
self.current_span_len() == Some(0)
}

/// Returns the number of channels. Channels are always interleaved.
/// Should never be Zero
fn channels(&self) -> ChannelCount;
Expand Down
Loading