diff --git a/src/audio/clip_player.rs b/src/audio/clip_player.rs index 8a312d7..9e8003f 100644 --- a/src/audio/clip_player.rs +++ b/src/audio/clip_player.rs @@ -5,7 +5,7 @@ //! shared status snapshot at its redraw cadence. use crate::files::AttachmentId; -use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source}; +use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError}; use std::io::Cursor; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; @@ -103,7 +103,11 @@ fn playback_worker(command_rx: mpsc::Receiver, status: SharedClipSt loop { match command_rx.recv_timeout(Duration::from_millis(100)) { Ok(ClipCommand::Play(id, bytes)) => { - let source = match Decoder::new(Cursor::new(bytes)) { + // In-memory readers do not expose file metadata to rodio. Pass + // the known attachment length explicitly so formats without a + // duration in their headers (notably MP3 and Vorbis) can derive + // a total duration and support reliable seeking. + let source = match decode_clip(bytes) { Ok(source) => source, Err(error) => { fail( @@ -194,6 +198,14 @@ fn playback_worker(command_rx: mpsc::Receiver, status: SharedClipSt } } +fn decode_clip(bytes: Vec) -> Result>>, DecoderError> { + let byte_len = bytes.len() as u64; + Decoder::builder() + .with_data(Cursor::new(bytes)) + .with_byte_len(byte_len) + .build() +} + fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) { crate::log_msg(&format!("Inline audio playback failed: {error}")); update_status(status, |s| { @@ -248,6 +260,20 @@ pub fn seek_target(fraction: f32, total: Duration) -> Duration { #[cfg(test)] mod tests { use super::*; + use base64::Engine; + + #[test] + fn in_memory_mp3_reports_duration() { + // One headerless constant-bitrate MP3 frame repeated to model files + // that do not carry an Xing/VBR duration header. + let frame = base64::engine::general_purpose::STANDARD + .decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=") + .expect("valid test fixture"); + let bytes = frame.repeat(20); + + let decoder = decode_clip(bytes).expect("CBR MP3 should decode"); + assert!(decoder.total_duration().is_some()); + } #[test] fn formats_clip_time() {