Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-audio/src/stream/mod.rs
T
cucadmuh 067ed00ae2 fix(audio): fix Opus/Ogg seek crash (symphonia do_seek panic) (#1100)
* fix(audio): fix Opus/Ogg seek crash by keeping random-access sources seekable through probe

Scrubbing the seekbar on Opus/Ogg files (then pressing Stop) crashed the whole
app. symphonia 0.6's Ogg demuxer records the physical stream's byte range only
when the source is seekable during the probe, but ProbeSeekGate hid seekability
there — so phys_byte_range_end stayed None and the first seek hit
Option::unwrap() on None on the cpal audio thread. That poisoned the engine
mutexes and aborted the process at the non-unwinding cpal FFI boundary (the
"crash on Stop" was a downstream symptom).

- Keep Ogg/Opus seekable through the probe on random-access sources (local
  files, in-memory) so the demuxer computes its seek bounds and seeking works
  for real. Progressive ranged-HTTP keeps the gate to avoid forcing a full
  download before playback starts.
- Contain any demuxer unwind inside SizedDecoder::try_seek (catch_unwind) so a
  panic on the audio thread can no longer poison engine state — covers streamed
  Ogg (still gated) and any future demuxer panic.
- Thread a random_access flag through PlayInput::SeekableMedia and new_streaming.

* docs(changelog): add 1.48.1 entry for the Opus/Ogg seek crash fix

Also note the Opus seek crash fix in WHATS_NEW.md (1.48.1).

(cherry picked from commit 8bfde08199)
2026-06-17 01:30:23 +03:00

142 lines
6.7 KiB
Rust

//! HTTP-backed and file-backed `MediaSource` implementations plus their
//! background download tasks.
//!
//! Submodule layout:
//! - `icy` — Shoutcast/Icecast inline-metadata state machine
//! - `reader` — `AudioStreamReader` (ringbuf → `std::io::Read` shim)
//! - `local_file` — `LocalFileSource` (file-backed, seekable)
//! - `ranged_http` — `RangedHttpSource` (seekable HTTP) + `ranged_download_task`
//! - `radio` — radio session state + `radio_download_task`
//! - `track_stream` — `track_download_task` (one-shot non-ranged HTTP)
mod icy;
mod local_file;
mod mp4;
mod radio;
mod ranged_http;
mod reader;
mod track_stream;
pub(crate) use mp4::{
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
};
/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis,
/// Opus, Speex, FLAC-in-Ogg).
///
/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at
/// construction time, but only when the source reports `is_seekable()` *during
/// the probe*. If seekability is hidden then (see `ProbeSeekGate`),
/// `phys_byte_range_end` stays `None` and the first real seek panics with
/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply
/// seek to EOF must therefore stay seekable through the probe for Ogg.
pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool {
let Some(h) = hint else { return false };
matches!(
h.to_ascii_lowercase().as_str(),
"ogg" | "oga" | "ogx" | "opus" | "spx"
)
}
pub(crate) use local_file::LocalFileSource;
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
pub(crate) use reader::AudioStreamReader;
pub(crate) use track_stream::track_download_task;
// ── Shared tuning constants ──────────────────────────────────────────────────
/// 256 KB on the heap — ≈16 s at 128 kbps, ≈6 s at 320 kbps.
/// Small enough that stale audio drains within a few seconds on reconnect;
/// large enough to absorb brief network hiccups without stuttering.
pub(crate) const RADIO_BUF_CAPACITY: usize = 256 * 1024;
/// Minimum ring buffer for on-demand track streaming starts.
pub(crate) const TRACK_STREAM_MIN_BUF_CAPACITY: usize = 1024 * 1024;
/// Cap ring buffer growth when content-length is known.
pub(crate) const TRACK_STREAM_MAX_BUF_CAPACITY: usize = 32 * 1024 * 1024;
/// Max bytes kept in RAM (`stream_completed_cache`) for fast replay; larger completed
/// ranged streams are spilled under app-data `stream-spill/` for hot-cache promote.
pub(crate) const TRACK_STREAM_PROMOTE_MAX_BYTES: usize = 64 * 1024 * 1024;
/// Hot/offline `psysonic-local://` files are read from disk for waveform/LUFS seeding — not the
/// same heap pressure as retaining a full HTTP capture. FLAC/DSD tracks often exceed 64 MiB;
/// using the stream-promote cap here skipped analysis entirely (empty seekbar).
pub(crate) const LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES: usize = 512 * 1024 * 1024;
/// Consecutive body-stream failures tolerated for track streaming before abort.
pub(crate) const TRACK_STREAM_MAX_RECONNECTS: u32 = 3;
/// Seconds at stall threshold while paused before hard-disconnect.
pub(crate) const RADIO_HARD_PAUSE_SECS: u64 = 5;
/// Live radio: if no audio bytes arrive for this long → EOF.
pub(crate) const RADIO_READ_TIMEOUT_SECS: u64 = 15;
/// On-demand tracks (`track-stream`, `RangedHttpSource`): allow long gaps while a
/// large file is still downloading (format probe may read/seek ahead of the filler).
pub(crate) const TRACK_READ_TIMEOUT_SECS: u64 = 120;
/// HTTP track paths (`AudioStreamReader`, `RangedHttpSource`): minimum linear
/// download before audible playback and seekbar progress (demux probe may read
/// far ahead of the play cursor).
pub(crate) const TRACK_STREAM_PLAY_START_BYTES: u64 = 384 * 1024;
/// Arm deferred playback / progress once enough of the file is buffered.
pub(crate) fn maybe_arm_stream_playback(downloaded: u64, playback_armed: &std::sync::atomic::AtomicBool) {
use std::sync::atomic::Ordering;
if !playback_armed.load(Ordering::Relaxed) && downloaded >= TRACK_STREAM_PLAY_START_BYTES {
playback_armed.store(true, Ordering::SeqCst);
crate::app_deprintln!(
"[stream] playback armed after {} KiB buffered",
downloaded / 1024
);
}
}
/// Held until `RangedHttpSource` has moov metadata for Symphonia probe (tail prefetch
/// or fast-start moov in the linear prefix).
pub(crate) struct RangedMp4ProbeGate {
pub(crate) tail_ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub(crate) buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
pub(crate) downloaded_to: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub(crate) gen_arc: std::sync::Arc<std::sync::atomic::AtomicU64>,
pub(crate) gen: u64,
pub(crate) format_hint: Option<String>,
}
/// Block until moov is reachable: tail prefetch completed or moov already in the
/// downloaded prefix (fast-start). Avoids Symphonia probing moov-at-end M4A before
/// the tail range is filled (format probe failed: end of stream).
pub(crate) async fn wait_for_ranged_mp4_probe_ready(gate: &RangedMp4ProbeGate) -> Result<(), String> {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
const PREFIX_SCAN_MIN: usize = 64 * 1024;
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
loop {
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
return Err("ranged-stream: superseded before moov metadata ready".into());
}
if gate.tail_ready.load(Ordering::Relaxed) {
crate::app_deprintln!("[stream] ranged: moov metadata ready (tail prefetch)");
return Ok(());
}
let dl = gate.downloaded_to.load(Ordering::Relaxed);
if dl >= PREFIX_SCAN_MIN {
let guard = gate.buf.lock().unwrap();
let n = dl.min(guard.len());
if !mp4::mp4_needs_tail_prefetch(&guard[..n], gate.format_hint.as_deref()) {
crate::app_deprintln!(
"[stream] ranged: moov metadata ready (fast-start, {} KiB prefix)",
n / 1024
);
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(
"ranged-stream: timed out waiting for moov metadata (tail prefetch)".into(),
);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
/// Sleep interval when ring buffer is empty (prevents CPU spin).
pub(crate) const RADIO_YIELD_MS: u64 = 2;