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}