Files
codex/codex-rs/realtime-webrtc/src/protocol.rs
T
Benjamin Carlsson e53e932dc8 Allow microphone channel selection for voice conversations (#49836)
## Why

Mixing every input on a multichannel audio device can include playback channels in microphone capture. Selecting only microphone inputs keeps those playback channels out of the captured audio.

## What changed

- Add `audio.microphone_channel` with one-based channel numbers, accepting a single value or an array such as `[1, 2]`. Leaving it unset preserves mixing of all inputs.
- Add an input-channel picker for the selected microphone when it has at least three channels. Require a nonempty selection and save it locally for the next voice conversation.
- Average only the selected channels, deduplicate selections, and reject empty or unavailable selections without falling back to other inputs.
- Clear saved channel selections when saving an input device and report configuration overrides that prevent the reset from taking effect.

## Testing

Add regression coverage for excluding playback channels, sample conversion, subset mixing, invalid selections, configuration loading, local persistence and overrides, and picker selection and cancellation. Update UI snapshots and verify channel selections are passed to the voice helper.

GitOrigin-RevId: 99960dcfa7b10cf38270768a90bb84a4be95a406
2026-10-01 02:59:26 +00:00

166 lines
4.9 KiB
Rust

//! Bounded same-build controls and redacted signaling; audio never crosses this pipe.
use std::io;
use std::io::Read;
use serde::Deserialize;
use serde::Serialize;
pub const MAX_FRAME_BYTES: usize = 128 * 1024;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AudioControls {
pub microphone_muted: bool,
pub speaker_suppressed: bool,
}
/// Peaks contain levels only, never retained audio or backend messages.
#[derive(Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AudioState {
pub microphone_peak: u16,
pub speaker_peak: u16,
}
/// Direction of a local audio device, shared by enumeration and capture/playback selection.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum AudioDeviceKind {
Input,
Output,
}
/// Local device preferences fixed for the lifetime of a voice conversation.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AudioDeviceSelection {
pub microphone: Option<String>,
pub speaker: Option<String>,
pub channel: Option<Vec<std::num::NonZeroU16>>,
}
/// Local device metadata; enumeration never opens streams.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AudioDevice {
pub name: String,
pub channels: u16,
pub is_default: bool,
}
/// SDP contains ICE credentials. Bound it at construction and never expose it in diagnostics.
#[derive(Deserialize, PartialEq, Serialize)]
#[serde(try_from = "String")]
pub struct SessionDescription(String);
impl TryFrom<String> for SessionDescription {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.is_empty() || value.len() > 64 * 1024 {
return Err("invalid voice session description length");
}
Ok(Self(value))
}
}
impl std::fmt::Debug for SessionDescription {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SessionDescription([REDACTED])")
}
}
impl SessionDescription {
pub fn into_sdp(self) -> String {
self.0
}
}
/// Fixed child settings prevent native initialization from scanning system plugins or caches.
pub const RUNTIME_ENVIRONMENT: [(&str, &str); 7] = [
("GST_PLUGIN_PATH", ""),
("GST_PLUGIN_PATH_1_0", ""),
("GST_PLUGIN_SYSTEM_PATH", ""),
("GST_PLUGIN_SYSTEM_PATH_1_0", ""),
(
"GST_REGISTRY",
if cfg!(windows) { "NUL" } else { "/dev/null" },
),
("GST_REGISTRY_UPDATE", "no"),
("GST_REGISTRY_FORK", "no"),
];
#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum Message {
Hello { protocol: u32, build_commit: String },
Ready {},
InitializeRuntime {},
RuntimeReady {},
StartTransport {},
Offer { sdp: SessionDescription },
ApplyAnswer { sdp: SessionDescription },
TransportReady {},
TransportTimedOut {},
ListDevices { kind: AudioDeviceKind },
DeviceList { devices: Vec<AudioDevice> },
OpenDevices { selection: AudioDeviceSelection },
DevicesOpened {},
SetAudioControls { controls: AudioControls },
AudioControlsApplied {},
InspectAudio {},
AudioState { state: AudioState },
Close {},
Closed {},
}
#[cfg(test)]
#[path = "protocol_tests.rs"]
mod tests;
pub fn encode_frame(message: &Message) -> io::Result<Vec<u8>> {
let payload = serde_json::to_vec(message)?;
if payload.len() > MAX_FRAME_BYTES {
return Err(io::Error::other("voice frame exceeds limit"));
}
let mut frame = (payload.len() as u32).to_be_bytes().to_vec();
frame.extend(payload);
Ok(frame)
}
pub fn decode_frame(frame: &[u8]) -> io::Result<Option<Message>> {
let [a, b, c, d, ..] = frame else {
return Ok(None);
};
let length = u32::from_be_bytes([*a, *b, *c, *d]) as usize;
if length > MAX_FRAME_BYTES || frame.len() > length + 4 {
return Err(io::Error::other("invalid voice frame length"));
}
frame
.get(4..length + 4)
.map(serde_json::from_slice)
.transpose()
.map_err(|_| io::Error::other("invalid voice frame"))
}
pub fn read_message(reader: &mut impl Read) -> io::Result<Option<Message>> {
let mut header = [0; 4];
match reader.read_exact(&mut header[..1]) {
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
result => result?,
}
reader.read_exact(&mut header[1..])?;
decode_frame(&header)?;
let length = u32::from_be_bytes(header) as usize;
let mut frame = header.to_vec();
frame.resize(length + 4, /*value*/ 0);
reader.read_exact(&mut frame[4..])?;
decode_frame(&frame)
}