aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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}