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);
}
}
+218
View File
@@ -0,0 +1,218 @@
//! Growing-file media source: reads a file that is still being written,
//! blocking (instead of returning EOF) when playback catches up to the
//! download's write head.
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
/// Everything rodio's Decoder needs from a source.
pub trait MediaRead: Read + Seek + Send + Sync {}
impl<T: Read + Seek + Send + Sync> MediaRead for T {}
#[derive(Debug, Default)]
struct ProgressState {
received: u64,
done: bool,
failed: Option<String>,
aborted: bool,
}
/// Shared write-progress of an in-flight download. The downloader calls
/// [`update`](Self::update); the audio thread's reads wait on it.
#[derive(Debug, Default)]
pub struct StreamProgress {
state: Mutex<ProgressState>,
cv: Condvar,
}
impl StreamProgress {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn update(&self, received: u64, done: bool, failed: Option<String>) {
let mut st = self.state.lock().unwrap();
st.received = st.received.max(received);
st.done |= done;
if st.failed.is_none() {
st.failed = failed;
}
self.cv.notify_all();
}
/// Unblock any waiting reads permanently (track switched / stopped).
pub fn abort(&self) {
let mut st = self.state.lock().unwrap();
st.aborted = true;
self.cv.notify_all();
}
/// Wait until at least `wanted` bytes exist (or terminal state).
/// Returns Ok(available bytes) or Err(reason) on failure/abort.
fn wait_for(&self, wanted: u64) -> Result<u64, String> {
let mut st = self.state.lock().unwrap();
loop {
if st.aborted {
return Err("stream aborted".into());
}
if let Some(reason) = &st.failed {
return Err(reason.clone());
}
if st.done || st.received >= wanted {
return Ok(st.received);
}
let (guard, _) = self
.cv
.wait_timeout(st, Duration::from_millis(250))
.map_err(|_| "progress lock poisoned".to_string())?;
st = guard;
}
}
fn snapshot(&self) -> (u64, bool) {
let st = self.state.lock().unwrap();
(st.received, st.done)
}
}
/// A file being written by a download, exposed as a blocking Read + Seek.
pub struct GrowingFile {
file: std::fs::File,
pos: u64,
total: u64,
progress: Arc<StreamProgress>,
}
impl GrowingFile {
pub fn open(
path: &Path,
total: u64,
progress: Arc<StreamProgress>,
) -> std::io::Result<GrowingFile> {
// The file may not exist until the remote queue clears; wait for
// the first bytes before opening.
progress.wait_for(1).map_err(std::io::Error::other)?;
let file = std::fs::File::open(path)?;
Ok(GrowingFile {
file,
pos: 0,
total,
progress,
})
}
}
impl Read for GrowingFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let (received, done) = self.progress.snapshot();
// At the write head and still downloading: wait for more.
if self.pos >= received && !done {
match self.progress.wait_for(self.pos + 1) {
Ok(_) => {}
Err(e) if e == "stream aborted" => return Ok(0), // graceful EOF
Err(e) => return Err(std::io::Error::other(e)),
}
}
let n = self.file.read(buf)?;
if n == 0 {
// Raced the writer: data was promised but the handle hasn't
// caught up; only report EOF once the download is done.
let (_, done) = self.progress.snapshot();
if !done {
std::thread::sleep(Duration::from_millis(50));
let n2 = self.file.read(buf)?;
self.pos += n2 as u64;
return Ok(n2);
}
}
self.pos += n as u64;
Ok(n)
}
}
impl Seek for GrowingFile {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
// Normalize End against the *final* size so seek math is stable
// even while the file is still growing.
let target = match pos {
SeekFrom::Start(o) => o,
SeekFrom::Current(d) => self.pos.saturating_add_signed(d),
SeekFrom::End(d) => self.total.saturating_add_signed(d),
};
// Block until the target region exists, so decoder probes into
// not-yet-downloaded space stall instead of erroring.
if target > 0 {
self.progress
.wait_for(target.min(self.total))
.map_err(std::io::Error::other)?;
}
self.pos = self.file.seek(SeekFrom::Start(target))?;
Ok(self.pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn growing_read_blocks_until_bytes_arrive() {
let dir = std::env::temp_dir().join("ssplus-player-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("grow.bin");
std::fs::write(&path, b"").unwrap();
let progress = StreamProgress::new();
let writer_progress = progress.clone();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&writer_path)
.unwrap();
for chunk in 0..4u8 {
std::thread::sleep(Duration::from_millis(60));
f.write_all(&[chunk; 100]).unwrap();
f.flush().unwrap();
writer_progress.update((chunk as u64 + 1) * 100, chunk == 3, None);
}
});
let mut g = GrowingFile::open(&path, 400, progress).unwrap();
let mut out = Vec::new();
g.read_to_end(&mut out).unwrap();
writer.join().unwrap();
assert_eq!(out.len(), 400);
assert_eq!(out[350], 3);
}
#[test]
fn abort_unblocks_reader() {
let dir = std::env::temp_dir().join("ssplus-player-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("abort.bin");
std::fs::write(&path, b"xx").unwrap();
let progress = StreamProgress::new();
progress.update(2, false, None);
let mut g = GrowingFile::open(&path, 1000, progress.clone()).unwrap();
let killer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
progress.abort();
});
let mut out = Vec::new();
// Reads the 2 bytes, then blocks at the write head until abort.
g.read_to_end(&mut out).unwrap();
killer.join().unwrap();
assert_eq!(out.len(), 2);
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Waveform extraction: decode an audio file and downsample it to a small
//! array of normalized 0..1 loudness values, for a waveform scrubber.
//!
//! Uses RMS energy per window, not peak amplitude: modern masters are
//! limited so hard that nearly every window's *peak* touches the digital
//! ceiling, which renders as a clipped brick. RMS tracks perceived
//! loudness and keeps the verse/chorus dynamics visible.
use std::path::Path;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
/// Peaks folded over this many frames before resampling to the final bucket
/// count. Keeps memory bounded regardless of track length.
const WINDOW: usize = 1024;
/// Decode `path` and return `buckets` normalized peak values (0.0..=1.0).
/// Returns an empty vec on any decode failure.
pub fn compute_waveform(path: &Path, buckets: usize) -> Vec<f32> {
match try_compute(path, buckets) {
Ok(v) => v,
Err(e) => {
log::debug!("waveform for {} failed: {e}", path.display());
Vec::new()
}
}
}
fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
// Stream partials are "<name>.<ext>.partial" — hint the codec ext.
let ext = if ext.eq_ignore_ascii_case("partial") {
path.file_stem()
.map(Path::new)
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.unwrap_or(ext)
} else {
ext
};
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| e.to_string())?;
let mut format = probed.format;
let track = format
.default_track()
.ok_or_else(|| "no default track".to_string())?;
let track_id = track.id;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| e.to_string())?;
let mut sample_buf: Option<SampleBuffer<f32>> = None;
let mut coarse: Vec<f32> = Vec::new();
let mut win_energy = 0f64;
let mut win_count = 0usize;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(_) => break, // EOF or error → stop with what we have
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(_) => break,
};
let spec = *decoded.spec();
let channels = spec.channels.count().max(1);
if sample_buf.is_none() {
sample_buf = Some(SampleBuffer::new(decoded.capacity() as u64, spec));
}
let sb = sample_buf.as_mut().unwrap();
sb.copy_interleaved_ref(decoded);
for frame in sb.samples().chunks(channels) {
let energy =
frame.iter().map(|&s| (s as f64) * (s as f64)).sum::<f64>() / channels as f64;
win_energy += energy;
win_count += 1;
if win_count >= WINDOW {
coarse.push((win_energy / WINDOW as f64).sqrt() as f32);
win_energy = 0.0;
win_count = 0;
}
}
}
if win_count > 0 {
coarse.push((win_energy / win_count as f64).sqrt() as f32);
}
Ok(resample(&coarse, buckets))
}
/// Downsample `coarse` RMS values to exactly `buckets`, averaging each
/// span, then normalize so the loudest bucket is 1.0.
fn resample(coarse: &[f32], buckets: usize) -> Vec<f32> {
let mut out = vec![0f32; buckets];
if coarse.is_empty() || buckets == 0 {
return out;
}
for (b, slot) in out.iter_mut().enumerate() {
let start = b * coarse.len() / buckets;
let end = (((b + 1) * coarse.len() / buckets).max(start + 1)).min(coarse.len());
let span = &coarse[start..end];
*slot = span.iter().sum::<f32>() / span.len() as f32;
}
let max = out.iter().fold(0f32, |m, &v| m.max(v));
if max > 0.0 {
for v in out.iter_mut() {
*v /= max;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resample_normalizes() {
let coarse = vec![0.2, 0.4, 0.8, 0.1, 0.6, 0.3];
let out = resample(&coarse, 3);
assert_eq!(out.len(), 3);
assert!((out.iter().cloned().fold(0f32, f32::max) - 1.0).abs() < 1e-6);
}
#[test]
fn empty_input() {
assert_eq!(resample(&[], 5), vec![0.0; 5]);
}
}