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