//! Audio preview: decodes samples to stereo f32 for playback. //! //! Decoding happens on the GUI thread via Symphonia; the resulting interleaved buffer is handed //! to the cpal audio output thread through [`PreviewPlayback`] behind a `parking_lot::Mutex`. use std::path::Path; use std::sync::atomic::Ordering; use tracing::{instrument, warn}; use symphonia::core::audio::SampleBuffer; use symphonia::core::codecs::DecoderOptions; use symphonia::core::formats::FormatOptions; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use crate::error::PreviewError; /// Decoded audio data ready for playback, always stored as interleaved stereo f32. pub struct PreviewBuffer { /// Interleaved stereo sample data (L, R, L, R, ...). pub data: Vec, /// Number of channels (always 2 after mono/multi-channel conversion). pub channels: usize, /// Original sample rate of the decoded file. pub sample_rate: u32, } /// Mutable playback state shared between the GUI and audio threads. pub struct PreviewPlayback { /// Currently loaded preview buffer, or `None` if nothing is loaded. pub buffer: Option, /// Current playback position in file-rate frames (fractional for resampling). pub position_frac: f64, /// Whether playback is active. pub playing: bool, /// Whether the sample should loop when it reaches the end. pub loop_enabled: bool, /// `true` while a background thread is still decoding and appending to the buffer. pub streaming: bool, /// Number of stereo frames decoded so far (grows during streaming). pub decoded_frames: usize, /// Total frame count from file metadata, for stable cursor display during streaming. pub total_frames_estimate: Option, } impl Default for PreviewPlayback { fn default() -> Self { Self { buffer: None, position_frac: 0.0, playing: false, loop_enabled: false, streaming: false, decoded_frames: 0, total_frames_estimate: None, } } } impl PreviewPlayback { /// Create a new idle playback state with no buffer loaded. pub fn new() -> Self { Self::default() } } /// Decode an audio file to interleaved stereo f32. /// Mono files are doubled to stereo. Multi-channel files are mixed down to stereo. #[instrument(skip_all)] pub fn decode_to_f32(path: &Path) -> Result { let file = std::fs::File::open(path).map_err(|e| PreviewError::Open { path: path.to_path_buf(), source: 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 = match symphonia::default::get_probe().format( &hint, mss, &FormatOptions::default(), &MetadataOptions::default(), ) { Ok(p) => p, Err(e) => { let is_wav = path .extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("wav")); if is_wav { warn!( path = %path.display(), "symphonia probe failed for preview, trying hound fallback: {e}" ); return decode_wav_hound_stereo(path); } return Err(PreviewError::Probe(e.to_string())); } }; let mut format = probed.format; let track = format .default_track() .ok_or(PreviewError::NoTrack)?; let track_id = track.id; let source_sample_rate = track.codec_params.sample_rate.unwrap_or(44100); let mut decoder = symphonia::default::get_codecs() .make(&track.codec_params, &DecoderOptions::default()) .map_err(|e| PreviewError::Decoder(e.to_string()))?; let mut all_samples: Vec = Vec::new(); // Cap at 10 minutes of stereo 48kHz to prevent OOM on files with bad metadata // that bypass the streaming threshold. const MAX_SAMPLES: usize = 10 * 60 * 48_000 * 2; // Reuse SampleBuffer across packets to avoid per-packet allocation. let mut sample_buf: Option> = None; loop { let packet = match format.next_packet() { Ok(p) => p, Err(symphonia::core::errors::Error::IoError(ref e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { break; } Err(e) => return Err(PreviewError::Packet(e.to_string())), }; if packet.track_id() != track_id { continue; } let decoded = match decoder.decode(&packet) { Ok(d) => d, Err(symphonia::core::errors::Error::DecodeError(_)) => continue, Err(e) => return Err(PreviewError::Decode(e.to_string())), }; let spec = *decoded.spec(); let num_frames = decoded.frames(); let num_channels = spec.channels.count(); // Reallocate only when the buffer can't fit this packet. let buf = match &mut sample_buf { Some(buf) if buf.capacity() >= num_frames => buf, _ => { sample_buf = Some(SampleBuffer::::new(num_frames as u64, spec)); sample_buf.as_mut().unwrap() } }; buf.copy_interleaved_ref(decoded); let samples = buf.samples(); // Convert to interleaved stereo interleaved_to_stereo(samples, num_channels, num_frames, &mut all_samples); if all_samples.len() >= MAX_SAMPLES { tracing::warn!("decode_to_f32: hit {MAX_SAMPLES} sample cap, truncating"); break; } } if all_samples.is_empty() { return Err(PreviewError::NoData); } Ok(PreviewBuffer { data: all_samples, channels: 2, sample_rate: source_sample_rate, }) } /// Estimate the duration of an audio file (in seconds) by reading codec metadata /// without decoding. Returns `None` if metadata is unavailable. #[instrument(skip_all)] pub fn estimate_duration(path: &Path) -> Option { let file = std::fs::File::open(path).ok()?; 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); } if let Ok(probed) = symphonia::default::get_probe().format( &hint, mss, &FormatOptions::default(), &MetadataOptions::default(), ) { let track = probed.format.default_track()?; let params = &track.codec_params; let n_frames = params.n_frames?; let sample_rate = params.sample_rate?; if sample_rate == 0 { return None; } return Some(n_frames as f64 / sample_rate as f64); } // Fallback for WAV files Symphonia rejects let is_wav = path.extension().and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("wav")); if is_wav { let reader = hound::WavReader::open(path).ok()?; let spec = reader.spec(); if spec.sample_rate == 0 { return None; } let frames = reader.len() as f64 / spec.channels as f64; return Some(frames / spec.sample_rate as f64); } None } /// Duration threshold in seconds: files longer than this use streaming decode. pub const STREAMING_THRESHOLD_SECS: f64 = 30.0; /// Number of seconds to pre-fill before enabling playback during streaming. const PREFILL_SECS: f64 = 0.5; /// Spawn a background thread that decodes the file and streams data into the /// shared `PreviewPlayback`. The buffer is pre-filled with ~0.5s of audio before /// `playing` is set to `true`, so playback starts without waiting for the full decode. /// /// Accepts `Arc` so the background thread can access the preview Mutex. #[instrument(skip_all)] pub fn start_streaming_decode( path: &Path, shared: &std::sync::Arc, ) -> Result<(), PreviewError> { let file = std::fs::File::open(path).map_err(|e| PreviewError::Open { path: path.to_path_buf(), source: 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 = match symphonia::default::get_probe().format( &hint, mss, &FormatOptions::default(), &MetadataOptions::default(), ) { Ok(p) => p, Err(e) => { let is_wav = path .extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("wav")); if is_wav { warn!( path = %path.display(), "symphonia probe failed for streaming, using hound fallback: {e}" ); // For WAV fallback, just do a full decode (WAV is PCM, fast to read) let buf = decode_wav_hound_stereo(path)?; let mut guard = shared.preview.lock(); let total_frames = buf.data.len() / 2; guard.buffer = Some(buf); guard.position_frac = 0.0; guard.playing = true; guard.streaming = false; guard.decoded_frames = total_frames; guard.total_frames_estimate = Some(total_frames); return Ok(()); } return Err(PreviewError::Probe(e.to_string())); } }; let mut format = probed.format; let track = format.default_track().ok_or(PreviewError::NoTrack)?; let track_id = track.id; let codec_params = track.codec_params.clone(); let source_sample_rate = codec_params.sample_rate.unwrap_or(44100); let n_frames_estimate = codec_params.n_frames.map(|n| n as usize); let prefill_frames = (PREFILL_SECS * source_sample_rate as f64) as usize; let mut decoder = symphonia::default::get_codecs() .make(&codec_params, &DecoderOptions::default()) .map_err(|e| PreviewError::Decoder(e.to_string()))?; // Increment generation to cancel any previous streaming decode thread let my_generation = shared.decode_generation.fetch_add(1, Ordering::AcqRel) + 1; // Set up the initial buffer and playback state (not yet playing) { // Cap capacity to prevent OOM from malformed codec metadata (max ~30 min stereo) let max_frames = source_sample_rate as usize * 60 * 30; let capacity = n_frames_estimate .map(|n| n.min(max_frames)) .unwrap_or(source_sample_rate as usize * 60) * 2; let mut guard = shared.preview.lock(); guard.buffer = Some(PreviewBuffer { data: Vec::with_capacity(capacity), channels: 2, sample_rate: source_sample_rate, }); guard.position_frac = 0.0; guard.playing = false; guard.streaming = true; guard.decoded_frames = 0; guard.total_frames_estimate = n_frames_estimate; } let shared = std::sync::Arc::clone(shared); std::thread::spawn(move || { let mut total_frames = 0usize; let mut started = false; // Reuse SampleBuffer across packets to avoid per-packet allocation. let mut sample_buf: Option> = None; loop { // Check if a newer decode has started — if so, this thread exits if shared.decode_generation.load(Ordering::Acquire) != my_generation { let mut guard = shared.preview.lock(); guard.streaming = false; return; } let packet = match format.next_packet() { Ok(p) => p, Err(symphonia::core::errors::Error::IoError(ref e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { break; } Err(_) => break, }; if packet.track_id() != track_id { continue; } let decoded = match decoder.decode(&packet) { Ok(d) => d, Err(symphonia::core::errors::Error::DecodeError(_)) => continue, Err(_) => break, }; let spec = *decoded.spec(); let num_frames = decoded.frames(); let num_channels = spec.channels.count(); let buf = match &mut sample_buf { Some(buf) if buf.capacity() >= num_frames => buf, _ => { sample_buf = Some(SampleBuffer::::new(num_frames as u64, spec)); sample_buf.as_mut().unwrap() } }; buf.copy_interleaved_ref(decoded); let samples = buf.samples(); // Convert to interleaved stereo in a local batch let mut batch = Vec::with_capacity(num_frames * 2); interleaved_to_stereo(samples, num_channels, num_frames, &mut batch); total_frames += num_frames; // Append batch to shared buffer (brief lock) { let mut guard = shared.preview.lock(); // Check cancellation: if playing was set to false externally (stop_preview), // abort the decode. if started && !guard.playing { guard.streaming = false; return; } if let Some(ref mut buf) = guard.buffer { buf.data.extend_from_slice(&batch); } guard.decoded_frames = total_frames; // Start playback once pre-fill threshold is reached if !started && total_frames >= prefill_frames { guard.playing = true; started = true; } } } // Decode complete let mut guard = shared.preview.lock(); guard.streaming = false; guard.decoded_frames = total_frames; // If the file was very short and we never hit the prefill threshold, start now if !started && total_frames > 0 { guard.playing = true; } }); Ok(()) } /// Convert interleaved multi-channel samples to interleaved stereo. fn interleaved_to_stereo( samples: &[f32], num_channels: usize, num_frames: usize, out: &mut Vec, ) { // Sanitize: replace NaN/Inf with silence to prevent downstream propagation // (NaN.clamp() returns NaN, so the audio output clamp won't catch it). let clean = |s: f32| if s.is_finite() { s } else { 0.0 }; match num_channels { 1 => { for &s in samples { let v = clean(s); out.push(v); out.push(v); } } 2 => { for &s in samples { out.push(clean(s)); } } n => { for frame in 0..num_frames { let base = frame * n; let left = clean(samples.get(base).copied().unwrap_or(0.0)); let right = clean(samples.get(base + 1).copied().unwrap_or(0.0)); out.push(left); out.push(right); } } } } /// Fallback WAV decoder using hound for files Symphonia rejects /// (non-standard fmt chunk sizes: 18 or 20 bytes instead of 16 for PCM). /// Returns interleaved stereo, matching the Symphonia decode path output. fn decode_wav_hound_stereo(path: &Path) -> Result { let reader = hound::WavReader::open(path).map_err(|e| PreviewError::Probe(format!("hound: {e}")))?; let spec = reader.spec(); let source_sample_rate = spec.sample_rate; let channels = spec.channels as usize; let raw: Vec = match spec.sample_format { hound::SampleFormat::Int => { let max_val = (1i64 << (spec.bits_per_sample - 1)) as f32; reader .into_samples::() .map(|s| s.map(|v| v as f32 / max_val)) .collect::>() .map_err(|e| PreviewError::Decode(format!("hound: {e}")))? } hound::SampleFormat::Float => reader .into_samples::() .collect::>() .map_err(|e| PreviewError::Decode(format!("hound: {e}")))?, }; let num_frames = raw.len() / channels.max(1); let mut stereo = Vec::with_capacity(num_frames * 2); interleaved_to_stereo(&raw, channels, num_frames, &mut stereo); if stereo.is_empty() { return Err(PreviewError::NoData); } Ok(PreviewBuffer { data: stereo, channels: 2, sample_rate: source_sample_rate, }) } #[cfg(test)] mod tests { use super::*; /// Write a minimal WAV file with f32 PCM data. fn write_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { use std::io::Write; let bytes_per_sample = 4u16; // f32 let block_align = channels * bytes_per_sample; let data_size = (samples.len() as u32) * 4; let file_size = 36 + data_size; let mut buf = Vec::with_capacity(44 + data_size as usize); // RIFF header buf.extend_from_slice(b"RIFF"); buf.extend_from_slice(&file_size.to_le_bytes()); buf.extend_from_slice(b"WAVE"); // fmt chunk — IEEE float buf.extend_from_slice(b"fmt "); buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size buf.extend_from_slice(&3u16.to_le_bytes()); // format = IEEE float buf.extend_from_slice(&channels.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes()); buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); // byte rate buf.extend_from_slice(&block_align.to_le_bytes()); buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); // bits per sample // data chunk buf.extend_from_slice(b"data"); buf.extend_from_slice(&data_size.to_le_bytes()); for &s in samples { buf.extend_from_slice(&s.to_le_bytes()); } let mut file = std::fs::File::create(path).unwrap(); file.write_all(&buf).unwrap(); } #[test] fn decode_mono_duplicates_to_stereo() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mono.wav"); write_wav(&path, 1, 44100, &[0.5, -0.5, 0.25]); let buf = decode_to_f32(&path).unwrap(); assert_eq!(buf.channels, 2); assert_eq!(buf.data, vec![0.5, 0.5, -0.5, -0.5, 0.25, 0.25]); } #[test] fn decode_stereo_passthrough() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("stereo.wav"); let samples = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]; write_wav(&path, 2, 48000, &samples); let buf = decode_to_f32(&path).unwrap(); assert_eq!(buf.channels, 2); assert_eq!(buf.data, samples); } #[test] fn decode_multichannel_takes_first_two() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("quad.wav"); // 4 channels, 2 frames: [L0, R0, C0, S0, L1, R1, C1, S1] let samples = vec![0.1, 0.2, 0.9, 0.8, 0.3, 0.4, 0.7, 0.6]; write_wav(&path, 4, 44100, &samples); let buf = decode_to_f32(&path).unwrap(); assert_eq!(buf.channels, 2); assert_eq!(buf.data, vec![0.1, 0.2, 0.3, 0.4]); } #[test] fn decode_preserves_sample_rate() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("rate.wav"); write_wav(&path, 1, 96000, &[0.0, 0.0]); let buf = decode_to_f32(&path).unwrap(); assert_eq!(buf.sample_rate, 96000); } #[test] fn decode_nonexistent_returns_open_error() { let result = decode_to_f32(Path::new("/tmp/nonexistent_audiofiles_test.wav")); assert!(matches!(result, Err(PreviewError::Open { .. }))); } #[test] fn decode_empty_wav_returns_no_data() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("empty.wav"); write_wav(&path, 1, 44100, &[]); let result = decode_to_f32(&path); assert!(matches!(result, Err(PreviewError::NoData))); } #[test] fn output_is_always_stereo() { let dir = tempfile::tempdir().unwrap(); for channels in [1u16, 2, 4] { let path = dir.path().join(format!("{channels}ch.wav")); let samples: Vec = (0..channels as usize * 3) .map(|i| i as f32 * 0.1) .collect(); write_wav(&path, channels, 44100, &samples); let buf = decode_to_f32(&path).unwrap(); assert_eq!(buf.channels, 2, "expected stereo for {channels}-channel input"); } } }