use playr::audio::resample::Resample;
fn sine(rate: u32, freq: f32, frames: usize) -> Vec<f32> {
(0..frames)
.flat_map(|i| {
let v = (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin() * 0.5;
[v, v]
})
.collect()
}
fn amplitude_at(buf: &[f32], rate: u32, channels: usize, freq: f32) -> f32 {
let ch0: Vec<f32> = buf.iter().step_by(channels).copied().collect();
let w = std::f32::consts::TAU * freq / rate as f32;
let c = 2.0 * w.cos();
let (mut s1, mut s2) = (0.0f32, 0.0f32);
for &x in &ch0 {
let s0 = x + c * s1 - s2;
s2 = s1;
s1 = s0;
}
let re = s1 - s2 * w.cos();
let im = s2 * w.sin();
(re * re + im * im).sqrt() / (ch0.len() as f32 / 2.0)
}
fn assert_clean_tone(buf: &[f32], rate: u32, channels: usize, freq: f32) {
let at = amplitude_at(buf, rate, channels, freq);
assert!(
at > 0.4 && at < 0.6,
"amplitude at {freq} Hz is {at}, expected ~0.5"
);
for off in [-60.0, -30.0, 30.0, 60.0] {
let side = amplitude_at(buf, rate, channels, freq + off);
assert!(
side < at / 5.0,
"spurious energy at {} Hz: {side} vs {at}",
freq + off
);
}
}
#[test]
fn output_length_follows_the_rate_ratio() {
let mut r = Resample::new(44100, 48000, 2, 1.0).unwrap();
let input = sine(44100, 440.0, 44100);
let mut out = Vec::new();
r.push(&input, &mut out);
r.flush(&mut out);
let frames = out.len() / 2;
let expected = 48000.0;
let err = (frames as f32 - expected).abs() / expected;
assert!(err < 0.02, "got {frames} frames, expected ~{expected}");
}
#[test]
fn resampling_preserves_the_tone() {
let mut r = Resample::new(44100, 48000, 2, 1.0).unwrap();
let input = sine(44100, 440.0, 44100);
let mut out = Vec::new();
r.push(&input, &mut out);
r.flush(&mut out);
assert_clean_tone(&out[10_000 * 2..40_000 * 2], 48000, 2, 440.0);
}
#[test]
fn downsampling_works_too() {
let mut r = Resample::new(96000, 48000, 2, 1.0).unwrap();
let input = sine(96000, 440.0, 96000);
let mut out = Vec::new();
r.push(&input, &mut out);
r.flush(&mut out);
let frames = out.len() / 2;
let err = (frames as f32 - 48000.0).abs() / 48000.0;
assert!(err < 0.02, "got {frames} frames, expected ~48000");
assert_clean_tone(&out[10_000 * 2..40_000 * 2], 48000, 2, 440.0);
}
#[test]
fn output_is_continuous_across_small_pushes() {
let input = sine(44100, 440.0, 44100);
let mut whole = Vec::new();
let mut r1 = Resample::new(44100, 48000, 2, 1.0).unwrap();
r1.push(&input, &mut whole);
r1.flush(&mut whole);
let mut pieces = Vec::new();
let mut r2 = Resample::new(44100, 48000, 2, 1.0).unwrap();
for chunk in input.chunks(577 * 2) {
r2.push(chunk, &mut pieces);
}
r2.flush(&mut pieces);
assert_eq!(
whole.len(),
pieces.len(),
"chunked feed changed the output length"
);
let max_diff = whole
.iter()
.zip(&pieces)
.fold(0f32, |m, (a, b)| m.max((a - b).abs()));
assert!(max_diff < 1e-5, "chunked feed diverged by {max_diff}");
}
#[test]
fn mono_and_multichannel_shapes_are_respected() {
for ch in [1u16, 2, 6] {
let mut r = Resample::new(44100, 48000, ch, 1.0).unwrap();
let frames = 44100;
let input = vec![0.1f32; frames * ch as usize];
let mut out = Vec::new();
r.push(&input, &mut out);
r.flush(&mut out);
assert_eq!(
out.len() % ch as usize,
0,
"{ch}ch output is not frame-aligned"
);
let got = out.len() / ch as usize;
assert!(
(got as f32 - 48000.0).abs() / 48000.0 < 0.02,
"{ch}ch gave {got} frames"
);
}
}
fn at_speed(input: &[f32], rate: u32, speed: f64) -> Vec<f32> {
let mut r = Resample::new(rate, rate, 2, speed).unwrap();
let mut out = Vec::new();
r.push(input, &mut out);
r.flush(&mut out);
out
}
#[test]
fn playing_an_octave_up_doubles_the_frequency() {
let input = sine(44100, 440.0, 44100);
let out = at_speed(&input, 44100, 2.0);
assert_clean_tone(&out[2_000 * 2..18_000 * 2], 44100, 2, 880.0);
}
#[test]
fn playing_an_octave_down_halves_the_frequency() {
let input = sine(44100, 440.0, 44100);
let out = at_speed(&input, 44100, 0.5);
assert_clean_tone(&out[10_000 * 2..70_000 * 2], 44100, 2, 220.0);
}
#[test]
fn one_semitone_up_is_a_semitone_of_pitch() {
let input = sine(44100, 440.0, 44100);
let out = at_speed(&input, 44100, 2f64.powf(1.0 / 12.0));
assert_clean_tone(&out[5_000 * 2..35_000 * 2], 44100, 2, 466.16);
}
#[test]
fn duration_scales_inversely_with_speed() {
let input = sine(44100, 440.0, 44100);
for (speed, want) in [(2.0f64, 22_050.0f32), (0.5, 88_200.0), (1.5, 29_400.0)] {
let frames = at_speed(&input, 44100, speed).len() / 2;
let err = (frames as f32 - want).abs() / want;
assert!(
err < 0.03,
"at {speed}x got {frames} frames, expected ~{want}"
);
}
}
const CLIP_LENGTHS: [usize; 5] = [10_240, 10_241, 10_000, 10_239, 50];
#[test]
fn a_flushed_clip_is_exactly_as_long_as_the_ratio_and_ends_on_signal() {
let cases = [
(44100u32, 48000u32, 1.0f64),
(48000, 44100, 1.0),
(44100, 44100, 2f64.powf(1.0 / 12.0)),
(44100, 44100, 2.0),
(44100, 44100, 0.5),
];
for (rate_in, rate_out, speed) in cases {
for frames in CLIP_LENGTHS {
let mut r = Resample::new(rate_in, rate_out, 2, speed).unwrap();
let mut out = Vec::new();
r.push(&vec![0.5; frames * 2], &mut out);
r.flush(&mut out);
let case = format!("{rate_in}->{rate_out} at {speed:.3}x, {frames} frames");
let want = frames as f64 * rate_out as f64 / (rate_in as f64 * speed);
let got = out.len() / 2;
assert!(
(got as f64 - want).abs() <= 1.0,
"{case}: got {got} frames, want {want:.1}"
);
let silent = out.iter().rev().take_while(|v| v.abs() < 0.1).count() / 2;
assert!(silent <= 1, "{case}: ends in {silent} silent frames");
}
}
}
#[test]
fn normal_speed_is_unchanged() {
let input = sine(44100, 440.0, 44100);
let out = at_speed(&input, 44100, 1.0);
let frames = out.len() / 2;
assert!(
(frames as f32 - 44100.0).abs() / 44100.0 < 0.03,
"got {frames} frames"
);
assert_clean_tone(&out[5_000 * 2..35_000 * 2], 44100, 2, 440.0);
}