diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:02:11 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:02:11 +0100 |
| commit | 853b63e543bbc72081c59d7e10d7c968178ce0ae (patch) | |
| tree | 384665cadca84d1fbb1e93f260078388dcfa27bb | |
| parent | fix a bug where adding track using `now` when shuffle is on does not work as ... (diff) | |
| download | rsplayer-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.lock | 60 | ||||
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | src/audio.rs | 941 |
3 files changed, 761 insertions, 243 deletions
| @@ -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" | |||
| 1654 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" | 1655 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" |
| 1655 | 1656 | ||
| 1656 | [[package]] | 1657 | [[package]] |
| 1658 | name = "opusic-sys" | ||
| 1659 | version = "0.7.5" | ||
| 1660 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1661 | checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a" | ||
| 1662 | dependencies = [ | ||
| 1663 | "cmake", | ||
| 1664 | ] | ||
| 1665 | |||
| 1666 | [[package]] | ||
| 1657 | name = "ordered-stream" | 1667 | name = "ordered-stream" |
| 1658 | version = "0.2.0" | 1668 | version = "0.2.0" |
| 1659 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1669 | source = "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]] |
| 2349 | name = "symphonia-adapter-libopus" | ||
| 2350 | version = "0.3.0" | ||
| 2351 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2352 | checksum = "c6febe6f88f9a9483db7e5b72a2dad916d8f8eb588d18905a39c861319fc7fa1" | ||
| 2353 | dependencies = [ | ||
| 2354 | "log", | ||
| 2355 | "opusic-sys", | ||
| 2356 | "symphonia-core", | ||
| 2357 | ] | ||
| 2358 | |||
| 2359 | [[package]] | ||
| 2336 | name = "symphonia-bundle-flac" | 2360 | name = "symphonia-bundle-flac" |
| 2337 | version = "0.6.0" | 2361 | version = "0.6.0" |
| 2338 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2362 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2356,6 +2380,18 @@ dependencies = [ | |||
| 2356 | ] | 2380 | ] |
| 2357 | 2381 | ||
| 2358 | [[package]] | 2382 | [[package]] |
| 2383 | name = "symphonia-codec-aac" | ||
| 2384 | version = "0.6.0" | ||
| 2385 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2386 | checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" | ||
| 2387 | dependencies = [ | ||
| 2388 | "lazy_static", | ||
| 2389 | "log", | ||
| 2390 | "symphonia-common", | ||
| 2391 | "symphonia-core", | ||
| 2392 | ] | ||
| 2393 | |||
| 2394 | [[package]] | ||
| 2359 | name = "symphonia-codec-vorbis" | 2395 | name = "symphonia-codec-vorbis" |
| 2360 | version = "0.6.0" | 2396 | version = "0.6.0" |
| 2361 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2397 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2392,6 +2428,30 @@ dependencies = [ | |||
| 2392 | ] | 2428 | ] |
| 2393 | 2429 | ||
| 2394 | [[package]] | 2430 | [[package]] |
| 2431 | name = "symphonia-format-isomp4" | ||
| 2432 | version = "0.6.0" | ||
| 2433 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2434 | checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" | ||
| 2435 | dependencies = [ | ||
| 2436 | "log", | ||
| 2437 | "symphonia-common", | ||
| 2438 | "symphonia-core", | ||
| 2439 | "symphonia-metadata", | ||
| 2440 | ] | ||
| 2441 | |||
| 2442 | [[package]] | ||
| 2443 | name = "symphonia-format-mkv" | ||
| 2444 | version = "0.6.0" | ||
| 2445 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2446 | checksum = "fb17713e134f5ad316c2690fa3104590ccc85842cdbcf82c3cd1a845cb08aa74" | ||
| 2447 | dependencies = [ | ||
| 2448 | "lazy_static", | ||
| 2449 | "log", | ||
| 2450 | "symphonia-common", | ||
| 2451 | "symphonia-core", | ||
| 2452 | ] | ||
| 2453 | |||
| 2454 | [[package]] | ||
| 2395 | name = "symphonia-format-ogg" | 2455 | name = "symphonia-format-ogg" |
| 2396 | version = "0.6.0" | 2456 | version = "0.6.0" |
| 2397 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2457 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -24,7 +24,8 @@ directories = "6.0" | |||
| 24 | serde = { version = "1.0.228", features = ["derive"] } | 24 | serde = { version = "1.0.228", features = ["derive"] } |
| 25 | toml = "1.1.2" | 25 | toml = "1.1.2" |
| 26 | reqwest = { version = "0.13.3", features = ["json", "form", "blocking"] } | 26 | reqwest = { version = "0.13.3", features = ["json", "form", "blocking"] } |
| 27 | symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "ogg", "id3v1", "id3v2"] } | 27 | symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "ogg", "mkv", "aac", "isomp4", "id3v1", "id3v2"] } |
| 28 | symphonia-adapter-libopus = "0.3" | ||
| 28 | cpal = "0.17.3" | 29 | cpal = "0.17.3" |
| 29 | serde_json = "1.0.149" | 30 | serde_json = "1.0.149" |
| 30 | dirs = "6.0" | 31 | dirs = "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 @@ | |||
| 8 | use crate::notification; | 8 | use crate::notification; |
| 9 | use std::io::{Read, Seek, SeekFrom}; | 9 | use std::io::{Read, Seek, SeekFrom}; |
| 10 | use std::path::{Path, PathBuf}; | 10 | use std::path::{Path, PathBuf}; |
| 11 | use std::sync::atomic::{AtomicBool, Ordering}; | 11 | use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; |
| 12 | use std::sync::mpsc::{self, Receiver, Sender}; | 12 | use std::sync::mpsc::{self, Receiver, Sender}; |
| 13 | use std::sync::{Arc, Mutex}; | 13 | use std::sync::{Arc, LazyLock, Mutex}; |
| 14 | use std::thread; | 14 | use std::thread; |
| 15 | use std::time::{Duration, Instant}; | ||
| 16 | use symphonia::core::codecs::registry::CodecRegistry; | ||
| 17 | use symphonia_adapter_libopus::OpusDecoder; | ||
| 18 | |||
| 19 | static 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 | ||
| 16 | struct HttpMediaSource { | 26 | struct 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 | ||
| 262 | const 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. | ||
| 265 | const 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. | ||
| 268 | const HEALTHY_STREAM: Duration = Duration::from_secs(15); | ||
| 269 | const MAX_RECOVERIES: usize = 3; | ||
| 270 | const 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. | ||
| 273 | const RETRY_BACKOFF: Duration = Duration::from_millis(250); | ||
| 274 | const MAX_BACKOFF: Duration = Duration::from_secs(2); | ||
| 275 | |||
| 276 | struct 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 | |||
| 292 | fn 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 | |||
| 409 | fn 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. | ||
| 467 | fn 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. | ||
| 484 | fn 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. | ||
| 494 | fn 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)] | ||
| 520 | struct 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 | |||
| 531 | impl 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. | ||
| 577 | fn 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 | |||
| 252 | fn audio_thread( | 609 | fn 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)] | ||
| 954 | mod 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 | } | ||
