Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+904
View File
@@ -0,0 +1,904 @@
//! 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 source;
mod waveform;
pub use source::StreamProgress;
pub use waveform::compute_waveform;
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};
use source::GrowingFile;
#[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>,
},
/// Decode and append the next local track before the active one ends.
Preload {
path: PathBuf,
duration_hint: Option<f64>,
},
Pause,
Resume,
Seek(f64),
Scratch {
secs: f64,
rate: f32,
},
EndScratch {
secs: f64,
},
Volume(f32),
SetOutputDevice {
device: Option<String>,
reply: mpsc::Sender<Result<(), String>>,
},
Stop,
}
#[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>,
}
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>();
std::thread::Builder::new()
.name("audio".into())
.spawn(move || audio_thread(cmd_rx, event_tx, output_device))
.expect("spawn audio thread");
(Player { cmd_tx }, event_rx)
}
pub fn play_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
let _ = self.cmd_tx.send(Cmd::Play {
path: path.into(),
stream: None,
total_bytes: 0,
duration_hint,
});
}
/// 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,
});
}
pub fn pause(&self) {
let _ = self.cmd_tx.send(Cmd::Pause);
}
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)));
}
/// 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 || {
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>,
) {
// 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;
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;
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,
}) => {
// 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;
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, None, 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::Resume) => {
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}");
}
}
}
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(volume);
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(volume);
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(v);
}
}
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();
playing = false;
scratching = false;
local_path = None;
active_track = None;
emit(PlayerEvent::Stopped);
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
// 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(volume);
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;
if ready.start_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);
}
}
}
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);
}
}