From f18414ff33d59fdf09a5417677e7cd0e027b1dfe Mon Sep 17 00:00:00 2001 From: mysma-9403 <64923976+mysma-9403@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:04:32 +0200 Subject: [PATCH] fix(voice): handle U16 sample format in the fallback capture path (#4537) --- src/openhuman/voice/audio_capture.rs | 45 +++++++++++++++++----- src/openhuman/voice/audio_capture_tests.rs | 19 +++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/openhuman/voice/audio_capture.rs b/src/openhuman/voice/audio_capture.rs index c80dfe2a2..e028366b9 100644 --- a/src/openhuman/voice/audio_capture.rs +++ b/src/openhuman/voice/audio_capture.rs @@ -368,8 +368,7 @@ fn record_on_thread( .build_input_stream( &stream_config, move |data: &[i16], _: &cpal::InputCallbackInfo| { - let floats: Vec = - data.iter().map(|&s| s as f32 / 32768.0).collect(); + let floats = i16_to_f32(data); let mono = to_mono(&floats, source_channels); update_peak_rms(&rms_tracker, &mono); let gated = gate.lock().process(&mono); @@ -389,10 +388,7 @@ fn record_on_thread( .build_input_stream( &stream_config, move |data: &[u16], _: &cpal::InputCallbackInfo| { - let floats: Vec = data - .iter() - .map(|&s| (s as f32 - 32768.0) / 32768.0) - .collect(); + let floats = u16_to_f32(data); let mono = to_mono(&floats, source_channels); update_peak_rms(&rms_tracker, &mono); let gated = gate.lock().process(&mono); @@ -446,8 +442,7 @@ fn record_on_thread( .build_input_stream( &sc, move |data: &[i16], _: &cpal::InputCallbackInfo| { - let floats: Vec = - data.iter().map(|&s| s as f32 / 32768.0).collect(); + let floats = i16_to_f32(data); let mono = to_mono(&floats, ch); update_peak_rms(&rt, &mono); let gated = gate.lock().process(&mono); @@ -459,7 +454,23 @@ fn record_on_thread( None, ) .map_err(|e| format!("fallback i16 stream failed: {e}")), - _ => Err(format!("unsupported fallback format: {fmt:?}")), + SampleFormat::U16 => device + .build_input_stream( + &sc, + move |data: &[u16], _: &cpal::InputCallbackInfo| { + let floats = u16_to_f32(data); + let mono = to_mono(&floats, ch); + update_peak_rms(&rt, &mono); + let gated = gate.lock().process(&mono); + if !gated.is_empty() { + sw.lock().extend_from_slice(&gated); + } + }, + |err| error!("{LOG_PREFIX} audio stream error: {err}"), + None, + ) + .map_err(|e| format!("fallback u16 stream failed: {e}")), + other => Err(format!("unsupported fallback format: {other:?}")), }; match fallback_stream { Ok(s) => (s, sr, ch), @@ -519,6 +530,22 @@ pub fn list_input_devices() -> Result, String> { Ok(names) } +/// Convert interleaved signed `i16` PCM samples to normalised `f32` in +/// `[-1.0, 1.0)` (`s / 32768`). Extracted so the preferred and fallback stream +/// paths share one conversion instead of duplicating it inline. +pub(crate) fn i16_to_f32(data: &[i16]) -> Vec { + data.iter().map(|&s| s as f32 / 32768.0).collect() +} + +/// Convert interleaved unsigned `u16` PCM samples (mid-scale 32768) to +/// normalised `f32` in `[-1.0, 1.0)` (`(s - 32768) / 32768`). Shared by the +/// preferred and fallback stream paths. +pub(crate) fn u16_to_f32(data: &[u16]) -> Vec { + data.iter() + .map(|&s| (s as f32 - 32768.0) / 32768.0) + .collect() +} + /// Convert interleaved multi-channel samples to mono by averaging channels. pub(crate) fn to_mono(samples: &[f32], channels: usize) -> Vec { if channels <= 1 { diff --git a/src/openhuman/voice/audio_capture_tests.rs b/src/openhuman/voice/audio_capture_tests.rs index bfcc15fce..b6a4d3f2a 100644 --- a/src/openhuman/voice/audio_capture_tests.rs +++ b/src/openhuman/voice/audio_capture_tests.rs @@ -1,6 +1,25 @@ use super::*; use cpal::{SampleFormat, SampleRate, SupportedBufferSize, SupportedStreamConfigRange}; +#[test] +fn i16_to_f32_normalises_to_unit_range() { + assert_eq!(i16_to_f32(&[0]), vec![0.0]); + assert_eq!(i16_to_f32(&[16384]), vec![0.5]); + assert_eq!(i16_to_f32(&[-32768]), vec![-1.0]); + // Interleaved frames are converted element-wise, order preserved. + assert_eq!(i16_to_f32(&[0, 16384, -16384]), vec![0.0, 0.5, -0.5]); +} + +#[test] +fn u16_to_f32_centers_on_midscale() { + // Unsigned PCM is offset-binary: 32768 is silence (0.0), the endpoints map + // to the extremes. This is the conversion the fallback path was missing. + assert_eq!(u16_to_f32(&[32768]), vec![0.0]); + assert_eq!(u16_to_f32(&[49152]), vec![0.5]); + assert_eq!(u16_to_f32(&[0]), vec![-1.0]); + assert_eq!(u16_to_f32(&[32768, 49152, 16384]), vec![0.0, 0.5, -0.5]); +} + #[test] fn to_mono_passthrough_single_channel() { let input = vec![0.1, 0.2, 0.3];