mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
6ea0acede5
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix Defer seekbar/progress until HTTP stream is armed for both legacy and RangedHttpSource; show buffering overlay on cover art. Add MP4 tail prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so moov-at-end M4A can start without reading the full mdat. * feat(hot-cache): spill large ranged streams to disk for promote When a ranged HTTP download completes above the 64 MiB RAM promote cap, write the existing buffer once to app-data stream-spill/ and register it for hot-cache promote (rename) and replay via fetch_data. Analysis seeds from the spill file up to the local-file cap (512 MiB). * fix(ui): stream buffering — grayscale cover and static clock icon Desaturate player and queue cover art while isPlaybackBuffering; keep a non-animated clock overlay for visibility without the spinning animation. * fix(playback): review follow-up — tests, i18n, spill cleanup, changelog Clippy and test layout fixes; stream spill orphan cleanup on startup; buffering flag guard in progress handler; bufferingStream in all player locales; CHANGELOG and contributor credits for stream/M4A work. * docs: attribute stream buffering and M4A streaming to PR #737 * test(audio): avoid create_engine in stream spill unit test CI runners have no audio output device; test spill take/consume via the Mutex slot only, matching install_stream_completed_spill tests.
1015 lines
39 KiB
Rust
1015 lines
39 KiB
Rust
//! 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::Arc;
|
||
use std::time::Duration;
|
||
|
||
use rodio::source::UniformSourceIterator;
|
||
use rodio::Source;
|
||
use symphonia::core::{
|
||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
|
||
meta::MetadataOptions,
|
||
probe::Hint,
|
||
units::{self, Time},
|
||
};
|
||
|
||
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
|
||
use super::sources::*;
|
||
|
||
// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ──────
|
||
//
|
||
// rodio's internal ReadSeekSource wraps Cursor<Vec<u8>> but hardcodes
|
||
// byte_len() → None. This tells symphonia "stream length unknown", which
|
||
// prevents the FLAC demuxer from seeking (it validates seek offsets against
|
||
// the total stream length from byte_len). MP3 is unaffected because its
|
||
// demuxer uses Xing/LAME headers instead.
|
||
//
|
||
// This wrapper provides the actual byte length, fixing seek for all formats.
|
||
|
||
pub(crate) struct SizedCursorSource {
|
||
inner: Cursor<Vec<u8>>,
|
||
len: u64,
|
||
}
|
||
|
||
impl Read for SizedCursorSource {
|
||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||
self.inner.read(buf)
|
||
}
|
||
}
|
||
|
||
impl Seek for SizedCursorSource {
|
||
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||
self.inner.seek(pos)
|
||
}
|
||
}
|
||
|
||
impl MediaSource for SizedCursorSource {
|
||
fn is_seekable(&self) -> bool { true }
|
||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||
}
|
||
|
||
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
|
||
//
|
||
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
|
||
// (byte_len = None). This constructs the symphonia pipeline directly,
|
||
// providing the correct byte_len via SizedCursorSource.
|
||
//
|
||
// Implements Iterator<Item = i16> + Source — identical interface to
|
||
// rodio::Decoder, so the rest of the source chain is unchanged.
|
||
|
||
/// Debug logging: codec parameters in human-readable form to verify whether
|
||
/// playback is genuinely lossless.
|
||
pub(crate) fn log_codec_resolution(
|
||
tag: &str,
|
||
params: &symphonia::core::codecs::CodecParameters,
|
||
container_hint: Option<&str>,
|
||
) {
|
||
let codec_name = symphonia::default::get_codecs()
|
||
.get_codec(params.codec)
|
||
.map(|d| d.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
|
||
.map(|c| format!("{}ch", c.count()))
|
||
.unwrap_or_else(|| "?ch".into());
|
||
let lossless = codec_name.starts_with("pcm")
|
||
|| matches!(
|
||
codec_name,
|
||
"flac" | "alac" | "wavpack" | "monkeys-audio" | "tta" | "shorten"
|
||
);
|
||
let kind = if lossless { "LOSSLESS" } else { "lossy" };
|
||
crate::app_deprintln!(
|
||
"[stream] {tag}: codec={codec_name} ({kind}) {bits} {rate} {ch} container={}",
|
||
container_hint.unwrap_or("?")
|
||
);
|
||
}
|
||
|
||
/// Max retries for IO/packet-read errors (fatal — network drop, truncated file).
|
||
const DECODE_MAX_RETRIES: usize = 3;
|
||
/// Max *consecutive* DecodeErrors before giving up on a file.
|
||
/// Non-fatal errors like "invalid main_data offset" are silently dropped up to
|
||
/// 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;
|
||
|
||
pub(crate) struct SizedDecoder {
|
||
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||
current_frame_offset: usize,
|
||
format: Box<dyn FormatReader>,
|
||
total_duration: Option<Time>,
|
||
buffer: SampleBuffer<f32>,
|
||
spec: SignalSpec,
|
||
/// 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,
|
||
}
|
||
|
||
impl SizedDecoder {
|
||
pub(crate) fn new(data: Vec<u8>, format_hint: Option<&str>, hi_res: bool) -> Result<Self, String> {
|
||
let data_len = data.len() as u64;
|
||
let source = SizedCursorSource {
|
||
inner: Cursor::new(data),
|
||
len: data_len,
|
||
};
|
||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||
// and compete with the playback thread at track start.
|
||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||
let mss = MediaSourceStream::new(
|
||
Box::new(source) as Box<dyn MediaSource>,
|
||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||
);
|
||
|
||
let mut hint = Hint::new();
|
||
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 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()
|
||
};
|
||
|
||
let probed = symphonia::default::get_probe()
|
||
.format(&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.
|
||
crate::app_eprintln!("[psysonic] probe failed (hint={hint_str}): {e}");
|
||
if e.to_string().to_lowercase().contains("unsupported") {
|
||
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
|
||
} else {
|
||
format!("could not open audio stream (.{hint_str}): {e}")
|
||
}
|
||
})?;
|
||
|
||
let track = probed.format
|
||
.tracks()
|
||
.iter()
|
||
// Explicitly select only audio tracks: must have a valid 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()
|
||
})
|
||
.ok_or_else(|| {
|
||
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.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));
|
||
|
||
log_codec_resolution("bytes", &track.codec_params, format_hint);
|
||
|
||
let mut decoder = psysonic_codec_registry()
|
||
.make(&track.codec_params, &DecoderOptions::default())
|
||
.map_err(|e| {
|
||
crate::app_eprintln!("[psysonic] codec init failed: {e}");
|
||
if e.to_string().to_lowercase().contains("unsupported") {
|
||
"unsupported codec: no decoder available for this audio format".to_string()
|
||
} else {
|
||
format!("failed to initialise audio decoder: {e}")
|
||
}
|
||
})?;
|
||
|
||
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,
|
||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||
break decoder.last_decoded();
|
||
}
|
||
Err(e) => {
|
||
crate::app_eprintln!("[psysonic] next_packet error: {e}");
|
||
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);
|
||
continue;
|
||
}
|
||
match decoder.decode(&packet) {
|
||
Ok(decoded) => break decoded,
|
||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||
decode_errors += 1;
|
||
crate::app_eprintln!("[psysonic] init: dropped corrupt frame #{decode_errors}: {msg}");
|
||
if decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||
return Err("too many consecutive decode errors during init — file may be corrupt".into());
|
||
}
|
||
}
|
||
Err(e) => {
|
||
crate::app_eprintln!("[psysonic] fatal decode error: {e}");
|
||
return Err(format!("audio decode error: {e}"));
|
||
}
|
||
}
|
||
};
|
||
|
||
let spec = decoded.spec().to_owned();
|
||
let buffer = Self::make_buffer(decoded, &spec);
|
||
|
||
Ok(SizedDecoder {
|
||
decoder,
|
||
current_frame_offset: 0,
|
||
format,
|
||
total_duration,
|
||
buffer,
|
||
spec,
|
||
consecutive_decode_errors: 0,
|
||
})
|
||
}
|
||
|
||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||
pub(crate) fn new_streaming(
|
||
media: Box<dyn MediaSource>,
|
||
format_hint: Option<&str>,
|
||
source_tag: &str,
|
||
) -> Result<Self, String> {
|
||
// 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 track = probed.format.tracks().iter()
|
||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||
.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);
|
||
// 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())
|
||
.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,
|
||
Err(_) => break decoder.last_decoded(),
|
||
};
|
||
if packet.track_id() != track_id { continue; }
|
||
match decoder.decode(&packet) {
|
||
Ok(d) => break d,
|
||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||
errors += 1;
|
||
crate::app_eprintln!("[psysonic] {source_tag} init: dropped corrupt frame #{errors}: {msg}");
|
||
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||
return Err(format!("{source_tag}: too many consecutive decode errors"));
|
||
}
|
||
}
|
||
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
|
||
}
|
||
};
|
||
let spec = decoded.spec().to_owned();
|
||
let buffer = Self::make_buffer(decoded, &spec);
|
||
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);
|
||
buffer
|
||
}
|
||
|
||
/// Refine position after a coarse seek — decode packets until we reach the
|
||
/// exact requested timestamp.
|
||
fn refine_position(
|
||
&mut self,
|
||
seek_res: symphonia::core::formats::SeekedTo,
|
||
) -> Result<(), String> {
|
||
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
|
||
let packet = loop {
|
||
let candidate = self.format.next_packet()
|
||
.map_err(|e| format!("refine seek: {e}"))?;
|
||
if candidate.dur() > samples_to_pass {
|
||
break candidate;
|
||
}
|
||
samples_to_pass -= candidate.dur();
|
||
};
|
||
|
||
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}"))?;
|
||
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();
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl Iterator for SizedDecoder {
|
||
type Item = f32;
|
||
|
||
#[inline]
|
||
fn next(&mut self) -> Option<f32> {
|
||
if self.current_frame_offset >= self.buffer.len() {
|
||
// Loop until a decodable packet is found or the stream ends.
|
||
// DecodeErrors (e.g. MP3 "invalid main_data offset") are non-fatal:
|
||
// 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()?;
|
||
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.current_frame_offset = 0;
|
||
break;
|
||
}
|
||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||
#[cfg(not(debug_assertions))]
|
||
let _ = msg;
|
||
self.consecutive_decode_errors += 1;
|
||
// Log sparingly: first drop, then every 10th to avoid spam.
|
||
if self.consecutive_decode_errors == 1
|
||
|| self.consecutive_decode_errors.is_multiple_of(10)
|
||
{
|
||
crate::app_deprintln!(
|
||
"[psysonic] dropped corrupt frame #{}: {msg}",
|
||
self.consecutive_decode_errors
|
||
);
|
||
}
|
||
if self.consecutive_decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||
crate::app_deprintln!(
|
||
"[psysonic] {MAX_CONSECUTIVE_DECODE_ERRORS} consecutive decode \
|
||
failures — stream appears unrecoverable, stopping"
|
||
);
|
||
return None;
|
||
}
|
||
// continue → fetch next packet
|
||
}
|
||
Err(_) => return None, // IO error or fatal codec error → end of stream
|
||
}
|
||
}
|
||
}
|
||
|
||
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
|
||
self.current_frame_offset += 1;
|
||
Some(sample)
|
||
}
|
||
}
|
||
|
||
impl Source for SizedDecoder {
|
||
#[inline]
|
||
fn current_span_len(&self) -> Option<usize> {
|
||
Some(self.buffer.samples().len())
|
||
}
|
||
|
||
#[inline]
|
||
fn channels(&self) -> rodio::ChannelCount {
|
||
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)
|
||
}
|
||
|
||
#[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)
|
||
})
|
||
}
|
||
|
||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||
let seek_beyond_end = self
|
||
.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());
|
||
// 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 }
|
||
} else {
|
||
pos.as_secs_f64().into()
|
||
};
|
||
|
||
let to_skip = self.current_frame_offset % self.channels().get() as usize;
|
||
|
||
let seek_res = self
|
||
.format
|
||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||
.map_err(|e| rodio::source::SeekError::Other(
|
||
std::sync::Arc::new(std::io::Error::other(e.to_string()))
|
||
))?;
|
||
|
||
self.refine_position(seek_res)
|
||
.map_err(|e| rodio::source::SeekError::Other(
|
||
std::sync::Arc::new(std::io::Error::other(e))
|
||
))?;
|
||
|
||
self.current_frame_offset += to_skip;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
|
||
//
|
||
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
||
// samples for LAME) and append end-padding to fill the final frame.
|
||
// iTunes embeds the exact counts in an ID3v2 COMM frame with description
|
||
// "iTunSMPB". Format: " 00000000 DELAY PADDING TOTAL ..." (space-separated hex)
|
||
//
|
||
// Parsing strategy: scan raw bytes for the ASCII marker, then extract the
|
||
// first whitespace-separated hex tokens after it.
|
||
|
||
#[derive(Default)]
|
||
pub(crate) struct GaplessInfo {
|
||
delay_samples: u64,
|
||
total_valid_samples: Option<u64>,
|
||
}
|
||
|
||
pub(crate) fn find_subsequence(data: &[u8], needle: &[u8]) -> Option<usize> {
|
||
data.windows(needle.len()).position(|w| w == needle)
|
||
}
|
||
|
||
pub(crate) fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
||
let pos = match find_subsequence(data, b"iTunSMPB") {
|
||
Some(p) => p,
|
||
None => return GaplessInfo::default(),
|
||
};
|
||
|
||
// In M4A/iTunes files the key is followed by a binary 'data' atom header
|
||
// (16 bytes: size[4] + "data"[4] + type_flags[4] + locale[4]) before the
|
||
// actual value string. Search for the " 00000000 " sentinel that every
|
||
// iTunSMPB value starts with to locate the true start of the text.
|
||
let search_end = data.len().min(pos + 8 + 128);
|
||
let search_window = &data[pos + 8..search_end];
|
||
let value_start = find_subsequence(search_window, b" 00000000 ")
|
||
.map(|off| pos + 8 + off)
|
||
.unwrap_or(pos + 8);
|
||
|
||
let tail = &data[value_start..data.len().min(value_start + 256)];
|
||
let text: String = tail.iter()
|
||
.map(|&b| b as char)
|
||
.filter(|c| c.is_ascii_hexdigit() || *c == ' ')
|
||
.collect();
|
||
|
||
let parts: Vec<&str> = text.split_whitespace().collect();
|
||
// parts[0] = "00000000", parts[1] = delay, parts[2] = padding, parts[3] = total
|
||
if parts.len() < 3 {
|
||
return GaplessInfo::default();
|
||
}
|
||
let delay = u64::from_str_radix(parts.get(1).unwrap_or(&"0"), 16).unwrap_or(0);
|
||
let padding = u64::from_str_radix(parts.get(2).unwrap_or(&"0"), 16).unwrap_or(0);
|
||
let total_raw = parts.get(3).and_then(|s| u64::from_str_radix(s, 16).ok());
|
||
|
||
let total_valid = total_raw.filter(|&t| t > 0).or_else(|| {
|
||
// Derive from delay + padding if total not available:
|
||
// Not possible without knowing total encoded samples, so just use None.
|
||
let _ = padding;
|
||
None
|
||
});
|
||
|
||
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
|
||
}
|
||
|
||
pub(crate) type BuiltSourceStack =
|
||
PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>>;
|
||
|
||
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
|
||
pub(crate) struct BuiltSource {
|
||
pub(crate) source: BuiltSourceStack,
|
||
pub(crate) duration_secs: f64,
|
||
pub(crate) output_rate: u32,
|
||
pub(crate) output_channels: u16,
|
||
/// Trigger for the sample-level crossfade fade-out.
|
||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||
/// Total samples for the fade-out (set before triggering).
|
||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||
}
|
||
|
||
/// Build a fully-prepared playback source:
|
||
/// decode → trim → resample → EQ → fade-in → triggered-fade-out → notify → count
|
||
///
|
||
/// `fade_in_dur`:
|
||
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
|
||
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click)
|
||
/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade
|
||
///
|
||
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
|
||
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
|
||
/// `format_hint`: optional file extension (e.g. "flac", "mp3") to help symphonia probe.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn build_source(
|
||
data: Vec<u8>,
|
||
duration_hint: f64,
|
||
eq_gains: Arc<[AtomicU32; 10]>,
|
||
eq_enabled: Arc<AtomicBool>,
|
||
eq_pre_gain: Arc<AtomicU32>,
|
||
done_flag: Arc<AtomicBool>,
|
||
fade_in_dur: Duration,
|
||
sample_counter: Arc<AtomicU64>,
|
||
target_rate: u32,
|
||
format_hint: Option<&str>,
|
||
hi_res: bool,
|
||
) -> Result<BuiltSource, String> {
|
||
let gapless = parse_gapless_info(&data);
|
||
|
||
let decoder = SizedDecoder::new(data, format_hint, hi_res)?;
|
||
let sample_rate = decoder.sample_rate();
|
||
let channels = decoder.channels();
|
||
|
||
// Determine effective duration.
|
||
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
|
||
let effective_dur = if duration_hint > 1.0 {
|
||
duration_hint
|
||
} else {
|
||
decoder.total_duration()
|
||
.map(|d| d.as_secs_f64())
|
||
.unwrap_or(duration_hint)
|
||
};
|
||
|
||
// Apply encoder-delay trim and optional end-padding trim,
|
||
// then resample to the canonical target rate if needed.
|
||
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
|
||
let delay_dur = Duration::from_secs_f64(
|
||
gapless.delay_samples as f64 / sample_rate.get() as f64
|
||
);
|
||
let base = decoder.skip_duration(delay_dur);
|
||
|
||
if let Some(total) = gapless.total_valid_samples {
|
||
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate.get() as f64);
|
||
let trimmed = base.take_duration(valid_dur);
|
||
if target_rate > 0 && sample_rate.get() != target_rate {
|
||
DynSource::new(UniformSourceIterator::new(
|
||
trimmed,
|
||
channels,
|
||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||
))
|
||
} else {
|
||
DynSource::new(trimmed)
|
||
}
|
||
} else if target_rate > 0 && sample_rate.get() != target_rate {
|
||
DynSource::new(UniformSourceIterator::new(
|
||
base,
|
||
channels,
|
||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||
))
|
||
} else {
|
||
DynSource::new(base)
|
||
}
|
||
} else {
|
||
let converted = decoder;
|
||
if target_rate > 0 && sample_rate.get() != target_rate {
|
||
DynSource::new(UniformSourceIterator::new(
|
||
converted,
|
||
channels,
|
||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||
))
|
||
} else {
|
||
DynSource::new(converted)
|
||
}
|
||
};
|
||
|
||
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate { target_rate } else { sample_rate.get() };
|
||
|
||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||
|
||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||
let counting = CountingSource::new(notifying, sample_counter);
|
||
let boosted = PriorityBoostSource::new(counting);
|
||
|
||
Ok(BuiltSource {
|
||
source: boosted,
|
||
duration_secs: effective_dur,
|
||
output_rate,
|
||
output_channels: channels.get(),
|
||
fadeout_trigger,
|
||
fadeout_samples,
|
||
})
|
||
}
|
||
|
||
/// Streaming variant of `build_source`: uses a live `SizedDecoder` source
|
||
/// (non-seekable) and skips iTunSMPB parsing, but preserves the same EQ/fade/
|
||
/// counting wrappers and output metadata.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn build_streaming_source(
|
||
decoder: SizedDecoder,
|
||
duration_hint: f64,
|
||
eq_gains: Arc<[AtomicU32; 10]>,
|
||
eq_enabled: Arc<AtomicBool>,
|
||
eq_pre_gain: Arc<AtomicU32>,
|
||
done_flag: Arc<AtomicBool>,
|
||
fade_in_dur: Duration,
|
||
sample_counter: Arc<AtomicU64>,
|
||
target_rate: u32,
|
||
count_gate: Option<Arc<AtomicBool>>,
|
||
) -> Result<BuiltSource, String> {
|
||
let sample_rate = decoder.sample_rate();
|
||
let channels = decoder.channels();
|
||
|
||
// For streaming starts prefer server-provided duration when available.
|
||
let effective_dur = if duration_hint > 1.0 {
|
||
duration_hint
|
||
} else {
|
||
decoder
|
||
.total_duration()
|
||
.map(|d| d.as_secs_f64())
|
||
.unwrap_or(duration_hint)
|
||
};
|
||
|
||
let converted = decoder;
|
||
let dyn_src: DynSource = if target_rate > 0 && sample_rate.get() != target_rate {
|
||
DynSource::new(UniformSourceIterator::new(
|
||
converted,
|
||
channels,
|
||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||
))
|
||
} else {
|
||
DynSource::new(converted)
|
||
};
|
||
|
||
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate {
|
||
target_rate
|
||
} else {
|
||
sample_rate.get()
|
||
};
|
||
|
||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||
|
||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||
let counting = match count_gate {
|
||
Some(gate) => CountingSource::new_gated(notifying, sample_counter, gate),
|
||
None => CountingSource::new(notifying, sample_counter),
|
||
};
|
||
let boosted = PriorityBoostSource::new(counting);
|
||
|
||
Ok(BuiltSource {
|
||
source: boosted,
|
||
duration_secs: effective_dur,
|
||
output_rate,
|
||
output_channels: channels.get(),
|
||
fadeout_trigger,
|
||
fadeout_samples,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// ── find_subsequence ─────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn find_subsequence_locates_needle_at_start() {
|
||
assert_eq!(find_subsequence(b"abcdef", b"abc"), Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn find_subsequence_locates_needle_in_middle() {
|
||
assert_eq!(find_subsequence(b"abcdef", b"cd"), Some(2));
|
||
}
|
||
|
||
#[test]
|
||
fn find_subsequence_returns_none_when_absent() {
|
||
assert!(find_subsequence(b"abcdef", b"xyz").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn find_subsequence_returns_none_for_needle_longer_than_haystack() {
|
||
assert!(find_subsequence(b"ab", b"abcd").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn find_subsequence_finds_first_occurrence_of_repeated_pattern() {
|
||
assert_eq!(find_subsequence(b"abab", b"ab"), Some(0));
|
||
}
|
||
|
||
// ── parse_gapless_info ───────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn parse_gapless_returns_default_when_itunsmpb_absent() {
|
||
let info = parse_gapless_info(b"no marker here");
|
||
assert_eq!(info.delay_samples, 0);
|
||
assert!(info.total_valid_samples.is_none());
|
||
}
|
||
|
||
fn synth_itunsmpb_blob(delay_hex: &str, padding_hex: &str, total_hex: &str) -> Vec<u8> {
|
||
let mut v = Vec::new();
|
||
v.extend_from_slice(b"random preamble bytes ");
|
||
v.extend_from_slice(b"iTunSMPB");
|
||
v.extend_from_slice(&[0u8; 16]);
|
||
v.push(b' ');
|
||
v.extend_from_slice(b"00000000");
|
||
v.push(b' ');
|
||
v.extend_from_slice(delay_hex.as_bytes());
|
||
v.push(b' ');
|
||
v.extend_from_slice(padding_hex.as_bytes());
|
||
v.push(b' ');
|
||
v.extend_from_slice(total_hex.as_bytes());
|
||
v.push(b' ');
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn parse_gapless_extracts_delay_from_itunsmpb_blob() {
|
||
let blob = synth_itunsmpb_blob("00000840", "00000000", "00ABCDEF");
|
||
let info = parse_gapless_info(&blob);
|
||
assert_eq!(info.delay_samples, 0x840, "delay decoded as hex");
|
||
assert_eq!(info.total_valid_samples, Some(0x00AB_CDEF));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_gapless_returns_none_total_when_total_field_is_zero() {
|
||
let blob = synth_itunsmpb_blob("00000840", "00000000", "00000000");
|
||
let info = parse_gapless_info(&blob);
|
||
assert_eq!(info.delay_samples, 0x840);
|
||
assert!(
|
||
info.total_valid_samples.is_none(),
|
||
"zero-total filters out per the implementation"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_gapless_handles_itunsmpb_without_value_string() {
|
||
let mut v = b"iTunSMPB".to_vec();
|
||
v.extend_from_slice(&[0u8; 16]);
|
||
let info = parse_gapless_info(&v);
|
||
assert_eq!(info.delay_samples, 0);
|
||
assert!(info.total_valid_samples.is_none());
|
||
}
|
||
|
||
// ── SizedDecoder::new with a synthetic WAV ───────────────────────────────
|
||
|
||
fn build_mono_pcm16_wav(samples: &[i16], sample_rate: u32) -> Vec<u8> {
|
||
let num_channels: u16 = 1;
|
||
let bits_per_sample: u16 = 16;
|
||
let byte_rate = sample_rate * (bits_per_sample as u32 / 8) * num_channels as u32;
|
||
let block_align = num_channels * (bits_per_sample / 8);
|
||
let data_size = (samples.len() * 2) as u32;
|
||
let riff_size = 36 + data_size;
|
||
|
||
let mut out = Vec::with_capacity(44 + data_size as usize);
|
||
out.extend_from_slice(b"RIFF");
|
||
out.extend_from_slice(&riff_size.to_le_bytes());
|
||
out.extend_from_slice(b"WAVE");
|
||
out.extend_from_slice(b"fmt ");
|
||
out.extend_from_slice(&16u32.to_le_bytes());
|
||
out.extend_from_slice(&1u16.to_le_bytes());
|
||
out.extend_from_slice(&num_channels.to_le_bytes());
|
||
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||
out.extend_from_slice(&byte_rate.to_le_bytes());
|
||
out.extend_from_slice(&block_align.to_le_bytes());
|
||
out.extend_from_slice(&bits_per_sample.to_le_bytes());
|
||
out.extend_from_slice(b"data");
|
||
out.extend_from_slice(&data_size.to_le_bytes());
|
||
for s in samples {
|
||
out.extend_from_slice(&s.to_le_bytes());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn synthetic_wav_bytes(secs: f32) -> Vec<u8> {
|
||
let sample_rate = 44_100u32;
|
||
let n = (sample_rate as f32 * secs) as usize;
|
||
let amp: f32 = 0.5 * i16::MAX as f32;
|
||
let samples: Vec<i16> = (0..n)
|
||
.map(|i| {
|
||
let t = i as f32 / sample_rate as f32;
|
||
((2.0 * std::f32::consts::PI * 440.0 * t).sin() * amp) as i16
|
||
})
|
||
.collect();
|
||
build_mono_pcm16_wav(&samples, sample_rate)
|
||
}
|
||
|
||
#[test]
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
fn sized_decoder_returns_err_for_garbage_input() {
|
||
let result = SizedDecoder::new(vec![0x00u8; 64], None, false);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn sized_decoder_uses_format_hint_when_provided() {
|
||
let wav = synthetic_wav_bytes(0.3);
|
||
let _decoder = SizedDecoder::new(wav, Some("wav"), true).expect("WAV decode with hi-res");
|
||
}
|
||
|
||
// ── 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;
|
||
params.sample_rate = Some(44_100);
|
||
params.bits_per_sample = Some(16);
|
||
params.channels = Some(symphonia::core::audio::Channels::FRONT_LEFT);
|
||
log_codec_resolution("test-tag", ¶ms, Some("wav"));
|
||
}
|
||
|
||
#[test]
|
||
fn log_codec_resolution_handles_unknown_codec_gracefully() {
|
||
let params = symphonia::core::codecs::CodecParameters::new();
|
||
log_codec_resolution("unknown", ¶ms, None);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod build_source_tests {
|
||
use super::*;
|
||
|
||
fn build_mono_pcm16_wav_local(samples: &[i16], sample_rate: u32) -> Vec<u8> {
|
||
let num_channels: u16 = 1;
|
||
let bits_per_sample: u16 = 16;
|
||
let byte_rate = sample_rate * (bits_per_sample as u32 / 8) * num_channels as u32;
|
||
let block_align = num_channels * (bits_per_sample / 8);
|
||
let data_size = (samples.len() * 2) as u32;
|
||
let riff_size = 36 + data_size;
|
||
|
||
let mut out = Vec::with_capacity(44 + data_size as usize);
|
||
out.extend_from_slice(b"RIFF");
|
||
out.extend_from_slice(&riff_size.to_le_bytes());
|
||
out.extend_from_slice(b"WAVE");
|
||
out.extend_from_slice(b"fmt ");
|
||
out.extend_from_slice(&16u32.to_le_bytes());
|
||
out.extend_from_slice(&1u16.to_le_bytes());
|
||
out.extend_from_slice(&num_channels.to_le_bytes());
|
||
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||
out.extend_from_slice(&byte_rate.to_le_bytes());
|
||
out.extend_from_slice(&block_align.to_le_bytes());
|
||
out.extend_from_slice(&bits_per_sample.to_le_bytes());
|
||
out.extend_from_slice(b"data");
|
||
out.extend_from_slice(&data_size.to_le_bytes());
|
||
for s in samples {
|
||
out.extend_from_slice(&s.to_le_bytes());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn synthetic_wav_bytes_local(secs: f32) -> Vec<u8> {
|
||
let sample_rate = 44_100u32;
|
||
let n = (sample_rate as f32 * secs) as usize;
|
||
let amp: f32 = 0.5 * i16::MAX as f32;
|
||
let samples: Vec<i16> = (0..n)
|
||
.map(|i| {
|
||
let t = i as f32 / sample_rate as f32;
|
||
((2.0 * std::f32::consts::PI * 440.0 * t).sin() * amp) as i16
|
||
})
|
||
.collect();
|
||
build_mono_pcm16_wav_local(&samples, sample_rate)
|
||
}
|
||
|
||
type EqGains = Arc<[AtomicU32; 10]>;
|
||
type SourceArgs = (EqGains, Arc<AtomicBool>, Arc<AtomicU32>, Arc<AtomicBool>, Arc<AtomicU64>);
|
||
|
||
fn default_source_args() -> SourceArgs {
|
||
let eq_gains: Arc<[AtomicU32; 10]> =
|
||
Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits())));
|
||
let eq_enabled = Arc::new(AtomicBool::new(false));
|
||
let eq_pre_gain = Arc::new(AtomicU32::new(0f32.to_bits()));
|
||
let done_flag = Arc::new(AtomicBool::new(false));
|
||
let sample_counter = Arc::new(AtomicU64::new(0));
|
||
(eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter)
|
||
}
|
||
|
||
#[test]
|
||
fn build_source_succeeds_for_synthetic_wav() {
|
||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||
let wav = synthetic_wav_bytes_local(0.4);
|
||
let built = build_source(
|
||
wav,
|
||
0.4,
|
||
eq_gains,
|
||
eq_enabled,
|
||
eq_pre_gain,
|
||
done_flag,
|
||
Duration::ZERO,
|
||
sample_counter,
|
||
0,
|
||
Some("wav"),
|
||
false,
|
||
)
|
||
.expect("build_source must succeed for a valid WAV");
|
||
assert_eq!(built.output_channels, 1);
|
||
assert!(built.duration_secs > 0.0);
|
||
assert!(built.output_rate > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn build_source_returns_err_for_garbage_bytes() {
|
||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||
let result = build_source(
|
||
vec![0u8; 32],
|
||
0.0,
|
||
eq_gains,
|
||
eq_enabled,
|
||
eq_pre_gain,
|
||
done_flag,
|
||
Duration::ZERO,
|
||
sample_counter,
|
||
0,
|
||
None,
|
||
false,
|
||
);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn build_streaming_source_succeeds_for_synthetic_wav() {
|
||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||
let wav = synthetic_wav_bytes_local(0.4);
|
||
let decoder = SizedDecoder::new(wav, Some("wav"), false).unwrap();
|
||
let built = build_streaming_source(
|
||
decoder,
|
||
0.4,
|
||
eq_gains,
|
||
eq_enabled,
|
||
eq_pre_gain,
|
||
done_flag,
|
||
Duration::ZERO,
|
||
sample_counter,
|
||
0,
|
||
None,
|
||
)
|
||
.expect("build_streaming_source must succeed for a valid WAV decoder");
|
||
assert_eq!(built.output_channels, 1);
|
||
assert!(built.output_rate > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn build_source_with_target_rate_resamples() {
|
||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||
let wav = synthetic_wav_bytes_local(0.3);
|
||
let built = build_source(
|
||
wav,
|
||
0.3,
|
||
eq_gains,
|
||
eq_enabled,
|
||
eq_pre_gain,
|
||
done_flag,
|
||
Duration::from_millis(5),
|
||
sample_counter,
|
||
48_000,
|
||
Some("wav"),
|
||
false,
|
||
)
|
||
.expect("resampled build_source must succeed");
|
||
assert_eq!(built.output_rate, 48_000);
|
||
}
|
||
}
|