forked from Keelrleek/SoulStream
1212 lines
48 KiB
Rust
1212 lines
48 KiB
Rust
//! Audio playback: rodio/symphonia on a dedicated audio thread, with a
|
||
//! growing-file source for play-while-downloading.
|
||
//!
|
||
//! The audio thread owns the output device and stays responsive: decoder
|
||
//! construction (which blocks until enough of the file exists) happens on
|
||
//! short-lived helper threads, tagged with a generation counter so stale
|
||
//! decodes are discarded when the user has already moved on.
|
||
|
||
mod loudness;
|
||
mod source;
|
||
pub mod vocals;
|
||
mod waveform;
|
||
|
||
pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter};
|
||
pub use source::{GrowingFile, StreamProgress, Terminal};
|
||
pub use waveform::{compute_waveform, compute_waveform_range};
|
||
|
||
use std::collections::VecDeque;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
use std::sync::mpsc;
|
||
use std::sync::Arc;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||
use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source};
|
||
|
||
|
||
/// Acknowledgement for [`Player::pause_acked`], sent from the audio thread
|
||
/// AFTER the pause has been applied. Semantics, stated honestly:
|
||
///
|
||
/// - `sink_was_active`: a live sink existed and `Sink::pause()` returned —
|
||
/// the control state is paused, so the mixer stops pulling samples; at
|
||
/// most one already-submitted device buffer can still drain (~ms).
|
||
/// - `latched`: no sink existed yet (decode in flight or idle). The
|
||
/// desired-paused latch is set, so a decoder completing for this same
|
||
/// generation starts PAUSED instead of auto-playing. `Resume` or a new
|
||
/// `Play` clears the latch.
|
||
/// - `generation`: the playback generation the acknowledgement is bound to.
|
||
/// A coordinator holding a snapshot from an older generation must treat
|
||
/// this ack as stale and re-negotiate.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct PauseAck {
|
||
pub generation: u64,
|
||
pub sink_was_active: bool,
|
||
pub latched: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum PlayerEvent {
|
||
/// Decoder is being set up (streaming: waiting for first bytes).
|
||
Buffering,
|
||
Playing,
|
||
Paused,
|
||
Stopped,
|
||
Position {
|
||
secs: f64,
|
||
duration: Option<f64>,
|
||
},
|
||
/// A track ended. `gapless` means the next decoder was already queued in
|
||
/// the audio sink and has begun without an output-device interruption.
|
||
TrackEnded {
|
||
gapless: bool,
|
||
},
|
||
Error(String),
|
||
}
|
||
|
||
enum Cmd {
|
||
Play {
|
||
path: PathBuf,
|
||
stream: Option<Arc<StreamProgress>>,
|
||
total_bytes: u64,
|
||
duration_hint: Option<f64>,
|
||
/// Start playback at this offset (seconds): the decoder comes up
|
||
/// already positioned (same mechanism as the device-change resume),
|
||
/// so there is no play-then-seek race and no audible blip at 0.
|
||
start_at_secs: Option<f64>,
|
||
},
|
||
/// Decode and append the next local track before the active one ends.
|
||
Preload {
|
||
path: PathBuf,
|
||
duration_hint: Option<f64>,
|
||
},
|
||
Pause,
|
||
/// Pause with an acknowledgement bound to the playback generation — the
|
||
/// audio-focus primitive (Phase 0.4). Unlike `Pause`, this also LATCHES:
|
||
/// a decoder still in flight for the current generation will come up
|
||
/// paused instead of auto-starting, which plain `Pause` silently allows
|
||
/// today (with no sink yet, `Pause` is a no-op and the track then plays).
|
||
PauseAcked {
|
||
reply: mpsc::Sender<PauseAck>,
|
||
},
|
||
Resume,
|
||
Seek(f64),
|
||
Scratch {
|
||
secs: f64,
|
||
rate: f32,
|
||
},
|
||
EndScratch {
|
||
secs: f64,
|
||
},
|
||
Volume(f32),
|
||
/// Loudness-normalization gain in dB, layered UNDER the user volume
|
||
/// (effective sink volume = user volume × 10^(dB/20)). `immediate`
|
||
/// jumps (pre-playback, from the loudness cache); otherwise the audio
|
||
/// thread slews toward it so a mid-track refinement is a gentle drift,
|
||
/// never a step.
|
||
SetNormGainDb {
|
||
db: f32,
|
||
immediate: bool,
|
||
},
|
||
/// Normalization gain for the NEXT gapless-queued track, applied
|
||
/// sample-accurately when the boundary passes — a track change is
|
||
/// exactly where loudness may step. Cleared by Play/Stop; an unknown
|
||
/// next track falls back to unity at the boundary.
|
||
SetNextNormGainDb(f32),
|
||
/// Transient fade multiplier (0..1) for the preview open/close crossfade,
|
||
/// SEPARATE from the user volume so a fade can never strand the volume
|
||
/// setting. Reset to 1 by every Play/Stop.
|
||
SetFadeGain(f32),
|
||
SetOutputDevice {
|
||
device: Option<String>,
|
||
reply: mpsc::Sender<Result<(), String>>,
|
||
},
|
||
Stop,
|
||
}
|
||
|
||
/// How far the normalization gain may move per 60 ms tick — 0.25 dB/s.
|
||
///
|
||
/// The rate IS the feature: a mid-track correction is only acceptable if the
|
||
/// correcting is inaudible. See the slew in the tick arm for why this replaced
|
||
/// the original 0.15 (≈2.5 dB/s).
|
||
const NORM_SLEW_PER_TICK_DB: f32 = 0.015;
|
||
|
||
#[derive(Clone)]
|
||
struct TrackSource {
|
||
path: PathBuf,
|
||
stream: Option<Arc<StreamProgress>>,
|
||
total_bytes: u64,
|
||
duration_hint: Option<f64>,
|
||
}
|
||
|
||
struct ReadyDecoder {
|
||
generation: u64,
|
||
decoder: Decoder<Box<dyn source::MediaRead>>,
|
||
track: TrackSource,
|
||
preload: bool,
|
||
resume_at: Option<Duration>,
|
||
start_paused: bool,
|
||
}
|
||
|
||
impl ReadyDecoder {
|
||
/// True duration for the timeline/seek range. The peer's search-metadata
|
||
/// hint can be wildly wrong (a bogus 30s on a 41-minute live track), and
|
||
/// on its own it would truncate the seek bar. The decoder reads the real
|
||
/// length from the container (FLAC STREAMINFO, an MP3's byte length), so
|
||
/// we take whichever is LONGER: a short hint can't truncate a genuinely
|
||
/// long file, and a still-partial decoder can't under-report against a
|
||
/// good hint.
|
||
fn track_duration(&self) -> Option<f64> {
|
||
reconcile_duration(
|
||
self.track.duration_hint,
|
||
self.decoder.total_duration().map(|d| d.as_secs_f64()),
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Pick the timeline duration from the peer's metadata hint and the decoder's
|
||
/// own reading: take whichever is longer. See [`ReadyDecoder::track_duration`].
|
||
fn reconcile_duration(hint: Option<f64>, decoded: Option<f64>) -> Option<f64> {
|
||
match (hint, decoded) {
|
||
(Some(h), Some(d)) => Some(h.max(d)),
|
||
(h, d) => h.or(d),
|
||
}
|
||
}
|
||
|
||
struct PendingTrack {
|
||
duration: Option<f64>,
|
||
track: TrackSource,
|
||
}
|
||
|
||
/// Forwards a boundary event from rodio's real-time source iterator exactly
|
||
/// when a decoder is exhausted. This lets the UI change its metadata after
|
||
/// the next queued source has already started producing samples.
|
||
struct EndNotifier<S> {
|
||
inner: S,
|
||
queued: Arc<AtomicUsize>,
|
||
event_tx: mpsc::Sender<PlayerEvent>,
|
||
sent: bool,
|
||
}
|
||
|
||
impl<S> Iterator for EndNotifier<S>
|
||
where
|
||
S: Source,
|
||
{
|
||
type Item = S::Item;
|
||
|
||
fn next(&mut self) -> Option<Self::Item> {
|
||
match self.inner.next() {
|
||
Some(sample) => Some(sample),
|
||
None => {
|
||
if !self.sent {
|
||
self.sent = true;
|
||
let remaining = self.queued.fetch_sub(1, Ordering::SeqCst);
|
||
let _ = self.event_tx.send(PlayerEvent::TrackEnded {
|
||
gapless: remaining > 1,
|
||
});
|
||
}
|
||
None
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<S> Source for EndNotifier<S>
|
||
where
|
||
S: Source,
|
||
{
|
||
fn current_span_len(&self) -> Option<usize> {
|
||
self.inner.current_span_len()
|
||
}
|
||
fn channels(&self) -> rodio::ChannelCount {
|
||
self.inner.channels()
|
||
}
|
||
fn sample_rate(&self) -> rodio::SampleRate {
|
||
self.inner.sample_rate()
|
||
}
|
||
fn total_duration(&self) -> Option<Duration> {
|
||
self.inner.total_duration()
|
||
}
|
||
// The gapless boundary wrapper must remain transparent to transport
|
||
// controls. Without this delegation, Source's default implementation
|
||
// reports `NotSupported` and makes every seek a no-op.
|
||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||
self.inner.try_seek(pos)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct Player {
|
||
cmd_tx: mpsc::Sender<Cmd>,
|
||
/// f32 bits; the audio thread mirrors its live fade-gain here for
|
||
/// observability (the debug overlay / crossfade diagnostics). 1.0 = full.
|
||
fade_report: Arc<std::sync::atomic::AtomicU32>,
|
||
/// f32 bits, dB: the gain the sink is being given RIGHT NOW, and the one
|
||
/// the slew is heading for. Mirrored separately because the interesting
|
||
/// state during a mid-track correction is the gap between them — a single
|
||
/// "current gain" readout cannot show that a correction is in flight, only
|
||
/// that it has finished.
|
||
norm_now_report: Arc<std::sync::atomic::AtomicU32>,
|
||
norm_target_report: Arc<std::sync::atomic::AtomicU32>,
|
||
}
|
||
|
||
impl Player {
|
||
/// Spawn the audio thread. Events stream out on the receiver.
|
||
pub fn spawn() -> (Player, mpsc::Receiver<PlayerEvent>) {
|
||
Self::spawn_with_device(None)
|
||
}
|
||
|
||
/// Spawn with a persisted output-device preference. `None` follows the
|
||
/// operating system's default output device.
|
||
pub fn spawn_with_device(
|
||
output_device: Option<String>,
|
||
) -> (Player, mpsc::Receiver<PlayerEvent>) {
|
||
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
||
let (event_tx, event_rx) = mpsc::channel::<PlayerEvent>();
|
||
let fade_report = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
|
||
let norm_now_report = Arc::new(std::sync::atomic::AtomicU32::new(0.0f32.to_bits()));
|
||
let norm_target_report = Arc::new(std::sync::atomic::AtomicU32::new(0.0f32.to_bits()));
|
||
|
||
let thread_report = fade_report.clone();
|
||
let thread_norm_now = norm_now_report.clone();
|
||
let thread_norm_target = norm_target_report.clone();
|
||
std::thread::Builder::new()
|
||
.name("audio".into())
|
||
.spawn(move || {
|
||
audio_thread(
|
||
cmd_rx,
|
||
event_tx,
|
||
output_device,
|
||
thread_report,
|
||
thread_norm_now,
|
||
thread_norm_target,
|
||
)
|
||
})
|
||
.expect("spawn audio thread");
|
||
|
||
(
|
||
Player {
|
||
cmd_tx,
|
||
fade_report,
|
||
norm_now_report,
|
||
norm_target_report,
|
||
},
|
||
event_rx,
|
||
)
|
||
}
|
||
|
||
/// The audio thread's current fade-gain (0..1). Observability only.
|
||
pub fn fade_gain(&self) -> f32 {
|
||
f32::from_bits(self.fade_report.load(Ordering::Relaxed))
|
||
}
|
||
|
||
/// The normalization gain being applied right now, and the one the slew is
|
||
/// heading for, both in dB. Equal means settled; different means a
|
||
/// correction is in flight. Observability only.
|
||
pub fn norm_gain_now_target_db(&self) -> (f32, f32) {
|
||
(
|
||
f32::from_bits(self.norm_now_report.load(Ordering::Relaxed)),
|
||
f32::from_bits(self.norm_target_report.load(Ordering::Relaxed)),
|
||
)
|
||
}
|
||
|
||
pub fn play_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
|
||
self.play_local_from(path, duration_hint, None);
|
||
}
|
||
|
||
/// `play_local`, but the decoder comes up at `start_at_secs` — used by
|
||
/// the preview session's resume memory. `None`/`0` is a plain start.
|
||
pub fn play_local_from(
|
||
&self,
|
||
path: impl Into<PathBuf>,
|
||
duration_hint: Option<f64>,
|
||
start_at_secs: Option<f64>,
|
||
) {
|
||
let _ = self.cmd_tx.send(Cmd::Play {
|
||
path: path.into(),
|
||
stream: None,
|
||
total_bytes: 0,
|
||
duration_hint,
|
||
start_at_secs: start_at_secs.filter(|s| *s > 0.0),
|
||
});
|
||
}
|
||
|
||
/// Queue a local file behind the current source. The decoder is prepared
|
||
/// on the helper thread, so disk I/O never interrupts audible playback.
|
||
pub fn preload_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
|
||
let _ = self.cmd_tx.send(Cmd::Preload {
|
||
path: path.into(),
|
||
duration_hint,
|
||
});
|
||
}
|
||
|
||
/// Play a file that is still being downloaded. `progress` must be kept
|
||
/// updated by the download; `total_bytes` is the expected final size.
|
||
pub fn play_stream(
|
||
&self,
|
||
path: impl Into<PathBuf>,
|
||
progress: Arc<StreamProgress>,
|
||
total_bytes: u64,
|
||
duration_hint: Option<f64>,
|
||
) {
|
||
let _ = self.cmd_tx.send(Cmd::Play {
|
||
path: path.into(),
|
||
stream: Some(progress),
|
||
total_bytes,
|
||
duration_hint,
|
||
start_at_secs: None,
|
||
});
|
||
}
|
||
|
||
pub fn pause(&self) {
|
||
let _ = self.cmd_tx.send(Cmd::Pause);
|
||
}
|
||
|
||
/// Pause and receive a [`PauseAck`] once the audio thread has applied it.
|
||
/// The returned receiver yields exactly one ack (or disconnects if the
|
||
/// player is gone). Callers should `recv_timeout` and treat a timeout as
|
||
/// "not acknowledged" — never as silence.
|
||
pub fn pause_acked(&self) -> mpsc::Receiver<PauseAck> {
|
||
let (reply, rx) = mpsc::channel();
|
||
let _ = self.cmd_tx.send(Cmd::PauseAcked { reply });
|
||
rx
|
||
}
|
||
|
||
pub fn resume(&self) {
|
||
let _ = self.cmd_tx.send(Cmd::Resume);
|
||
}
|
||
|
||
pub fn seek(&self, secs: f64) {
|
||
let _ = self.cmd_tx.send(Cmd::Seek(secs));
|
||
}
|
||
|
||
pub fn scratch(&self, secs: f64, rate: f32) {
|
||
let _ = self.cmd_tx.send(Cmd::Scratch { secs, rate });
|
||
}
|
||
|
||
pub fn end_scratch(&self, secs: f64) {
|
||
let _ = self.cmd_tx.send(Cmd::EndScratch { secs });
|
||
}
|
||
|
||
pub fn set_volume(&self, volume: f32) {
|
||
let _ = self.cmd_tx.send(Cmd::Volume(volume.clamp(0.0, 2.0)));
|
||
}
|
||
|
||
/// Loudness-normalization gain (dB), independent of the user volume.
|
||
/// `immediate` applies it as a jump — correct before audio starts (a
|
||
/// cached measurement); pass false while sounding and the audio thread
|
||
/// glides there instead. Reset to 0 dB by every new `Play`.
|
||
pub fn set_norm_gain_db(&self, db: f32, immediate: bool) {
|
||
let _ = self.cmd_tx.send(Cmd::SetNormGainDb {
|
||
db: db.clamp(-18.0, 9.0),
|
||
immediate,
|
||
});
|
||
}
|
||
|
||
/// Normalization gain for the next GAPLESS-queued track; the audio
|
||
/// thread applies it exactly at the boundary. See [`Cmd::SetNextNormGainDb`].
|
||
pub fn set_next_norm_gain_db(&self, db: f32) {
|
||
let _ = self
|
||
.cmd_tx
|
||
.send(Cmd::SetNextNormGainDb(db.clamp(-18.0, 9.0)));
|
||
}
|
||
|
||
/// Transient fade multiplier (0..1) for the preview open/close crossfade.
|
||
/// A separate channel from the user volume — reset to 1 by every Play/Stop
|
||
/// — so a fade can never leave a later track silent. See [`Cmd::SetFadeGain`].
|
||
pub fn set_fade_gain(&self, gain: f32) {
|
||
let _ = self.cmd_tx.send(Cmd::SetFadeGain(gain.clamp(0.0, 1.0)));
|
||
}
|
||
|
||
/// Change devices immediately. The audio thread acknowledges only after
|
||
/// the endpoint has opened successfully, so callers can safely persist it.
|
||
pub fn set_output_device(&self, device: Option<String>) -> Result<(), String> {
|
||
let (reply, result) = mpsc::channel();
|
||
self.cmd_tx
|
||
.send(Cmd::SetOutputDevice { device, reply })
|
||
.map_err(|_| "audio thread is unavailable".to_string())?;
|
||
result
|
||
.recv_timeout(Duration::from_secs(5))
|
||
.map_err(|_| "timed out while opening the audio device".to_string())?
|
||
}
|
||
|
||
pub fn stop(&self) {
|
||
let _ = self.cmd_tx.send(Cmd::Stop);
|
||
}
|
||
}
|
||
|
||
/// Names reported by the platform audio host, suitable for a device picker.
|
||
pub fn output_device_names() -> Result<Vec<String>, String> {
|
||
let host = rodio::cpal::default_host();
|
||
let mut names: Vec<String> = host
|
||
.output_devices()
|
||
.map_err(|e| format!("could not list audio devices: {e}"))?
|
||
.filter_map(|device| device.name().ok())
|
||
.collect();
|
||
names.sort_by_key(|name| name.to_lowercase());
|
||
names.dedup();
|
||
Ok(names)
|
||
}
|
||
|
||
fn default_output_device_name() -> Option<String> {
|
||
rodio::cpal::default_host()
|
||
.default_output_device()?
|
||
.name()
|
||
.ok()
|
||
}
|
||
|
||
fn open_output_stream(device_name: Option<&str>) -> Result<(rodio::OutputStream, String), String> {
|
||
let host = rodio::cpal::default_host();
|
||
let device = match device_name {
|
||
Some(wanted) => host
|
||
.output_devices()
|
||
.map_err(|e| format!("could not list audio devices: {e}"))?
|
||
.find(|device| device.name().ok().as_deref() == Some(wanted))
|
||
.ok_or_else(|| format!("audio device not found: {wanted}"))?,
|
||
None => host
|
||
.default_output_device()
|
||
.ok_or_else(|| "Windows has no default audio output device".to_string())?,
|
||
};
|
||
let actual_name = device.name().unwrap_or_else(|_| "Unknown device".into());
|
||
let builder = rodio::OutputStreamBuilder::from_device(device)
|
||
.map_err(|e| format!("could not configure {actual_name}: {e}"))?;
|
||
let stream = builder
|
||
.open_stream_or_fallback()
|
||
.map_err(|e| format!("could not open {actual_name}: {e}"))?;
|
||
Ok((stream, actual_name))
|
||
}
|
||
|
||
fn start_decoder(
|
||
track: TrackSource,
|
||
generation: u64,
|
||
preload: bool,
|
||
resume_at: Option<Duration>,
|
||
start_paused: bool,
|
||
tx: mpsc::Sender<Result<ReadyDecoder, (u64, String)>>,
|
||
) {
|
||
std::thread::Builder::new()
|
||
.name(
|
||
if preload {
|
||
"decode-preload"
|
||
} else {
|
||
"decode-setup"
|
||
}
|
||
.into(),
|
||
)
|
||
.spawn(move || {
|
||
// Refuse formats there is no decoder for, BEFORE opening them.
|
||
//
|
||
// Most simply fail to build and report themselves. WavPack does
|
||
// not: a `.wv` carrying the original RIFF header in its wrapper is
|
||
// probed as raw PCM, so the decoder reports success and streams the
|
||
// compressed body as samples — measured at 136,815 NaN/Inf values
|
||
// in a single track, straight into the mixer and the normaliser.
|
||
// An extension check is the only thing that can stop that, because
|
||
// by the time there is a decoder there is no error to notice.
|
||
if let Some(reason) = audio_formats::unplayable_path_reason(&track.path) {
|
||
let _ = tx.send(Err((generation, reason.to_string())));
|
||
return;
|
||
}
|
||
let (reader, byte_len): (Box<dyn source::MediaRead>, u64) = match track.stream.clone() {
|
||
Some(progress) => {
|
||
match GrowingFile::open(&track.path, track.total_bytes, progress) {
|
||
Ok(g) => (Box::new(g), track.total_bytes),
|
||
Err(e) => {
|
||
let _ = tx.send(Err((generation, e.to_string())));
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
None => match std::fs::File::open(&track.path) {
|
||
Ok(f) => {
|
||
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
|
||
(Box::new(std::io::BufReader::new(f)), len)
|
||
}
|
||
Err(e) => {
|
||
let _ = tx.send(Err((generation, e.to_string())));
|
||
return;
|
||
}
|
||
},
|
||
};
|
||
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
|
||
if byte_len > 0 {
|
||
builder = builder.with_byte_len(byte_len);
|
||
}
|
||
match builder.build() {
|
||
Ok(decoder) => {
|
||
let _ = tx.send(Ok(ReadyDecoder {
|
||
generation,
|
||
decoder,
|
||
track,
|
||
preload,
|
||
resume_at,
|
||
start_paused,
|
||
}));
|
||
}
|
||
Err(e) => {
|
||
let _ = tx.send(Err((generation, format!("decode failed: {e}"))));
|
||
}
|
||
}
|
||
})
|
||
.ok();
|
||
}
|
||
|
||
fn audio_thread(
|
||
cmd_rx: mpsc::Receiver<Cmd>,
|
||
event_tx: mpsc::Sender<PlayerEvent>,
|
||
initial_device: Option<String>,
|
||
fade_report: Arc<std::sync::atomic::AtomicU32>,
|
||
norm_now_report: Arc<std::sync::atomic::AtomicU32>,
|
||
norm_target_report: Arc<std::sync::atomic::AtomicU32>,
|
||
) {
|
||
// Open the output device lazily and keep it across tracks. If the device
|
||
// is momentarily busy (e.g. another instance still releasing it), we
|
||
// retry on the next Play instead of killing the thread for the session.
|
||
let (mut output_stream, mut current_device_name) =
|
||
match open_output_stream(initial_device.as_deref()) {
|
||
Ok((stream, name)) => (Some(stream), Some(name)),
|
||
Err(e) => {
|
||
log::warn!("could not open configured audio output: {e}");
|
||
(None, None)
|
||
}
|
||
};
|
||
let mut selected_device = initial_device;
|
||
let mut last_default_check = Instant::now();
|
||
|
||
let (decoder_tx, decoder_rx) = mpsc::channel::<Result<ReadyDecoder, (u64, String)>>();
|
||
|
||
let mut sink: Option<Sink> = None;
|
||
let mut generation: u64 = 0;
|
||
let mut duration: Option<f64> = None;
|
||
let mut volume: f32 = 0.7;
|
||
// Loudness-normalization gain, layered under the user volume. `norm_db`
|
||
// is what the sink currently gets; `norm_target_db` is where the slew in
|
||
// the tick arm is heading (~2.5 dB/s — a refinement mid-track drifts,
|
||
// it never steps). Every new Play resets both to unity.
|
||
let mut norm_db: f32 = 0.0;
|
||
let mut norm_target_db: f32 = 0.0;
|
||
// Gain waiting for the next gapless boundary (None → unity there).
|
||
let mut pending_norm_db: Option<f32> = None;
|
||
// Transient fade multiplier (0..1) for the preview session's open/close
|
||
// crossfade — a SEPARATE channel from the user volume, so a fade can
|
||
// never corrupt the volume setting (leaving a track silent after the
|
||
// fade or across the next Play). Reset to 1 by every Play/Stop.
|
||
let mut fade_gain: f32 = 1.0;
|
||
let eff = |user: f32, ndb: f32, fg: f32| user * 10f32.powf(ndb / 20.0) * fg;
|
||
let mut playing = false;
|
||
// The active stream source, so we can abort its blocking reads.
|
||
let mut active_stream: Option<Arc<StreamProgress>> = None;
|
||
// Number of sources handed to the sink but not yet exhausted. Kept in an
|
||
// atomic because the rodio mixer consumes sources on its own audio thread.
|
||
let queued_sources = Arc::new(AtomicUsize::new(0));
|
||
let mut waiting_preload: Option<ReadyDecoder> = None;
|
||
// Durations of sources queued behind the active one. When a gapless
|
||
// boundary passes, the front entry becomes the new track's duration —
|
||
// otherwise Position events keep reporting the finished track's length.
|
||
let mut pending_tracks: VecDeque<PendingTrack> = VecDeque::new();
|
||
let mut last_queued: usize = 0;
|
||
// Scratch playback is possible only for a stable, complete local file.
|
||
let mut local_path: Option<PathBuf> = None;
|
||
let mut active_track: Option<TrackSource> = None;
|
||
let mut scratching = false;
|
||
let mut scratch_was_playing = false;
|
||
// Desired-paused latch (audio-focus primitive): set by PauseAcked, and it
|
||
// means "nothing for the CURRENT generation may become audible on its
|
||
// own". Its one job is closing the window where a pause lands while the
|
||
// decoder is still in flight — plain Pause is a no-op then, and the track
|
||
// auto-starts moments after the caller believed playback was silenced.
|
||
// Cleared by Resume and by Play (a new track is a new intent).
|
||
let mut desired_paused = false;
|
||
|
||
let emit = |ev: PlayerEvent| {
|
||
let _ = event_tx.send(ev);
|
||
};
|
||
|
||
loop {
|
||
let mut output_change: Option<(Option<String>, Option<mpsc::Sender<Result<(), String>>>)> =
|
||
None;
|
||
// Commands, with a 60 ms tick for a visibly smooth playhead without
|
||
// flooding the UI with frame-rate IPC events.
|
||
match cmd_rx.recv_timeout(Duration::from_millis(60)) {
|
||
Ok(Cmd::Play {
|
||
path,
|
||
stream,
|
||
total_bytes,
|
||
duration_hint,
|
||
start_at_secs,
|
||
}) => {
|
||
// Abort whatever was playing or buffering.
|
||
generation += 1;
|
||
if let Some(s) = active_stream.take() {
|
||
s.abort();
|
||
}
|
||
if let Some(s) = sink.take() {
|
||
s.stop();
|
||
}
|
||
queued_sources.store(0, Ordering::SeqCst);
|
||
waiting_preload = None;
|
||
pending_tracks.clear();
|
||
playing = false;
|
||
scratching = false;
|
||
desired_paused = false; // a new Play is explicit listen intent
|
||
// A new track's loudness is unknown until measured — start at
|
||
// unity; a cached measurement arrives as an immediate
|
||
// SetNormGainDb right behind this command, before any sink.
|
||
norm_db = 0.0;
|
||
norm_target_db = 0.0;
|
||
pending_norm_db = None;
|
||
// A clicked/queued track always plays at full: never inherit a
|
||
// preview crossfade's residual fade level (that was the
|
||
// "audio broken after a track click" bug).
|
||
fade_gain = 1.0;
|
||
fade_report.store(fade_gain.to_bits(), Ordering::Relaxed);
|
||
let track = TrackSource {
|
||
path,
|
||
stream,
|
||
total_bytes,
|
||
duration_hint,
|
||
};
|
||
local_path = track.stream.is_none().then(|| track.path.clone());
|
||
duration = duration_hint;
|
||
emit(PlayerEvent::Buffering);
|
||
|
||
active_stream = track.stream.clone();
|
||
active_track = Some(track.clone());
|
||
start_decoder(
|
||
track,
|
||
generation,
|
||
false,
|
||
start_at_secs.map(Duration::from_secs_f64),
|
||
false,
|
||
decoder_tx.clone(),
|
||
);
|
||
}
|
||
Ok(Cmd::Preload {
|
||
path,
|
||
duration_hint,
|
||
}) => {
|
||
// Streams are intentionally excluded: a growing file can
|
||
// block while waiting for bytes, which would defeat a clean
|
||
// album transition. Local files are fully available.
|
||
start_decoder(
|
||
TrackSource {
|
||
path,
|
||
stream: None,
|
||
total_bytes: 0,
|
||
duration_hint,
|
||
},
|
||
generation,
|
||
true,
|
||
None,
|
||
false,
|
||
decoder_tx.clone(),
|
||
);
|
||
}
|
||
Ok(Cmd::Pause) => {
|
||
if let Some(s) = &sink {
|
||
s.pause();
|
||
playing = false;
|
||
emit(PlayerEvent::Paused);
|
||
}
|
||
}
|
||
Ok(Cmd::PauseAcked { reply }) => {
|
||
let sink_was_active = if let Some(s) = &sink {
|
||
s.pause();
|
||
playing = false;
|
||
emit(PlayerEvent::Paused);
|
||
true
|
||
} else {
|
||
false
|
||
};
|
||
// Latch regardless: if a decoder for this generation is still
|
||
// in flight, it must come up paused, not audible.
|
||
desired_paused = true;
|
||
let _ = reply.send(PauseAck {
|
||
generation,
|
||
sink_was_active,
|
||
latched: !sink_was_active,
|
||
});
|
||
}
|
||
Ok(Cmd::Resume) => {
|
||
desired_paused = false;
|
||
if let Some(s) = &sink {
|
||
s.play();
|
||
playing = true;
|
||
emit(PlayerEvent::Playing);
|
||
}
|
||
}
|
||
Ok(Cmd::Seek(secs)) => {
|
||
if let Some(s) = &sink {
|
||
if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) {
|
||
log::debug!("seek failed: {e}");
|
||
}
|
||
// Land the pending correction rather than carrying it
|
||
// across the jump. The slew is slow ONLY because a gain
|
||
// change is audible against continuous music — and a seek
|
||
// is already a discontinuity, so the step hides inside it.
|
||
// Drifting afterwards would be the audible case: the same
|
||
// few seconds of new material rising in level for no
|
||
// reason the listener can attribute to anything.
|
||
norm_db = norm_target_db;
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
}
|
||
}
|
||
Ok(Cmd::Scratch { secs, rate }) => {
|
||
let (Some(path), Some(out)) = (local_path.as_ref(), output_stream.as_ref()) else {
|
||
continue;
|
||
};
|
||
if !scratching {
|
||
// Discard any asynchronous decoder/preload that was
|
||
// prepared for the normal gapless path. Scratch owns the
|
||
// sink until release and must never mix in the next song.
|
||
generation += 1;
|
||
scratch_was_playing = playing;
|
||
scratching = true;
|
||
queued_sources.store(0, Ordering::SeqCst);
|
||
waiting_preload = None;
|
||
pending_tracks.clear();
|
||
last_queued = 0;
|
||
}
|
||
if let Some(old) = sink.take() {
|
||
old.stop();
|
||
}
|
||
match decode_scratch_window(path, secs, rate) {
|
||
Ok((samples, speed)) => {
|
||
let s = Sink::connect_new(out.mixer());
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
s.set_speed(speed);
|
||
s.append(samples);
|
||
s.play();
|
||
sink = Some(s);
|
||
// The UI owns position while the platter is dragged.
|
||
playing = true;
|
||
}
|
||
Err(e) => log::debug!("scratch decode failed: {e}"),
|
||
}
|
||
}
|
||
Ok(Cmd::EndScratch { secs }) => {
|
||
if !scratching {
|
||
continue;
|
||
}
|
||
scratching = false;
|
||
if let Some(old) = sink.take() {
|
||
old.stop();
|
||
}
|
||
queued_sources.store(0, Ordering::SeqCst);
|
||
pending_tracks.clear();
|
||
last_queued = 0;
|
||
let (Some(path), Some(out)) = (local_path.as_ref(), output_stream.as_ref()) else {
|
||
continue;
|
||
};
|
||
match local_decoder(path) {
|
||
Ok(decoder) => {
|
||
let s = Sink::connect_new(out.mixer());
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
queued_sources.store(1, Ordering::SeqCst);
|
||
s.append(EndNotifier {
|
||
inner: decoder,
|
||
queued: queued_sources.clone(),
|
||
event_tx: event_tx.clone(),
|
||
sent: false,
|
||
});
|
||
// Seek through the sink rather than before append so
|
||
// Sink::get_pos continues to report the track's real
|
||
// absolute position after a scratch release.
|
||
if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) {
|
||
log::debug!("scratch release seek failed: {e}");
|
||
}
|
||
if scratch_was_playing {
|
||
s.play();
|
||
playing = true;
|
||
emit(PlayerEvent::Playing);
|
||
} else {
|
||
s.pause();
|
||
playing = false;
|
||
emit(PlayerEvent::Paused);
|
||
}
|
||
sink = Some(s);
|
||
}
|
||
Err(e) => emit(PlayerEvent::Error(e)),
|
||
}
|
||
}
|
||
Ok(Cmd::Volume(v)) => {
|
||
volume = v;
|
||
if let Some(s) = &sink {
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
}
|
||
}
|
||
Ok(Cmd::SetNormGainDb { db, immediate }) => {
|
||
norm_target_db = db;
|
||
if immediate {
|
||
norm_db = db;
|
||
if let Some(s) = &sink {
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
}
|
||
}
|
||
// Non-immediate: the tick arm slews norm_db there.
|
||
}
|
||
Ok(Cmd::SetNextNormGainDb(db)) => {
|
||
pending_norm_db = Some(db);
|
||
}
|
||
Ok(Cmd::SetFadeGain(fg)) => {
|
||
fade_gain = fg.clamp(0.0, 1.0);
|
||
fade_report.store(fade_gain.to_bits(), Ordering::Relaxed);
|
||
if let Some(s) = &sink {
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
}
|
||
}
|
||
Ok(Cmd::SetOutputDevice { device, reply }) => {
|
||
output_change = Some((device, Some(reply)));
|
||
}
|
||
Ok(Cmd::Stop) => {
|
||
generation += 1;
|
||
if let Some(s) = active_stream.take() {
|
||
s.abort();
|
||
}
|
||
if let Some(s) = sink.take() {
|
||
s.stop();
|
||
}
|
||
queued_sources.store(0, Ordering::SeqCst);
|
||
waiting_preload = None;
|
||
pending_tracks.clear();
|
||
pending_norm_db = None;
|
||
fade_gain = 1.0;
|
||
fade_report.store(fade_gain.to_bits(), Ordering::Relaxed);
|
||
playing = false;
|
||
scratching = false;
|
||
local_path = None;
|
||
active_track = None;
|
||
emit(PlayerEvent::Stopped);
|
||
}
|
||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||
// Normalization slew: glide toward the analyzer's latest
|
||
// target, slowly enough that the move is not itself audible.
|
||
//
|
||
// This ran at ~2.5 dB/s (0.15 dB per 60 ms tick), and at that
|
||
// rate a mid-track refinement was heard as a drift — which is
|
||
// why mid-track correction was switched off entirely on
|
||
// 2026-07-23 rather than slowed down. It is back on because
|
||
// the alternative is worse: a streamed first play is gained
|
||
// from a 10 s prefix, which reads a median 3.4 dB too quiet
|
||
// across the cached corpus, and sometimes from nothing at all.
|
||
// Being 3 dB wrong for a whole track is a bigger error than a
|
||
// correction nobody can hear.
|
||
//
|
||
// 0.25 dB/s sits under the threshold at which a gradual level
|
||
// change reads as a change rather than as the music: the
|
||
// median 3.4 dB correction takes ~14 s and the worst observed
|
||
// (~6 dB) about 24 s, both spread thinly enough to be masked
|
||
// by the track's own dynamics.
|
||
let diff = norm_target_db - norm_db;
|
||
if diff.abs() > 0.001 {
|
||
norm_db += diff.clamp(-NORM_SLEW_PER_TICK_DB, NORM_SLEW_PER_TICK_DB);
|
||
if let Some(s) = &sink {
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
}
|
||
}
|
||
}
|
||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||
}
|
||
|
||
// Mirror the normalization pair after every command and every tick, so
|
||
// the debug overlay sees the slew moving rather than only its endpoint.
|
||
norm_now_report.store(norm_db.to_bits(), Ordering::Relaxed);
|
||
norm_target_report.store(norm_target_db.to_bits(), Ordering::Relaxed);
|
||
|
||
// In system-default mode, periodically compare endpoint names. CPAL's
|
||
// existing stream remains attached to the old endpoint when Windows'
|
||
// default changes, so reopening is required.
|
||
if output_change.is_none()
|
||
&& selected_device.is_none()
|
||
&& !scratching
|
||
&& last_default_check.elapsed() >= Duration::from_secs(1)
|
||
{
|
||
last_default_check = Instant::now();
|
||
if let Some(default_name) = default_output_device_name() {
|
||
if current_device_name.as_deref() != Some(default_name.as_str()) {
|
||
output_change = Some((None, None));
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some((requested, reply)) = output_change {
|
||
if scratching {
|
||
if let Some(reply) = reply {
|
||
let _ =
|
||
reply.send(Err("finish scratching before changing audio devices".into()));
|
||
}
|
||
} else {
|
||
match open_output_stream(requested.as_deref()) {
|
||
Err(e) => {
|
||
if let Some(reply) = reply {
|
||
let _ = reply.send(Err(e));
|
||
} else {
|
||
log::warn!("could not follow Windows audio output change: {e}");
|
||
}
|
||
}
|
||
Ok((new_stream, name)) => {
|
||
let resume_at = sink.as_ref().map(|s| s.get_pos());
|
||
let start_paused = sink.is_some() && !playing;
|
||
if active_track.is_some() {
|
||
generation += 1;
|
||
}
|
||
if let Some(old) = sink.take() {
|
||
old.stop();
|
||
}
|
||
queued_sources.store(0, Ordering::SeqCst);
|
||
waiting_preload = None;
|
||
pending_tracks.clear();
|
||
last_queued = 0;
|
||
output_stream = Some(new_stream);
|
||
current_device_name = Some(name);
|
||
selected_device = requested;
|
||
|
||
if let Some(track) = active_track.clone() {
|
||
playing = false;
|
||
emit(PlayerEvent::Buffering);
|
||
start_decoder(
|
||
track,
|
||
generation,
|
||
false,
|
||
resume_at,
|
||
start_paused,
|
||
decoder_tx.clone(),
|
||
);
|
||
}
|
||
if let Some(reply) = reply {
|
||
let _ = reply.send(Ok(()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Finished decoder setups.
|
||
while let Ok(result) = decoder_rx.try_recv() {
|
||
match result {
|
||
Ok(ready) if ready.generation == generation => {
|
||
// (Re)acquire the output device if we don't have it yet.
|
||
if output_stream.is_none() {
|
||
match open_output_stream(selected_device.as_deref()) {
|
||
Ok((new_stream, name)) => {
|
||
output_stream = Some(new_stream);
|
||
current_device_name = Some(name);
|
||
}
|
||
Err(e) => log::warn!("could not acquire audio output: {e}"),
|
||
}
|
||
}
|
||
let Some(out) = output_stream.as_ref() else {
|
||
emit(PlayerEvent::Error(
|
||
"audio device unavailable — is another app using it?".into(),
|
||
));
|
||
continue;
|
||
};
|
||
if ready.preload && sink.is_none() {
|
||
// Decoder setup can beat the first decoder on a fast
|
||
// disk. Hold it until the active sink exists.
|
||
waiting_preload = Some(ready);
|
||
continue;
|
||
}
|
||
|
||
let is_preload = ready.preload;
|
||
let track_duration = ready.track_duration();
|
||
if !is_preload {
|
||
let s = Sink::connect_new(out.mixer());
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
sink = Some(s);
|
||
active_track = Some(ready.track.clone());
|
||
active_stream = ready.track.stream.clone();
|
||
local_path = ready
|
||
.track
|
||
.stream
|
||
.is_none()
|
||
.then(|| ready.track.path.clone());
|
||
}
|
||
let target = sink
|
||
.as_ref()
|
||
.expect("sink exists for active or preloaded decoder");
|
||
queued_sources.fetch_add(1, Ordering::SeqCst);
|
||
target.append(EndNotifier {
|
||
inner: ready.decoder,
|
||
queued: queued_sources.clone(),
|
||
event_tx: event_tx.clone(),
|
||
sent: false,
|
||
});
|
||
if let Some(pos) = ready.resume_at {
|
||
if let Err(e) = target.try_seek(pos) {
|
||
log::warn!("could not resume after audio-device change: {e}");
|
||
}
|
||
}
|
||
if is_preload {
|
||
// Nothing changes visually yet; samples will switch at
|
||
// the exact decoder boundary.
|
||
pending_tracks.push_back(PendingTrack {
|
||
duration: track_duration,
|
||
track: ready.track,
|
||
});
|
||
} else {
|
||
duration = track_duration;
|
||
// desired_paused: an acknowledged pause landed while
|
||
// this decoder was in flight. Honouring it here is
|
||
// the whole point of the latch — the caller was told
|
||
// "silent", so the track must not become audible.
|
||
if ready.start_paused || desired_paused {
|
||
target.pause();
|
||
playing = false;
|
||
emit(PlayerEvent::Paused);
|
||
} else {
|
||
target.play();
|
||
playing = true;
|
||
emit(PlayerEvent::Playing);
|
||
}
|
||
if let Some(next) = waiting_preload.take() {
|
||
let next_duration = next.track_duration();
|
||
pending_tracks.push_back(PendingTrack {
|
||
duration: next_duration,
|
||
track: next.track.clone(),
|
||
});
|
||
queued_sources.fetch_add(1, Ordering::SeqCst);
|
||
target.append(EndNotifier {
|
||
inner: next.decoder,
|
||
queued: queued_sources.clone(),
|
||
event_tx: event_tx.clone(),
|
||
sent: false,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
Ok(_) => {} // stale generation: user moved on
|
||
Err((gen, msg)) if gen == generation => emit(PlayerEvent::Error(msg)),
|
||
Err(_) => {}
|
||
}
|
||
}
|
||
|
||
// A queued source finished while the sink kept playing (gapless
|
||
// boundary): adopt the next track's duration for Position events.
|
||
let q = queued_sources.load(Ordering::SeqCst);
|
||
if q < last_queued && q > 0 {
|
||
for _ in 0..(last_queued - q) {
|
||
if let Some(next) = pending_tracks.pop_front() {
|
||
duration = next.duration;
|
||
local_path = next.track.stream.is_none().then(|| next.track.path.clone());
|
||
active_stream = next.track.stream.clone();
|
||
active_track = Some(next.track);
|
||
}
|
||
}
|
||
// Gapless boundary = a track change: apply the next track's
|
||
// normalization gain (unity when unmeasured) as a step HERE —
|
||
// never mid-track.
|
||
norm_db = pending_norm_db.take().unwrap_or(0.0);
|
||
norm_target_db = norm_db;
|
||
if let Some(s) = &sink {
|
||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||
}
|
||
}
|
||
last_queued = q;
|
||
|
||
// Position tick / end detection.
|
||
if let Some(s) = &sink {
|
||
if playing && !scratching {
|
||
if s.empty() {
|
||
playing = false;
|
||
sink = None;
|
||
if let Some(st) = active_stream.take() {
|
||
st.abort();
|
||
}
|
||
active_track = None;
|
||
local_path = None;
|
||
// EndNotifier emits TrackEnded from the audio callback;
|
||
// do not emit a second, late event from this polling tick.
|
||
} else {
|
||
emit(PlayerEvent::Position {
|
||
secs: s.get_pos().as_secs_f64(),
|
||
duration,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build a seekable decoder for a complete local file. Growing network
|
||
/// downloads stay on ordinary seek behavior because reverse windows need
|
||
/// stable bytes on disk.
|
||
fn local_decoder(path: &Path) -> Result<Decoder<Box<dyn source::MediaRead>>, String> {
|
||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||
let mut builder = Decoder::builder()
|
||
.with_data(Box::new(std::io::BufReader::new(file)) as Box<dyn source::MediaRead>)
|
||
.with_seekable(true);
|
||
if len > 0 {
|
||
builder = builder.with_byte_len(len);
|
||
}
|
||
builder.build().map_err(|e| format!("decode failed: {e}"))
|
||
}
|
||
|
||
/// Decode ~180 ms around the jog point. Reversing complete interleaved frames
|
||
/// (not individual samples) preserves stereo while producing true reverse
|
||
/// audio; the sink rate carries the hand-speed/pitch relationship.
|
||
fn decode_scratch_window(
|
||
path: &Path,
|
||
secs: f64,
|
||
rate: f32,
|
||
) -> Result<(SamplesBuffer, f32), String> {
|
||
let mut decoder = local_decoder(path)?;
|
||
let channels = decoder.channels();
|
||
let sample_rate = decoder.sample_rate();
|
||
let speed = rate.abs().clamp(0.25, 4.0);
|
||
let window_secs = 0.18 * f64::from(speed);
|
||
let start = if rate < 0.0 {
|
||
(secs - window_secs).max(0.0)
|
||
} else {
|
||
secs.max(0.0)
|
||
};
|
||
decoder
|
||
.try_seek(Duration::from_secs_f64(start))
|
||
.map_err(|e| format!("scratch seek failed: {e}"))?;
|
||
let count = (window_secs * f64::from(sample_rate) * f64::from(channels)) as usize;
|
||
let mut data: Vec<f32> = decoder
|
||
.by_ref()
|
||
.take(count.max(channels as usize))
|
||
.collect();
|
||
if data.is_empty() {
|
||
return Err("no audio at scratch position".into());
|
||
}
|
||
if rate < 0.0 {
|
||
let frame = channels as usize;
|
||
data = data
|
||
.chunks(frame)
|
||
.rev()
|
||
.flat_map(|f| f.iter().copied())
|
||
.collect();
|
||
}
|
||
Ok((SamplesBuffer::new(channels, sample_rate, data), speed))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::reconcile_duration;
|
||
|
||
#[test]
|
||
fn bogus_short_hint_cannot_truncate_a_long_file() {
|
||
// The reported bug: peer metadata said 30s for a 41-minute live FLAC
|
||
// whose decoder reads the true length. The real duration must win.
|
||
assert_eq!(reconcile_duration(Some(30.0), Some(2491.0)), Some(2491.0));
|
||
}
|
||
|
||
#[test]
|
||
fn partial_decoder_cannot_underreport_against_a_good_hint() {
|
||
// A still-downloading source whose decoder only sees part of the file
|
||
// must not shrink a correct hint.
|
||
assert_eq!(reconcile_duration(Some(240.0), Some(45.0)), Some(240.0));
|
||
}
|
||
|
||
#[test]
|
||
fn falls_back_to_whichever_exists() {
|
||
assert_eq!(reconcile_duration(None, Some(180.0)), Some(180.0));
|
||
assert_eq!(reconcile_duration(Some(180.0), None), Some(180.0));
|
||
assert_eq!(reconcile_duration(None, None), None);
|
||
}
|
||
}
|