Skip to main content

active_call/media/track/
file.rs

1use crate::event::{EventSender, SessionEvent};
2use crate::media::processor::ProcessorChain;
3use crate::media::{AudioFrame, PcmBuf, Samples, TrackId};
4use crate::media::track::{Track, TrackConfig, TrackPacketSender};
5use anyhow::{Result, anyhow};
6use async_trait::async_trait;
7use audio_codec::BoxedResampler;
8use hound::WavReader;
9use std::cmp::min;
10use std::fs::File;
11use std::io::BufReader;
12use std::sync::{
13    Arc,
14    atomic::{AtomicBool, Ordering},
15};
16use std::time::Instant;
17use tokio::select;
18use tokio::time::Duration;
19use tokio_util::sync::CancellationToken;
20use tracing::{debug, info, warn};
21
22trait AudioReader: Send {
23    fn fill_buffer(&mut self) -> Result<usize>;
24
25    fn read_chunk(&mut self, packet_duration_ms: u32) -> Result<Option<(PcmBuf, u32)>> {
26        let max_chunk_size = self.sample_rate() as usize * packet_duration_ms as usize / 1000;
27
28        if self.buffer_size() == 0 || self.position() >= self.buffer_size() {
29            let samples_read = self.fill_buffer()?;
30            if samples_read == 0 {
31                return Ok(None);
32            }
33            self.set_position(0);
34        }
35
36        let remaining = self.buffer_size() - self.position();
37        if remaining == 0 {
38            return Ok(None);
39        }
40
41        let chunk_size = min(max_chunk_size, remaining);
42        let end_pos = self.position() + chunk_size;
43
44        assert!(
45            end_pos <= self.buffer_size(),
46            "Buffer overrun: pos={}, end={}, size={}",
47            self.position(),
48            end_pos,
49            self.buffer_size()
50        );
51
52        let chunk = self.extract_chunk(self.position(), end_pos);
53        self.set_position(end_pos);
54
55        let final_chunk =
56            if self.sample_rate() != self.target_sample_rate() && self.sample_rate() > 0 {
57                self.resample_chunk(&chunk)
58            } else {
59                chunk
60            };
61
62        Ok(Some((final_chunk, self.target_sample_rate())))
63    }
64
65    fn buffer_size(&self) -> usize;
66    fn position(&self) -> usize;
67    fn set_position(&mut self, pos: usize);
68    fn sample_rate(&self) -> u32;
69    fn target_sample_rate(&self) -> u32;
70    fn channels(&self) -> u16;
71    fn extract_chunk(&self, start: usize, end: usize) -> Vec<i16>;
72    fn resample_chunk(&mut self, chunk: &[i16]) -> Vec<i16>;
73}
74
75struct DecodedAudioReader {
76    buffer: Vec<i16>,
77    sample_rate: u32,
78    position: usize,
79    target_sample_rate: u32,
80    resampler: Option<BoxedResampler>,
81}
82
83impl DecodedAudioReader {
84    #[cfg(test)]
85    fn from_file(
86        file: File,
87        extension: &str,
88        mime_type: Option<&str>,
89        target_sample_rate: u32,
90    ) -> Result<Self> {
91        let all_samples =
92            crate::media::loader::decode_audio(file, extension, mime_type, target_sample_rate)?;
93        Ok(Self {
94            buffer: all_samples,
95            sample_rate: target_sample_rate,
96            position: 0,
97            target_sample_rate,
98            resampler: None,
99        })
100    }
101
102    /// Build a reader from already-decoded PCM samples. The samples are assumed
103    /// to already be at `sample_rate`, so no further resampling happens when it
104    /// matches `target_sample_rate`.
105    fn from_samples(buffer: Vec<i16>, sample_rate: u32, target_sample_rate: u32) -> Self {
106        Self {
107            buffer,
108            sample_rate,
109            position: 0,
110            target_sample_rate,
111            resampler: None,
112        }
113    }
114}
115
116impl AudioReader for DecodedAudioReader {
117    fn fill_buffer(&mut self) -> Result<usize> {
118        if self.position >= self.buffer.len() {
119            return Ok(0);
120        }
121        Ok(self.buffer.len() - self.position)
122    }
123
124    fn buffer_size(&self) -> usize {
125        self.buffer.len()
126    }
127
128    fn position(&self) -> usize {
129        self.position
130    }
131
132    fn set_position(&mut self, pos: usize) {
133        self.position = pos;
134    }
135
136    fn sample_rate(&self) -> u32 {
137        self.sample_rate
138    }
139
140    fn target_sample_rate(&self) -> u32 {
141        self.target_sample_rate
142    }
143
144    fn channels(&self) -> u16 {
145        1
146    }
147
148    fn extract_chunk(&self, start: usize, end: usize) -> Vec<i16> {
149        self.buffer[start..end].to_vec()
150    }
151
152    fn resample_chunk(&mut self, chunk: &[i16]) -> Vec<i16> {
153        if self.sample_rate == 0 || self.sample_rate == self.target_sample_rate {
154            return chunk.to_vec();
155        }
156
157        if let Some(resampler) = &mut self.resampler {
158            resampler.resample(chunk)
159        } else {
160            let mut new_resampler = BoxedResampler::new(
161                self.sample_rate as usize,
162                self.target_sample_rate as usize,
163            )
164            .expect("invalid sample rate");
165            let result = new_resampler.resample(chunk);
166            self.resampler = Some(new_resampler);
167            result
168        }
169    }
170}
171
172// Unified function to process any audio reader and stream audio
173async fn process_audio_reader(
174    mut processor_chain: ProcessorChain,
175    mut audio_reader: Box<dyn AudioReader>,
176    track_id: &str,
177    packet_duration_ms: u32,
178    target_sample_rate: u32,
179    token: CancellationToken,
180    paused: Arc<AtomicBool>,
181    packet_sender: TrackPacketSender,
182) -> Result<()> {
183    info!(
184        "streaming audio with target_sample_rate: {}, packet_duration: {}ms",
185        target_sample_rate, packet_duration_ms
186    );
187    let stream_loop = async move {
188        let start_time = Instant::now();
189        let mut ticker = tokio::time::interval(Duration::from_millis(packet_duration_ms as u64));
190        let channels = audio_reader.channels();
191        loop {
192            if paused.load(Ordering::Relaxed) {
193                ticker.tick().await;
194                continue;
195            }
196
197            let Some((chunk, chunk_sample_rate)) = audio_reader.read_chunk(packet_duration_ms)?
198            else {
199                break;
200            };
201
202            let mut packet = AudioFrame {
203                track_id: track_id.to_string(),
204                timestamp: crate::media::get_timestamp(),
205                samples: Samples::PCM { samples: chunk },
206                sample_rate: chunk_sample_rate,
207                channels,
208                ..Default::default()
209            };
210
211            match processor_chain.process_frame(&mut packet) {
212                Ok(_) => {}
213                Err(e) => {
214                    warn!("failed to process audio packet: {}", e);
215                }
216            }
217
218            if let Err(e) = packet_sender.send(packet) {
219                warn!("failed to send audio packet: {}", e);
220                break;
221            }
222
223            ticker.tick().await;
224        }
225
226        info!("stream loop finished in {:?}", start_time.elapsed());
227        Ok(()) as Result<()>
228    };
229
230    select! {
231        _ = token.cancelled() => {
232            info!("stream cancelled");
233            return Ok(());
234        }
235        result = stream_loop => {
236            info!("stream loop finished");
237            result
238        }
239    }
240}
241
242pub struct FileTrack {
243    track_id: TrackId,
244    play_id: Option<String>,
245    config: TrackConfig,
246    cancel_token: CancellationToken,
247    processor_chain: ProcessorChain,
248    path: Option<String>,
249    use_cache: bool,
250    ssrc: u32,
251    offset_ms: u32,
252    paused: Arc<AtomicBool>,
253}
254
255impl FileTrack {
256    pub fn new(id: TrackId) -> Self {
257        let config = TrackConfig::default();
258        Self {
259            track_id: id,
260            play_id: None,
261            processor_chain: ProcessorChain::new(config.samplerate),
262            config,
263            cancel_token: CancellationToken::new(),
264            path: None,
265            use_cache: true,
266            ssrc: 0,
267            offset_ms: 0,
268            paused: Arc::new(AtomicBool::new(false)),
269        }
270    }
271
272    pub fn with_play_id(mut self, play_id: Option<String>) -> Self {
273        self.play_id = play_id;
274        self
275    }
276
277    pub fn with_ssrc(mut self, ssrc: u32) -> Self {
278        self.ssrc = ssrc;
279        self
280    }
281    pub fn with_config(mut self, config: TrackConfig) -> Self {
282        self.config = config;
283        self
284    }
285
286    pub fn with_cancel_token(mut self, cancel_token: CancellationToken) -> Self {
287        self.cancel_token = cancel_token;
288        self
289    }
290
291    pub fn with_path(mut self, path: String) -> Self {
292        self.path = Some(path);
293        self
294    }
295
296    pub fn with_sample_rate(mut self, sample_rate: u32) -> Self {
297        self.config = self.config.with_sample_rate(sample_rate);
298        self
299    }
300
301    pub fn with_ptime(mut self, ptime: Duration) -> Self {
302        self.config = self.config.with_ptime(ptime);
303        self
304    }
305
306    pub fn with_cache_enabled(mut self, use_cache: bool) -> Self {
307        self.use_cache = use_cache;
308        self
309    }
310
311    pub fn with_offset_ms(mut self, offset_ms: u32) -> Self {
312        self.offset_ms = offset_ms;
313        self
314    }
315}
316
317#[async_trait]
318impl Track for FileTrack {
319    fn ssrc(&self) -> u32 {
320        self.ssrc
321    }
322    fn id(&self) -> &TrackId {
323        &self.track_id
324    }
325    fn config(&self) -> &TrackConfig {
326        &self.config
327    }
328    fn set_paused(&self, paused: bool) -> bool {
329        self.paused.store(paused, Ordering::Relaxed);
330        true
331    }
332    fn is_paused(&self) -> bool {
333        self.paused.load(Ordering::Relaxed)
334    }
335    fn processor_chain(&mut self) -> &mut ProcessorChain {
336        &mut self.processor_chain
337    }
338
339    async fn handshake(&mut self, _offer: String, _timeout: Option<Duration>) -> Result<String> {
340        Ok("".to_string())
341    }
342    async fn update_remote_description(&mut self, _answer: &String) -> Result<()> {
343        Ok(())
344    }
345
346    async fn start(
347        &mut self,
348        event_sender: EventSender,
349        packet_sender: TrackPacketSender,
350    ) -> Result<()> {
351        if self.path.is_none() {
352            return Err(anyhow::anyhow!("filetrack: No path provided for FileTrack"));
353        }
354        let path = self.path.clone().unwrap();
355        let id = self.track_id.clone();
356        let sample_rate = self.config.samplerate;
357        let use_cache = self.use_cache;
358        let packet_duration_ms = self.config.ptime.as_millis() as u32;
359        let processor_chain = self.processor_chain.clone();
360        let token = self.cancel_token.clone();
361        let start_time = crate::media::get_timestamp();
362        let ssrc = self.ssrc;
363        let offset_ms = self.offset_ms;
364        let paused = self.paused.clone();
365        // Spawn async task to handle file streaming
366        let play_id = self.play_id.clone();
367        crate::spawn(async move {
368            let res = async move {
369                // Load (and cache) the decoded PCM so we don't re-download or
370                // re-decode the original file on every play.
371                let load_result = crate::media::loader::load_audio_as_pcm_cached(
372                    &path,
373                    sample_rate,
374                    use_cache,
375                    offset_ms,
376                )
377                .await;
378                let samples = match load_result {
379                    Ok(samples) => samples,
380                    Err(e) => {
381                        warn!("filetrack: Error loading audio: {} {}", path, e);
382                        event_sender
383                            .send(SessionEvent::Error {
384                                track_id: id.clone(),
385                                timestamp: crate::media::get_timestamp(),
386                                sender: format!("filetrack: {}", path),
387                                error: e.to_string(),
388                                code: None,
389                            })
390                            .ok();
391                        event_sender
392                            .send(SessionEvent::TrackEnd {
393                                track_id: id,
394                                timestamp: crate::media::get_timestamp(),
395                                duration: crate::media::get_timestamp() - start_time,
396                                ssrc,
397                                play_id: play_id.clone(),
398                            })
399                            .ok();
400                        return Err(e);
401                    }
402                };
403
404                // Stream the decoded PCM (offset already applied during load)
405                let stream_result = stream_pcm_samples(
406                    processor_chain,
407                    samples,
408                    sample_rate,
409                    &id,
410                    packet_duration_ms,
411                    token,
412                    paused,
413                    packet_sender,
414                )
415                .await;
416
417                // Handle any streaming errors
418                if let Err(e) = stream_result {
419                    warn!("filetrack: Error streaming audio: {}, {}", path, e);
420                    event_sender
421                        .send(SessionEvent::Error {
422                            track_id: id.clone(),
423                            timestamp: crate::media::get_timestamp(),
424                            sender: format!("filetrack: {}", path),
425                            error: e.to_string(),
426                            code: None,
427                        })
428                        .ok();
429                }
430
431                // Send track end event
432                event_sender
433                    .send(SessionEvent::TrackEnd {
434                        track_id: id,
435                        timestamp: crate::media::get_timestamp(),
436                        duration: crate::media::get_timestamp() - start_time,
437                        ssrc,
438                        play_id,
439                    })
440                    .ok();
441                Ok::<(), anyhow::Error>(())
442            }
443            .await;
444            if let Err(e) = res {
445                debug!("filetrack: streaming task finished with error: {:?}", e);
446            }
447        });
448        Ok(())
449    }
450
451    async fn stop(&self) -> Result<()> {
452        // Cancel the file streaming task
453        self.cancel_token.cancel();
454        Ok(())
455    }
456
457    // Do nothing as we are not sending packets
458    async fn send_packet(&mut self, _packet: &AudioFrame) -> Result<()> {
459        Ok(())
460    }
461}
462
463// Helper function to stream already-decoded PCM samples
464async fn stream_pcm_samples(
465    processor_chain: ProcessorChain,
466    samples: Vec<i16>,
467    target_sample_rate: u32,
468    track_id: &str,
469    packet_duration_ms: u32,
470    token: CancellationToken,
471    paused: Arc<AtomicBool>,
472    packet_sender: TrackPacketSender,
473) -> Result<()> {
474    let reader =
475        DecodedAudioReader::from_samples(samples, target_sample_rate, target_sample_rate);
476    let audio_reader = Box::new(reader) as Box<dyn AudioReader>;
477    info!(
478        "filetrack: streaming {} decoded samples at {} Hz",
479        audio_reader.buffer_size(),
480        audio_reader.sample_rate(),
481    );
482    process_audio_reader(
483        processor_chain,
484        audio_reader,
485        track_id,
486        packet_duration_ms,
487        target_sample_rate,
488        token,
489        paused,
490        packet_sender,
491    )
492    .await
493}
494
495/// Read WAV file and return PCM samples and sample rate
496pub fn read_wav_file(path: &str) -> Result<(PcmBuf, u32)> {
497    let reader = BufReader::new(File::open(path)?);
498    let mut wav_reader = WavReader::new(reader)?;
499    let spec = wav_reader.spec();
500    let mut all_samples = Vec::new();
501
502    match spec.sample_format {
503        hound::SampleFormat::Int => match spec.bits_per_sample {
504            16 => {
505                for sample in wav_reader.samples::<i16>() {
506                    all_samples.push(sample.unwrap_or(0));
507                }
508            }
509            8 => {
510                for sample in wav_reader.samples::<i8>() {
511                    all_samples.push(sample.unwrap_or(0) as i16);
512                }
513            }
514            24 | 32 => {
515                for sample in wav_reader.samples::<i32>() {
516                    all_samples.push((sample.unwrap_or(0) >> 16) as i16);
517                }
518            }
519            _ => {
520                return Err(anyhow!(
521                    "Unsupported bits per sample: {}",
522                    spec.bits_per_sample
523                ));
524            }
525        },
526        hound::SampleFormat::Float => {
527            for sample in wav_reader.samples::<f32>() {
528                all_samples.push((sample.unwrap_or(0.0) * 32767.0) as i16);
529            }
530        }
531    }
532
533    // If stereo, convert to mono by averaging channels
534    if spec.channels == 2 {
535        let mono_samples = all_samples
536            .chunks(2)
537            .map(|chunk| ((chunk[0] as i32 + chunk[1] as i32) / 2) as i16)
538            .collect();
539        all_samples = mono_samples;
540    }
541    Ok((all_samples, spec.sample_rate))
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::media::cache;
548    use crate::media::cache::ensure_cache_dir;
549    use std::io::Write;
550    use tokio::sync::{broadcast, mpsc};
551
552    #[tokio::test]
553    async fn test_wav_reader() -> Result<()> {
554        let file_path = "fixtures/sample.wav";
555        let file = File::open(file_path)?;
556        let mut reader = DecodedAudioReader::from_file(file, "wav", None, 16000)?;
557        let mut total_samples = 0;
558        let mut total_duration_ms = 0.0;
559        let mut chunk_count = 0;
560        while let Some((chunk, chunk_sample_rate)) = reader.read_chunk(20)? {
561            total_samples += chunk.len();
562            chunk_count += 1;
563            let chunk_duration_ms = (chunk.len() as f64 / chunk_sample_rate as f64) * 1000.0;
564            total_duration_ms += chunk_duration_ms;
565        }
566
567        let duration_seconds = total_duration_ms / 1000.0;
568        println!("Total chunks: {}", chunk_count);
569        println!("Actual samples: {}", total_samples);
570        println!("Actual duration: {:.2} seconds", duration_seconds);
571        assert_eq!(format!("{:.2}", duration_seconds), "7.51");
572        Ok(())
573    }
574    #[tokio::test]
575    async fn test_wav_file_track() -> Result<()> {
576        println!("Starting WAV file track test");
577
578        let file_path = "fixtures/sample.wav";
579        let file = File::open(file_path)?;
580
581        // First get the expected duration and samples using hound directly
582        let mut reader = hound::WavReader::new(File::open(file_path)?)?;
583        let spec = reader.spec();
584        let total_expected_samples = reader.duration() as usize;
585        let expected_duration = total_expected_samples as f64 / spec.sample_rate as f64;
586        println!("WAV file spec: {:?}", spec);
587        println!("Expected samples: {}", total_expected_samples);
588        println!("Expected duration: {:.2} seconds", expected_duration);
589
590        // Verify we can read all samples
591        let mut verify_samples = Vec::new();
592        for sample in reader.samples::<i16>() {
593            verify_samples.push(sample?);
594        }
595        println!("Verified total samples: {}", verify_samples.len());
596
597        let mut reader = DecodedAudioReader::from_file(file, "wav", None, 16000)?;
598        let mut total_samples = 0;
599        let mut total_duration_ms = 0.0;
600        let mut chunk_count = 0;
601
602        while let Some((chunk, chunk_sample_rate)) = reader.read_chunk(320)? {
603            total_samples += chunk.len();
604            chunk_count += 1;
605            // Calculate duration for this chunk
606            let chunk_duration_ms = (chunk.len() as f64 / chunk_sample_rate as f64) * 1000.0;
607            total_duration_ms += chunk_duration_ms;
608        }
609
610        let duration_seconds = total_duration_ms / 1000.0;
611        println!("Total chunks: {}", chunk_count);
612        println!("Actual samples: {}", total_samples);
613        println!("Actual duration: {:.2} seconds", duration_seconds);
614
615        // Allow for 1% tolerance in duration and sample count
616        const TOLERANCE: f64 = 0.01; // 1% tolerance
617
618        // If the file is stereo, we need to adjust the expected sample count
619        let expected_samples = if spec.channels == 2 {
620            total_expected_samples / 2 // We convert stereo to mono
621        } else {
622            total_expected_samples
623        };
624
625        assert!(
626            (duration_seconds - expected_duration).abs() < expected_duration * TOLERANCE,
627            "Duration {:.2} differs from expected {:.2} by more than {}%",
628            duration_seconds,
629            expected_duration,
630            TOLERANCE * 100.0
631        );
632
633        assert!(
634            (total_samples as f64 - expected_samples as f64).abs()
635                < expected_samples as f64 * TOLERANCE,
636            "Sample count {} differs from expected {} by more than {}%",
637            total_samples,
638            expected_samples,
639            TOLERANCE * 100.0
640        );
641
642        Ok(())
643    }
644
645    #[tokio::test]
646    async fn test_file_track_with_cache() -> Result<()> {
647        ensure_cache_dir().await?;
648        // Clear any stale cache from previous runs to avoid WAV-header-in-PCM issue
649        let cache_key = crate::media::cache::generate_cache_key("fixtures/sample.wav", 16000, None, None);
650        let _ = crate::media::cache::delete_from_cache(&cache_key).await;
651        let file_path = "fixtures/sample.wav".to_string();
652
653        // Create a FileTrack instance
654        let track_id = "test_track".to_string();
655        let mut file_track = FileTrack::new(track_id.clone())
656            .with_path(file_path.clone())
657            .with_sample_rate(16000)
658            .with_cache_enabled(true);
659
660        // Create channels for events and packets
661        let (event_tx, mut event_rx) = broadcast::channel(100);
662        let (packet_tx, mut packet_rx) = mpsc::unbounded_channel();
663
664        file_track.start(event_tx, packet_tx).await?;
665
666        // Receive packets to verify streaming
667        let mut received_packet = false;
668
669        // Use a timeout to ensure we don't wait forever
670        let timeout_duration = tokio::time::Duration::from_secs(5);
671        match tokio::time::timeout(timeout_duration, packet_rx.recv()).await {
672            Ok(Some(_)) => {
673                received_packet = true;
674            }
675            Ok(None) => {
676                println!("No packet received, channel closed");
677            }
678            Err(_) => {
679                println!("Timeout waiting for packet");
680            }
681        }
682
683        // Wait for the stop event
684        let mut received_stop = false;
685        while let Ok(event) = event_rx.recv().await {
686            if let SessionEvent::TrackEnd { track_id: id, .. } = event {
687                if id == track_id {
688                    received_stop = true;
689                    break;
690                }
691            }
692        }
693
694        // Add a delay to ensure the cache file is written
695        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
696
697        // The cache key is derived from the path and the target sample rate; the
698        // cached payload should be the decoded PCM, not the original WAV bytes.
699        let cache_key = cache::generate_cache_key(&file_path, 16000, None, None);
700
701        // Verify cache exists
702        assert!(
703            cache::is_cached(&cache_key).await?,
704            "Cache file should exist for key: {}",
705            cache_key
706        );
707
708        // The cached PCM should match the freshly decoded PCM and differ from the
709        // original encoded file.
710        let cached_pcm = cache::retrieve_pcm_from_cache(&cache_key).await?;
711        let decoded_pcm =
712            crate::media::loader::load_audio_as_pcm(&file_path, 16000, false).await?;
713        assert_eq!(
714            cached_pcm, decoded_pcm,
715            "cached PCM should match freshly decoded PCM"
716        );
717        assert!(!cached_pcm.is_empty(), "cached PCM should not be empty");
718
719        // Allow the test to pass if packets weren't received
720        if !received_packet {
721            println!("Warning: No packets received in test, but cache operations were verified");
722        } else {
723            assert!(received_packet);
724        }
725        assert!(received_stop);
726
727        Ok(())
728    }
729
730    #[tokio::test]
731    async fn test_mp3_decode_sample_file() -> Result<()> {
732        let file_path = "fixtures/sample.mp3".to_string();
733        match File::open(&file_path) {
734            Ok(file) => {
735                let samples = crate::media::loader::decode_audio(file, "mp3", None, 16000)?;
736                assert!(
737                    !samples.is_empty(),
738                    "Decoded sample list should not be empty"
739                );
740            }
741            Err(_) => {
742                println!("Skipping MP3 test: sample file not found at {}", file_path);
743            }
744        }
745        Ok(())
746    }
747
748    #[tokio::test]
749    async fn test_mp3_file_track() -> Result<()> {
750        println!("Starting MP3 file track test");
751
752        // Check if the MP3 file exists
753        let file_path = "fixtures/sample.mp3".to_string();
754        let file = File::open(&file_path)?;
755        let sample_rate = 16000;
756        let mut reader = DecodedAudioReader::from_file(file, "mp3", None, sample_rate)?;
757        let mut total_samples = 0;
758        let mut total_duration_ms = 0.0;
759        while let Some((chunk, _chunk_sample_rate)) = reader.read_chunk(320)? {
760            total_samples += chunk.len();
761            // Calculate duration for this chunk
762            let chunk_duration_ms = (chunk.len() as f64 / sample_rate as f64) * 1000.0;
763            total_duration_ms += chunk_duration_ms;
764        }
765        let duration_seconds = total_duration_ms / 1000.0;
766        println!("Total samples: {}", total_samples);
767        println!("Duration: {:.2} seconds", duration_seconds);
768
769        const EXPECTED_SAMPLES: usize = 226310;
770        assert!(
771            total_samples == EXPECTED_SAMPLES,
772            "Sample count {} does not match expected {}",
773            total_samples,
774            EXPECTED_SAMPLES
775        );
776        Ok(())
777    }
778
779    #[tokio::test]
780    #[ignore = "manual debug helper: dumps raw pcm for ffplay"]
781    async fn dump_mp3_as_16k_pcm_for_ffplay() -> Result<()> {
782        let input_path = "fixtures/sample.mp3";
783        let output_path = "target/tmp/sample_16k_mono_s16le.pcm";
784
785        let file = File::open(input_path)?;
786        let mut reader = DecodedAudioReader::from_file(file, "mp3", None, 16000)?;
787
788        let mut pcm_samples = Vec::<i16>::new();
789        while let Some((chunk, _chunk_sample_rate)) = reader.read_chunk(320)? {
790            pcm_samples.extend_from_slice(&chunk);
791        }
792
793        std::fs::create_dir_all("target/tmp")?;
794        let mut out = std::fs::File::create(output_path)?;
795        for sample in pcm_samples {
796            out.write_all(&sample.to_le_bytes())?;
797        }
798        out.flush()?;
799
800        println!("PCM dumped: {}", output_path);
801        println!("ffplay -f s16le -ar 16000 -ac 1 {}", output_path);
802        Ok(())
803    }
804}