forked from Keelrleek/SoulStream
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:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "player"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rodio = { version = "0.21", features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = "0.11"
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Local playback smoke test: play a file, seek, report positions.
|
||||
//!
|
||||
//! cargo run -p player --example play -- "C:\path\to\file.flac"
|
||||
|
||||
use player::{Player, PlayerEvent};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init();
|
||||
let path = std::env::args().nth(1).expect("usage: play <file>");
|
||||
|
||||
let (player, events) = Player::spawn();
|
||||
player.play_local(&path, None);
|
||||
|
||||
let mut last_pos = 0.0;
|
||||
let start = std::time::Instant::now();
|
||||
let mut seeked = false;
|
||||
|
||||
while start.elapsed() < Duration::from_secs(12) {
|
||||
match events.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(PlayerEvent::Playing) => println!("PLAYING"),
|
||||
Ok(PlayerEvent::Position { secs, duration }) => {
|
||||
last_pos = secs;
|
||||
println!("pos={secs:.1} dur={duration:?}");
|
||||
if secs > 3.0 && !seeked {
|
||||
seeked = true;
|
||||
println!("SEEK to 60s…");
|
||||
player.seek(60.0);
|
||||
}
|
||||
}
|
||||
Ok(PlayerEvent::Error(e)) => {
|
||||
println!("ERROR: {e}");
|
||||
return;
|
||||
}
|
||||
Ok(ev) => println!("{ev:?}"),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
println!("final pos: {last_pos:.1} (expect > 60 if seek worked)");
|
||||
player.stop();
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Phase 0.3 — native transport probe (isolated diagnostic, not feature code).
|
||||
//!
|
||||
//! Answers the native column's core questions with the existing crate, no new
|
||||
//! dependencies:
|
||||
//!
|
||||
//! 1. Can a SECOND `Player` instance coexist with a "main" one — two sinks,
|
||||
//! two event channels, no shared state?
|
||||
//! 2. Does a real Deezer preview (MPEG-1 Layer III, 128k CBR, ~480 KB)
|
||||
//! decode and play through rodio/symphonia?
|
||||
//! 3. Do pause / resume / stop behave on the second instance while the
|
||||
//! first is mid-track, without cross-talk on either event channel?
|
||||
//!
|
||||
//! The preview file is downloaded OUTSIDE this probe (curl) and passed as
|
||||
//! argv[1]; argv[2] is any long local track for the "main" side. Run:
|
||||
//!
|
||||
//! cargo run -p player --example preview_probe -- preview.mp3 main.mp3
|
||||
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use player::{Player, PlayerEvent};
|
||||
|
||||
fn drain(
|
||||
label: &str,
|
||||
rx: &std::sync::mpsc::Receiver<PlayerEvent>,
|
||||
for_ms: u64,
|
||||
log: &mut Vec<String>,
|
||||
) {
|
||||
let end = Instant::now() + Duration::from_millis(for_ms);
|
||||
loop {
|
||||
let left = end.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
break;
|
||||
}
|
||||
match rx.recv_timeout(left) {
|
||||
Ok(ev) => log.push(format!("{label}: {ev:?}")),
|
||||
Err(RecvTimeoutError::Timeout) => break,
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
log.push(format!("{label}: channel closed"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let preview = args.next().expect("argv[1]: downloaded preview mp3");
|
||||
let main_track = args.next();
|
||||
|
||||
let mut log = Vec::new();
|
||||
|
||||
// "Main" player — only if a second file was provided; the coexistence
|
||||
// half of the probe is skipped otherwise.
|
||||
let main_pair = main_track.map(|p| {
|
||||
let (main, main_rx) = Player::spawn();
|
||||
main.play_local(&p, None);
|
||||
(main, main_rx, p)
|
||||
});
|
||||
|
||||
// Preview player: a wholly separate instance and event channel.
|
||||
let (prev, prev_rx) = Player::spawn();
|
||||
let t0 = Instant::now();
|
||||
prev.play_local(&preview, Some(30.0));
|
||||
|
||||
drain("preview", &prev_rx, 2500, &mut log);
|
||||
let started = log.iter().any(|l| l.contains("Playing"));
|
||||
println!("PROBE preview-starts: {started} ({} ms to first events)", t0.elapsed().as_millis());
|
||||
|
||||
prev.pause();
|
||||
drain("preview", &prev_rx, 600, &mut log);
|
||||
let paused = log.iter().any(|l| l.contains("Paused"));
|
||||
println!("PROBE preview-pauses: {paused}");
|
||||
|
||||
prev.resume();
|
||||
drain("preview", &prev_rx, 900, &mut log);
|
||||
|
||||
if let Some((_main, main_rx, ref path)) = main_pair {
|
||||
let mut main_log = Vec::new();
|
||||
drain("main", &main_rx, 1200, &mut main_log);
|
||||
let main_alive = main_log.iter().any(|l| l.contains("Playing") || l.contains("Position"));
|
||||
println!("PROBE main-coexists: {main_alive} ({path})");
|
||||
// cross-talk check: no preview-channel event may appear on main's
|
||||
// channel or vice versa — separate receivers make this structural,
|
||||
// so what we actually assert is that both channels stayed live.
|
||||
println!("PROBE channels-independent: true (separate mpsc receivers by construction)");
|
||||
} else {
|
||||
println!("PROBE main-coexists: skipped (no second file)");
|
||||
}
|
||||
|
||||
prev.stop();
|
||||
drain("preview", &prev_rx, 600, &mut log);
|
||||
let stopped = log.iter().any(|l| l.contains("Stopped"));
|
||||
println!("PROBE preview-stops: {stopped}");
|
||||
|
||||
println!("--- event log ---");
|
||||
for l in &log {
|
||||
println!("{l}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Print an ASCII preview of a file's waveform shape.
|
||||
//! cargo run -p player --example wf -- "C:\path\to\file.flac"
|
||||
|
||||
fn main() {
|
||||
let path = std::path::PathBuf::from(std::env::args().nth(1).expect("usage: wf <file>"));
|
||||
let t = std::time::Instant::now();
|
||||
let peaks = player::compute_waveform(&path, 80);
|
||||
println!("{} buckets in {:?}", peaks.len(), t.elapsed());
|
||||
let glyphs = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
let bars: String = peaks
|
||||
.iter()
|
||||
.map(|&v| glyphs[((v * 8.0) as usize).min(8)])
|
||||
.collect();
|
||||
println!("{bars}");
|
||||
let flat = peaks.iter().filter(|&&v| v > 0.95).count();
|
||||
println!("buckets >0.95: {flat}/{}", peaks.len());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user