feat(audio): migrate Symphonia 0.5 -> 0.6 (#999)

* feat(audio): migrate Symphonia 0.5 -> 0.6

Port the audio + analysis pipeline to the Symphonia 0.6 API:
- bump symphonia to 0.6 and symphonia-adapter-libopus to 0.3; drop rodio's
  symphonia-all feature to avoid a duplicate symphonia-core 0.5
- remove the local symphonia-format-isomp4 0.5 patch and rely on upstream 0.6
- switch codec registries to register_enabled_codecs / make_audio_decoder with
  AudioCodecParameters + AudioDecoderOptions
- rework decode.rs SizedDecoder and analysis decode loops for the new
  AudioDecoder trait, GenericAudioBufferRef, Time/Timestamp newtypes, and
  next_packet() -> Result<Option<Packet>>

Streaming regression fixes:
- ProbeSeekGate hides seekability during probe for non-MP4 progressive streams
  so Symphonia 0.6's trailing-metadata scan no longer forces a full download
  before ranged FLAC/MP3/OGG playback can start
- guard the streaming probe() with a 20s timeout on a worker thread so a stalled
  ranged source (e.g. right after a server switch) can no longer hang playback
  start until a player restart; add probe start/done diagnostics

* docs(changelog): note Symphonia 0.6 migration and streaming fixes (#999)

Add CHANGELOG entries (Changed + Fixed) and a CONTRIBUTORS line for the
Symphonia 0.6 migration, ranged-stream start-latency fix, and probe timeout.

* test(audio): cover streaming probe path and ProbeSeekGate

Add unit tests for SizedDecoder::new_streaming (success + garbage) and
ProbeSeekGate (seekability toggle, byte_len, read/seek passthrough) to
restore decode.rs above the 70% hot-path coverage gate (67.3% -> 79.4%).
This commit is contained in:
cucadmuh
2026-06-05 14:12:57 +03:00
committed by GitHub
parent c39465dfad
commit c674e4515b
60 changed files with 451 additions and 6360 deletions
+7 -6
View File
@@ -1,21 +1,22 @@
//! Symphonia codec registry (incl. Opus) and radio decoder factory.
use std::sync::OnceLock;
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn try_make_radio_decoder(
params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make(params, opts)
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
}
+300 -98
View File
@@ -1,19 +1,20 @@
//! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`.
use std::io::{Cursor, Read, Seek};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use rodio::source::UniformSourceIterator;
use rodio::Source;
use symphonia::core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL},
audio::{AudioSpec, GenericAudioBufferRef},
codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions},
formats::probe::Hint,
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
common::Limit,
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
meta::MetadataOptions,
probe::Hint,
units::{self, Time},
units::{Time, Timestamp},
};
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
@@ -52,6 +53,46 @@ impl MediaSource for SizedCursorSource {
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
// ─── ProbeSeekGate — temporarily hide seekability during probing ──────────────
//
// Symphonia 0.6's `Probe::probe` scans for *trailing* metadata (ID3v1/APEv2/…)
// whenever the source reports `is_seekable() == true` and a known `byte_len()`.
// That scan seeks to the end of the stream. For a progressive ranged-HTTP source
// this forces a download all the way to EOF before the first sample can play
// (FLAC/MP3/OGG regressed to "won't start until fully downloaded").
//
// These formats are demuxed sequentially from the start, and their seek paths
// re-check `is_seekable()` dynamically, so we can advertise the source as
// non-seekable for the duration of the probe (skipping the trailing scan) and
// flip it back to seekable afterwards to preserve scrubbing. MP4/ISO-BMFF is
// excluded because its demuxer captures seekability at construction and relies
// on seeking to locate `moov` (its tail is prefetched separately instead).
struct ProbeSeekGate {
inner: Box<dyn MediaSource>,
seekable: Arc<AtomicBool>,
}
impl Read for ProbeSeekGate {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for ProbeSeekGate {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for ProbeSeekGate {
fn is_seekable(&self) -> bool {
self.seekable.load(Ordering::Relaxed) && self.inner.is_seekable()
}
fn byte_len(&self) -> Option<u64> {
self.inner.byte_len()
}
}
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
//
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
@@ -65,19 +106,19 @@ impl MediaSource for SizedCursorSource {
/// playback is genuinely lossless.
pub(crate) fn log_codec_resolution(
tag: &str,
params: &symphonia::core::codecs::CodecParameters,
params: &AudioCodecParameters,
container_hint: Option<&str>,
) {
let codec_name = symphonia::default::get_codecs()
.get_codec(params.codec)
.map(|d| d.short_name)
.get_audio_decoder(params.codec)
.map(|d| d.codec.info.short_name)
.unwrap_or("?");
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
let bits = params.bits_per_sample
.or(params.bits_per_coded_sample)
.map(|b| format!("{}-bit", b))
.unwrap_or_else(|| "?-bit".into());
let ch = params.channels
let ch = params.channels.as_ref()
.map(|c| format!("{}ch", c.count()))
.unwrap_or_else(|| "?ch".into());
let lossless = codec_name.starts_with("pcm")
@@ -99,14 +140,20 @@ const DECODE_MAX_RETRIES: usize = 3;
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
/// playable track (VLC-style frame dropping).
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
/// Wall-clock cap for the streaming `probe()` call. A ranged-HTTP source whose
/// download stalls (e.g. right after a server switch) can otherwise block the
/// probe — and therefore playback start — indefinitely. On timeout we abort with
/// an error so the player can recover/retry instead of hanging until a restart.
const STREAM_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
pub(crate) struct SizedDecoder {
decoder: Box<dyn symphonia::core::codecs::Decoder>,
decoder: Box<dyn AudioDecoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
total_duration: Option<Time>,
buffer: SampleBuffer<f32>,
spec: SignalSpec,
/// Interleaved f32 samples of the currently decoded packet.
buffer: Vec<f32>,
spec: AudioSpec,
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
/// successfully decoded frame. Used to detect fully undecodable streams.
consecutive_decode_errors: usize,
@@ -133,22 +180,15 @@ impl SizedDecoder {
if let Some(ext) = format_hint {
hint.with_extension(ext);
}
let format_opts = FormatOptions {
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
// present in older iTunes-purchased M4A files.
enable_gapless: false,
..Default::default()
};
let format_opts = FormatOptions::default();
let meta_opts = symphonia::core::meta::MetadataOptions {
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
..Default::default()
};
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
let meta_opts =
MetadataOptions::default().limit_visual_bytes(Limit::Maximum(8 * 1024 * 1024));
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &meta_opts)
let mut format = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
.map_err(|e| {
let hint_str = format_hint.unwrap_or("unknown");
// Always print the raw Symphonia error to the terminal for diagnosis.
@@ -160,30 +200,45 @@ impl SizedDecoder {
}
})?;
let track = probed.format
let track = format
.tracks()
.iter()
// Explicitly select only audio tracks: must have a valid codec and a
// Explicitly select only audio tracks: must have an audio codec and a
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
// files embed as a secondary video track.
.find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some())
})
.ok_or_else(|| {
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", format.tracks().len());
"no playable audio track found in file".to_string()
})?;
let track_id = track.id;
let total_duration = track.codec_params.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(frames));
// Encoder-delay-aware total duration (timebase units → Time).
let total_duration = track
.time_base
.zip(track.num_frames)
.and_then(|(base, frames)| {
Timestamp::try_from(frames).ok().and_then(|ts| base.calc_time(ts))
});
log_codec_resolution("bytes", &track.codec_params, format_hint);
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| "selected track has no audio codec parameters".to_string())?
.clone();
log_codec_resolution("bytes", &audio_params, format_hint);
// Gapless trimming is performed by `build_source` (iTunSMPB), so disable
// the decoder's built-in trimming to avoid double-trimming.
let mut decoder = psysonic_codec_registry()
.make(&track.codec_params, &DecoderOptions::default())
.make_audio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
.map_err(|e| {
crate::app_eprintln!("[psysonic] codec init failed: {e}");
if e.to_string().to_lowercase().contains("unsupported") {
@@ -193,15 +248,15 @@ impl SizedDecoder {
}
})?;
let mut format = probed.format;
// Decode the first packet to initialise spec + buffer.
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
let mut decode_errors: usize = 0;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Ok(Some(p)) => p,
// Clean EOF before any decodable packet.
Ok(None) => break decoder.last_decoded(),
Err(symphonia::core::errors::Error::IoError(_)) => {
break decoder.last_decoded();
}
@@ -210,8 +265,8 @@ impl SizedDecoder {
return Err(format!("could not read audio data: {e}"));
}
};
if packet.track_id() != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
if packet.track_id != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id, track_id);
continue;
}
match decoder.decode(&packet) {
@@ -230,8 +285,8 @@ impl SizedDecoder {
}
};
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
Ok(SizedDecoder {
decoder,
@@ -252,34 +307,107 @@ impl SizedDecoder {
format_hint: Option<&str>,
source_tag: &str,
) -> Result<Self, String> {
// For non-MP4 progressive streams, hide seekability during the probe so
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
// and block until the whole file is downloaded). Re-enabled right after.
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
// prefetched separately).
let stream_len = media.byte_len();
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
None => media,
};
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
// read() call frequency into the ring buffer, easing I/O spikes.
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
let mut hint = Hint::new();
if let Some(ext) = format_hint { hint.with_extension(ext); }
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
let format_opts = FormatOptions::default();
let meta_opts = MetadataOptions::default();
let track = probed.format.tracks().iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
crate::app_deprintln!(
"[stream] {source_tag}: probe start (hint={}, stream_len={})",
format_hint.unwrap_or("?"),
stream_len.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
);
let probe_start = std::time::Instant::now();
// Run the probe on a dedicated thread guarded by a timeout. If a ranged
// source stalls (download never reaches the bytes Symphonia needs), the
// probe blocks forever; without this guard playback start would hang until
// the user restarts the player. On timeout we abandon the worker thread
// (it unblocks once the underlying read errors/returns) and surface an
// error so the caller can retry.
let hint_ext = format_hint.map(|s| s.to_string());
let tag_owned = source_tag.to_string();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("symphonia-probe".into())
.spawn(move || {
let mut hint = Hint::new();
if let Some(ext) = &hint_ext {
hint.with_extension(ext);
}
let result = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
.map_err(|e| format!("{tag_owned}: format probe failed: {e}"));
// Receiver is gone if we already timed out — ignore the send error.
let _ = tx.send(result);
})
.map_err(|e| format!("{source_tag}: failed to spawn probe thread: {e}"))?;
let mut format = match rx.recv_timeout(STREAM_PROBE_TIMEOUT) {
Ok(Ok(format)) => format,
Ok(Err(e)) => return Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
crate::app_eprintln!(
"[stream] {source_tag}: probe timed out after {STREAM_PROBE_TIMEOUT:?} \
(stream stalled?) — aborting so the player can retry"
);
return Err(format!(
"{source_tag}: format probe timed out after {STREAM_PROBE_TIMEOUT:?}"
));
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return Err(format!("{source_tag}: probe thread ended unexpectedly"));
}
};
crate::app_deprintln!(
"[stream] {source_tag}: probe done in {} ms",
probe_start.elapsed().as_millis()
);
// Trailing-metadata scan is done; restore real seekability for scrubbing.
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format.tracks().iter()
.find(|t| t.codec_params.as_ref().and_then(|c| c.audio()).is_some())
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
let track_id = track.id;
log_codec_resolution(source_tag, &track.codec_params, format_hint);
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| format!("{source_tag}: track has no audio codec parameters"))?
.clone();
log_codec_resolution(source_tag, &audio_params, format_hint);
// Live streams have no known total frame count → total_duration = None.
let total_duration = None;
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
let mut decoder = try_make_radio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
let mut format = probed.format;
let mut errors = 0usize;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Ok(Some(p)) => p,
Ok(None) => break decoder.last_decoded(),
Err(_) => break decoder.last_decoded(),
};
if packet.track_id() != track_id { continue; }
if packet.track_id != track_id { continue; }
match decoder.decode(&packet) {
Ok(d) => break d,
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
@@ -292,16 +420,15 @@ impl SizedDecoder {
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
}
};
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
}
#[inline]
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
let duration = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
fn make_buffer(decoded: &GenericAudioBufferRef<'_>) -> Vec<f32> {
let mut buffer = Vec::new();
decoded.copy_to_vec_interleaved(&mut buffer);
buffer
}
@@ -311,29 +438,43 @@ impl SizedDecoder {
&mut self,
seek_res: symphonia::core::formats::SeekedTo,
) -> Result<(), String> {
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
// Number of frames between where the demuxer landed and the requested ts.
let mut samples_to_pass: u64 = seek_res
.required_ts
.get()
.saturating_sub(seek_res.actual_ts.get())
.max(0) as u64;
let packet = loop {
let candidate = self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?;
if candidate.dur() > samples_to_pass {
let candidate = match self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?
{
Some(p) => p,
// EOF while refining — nothing more to skip.
None => return Ok(()),
};
if candidate.dur.get() > samples_to_pass {
break candidate;
}
samples_to_pass -= candidate.dur();
samples_to_pass -= candidate.dur.get();
};
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?;
let p = match self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?
{
Some(p) => p,
None => break,
};
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels().count();
Ok(())
}
}
@@ -349,12 +490,12 @@ impl Iterator for SizedDecoder {
// drop the frame and advance to the next packet. IO errors and a
// clean end-of-stream both terminate the iterator normally.
loop {
let packet = self.format.next_packet().ok()?;
let packet = self.format.next_packet().ok()??;
match self.decoder.decode(&packet) {
Ok(decoded) => {
self.consecutive_decode_errors = 0;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
self.current_frame_offset = 0;
break;
}
@@ -385,7 +526,7 @@ impl Iterator for SizedDecoder {
}
}
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
let sample = *self.buffer.get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
@@ -394,25 +535,24 @@ impl Iterator for SizedDecoder {
impl Source for SizedDecoder {
#[inline]
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.samples().len())
Some(self.buffer.len())
}
#[inline]
fn channels(&self) -> rodio::ChannelCount {
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
std::num::NonZeroU16::new(self.spec.channels().count() as u16)
.unwrap_or(std::num::NonZeroU16::MIN)
}
#[inline]
fn sample_rate(&self) -> rodio::SampleRate {
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
std::num::NonZeroU32::new(self.spec.rate()).unwrap_or(std::num::NonZeroU32::MIN)
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
self.total_duration.map(|Time { seconds, frac }| {
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
})
self.total_duration
.map(|t| Duration::from_secs_f64(t.as_secs_f64().max(0.0)))
}
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
@@ -420,20 +560,19 @@ impl Source for SizedDecoder {
.total_duration()
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
let time: Time = if seek_beyond_end {
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
let target_secs = if seek_beyond_end {
// Step back a tiny bit — some demuxers can't seek to the exact end.
let mut secs = t.seconds;
let mut frac = t.frac - 0.0001;
if frac < 0.0 {
secs = secs.saturating_sub(1);
frac = 1.0 - frac;
}
Time { seconds: secs, frac }
let total = self
.total_duration
.map(|t| t.as_secs_f64())
.unwrap_or_else(|| pos.as_secs_f64());
(total - 0.0001).max(0.0)
} else {
pos.as_secs_f64().into()
pos.as_secs_f64()
};
let time = Time::try_from_secs_f64(target_secs).unwrap_or(Time::ZERO);
let to_skip = self.current_frame_offset % self.channels().get() as usize;
let seek_res = self
@@ -841,8 +980,8 @@ mod tests {
fn sized_decoder_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new(wav, Some("wav"), false).expect("WAV decode setup");
assert_eq!(decoder.spec.rate, 44_100);
assert_eq!(decoder.spec.channels.count(), 1);
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
}
#[test]
@@ -857,21 +996,84 @@ mod tests {
let _decoder = SizedDecoder::new(wav, Some("wav"), true).expect("WAV decode with hi-res");
}
// ── new_streaming + ProbeSeekGate ────────────────────────────────────────
fn seekable_source(bytes: Vec<u8>) -> Box<dyn MediaSource> {
let len = bytes.len() as u64;
Box::new(SizedCursorSource { inner: Cursor::new(bytes), len })
}
#[test]
fn new_streaming_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
.expect("streaming WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
// Live streams report no total duration.
assert!(decoder.total_duration.is_none());
}
#[test]
fn new_streaming_returns_err_for_garbage_input() {
let result = SizedDecoder::new_streaming(
seekable_source(vec![0x00u8; 64]),
None,
"test-stream",
);
assert!(result.is_err());
}
#[test]
fn probe_seek_gate_toggles_seekability() {
let wav = synthetic_wav_bytes(0.1);
let len = wav.len() as u64;
let flag = Arc::new(AtomicBool::new(false));
let gate = ProbeSeekGate {
inner: seekable_source(wav),
seekable: flag.clone(),
};
// Hidden during probe …
assert!(!gate.is_seekable());
// … restored afterwards.
flag.store(true, Ordering::Relaxed);
assert!(gate.is_seekable());
// byte_len always passes through to the inner source.
assert_eq!(gate.byte_len(), Some(len));
}
#[test]
fn probe_seek_gate_read_and_seek_pass_through() {
let bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
let mut gate = ProbeSeekGate {
inner: seekable_source(bytes),
seekable: Arc::new(AtomicBool::new(true)),
};
let mut buf = [0u8; 4];
let n = gate.read(&mut buf).expect("read");
assert_eq!(n, 4);
assert_eq!(&buf, &[1, 2, 3, 4]);
let pos = gate.seek(std::io::SeekFrom::Start(6)).expect("seek");
assert_eq!(pos, 6);
let n = gate.read(&mut buf).expect("read after seek");
assert_eq!(&buf[..n], &[7, 8]);
}
// ── log_codec_resolution ─────────────────────────────────────────────────
#[test]
fn log_codec_resolution_does_not_panic_for_valid_params() {
let mut params = symphonia::core::codecs::CodecParameters::new();
params.codec = symphonia::core::codecs::CODEC_TYPE_PCM_S16LE;
let mut params = AudioCodecParameters::new();
params.codec = symphonia::core::codecs::audio::well_known::CODEC_ID_PCM_S16LE;
params.sample_rate = Some(44_100);
params.bits_per_sample = Some(16);
params.channels = Some(symphonia::core::audio::Channels::FRONT_LEFT);
params.channels = Some(symphonia::core::audio::Channels::Discrete(1));
log_codec_resolution("test-tag", &params, Some("wav"));
}
#[test]
fn log_codec_resolution_handles_unknown_codec_gracefully() {
let params = symphonia::core::codecs::CodecParameters::new();
let params = AudioCodecParameters::new();
log_codec_resolution("unknown", &params, None);
}
}