aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:02:11 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:02:11 +0100
commit853b63e543bbc72081c59d7e10d7c968178ce0ae (patch)
tree384665cadca84d1fbb1e93f260078388dcfa27bb
parentfix a bug where adding track using `now` when shuffle is on does not work as ... (diff)
downloadrsplayer-853b63e543bbc72081c59d7e10d7c968178ce0ae.tar.gz
rsplayer-853b63e543bbc72081c59d7e10d7c968178ce0ae.zip
audio: http streaming, opus/mkv support and stream recovery
- read http(s) urls through a MediaSourceStream instead of failing - register the libopus decoder and enable mkv/aac/isomp4 in symphonia - resample to the device rate with linear interpolation - rebuild the output stream when the backend dies instead of leaving playback silent while the daemon still reports "Playing" A dead pcm makes cpal call the error callback hundreds of thousands of times per second, so errors are only counted there and reported once when the stream is dropped. The first failure of a healthy stream is retried immediately, consecutive failures back off, and a stream that played long enough clears the failure budget.
-rw-r--r--Cargo.lock60
-rw-r--r--Cargo.toml3
-rw-r--r--src/audio.rs941
3 files changed, 761 insertions, 243 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 9c69f54..7a4524a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -22,6 +22,7 @@ dependencies = [
22 "serde", 22 "serde",
23 "serde_json", 23 "serde_json",
24 "symphonia", 24 "symphonia",
25 "symphonia-adapter-libopus",
25 "toml", 26 "toml",
26 "zbus", 27 "zbus",
27] 28]
@@ -1654,6 +1655,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1654checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1655checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
1655 1656
1656[[package]] 1657[[package]]
1658name = "opusic-sys"
1659version = "0.7.5"
1660source = "registry+https://github.com/rust-lang/crates.io-index"
1661checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a"
1662dependencies = [
1663 "cmake",
1664]
1665
1666[[package]]
1657name = "ordered-stream" 1667name = "ordered-stream"
1658version = "0.2.0" 1668version = "0.2.0"
1659source = "registry+https://github.com/rust-lang/crates.io-index" 1669source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2325,14 +2335,28 @@ dependencies = [
2325 "lazy_static", 2335 "lazy_static",
2326 "symphonia-bundle-flac", 2336 "symphonia-bundle-flac",
2327 "symphonia-bundle-mp3", 2337 "symphonia-bundle-mp3",
2338 "symphonia-codec-aac",
2328 "symphonia-codec-vorbis", 2339 "symphonia-codec-vorbis",
2329 "symphonia-core", 2340 "symphonia-core",
2341 "symphonia-format-isomp4",
2342 "symphonia-format-mkv",
2330 "symphonia-format-ogg", 2343 "symphonia-format-ogg",
2331 "symphonia-format-riff", 2344 "symphonia-format-riff",
2332 "symphonia-metadata", 2345 "symphonia-metadata",
2333] 2346]
2334 2347
2335[[package]] 2348[[package]]
2349name = "symphonia-adapter-libopus"
2350version = "0.3.0"
2351source = "registry+https://github.com/rust-lang/crates.io-index"
2352checksum = "c6febe6f88f9a9483db7e5b72a2dad916d8f8eb588d18905a39c861319fc7fa1"
2353dependencies = [
2354 "log",
2355 "opusic-sys",
2356 "symphonia-core",
2357]
2358
2359[[package]]
2336name = "symphonia-bundle-flac" 2360name = "symphonia-bundle-flac"
2337version = "0.6.0" 2361version = "0.6.0"
2338source = "registry+https://github.com/rust-lang/crates.io-index" 2362source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2356,6 +2380,18 @@ dependencies = [
2356] 2380]
2357 2381
2358[[package]] 2382[[package]]
2383name = "symphonia-codec-aac"
2384version = "0.6.0"
2385source = "registry+https://github.com/rust-lang/crates.io-index"
2386checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76"
2387dependencies = [
2388 "lazy_static",
2389 "log",
2390 "symphonia-common",
2391 "symphonia-core",
2392]
2393
2394[[package]]
2359name = "symphonia-codec-vorbis" 2395name = "symphonia-codec-vorbis"
2360version = "0.6.0" 2396version = "0.6.0"
2361source = "registry+https://github.com/rust-lang/crates.io-index" 2397source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2392,6 +2428,30 @@ dependencies = [
2392] 2428]
2393 2429
2394[[package]] 2430[[package]]
2431name = "symphonia-format-isomp4"
2432version = "0.6.0"
2433source = "registry+https://github.com/rust-lang/crates.io-index"
2434checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e"
2435dependencies = [
2436 "log",
2437 "symphonia-common",
2438 "symphonia-core",
2439 "symphonia-metadata",
2440]
2441
2442[[package]]
2443name = "symphonia-format-mkv"
2444version = "0.6.0"
2445source = "registry+https://github.com/rust-lang/crates.io-index"
2446checksum = "fb17713e134f5ad316c2690fa3104590ccc85842cdbcf82c3cd1a845cb08aa74"
2447dependencies = [
2448 "lazy_static",
2449 "log",
2450 "symphonia-common",
2451 "symphonia-core",
2452]
2453
2454[[package]]
2395name = "symphonia-format-ogg" 2455name = "symphonia-format-ogg"
2396version = "0.6.0" 2456version = "0.6.0"
2397source = "registry+https://github.com/rust-lang/crates.io-index" 2457source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index e3567d7..51eebbb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,7 +24,8 @@ directories = "6.0"
24serde = { version = "1.0.228", features = ["derive"] } 24serde = { version = "1.0.228", features = ["derive"] }
25toml = "1.1.2" 25toml = "1.1.2"
26reqwest = { version = "0.13.3", features = ["json", "form", "blocking"] } 26reqwest = { version = "0.13.3", features = ["json", "form", "blocking"] }
27symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "ogg", "id3v1", "id3v2"] } 27symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "ogg", "mkv", "aac", "isomp4", "id3v1", "id3v2"] }
28symphonia-adapter-libopus = "0.3"
28cpal = "0.17.3" 29cpal = "0.17.3"
29serde_json = "1.0.149" 30serde_json = "1.0.149"
30dirs = "6.0" 31dirs = "6.0"
diff --git a/src/audio.rs b/src/audio.rs
index f8eba04..8bac26f 100644
--- a/src/audio.rs
+++ b/src/audio.rs
@@ -8,10 +8,20 @@
8use crate::notification; 8use crate::notification;
9use std::io::{Read, Seek, SeekFrom}; 9use std::io::{Read, Seek, SeekFrom};
10use std::path::{Path, PathBuf}; 10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicBool, Ordering}; 11use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12use std::sync::mpsc::{self, Receiver, Sender}; 12use std::sync::mpsc::{self, Receiver, Sender};
13use std::sync::{Arc, Mutex}; 13use std::sync::{Arc, LazyLock, Mutex};
14use std::thread; 14use std::thread;
15use std::time::{Duration, Instant};
16use symphonia::core::codecs::registry::CodecRegistry;
17use symphonia_adapter_libopus::OpusDecoder;
18
19static CODEC_REGISTRY: LazyLock<CodecRegistry> = LazyLock::new(|| {
20 let mut registry = CodecRegistry::new();
21 symphonia::default::register_enabled_codecs(&mut registry);
22 registry.register_audio_decoder::<OpusDecoder>();
23 registry
24});
15 25
16struct HttpMediaSource { 26struct HttpMediaSource {
17 reader: Mutex<Box<dyn Read + Send>>, 27 reader: Mutex<Box<dyn Read + Send>>,
@@ -249,6 +259,353 @@ fn resample_linear(input: &[f32], ratio: f64, channels: usize) -> Vec<f32> {
249 output 259 output
250} 260}
251 261
262const WATCHDOG_INTERVAL: Duration = Duration::from_millis(250);
263/// A playing stream must fill a buffer every few milliseconds, so a gap this
264/// long means the backend stopped driving the callback entirely.
265const STALL_TIMEOUT: Duration = Duration::from_secs(2);
266/// A stream that stayed up at least this long is considered healthy: its
267/// failure does not count against the retry budget.
268const HEALTHY_STREAM: Duration = Duration::from_secs(15);
269const MAX_RECOVERIES: usize = 3;
270const RECOVERY_WINDOW: Duration = Duration::from_secs(60);
271/// Delay before rebuilding after a stream died almost immediately. Doubled per
272/// consecutive failure, so a device that is really gone is not hammered.
273const RETRY_BACKOFF: Duration = Duration::from_millis(250);
274const MAX_BACKOFF: Duration = Duration::from_secs(2);
275
276struct StreamContext {
277 audio_data: Arc<Mutex<Option<AudioData>>>,
278 volume: Arc<Mutex<f32>>,
279 track_ended: Arc<AtomicBool>,
280 is_playing: Arc<AtomicBool>,
281 position: Arc<Mutex<f64>>,
282 failed: Arc<AtomicBool>,
283 callbacks: Arc<AtomicU64>,
284 /// Error callbacks seen on the current stream. A dead PCM fires the error
285 /// callback hundreds of thousands of times per second, so it is only
286 /// counted; the tally is reported once, when the stream is torn down.
287 stream_errors: Arc<AtomicU64>,
288 /// Message of the first error on the current stream, formatted once.
289 first_error: Arc<Mutex<Option<String>>>,
290}
291
292fn make_data_callback(
293 ctx: &StreamContext,
294 device_sample_rate: u32,
295) -> impl FnMut(&mut [f32], &cpal::OutputCallbackInfo) + Send + 'static {
296 let audio_data = Arc::clone(&ctx.audio_data);
297 let volume = Arc::clone(&ctx.volume);
298 let track_ended = Arc::clone(&ctx.track_ended);
299 let is_playing = Arc::clone(&ctx.is_playing);
300 let position = Arc::clone(&ctx.position);
301 let callbacks = Arc::clone(&ctx.callbacks);
302
303 move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
304 // Liveness marker for the watchdog: a relaxed increment is enough and
305 // avoids a clock read in the realtime callback.
306 callbacks.fetch_add(1, Ordering::Relaxed);
307
308 let vol = volume.lock().map(|v| *v).unwrap_or(1.0);
309
310 if let Ok(mut audio_data) = audio_data.lock() {
311 let mut should_clear = false;
312
313 if let Some(ref mut state) = *audio_data {
314 let channels = state.channels as usize;
315 let mut offset = 0usize;
316
317 // Keep decoding until we fill the buffer or run out of data
318 while offset < data.len() {
319 // First, copy any buffered samples
320 if state.buffer_pos < state.sample_buffer.len() {
321 let remaining = &state.sample_buffer[state.buffer_pos..];
322 let to_copy = remaining.len().min(data.len() - offset);
323 data[offset..offset + to_copy].copy_from_slice(&remaining[..to_copy]);
324 state.buffer_pos += to_copy;
325 offset += to_copy;
326
327 // If we've consumed all buffered samples, clear the buffer
328 if state.buffer_pos >= state.sample_buffer.len() {
329 state.sample_buffer.clear();
330 state.buffer_pos = 0;
331 }
332 continue; // Try to fill more of the buffer
333 }
334
335 // Decode more data
336 match state.format.next_packet() {
337 Ok(Some(packet)) => {
338 match state.decoder.decode(&packet) {
339 Ok(decoded) => {
340 let num_samples = decoded.samples_interleaved();
341 let mut samples = vec![0.0f32; num_samples];
342 decoded.copy_to_slice_interleaved(&mut samples);
343
344 // Resample if needed
345 let resampled = if let Some(ratio) = state.resample_ratio {
346 resample_linear(&samples, ratio, channels)
347 } else {
348 samples
349 };
350
351 // Copy what we can to output
352 let to_copy = resampled.len().min(data.len() - offset);
353 data[offset..offset + to_copy]
354 .copy_from_slice(&resampled[..to_copy]);
355 offset += to_copy;
356
357 // Buffer the rest
358 if to_copy < resampled.len() {
359 state.sample_buffer.clear();
360 state
361 .sample_buffer
362 .extend_from_slice(&resampled[to_copy..]);
363 state.buffer_pos = 0;
364 }
365 }
366 Err(_) => break,
367 }
368 }
369 _ => break,
370 }
371 }
372
373 // Apply volume
374 for sample in data.iter_mut().take(offset) {
375 *sample *= vol;
376 }
377
378 // Fill remaining with silence
379 for sample in data.iter_mut().skip(offset) {
380 *sample = 0.0;
381 }
382
383 // Track playback position
384 if offset > 0
385 && let Ok(mut pos) = position.lock()
386 {
387 *pos += offset as f64 / (device_sample_rate as f64 * channels as f64);
388 }
389
390 // If no more data and offset is 0, track ended
391 if offset == 0 && state.sample_buffer.is_empty() {
392 track_ended.store(true, Ordering::SeqCst);
393 is_playing.store(false, Ordering::SeqCst);
394 should_clear = true;
395 }
396 } else {
397 for sample in data.iter_mut() {
398 *sample = 0.0;
399 }
400 }
401
402 if should_clear {
403 *audio_data = None;
404 }
405 }
406 }
407}
408
409fn build_output_stream(
410 device: &cpal::Device,
411 config: cpal::SupportedStreamConfig,
412 ctx: &StreamContext,
413) -> Result<cpal::Stream, String> {
414 use cpal::traits::{DeviceTrait, StreamTrait};
415
416 let device_sample_rate = config.sample_rate();
417 let stream_config: cpal::StreamConfig = config.into();
418 let failed = Arc::clone(&ctx.failed);
419 let errors = Arc::clone(&ctx.stream_errors);
420 let first_error = Arc::clone(&ctx.first_error);
421
422 // Every stream gets a fresh tally, so a report only covers this one.
423 errors.store(0, Ordering::Relaxed);
424 if let Ok(mut slot) = first_error.lock() {
425 *slot = None;
426 }
427 let error_seen = AtomicBool::new(false);
428
429 let stream = device
430 .build_output_stream(
431 &stream_config,
432 make_data_callback(ctx, device_sample_rate),
433 move |err: cpal::StreamError| {
434 // Buffer underruns are reported but already recovered by the
435 // backend, so they must not tear down a working stream.
436 if matches!(err, cpal::StreamError::BufferUnderrun) {
437 if errors.fetch_add(1, Ordering::Relaxed) == 0 {
438 eprintln!("Audio buffer underrun");
439 }
440 return;
441 }
442 failed.store(true, Ordering::SeqCst);
443 // A broken PCM spins on `poll()` and reports the same error
444 // again immediately: keep this path free of allocation and
445 // logging, and format the message only for the first one.
446 errors.fetch_add(1, Ordering::Relaxed);
447 if !error_seen.swap(true, Ordering::Relaxed)
448 && let Ok(mut slot) = first_error.lock()
449 {
450 *slot = Some(err.to_string());
451 }
452 },
453 None,
454 )
455 .map_err(|e| e.to_string())?;
456
457 stream.play().map_err(|e| e.to_string())?;
458
459 Ok(stream)
460}
461
462/// Reports the error that tore the current stream down.
463///
464/// The error callback only counts, because a dead backend calls it hundreds of
465/// thousands of times per second; the message and the tally are printed here,
466/// once, from the watchdog thread.
467fn log_stream_error(ctx: &StreamContext) {
468 let count = ctx.stream_errors.swap(0, Ordering::Relaxed);
469 let Ok(mut slot) = ctx.first_error.lock() else {
470 return;
471 };
472 let Some(message) = slot.take() else {
473 return;
474 };
475 if count > 1 {
476 eprintln!("Stream error: {} ({} callbacks)", message, count);
477 } else {
478 eprintln!("Stream error: {}", message);
479 }
480}
481
482/// Stops playback of the current track after the output device stopped working,
483/// so the queue can move on instead of reporting "Playing" in silence.
484fn abandon_playback(ctx: &StreamContext) {
485 if let Ok(mut d) = ctx.audio_data.lock() {
486 *d = None;
487 }
488 ctx.is_playing.store(false, Ordering::SeqCst);
489 ctx.track_ended.store(true, Ordering::SeqCst);
490 notification::notify_error("Audio output unavailable");
491}
492
493/// Stops trying once the retry budget is spent.
494fn give_up(recovery: &mut RecoveryState, ctx: &StreamContext) {
495 eprintln!(
496 "Audio stream failed {} times within {}s, giving up",
497 MAX_RECOVERIES,
498 RECOVERY_WINDOW.as_secs()
499 );
500 recovery.retry_at = None;
501 abandon_playback(ctx);
502}
503
504/// Decides how the audio thread recovers from a stream the backend broke.
505///
506/// cpal's ALSA backend cannot revive a dead PCM: `poll()` keeps returning
507/// `POLLERR`, the data callback is never called again, and the worker thread
508/// spins at full speed. The only way out is to drop the stream and build a new
509/// one; the decoded audio lives in [`StreamContext::audio_data`], so playback
510/// continues from where it stopped.
511///
512/// Rebuilding is cheap, but a device that is really gone would be hammered in a
513/// loop, so failures are budgeted:
514///
515/// * the first failure of a healthy stream is retried immediately,
516/// * consecutive failures back off further each time,
517/// * a stream that played for [`HEALTHY_STREAM`] clears the budget, and
518/// * [`MAX_RECOVERIES`] failures inside [`RECOVERY_WINDOW`] give up.
519#[derive(Default)]
520struct RecoveryState {
521 /// When the current stream was built.
522 stream_started: Option<Instant>,
523 /// Failures inside [`RECOVERY_WINDOW`].
524 failures: Vec<Instant>,
525 /// Failures since the last stream that played long enough to count.
526 consecutive: usize,
527 /// Earliest instant at which the next rebuild may start.
528 retry_at: Option<Instant>,
529}
530
531impl RecoveryState {
532 /// Forgets the whole budget, for a new track or an explicit stop.
533 fn clear(&mut self) {
534 *self = Self::default();
535 }
536
537 fn stream_built(&mut self, now: Instant) {
538 self.stream_started = Some(now);
539 }
540
541 /// Records a failure and returns how long to wait before rebuilding, or
542 /// `None` when the backend has failed too often to keep trying.
543 fn failed(&mut self, now: Instant) -> Option<Duration> {
544 let uptime = self
545 .stream_started
546 .map_or(Duration::ZERO, |t| now.saturating_duration_since(t));
547 self.failures
548 .retain(|t| now.saturating_duration_since(*t) < RECOVERY_WINDOW);
549 if uptime >= HEALTHY_STREAM {
550 self.failures.clear();
551 self.consecutive = 0;
552 }
553 if self.failures.len() >= MAX_RECOVERIES {
554 return None;
555 }
556 self.failures.push(now);
557 // The first retry is immediate: the outage is already audible, and a
558 // healthy device comes back without a gap. Everything after that backs
559 // off, because retrying straight away clearly did not help.
560 let delay = if self.consecutive == 0 {
561 Duration::ZERO
562 } else {
563 let shift = (self.consecutive as u32 - 1).min(4);
564 (RETRY_BACKOFF * 2u32.pow(shift)).min(MAX_BACKOFF)
565 };
566 self.consecutive += 1;
567 self.retry_at = Some(now + delay);
568 Some(delay)
569 }
570}
571
572/// Rebuilds the output stream after the backend broke it.
573///
574/// The default output can change while the daemon runs (a re-plugged DAC, a
575/// sink switch, a rate change), which leaves the cached device handle pointing
576/// at a PCM that no longer exists, so it is looked up again.
577fn rebuild_stream(
578 host: &cpal::Host,
579 device: &mut cpal::Device,
580 ctx: &StreamContext,
581) -> Result<cpal::Stream, String> {
582 use cpal::traits::{DeviceTrait, HostTrait};
583
584 if let Some(fresh) = host.default_output_device() {
585 *device = fresh;
586 }
587
588 let config = device
589 .default_output_config()
590 .map_err(|e| format!("Failed to get output config while rebuilding: {}", e))?;
591 let device_sample_rate = config.sample_rate();
592
593 // The device may report a different rate now than when the track was
594 // opened, so re-target the resampler before resuming.
595 if let Ok(mut d) = ctx.audio_data.lock()
596 && let Some(state) = d.as_mut()
597 && state.sample_rate != device_sample_rate
598 {
599 crate::debug_eprintln(format_args!(
600 "Retargeting resampler from {} Hz to {} Hz after recovery",
601 state.sample_rate, device_sample_rate
602 ));
603 state.resample_ratio = Some(device_sample_rate as f64 / state.sample_rate as f64);
604 }
605
606 build_output_stream(device, config, ctx)
607}
608
252fn audio_thread( 609fn audio_thread(
253 rx: Receiver<AudioCommand>, 610 rx: Receiver<AudioCommand>,
254 is_playing: Arc<AtomicBool>, 611 is_playing: Arc<AtomicBool>,
@@ -260,7 +617,7 @@ fn audio_thread(
260 use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; 617 use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
261 618
262 let host = cpal::default_host(); 619 let host = cpal::default_host();
263 let device = match host.default_output_device() { 620 let mut device = match host.default_output_device() {
264 Some(d) => d, 621 Some(d) => d,
265 None => { 622 None => {
266 eprintln!("No output device available"); 623 eprintln!("No output device available");
@@ -268,270 +625,240 @@ fn audio_thread(
268 } 625 }
269 }; 626 };
270 627
271 let config = match device.default_output_config() {
272 Ok(c) => c,
273 Err(e) => {
274 eprintln!("Failed to get output config: {}", e);
275 return;
276 }
277 };
278
279 let device_sample_rate = config.sample_rate();
280 let channels = config.channels();
281 crate::debug_eprintln(format_args!(
282 "Default output: {} Hz, {} channels",
283 device_sample_rate, channels
284 ));
285
286 let mut current_stream: Option<cpal::Stream> = None; 628 let mut current_stream: Option<cpal::Stream> = None;
287 let audio_data: Arc<Mutex<Option<AudioData>>> = Arc::new(Mutex::new(None)); 629 let ctx = StreamContext {
288 630 audio_data: Arc::new(Mutex::new(None)),
289 while let Ok(cmd) = rx.recv() { 631 volume: Arc::clone(&volume),
290 match cmd { 632 track_ended: Arc::clone(&track_ended),
291 AudioCommand::Play(path) => { 633 is_playing: Arc::clone(&is_playing),
292 is_playing.store(true, Ordering::SeqCst); 634 position: Arc::clone(&position),
293 if let Ok(mut track) = current_track.lock() { 635 failed: Arc::new(AtomicBool::new(false)),
294 *track = Some(path.clone()); 636 callbacks: Arc::new(AtomicU64::new(0)),
295 } 637 stream_errors: Arc::new(AtomicU64::new(0)),
296 if let Ok(mut pos) = position.lock() { 638 first_error: Arc::new(Mutex::new(None)),
297 *pos = 0.0; 639 };
298 }
299
300 // Drop the current stream before creating a new one
301 current_stream = None;
302 640
303 match open_audio_file(&path) { 641 let mut recovery = RecoveryState::default();
304 Ok(mut data) => { 642 let mut last_callbacks = 0u64;
305 crate::debug_eprintln(format_args!( 643 let mut last_progress = Instant::now();
306 "File: {} Hz, {} channels", 644
307 data.sample_rate, data.channels 645 loop {
308 )); 646 // Wake up early when a rebuild is due, so the backoff does not add
647 // jitter on top of the watchdog tick.
648 let wait = if current_stream.is_none() && is_playing.load(Ordering::SeqCst) {
649 match recovery.retry_at {
650 Some(at) => WATCHDOG_INTERVAL.min(at.saturating_duration_since(Instant::now())),
651 None => WATCHDOG_INTERVAL,
652 }
653 } else {
654 WATCHDOG_INTERVAL
655 };
656
657 let cmd = match rx.recv_timeout(wait) {
658 Ok(cmd) => Some(cmd),
659 Err(mpsc::RecvTimeoutError::Timeout) => None,
660 Err(mpsc::RecvTimeoutError::Disconnected) => break,
661 };
662
663 if let Some(cmd) = cmd {
664 match cmd {
665 AudioCommand::Play(path) => {
666 is_playing.store(true, Ordering::SeqCst);
667 if let Ok(mut track) = current_track.lock() {
668 *track = Some(path.clone());
669 }
670 if let Ok(mut pos) = position.lock() {
671 *pos = 0.0;
672 }
309 673
310 if data.sample_rate != device_sample_rate { 674 // Drop the current stream before creating a new one
675 current_stream = None;
676 recovery.clear();
677 ctx.failed.store(false, Ordering::SeqCst);
678
679 // Query the config every time instead of caching it at
680 // startup: the default device or its rate can change while
681 // the daemon runs.
682 let config = match device.default_output_config() {
683 Ok(c) => c,
684 Err(e) => {
685 eprintln!("Failed to get output config: {}", e);
686 notification::notify_error("Failed to query audio output");
687 is_playing.store(false, Ordering::SeqCst);
688 continue;
689 }
690 };
691 let device_sample_rate = config.sample_rate();
692 crate::debug_eprintln(format_args!(
693 "Default output: {} Hz, {} channels",
694 device_sample_rate,
695 config.channels()
696 ));
697
698 match open_audio_file(&path) {
699 Ok(mut data) => {
311 crate::debug_eprintln(format_args!( 700 crate::debug_eprintln(format_args!(
312 "Resampling from {} Hz to {} Hz", 701 "File: {} Hz, {} channels",
313 data.sample_rate, device_sample_rate 702 data.sample_rate, data.channels
314 )); 703 ));
315 data.resample_ratio =
316 Some(device_sample_rate as f64 / data.sample_rate as f64);
317 }
318 704
319 // Pre-decode some samples before starting playback to prevent buffer underrun 705 if data.sample_rate != device_sample_rate {
320 let channels = data.channels as usize; 706 crate::debug_eprintln(format_args!(
321 for _ in 0..100 { 707 "Resampling from {} Hz to {} Hz",
322 match data.format.next_packet() { 708 data.sample_rate, device_sample_rate
323 Ok(Some(packet)) => match data.decoder.decode(&packet) { 709 ));
324 Ok(decoded) => { 710 data.resample_ratio =
325 let num_samples = decoded.samples_interleaved(); 711 Some(device_sample_rate as f64 / data.sample_rate as f64);
326 let mut samples = vec![0.0f32; num_samples];
327 decoded.copy_to_slice_interleaved(&mut samples);
328
329 let resampled = if let Some(ratio) = data.resample_ratio {
330 resample_linear(&samples, ratio, channels)
331 } else {
332 samples
333 };
334
335 data.sample_buffer.extend(resampled);
336 }
337 Err(_) => break,
338 },
339 _ => break,
340 } 712 }
341 }
342 data.buffer_pos = 0;
343
344 let stream_config: cpal::StreamConfig =
345 device.default_output_config().unwrap().into();
346
347 let audio_data_clone = Arc::clone(&audio_data);
348 let vol = Arc::clone(&volume);
349 let ended = Arc::clone(&track_ended);
350 let is_playing_clone = Arc::clone(&is_playing);
351 let position_clone = Arc::clone(&position);
352
353 if let Ok(mut d) = audio_data.lock() {
354 *d = Some(data);
355 }
356
357 match device.build_output_stream(
358 &stream_config,
359 move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
360 let vol = vol.lock().map(|v| *v).unwrap_or(1.0);
361
362 if let Ok(mut audio_data) = audio_data_clone.lock() {
363 let mut should_clear = false;
364
365 if let Some(ref mut state) = *audio_data {
366 let channels = state.channels as usize;
367 let mut offset = 0usize;
368
369 // Keep decoding until we fill the buffer or run out of data
370 while offset < data.len() {
371 // First, copy any buffered samples
372 if state.buffer_pos < state.sample_buffer.len() {
373 let remaining =
374 &state.sample_buffer[state.buffer_pos..];
375 let to_copy =
376 remaining.len().min(data.len() - offset);
377 data[offset..offset + to_copy]
378 .copy_from_slice(&remaining[..to_copy]);
379 state.buffer_pos += to_copy;
380 offset += to_copy;
381
382 // If we've consumed all buffered samples, clear the buffer
383 if state.buffer_pos >= state.sample_buffer.len() {
384 state.sample_buffer.clear();
385 state.buffer_pos = 0;
386 }
387 continue; // Try to fill more of the buffer
388 }
389
390 // Decode more data
391 match state.format.next_packet() {
392 Ok(Some(packet)) => {
393 match state.decoder.decode(&packet) {
394 Ok(decoded) => {
395 let num_samples =
396 decoded.samples_interleaved();
397 let mut samples =
398 vec![0.0f32; num_samples];
399 decoded.copy_to_slice_interleaved(
400 &mut samples,
401 );
402
403 // Resample if needed
404 let resampled = if let Some(ratio) =
405 state.resample_ratio
406 {
407 resample_linear(
408 &samples, ratio, channels,
409 )
410 } else {
411 samples
412 };
413
414 // Copy what we can to output
415 let to_copy = resampled
416 .len()
417 .min(data.len() - offset);
418 data[offset..offset + to_copy]
419 .copy_from_slice(
420 &resampled[..to_copy],
421 );
422 offset += to_copy;
423
424 // Buffer the rest
425 if to_copy < resampled.len() {
426 state.sample_buffer.clear();
427 state
428 .sample_buffer
429 .extend_from_slice(
430 &resampled[to_copy..],
431 );
432 state.buffer_pos = 0;
433 }
434 }
435 Err(_) => break,
436 }
437 }
438 _ => break,
439 }
440 }
441 713
442 // Apply volume 714 // Pre-decode some samples before starting playback to prevent buffer underrun
443 for sample in data.iter_mut().take(offset) { 715 let channels = data.channels as usize;
444 *sample *= vol; 716 for _ in 0..100 {
445 } 717 match data.format.next_packet() {
446 718 Ok(Some(packet)) => match data.decoder.decode(&packet) {
447 // Fill remaining with silence 719 Ok(decoded) => {
448 for sample in data.iter_mut().skip(offset) { 720 let num_samples = decoded.samples_interleaved();
449 *sample = 0.0; 721 let mut samples = vec![0.0f32; num_samples];
450 } 722 decoded.copy_to_slice_interleaved(&mut samples);
451 723
452 // Track playback position 724 let resampled = if let Some(ratio) = data.resample_ratio
453 if offset > 0 725 {
454 && let Ok(mut pos) = position_clone.lock() 726 resample_linear(&samples, ratio, channels)
455 { 727 } else {
456 *pos += offset as f64 728 samples
457 / (device_sample_rate as f64 * channels as f64); 729 };
730
731 data.sample_buffer.extend(resampled);
458 } 732 }
733 Err(_) => break,
734 },
735 _ => break,
736 }
737 }
738 data.buffer_pos = 0;
459 739
460 // If no more data and offset is 0, track ended 740 if let Ok(mut d) = ctx.audio_data.lock() {
461 if offset == 0 && state.sample_buffer.is_empty() { 741 *d = Some(data);
462 ended.store(true, Ordering::SeqCst); 742 }
463 is_playing_clone.store(false, Ordering::SeqCst);
464 should_clear = true;
465 }
466 } else {
467 for sample in data.iter_mut() {
468 *sample = 0.0;
469 }
470 }
471 743
472 if should_clear { 744 match build_output_stream(&device, config, &ctx) {
473 *audio_data = None; 745 Ok(stream) => {
474 } 746 current_stream = Some(stream);
747 recovery.stream_built(Instant::now());
475 } 748 }
476 }, 749 Err(e) => {
477 |err| { 750 eprintln!("Failed to create stream: {}", e);
478 eprintln!("Stream error: {}", err); 751 notification::notify_error("Failed to create audio stream");
479 },
480 None,
481 ) {
482 Ok(stream) => {
483 if let Err(e) = stream.play() {
484 eprintln!("Failed to start stream: {}", e);
485 notification::notify_error("Failed to start audio stream");
486 is_playing.store(false, Ordering::SeqCst); 752 is_playing.store(false, Ordering::SeqCst);
487 } else {
488 current_stream = Some(stream);
489 } 753 }
490 } 754 }
491 Err(e) => { 755 }
492 eprintln!("Failed to create stream: {}", e); 756 Err(e) => {
493 notification::notify_error("Failed to create audio stream"); 757 eprintln!("Failed to open audio: {}", e);
494 is_playing.store(false, Ordering::SeqCst); 758 notification::notify_error("Failed to open audio file");
495 } 759 is_playing.store(false, Ordering::SeqCst);
496 } 760 }
497 } 761 }
498 Err(e) => { 762
499 eprintln!("Failed to open audio: {}", e); 763 // Opening a file (or an http stream) can take a while, so
500 notification::notify_error("Failed to open audio file"); 764 // restart the stall window once the stream is up.
765 last_progress = Instant::now();
766 }
767 AudioCommand::Pause => {
768 if let Some(ref stream) = current_stream {
769 if let Err(e) = stream.pause() {
770 eprintln!("Failed to pause stream: {}", e);
771 }
501 is_playing.store(false, Ordering::SeqCst); 772 is_playing.store(false, Ordering::SeqCst);
502 } 773 }
503 } 774 }
504 } 775 AudioCommand::Resume => {
505 AudioCommand::Pause => { 776 if let Some(ref stream) = current_stream {
506 if let Some(ref stream) = current_stream { 777 if let Err(e) = stream.play() {
507 if let Err(e) = stream.pause() { 778 eprintln!("Failed to resume stream: {}", e);
508 eprintln!("Failed to pause stream: {}", e); 779 } else {
780 is_playing.store(true, Ordering::SeqCst);
781 last_progress = Instant::now();
782 }
783 } else if ctx.audio_data.lock().is_ok_and(|d| d.is_some()) {
784 // The stream was dropped while paused because the
785 // backend broke it; let the recovery path rebuild it
786 // instead of resuming into a dead stream.
787 is_playing.store(true, Ordering::SeqCst);
788 recovery.retry_at = Some(Instant::now());
789 }
790 }
791 AudioCommand::Stop => {
792 current_stream = None;
793 recovery.clear();
794 if let Ok(mut d) = ctx.audio_data.lock() {
795 *d = None;
509 } 796 }
510 is_playing.store(false, Ordering::SeqCst); 797 is_playing.store(false, Ordering::SeqCst);
798 if let Ok(mut track) = current_track.lock() {
799 *track = None;
800 }
511 } 801 }
512 } 802 AudioCommand::SetVolume(vol) => {
513 AudioCommand::Resume => { 803 if let Ok(mut volume_ref) = volume.lock() {
514 if let Some(ref stream) = current_stream { 804 *volume_ref = vol;
515 if let Err(e) = stream.play() {
516 eprintln!("Failed to resume stream: {}", e);
517 } else {
518 is_playing.store(true, Ordering::SeqCst);
519 } 805 }
520 } 806 }
521 } 807 }
522 AudioCommand::Stop => { 808 }
523 current_stream = None; 809
524 if let Ok(mut d) = audio_data.lock() { 810 // Watchdog: the backend never brings a dead stream back on its own.
525 *d = None; 811 // It either reports a fatal error and then stops feeding the callback
526 } 812 // (`POLLERR` and friends), or - when the audio server is restarted or
527 is_playing.store(false, Ordering::SeqCst); 813 // changed underneath it - silently stops scheduling the callback.
528 if let Ok(mut track) = current_track.lock() { 814 // Both look the same from the outside: playback is dead while the
529 *track = None; 815 // daemon still reports "Playing", so react to either one at once.
530 } 816 let failed = ctx.failed.swap(false, Ordering::SeqCst);
817 let callbacks = ctx.callbacks.load(Ordering::Relaxed);
818 let advanced = callbacks != last_callbacks;
819 if advanced {
820 last_callbacks = callbacks;
821 last_progress = Instant::now();
822 }
823 let stalled = current_stream.is_some()
824 && !advanced
825 && is_playing.load(Ordering::SeqCst)
826 && last_progress.elapsed() > STALL_TIMEOUT;
827
828 if current_stream.is_some() && (failed || stalled) {
829 if stalled {
830 eprintln!("Audio stream stalled, rebuilding it");
831 }
832 // Dropping the stream is what stops the backend spinning on the
833 // dead PCM, so do that before deciding whether to retry.
834 current_stream = None;
835 ctx.failed.store(false, Ordering::SeqCst);
836 last_progress = Instant::now();
837 log_stream_error(&ctx);
838
839 if recovery.failed(Instant::now()).is_none() {
840 give_up(&mut recovery, &ctx);
531 } 841 }
532 AudioCommand::SetVolume(vol) => { 842 }
533 if let Ok(mut volume_ref) = volume.lock() { 843
534 *volume_ref = vol; 844 // Rebuild once the backoff has elapsed. Playback has to be wanted and
845 // the stream gone, otherwise a paused daemon would start audio.
846 let retry_due = current_stream.is_none()
847 && is_playing.load(Ordering::SeqCst)
848 && recovery.retry_at.is_some_and(|at| at <= Instant::now());
849 if retry_due {
850 recovery.retry_at = None;
851 match rebuild_stream(&host, &mut device, &ctx) {
852 Ok(stream) => {
853 current_stream = Some(stream);
854 recovery.stream_built(Instant::now());
855 last_progress = Instant::now();
856 }
857 Err(e) => {
858 eprintln!("Failed to rebuild audio stream: {}", e);
859 if recovery.failed(Instant::now()).is_none() {
860 give_up(&mut recovery, &ctx);
861 }
535 } 862 }
536 } 863 }
537 } 864 }
@@ -609,8 +936,8 @@ fn open_audio_file(path: &PathBuf) -> Result<AudioData, Box<dyn std::error::Erro
609 .unwrap_or(CHANNEL_LAYOUT_STEREO) 936 .unwrap_or(CHANNEL_LAYOUT_STEREO)
610 .count() as u16; 937 .count() as u16;
611 938
612 let decoder = symphonia::default::get_codecs() 939 let decoder =
613 .make_audio_decoder(codec_params, &AudioDecoderOptions::default())?; 940 CODEC_REGISTRY.make_audio_decoder(codec_params, &AudioDecoderOptions::default())?;
614 941
615 Ok(AudioData { 942 Ok(AudioData {
616 format, 943 format,
@@ -622,3 +949,133 @@ fn open_audio_file(path: &PathBuf) -> Result<AudioData, Box<dyn std::error::Erro
622 resample_ratio: None, 949 resample_ratio: None,
623 }) 950 })
624} 951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956
957 fn test_context() -> StreamContext {
958 StreamContext {
959 audio_data: Arc::new(Mutex::new(None)),
960 volume: Arc::new(Mutex::new(1.0)),
961 track_ended: Arc::new(AtomicBool::new(false)),
962 is_playing: Arc::new(AtomicBool::new(true)),
963 position: Arc::new(Mutex::new(0.0)),
964 failed: Arc::new(AtomicBool::new(false)),
965 callbacks: Arc::new(AtomicU64::new(0)),
966 stream_errors: Arc::new(AtomicU64::new(0)),
967 first_error: Arc::new(Mutex::new(None)),
968 }
969 }
970
971 #[test]
972 fn first_failure_of_a_healthy_stream_retries_immediately() {
973 let mut state = RecoveryState::default();
974 let start = Instant::now();
975 state.stream_built(start);
976
977 let delay = state.failed(start + Duration::from_secs(30));
978
979 assert_eq!(delay, Some(Duration::ZERO), "retry without adding silence");
980 assert_eq!(state.retry_at, Some(start + Duration::from_secs(30)));
981 }
982
983 #[test]
984 fn fast_failures_back_off_then_give_up() {
985 let mut state = RecoveryState::default();
986 let start = Instant::now();
987 let mut delays = Vec::new();
988
989 for i in 0..4 {
990 let at = start + Duration::from_millis(100 * i);
991 state.stream_built(at);
992 delays.push(state.failed(at + Duration::from_millis(50)));
993 }
994
995 assert_eq!(
996 delays,
997 vec![
998 Some(Duration::ZERO),
999 Some(RETRY_BACKOFF),
1000 Some(RETRY_BACKOFF * 2),
1001 None,
1002 ],
1003 "consecutive fast failures back off, then stop trying"
1004 );
1005 }
1006
1007 #[test]
1008 fn healthy_stream_clears_the_budget() {
1009 let mut state = RecoveryState::default();
1010 let start = Instant::now();
1011
1012 for i in 0..3 {
1013 state.stream_built(start);
1014 assert!(state.failed(start + Duration::from_secs(1 + i)).is_some());
1015 }
1016
1017 // A stream that survived long enough must not inherit the strikes.
1018 let healthy = start + Duration::from_secs(10);
1019 state.stream_built(healthy);
1020 let delay = state.failed(healthy + HEALTHY_STREAM + Duration::from_secs(1));
1021
1022 assert_eq!(delay, Some(Duration::ZERO));
1023 assert_eq!(state.failures.len(), 1);
1024 assert_eq!(state.consecutive, 1);
1025 }
1026
1027 #[test]
1028 fn stale_failures_do_not_count() {
1029 let mut state = RecoveryState::default();
1030 let start = Instant::now();
1031 state.stream_built(start);
1032 state.failures = vec![
1033 start - RECOVERY_WINDOW - Duration::from_secs(5),
1034 start - RECOVERY_WINDOW - Duration::from_secs(4),
1035 start - RECOVERY_WINDOW - Duration::from_secs(3),
1036 ];
1037
1038 assert!(state.failed(start).is_some());
1039 assert_eq!(state.failures.len(), 1);
1040 }
1041
1042 #[test]
1043 fn gives_up_once_the_budget_is_spent() {
1044 let ctx = test_context();
1045 ctx.stream_errors.store(42, Ordering::Relaxed);
1046 if let Ok(mut slot) = ctx.first_error.lock() {
1047 *slot = Some("boom".to_string());
1048 }
1049
1050 abandon_playback(&ctx);
1051
1052 assert!(!ctx.is_playing.load(Ordering::SeqCst));
1053 assert!(ctx.track_ended.load(Ordering::SeqCst));
1054 assert!(ctx.audio_data.lock().expect("lock").is_none());
1055
1056 // The tally is reported exactly once and does not leak into the next
1057 // stream.
1058 log_stream_error(&ctx);
1059 assert_eq!(ctx.stream_errors.load(Ordering::Relaxed), 0);
1060 assert!(ctx.first_error.lock().expect("lock").is_none());
1061 }
1062
1063 #[test]
1064 fn rebuilds_stream_after_error() {
1065 use cpal::traits::{HostTrait, StreamTrait};
1066
1067 let host = cpal::default_host();
1068 let Some(mut device) = host.default_output_device() else {
1069 eprintln!("skipped: no output device available");
1070 return;
1071 };
1072
1073 let ctx = test_context();
1074 let Ok(stream) = rebuild_stream(&host, &mut device, &ctx) else {
1075 eprintln!("skipped: cannot build output stream");
1076 return;
1077 };
1078
1079 stream.pause().expect("rebuilt stream is usable");
1080 }
1081}