284 lines
9.7 KiB
Rust
284 lines
9.7 KiB
Rust
use std::fs::File;
|
|
use std::path::Path;
|
|
|
|
use symphonia::core::audio::SampleBuffer;
|
|
use symphonia::core::codecs::DecoderOptions;
|
|
use symphonia::core::errors::Error as SymphoniaError;
|
|
use symphonia::core::formats::FormatOptions;
|
|
use symphonia::core::io::MediaSourceStream;
|
|
use symphonia::core::meta::MetadataOptions;
|
|
use symphonia::core::probe::Hint;
|
|
|
|
use kon_core::error::{KonError, Result};
|
|
use kon_core::types::AudioSamples;
|
|
|
|
/// Decode an audio file to mono f32 PCM samples.
|
|
/// Supports all formats symphonia handles: mp3, aac, flac, wav, ogg, etc.
|
|
///
|
|
/// Any read- or decode-side error is propagated as `KonError::AudioDecodeFailed`.
|
|
/// A previous implementation `break`ed out of the packet loop on any read
|
|
/// error and skipped per-packet decode errors, so a truncated or corrupt
|
|
/// input silently returned `Ok` with whatever had decoded before the
|
|
/// failure — flagged by the 2026-04-22 review (RB-09).
|
|
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
|
|
decode_audio_file_limited(path, None)
|
|
}
|
|
|
|
pub fn decode_audio_file_limited(
|
|
path: &Path,
|
|
max_duration_secs: Option<f64>,
|
|
) -> Result<AudioSamples> {
|
|
let file = File::open(path)
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
|
|
|
let mut hint = Hint::new();
|
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
|
hint.with_extension(ext);
|
|
}
|
|
|
|
decode_media_stream(mss, &hint, max_duration_secs)
|
|
}
|
|
|
|
pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> {
|
|
let file = File::open(path)
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
|
let mut hint = Hint::new();
|
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
|
hint.with_extension(ext);
|
|
}
|
|
|
|
let probed = symphonia::default::get_probe()
|
|
.format(
|
|
&hint,
|
|
mss,
|
|
&FormatOptions::default(),
|
|
&MetadataOptions::default(),
|
|
)
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
|
|
let track = probed
|
|
.format
|
|
.default_track()
|
|
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
|
|
let sample_rate = track
|
|
.codec_params
|
|
.sample_rate
|
|
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
|
Ok(track
|
|
.codec_params
|
|
.n_frames
|
|
.map(|frames| frames as f64 / sample_rate as f64))
|
|
}
|
|
|
|
/// Decode from an already-constructed `MediaSourceStream`. Split out so
|
|
/// tests can inject a custom `MediaSource` (for example, one that
|
|
/// returns a mid-stream I/O error) to verify error propagation.
|
|
fn decode_media_stream(
|
|
mss: MediaSourceStream,
|
|
hint: &Hint,
|
|
max_duration_secs: Option<f64>,
|
|
) -> Result<AudioSamples> {
|
|
let probed = symphonia::default::get_probe()
|
|
.format(
|
|
hint,
|
|
mss,
|
|
&FormatOptions::default(),
|
|
&MetadataOptions::default(),
|
|
)
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
|
|
|
|
let mut format = probed.format;
|
|
|
|
let track = format
|
|
.default_track()
|
|
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
|
|
let sample_rate = track
|
|
.codec_params
|
|
.sample_rate
|
|
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
|
|
|
if sample_rate == 0 {
|
|
return Err(KonError::AudioDecodeFailed("Invalid sample rate: 0".into()));
|
|
}
|
|
|
|
let track_id = track.id;
|
|
let max_samples = max_duration_secs.map(|secs| (secs * sample_rate as f64).ceil() as usize);
|
|
|
|
let mut decoder = symphonia::default::get_codecs()
|
|
.make(&track.codec_params, &DecoderOptions::default())
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("Codec error: {e}")))?;
|
|
|
|
let mut samples: Vec<f32> = Vec::new();
|
|
|
|
loop {
|
|
let packet = match format.next_packet() {
|
|
Ok(p) => p,
|
|
Err(SymphoniaError::IoError(ref e))
|
|
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
|
{
|
|
// Normal end of stream — symphonia signals EOF via UnexpectedEof.
|
|
break;
|
|
}
|
|
Err(SymphoniaError::ResetRequired) => {
|
|
return Err(KonError::AudioDecodeFailed(
|
|
"decoder reset required mid-stream — input contains a discontinuity".into(),
|
|
));
|
|
}
|
|
Err(e) => {
|
|
return Err(KonError::AudioDecodeFailed(format!(
|
|
"packet read failed: {e}"
|
|
)));
|
|
}
|
|
};
|
|
|
|
if packet.track_id() != track_id {
|
|
continue;
|
|
}
|
|
|
|
let decoded = decoder
|
|
.decode(&packet)
|
|
.map_err(|e| KonError::AudioDecodeFailed(format!("packet decode failed: {e}")))?;
|
|
|
|
let spec = *decoded.spec();
|
|
let channels = spec.channels.count();
|
|
let mut sample_buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
|
sample_buf.copy_interleaved_ref(decoded);
|
|
|
|
let buf = sample_buf.samples();
|
|
if channels == 1 {
|
|
samples.extend_from_slice(buf);
|
|
} else {
|
|
for chunk in buf.chunks(channels) {
|
|
let sum: f32 = chunk.iter().sum();
|
|
samples.push(sum / channels as f32);
|
|
}
|
|
}
|
|
if max_samples
|
|
.map(|limit| samples.len() > limit)
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(KonError::AudioDecodeFailed(format!(
|
|
"Audio is longer than the {:.0} minute import limit",
|
|
max_duration_secs.unwrap_or(0.0) / 60.0
|
|
)));
|
|
}
|
|
}
|
|
|
|
if samples.is_empty() {
|
|
return Err(KonError::AudioDecodeFailed("No audio data decoded".into()));
|
|
}
|
|
|
|
Ok(AudioSamples::new(samples, sample_rate, 1))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::wav::write_wav;
|
|
use std::io::{Cursor, Read, Seek, SeekFrom};
|
|
use symphonia::core::io::MediaSource;
|
|
|
|
fn temp_path(name: &str) -> std::path::PathBuf {
|
|
let mut p = std::env::temp_dir();
|
|
p.push(name);
|
|
let _ = std::fs::remove_file(&p);
|
|
p
|
|
}
|
|
|
|
fn valid_wav_bytes(sample_count: usize) -> Vec<u8> {
|
|
let path = temp_path("kon_decode_tmp_for_bytes.wav");
|
|
let samples: Vec<f32> = (0..sample_count).map(|i| (i as f32) / 1000.0).collect();
|
|
let audio = AudioSamples::mono_16khz(samples);
|
|
write_wav(&path, &audio).unwrap();
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
std::fs::remove_file(&path).ok();
|
|
bytes
|
|
}
|
|
|
|
/// A `MediaSource` that wraps a byte buffer and returns an injected
|
|
/// I/O error once more than `fail_after_bytes` total bytes have been
|
|
/// returned successfully. Simulates real-world disk or network read
|
|
/// failure mid-stream.
|
|
struct FlakyCursor {
|
|
inner: Cursor<Vec<u8>>,
|
|
fail_after_bytes: u64,
|
|
bytes_read: u64,
|
|
}
|
|
|
|
impl Read for FlakyCursor {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
if self.bytes_read >= self.fail_after_bytes {
|
|
return Err(std::io::Error::other("injected mid-stream read error"));
|
|
}
|
|
let n = self.inner.read(buf)?;
|
|
self.bytes_read = self.bytes_read.saturating_add(n as u64);
|
|
Ok(n)
|
|
}
|
|
}
|
|
|
|
impl Seek for FlakyCursor {
|
|
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
|
self.inner.seek(pos)
|
|
}
|
|
}
|
|
|
|
impl MediaSource for FlakyCursor {
|
|
fn is_seekable(&self) -> bool {
|
|
true
|
|
}
|
|
fn byte_len(&self) -> Option<u64> {
|
|
Some(self.inner.get_ref().len() as u64)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_valid_wav_successfully() {
|
|
let path = temp_path("kon_decode_valid.wav");
|
|
let samples: Vec<f32> = (0..4_000).map(|i| (i as f32) / 1000.0).collect();
|
|
write_wav(&path, &AudioSamples::mono_16khz(samples)).unwrap();
|
|
|
|
let loaded = decode_audio_file(&path).expect("valid WAV must decode");
|
|
assert_eq!(loaded.sample_rate(), 16_000);
|
|
assert!(!loaded.samples().is_empty());
|
|
|
|
std::fs::remove_file(&path).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn missing_file_surfaces_error() {
|
|
let path = temp_path("kon_decode_missing.wav");
|
|
let result = decode_audio_file(&path);
|
|
assert!(result.is_err(), "missing file must error, got: {result:?}");
|
|
}
|
|
|
|
// RB-09 regression: once probe has succeeded, any mid-stream I/O
|
|
// error must surface as `Err(AudioDecodeFailed)` rather than being
|
|
// silently swallowed and returning whatever was decoded so far.
|
|
//
|
|
// Pre-fix behaviour: the packet loop had `Err(_) => break`, so an
|
|
// I/O error during `format.next_packet()` dropped out of the loop
|
|
// and the function returned `Ok` with partial samples.
|
|
#[test]
|
|
fn mid_stream_io_error_propagates_instead_of_returning_partial_audio() {
|
|
let bytes = valid_wav_bytes(16_000);
|
|
// Fail after ~1 KiB — probe has seen the RIFF/WAVE header by then,
|
|
// so probing succeeds. The packet loop hits our injected error
|
|
// before the stream reaches its natural EOF.
|
|
let flaky = FlakyCursor {
|
|
inner: Cursor::new(bytes),
|
|
fail_after_bytes: 1024,
|
|
bytes_read: 0,
|
|
};
|
|
let mss = MediaSourceStream::new(Box::new(flaky), Default::default());
|
|
let mut hint = Hint::new();
|
|
hint.with_extension("wav");
|
|
|
|
let result = decode_media_stream(mss, &hint, None);
|
|
assert!(
|
|
result.is_err(),
|
|
"mid-stream I/O error must surface, got: {result:?}"
|
|
);
|
|
}
|
|
}
|