From af033173d558ad7828fdb25784cc81cb0abb2d6f Mon Sep 17 00:00:00 2001 From: Yara Date: Sat, 3 Jan 2026 16:09:17 +0100 Subject: [PATCH 01/20] rename OutputStream -> OS-Sink, Sink -> Player --- src/lib.rs | 2 ++ src/speakers/builder.rs | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index dbd6174c..e7ecca0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -234,6 +234,8 @@ pub mod math; pub mod microphone; pub mod mixer; pub mod queue; +// #[cfg(feature = "experimental")] +pub mod fixed_source; pub mod source; pub mod static_buffer; diff --git a/src/speakers/builder.rs b/src/speakers/builder.rs index 0ad89165..a3a4616a 100644 --- a/src/speakers/builder.rs +++ b/src/speakers/builder.rs @@ -634,6 +634,72 @@ where Ok(SinkHandle { _stream: stream }) } + + /// TODO + pub fn open_queue_sink(&self) -> Result { + todo!() + } + + /// TODO + pub fn play( + self, + mut source: impl FixedSource + Send + 'static, + ) -> Result { + use cpal::Sample as _; + + let config = self.config.expect("ConfigIsSet"); + let device = self.device.expect("DeviceIsSet").0; + let cpal_config1 = config.into_cpal_config(); + let cpal_config2 = (&cpal_config1).into(); + + macro_rules! build_output_streams { + ($($sample_format:tt, $generic:ty);+) => { + match config.sample_format { + $( + cpal::SampleFormat::$sample_format => device.build_output_stream::<$generic, _, _>( + &cpal_config2, + move |data, _| { + data.iter_mut().for_each(|d| { + *d = source + .next() + .map(cpal::Sample::from_sample) + .unwrap_or(<$generic>::EQUILIBRIUM) + }) + }, + self.error_callback, + None, + ), + )+ + _ => return Err(OsSinkError::UnsupportedSampleFormat), + } + }; + } + + let result = build_output_streams!( + F32, f32; + F64, f64; + I8, i8; + I16, i16; + I24, cpal::I24; + I32, i32; + I64, i64; + U8, u8; + U16, u16; + U24, cpal::U24; + U32, u32; + U64, u64 + ); + + result + .map_err(OsSinkError::BuildError) + .map(|stream| { + stream + .play() + .map_err(OsSinkError::PlayError) + .map(|()| stream) + })? + .map(SinkHandle) + } } // TODO cant introduce till we have introduced the other fixed source parts From 8c343a722e27c18280e675c092db64394221528d Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 21 Jan 2026 21:20:05 +0100 Subject: [PATCH 02/20] feat: add high-quality resampler --- Cargo.lock | 85 +++ Cargo.toml | 25 +- examples/resample.rs | 118 +++ src/source/mod.rs | 53 +- src/source/resample.rs | 1643 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1917 insertions(+), 7 deletions(-) create mode 100644 examples/resample.rs create mode 100644 src/source/resample.rs diff --git a/Cargo.lock b/Cargo.lock index f2ac235d..1c3e2e06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" +[[package]] +name = "audio-core" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ebbf82d06013f4c41fe71303feb980cddd78496d904d06be627972de51a24" + [[package]] name = "audio_thread_priority" version = "0.35.1" @@ -103,6 +109,38 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "audioadapter" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98e72b98fa467adcb7a88c5d1b8b686193185c81b9bf9c3fa3ac3524180cd55c" +dependencies = [ + "audio-core", + "libm", + "num-traits", +] + +[[package]] +name = "audioadapter-buffers" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6af89882334c4e501faa08992888593ada468f9e1ab211635c32f9ada7786e0" +dependencies = [ + "audioadapter", + "audioadapter-sample", + "num-traits", +] + +[[package]] +name = "audioadapter-sample" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e9a3d502fec0b21aa420febe0b110875cf8a7057c49e83a0cace1df6a73e03e" +dependencies = [ + "audio-core", + "num-traits", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -1555,6 +1593,15 @@ dependencies = [ "rand 0.10.1", ] +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1611,6 +1658,7 @@ version = "0.22.2" dependencies = [ "approx", "atomic_float", + "audioadapter-buffers", "claxon", "cpal", "crossbeam-channel", @@ -1627,6 +1675,7 @@ dependencies = [ "rstest", "rstest_reuse", "rtrb", + "rubato", "symphonia", "symphonia-adapter-fdk-aac", "symphonia-adapter-libopus", @@ -1680,6 +1729,22 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" +[[package]] +name = "rubato" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90173154a8a14e6adb109ea641743bc95ec81c093d94e70c6763565f7108ebeb" +dependencies = [ + "audioadapter", + "audioadapter-buffers", + "num-complex", + "num-integer", + "num-traits", + "realfft", + "visibility", + "windowfunctions", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2332,6 +2397,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2496,6 +2572,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windowfunctions" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a" +dependencies = [ + "num-traits", +] + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 38f28633..7835cee0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ edition = "2021" rust-version = "1.89" [features] -# Default feature set provides audio playback and common format support default = [ # Core functionality "playback", @@ -88,14 +87,16 @@ symphonia = ["dep:symphonia"] # - .mp3 is an MPEG-1 Audio Layer III file, which is a container format that uses the MP3 codec # - .mp4 is an MPEG-4 container, typically (but not always) with an AAC-encoded audio stream # - .ogg is an Ogg container with a Vorbis-encoded audio stream -# -# A reasonable set of audio demuxers and decoders for most applications. flac = ["symphonia-flac"] mp3 = ["symphonia-mp3"] mp4 = ["symphonia-isomp4", "symphonia-aac"] vorbis = ["symphonia-ogg", "symphonia-vorbis"] wav = ["symphonia-wav", "symphonia-pcm"] +# Aliases +aac = ["mp4"] +ogg = ["vorbis"] + # The following features are combinations of demuxers and decoders provided by Symphonia. # Unless you are developing a generic audio player, this is probably overkill. symphonia-all = ["symphonia/all-formats", "symphonia/all-codecs"] @@ -127,11 +128,19 @@ symphonia-wav = ["symphonia/wav"] # libopus adapter for Symphonia symphonia-libopus = ["symphonia", "dep:symphonia-adapter-libopus"] +# Resampling features +# +# Enable FFT-based synchronous resampling as an optimization for fixed-ratio conversions. When +# enabled, conversions between standard sample rates (e.g., 48 kHz and 96 kHz, as well as 44.1 kHz +# and 48 kHz) that simplify to small integer ratios automatically use FFT processing for faster +# processing at the cost of a larger binary. +rubato-fft = ["rubato/fft_resampler"] + # Alternative decoders and demuxers claxon = ["dep:claxon"] # FLAC hound = ["dep:hound"] # WAV -minimp3 = ["dep:minimp3_fixed"] # MP3 lewton = ["dep:lewton"] # Ogg Vorbis +minimp3 = ["dep:minimp3_fixed"] # MP3 [package.metadata.docs.rs] all-features = true @@ -155,6 +164,10 @@ tracing = { version = "0.1.40", optional = true } atomic_float = { version = "1.1.0", optional = true } rtrb = { version = "0.3.2", optional = true } + +# Rubato resampling +rubato = { version = "1.0", default-features = false } +audioadapter-buffers = "2.0" num-rational = "0.4.2" symphonia-adapter-libopus = { version = "0.2", optional = true } @@ -272,6 +285,10 @@ required-features = ["playback", "symphonia-libopus"] name = "noise_generator" required-features = ["playback", "noise"] +[[example]] +name = "resample" +required-features = ["playback"] + [[example]] name = "reverb" required-features = ["playback", "vorbis"] diff --git a/examples/resample.rs b/examples/resample.rs new file mode 100644 index 00000000..fc5508b3 --- /dev/null +++ b/examples/resample.rs @@ -0,0 +1,118 @@ +//! Example demonstrating audio resampling with different quality presets. + +use rodio::source::{resample::Poly, ResampleConfig, Source}; +use rodio::{Decoder, Player}; +use std::error::Error; +use std::fs::File; +use std::io::BufReader; +use std::time::Instant; + +fn main() -> Result<(), Box> { + #[cfg(debug_assertions)] + { + eprintln!("WARNING: Running in debug mode. Audio may be choppy, especially with"); + eprintln!(" sinc resampling of non-integer ratios (async resampling)."); + eprintln!(" For best results, compile with --release"); + eprintln!(); + } + + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: {} [audio_file] [method]", args[0]); + eprintln!("\nTarget rate: Sample rate in Hz (e.g., 48000, 96000)"); + eprintln!("\nAudio file (optional): Path to audio file (default: assets/music.ogg)"); + eprintln!("\nMethod (optional): nearest, linear, fast, balanced, accurate"); + eprintln!("\nMethod details:"); + eprintln!(" nearest - Zero-order hold (non-oversampling), fastest"); + eprintln!(" linear - Linear polynomial interpolation, fast"); + eprintln!(" very_fast - 64-tap sinc, linear interpolation, Hann2 window"); + eprintln!(" fast - 128-tap sinc, linear interpolation, Hann2 window"); + eprintln!(" balanced - 192-tap sinc, linear interpolation, Blackman2 window (default)"); + eprintln!(" accurate - 256-tap sinc, cubic interpolation, BlackmanHarris2 window"); + eprintln!("\nExamples:"); + eprintln!(" {} 48000", args[0]); + eprintln!(" {} 96000 assets/music.ogg accurate", args[0]); + std::process::exit(1); + } + + let target_rate: u32 = args[1].parse().map_err(|_| { + format!( + "Invalid target rate '{}'. Must be a positive integer (e.g., 48000)", + args[1] + ) + })?; + + if target_rate == 0 { + return Err("Target rate must be greater than 0".into()); + } + + let audio_path = if args.len() > 2 { + args[2].clone() + } else { + "assets/music.ogg".to_string() + }; + + let config = if args.len() > 3 { + parse_quality(&args[3])? + } else { + ResampleConfig::default() + }; + + println!("Audio file: {audio_path}"); + + let file = File::open(&audio_path) + .map_err(|e| format!("Failed to open audio file '{audio_path}': {e}"))?; + let source = Decoder::try_from(BufReader::new(file))?; + + let source_rate = source.sample_rate().get(); + let channels = source.channels().get(); + let duration = source.total_duration(); + + if let Some(dur) = duration { + println!("Duration: {:.2}s", dur.as_secs_f32()); + } + + println!("\nResampling {channels} channels from {source_rate} Hz to {target_rate} Hz..."); + println!("Configuration: {config:#?}"); + let resampled = source.resample(rodio::SampleRate::new(target_rate).unwrap(), config); + + println!("\nConfiguring output device to {target_rate} Hz..."); + let stream_handle = rodio::DeviceSinkBuilder::from_default_device()? + .with_sample_rate(rodio::SampleRate::new(target_rate).unwrap()) + .open_stream()?; + let player = Player::connect_new(stream_handle.mixer()); + + println!("Playing resampled audio..."); + println!("Press Ctrl+C to stop"); + + let playback_start = Instant::now(); + player.append(resampled); + player.sleep_until_end(); + + let playback_time = playback_start.elapsed(); + println!("\nPlayback finished in {:.2}s", playback_time.as_secs_f32()); + + Ok(()) +} + +/// Parse the resampling quality from a string argument +fn parse_quality(method: &str) -> Result> { + let config = match method.to_lowercase().as_str() { + "nearest" => ResampleConfig::poly().degree(Poly::Nearest).build(), + "linear" => ResampleConfig::poly().degree(Poly::Linear).build(), + "cubic" => ResampleConfig::poly().degree(Poly::Cubic).build(), + "quintic" => ResampleConfig::poly().degree(Poly::Quintic).build(), + "septic" => ResampleConfig::poly().degree(Poly::Septic).build(), + "very_fast" => ResampleConfig::very_fast(), + "fast" => ResampleConfig::fast(), + "balanced" => ResampleConfig::balanced(), + "accurate" => ResampleConfig::accurate(), + _ => return Err(format!( + "Unknown resampling method '{}'. Valid options: nearest, linear, cubic, quintic, septic, very_fast, fast, balanced, accurate", + method + ) + .into()), + }; + Ok(config) +} diff --git a/src/source/mod.rs b/src/source/mod.rs index 63d5233e..95f484f2 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -9,6 +9,9 @@ use crate::{ math, Float, Sample, }; +#[cfg(feature = "dither")] +use crate::BitDepth; + use dasp_sample::FromSample; pub use self::agc::{AutomaticGainControl, AutomaticGainControlSettings}; @@ -34,6 +37,7 @@ pub use self::pausable::Pausable; pub use self::periodic::PeriodicAccess; pub use self::position::TrackPosition; pub use self::repeat::Repeat; +pub use self::resample::{Resample, ResampleConfig}; pub use self::sawtooth::SawtoothWave; pub use self::signal_generator::{Function, GeneratorFunction, SignalGenerator}; pub use self::sine::SineWave; @@ -71,6 +75,7 @@ mod pausable; mod periodic; mod position; mod repeat; +pub mod resample; mod sawtooth; mod signal_generator; mod sine; @@ -730,9 +735,33 @@ pub trait Source: Iterator { distortion::distortion(self, gain, threshold) } - // There is no `can_seek()` method as it is impossible to use correctly. Between - // checking if a source supports seeking and actually seeking the sink can - // switch to a new source. + /// Resamples this source to a different sample rate. + /// + /// See the [`resample`] module documentation for detailed information about resampling + /// algorithms and quality presets. + /// + /// # Quality Presets + /// + /// - **Fast**: Lower quality, lower CPU usage, lower latency + /// - **Balanced**: Good quality, moderate CPU usage (default) + /// - **Accurate**: Best quality, higher CPU usage + /// + /// # Examples + /// + /// ``` + /// use rodio::SampleRate; + /// use rodio::source::{SineWave, Source, ResampleConfig}; + /// + /// let source = SineWave::new(440.0); + /// let resampled = source.resample(SampleRate::new(96000).unwrap(), ResampleConfig::balanced()); + /// ``` + #[inline] + fn resample(self, target_rate: SampleRate, config: ResampleConfig) -> Resample + where + Self: Sized, + { + Resample::new(self, target_rate, config) + } /// Attempts to seek to a given position in the current source. /// @@ -861,6 +890,24 @@ pub(crate) fn padding_samples_needed( } } +/// 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; + } +} + #[cfg(test)] pub(crate) mod test_utils { use super::*; diff --git a/src/source/resample.rs b/src/source/resample.rs new file mode 100644 index 00000000..dac54aec --- /dev/null +++ b/src/source/resample.rs @@ -0,0 +1,1643 @@ +//! Audio resampling from one sample rate to another. +//! +//! # Quick Start +//! +//! Use the [`Source::resample`] method with a quality preset: +//! +//! ```rust +//! use rodio::SampleRate; +//! use rodio::source::{SineWave, Source, ResampleConfig}; +//! +//! let source = SineWave::new(440.0); +//! let config = ResampleConfig::balanced(); +//! let resampled = source.resample(SampleRate::new(96000).unwrap(), config); +//! ``` +//! +//! For advanced control, use the [`ResampleConfig`] builder: +//! +//! ```rust +//! use rodio::math::nz; +//! use rodio::source::{SineWave, Source, Resample, ResampleConfig}; +//! use rodio::source::resample::{Sinc, WindowFunction}; +//! +//! let source = SineWave::new(440.0); +//! let config = ResampleConfig::sinc() // Sinc resampling +//! .sinc_len(nz!(256)) // 256-tap filter +//! .interpolation(Sinc::Cubic) // Cubic interpolation +//! .window(WindowFunction::BlackmanHarris2) // Squared Blackman-Harris window +//! .chunk_size(nz!(512)) // Low latency (5.3 ms @ 1-channel 96 kHz) +//! .build(); +//! let resampled = Resample::new(source, nz!(96000), config); +//! ``` +//! +//! # Understanding Resampling +//! +//! ## Polynomial vs. Sinc Interpolation +//! +//! When converting between sample rates, sample values at positions that don't exist in the +//! original signal need to be calculated. There are two main approaches: +//! +//! **Polynomial interpolation** is fast but does not include anti-aliasing. This can cause +//! artifacts in the output audio. Higher degrees provide smoother interpolation but cannot +//! prevent these artifacts. +//! +//! **Sinc interpolation** uses a windowed sinc function for mathematically correct reconstruction. +//! It is of higher quality and includes anti-aliasing to reduce artifacts, but is more +//! computationally expensive. +//! +//! ## Fixed vs Arbitrary Ratios +//! +//! A **fixed ratio** is when the sample rate conversion can be expressed as a simple fraction, +//! like 1:2 (e.g., 48 kHz and 96 kHz) or 147:160 (e.g., 44.1 kHz and 48 kHz). +//! +//! When the resampler is configured for sinc interpolation, it automatically detects these ratios +//! and optimizes resampling by switching to: +//! 1. optimized FFT-based processing when the `rubato-fft` feature is enabled +//! 2. sinc interpolation with nearest-neighbor lookup when FFT is not available +//! +//! This reduces CPU usage while providing highest quality. +//! +//! **Arbitrary ratios** (non-reducible or large fractions) use the async sinc resampler, which +//! can handle any conversion. This is CPU intensive and should be compiled with release profile to +//! prevent choppy audio. +//! +//! # Quality Presets +//! +//! As per [`CamillaDSP`](https://henquist.github.io/3.0.x/): +//! +//! | Parameter | [`VeryFast`](ResampleConfig::very_fast) | [`Fast`](ResampleConfig::fast) | [`Balanced`](ResampleConfig::balanced) | [`Accurate`](ResampleConfig::accurate) | +//! | sinc_len | 64 | 128 | 192 | 256 | +//! | oversampling_factor | 1024 | 1024 | 512 | 256 | +//! | interpolation | Linear | Linear | Quadratic | Cubic | +//! | window | Hann2 | Blackman2 | BlackmanHarris2 | BlackmanHarris2 | +//! | f_cutoff (#) | 0.91 | 0.92 | 0.93 | 0.95 | +//! (#) These cutoff values are approximate. The actual values used are calculated automatically at runtime for the combination of sinc length and window. + +#![cfg_attr(docsrs, feature(doc_cfg))] + +use std::{num::NonZero, time::Duration}; + +use dasp_sample::Sample as _; +use num_rational::Ratio; +use rubato::{Indexing, Resampler as RubatoResampler}; + +use super::{reset_seek_span_tracking, SeekError}; +use crate::{ + common::{ChannelCount, Sample, SampleRate}, + Float, Source, +}; + +const DEFAULT_CHUNK_SIZE: usize = 1024; +#[cfg(feature = "rubato-fft")] +const DEFAULT_SUB_CHUNKS: usize = 1; + +/// Maximum for optimized fixed-ratio resampling: 44.1 and 384 kHz (147:1280). +const MAX_FIXED_RATIO: u32 = 1280; + +/// Polynomial interpolation degree, no anti-aliasing. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum Poly { + /// Zero-order hold - nearest neighbor sampling. + /// + /// Simply picks the nearest input sample without interpolation. + /// Creates a "stepped" waveform. + Nearest, + + /// Linear interpolation between 2 samples. + #[default] + Linear, + + /// Cubic interpolation using 4 samples. + Cubic, + + /// Quintic interpolation using 6 samples. + Quintic, + + /// Septic interpolation using 8 samples. + Septic, +} + +/// Sinc interpolation type. +/// +/// Controls how intermediate values are calculated between precomputed sinc points +/// in the windowed sinc filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Sinc { + /// No interpolation - picks nearest intermediate point. + /// + /// Optimal when upsampling by exact ratios (e.g., 48kHz and 96kHz) and the oversampling factor + /// is equal to the ratio. In these cases, no unnecessary computations are performed and the + /// result is equivalent to that of synchronous resampling. + Nearest, + + /// Linear interpolation between two nearest points. + /// + /// Relatively fast, but needs a large number of intermediate points to push the resampling + /// artefacts below the noise floor. + #[default] + Linear, + + /// Quadratic interpolation using three nearest points. + /// + /// The computation time lies approximately halfway between that of linear and quadratic + /// interpolation. + Quadratic, + + /// Cubic interpolation using four nearest points. + /// + /// The computation time is approximately twice as long as that of linear interpolation, but it + /// requires much fewer intermediate points for a good result. + Cubic, +} + +impl From for rubato::SincInterpolationType { + fn from(sinc: Sinc) -> Self { + match sinc { + Sinc::Nearest => rubato::SincInterpolationType::Nearest, + Sinc::Linear => rubato::SincInterpolationType::Linear, + Sinc::Quadratic => rubato::SincInterpolationType::Quadratic, + Sinc::Cubic => rubato::SincInterpolationType::Cubic, + } + } +} + +impl From for rubato::PolynomialDegree { + fn from(poly: Poly) -> Self { + match poly { + Poly::Nearest => rubato::PolynomialDegree::Nearest, + Poly::Linear => rubato::PolynomialDegree::Linear, + Poly::Cubic => rubato::PolynomialDegree::Cubic, + Poly::Quintic => rubato::PolynomialDegree::Quintic, + Poly::Septic => rubato::PolynomialDegree::Septic, + } + } +} + +/// Window functions for sinc filter. +/// +/// The window function is applied to the sinc filter to reduce ripple artifacts and control the +/// trade-off between transition bandwidth and stopband attenuation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WindowFunction { + /// Hann window: ~44 dB stopband attenuation, fast -18 dB/octave rolloff. + /// + /// Good transition band but moderate rejection. Suitable for less critical applications. + Hann, + + /// Squared Hann: ~50 dB stopband attenuation, medium -12 dB/octave rolloff. + /// + /// Better rejection than Hann with slightly wider transition band. + Hann2, + + /// Blackman window: ~75 dB stopband attenuation, fast -18 dB/octave rolloff. + /// + /// Excellent rejection with sharp cutoff. + Blackman, + + /// Squared Blackman: ~81 dB stopband attenuation, medium -12 dB/octave rolloff. + /// + /// Very good rejection with moderate transition band. + Blackman2, + + /// Blackman-Harris window: ~92 dB stopband attenuation, slow -6 dB/octave rolloff. + /// + /// Extremely high rejection but wider transition band. + BlackmanHarris, + + /// Squared Blackman-Harris: ~98 dB stopband attenuation, very slow -3 dB/octave rolloff. + /// + /// Maximum stopband rejection, widest transition band. + #[default] + BlackmanHarris2, +} + +impl From for rubato::WindowFunction { + fn from(window: WindowFunction) -> Self { + match window { + WindowFunction::Hann => rubato::WindowFunction::Hann, + WindowFunction::Hann2 => rubato::WindowFunction::Hann2, + WindowFunction::Blackman => rubato::WindowFunction::Blackman, + WindowFunction::Blackman2 => rubato::WindowFunction::Blackman2, + WindowFunction::BlackmanHarris => rubato::WindowFunction::BlackmanHarris, + WindowFunction::BlackmanHarris2 => rubato::WindowFunction::BlackmanHarris2, + } + } +} + +/// Builder for polynomial resampling configuration without anti-aliasing. +#[derive(Debug, Clone)] +pub struct PolyConfigBuilder { + degree: Poly, + chunk_size: usize, +} + +impl Default for PolyConfigBuilder { + fn default() -> Self { + Self { + degree: Poly::default(), + chunk_size: DEFAULT_CHUNK_SIZE, + } + } +} + +/// Builder for sinc resampling configuration with anti-aliasing. +#[derive(Debug, Clone)] +pub struct SincConfigBuilder { + sinc_len: usize, + oversampling_factor: usize, + interpolation: Sinc, + window: WindowFunction, + f_cutoff: Float, + chunk_size: usize, + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + sub_chunks: usize, +} + +impl Default for SincConfigBuilder { + fn default() -> Self { + Self { + sinc_len: 256, + window: WindowFunction::default(), + oversampling_factor: 128, + interpolation: Sinc::default(), + f_cutoff: 0.95, + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } +} + +/// Resampling configuration. +/// +/// Specifies the algorithm and parameters for sample rate conversion. +/// +/// # Examples +/// +/// ```rust +/// use rodio::math::nz; +/// use rodio::source::{resample::Poly, ResampleConfig}; +/// +/// // Use presets +/// let config = ResampleConfig::balanced(); +/// let config = ResampleConfig::fast(); +/// let config = ResampleConfig::accurate(); +/// +/// // Customize from builder +/// let config = ResampleConfig::sinc().chunk_size(nz!(512)); +/// let config = ResampleConfig::poly().degree(Poly::Cubic); +/// ``` +#[derive(Debug, Clone)] +pub enum ResampleConfig { + /// Polynomial resampling (fast, no anti-aliasing) + Poly { + /// Polynomial degree + degree: Poly, + /// Desired chunk size in frames + chunk_size: usize, + }, + /// Sinc resampling (high quality, anti-aliasing) + Sinc { + /// Length of the windowed sinc interpolation filter + sinc_len: usize, + /// The number of intermediate points to use for interpolation + oversampling_factor: usize, + /// Interpolation type for filter table lookup + interpolation: Sinc, + /// Window function to use + window: WindowFunction, + /// Cutoff frequency of the sinc interpolation filter relative to Nyquist (0.0-1.0) + f_cutoff: Float, + /// Desired chunk size in frames + chunk_size: usize, + /// Desired number of sub chunks to use for processing + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + sub_chunks: usize, + }, +} + +// Implementation for ResampleConfig with presets and entry points +impl ResampleConfig { + /// Create a very fast sinc resampling configuration. + pub fn very_fast() -> Self { + let sinc_len = 64; + let window = WindowFunction::Hann2; + Self::Sinc { + sinc_len, + window, + oversampling_factor: 1024, + interpolation: Sinc::Linear, + f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } + + /// Create a fast sinc resampling configuration. + pub fn fast() -> Self { + let sinc_len = 128; + let window = WindowFunction::Blackman2; + Self::Sinc { + sinc_len, + window, + oversampling_factor: 1024, + interpolation: Sinc::Linear, + f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } + + /// Create a balanced sinc resampling configuration. + pub fn balanced() -> Self { + let sinc_len = 192; + let window = WindowFunction::BlackmanHarris2; + Self::Sinc { + sinc_len, + window, + oversampling_factor: 512, + interpolation: Sinc::Quadratic, + f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } + + /// Create an accurate sinc resampling configuration. + pub fn accurate() -> Self { + let sinc_len = 256; + let window = WindowFunction::BlackmanHarris2; + Self::Sinc { + sinc_len, + window, + oversampling_factor: 256, + interpolation: Sinc::Cubic, + f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } + + /// Create a polynomial resampling configuration builder. + pub fn poly() -> PolyConfigBuilder { + PolyConfigBuilder::default() + } + + /// Create a sinc resampling configuration builder. + pub fn sinc() -> SincConfigBuilder { + SincConfigBuilder::default() + } +} + +impl Default for ResampleConfig { + fn default() -> Self { + Self::balanced() + } +} + +impl PolyConfigBuilder { + /// Set the polynomial degree for interpolation. + pub fn degree(mut self, degree: Poly) -> Self { + self.degree = degree; + self + } + + /// Set number of audio frames processed at once (typical range: 32-2048). + /// + /// Smaller chunks reduce latency (time delay through the resampler) but increase per-sample + /// overhead. One frame contains one sample per channel. Default is 1024 frames, which at 48 + /// kHz is ~10.7ms latency. + pub fn chunk_size(mut self, size: NonZero) -> Self { + self.chunk_size = size.get(); + self + } + + /// Build the final [`ResampleConfig`]. + pub fn build(self) -> ResampleConfig { + ResampleConfig::Poly { + degree: self.degree, + chunk_size: self.chunk_size, + } + } +} + +impl From for ResampleConfig { + fn from(builder: PolyConfigBuilder) -> Self { + builder.build() + } +} + +impl SincConfigBuilder { + /// Set the length of the sinc filter in taps (typical range: 32-2048). + /// + /// Longer filters provide better quality but use more CPU. + pub fn sinc_len(mut self, len: NonZero) -> Self { + self.sinc_len = len.get(); + self + } + + /// Set oversampling factor (typical range: 64-4096). + /// + /// Higher values improve interpolation accuracy but increase memory usage. + pub fn oversampling_factor(mut self, factor: NonZero) -> Self { + self.oversampling_factor = factor.get(); + self + } + + /// Set interpolation type. + pub fn interpolation(mut self, interpolator: Sinc) -> Self { + self.interpolation = interpolator; + self + } + + /// Set window function. + pub fn window(mut self, window: WindowFunction) -> Self { + self.window = window; + self + } + + /// Set the cutoff frequency as fraction of the Nyquist frequency. + /// + /// Value should be between 0.0 and 1.0, where 1.0 represents the Nyquist frequency (half the + /// sample rate) of the input sampling rate or output sampling rate, whichever is lower. The + /// cutoff determines where the anti-aliasing filter begins to attenuate frequencies. + /// + /// Lower values provide more anti-aliasing protection but reduce high frequency response. + /// + /// # Panics + /// + /// Panics if cutoff is not in range 0.0-1.0. + pub fn f_cutoff(mut self, cutoff: Float) -> Self { + assert!( + (0.0..=1.0).contains(&cutoff), + "f_cutoff must be between 0.0 and 1.0" + ); + self.f_cutoff = cutoff; + self + } + + /// Set the length of the sinc filter, the window function, automatically calculating + /// the cutoff frequency for the combination of the two. + pub fn with_sinc_and_window( + mut self, + sinc_len: NonZero, + window: WindowFunction, + ) -> Self { + self.sinc_len = sinc_len.get(); + self.window = window; + self.f_cutoff = rubato::calculate_cutoff(sinc_len.get(), window.into()); + self + } + + /// Set chunk size for processing (typical range: 512-4096). + /// + /// This balances between efficiency and memory usage. If the device sink uses a fixed buffer + /// size, then this number of frames is a good choice for the resampler chunk size. + pub fn chunk_size(mut self, size: NonZero) -> Self { + self.chunk_size = size.get(); + self + } + + /// Set number of sub-chunks for FFT resampling. + /// + /// The delay of the resampler can be reduced by increasing the number of sub-chunks. A large + /// number of sub-chunks reduces the cutoff frequency of the anti-aliasing filter. It is + /// recommended to set keep this at 1 unless this leads to an unacceptably large delay. + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + pub fn sub_chunks(mut self, count: NonZero) -> Self { + self.sub_chunks = count.get(); + self + } + + /// Build the final [`ResampleConfig`]. + pub fn build(self) -> ResampleConfig { + ResampleConfig::Sinc { + sinc_len: self.sinc_len, + oversampling_factor: self.oversampling_factor, + interpolation: self.interpolation, + window: self.window, + f_cutoff: self.f_cutoff, + chunk_size: self.chunk_size, + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + sub_chunks: self.sub_chunks, + } + } +} + +impl From for ResampleConfig { + fn from(builder: SincConfigBuilder) -> Self { + builder.build() + } +} + +/// Resamples an audio source to a target sample rate using Rubato. +#[derive(Debug)] +pub struct Resample +where + I: Source, +{ + inner: Option>, + target_rate: SampleRate, + config: ResampleConfig, + cached_input_span_len: Option, +} + +impl Clone for Resample +where + I: Source + Clone, +{ + fn clone(&self) -> Self { + // Shallow clone: this resets filter state + let source = self.inner().clone(); + Resample::new(source, self.target_rate, self.config.clone()) + } +} + +impl Resample +where + I: Source, +{ + /// Create a new resampler with the given configuration. + pub fn new(source: I, target_rate: SampleRate, config: ResampleConfig) -> Self { + let inner = Self::create_resampler(source, target_rate, &config); + let cached_input_span_len = match &inner { + ResampleInner::Passthrough { .. } => inner.input().current_span_len(), + ResampleInner::Poly(resampler) => resampler.input.current_span_len(), + ResampleInner::Sinc(resampler) => resampler.input.current_span_len(), + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.input.current_span_len(), + }; + + Self { + inner: Some(inner), + target_rate, + config, + cached_input_span_len, + } + } + + /// Helper method to create a resampler from a source using the stored config and target rate. + fn create_resampler( + source: I, + target_rate: SampleRate, + config: &ResampleConfig, + ) -> ResampleInner { + let source_rate = source.sample_rate(); + + if source.is_exhausted() || source_rate == target_rate { + let channels = source.channels(); + ResampleInner::Passthrough { + source, + input_span_pos: 0, + channels, + source_rate, + } + } else { + let ratio = Ratio::new(target_rate.get(), source_rate.get()); + match config { + ResampleConfig::Poly { degree, chunk_size } => { + let resampler = + RubatoAsyncResample::new_poly(source, target_rate, *chunk_size, *degree) + .expect("Failed to create polynomial resampler"); + ResampleInner::Poly(resampler) + } + #[cfg(feature = "rubato-fft")] + ResampleConfig::Sinc { + sinc_len, + oversampling_factor, + interpolation, + window, + f_cutoff, + chunk_size, + sub_chunks, + } => { + if *ratio.numer() <= MAX_FIXED_RATIO && *ratio.denom() <= MAX_FIXED_RATIO { + // Use FFT resampler for optimal performance + let resampler = + RubatoFftResample::new(source, target_rate, *chunk_size, *sub_chunks) + .expect("Failed to create FFT resampler"); + ResampleInner::Fft(resampler) + } else { + let resampler = RubatoAsyncResample::new_sinc( + source, + target_rate, + *chunk_size, + *sinc_len, + *f_cutoff, + *oversampling_factor, + *interpolation, + *window, + ) + .expect("Failed to create sinc resampler"); + ResampleInner::Sinc(resampler) + } + } + #[cfg(not(feature = "rubato-fft"))] + ResampleConfig::Sinc { + sinc_len, + oversampling_factor, + interpolation, + window, + f_cutoff, + chunk_size, + } => { + if *ratio.numer() <= MAX_FIXED_RATIO && *ratio.denom() <= MAX_FIXED_RATIO { + // Fixed ratio without FFT - use Sinc::Nearest optimization + // Set oversampling_factor to match the ratio for optimal performance + let ratio = *ratio.numer().max(ratio.denom()) as usize; + let resampler = RubatoAsyncResample::new_sinc( + source, + target_rate, + *chunk_size, + *sinc_len, + *f_cutoff, + ratio, + Sinc::Nearest, + *window, + ) + .expect("Failed to create optimized sinc resampler"); + ResampleInner::Sinc(resampler) + } else { + let resampler = RubatoAsyncResample::new_sinc( + source, + target_rate, + *chunk_size, + *sinc_len, + *f_cutoff, + *oversampling_factor, + *interpolation, + *window, + ) + .expect("Failed to create sinc resampler"); + ResampleInner::Sinc(resampler) + } + } + } + } + } + + /// Returns a reference to the inner source. + #[inline] + pub fn inner(&self) -> &I { + match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => source, + ResampleInner::Poly(resampler) => &resampler.input, + ResampleInner::Sinc(resampler) => &resampler.input, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => &resampler.input, + } + } + + /// Returns a mutable reference to the inner source. + #[inline] + pub fn inner_mut(&mut self) -> &mut I { + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { source, .. } => source, + ResampleInner::Poly(resampler) => &mut resampler.input, + ResampleInner::Sinc(resampler) => &mut resampler.input, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => &mut resampler.input, + } + } + + /// Returns the inner source. + #[inline] + pub fn into_inner(self) -> I { + self.inner.unwrap().into_inner() + } +} + +impl Source for Resample +where + I: Source, +{ + #[inline] + fn current_span_len(&self) -> Option { + let ( + input_span_len, + input_sample_rate, + input_exhausted, + output_buffer_len, + output_buffer_pos, + output_frames_next, + ) = match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => return source.current_span_len(), + ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => ( + resampler.input.current_span_len(), + resampler.input.sample_rate(), + resampler.input.is_exhausted(), + resampler.output_buffer_len, + resampler.output_buffer_pos, + resampler.resampler.output_frames_next(), + ), + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => ( + resampler.input.current_span_len(), + resampler.input.sample_rate(), + resampler.input.is_exhausted(), + resampler.output_buffer_len, + resampler.output_buffer_pos, + resampler.resampler.output_frames_next(), + ), + }; + + let ratio = Ratio::new(self.sample_rate().get(), input_sample_rate.get()); + if ratio.is_integer() { + // Integer upsampling (2x, 3x, etc.) - always exact and frame-aligned + input_span_len.map(|len| *ratio.numer() as usize * len) + } else { + // When the ratio contains a fraction, we cannot choose the floor or ceiling + // arbitrarily, because the resampler may produce either based on its internal state + if output_buffer_pos < output_buffer_len { + // Running state: we are iterating over our buffer with resampled samples + Some(output_buffer_len) + } else if input_exhausted { + // End state: we are at the end of our buffer and the source is exhausted + Some(0) + } else { + // Initial state: our buffer is empty until the first call to next() loads it with + // resampled samples. Return the size of the next buffer. + Some(output_frames_next * self.channels().get() as usize) + } + } + } + + #[inline] + fn sample_rate(&self) -> SampleRate { + self.target_rate + } + + #[inline] + fn channels(&self) -> ChannelCount { + match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => source.channels(), + ResampleInner::Poly(resampler) => resampler.channels, + ResampleInner::Sinc(resampler) => resampler.channels, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.channels, + } + } + + #[inline] + fn total_duration(&self) -> Option { + match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => source.total_duration(), + ResampleInner::Poly(resampler) => resampler.input.total_duration(), + ResampleInner::Sinc(resampler) => resampler.input.total_duration(), + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.input.total_duration(), + } + } + + #[inline] + fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { source, .. } => source.try_seek(position)?, + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + r.input.try_seek(position)?; + r.reset(); + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => { + r.input.try_seek(position)?; + r.reset(); + } + } + + let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); + + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { + input_span_pos: input_samples_consumed, + .. + } => { + reset_seek_span_tracking( + input_samples_consumed, + &mut self.cached_input_span_len, + position, + input_span_len, + ); + } + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + reset_seek_span_tracking( + &mut r.input_samples_consumed, + &mut self.cached_input_span_len, + position, + input_span_len, + ); + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => { + reset_seek_span_tracking( + &mut r.input_samples_consumed, + &mut self.cached_input_span_len, + position, + input_span_len, + ); + } + } + + Ok(()) + } +} + +impl Iterator for Resample +where + I: Source, +{ + type Item = Sample; + + #[inline] + fn next(&mut self) -> Option { + let sample = match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { source, .. } => source.next()?, + ResampleInner::Poly(resampler) => resampler.next_sample()?, + ResampleInner::Sinc(resampler) => resampler.next_sample()?, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.next_sample()?, + }; + + // If input reports no span length, parameters are stable by contract + let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); + if input_span_len.is_some() { + let (expected_channels, expected_rate, samples_consumed) = + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { + input_span_pos: input_samples_consumed, + channels, + source_rate, + .. + } => { + *input_samples_consumed += 1; + (*channels, *source_rate, *input_samples_consumed) + } + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + (r.channels, r.source_rate, r.input_samples_consumed) + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => (r.channels, r.source_rate, r.input_samples_consumed), + }; + + // Get current parameters from input + let input = self.inner.as_ref().unwrap().input(); + let current_channels = input.channels(); + let current_rate = input.sample_rate(); + + // Determine if we're at a span boundary: + // - Counting mode (Some): boundary when we've consumed span_len samples + // - Detection mode (None): boundary when parameters change (mid-span seek recovery) + let mut parameters_changed = false; + let at_boundary = { + let known_boundary = self + .cached_input_span_len + .map(|cached_len| samples_consumed >= cached_len); + + // In counting mode: only check parameters at boundary + // In detection mode: check parameters at every sample until detecting a boundary + if known_boundary.is_none_or(|at_boundary| at_boundary) { + parameters_changed = + current_channels != expected_channels || current_rate != expected_rate; + } + + known_boundary.unwrap_or(parameters_changed) + }; + + if at_boundary { + // Update cached span length (exits detection mode if we were in it) + self.cached_input_span_len = input_span_len; + + if parameters_changed { + // Recreate resampler - new resampler will have counters reset to 0 + let source = self.inner.take().unwrap().into_inner(); + self.inner = Some(Self::create_resampler( + source, + self.target_rate, + &self.config, + )); + } else { + // Just crossed boundary without parameter change, reset counter + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { + input_span_pos: input_samples_consumed, + .. + } => { + *input_samples_consumed = 0; + } + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + r.input_samples_consumed = 0; + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => { + r.input_samples_consumed = 0; + } + } + } + } + } + + Some(sample) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let (input_hint, source_rate, buffered_remaining) = match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => return source.size_hint(), + ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => { + let input_hint = resampler.input.size_hint(); + let buffered_remaining = resampler.output_buffer_len - resampler.output_buffer_pos; + (input_hint, resampler.source_rate, buffered_remaining) + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => { + let input_hint = resampler.input.size_hint(); + let buffered_remaining = resampler.output_buffer_len - resampler.output_buffer_pos; + (input_hint, resampler.source_rate, buffered_remaining) + } + }; + + let (input_lower, input_upper) = input_hint; + let ratio = self.target_rate.get() as Float / source_rate.get() as Float; + + let lower = buffered_remaining + (input_lower as Float * ratio).ceil() as usize; + let upper = + input_upper.map(|upper| buffered_remaining + (upper as Float * ratio).ceil() as usize); + + (lower, upper) + } +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +enum ResampleInner { + /// Passthrough when source rate is equal to the target rate + Passthrough { + source: I, + input_span_pos: usize, + channels: ChannelCount, + source_rate: SampleRate, + }, + + /// Polynomial resampling (fast, no anti-aliasing) + Poly(RubatoAsyncResample), + + /// Sinc resampling (with anti-aliasing) + Sinc(RubatoAsyncResample), + + /// FFT resampling for fixed ratios (synchronous resampling) + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + Fft(RubatoFftResample), +} + +impl ResampleInner { + /// Get a reference to the inner input source + #[inline] + fn input(&self) -> &I { + match self { + ResampleInner::Passthrough { source, .. } => source, + ResampleInner::Poly(resampler) => &resampler.input, + ResampleInner::Sinc(resampler) => &resampler.input, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => &resampler.input, + } + } + + /// Extract the inner input source, consuming the resampler + #[inline] + fn into_inner(self) -> I { + match self { + ResampleInner::Passthrough { source, .. } => source, + ResampleInner::Poly(resampler) => resampler.input, + ResampleInner::Sinc(resampler) => resampler.input, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.input, + } + } +} + +/// Generic wrapper around Rubato resamplers for sample-by-sample iteration. +#[derive(Debug)] +struct RubatoResample> { + input: I, + resampler: R, + + input_buffer: Box<[Sample]>, + input_frame_count: usize, + + output_buffer: Box<[Sample]>, + output_buffer_pos: usize, + output_buffer_len: usize, + + channels: ChannelCount, + source_rate: SampleRate, + + input_samples_consumed: usize, + input_exhausted: bool, + + total_input_frames: usize, + total_output_samples: usize, + expected_output_samples: usize, + + /// The number of real (non-flush) frames currently in the input buffer. + real_frames_in_buffer: usize, + + output_delay_remaining: usize, + resample_ratio: Float, + indexing: Indexing, +} + +/// Type alias for Async (polynomial/sinc) resampler. +type RubatoAsyncResample = RubatoResample>; + +impl> RubatoResample { + /// Calculate the number of output samples to skip for delay compensation. + fn calculate_delay_compensation(resampler: &R, channels: ChannelCount) -> usize { + // Skip delay-1 frames to align the first output frame with input position 0. + let delay_frames = resampler.output_delay(); + let delay_to_skip = delay_frames.saturating_sub(1); + delay_to_skip * channels.get() as usize + } + + fn reset(&mut self) { + self.resampler.reset(); + self.output_buffer_pos = 0; + self.output_buffer_len = 0; + self.input_frame_count = 0; + self.input_samples_consumed = 0; + self.input_exhausted = false; + self.total_input_frames = 0; + self.total_output_samples = 0; + self.expected_output_samples = 0; + self.real_frames_in_buffer = 0; + self.indexing.partial_len = None; + self.output_delay_remaining = + Self::calculate_delay_compensation(&self.resampler, self.channels); + } + + fn next_sample(&mut self) -> Option { + let num_channels = self.channels.get() as usize; + loop { + // If we have buffered output, return it + if self.output_buffer_pos < self.output_buffer_len { + let sample = self.output_buffer[self.output_buffer_pos]; + self.output_buffer_pos += 1; + self.total_output_samples += 1; + + if self.total_output_samples > self.expected_output_samples { + // Cut off filter artifacts after input is exhausted + return None; + } + + return Some(sample); + } + + // Need more input - first check if we're completely done + if self.input_exhausted + && self.input_frame_count == 0 + && self.total_output_samples >= self.expected_output_samples + { + return None; + } + + // Fill input buffer - accumulate frames until we hit needed amount or run out of input + let needed_input = self.resampler.input_frames_next(); + let frames_before = self.input_frame_count; + while self.input_frame_count < needed_input && !self.input_exhausted { + let sample_pos = self.input_frame_count * num_channels; + for ch in 0..num_channels { + if let Some(sample) = self.input.next() { + self.input_buffer[sample_pos + ch] = sample; + } else { + self.input_exhausted = true; + break; + } + } + if !self.input_exhausted { + self.input_frame_count += 1; + self.real_frames_in_buffer += 1; + } + } + + // If we have no input, flush the filter tail with zeros + if self.input_frame_count == 0 { + // Zero-pad a full chunk to drain the filter delay + self.input_buffer[..needed_input * num_channels].fill(Sample::EQUILIBRIUM); + self.input_frame_count = needed_input; + // real_frames_in_buffer stays at 0 - these are flush frames + } + + // We can process with fewer frames than needed using partial_len when the input is + // exhausted. If we don't have enough input and more is coming, wait. + let made_progress = self.input_frame_count > frames_before; + if self.input_frame_count < needed_input && !self.input_exhausted && made_progress { + continue; + } + + let actual_frames = self.input_frame_count; + + // Prevent stack allocations in the hot path by reusing the indexing struct + let indexing_ref = if actual_frames < needed_input { + self.indexing.partial_len = Some(actual_frames); + Some(&self.indexing) + } else { + None + }; + + let (frames_in, frames_out) = { + // InterleavedSlice is a zero-cost abstraction - no heap allocation occurs here + let input_adapter = audioadapter_buffers::direct::InterleavedSlice::new( + &self.input_buffer, + num_channels, + actual_frames, + ) + .ok()?; + + let num_frames = self.output_buffer.len() / num_channels; + let mut output_adapter = audioadapter_buffers::direct::InterleavedSlice::new_mut( + &mut self.output_buffer, + num_channels, + num_frames, + ) + .ok()?; + + self.resampler + .process_into_buffer(&input_adapter, &mut output_adapter, indexing_ref) + .ok()? + }; + + // If no output was produced and input is exhausted, we're done + if frames_out == 0 && self.input_exhausted { + return None; + } + + // When using partial_len, Rubato may report consuming more frames than we + // actually provided (it counts the zero-padded frames). Clamp to actual. + let actual_consumed = frames_in.min(actual_frames); + self.input_samples_consumed += actual_consumed * num_channels; + + // Only count real (non-flush) frames toward expected output + let real_consumed = actual_consumed.min(self.real_frames_in_buffer); + self.real_frames_in_buffer -= real_consumed; + self.total_input_frames += real_consumed; + self.expected_output_samples = (self.total_input_frames as Float * self.resample_ratio) + .ceil() as usize + * num_channels; + + // Shift remaining input samples to beginning of buffer + if actual_consumed < self.input_frame_count { + let src_start = actual_consumed * num_channels; + let src_end = self.input_frame_count * num_channels; + self.input_buffer.copy_within(src_start..src_end, 0); + } + self.input_frame_count -= actual_consumed; + + self.output_buffer_pos = 0; + self.output_buffer_len = frames_out * num_channels; + + // Skip warmup delay samples + if self.output_delay_remaining > 0 { + let samples_to_skip = self.output_delay_remaining.min(self.output_buffer_len); + self.output_buffer_pos += samples_to_skip; + self.output_delay_remaining -= samples_to_skip; + } + } + } +} + +impl RubatoAsyncResample { + fn new_poly( + input: I, + target_rate: SampleRate, + chunk_size: usize, + degree: Poly, + ) -> Result { + let source_rate = input.sample_rate(); + let channels = input.channels(); + + let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; + + let resampler = rubato::Async::new_poly( + resample_ratio.into(), + 1.0, + degree.into(), + chunk_size, + channels.get() as usize, + rubato::FixedAsync::Output, + ) + .map_err(|e| format!("Failed to create polynomial resampler: {:?}", e))?; + + let input_buf_size = resampler.input_frames_max(); + let output_buf_size = resampler.output_frames_max(); + + let output_delay_remaining = + RubatoResample::>::calculate_delay_compensation( + &resampler, channels, + ); + + Ok(Self { + input, + resampler, + input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] + .into_boxed_slice(), + input_frame_count: 0, + output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] + .into_boxed_slice(), + output_buffer_pos: 0, + output_buffer_len: 0, + channels, + source_rate, + input_samples_consumed: 0, + input_exhausted: false, + output_delay_remaining, + total_input_frames: 0, + total_output_samples: 0, + expected_output_samples: 0, + real_frames_in_buffer: 0, + resample_ratio, + indexing: Indexing { + input_offset: 0, + output_offset: 0, + partial_len: None, + active_channels_mask: None, + }, + }) + } + + #[allow(clippy::too_many_arguments)] + fn new_sinc( + input: I, + target_rate: SampleRate, + chunk_size: usize, + sinc_len: usize, + f_cutoff: Float, + oversampling_factor: usize, + interpolation: Sinc, + window: WindowFunction, + ) -> Result { + let source_rate = input.sample_rate(); + let channels = input.channels(); + + let parameters = rubato::SincInterpolationParameters { + sinc_len, + f_cutoff: f_cutoff as _, + oversampling_factor, + interpolation: interpolation.into(), + window: window.into(), + }; + + let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; + + let resampler = rubato::Async::new_sinc( + resample_ratio.into(), + 1.0, + ¶meters, + chunk_size, + channels.get() as usize, + rubato::FixedAsync::Output, + ) + .map_err(|e| format!("Failed to create sinc resampler: {:?}", e))?; + + let input_buf_size = resampler.input_frames_max(); + let output_buf_size = resampler.output_frames_max(); + + let output_delay_remaining = + RubatoResample::>::calculate_delay_compensation( + &resampler, channels, + ); + + Ok(Self { + input, + resampler, + input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] + .into_boxed_slice(), + input_frame_count: 0, + output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] + .into_boxed_slice(), + output_buffer_pos: 0, + output_buffer_len: 0, + channels, + source_rate, + input_samples_consumed: 0, + input_exhausted: false, + output_delay_remaining, + total_input_frames: 0, + total_output_samples: 0, + expected_output_samples: 0, + real_frames_in_buffer: 0, + resample_ratio, + indexing: Indexing { + input_offset: 0, + output_offset: 0, + partial_len: None, + active_channels_mask: None, + }, + }) + } +} + +/// Type alias for FFT resampler (synchronous, fixed-ratio). +/// Input and output chunk sizes must be exact multiples of the ratio components. +#[cfg(feature = "rubato-fft")] +#[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] +type RubatoFftResample = RubatoResample>; + +// FFT-specific constructor +#[cfg(feature = "rubato-fft")] +#[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] +impl RubatoFftResample { + /// Create a new FFT resampler for fixed-ratio sample rate conversion. + /// + /// The FFT resampler requires that: + /// - Input chunk size must be a multiple of the GCD-reduced denominator + /// - Output chunk size must be a multiple of the GCD-reduced numerator + fn new( + input: I, + target_rate: SampleRate, + chunk_size: usize, + sub_chunks: usize, + ) -> Result { + let source_rate = input.sample_rate(); + let channels = input.channels(); + + // Calculate the GCD-reduced ratio + let ratio = Ratio::new(target_rate.get(), source_rate.get()); + let (_num, den) = ratio.into_raw(); + + // Determine input chunk size - must be multiple of denominator + let input_chunk_size = ((chunk_size / den as usize) + 1) * den as usize; + + let resampler = rubato::Fft::new( + source_rate.get() as usize, + target_rate.get() as usize, + input_chunk_size, + sub_chunks, + channels.get() as usize, + rubato::FixedSync::Output, + ) + .map_err(|e| format!("Failed to create FFT resampler: {:?}", e))?; + + let input_buf_size = resampler.input_frames_max(); + let output_buf_size = resampler.output_frames_max(); + let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; + + let output_delay_remaining = Self::calculate_delay_compensation(&resampler, channels); + + Ok(Self { + input, + resampler, + input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] + .into_boxed_slice(), + input_frame_count: 0, + output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] + .into_boxed_slice(), + output_buffer_pos: 0, + output_buffer_len: 0, + channels, + source_rate, + input_samples_consumed: 0, + input_exhausted: false, + total_input_frames: 0, + total_output_samples: 0, + expected_output_samples: 0, + real_frames_in_buffer: 0, + output_delay_remaining, + resample_ratio, + indexing: Indexing { + input_offset: 0, + output_offset: 0, + partial_len: None, + active_channels_mask: None, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::{from_iter, SineWave}; + use crate::Source; + use dasp_sample::ToSample; + use quickcheck::{quickcheck, Arbitrary, Gen, TestResult}; + use std::num::NonZero; + + #[derive(Debug, Clone, Copy)] + struct TestSampleRate(SampleRate); + + impl Arbitrary for TestSampleRate { + fn arbitrary(g: &mut Gen) -> Self { + // Generate realistic sample rates: 8 kHz to 384 kHz + let rate = u32::arbitrary(g) % 376_001 + 8_000; + TestSampleRate(SampleRate::new(rate).unwrap()) + } + } + + impl std::ops::Deref for TestSampleRate { + type Target = SampleRate; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + #[derive(Debug, Clone, Copy)] + struct TestChannelCount(ChannelCount); + + impl Arbitrary for TestChannelCount { + fn arbitrary(g: &mut Gen) -> Self { + // Generate realistic channel counts: 1 to 8 + let channels = (u16::arbitrary(g) % 7) + 1; + TestChannelCount(ChannelCount::new(channels).unwrap()) + } + } + + impl std::ops::Deref for TestChannelCount { + type Target = ChannelCount; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + struct TestSource { + samples: Vec, + index: usize, + sample_rate: SampleRate, + channels: ChannelCount, + } + + impl TestSource { + fn new(samples: Vec, sample_rate: SampleRate, channels: ChannelCount) -> Self { + Self { + samples, + index: 0, + sample_rate, + channels, + } + } + } + + impl Iterator for TestSource { + type Item = Sample; + + fn next(&mut self) -> Option { + if self.index < self.samples.len() { + let sample = self.samples[self.index]; + self.index += 1; + Some(sample) + } else { + None + } + } + } + + impl Source for TestSource { + fn current_span_len(&self) -> Option { + Some(self.samples.len()) + } + + fn sample_rate(&self) -> SampleRate { + self.sample_rate + } + + fn channels(&self) -> ChannelCount { + self.channels + } + + fn total_duration(&self) -> Option { + let samples = self.samples.len() / self.channels.get() as usize; + Some(Duration::from_secs_f64( + samples as f64 / self.sample_rate.get() as f64, + )) + } + + fn try_seek(&mut self, _position: Duration) -> Result<(), SeekError> { + Ok(()) + } + } + + /// Convert and truncate input to contain a frame-aligned number of samples. + fn convert_to_frames>( + input: Vec, + channels: ChannelCount, + ) -> Vec { + let mut input: Vec = input.iter().map(|x| x.to_sample()).collect(); + let frame_size = channels.get() as usize; + input.truncate(frame_size * (input.len() / frame_size)); + input + } + + quickcheck! { + /// Check that resampling an empty input produces no output. + fn empty(from: TestSampleRate, to: TestSampleRate, channels: TestChannelCount) -> bool { + let input = vec![]; + let config = ResampleConfig::default(); + let source = from_iter(input.clone().into_iter(), *channels, *from); + let output = Resample::new(source, *to, config).collect::>(); + input == output + } + + /// Check that resampling to the same rate does not change the signal. + fn identity(from: TestSampleRate, channels: TestChannelCount, input: Vec) -> bool { + let input = convert_to_frames(input, *channels); + let config = ResampleConfig::default(); + let source = from_iter(input.clone().into_iter(), *channels, *from); + let output = Resample::new(source, *from, config).collect::>(); + input == output + } + + /// Check that resampling does not change the audio duration, except by a negligible + /// amount (± 1ms). Reproduces #316. + fn preserve_durations(d: Duration, freq: f32, to: TestSampleRate) -> TestResult { + use crate::source::{SineWave, Source}; + if !freq.is_normal() || freq <= 0.0 || d > Duration::from_secs(1) { + return TestResult::discard(); + } + + let source = SineWave::new(freq).take_duration(d); + let from = source.sample_rate(); + + let config = ResampleConfig::poly().degree(Poly::Linear).build(); + let resampled = Resample::new(source, *to, config); + let duration = Duration::from_secs_f32(resampled.count() as f32 / to.get() as f32); + + let delta = duration.abs_diff(d); + TestResult::from_bool(delta < Duration::from_millis(1)) + } + } + + /// Helper to create interleaved multi-channel test data using SineWave sources. + fn create_test_input(frames: usize, channels: u16) -> Vec { + let frequencies = [440.0, 1000.0]; + let total_samples = frames * channels as usize; + let mut input = Vec::with_capacity(total_samples); + + // Create a SineWave for each channel + let mut waves: Vec<_> = (0..channels) + .map(|ch| SineWave::new(frequencies[ch as usize % frequencies.len()])) + .collect(); + + // Interleave samples from each channel + for _ in 0..frames { + for wave in waves.iter_mut() { + input.push(wave.next().unwrap()); + } + } + input + } + + /// Test various ratio types: integer, fractional, and reciprocal. + #[test] + fn test_sample_rate_conversions() { + let test_cases = [ + // (from_rate, to_rate, channels, description) + (1000, 7000, 1, "integer upsample 7x"), + (2000, 3000, 2, "fractional upsample 1.5x"), + (12000, 2400, 1, "integer downsample 1/5x"), + (48000, 44100, 2, "fractional downsample (DVD to CD)"), + (8000, 48001, 1, "async sinc"), + ]; + + let configs: &[(&str, ResampleConfig)] = &[ + ("poly", ResampleConfig::poly().build()), + ("sinc", ResampleConfig::sinc().build()), + ]; + + for (config_name, config) in configs { + for (from_rate, to_rate, channels, desc) in test_cases { + let from = SampleRate::new(from_rate).unwrap(); + let to = SampleRate::new(to_rate).unwrap(); + let ch = ChannelCount::new(channels).unwrap(); + + let input_frames = 100; + let input = create_test_input(input_frames, channels); + let input_samples = input.len(); + + let source = from_iter(input.into_iter(), ch, from); + let resampler = Resample::new(source, to, config.clone()); + + let size_hint_lower = resampler.size_hint().0; + let output_count = resampler.count(); + + assert_eq!( + output_count, size_hint_lower, + "[{config_name}] {desc}: size_hint {size_hint_lower} should equal actual output {output_count}", + ); + + let ratio = to.get() as f64 / from.get() as f64; + let expected_samples = (input_samples as f64 * ratio).ceil() as usize; + + assert_eq!( + output_count.abs_diff(expected_samples), 0, + "[{config_name}] {desc}: expected {expected_samples} samples, got {output_count}", + ); + } + } + } +} From 6485c36ed38321babd683e59aa49d974ced2db23 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 1 Feb 2026 23:10:28 +0100 Subject: [PATCH 03/20] refactor: use Resample for sample rate conversion Replace manual linear-interpolation converter with a Resample-backed implementation and a SourceAdapter for iterator inputs. --- src/conversions/sample_rate.rs | 487 ++++++++++++++------------------- src/math.rs | 49 +--- src/mixer.rs | 1 + src/speakers/builder.rs | 66 ----- 4 files changed, 215 insertions(+), 388 deletions(-) diff --git a/src/conversions/sample_rate.rs b/src/conversions/sample_rate.rs index 2120a7c8..c289de8e 100644 --- a/src/conversions/sample_rate.rs +++ b/src/conversions/sample_rate.rs @@ -1,124 +1,69 @@ use crate::common::{ChannelCount, SampleRate}; -use crate::{math, Sample}; -use num_rational::Ratio; -use std::collections::VecDeque; -use std::mem; - -/// Iterator that converts from a certain sample rate to another. -#[derive(Clone, Debug)] +use crate::source::{resample::Poly, Resample, ResampleConfig, SeekError}; +use crate::{Sample, Source}; +use std::time::Duration; + +/// Iterator that converts from one sample rate to another. +/// +/// Uses `Resample` internally configured with linear polynomial interpolation. This is fast but +/// low quality. For better quality, consider using `Resample` directly with a higher-quality +/// configuration. +#[derive(Debug)] pub struct SampleRateConverter where - I: Iterator, + I: Iterator, { - /// The iterator that gives us samples. - input: I, - /// We convert chunks of `from` samples into chunks of `to` samples. - from: u32, - /// We convert chunks of `from` samples into chunks of `to` samples. - to: u32, - /// Number of channels in the stream - channels: ChannelCount, - /// One sample per channel, extracted from `input`. - current_span: Vec, - /// Position of `current_sample` modulo `from`. - current_span_pos_in_chunk: u32, - /// The samples right after `current_sample` (one per channel), extracted from `input`. - next_frame: Vec, - /// The position of the next sample that the iterator should return, modulo `to`. - /// This counter is incremented (modulo `to`) every time the iterator is called. - next_output_span_pos_in_chunk: u32, - /// The buffer containing the samples waiting to be output. - output_buffer: VecDeque, + inner: Resample>, } impl SampleRateConverter where - I: Iterator, + I: Iterator, { /// Create new sample rate converter. - /// - /// The converter uses simple linear interpolation for up-sampling - /// and discards samples for down-sampling. This may introduce audible - /// distortions in some cases (see [#584](https://github.com/RustAudio/rodio/issues/584)). - /// - /// # Limitations - /// Some rate conversions where target rate is high and rates are mutual primes the sample - /// interpolation may cause numeric overflows. Conversion between usual sample rates - /// 2400, 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, ... is expected to work. - /// - /// # Panic - /// Panics if `from`, `to` or `num_channels` are 0. - #[inline] - pub fn new( - mut input: I, - from: SampleRate, - to: SampleRate, - num_channels: ChannelCount, - ) -> SampleRateConverter { - let (first_samples, next_samples) = if from == to { - // if `from` == `to` == 1, then we just pass through - (Vec::new(), Vec::new()) - } else { - let first = input - .by_ref() - .take(num_channels.get() as usize) - .collect::>(); - let next = input - .by_ref() - .take(num_channels.get() as usize) - .collect::>(); - (first, next) + pub fn new(input: I, from: SampleRate, to: SampleRate, channels: ChannelCount) -> Self { + let adapter = SourceAdapter { + iter: input, + channels, + sample_rate: from, }; - // Reducing numerator to avoid numeric overflows during interpolation. - let (to, from) = Ratio::new(to.get(), from.get()).into_raw(); + let config = ResampleConfig::poly().degree(Poly::Linear).build(); + let inner = Resample::new(adapter, to, config); - SampleRateConverter { - input, - from, - to, - channels: num_channels, - current_span_pos_in_chunk: 0, - next_output_span_pos_in_chunk: 0, - current_span: first_samples, - next_frame: next_samples, - // Capacity: worst case is upsampling where we buffer multiple frames worth of samples. - output_buffer: VecDeque::with_capacity( - (to as f32 / from as f32).ceil() as usize * num_channels.get() as usize, - ), - } + Self { inner } } /// Destroys this iterator and returns the underlying iterator. #[inline] pub fn into_inner(self) -> I { - self.input + self.inner.into_inner().iter } - /// Get mutable access to the iterator + /// Get mutable access to the underlying iterator. #[inline] pub fn inner_mut(&mut self) -> &mut I { - &mut self.input + &mut self.inner.inner_mut().iter } - /// Get a reference to the underlying iterator + /// Get access to the underlying iterator. #[inline] pub fn inner(&self) -> &I { - &self.input + &self.inner.inner().iter } +} - fn next_input_span(&mut self) { - self.current_span_pos_in_chunk += 1; - - mem::swap(&mut self.current_span, &mut self.next_frame); - self.next_frame.clear(); - for _ in 0..self.channels.get() { - if let Some(i) = self.input.next() { - self.next_frame.push(i); - } else { - break; - } - } +impl Clone for SampleRateConverter +where + I: Iterator + Clone, +{ + fn clone(&self) -> Self { + Self::new( + self.inner.inner().iter.clone(), + self.inner.inner().sample_rate(), + self.inner.sample_rate(), + self.inner.inner().channels(), + ) } } @@ -126,219 +71,183 @@ impl Iterator for SampleRateConverter where I: Iterator, { - type Item = I::Item; - - fn next(&mut self) -> Option { - // the algorithm below doesn't work if `self.from == self.to` - if self.from == self.to { - debug_assert_eq!(self.from, 1); - return self.input.next(); - } + type Item = Sample; - // Short circuit if there are some samples waiting. - if let Some(sample) = self.output_buffer.pop_front() { - return Some(sample); - } + #[inline] + fn next(&mut self) -> Option { + self.inner.next() + } - // The span we are going to return from this function will be a linear interpolation - // between `self.current_span` and `self.next_span`. + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} - if self.next_output_span_pos_in_chunk == self.to { - // If we jump to the next span, we reset the whole state. - self.next_output_span_pos_in_chunk = 0; +impl ExactSizeIterator for SampleRateConverter where + I: Iterator + ExactSizeIterator +{ +} - self.next_input_span(); - while self.current_span_pos_in_chunk != self.from { - self.next_input_span(); - } - self.current_span_pos_in_chunk = 0; - } else { - // Finding the position of the first sample of the linear interpolation. - let req_left_sample = - (self.from * self.next_output_span_pos_in_chunk / self.to) % self.from; - - // Advancing `self.current_span`, `self.next_span` and - // `self.current_span_pos_in_chunk` until the latter variable - // matches `req_left_sample`. - while self.current_span_pos_in_chunk != req_left_sample { - self.next_input_span(); - debug_assert!(self.current_span_pos_in_chunk < self.from); - } - } +/// Simple adapter that provides Source trait for any iterator. +#[derive(Clone, Debug)] +struct SourceAdapter { + iter: I, + channels: ChannelCount, + sample_rate: SampleRate, +} - // Merging `self.current_span` and `self.next_span` into `self.output_buffer`. - // Note that `self.output_buffer` can be truncated if there is not enough data in - // `self.next_span`. - let mut result = None; - let numerator = (self.from * self.next_output_span_pos_in_chunk) % self.to; - for (off, (cur, next)) in self - .current_span - .iter() - .zip(self.next_frame.iter()) - .enumerate() - { - let sample = math::lerp(*cur, *next, numerator, self.to); - - if off == 0 { - result = Some(sample); - } else { - self.output_buffer.push_back(sample); - } - } +impl Iterator for SourceAdapter +where + I: Iterator, +{ + type Item = Sample; - // Incrementing the counter for the next iteration. - self.next_output_span_pos_in_chunk += 1; - - if result.is_some() { - result - } else { - // draining `self.current_span` - let mut current_span = self.current_span.drain(..); - let r = current_span.next()?; - self.output_buffer.extend(current_span); - Some(r) - } + #[inline] + fn next(&mut self) -> Option { + self.iter.next() } #[inline] fn size_hint(&self) -> (usize, Option) { - let apply = |samples: usize| { - // `samples_after_chunk` will contain the number of samples remaining after the chunk - // currently being processed - let samples_after_chunk = samples; - // adding the samples of the next chunk that may have already been read - let samples_after_chunk = if self.current_span_pos_in_chunk == self.from - 1 { - samples_after_chunk + self.next_frame.len() - } else { - samples_after_chunk - }; - // removing the samples of the current chunk that have not yet been read - let samples_after_chunk = samples_after_chunk.saturating_sub( - self.from.saturating_sub(self.current_span_pos_in_chunk + 2) as usize - * usize::from(self.channels.get()), - ); - // calculating the number of samples after the transformation - // TODO: this is wrong here \|/ - let samples_after_chunk = samples_after_chunk * self.to as usize / self.from as usize; - - // `samples_current_chunk` will contain the number of samples remaining to be output - // for the chunk currently being processed - let samples_current_chunk = (self.to - self.next_output_span_pos_in_chunk) as usize - * usize::from(self.channels.get()); - - samples_current_chunk + samples_after_chunk + self.output_buffer.len() - }; + self.iter.size_hint() + } +} - if self.from == self.to { - self.input.size_hint() - } else { - let (min, max) = self.input.size_hint(); - (apply(min), max.map(apply)) - } +impl Source for SourceAdapter +where + I: Iterator, +{ + #[inline] + fn current_span_len(&self) -> Option { + self.iter.size_hint().1 + } + + #[inline] + fn channels(&self) -> ChannelCount { + self.channels + } + + #[inline] + fn sample_rate(&self) -> SampleRate { + self.sample_rate + } + + #[inline] + fn total_duration(&self) -> Option { + None + } + + #[inline] + fn try_seek(&mut self, _pos: Duration) -> Result<(), SeekError> { + Err(SeekError::NotSupported { + underlying_source: std::any::type_name::(), + }) } } -impl ExactSizeIterator for SampleRateConverter where I: ExactSizeIterator {} +impl ExactSizeIterator for SourceAdapter where I: ExactSizeIterator {} #[cfg(test)] mod test { use super::SampleRateConverter; use crate::common::{ChannelCount, SampleRate}; use crate::math::nz; - use crate::Sample; - use core::time::Duration; + use crate::{Float, Sample}; + use dasp_sample::ToSample; use quickcheck::{quickcheck, TestResult}; + use std::num::NonZero; + use std::time::Duration; + + /// Convert and truncate input to contain a frame-aligned number of samples. + fn convert_to_frames>( + input: Vec, + channels: ChannelCount, + ) -> Vec { + let mut input: Vec = input.iter().map(|x| x.to_sample()).collect(); + let frame_size = channels.get() as usize; + input.truncate(frame_size * (input.len() / frame_size)); + input + } quickcheck! { /// Check that resampling an empty input produces no output. fn empty(from: SampleRate, to: SampleRate, channels: ChannelCount) -> TestResult { - if from.get() > 384_000*2 || to.get() > 384_000*2 || channels.get() > 128 - { + if from.get() > 384_000*2 || to.get() > 384_000*2 || channels.get() > 128 { return TestResult::discard(); } - let input: Vec = Vec::new(); - let output = - SampleRateConverter::new(input.into_iter(), from, to, channels) - .collect::>(); + let input = vec![]; + let output = SampleRateConverter::new(input.clone().into_iter(), from, to, channels) + .collect::>(); - assert_eq!(output, []); - TestResult::passed() + TestResult::from_bool(input == output) } /// Check that resampling to the same rate does not change the signal. fn identity(from: SampleRate, channels: ChannelCount, input: Vec) -> TestResult { if channels.get() > 128 { return TestResult::discard(); } - let input = Vec::from_iter(input.iter().map(|x| *x as Sample)); - let output = - SampleRateConverter::new(input.clone().into_iter(), from, from, channels) - .collect::>(); + let input = convert_to_frames(input, channels); + let output = SampleRateConverter::new(input.clone().into_iter(), from, from, channels) + .collect::>(); TestResult::from_bool(input == output) } - /// Check that dividing the sample rate by k (integer) is the same as - /// dropping a sample from each channel. - fn divide_sample_rate(to: SampleRate, k: u16, input: Vec, channels: ChannelCount) -> TestResult { - if k == 0 || channels.get() > 128 || to.get() > 48000 { + /// Check that dividing the sample rate by k (integer) is the same as dropping a sample + /// from each channel. + fn divide_sample_rate(to: SampleRate, k: NonZero, input: Vec, channels: ChannelCount) -> TestResult { + if channels.get() > 128 || to.get() > 48000 { return TestResult::discard(); } - let input = Vec::from_iter(input.iter().map(|x| *x as Sample)); - let to = to as SampleRate; - let from = to.get() * k as u32; + let from = SampleRate::new(to.get() * k.get() as u32).unwrap(); - // Truncate the input, so it contains an integer number of spans. - let input = { - let ns = channels.get() as usize; - let mut i = input; - i.truncate(ns * (i.len() / ns)); - i - }; + let input = convert_to_frames(input, channels); + let output = SampleRateConverter::new(input.clone().into_iter(), from, to, channels) + .collect::>(); - let output = - SampleRateConverter::new(input.clone().into_iter(), SampleRate::new(from).expect("to is nonzero and k is nonzero"), to, channels) - .collect::>(); + let expected = input + .chunks_exact(channels.get() as usize) + .step_by(k.get() as usize) + .flatten() + .copied() + .collect::>(); - TestResult::from_bool(input.chunks_exact(channels.get().into()) - .step_by(k as usize).collect::>().concat() == output) + TestResult::from_bool(output == expected) } - /// Check that, after multiplying the sample rate by k, every k-th - /// sample in the output matches exactly with the input. - fn multiply_sample_rate(from: SampleRate, k: u8, input: Vec, channels: ChannelCount) -> TestResult { - if k == 0 || from.get() > u16::MAX as u32 || channels.get() > 128 { + /// Check that, after multiplying the sample rate by k, every k-th sample in the output + /// matches exactly with the input. + fn multiply_sample_rate(from: SampleRate, k: NonZero, input: Vec, channels: ChannelCount) -> TestResult { + if from.get() > u16::MAX as u32 || channels.get() > 128 { return TestResult::discard(); } - let input = Vec::from_iter(input.iter().map(|x| *x as Sample)); - - let from = from as SampleRate; - let to = from.get() * k as u32; - - // Truncate the input, so it contains an integer number of spans. - let input = { - let ns = channels.get() as usize; - let mut i = input; - i.truncate(ns * (i.len() / ns)); - i - }; - - let output = - SampleRateConverter::new(input.clone().into_iter(), from, SampleRate::new(to).unwrap(), channels) - .collect::>(); - - TestResult::from_bool(input == - output.chunks_exact(channels.get().into()) - .step_by(k as usize).collect::>().concat()) + + let to = SampleRate::new(from.get() * k.get() as u32).unwrap(); + + let input = convert_to_frames(input, channels); + let output = SampleRateConverter::new(input.clone().into_iter(), from, to, channels) + .collect::>(); + + let recovered = output + .chunks_exact(channels.get() as usize) + .step_by(k.get() as usize) + .flatten() + .copied() + .collect::>(); + + TestResult::from_bool(input == recovered) } - #[ignore] - /// Check that resampling does not change the audio duration, - /// except by a negligible amount (± 1ms). Reproduces #316. - /// Ignored, pending a bug fix. + /// Check that resampling does not change the audio duration, except by a negligible + /// amount (± 1ms). Reproduces #316. fn preserve_durations(d: Duration, freq: f32, to: SampleRate) -> TestResult { use crate::source::{SineWave, Source}; + if !freq.is_normal() || freq <= 0.0 || d > Duration::from_secs(1) { + return TestResult::discard(); + } let source = SineWave::new(freq).take_duration(d); let from = source.sample_rate(); @@ -353,36 +262,66 @@ mod test { } } - #[test] - fn upsample() { - let input = vec![2.0, 16.0, 4.0, 18.0, 6.0, 20.0, 8.0, 22.0]; - let output = SampleRateConverter::new(input.into_iter(), nz!(2000), nz!(3000), nz!(2)); - assert_eq!(output.len(), 12); // Test the source's Iterator::size_hint() + fn test_sample_rate_conversion( + input: Vec, + from: SampleRate, + to: SampleRate, + channels: ChannelCount, + ) { + let input_len = input.len(); + let converter = SampleRateConverter::new(input.into_iter(), from, to, channels); + let converter_len = converter.len(); + let output_len = converter.count(); - let output = output.map(|x| x.trunc()).collect::>(); assert_eq!( - output, - [2.0, 16.0, 3.0, 17.0, 4.0, 18.0, 6.0, 20.0, 7.0, 21.0, 8.0, 22.0] + converter_len, output_len, + "size_hint should match actual output" + ); + assert_eq!( + output_len, + (input_len as Float * to.get() as Float / from.get() as Float).ceil() as usize, + "duration must be preserved" ); } #[test] - fn upsample2() { - let input = vec![1.0, 14.0]; - let output = SampleRateConverter::new(input.into_iter(), nz!(1000), nz!(7000), nz!(1)); - let size_estimation = output.len(); - let output = output.map(|x| x.trunc()).collect::>(); - assert_eq!(output, [1.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0]); - assert!((size_estimation as f32 / output.len() as f32).abs() < 2.0); + fn upsample_fractional_ratio() { + let from = nz!(2000); + let to = nz!(3000); + assert!(to.get() % from.get() != 0, "should be fractional ratio"); + + // 4 input frames (8 samples) at 1.5x = 6 output frames (12 samples) + // Preserves duration: 4/2000 = 6/3000 = 0.002 seconds + test_sample_rate_conversion( + vec![2.0, 16.0, 4.0, 18.0, 6.0, 20.0, 8.0, 22.0], + from, + to, + nz!(2), + ); + } + + #[test] + fn upsample_integer_ratio() { + let from = nz!(1000); + let to = nz!(7000); + assert!(to.get() % from.get() == 0, "should be integer ratio"); + + test_sample_rate_conversion(vec![1.0, 14.0], from, to, nz!(1)); } #[test] fn downsample() { - let input = Vec::from_iter((0..17).map(|x| x as Sample)); - let output = SampleRateConverter::new(input.into_iter(), nz!(12000), nz!(2400), nz!(1)); - let size_estimation = output.len(); - let output = output.collect::>(); - assert_eq!(output, [0.0, 5.0, 10.0, 15.0]); - assert!((size_estimation as f32 / output.len() as f32).abs() < 2.0); + let from = nz!(12000); + let to = nz!(2400); + assert!(from.get() > to.get(), "should be downsampling"); + + // Note: Rubato's polynomial downsampler has inherent phase offset + // (samples at positions [4, 9, 14] instead of [0, 5, 10, 15]) + test_sample_rate_conversion( + Vec::from_iter((0..17).map(|x| x as Sample)), + from, + to, + nz!(1), + ); } } diff --git a/src/math.rs b/src/math.rs index 5a1fa279..13cc7fa9 100644 --- a/src/math.rs +++ b/src/math.rs @@ -1,6 +1,6 @@ //! Math utilities for audio processing. -use crate::common::SampleRate; +use crate::{Float, SampleRate}; use std::time::Duration; /// Nanoseconds per second, used for Duration calculations. @@ -13,18 +13,6 @@ pub use std::f32::consts::{E, LN_10, LN_2, LOG10_2, LOG10_E, LOG2_10, LOG2_E, PI #[cfg(feature = "64bit")] pub use std::f64::consts::{E, LN_10, LN_2, LOG10_2, LOG10_E, LOG2_10, LOG2_E, PI, TAU}; -/// Linear interpolation between two samples. -/// -/// The result should be equivalent to -/// `first * (1 - numerator / denominator) + second * numerator / denominator`. -/// -/// To avoid numeric overflows pick smaller numerator. -// TODO (refactoring) Streamline this using coefficient instead of numerator and denominator. -#[inline] -pub(crate) fn lerp(first: Sample, second: Sample, numerator: u32, denominator: u32) -> Sample { - first + (second - first) * numerator as Float / denominator as Float -} - /// Converts decibels to linear amplitude scale. /// /// This function converts a decibel value to its corresponding linear amplitude value @@ -176,44 +164,9 @@ macro_rules! nz { pub use nz; -use crate::{common::Float, Sample}; - #[cfg(test)] mod test { use super::*; - use num_rational::Ratio; - use quickcheck::{quickcheck, TestResult}; - - quickcheck! { - fn lerp_random(first: Sample, second: Sample, numerator: u32, denominator: u32) -> TestResult { - if denominator == 0 { return TestResult::discard(); } - - // Constrain to realistic audio sample range [-1.0, 1.0] - // Audio samples rarely exceed this range, and large values cause floating-point error accumulation - if first.abs() > 1.0 || second.abs() > 1.0 { return TestResult::discard(); } - - // Discard infinite or NaN samples (can occur in quickcheck) - if !first.is_finite() || !second.is_finite() { return TestResult::discard(); } - - let (numerator, denominator) = Ratio::new(numerator, denominator).into_raw(); - // Reduce max numerator to avoid floating-point error accumulation with large ratios - if numerator > 1000 { return TestResult::discard(); } - - let a = first as f64; - let b = second as f64; - let c = numerator as f64 / denominator as f64; - if !(0.0..=1.0).contains(&c) { return TestResult::discard(); }; - - let reference = a * (1.0 - c) + b * c; - let x = lerp(first, second, numerator, denominator); - - // With realistic audio-range inputs, lerp should be very precise - // f32 has ~7 decimal digits, so 1e-6 tolerance is reasonable - // This is well below 16-bit audio precision (~1.5e-5) - let tolerance = 1e-6; - TestResult::from_bool((x as f64 - reference).abs() < tolerance) - } - } /// Tolerance values for precision tests, derived from empirical measurement /// of actual implementation errors across the full ±100dB range. diff --git a/src/mixer.rs b/src/mixer.rs index 3e9d40de..39f8b8a9 100644 --- a/src/mixer.rs +++ b/src/mixer.rs @@ -281,6 +281,7 @@ mod tests { assert_eq!(rx.next(), Some(15.0)); assert_eq!(rx.next(), Some(5.0)); assert_eq!(rx.next(), Some(-5.0)); + assert_eq!(rx.next(), Some(-2.5)); assert_eq!(rx.next(), None); } diff --git a/src/speakers/builder.rs b/src/speakers/builder.rs index a3a4616a..0ad89165 100644 --- a/src/speakers/builder.rs +++ b/src/speakers/builder.rs @@ -634,72 +634,6 @@ where Ok(SinkHandle { _stream: stream }) } - - /// TODO - pub fn open_queue_sink(&self) -> Result { - todo!() - } - - /// TODO - pub fn play( - self, - mut source: impl FixedSource + Send + 'static, - ) -> Result { - use cpal::Sample as _; - - let config = self.config.expect("ConfigIsSet"); - let device = self.device.expect("DeviceIsSet").0; - let cpal_config1 = config.into_cpal_config(); - let cpal_config2 = (&cpal_config1).into(); - - macro_rules! build_output_streams { - ($($sample_format:tt, $generic:ty);+) => { - match config.sample_format { - $( - cpal::SampleFormat::$sample_format => device.build_output_stream::<$generic, _, _>( - &cpal_config2, - move |data, _| { - data.iter_mut().for_each(|d| { - *d = source - .next() - .map(cpal::Sample::from_sample) - .unwrap_or(<$generic>::EQUILIBRIUM) - }) - }, - self.error_callback, - None, - ), - )+ - _ => return Err(OsSinkError::UnsupportedSampleFormat), - } - }; - } - - let result = build_output_streams!( - F32, f32; - F64, f64; - I8, i8; - I16, i16; - I24, cpal::I24; - I32, i32; - I64, i64; - U8, u8; - U16, u16; - U24, cpal::U24; - U32, u32; - U64, u64 - ); - - result - .map_err(OsSinkError::BuildError) - .map(|stream| { - stream - .play() - .map_err(OsSinkError::PlayError) - .map(|()| stream) - })? - .map(SinkHandle) - } } // TODO cant introduce till we have introduced the other fixed source parts From c2fb02ae6e61de17cf2130b5aab4cb7a40e34fde Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 1 Feb 2026 23:11:16 +0100 Subject: [PATCH 04/20] fix: conditionally test channel volume --- tests/flac_test.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/flac_test.rs b/tests/flac_test.rs index e17602a6..e9b000fb 100644 --- a/tests/flac_test.rs +++ b/tests/flac_test.rs @@ -1,9 +1,8 @@ -#[cfg(any(feature = "claxon", feature = "symphonia-flac"))] +#![cfg(any(feature = "claxon", feature = "symphonia-flac"))] + use rodio::Source; -#[cfg(any(feature = "claxon", feature = "symphonia-flac"))] use std::time::Duration; -#[cfg(any(feature = "claxon", feature = "symphonia-flac"))] #[test] fn test_flac_encodings() { // 16 bit FLAC file exported from Audacity (2 channels, compression level 5) From 8d40185497a42e77ce22b5a040dee89ccc40b010 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 2 Feb 2026 22:28:15 +0100 Subject: [PATCH 05/20] docs: update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d434dc..61d8086e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All sources now implement `Iterator::size_hint()`. - `Chirp` now implements `try_seek`. - Added `DEFAULT_SAMPLE_RATE` set to match `cpal::SAMPLE_RATE_48K`. +- Added `Resample` source for high-quality sample rate conversion. ### Changed @@ -125,6 +126,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `cpal` to v0.17. - Clarified `Source::current_span_len()` contract documentation. - Improved queue, mixer and sample rate conversion performance. +- `SampleRateConverter` uses the new `Resample` source for better quality. ## Version [0.21.1] (2025-07-14) From a988d64d0a6f80e940e730ec5809085ed217dba0 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 9 Feb 2026 22:41:14 +0100 Subject: [PATCH 06/20] refactor: replace SampleRateConverter with Resample wrapper --- CHANGELOG.md | 12 ++ src/conversions/channels.rs | 50 +++++- src/conversions/mod.rs | 7 +- src/conversions/sample.rs | 8 +- src/conversions/sample_rate.rs | 278 ++++--------------------------- src/queue.rs | 77 +++++---- src/source/chain.rs | 181 ++++++++++++++++++++ src/source/from_factory.rs | 37 ----- src/source/from_fn.rs | 52 ++++++ src/source/from_iter.rs | 176 ++++++++------------ src/source/mod.rs | 7 +- src/source/uniform.rs | 295 +++++++++++++++++++++------------ 12 files changed, 639 insertions(+), 541 deletions(-) create mode 100644 src/source/chain.rs delete mode 100644 src/source/from_factory.rs create mode 100644 src/source/from_fn.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d8086e..df02a198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Source::dither()` function for applying dithering - Added `64bit` feature to opt-in to 64-bit sample precision (`f64`). - Added `SampleRateConverter::inner` to get underlying iterator by ref. +- Added `Resample` source for high-quality sample rate conversion. +- Added `FromIter` source that wraps a sample iterator. +- Added `ChannelCountConverter::inner()` for immutable access to the underlying iterator. +- `ChannelCountConverter` now implements `Source`. +- Added `FromIter::{inner, inner_mut, into_inner}` accessor methods. ### Fixed @@ -105,6 +110,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed `Empty` source to properly report exhaustion. - Fixed `Zero::current_span_len` returning remaining samples instead of span length. +### Deprecated +- `SampleRateConverter` is deprecated in favor of using `Resample` with `FromIter`. +- `FromFactoryIter` type is deprecated, renamed to `FromFn`. +- `from_factory()` function is deprecated, renamed to `from_fn()`. + ### Changed - Breaking: _Sink_ terms are replaced with _Player_ and _Stream_ terms replaced @@ -127,6 +137,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Clarified `Source::current_span_len()` contract documentation. - Improved queue, mixer and sample rate conversion performance. - `SampleRateConverter` uses the new `Resample` source for better quality. +- Renamed `FromIter` for sequencing multiple sources to `Chain`. +- Renamed `FromFactoryIter` for generating sources from a function to `FromFn`. ## Version [0.21.1] (2025-07-14) diff --git a/src/conversions/channels.rs b/src/conversions/channels.rs index c1401357..e01db818 100644 --- a/src/conversions/channels.rs +++ b/src/conversions/channels.rs @@ -1,5 +1,6 @@ use crate::common::ChannelCount; -use crate::Sample; +use crate::{Sample, Source}; +use dasp_sample::Sample as _; /// Iterator that converts from a certain channel count to another. #[derive(Clone, Debug)] @@ -19,11 +20,6 @@ where I: Iterator, { /// Initializes the iterator. - /// - /// # Panic - /// - /// Panics if `from` or `to` are equal to 0. - /// #[inline] pub fn new(input: I, from: ChannelCount, to: ChannelCount) -> ChannelCountConverter { ChannelCountConverter { @@ -41,7 +37,13 @@ where self.input } - /// Get mutable access to the iterator + /// Get immutable access to the underlying iterator. + #[inline] + pub fn inner(&self) -> &I { + &self.input + } + + /// Get mutable access to the underlying iterator. #[inline] pub fn inner_mut(&mut self) -> &mut I { &mut self.input @@ -64,7 +66,7 @@ where } x if x < self.from.get() => self.input.next(), 1 => self.sample_repeat, - _ => Some(0.0), + _ => Some(Sample::EQUILIBRIUM), }; if result.is_some() { @@ -104,6 +106,38 @@ where impl ExactSizeIterator for ChannelCountConverter where I: ExactSizeIterator {} +impl crate::Source for ChannelCountConverter +where + I: Source, +{ + #[inline] + fn current_span_len(&self) -> Option { + self.input + .current_span_len() + .map(|input_len| input_len / self.from.get() as usize * self.to.get() as usize) + } + + #[inline] + fn channels(&self) -> crate::common::ChannelCount { + self.to + } + + #[inline] + fn sample_rate(&self) -> crate::common::SampleRate { + self.input.sample_rate() + } + + #[inline] + fn total_duration(&self) -> Option { + self.input.total_duration() + } + + #[inline] + fn try_seek(&mut self, pos: std::time::Duration) -> Result<(), crate::source::SeekError> { + self.input.try_seek(pos) + } +} + #[cfg(test)] mod test { use super::ChannelCountConverter; diff --git a/src/conversions/mod.rs b/src/conversions/mod.rs index 0ec9d94f..3e972ecc 100644 --- a/src/conversions/mod.rs +++ b/src/conversions/mod.rs @@ -1,11 +1,8 @@ -/*! -This module contains functions that convert from one PCM format to another. - -This includes conversion between sample formats, channels or sample rates. -*/ +//! This module contains functions that convert from one PCM format to another. pub use self::channels::ChannelCountConverter; pub use self::sample::SampleTypeConverter; +#[allow(deprecated)] pub use self::sample_rate::SampleRateConverter; mod channels; diff --git a/src/conversions/sample.rs b/src/conversions/sample.rs index f231f33b..cddb1b34 100644 --- a/src/conversions/sample.rs +++ b/src/conversions/sample.rs @@ -24,7 +24,13 @@ impl SampleTypeConverter { self.input } - /// get mutable access to the iterator + /// Get immutable access to the underlying iterator. + #[inline] + pub fn inner(&self) -> &I { + &self.input + } + + /// Get mutable access to the underlying iterator. #[inline] pub fn inner_mut(&mut self) -> &mut I { &mut self.input diff --git a/src/conversions/sample_rate.rs b/src/conversions/sample_rate.rs index c289de8e..35fc991b 100644 --- a/src/conversions/sample_rate.rs +++ b/src/conversions/sample_rate.rs @@ -1,33 +1,29 @@ use crate::common::{ChannelCount, SampleRate}; -use crate::source::{resample::Poly, Resample, ResampleConfig, SeekError}; +use crate::source::{resample::Poly, FromIter, Resample, ResampleConfig}; use crate::{Sample, Source}; -use std::time::Duration; /// Iterator that converts from one sample rate to another. -/// -/// Uses `Resample` internally configured with linear polynomial interpolation. This is fast but -/// low quality. For better quality, consider using `Resample` directly with a higher-quality -/// configuration. +#[deprecated( + since = "0.22.0", + note = "Use `Resample` with `FromIter` (or `from_iter` function) directly" +)] #[derive(Debug)] +#[allow(deprecated)] pub struct SampleRateConverter where I: Iterator, { - inner: Resample>, + inner: Resample>, } +#[allow(deprecated)] impl SampleRateConverter where I: Iterator, { /// Create new sample rate converter. pub fn new(input: I, from: SampleRate, to: SampleRate, channels: ChannelCount) -> Self { - let adapter = SourceAdapter { - iter: input, - channels, - sample_rate: from, - }; - + let adapter = FromIter::new(input, channels, from); let config = ResampleConfig::poly().degree(Poly::Linear).build(); let inner = Resample::new(adapter, to, config); @@ -37,36 +33,39 @@ where /// Destroys this iterator and returns the underlying iterator. #[inline] pub fn into_inner(self) -> I { - self.inner.into_inner().iter + self.inner.into_inner().into_inner() } /// Get mutable access to the underlying iterator. #[inline] pub fn inner_mut(&mut self) -> &mut I { - &mut self.inner.inner_mut().iter + self.inner.inner_mut().inner_mut() } /// Get access to the underlying iterator. #[inline] pub fn inner(&self) -> &I { - &self.inner.inner().iter + self.inner.inner().inner() } } +#[allow(deprecated)] impl Clone for SampleRateConverter where I: Iterator + Clone, { fn clone(&self) -> Self { + let from_iter = self.inner.inner(); Self::new( - self.inner.inner().iter.clone(), - self.inner.inner().sample_rate(), + from_iter.inner().clone(), + from_iter.sample_rate(), self.inner.sample_rate(), - self.inner.inner().channels(), + from_iter.channels(), ) } } +#[allow(deprecated)] impl Iterator for SampleRateConverter where I: Iterator, @@ -84,244 +83,37 @@ where } } +#[allow(deprecated)] impl ExactSizeIterator for SampleRateConverter where I: Iterator + ExactSizeIterator { } -/// Simple adapter that provides Source trait for any iterator. -#[derive(Clone, Debug)] -struct SourceAdapter { - iter: I, - channels: ChannelCount, - sample_rate: SampleRate, -} - -impl Iterator for SourceAdapter -where - I: Iterator, -{ - type Item = Sample; - - #[inline] - fn next(&mut self) -> Option { - self.iter.next() - } - - #[inline] - fn size_hint(&self) -> (usize, Option) { - self.iter.size_hint() - } -} - -impl Source for SourceAdapter -where - I: Iterator, -{ - #[inline] - fn current_span_len(&self) -> Option { - self.iter.size_hint().1 - } - - #[inline] - fn channels(&self) -> ChannelCount { - self.channels - } - - #[inline] - fn sample_rate(&self) -> SampleRate { - self.sample_rate - } - - #[inline] - fn total_duration(&self) -> Option { - None - } - - #[inline] - fn try_seek(&mut self, _pos: Duration) -> Result<(), SeekError> { - Err(SeekError::NotSupported { - underlying_source: std::any::type_name::(), - }) - } -} - -impl ExactSizeIterator for SourceAdapter where I: ExactSizeIterator {} - #[cfg(test)] +#[allow(deprecated)] mod test { use super::SampleRateConverter; - use crate::common::{ChannelCount, SampleRate}; use crate::math::nz; - use crate::{Float, Sample}; - use dasp_sample::ToSample; - use quickcheck::{quickcheck, TestResult}; - use std::num::NonZero; - use std::time::Duration; - - /// Convert and truncate input to contain a frame-aligned number of samples. - fn convert_to_frames>( - input: Vec, - channels: ChannelCount, - ) -> Vec { - let mut input: Vec = input.iter().map(|x| x.to_sample()).collect(); - let frame_size = channels.get() as usize; - input.truncate(frame_size * (input.len() / frame_size)); - input - } - - quickcheck! { - /// Check that resampling an empty input produces no output. - fn empty(from: SampleRate, to: SampleRate, channels: ChannelCount) -> TestResult { - if from.get() > 384_000*2 || to.get() > 384_000*2 || channels.get() > 128 { - return TestResult::discard(); - } - - let input = vec![]; - let output = SampleRateConverter::new(input.clone().into_iter(), from, to, channels) - .collect::>(); - - TestResult::from_bool(input == output) - } - - /// Check that resampling to the same rate does not change the signal. - fn identity(from: SampleRate, channels: ChannelCount, input: Vec) -> TestResult { - if channels.get() > 128 { return TestResult::discard(); } - - let input = convert_to_frames(input, channels); - let output = SampleRateConverter::new(input.clone().into_iter(), from, from, channels) - .collect::>(); - - TestResult::from_bool(input == output) - } - - /// Check that dividing the sample rate by k (integer) is the same as dropping a sample - /// from each channel. - fn divide_sample_rate(to: SampleRate, k: NonZero, input: Vec, channels: ChannelCount) -> TestResult { - if channels.get() > 128 || to.get() > 48000 { - return TestResult::discard(); - } - - let from = SampleRate::new(to.get() * k.get() as u32).unwrap(); - - let input = convert_to_frames(input, channels); - let output = SampleRateConverter::new(input.clone().into_iter(), from, to, channels) - .collect::>(); - - let expected = input - .chunks_exact(channels.get() as usize) - .step_by(k.get() as usize) - .flatten() - .copied() - .collect::>(); - - TestResult::from_bool(output == expected) - } - - /// Check that, after multiplying the sample rate by k, every k-th sample in the output - /// matches exactly with the input. - fn multiply_sample_rate(from: SampleRate, k: NonZero, input: Vec, channels: ChannelCount) -> TestResult { - if from.get() > u16::MAX as u32 || channels.get() > 128 { - return TestResult::discard(); - } - - let to = SampleRate::new(from.get() * k.get() as u32).unwrap(); - - let input = convert_to_frames(input, channels); - let output = SampleRateConverter::new(input.clone().into_iter(), from, to, channels) - .collect::>(); - - let recovered = output - .chunks_exact(channels.get() as usize) - .step_by(k.get() as usize) - .flatten() - .copied() - .collect::>(); - - TestResult::from_bool(input == recovered) - } - - /// Check that resampling does not change the audio duration, except by a negligible - /// amount (± 1ms). Reproduces #316. - fn preserve_durations(d: Duration, freq: f32, to: SampleRate) -> TestResult { - use crate::source::{SineWave, Source}; - if !freq.is_normal() || freq <= 0.0 || d > Duration::from_secs(1) { - return TestResult::discard(); - } - - let source = SineWave::new(freq).take_duration(d); - let from = source.sample_rate(); - - let resampled = - SampleRateConverter::new(source, from, to, nz!(1)); - let duration = - Duration::from_secs_f32(resampled.count() as f32 / to.get() as f32); - - let delta = duration.abs_diff(d); - TestResult::from_bool(delta < Duration::from_millis(1)) - } - } - - fn test_sample_rate_conversion( - input: Vec, - from: SampleRate, - to: SampleRate, - channels: ChannelCount, - ) { - let input_len = input.len(); - let converter = SampleRateConverter::new(input.into_iter(), from, to, channels); - let converter_len = converter.len(); - let output_len = converter.count(); - - assert_eq!( - converter_len, output_len, - "size_hint should match actual output" - ); - assert_eq!( - output_len, - (input_len as Float * to.get() as Float / from.get() as Float).ceil() as usize, - "duration must be preserved" - ); - } + use crate::Sample; + /// Minimal smoke test to ensure the deprecated SampleRateConverter wrapper still works. + /// Core resampling tests have been moved to src/source/resample.rs. #[test] - fn upsample_fractional_ratio() { - let from = nz!(2000); - let to = nz!(3000); - assert!(to.get() % from.get() != 0, "should be fractional ratio"); - - // 4 input frames (8 samples) at 1.5x = 6 output frames (12 samples) - // Preserves duration: 4/2000 = 6/3000 = 0.002 seconds - test_sample_rate_conversion( - vec![2.0, 16.0, 4.0, 18.0, 6.0, 20.0, 8.0, 22.0], - from, - to, - nz!(2), - ); - } - - #[test] - fn upsample_integer_ratio() { + fn deprecated_wrapper_works() { + // Test basic upsampling + let input: Vec = vec![0.0, 0.5, 1.0, 0.5, 0.0]; let from = nz!(1000); - let to = nz!(7000); - assert!(to.get() % from.get() == 0, "should be integer ratio"); + let to = nz!(2000); + let channels = nz!(1); - test_sample_rate_conversion(vec![1.0, 14.0], from, to, nz!(1)); - } - - #[test] - fn downsample() { - let from = nz!(12000); - let to = nz!(2400); - assert!(from.get() > to.get(), "should be downsampling"); + let converter = SampleRateConverter::new(input.into_iter(), from, to, channels); + let output: Vec<_> = converter.collect(); - // Note: Rubato's polynomial downsampler has inherent phase offset - // (samples at positions [4, 9, 14] instead of [0, 5, 10, 15]) - test_sample_rate_conversion( - Vec::from_iter((0..17).map(|x| x as Sample)), - from, - to, - nz!(1), + // Should produce approximately 2x samples (upsampling) + assert!( + output.len() >= 8 && output.len() <= 12, + "Expected approximately 10 samples, got {}", + output.len() ); } } diff --git a/src/queue.rs b/src/queue.rs index 542a05c8..b85bcef7 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -37,6 +37,7 @@ 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, }; @@ -121,47 +122,38 @@ pub struct SourcesQueueOutput { // The next sounds. input: Arc, - // This counts how many silence samples to inject for keep-alive behavior. + // 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. silence_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 +} + impl Source for SourcesQueueOutput { #[inline] fn current_span_len(&self) -> Option { - 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.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()) + 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() { - if let Some((next, _)) = self.input.next_sounds.lock().unwrap().front() { - return next - .current_span_len() - .or_else(|| Some(next.channels().get() as usize)); - } + // Return what that Zero's current_span_len() will be: Some(threshold(channels)). + return Some(threshold(self.current.channels())); } - // 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) + None } #[inline] @@ -217,10 +209,10 @@ impl Iterator for SourcesQueueOutput { #[inline] fn next(&mut self) -> Option { loop { - // If we're playing silence for keep-alive, return silence. + // 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); + return Some(0.0); } // Basic situation that will happen most of the time. @@ -228,8 +220,21 @@ impl Iterator for SourcesQueueOutput { return Some(sample); } - // 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. + // 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.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 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() { if self.input.keep_alive_if_empty() { self.silence_samples_remaining = self.current.channels().get() as usize; diff --git a/src/source/chain.rs b/src/source/chain.rs new file mode 100644 index 00000000..e0bd85d9 --- /dev/null +++ b/src/source/chain.rs @@ -0,0 +1,181 @@ +use std::time::Duration; + +use super::SeekError; +use crate::common::{ChannelCount, SampleRate}; +use crate::math::nz; +use crate::Source; + +/// Builds a source that chains sources provided by an iterator. +/// +/// The `iterator` parameter is an iterator that produces a source. The source is then played. +/// Whenever the source ends, the `iterator` is used again in order to produce the source that is +/// played next. +/// +/// If the `iterator` produces `None`, then the sound ends. +pub fn chain(iterator: I) -> Chain +where + I: IntoIterator, +{ + let mut iterator = iterator.into_iter(); + let first_source = iterator.next(); + + Chain { + iterator, + current_source: first_source, + } +} + +/// A source that chains sources provided by an iterator. +#[derive(Clone)] +pub struct Chain +where + I: Iterator, +{ + // The iterator that provides sources. + iterator: I, + // Is only ever `None` if the first element of the iterator is `None`. + current_source: Option, +} + +impl Iterator for Chain +where + I: Iterator, + I::Item: Iterator + Source, +{ + type Item = ::Item; + + #[inline] + fn next(&mut self) -> Option { + loop { + if let Some(src) = &mut self.current_source { + if let Some(value) = src.next() { + return Some(value); + } + } + + if let Some(src) = self.iterator.next() { + self.current_source = Some(src); + } else { + return None; + } + } + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + if let Some(cur) = &self.current_source { + (cur.size_hint().0, None) + } else { + (0, Some(0)) + } + } +} + +impl Source for Chain +where + I: Iterator, + I::Item: Iterator + Source, +{ + #[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) + } + + #[inline] + fn channels(&self) -> ChannelCount { + if let Some(src) = &self.current_source { + src.channels() + } else { + // Dummy value that only happens if the iterator was empty. + nz!(2) + } + } + + #[inline] + fn sample_rate(&self) -> SampleRate { + if let Some(src) = &self.current_source { + src.sample_rate() + } else { + // Dummy value that only happens if the iterator was empty. + nz!(44100) + } + } + + #[inline] + fn total_duration(&self) -> Option { + None + } + + #[inline] + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + if let Some(source) = self.current_source.as_mut() { + source.try_seek(pos) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use crate::buffer::SamplesBuffer; + use crate::math::nz; + use crate::source::{chain, Source}; + + #[test] + fn basic() { + let mut rx = chain((0..2).map(|n| { + if n == 0 { + SamplesBuffer::new(nz!(1), nz!(48000), vec![10.0, -10.0, 10.0, -10.0]) + } else if n == 1 { + SamplesBuffer::new(nz!(2), nz!(96000), vec![5.0, 5.0, 5.0, 5.0]) + } else { + unreachable!() + } + })); + + assert_eq!(rx.channels(), nz!(1)); + assert_eq!(rx.sample_rate().get(), 48000); + assert_eq!(rx.next(), Some(10.0)); + assert_eq!(rx.next(), Some(-10.0)); + assert_eq!(rx.next(), Some(10.0)); + assert_eq!(rx.next(), Some(-10.0)); + /*assert_eq!(rx.channels(), 2); + assert_eq!(rx.sample_rate().get(), 96000);*/ + // FIXME: not working + assert_eq!(rx.next(), Some(5.0)); + assert_eq!(rx.next(), Some(5.0)); + assert_eq!(rx.next(), Some(5.0)); + assert_eq!(rx.next(), Some(5.0)); + assert_eq!(rx.next(), None); + } +} diff --git a/src/source/from_factory.rs b/src/source/from_factory.rs deleted file mode 100644 index e1219c6c..00000000 --- a/src/source/from_factory.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::source::{from_iter, FromIter}; - -/// Builds a source that chains sources built from a factory. -/// -/// The `factory` parameter is a function that produces a source. The source is then played. -/// Whenever the source ends, `factory` is called again in order to produce the source that is -/// played next. -/// -/// If the `factory` closure returns `None`, then the sound ends. -pub fn from_factory(factory: F) -> FromIter> -where - F: FnMut() -> Option, -{ - from_iter(FromFactoryIter { factory }) -} - -/// Internal type used by `from_factory`. -pub struct FromFactoryIter { - factory: F, -} - -impl Iterator for FromFactoryIter -where - F: FnMut() -> Option, -{ - type Item = S; - - #[inline] - fn next(&mut self) -> Option { - (self.factory)() - } - - #[inline] - fn size_hint(&self) -> (usize, Option) { - (0, None) - } -} diff --git a/src/source/from_fn.rs b/src/source/from_fn.rs new file mode 100644 index 00000000..54f85959 --- /dev/null +++ b/src/source/from_fn.rs @@ -0,0 +1,52 @@ +use crate::source::{chain, Chain}; + +/// Builds a source that chains sources built from a factory function. +/// +/// The `factory` parameter is a function that produces a source. The source is then played. +/// Whenever the source ends, `factory` is called again in order to produce the source that is +/// played next. +/// +/// If the `factory` closure returns `None`, then the sound ends. +pub fn from_fn(factory: F) -> Chain> +where + F: FnMut() -> Option, +{ + chain(FromFn { factory }) +} + +/// Deprecated: Use `from_fn()` instead. +#[deprecated(since = "0.22.0", note = "Use `from_fn()` instead")] +pub fn from_factory(factory: F) -> Chain> +where + F: FnMut() -> Option, +{ + from_fn(factory) +} + +/// Iterator that generates sources from a factory function. +/// +/// Created by the `from_fn()` function. +pub struct FromFn { + factory: F, +} + +/// Deprecated: Use `FromFn` instead. +#[deprecated(since = "0.22.0", note = "Use `FromFn` instead")] +pub type FromFactoryIter = FromFn; + +impl Iterator for FromFn +where + F: FnMut() -> Option, +{ + type Item = S; + + #[inline] + fn next(&mut self) -> Option { + (self.factory)() + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (0, None) + } +} diff --git a/src/source/from_iter.rs b/src/source/from_iter.rs index 2b4544ec..0ade85bc 100644 --- a/src/source/from_iter.rs +++ b/src/source/from_iter.rs @@ -2,109 +2,108 @@ use std::time::Duration; use super::SeekError; use crate::common::{ChannelCount, SampleRate}; -use crate::math::nz; -use crate::Source; +use crate::{Sample, Source}; -/// Builds a source that chains sources provided by an iterator. +/// Creates a `Source` from a sample iterator with specified audio parameters. /// -/// The `iterator` parameter is an iterator that produces a source. The source is then played. -/// Whenever the source ends, the `iterator` is used again in order to produce the source that is -/// played next. +/// This adapter wraps any iterator that produces `Sample` values and provides +/// the `Source` trait implementation by storing the channel count and sample rate. /// -/// If the `iterator` produces `None`, then the sound ends. -pub fn from_iter(iterator: I) -> FromIter +/// # Example +/// +/// ``` +/// use rodio::source::from_iter; +/// use rodio::math::nz; +/// +/// let samples = vec![0.1, 0.2, 0.3, 0.4]; +/// let source = from_iter(samples.into_iter(), nz!(2), nz!(44100)); +/// ``` +#[inline] +pub fn from_iter(iter: I, channels: ChannelCount, sample_rate: SampleRate) -> FromIter where - I: IntoIterator, + I: Iterator, { - let mut iterator = iterator.into_iter(); - let first_source = iterator.next(); - FromIter { - iterator, - current_source: first_source, + iter, + channels, + sample_rate, } } -/// A source that chains sources provided by an iterator. -#[derive(Clone)] -pub struct FromIter -where - I: Iterator, -{ - // The iterator that provides sources. - iterator: I, - // Is only ever `None` if the first element of the iterator is `None`. - current_source: Option, +/// A `Source` that wraps a sample iterator with audio metadata. +/// +/// Created by the `from_iter()` function. +#[derive(Clone, Debug)] +pub struct FromIter { + iter: I, + channels: ChannelCount, + sample_rate: SampleRate, +} + +impl FromIter { + /// Creates a new `FromIter` from an iterator and audio parameters. + #[inline] + pub fn new(iter: I, channels: ChannelCount, sample_rate: SampleRate) -> Self { + Self { + iter, + channels, + sample_rate, + } + } + + /// Destroys this source and returns the underlying iterator. + #[inline] + pub fn into_inner(self) -> I { + self.iter + } + + /// Get immutable access to the underlying iterator. + #[inline] + pub fn inner(&self) -> &I { + &self.iter + } + + /// Get mutable access to the underlying iterator. + #[inline] + pub fn inner_mut(&mut self) -> &mut I { + &mut self.iter + } } impl Iterator for FromIter where - I: Iterator, - I::Item: Iterator + Source, + I: Iterator, { - type Item = ::Item; + type Item = Sample; #[inline] fn next(&mut self) -> Option { - loop { - if let Some(src) = &mut self.current_source { - if let Some(value) = src.next() { - return Some(value); - } - } - - if let Some(src) = self.iterator.next() { - self.current_source = Some(src); - } else { - return None; - } - } + self.iter.next() } #[inline] fn size_hint(&self) -> (usize, Option) { - if let Some(cur) = &self.current_source { - (cur.size_hint().0, None) - } else { - (0, Some(0)) - } + self.iter.size_hint() } } impl Source for FromIter where - I: Iterator, - I::Item: Iterator + Source, + I: Iterator, { #[inline] fn current_span_len(&self) -> Option { - if let Some(src) = &self.current_source { - if !src.is_exhausted() { - return src.current_span_len(); - } - } - - None + self.iter.size_hint().1 } #[inline] fn channels(&self) -> ChannelCount { - if let Some(src) = &self.current_source { - src.channels() - } else { - // Dummy value that only happens if the iterator was empty. - nz!(2) - } + self.channels } #[inline] fn sample_rate(&self) -> SampleRate { - if let Some(src) = &self.current_source { - src.sample_rate() - } else { - // Dummy value that only happens if the iterator was empty. - crate::DEFAULT_SAMPLE_RATE - } + self.sample_rate } #[inline] @@ -113,46 +112,11 @@ where } #[inline] - fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { - if let Some(source) = self.current_source.as_mut() { - source.try_seek(pos) - } else { - Ok(()) - } + fn try_seek(&mut self, _pos: Duration) -> Result<(), SeekError> { + Err(SeekError::NotSupported { + underlying_source: std::any::type_name::(), + }) } } -#[cfg(test)] -mod tests { - use crate::buffer::SamplesBuffer; - use crate::math::nz; - use crate::source::{from_iter, Source}; - - #[test] - fn basic() { - let mut rx = from_iter((0..2).map(|n| { - if n == 0 { - SamplesBuffer::new(nz!(1), nz!(48000), vec![10.0, -10.0, 10.0, -10.0]) - } else if n == 1 { - SamplesBuffer::new(nz!(2), nz!(96000), vec![5.0, 5.0, 5.0, 5.0]) - } else { - unreachable!() - } - })); - - assert_eq!(rx.channels(), nz!(1)); - assert_eq!(rx.sample_rate().get(), 48000); - assert_eq!(rx.next(), Some(10.0)); - assert_eq!(rx.next(), Some(-10.0)); - assert_eq!(rx.next(), Some(10.0)); - assert_eq!(rx.next(), Some(-10.0)); - /*assert_eq!(rx.channels(), 2); - assert_eq!(rx.sample_rate().get(), 96000);*/ - // FIXME: not working - assert_eq!(rx.next(), Some(5.0)); - assert_eq!(rx.next(), Some(5.0)); - assert_eq!(rx.next(), Some(5.0)); - assert_eq!(rx.next(), Some(5.0)); - assert_eq!(rx.next(), None); - } -} +impl ExactSizeIterator for FromIter where I: ExactSizeIterator {} diff --git a/src/source/mod.rs b/src/source/mod.rs index 95f484f2..aee7c3d6 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -18,6 +18,7 @@ pub use self::agc::{AutomaticGainControl, AutomaticGainControlSettings}; pub use self::amplify::Amplify; pub use self::blt::BltFilter; pub use self::buffered::Buffered; +pub use self::chain::{chain, Chain}; pub use self::channel_volume::ChannelVolume; pub use self::chirp::{chirp, Chirp}; pub use self::crossfade::Crossfade; @@ -28,7 +29,8 @@ pub use self::empty::Empty; pub use self::empty_callback::EmptyCallback; pub use self::fadein::FadeIn; pub use self::fadeout::FadeOut; -pub use self::from_factory::{from_factory, FromFactoryIter}; +#[allow(deprecated)] +pub use self::from_fn::{from_factory, from_fn, FromFactoryIter, FromFn}; pub use self::from_iter::{from_iter, FromIter}; pub use self::limit::{Limit, LimitSettings}; pub use self::linear_ramp::LinearGainRamp; @@ -56,6 +58,7 @@ mod agc; mod amplify; mod blt; mod buffered; +mod chain; mod channel_volume; mod chirp; mod crossfade; @@ -66,7 +69,7 @@ mod empty; mod empty_callback; mod fadein; mod fadeout; -mod from_factory; +mod from_fn; mod from_iter; mod limit; mod linear_ramp; diff --git a/src/source/uniform.rs b/src/source/uniform.rs index cacf5e32..3efccbd9 100644 --- a/src/source/uniform.rs +++ b/src/source/uniform.rs @@ -1,11 +1,108 @@ -use std::cmp; use std::time::Duration; +use super::resample::{Poly, Resample, ResampleConfig}; use super::SeekError; use crate::common::{ChannelCount, SampleRate}; -use crate::conversions::{ChannelCountConverter, SampleRateConverter}; +use crate::conversions::ChannelCountConverter; use crate::Source; +#[derive(Clone)] +enum UniformInner { + Passthrough(I), + SampleRate(Resample), + ChannelCount(ChannelCountConverter), + BothUpmix(ChannelCountConverter>), + BothDownmix(Resample>), +} + +impl Iterator for UniformInner { + type Item = I::Item; + + #[inline] + fn next(&mut self) -> Option { + match self { + UniformInner::Passthrough(take) => take.next(), + UniformInner::SampleRate(converter) => converter.next(), + UniformInner::ChannelCount(converter) => converter.next(), + UniformInner::BothUpmix(converter) => converter.next(), + UniformInner::BothDownmix(converter) => converter.next(), + } + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + match self { + UniformInner::Passthrough(take) => take.size_hint(), + UniformInner::SampleRate(converter) => converter.size_hint(), + UniformInner::ChannelCount(converter) => converter.size_hint(), + UniformInner::BothUpmix(converter) => converter.size_hint(), + UniformInner::BothDownmix(converter) => converter.size_hint(), + } + } +} + +impl UniformInner { + #[inline] + fn into_inner(self) -> I { + match self { + UniformInner::Passthrough(source) => source, + UniformInner::SampleRate(converter) => converter.into_inner(), + UniformInner::ChannelCount(converter) => converter.into_inner(), + UniformInner::BothUpmix(converter) => converter.into_inner().into_inner(), + UniformInner::BothDownmix(converter) => converter.into_inner().into_inner(), + } + } + + #[inline] + fn inner(&self) -> &I { + match self { + UniformInner::Passthrough(source) => source, + UniformInner::SampleRate(converter) => converter.inner(), + UniformInner::ChannelCount(converter) => converter.inner(), + UniformInner::BothUpmix(converter) => converter.inner().inner(), + UniformInner::BothDownmix(converter) => converter.inner().inner(), + } + } + + #[inline] + fn inner_mut(&mut self) -> &mut I { + match self { + UniformInner::Passthrough(source) => source, + UniformInner::SampleRate(converter) => converter.inner_mut(), + UniformInner::ChannelCount(converter) => converter.inner_mut(), + UniformInner::BothUpmix(converter) => converter.inner_mut().inner_mut(), + UniformInner::BothDownmix(converter) => converter.inner_mut().inner_mut(), + } + } +} + +impl Source for UniformInner { + #[inline] + fn current_span_len(&self) -> Option { + self.inner().current_span_len() + } + + #[inline] + fn channels(&self) -> ChannelCount { + self.inner().channels() + } + + #[inline] + fn sample_rate(&self) -> SampleRate { + self.inner().sample_rate() + } + + #[inline] + fn total_duration(&self) -> Option { + self.inner().total_duration() + } + + #[inline] + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + self.inner_mut().try_seek(pos) + } +} + /// An iterator that reads from a `Source` and converts the samples to a /// specific type, sample-rate and channels count. /// @@ -16,11 +113,13 @@ pub struct UniformSourceIterator where I: Source, { - inner: Option>>>, - pending: Option, + inner: Option>, target_channels: ChannelCount, target_sample_rate: SampleRate, - total_duration: Option, + current_channels: ChannelCount, + current_sample_rate: SampleRate, + current_span_pos: usize, + cached_span_len: Option, } impl UniformSourceIterator @@ -29,42 +128,67 @@ where { /// Wrap a `Source` and lazily convert its samples to a specific type, /// sample-rate and channels count. - #[inline] pub fn new( input: I, target_channels: ChannelCount, target_sample_rate: SampleRate, ) -> UniformSourceIterator { - let total_duration = input.total_duration(); + let current_channels = input.channels(); + let current_sample_rate = input.sample_rate(); + let inner = UniformSourceIterator::bootstrap(input, target_channels, target_sample_rate); + let cached_span_len = inner.current_span_len(); - UniformSourceIterator { - inner: None, - pending: Some(input), + Self { + inner: Some(inner), target_channels, target_sample_rate, - total_duration, + current_channels, + current_sample_rate, + current_span_pos: 0, + cached_span_len, } } - #[inline] fn bootstrap( input: I, target_channels: ChannelCount, target_sample_rate: SampleRate, - ) -> ChannelCountConverter>> { - // Limit the span length to something reasonable - let span_len = input.current_span_len().map(|x| x.min(32768)); - + ) -> UniformInner { let from_channels = input.channels(); let from_sample_rate = input.sample_rate(); - let input = Take { - iter: input, - n: span_len, - }; - let input = - SampleRateConverter::new(input, from_sample_rate, target_sample_rate, from_channels); - ChannelCountConverter::new(input, from_channels, target_channels) + let needs_rate_conversion = from_sample_rate != target_sample_rate; + let needs_channel_conversion = from_channels != target_channels; + + match (needs_rate_conversion, needs_channel_conversion) { + (false, false) => UniformInner::Passthrough(input), + (true, false) => { + let config = ResampleConfig::poly().degree(Poly::Linear).build(); + let rate_converted = Resample::new(input, target_sample_rate, config); + UniformInner::SampleRate(rate_converted) + } + (false, true) => { + let channel_converted = + ChannelCountConverter::new(input, from_channels, target_channels); + UniformInner::ChannelCount(channel_converted) + } + (true, true) => { + let config = ResampleConfig::poly().degree(Poly::Linear).build(); + + if target_channels > from_channels { + let rate_converted = Resample::new(input, target_sample_rate, config); + let channel_converted = + ChannelCountConverter::new(rate_converted, from_channels, target_channels); + UniformInner::BothUpmix(channel_converted) + } else { + let channel_converted = + ChannelCountConverter::new(input, from_channels, target_channels); + let rate_converted = + Resample::new(channel_converted, target_sample_rate, config); + UniformInner::BothDownmix(rate_converted) + } + } + } } } @@ -76,35 +200,56 @@ where #[inline] fn next(&mut self) -> Option { - if let Some(value) = self.inner.as_mut().and_then(|i| i.next()) { - return Some(value); - } + if let Some(span_len) = self.cached_span_len { + if self.current_span_pos >= span_len { + // At span boundary - check if parameters changed + let source = self.inner.as_mut().unwrap().inner_mut(); + let new_channels = source.channels(); + let new_sample_rate = source.sample_rate(); + + let parameters_changed = new_channels != self.current_channels + || new_sample_rate != self.current_sample_rate; + + if parameters_changed { + let source = self.inner.take().unwrap().into_inner(); + self.current_channels = new_channels; + self.current_sample_rate = new_sample_rate; + let new_inner = UniformSourceIterator::bootstrap( + source, + self.target_channels, + self.target_sample_rate, + ); + self.inner = Some(new_inner); + } - let input = match self.inner.take() { - Some(inner) => inner.into_inner().into_inner().iter, - None => self - .pending - .take() - .expect("pending is Some when inner is None"), - }; + // Calculate new output span length based on the conversion type + let new_span_len = match self.inner.as_ref().unwrap() { + UniformInner::Passthrough(source) => source.current_span_len(), + UniformInner::SampleRate(resample) => resample.current_span_len(), + UniformInner::ChannelCount(converter) => converter.current_span_len(), + UniformInner::BothUpmix(converter) => converter.current_span_len(), + UniformInner::BothDownmix(converter) => converter.current_span_len(), + }; + + self.current_span_pos = 0; + self.cached_span_len = new_span_len; + } + } - let mut input = - UniformSourceIterator::bootstrap(input, self.target_channels, self.target_sample_rate); + if let Some(sample) = self.inner.as_mut().unwrap().next() { + // Only increment counter when tracking spans + if self.cached_span_len.is_some() { + self.current_span_pos += 1; + } + return Some(sample); + } - let value = input.next(); - self.inner = Some(input); - value + None } #[inline] fn size_hint(&self) -> (usize, Option) { - let lower = self - .inner - .as_ref() - .map(|i| i.size_hint().0) - .or_else(|| self.pending.as_ref().map(|p| p.size_hint().0)) - .unwrap_or(0); - (lower, None) + (self.inner.as_ref().unwrap().size_hint().0, None) } } @@ -129,71 +274,15 @@ where #[inline] fn total_duration(&self) -> Option { - self.total_duration + self.inner.as_ref().unwrap().inner().total_duration() } #[inline] fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { if let Some(input) = self.inner.as_mut() { - input.inner_mut().inner_mut().inner_mut().try_seek(pos) - } else if let Some(pending) = self.pending.as_mut() { - pending.try_seek(pos) + input.inner_mut().try_seek(pos) } else { Ok(()) } } } - -#[derive(Clone, Debug)] -struct Take { - iter: I, - n: Option, -} - -impl Take { - #[inline] - pub fn inner_mut(&mut self) -> &mut I { - &mut self.iter - } -} - -impl Iterator for Take -where - I: Iterator, -{ - type Item = ::Item; - - #[inline] - fn next(&mut self) -> Option<::Item> { - if let Some(n) = &mut self.n { - if *n != 0 { - *n -= 1; - self.iter.next() - } else { - None - } - } else { - self.iter.next() - } - } - - #[inline] - fn size_hint(&self) -> (usize, Option) { - if let Some(n) = self.n { - let (lower, upper) = self.iter.size_hint(); - - let lower = cmp::min(lower, n); - - let upper = match upper { - Some(x) if x < n => Some(x), - _ => Some(n), - }; - - (lower, upper) - } else { - self.iter.size_hint() - } - } -} - -impl ExactSizeIterator for Take where I: ExactSizeIterator {} From d7e3ead576cdeec3120086cf173ddf4455336a44 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 9 Feb 2026 23:18:19 +0100 Subject: [PATCH 07/20] fix: rebase error --- src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e7ecca0d..dbd6174c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -234,8 +234,6 @@ pub mod math; pub mod microphone; pub mod mixer; pub mod queue; -// #[cfg(feature = "experimental")] -pub mod fixed_source; pub mod source; pub mod static_buffer; From 3f3750dfb64f924ec12a3e9bad40180aa17207c6 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 9 Feb 2026 23:43:02 +0100 Subject: [PATCH 08/20] fix: clippy lint on 64bit --- src/source/resample.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/source/resample.rs b/src/source/resample.rs index dac54aec..a7c7674e 100644 --- a/src/source/resample.rs +++ b/src/source/resample.rs @@ -1225,7 +1225,7 @@ impl RubatoAsyncResample { let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; let resampler = rubato::Async::new_poly( - resample_ratio.into(), + resample_ratio as _, 1.0, degree.into(), chunk_size, @@ -1296,7 +1296,7 @@ impl RubatoAsyncResample { let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; let resampler = rubato::Async::new_sinc( - resample_ratio.into(), + resample_ratio as _, 1.0, ¶meters, chunk_size, From c0b030ad8c1524955830b88f1a4c90e05c66ccd9 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 12 Feb 2026 21:20:53 +0100 Subject: [PATCH 09/20] refactor: split resample into multiple modules --- src/source/resample.rs | 1643 -------------------------------- src/source/resample/builder.rs | 452 +++++++++ src/source/resample/mod.rs | 759 +++++++++++++++ src/source/resample/rubato.rs | 455 +++++++++ 4 files changed, 1666 insertions(+), 1643 deletions(-) delete mode 100644 src/source/resample.rs create mode 100644 src/source/resample/builder.rs create mode 100644 src/source/resample/mod.rs create mode 100644 src/source/resample/rubato.rs diff --git a/src/source/resample.rs b/src/source/resample.rs deleted file mode 100644 index a7c7674e..00000000 --- a/src/source/resample.rs +++ /dev/null @@ -1,1643 +0,0 @@ -//! Audio resampling from one sample rate to another. -//! -//! # Quick Start -//! -//! Use the [`Source::resample`] method with a quality preset: -//! -//! ```rust -//! use rodio::SampleRate; -//! use rodio::source::{SineWave, Source, ResampleConfig}; -//! -//! let source = SineWave::new(440.0); -//! let config = ResampleConfig::balanced(); -//! let resampled = source.resample(SampleRate::new(96000).unwrap(), config); -//! ``` -//! -//! For advanced control, use the [`ResampleConfig`] builder: -//! -//! ```rust -//! use rodio::math::nz; -//! use rodio::source::{SineWave, Source, Resample, ResampleConfig}; -//! use rodio::source::resample::{Sinc, WindowFunction}; -//! -//! let source = SineWave::new(440.0); -//! let config = ResampleConfig::sinc() // Sinc resampling -//! .sinc_len(nz!(256)) // 256-tap filter -//! .interpolation(Sinc::Cubic) // Cubic interpolation -//! .window(WindowFunction::BlackmanHarris2) // Squared Blackman-Harris window -//! .chunk_size(nz!(512)) // Low latency (5.3 ms @ 1-channel 96 kHz) -//! .build(); -//! let resampled = Resample::new(source, nz!(96000), config); -//! ``` -//! -//! # Understanding Resampling -//! -//! ## Polynomial vs. Sinc Interpolation -//! -//! When converting between sample rates, sample values at positions that don't exist in the -//! original signal need to be calculated. There are two main approaches: -//! -//! **Polynomial interpolation** is fast but does not include anti-aliasing. This can cause -//! artifacts in the output audio. Higher degrees provide smoother interpolation but cannot -//! prevent these artifacts. -//! -//! **Sinc interpolation** uses a windowed sinc function for mathematically correct reconstruction. -//! It is of higher quality and includes anti-aliasing to reduce artifacts, but is more -//! computationally expensive. -//! -//! ## Fixed vs Arbitrary Ratios -//! -//! A **fixed ratio** is when the sample rate conversion can be expressed as a simple fraction, -//! like 1:2 (e.g., 48 kHz and 96 kHz) or 147:160 (e.g., 44.1 kHz and 48 kHz). -//! -//! When the resampler is configured for sinc interpolation, it automatically detects these ratios -//! and optimizes resampling by switching to: -//! 1. optimized FFT-based processing when the `rubato-fft` feature is enabled -//! 2. sinc interpolation with nearest-neighbor lookup when FFT is not available -//! -//! This reduces CPU usage while providing highest quality. -//! -//! **Arbitrary ratios** (non-reducible or large fractions) use the async sinc resampler, which -//! can handle any conversion. This is CPU intensive and should be compiled with release profile to -//! prevent choppy audio. -//! -//! # Quality Presets -//! -//! As per [`CamillaDSP`](https://henquist.github.io/3.0.x/): -//! -//! | Parameter | [`VeryFast`](ResampleConfig::very_fast) | [`Fast`](ResampleConfig::fast) | [`Balanced`](ResampleConfig::balanced) | [`Accurate`](ResampleConfig::accurate) | -//! | sinc_len | 64 | 128 | 192 | 256 | -//! | oversampling_factor | 1024 | 1024 | 512 | 256 | -//! | interpolation | Linear | Linear | Quadratic | Cubic | -//! | window | Hann2 | Blackman2 | BlackmanHarris2 | BlackmanHarris2 | -//! | f_cutoff (#) | 0.91 | 0.92 | 0.93 | 0.95 | -//! (#) These cutoff values are approximate. The actual values used are calculated automatically at runtime for the combination of sinc length and window. - -#![cfg_attr(docsrs, feature(doc_cfg))] - -use std::{num::NonZero, time::Duration}; - -use dasp_sample::Sample as _; -use num_rational::Ratio; -use rubato::{Indexing, Resampler as RubatoResampler}; - -use super::{reset_seek_span_tracking, SeekError}; -use crate::{ - common::{ChannelCount, Sample, SampleRate}, - Float, Source, -}; - -const DEFAULT_CHUNK_SIZE: usize = 1024; -#[cfg(feature = "rubato-fft")] -const DEFAULT_SUB_CHUNKS: usize = 1; - -/// Maximum for optimized fixed-ratio resampling: 44.1 and 384 kHz (147:1280). -const MAX_FIXED_RATIO: u32 = 1280; - -/// Polynomial interpolation degree, no anti-aliasing. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum Poly { - /// Zero-order hold - nearest neighbor sampling. - /// - /// Simply picks the nearest input sample without interpolation. - /// Creates a "stepped" waveform. - Nearest, - - /// Linear interpolation between 2 samples. - #[default] - Linear, - - /// Cubic interpolation using 4 samples. - Cubic, - - /// Quintic interpolation using 6 samples. - Quintic, - - /// Septic interpolation using 8 samples. - Septic, -} - -/// Sinc interpolation type. -/// -/// Controls how intermediate values are calculated between precomputed sinc points -/// in the windowed sinc filter. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Sinc { - /// No interpolation - picks nearest intermediate point. - /// - /// Optimal when upsampling by exact ratios (e.g., 48kHz and 96kHz) and the oversampling factor - /// is equal to the ratio. In these cases, no unnecessary computations are performed and the - /// result is equivalent to that of synchronous resampling. - Nearest, - - /// Linear interpolation between two nearest points. - /// - /// Relatively fast, but needs a large number of intermediate points to push the resampling - /// artefacts below the noise floor. - #[default] - Linear, - - /// Quadratic interpolation using three nearest points. - /// - /// The computation time lies approximately halfway between that of linear and quadratic - /// interpolation. - Quadratic, - - /// Cubic interpolation using four nearest points. - /// - /// The computation time is approximately twice as long as that of linear interpolation, but it - /// requires much fewer intermediate points for a good result. - Cubic, -} - -impl From for rubato::SincInterpolationType { - fn from(sinc: Sinc) -> Self { - match sinc { - Sinc::Nearest => rubato::SincInterpolationType::Nearest, - Sinc::Linear => rubato::SincInterpolationType::Linear, - Sinc::Quadratic => rubato::SincInterpolationType::Quadratic, - Sinc::Cubic => rubato::SincInterpolationType::Cubic, - } - } -} - -impl From for rubato::PolynomialDegree { - fn from(poly: Poly) -> Self { - match poly { - Poly::Nearest => rubato::PolynomialDegree::Nearest, - Poly::Linear => rubato::PolynomialDegree::Linear, - Poly::Cubic => rubato::PolynomialDegree::Cubic, - Poly::Quintic => rubato::PolynomialDegree::Quintic, - Poly::Septic => rubato::PolynomialDegree::Septic, - } - } -} - -/// Window functions for sinc filter. -/// -/// The window function is applied to the sinc filter to reduce ripple artifacts and control the -/// trade-off between transition bandwidth and stopband attenuation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum WindowFunction { - /// Hann window: ~44 dB stopband attenuation, fast -18 dB/octave rolloff. - /// - /// Good transition band but moderate rejection. Suitable for less critical applications. - Hann, - - /// Squared Hann: ~50 dB stopband attenuation, medium -12 dB/octave rolloff. - /// - /// Better rejection than Hann with slightly wider transition band. - Hann2, - - /// Blackman window: ~75 dB stopband attenuation, fast -18 dB/octave rolloff. - /// - /// Excellent rejection with sharp cutoff. - Blackman, - - /// Squared Blackman: ~81 dB stopband attenuation, medium -12 dB/octave rolloff. - /// - /// Very good rejection with moderate transition band. - Blackman2, - - /// Blackman-Harris window: ~92 dB stopband attenuation, slow -6 dB/octave rolloff. - /// - /// Extremely high rejection but wider transition band. - BlackmanHarris, - - /// Squared Blackman-Harris: ~98 dB stopband attenuation, very slow -3 dB/octave rolloff. - /// - /// Maximum stopband rejection, widest transition band. - #[default] - BlackmanHarris2, -} - -impl From for rubato::WindowFunction { - fn from(window: WindowFunction) -> Self { - match window { - WindowFunction::Hann => rubato::WindowFunction::Hann, - WindowFunction::Hann2 => rubato::WindowFunction::Hann2, - WindowFunction::Blackman => rubato::WindowFunction::Blackman, - WindowFunction::Blackman2 => rubato::WindowFunction::Blackman2, - WindowFunction::BlackmanHarris => rubato::WindowFunction::BlackmanHarris, - WindowFunction::BlackmanHarris2 => rubato::WindowFunction::BlackmanHarris2, - } - } -} - -/// Builder for polynomial resampling configuration without anti-aliasing. -#[derive(Debug, Clone)] -pub struct PolyConfigBuilder { - degree: Poly, - chunk_size: usize, -} - -impl Default for PolyConfigBuilder { - fn default() -> Self { - Self { - degree: Poly::default(), - chunk_size: DEFAULT_CHUNK_SIZE, - } - } -} - -/// Builder for sinc resampling configuration with anti-aliasing. -#[derive(Debug, Clone)] -pub struct SincConfigBuilder { - sinc_len: usize, - oversampling_factor: usize, - interpolation: Sinc, - window: WindowFunction, - f_cutoff: Float, - chunk_size: usize, - #[cfg(feature = "rubato-fft")] - #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] - sub_chunks: usize, -} - -impl Default for SincConfigBuilder { - fn default() -> Self { - Self { - sinc_len: 256, - window: WindowFunction::default(), - oversampling_factor: 128, - interpolation: Sinc::default(), - f_cutoff: 0.95, - chunk_size: DEFAULT_CHUNK_SIZE, - #[cfg(feature = "rubato-fft")] - sub_chunks: DEFAULT_SUB_CHUNKS, - } - } -} - -/// Resampling configuration. -/// -/// Specifies the algorithm and parameters for sample rate conversion. -/// -/// # Examples -/// -/// ```rust -/// use rodio::math::nz; -/// use rodio::source::{resample::Poly, ResampleConfig}; -/// -/// // Use presets -/// let config = ResampleConfig::balanced(); -/// let config = ResampleConfig::fast(); -/// let config = ResampleConfig::accurate(); -/// -/// // Customize from builder -/// let config = ResampleConfig::sinc().chunk_size(nz!(512)); -/// let config = ResampleConfig::poly().degree(Poly::Cubic); -/// ``` -#[derive(Debug, Clone)] -pub enum ResampleConfig { - /// Polynomial resampling (fast, no anti-aliasing) - Poly { - /// Polynomial degree - degree: Poly, - /// Desired chunk size in frames - chunk_size: usize, - }, - /// Sinc resampling (high quality, anti-aliasing) - Sinc { - /// Length of the windowed sinc interpolation filter - sinc_len: usize, - /// The number of intermediate points to use for interpolation - oversampling_factor: usize, - /// Interpolation type for filter table lookup - interpolation: Sinc, - /// Window function to use - window: WindowFunction, - /// Cutoff frequency of the sinc interpolation filter relative to Nyquist (0.0-1.0) - f_cutoff: Float, - /// Desired chunk size in frames - chunk_size: usize, - /// Desired number of sub chunks to use for processing - #[cfg(feature = "rubato-fft")] - #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] - sub_chunks: usize, - }, -} - -// Implementation for ResampleConfig with presets and entry points -impl ResampleConfig { - /// Create a very fast sinc resampling configuration. - pub fn very_fast() -> Self { - let sinc_len = 64; - let window = WindowFunction::Hann2; - Self::Sinc { - sinc_len, - window, - oversampling_factor: 1024, - interpolation: Sinc::Linear, - f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), - chunk_size: DEFAULT_CHUNK_SIZE, - #[cfg(feature = "rubato-fft")] - sub_chunks: DEFAULT_SUB_CHUNKS, - } - } - - /// Create a fast sinc resampling configuration. - pub fn fast() -> Self { - let sinc_len = 128; - let window = WindowFunction::Blackman2; - Self::Sinc { - sinc_len, - window, - oversampling_factor: 1024, - interpolation: Sinc::Linear, - f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), - chunk_size: DEFAULT_CHUNK_SIZE, - #[cfg(feature = "rubato-fft")] - sub_chunks: DEFAULT_SUB_CHUNKS, - } - } - - /// Create a balanced sinc resampling configuration. - pub fn balanced() -> Self { - let sinc_len = 192; - let window = WindowFunction::BlackmanHarris2; - Self::Sinc { - sinc_len, - window, - oversampling_factor: 512, - interpolation: Sinc::Quadratic, - f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), - chunk_size: DEFAULT_CHUNK_SIZE, - #[cfg(feature = "rubato-fft")] - sub_chunks: DEFAULT_SUB_CHUNKS, - } - } - - /// Create an accurate sinc resampling configuration. - pub fn accurate() -> Self { - let sinc_len = 256; - let window = WindowFunction::BlackmanHarris2; - Self::Sinc { - sinc_len, - window, - oversampling_factor: 256, - interpolation: Sinc::Cubic, - f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), - chunk_size: DEFAULT_CHUNK_SIZE, - #[cfg(feature = "rubato-fft")] - sub_chunks: DEFAULT_SUB_CHUNKS, - } - } - - /// Create a polynomial resampling configuration builder. - pub fn poly() -> PolyConfigBuilder { - PolyConfigBuilder::default() - } - - /// Create a sinc resampling configuration builder. - pub fn sinc() -> SincConfigBuilder { - SincConfigBuilder::default() - } -} - -impl Default for ResampleConfig { - fn default() -> Self { - Self::balanced() - } -} - -impl PolyConfigBuilder { - /// Set the polynomial degree for interpolation. - pub fn degree(mut self, degree: Poly) -> Self { - self.degree = degree; - self - } - - /// Set number of audio frames processed at once (typical range: 32-2048). - /// - /// Smaller chunks reduce latency (time delay through the resampler) but increase per-sample - /// overhead. One frame contains one sample per channel. Default is 1024 frames, which at 48 - /// kHz is ~10.7ms latency. - pub fn chunk_size(mut self, size: NonZero) -> Self { - self.chunk_size = size.get(); - self - } - - /// Build the final [`ResampleConfig`]. - pub fn build(self) -> ResampleConfig { - ResampleConfig::Poly { - degree: self.degree, - chunk_size: self.chunk_size, - } - } -} - -impl From for ResampleConfig { - fn from(builder: PolyConfigBuilder) -> Self { - builder.build() - } -} - -impl SincConfigBuilder { - /// Set the length of the sinc filter in taps (typical range: 32-2048). - /// - /// Longer filters provide better quality but use more CPU. - pub fn sinc_len(mut self, len: NonZero) -> Self { - self.sinc_len = len.get(); - self - } - - /// Set oversampling factor (typical range: 64-4096). - /// - /// Higher values improve interpolation accuracy but increase memory usage. - pub fn oversampling_factor(mut self, factor: NonZero) -> Self { - self.oversampling_factor = factor.get(); - self - } - - /// Set interpolation type. - pub fn interpolation(mut self, interpolator: Sinc) -> Self { - self.interpolation = interpolator; - self - } - - /// Set window function. - pub fn window(mut self, window: WindowFunction) -> Self { - self.window = window; - self - } - - /// Set the cutoff frequency as fraction of the Nyquist frequency. - /// - /// Value should be between 0.0 and 1.0, where 1.0 represents the Nyquist frequency (half the - /// sample rate) of the input sampling rate or output sampling rate, whichever is lower. The - /// cutoff determines where the anti-aliasing filter begins to attenuate frequencies. - /// - /// Lower values provide more anti-aliasing protection but reduce high frequency response. - /// - /// # Panics - /// - /// Panics if cutoff is not in range 0.0-1.0. - pub fn f_cutoff(mut self, cutoff: Float) -> Self { - assert!( - (0.0..=1.0).contains(&cutoff), - "f_cutoff must be between 0.0 and 1.0" - ); - self.f_cutoff = cutoff; - self - } - - /// Set the length of the sinc filter, the window function, automatically calculating - /// the cutoff frequency for the combination of the two. - pub fn with_sinc_and_window( - mut self, - sinc_len: NonZero, - window: WindowFunction, - ) -> Self { - self.sinc_len = sinc_len.get(); - self.window = window; - self.f_cutoff = rubato::calculate_cutoff(sinc_len.get(), window.into()); - self - } - - /// Set chunk size for processing (typical range: 512-4096). - /// - /// This balances between efficiency and memory usage. If the device sink uses a fixed buffer - /// size, then this number of frames is a good choice for the resampler chunk size. - pub fn chunk_size(mut self, size: NonZero) -> Self { - self.chunk_size = size.get(); - self - } - - /// Set number of sub-chunks for FFT resampling. - /// - /// The delay of the resampler can be reduced by increasing the number of sub-chunks. A large - /// number of sub-chunks reduces the cutoff frequency of the anti-aliasing filter. It is - /// recommended to set keep this at 1 unless this leads to an unacceptably large delay. - #[cfg(feature = "rubato-fft")] - #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] - pub fn sub_chunks(mut self, count: NonZero) -> Self { - self.sub_chunks = count.get(); - self - } - - /// Build the final [`ResampleConfig`]. - pub fn build(self) -> ResampleConfig { - ResampleConfig::Sinc { - sinc_len: self.sinc_len, - oversampling_factor: self.oversampling_factor, - interpolation: self.interpolation, - window: self.window, - f_cutoff: self.f_cutoff, - chunk_size: self.chunk_size, - #[cfg(feature = "rubato-fft")] - #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] - sub_chunks: self.sub_chunks, - } - } -} - -impl From for ResampleConfig { - fn from(builder: SincConfigBuilder) -> Self { - builder.build() - } -} - -/// Resamples an audio source to a target sample rate using Rubato. -#[derive(Debug)] -pub struct Resample -where - I: Source, -{ - inner: Option>, - target_rate: SampleRate, - config: ResampleConfig, - cached_input_span_len: Option, -} - -impl Clone for Resample -where - I: Source + Clone, -{ - fn clone(&self) -> Self { - // Shallow clone: this resets filter state - let source = self.inner().clone(); - Resample::new(source, self.target_rate, self.config.clone()) - } -} - -impl Resample -where - I: Source, -{ - /// Create a new resampler with the given configuration. - pub fn new(source: I, target_rate: SampleRate, config: ResampleConfig) -> Self { - let inner = Self::create_resampler(source, target_rate, &config); - let cached_input_span_len = match &inner { - ResampleInner::Passthrough { .. } => inner.input().current_span_len(), - ResampleInner::Poly(resampler) => resampler.input.current_span_len(), - ResampleInner::Sinc(resampler) => resampler.input.current_span_len(), - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => resampler.input.current_span_len(), - }; - - Self { - inner: Some(inner), - target_rate, - config, - cached_input_span_len, - } - } - - /// Helper method to create a resampler from a source using the stored config and target rate. - fn create_resampler( - source: I, - target_rate: SampleRate, - config: &ResampleConfig, - ) -> ResampleInner { - let source_rate = source.sample_rate(); - - if source.is_exhausted() || source_rate == target_rate { - let channels = source.channels(); - ResampleInner::Passthrough { - source, - input_span_pos: 0, - channels, - source_rate, - } - } else { - let ratio = Ratio::new(target_rate.get(), source_rate.get()); - match config { - ResampleConfig::Poly { degree, chunk_size } => { - let resampler = - RubatoAsyncResample::new_poly(source, target_rate, *chunk_size, *degree) - .expect("Failed to create polynomial resampler"); - ResampleInner::Poly(resampler) - } - #[cfg(feature = "rubato-fft")] - ResampleConfig::Sinc { - sinc_len, - oversampling_factor, - interpolation, - window, - f_cutoff, - chunk_size, - sub_chunks, - } => { - if *ratio.numer() <= MAX_FIXED_RATIO && *ratio.denom() <= MAX_FIXED_RATIO { - // Use FFT resampler for optimal performance - let resampler = - RubatoFftResample::new(source, target_rate, *chunk_size, *sub_chunks) - .expect("Failed to create FFT resampler"); - ResampleInner::Fft(resampler) - } else { - let resampler = RubatoAsyncResample::new_sinc( - source, - target_rate, - *chunk_size, - *sinc_len, - *f_cutoff, - *oversampling_factor, - *interpolation, - *window, - ) - .expect("Failed to create sinc resampler"); - ResampleInner::Sinc(resampler) - } - } - #[cfg(not(feature = "rubato-fft"))] - ResampleConfig::Sinc { - sinc_len, - oversampling_factor, - interpolation, - window, - f_cutoff, - chunk_size, - } => { - if *ratio.numer() <= MAX_FIXED_RATIO && *ratio.denom() <= MAX_FIXED_RATIO { - // Fixed ratio without FFT - use Sinc::Nearest optimization - // Set oversampling_factor to match the ratio for optimal performance - let ratio = *ratio.numer().max(ratio.denom()) as usize; - let resampler = RubatoAsyncResample::new_sinc( - source, - target_rate, - *chunk_size, - *sinc_len, - *f_cutoff, - ratio, - Sinc::Nearest, - *window, - ) - .expect("Failed to create optimized sinc resampler"); - ResampleInner::Sinc(resampler) - } else { - let resampler = RubatoAsyncResample::new_sinc( - source, - target_rate, - *chunk_size, - *sinc_len, - *f_cutoff, - *oversampling_factor, - *interpolation, - *window, - ) - .expect("Failed to create sinc resampler"); - ResampleInner::Sinc(resampler) - } - } - } - } - } - - /// Returns a reference to the inner source. - #[inline] - pub fn inner(&self) -> &I { - match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => source, - ResampleInner::Poly(resampler) => &resampler.input, - ResampleInner::Sinc(resampler) => &resampler.input, - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => &resampler.input, - } - } - - /// Returns a mutable reference to the inner source. - #[inline] - pub fn inner_mut(&mut self) -> &mut I { - match self.inner.as_mut().unwrap() { - ResampleInner::Passthrough { source, .. } => source, - ResampleInner::Poly(resampler) => &mut resampler.input, - ResampleInner::Sinc(resampler) => &mut resampler.input, - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => &mut resampler.input, - } - } - - /// Returns the inner source. - #[inline] - pub fn into_inner(self) -> I { - self.inner.unwrap().into_inner() - } -} - -impl Source for Resample -where - I: Source, -{ - #[inline] - fn current_span_len(&self) -> Option { - let ( - input_span_len, - input_sample_rate, - input_exhausted, - output_buffer_len, - output_buffer_pos, - output_frames_next, - ) = match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => return source.current_span_len(), - ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => ( - resampler.input.current_span_len(), - resampler.input.sample_rate(), - resampler.input.is_exhausted(), - resampler.output_buffer_len, - resampler.output_buffer_pos, - resampler.resampler.output_frames_next(), - ), - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => ( - resampler.input.current_span_len(), - resampler.input.sample_rate(), - resampler.input.is_exhausted(), - resampler.output_buffer_len, - resampler.output_buffer_pos, - resampler.resampler.output_frames_next(), - ), - }; - - let ratio = Ratio::new(self.sample_rate().get(), input_sample_rate.get()); - if ratio.is_integer() { - // Integer upsampling (2x, 3x, etc.) - always exact and frame-aligned - input_span_len.map(|len| *ratio.numer() as usize * len) - } else { - // When the ratio contains a fraction, we cannot choose the floor or ceiling - // arbitrarily, because the resampler may produce either based on its internal state - if output_buffer_pos < output_buffer_len { - // Running state: we are iterating over our buffer with resampled samples - Some(output_buffer_len) - } else if input_exhausted { - // End state: we are at the end of our buffer and the source is exhausted - Some(0) - } else { - // Initial state: our buffer is empty until the first call to next() loads it with - // resampled samples. Return the size of the next buffer. - Some(output_frames_next * self.channels().get() as usize) - } - } - } - - #[inline] - fn sample_rate(&self) -> SampleRate { - self.target_rate - } - - #[inline] - fn channels(&self) -> ChannelCount { - match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => source.channels(), - ResampleInner::Poly(resampler) => resampler.channels, - ResampleInner::Sinc(resampler) => resampler.channels, - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => resampler.channels, - } - } - - #[inline] - fn total_duration(&self) -> Option { - match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => source.total_duration(), - ResampleInner::Poly(resampler) => resampler.input.total_duration(), - ResampleInner::Sinc(resampler) => resampler.input.total_duration(), - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => resampler.input.total_duration(), - } - } - - #[inline] - fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { - match self.inner.as_mut().unwrap() { - ResampleInner::Passthrough { source, .. } => source.try_seek(position)?, - ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { - r.input.try_seek(position)?; - r.reset(); - } - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(r) => { - r.input.try_seek(position)?; - r.reset(); - } - } - - let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); - - match self.inner.as_mut().unwrap() { - ResampleInner::Passthrough { - input_span_pos: input_samples_consumed, - .. - } => { - reset_seek_span_tracking( - input_samples_consumed, - &mut self.cached_input_span_len, - position, - input_span_len, - ); - } - ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { - reset_seek_span_tracking( - &mut r.input_samples_consumed, - &mut self.cached_input_span_len, - position, - input_span_len, - ); - } - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(r) => { - reset_seek_span_tracking( - &mut r.input_samples_consumed, - &mut self.cached_input_span_len, - position, - input_span_len, - ); - } - } - - Ok(()) - } -} - -impl Iterator for Resample -where - I: Source, -{ - type Item = Sample; - - #[inline] - fn next(&mut self) -> Option { - let sample = match self.inner.as_mut().unwrap() { - ResampleInner::Passthrough { source, .. } => source.next()?, - ResampleInner::Poly(resampler) => resampler.next_sample()?, - ResampleInner::Sinc(resampler) => resampler.next_sample()?, - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => resampler.next_sample()?, - }; - - // If input reports no span length, parameters are stable by contract - let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); - if input_span_len.is_some() { - let (expected_channels, expected_rate, samples_consumed) = - match self.inner.as_mut().unwrap() { - ResampleInner::Passthrough { - input_span_pos: input_samples_consumed, - channels, - source_rate, - .. - } => { - *input_samples_consumed += 1; - (*channels, *source_rate, *input_samples_consumed) - } - ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { - (r.channels, r.source_rate, r.input_samples_consumed) - } - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(r) => (r.channels, r.source_rate, r.input_samples_consumed), - }; - - // Get current parameters from input - let input = self.inner.as_ref().unwrap().input(); - let current_channels = input.channels(); - let current_rate = input.sample_rate(); - - // Determine if we're at a span boundary: - // - Counting mode (Some): boundary when we've consumed span_len samples - // - Detection mode (None): boundary when parameters change (mid-span seek recovery) - let mut parameters_changed = false; - let at_boundary = { - let known_boundary = self - .cached_input_span_len - .map(|cached_len| samples_consumed >= cached_len); - - // In counting mode: only check parameters at boundary - // In detection mode: check parameters at every sample until detecting a boundary - if known_boundary.is_none_or(|at_boundary| at_boundary) { - parameters_changed = - current_channels != expected_channels || current_rate != expected_rate; - } - - known_boundary.unwrap_or(parameters_changed) - }; - - if at_boundary { - // Update cached span length (exits detection mode if we were in it) - self.cached_input_span_len = input_span_len; - - if parameters_changed { - // Recreate resampler - new resampler will have counters reset to 0 - let source = self.inner.take().unwrap().into_inner(); - self.inner = Some(Self::create_resampler( - source, - self.target_rate, - &self.config, - )); - } else { - // Just crossed boundary without parameter change, reset counter - match self.inner.as_mut().unwrap() { - ResampleInner::Passthrough { - input_span_pos: input_samples_consumed, - .. - } => { - *input_samples_consumed = 0; - } - ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { - r.input_samples_consumed = 0; - } - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(r) => { - r.input_samples_consumed = 0; - } - } - } - } - } - - Some(sample) - } - - #[inline] - fn size_hint(&self) -> (usize, Option) { - let (input_hint, source_rate, buffered_remaining) = match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => return source.size_hint(), - ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => { - let input_hint = resampler.input.size_hint(); - let buffered_remaining = resampler.output_buffer_len - resampler.output_buffer_pos; - (input_hint, resampler.source_rate, buffered_remaining) - } - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => { - let input_hint = resampler.input.size_hint(); - let buffered_remaining = resampler.output_buffer_len - resampler.output_buffer_pos; - (input_hint, resampler.source_rate, buffered_remaining) - } - }; - - let (input_lower, input_upper) = input_hint; - let ratio = self.target_rate.get() as Float / source_rate.get() as Float; - - let lower = buffered_remaining + (input_lower as Float * ratio).ceil() as usize; - let upper = - input_upper.map(|upper| buffered_remaining + (upper as Float * ratio).ceil() as usize); - - (lower, upper) - } -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug)] -enum ResampleInner { - /// Passthrough when source rate is equal to the target rate - Passthrough { - source: I, - input_span_pos: usize, - channels: ChannelCount, - source_rate: SampleRate, - }, - - /// Polynomial resampling (fast, no anti-aliasing) - Poly(RubatoAsyncResample), - - /// Sinc resampling (with anti-aliasing) - Sinc(RubatoAsyncResample), - - /// FFT resampling for fixed ratios (synchronous resampling) - #[cfg(feature = "rubato-fft")] - #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] - Fft(RubatoFftResample), -} - -impl ResampleInner { - /// Get a reference to the inner input source - #[inline] - fn input(&self) -> &I { - match self { - ResampleInner::Passthrough { source, .. } => source, - ResampleInner::Poly(resampler) => &resampler.input, - ResampleInner::Sinc(resampler) => &resampler.input, - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => &resampler.input, - } - } - - /// Extract the inner input source, consuming the resampler - #[inline] - fn into_inner(self) -> I { - match self { - ResampleInner::Passthrough { source, .. } => source, - ResampleInner::Poly(resampler) => resampler.input, - ResampleInner::Sinc(resampler) => resampler.input, - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => resampler.input, - } - } -} - -/// Generic wrapper around Rubato resamplers for sample-by-sample iteration. -#[derive(Debug)] -struct RubatoResample> { - input: I, - resampler: R, - - input_buffer: Box<[Sample]>, - input_frame_count: usize, - - output_buffer: Box<[Sample]>, - output_buffer_pos: usize, - output_buffer_len: usize, - - channels: ChannelCount, - source_rate: SampleRate, - - input_samples_consumed: usize, - input_exhausted: bool, - - total_input_frames: usize, - total_output_samples: usize, - expected_output_samples: usize, - - /// The number of real (non-flush) frames currently in the input buffer. - real_frames_in_buffer: usize, - - output_delay_remaining: usize, - resample_ratio: Float, - indexing: Indexing, -} - -/// Type alias for Async (polynomial/sinc) resampler. -type RubatoAsyncResample = RubatoResample>; - -impl> RubatoResample { - /// Calculate the number of output samples to skip for delay compensation. - fn calculate_delay_compensation(resampler: &R, channels: ChannelCount) -> usize { - // Skip delay-1 frames to align the first output frame with input position 0. - let delay_frames = resampler.output_delay(); - let delay_to_skip = delay_frames.saturating_sub(1); - delay_to_skip * channels.get() as usize - } - - fn reset(&mut self) { - self.resampler.reset(); - self.output_buffer_pos = 0; - self.output_buffer_len = 0; - self.input_frame_count = 0; - self.input_samples_consumed = 0; - self.input_exhausted = false; - self.total_input_frames = 0; - self.total_output_samples = 0; - self.expected_output_samples = 0; - self.real_frames_in_buffer = 0; - self.indexing.partial_len = None; - self.output_delay_remaining = - Self::calculate_delay_compensation(&self.resampler, self.channels); - } - - fn next_sample(&mut self) -> Option { - let num_channels = self.channels.get() as usize; - loop { - // If we have buffered output, return it - if self.output_buffer_pos < self.output_buffer_len { - let sample = self.output_buffer[self.output_buffer_pos]; - self.output_buffer_pos += 1; - self.total_output_samples += 1; - - if self.total_output_samples > self.expected_output_samples { - // Cut off filter artifacts after input is exhausted - return None; - } - - return Some(sample); - } - - // Need more input - first check if we're completely done - if self.input_exhausted - && self.input_frame_count == 0 - && self.total_output_samples >= self.expected_output_samples - { - return None; - } - - // Fill input buffer - accumulate frames until we hit needed amount or run out of input - let needed_input = self.resampler.input_frames_next(); - let frames_before = self.input_frame_count; - while self.input_frame_count < needed_input && !self.input_exhausted { - let sample_pos = self.input_frame_count * num_channels; - for ch in 0..num_channels { - if let Some(sample) = self.input.next() { - self.input_buffer[sample_pos + ch] = sample; - } else { - self.input_exhausted = true; - break; - } - } - if !self.input_exhausted { - self.input_frame_count += 1; - self.real_frames_in_buffer += 1; - } - } - - // If we have no input, flush the filter tail with zeros - if self.input_frame_count == 0 { - // Zero-pad a full chunk to drain the filter delay - self.input_buffer[..needed_input * num_channels].fill(Sample::EQUILIBRIUM); - self.input_frame_count = needed_input; - // real_frames_in_buffer stays at 0 - these are flush frames - } - - // We can process with fewer frames than needed using partial_len when the input is - // exhausted. If we don't have enough input and more is coming, wait. - let made_progress = self.input_frame_count > frames_before; - if self.input_frame_count < needed_input && !self.input_exhausted && made_progress { - continue; - } - - let actual_frames = self.input_frame_count; - - // Prevent stack allocations in the hot path by reusing the indexing struct - let indexing_ref = if actual_frames < needed_input { - self.indexing.partial_len = Some(actual_frames); - Some(&self.indexing) - } else { - None - }; - - let (frames_in, frames_out) = { - // InterleavedSlice is a zero-cost abstraction - no heap allocation occurs here - let input_adapter = audioadapter_buffers::direct::InterleavedSlice::new( - &self.input_buffer, - num_channels, - actual_frames, - ) - .ok()?; - - let num_frames = self.output_buffer.len() / num_channels; - let mut output_adapter = audioadapter_buffers::direct::InterleavedSlice::new_mut( - &mut self.output_buffer, - num_channels, - num_frames, - ) - .ok()?; - - self.resampler - .process_into_buffer(&input_adapter, &mut output_adapter, indexing_ref) - .ok()? - }; - - // If no output was produced and input is exhausted, we're done - if frames_out == 0 && self.input_exhausted { - return None; - } - - // When using partial_len, Rubato may report consuming more frames than we - // actually provided (it counts the zero-padded frames). Clamp to actual. - let actual_consumed = frames_in.min(actual_frames); - self.input_samples_consumed += actual_consumed * num_channels; - - // Only count real (non-flush) frames toward expected output - let real_consumed = actual_consumed.min(self.real_frames_in_buffer); - self.real_frames_in_buffer -= real_consumed; - self.total_input_frames += real_consumed; - self.expected_output_samples = (self.total_input_frames as Float * self.resample_ratio) - .ceil() as usize - * num_channels; - - // Shift remaining input samples to beginning of buffer - if actual_consumed < self.input_frame_count { - let src_start = actual_consumed * num_channels; - let src_end = self.input_frame_count * num_channels; - self.input_buffer.copy_within(src_start..src_end, 0); - } - self.input_frame_count -= actual_consumed; - - self.output_buffer_pos = 0; - self.output_buffer_len = frames_out * num_channels; - - // Skip warmup delay samples - if self.output_delay_remaining > 0 { - let samples_to_skip = self.output_delay_remaining.min(self.output_buffer_len); - self.output_buffer_pos += samples_to_skip; - self.output_delay_remaining -= samples_to_skip; - } - } - } -} - -impl RubatoAsyncResample { - fn new_poly( - input: I, - target_rate: SampleRate, - chunk_size: usize, - degree: Poly, - ) -> Result { - let source_rate = input.sample_rate(); - let channels = input.channels(); - - let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; - - let resampler = rubato::Async::new_poly( - resample_ratio as _, - 1.0, - degree.into(), - chunk_size, - channels.get() as usize, - rubato::FixedAsync::Output, - ) - .map_err(|e| format!("Failed to create polynomial resampler: {:?}", e))?; - - let input_buf_size = resampler.input_frames_max(); - let output_buf_size = resampler.output_frames_max(); - - let output_delay_remaining = - RubatoResample::>::calculate_delay_compensation( - &resampler, channels, - ); - - Ok(Self { - input, - resampler, - input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] - .into_boxed_slice(), - input_frame_count: 0, - output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] - .into_boxed_slice(), - output_buffer_pos: 0, - output_buffer_len: 0, - channels, - source_rate, - input_samples_consumed: 0, - input_exhausted: false, - output_delay_remaining, - total_input_frames: 0, - total_output_samples: 0, - expected_output_samples: 0, - real_frames_in_buffer: 0, - resample_ratio, - indexing: Indexing { - input_offset: 0, - output_offset: 0, - partial_len: None, - active_channels_mask: None, - }, - }) - } - - #[allow(clippy::too_many_arguments)] - fn new_sinc( - input: I, - target_rate: SampleRate, - chunk_size: usize, - sinc_len: usize, - f_cutoff: Float, - oversampling_factor: usize, - interpolation: Sinc, - window: WindowFunction, - ) -> Result { - let source_rate = input.sample_rate(); - let channels = input.channels(); - - let parameters = rubato::SincInterpolationParameters { - sinc_len, - f_cutoff: f_cutoff as _, - oversampling_factor, - interpolation: interpolation.into(), - window: window.into(), - }; - - let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; - - let resampler = rubato::Async::new_sinc( - resample_ratio as _, - 1.0, - ¶meters, - chunk_size, - channels.get() as usize, - rubato::FixedAsync::Output, - ) - .map_err(|e| format!("Failed to create sinc resampler: {:?}", e))?; - - let input_buf_size = resampler.input_frames_max(); - let output_buf_size = resampler.output_frames_max(); - - let output_delay_remaining = - RubatoResample::>::calculate_delay_compensation( - &resampler, channels, - ); - - Ok(Self { - input, - resampler, - input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] - .into_boxed_slice(), - input_frame_count: 0, - output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] - .into_boxed_slice(), - output_buffer_pos: 0, - output_buffer_len: 0, - channels, - source_rate, - input_samples_consumed: 0, - input_exhausted: false, - output_delay_remaining, - total_input_frames: 0, - total_output_samples: 0, - expected_output_samples: 0, - real_frames_in_buffer: 0, - resample_ratio, - indexing: Indexing { - input_offset: 0, - output_offset: 0, - partial_len: None, - active_channels_mask: None, - }, - }) - } -} - -/// Type alias for FFT resampler (synchronous, fixed-ratio). -/// Input and output chunk sizes must be exact multiples of the ratio components. -#[cfg(feature = "rubato-fft")] -#[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] -type RubatoFftResample = RubatoResample>; - -// FFT-specific constructor -#[cfg(feature = "rubato-fft")] -#[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] -impl RubatoFftResample { - /// Create a new FFT resampler for fixed-ratio sample rate conversion. - /// - /// The FFT resampler requires that: - /// - Input chunk size must be a multiple of the GCD-reduced denominator - /// - Output chunk size must be a multiple of the GCD-reduced numerator - fn new( - input: I, - target_rate: SampleRate, - chunk_size: usize, - sub_chunks: usize, - ) -> Result { - let source_rate = input.sample_rate(); - let channels = input.channels(); - - // Calculate the GCD-reduced ratio - let ratio = Ratio::new(target_rate.get(), source_rate.get()); - let (_num, den) = ratio.into_raw(); - - // Determine input chunk size - must be multiple of denominator - let input_chunk_size = ((chunk_size / den as usize) + 1) * den as usize; - - let resampler = rubato::Fft::new( - source_rate.get() as usize, - target_rate.get() as usize, - input_chunk_size, - sub_chunks, - channels.get() as usize, - rubato::FixedSync::Output, - ) - .map_err(|e| format!("Failed to create FFT resampler: {:?}", e))?; - - let input_buf_size = resampler.input_frames_max(); - let output_buf_size = resampler.output_frames_max(); - let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; - - let output_delay_remaining = Self::calculate_delay_compensation(&resampler, channels); - - Ok(Self { - input, - resampler, - input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] - .into_boxed_slice(), - input_frame_count: 0, - output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] - .into_boxed_slice(), - output_buffer_pos: 0, - output_buffer_len: 0, - channels, - source_rate, - input_samples_consumed: 0, - input_exhausted: false, - total_input_frames: 0, - total_output_samples: 0, - expected_output_samples: 0, - real_frames_in_buffer: 0, - output_delay_remaining, - resample_ratio, - indexing: Indexing { - input_offset: 0, - output_offset: 0, - partial_len: None, - active_channels_mask: None, - }, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::source::{from_iter, SineWave}; - use crate::Source; - use dasp_sample::ToSample; - use quickcheck::{quickcheck, Arbitrary, Gen, TestResult}; - use std::num::NonZero; - - #[derive(Debug, Clone, Copy)] - struct TestSampleRate(SampleRate); - - impl Arbitrary for TestSampleRate { - fn arbitrary(g: &mut Gen) -> Self { - // Generate realistic sample rates: 8 kHz to 384 kHz - let rate = u32::arbitrary(g) % 376_001 + 8_000; - TestSampleRate(SampleRate::new(rate).unwrap()) - } - } - - impl std::ops::Deref for TestSampleRate { - type Target = SampleRate; - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - #[derive(Debug, Clone, Copy)] - struct TestChannelCount(ChannelCount); - - impl Arbitrary for TestChannelCount { - fn arbitrary(g: &mut Gen) -> Self { - // Generate realistic channel counts: 1 to 8 - let channels = (u16::arbitrary(g) % 7) + 1; - TestChannelCount(ChannelCount::new(channels).unwrap()) - } - } - - impl std::ops::Deref for TestChannelCount { - type Target = ChannelCount; - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - struct TestSource { - samples: Vec, - index: usize, - sample_rate: SampleRate, - channels: ChannelCount, - } - - impl TestSource { - fn new(samples: Vec, sample_rate: SampleRate, channels: ChannelCount) -> Self { - Self { - samples, - index: 0, - sample_rate, - channels, - } - } - } - - impl Iterator for TestSource { - type Item = Sample; - - fn next(&mut self) -> Option { - if self.index < self.samples.len() { - let sample = self.samples[self.index]; - self.index += 1; - Some(sample) - } else { - None - } - } - } - - impl Source for TestSource { - fn current_span_len(&self) -> Option { - Some(self.samples.len()) - } - - fn sample_rate(&self) -> SampleRate { - self.sample_rate - } - - fn channels(&self) -> ChannelCount { - self.channels - } - - fn total_duration(&self) -> Option { - let samples = self.samples.len() / self.channels.get() as usize; - Some(Duration::from_secs_f64( - samples as f64 / self.sample_rate.get() as f64, - )) - } - - fn try_seek(&mut self, _position: Duration) -> Result<(), SeekError> { - Ok(()) - } - } - - /// Convert and truncate input to contain a frame-aligned number of samples. - fn convert_to_frames>( - input: Vec, - channels: ChannelCount, - ) -> Vec { - let mut input: Vec = input.iter().map(|x| x.to_sample()).collect(); - let frame_size = channels.get() as usize; - input.truncate(frame_size * (input.len() / frame_size)); - input - } - - quickcheck! { - /// Check that resampling an empty input produces no output. - fn empty(from: TestSampleRate, to: TestSampleRate, channels: TestChannelCount) -> bool { - let input = vec![]; - let config = ResampleConfig::default(); - let source = from_iter(input.clone().into_iter(), *channels, *from); - let output = Resample::new(source, *to, config).collect::>(); - input == output - } - - /// Check that resampling to the same rate does not change the signal. - fn identity(from: TestSampleRate, channels: TestChannelCount, input: Vec) -> bool { - let input = convert_to_frames(input, *channels); - let config = ResampleConfig::default(); - let source = from_iter(input.clone().into_iter(), *channels, *from); - let output = Resample::new(source, *from, config).collect::>(); - input == output - } - - /// Check that resampling does not change the audio duration, except by a negligible - /// amount (± 1ms). Reproduces #316. - fn preserve_durations(d: Duration, freq: f32, to: TestSampleRate) -> TestResult { - use crate::source::{SineWave, Source}; - if !freq.is_normal() || freq <= 0.0 || d > Duration::from_secs(1) { - return TestResult::discard(); - } - - let source = SineWave::new(freq).take_duration(d); - let from = source.sample_rate(); - - let config = ResampleConfig::poly().degree(Poly::Linear).build(); - let resampled = Resample::new(source, *to, config); - let duration = Duration::from_secs_f32(resampled.count() as f32 / to.get() as f32); - - let delta = duration.abs_diff(d); - TestResult::from_bool(delta < Duration::from_millis(1)) - } - } - - /// Helper to create interleaved multi-channel test data using SineWave sources. - fn create_test_input(frames: usize, channels: u16) -> Vec { - let frequencies = [440.0, 1000.0]; - let total_samples = frames * channels as usize; - let mut input = Vec::with_capacity(total_samples); - - // Create a SineWave for each channel - let mut waves: Vec<_> = (0..channels) - .map(|ch| SineWave::new(frequencies[ch as usize % frequencies.len()])) - .collect(); - - // Interleave samples from each channel - for _ in 0..frames { - for wave in waves.iter_mut() { - input.push(wave.next().unwrap()); - } - } - input - } - - /// Test various ratio types: integer, fractional, and reciprocal. - #[test] - fn test_sample_rate_conversions() { - let test_cases = [ - // (from_rate, to_rate, channels, description) - (1000, 7000, 1, "integer upsample 7x"), - (2000, 3000, 2, "fractional upsample 1.5x"), - (12000, 2400, 1, "integer downsample 1/5x"), - (48000, 44100, 2, "fractional downsample (DVD to CD)"), - (8000, 48001, 1, "async sinc"), - ]; - - let configs: &[(&str, ResampleConfig)] = &[ - ("poly", ResampleConfig::poly().build()), - ("sinc", ResampleConfig::sinc().build()), - ]; - - for (config_name, config) in configs { - for (from_rate, to_rate, channels, desc) in test_cases { - let from = SampleRate::new(from_rate).unwrap(); - let to = SampleRate::new(to_rate).unwrap(); - let ch = ChannelCount::new(channels).unwrap(); - - let input_frames = 100; - let input = create_test_input(input_frames, channels); - let input_samples = input.len(); - - let source = from_iter(input.into_iter(), ch, from); - let resampler = Resample::new(source, to, config.clone()); - - let size_hint_lower = resampler.size_hint().0; - let output_count = resampler.count(); - - assert_eq!( - output_count, size_hint_lower, - "[{config_name}] {desc}: size_hint {size_hint_lower} should equal actual output {output_count}", - ); - - let ratio = to.get() as f64 / from.get() as f64; - let expected_samples = (input_samples as f64 * ratio).ceil() as usize; - - assert_eq!( - output_count.abs_diff(expected_samples), 0, - "[{config_name}] {desc}: expected {expected_samples} samples, got {output_count}", - ); - } - } - } -} diff --git a/src/source/resample/builder.rs b/src/source/resample/builder.rs new file mode 100644 index 00000000..4c192ff3 --- /dev/null +++ b/src/source/resample/builder.rs @@ -0,0 +1,452 @@ +//! Configuration types and builders for resampling. + +use std::num::NonZero; + +use crate::Float; + +const DEFAULT_CHUNK_SIZE: usize = 1024; +#[cfg(feature = "rubato-fft")] +const DEFAULT_SUB_CHUNKS: usize = 1; + +/// Polynomial interpolation degree, no anti-aliasing. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum Poly { + /// Zero-order hold - nearest neighbor sampling. + /// + /// Simply picks the nearest input sample without interpolation. + /// Creates a "stepped" waveform. + Nearest, + + /// Linear interpolation between 2 samples. + #[default] + Linear, + + /// Cubic interpolation using 4 samples. + Cubic, + + /// Quintic interpolation using 6 samples. + Quintic, + + /// Septic interpolation using 8 samples. + Septic, +} + +impl From for rubato::PolynomialDegree { + fn from(poly: Poly) -> Self { + match poly { + Poly::Nearest => rubato::PolynomialDegree::Nearest, + Poly::Linear => rubato::PolynomialDegree::Linear, + Poly::Cubic => rubato::PolynomialDegree::Cubic, + Poly::Quintic => rubato::PolynomialDegree::Quintic, + Poly::Septic => rubato::PolynomialDegree::Septic, + } + } +} + +/// Sinc interpolation type. +/// +/// Controls how intermediate values are calculated between precomputed sinc points +/// in the windowed sinc filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Sinc { + /// No interpolation - picks nearest intermediate point. + /// + /// Optimal when upsampling by exact ratios (e.g., 48kHz and 96kHz) and the oversampling factor + /// is equal to the ratio. In these cases, no unnecessary computations are performed and the + /// result is equivalent to that of synchronous resampling. + Nearest, + + /// Linear interpolation between two nearest points. + /// + /// Relatively fast, but needs a large number of intermediate points to push the resampling + /// artefacts below the noise floor. + #[default] + Linear, + + /// Quadratic interpolation using three nearest points. + /// + /// The computation time lies approximately halfway between that of linear and quadratic + /// interpolation. + Quadratic, + + /// Cubic interpolation using four nearest points. + /// + /// The computation time is approximately twice as long as that of linear interpolation, but it + /// requires much fewer intermediate points for a good result. + Cubic, +} + +impl From for rubato::SincInterpolationType { + fn from(sinc: Sinc) -> Self { + match sinc { + Sinc::Nearest => rubato::SincInterpolationType::Nearest, + Sinc::Linear => rubato::SincInterpolationType::Linear, + Sinc::Quadratic => rubato::SincInterpolationType::Quadratic, + Sinc::Cubic => rubato::SincInterpolationType::Cubic, + } + } +} + +/// Window functions for sinc filter. +/// +/// The window function is applied to the sinc filter to reduce ripple artifacts and control the +/// trade-off between transition bandwidth and stopband attenuation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WindowFunction { + /// Hann window: ~44 dB stopband attenuation, fast -18 dB/octave rolloff. + /// + /// Good transition band but moderate rejection. Suitable for less critical applications. + Hann, + + /// Squared Hann: ~50 dB stopband attenuation, medium -12 dB/octave rolloff. + /// + /// Better rejection than Hann with slightly wider transition band. + Hann2, + + /// Blackman window: ~75 dB stopband attenuation, fast -18 dB/octave rolloff. + /// + /// Excellent rejection with sharp cutoff. + Blackman, + + /// Squared Blackman: ~81 dB stopband attenuation, medium -12 dB/octave rolloff. + /// + /// Very good rejection with moderate transition band. + Blackman2, + + /// Blackman-Harris window: ~92 dB stopband attenuation, slow -6 dB/octave rolloff. + /// + /// Extremely high rejection but wider transition band. + BlackmanHarris, + + /// Squared Blackman-Harris: ~98 dB stopband attenuation, very slow -3 dB/octave rolloff. + /// + /// Maximum stopband rejection, widest transition band. + #[default] + BlackmanHarris2, +} + +impl From for rubato::WindowFunction { + fn from(window: WindowFunction) -> Self { + match window { + WindowFunction::Hann => rubato::WindowFunction::Hann, + WindowFunction::Hann2 => rubato::WindowFunction::Hann2, + WindowFunction::Blackman => rubato::WindowFunction::Blackman, + WindowFunction::Blackman2 => rubato::WindowFunction::Blackman2, + WindowFunction::BlackmanHarris => rubato::WindowFunction::BlackmanHarris, + WindowFunction::BlackmanHarris2 => rubato::WindowFunction::BlackmanHarris2, + } + } +} + +/// Builder for polynomial resampling configuration without anti-aliasing. +#[derive(Debug, Clone)] +pub struct PolyConfigBuilder { + degree: Poly, + chunk_size: usize, +} + +impl Default for PolyConfigBuilder { + fn default() -> Self { + Self { + degree: Poly::default(), + chunk_size: DEFAULT_CHUNK_SIZE, + } + } +} + +/// Builder for sinc resampling configuration with anti-aliasing. +#[derive(Debug, Clone)] +pub struct SincConfigBuilder { + sinc_len: usize, + oversampling_factor: usize, + interpolation: Sinc, + window: WindowFunction, + f_cutoff: Float, + chunk_size: usize, + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + sub_chunks: usize, +} + +impl Default for SincConfigBuilder { + fn default() -> Self { + Self { + sinc_len: 256, + window: WindowFunction::default(), + oversampling_factor: 128, + interpolation: Sinc::default(), + f_cutoff: 0.95, + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } +} + +/// Resampling configuration. +/// +/// Specifies the algorithm and parameters for sample rate conversion. +/// +/// # Examples +/// +/// ```rust +/// use rodio::math::nz; +/// use rodio::source::{resample::Poly, ResampleConfig}; +/// +/// // Use presets +/// let config = ResampleConfig::balanced(); +/// let config = ResampleConfig::fast(); +/// let config = ResampleConfig::accurate(); +/// +/// // Customize from builder +/// let config = ResampleConfig::sinc().chunk_size(nz!(512)); +/// let config = ResampleConfig::poly().degree(Poly::Cubic); +/// ``` +#[derive(Debug, Clone)] +pub enum ResampleConfig { + /// Polynomial resampling (fast, no anti-aliasing) + Poly { + /// Polynomial degree + degree: Poly, + /// Desired chunk size in frames + chunk_size: usize, + }, + /// Sinc resampling (high quality, anti-aliasing) + Sinc { + /// Length of the windowed sinc interpolation filter + sinc_len: usize, + /// The number of intermediate points to use for interpolation + oversampling_factor: usize, + /// Interpolation type for filter table lookup + interpolation: Sinc, + /// Window function to use + window: WindowFunction, + /// Cutoff frequency of the sinc interpolation filter relative to Nyquist (0.0-1.0) + f_cutoff: Float, + /// Desired chunk size in frames + chunk_size: usize, + /// Desired number of sub chunks to use for processing + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + sub_chunks: usize, + }, +} + +impl ResampleConfig { + /// Create a very fast sinc resampling configuration. + pub fn very_fast() -> Self { + let sinc_len = 64; + let window = WindowFunction::Hann2; + Self::Sinc { + sinc_len, + window, + oversampling_factor: 1024, + interpolation: Sinc::Linear, + f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } + + /// Create a fast sinc resampling configuration. + pub fn fast() -> Self { + let sinc_len = 128; + let window = WindowFunction::Blackman2; + Self::Sinc { + sinc_len, + window, + oversampling_factor: 1024, + interpolation: Sinc::Linear, + f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } + + /// Create a balanced sinc resampling configuration. + pub fn balanced() -> Self { + let sinc_len = 192; + let window = WindowFunction::BlackmanHarris2; + Self::Sinc { + sinc_len, + window, + oversampling_factor: 512, + interpolation: Sinc::Quadratic, + f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } + + /// Create an accurate sinc resampling configuration. + pub fn accurate() -> Self { + let sinc_len = 256; + let window = WindowFunction::BlackmanHarris2; + Self::Sinc { + sinc_len, + window, + oversampling_factor: 256, + interpolation: Sinc::Cubic, + f_cutoff: rubato::calculate_cutoff(sinc_len, window.into()), + chunk_size: DEFAULT_CHUNK_SIZE, + #[cfg(feature = "rubato-fft")] + sub_chunks: DEFAULT_SUB_CHUNKS, + } + } + + /// Create a polynomial resampling configuration builder. + pub fn poly() -> PolyConfigBuilder { + PolyConfigBuilder::default() + } + + /// Create a sinc resampling configuration builder. + pub fn sinc() -> SincConfigBuilder { + SincConfigBuilder::default() + } +} + +impl Default for ResampleConfig { + fn default() -> Self { + Self::balanced() + } +} + +impl PolyConfigBuilder { + /// Set the polynomial degree for interpolation. + pub fn degree(mut self, degree: Poly) -> Self { + self.degree = degree; + self + } + + /// Set number of audio frames processed at once (typical range: 32-2048). + /// + /// Smaller chunks reduce latency (time delay through the resampler) but increase per-sample + /// overhead. One frame contains one sample per channel. Default is 1024 frames, which at 48 + /// kHz is ~10.7ms latency. + pub fn chunk_size(mut self, size: NonZero) -> Self { + self.chunk_size = size.get(); + self + } + + /// Build the final [`ResampleConfig`]. + pub fn build(self) -> ResampleConfig { + ResampleConfig::Poly { + degree: self.degree, + chunk_size: self.chunk_size, + } + } +} + +impl From for ResampleConfig { + fn from(builder: PolyConfigBuilder) -> Self { + builder.build() + } +} + +impl SincConfigBuilder { + /// Set the length of the sinc filter in taps (typical range: 32-2048). + /// + /// Longer filters provide better quality but use more CPU. + pub fn sinc_len(mut self, len: NonZero) -> Self { + self.sinc_len = len.get(); + self + } + + /// Set oversampling factor (typical range: 64-4096). + /// + /// Higher values improve interpolation accuracy but increase memory usage. + pub fn oversampling_factor(mut self, factor: NonZero) -> Self { + self.oversampling_factor = factor.get(); + self + } + + /// Set interpolation type. + pub fn interpolation(mut self, interpolator: Sinc) -> Self { + self.interpolation = interpolator; + self + } + + /// Set window function. + pub fn window(mut self, window: WindowFunction) -> Self { + self.window = window; + self + } + + /// Set the cutoff frequency as fraction of the Nyquist frequency. + /// + /// Value should be between 0.0 and 1.0, where 1.0 represents the Nyquist frequency (half the + /// sample rate) of the input sampling rate or output sampling rate, whichever is lower. The + /// cutoff determines where the anti-aliasing filter begins to attenuate frequencies. + /// + /// Lower values provide more anti-aliasing protection but reduce high frequency response. + /// + /// # Panics + /// + /// Panics if cutoff is not in range 0.0-1.0. + pub fn f_cutoff(mut self, cutoff: Float) -> Self { + assert!( + (0.0..=1.0).contains(&cutoff), + "f_cutoff must be between 0.0 and 1.0" + ); + self.f_cutoff = cutoff; + self + } + + /// Set the length of the sinc filter, the window function, automatically calculating + /// the cutoff frequency for the combination of the two. + pub fn with_sinc_and_window( + mut self, + sinc_len: NonZero, + window: WindowFunction, + ) -> Self { + self.sinc_len = sinc_len.get(); + self.window = window; + self.f_cutoff = rubato::calculate_cutoff(sinc_len.get(), window.into()); + self + } + + /// Set chunk size for processing (typical range: 512-4096). + /// + /// This balances between efficiency and memory usage. If the device sink uses a fixed buffer + /// size, then this number of frames is a good choice for the resampler chunk size. + pub fn chunk_size(mut self, size: NonZero) -> Self { + self.chunk_size = size.get(); + self + } + + /// Set number of sub-chunks for FFT resampling. + /// + /// The delay of the resampler can be reduced by increasing the number of sub-chunks. A large + /// number of sub-chunks reduces the cutoff frequency of the anti-aliasing filter. It is + /// recommended to set keep this at 1 unless this leads to an unacceptably large delay. + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + pub fn sub_chunks(mut self, count: NonZero) -> Self { + self.sub_chunks = count.get(); + self + } + + /// Build the final [`ResampleConfig`]. + pub fn build(self) -> ResampleConfig { + ResampleConfig::Sinc { + sinc_len: self.sinc_len, + oversampling_factor: self.oversampling_factor, + interpolation: self.interpolation, + window: self.window, + f_cutoff: self.f_cutoff, + chunk_size: self.chunk_size, + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + sub_chunks: self.sub_chunks, + } + } +} + +impl From for ResampleConfig { + fn from(builder: SincConfigBuilder) -> Self { + builder.build() + } +} diff --git a/src/source/resample/mod.rs b/src/source/resample/mod.rs new file mode 100644 index 00000000..2cd09c9d --- /dev/null +++ b/src/source/resample/mod.rs @@ -0,0 +1,759 @@ +//! Audio resampling from one sample rate to another. +//! +//! # Quick Start +//! +//! Use the [`Source::resample`] method with a quality preset: +//! +//! ```rust +//! use rodio::SampleRate; +//! use rodio::source::{SineWave, Source, ResampleConfig}; +//! +//! let source = SineWave::new(440.0); +//! let config = ResampleConfig::balanced(); +//! let resampled = source.resample(SampleRate::new(96000).unwrap(), config); +//! ``` +//! +//! For advanced control, use the [`ResampleConfig`] builder: +//! +//! ```rust +//! use rodio::math::nz; +//! use rodio::source::{SineWave, Source, Resample, ResampleConfig}; +//! use rodio::source::resample::{Sinc, WindowFunction}; +//! +//! let source = SineWave::new(440.0); +//! let config = ResampleConfig::sinc() // Sinc resampling +//! .sinc_len(nz!(256)) // 256-tap filter +//! .interpolation(Sinc::Cubic) // Cubic interpolation +//! .window(WindowFunction::BlackmanHarris2) // Squared Blackman-Harris window +//! .chunk_size(nz!(512)) // Low latency (5.3 ms @ 1-channel 96 kHz) +//! .build(); +//! let resampled = Resample::new(source, nz!(96000), config); +//! ``` +//! +//! # Understanding Resampling +//! +//! ## Polynomial vs. Sinc Interpolation +//! +//! When converting between sample rates, sample values at positions that don't exist in the +//! original signal need to be calculated. There are two main approaches: +//! +//! **Polynomial interpolation** is fast but does not include anti-aliasing. This can cause +//! artifacts in the output audio. Higher degrees provide smoother interpolation but cannot +//! prevent these artifacts. +//! +//! **Sinc interpolation** uses a windowed sinc function for mathematically correct reconstruction. +//! It is of higher quality and includes anti-aliasing to reduce artifacts, but is more +//! computationally expensive. +//! +//! ## Fixed vs Arbitrary Ratios +//! +//! A **fixed ratio** is when the sample rate conversion can be expressed as a simple fraction, +//! like 1:2 (e.g., 48 kHz and 96 kHz) or 147:160 (e.g., 44.1 kHz and 48 kHz). +//! +//! When the resampler is configured for sinc interpolation, it automatically detects these ratios +//! and optimizes resampling by switching to: +//! 1. optimized FFT-based processing when the `rubato-fft` feature is enabled +//! 2. sinc interpolation with nearest-neighbor lookup when FFT is not available +//! +//! This reduces CPU usage while providing highest quality. +//! +//! **Arbitrary ratios** (non-reducible or large fractions) use the async sinc resampler, which +//! can handle any conversion. This is CPU intensive and should be compiled with release profile to +//! prevent choppy audio. +//! +//! # Quality Presets +//! +//! As per [`CamillaDSP`](https://henquist.github.io/3.0.x/): +//! +//! | Parameter | [`VeryFast`](ResampleConfig::very_fast) | [`Fast`](ResampleConfig::fast) | [`Balanced`](ResampleConfig::balanced) | [`Accurate`](ResampleConfig::accurate) | +//! | sinc_len | 64 | 128 | 192 | 256 | +//! | oversampling_factor | 1024 | 1024 | 512 | 256 | +//! | interpolation | Linear | Linear | Quadratic | Cubic | +//! | window | Hann2 | Blackman2 | BlackmanHarris2 | BlackmanHarris2 | +//! | f_cutoff (#) | 0.91 | 0.92 | 0.93 | 0.95 | +//! (#) These cutoff values are approximate. The actual values used are calculated automatically at runtime for the combination of sinc length and window. + +#![cfg_attr(docsrs, feature(doc_cfg))] + +use std::time::Duration; + +use num_rational::Ratio; +use ::rubato::Resampler as _; + +use super::{reset_seek_span_tracking, SeekError}; +use crate::{ + common::{ChannelCount, Sample, SampleRate}, + Float, Source, +}; + +mod builder; +mod rubato; + +use rubato::{ResampleInner, RubatoAsyncResample}; +#[cfg(feature = "rubato-fft")] +use rubato::RubatoFftResample; + +pub use builder::{ + Poly, PolyConfigBuilder, ResampleConfig, Sinc, SincConfigBuilder, WindowFunction, +}; + +/// Maximum for optimized fixed-ratio resampling: 44.1 and 384 kHz (147:1280). +const MAX_FIXED_RATIO: u32 = 1280; + +/// Resamples an audio source to a target sample rate using Rubato. +#[derive(Debug)] +pub struct Resample +where + I: Source, +{ + inner: Option>, + target_rate: SampleRate, + config: ResampleConfig, + cached_input_span_len: Option, +} + +impl Clone for Resample +where + I: Source + Clone, +{ + fn clone(&self) -> Self { + // Shallow clone: this resets filter state + let source = self.inner().clone(); + Resample::new(source, self.target_rate, self.config.clone()) + } +} + +impl Resample +where + I: Source, +{ + /// Create a new resampler with the given configuration. + pub fn new(source: I, target_rate: SampleRate, config: ResampleConfig) -> Self { + let inner = Self::create_resampler(source, target_rate, &config); + let cached_input_span_len = match &inner { + ResampleInner::Passthrough { .. } => inner.input().current_span_len(), + ResampleInner::Poly(resampler) => resampler.input.current_span_len(), + ResampleInner::Sinc(resampler) => resampler.input.current_span_len(), + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.input.current_span_len(), + }; + + Self { + inner: Some(inner), + target_rate, + config, + cached_input_span_len, + } + } + + /// Helper method to create a resampler from a source using the stored config and target rate. + fn create_resampler( + source: I, + target_rate: SampleRate, + config: &ResampleConfig, + ) -> ResampleInner { + let source_rate = source.sample_rate(); + + if source.is_exhausted() || source_rate == target_rate { + let channels = source.channels(); + ResampleInner::Passthrough { + source, + input_span_pos: 0, + channels, + source_rate, + } + } else { + let ratio = Ratio::new(target_rate.get(), source_rate.get()); + match config { + ResampleConfig::Poly { degree, chunk_size } => { + let resampler = + RubatoAsyncResample::new_poly(source, target_rate, *chunk_size, *degree) + .expect("Failed to create polynomial resampler"); + ResampleInner::Poly(resampler) + } + #[cfg(feature = "rubato-fft")] + ResampleConfig::Sinc { + sinc_len, + oversampling_factor, + interpolation, + window, + f_cutoff, + chunk_size, + sub_chunks, + } => { + if *ratio.numer() <= MAX_FIXED_RATIO && *ratio.denom() <= MAX_FIXED_RATIO { + // Use FFT resampler for optimal performance + let resampler = + RubatoFftResample::new(source, target_rate, *chunk_size, *sub_chunks) + .expect("Failed to create FFT resampler"); + ResampleInner::Fft(resampler) + } else { + let resampler = RubatoAsyncResample::new_sinc( + source, + target_rate, + *chunk_size, + *sinc_len, + *f_cutoff, + *oversampling_factor, + *interpolation, + *window, + ) + .expect("Failed to create sinc resampler"); + ResampleInner::Sinc(resampler) + } + } + #[cfg(not(feature = "rubato-fft"))] + ResampleConfig::Sinc { + sinc_len, + oversampling_factor, + interpolation, + window, + f_cutoff, + chunk_size, + } => { + if *ratio.numer() <= MAX_FIXED_RATIO && *ratio.denom() <= MAX_FIXED_RATIO { + // Fixed ratio without FFT - use Sinc::Nearest optimization + // Set oversampling_factor to match the ratio for optimal performance + let ratio = *ratio.numer().max(ratio.denom()) as usize; + let resampler = RubatoAsyncResample::new_sinc( + source, + target_rate, + *chunk_size, + *sinc_len, + *f_cutoff, + ratio, + Sinc::Nearest, + *window, + ) + .expect("Failed to create optimized sinc resampler"); + ResampleInner::Sinc(resampler) + } else { + let resampler = RubatoAsyncResample::new_sinc( + source, + target_rate, + *chunk_size, + *sinc_len, + *f_cutoff, + *oversampling_factor, + *interpolation, + *window, + ) + .expect("Failed to create sinc resampler"); + ResampleInner::Sinc(resampler) + } + } + } + } + } + + /// Returns a reference to the inner source. + #[inline] + pub fn inner(&self) -> &I { + match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => source, + ResampleInner::Poly(resampler) => &resampler.input, + ResampleInner::Sinc(resampler) => &resampler.input, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => &resampler.input, + } + } + + /// Returns a mutable reference to the inner source. + #[inline] + pub fn inner_mut(&mut self) -> &mut I { + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { source, .. } => source, + ResampleInner::Poly(resampler) => &mut resampler.input, + ResampleInner::Sinc(resampler) => &mut resampler.input, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => &mut resampler.input, + } + } + + /// Returns the inner source. + #[inline] + pub fn into_inner(self) -> I { + self.inner.unwrap().into_inner() + } +} + +impl Source for Resample +where + I: Source, +{ + #[inline] + fn current_span_len(&self) -> Option { + let ( + input_span_len, + input_sample_rate, + input_exhausted, + output_buffer_len, + output_buffer_pos, + output_frames_next, + ) = match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => return source.current_span_len(), + ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => ( + resampler.input.current_span_len(), + resampler.input.sample_rate(), + resampler.input.is_exhausted(), + resampler.output_buffer_len, + resampler.output_buffer_pos, + resampler.resampler.output_frames_next(), + ), + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => ( + resampler.input.current_span_len(), + resampler.input.sample_rate(), + resampler.input.is_exhausted(), + resampler.output_buffer_len, + resampler.output_buffer_pos, + resampler.resampler.output_frames_next(), + ), + }; + + let ratio = Ratio::new(self.sample_rate().get(), input_sample_rate.get()); + if ratio.is_integer() { + // Integer upsampling (2x, 3x, etc.) - always exact and frame-aligned + input_span_len.map(|len| *ratio.numer() as usize * len) + } else { + // When the ratio contains a fraction, we cannot choose the floor or ceiling + // arbitrarily, because the resampler may produce either based on its internal state + if output_buffer_pos < output_buffer_len { + // Running state: we are iterating over our buffer with resampled samples + Some(output_buffer_len) + } else if input_exhausted { + // End state: we are at the end of our buffer and the source is exhausted + Some(0) + } else { + // Initial state: our buffer is empty until the first call to next() loads it with + // resampled samples. Return the size of the next buffer. + Some(output_frames_next * self.channels().get() as usize) + } + } + } + + #[inline] + fn sample_rate(&self) -> SampleRate { + self.target_rate + } + + #[inline] + fn channels(&self) -> ChannelCount { + match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => source.channels(), + ResampleInner::Poly(resampler) => resampler.channels, + ResampleInner::Sinc(resampler) => resampler.channels, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.channels, + } + } + + #[inline] + fn total_duration(&self) -> Option { + match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => source.total_duration(), + ResampleInner::Poly(resampler) => resampler.input.total_duration(), + ResampleInner::Sinc(resampler) => resampler.input.total_duration(), + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.input.total_duration(), + } + } + + #[inline] + fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { source, .. } => source.try_seek(position)?, + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + r.input.try_seek(position)?; + r.reset(); + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => { + r.input.try_seek(position)?; + r.reset(); + } + } + + let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); + + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { + input_span_pos: input_samples_consumed, + .. + } => { + reset_seek_span_tracking( + input_samples_consumed, + &mut self.cached_input_span_len, + position, + input_span_len, + ); + } + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + reset_seek_span_tracking( + &mut r.input_samples_consumed, + &mut self.cached_input_span_len, + position, + input_span_len, + ); + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => { + reset_seek_span_tracking( + &mut r.input_samples_consumed, + &mut self.cached_input_span_len, + position, + input_span_len, + ); + } + } + + Ok(()) + } +} + +impl Iterator for Resample +where + I: Source, +{ + type Item = Sample; + + #[inline] + fn next(&mut self) -> Option { + let sample = match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { source, .. } => source.next()?, + ResampleInner::Poly(resampler) => resampler.next_sample()?, + ResampleInner::Sinc(resampler) => resampler.next_sample()?, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.next_sample()?, + }; + + // If input reports no span length, parameters are stable by contract + let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); + if input_span_len.is_some() { + let (expected_channels, expected_rate, samples_consumed) = + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { + input_span_pos: input_samples_consumed, + channels, + source_rate, + .. + } => { + *input_samples_consumed += 1; + (*channels, *source_rate, *input_samples_consumed) + } + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + (r.channels, r.source_rate, r.input_samples_consumed) + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => (r.channels, r.source_rate, r.input_samples_consumed), + }; + + // Get current parameters from input + let input = self.inner.as_ref().unwrap().input(); + let current_channels = input.channels(); + let current_rate = input.sample_rate(); + + // Determine if we're at a span boundary: + // - Counting mode (Some): boundary when we've consumed span_len samples + // - Detection mode (None): boundary when parameters change (mid-span seek recovery) + let mut parameters_changed = false; + let at_boundary = { + let known_boundary = self + .cached_input_span_len + .map(|cached_len| samples_consumed >= cached_len); + + // In counting mode: only check parameters at boundary + // In detection mode: check parameters at every sample until detecting a boundary + if known_boundary.is_none_or(|at_boundary| at_boundary) { + parameters_changed = + current_channels != expected_channels || current_rate != expected_rate; + } + + known_boundary.unwrap_or(parameters_changed) + }; + + if at_boundary { + // Update cached span length (exits detection mode if we were in it) + self.cached_input_span_len = input_span_len; + + if parameters_changed { + // Recreate resampler - new resampler will have counters reset to 0 + let source = self.inner.take().unwrap().into_inner(); + self.inner = Some(Self::create_resampler( + source, + self.target_rate, + &self.config, + )); + } else { + // Just crossed boundary without parameter change, reset counter + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { + input_span_pos: input_samples_consumed, + .. + } => { + *input_samples_consumed = 0; + } + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + r.input_samples_consumed = 0; + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => { + r.input_samples_consumed = 0; + } + } + } + } + } + + Some(sample) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let (input_hint, source_rate, buffered_remaining) = match self.inner.as_ref().unwrap() { + ResampleInner::Passthrough { source, .. } => return source.size_hint(), + ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => { + let input_hint = resampler.input.size_hint(); + let buffered_remaining = resampler.output_buffer_len - resampler.output_buffer_pos; + (input_hint, resampler.source_rate, buffered_remaining) + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => { + let input_hint = resampler.input.size_hint(); + let buffered_remaining = resampler.output_buffer_len - resampler.output_buffer_pos; + (input_hint, resampler.source_rate, buffered_remaining) + } + }; + + let (input_lower, input_upper) = input_hint; + let ratio = self.target_rate.get() as Float / source_rate.get() as Float; + + let lower = buffered_remaining + (input_lower as Float * ratio).ceil() as usize; + let upper = + input_upper.map(|upper| buffered_remaining + (upper as Float * ratio).ceil() as usize); + + (lower, upper) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::{from_iter, SineWave}; + use crate::Source; + use dasp_sample::ToSample; + use quickcheck::{quickcheck, Arbitrary, Gen, TestResult}; + use std::num::NonZero; + + #[derive(Debug, Clone, Copy)] + struct TestSampleRate(SampleRate); + + impl Arbitrary for TestSampleRate { + fn arbitrary(g: &mut Gen) -> Self { + // Generate realistic sample rates: 8 kHz to 384 kHz + let rate = u32::arbitrary(g) % 376_001 + 8_000; + TestSampleRate(SampleRate::new(rate).unwrap()) + } + } + + impl std::ops::Deref for TestSampleRate { + type Target = SampleRate; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + #[derive(Debug, Clone, Copy)] + struct TestChannelCount(ChannelCount); + + impl Arbitrary for TestChannelCount { + fn arbitrary(g: &mut Gen) -> Self { + // Generate realistic channel counts: 1 to 8 + let channels = (u16::arbitrary(g) % 7) + 1; + TestChannelCount(ChannelCount::new(channels).unwrap()) + } + } + + impl std::ops::Deref for TestChannelCount { + type Target = ChannelCount; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + struct TestSource { + samples: Vec, + index: usize, + sample_rate: SampleRate, + channels: ChannelCount, + } + + impl TestSource { + fn new(samples: Vec, sample_rate: SampleRate, channels: ChannelCount) -> Self { + Self { + samples, + index: 0, + sample_rate, + channels, + } + } + } + + impl Iterator for TestSource { + type Item = Sample; + + fn next(&mut self) -> Option { + if self.index < self.samples.len() { + let sample = self.samples[self.index]; + self.index += 1; + Some(sample) + } else { + None + } + } + } + + impl Source for TestSource { + fn current_span_len(&self) -> Option { + Some(self.samples.len()) + } + + fn sample_rate(&self) -> SampleRate { + self.sample_rate + } + + fn channels(&self) -> ChannelCount { + self.channels + } + + fn total_duration(&self) -> Option { + let samples = self.samples.len() / self.channels.get() as usize; + Some(Duration::from_secs_f64( + samples as f64 / self.sample_rate.get() as f64, + )) + } + + fn try_seek(&mut self, _position: Duration) -> Result<(), SeekError> { + Ok(()) + } + } + + /// Convert and truncate input to contain a frame-aligned number of samples. + fn convert_to_frames>( + input: Vec, + channels: ChannelCount, + ) -> Vec { + let mut input: Vec = input.iter().map(|x| x.to_sample()).collect(); + let frame_size = channels.get() as usize; + input.truncate(frame_size * (input.len() / frame_size)); + input + } + + quickcheck! { + /// Check that resampling an empty input produces no output. + fn empty(from: TestSampleRate, to: TestSampleRate, channels: TestChannelCount) -> bool { + let input = vec![]; + let config = ResampleConfig::default(); + let source = from_iter(input.clone().into_iter(), *channels, *from); + let output = Resample::new(source, *to, config).collect::>(); + input == output + } + + /// Check that resampling to the same rate does not change the signal. + fn identity(from: TestSampleRate, channels: TestChannelCount, input: Vec) -> bool { + let input = convert_to_frames(input, *channels); + let config = ResampleConfig::default(); + let source = from_iter(input.clone().into_iter(), *channels, *from); + let output = Resample::new(source, *from, config).collect::>(); + input == output + } + + /// Check that resampling does not change the audio duration, except by a negligible + /// amount (± 1ms). Reproduces #316. + fn preserve_durations(d: Duration, freq: f32, to: TestSampleRate) -> TestResult { + use crate::source::{SineWave, Source}; + if !freq.is_normal() || freq <= 0.0 || d > Duration::from_secs(1) { + return TestResult::discard(); + } + + let source = SineWave::new(freq).take_duration(d); + let from = source.sample_rate(); + + let config = ResampleConfig::poly().degree(Poly::Linear).build(); + let resampled = Resample::new(source, *to, config); + let duration = Duration::from_secs_f32(resampled.count() as f32 / to.get() as f32); + + let delta = duration.abs_diff(d); + TestResult::from_bool(delta < Duration::from_millis(1)) + } + } + + /// Helper to create interleaved multi-channel test data using SineWave sources. + fn create_test_input(frames: usize, channels: u16) -> Vec { + let frequencies = [440.0, 1000.0]; + let total_samples = frames * channels as usize; + let mut input = Vec::with_capacity(total_samples); + + // Create a SineWave for each channel + let mut waves: Vec<_> = (0..channels) + .map(|ch| SineWave::new(frequencies[ch as usize % frequencies.len()])) + .collect(); + + // Interleave samples from each channel + for _ in 0..frames { + for wave in waves.iter_mut() { + input.push(wave.next().unwrap()); + } + } + input + } + + /// Test various ratio types: integer, fractional, and reciprocal. + #[test] + fn test_sample_rate_conversions() { + let test_cases = [ + // (from_rate, to_rate, channels, description) + (1000, 7000, 1, "integer upsample 7x"), + (2000, 3000, 2, "fractional upsample 1.5x"), + (12000, 2400, 1, "integer downsample 1/5x"), + (48000, 44100, 2, "fractional downsample (DVD to CD)"), + (8000, 48001, 1, "async sinc"), + ]; + + let configs: &[(&str, ResampleConfig)] = &[ + ("poly", ResampleConfig::poly().build()), + ("sinc", ResampleConfig::sinc().build()), + ]; + + for (config_name, config) in configs { + for (from_rate, to_rate, channels, desc) in test_cases { + let from = SampleRate::new(from_rate).unwrap(); + let to = SampleRate::new(to_rate).unwrap(); + let ch = ChannelCount::new(channels).unwrap(); + + let input_frames = 100; + let input = create_test_input(input_frames, channels); + let input_samples = input.len(); + + let source = from_iter(input.into_iter(), ch, from); + let resampler = Resample::new(source, to, config.clone()); + + let size_hint_lower = resampler.size_hint().0; + let output_count = resampler.count(); + + assert_eq!( + output_count, size_hint_lower, + "[{config_name}] {desc}: size_hint {size_hint_lower} should equal actual output {output_count}", + ); + + let ratio = to.get() as f64 / from.get() as f64; + let expected_samples = (input_samples as f64 * ratio).ceil() as usize; + + assert_eq!( + output_count.abs_diff(expected_samples), 0, + "[{config_name}] {desc}: expected {expected_samples} samples, got {output_count}", + ); + } + } + } +} diff --git a/src/source/resample/rubato.rs b/src/source/resample/rubato.rs new file mode 100644 index 00000000..f3028e0c --- /dev/null +++ b/src/source/resample/rubato.rs @@ -0,0 +1,455 @@ +//! Rubato resampler wrapper and implementations. + +use dasp_sample::Sample as _; +use num_rational::Ratio; +use rubato::Resampler; + +use crate::source::{ChannelCount, SampleRate, Source}; +use crate::{Float, Sample}; + +use super::builder::{Poly, Sinc, WindowFunction}; + +/// Type alias for Async (polynomial/sinc) resampler. +pub type RubatoAsyncResample = RubatoResample>; + +/// Type alias for FFT resampler (synchronous, fixed-ratio). +#[cfg(feature = "rubato-fft")] +pub type RubatoFftResample = RubatoResample>; + +/// The inner resampler implementation chosen based on configuration and sample rates. +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum ResampleInner { + /// Passthrough when source rate is equal to the target rate + Passthrough { + source: I, + input_span_pos: usize, + channels: ChannelCount, + source_rate: SampleRate, + }, + + /// Polynomial resampling (fast, no anti-aliasing) + Poly(RubatoAsyncResample), + + /// Sinc resampling (with anti-aliasing) + Sinc(RubatoAsyncResample), + + /// FFT resampling for fixed ratios (synchronous resampling) + #[cfg(feature = "rubato-fft")] + #[cfg_attr(docsrs, doc(cfg(feature = "rubato-fft")))] + Fft(RubatoFftResample), +} + +impl ResampleInner { + /// Get a reference to the inner input source + #[inline] + pub fn input(&self) -> &I { + match self { + ResampleInner::Passthrough { source, .. } => source, + ResampleInner::Poly(resampler) => &resampler.input, + ResampleInner::Sinc(resampler) => &resampler.input, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => &resampler.input, + } + } + + /// Extract the inner input source, consuming the resampler + #[inline] + pub fn into_inner(self) -> I { + match self { + ResampleInner::Passthrough { source, .. } => source, + ResampleInner::Poly(resampler) => resampler.input, + ResampleInner::Sinc(resampler) => resampler.input, + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => resampler.input, + } + } +} + +/// Generic wrapper around Rubato resamplers for sample-by-sample iteration. +#[derive(Debug)] +pub struct RubatoResample> { + pub input: I, + pub resampler: R, + + pub input_buffer: Box<[Sample]>, + pub input_frame_count: usize, + + pub output_buffer: Box<[Sample]>, + pub output_buffer_pos: usize, + pub output_buffer_len: usize, + + pub channels: ChannelCount, + pub source_rate: SampleRate, + + pub input_samples_consumed: usize, + pub input_exhausted: bool, + + pub total_input_frames: usize, + pub total_output_samples: usize, + pub expected_output_samples: usize, + + /// The number of real (non-flush) frames currently in the input buffer. + pub real_frames_in_buffer: usize, + + pub output_delay_remaining: usize, + pub resample_ratio: Float, + pub indexing: rubato::Indexing, +} + +impl> RubatoResample { + /// Calculate the number of output samples to skip for delay compensation. + pub fn calculate_delay_compensation(resampler: &R, channels: ChannelCount) -> usize { + // Skip delay-1 frames to align the first output frame with input position 0. + let delay_frames = resampler.output_delay(); + let delay_to_skip = delay_frames.saturating_sub(1); + delay_to_skip * channels.get() as usize + } + + pub fn reset(&mut self) { + self.resampler.reset(); + self.output_buffer_pos = 0; + self.output_buffer_len = 0; + self.input_frame_count = 0; + self.input_samples_consumed = 0; + self.input_exhausted = false; + self.total_input_frames = 0; + self.total_output_samples = 0; + self.expected_output_samples = 0; + self.real_frames_in_buffer = 0; + self.indexing.partial_len = None; + self.output_delay_remaining = + Self::calculate_delay_compensation(&self.resampler, self.channels); + } + + pub fn next_sample(&mut self) -> Option { + let num_channels = self.channels.get() as usize; + loop { + // If we have buffered output, return it + if self.output_buffer_pos < self.output_buffer_len { + let sample = self.output_buffer[self.output_buffer_pos]; + self.output_buffer_pos += 1; + self.total_output_samples += 1; + + if self.total_output_samples > self.expected_output_samples { + // Cut off filter artifacts after input is exhausted + return None; + } + + return Some(sample); + } + + // Need more input - first check if we're completely done + if self.input_exhausted + && self.input_frame_count == 0 + && self.total_output_samples >= self.expected_output_samples + { + return None; + } + + // Fill input buffer - accumulate frames until we hit needed amount or run out of input + let needed_input = self.resampler.input_frames_next(); + let frames_before = self.input_frame_count; + while self.input_frame_count < needed_input && !self.input_exhausted { + let sample_pos = self.input_frame_count * num_channels; + for ch in 0..num_channels { + if let Some(sample) = self.input.next() { + self.input_buffer[sample_pos + ch] = sample; + } else { + self.input_exhausted = true; + break; + } + } + if !self.input_exhausted { + self.input_frame_count += 1; + self.real_frames_in_buffer += 1; + } + } + + // If we have no input, flush the filter tail with zeros + if self.input_frame_count == 0 { + // Zero-pad a full chunk to drain the filter delay + self.input_buffer[..needed_input * num_channels].fill(Sample::EQUILIBRIUM); + self.input_frame_count = needed_input; + // real_frames_in_buffer stays at 0 - these are flush frames + } + + // We can process with fewer frames than needed using partial_len when the input is + // exhausted. If we don't have enough input and more is coming, wait. + let made_progress = self.input_frame_count > frames_before; + if self.input_frame_count < needed_input && !self.input_exhausted && made_progress { + continue; + } + + let actual_frames = self.input_frame_count; + + // Prevent stack allocations in the hot path by reusing the indexing struct + let indexing_ref = if actual_frames < needed_input { + self.indexing.partial_len = Some(actual_frames); + Some(&self.indexing) + } else { + None + }; + + let (frames_in, frames_out) = { + // InterleavedSlice is a zero-cost abstraction - no heap allocation occurs here + let input_adapter = audioadapter_buffers::direct::InterleavedSlice::new( + &self.input_buffer, + num_channels, + actual_frames, + ) + .ok()?; + + let num_frames = self.output_buffer.len() / num_channels; + let mut output_adapter = audioadapter_buffers::direct::InterleavedSlice::new_mut( + &mut self.output_buffer, + num_channels, + num_frames, + ) + .ok()?; + + self.resampler + .process_into_buffer(&input_adapter, &mut output_adapter, indexing_ref) + .ok()? + }; + + // If no output was produced and input is exhausted, we're done + if frames_out == 0 && self.input_exhausted { + return None; + } + + // When using partial_len, Rubato may report consuming more frames than we + // actually provided (it counts the zero-padded frames). Clamp to actual. + let actual_consumed = frames_in.min(actual_frames); + self.input_samples_consumed += actual_consumed * num_channels; + + // Only count real (non-flush) frames toward expected output + let real_consumed = actual_consumed.min(self.real_frames_in_buffer); + self.real_frames_in_buffer -= real_consumed; + self.total_input_frames += real_consumed; + self.expected_output_samples = (self.total_input_frames as Float * self.resample_ratio) + .ceil() as usize + * num_channels; + + // Shift remaining input samples to beginning of buffer + if actual_consumed < self.input_frame_count { + let src_start = actual_consumed * num_channels; + let src_end = self.input_frame_count * num_channels; + self.input_buffer.copy_within(src_start..src_end, 0); + } + self.input_frame_count -= actual_consumed; + + self.output_buffer_pos = 0; + self.output_buffer_len = frames_out * num_channels; + + // Skip warmup delay samples + if self.output_delay_remaining > 0 { + let samples_to_skip = self.output_delay_remaining.min(self.output_buffer_len); + self.output_buffer_pos += samples_to_skip; + self.output_delay_remaining -= samples_to_skip; + } + } + } +} + +// Async resampler (polynomial and sinc) implementations +impl RubatoAsyncResample { + pub fn new_poly( + input: I, + target_rate: SampleRate, + chunk_size: usize, + degree: Poly, + ) -> Result { + let source_rate = input.sample_rate(); + let channels = input.channels(); + + let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; + + let resampler = rubato::Async::new_poly( + resample_ratio as _, + 1.0, + degree.into(), + chunk_size, + channels.get() as usize, + rubato::FixedAsync::Output, + ) + .map_err(|e| format!("Failed to create polynomial resampler: {:?}", e))?; + + let input_buf_size = resampler.input_frames_max(); + let output_buf_size = resampler.output_frames_max(); + + let output_delay_remaining = + RubatoResample::>::calculate_delay_compensation( + &resampler, channels, + ); + + Ok(Self { + input, + resampler, + input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] + .into_boxed_slice(), + input_frame_count: 0, + output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] + .into_boxed_slice(), + output_buffer_pos: 0, + output_buffer_len: 0, + channels, + source_rate, + input_samples_consumed: 0, + input_exhausted: false, + output_delay_remaining, + total_input_frames: 0, + total_output_samples: 0, + expected_output_samples: 0, + real_frames_in_buffer: 0, + resample_ratio, + indexing: rubato::Indexing { + input_offset: 0, + output_offset: 0, + partial_len: None, + active_channels_mask: None, + }, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_sinc( + input: I, + target_rate: SampleRate, + chunk_size: usize, + sinc_len: usize, + f_cutoff: Float, + oversampling_factor: usize, + interpolation: Sinc, + window: WindowFunction, + ) -> Result { + let source_rate = input.sample_rate(); + let channels = input.channels(); + + let parameters = rubato::SincInterpolationParameters { + sinc_len, + f_cutoff: f_cutoff as _, + oversampling_factor, + interpolation: interpolation.into(), + window: window.into(), + }; + + let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; + + let resampler = rubato::Async::new_sinc( + resample_ratio as _, + 1.0, + ¶meters, + chunk_size, + channels.get() as usize, + rubato::FixedAsync::Output, + ) + .map_err(|e| format!("Failed to create sinc resampler: {:?}", e))?; + + let input_buf_size = resampler.input_frames_max(); + let output_buf_size = resampler.output_frames_max(); + + let output_delay_remaining = + RubatoResample::>::calculate_delay_compensation( + &resampler, channels, + ); + + Ok(Self { + input, + resampler, + input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] + .into_boxed_slice(), + input_frame_count: 0, + output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] + .into_boxed_slice(), + output_buffer_pos: 0, + output_buffer_len: 0, + channels, + source_rate, + input_samples_consumed: 0, + input_exhausted: false, + output_delay_remaining, + total_input_frames: 0, + total_output_samples: 0, + expected_output_samples: 0, + real_frames_in_buffer: 0, + resample_ratio, + indexing: rubato::Indexing { + input_offset: 0, + output_offset: 0, + partial_len: None, + active_channels_mask: None, + }, + }) + } +} + +// FFT resampler implementation +#[cfg(feature = "rubato-fft")] +impl RubatoFftResample { + /// Create a new FFT resampler for fixed-ratio sample rate conversion. + /// + /// The FFT resampler requires that: + /// - Input chunk size must be a multiple of the GCD-reduced denominator + /// - Output chunk size must be a multiple of the GCD-reduced numerator + pub fn new( + input: I, + target_rate: SampleRate, + chunk_size: usize, + sub_chunks: usize, + ) -> Result { + let source_rate = input.sample_rate(); + let channels = input.channels(); + + // Calculate the GCD-reduced ratio + let ratio = Ratio::new(target_rate.get(), source_rate.get()); + let (_num, den) = ratio.into_raw(); + + // Determine input chunk size - must be multiple of denominator + let input_chunk_size = ((chunk_size / den as usize) + 1) * den as usize; + + let resampler = rubato::Fft::new( + source_rate.get() as usize, + target_rate.get() as usize, + input_chunk_size, + sub_chunks, + channels.get() as usize, + rubato::FixedSync::Output, + ) + .map_err(|e| format!("Failed to create FFT resampler: {:?}", e))?; + + let input_buf_size = resampler.input_frames_max(); + let output_buf_size = resampler.output_frames_max(); + let resample_ratio = target_rate.get() as Float / source_rate.get() as Float; + + let output_delay_remaining = Self::calculate_delay_compensation(&resampler, channels); + + Ok(Self { + input, + resampler, + input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] + .into_boxed_slice(), + input_frame_count: 0, + output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] + .into_boxed_slice(), + output_buffer_pos: 0, + output_buffer_len: 0, + channels, + source_rate, + input_samples_consumed: 0, + input_exhausted: false, + total_input_frames: 0, + total_output_samples: 0, + expected_output_samples: 0, + real_frames_in_buffer: 0, + output_delay_remaining, + resample_ratio, + indexing: rubato::Indexing { + input_offset: 0, + output_offset: 0, + partial_len: None, + active_channels_mask: None, + }, + }) + } +} From 4ecec6a023c5d123d274a827854498fe9eaab935 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 12 Feb 2026 21:21:51 +0100 Subject: [PATCH 10/20] style: cargo fmt --- src/source/resample/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/source/resample/mod.rs b/src/source/resample/mod.rs index 2cd09c9d..b9ea2652 100644 --- a/src/source/resample/mod.rs +++ b/src/source/resample/mod.rs @@ -77,8 +77,8 @@ use std::time::Duration; -use num_rational::Ratio; use ::rubato::Resampler as _; +use num_rational::Ratio; use super::{reset_seek_span_tracking, SeekError}; use crate::{ @@ -89,9 +89,9 @@ use crate::{ mod builder; mod rubato; -use rubato::{ResampleInner, RubatoAsyncResample}; #[cfg(feature = "rubato-fft")] use rubato::RubatoFftResample; +use rubato::{ResampleInner, RubatoAsyncResample}; pub use builder::{ Poly, PolyConfigBuilder, ResampleConfig, Sinc, SincConfigBuilder, WindowFunction, From 3e486d7a2f330ccff24e449e0d8a83d3c60cd907 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 15 May 2026 13:00:02 +0200 Subject: [PATCH 11/20] refactor: address review points --- Cargo.lock | 90 +++++++++++++++ Cargo.toml | 1 + examples/resample.rs | 160 ++++++++++++-------------- src/decoder/symphonia.rs | 14 +-- src/queue.rs | 75 ++++++++++-- src/source/chain.rs | 55 +++++---- src/source/resample/buffer.rs | 90 +++++++++++++++ src/source/resample/builder.rs | 38 ++++++- src/source/resample/mod.rs | 201 ++++++++++++++++++--------------- src/source/resample/rubato.rs | 173 ++++++++++++++-------------- tests/vorbis_test.rs | 16 +++ 11 files changed, 594 insertions(+), 319 deletions(-) create mode 100644 src/source/resample/buffer.rs create mode 100644 tests/vorbis_test.rs diff --git a/Cargo.lock b/Cargo.lock index 1c3e2e06..23f70b36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,12 +43,56 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -279,6 +323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -287,11 +332,25 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", "terminal_size", ] +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "1.1.0" @@ -313,6 +372,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -839,6 +904,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1373,6 +1444,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opusic-sys" version = "0.7.3" @@ -1659,6 +1736,7 @@ dependencies = [ "approx", "atomic_float", "audioadapter-buffers", + "clap", "claxon", "cpal", "crossbeam-channel", @@ -1953,6 +2031,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "symphonia" version = "0.5.5" @@ -2391,6 +2475,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7835cee0..b8eaf5b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -180,6 +180,7 @@ approx = "0.5.1" divan = "0.1.14" inquire = "0.9.3" symphonia-adapter-fdk-aac = "0.1" +clap = { version = "4", features = ["derive"] } [[bench]] name = "effects" diff --git a/examples/resample.rs b/examples/resample.rs index fc5508b3..4953a6e0 100644 --- a/examples/resample.rs +++ b/examples/resample.rs @@ -1,118 +1,100 @@ //! Example demonstrating audio resampling with different quality presets. -use rodio::source::{resample::Poly, ResampleConfig, Source}; -use rodio::{Decoder, Player}; +use clap::Parser; +use rodio::source::{ResampleConfig, Source}; +use rodio::{Decoder, DeviceSinkBuilder, Player}; use std::error::Error; -use std::fs::File; -use std::io::BufReader; +use std::num::NonZero; +use std::path::PathBuf; use std::time::Instant; -fn main() -> Result<(), Box> { - #[cfg(debug_assertions)] - { - eprintln!("WARNING: Running in debug mode. Audio may be choppy, especially with"); - eprintln!(" sinc resampling of non-integer ratios (async resampling)."); - eprintln!(" For best results, compile with --release"); - eprintln!(); - } +#[derive(Parser)] +#[command(about = "Resample audio using different quality presets")] +struct Args { + /// Target sample rate in Hz (default: device native rate) + #[arg(long = "rate")] + target_rate: Option>, - let args: Vec = std::env::args().collect(); + /// Path to audio file + #[arg(long = "file", default_value = "assets/music.ogg")] + audio_file: PathBuf, - if args.len() < 2 { - eprintln!("Usage: {} [audio_file] [method]", args[0]); - eprintln!("\nTarget rate: Sample rate in Hz (e.g., 48000, 96000)"); - eprintln!("\nAudio file (optional): Path to audio file (default: assets/music.ogg)"); - eprintln!("\nMethod (optional): nearest, linear, fast, balanced, accurate"); - eprintln!("\nMethod details:"); - eprintln!(" nearest - Zero-order hold (non-oversampling), fastest"); - eprintln!(" linear - Linear polynomial interpolation, fast"); - eprintln!(" very_fast - 64-tap sinc, linear interpolation, Hann2 window"); - eprintln!(" fast - 128-tap sinc, linear interpolation, Hann2 window"); - eprintln!(" balanced - 192-tap sinc, linear interpolation, Blackman2 window (default)"); - eprintln!(" accurate - 256-tap sinc, cubic interpolation, BlackmanHarris2 window"); - eprintln!("\nExamples:"); - eprintln!(" {} 48000", args[0]); - eprintln!(" {} 96000 assets/music.ogg accurate", args[0]); - std::process::exit(1); - } + /// Resampling method + #[arg(long = "method", value_enum, default_value_t = Method::Balanced)] + method: Method, +} - let target_rate: u32 = args[1].parse().map_err(|_| { - format!( - "Invalid target rate '{}'. Must be a positive integer (e.g., 48000)", - args[1] - ) - })?; +#[derive(clap::ValueEnum, Clone)] +enum Method { + /// Nearest-neighbor (zero-order hold) polynomial resampling. Fastest, no anti-aliasing. + Nearest, + /// Linear polynomial resampling. Fast, no anti-aliasing. + Linear, + /// Cubic polynomial resampling. Smoother than linear, no anti-aliasing. + Cubic, + /// Quintic polynomial resampling. Smoother than cubic, no anti-aliasing. + Quintic, + /// Septic polynomial resampling. Highest polynomial quality, no anti-aliasing. + Septic, + /// 64-tap sinc, linear interpolation, Hann2 window. + VeryFast, + /// 128-tap sinc, linear interpolation, Blackman2 window. + Fast, + /// 192-tap sinc, quadratic interpolation, BlackmanHarris2 window (default). + Balanced, + /// 256-tap sinc, cubic interpolation, BlackmanHarris2 window. + Accurate, +} - if target_rate == 0 { - return Err("Target rate must be greater than 0".into()); +impl From for ResampleConfig { + fn from(method: Method) -> Self { + match method { + Method::Nearest => ResampleConfig::nearest(), + Method::Linear => ResampleConfig::linear(), + Method::Cubic => ResampleConfig::cubic(), + Method::Quintic => ResampleConfig::quintic(), + Method::Septic => ResampleConfig::septic(), + Method::VeryFast => ResampleConfig::very_fast(), + Method::Fast => ResampleConfig::fast(), + Method::Balanced => ResampleConfig::balanced(), + Method::Accurate => ResampleConfig::accurate(), + } } +} - let audio_path = if args.len() > 2 { - args[2].clone() - } else { - "assets/music.ogg".to_string() - }; +fn main() -> Result<(), Box> { + let args = Args::parse(); + let config = ResampleConfig::from(args.method); - let config = if args.len() > 3 { - parse_quality(&args[3])? - } else { - ResampleConfig::default() + let builder = DeviceSinkBuilder::from_default_device()?; + let stream_handle = match args.target_rate { + Some(rate) => builder.with_sample_rate(rate).open_stream()?, + None => builder.open_stream()?, }; + let target_rate = stream_handle.config().sample_rate(); - println!("Audio file: {audio_path}"); - - let file = File::open(&audio_path) - .map_err(|e| format!("Failed to open audio file '{audio_path}': {e}"))?; - let source = Decoder::try_from(BufReader::new(file))?; + let file = std::fs::File::open(&args.audio_file) + .map_err(|e| format!("Failed to open '{}': {e}", args.audio_file.display()))?; + let source = Decoder::try_from(file)?; let source_rate = source.sample_rate().get(); let channels = source.channels().get(); - let duration = source.total_duration(); - if let Some(dur) = duration { - println!("Duration: {:.2}s", dur.as_secs_f32()); + if let Some(dur) = source.total_duration() { + println!("Duration: {dur:?}"); } - println!("\nResampling {channels} channels from {source_rate} Hz to {target_rate} Hz..."); + println!("Resampling {channels}ch {source_rate} Hz → {target_rate} Hz"); println!("Configuration: {config:#?}"); - let resampled = source.resample(rodio::SampleRate::new(target_rate).unwrap(), config); - println!("\nConfiguring output device to {target_rate} Hz..."); - let stream_handle = rodio::DeviceSinkBuilder::from_default_device()? - .with_sample_rate(rodio::SampleRate::new(target_rate).unwrap()) - .open_stream()?; + let resampled = source.resample(target_rate, config); let player = Player::connect_new(stream_handle.mixer()); - println!("Playing resampled audio..."); - println!("Press Ctrl+C to stop"); - - let playback_start = Instant::now(); + println!("Playing... (Ctrl+C to stop)"); + let start = Instant::now(); player.append(resampled); player.sleep_until_end(); - let playback_time = playback_start.elapsed(); - println!("\nPlayback finished in {:.2}s", playback_time.as_secs_f32()); - + println!("Finished in {:?}", start.elapsed()); Ok(()) } - -/// Parse the resampling quality from a string argument -fn parse_quality(method: &str) -> Result> { - let config = match method.to_lowercase().as_str() { - "nearest" => ResampleConfig::poly().degree(Poly::Nearest).build(), - "linear" => ResampleConfig::poly().degree(Poly::Linear).build(), - "cubic" => ResampleConfig::poly().degree(Poly::Cubic).build(), - "quintic" => ResampleConfig::poly().degree(Poly::Quintic).build(), - "septic" => ResampleConfig::poly().degree(Poly::Septic).build(), - "very_fast" => ResampleConfig::very_fast(), - "fast" => ResampleConfig::fast(), - "balanced" => ResampleConfig::balanced(), - "accurate" => ResampleConfig::accurate(), - _ => return Err(format!( - "Unknown resampling method '{}'. Valid options: nearest, linear, cubic, quintic, septic, very_fast, fast, balanced, accurate", - method - ) - .into()), - }; - Ok(config) -} diff --git a/src/decoder/symphonia.rs b/src/decoder/symphonia.rs index fd3383ac..5e1c6884 100644 --- a/src/decoder/symphonia.rs +++ b/src/decoder/symphonia.rs @@ -147,8 +147,9 @@ impl SymphoniaDecoder { continue; } - let decoded = match decoder.decode(¤t_span) { - Ok(decoded) => decoded, + match decoder.decode(¤t_span) { + Ok(decoded) if decoded.frames() > 0 => break decoded, + Ok(_) => continue, // skip setup/header packets with no audio frames (e.g. Vorbis) Err(e) => match e { Error::DecodeError(_) => { // Decode errors are intentionally ignored with no retry limit. @@ -158,15 +159,6 @@ impl SymphoniaDecoder { } _ => return Err(e), }, - }; - - // 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; } }; let spec = decoded.spec().to_owned(); diff --git a/src/queue.rs b/src/queue.rs index b85bcef7..3b9f9577 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -159,10 +159,22 @@ impl Source for SourcesQueueOutput { #[inline] fn channels(&self) -> ChannelCount { 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. + // Skip exhausted sources at the head of the queue (e.g. an empty chain) and + // return the first non-exhausted source's metadata. This is critical: + // UniformSourceIterator queries metadata before pulling any samples, so we + // must report the upcoming source's format, not a preceding exhausted stub. + // + // If the queue is genuinely empty there is nothing to peek at. The stale value + // is returned below. This is corrected at the first span boundary after the + // new source begins playing. + if let Some((next, _)) = self + .input + .next_sounds + .lock() + .unwrap() + .iter() + .find(|(s, _)| !s.is_exhausted()) + { return next.channels(); } } @@ -173,9 +185,14 @@ impl Source for SourcesQueueOutput { #[inline] fn sample_rate(&self) -> SampleRate { 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 + if let Some((next, _)) = self + .input + .next_sounds + .lock() + .unwrap() + .iter() + .find(|(s, _)| !s.is_exhausted()) + { return next.sample_rate(); } } @@ -277,9 +294,47 @@ impl SourcesQueueOutput { mod tests { use crate::buffer::SamplesBuffer; use crate::math::nz; - use crate::queue; - use crate::source::test_utils::TestSource; - use crate::source::Source; + use crate::source::{chain, SeekError, Source}; + use crate::{queue, ChannelCount, Sample, SampleRate}; + use std::time::Duration; + + #[test] + #[ignore = "known limitation: metadata gap when queue is briefly empty after exhaustion"] + fn metadata_gap_when_queue_briefly_empty() { + let new_rate = nz!(48000); + let (tx, mut rx) = queue::queue(false); + tx.append(SamplesBuffer::new(nz!(1), nz!(44100), vec![1.0_f32])); + assert_eq!(rx.next(), Some(1.0)); + + // Source is exhausted, nothing queued yet. A real consumer reads metadata here + // to set up its converter — it gets the stale value. + let rate_seen_by_consumer = rx.sample_rate(); + + // The replacement source arrives only after the metadata was already queried. + tx.append(SamplesBuffer::new(nz!(1), new_rate, vec![2.0_f32])); + + // Ideally the consumer would have seen 48000. In practice it saw 44100. + assert_eq!(rate_seen_by_consumer, new_rate); + } + + #[test] + fn exhausted_source_in_queue_is_skipped_for_metadata() { + let source_rate = nz!(48000); + // The empty chain's dummy rate must differ from source_rate, otherwise the test + // would not catch the bug (both values would satisfy the assertion below). + let empty_chain_dummy_rate = chain(std::iter::empty::()).sample_rate(); + assert_ne!(empty_chain_dummy_rate, source_rate); + + let (tx, mut rx) = queue::queue(false); + tx.append(chain(std::iter::empty::())); + tx.append(SamplesBuffer::new(nz!(1), source_rate, vec![1.0_f32, 2.0])); + + assert_eq!(rx.channels(), nz!(1)); + assert_eq!(rx.sample_rate(), source_rate); + assert_eq!(rx.next(), Some(1.0)); + assert_eq!(rx.next(), Some(2.0)); + assert_eq!(rx.next(), None); + } #[test] fn basic() { diff --git a/src/source/chain.rs b/src/source/chain.rs index e0bd85d9..203cf4a7 100644 --- a/src/source/chain.rs +++ b/src/source/chain.rs @@ -78,36 +78,14 @@ 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); - } - } + // The transition between sources must be a span boundary. We propagate the current + // source's span length directly. When the source is exhausted it already returns Some(0), + // which correctly signals end-of-span. The None case (empty iterator) is likewise + // signalled as Some(0). + match &self.current_source { + None => Some(0), + Some(src) => src.current_span_len(), } - - // Otherwise we use the constant value. - Some(THRESHOLD) } #[inline] @@ -151,6 +129,25 @@ mod tests { use crate::math::nz; use crate::source::{chain, Source}; + #[test] + fn empty_chain_reports_end_of_span() { + let c = chain(std::iter::empty::()); + assert_eq!(c.current_span_len(), Some(0)); + } + + #[test] + fn exhausted_chain_reports_end_of_span() { + let mut c = chain(std::iter::once(SamplesBuffer::new( + nz!(1), + nz!(48000), + vec![1.0_f32, 2.0], + ))); + assert_eq!(c.next(), Some(1.0)); + assert_eq!(c.next(), Some(2.0)); + assert_eq!(c.next(), None); + assert_eq!(c.current_span_len(), Some(0)); + } + #[test] fn basic() { let mut rx = chain((0..2).map(|n| { diff --git a/src/source/resample/buffer.rs b/src/source/resample/buffer.rs new file mode 100644 index 00000000..7e39be47 --- /dev/null +++ b/src/source/resample/buffer.rs @@ -0,0 +1,90 @@ +//! Fixed-capacity sample buffer with a read cursor. +//! +//! Holds one chunk of resampled output. Callers reset it with the number of freshly written +//! samples, optionally skip delay samples at the head, optionally cap it to trim filter +//! artifacts at the tail, and then drain it sample-by-sample. + +use std::fmt; + +use crate::Sample; +use dasp_sample::Sample as _; + +/// Fixed-capacity sample buffer with a read cursor. +pub struct Buffer { + data: Box<[Sample]>, + pos: usize, + len: usize, +} + +impl fmt::Debug for Buffer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Buffer") + .field("capacity", &self.data.len()) + .field("pos", &self.pos) + .field("len", &self.len) + .finish() + } +} + +impl Buffer { + /// Create a new buffer with the given capacity, initialized to equilibrium samples. + pub fn new(capacity: usize) -> Self { + Self { + data: vec![Sample::EQUILIBRIUM; capacity].into_boxed_slice(), + pos: 0, + len: 0, + } + } + + /// Reset for a new fill: rewind cursor to 0 and record the number of valid samples. + pub fn reset(&mut self, filled: usize) { + self.pos = 0; + self.len = filled; + } + + /// Advance the cursor by `n` samples (capped at `len`). + pub fn skip(&mut self, n: usize) { + self.pos = (self.pos + n).min(self.len); + } + + /// Shrink `len` so at most `remaining` more samples will be returned from the cursor. + pub fn cap_to_remaining(&mut self, remaining: usize) { + self.len = self.len.min(self.pos + remaining); + } + + /// True when the cursor has reached the end of the valid data. + #[inline] + pub fn is_empty(&self) -> bool { + self.pos >= self.len + } + + /// Read the next sample and advance the cursor. Panics in debug if the buffer is empty. + #[inline] + pub fn read(&mut self) -> Sample { + debug_assert!(!self.is_empty(), "read from empty Buffer"); + let s = self.data[self.pos]; + self.pos += 1; + s + } + + /// Total capacity of the backing allocation. + pub fn capacity(&self) -> usize { + self.data.len() + } + + /// Number of valid samples set by the last `reset` call. + pub fn len(&self) -> usize { + self.len + } + + /// Number of samples remaining before the cursor reaches the end. + #[inline] + pub fn remaining(&self) -> usize { + self.len - self.pos + } + + /// Full backing slice for writing via an audio adapter. + pub fn as_mut_slice(&mut self) -> &mut [Sample] { + &mut self.data + } +} diff --git a/src/source/resample/builder.rs b/src/source/resample/builder.rs index 4c192ff3..1c830ed8 100644 --- a/src/source/resample/builder.rs +++ b/src/source/resample/builder.rs @@ -56,16 +56,21 @@ pub enum Sinc { /// result is equivalent to that of synchronous resampling. Nearest, - /// Linear interpolation between two nearest points. + /// Linear interpolation between two adjacent sinc filter coefficients. /// - /// Relatively fast, but needs a large number of intermediate points to push the resampling - /// artefacts below the noise floor. + /// To resample to a fractional position, Rubato looks up the nearest two entries in the + /// precomputed sinc coefficient table and draws a straight line between them. Because the sinc + /// function is curved, this straight-line segment requires more intermediate points to push + /// the resampling artefacts below the noise floor. This is achieved using a higher + /// [`oversampling_factor`](SincConfigBuilder::oversampling_factor). [`Cubic`](Sinc::Cubic) + /// fits a polynomial that follows the curvature, achieving the same accuracy with a smaller + /// table. #[default] Linear, /// Quadratic interpolation using three nearest points. /// - /// The computation time lies approximately halfway between that of linear and quadratic + /// The computation time lies approximately halfway between that of linear and cubic /// interpolation. Quadratic, @@ -297,6 +302,31 @@ impl ResampleConfig { } } + /// Nearest-neighbor (zero-order hold) polynomial resampling. Fastest, no anti-aliasing. + pub fn nearest() -> Self { + Self::poly().degree(Poly::Nearest).build() + } + + /// Linear polynomial resampling. Fast, no anti-aliasing. + pub fn linear() -> Self { + Self::poly().degree(Poly::Linear).build() + } + + /// Cubic polynomial resampling. Smoother than linear, no anti-aliasing. + pub fn cubic() -> Self { + Self::poly().degree(Poly::Cubic).build() + } + + /// Quintic polynomial resampling. Smoother than cubic, no anti-aliasing. + pub fn quintic() -> Self { + Self::poly().degree(Poly::Quintic).build() + } + + /// Septic polynomial resampling. Highest polynomial quality, no anti-aliasing. + pub fn septic() -> Self { + Self::poly().degree(Poly::Septic).build() + } + /// Create a polynomial resampling configuration builder. pub fn poly() -> PolyConfigBuilder { PolyConfigBuilder::default() diff --git a/src/source/resample/mod.rs b/src/source/resample/mod.rs index b9ea2652..d0c4269d 100644 --- a/src/source/resample/mod.rs +++ b/src/source/resample/mod.rs @@ -86,6 +86,7 @@ use crate::{ Float, Source, }; +mod buffer; mod builder; mod rubato; @@ -106,6 +107,7 @@ pub struct Resample where I: Source, { + // Kept in Option so we can take ownership for in-place recreation on parameter change inner: Option>, target_rate: SampleRate, config: ResampleConfig, @@ -130,6 +132,16 @@ where /// Create a new resampler with the given configuration. pub fn new(source: I, target_rate: SampleRate, config: ResampleConfig) -> Self { let inner = Self::create_resampler(source, target_rate, &config); + + #[cfg(debug_assertions)] + if matches!(inner, ResampleInner::Sinc(_)) { + eprintln!( + "Warning: async sinc resampling is active. This is CPU-intensive and may \ + produce choppy audio in a debug build. Either use an integer-multiple ratio \ + or compile with --release." + ); + } + let cached_input_span_len = match &inner { ResampleInner::Passthrough { .. } => inner.input().current_span_len(), ResampleInner::Poly(resampler) => resampler.input.current_span_len(), @@ -246,6 +258,16 @@ where } } + #[inline] + fn resampler(&self) -> &ResampleInner { + self.inner.as_ref().unwrap() + } + + #[inline] + fn resampler_mut(&mut self) -> &mut ResampleInner { + self.inner.as_mut().unwrap() + } + /// Returns a reference to the inner source. #[inline] pub fn inner(&self) -> &I { @@ -275,6 +297,33 @@ where pub fn into_inner(self) -> I { self.inner.unwrap().into_inner() } + + /// Returns `(at_boundary, parameters_changed)` given span tracking state. + /// + /// Two modes: + /// - Counting (`cached_span_len` is `Some`): boundary when `samples_consumed >= span_len` + /// - Detection (`cached_span_len` is `None`): boundary when parameters change (post-seek) + fn detect_boundary( + cached_span_len: Option, + samples_consumed: usize, + current_channels: ChannelCount, + expected_channels: ChannelCount, + current_rate: SampleRate, + expected_rate: SampleRate, + ) -> (bool, bool) { + let known_boundary = cached_span_len.map(|len| samples_consumed >= len); + // In counting mode: only check parameters at boundary + // In detection mode: check parameters at every sample until a boundary is detected + let parameters_changed = if known_boundary.is_none_or(|at| at) { + current_channels != expected_channels || current_rate != expected_rate + } else { + false + }; + ( + known_boundary.unwrap_or(parameters_changed), + parameters_changed, + ) + } } impl Source for Resample @@ -287,8 +336,8 @@ where input_span_len, input_sample_rate, input_exhausted, - output_buffer_len, - output_buffer_pos, + output_has_samples, + output_len, output_frames_next, ) = match self.inner.as_ref().unwrap() { ResampleInner::Passthrough { source, .. } => return source.current_span_len(), @@ -296,8 +345,8 @@ where resampler.input.current_span_len(), resampler.input.sample_rate(), resampler.input.is_exhausted(), - resampler.output_buffer_len, - resampler.output_buffer_pos, + resampler.output_has_samples(), + resampler.output_len(), resampler.resampler.output_frames_next(), ), #[cfg(feature = "rubato-fft")] @@ -305,8 +354,8 @@ where resampler.input.current_span_len(), resampler.input.sample_rate(), resampler.input.is_exhausted(), - resampler.output_buffer_len, - resampler.output_buffer_pos, + resampler.output_has_samples(), + resampler.output_len(), resampler.resampler.output_frames_next(), ), }; @@ -318,9 +367,9 @@ where } else { // When the ratio contains a fraction, we cannot choose the floor or ceiling // arbitrarily, because the resampler may produce either based on its internal state - if output_buffer_pos < output_buffer_len { + if output_has_samples { // Running state: we are iterating over our buffer with resampled samples - Some(output_buffer_len) + Some(output_len) } else if input_exhausted { // End state: we are at the end of our buffer and the source is exhausted Some(0) @@ -339,29 +388,17 @@ where #[inline] fn channels(&self) -> ChannelCount { - match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => source.channels(), - ResampleInner::Poly(resampler) => resampler.channels, - ResampleInner::Sinc(resampler) => resampler.channels, - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => resampler.channels, - } + self.resampler().input().channels() } #[inline] fn total_duration(&self) -> Option { - match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => source.total_duration(), - ResampleInner::Poly(resampler) => resampler.input.total_duration(), - ResampleInner::Sinc(resampler) => resampler.input.total_duration(), - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => resampler.input.total_duration(), - } + self.resampler().input().total_duration() } #[inline] fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { - match self.inner.as_mut().unwrap() { + match self.resampler_mut() { ResampleInner::Passthrough { source, .. } => source.try_seek(position)?, ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { r.input.try_seek(position)?; @@ -374,7 +411,7 @@ where } } - let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); + let input_span_len = self.resampler().input().current_span_len(); match self.inner.as_mut().unwrap() { ResampleInner::Passthrough { @@ -429,77 +466,65 @@ where // If input reports no span length, parameters are stable by contract let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); - if input_span_len.is_some() { - let (expected_channels, expected_rate, samples_consumed) = + if input_span_len.is_none() { + return Some(sample); + } + + let (expected_channels, expected_rate, samples_consumed) = + match self.inner.as_mut().unwrap() { + ResampleInner::Passthrough { + input_span_pos: input_samples_consumed, + channels, + source_rate, + .. + } => { + *input_samples_consumed += 1; + (*channels, *source_rate, *input_samples_consumed) + } + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + (r.channels, r.source_rate, r.input_samples_consumed) + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => (r.channels, r.source_rate, r.input_samples_consumed), + }; + + let input = self.inner.as_ref().unwrap().input(); + let (at_boundary, parameters_changed) = Self::detect_boundary( + self.cached_input_span_len, + samples_consumed, + input.channels(), + expected_channels, + input.sample_rate(), + expected_rate, + ); + + if at_boundary { + // Update cached span length (exits detection mode if we were in it) + self.cached_input_span_len = input_span_len; + + if parameters_changed { + // Recreate resampler - new resampler will have counters reset to 0 + let source = self.inner.take().unwrap().into_inner(); + self.inner = Some(Self::create_resampler( + source, + self.target_rate, + &self.config, + )); + } else { + // Just crossed boundary without parameter change, reset counter match self.inner.as_mut().unwrap() { ResampleInner::Passthrough { input_span_pos: input_samples_consumed, - channels, - source_rate, .. } => { - *input_samples_consumed += 1; - (*channels, *source_rate, *input_samples_consumed) + *input_samples_consumed = 0; } ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { - (r.channels, r.source_rate, r.input_samples_consumed) + r.input_samples_consumed = 0; } #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(r) => (r.channels, r.source_rate, r.input_samples_consumed), - }; - - // Get current parameters from input - let input = self.inner.as_ref().unwrap().input(); - let current_channels = input.channels(); - let current_rate = input.sample_rate(); - - // Determine if we're at a span boundary: - // - Counting mode (Some): boundary when we've consumed span_len samples - // - Detection mode (None): boundary when parameters change (mid-span seek recovery) - let mut parameters_changed = false; - let at_boundary = { - let known_boundary = self - .cached_input_span_len - .map(|cached_len| samples_consumed >= cached_len); - - // In counting mode: only check parameters at boundary - // In detection mode: check parameters at every sample until detecting a boundary - if known_boundary.is_none_or(|at_boundary| at_boundary) { - parameters_changed = - current_channels != expected_channels || current_rate != expected_rate; - } - - known_boundary.unwrap_or(parameters_changed) - }; - - if at_boundary { - // Update cached span length (exits detection mode if we were in it) - self.cached_input_span_len = input_span_len; - - if parameters_changed { - // Recreate resampler - new resampler will have counters reset to 0 - let source = self.inner.take().unwrap().into_inner(); - self.inner = Some(Self::create_resampler( - source, - self.target_rate, - &self.config, - )); - } else { - // Just crossed boundary without parameter change, reset counter - match self.inner.as_mut().unwrap() { - ResampleInner::Passthrough { - input_span_pos: input_samples_consumed, - .. - } => { - *input_samples_consumed = 0; - } - ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { - r.input_samples_consumed = 0; - } - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(r) => { - r.input_samples_consumed = 0; - } + ResampleInner::Fft(r) => { + r.input_samples_consumed = 0; } } } @@ -514,13 +539,13 @@ where ResampleInner::Passthrough { source, .. } => return source.size_hint(), ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => { let input_hint = resampler.input.size_hint(); - let buffered_remaining = resampler.output_buffer_len - resampler.output_buffer_pos; + let buffered_remaining = resampler.output_remaining(); (input_hint, resampler.source_rate, buffered_remaining) } #[cfg(feature = "rubato-fft")] ResampleInner::Fft(resampler) => { let input_hint = resampler.input.size_hint(); - let buffered_remaining = resampler.output_buffer_len - resampler.output_buffer_pos; + let buffered_remaining = resampler.output_remaining(); (input_hint, resampler.source_rate, buffered_remaining) } }; diff --git a/src/source/resample/rubato.rs b/src/source/resample/rubato.rs index f3028e0c..388ecfbf 100644 --- a/src/source/resample/rubato.rs +++ b/src/source/resample/rubato.rs @@ -7,8 +7,13 @@ use rubato::Resampler; use crate::source::{ChannelCount, SampleRate, Source}; use crate::{Float, Sample}; +use super::buffer::Buffer; use super::builder::{Poly, Sinc, WindowFunction}; +#[derive(thiserror::Error, Debug)] +#[error("Failed to create resampler")] +pub(super) struct ResamplerCreationError(#[from] rubato::ResamplerConstructionError); + /// Type alias for Async (polynomial/sinc) resampler. pub type RubatoAsyncResample = RubatoResample>; @@ -75,10 +80,9 @@ pub struct RubatoResample> { pub input_buffer: Box<[Sample]>, pub input_frame_count: usize, - pub output_buffer: Box<[Sample]>, - pub output_buffer_pos: usize, - pub output_buffer_len: usize, + output_buffer: Buffer, + /// The following are cached at construction for parameter-change detection. pub channels: ChannelCount, pub source_rate: SampleRate, @@ -94,7 +98,6 @@ pub struct RubatoResample> { pub output_delay_remaining: usize, pub resample_ratio: Float, - pub indexing: rubato::Indexing, } impl> RubatoResample { @@ -106,10 +109,24 @@ impl> RubatoResample { delay_to_skip * channels.get() as usize } + /// Whether the output buffer has unconsumed samples. + pub fn output_has_samples(&self) -> bool { + !self.output_buffer.is_empty() + } + + /// Number of valid samples in the current output chunk. + pub fn output_len(&self) -> usize { + self.output_buffer.len() + } + + /// Number of output samples remaining to be read. + pub fn output_remaining(&self) -> usize { + self.output_buffer.remaining() + } + pub fn reset(&mut self) { self.resampler.reset(); - self.output_buffer_pos = 0; - self.output_buffer_len = 0; + self.output_buffer.reset(0); self.input_frame_count = 0; self.input_samples_consumed = 0; self.input_exhausted = false; @@ -117,25 +134,45 @@ impl> RubatoResample { self.total_output_samples = 0; self.expected_output_samples = 0; self.real_frames_in_buffer = 0; - self.indexing.partial_len = None; self.output_delay_remaining = Self::calculate_delay_compensation(&self.resampler, self.channels); } + fn fill_input_buffer(&mut self, needed: usize, num_channels: usize) { + while self.input_frame_count < needed { + if self.input_exhausted { + break; + } + let sample_pos = self.input_frame_count * num_channels; + for ch in 0..num_channels { + if let Some(sample) = self.input.next() { + self.input_buffer[sample_pos + ch] = sample; + } else { + self.input_exhausted = true; + break; + } + } + if !self.input_exhausted { + self.input_frame_count += 1; + self.real_frames_in_buffer += 1; + } + } + + // Zero-pad if we ran out of input to flush the filter tail + if self.input_frame_count == 0 { + self.input_buffer[..needed * num_channels].fill(Sample::EQUILIBRIUM); + self.input_frame_count = needed; + // real_frames_in_buffer stays at 0 - these are flush frames + } + } + pub fn next_sample(&mut self) -> Option { let num_channels = self.channels.get() as usize; loop { // If we have buffered output, return it - if self.output_buffer_pos < self.output_buffer_len { - let sample = self.output_buffer[self.output_buffer_pos]; - self.output_buffer_pos += 1; + if !self.output_buffer.is_empty() { + let sample = self.output_buffer.read(); self.total_output_samples += 1; - - if self.total_output_samples > self.expected_output_samples { - // Cut off filter artifacts after input is exhausted - return None; - } - return Some(sample); } @@ -147,32 +184,10 @@ impl> RubatoResample { return None; } - // Fill input buffer - accumulate frames until we hit needed amount or run out of input + // Fill input buffer, flushing with zeros if input is exhausted let needed_input = self.resampler.input_frames_next(); let frames_before = self.input_frame_count; - while self.input_frame_count < needed_input && !self.input_exhausted { - let sample_pos = self.input_frame_count * num_channels; - for ch in 0..num_channels { - if let Some(sample) = self.input.next() { - self.input_buffer[sample_pos + ch] = sample; - } else { - self.input_exhausted = true; - break; - } - } - if !self.input_exhausted { - self.input_frame_count += 1; - self.real_frames_in_buffer += 1; - } - } - - // If we have no input, flush the filter tail with zeros - if self.input_frame_count == 0 { - // Zero-pad a full chunk to drain the filter delay - self.input_buffer[..needed_input * num_channels].fill(Sample::EQUILIBRIUM); - self.input_frame_count = needed_input; - // real_frames_in_buffer stays at 0 - these are flush frames - } + self.fill_input_buffer(needed_input, num_channels); // We can process with fewer frames than needed using partial_len when the input is // exhausted. If we don't have enough input and more is coming, wait. @@ -183,10 +198,15 @@ impl> RubatoResample { let actual_frames = self.input_frame_count; - // Prevent stack allocations in the hot path by reusing the indexing struct + let indexing; let indexing_ref = if actual_frames < needed_input { - self.indexing.partial_len = Some(actual_frames); - Some(&self.indexing) + indexing = rubato::Indexing { + input_offset: 0, + output_offset: 0, + partial_len: Some(actual_frames), + active_channels_mask: None, + }; + Some(&indexing) } else { None }; @@ -200,9 +220,9 @@ impl> RubatoResample { ) .ok()?; - let num_frames = self.output_buffer.len() / num_channels; + let num_frames = self.output_buffer.capacity() / num_channels; let mut output_adapter = audioadapter_buffers::direct::InterleavedSlice::new_mut( - &mut self.output_buffer, + self.output_buffer.as_mut_slice(), num_channels, num_frames, ) @@ -239,15 +259,22 @@ impl> RubatoResample { } self.input_frame_count -= actual_consumed; - self.output_buffer_pos = 0; - self.output_buffer_len = frames_out * num_channels; + self.output_buffer.reset(frames_out * num_channels); // Skip warmup delay samples if self.output_delay_remaining > 0 { - let samples_to_skip = self.output_delay_remaining.min(self.output_buffer_len); - self.output_buffer_pos += samples_to_skip; + let samples_to_skip = self.output_delay_remaining.min(self.output_buffer.len()); + self.output_buffer.skip(samples_to_skip); self.output_delay_remaining -= samples_to_skip; } + + // Cap output to cut off filter artifacts once input is exhausted + if self.input_exhausted && self.expected_output_samples > 0 { + let remaining = self + .expected_output_samples + .saturating_sub(self.total_output_samples); + self.output_buffer.cap_to_remaining(remaining); + } } } } @@ -259,7 +286,7 @@ impl RubatoAsyncResample { target_rate: SampleRate, chunk_size: usize, degree: Poly, - ) -> Result { + ) -> Result { let source_rate = input.sample_rate(); let channels = input.channels(); @@ -272,8 +299,7 @@ impl RubatoAsyncResample { chunk_size, channels.get() as usize, rubato::FixedAsync::Output, - ) - .map_err(|e| format!("Failed to create polynomial resampler: {:?}", e))?; + )?; let input_buf_size = resampler.input_frames_max(); let output_buf_size = resampler.output_frames_max(); @@ -289,10 +315,7 @@ impl RubatoAsyncResample { input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] .into_boxed_slice(), input_frame_count: 0, - output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] - .into_boxed_slice(), - output_buffer_pos: 0, - output_buffer_len: 0, + output_buffer: Buffer::new(output_buf_size * channels.get() as usize), channels, source_rate, input_samples_consumed: 0, @@ -303,12 +326,6 @@ impl RubatoAsyncResample { expected_output_samples: 0, real_frames_in_buffer: 0, resample_ratio, - indexing: rubato::Indexing { - input_offset: 0, - output_offset: 0, - partial_len: None, - active_channels_mask: None, - }, }) } @@ -322,7 +339,7 @@ impl RubatoAsyncResample { oversampling_factor: usize, interpolation: Sinc, window: WindowFunction, - ) -> Result { + ) -> Result { let source_rate = input.sample_rate(); let channels = input.channels(); @@ -343,8 +360,7 @@ impl RubatoAsyncResample { chunk_size, channels.get() as usize, rubato::FixedAsync::Output, - ) - .map_err(|e| format!("Failed to create sinc resampler: {:?}", e))?; + )?; let input_buf_size = resampler.input_frames_max(); let output_buf_size = resampler.output_frames_max(); @@ -360,10 +376,7 @@ impl RubatoAsyncResample { input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] .into_boxed_slice(), input_frame_count: 0, - output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] - .into_boxed_slice(), - output_buffer_pos: 0, - output_buffer_len: 0, + output_buffer: Buffer::new(output_buf_size * channels.get() as usize), channels, source_rate, input_samples_consumed: 0, @@ -374,12 +387,6 @@ impl RubatoAsyncResample { expected_output_samples: 0, real_frames_in_buffer: 0, resample_ratio, - indexing: rubato::Indexing { - input_offset: 0, - output_offset: 0, - partial_len: None, - active_channels_mask: None, - }, }) } } @@ -397,7 +404,7 @@ impl RubatoFftResample { target_rate: SampleRate, chunk_size: usize, sub_chunks: usize, - ) -> Result { + ) -> Result { let source_rate = input.sample_rate(); let channels = input.channels(); @@ -415,8 +422,7 @@ impl RubatoFftResample { sub_chunks, channels.get() as usize, rubato::FixedSync::Output, - ) - .map_err(|e| format!("Failed to create FFT resampler: {:?}", e))?; + )?; let input_buf_size = resampler.input_frames_max(); let output_buf_size = resampler.output_frames_max(); @@ -430,10 +436,7 @@ impl RubatoFftResample { input_buffer: vec![Sample::EQUILIBRIUM; input_buf_size * channels.get() as usize] .into_boxed_slice(), input_frame_count: 0, - output_buffer: vec![Sample::EQUILIBRIUM; output_buf_size * channels.get() as usize] - .into_boxed_slice(), - output_buffer_pos: 0, - output_buffer_len: 0, + output_buffer: Buffer::new(output_buf_size * channels.get() as usize), channels, source_rate, input_samples_consumed: 0, @@ -444,12 +447,6 @@ impl RubatoFftResample { real_frames_in_buffer: 0, output_delay_remaining, resample_ratio, - indexing: rubato::Indexing { - input_offset: 0, - output_offset: 0, - partial_len: None, - active_channels_mask: None, - }, }) } } diff --git a/tests/vorbis_test.rs b/tests/vorbis_test.rs new file mode 100644 index 00000000..a0c0e28b --- /dev/null +++ b/tests/vorbis_test.rs @@ -0,0 +1,16 @@ +#![cfg(feature = "symphonia-vorbis")] + +use rodio::{Decoder, Source}; + +#[test] +fn vorbis_decoder_not_exhausted_at_construction() { + let file = std::fs::File::open("assets/music.ogg").unwrap(); + let decoder = Decoder::try_from(file).unwrap(); + + assert!( + !decoder.is_exhausted(), + "decoder should not be exhausted immediately after construction; \ + current_span_len={:?}", + decoder.current_span_len(), + ); +} From e9ca9c39bab03963a5ee24f998f39f3cc76c2145 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 15 May 2026 13:10:07 +0200 Subject: [PATCH 12/20] fix: compilation with 64bit --- src/queue.rs | 6 +++--- src/source/chain.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/queue.rs b/src/queue.rs index 3b9f9577..1ecc0898 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -303,7 +303,7 @@ mod tests { fn metadata_gap_when_queue_briefly_empty() { let new_rate = nz!(48000); let (tx, mut rx) = queue::queue(false); - tx.append(SamplesBuffer::new(nz!(1), nz!(44100), vec![1.0_f32])); + tx.append(SamplesBuffer::new(nz!(1), nz!(44100), vec![1.0])); assert_eq!(rx.next(), Some(1.0)); // Source is exhausted, nothing queued yet. A real consumer reads metadata here @@ -311,7 +311,7 @@ mod tests { let rate_seen_by_consumer = rx.sample_rate(); // The replacement source arrives only after the metadata was already queried. - tx.append(SamplesBuffer::new(nz!(1), new_rate, vec![2.0_f32])); + tx.append(SamplesBuffer::new(nz!(1), new_rate, vec![2.0])); // Ideally the consumer would have seen 48000. In practice it saw 44100. assert_eq!(rate_seen_by_consumer, new_rate); @@ -327,7 +327,7 @@ mod tests { let (tx, mut rx) = queue::queue(false); tx.append(chain(std::iter::empty::())); - tx.append(SamplesBuffer::new(nz!(1), source_rate, vec![1.0_f32, 2.0])); + tx.append(SamplesBuffer::new(nz!(1), source_rate, vec![1.0, 2.0])); assert_eq!(rx.channels(), nz!(1)); assert_eq!(rx.sample_rate(), source_rate); diff --git a/src/source/chain.rs b/src/source/chain.rs index 203cf4a7..2440dca4 100644 --- a/src/source/chain.rs +++ b/src/source/chain.rs @@ -140,7 +140,7 @@ mod tests { let mut c = chain(std::iter::once(SamplesBuffer::new( nz!(1), nz!(48000), - vec![1.0_f32, 2.0], + vec![1.0, 2.0], ))); assert_eq!(c.next(), Some(1.0)); assert_eq!(c.next(), Some(2.0)); From 11ebcc47cd0dd319ea2cbd16ffe28b0e842765f0 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 15 May 2026 13:10:25 +0200 Subject: [PATCH 13/20] chore: update to Rubato v2.0 --- Cargo.lock | 18 ++++++++---------- Cargo.toml | 3 +-- src/source/resample/rubato.rs | 12 ++++-------- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23f70b36..9a6d22e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,20 +155,19 @@ dependencies = [ [[package]] name = "audioadapter" -version = "2.0.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98e72b98fa467adcb7a88c5d1b8b686193185c81b9bf9c3fa3ac3524180cd55c" +checksum = "91f87b70b051c5866680ad79f6743a42ccab264c009d1a71f4d33a3872ae60c8" dependencies = [ "audio-core", - "libm", "num-traits", ] [[package]] name = "audioadapter-buffers" -version = "2.0.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6af89882334c4e501faa08992888593ada468f9e1ab211635c32f9ada7786e0" +checksum = "9097d67933fb083d382ce980430afdb758aada60846010aee6be068c06cef0ca" dependencies = [ "audioadapter", "audioadapter-sample", @@ -177,9 +176,9 @@ dependencies = [ [[package]] name = "audioadapter-sample" -version = "2.0.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9a3d502fec0b21aa420febe0b110875cf8a7057c49e83a0cace1df6a73e03e" +checksum = "34ab94f2bc04a14e1f49ee5f222f66460e8a1b51627bdfedf34eed394d747938" dependencies = [ "audio-core", "num-traits", @@ -1735,7 +1734,6 @@ version = "0.22.2" dependencies = [ "approx", "atomic_float", - "audioadapter-buffers", "clap", "claxon", "cpal", @@ -1809,9 +1807,9 @@ checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" [[package]] name = "rubato" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90173154a8a14e6adb109ea641743bc95ec81c093d94e70c6763565f7108ebeb" +checksum = "ce96ead1a91f7895704a9f08ea5947dfc8bd7c1f2936a22295b655ec67e5c6ef" dependencies = [ "audioadapter", "audioadapter-buffers", diff --git a/Cargo.toml b/Cargo.toml index b8eaf5b7..d876027f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,8 +166,7 @@ atomic_float = { version = "1.1.0", optional = true } rtrb = { version = "0.3.2", optional = true } # Rubato resampling -rubato = { version = "1.0", default-features = false } -audioadapter-buffers = "2.0" +rubato = { version = "2.0", default-features = false } num-rational = "0.4.2" symphonia-adapter-libopus = { version = "0.2", optional = true } diff --git a/src/source/resample/rubato.rs b/src/source/resample/rubato.rs index 388ecfbf..a5096df7 100644 --- a/src/source/resample/rubato.rs +++ b/src/source/resample/rubato.rs @@ -2,7 +2,7 @@ use dasp_sample::Sample as _; use num_rational::Ratio; -use rubato::Resampler; +use rubato::{audioadapter_buffers::direct::InterleavedSlice, Resampler}; use crate::source::{ChannelCount, SampleRate, Source}; use crate::{Float, Sample}; @@ -213,15 +213,11 @@ impl> RubatoResample { let (frames_in, frames_out) = { // InterleavedSlice is a zero-cost abstraction - no heap allocation occurs here - let input_adapter = audioadapter_buffers::direct::InterleavedSlice::new( - &self.input_buffer, - num_channels, - actual_frames, - ) - .ok()?; + let input_adapter = + InterleavedSlice::new(&self.input_buffer, num_channels, actual_frames).ok()?; let num_frames = self.output_buffer.capacity() / num_channels; - let mut output_adapter = audioadapter_buffers::direct::InterleavedSlice::new_mut( + let mut output_adapter = InterleavedSlice::new_mut( self.output_buffer.as_mut_slice(), num_channels, num_frames, From fc10292422cdd9f105499525bdaed8e7315eecc8 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 15 May 2026 13:36:27 +0200 Subject: [PATCH 14/20] fix: rebasing mistakes --- src/queue.rs | 23 +++-------------------- src/source/mod.rs | 3 --- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/src/queue.rs b/src/queue.rs index 1ecc0898..840e4f50 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -5,8 +5,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use dasp_sample::Sample as _; - use crate::source::{Empty, SeekError, Source}; use crate::Sample; @@ -130,30 +128,15 @@ pub struct SourcesQueueOutput { silence_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 -} - 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() - { - // Return what that Zero's current_span_len() will be: Some(threshold(channels)). - return Some(threshold(self.current.channels())); } - - None + // A queue must never return None: that would cause downstream sources to miss format + // changes between queue items. Return a small value so boundaries are checked often. + Some(self.channels().get() as usize) } #[inline] diff --git a/src/source/mod.rs b/src/source/mod.rs index aee7c3d6..ad03cdae 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -9,9 +9,6 @@ use crate::{ math, Float, Sample, }; -#[cfg(feature = "dither")] -use crate::BitDepth; - use dasp_sample::FromSample; pub use self::agc::{AutomaticGainControl, AutomaticGainControlSettings}; From 80b9419dce5ec6ba5f2d79564a97b9e71028b30c Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 15 May 2026 19:47:52 +0200 Subject: [PATCH 15/20] feat: add WAV output option to resample example --- examples/resample.rs | 47 ++++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/examples/resample.rs b/examples/resample.rs index 4953a6e0..b93de8bd 100644 --- a/examples/resample.rs +++ b/examples/resample.rs @@ -2,6 +2,8 @@ use clap::Parser; use rodio::source::{ResampleConfig, Source}; +#[cfg(feature = "wav_output")] +use rodio::wav_to_file; use rodio::{Decoder, DeviceSinkBuilder, Player}; use std::error::Error; use std::num::NonZero; @@ -11,13 +13,19 @@ use std::time::Instant; #[derive(Parser)] #[command(about = "Resample audio using different quality presets")] struct Args { - /// Target sample rate in Hz (default: device native rate) + /// Target sample rate in Hz (default: device native rate when playing, source rate when + /// writing) #[arg(long = "rate")] target_rate: Option>, - /// Path to audio file - #[arg(long = "file", default_value = "assets/music.ogg")] - audio_file: PathBuf, + /// Path to input audio file + #[arg(long = "input", default_value = "assets/music.ogg")] + input: PathBuf, + + /// Path to output WAV file; if omitted, audio plays to the default device + #[cfg(feature = "wav_output")] + #[arg(long = "output")] + output: Option, /// Resampling method #[arg(long = "method", value_enum, default_value_t = Method::Balanced)] @@ -66,15 +74,8 @@ fn main() -> Result<(), Box> { let args = Args::parse(); let config = ResampleConfig::from(args.method); - let builder = DeviceSinkBuilder::from_default_device()?; - let stream_handle = match args.target_rate { - Some(rate) => builder.with_sample_rate(rate).open_stream()?, - None => builder.open_stream()?, - }; - let target_rate = stream_handle.config().sample_rate(); - - let file = std::fs::File::open(&args.audio_file) - .map_err(|e| format!("Failed to open '{}': {e}", args.audio_file.display()))?; + let file = std::fs::File::open(&args.input) + .map_err(|e| format!("Failed to open '{}': {e}", args.input.display()))?; let source = Decoder::try_from(file)?; let source_rate = source.sample_rate().get(); @@ -84,6 +85,26 @@ fn main() -> Result<(), Box> { println!("Duration: {dur:?}"); } + #[cfg(feature = "wav_output")] + if let Some(output_path) = args.output { + let target_rate = args.target_rate.unwrap_or_else(|| source.sample_rate()); + println!("Resampling {channels}ch {source_rate} Hz → {target_rate} Hz"); + println!("Configuration: {config:#?}"); + let resampled = source.resample(target_rate, config); + println!("Writing to '{}'...", output_path.display()); + let start = Instant::now(); + wav_to_file(resampled, &output_path)?; + println!("Finished in {:?}", start.elapsed()); + return Ok(()); + } + + let builder = DeviceSinkBuilder::from_default_device()?; + let stream_handle = match args.target_rate { + Some(rate) => builder.with_sample_rate(rate).open_stream()?, + None => builder.open_stream()?, + }; + let target_rate = stream_handle.config().sample_rate(); + println!("Resampling {channels}ch {source_rate} Hz → {target_rate} Hz"); println!("Configuration: {config:#?}"); From 482a896238f8e06d1e8db165b98f4699b1d043cb Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 31 May 2026 20:56:07 +0200 Subject: [PATCH 16/20] fix: correct span-boundary cap counter, partial-chunk output trimming, and exhaustion flush --- Cargo.lock | 22 --- Cargo.toml | 1 - src/source/resample/builder.rs | 15 +- src/source/resample/mod.rs | 318 ++++++++++++++++++++++----------- src/source/resample/rubato.rs | 78 ++++++-- 5 files changed, 292 insertions(+), 142 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a6d22e6..6a26b8d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1256,16 +1256,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -1295,17 +1285,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1744,7 +1723,6 @@ dependencies = [ "inquire", "lewton", "minimp3_fixed", - "num-rational", "quickcheck", "rand 0.10.1", "rand_distr", diff --git a/Cargo.toml b/Cargo.toml index d876027f..adff69f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -167,7 +167,6 @@ rtrb = { version = "0.3.2", optional = true } # Rubato resampling rubato = { version = "2.0", default-features = false } -num-rational = "0.4.2" symphonia-adapter-libopus = { version = "0.2", optional = true } diff --git a/src/source/resample/builder.rs b/src/source/resample/builder.rs index 1c830ed8..13f5a99d 100644 --- a/src/source/resample/builder.rs +++ b/src/source/resample/builder.rs @@ -220,7 +220,12 @@ pub enum ResampleConfig { Sinc { /// Length of the windowed sinc interpolation filter sinc_len: usize, - /// The number of intermediate points to use for interpolation + /// Number of entries per tap in the precomputed sinc filter lookup table. + /// + /// A higher value means finer granularity between adjacent table entries, which reduces + /// the interpolation error when using [`Sinc::Linear`] or [`Sinc::Quadratic`]. For + /// [`Sinc::Cubic`], fewer entries are needed because the polynomial follows the curvature + /// of the sinc function more closely. See [`Sinc::Linear`] for a detailed explanation. oversampling_factor: usize, /// Interpolation type for filter table lookup interpolation: Sinc, @@ -385,9 +390,13 @@ impl SincConfigBuilder { self } - /// Set oversampling factor (typical range: 64-4096). + /// Set the number of entries per tap in the precomputed sinc filter lookup table + /// (typical range: 64-4096). /// - /// Higher values improve interpolation accuracy but increase memory usage. + /// A higher value packs more entries into the table, so adjacent entries are closer together + /// and interpolation between them incurs less error. This matters most with + /// [`Sinc::Linear`] and [`Sinc::Quadratic`]; [`Sinc::Cubic`] achieves comparable accuracy + /// with a smaller table. Increases memory usage proportionally. pub fn oversampling_factor(mut self, factor: NonZero) -> Self { self.oversampling_factor = factor.get(); self diff --git a/src/source/resample/mod.rs b/src/source/resample/mod.rs index d0c4269d..17e4fac6 100644 --- a/src/source/resample/mod.rs +++ b/src/source/resample/mod.rs @@ -77,9 +77,6 @@ use std::time::Duration; -use ::rubato::Resampler as _; -use num_rational::Ratio; - use super::{reset_seek_span_tracking, SeekError}; use crate::{ common::{ChannelCount, Sample, SampleRate}, @@ -101,6 +98,15 @@ pub use builder::{ /// Maximum for optimized fixed-ratio resampling: 44.1 and 384 kHz (147:1280). const MAX_FIXED_RATIO: u32 = 1280; +pub(super) fn gcd(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a +} + /// Resamples an audio source to a target sample rate using Rubato. #[derive(Debug)] pub struct Resample @@ -112,6 +118,10 @@ where target_rate: SampleRate, config: ResampleConfig, cached_input_span_len: Option, + // True when a format change was detected at a span boundary but the output buffer still + // has samples from the old format. Recreation is deferred until the buffer is drained so + // fill_input_buffer never reads the next span's samples with the wrong channel count. + pending_recreate: bool, } impl Clone for Resample @@ -155,6 +165,7 @@ where target_rate, config, cached_input_span_len, + pending_recreate: false, } } @@ -175,7 +186,6 @@ where source_rate, } } else { - let ratio = Ratio::new(target_rate.get(), source_rate.get()); match config { ResampleConfig::Poly { degree, chunk_size } => { let resampler = @@ -193,7 +203,10 @@ where chunk_size, sub_chunks, } => { - if *ratio.numer() <= MAX_FIXED_RATIO && *ratio.denom() <= MAX_FIXED_RATIO { + let g = gcd(target_rate.get(), source_rate.get()); + let numer = target_rate.get() / g; + let denom = source_rate.get() / g; + if numer <= MAX_FIXED_RATIO && denom <= MAX_FIXED_RATIO { // Use FFT resampler for optimal performance let resampler = RubatoFftResample::new(source, target_rate, *chunk_size, *sub_chunks) @@ -223,10 +236,13 @@ where f_cutoff, chunk_size, } => { - if *ratio.numer() <= MAX_FIXED_RATIO && *ratio.denom() <= MAX_FIXED_RATIO { + let g = gcd(target_rate.get(), source_rate.get()); + let numer = target_rate.get() / g; + let denom = source_rate.get() / g; + if numer <= MAX_FIXED_RATIO && denom <= MAX_FIXED_RATIO { // Fixed ratio without FFT - use Sinc::Nearest optimization // Set oversampling_factor to match the ratio for optimal performance - let ratio = *ratio.numer().max(ratio.denom()) as usize; + let ratio = numer.max(denom) as usize; let resampler = RubatoAsyncResample::new_sinc( source, target_rate, @@ -271,19 +287,13 @@ where /// Returns a reference to the inner source. #[inline] pub fn inner(&self) -> &I { - match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => source, - ResampleInner::Poly(resampler) => &resampler.input, - ResampleInner::Sinc(resampler) => &resampler.input, - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => &resampler.input, - } + self.resampler().input() } /// Returns a mutable reference to the inner source. #[inline] pub fn inner_mut(&mut self) -> &mut I { - match self.inner.as_mut().unwrap() { + match self.resampler_mut() { ResampleInner::Passthrough { source, .. } => source, ResampleInner::Poly(resampler) => &mut resampler.input, ResampleInner::Sinc(resampler) => &mut resampler.input, @@ -332,38 +342,32 @@ where { #[inline] fn current_span_len(&self) -> Option { - let ( - input_span_len, - input_sample_rate, - input_exhausted, - output_has_samples, - output_len, - output_frames_next, - ) = match self.inner.as_ref().unwrap() { - ResampleInner::Passthrough { source, .. } => return source.current_span_len(), - ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => ( - resampler.input.current_span_len(), - resampler.input.sample_rate(), - resampler.input.is_exhausted(), - resampler.output_has_samples(), - resampler.output_len(), - resampler.resampler.output_frames_next(), - ), - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => ( - resampler.input.current_span_len(), - resampler.input.sample_rate(), - resampler.input.is_exhausted(), - resampler.output_has_samples(), - resampler.output_len(), - resampler.resampler.output_frames_next(), - ), - }; + let (input_span_len, input_sample_rate, input_exhausted, output_has_samples, output_len) = + match self.resampler() { + ResampleInner::Passthrough { source, .. } => return source.current_span_len(), + ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => ( + resampler.input.current_span_len(), + resampler.input.sample_rate(), + resampler.input.is_exhausted(), + resampler.output_has_samples(), + resampler.output_len(), + ), + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(resampler) => ( + resampler.input.current_span_len(), + resampler.input.sample_rate(), + resampler.input.is_exhausted(), + resampler.output_has_samples(), + resampler.output_len(), + ), + }; - let ratio = Ratio::new(self.sample_rate().get(), input_sample_rate.get()); - if ratio.is_integer() { + let g = gcd(self.sample_rate().get(), input_sample_rate.get()); + let numer = self.sample_rate().get() / g; + let denom = input_sample_rate.get() / g; + if denom == 1 { // Integer upsampling (2x, 3x, etc.) - always exact and frame-aligned - input_span_len.map(|len| *ratio.numer() as usize * len) + input_span_len.map(|len| numer as usize * len) } else { // When the ratio contains a fraction, we cannot choose the floor or ceiling // arbitrarily, because the resampler may produce either based on its internal state @@ -374,9 +378,9 @@ where // End state: we are at the end of our buffer and the source is exhausted Some(0) } else { - // Initial state: our buffer is empty until the first call to next() loads it with - // resampled samples. Return the size of the next buffer. - Some(output_frames_next * self.channels().get() as usize) + // Initial state: buffer is empty, actual output count is unknown until the first + // process_into_buffer call. Return one frame so consumers recheck promptly. + Some(self.channels().get() as usize) } } } @@ -411,8 +415,10 @@ where } } + self.pending_recreate = false; let input_span_len = self.resampler().input().current_span_len(); + // Use field-level borrow so we can simultaneously access self.cached_input_span_len. match self.inner.as_mut().unwrap() { ResampleInner::Passthrough { input_span_pos: input_samples_consumed, @@ -456,39 +462,60 @@ where #[inline] fn next(&mut self) -> Option { - let sample = match self.inner.as_mut().unwrap() { + // If a format change was detected at the previous span boundary, wait until the + // output buffer is fully drained before recreating the resampler. This guarantees + // that fill_input_buffer only ever reads from the current span. + if self.pending_recreate { + let output_empty = match self.resampler() { + ResampleInner::Passthrough { .. } => true, + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => !r.output_has_samples(), + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => !r.output_has_samples(), + }; + if output_empty { + let source = self.inner.take().unwrap().into_inner(); + self.inner = Some(Self::create_resampler( + source, + self.target_rate, + &self.config, + )); + self.pending_recreate = false; + } + } + + let cached = self.cached_input_span_len; + let sample = match self.resampler_mut() { ResampleInner::Passthrough { source, .. } => source.next()?, - ResampleInner::Poly(resampler) => resampler.next_sample()?, - ResampleInner::Sinc(resampler) => resampler.next_sample()?, + ResampleInner::Poly(resampler) => resampler.next_sample(cached)?, + ResampleInner::Sinc(resampler) => resampler.next_sample(cached)?, #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(resampler) => resampler.next_sample()?, + ResampleInner::Fft(resampler) => resampler.next_sample(cached)?, }; // If input reports no span length, parameters are stable by contract - let input_span_len = self.inner.as_ref().unwrap().input().current_span_len(); + let input_span_len = self.resampler().input().current_span_len(); if input_span_len.is_none() { return Some(sample); } - let (expected_channels, expected_rate, samples_consumed) = - match self.inner.as_mut().unwrap() { - ResampleInner::Passthrough { - input_span_pos: input_samples_consumed, - channels, - source_rate, - .. - } => { - *input_samples_consumed += 1; - (*channels, *source_rate, *input_samples_consumed) - } - ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { - (r.channels, r.source_rate, r.input_samples_consumed) - } - #[cfg(feature = "rubato-fft")] - ResampleInner::Fft(r) => (r.channels, r.source_rate, r.input_samples_consumed), - }; + let (expected_channels, expected_rate, samples_consumed) = match self.resampler_mut() { + ResampleInner::Passthrough { + input_span_pos: input_samples_consumed, + channels, + source_rate, + .. + } => { + *input_samples_consumed += 1; + (*channels, *source_rate, *input_samples_consumed) + } + ResampleInner::Poly(r) | ResampleInner::Sinc(r) => { + (r.channels, r.source_rate, r.input_samples_consumed) + } + #[cfg(feature = "rubato-fft")] + ResampleInner::Fft(r) => (r.channels, r.source_rate, r.input_samples_consumed), + }; - let input = self.inner.as_ref().unwrap().input(); + let input = self.resampler().input(); let (at_boundary, parameters_changed) = Self::detect_boundary( self.cached_input_span_len, samples_consumed, @@ -503,16 +530,12 @@ where self.cached_input_span_len = input_span_len; if parameters_changed { - // Recreate resampler - new resampler will have counters reset to 0 - let source = self.inner.take().unwrap().into_inner(); - self.inner = Some(Self::create_resampler( - source, - self.target_rate, - &self.config, - )); + // Defer recreation until the output buffer is drained (handled above at the + // top of the next next() call) so no cross-span reads occur. + self.pending_recreate = true; } else { // Just crossed boundary without parameter change, reset counter - match self.inner.as_mut().unwrap() { + match self.resampler_mut() { ResampleInner::Passthrough { input_span_pos: input_samples_consumed, .. @@ -535,7 +558,7 @@ where #[inline] fn size_hint(&self) -> (usize, Option) { - let (input_hint, source_rate, buffered_remaining) = match self.inner.as_ref().unwrap() { + let (input_hint, source_rate, buffered_remaining) = match self.resampler() { ResampleInner::Passthrough { source, .. } => return source.size_hint(), ResampleInner::Poly(resampler) | ResampleInner::Sinc(resampler) => { let input_hint = resampler.input.size_hint(); @@ -606,21 +629,52 @@ mod tests { } } - struct TestSource { + struct TestSpan { samples: Vec, - index: usize, - sample_rate: SampleRate, + rate: SampleRate, channels: ChannelCount, } + /// Multi-span test source. + /// + /// `span` is advanced eagerly — the moment the last sample of span N is returned, + /// `span` becomes N+1. This keeps `current_span_len()` a simple array lookup and + /// makes the exhausted state (`span == spans.len()`) explicit. + /// Build with [`TestSource::new`] and extend with [`.chain()`](TestSource::chain). + struct TestSource { + spans: Vec, + span: usize, + offset: usize, + } + impl TestSource { - fn new(samples: Vec, sample_rate: SampleRate, channels: ChannelCount) -> Self { + fn new(samples: Vec, rate: SampleRate, channels: ChannelCount) -> Self { Self { + spans: vec![TestSpan { + samples, + rate, + channels, + }], + span: 0, + offset: 0, + } + } + + fn chain(mut self, samples: Vec, rate: SampleRate, channels: ChannelCount) -> Self { + self.spans.push(TestSpan { samples, - index: 0, - sample_rate, + rate, channels, - } + }); + self + } + + /// Returns the active span for metadata queries, falling back to the last span + /// once the source is exhausted so rate/channels remain defined. + fn current_span(&self) -> &TestSpan { + self.spans + .get(self.span) + .unwrap_or_else(|| self.spans.last().unwrap()) } } @@ -628,37 +682,65 @@ mod tests { type Item = Sample; fn next(&mut self) -> Option { - if self.index < self.samples.len() { - let sample = self.samples[self.index]; - self.index += 1; - Some(sample) - } else { - None + if self.span >= self.spans.len() { + return None; + } + let s = self.spans[self.span].samples[self.offset]; + self.offset += 1; + if self.offset >= self.spans[self.span].samples.len() { + self.span += 1; + self.offset = 0; } + Some(s) } } impl Source for TestSource { fn current_span_len(&self) -> Option { - Some(self.samples.len()) + Some(self.spans.get(self.span).map_or(0, |s| s.samples.len())) } fn sample_rate(&self) -> SampleRate { - self.sample_rate + self.current_span().rate } fn channels(&self) -> ChannelCount { - self.channels + self.current_span().channels } fn total_duration(&self) -> Option { - let samples = self.samples.len() / self.channels.get() as usize; - Some(Duration::from_secs_f64( - samples as f64 / self.sample_rate.get() as f64, - )) + let secs: f64 = self + .spans + .iter() + .map(|s| { + let frames = s.samples.len() / s.channels.get() as usize; + frames as f64 / s.rate.get() as f64 + }) + .sum(); + Some(Duration::from_secs_f64(secs)) } - fn try_seek(&mut self, _position: Duration) -> Result<(), SeekError> { + fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { + let mut remaining = position.as_secs_f64(); + for (i, span) in self.spans.iter().enumerate() { + let frames = span.samples.len() / span.channels.get() as usize; + let span_dur = frames as f64 / span.rate.get() as f64; + let is_last = i + 1 == self.spans.len(); + if remaining < span_dur || is_last { + let frame_offset = (remaining * span.rate.get() as f64) as usize; + let sample_offset = + (frame_offset * span.channels.get() as usize).min(span.samples.len()); + self.span = i; + self.offset = sample_offset; + if self.offset >= span.samples.len() { + self.span += 1; + self.offset = 0; + } + return Ok(()); + } + remaining -= span_dur; + } + // Empty spans vec — nothing to seek. Ok(()) } } @@ -781,4 +863,38 @@ mod tests { } } } + + /// Without the `already_read` fix, `total_input_frames` (never reset at same-format + /// span boundaries) was used instead of `input_samples_consumed` (reset at each + /// boundary). On the second span that caused the span cap to evaluate to zero + /// immediately, so Rubato would error on a zero-length partial chunk and + /// `next_sample` would return `None` early — producing roughly half the expected + /// output. + #[test] + fn test_span_boundary_same_format() { + let span_frames = 100usize; + let channels = ChannelCount::new(1).unwrap(); + let rate = SampleRate::new(44100).unwrap(); + let target = SampleRate::new(48000).unwrap(); + + let source = TestSource::new(vec![0.1; span_frames], rate, channels).chain( + vec![0.9; span_frames], + rate, + channels, + ); + + let output: Vec = + Resample::new(source, target, ResampleConfig::poly().build()).collect(); + + let ratio = target.get() as f64 / rate.get() as f64; + let expected = ((2 * span_frames) as f64 * ratio).ceil() as usize; + + assert_eq!( + output.len(), + expected, + "expected {expected} samples from both spans, got {} \ + (second span likely not processed)", + output.len() + ); + } } diff --git a/src/source/resample/rubato.rs b/src/source/resample/rubato.rs index a5096df7..b2111c12 100644 --- a/src/source/resample/rubato.rs +++ b/src/source/resample/rubato.rs @@ -1,7 +1,6 @@ //! Rubato resampler wrapper and implementations. use dasp_sample::Sample as _; -use num_rational::Ratio; use rubato::{audioadapter_buffers::direct::InterleavedSlice, Resampler}; use crate::source::{ChannelCount, SampleRate, Source}; @@ -166,7 +165,7 @@ impl> RubatoResample { } } - pub fn next_sample(&mut self) -> Option { + pub fn next_sample(&mut self, cached_input_span_len: Option) -> Option { let num_channels = self.channels.get() as usize; loop { // If we have buffered output, return it @@ -184,10 +183,41 @@ impl> RubatoResample { return None; } - // Fill input buffer, flushing with zeros if input is exhausted - let needed_input = self.resampler.input_frames_next(); + // Fill input buffer, flushing with zeros if input is exhausted. + // Cap to the span boundary so we never read into the next span's samples, + // which may have a different channel count or sample rate. + let original_needed = self.resampler.input_frames_next(); + let needed_input = if let Some(span_len) = cached_input_span_len { + let span_frames = span_len / num_channels; + // input_samples_consumed resets to 0 at each span boundary, so this + // stays span-relative even when the resampler processes multiple spans. + let already_read = + self.input_samples_consumed / num_channels + self.real_frames_in_buffer; + let remaining = span_frames.saturating_sub(already_read); + original_needed.min(self.input_frame_count + remaining) + } else { + original_needed + }; + + // When the span cap brings needed_input to zero and the buffer is empty, + // probe the source to distinguish a genuinely exhausted single-span source + // from a span boundary in a multi-span source. For multi-span sources the + // outer Resample::next() will have updated cached_input_span_len before the + // next call, so this probe only fires for truly exhausted sources. + if needed_input == 0 && !self.input_exhausted && self.input_frame_count == 0 { + if self.input.next().is_none() { + self.input_exhausted = true; + } + } + + // When exhausted, use a full chunk so zero-padding flushes the filter tail. + let fill_target = if self.input_exhausted { + original_needed + } else { + needed_input + }; let frames_before = self.input_frame_count; - self.fill_input_buffer(needed_input, num_channels); + self.fill_input_buffer(fill_target, num_channels); // We can process with fewer frames than needed using partial_len when the input is // exhausted. If we don't have enough input and more is coming, wait. @@ -198,8 +228,11 @@ impl> RubatoResample { let actual_frames = self.input_frame_count; + // Use original_needed (pre-cap) so that partial_len is signalled to Rubato + // whenever we have fewer frames than a full chunk, regardless of whether the + // shortfall is due to source exhaustion or a span boundary cap. let indexing; - let indexing_ref = if actual_frames < needed_input { + let indexing_ref = if actual_frames < original_needed { indexing = rubato::Indexing { input_offset: 0, output_offset: 0, @@ -214,7 +247,12 @@ impl> RubatoResample { let (frames_in, frames_out) = { // InterleavedSlice is a zero-cost abstraction - no heap allocation occurs here let input_adapter = - InterleavedSlice::new(&self.input_buffer, num_channels, actual_frames).ok()?; + InterleavedSlice::new(&self.input_buffer, num_channels, actual_frames) + .inspect_err(|_e| { + #[cfg(feature = "tracing")] + tracing::error!("resampler: failed to create input adapter: {_e}"); + }) + .ok()?; let num_frames = self.output_buffer.capacity() / num_channels; let mut output_adapter = InterleavedSlice::new_mut( @@ -222,10 +260,18 @@ impl> RubatoResample { num_channels, num_frames, ) + .inspect_err(|_e| { + #[cfg(feature = "tracing")] + tracing::error!("resampler: failed to create output adapter: {_e}"); + }) .ok()?; self.resampler .process_into_buffer(&input_adapter, &mut output_adapter, indexing_ref) + .inspect_err(|_e| { + #[cfg(feature = "tracing")] + tracing::error!("resampler: processing failed: {_e}"); + }) .ok()? }; @@ -264,8 +310,12 @@ impl> RubatoResample { self.output_delay_remaining -= samples_to_skip; } - // Cap output to cut off filter artifacts once input is exhausted - if self.input_exhausted && self.expected_output_samples > 0 { + // Cap output whenever a partial chunk was processed (span boundary or + // exhaustion) or once all input is gone. Rubato internally zero-pads a + // partial chunk to a full output chunk, so without the cap the output + // would exceed expected_output_samples. + if (self.input_exhausted || indexing_ref.is_some()) && self.expected_output_samples > 0 + { let remaining = self .expected_output_samples .saturating_sub(self.total_output_samples); @@ -404,12 +454,10 @@ impl RubatoFftResample { let source_rate = input.sample_rate(); let channels = input.channels(); - // Calculate the GCD-reduced ratio - let ratio = Ratio::new(target_rate.get(), source_rate.get()); - let (_num, den) = ratio.into_raw(); - - // Determine input chunk size - must be multiple of denominator - let input_chunk_size = ((chunk_size / den as usize) + 1) * den as usize; + // Determine input chunk size - must be multiple of the GCD-reduced denominator + let g = super::gcd(target_rate.get(), source_rate.get()); + let den = (source_rate.get() / g) as usize; + let input_chunk_size = ((chunk_size / den) + 1) * den; let resampler = rubato::Fft::new( source_rate.get() as usize, From cf539db41071a7c1e97e1c15fe4c35a046dff9e8 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 31 May 2026 23:13:40 +0200 Subject: [PATCH 17/20] refactor: move gcd to math, trim oversampling doc, fix TestSource try_seek --- src/math.rs | 10 ++++++++ src/source/resample/builder.rs | 5 +--- src/source/resample/mod.rs | 45 ++++++++++------------------------ src/source/resample/rubato.rs | 30 +++++------------------ 4 files changed, 30 insertions(+), 60 deletions(-) diff --git a/src/math.rs b/src/math.rs index 13cc7fa9..9fe31722 100644 --- a/src/math.rs +++ b/src/math.rs @@ -164,6 +164,16 @@ macro_rules! nz { pub use nz; +/// Greatest common divisor (Euclidean algorithm). +pub(crate) fn gcd(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a +} + #[cfg(test)] mod test { use super::*; diff --git a/src/source/resample/builder.rs b/src/source/resample/builder.rs index 13f5a99d..94493aa1 100644 --- a/src/source/resample/builder.rs +++ b/src/source/resample/builder.rs @@ -222,10 +222,7 @@ pub enum ResampleConfig { sinc_len: usize, /// Number of entries per tap in the precomputed sinc filter lookup table. /// - /// A higher value means finer granularity between adjacent table entries, which reduces - /// the interpolation error when using [`Sinc::Linear`] or [`Sinc::Quadratic`]. For - /// [`Sinc::Cubic`], fewer entries are needed because the polynomial follows the curvature - /// of the sinc function more closely. See [`Sinc::Linear`] for a detailed explanation. + /// See [`SincConfigBuilder::oversampling_factor`] for details. oversampling_factor: usize, /// Interpolation type for filter table lookup interpolation: Sinc, diff --git a/src/source/resample/mod.rs b/src/source/resample/mod.rs index 17e4fac6..0fc365fa 100644 --- a/src/source/resample/mod.rs +++ b/src/source/resample/mod.rs @@ -80,6 +80,7 @@ use std::time::Duration; use super::{reset_seek_span_tracking, SeekError}; use crate::{ common::{ChannelCount, Sample, SampleRate}, + math::gcd, Float, Source, }; @@ -98,15 +99,6 @@ pub use builder::{ /// Maximum for optimized fixed-ratio resampling: 44.1 and 384 kHz (147:1280). const MAX_FIXED_RATIO: u32 = 1280; -pub(super) fn gcd(mut a: u32, mut b: u32) -> u32 { - while b != 0 { - let r = a % b; - a = b; - b = r; - } - a -} - /// Resamples an audio source to a target sample rate using Rubato. #[derive(Debug)] pub struct Resample @@ -418,7 +410,6 @@ where self.pending_recreate = false; let input_span_len = self.resampler().input().current_span_len(); - // Use field-level borrow so we can simultaneously access self.cached_input_span_len. match self.inner.as_mut().unwrap() { ResampleInner::Passthrough { input_span_pos: input_samples_consumed, @@ -637,9 +628,6 @@ mod tests { /// Multi-span test source. /// - /// `span` is advanced eagerly — the moment the last sample of span N is returned, - /// `span` becomes N+1. This keeps `current_span_len()` a simple array lookup and - /// makes the exhausted state (`span == spans.len()`) explicit. /// Build with [`TestSource::new`] and extend with [`.chain()`](TestSource::chain). struct TestSource { spans: Vec, @@ -669,8 +657,7 @@ mod tests { self } - /// Returns the active span for metadata queries, falling back to the last span - /// once the source is exhausted so rate/channels remain defined. + /// Returns the active span for metadata queries. fn current_span(&self) -> &TestSpan { self.spans .get(self.span) @@ -709,18 +696,19 @@ mod tests { } fn total_duration(&self) -> Option { - let secs: f64 = self - .spans - .iter() - .map(|s| { - let frames = s.samples.len() / s.channels.get() as usize; - frames as f64 / s.rate.get() as f64 - }) - .sum(); - Some(Duration::from_secs_f64(secs)) + Some( + self.spans + .iter() + .map(|s| { + let frames = s.samples.len() / s.channels.get() as usize; + Duration::from_secs_f64(frames as f64 / s.rate.get() as f64) + }) + .sum(), + ) } fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { + let position = self.total_duration().map_or(position, |d| position.min(d)); let mut remaining = position.as_secs_f64(); for (i, span) in self.spans.iter().enumerate() { let frames = span.samples.len() / span.channels.get() as usize; @@ -740,8 +728,7 @@ mod tests { } remaining -= span_dur; } - // Empty spans vec — nothing to seek. - Ok(()) + unreachable!("TestSource always has at least one span") } } @@ -864,12 +851,6 @@ mod tests { } } - /// Without the `already_read` fix, `total_input_frames` (never reset at same-format - /// span boundaries) was used instead of `input_samples_consumed` (reset at each - /// boundary). On the second span that caused the span cap to evaluate to zero - /// immediately, so Rubato would error on a zero-length partial chunk and - /// `next_sample` would return `None` early — producing roughly half the expected - /// output. #[test] fn test_span_boundary_same_format() { let span_frames = 100usize; diff --git a/src/source/resample/rubato.rs b/src/source/resample/rubato.rs index b2111c12..7901efdc 100644 --- a/src/source/resample/rubato.rs +++ b/src/source/resample/rubato.rs @@ -200,14 +200,11 @@ impl> RubatoResample { }; // When the span cap brings needed_input to zero and the buffer is empty, - // probe the source to distinguish a genuinely exhausted single-span source - // from a span boundary in a multi-span source. For multi-span sources the - // outer Resample::next() will have updated cached_input_span_len before the - // next call, so this probe only fires for truly exhausted sources. - if needed_input == 0 && !self.input_exhausted && self.input_frame_count == 0 { - if self.input.next().is_none() { - self.input_exhausted = true; - } + // check whether the source is truly exhausted (single-span done) or just + // at a same-format span boundary. For the latter, Resample::next() will have + // refreshed cached_input_span_len before the next call. + if needed_input == 0 && !self.input_exhausted { + self.input_exhausted = self.input.is_exhausted(); } // When exhausted, use a full chunk so zero-padding flushes the filter tail. @@ -216,16 +213,8 @@ impl> RubatoResample { } else { needed_input }; - let frames_before = self.input_frame_count; self.fill_input_buffer(fill_target, num_channels); - // We can process with fewer frames than needed using partial_len when the input is - // exhausted. If we don't have enough input and more is coming, wait. - let made_progress = self.input_frame_count > frames_before; - if self.input_frame_count < needed_input && !self.input_exhausted && made_progress { - continue; - } - let actual_frames = self.input_frame_count; // Use original_needed (pre-cap) so that partial_len is signalled to Rubato @@ -245,7 +234,6 @@ impl> RubatoResample { }; let (frames_in, frames_out) = { - // InterleavedSlice is a zero-cost abstraction - no heap allocation occurs here let input_adapter = InterleavedSlice::new(&self.input_buffer, num_channels, actual_frames) .inspect_err(|_e| { @@ -293,12 +281,6 @@ impl> RubatoResample { .ceil() as usize * num_channels; - // Shift remaining input samples to beginning of buffer - if actual_consumed < self.input_frame_count { - let src_start = actual_consumed * num_channels; - let src_end = self.input_frame_count * num_channels; - self.input_buffer.copy_within(src_start..src_end, 0); - } self.input_frame_count -= actual_consumed; self.output_buffer.reset(frames_out * num_channels); @@ -455,7 +437,7 @@ impl RubatoFftResample { let channels = input.channels(); // Determine input chunk size - must be multiple of the GCD-reduced denominator - let g = super::gcd(target_rate.get(), source_rate.get()); + let g = crate::math::gcd(target_rate.get(), source_rate.get()); let den = (source_rate.get() / g) as usize; let input_chunk_size = ((chunk_size / den) + 1) * den; From faf7d257ca43c7a3409395c6f3bc475be4e70b1f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 31 May 2026 23:53:25 +0200 Subject: [PATCH 18/20] fix: current_span_len overcounts by delay offset on first output chunk --- src/source/resample/mod.rs | 38 +++++++++++++++++++++++++++++++++-- src/source/resample/rubato.rs | 18 ++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/source/resample/mod.rs b/src/source/resample/mod.rs index 0fc365fa..0ce04cb3 100644 --- a/src/source/resample/mod.rs +++ b/src/source/resample/mod.rs @@ -342,7 +342,7 @@ where resampler.input.sample_rate(), resampler.input.is_exhausted(), resampler.output_has_samples(), - resampler.output_len(), + resampler.output_span_len(), ), #[cfg(feature = "rubato-fft")] ResampleInner::Fft(resampler) => ( @@ -350,7 +350,7 @@ where resampler.input.sample_rate(), resampler.input.is_exhausted(), resampler.output_has_samples(), - resampler.output_len(), + resampler.output_span_len(), ), }; @@ -851,6 +851,40 @@ mod tests { } } + #[test] + fn test_current_span_len_excludes_delay() { + let channels = ChannelCount::new(1).unwrap(); + let from = SampleRate::new(44100).unwrap(); + let to = SampleRate::new(48000).unwrap(); + + let input = create_test_input(2048, 1); + let source = from_iter(input.into_iter(), channels, from); + // sinc_len=16 gives a non-zero output delay without being slow in debug builds + let config = ResampleConfig::sinc() + .sinc_len(NonZero::new(16).unwrap()) + .build(); + let mut resampler = Resample::new(source, to, config); + + let _ = resampler.next().expect("should have samples"); + let reported = resampler + .current_span_len() + .expect("should report span len"); + + let mut count = 1; + while resampler.current_span_len() == Some(reported) { + assert!( + resampler.next().is_some(), + "source exhausted before first chunk drained" + ); + count += 1; + } + + assert_eq!( + count, reported, + "current_span_len() = {reported} but first chunk emitted {count} samples" + ); + } + #[test] fn test_span_boundary_same_format() { let span_frames = 100usize; diff --git a/src/source/resample/rubato.rs b/src/source/resample/rubato.rs index 7901efdc..b8d4bdd8 100644 --- a/src/source/resample/rubato.rs +++ b/src/source/resample/rubato.rs @@ -97,6 +97,10 @@ pub struct RubatoResample> { pub output_delay_remaining: usize, pub resample_ratio: Float, + + /// Effective length of the current output chunk as seen by callers of `current_span_len`. + /// Differs from `output_buffer.len()` when delay-compensation skip consumed leading samples. + pub output_span_len: usize, } impl> RubatoResample { @@ -113,9 +117,9 @@ impl> RubatoResample { !self.output_buffer.is_empty() } - /// Number of valid samples in the current output chunk. - pub fn output_len(&self) -> usize { - self.output_buffer.len() + /// Effective span length of the current output chunk for `current_span_len` reporting. + pub fn output_span_len(&self) -> usize { + self.output_span_len } /// Number of output samples remaining to be read. @@ -135,6 +139,7 @@ impl> RubatoResample { self.real_frames_in_buffer = 0; self.output_delay_remaining = Self::calculate_delay_compensation(&self.resampler, self.channels); + self.output_span_len = 0; } fn fill_input_buffer(&mut self, needed: usize, num_channels: usize) { @@ -303,6 +308,10 @@ impl> RubatoResample { .saturating_sub(self.total_output_samples); self.output_buffer.cap_to_remaining(remaining); } + + // Snapshot remaining after skip and cap. Stays constant while the chunk drains, + // giving current_span_len a stable total that excludes delay-skipped leading samples. + self.output_span_len = self.output_buffer.remaining(); } } } @@ -349,6 +358,7 @@ impl RubatoAsyncResample { input_samples_consumed: 0, input_exhausted: false, output_delay_remaining, + output_span_len: 0, total_input_frames: 0, total_output_samples: 0, expected_output_samples: 0, @@ -410,6 +420,7 @@ impl RubatoAsyncResample { input_samples_consumed: 0, input_exhausted: false, output_delay_remaining, + output_span_len: 0, total_input_frames: 0, total_output_samples: 0, expected_output_samples: 0, @@ -472,6 +483,7 @@ impl RubatoFftResample { expected_output_samples: 0, real_frames_in_buffer: 0, output_delay_remaining, + output_span_len: 0, resample_ratio, }) } From 55e943832cb2cd0a747c01d7b18ce0099e1bb1ae Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 7 Jun 2026 21:37:26 +0200 Subject: [PATCH 19/20] refactor: move resampler to conversions::SampleRateConverter --- CHANGELOG.md | 3 +- src/conversions/channels.rs | 5 +- src/conversions/mod.rs | 27 +++- src/conversions/sample.rs | 5 +- src/conversions/sample_rate.rs | 119 ------------------ .../sample_rate}/buffer.rs | 0 .../sample_rate}/builder.rs | 2 +- .../sample_rate}/mod.rs | 34 ++--- .../sample_rate}/rubato.rs | 8 +- src/source/mod.rs | 12 +- src/source/uniform.rs | 15 +-- 11 files changed, 69 insertions(+), 161 deletions(-) delete mode 100644 src/conversions/sample_rate.rs rename src/{source/resample => conversions/sample_rate}/buffer.rs (100%) rename src/{source/resample => conversions/sample_rate}/builder.rs (99%) rename src/{source/resample => conversions/sample_rate}/mod.rs (96%) rename src/{source/resample => conversions/sample_rate}/rubato.rs (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index df02a198..9ed597ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All sources now implement `Iterator::size_hint()`. - `Chirp` now implements `try_seek`. - Added `DEFAULT_SAMPLE_RATE` set to match `cpal::SAMPLE_RATE_48K`. -- Added `Resample` source for high-quality sample rate conversion. +- Added `Source::resample` and `ResampleConfig` for high-quality sample rate conversion. ### Changed @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Explicitly document the requirement for sources to return complete frames. - Ensured decoders to always return complete frames, as well as `TakeDuration` when expired. - Breaking: `Zero::new_samples()` now returns `Result` requiring a frame-aligned number of samples. +- Breaking: `SampleRateConverter` now wraps a `Source` instead of an `Iterator` and takes a `ResampleConfig`. - Improved queue, buffer, mixer and sample rate conversion performance. - Default sample rate changed from 44.1 kHz to 48 kHz consistently. - `open_sink_or_fallback` now tries 48 kHz and 44.1 kHz before the device's maximum sample rate. diff --git a/src/conversions/channels.rs b/src/conversions/channels.rs index e01db818..94bcf442 100644 --- a/src/conversions/channels.rs +++ b/src/conversions/channels.rs @@ -2,7 +2,10 @@ use crate::common::ChannelCount; use crate::{Sample, Source}; use dasp_sample::Sample as _; -/// Iterator that converts from a certain channel count to another. +/// Converts a source from one channel count to another. +/// +/// When adding channels, a mono input is duplicated across the first two channels and the rest are +/// filled with silence. When removing channels, the surplus channels are dropped. #[derive(Clone, Debug)] pub struct ChannelCountConverter where diff --git a/src/conversions/mod.rs b/src/conversions/mod.rs index 3e972ecc..6adaa15f 100644 --- a/src/conversions/mod.rs +++ b/src/conversions/mod.rs @@ -1,10 +1,29 @@ -//! This module contains functions that convert from one PCM format to another. +//! Convert audio between PCM formats. +//! +//! A PCM stream is described by three properties, each with a converter that changes it while +//! leaving the others intact: +//! +//! - **Sample type:** the numeric representation of each sample (e.g. `i16`, `f32`). +//! Changed with [`SampleTypeConverter`]. +//! - **Channel count:** the number of channels in the audio stream (e.g. mono, stereo, 5.1). +//! Changed with [`ChannelCountConverter`], which duplicates or drops channels as needed. +//! - **Sample rate:** frames per second (e.g. 44.1 kHz to 48 kHz). Changed with +//! [`SampleRateConverter`], which resamples the signal. See the [`sample_rate`] module for the +//! available algorithms and quality presets. +//! +//! Each converter is a [`Source`](crate::Source) (and [`Iterator`]) adapter that wraps another +//! source, so they can be composed by nesting. To retarget a source to a fixed output format in +//! one step, prefer the higher-level [`UniformSourceIterator`](crate::source::UniformSourceIterator), +//! which applies channel-count and sample-rate conversion together in the correct order, or +//! [`Source::resample`](crate::Source::resample) for the sample rate alone. pub use self::channels::ChannelCountConverter; pub use self::sample::SampleTypeConverter; -#[allow(deprecated)] -pub use self::sample_rate::SampleRateConverter; +pub use self::sample_rate::{ + Poly, PolyConfigBuilder, ResampleConfig, SampleRateConverter, Sinc, SincConfigBuilder, + WindowFunction, +}; mod channels; mod sample; -mod sample_rate; +pub mod sample_rate; diff --git a/src/conversions/sample.rs b/src/conversions/sample.rs index cddb1b34..cf709430 100644 --- a/src/conversions/sample.rs +++ b/src/conversions/sample.rs @@ -1,7 +1,10 @@ use dasp_sample::{FromSample, ToSample}; use std::marker::PhantomData; -/// Converts the samples data type to `O`. +/// Converts each sample's numeric type to `O`. +/// +/// Rescales the values to the target type's range (for example `i16` to `f32`), leaving the +/// channel count and sample rate unchanged. #[derive(Clone, Debug)] pub struct SampleTypeConverter { input: I, diff --git a/src/conversions/sample_rate.rs b/src/conversions/sample_rate.rs deleted file mode 100644 index 35fc991b..00000000 --- a/src/conversions/sample_rate.rs +++ /dev/null @@ -1,119 +0,0 @@ -use crate::common::{ChannelCount, SampleRate}; -use crate::source::{resample::Poly, FromIter, Resample, ResampleConfig}; -use crate::{Sample, Source}; - -/// Iterator that converts from one sample rate to another. -#[deprecated( - since = "0.22.0", - note = "Use `Resample` with `FromIter` (or `from_iter` function) directly" -)] -#[derive(Debug)] -#[allow(deprecated)] -pub struct SampleRateConverter -where - I: Iterator, -{ - inner: Resample>, -} - -#[allow(deprecated)] -impl SampleRateConverter -where - I: Iterator, -{ - /// Create new sample rate converter. - pub fn new(input: I, from: SampleRate, to: SampleRate, channels: ChannelCount) -> Self { - let adapter = FromIter::new(input, channels, from); - let config = ResampleConfig::poly().degree(Poly::Linear).build(); - let inner = Resample::new(adapter, to, config); - - Self { inner } - } - - /// Destroys this iterator and returns the underlying iterator. - #[inline] - pub fn into_inner(self) -> I { - self.inner.into_inner().into_inner() - } - - /// Get mutable access to the underlying iterator. - #[inline] - pub fn inner_mut(&mut self) -> &mut I { - self.inner.inner_mut().inner_mut() - } - - /// Get access to the underlying iterator. - #[inline] - pub fn inner(&self) -> &I { - self.inner.inner().inner() - } -} - -#[allow(deprecated)] -impl Clone for SampleRateConverter -where - I: Iterator + Clone, -{ - fn clone(&self) -> Self { - let from_iter = self.inner.inner(); - Self::new( - from_iter.inner().clone(), - from_iter.sample_rate(), - self.inner.sample_rate(), - from_iter.channels(), - ) - } -} - -#[allow(deprecated)] -impl Iterator for SampleRateConverter -where - I: Iterator, -{ - type Item = Sample; - - #[inline] - fn next(&mut self) -> Option { - self.inner.next() - } - - #[inline] - fn size_hint(&self) -> (usize, Option) { - self.inner.size_hint() - } -} - -#[allow(deprecated)] -impl ExactSizeIterator for SampleRateConverter where - I: Iterator + ExactSizeIterator -{ -} - -#[cfg(test)] -#[allow(deprecated)] -mod test { - use super::SampleRateConverter; - use crate::math::nz; - use crate::Sample; - - /// Minimal smoke test to ensure the deprecated SampleRateConverter wrapper still works. - /// Core resampling tests have been moved to src/source/resample.rs. - #[test] - fn deprecated_wrapper_works() { - // Test basic upsampling - let input: Vec = vec![0.0, 0.5, 1.0, 0.5, 0.0]; - let from = nz!(1000); - let to = nz!(2000); - let channels = nz!(1); - - let converter = SampleRateConverter::new(input.into_iter(), from, to, channels); - let output: Vec<_> = converter.collect(); - - // Should produce approximately 2x samples (upsampling) - assert!( - output.len() >= 8 && output.len() <= 12, - "Expected approximately 10 samples, got {}", - output.len() - ); - } -} diff --git a/src/source/resample/buffer.rs b/src/conversions/sample_rate/buffer.rs similarity index 100% rename from src/source/resample/buffer.rs rename to src/conversions/sample_rate/buffer.rs diff --git a/src/source/resample/builder.rs b/src/conversions/sample_rate/builder.rs similarity index 99% rename from src/source/resample/builder.rs rename to src/conversions/sample_rate/builder.rs index 94493aa1..ae361342 100644 --- a/src/source/resample/builder.rs +++ b/src/conversions/sample_rate/builder.rs @@ -196,7 +196,7 @@ impl Default for SincConfigBuilder { /// /// ```rust /// use rodio::math::nz; -/// use rodio::source::{resample::Poly, ResampleConfig}; +/// use rodio::conversions::{Poly, ResampleConfig}; /// /// // Use presets /// let config = ResampleConfig::balanced(); diff --git a/src/source/resample/mod.rs b/src/conversions/sample_rate/mod.rs similarity index 96% rename from src/source/resample/mod.rs rename to src/conversions/sample_rate/mod.rs index 0ce04cb3..12247f19 100644 --- a/src/source/resample/mod.rs +++ b/src/conversions/sample_rate/mod.rs @@ -13,12 +13,12 @@ //! let resampled = source.resample(SampleRate::new(96000).unwrap(), config); //! ``` //! -//! For advanced control, use the [`ResampleConfig`] builder: +//! For advanced control, use [`SampleRateConverter`] directly: //! //! ```rust //! use rodio::math::nz; -//! use rodio::source::{SineWave, Source, Resample, ResampleConfig}; -//! use rodio::source::resample::{Sinc, WindowFunction}; +//! use rodio::source::{SineWave, Source, ResampleConfig}; +//! use rodio::conversions::{SampleRateConverter, Sinc, WindowFunction}; //! //! let source = SineWave::new(440.0); //! let config = ResampleConfig::sinc() // Sinc resampling @@ -27,7 +27,7 @@ //! .window(WindowFunction::BlackmanHarris2) // Squared Blackman-Harris window //! .chunk_size(nz!(512)) // Low latency (5.3 ms @ 1-channel 96 kHz) //! .build(); -//! let resampled = Resample::new(source, nz!(96000), config); +//! let resampled = SampleRateConverter::new(source, nz!(96000), config); //! ``` //! //! # Understanding Resampling @@ -77,7 +77,7 @@ use std::time::Duration; -use super::{reset_seek_span_tracking, SeekError}; +use crate::source::{reset_seek_span_tracking, SeekError}; use crate::{ common::{ChannelCount, Sample, SampleRate}, math::gcd, @@ -101,7 +101,7 @@ const MAX_FIXED_RATIO: u32 = 1280; /// Resamples an audio source to a target sample rate using Rubato. #[derive(Debug)] -pub struct Resample +pub struct SampleRateConverter where I: Source, { @@ -116,18 +116,18 @@ where pending_recreate: bool, } -impl Clone for Resample +impl Clone for SampleRateConverter where I: Source + Clone, { fn clone(&self) -> Self { // Shallow clone: this resets filter state let source = self.inner().clone(); - Resample::new(source, self.target_rate, self.config.clone()) + SampleRateConverter::new(source, self.target_rate, self.config.clone()) } } -impl Resample +impl SampleRateConverter where I: Source, { @@ -328,7 +328,7 @@ where } } -impl Source for Resample +impl Source for SampleRateConverter where I: Source, { @@ -445,7 +445,7 @@ where } } -impl Iterator for Resample +impl Iterator for SampleRateConverter where I: Source, { @@ -749,7 +749,7 @@ mod tests { let input = vec![]; let config = ResampleConfig::default(); let source = from_iter(input.clone().into_iter(), *channels, *from); - let output = Resample::new(source, *to, config).collect::>(); + let output = SampleRateConverter::new(source, *to, config).collect::>(); input == output } @@ -758,7 +758,7 @@ mod tests { let input = convert_to_frames(input, *channels); let config = ResampleConfig::default(); let source = from_iter(input.clone().into_iter(), *channels, *from); - let output = Resample::new(source, *from, config).collect::>(); + let output = SampleRateConverter::new(source, *from, config).collect::>(); input == output } @@ -774,7 +774,7 @@ mod tests { let from = source.sample_rate(); let config = ResampleConfig::poly().degree(Poly::Linear).build(); - let resampled = Resample::new(source, *to, config); + let resampled = SampleRateConverter::new(source, *to, config); let duration = Duration::from_secs_f32(resampled.count() as f32 / to.get() as f32); let delta = duration.abs_diff(d); @@ -830,7 +830,7 @@ mod tests { let input_samples = input.len(); let source = from_iter(input.into_iter(), ch, from); - let resampler = Resample::new(source, to, config.clone()); + let resampler = SampleRateConverter::new(source, to, config.clone()); let size_hint_lower = resampler.size_hint().0; let output_count = resampler.count(); @@ -863,7 +863,7 @@ mod tests { let config = ResampleConfig::sinc() .sinc_len(NonZero::new(16).unwrap()) .build(); - let mut resampler = Resample::new(source, to, config); + let mut resampler = SampleRateConverter::new(source, to, config); let _ = resampler.next().expect("should have samples"); let reported = resampler @@ -899,7 +899,7 @@ mod tests { ); let output: Vec = - Resample::new(source, target, ResampleConfig::poly().build()).collect(); + SampleRateConverter::new(source, target, ResampleConfig::poly().build()).collect(); let ratio = target.get() as f64 / rate.get() as f64; let expected = ((2 * span_frames) as f64 * ratio).ceil() as usize; diff --git a/src/source/resample/rubato.rs b/src/conversions/sample_rate/rubato.rs similarity index 98% rename from src/source/resample/rubato.rs rename to src/conversions/sample_rate/rubato.rs index b8d4bdd8..64fa5135 100644 --- a/src/source/resample/rubato.rs +++ b/src/conversions/sample_rate/rubato.rs @@ -3,8 +3,8 @@ use dasp_sample::Sample as _; use rubato::{audioadapter_buffers::direct::InterleavedSlice, Resampler}; -use crate::source::{ChannelCount, SampleRate, Source}; -use crate::{Float, Sample}; +use crate::common::{ChannelCount, SampleRate}; +use crate::{Float, Sample, Source}; use super::buffer::Buffer; use super::builder::{Poly, Sinc, WindowFunction}; @@ -206,8 +206,8 @@ impl> RubatoResample { // When the span cap brings needed_input to zero and the buffer is empty, // check whether the source is truly exhausted (single-span done) or just - // at a same-format span boundary. For the latter, Resample::next() will have - // refreshed cached_input_span_len before the next call. + // at a same-format span boundary. For the latter, SampleRateConverter::next() + // will have refreshed cached_input_span_len before the next call. if needed_input == 0 && !self.input_exhausted { self.input_exhausted = self.input.is_exhausted(); } diff --git a/src/source/mod.rs b/src/source/mod.rs index ad03cdae..7e445280 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use crate::{ buffer::SamplesBuffer, common::{assert_error_traits, ChannelCount, SampleRate}, + conversions::SampleRateConverter, math, Float, Sample, }; @@ -36,7 +37,6 @@ pub use self::pausable::Pausable; pub use self::periodic::PeriodicAccess; pub use self::position::TrackPosition; pub use self::repeat::Repeat; -pub use self::resample::{Resample, ResampleConfig}; pub use self::sawtooth::SawtoothWave; pub use self::signal_generator::{Function, GeneratorFunction, SignalGenerator}; pub use self::sine::SineWave; @@ -50,6 +50,7 @@ pub use self::take::TakeDuration; pub use self::triangle::TriangleWave; pub use self::uniform::UniformSourceIterator; pub use self::zero::{Zero, ZeroError}; +pub use crate::conversions::ResampleConfig; mod agc; mod amplify; @@ -75,7 +76,6 @@ mod pausable; mod periodic; mod position; mod repeat; -pub mod resample; mod sawtooth; mod signal_generator; mod sine; @@ -737,8 +737,8 @@ pub trait Source: Iterator { /// Resamples this source to a different sample rate. /// - /// See the [`resample`] module documentation for detailed information about resampling - /// algorithms and quality presets. + /// See the [`sample_rate`](crate::conversions::sample_rate) module documentation for detailed + /// information about resampling algorithms and quality presets. /// /// # Quality Presets /// @@ -756,11 +756,11 @@ pub trait Source: Iterator { /// let resampled = source.resample(SampleRate::new(96000).unwrap(), ResampleConfig::balanced()); /// ``` #[inline] - fn resample(self, target_rate: SampleRate, config: ResampleConfig) -> Resample + fn resample(self, target_rate: SampleRate, config: ResampleConfig) -> SampleRateConverter where Self: Sized, { - Resample::new(self, target_rate, config) + SampleRateConverter::new(self, target_rate, config) } /// Attempts to seek to a given position in the current source. diff --git a/src/source/uniform.rs b/src/source/uniform.rs index 3efccbd9..14b2421b 100644 --- a/src/source/uniform.rs +++ b/src/source/uniform.rs @@ -1,18 +1,18 @@ use std::time::Duration; -use super::resample::{Poly, Resample, ResampleConfig}; use super::SeekError; use crate::common::{ChannelCount, SampleRate}; use crate::conversions::ChannelCountConverter; +use crate::conversions::{Poly, ResampleConfig, SampleRateConverter}; use crate::Source; #[derive(Clone)] enum UniformInner { Passthrough(I), - SampleRate(Resample), + SampleRate(SampleRateConverter), ChannelCount(ChannelCountConverter), - BothUpmix(ChannelCountConverter>), - BothDownmix(Resample>), + BothUpmix(ChannelCountConverter>), + BothDownmix(SampleRateConverter>), } impl Iterator for UniformInner { @@ -164,7 +164,7 @@ where (false, false) => UniformInner::Passthrough(input), (true, false) => { let config = ResampleConfig::poly().degree(Poly::Linear).build(); - let rate_converted = Resample::new(input, target_sample_rate, config); + let rate_converted = SampleRateConverter::new(input, target_sample_rate, config); UniformInner::SampleRate(rate_converted) } (false, true) => { @@ -176,7 +176,8 @@ where let config = ResampleConfig::poly().degree(Poly::Linear).build(); if target_channels > from_channels { - let rate_converted = Resample::new(input, target_sample_rate, config); + let rate_converted = + SampleRateConverter::new(input, target_sample_rate, config); let channel_converted = ChannelCountConverter::new(rate_converted, from_channels, target_channels); UniformInner::BothUpmix(channel_converted) @@ -184,7 +185,7 @@ where let channel_converted = ChannelCountConverter::new(input, from_channels, target_channels); let rate_converted = - Resample::new(channel_converted, target_sample_rate, config); + SampleRateConverter::new(channel_converted, target_sample_rate, config); UniformInner::BothDownmix(rate_converted) } } From 877e5b17d529451bd5ae90e5bc0ba878f7b281c9 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 7 Jun 2026 21:50:53 +0200 Subject: [PATCH 20/20] chore: update to Rubato 3.0 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a26b8d2..bf4f0a11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1785,9 +1785,9 @@ checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" [[package]] name = "rubato" -version = "2.0.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce96ead1a91f7895704a9f08ea5947dfc8bd7c1f2936a22295b655ec67e5c6ef" +checksum = "d4be9c88e3d722d3d36939e41941f6b6f52810c1235bf19998a7d4535b402892" dependencies = [ "audioadapter", "audioadapter-buffers", diff --git a/Cargo.toml b/Cargo.toml index adff69f0..586f8b0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,7 +166,7 @@ atomic_float = { version = "1.1.0", optional = true } rtrb = { version = "0.3.2", optional = true } # Rubato resampling -rubato = { version = "2.0", default-features = false } +rubato = { version = "3.0", default-features = false } symphonia-adapter-libopus = { version = "0.2", optional = true }