From 7ef70f95d5c07976f3c992e413a02a6db238b0d0 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Fri, 11 Sep 2026 21:59:10 +0000 Subject: [PATCH] Refresh the speaker format when restarting voice output (#44924) ## Why Opening a Bluetooth microphone can change the speaker format, leaving the previously cached sample rate unsupported when the speaker stream restarts. ## What changed Requery and validate the speaker configuration after stopping the old stream. Update playback and render resampling to the current sample rate, and revalidate callback timing. Discard stale echo references while preserving capture history and audio processing state. ## Testing Extend the speaker reset test to cover 48, 24, and 44.1 kHz output rates, checking that render output matches a fresh converter while capture history and pending samples are preserved. GitOrigin-RevId: 3a4fba577288a83a7509fc78be86171ac85a3c0a --- codex-rs/voice-host/src/devices.rs | 45 ++++++++++----- codex-rs/voice-host/src/processing.rs | 9 +++ codex-rs/voice-host/src/processing_tests.rs | 62 +++++++++++---------- 3 files changed, 71 insertions(+), 45 deletions(-) diff --git a/codex-rs/voice-host/src/devices.rs b/codex-rs/voice-host/src/devices.rs index 41d34d4148..93f43fe94f 100644 --- a/codex-rs/voice-host/src/devices.rs +++ b/codex-rs/voice-host/src/devices.rs @@ -47,8 +47,7 @@ pub(super) struct Devices { _input: cpal::Stream, output: Option, output_device: cpal::Device, - output_config: cpal::SupportedStreamConfig, - output_stream_config: cpal::StreamConfig, + input_frames: u32, playback: PlaybackPort, playout: Option, worker: capture_worker::CaptureWorker, @@ -91,14 +90,6 @@ impl Devices { let output_config = output .default_output_config() .map_err(|_| io::Error::other("speaker configuration unavailable"))?; - for config in [&input_config, &output_config] { - if config.channels() == 0 - || config.channels() > 32 - || !(8_000..=384_000).contains(&config.sample_rate()) - { - return Err(io::Error::other("unsupported audio device configuration")); - } - } let input_stream_config = bounded_stream_config(&input_config)?; let output_stream_config = bounded_stream_config(&output_config)?; let buffers = Arc::new(Buffers::new( @@ -144,8 +135,7 @@ impl Devices { _input: input, output: Some(output_stream), output_device: output, - output_config, - output_stream_config, + input_frames, playback, playout: None, worker: capture_worker::CaptureWorker { @@ -181,16 +171,35 @@ impl Devices { buffers.last_dac_ns.store(/*val*/ 0, Ordering::Release); drop(producer); while buffers.rendered.pop().is_some() {} - self.worker.processor.reset_render(); + // Opening a Bluetooth microphone can change the speaker's format. + // Requery after stopping the old stream rather than restoring its + // pre-microphone rate, which the device may no longer support. + let output_config = self + .output_device + .default_output_config() + .map_err(|_| io::Error::other("speaker configuration unavailable"))?; + let output_stream_config = bounded_stream_config(&output_config)?; + let cpal::BufferSize::Fixed(output_frames) = output_stream_config.buffer_size else { + return Err(io::Error::other("audio callback size unavailable")); + }; + self.worker + .processor + .set_render_rate(output_config.sample_rate()) + .map_err(io::Error::other)?; + self.worker + .processor + .validate_callback_timing(self.input_frames, output_frames) + .map_err(io::Error::other)?; + self.playback = PlaybackPort::new(buffers.clone(), output_config.sample_rate()); if !controls.speaker_suppressed { self.playout = Some(playout::Playout::new(self.playback.writer()).map_err(io::Error::other)?); } let output = stream!( - self.output_config.sample_format(), + output_config.sample_format(), build_output, &self.output_device, - &self.output_stream_config, + &output_stream_config, buffers ) .map_err(|_| io::Error::other("failed to reset speaker"))?; @@ -234,6 +243,12 @@ impl Drop for Devices { fn bounded_stream_config( supported: &cpal::SupportedStreamConfig, ) -> io::Result { + if supported.channels() == 0 + || supported.channels() > 32 + || !(8_000..=384_000).contains(&supported.sample_rate()) + { + return Err(io::Error::other("unsupported audio device configuration")); + } let cpal::SupportedBufferSize::Range { min, max } = *supported.buffer_size() else { return Err(io::Error::other("audio callback size range unavailable")); }; diff --git a/codex-rs/voice-host/src/processing.rs b/codex-rs/voice-host/src/processing.rs index b05dd19365..6710be932e 100644 --- a/codex-rs/voice-host/src/processing.rs +++ b/codex-rs/voice-host/src/processing.rs @@ -151,6 +151,15 @@ impl Processor { Ok(()) } + pub(super) fn set_render_rate(&mut self, rate: u32) -> Result<()> { + // Capture keeps its own rate and history across speaker-only changes. + if self.render.rate != rate { + self.render = Converter::new(rate)?; + } + self.reset_render(); + Ok(()) + } + pub(super) fn reset_render(&mut self) { // Speaker changes discard old references without resetting capture or APM state. self.render.resampler.reset(); diff --git a/codex-rs/voice-host/src/processing_tests.rs b/codex-rs/voice-host/src/processing_tests.rs index 43e62b0661..4dffb43719 100644 --- a/codex-rs/voice-host/src/processing_tests.rs +++ b/codex-rs/voice-host/src/processing_tests.rs @@ -156,38 +156,40 @@ fn unmute_reset_rejects_delayed_pre_unmute_audio_and_partial_history() { } #[test] -fn speaker_reset_discards_partial_echo_reference_but_keeps_capture_history() { - let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); - let mut fresh = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); - let start = Instant::now(); - let mut frame = Frame { - samples: [0.75; 256], - len: 256, - at: start, - generation: 2, - }; - processor.render.push(&frame).unwrap(); - processor.render.output.push_back(0.75); - processor.capture.input.extend([0.25; 256]); - processor.pending.extend([0.25; 480]); - processor.render_delay = 123; - processor.reset_render(); - assert_eq!(processor.capture.input, VecDeque::from(vec![0.25; 256])); - assert_eq!(processor.pending, vec![0.25; 480]); - assert_eq!(processor.render_delay, 0); - - frame.samples.fill(0.0); - let mut actual = Vec::new(); - let mut expected = Vec::new(); - for index in 0..5 { - frame.at = start + Duration::from_secs_f64(index as f64 * 256.0 / 48_000.0); +fn speaker_rate_change_discards_old_reference_but_keeps_capture_history() { + for rate in [48_000, 24_000, 44_100] { + let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + let mut fresh = Processor::new(/*input_rate*/ 48_000, rate).unwrap(); + let start = Instant::now(); + let mut frame = Frame { + samples: [0.75; 256], + len: 256, + at: start, + generation: 2, + }; processor.render.push(&frame).unwrap(); - fresh.render.push(&frame).unwrap(); - actual.extend(std::iter::from_fn(|| processor.render.next())); - expected.extend(std::iter::from_fn(|| fresh.render.next())); + processor.render.output.push_back(0.75); + processor.capture.input.extend([0.25; 256]); + processor.pending.extend([0.25; 480]); + processor.render_delay = 123; + processor.set_render_rate(rate).unwrap(); + assert_eq!(processor.capture.input, VecDeque::from(vec![0.25; 256])); + assert_eq!(processor.pending, vec![0.25; 480]); + assert_eq!(processor.render_delay, 0); + + frame.samples.fill(0.0); + let mut actual = Vec::new(); + let mut expected = Vec::new(); + for index in 0..5 { + frame.at = start + Duration::from_secs_f64(index as f64 * 256.0 / f64::from(rate)); + processor.render.push(&frame).unwrap(); + fresh.render.push(&frame).unwrap(); + actual.extend(std::iter::from_fn(|| processor.render.next())); + expected.extend(std::iter::from_fn(|| fresh.render.next())); + } + assert!(!actual.is_empty()); + assert_eq!(actual, expected); } - assert!(!actual.is_empty()); - assert_eq!(actual, expected); } #[test]