diff options
Diffstat (limited to '')
| -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 | } | ||
