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]