Files
soundcraft/crates/audio-io/tests/roundtrip.rs
T
Brandon Thomas 8ae491b6d2 Merge remote-tracking branch 'origin/main' into m/628
# Conflicts:
#	crates/audio-io/tests/roundtrip.rs
2026-10-19 03:04:59 -04:00

319 lines
15 KiB
Rust

use soundcraft_audio_io::{
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
};
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
let mut b = AudioBuffer::new(sr, channels, frames);
for (c, ch) in b.channels.iter_mut().enumerate() {
let f = 220.0 * (c as f32 + 1.0);
for (i, s) in ch.iter_mut().enumerate() {
*s = 0.8 * (2.0 * std::f32::consts::PI * f * i as f32 / sr as f32 + c as f32).sin();
}
}
b
}
fn tolerance(depth: BitDepth, dither: bool) -> f32 {
let lsb = match depth {
BitDepth::Int16 => 1.0 / 32768.0,
BitDepth::Int24 => 1.0 / 8_388_608.0,
BitDepth::Int32 => 1e-7,
BitDepth::Float32 => 0.0,
};
lsb * if dither { 1.6 } else { 0.6 } + 1e-7
}
fn max_err(a: &AudioBuffer, b: &AudioBuffer) -> f32 {
assert_eq!(a.num_channels(), b.num_channels());
assert_eq!(a.frames(), b.frames());
a.channels.iter().zip(&b.channels).flat_map(|(x, y)| x.iter().zip(y).map(|(p, q)| (p - q).abs())).fold(0.0, f32::max)
}
const DEPTHS: [BitDepth; 4] = [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32, BitDepth::Float32];
fn expected_sf(d: BitDepth) -> SampleFormat {
match d {
BitDepth::Int16 => SampleFormat::Int16,
BitDepth::Int24 => SampleFormat::Int24,
BitDepth::Int32 => SampleFormat::Int32,
BitDepth::Float32 => SampleFormat::Float32,
}
}
#[test]
fn wav_and_aiff_roundtrip_all_depths_and_layouts() {
for format in [FileFormat::Wav, FileFormat::Aiff] {
for depth in DEPTHS {
for channels in [1, 2, 6] {
for dither in [false, true] {
let src = sine(48_000, channels, 4801);
let opts = EncodeOptions { format, bit_depth: depth, dither, bwf: None };
let bytes = encode(&src, &opts).unwrap();
assert_eq!(detect_format(&bytes, None), format);
let info = probe(&bytes, None).unwrap();
assert_eq!(info.format, format);
assert_eq!(info.sample_rate, 48_000);
assert_eq!(info.channels as usize, channels);
assert_eq!(info.frames, 4801);
assert_eq!(info.sample_format, expected_sf(depth));
let (dinfo, out) = decode(&bytes, None).unwrap();
assert_eq!(dinfo, info);
assert_eq!(out.sample_rate, 48_000);
let err = max_err(&src, &out);
assert!(err <= tolerance(depth, dither), "{format:?} {depth:?} {channels}ch dither={dither}: err {err}");
}
}
}
}
}
#[test]
fn flac_roundtrip_is_lossless_vs_wav() {
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
for channels in [1, 2, 6] {
// Spans several blocks plus a short final block.
let src = sine(44_100, channels, 4096 * 2 + 17);
let wav = encode(&src, &EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: None }).unwrap();
let flac = encode(&src, &EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither: false, bwf: None }).unwrap();
assert_eq!(detect_format(&flac, None), FileFormat::Flac);
let info = probe(&flac, None).unwrap();
assert_eq!(info.format, FileFormat::Flac);
assert_eq!((info.sample_rate, info.channels as usize, info.frames), (44_100, channels, src.frames() as u64));
assert_eq!(info.sample_format, expected_sf(depth));
let (_, from_wav) = decode(&wav, None).unwrap();
let (finfo, from_flac) = decode(&flac, None).unwrap();
assert_eq!(finfo.frames, src.frames() as u64);
assert_eq!(from_flac, from_wav, "{depth:?} {channels}ch");
if depth != BitDepth::Int32 {
assert!(flac.len() < wav.len(), "{depth:?} {channels}ch: flac {} >= wav {}", flac.len(), wav.len());
}
}
}
}
#[test]
fn flac_handles_silence_noise_full_scale_and_odd_rates() {
let frames = 10_000;
let mut b = AudioBuffer::new(37_123, 3, frames);
let mut seed = 1u32;
for i in 0..frames {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
b.channels[1][i] = (seed as f32 / u32::MAX as f32) * 2.0 - 1.0; // white noise
b.channels[2][i] = if i % 2 == 0 { 1.5 } else { -1.5 }; // clipped full-scale square
}
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
for dither in [false, true] {
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither, bwf: None };
let flac = encode(&b, &opts).unwrap();
let (info, out) = decode(&flac, None).unwrap();
assert_eq!(info.sample_rate, 37_123);
let wav = encode(&b, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap();
let (_, expect) = decode(&wav, None).unwrap();
assert_eq!(out, expect, "{depth:?} dither={dither}");
}
}
}
#[test]
fn flac_rejects_unsupported_layouts() {
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Float32, dither: false, bwf: None };
assert!(encode(&sine(48_000, 2, 10), &opts).is_err());
let opts = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
assert!(encode(&sine(48_000, 9, 10), &opts).is_err());
assert!(encode(&sine(700_000, 1, 10), &opts).is_err());
// Tiny buffers still encode and decode.
let one = sine(48_000, 2, 1);
let (_, out) = decode(&encode(&one, &opts).unwrap(), None).unwrap();
assert_eq!(out.frames(), 1);
}
#[test]
fn bwf_roundtrip() {
let bwf = BwfInfo {
description: "Scene 12 take 3".into(),
originator: "SoundCraft".into(),
originator_reference: "SC-0001".into(),
origination_date: "2026-10-06".into(),
origination_time: "12:34:56".into(),
time_reference: 0x1_2345_6789, // > 32 bits
};
let src = sine(96_000, 2, 1000);
for depth in DEPTHS {
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: Some(bwf.clone()) };
let bytes = encode(&src, &opts).unwrap();
let info = probe(&bytes, None).unwrap();
assert_eq!(info.bwf.as_ref(), Some(&bwf));
let (dinfo, _) = decode(&bytes, None).unwrap();
assert_eq!(dinfo.bwf, Some(bwf.clone()));
// RF64 layout reads back identically.
let rf64 = encode_wav_rf64(&src, &opts).unwrap();
assert_eq!(&rf64[..4], b"RF64");
let (rinfo, rbuf) = decode(&rf64, None).unwrap();
assert_eq!(rinfo.bwf, Some(bwf.clone()));
assert_eq!(rinfo.frames, 1000);
assert_eq!(rbuf, decode(&bytes, None).unwrap().1);
}
// Overlong strings are truncated to the field width, not an error.
let long = BwfInfo { description: "x".repeat(400), ..BwfInfo::default() };
let bytes = encode(&src, &EncodeOptions { bwf: Some(long), ..EncodeOptions::default() }).unwrap();
assert_eq!(probe(&bytes, None).unwrap().bwf.unwrap().description.len(), 256);
}
#[test]
fn wav_header_layout() {
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { bit_depth: BitDepth::Int16, ..EncodeOptions::default() }).unwrap();
// Plain PCM for 16-bit stereo; extensible otherwise.
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 1);
let b = encode(&sine(44_100, 2, 10), &EncodeOptions::default()).unwrap();
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 0xFFFE);
let riff = u32::from_le_bytes([b[4], b[5], b[6], b[7]]) as usize;
assert_eq!(riff + 8, b.len());
// Odd data length (24-bit mono, odd frames) gets a pad byte.
let b = encode(&sine(44_100, 1, 3), &EncodeOptions::default()).unwrap();
assert_eq!(b.len() % 2, 0);
assert_eq!(decode(&b, None).unwrap().1.frames(), 3);
}
#[test]
fn aiff_float_is_aifc() {
let b =
encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap();
assert_eq!(&b[8..12], b"AIFC");
assert!(b.windows(4).any(|w| w == b"fl32"));
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, ..EncodeOptions::default() }).unwrap();
assert_eq!(&b[8..12], b"AIFF");
let form = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
assert_eq!(form + 8, b.len());
}
#[test]
fn unusual_sample_rates_roundtrip() {
for sr in [1, 8_000, 11_025, 22_050, 44_100, 88_200, 176_400, 192_000, 352_800, 384_000, 768_000] {
for format in [FileFormat::Wav, FileFormat::Aiff] {
let bytes = encode(&sine(sr, 1, 8), &EncodeOptions { format, ..EncodeOptions::default() }).unwrap();
assert_eq!(probe(&bytes, None).unwrap().sample_rate, sr, "{format:?}");
}
}
}
#[test]
fn encode_rejects_bad_buffers_and_formats() {
assert!(encode(&AudioBuffer::default(), &EncodeOptions::default()).is_err());
assert!(encode(&AudioBuffer::new(0, 2, 10), &EncodeOptions::default()).is_err());
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
}
// Non-finite and out-of-range samples are clamped, not errors.
let mut b = AudioBuffer::new(48_000, 1, 4);
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for depth in DEPTHS {
if format == FileFormat::Flac && depth == BitDepth::Float32 {
continue;
}
let bytes = encode(&b, &EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() }).unwrap();
let (_, out) = decode(&bytes, None).unwrap();
assert_eq!(out.channels[0][0], 0.0);
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
assert_eq!(out.channels[0][2], -1.0);
}
}
// Ragged buffers encode the common length.
let ragged = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10], vec![0.2; 7]] };
let (_, out) = decode(&encode(&ragged, &EncodeOptions::default()).unwrap(), None).unwrap();
assert_eq!(out.frames(), 7);
}
#[test]
fn audio_buffer_helpers() {
let b = AudioBuffer::from_interleaved(48_000, 2, &[0.1, -0.2, 0.3, -0.4, 0.5]);
assert_eq!(b.frames(), 2);
assert_eq!(b.num_channels(), 2);
assert_eq!(b.interleaved(), vec![0.1, -0.2, 0.3, -0.4]);
assert!((b.peak() - 0.4).abs() < 1e-7);
assert!((b.duration_secs() - 2.0 / 48_000.0).abs() < 1e-12);
assert_eq!(AudioBuffer::from_interleaved(1, 0, &[1.0]).num_channels(), 0);
assert_eq!(AudioBuffer::new(0, 1, 5).duration_secs(), 0.0);
assert_eq!(AudioBuffer::default().frames(), 0);
}
#[test]
fn serde_types_roundtrip() {
let opts = EncodeOptions { bwf: Some(BwfInfo { time_reference: 42, ..BwfInfo::default() }), ..EncodeOptions::default() };
let s = serde_json::to_string(&opts).unwrap();
assert_eq!(serde_json::from_str::<EncodeOptions>(&s).unwrap(), opts);
let info = probe(&encode(&sine(48_000, 1, 4), &opts).unwrap(), None).unwrap();
let s = serde_json::to_string(&info).unwrap();
assert_eq!(serde_json::from_str::<soundcraft_audio_io::AudioInfo>(&s).unwrap(), info);
}
#[test]
fn multichannel_wav_speaker_masks() {
let buf = |n: usize| AudioBuffer { sample_rate: 48_000, channels: (0..n).map(|c| vec![0.01 * c as f32; 64]).collect() };
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
for (n, mask) in [(6usize, 0x3Fu32), (8, 0x63F), (12, 0x2_D63F)] {
let bytes = soundcraft_audio_io::encode(&buf(n), &opts).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(mask), "{n} channels");
assert_eq!(soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap().1.channels.len(), n);
}
// Explicit masks: 7.0 (sides + rears, no LFE) and Ambisonics (no positions).
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(7), &opts, 0x637).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0x637));
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(4), &opts, 0).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0));
// Plain 16-bit stereo stays a plain PCM header.
let o16 = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
}
#[test]
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
let take = sine(48_000, 2, 1_000);
let mut file = wav_float_header(2, 48_000, 0).unwrap();
let len = file.len();
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
assert!(wav_float_header(0, 48_000, 0).is_err());
}
#[test]
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for depth in [BitDepth::Int16, BitDepth::Int24] {
for channels in [1, 2] {
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
let info = probe(&bytes, None).unwrap();
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
assert_eq!(usize::from(info.channels), channels);
let (info, buf) = decode(&bytes, None).unwrap();
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
}
}
}
}
#[test]
fn flac_claiming_frames_but_holding_none_still_errors() {
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
assert!(decode(&bytes, None).is_err());
}
#[test]
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
let frames = u64::MAX / 4;
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
}