Skip to main content

audio_codec_bsd/
symphonia_impl.rs

1//! FLAC + PCM container decoder backed by [`symphonia`] 0.6.
2//!
3//! `SymphoniaDecoder` runs on a **worker thread**: it performs blocking file
4//! I/O, format probing, and heap allocation, and is explicitly **not**
5//! real-time safe. Decoded frames must be handed to a lock-free ring buffer so
6//! the RT audio thread never calls into this module.
7//!
8//! ## Sample normalisation
9//!
10//! Symphonia's typed [`AudioBuffer`] is internally **planar** — each plane is
11//! a contiguous `Vec<S>` for one channel. The decoder uses
12//! [`GenericAudioBufferRef::copy_to_slice_planar`] to emit per-channel `f32`
13//! slices with automatic sample-format conversion, then concatenates the
14//! planes into the flat planar layout required by [`audio_core_bsd::AudioFrame`].
15//! No manual de-interleave is needed: symphonia hands us planar data and we
16//! preserve that grouping.
17//!
18//! Integer samples are scaled by symphonia's own `FromSample` impls to a
19//! normalised `f32` range; 32-bit float samples pass through. Non-finite
20//! samples (`NaN`/`±Inf`) are coerced to `0.0`.
21//!
22//! ## End-of-stream
23//!
24//! Symphonia signals end-of-stream via [`FormatReader::next_packet`]
25//! returning `Ok(None)`. This is mapped to `Ok(None)` from
26//! [`ContainerDecoder::next_frame`]; EOF never panics and is never reported
27//! as a decode error.
28//!
29//! [`symphonia`]: https://crates.io/crates/symphonia
30//! [`AudioBuffer`]: symphonia::core::audio::AudioBuffer
31//! [`GenericAudioBufferRef::copy_to_slice_planar`]: symphonia::core::audio::GenericAudioBufferRef::copy_to_slice_planar
32//! [`FormatReader::next_packet`]: symphonia::core::formats::FormatReader::next_packet
33
34use std::fs::File;
35use std::path::{Path, PathBuf};
36
37use audio_core_bsd::AudioFrame;
38use symphonia::core::audio::GenericAudioBufferRef;
39use symphonia::core::codecs::audio::{
40    well_known::CODEC_ID_FLAC, AudioDecoder, AudioDecoderOptions,
41};
42use symphonia::core::codecs::CodecParameters;
43use symphonia::core::errors::Error as SymphoniaError;
44use symphonia::core::formats::probe::Hint;
45use symphonia::core::formats::{FormatOptions, FormatReader};
46use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
47use symphonia::core::meta::MetadataOptions;
48use symphonia::default::{get_codecs, get_probe};
49
50use crate::decoder::{ContainerDecoder, FormatKind, StreamInfo};
51use crate::error::{CodecError, Result};
52
53/// A [`symphonia`]-backed decoder for FLAC (primary) and PCM containers.
54///
55/// Construct with [`SymphoniaDecoder::open`], then drive through the
56/// [`ContainerDecoder`] trait. See the [module docs](self) for the
57/// worker-thread / planar-layout / end-of-stream contract.
58pub struct SymphoniaDecoder {
59    /// Path the decoder was constructed against; used to decide whether a
60    /// trait `open` call should re-open the source.
61    path: PathBuf,
62    /// The probed format reader (owns the `MediaSourceStream`).
63    format: Box<dyn FormatReader>,
64    /// The codec decoder for the selected audio track.
65    decoder: Box<dyn AudioDecoder>,
66    /// Track id of the selected audio stream.
67    track_id: u32,
68    /// Cached stream metadata (avoids re-reading `codec_params` each frame).
69    sample_rate: u32,
70    channels: u16,
71    bits_per_sample: u32,
72    total_frames: Option<u64>,
73    /// sniffed container kind (Flac vs Wav) reported in `StreamInfo`.
74    format_kind: FormatKind,
75    /// Sticky end-of-stream flag; once true, `next_frame` short-circuits.
76    eof: bool,
77}
78
79impl SymphoniaDecoder {
80    /// Open the container at `path`, probe its format, and instantiate the
81    /// matching audio decoder for the first audio track.
82    ///
83    /// # Errors
84    ///
85    /// - [`CodecError::Io`] if the file cannot be opened.
86    /// - [`CodecError::Format`] if no container or audio track is found.
87    /// - [`CodecError::Decode`] if the codec is not registered.
88    pub fn open(path: &Path) -> Result<Self> {
89        let SymphoniaDecoderState {
90            format,
91            decoder,
92            track_id,
93            sample_rate,
94            channels,
95            bits_per_sample,
96            total_frames,
97            format_kind,
98            eof,
99        } = Self::open_inner(path)?;
100        Ok(Self {
101            path: path.to_path_buf(),
102            format,
103            decoder,
104            track_id,
105            sample_rate,
106            channels,
107            bits_per_sample,
108            total_frames,
109            format_kind,
110            eof,
111        })
112    }
113
114    /// Shared open logic returning the heavy state without the (re-derivable)
115    /// `path` field, so `reopen` can reuse it.
116    fn open_inner(path: &Path) -> Result<SymphoniaDecoderState> {
117        let file = File::open(path).map_err(|e| CodecError::Io(e.to_string()))?;
118        let mss = MediaSourceStream::new(Box::new(file), MediaSourceStreamOptions::default());
119
120        let mut hint = Hint::new();
121        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
122            hint.with_extension(ext);
123        }
124
125        let probe = get_probe();
126        let format = probe
127            .probe(
128                &hint,
129                mss,
130                FormatOptions::default(),
131                MetadataOptions::default(),
132            )
133            .map_err(map_symphonia_err)?;
134
135        // Pick the first track that advertises audio codec parameters.
136        let tracks = format.tracks();
137        let track = tracks
138            .iter()
139            .find(|t| {
140                t.codec_params
141                    .as_ref()
142                    .is_some_and(CodecParameters::is_audio)
143            })
144            .ok_or_else(|| CodecError::Format("no audio track found in container".into()))?;
145
146        let params = track
147            .codec_params
148            .as_ref()
149            .and_then(CodecParameters::audio)
150            .ok_or_else(|| CodecError::Format("selected track has no audio codec params".into()))?;
151
152        let decoder = get_codecs()
153            .make_audio_decoder(params, &AudioDecoderOptions::default())
154            .map_err(map_symphonia_err)?;
155
156        let sample_rate = params
157            .sample_rate
158            .ok_or_else(|| CodecError::Format("container reported no sample rate".into()))?;
159        if sample_rate == 0 {
160            return Err(CodecError::InvalidSampleRate(0));
161        }
162        let channel_count = params
163            .channels
164            .as_ref()
165            .map(|c| u16::try_from(c.count()).unwrap_or(0))
166            .ok_or_else(|| CodecError::Format("container reported no channels".into()))?;
167        if channel_count == 0 {
168            return Err(CodecError::InvalidChannelCount(0));
169        }
170        let bits_per_sample = params.bits_per_sample.unwrap_or(0);
171        let format_kind = if params.codec == CODEC_ID_FLAC {
172            FormatKind::Flac
173        } else {
174            // PCM inside a RIFF/WAV container — report the container, not the
175            // (lossless) codec, so callers can route by container.
176            FormatKind::Wav
177        };
178
179        // Copy the track id / frame count out of the borrowed `track` before
180        // moving `format` into the state struct below (the borrow would
181        // otherwise extend across the move).
182        let track_id = track.id;
183        let total_frames = track.num_frames;
184
185        Ok(SymphoniaDecoderState {
186            format,
187            decoder,
188            track_id,
189            sample_rate,
190            channels: channel_count,
191            bits_per_sample,
192            total_frames,
193            format_kind,
194            eof: false,
195        })
196    }
197
198    /// Re-open the source from `path`, replacing all heavy state.
199    fn reopen(&mut self, path: &Path) -> Result<()> {
200        let inner = Self::open_inner(path)?;
201        let SymphoniaDecoderState {
202            format,
203            decoder,
204            track_id,
205            sample_rate,
206            channels,
207            bits_per_sample,
208            total_frames,
209            format_kind,
210            eof,
211        } = inner;
212        self.format = format;
213        self.decoder = decoder;
214        self.track_id = track_id;
215        self.sample_rate = sample_rate;
216        self.channels = channels;
217        self.bits_per_sample = bits_per_sample;
218        self.total_frames = total_frames;
219        self.format_kind = format_kind;
220        self.eof = eof;
221        self.path = path.to_path_buf();
222        Ok(())
223    }
224
225    /// Build a [`StreamInfo`] snapshot from the cached metadata.
226    fn stream_info(&self) -> StreamInfo {
227        StreamInfo {
228            format: self.format_kind,
229            sample_rate: self.sample_rate,
230            channels: self.channels,
231            bits_per_sample: self.bits_per_sample,
232            total_frames: self.total_frames,
233        }
234    }
235
236    /// Decode one packet into a planar [`AudioFrame`]. Returns `Ok(None)` at
237    /// clean end-of-stream.
238    fn decode_packet(&mut self) -> Result<Option<AudioFrame>> {
239        if self.eof {
240            return Ok(None);
241        }
242
243        let packet = match self.format.next_packet() {
244            Ok(Some(p)) => p,
245            // Symphonia signals EOS via Ok(None); never an error.
246            Ok(None) => {
247                self.eof = true;
248                return Ok(None);
249            }
250            Err(e) => return Err(map_symphonia_err(e)),
251        };
252
253        // Only decode packets belonging to our selected audio track.
254        if packet.track_id != self.track_id {
255            return self.decode_packet();
256        }
257
258        let decoded = self.decoder.decode(&packet).map_err(map_symphonia_err)?;
259        Ok(Some(Self::buffer_to_frame(
260            &decoded,
261            self.channels,
262            self.sample_rate,
263        )))
264    }
265
266    /// Convert a symphonia decoded buffer (already planar internally) into a
267    /// flat planar [`AudioFrame`].
268    fn buffer_to_frame(
269        decoded: &GenericAudioBufferRef<'_>,
270        channels: u16,
271        sample_rate: u32,
272    ) -> AudioFrame {
273        let chan_us = usize::from(channels);
274        let frames = decoded.frames();
275        if frames == 0 {
276            return AudioFrame::new(channels, sample_rate);
277        }
278
279        // The buffer exposes one plane per channel; copy each into its own
280        // f32 slice, then concatenate into the flat planar layout.
281        let mut samples: Vec<f32> = vec![0.0; chan_us * frames];
282        // Borrow the whole buffer once, then split `samples` into per-channel
283        // mutable slices for the bulk copy.
284        {
285            // `chunks_mut` yields `chan_us` slices each of length `frames`.
286            let mut planes: Vec<&mut [f32]> = samples
287                .chunks_mut(frames)
288                .take(decoded.num_planes())
289                .collect();
290            decoded.copy_to_slice_planar::<f32, &mut [f32]>(&mut planes);
291        }
292        // Coerce any non-finite sample to 0.0 (defensive; symphonia's
293        // conversion should already be finite for the supported codecs).
294        for s in &mut samples {
295            if !s.is_finite() {
296                *s = 0.0;
297            }
298        }
299        AudioFrame::from_planar(channels, sample_rate, samples)
300    }
301}
302
303/// Internal helper: the heavy decoder state returned by `open_inner`, split
304/// out so the public struct can be assembled from `(path, state)`.
305struct SymphoniaDecoderState {
306    format: Box<dyn FormatReader>,
307    decoder: Box<dyn AudioDecoder>,
308    track_id: u32,
309    sample_rate: u32,
310    channels: u16,
311    bits_per_sample: u32,
312    total_frames: Option<u64>,
313    format_kind: FormatKind,
314    eof: bool,
315}
316
317impl ContainerDecoder for SymphoniaDecoder {
318    fn open(&mut self, path: &Path) -> Result<StreamInfo> {
319        if path != self.path {
320            self.reopen(path)?;
321        }
322        Ok(self.stream_info())
323    }
324
325    fn next_frame(&mut self) -> Result<Option<AudioFrame>> {
326        self.decode_packet()
327    }
328}
329
330/// Map a [`symphonia`] error into the crate's [`CodecError`].
331///
332/// `IoError` is surfaced as [`CodecError::Io`]; every other variant (decode,
333/// seek, unsupported, limit, reset) is surfaced as [`CodecError::Decode`] so
334/// callers see a single, stable error surface regardless of the backend.
335fn map_symphonia_err(err: SymphoniaError) -> CodecError {
336    match err {
337        SymphoniaError::IoError(e) => CodecError::Io(e.to_string()),
338        other => CodecError::Decode(other.to_string()),
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    /// Opening a non-existent file surfaces [`CodecError::Io`] — never a panic.
347    #[test]
348    fn missing_file_yields_io_error_not_panic() {
349        let mut bogus = std::path::PathBuf::from("/this/path/does/not/exist.flac");
350        bogus.set_extension("flac");
351        let res = SymphoniaDecoder::open(&bogus);
352        let err = res.err().expect("expected an error");
353        assert!(matches!(err, CodecError::Io(_)), "got {err:?}");
354    }
355
356    /// Opening a file that exists but is not a valid container surfaces
357    /// [`CodecError::Format`] or [`CodecError::Io`] — never a panic. This is
358    /// the graceful-degradation gate for the FLAC path when no FLAC fixture is
359    /// available.
360    #[test]
361    fn malformed_file_yields_graceful_error_not_panic() {
362        let mut path = std::env::temp_dir();
363        path.push(format!(
364            "audio_codec_bsd_symphonia_malformed_{}.bin",
365            std::process::id()
366        ));
367        // Write bytes that are neither a valid FLAC nor a valid RIFF/WAVE.
368        std::fs::write(&path, b"not a real audio container at all").expect("write temp");
369        let res = SymphoniaDecoder::open(&path);
370        let _ = std::fs::remove_file(&path);
371        // The gate is "no panic": symphonia surfaces an unrecognised container
372        // variously as Format/Decode/Io depending on how far probing got, so
373        // accept any graceful CodecError variant.
374        let err = res.err().expect("expected a graceful error");
375        assert!(
376            matches!(
377                err,
378                CodecError::Format(_) | CodecError::Io(_) | CodecError::Decode(_)
379            ),
380            "expected a graceful error variant, got {err:?}"
381        );
382    }
383
384    /// If a valid `.flac` fixture is found at the path named by the
385    /// `AUDIO_CODEC_FLAC_FIXTURE` env var, decode it end-to-end and assert:
386    /// (a) the reported format is `Flac`, (b) every decoded sample is finite
387    /// and in `[-1.0, 1.0]`, and (c) the planar length invariant holds.
388    ///
389    /// This test is **guarded by fixture availability**: there is no FLAC
390    /// encoder in the dependency set (symphonia is decode-only, hound writes
391    /// WAV only), so a committed binary fixture is the only way to exercise
392    /// the real FLAC path. Without one the test self-skips so the suite stays
393    /// green on hosts lacking a fixture — see the impl-notes for the rationale
394    /// and how to opt in.
395    #[test]
396    fn flac_fixture_decode_when_provided() {
397        let path = if let Some(p) = std::env::var_os("AUDIO_CODEC_FLAC_FIXTURE") {
398            std::path::PathBuf::from(p)
399        } else {
400            eprintln!(
401                "[skip] AUDIO_CODEC_FLAC_FIXTURE not set; no FLAC encoder is \
402                 available to synthesise one. Skipping the FLAC round-trip test."
403            );
404            return;
405        };
406        if !path.exists() {
407            eprintln!(
408                "[skip] AUDIO_CODEC_FLAC_FIXTURE={path:?} does not exist on disk; \
409                 skipping the FLAC round-trip test."
410            );
411            return;
412        }
413
414        let mut dec = SymphoniaDecoder::open(&path).expect("open fixture");
415        let info = dec.open(&path).expect("trait open");
416        assert_eq!(info.format, FormatKind::Flac, "expected FLAC container");
417        assert!(info.sample_rate > 0);
418        assert!(info.channels >= 1);
419
420        let mut got_frames = 0usize;
421        while let Some(frame) = dec.next_frame().expect("decode frame") {
422            assert_eq!(frame.channels, info.channels);
423            // Planar length invariant: samples.len() == channels * num_frames.
424            assert_eq!(
425                frame.samples.len(),
426                usize::from(frame.channels) * frame.num_frames()
427            );
428            // Every sample must be finite and in [-1, 1].
429            for &s in &frame.samples {
430                assert!(s.is_finite(), "non-finite sample {s}");
431                assert!((-1.0..=1.0).contains(&s), "sample {s} out of [-1,1]");
432            }
433            got_frames += 1;
434        }
435        assert!(got_frames > 0, "decoded zero frames from a valid fixture");
436    }
437
438    // ---- Stereo de-interleave via the symphonia path ----------------------
439    //
440    // The helpers below are the symphonia-path analogue of the WAV/hound
441    // fixture helpers in `wav.rs`. They write a hand-built stereo WAV with
442    // hound, then route it through `SymphoniaDecoder` so the
443    // probe → packet → `buffer_to_frame` → `copy_to_slice_planar` pipeline —
444    // a structurally different de-interleave than hound's interleaved-sample
445    // loop — is exercised end-to-end.
446
447    /// Exact-enough float comparison (uses `<`, never `==`, to stay
448    /// `clippy::float_cmp`-clean). Matches the tolerance used by `wav.rs`.
449    fn approx_eq(a: f32, b: f32) -> bool {
450        (a - b).abs() < 1e-4
451    }
452
453    /// Scale factor for 16-bit integer PCM: `1 / 2^15`.
454    const SCALE_16BIT: f32 = 1.0 / 32_768.0;
455
456    /// Write `interleaved` 16-bit samples to a temp PCM WAV file and return
457    /// its path. Uses native `i16` values so no `cast_*` pedantic lint fires.
458    fn write_symphonia_wav_fixture(
459        channels: u16,
460        sample_rate: u32,
461        interleaved: &[i16],
462    ) -> PathBuf {
463        use hound::{SampleFormat, WavSpec, WavWriter};
464        let spec = WavSpec {
465            channels,
466            sample_rate,
467            bits_per_sample: 16,
468            sample_format: SampleFormat::Int,
469        };
470        let mut path = std::env::temp_dir();
471        path.push(format!(
472            "audio_codec_bsd_symphonia_stereo_{}_{}.wav",
473            std::process::id(),
474            std::time::SystemTime::now()
475                .duration_since(std::time::UNIX_EPOCH)
476                .map_or(0, |d| d.as_nanos()),
477        ));
478        let mut writer = WavWriter::create(&path, spec).expect("create wav fixture");
479        for &v in interleaved {
480            writer.write_sample(v).expect("write sample");
481        }
482        writer.finalize().expect("finalize wav fixture");
483        path
484    }
485
486    /// **Symphonia-path stereo gate:** routes a hand-built 2-channel WAV
487    /// through `SymphoniaDecoder` (NOT `WavDecoder`/hound), exercising the
488    /// packet/buffer API whose de-interleave is structurally distinct from
489    /// hound's interleaved-sample loop. Asserts that:
490    ///
491    /// - `StreamInfo` reports `FormatKind::Wav` and `channels == 2`.
492    /// - `channel_slice(0)` and `channel_slice(1)` recover their *independent*
493    ///   references within tolerance (proving correct planar grouping).
494    /// - The two channels *differ* (catches an interleaved-as-planar bug that
495    ///   a byte-count check alone would miss).
496    ///
497    /// This is the running counterpart to the fixture-gated `flac_*` test: no
498    /// external FLAC encoder is needed because symphonia's `wav` + `pcm`
499    /// features decode the hound-synthesised RIFF/WAVE in-process.
500    #[test]
501    fn stereo_wav_deinterleave_via_symphonia() {
502        const FRAMES: usize = 8;
503        // Channel 0: ascending ramp; Channel 1: a constant plateau. The two
504        // patterns are deliberately distinct so an interleaved-as-planar bug
505        // would scramble both.
506        let want_ch0: [i16; FRAMES] = [-3500, -2500, -1500, -500, 500, 1500, 2500, 3500];
507        let want_ch1: [i16; FRAMES] = [16_000; FRAMES];
508
509        // Interleave and write a 16-bit PCM stereo WAV.
510        let mut interleaved: Vec<i16> = Vec::with_capacity(FRAMES * 2);
511        for i in 0..FRAMES {
512            interleaved.push(want_ch0[i]);
513            interleaved.push(want_ch1[i]);
514        }
515        let path = write_symphonia_wav_fixture(2, 48_000, &interleaved);
516
517        // Open via the symphonia path: probe → track → PCM decoder.
518        let mut dec = SymphoniaDecoder::open(&path).expect("symphonia open");
519        let info = dec.open(&path).expect("trait open");
520        // Symphonia reports PCM-in-RIFF as the Wav container (non-FLAC codec).
521        assert_eq!(info.format, FormatKind::Wav, "expected Wav container");
522        assert_eq!(info.channels, 2, "expected stereo");
523        assert_eq!(info.sample_rate, 48_000);
524
525        // Drain every packet, collecting per-channel planar samples. Symphonia
526        // may split a small file across packets, so accumulate across frames.
527        let mut got_ch0: Vec<f32> = Vec::new();
528        let mut got_ch1: Vec<f32> = Vec::new();
529        while let Some(frame) = dec.next_frame().expect("decode frame") {
530            assert_eq!(frame.channels, 2, "every frame must be stereo");
531            got_ch0.extend_from_slice(frame.channel_slice(0));
532            got_ch1.extend_from_slice(frame.channel_slice(1));
533        }
534        assert!(!got_ch0.is_empty(), "decoded zero ch0 samples");
535
536        // (b) The two channels MUST differ — catches interleaved-as-planar.
537        assert_ne!(
538            got_ch0.as_slice(),
539            got_ch1.as_slice(),
540            "channels identical: de-interleave failed"
541        );
542
543        // (a) Each channel recovers its independent reference within tolerance.
544        assert_eq!(got_ch0.len(), FRAMES, "ch0 sample count");
545        assert_eq!(got_ch1.len(), FRAMES, "ch1 sample count");
546        for (i, &want) in want_ch0.iter().enumerate() {
547            let expected = f32::from(want) * SCALE_16BIT;
548            assert!(
549                approx_eq(got_ch0[i], expected),
550                "ch0[{i}] = {}, expected {expected}",
551                got_ch0[i]
552            );
553        }
554        for (i, &want) in want_ch1.iter().enumerate() {
555            let expected = f32::from(want) * SCALE_16BIT;
556            assert!(
557                approx_eq(got_ch1[i], expected),
558                "ch1[{i}] = {}, expected {expected}",
559                got_ch1[i]
560            );
561        }
562
563        let _ = std::fs::remove_file(&path);
564    }
565}