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:
parent
b6f1275d6b
commit
bc3d2e099e
|
|
@ -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> {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue