A simple sample rate conversion lib for audio.
Use SrcManager to create converters. Select [Kernel::Sinc] (default) for
high-quality FIR interpolation, [Kernel::Cubic] for a faster middle tier, or
[Kernel::Linear] when performance matters most.
Sinc converters have FIR latency. For a complete buffer, call
SrcManager::convert, which pads zeros and drops the leading delay.
For streaming, skip SrcManager::latency samples at the start and call
Convert::flush after the last input until it returns 0. Built-in
sinc/linear/cubic converters stop once the delay line is empty (they may still
need more than one call if the flush buffer is short). The trait default
of Convert::flush only fills the provided buffer and does not stop on
an empty delay.
Float ratios such as 48000.0 / 44100.0 may be reduced to a rational when
a continued-fraction fit has numerator and denominator ≤ 16384 and relative
error ≤ 1e-12 (so 0.7 becomes 7/10, while π stays float phase).
Prefer SrcManager::builder with .sample_rate
and .fast() for exact integer rate pairs.
Multi-channel audio is N independent mono converters. Keep planar buffers
(one slice per channel) and use a converter per channel, or
process_planar to keep consume/produce counts aligned. Pass the same
converter count, buffer lengths, and process history on every channel;
mismatches return Error instead of panicking.
Build with SrcManager::builder. Sinc needs filter parameters
(attenuation, transition width or pass frequency, and quantify on the
generic path). SrcManager::with_ratio and SrcManager::with_sample_rate
build linear converters only; for sinc, use the builder.
Choose the interpolation path with .generic(),
.fast(), or sinc_path(SincPath::…):
| Path | Role |
|---|---|
SincPath::Auto (default) |
Use fast polyphase when the ratio is eligible (reduced numerator ≤ 1024); otherwise generic half-table. |
SincPath::Generic |
Half Kaiser–sinc table; quantify is required. |
SincPath::Fast |
Precomputed polyphase LUT; quantify is ignored. |
On Auto or Fast, any quantify you set (including via
.quality()) is silently ignored. Call
.generic() explicitly when you need half-table
interpolation.
Generic interpolation uses a half Kaiser-sinc table and quantify. Fast
polyphase interpolation precomputes one tap set per rational phase; it
requires a rational ratio whose reduced numerator is ≤ 1024, does not take
quantify, and returns [Error::FastUnavailable] otherwise.
Both paths use the same filter order (identical latency and per-sample cost);
they differ in table size. Generic stores (quantify + 1) × (order + 1)
coefficients, Fast stores numerator × (order + 1), where numerator is the
reduced ratio numerator. So:
- Fast wins on memory when
numerator ≤ quantify— dramatically so for integer ratios (2x, 3x, …), which is what the defaultAutopath picks. - Generic wins on memory when
numerator > quantify + 1, by a factor ofnumerator / (quantify + 1). This is the low-memory / moderate-precision niche: large fractional ratios at low quality tiers. Generic also covers arbitrary float ratios (π, √2, …) where Fast is unavailable entirely.
Measured with pass_freq(20000) (Generic KiB / Fast KiB per quality tier):
| 44.1→48: | 48→44.1: | 44.1→96: | 96→44.1: | |
|---|---|---|---|---|
| Bit8Fast | 5 / 89 | 5 / 88 | 5 / 178 | 11 / 176 |
| Bit16Fast | 144 / 179 | 156 / 178 | 144 / 358 | 311 / 355 |
| Bit24Fast | 3442 / 269 | 3730 / 268 | 3442 / 538 | 7444 / 534 |
(Bit16Fast 44.1→48 is near the crossover: 129 vs 160 rows. Full rule of thumb:
Generic saves numerator / (quantify + 1) when that value exceeds 1.)
The memory saving costs a little passband ripple: Generic interpolates the
kernel between adjacent quantify phase rows, which adds ripple near the top
of the passband (≈ 0.15 dB at quantify = 8, matching Fast from
quantify ≥ 16 up). Stop-band attenuation is unaffected — the phase
interpolation error scales with the local response and vanishes where H(ω)
is already zero. Latency and per-sample cost are identical between the paths.
Typical 44100/48000 conversion:
use simple_src::{Quality, SrcManager};
let samples = vec![1.0, 2.0, 3.0, 4.0];
let manager = SrcManager::builder()
.sample_rate(44100, 48000)
.fast()
.quality(Quality::Bit16Fast)
.pass_freq(20000)
.build()
.unwrap();
for s in manager.convert(&samples) {
println!("{s}");
}Generic path with a quality preset (quantify is used):
use simple_src::{Quality, SrcManager};
let samples = vec![1.0, 2.0, 3.0, 4.0];
let manager = SrcManager::builder()
.ratio(2.0)
.generic()
.quality(Quality::Bit8Fast)
.trans_width(0.1)
.build()
.unwrap();
for s in manager.convert(&samples) {
println!("{s}");
}Streaming with a converter instance:
use simple_src::{Convert, SrcManager};
let samples = vec![1.0, 2.0, 3.0, 4.0];
let manager = SrcManager::builder()
.ratio(2.0)
.generic()
.attenuation(48.0)
.quantify(8)
.pass_width(0.9)
.build()
.unwrap();
let mut converter = manager.converter();
for s in converter.process(samples.into_iter()) {
println!("{s}");
}For multi-channel example see two_channels.rs.
Linear interpolation only needs a ratio. Use the convenience constructor or the builder:
use simple_src::{Convert, Kernel, SrcManager};
let samples = vec![1.0, 2.0, 3.0, 4.0];
// Convenience (linear only; no kernel argument)
let manager = SrcManager::with_ratio(2.0).unwrap();
// Or via builder
let manager = SrcManager::builder()
.ratio(2.0)
.kernel(Kernel::Linear)
.build()
.unwrap();
let mut converter = manager.converter();
for s in converter.process(samples.into_iter()) {
println!("{s}");
}Catmull-Rom cubic interpolation sits between linear and sinc in quality and cost. Like linear, it only needs a ratio (no FIR parameters):
use simple_src::{Convert, Kernel, SrcManager};
let samples = vec![1.0, 2.0, 3.0, 4.0];
let manager = SrcManager::builder()
.ratio(2.0)
.kernel(Kernel::Cubic)
.build()
.unwrap();
let mut converter = manager.converter();
for s in converter.process(samples.into_iter()) {
println!("{s}");
}Recommended initialization parameters for sinc, also available as
Quality presets:
| attenuation | quantify | Quality |
|
|---|---|---|---|
| 8bit fast | 48 | 8 | Bit8Fast |
| 8bit medium | 60 | 16 | Bit8Medium |
| 8bit better | 72 | 32 | Bit8Better |
| 16bit lower | 84 | 64 | Bit16Lower |
| 16bit fast | 96 | 128 | Bit16Fast |
| 16bit medium | 108 | 256 | Bit16Medium |
| 16bit better | 120 | 512 | Bit16Better |
| 24bit lower | 132 | 1024 | Bit24Lower |
| 24bit fast | 144 | 2048 | Bit24Fast |
| 24bit medium | 156 | 4096 | Bit24Medium |
| 24bit better | 168 | 8192 | Bit24Better |
The relationship between attenuation and quantify is about Q = 2 ^ (A / 12 - 1), A = 12 + 12 * log2(Q).
Due to the amount of calculation and the size of LUT, A = 144 or 156 for 24bit audio is usually fine, and for 16bit, A = 120 is enough.
Quality::attenuation applies to Generic and Fast; Quality::quantify is
Generic-only (ignored on Fast or Auto when Fast is selected).
- Order: From attenuation and transition width, length uses A + 6 dB
and is rounded up to an even order (capped at 2048). Explicit
.order()/ raw builder fields (kaiser_beta,cutoff) skip that margin. - Cutoff: Ideal lowpass cutoff is at
min(1, ratio) * (1 - trans_width), so the transition sits entirely below the applicable Nyquist (output Nyquist when downsampling, input Nyquist when upsampling). The −6 dB point is therefore near the pass edge frompass_freq/pass_width; frequencies between that edge and Nyquist are already in the transition or stop band. Stop-band attenuation A applies beyond the stop edge (about halfway from the pass edge to Nyquist), not inside the transition. - Coefficients: Fast phases and the Generic half-table are scaled for unity DC gain.
- Measured trim (experimental):
.trim_filter(true)replaces the fixed A + 6 dB order margin and the approximate Kaiser beta mapping with an init-time search that measures the realized stopband of the worst polyphase branch and picks the smallest even order meeting the requested attenuation exactly. Saves ~4–6% taps (order = per-sample cost) at the same guaranteed stopband; costs 8–54 ms of extra init time (ratio/attenuation dependent) and falls back to the formula design if the search cannot converge within the order cap. The stopband sweep uses RustFFT by default (enabled by therustfftfeature); use--no-default-featuresto build with the built-in hand-written radix-2 FFT instead.
cargo run -p simple-src-cli -- input.wav -r 48000 -o output.wav
The CLI uses sinc + Fast polyphase interpolation by default. Pass
--generic (and --quantify if needed) for half-table interpolation.
Pass --kernel linear or --kernel cubic for ratio-only interpolation
(attenuation and quantify are then ignored).
Use plots.py to show the results of conversion. It needs numpy, scipy and matplotlib.
The quality report is a standalone build command (an example, not a test). It
recomputes every quality baseline and bundles all charts and metrics tables
into a single page — SVGs inline, charts folded behind <details> blocks, so
browsing does not open dozens of tabs:
$ cargo run -p simple_src --example report
$ # open output/report/index.html (artifacts: output/report/artifacts/)
When run as part of the spectral assertion tests, the artifacts instead land
in $CARGO_TARGET_TMPDIR/quality/. The report has sections for THD+N / max
spur, stopband alias rejection, passband flatness, generic-vs-fast overlays,
measured-trim vs formula design, and the quality-tier LUT cost curves, plus
the exact commands to reproduce it.
The report command also writes the spectrum CSVs and raw sweep files, so the
matplotlib extras below work without running any test first.
For matplotlib extras (spectrum overlays, filter frequency response,
passband zooms, spectrograms) rendered headlessly as PNGs with a tiny index
page:
$ python plots.py --report \
--quality-dir output/report/artifacts \
--wav-dir output/32bitfloat \
--out output/report
$ # open output/report/extras.html
Here is an example showing the results of a downsampling 96kHz:
$ cargo test -p simple_src -r --test testwav -- --ignored --exact --show-output generate
$ cargo test -p simple_src -r --test sinc -- --ignored --exact --show-output ta120_2_96k_down
$ python
>>> import plots
>>> import os
>>> os.chdir('output')
>>> plots.spectrum('beep_96k_44k_s_a120_2.wav')
>>> plots.spectrogram('sweep_96k_44k_s_a120_2.wav')
>>> plots.impulse('impulse_96k_44k_s_a120_2.wav')
>>> plots.impulse('impulse_96k_44k_s_a120_2.wav', True)
See code in tests for more details.
- Smith, J.O. Digital Audio Resampling Home Page https://ccrma.stanford.edu/~jos/resample/.
- Alan V. Oppenheim, Ronald W. Schafer. Discrete-Time Signal Processing, Third Edition.