From 90318fd6d6ac9e329c11d6dfc27191bda882d6a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:28:26 +0000 Subject: [PATCH 1/2] playback: retry the next stream config when a built stream won't start --- crates/playback/src/lib.rs | 47 +++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index b945d4b6..c16713d4 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -526,7 +526,6 @@ impl Player { let (gtx, grx) = channel(); let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx); let fault = Arc::new(Fault::default()); - use cpal::traits::StreamTrait; let built = match format { cpal::SampleFormat::I32 => build_output::(&device, &config, state, channels, &fault, &shared), cpal::SampleFormat::I16 => build_output::(&device, &config, state, channels, &fault, &shared), @@ -534,8 +533,9 @@ impl Player { }; match built { Ok(stream) => { - if let Err(e) = stream.play() { - log::warn!("audio stream failed to start: {e}"); + // A stream that builds but will not start is dropped, and the next config is tried. + if !start_stream(&stream) { + continue; } let buffer = match config.buffer_size { cpal::BufferSize::Fixed(n) => Some(n), @@ -862,6 +862,18 @@ fn fold_gains(s: &Session) -> [(f32, f32); MAX_CHANNELS] { g } +/// Start a freshly built output stream. `false` when the device refuses to start it, so the caller +/// treats it like a stream that failed to build and tries the next configuration. +fn start_stream(stream: &impl cpal::traits::StreamTrait) -> bool { + match stream.play() { + Ok(()) => true, + Err(e) => { + log::warn!("audio stream failed to start: {e}"); + false + } + } +} + /// The output stream: the mix renders f32 into a scratch buffer allocated here (so the callback /// never allocates), converted to the device's sample type. fn build_output( @@ -967,6 +979,35 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal: mod tests { use super::*; + /// A stream that builds but refuses to start, as a device can after a reconnect. + struct RefusesToStart; + + impl cpal::traits::StreamTrait for RefusesToStart { + fn play(&self) -> Result<(), cpal::PlayStreamError> { + Err(cpal::PlayStreamError::DeviceNotAvailable) + } + fn pause(&self) -> Result<(), cpal::PauseStreamError> { + Ok(()) + } + } + + struct Starts; + + impl cpal::traits::StreamTrait for Starts { + fn play(&self) -> Result<(), cpal::PlayStreamError> { + Ok(()) + } + fn pause(&self) -> Result<(), cpal::PauseStreamError> { + Ok(()) + } + } + + #[test] + fn a_stream_that_will_not_start_is_reported_as_failed() { + assert!(!start_stream(&RefusesToStart)); + assert!(start_stream(&Starts)); + } + #[test] fn reusable_meter_snapshot_updates_and_drops_stale_tracks() { let keep = TrackId(1); From 79adfc0b57fc423cfba3f8c9da51e6af89b08e0b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:30:29 +0000 Subject: [PATCH 2/2] playback: test the stream fallback order through first_started Build and start each attempt in one helper, so the fallback order can be tested without an audio device. The first config that builds and starts wins; a build error or a refused start moves on to the next; none gives None, which leads to the silent clock. Claude-Session: https://claude.ai/code/session_014JH3FxAq7VHhaKjG9U3vfA --- crates/playback/src/lib.rs | 181 ++++++++++++++++++++++++------------- 1 file changed, 120 insertions(+), 61 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index c16713d4..9d0d1579 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -519,54 +519,58 @@ impl Player { if config.buffer_size != cpal::BufferSize::Default { attempts.push(cpal::StreamConfig { buffer_size: cpal::BufferSize::Default, ..config }); } - for config in attempts { - let rate = config.sample_rate.0; - let channels = usize::from(config.channels); - let (tx, rx) = channel(); - let (gtx, grx) = channel(); - let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx); - let fault = Arc::new(Fault::default()); - let built = match format { - cpal::SampleFormat::I32 => build_output::(&device, &config, state, channels, &fault, &shared), - cpal::SampleFormat::I16 => build_output::(&device, &config, state, channels, &fault, &shared), - _ => build_output::(&device, &config, state, channels, &fault, &shared), - }; - match built { - Ok(stream) => { - // A stream that builds but will not start is dropped, and the next config is tried. - if !start_stream(&stream) { - continue; - } - let buffer = match config.buffer_size { - cpal::BufferSize::Fixed(n) => Some(n), - cpal::BufferSize::Default => None, - }; - log::info!( - "audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}", - buffer.map_or("default".to_string(), |n| n.to_string()) - ); - return Player { - shared, - fault, - #[cfg(not(target_arch = "wasm32"))] - watch: Mutex::new(None), - tx, - side: new_side(grx), - _stream: Some(stream), - device_name: name, - device_rate: rate, - device_channels: config.channels, - device_buffer: buffer, - silent: false, - }; + let opened = first_started( + attempts, + |config: &cpal::StreamConfig| -> Result { + let rate = config.sample_rate.0; + let channels = usize::from(config.channels); + let (tx, rx) = channel(); + let (gtx, grx) = channel(); + let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx); + let fault = Arc::new(Fault::default()); + let stream = match format { + cpal::SampleFormat::I32 => build_output::(&device, config, state, channels, &fault, &shared), + cpal::SampleFormat::I16 => build_output::(&device, config, state, channels, &fault, &shared), + _ => build_output::(&device, config, state, channels, &fault, &shared), + }?; + Ok(Opened { stream, tx, grx, fault }) + }, + |opened| start_stream(&opened.stream), + ); + match opened { + Some((config, Opened { stream, tx, grx, fault })) => { + let rate = config.sample_rate.0; + let channels = usize::from(config.channels); + let buffer = match config.buffer_size { + cpal::BufferSize::Fixed(n) => Some(n), + cpal::BufferSize::Default => None, + }; + log::info!( + "audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}", + buffer.map_or("default".to_string(), |n| n.to_string()) + ); + Player { + shared, + fault, + #[cfg(not(target_arch = "wasm32"))] + watch: Mutex::new(None), + tx, + side: new_side(grx), + _stream: Some(stream), + device_name: name, + device_rate: rate, + device_channels: config.channels, + device_buffer: buffer, + silent: false, } - Err(e) => log::warn!("cannot open audio stream ({:?}): {e}", config.buffer_size), + } + None => { + log::warn!("no usable audio stream; using a silent clock"); + let (tx, rx) = channel(); + let (gtx, grx) = channel(); + Player::silent_clock(shared, tx, rx, want, new_side(grx), gtx) } } - log::warn!("no usable audio stream; using a silent clock"); - let (tx, rx) = channel(); - let (gtx, grx) = channel(); - Player::silent_clock(shared, tx, rx, want, new_side(grx), gtx) } Err(e) => { log::warn!("no audio output: {e}; using a silent clock"); @@ -874,6 +878,32 @@ fn start_stream(stream: &impl cpal::traits::StreamTrait) -> bool { } } +/// A built output stream and the channels and fault flag its callback shares with the player. +struct Opened { + stream: cpal::Stream, + tx: Sender, + grx: Receiver>>, + fault: Arc, +} + +/// Build and start the attempts in order; the first that builds and starts wins. A build error or +/// a stream that will not start is logged, and the next attempt is tried. `None` when none works. +fn first_started( + attempts: impl IntoIterator, + mut build: impl FnMut(&A) -> Result, + start: impl Fn(&S) -> bool, +) -> Option<(A, S)> { + for attempt in attempts { + match build(&attempt) { + Ok(built) if start(&built) => return Some((attempt, built)), + // `start` has already logged why the stream would not start. + Ok(_) => {} + Err(e) => log::warn!("cannot open audio stream ({attempt:?}): {e}"), + } + } + None +} + /// The output stream: the mix renders f32 into a scratch buffer allocated here (so the callback /// never allocates), converted to the device's sample type. fn build_output( @@ -979,23 +1009,15 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal: mod tests { use super::*; - /// A stream that builds but refuses to start, as a device can after a reconnect. - struct RefusesToStart; - - impl cpal::traits::StreamTrait for RefusesToStart { - fn play(&self) -> Result<(), cpal::PlayStreamError> { - Err(cpal::PlayStreamError::DeviceNotAvailable) - } - fn pause(&self) -> Result<(), cpal::PauseStreamError> { - Ok(()) - } + /// A stream that builds; `starts` says whether the device lets it play (it can refuse after a + /// reconnect). + struct Fake { + starts: bool, } - struct Starts; - - impl cpal::traits::StreamTrait for Starts { + impl cpal::traits::StreamTrait for Fake { fn play(&self) -> Result<(), cpal::PlayStreamError> { - Ok(()) + if self.starts { Ok(()) } else { Err(cpal::PlayStreamError::DeviceNotAvailable) } } fn pause(&self) -> Result<(), cpal::PauseStreamError> { Ok(()) @@ -1004,8 +1026,45 @@ mod tests { #[test] fn a_stream_that_will_not_start_is_reported_as_failed() { - assert!(!start_stream(&RefusesToStart)); - assert!(start_stream(&Starts)); + assert!(!start_stream(&Fake { starts: false })); + assert!(start_stream(&Fake { starts: true })); + } + + #[test] + fn the_first_config_that_starts_wins_and_later_ones_are_not_tried() { + let mut tried = Vec::new(); + let won = first_started( + [1u32, 2, 3], + |&n| { + tried.push(n); + Ok::<_, String>(Fake { starts: n != 1 }) + }, + start_stream, + ); + assert_eq!(won.map(|(n, _)| n), Some(2)); + assert_eq!(tried, vec![1, 2]); + } + + #[test] + fn a_config_that_fails_to_build_falls_through_to_the_next() { + let won = + first_started([1u32, 2], |&n| if n == 1 { Err("no such buffer size".to_string()) } else { Ok(Fake { starts: true }) }, start_stream); + assert_eq!(won.map(|(n, _)| n), Some(2)); + } + + #[test] + fn no_config_that_starts_yields_none_so_the_caller_falls_back_to_a_silent_clock() { + let mut tried = 0; + let won = first_started( + [1u32, 2], + |_| { + tried += 1; + Ok::<_, String>(Fake { starts: false }) + }, + start_stream, + ); + assert!(won.is_none()); + assert_eq!(tried, 2); } #[test]