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).
This commit is contained in:
cucadmuh
2026-06-16 16:26:37 +03:00
committed by GitHub
parent 49f9f03e5e
commit 8bfde08199
9 changed files with 103 additions and 23 deletions
+7
View File
@@ -22,6 +22,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* On macOS, pausing or stopping playback and then disconnecting headphones (or otherwise switching the audio output device) could make playback restart on the newly selected device. Playback now reliably stays paused or stopped across a device change.
### Crash when seeking Opus/Ogg files
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1100](https://github.com/Psychotoxical/psysonic/pull/1100)**
* Scrubbing the seekbar on an Opus/Ogg file — and then pressing Stop — crashed the whole app (a 1.48 regression from the Symphonia 0.6 migration). The Ogg demuxer recorded its seek bounds only when the source was seekable during the format probe, but probing hid seekability, so the first seek panicked on the audio thread (`Option::unwrap()` on `None`) and took the process down at the audio backend boundary.
* Local and in-memory Opus/Ogg sources now stay seekable through the probe, so seeking works correctly. As a safety net, any decoder panic during a seek is contained instead of crashing the app; for Opus/Ogg streamed over HTTP, seeking is a no-op for now rather than a crash.
## [1.48.0]
+6
View File
@@ -7,6 +7,12 @@ current line before promoting to `next` / `release`. Technical details and PR cr
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
## [1.48.1]
## Fixed
- Fixed an issue with Opus playback where seeking the seekbar could crash the app.
## [1.48.0]
## Highlights
+51 -13
View File
@@ -169,8 +169,14 @@ impl SizedDecoder {
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
//
// Exception: Ogg (Vorbis/Opus/…) must stay seekable through the probe,
// otherwise its demuxer never records `phys_byte_range_end` and the first
// seek panics (see `container_hint_is_ogg`). This source is fully
// in-memory, so the trailing-metadata scan it re-enables is free.
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
&& !crate::stream::container_hint_is_ogg(format_hint);
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
@@ -315,19 +321,33 @@ impl SizedDecoder {
/// 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.
/// `source_random_access`: the underlying source can cheaply seek to EOF
/// (e.g. a local file), so the probe-time trailing-metadata / stream-end scan
/// is not a full download. Progressive sources (ranged HTTP) pass `false`.
pub(crate) fn new_streaming(
media: Box<dyn MediaSource>,
format_hint: Option<&str>,
source_tag: &str,
source_random_access: bool,
) -> 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).
//
// Ogg also keeps seekability through the probe, but only on random-access
// sources: its demuxer records `phys_byte_range_end` during the probe and
// panics on the first seek otherwise (see `container_hint_is_ogg`). On a
// local file the stream-end scan is cheap; on a progressive ranged stream
// it would force a full download, so there we keep the gate and accept
// that seeking is a no-op (the panic itself is contained in `try_seek`).
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 ogg_needs_seekable_probe =
source_random_access && crate::stream::container_hint_is_ogg(format_hint);
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
&& !ogg_needs_seekable_probe;
let probe_seek_gate = gate_needed.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,
@@ -588,21 +608,37 @@ impl Source for SizedDecoder {
let to_skip = self.current_frame_offset % self.channels().get() as usize;
// symphonia 0.6's OGG demuxer can `panic!` (e.g. `Option::unwrap()` on
// `None` in `OggReader::do_seek`) on some streams instead of returning
// an `Err`. `try_seek` runs on rodio's cpal output thread, so an escaping
// panic poisons the engine mutexes and then aborts the whole process at
// the non-unwinding cpal FFI boundary (the "crash on Stop" is a downstream
// symptom of that poison). Contain the unwind here — including the packet
// reads in `refine_position`, which can hit the same broken demuxer state —
// and surface it as a recoverable `SeekError` so the engine stays alive
// (the seek becomes a no-op rather than killing playback).
let seek_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
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))
))?;
.map_err(|e| e.to_string())?;
self.refine_position(seek_res)?;
Ok::<(), String>(())
}));
match seek_outcome {
Ok(Ok(())) => {
self.current_frame_offset += to_skip;
Ok(())
}
Ok(Err(e)) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
std::io::Error::other(e),
))),
Err(_panic) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
std::io::Error::other("seek panicked inside the demuxer (contained)"),
))),
}
}
}
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
@@ -1019,7 +1055,8 @@ mod tests {
#[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")
let decoder =
SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream", true)
.expect("streaming WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
@@ -1033,6 +1070,7 @@ mod tests {
seekable_source(vec![0x00u8; 64]),
None,
"test-stream",
true,
);
assert!(result.is_err());
}
@@ -87,6 +87,7 @@ pub(crate) async fn try_resume_after_device_change(
reader: Box::new(LocalFileSource { file, len }),
format_hint: url_format_hint(url),
tag: "LocalFile[device-resume]",
random_access: true,
mp4_probe_gate: None,
}
}
@@ -40,6 +40,9 @@ pub(crate) enum PlayInput {
reader: Box<dyn MediaSource>,
format_hint: Option<String>,
tag: &'static str,
/// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps
/// seekability through the probe so its seek path does not panic.
random_access: bool,
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
},
@@ -201,6 +204,7 @@ fn open_local_file_input(
reader: Box::new(reader),
format_hint: local_hint,
tag: "local-file",
random_access: true,
mp4_probe_gate: None,
})
}
@@ -345,6 +349,7 @@ async fn open_ranged_or_streaming_input(
reader: Box::new(reader),
format_hint: stream_hint,
tag: "ranged-stream",
random_access: false,
mp4_probe_gate,
}));
}
@@ -282,7 +282,7 @@ async fn open_preview_decoder(
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream", false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
@@ -124,7 +124,7 @@ pub async fn audio_play_radio(
let hint_clone = fmt_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false)
})
.await
.map_err(|e| e.to_string())??;
@@ -345,6 +345,7 @@ async fn build_source_from_play_input(
reader,
format_hint: media_hint,
tag,
random_access,
mp4_probe_gate,
} => {
if let Some(gate) = mp4_probe_gate.as_ref() {
@@ -354,7 +355,7 @@ async fn build_source_from_play_input(
}
}
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
})
.await
.map_err(|e| e.to_string())??;
@@ -375,7 +376,12 @@ async fn build_source_from_play_input(
PlayInput::Streaming { reader, format_hint: stream_hint } => {
is_seekable = false;
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
SizedDecoder::new_streaming(
Box::new(reader),
stream_hint.as_deref(),
"track-stream",
false,
)
})
.await
.map_err(|e| e.to_string())??;
@@ -21,6 +21,23 @@ 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};