From e58ad3788f30e16502d5d6978db1caa78e1c3cb7 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 17:50:45 +0200 Subject: [PATCH 1/2] fix(player): play the end of a track out of the ring before the next one starts The decoder runs a ring (1 s at 48 kHz stereo) ahead of the output. A natural end was declared at the decoder's EOF and the advance's LoadAndPlay drained what was still queued, cutting the last second of every track; the gapless and crossfade triggers read the heard clock, so the gapless swap never ran. The triggers now read the decoder's position and both play loops wait for the ring to play out before declaring the end. Refs #932 --- docs/features/playback.md | 7 + src-tauri/crates/app/src/audio/decoder.rs | 197 +++++++++++++++++++++- src-tauri/crates/app/src/audio/state.rs | 66 ++++++++ 3 files changed, 266 insertions(+), 4 deletions(-) diff --git a/docs/features/playback.md b/docs/features/playback.md index 58b01cbd..6473c998 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -33,6 +33,13 @@ Real dual-decoder mix in [`decoder.rs`](../../src-tauri/crates/app/src/audio/dec **The position clock at the hand-off.** The new track is already playing under the fade, and the end of the mix is still queued in the ring when the decoder swaps streams. The swap therefore restarts the clock at the track time the fade covered, with the queued samples as a lead-in ([`SharedPlayback::restart_clock`](../../src-tauri/crates/app/src/audio/state.rs)): the position reads fade − queue at the swap and reaches the fade length once the output has played the queue. The gapless swap does the same with a base of 0, since what is queued there is the finished track's tail. Restarting at 0 used to leave the seek bar, the lyrics, the resume point and every external surface a fade behind the audio (≈ 4.7 s with a 5 s fade) until the next seek. +**The end of a track is timed on the decoder, and played out before it is declared** (#932). The decoder runs up to a ring ahead of the output: `RING_CAPACITY` is 96 000 samples, so 1 s at 48 kHz stereo, 0.27 s on an 8-channel mixer at 44.1 kHz. Two things went wrong because of it, and together they cut the last second of every track that was followed by another. + +- **The triggers read the heard position**, a ring behind what the decoder had reached. Gapless asked for the next track at 500 ms before the end _as heard_, which the decoder reached only after its EOF: the swap never ran. A crossfade started a ring late and was a ring shorter, and one shorter than the ring never started at all. Both now read [`SharedPlayback::decoded_position_ms`](../../src-tauri/crates/app/src/audio/state.rs): the heard position plus what is queued. +- **A natural end was declared at the decoder's EOF**, with the end of the track still queued. The advance that followed sent `LoadAndPlay`, whose drain is right for a skip and threw that audio away here. Now `play_track` and `play_dop_track` wait at EOF until the output has played the ring out ([`TailDrain`](../../src-tauri/crates/app/src/audio/decoder.rs)) before emitting `player:track-ended`. The top of the loop keeps serving commands (a pause holds the wait, a seek or another track ends it), and a gapless next track that arrives meanwhile still takes over. The wait gives up when the ring has not moved for 1.5 s while playing, so a lost device cannot hold an end forever. This also puts the sleep timer's end of track and `listened_ms` on the end that was heard. + +Measured on a file queue, which always goes through the natural end: seeking 4 s before the end of a 139.2 s FLAC, the position used to stop at 138.9 s with 3.82 s heard, and it now reaches 139.15 s with 4.09 s heard. That machine's 8-channel output gives a 0.27 s ring, the reporter's stereo one a 1 s ring. + ### Smart crossfade (album-aware skip) A separate `SharedPlayback::smart_crossfade_enabled` toggle (default OFF — opt-in because it's an opinionated behaviour change, persisted in `profile_setting['audio.smart_crossfade']`) suppresses the fade for two consecutive tracks belonging to the same album — concept records / live sets hand off naturally instead of getting smeared. Mechanism: diff --git a/src-tauri/crates/app/src/audio/decoder.rs b/src-tauri/crates/app/src/audio/decoder.rs index 6c6fbdfa..d842f921 100644 --- a/src-tauri/crates/app/src/audio/decoder.rs +++ b/src-tauri/crates/app/src/audio/decoder.rs @@ -1361,6 +1361,10 @@ fn play_dop_track( let mut ring_scratch: Vec = Vec::with_capacity(16 * 1024); let mut last_position_emit = Instant::now(); let mut ended_naturally = false; + // The file is read to its end and its last words are being played out + // of the ring (#932), as in [`play_track`]. + let mut dop_at_eof = false; + let mut tail_drain: Option = None; transition_state(shared, app, PlayerState::Playing, Some(stream.track_id)); @@ -1391,6 +1395,8 @@ fn play_dop_track( stream.reset_decoder(); reset_clock(shared, ms); drain_ring_silent(producer, shared); + dop_at_eof = false; + tail_drain = None; let _ = app.emit(EVENT_POSITION, PositionPayload { ms }); last_position_emit = Instant::now(); continue; @@ -1409,17 +1415,46 @@ fn play_dop_track( stream.reset_decoder(); reset_clock(shared, a_ms); drain_ring_silent(producer, shared); + dop_at_eof = false; + tail_drain = None; let _ = app.emit(EVENT_POSITION, PositionPayload { ms: a_ms }); last_position_emit = Instant::now(); continue; } } + // The end reached: the track ends once the output has played what + // the ring still holds of it, not as soon as the file is read. + if dop_at_eof { + if TailDrain::still_playing( + &mut tail_drain, + queued_samples(producer), + shared.paused_output.load(Ordering::Relaxed), + Instant::now(), + ) { + if last_position_emit.elapsed() >= POSITION_EMIT_INTERVAL + && shared.state() == PlayerState::Playing + { + let _ = app.emit( + EVENT_POSITION, + PositionPayload { + ms: shared.current_position_ms(), + }, + ); + last_position_emit = Instant::now(); + } + std::thread::sleep(TAIL_POLL); + continue; + } + ended_naturally = true; + break 'pkt; + } + dop_words.clear(); match stream.decode_dop_block(&mut dop_words) { Ok(true) => { - ended_naturally = true; - break 'pkt; + dop_at_eof = true; + continue; } Ok(false) => {} Err(err) => { @@ -1464,6 +1499,8 @@ fn play_dop_track( stream.reset_decoder(); reset_clock(shared, ms); drain_ring_silent(producer, shared); + dop_at_eof = false; + tail_drain = None; let _ = app.emit(EVENT_POSITION, PositionPayload { ms }); last_position_emit = Instant::now(); continue; @@ -1583,6 +1620,8 @@ fn play_track( transition_state(shared, app, PlayerState::Playing, Some(stream.track_id)); let mut ended_naturally = false; + // The end of the track being played out of the ring (#932). + let mut tail_drain: Option = None; 'pkt: loop { match drain_commands( @@ -1708,7 +1747,11 @@ fn play_track( // track, so a 30 s clip with crossfade=12 s doesn't // start mixing at the 18 s mark. let effective_ms = cf_ms.min(stream.duration_ms / 2); - let pos = shared.current_position_ms(); + // Timed on what the decoder has reached, not on what was + // heard (#932): the mix written now is heard a ring later, + // so on the heard clock the fade started that much late + // and a fade shorter than the ring never started at all. + let pos = shared.decoded_position_ms(queued_samples(producer)); let remaining = stream.duration_ms.saturating_sub(pos); // Pre-fetch ~500 ms before the window starts so the @@ -1743,7 +1786,12 @@ fn play_track( // happens in the EOF branch below — no fade, no overlap, // just an immediate baton hand-off the moment the // primary buffer drains. - let pos = shared.current_position_ms(); + // + // On the decoder's clock (#932). On the heard one, a ring + // behind, 500 ms before the end was reached only after the + // decoder had hit EOF: the hand-off never ran, and the + // advance that replaced it cut the last second. + let pos = shared.decoded_position_ms(queued_samples(producer)); let remaining = stream.duration_ms.saturating_sub(pos); if !next_requested && pending_next.is_none() && remaining <= 500 { let _ = analytics_tx.send(AnalyticsMsg::PrefetchNext); @@ -2028,9 +2076,39 @@ fn play_track( last_position_emit = Instant::now(); continue; } + // A natural end, and the ring still holds up to a second + // of the track (#932). Declared now, the end let the + // advance that follows drain it unheard: every track lost + // its last second. Wait for the output to play it. The top + // of the loop keeps serving commands meanwhile (a pause, a + // seek, another track) and the gapless trigger, whose next + // track can still arrive and take over in the swap above. + if TailDrain::still_playing( + &mut tail_drain, + queued_samples(producer), + shared.paused_output.load(Ordering::Relaxed), + Instant::now(), + ) { + if last_position_emit.elapsed() >= POSITION_EMIT_INTERVAL + && shared.state() == PlayerState::Playing + { + let _ = app.emit( + EVENT_POSITION, + PositionPayload { + ms: shared.current_position_ms(), + }, + ); + last_position_emit = Instant::now(); + } + std::thread::sleep(TAIL_POLL); + continue; + } ended_naturally = true; break 'pkt; } + // Producing audio again: whatever end was being waited for + // (before a seek) is no longer the one in the ring. + tail_drain = None; eq_processor.process( &mut primary_resampled, dst_channels, @@ -2222,6 +2300,48 @@ fn queued_samples(producer: &Producer) -> u64 { (producer.buffer().capacity() - producer.slots()) as u64 } +/// How often the end of a track is checked while the output plays it out. +const TAIL_POLL: Duration = Duration::from_millis(10); + +/// How long the end of a track may sit in the ring with the output taking +/// none of it, while not paused, before the track is declared over anyway: +/// a device that went away must not hold an end forever. +const TAIL_STALL_LIMIT: Duration = Duration::from_millis(1500); + +/// The end of a track, decoded and still in the ring, being played out +/// before the end is declared (#932): what was queued at the last sign of +/// progress, and when that was. +#[derive(Debug)] +struct TailDrain { + queued: u64, + progressed_at: Instant, +} + +impl TailDrain { + /// Whether the end of the track is still being played: something is + /// queued, and the output is taking it or is paused. False once the + /// ring is empty, or when it has not moved for [`TAIL_STALL_LIMIT`] + /// while playing. + fn still_playing(slot: &mut Option, queued: u64, paused: bool, now: Instant) -> bool { + if queued == 0 { + *slot = None; + return false; + } + match slot { + Some(drain) if queued >= drain.queued && !paused => { + now.duration_since(drain.progressed_at) < TAIL_STALL_LIMIT + } + _ => { + *slot = Some(Self { + queued, + progressed_at: now, + }); + true + } + } + } +} + enum ControlFlow { Continue, Break, @@ -2742,6 +2862,75 @@ mod crossfade_mix_tests { } } +#[cfg(test)] +mod tail_drain_tests { + use std::time::{Duration, Instant}; + + use super::{TailDrain, TAIL_STALL_LIMIT}; + + #[test] + fn an_empty_ring_ends_the_track() { + let mut slot = None; + assert!(!TailDrain::still_playing( + &mut slot, + 0, + false, + Instant::now() + )); + assert!(slot.is_none()); + } + + #[test] + fn the_end_plays_out_while_the_output_takes_it() { + let start = Instant::now(); + let mut slot = None; + assert!(TailDrain::still_playing(&mut slot, 96_000, false, start)); + // Progress, however long it takes in all: still the end playing. + let later = start + TAIL_STALL_LIMIT * 2; + assert!(TailDrain::still_playing(&mut slot, 40_000, false, later)); + assert!(TailDrain::still_playing( + &mut slot, + 1_000, + false, + later + TAIL_STALL_LIMIT / 2 + )); + assert!(!TailDrain::still_playing( + &mut slot, + 0, + false, + later + TAIL_STALL_LIMIT + )); + } + + #[test] + fn a_pause_holds_the_end_and_a_stall_gives_it_up() { + let start = Instant::now(); + let mut slot = None; + assert!(TailDrain::still_playing(&mut slot, 50_000, false, start)); + // Paused for a minute: the end waits for the user. + assert!(TailDrain::still_playing( + &mut slot, + 50_000, + true, + start + Duration::from_secs(60) + )); + // Playing again, and the output takes nothing: a lost device. + let resumed = start + Duration::from_secs(60); + assert!(TailDrain::still_playing( + &mut slot, + 50_000, + false, + resumed + TAIL_STALL_LIMIT / 2 + )); + assert!(!TailDrain::still_playing( + &mut slot, + 50_000, + false, + resumed + TAIL_STALL_LIMIT + )); + } +} + #[cfg(test)] mod load_ordering_tests { use std::path::PathBuf; diff --git a/src-tauri/crates/app/src/audio/state.rs b/src-tauri/crates/app/src/audio/state.rs index e623107c..efddd57f 100644 --- a/src-tauri/crates/app/src/audio/state.rs +++ b/src-tauri/crates/app/src/audio/state.rs @@ -651,6 +651,29 @@ impl SharedPlayback { (base + track_delta_ms).max(0) as u64 } + /// Where the decoder is in the track: the position heard, plus the + /// `queued_samples` written to the ring ahead of it and not played yet. + /// The decoder runs up to a ring (about a second) ahead of the output, + /// so what is timed on the end of what it decodes (the gapless + /// prefetch, the crossfade) reads this, not [`Self::current_position_ms`], + /// which is a ring late (#932). + /// + /// Counted from the raw counters, not added to the heard position: + /// that one stops at 0 while the lead-in plays, so right after a gapless + /// swap, with the finished track's tail still queued, adding the queue + /// to it counted that tail as the new track's. + pub fn decoded_position_ms(&self, queued_samples: u64) -> u64 { + let sr = self.sample_rate.load(Ordering::Relaxed).max(1) as i64; + let ch = self.channels.load(Ordering::Relaxed).max(1) as i64; + let played = self.samples_played.load(Ordering::Relaxed) as i64; + let lead_in = self.clock_lead_in.load(Ordering::Relaxed) as i64; + let written = played + queued_samples as i64; + let wall_delta_ms = ((written - lead_in) * 1000) / (sr * ch); + let track_delta_ms = (wall_delta_ms as f32 * self.playback_speed()) as i64; + let base = self.base_offset_ms.load(Ordering::Relaxed) as i64; + (base + track_delta_ms).max(0) as u64 + } + /// Restart the position clock at `base_ms`, with `lead_in_samples` /// still queued in the ring ahead of that point. `lead_in_is_previous_track` /// says whose audio those samples are: the finished track's tail after @@ -846,6 +869,49 @@ mod tests { assert_eq!(s.session_listened_ms(), 1_000); } + #[test] + fn the_decoder_is_a_ring_ahead_of_what_was_heard() { + // 44.1 kHz stereo: 1 s heard, 1 s more queued in the ring. The + // decoder has reached 2 s; the triggers timed on the end of what + // it decodes must read that, not the 1 s heard (#932). + let s = SharedPlayback::new(); + s.sample_rate.store(44_100, Ordering::Relaxed); + s.channels.store(2, Ordering::Relaxed); + s.samples_played.store(44_100 * 2, Ordering::Relaxed); + assert_eq!(s.current_position_ms(), 1_000); + assert_eq!(s.decoded_position_ms(44_100 * 2), 2_000); + assert_eq!(s.decoded_position_ms(0), 1_000); + + // At double speed a second of output holds two of the track. + s.set_playback_speed(2.0); + assert_eq!( + s.decoded_position_ms(44_100 * 2) - s.current_position_ms(), + 2_000 + ); + } + + #[test] + fn the_finished_tracks_tail_is_not_the_new_tracks_after_a_gapless_swap() { + // A gapless swap with 1 s of the finished track still queued: the + // new track has decoded nothing yet, so the decoder is at 0, not at + // the 1 s the queue holds. + let s = SharedPlayback::new(); + s.sample_rate.store(44_100, Ordering::Relaxed); + s.channels.store(2, Ordering::Relaxed); + let tail = 44_100 * 2; + s.restart_clock(0, tail, true); + assert_eq!(s.decoded_position_ms(tail), 0); + + // Half a second of the new track written behind the tail. + assert_eq!(s.decoded_position_ms(tail + 22_050 * 2), 500); + + // The output played the tail: still 500 ms decoded, now all of it + // the new track's, and none of it heard yet. + s.samples_played.store(tail, Ordering::Relaxed); + assert_eq!(s.decoded_position_ms(22_050 * 2), 500); + assert_eq!(s.current_position_ms(), 0); + } + #[test] fn crossfade_restart_counts_the_fade_and_not_the_queued_tail() { // A 5 s crossfade ends with 0.25 s of the mix still in the ring. From 59d2718aed43d3302ae50a0e72ab6c7c5c78b37a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 18:16:03 +0200 Subject: [PATCH 2/2] fix(player): keep the end of a track through a pause and an a-b jump A pause blocks the decoder in drain_commands, so the tail wait counted the paused time as a stall and cut the rest on resume; a gap between checks now restarts the stall clock. An A-B jump made while the end plays out cleared nothing and ended the track; it now resets the EOF state like a seek. --- docs/features/playback.md | 2 +- src-tauri/crates/app/src/audio/decoder.rs | 90 ++++++++++++++++++----- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/docs/features/playback.md b/docs/features/playback.md index 6473c998..9d213eba 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -36,7 +36,7 @@ Real dual-decoder mix in [`decoder.rs`](../../src-tauri/crates/app/src/audio/dec **The end of a track is timed on the decoder, and played out before it is declared** (#932). The decoder runs up to a ring ahead of the output: `RING_CAPACITY` is 96 000 samples, so 1 s at 48 kHz stereo, 0.27 s on an 8-channel mixer at 44.1 kHz. Two things went wrong because of it, and together they cut the last second of every track that was followed by another. - **The triggers read the heard position**, a ring behind what the decoder had reached. Gapless asked for the next track at 500 ms before the end _as heard_, which the decoder reached only after its EOF: the swap never ran. A crossfade started a ring late and was a ring shorter, and one shorter than the ring never started at all. Both now read [`SharedPlayback::decoded_position_ms`](../../src-tauri/crates/app/src/audio/state.rs): the heard position plus what is queued. -- **A natural end was declared at the decoder's EOF**, with the end of the track still queued. The advance that followed sent `LoadAndPlay`, whose drain is right for a skip and threw that audio away here. Now `play_track` and `play_dop_track` wait at EOF until the output has played the ring out ([`TailDrain`](../../src-tauri/crates/app/src/audio/decoder.rs)) before emitting `player:track-ended`. The top of the loop keeps serving commands (a pause holds the wait, a seek or another track ends it), and a gapless next track that arrives meanwhile still takes over. The wait gives up when the ring has not moved for 1.5 s while playing, so a lost device cannot hold an end forever. This also puts the sleep timer's end of track and `listened_ms` on the end that was heard. +- **A natural end was declared at the decoder's EOF**, with the end of the track still queued. The advance that followed sent `LoadAndPlay`, whose drain is right for a skip and threw that audio away here. Now `play_track` and `play_dop_track` wait at EOF until the output has played the ring out ([`TailDrain`](../../src-tauri/crates/app/src/audio/decoder.rs)) before emitting `player:track-ended`. The top of the loop keeps serving commands (a pause holds the wait, a seek or another track ends it), and a gapless next track that arrives meanwhile still takes over. The wait gives up when the ring has not moved for 1.5 s of watching it play, so a lost device cannot hold an end forever. A pause blocks the loop in `drain_commands` until Resume, so a gap between two checks longer than 250 ms restarts that clock rather than counting as a stall: otherwise a pause near the end would cut the rest on resume. An A-B jump made while the end plays out clears the EOF flag like a seek does, so the loop goes back to A instead of ending the track. This also puts the sleep timer's end of track and `listened_ms` on the end that was heard. Measured on a file queue, which always goes through the natural end: seeking 4 s before the end of a 139.2 s FLAC, the position used to stop at 138.9 s with 3.82 s heard, and it now reaches 139.15 s with 4.09 s heard. That machine's 8-channel output gives a 0.27 s ring, the reporter's stereo one a 1 s ring. diff --git a/src-tauri/crates/app/src/audio/decoder.rs b/src-tauri/crates/app/src/audio/decoder.rs index d842f921..71f6112b 100644 --- a/src-tauri/crates/app/src/audio/decoder.rs +++ b/src-tauri/crates/app/src/audio/decoder.rs @@ -1705,6 +1705,13 @@ fn play_track( if let Some((a_ms, b_ms)) = shared.ab_loop_armed() { let pos = shared.current_position_ms(); if pos >= b_ms { + // Cleared like the two seek arms: B can be reached + // while the end of the track plays out, with the file + // already at EOF, and keeping that flag ended the + // track instead of looping it (#932). + primary_resampled.clear(); + primary_at_eof = false; + tail_drain = None; stream.seek_ms(a_ms); reset_clock(shared, a_ms); spectrum_analyzer.discard_pending(); @@ -2308,33 +2315,45 @@ const TAIL_POLL: Duration = Duration::from_millis(10); /// a device that went away must not hold an end forever. const TAIL_STALL_LIMIT: Duration = Duration::from_millis(1500); +/// A gap between two checks longer than this means the loop was elsewhere +/// (blocked in `drain_commands` by a pause until Resume, or a slow command), +/// not watching an output that took nothing: the stall clock starts over. +const TAIL_MAX_POLL_GAP: Duration = Duration::from_millis(250); + /// The end of a track, decoded and still in the ring, being played out /// before the end is declared (#932): what was queued at the last sign of -/// progress, and when that was. +/// progress, when that was, and when the ring was last looked at. #[derive(Debug)] struct TailDrain { queued: u64, progressed_at: Instant, + polled_at: Instant, } impl TailDrain { /// Whether the end of the track is still being played: something is /// queued, and the output is taking it or is paused. False once the /// ring is empty, or when it has not moved for [`TAIL_STALL_LIMIT`] - /// while playing. + /// of watching it play. fn still_playing(slot: &mut Option, queued: u64, paused: bool, now: Instant) -> bool { if queued == 0 { *slot = None; return false; } match slot { - Some(drain) if queued >= drain.queued && !paused => { + Some(drain) + if queued >= drain.queued + && !paused + && now.duration_since(drain.polled_at) <= TAIL_MAX_POLL_GAP => + { + drain.polled_at = now; now.duration_since(drain.progressed_at) < TAIL_STALL_LIMIT } _ => { *slot = Some(Self { queued, progressed_at: now, + polled_at: now, }); true } @@ -2866,7 +2885,19 @@ mod crossfade_mix_tests { mod tail_drain_tests { use std::time::{Duration, Instant}; - use super::{TailDrain, TAIL_STALL_LIMIT}; + use super::{TailDrain, TAIL_POLL, TAIL_STALL_LIMIT}; + + /// Poll every [`TAIL_POLL`] with nothing taken from the ring, from + /// `from` for `watched`; the answer at the last poll. + fn watch_stalled(slot: &mut Option, from: Instant, watched: Duration) -> bool { + let mut t = from; + let mut playing = true; + while t.duration_since(from) < watched { + t += TAIL_POLL; + playing = TailDrain::still_playing(slot, 50_000, false, t); + } + playing + } #[test] fn an_empty_ring_ends_the_track() { @@ -2903,30 +2934,51 @@ mod tail_drain_tests { } #[test] - fn a_pause_holds_the_end_and_a_stall_gives_it_up() { + fn a_stall_watched_for_the_limit_gives_the_end_up() { + // Playing, and the output takes nothing: a lost device. + let start = Instant::now(); + let mut slot = None; + assert!(TailDrain::still_playing(&mut slot, 50_000, false, start)); + assert!(watch_stalled(&mut slot, start, TAIL_STALL_LIMIT / 2)); + let mut slot = None; + assert!(TailDrain::still_playing(&mut slot, 50_000, false, start)); + assert!(!watch_stalled(&mut slot, start, TAIL_STALL_LIMIT)); + } + + #[test] + fn a_pause_holds_the_end() { + let start = Instant::now(); + let mut slot = None; + assert!(TailDrain::still_playing(&mut slot, 50_000, false, start)); + // Polled while paused: the end waits for the user, however long. + let mut t = start; + for _ in 0..1_000 { + t += TAIL_POLL * 10; + assert!(TailDrain::still_playing(&mut slot, 50_000, true, t)); + } + } + + #[test] + fn time_away_from_the_loop_is_not_a_stall() { + // A pause blocks the decoder in `drain_commands` until Resume: the + // ring is not looked at meanwhile, and that minute is not the + // output failing to take samples. Resumed, the end plays on. let start = Instant::now(); let mut slot = None; assert!(TailDrain::still_playing(&mut slot, 50_000, false, start)); - // Paused for a minute: the end waits for the user. - assert!(TailDrain::still_playing( - &mut slot, - 50_000, - true, - start + Duration::from_secs(60) - )); - // Playing again, and the output takes nothing: a lost device. let resumed = start + Duration::from_secs(60); + assert!(TailDrain::still_playing(&mut slot, 50_000, false, resumed)); assert!(TailDrain::still_playing( &mut slot, - 50_000, + 40_000, false, - resumed + TAIL_STALL_LIMIT / 2 + resumed + TAIL_POLL )); - assert!(!TailDrain::still_playing( + // And a real stall after it is still caught. + assert!(!watch_stalled( &mut slot, - 50_000, - false, - resumed + TAIL_STALL_LIMIT + resumed + TAIL_POLL, + TAIL_STALL_LIMIT )); } }