Audio: open the next source before stopping the current one

The engine stopped the sink (instant silence) and only then opened the
new source, whose initial network prefetch blocks up to 30s -- so every
track change, and especially replacing the queue, left an audible gap
for the whole open. The old song was already gone while we fetched.

Open and decode the new source first, into a boxed rodio source, while
the current one keeps playing on the audio thread; only once it is ready
do we stop the sink and swap it in. The gap shrinks to the near-instant
sink swap. If the open fails the current track keeps playing and the
error propagates unchanged. This covers every transition -- Replace,
Next, and end-of-track re-plays.

`play` now splits into `open_source` (the slow, sink-free open/decode)
and `append_source` (the sink swap + generation-tagged EOS callback).
Generation is bumped once, in `reset`, and read after the reset, so a
swapped-out source still never signals a spurious Next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-23 19:30:37 +02:00
parent b6f1275d6b
commit bc3d2e099e
1 changed files with 33 additions and 19 deletions

View File

@ -206,11 +206,19 @@ impl PlayerEngine {
#[instrument(skip_all, fields(source = %display_source(source_str)))] #[instrument(skip_all, fields(source = %display_source(source_str)))]
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> { pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
// Make-before-break: open and decode the new source *before* stopping
// the current one. Opening a network stream blocks up to
// STREAM_OPEN_TIMEOUT, and the audio sink runs on its own thread, so
// the previous track stays audible throughout — the audible gap
// shrinks to the near-instant sink swap below. If the open fails, the
// current track keeps playing and the error propagates untouched. This
// covers every transition: Replace, Next, and end-of-track re-plays.
let (source, duration) = self.open_source(source_str)?;
self.reset(); self.reset();
self.append_source(source);
let duration = self.start_source(source_str)?;
let media_info = MediaInfo { duration }; let media_info = MediaInfo { duration };
self.media_info = Some(media_info.clone()); self.media_info = Some(media_info.clone());
self.current_source = Some(source_str.to_string()); self.current_source = Some(source_str.to_string());
@ -225,11 +233,13 @@ impl PlayerEngine {
Ok(media_info) Ok(media_info)
} }
/// Decodes the source and appends it (plus an end-of-stream callback) to /// Opens and decodes a source into a ready-to-play boxed rodio source,
/// the sink. Returns the total duration if known. /// touching neither the sink nor the generation counter. This is the slow,
fn start_source(&mut self, source_str: &str) -> Result<Option<Duration>> { /// network-bound step (the initial stream prefetch); keeping it off the
self.generation += 1; /// sink lets the currently-playing track continue while it runs. Returns
let duration = match Url::parse(source_str) { /// the decoded source and its total duration if known.
fn open_source(&self, source_str: &str) -> Result<(Box<dyn Source + Send>, Option<Duration>)> {
match Url::parse(source_str) {
Ok(url) if matches!(url.scheme(), "http" | "https") => { Ok(url) if matches!(url.scheme(), "http" | "https") => {
trace!( trace!(
host = url.host_str().unwrap_or("?"), host = url.host_str().unwrap_or("?"),
@ -269,11 +279,11 @@ impl PlayerEngine {
let duration = decoder.total_duration(); let duration = decoder.total_duration();
// Mirror the played audio into the spectrum tap (it only // Mirror the played audio into the spectrum tap (it only
// observes; playback is unaffected). // observes; playback is unaffected).
self.sink let source: Box<dyn Source + Send> =
.append(TappingSource::new(decoder, self.spectrum.clone())); Box::new(TappingSource::new(decoder, self.spectrum.clone()));
duration Ok((source, duration))
} }
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())), Ok(url) => Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
Err(_) => { Err(_) => {
trace!(path = source_str, "opening local file"); trace!(path = source_str, "opening local file");
let file = File::open(source_str) let file = File::open(source_str)
@ -291,14 +301,20 @@ impl PlayerEngine {
} }
let decoder = builder.build().context("failed to decode file")?; let decoder = builder.build().context("failed to decode file")?;
let duration = decoder.total_duration(); let duration = decoder.total_duration();
self.sink let source: Box<dyn Source + Send> =
.append(TappingSource::new(decoder, self.spectrum.clone())); Box::new(TappingSource::new(decoder, self.spectrum.clone()));
duration Ok((source, duration))
}
}
} }
};
// Fires only when the decoder ahead of it finished naturally; a /// Appends an already-decoded source to the freshly-reset sink, followed
// stop/replace clears the queue before this source is ever played. /// by an end-of-stream callback tagged with the current generation. The
/// callback fires only when this source finishes naturally; a later
/// stop/replace bumps the generation via `reset`, so a stale source that
/// was swapped out can never signal `Next`.
fn append_source(&mut self, source: Box<dyn Source + Send>) {
self.sink.append(source);
let tx_engine = self.tx_engine.clone(); let tx_engine = self.tx_engine.clone();
let generation = self.generation; let generation = self.generation;
self.sink.append(EmptyCallback::new(Box::new(move || { self.sink.append(EmptyCallback::new(Box::new(move || {
@ -306,8 +322,6 @@ impl PlayerEngine {
warn!("failed to send end-of-stream signal: {err}"); warn!("failed to send end-of-stream signal: {err}");
} }
}))); })));
Ok(duration)
} }
pub fn restart(&mut self) -> Result<MediaInfo> { pub fn restart(&mut self) -> Result<MediaInfo> {