use std::path::Path;
const MAX_WAV_FILE_SIZE: u64 = 1_073_741_824; const MAX_DURATION_SECS: f64 = 3600.0;
pub const TARGET_SAMPLE_RATE: u32 = 16_000;
#[cfg(feature = "audio-io")]
mod decode;
#[cfg(feature = "audio-io")]
mod resample;
#[derive(thiserror::Error, Debug)]
pub enum WavError {
#[error("failed to read WAV: {0}")]
Read(#[from] hound::Error),
#[error("unsupported sample format: {0}")]
UnsupportedFormat(String),
#[error("WAV file too large: {size} bytes (max {max} bytes)")]
FileTooLarge { size: u64, max: u64 },
#[error("WAV duration too long: {duration_secs:.1}s (max {max_secs:.1}s)")]
DurationTooLong { duration_secs: f64, max_secs: f64 },
#[error("failed to get file metadata: {0}")]
Metadata(#[from] std::io::Error),
#[error(
"unsupported sample rate {actual} Hz (expected {expected} Hz mono). \
Rebuild with `--features audio-io` to enable resampling of any rate to 16 kHz"
)]
UnsupportedSampleRate { actual: u32, expected: u32 },
#[error(
"cannot decode '{path}': multi-format decoding requires the `audio-io` cargo feature \
(rebuild with `--features audio-io`). With that feature: mp3, flac, ogg, m4a/aac, wav \
at any sample rate are decoded and resampled to 16 kHz mono"
)]
FeatureRequired { path: String },
#[error("failed to decode audio: {0}")]
Decode(String),
#[error("failed to resample audio: {0}")]
Resample(String),
}
pub fn read_wav(path: &Path) -> Result<(Vec<f32>, u32), WavError> {
let metadata = std::fs::metadata(path)?;
let file_size = metadata.len();
if file_size > MAX_WAV_FILE_SIZE {
return Err(WavError::FileTooLarge {
size: file_size,
max: MAX_WAV_FILE_SIZE,
});
}
let reader = hound::WavReader::open(path)?;
let spec = reader.spec();
let channels = spec.channels as usize;
let sample_rate = spec.sample_rate;
let duration = reader.duration();
let duration_secs = duration as f64 / sample_rate as f64;
if duration_secs > MAX_DURATION_SECS {
return Err(WavError::DurationTooLong {
duration_secs,
max_secs: MAX_DURATION_SECS,
});
}
let bps = spec.bits_per_sample;
if bps == 0 || bps > 32 {
return Err(WavError::UnsupportedFormat(format!(
"bits_per_sample {bps} out of supported range 1..=32"
)));
}
let interleaved: Vec<f32> = match spec.sample_format {
hound::SampleFormat::Int => {
let max_val = (1i64 << (bps - 1)) as f32;
reader
.into_samples::<i32>()
.map(|s| s.map(|v| v as f32 / max_val))
.collect::<Result<Vec<f32>, _>>()?
}
hound::SampleFormat::Float => reader
.into_samples::<f32>()
.collect::<Result<Vec<f32>, _>>()?,
};
let mono = downmix_to_mono(interleaved, channels);
Ok((mono, sample_rate))
}
pub fn load_audio(path: &Path) -> Result<(Vec<f32>, u32), WavError> {
#[cfg(feature = "audio-io")]
{
load_audio_any(path)
}
#[cfg(not(feature = "audio-io"))]
{
load_audio_wav_only(path)
}
}
#[cfg(not(feature = "audio-io"))]
fn load_audio_wav_only(path: &Path) -> Result<(Vec<f32>, u32), WavError> {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext = ext.to_ascii_lowercase();
if !matches!(ext.as_str(), "wav" | "wave") {
return Err(WavError::FeatureRequired {
path: path.display().to_string(),
});
}
}
let (samples, sr) = read_wav(path)?;
if sr != TARGET_SAMPLE_RATE {
return Err(WavError::UnsupportedSampleRate {
actual: sr,
expected: TARGET_SAMPLE_RATE,
});
}
Ok((samples, TARGET_SAMPLE_RATE))
}
#[cfg(feature = "audio-io")]
fn load_audio_any(path: &Path) -> Result<(Vec<f32>, u32), WavError> {
let (samples, sr) = decode_path(path)?;
if samples.is_empty() {
return Ok((samples, TARGET_SAMPLE_RATE));
}
if sr == TARGET_SAMPLE_RATE {
return Ok((samples, TARGET_SAMPLE_RATE));
}
if sr == 0 {
return Err(WavError::Decode("sample rate is zero".into()));
}
let resampled = resample::resample_mono(&samples, sr, TARGET_SAMPLE_RATE)?;
Ok((resampled, TARGET_SAMPLE_RATE))
}
#[cfg(feature = "audio-io")]
fn decode_path(path: &Path) -> Result<(Vec<f32>, u32), WavError> {
let is_wav = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "wav" | "wave"));
if is_wav {
return read_wav(path);
}
decode::decode_with_symphonia(path)
}
fn downmix_to_mono(interleaved: Vec<f32>, channels: usize) -> Vec<f32> {
if channels <= 1 {
return interleaved;
}
interleaved
.chunks(channels)
.map(|ch| ch.iter().sum::<f32>() / channels as f32)
.collect()
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn write_sine_wav(path: &Path, sample_rate: u32, duration_secs: f32, freq_hz: f32) {
let spec = hound::WavSpec {
channels: 1,
sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let n = (sample_rate as f32 * duration_secs) as usize;
let mut writer = hound::WavWriter::create(path, spec).unwrap();
for i in 0..n {
let t = i as f32 / sample_rate as f32;
let sample = (t * std::f32::consts::TAU * freq_hz).sin();
writer
.write_sample((sample * 32767.0 * 0.5) as i16)
.unwrap();
}
writer.finalize().unwrap();
}
#[test]
fn missing_file_error() {
let result = read_wav(Path::new("/nonexistent/path/file.wav"));
assert!(result.is_err());
}
#[test]
fn wav_error_display() {
let e = WavError::FileTooLarge {
size: 2_000_000_000,
max: MAX_WAV_FILE_SIZE,
};
let msg = format!("{e}");
assert!(msg.contains("too large"));
}
#[test]
fn load_audio_accepts_16k_wav() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ok.wav");
write_sine_wav(&path, 16_000, 0.25, 440.0);
let (samples, sr) = load_audio(&path).unwrap();
assert_eq!(sr, TARGET_SAMPLE_RATE);
assert_eq!(samples.len(), 4_000);
}
#[test]
fn load_audio_rejects_non_wav_without_feature() {
#[cfg(not(feature = "audio-io"))]
{
let err = load_audio(Path::new("meeting.mp3")).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("audio-io"),
"expected rebuild hint, got: {msg}"
);
}
}
#[test]
fn load_audio_rejects_wrong_rate_without_feature() {
#[cfg(not(feature = "audio-io"))]
{
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("48k.wav");
write_sine_wav(&path, 48_000, 0.1, 440.0);
let err = load_audio(&path).unwrap_err();
match err {
WavError::UnsupportedSampleRate {
actual: 48_000,
expected: TARGET_SAMPLE_RATE,
} => {}
other => panic!("unexpected error: {other}"),
}
let msg = format!("{err}");
assert!(msg.contains("audio-io"));
}
}
#[cfg(feature = "audio-io")]
#[test]
fn load_audio_resamples_multi_rate_wav_duration() {
let cases: &[(u32, f32)] = &[(8_000, 1.0), (22_050, 1.0), (44_100, 1.0), (48_000, 1.0)];
let dir = tempfile::tempdir().unwrap();
for &(rate, secs) in cases {
let path = dir.path().join(format!("tone_{rate}.wav"));
write_sine_wav(&path, rate, secs, 220.0);
let (samples, sr) = load_audio(&path).unwrap();
assert_eq!(sr, TARGET_SAMPLE_RATE, "rate={rate}");
let got_secs = samples.len() as f64 / TARGET_SAMPLE_RATE as f64;
let err = (got_secs - secs as f64).abs();
assert!(
err < 0.02,
"rate={rate}: duration {got_secs:.4}s vs {secs}s (err={err:.4})"
);
let energy: f32 = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
assert!(energy > 1e-6, "rate={rate}: near-silent after resample");
}
}
#[cfg(feature = "audio-io")]
#[test]
fn load_audio_16k_wav_bypasses_resampler_byte_length() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("native16k.wav");
write_sine_wav(&path, 16_000, 0.5, 330.0);
let (via_load, sr) = load_audio(&path).unwrap();
let (via_read, sr2) = read_wav(&path).unwrap();
assert_eq!(sr, 16_000);
assert_eq!(sr2, 16_000);
assert_eq!(via_load.len(), via_read.len());
assert_eq!(via_load, via_read);
}
#[test]
fn read_wav_stereo_roundtrip_via_cursor_buffer() {
let spec = hound::WavSpec {
channels: 1,
sample_rate: 16_000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut buf = Vec::new();
{
let cursor = Cursor::new(&mut buf);
let mut writer = hound::WavWriter::new(cursor, spec).unwrap();
for _ in 0..100 {
writer.write_sample(0i16).unwrap();
}
writer.finalize().unwrap();
}
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &buf).unwrap();
let (samples, sr) = read_wav(tmp.path()).unwrap();
assert_eq!(sr, 16_000);
assert_eq!(samples.len(), 100);
}
}