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 { decode_audio_file_limited(path, None) } pub fn decode_audio_file_limited( path: &Path, max_duration_secs: Option, ) -> Result { 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> { 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, ) -> Result { 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 = 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::::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 { let path = temp_path("kon_decode_tmp_for_bytes.wav"); let samples: Vec = (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>, fail_after_bytes: u64, bytes_read: u64, } impl Read for FlakyCursor { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { 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 { self.inner.seek(pos) } } impl MediaSource for FlakyCursor { fn is_seekable(&self) -> bool { true } fn byte_len(&self) -> Option { 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 = (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:?}" ); } }