Add opt-in local audio devices to the voice helper (#43079)

## What changed

- Add `openDevices` and `setAudioControls` to the helper protocol. Open the default microphone and speaker after runtime initialization and transport negotiation, initially muted and suppressed.
- Use CPAL on macOS, GNU Linux, and MSVC Windows, with native audio dependencies confined to `codex-voice-host`. Add ALSA development dependencies to Linux CI.
- Pack callback samples into preallocated, bounded queues and service devices every 5 ms. Reject incompatible callback configurations, invalidate queued audio on control transitions, and reject capture backlog after unmute using device timestamps.
- Handle recoverable stream underruns and overruns without terminating the helper; terminate on device failure, active audio queue overflow, or stale queued audio.

Device opening only establishes local device readiness. Capture and rendered audio references are drained locally; encoding, decoding, and peer audio are not connected by this change.

## Testing

Add unit tests for mute boundaries, stale playback invalidation, sample bounds, partial-block packing and timestamps, callback size and timing limits, startup silence, queue overflow, and recoverable stream errors.

GitOrigin-RevId: 108aea3d411ef6c12908fe5fb67aaf9cddf4475f
This commit is contained in:
Benjamin Carlsson
2026-09-05 19:11:52 +00:00
committed by copyberry
parent 51c97f3a67
commit 19b62211d9
14 changed files with 1318 additions and 9 deletions
@@ -82,7 +82,7 @@ jobs:
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential pkg-config libcap-dev bubblewrap
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential pkg-config libcap-dev libasound2-dev bubblewrap
fi
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
@@ -297,7 +297,7 @@ jobs:
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev libasound2-dev bubblewrap
fi
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
+1 -1
View File
@@ -256,7 +256,7 @@ jobs:
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential pkg-config libcap-dev
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential pkg-config libcap-dev libasound2-dev
fi
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
+15
View File
File diff suppressed because one or more lines are too long
+186 -1
View File
@@ -305,6 +305,28 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags 2.13.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -4970,6 +4992,8 @@ dependencies = [
"codex-process-hardening",
"codex-realtime-webrtc",
"codex-utils-cargo-bin",
"cpal",
"crossbeam-queue",
"futures",
"libloading",
"pretty_assertions",
@@ -5359,6 +5383,50 @@ dependencies = [
"zstd",
]
[[package]]
name = "coreaudio-rs"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
dependencies = [
"bitflags 2.13.1",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "cpal"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
dependencies = [
"alsa",
"block2",
"coreaudio-rs",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
"objc2",
"objc2-audio-toolbox",
"objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
"web-sys",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -5808,6 +5876,12 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.10.0"
@@ -9125,7 +9199,7 @@ dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
@@ -9146,6 +9220,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
@@ -9598,6 +9681,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "mach2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
[[package]]
name = "maplit"
version = "1.0.2"
@@ -9841,12 +9930,35 @@ dependencies = [
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
@@ -10093,6 +10205,28 @@ dependencies = [
"libc",
]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "num_threads"
version = "0.1.7"
@@ -10149,6 +10283,32 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.13.1",
"libc",
"objc2",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-avf-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
@@ -10160,6 +10320,29 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
"objc2",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.13.1",
"objc2",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
@@ -10177,7 +10360,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.1",
"block2",
"dispatch2",
"libc",
"objc2",
]
+1
View File
@@ -3,6 +3,7 @@ mod message_reader;
mod protocol;
pub use client::VoiceHost;
pub use protocol::AudioControls;
pub use protocol::MAX_FRAME_BYTES;
pub use protocol::Message;
pub use protocol::RUNTIME_ENVIRONMENT;
+11
View File
@@ -8,6 +8,13 @@ use serde::Serialize;
pub const MAX_FRAME_BYTES: usize = 128 * 1024;
#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AudioControls {
pub microphone_muted: bool,
pub speaker_suppressed: bool,
}
/// SDP contains ICE credentials. Bound it at construction and never expose it in diagnostics.
#[derive(Deserialize, PartialEq, Serialize)]
#[serde(try_from = "String")]
@@ -66,6 +73,10 @@ pub enum Message {
Offer { sdp: SessionDescription },
ApplyAnswer { sdp: SessionDescription },
TransportReady {},
OpenDevices {},
DevicesOpened {},
SetAudioControls { controls: AudioControls },
AudioControlsApplied {},
Close {},
Closed {},
}
+4
View File
@@ -20,6 +20,10 @@ rtc = "=0.20.3"
tokio = { workspace = true, features = ["rt-multi-thread", "sync", "time"] }
webrtc = "=0.20.3"
[target.'cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"), all(windows, target_env = "msvc")))'.dependencies]
cpal = "=0.18.2"
crossbeam-queue = "0.3.12"
[dev-dependencies]
anyhow = { workspace = true }
codex-install-context = { workspace = true }
+34 -2
View File
@@ -1,8 +1,8 @@
# Private voice helper foundation
`codex-voice-host` establishes the inherited-pipe lifecycle for the proposed
bundled voice process and owns WebRTC negotiation. It does not open devices or
enable voice in the TUI. The existing CLI is unchanged.
bundled voice process and owns WebRTC negotiation and opt-in local devices.
It does not enable voice in the TUI. The existing CLI is unchanged.
Frames are a big-endian u32 length followed by at most 128 KiB of JSON. SDP is
limited to 64 KiB and redacted in diagnostics. The
@@ -22,6 +22,38 @@ without native audio initialization and does not establish audio readiness.
Negotiation has a deadline; `close` tears down the peer before acknowledging exit.
UDP and TCP peer tests use real sockets, without a backend or audio devices.
After native initialization and transport negotiation, `openDevices` opens the
default microphone and speaker, initially muted and suppressed. `setAudioControls`
invalidates old queued audio. Device errors or queue overflow end the helper.
Startup callbacks emit silence without collecting references until worker service
begins. After unmute, capture waits for a following callback's device timestamp
and rejects buffers captured before that cutoff, including buffers crossing it.
This can discard speech onset; it relies on backend timestamp estimates and does
not establish a precise physical mute boundary.
Both streams request roughly 10 ms callbacks within the device's supported range
and the 8,192-frame queue budget. Unknown or incompatible ranges are rejected;
there is no fallback to an unbounded default. Backends may deliver smaller
callbacks than requested, so capture and rendered references pack samples into
full 256-frame queue slots across callbacks without allocating. Each block keeps
its oldest sample timestamp. Partial blocks are discarded on generation changes
or rejected capture callbacks, including mute and invalid or backwards capture timestamps.
Packing retains fewer than 256 samples (5.33 ms at 48 kHz; 32 ms at 8 kHz);
delivery also waits for the next callback. Speaker rendering remains immediate.
Selected callbacks must leave room for packing and the 5 ms service interval before capture becomes
stale at one second. The queue's sample capacity must span more than that service
interval.
Processing lag can still overflow a queue. Before devices open, the worker blocks
on commands instead of polling.
`devicesOpened` confirms device opening only: capture/reference are drained locally
until the following media stage connects encoding, decoding and the peer.
Cargo Linux builds require ALSA development inputs discoverable by pkg-config
(for example `libasound2-dev` on Debian/Ubuntu). Bazel uses the declared `alsa_lib`
source target through `alsa-sys`; macOS uses CoreAudio and Windows uses WASAPI.
CPAL and its native link inputs belong only to the helper. Producing a closed
Linux distribution still requires the prepared ALSA SDK/runtime/configuration
closure; a successful source build does not establish that packaging proof.
Bazel stamps the binary with `STABLE_GIT_COMMIT`. Cargo builders must provide the
same variable; an unstamped source build reports `dev` via `--build-commit` and is
not a distributable build identity. The client/control crate has no native audio
+194
View File
@@ -0,0 +1,194 @@
//! Preallocated callback buffers pack small callbacks into full queue slots without locks.
//! Generation changes and rejected capture buffers discard incomplete frames.
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use crossbeam_queue::ArrayQueue;
pub(super) const BLOCK: usize = 256;
pub(super) const QUEUE_CAPACITY: usize = 32;
pub(super) struct Frame {
pub(super) samples: [f32; BLOCK],
pub(super) len: usize,
pub(super) at: Instant,
pub(super) generation: u64,
}
/// Packs serialized callbacks without allocating; only complete blocks enter the queue.
/// The oldest sample keeps its timestamp. Callers reset after rejected capture data.
#[derive(Default)]
pub(super) struct FramePacker {
frame: Option<Frame>,
}
impl FramePacker {
pub(super) fn reset(&mut self) {
self.frame = None;
}
pub(super) fn push(&mut self, frame: Frame, rate: f64, queue: &ArrayQueue<Frame>) -> bool {
if self
.frame
.as_ref()
.is_some_and(|partial| partial.generation != frame.generation)
{
self.reset();
}
let mut offset = 0;
while offset < frame.len {
let partial = self.frame.get_or_insert_with(|| Frame {
samples: [0.0; BLOCK],
len: 0,
at: frame.at + Duration::from_secs_f64(offset as f64 / rate),
generation: frame.generation,
});
let count = (BLOCK - partial.len).min(frame.len - offset);
partial.samples[partial.len..partial.len + count]
.copy_from_slice(&frame.samples[offset..offset + count]);
partial.len += count;
offset += count;
if partial.len == BLOCK
&& let Some(full) = self.frame.take()
&& let Err(rejected) = queue.push(full)
{
// Suppressed output is known silence; dropping it cannot lose audible output.
// Never discard an active generation or a block containing real audio.
if rejected.generation.is_multiple_of(2)
|| rejected.samples.iter().any(|sample| *sample != 0.0)
{
return false;
}
}
}
true
}
}
pub(super) struct Buffers {
pub(super) capture: ArrayQueue<Frame>,
pub(super) rendered: ArrayQueue<Frame>,
pub(super) playback: ArrayQueue<Frame>,
pub(super) microphone: AtomicU64,
pub(super) speaker: AtomicU64,
pub(super) serviced: AtomicBool,
pub(super) failed: AtomicBool,
}
impl Buffers {
pub(super) fn new() -> Self {
Self {
capture: ArrayQueue::new(QUEUE_CAPACITY),
rendered: ArrayQueue::new(QUEUE_CAPACITY),
playback: ArrayQueue::new(QUEUE_CAPACITY),
microphone: AtomicU64::new(/*v*/ 1),
speaker: AtomicU64::new(/*v*/ 1),
serviced: AtomicBool::new(false),
failed: AtomicBool::new(false),
}
}
// One control worker writes each epoch. Odd epochs are disabled; every
// transition advances the epoch so disable/re-enable cannot replay old audio.
pub(super) fn set_disabled(epoch: &AtomicU64, disabled: bool) -> std::io::Result<()> {
let current = epoch.load(Ordering::Acquire);
if (current % 2 == 1) != disabled {
epoch.store(
current
.checked_add(1)
.ok_or_else(|| std::io::Error::other("audio generation exhausted"))?,
Ordering::Release,
);
}
Ok(())
}
}
/// Rejects capture backlog after unmute, using offsets in the device's clock.
/// This is conservative software admission, not a guarantee of hardware clock accuracy.
#[derive(Default)]
pub(super) struct CaptureBoundary {
generation: Option<u64>,
cutoff: Option<Duration>,
previous_capture: Option<Duration>,
}
impl CaptureBoundary {
pub(super) fn accepts(
&mut self,
generation: u64,
callback: Duration,
capture: Duration,
) -> bool {
if generation % 2 == 1 || self.generation != Some(generation) {
self.generation = Some(generation);
self.cutoff = None;
self.previous_capture = None;
// The current callback timestamp may have been sampled before unmute.
// Wait for the next serialized callback before establishing a cutoff.
return false;
}
let accepted = capture >= *self.cutoff.get_or_insert(callback)
&& self
.previous_capture
.is_none_or(|previous| capture >= previous);
if accepted {
self.previous_capture = Some(capture);
}
accepted
}
}
#[derive(Default)]
pub(super) struct Playback {
frame: Option<Frame>,
offset: usize,
}
impl Playback {
pub(super) fn next(&mut self, buffers: &Buffers) -> f32 {
let epoch = buffers.speaker.load(Ordering::Acquire);
if self
.frame
.as_ref()
.is_some_and(|frame| frame.generation != epoch || self.offset == frame.len)
{
self.frame = None;
}
// Bound stale-frame work even if a producer keeps writing during a mute.
for _ in 0..buffers.playback.capacity() {
if self.frame.is_some() {
break;
}
let Some(frame) = buffers.playback.pop() else {
break;
};
if epoch.is_multiple_of(2)
&& frame.generation == epoch
&& frame.len > 0
&& frame.len <= BLOCK
{
self.frame = Some(frame);
self.offset = 0;
}
}
let Some(frame) = &self.frame else {
return 0.0;
};
let sample = frame.samples[self.offset];
self.offset += 1;
if epoch % 2 == 1 || !sample.is_finite() {
0.0
} else {
sample.clamp(-1.0, 1.0)
}
}
}
#[cfg(test)]
#[path = "device_buffers_tests.rs"]
mod tests;
@@ -0,0 +1,212 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn unmute_rejects_backlog_until_capture_reaches_the_following_callback() {
let mut boundary = CaptureBoundary::default();
// Times are offsets in the same device clock. The first callback's timestamp
// can precede unmute, so even capture after that timestamp must be rejected.
for (generation, callback, capture, expected) in [
(1, 20, 10, false), // muted
(2, 21, 15, false), // first observed unmute
(2, 25, 22, false), // next callback establishes cutoff 25
(2, 26, 24, false), // another old/crossing buffer
(2, 27, 25, true),
(2, 28, 26, true), // same generation must not move the cutoff
(2, 29, 25, false), // backwards device capture is a discontinuity
(2, 30, 26, true), // forward capture can resume without moving the cutoff
(4, 30, 29, false), // mute/unmute happened without a callback
(4, 34, 31, false),
(4, 35, 33, false),
(4, 36, 34, true),
(5, 37, 35, false), // observed mute
(6, 38, 36, false),
(6, 40, 40, true),
(6, 101, 100, true),
(6, 102, 90, false),
(6, 103, 95, false), // rejection must not lower the last accepted timestamp
(6, 104, 101, true),
] {
assert_eq!(
boundary.accepts(
generation,
Duration::from_millis(callback),
Duration::from_millis(capture),
),
expected,
"generation={generation}, callback={callback}, capture={capture}",
);
}
}
#[test]
fn suppression_discards_partial_and_queued_previous_generations() {
let buffers = Buffers::new();
let mut playback = Playback::default();
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
let generation = buffers.speaker.load(Ordering::Acquire);
for _ in 0..2 {
assert!(
buffers
.playback
.push(Frame {
samples: [0.5; BLOCK],
len: BLOCK,
at: Instant::now(),
generation
})
.is_ok()
);
}
assert_eq!(playback.next(&buffers), 0.5);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap();
assert_eq!(playback.next(&buffers), 0.0);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
assert_eq!(playback.next(&buffers), 0.0);
assert!(buffers.playback.is_empty());
}
#[test]
fn output_is_finite_and_bounded_and_underflow_is_silence() {
let buffers = Buffers::new();
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
let generation = buffers.speaker.load(Ordering::Acquire);
let mut samples = [0.0; BLOCK];
samples[..4].copy_from_slice(&[f32::NAN, f32::INFINITY, -2.0, 2.0]);
assert!(
buffers
.playback
.push(Frame {
samples,
len: 4,
at: Instant::now(),
generation
})
.is_ok()
);
let mut playback = Playback::default();
let output: Vec<_> = (0..5).map(|_| playback.next(&buffers)).collect();
assert_eq!(output, [0.0, 0.0, -1.0, 1.0, 0.0]);
}
#[test]
fn repeated_controls_preserve_epoch_but_transitions_invalidate_capture() {
let buffers = Buffers::new();
Buffers::set_disabled(&buffers.microphone, /*disabled*/ false).unwrap();
let first = buffers.microphone.load(Ordering::Acquire);
Buffers::set_disabled(&buffers.microphone, /*disabled*/ false).unwrap();
assert_eq!(buffers.microphone.load(Ordering::Acquire), first);
Buffers::set_disabled(&buffers.microphone, /*disabled*/ true).unwrap();
Buffers::set_disabled(&buffers.microphone, /*disabled*/ false).unwrap();
assert_ne!(buffers.microphone.load(Ordering::Acquire), first);
}
#[test]
fn tiny_callbacks_share_slots_and_preserve_the_oldest_timestamp() {
let buffers = Buffers::new();
let start = Instant::now();
for queue in [&buffers.capture, &buffers.rendered] {
let mut packer = FramePacker::default();
// Five milliseconds at 384 kHz arrives as 120 sixteen-frame callbacks.
for callback in 0..120 {
assert!(packer.push(
Frame {
samples: [callback as f32; BLOCK],
len: 16,
at: start + Duration::from_secs_f64((callback * 16) as f64 / 384_000.0),
generation: 2,
},
/*rate*/ 384_000.0,
queue,
));
}
assert_eq!(queue.len(), 7);
for block in 0..7 {
let frame = queue.pop().unwrap();
assert_eq!(
(frame.samples, frame.len, frame.at, frame.generation),
(
std::array::from_fn(|sample| (block * 16 + sample / 16) as f32),
BLOCK,
start + Duration::from_secs_f64((block * BLOCK) as f64 / 384_000.0),
2,
),
);
}
}
}
#[test]
fn packing_drops_partial_audio_on_generation_changes_and_capture_rejection() {
let queue = ArrayQueue::new(QUEUE_CAPACITY);
let mut packer = FramePacker::default();
let start = Instant::now();
for (generation, sample) in [(2, 0.1), (4, 0.2), (4, 0.3)] {
assert!(packer.push(
Frame {
samples: [sample; BLOCK],
len: BLOCK / 2,
at: start,
generation,
},
/*rate*/ 48_000.0,
&queue,
));
}
let frame = queue.pop().unwrap();
assert_eq!(
(frame.samples, frame.len, frame.at, frame.generation),
(
std::array::from_fn(|i| if i < BLOCK / 2 { 0.2 } else { 0.3 }),
BLOCK,
start,
4
),
);
for sample in [0.4, 0.5] {
assert!(packer.push(
Frame {
samples: [sample; BLOCK],
len: BLOCK / 2,
at: start,
generation: 4
},
/*rate*/ 48_000.0,
&queue,
));
packer.reset();
}
assert!(queue.is_empty());
}
#[test]
fn packing_preserves_a_remainder_timestamp_across_uneven_callbacks() {
let queue = ArrayQueue::new(QUEUE_CAPACITY);
let mut packer = FramePacker::default();
let start = Instant::now();
let second = start + Duration::from_millis(10);
for (len, sample, at) in [(100, 0.1, start), (200, 0.2, second), (212, 0.3, second)] {
assert!(packer.push(
Frame {
samples: [sample; BLOCK],
len,
at,
generation: 2
},
/*rate*/ 48_000.0,
&queue,
));
}
let first = queue.pop().unwrap();
let remainder = queue.pop().unwrap();
assert_eq!(
(first.samples, first.at, remainder.samples, remainder.at),
(
std::array::from_fn(|i| if i < 100 { 0.1 } else { 0.2 }),
start,
std::array::from_fn(|i| if i < 44 { 0.2 } else { 0.3 }),
second + Duration::from_secs_f64(156.0 / 48_000.0),
),
);
assert!(queue.is_empty());
}
+327
View File
@@ -0,0 +1,327 @@
//! Owns local device streams on the helper worker. Callbacks allocate no buffers and take no locks.
//! Small callbacks share full queue slots; processing lag still fails the session closed.
//! Capture and actual rendered output carry device timing.
//! References start with worker service; unmute rejects earlier device capture buffers.
#[path = "device_buffers.rs"]
mod buffers;
use std::io;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use cpal::FromSample;
use cpal::Sample;
use cpal::SampleFormat;
use cpal::SizedSample;
use cpal::traits::DeviceTrait;
use cpal::traits::HostTrait;
use cpal::traits::StreamTrait;
use buffers::BLOCK;
use buffers::Buffers;
use buffers::CaptureBoundary;
use buffers::Frame;
use buffers::FramePacker;
use buffers::Playback;
use buffers::QUEUE_CAPACITY;
const MAX_CAPTURE_AGE: Duration = Duration::from_secs(/*secs*/ 1);
pub(super) struct Devices {
_input: cpal::Stream,
_output: cpal::Stream,
buffers: Arc<Buffers>,
}
macro_rules! stream {
($format:expr, $build:ident, $($arg:expr),+) => {
match $format {
SampleFormat::I8 => $build::<i8>($($arg),+),
SampleFormat::I16 => $build::<i16>($($arg),+),
SampleFormat::I32 => $build::<i32>($($arg),+),
SampleFormat::I64 => $build::<i64>($($arg),+),
SampleFormat::U8 => $build::<u8>($($arg),+),
SampleFormat::U16 => $build::<u16>($($arg),+),
SampleFormat::U32 => $build::<u32>($($arg),+),
SampleFormat::U64 => $build::<u64>($($arg),+),
SampleFormat::F32 => $build::<f32>($($arg),+),
SampleFormat::F64 => $build::<f64>($($arg),+),
_ => Err(cpal::Error::from(cpal::ErrorKind::UnsupportedConfig)),
}
};
}
impl Devices {
pub(super) fn open() -> io::Result<Self> {
let host = cpal::default_host();
let input = host
.default_input_device()
.ok_or_else(|| io::Error::other("microphone unavailable"))?;
let output = host
.default_output_device()
.ok_or_else(|| io::Error::other("speaker unavailable"))?;
let input_config = input
.default_input_config()
.map_err(|_| io::Error::other("microphone configuration unavailable"))?;
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());
let input = stream!(
input_config.sample_format(),
build_input,
&input,
&input_stream_config,
buffers.clone()
)
.map_err(|_| io::Error::other("failed to open microphone"))?;
let output = stream!(
output_config.sample_format(),
build_output,
&output,
&output_stream_config,
buffers.clone()
)
.map_err(|_| io::Error::other("failed to open speaker"))?;
output
.play()
.map_err(|_| io::Error::other("failed to start speaker"))?;
input
.play()
.map_err(|_| io::Error::other("failed to start microphone"))?;
Ok(Self {
_input: input,
_output: output,
buffers,
})
}
pub(super) fn set_controls(
&self,
controls: codex_realtime_webrtc::AudioControls,
) -> io::Result<()> {
Buffers::set_disabled(&self.buffers.microphone, controls.microphone_muted)?;
Buffers::set_disabled(&self.buffers.speaker, controls.speaker_suppressed)
}
pub(super) fn service(&self) -> io::Result<()> {
// Some backends start callbacks during stream construction, before open returns.
self.buffers.serviced.store(true, Ordering::Release);
// Device-only stage: consume locally until the media worker is attached.
// Reading timestamps here also rejects a stalled callback before readiness is reused.
for queue in [&self.buffers.capture, &self.buffers.rendered] {
for _ in 0..queue.capacity() {
let Some(frame) = queue.pop() else {
break;
};
if frame.at.elapsed() > MAX_CAPTURE_AGE {
return Err(io::Error::other("audio device processing fell behind"));
}
}
}
if self.buffers.failed.load(Ordering::Acquire) {
return Err(io::Error::other("audio device failed"));
}
Ok(())
}
}
fn bounded_stream_config(
supported: &cpal::SupportedStreamConfig,
) -> io::Result<cpal::StreamConfig> {
let cpal::SupportedBufferSize::Range { min, max } = *supported.buffer_size() else {
return Err(io::Error::other("audio callback size range unavailable"));
};
let mut config = supported.config();
// Leave room for a partial block and smaller callbacks arriving before service.
let service_frames = (f64::from(config.sample_rate)
* super::DEVICE_SERVICE_INTERVAL.as_secs_f64())
.ceil() as usize;
let callback_capacity = (BLOCK * QUEUE_CAPACITY).saturating_sub(service_frames + BLOCK);
let min = min.max(1);
let max = max.min(callback_capacity as u32);
if min > max {
return Err(io::Error::other("unsupported audio callback size range"));
}
// Aim for 10 ms without consuming the queue's service headroom.
// Do not fall back to the backend's potentially much larger default buffer.
let frames = (config.sample_rate / 100).clamp(min, max);
let callback_duration =
Duration::from_secs_f64(f64::from(frames) / f64::from(config.sample_rate));
// Backends may deliver smaller callbacks than requested. Packing makes queue
// coverage depend on samples, while an incomplete block adds bounded latency.
let packing_duration = Duration::from_secs_f64(BLOCK as f64 / f64::from(config.sample_rate));
let queue_duration = packing_duration * QUEUE_CAPACITY as u32;
if callback_duration + packing_duration + super::DEVICE_SERVICE_INTERVAL >= MAX_CAPTURE_AGE
|| queue_duration <= super::DEVICE_SERVICE_INTERVAL
{
return Err(io::Error::other("unsupported audio callback timing"));
}
config.buffer_size = cpal::BufferSize::Fixed(frames);
Ok(config)
}
// CPAL reports recoverable underruns/overruns through the same callback as device loss.
fn handle_stream_error(buffers: &Buffers, error: cpal::Error) {
if error.kind() != cpal::ErrorKind::Xrun {
buffers.failed.store(true, Ordering::Release);
}
}
fn build_input<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
buffers: Arc<Buffers>,
) -> Result<cpal::Stream, cpal::Error>
where
T: SizedSample,
f32: FromSample<T>,
{
let channels = usize::from(config.channels);
let rate = f64::from(config.sample_rate);
let failure = buffers.clone();
let mut origin = None;
let mut boundary = CaptureBoundary::default();
let mut capture = FramePacker::default();
device.build_input_stream(
*config,
move |data: &[T], info: &cpal::InputCallbackInfo| {
let generation = buffers.microphone.load(Ordering::Acquire);
if generation % 2 == 1 {
capture.reset();
return;
}
let timestamp = info.timestamp();
let origin = origin.get_or_insert(timestamp.capture);
let (Some(callback), Some(captured)) = (
timestamp.callback.checked_duration_since(*origin),
timestamp.capture.checked_duration_since(*origin),
) else {
capture.reset();
return;
};
if !boundary.accepts(generation, callback, captured) {
capture.reset();
return;
}
let start = Instant::now()
.checked_sub(
timestamp
.callback
.checked_duration_since(timestamp.capture)
.unwrap_or_default(),
)
.unwrap_or_else(Instant::now);
for (index, chunk) in data.chunks(BLOCK * channels).enumerate() {
let mut frame = Frame {
samples: [0.0; BLOCK],
len: chunk.len() / channels,
at: start + Duration::from_secs_f64((index * BLOCK) as f64 / rate),
generation,
};
for (output, input) in frame.samples.iter_mut().zip(chunk.chunks_exact(channels)) {
*output = input
.iter()
.map(|sample| f32::from_sample(*sample))
.sum::<f32>()
/ channels as f32;
if !output.is_finite() {
buffers.failed.store(true, Ordering::Release);
return;
}
}
if !capture.push(frame, rate, &buffers.capture) {
buffers.failed.store(true, Ordering::Release);
return;
}
}
},
move |error| handle_stream_error(&failure, error),
/*timeout*/ None,
)
}
fn build_output<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
buffers: Arc<Buffers>,
) -> Result<cpal::Stream, cpal::Error>
where
T: SizedSample + FromSample<f32>,
f32: FromSample<T>,
{
let channels = usize::from(config.channels);
let rate = f64::from(config.sample_rate);
let failure = buffers.clone();
let mut output = OutputState::default();
device.build_output_stream(
*config,
move |data: &mut [T], info: &cpal::OutputCallbackInfo| {
let timestamp = info.timestamp();
let start = Instant::now()
+ timestamp
.playback
.checked_duration_since(timestamp.callback)
.unwrap_or_default();
render_output(data, channels, rate, start, &buffers, &mut output);
},
move |error| handle_stream_error(&failure, error),
/*timeout*/ None,
)
}
#[derive(Default)]
struct OutputState {
playback: Playback,
reference: FramePacker,
}
fn render_output<T>(
data: &mut [T],
channels: usize,
rate: f64,
start: Instant,
buffers: &Buffers,
output: &mut OutputState,
) where
T: SizedSample + FromSample<f32>,
f32: FromSample<T>,
{
if !buffers.serviced.load(Ordering::Acquire) {
data.fill(T::from_sample(0.0));
return;
}
for (index, chunk) in data.chunks_mut(BLOCK * channels).enumerate() {
let mut reference = Frame {
samples: [0.0; BLOCK],
len: chunk.len() / channels,
at: start + Duration::from_secs_f64((index * BLOCK) as f64 / rate),
generation: buffers.speaker.load(Ordering::Acquire),
};
for (frame, sample) in chunk.chunks_mut(channels).zip(&mut reference.samples) {
let rendered = T::from_sample(output.playback.next(buffers));
frame.fill(rendered);
*sample = f32::from_sample(rendered);
}
if !output.reference.push(reference, rate, &buffers.rendered) {
buffers.failed.store(true, Ordering::Release);
}
}
}
#[cfg(test)]
#[path = "devices_tests.rs"]
mod tests;
+271
View File
@@ -0,0 +1,271 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn startup_output_waits_for_service_but_later_overflow_still_fails() {
let buffers = Buffers::new();
let mut state = OutputState::default();
let mut output = [1.0_f32; BLOCK * 2];
let start = Instant::now();
for _ in 0..QUEUE_CAPACITY + 1 {
render_output(
&mut output,
/*channels*/ 2,
/*rate*/ 384_000.0,
start,
&buffers,
&mut state,
);
assert_eq!(output, [0.0; BLOCK * 2]);
}
assert!(buffers.rendered.is_empty());
assert!(!buffers.failed.load(Ordering::Acquire));
buffers.serviced.store(true, Ordering::Release);
render_output(
&mut output,
/*channels*/ 2,
/*rate*/ 384_000.0,
start,
&buffers,
&mut state,
);
let reference = buffers.rendered.pop().unwrap();
assert_eq!(
(
reference.samples,
reference.len,
reference.at,
reference.generation
),
(
[0.0; BLOCK],
BLOCK,
start,
buffers.speaker.load(Ordering::Acquire)
),
);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
for _ in 0..QUEUE_CAPACITY {
render_output(
&mut output,
/*channels*/ 2,
/*rate*/ 384_000.0,
start,
&buffers,
&mut state,
);
}
assert!(!buffers.failed.load(Ordering::Acquire));
render_output(
&mut output,
/*channels*/ 2,
/*rate*/ 384_000.0,
start,
&buffers,
&mut state,
);
assert!(buffers.failed.load(Ordering::Acquire));
}
#[test]
fn callback_configuration_fits_supported_range_and_actual_queue() {
for (rate, min, max, frames) in [
(96_000, 0, u32::MAX, 960),
(96_000, 1, 16, 16),
(384_000, 1, 64, 64),
(8_000, 7_680, 8_192, 7_680),
(384_000, 1, 16, 16),
(96_000, 1, 15, 15),
(48_000, 1, 128, 128),
(48_000, 2_048, 16_384, 2_048),
(384_000, 6_016, 16_384, 6_016),
] {
let supported = cpal::SupportedStreamConfig::new(
/*channels*/ 2,
rate,
cpal::SupportedBufferSize::Range { min, max },
cpal::SampleFormat::F32,
);
let config = bounded_stream_config(&supported).unwrap();
assert_eq!(
config,
cpal::StreamConfig {
channels: 2,
sample_rate: rate,
buffer_size: cpal::BufferSize::Fixed(frames),
}
);
let buffers = Buffers::new();
for queue in [&buffers.capture, &buffers.rendered] {
for offset in (0..frames as usize).step_by(BLOCK) {
assert!(
queue
.push(Frame {
samples: [0.0; BLOCK],
len: (frames as usize - offset).min(BLOCK),
at: Instant::now(),
generation: 0,
})
.is_ok()
);
}
}
}
}
#[test]
fn callback_configuration_reserves_room_for_partial_and_following_callbacks() {
for min in [6_016, 6_017, 8_192] {
let supported = cpal::SupportedStreamConfig::new(
/*channels*/ 1,
384_000,
cpal::SupportedBufferSize::Range { min, max: 16_384 },
cpal::SampleFormat::F32,
);
let config = bounded_stream_config(&supported);
if min != 6_016 {
assert!(config.is_err(), "minimum callback size {min}");
continue;
}
let cpal::BufferSize::Fixed(frames) = config.unwrap().buffer_size else {
panic!("callback size must be bounded");
};
let buffers = Buffers::new();
buffers.serviced.store(true, Ordering::Release);
let mut output = OutputState::default();
let start = Instant::now();
// An existing partial block, a full callback, then 5 ms of 128-frame callbacks.
for count in [BLOCK - 1, frames as usize].into_iter().chain([128; 15]) {
render_output(
&mut vec![0.0_f32; count],
/*channels*/ 1,
/*rate*/ 384_000.0,
start,
&buffers,
&mut output,
);
}
assert_eq!(buffers.rendered.len(), QUEUE_CAPACITY - 1);
assert!(!buffers.failed.load(Ordering::Acquire));
}
}
#[test]
fn unsupported_callback_ranges_do_not_fall_back_to_default() {
for range in [
cpal::SupportedBufferSize::Unknown,
cpal::SupportedBufferSize::Range { min: 0, max: 0 },
cpal::SupportedBufferSize::Range {
min: 1_024,
max: 512,
},
cpal::SupportedBufferSize::Range {
min: 8_193,
max: 16_384,
},
] {
let supported = cpal::SupportedStreamConfig::new(
/*channels*/ 2,
96_000,
range,
cpal::SampleFormat::F32,
);
assert!(bounded_stream_config(&supported).is_err(), "{range:?}");
}
}
#[test]
fn callback_timing_must_fit_service_and_stale_limits() {
for (rate, min, max) in [(8_000, 8_192, 8_192), (8_000, 7_704, 8_192)] {
let supported = cpal::SupportedStreamConfig::new(
/*channels*/ 2,
rate,
cpal::SupportedBufferSize::Range { min, max },
cpal::SampleFormat::F32,
);
assert!(
bounded_stream_config(&supported).is_err(),
"rate={rate}, range={min}..={max}",
);
}
}
#[test]
fn tiny_output_callbacks_pack_references_without_delaying_rendering() {
let buffers = Buffers::new();
buffers.serviced.store(true, Ordering::Release);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
let generation = buffers.speaker.load(Ordering::Acquire);
let start = Instant::now();
for _ in 0..8 {
assert!(
buffers
.playback
.push(Frame {
samples: [0.5; BLOCK],
len: BLOCK,
at: start,
generation,
})
.is_ok()
);
}
let mut state = OutputState::default();
for callback in 0..120 {
let mut output = [0.0_f32; 32];
render_output(
&mut output,
/*channels*/ 2,
/*rate*/ 384_000.0,
start + Duration::from_secs_f64((callback * 16) as f64 / 384_000.0),
&buffers,
&mut state,
);
assert_eq!(output, [0.5; 32]);
}
assert_eq!(buffers.rendered.len(), 7);
assert!(!buffers.failed.load(Ordering::Acquire));
}
#[test]
fn suppressed_output_does_not_fail_when_reference_queue_is_full() {
let buffers = Buffers::new();
buffers.serviced.store(true, Ordering::Release);
let mut state = OutputState::default();
let mut output = [1.0_f32; BLOCK];
for _ in 0..QUEUE_CAPACITY * 3 {
render_output(
&mut output,
/*channels*/ 1,
/*rate*/ 384_000.0,
Instant::now(),
&buffers,
&mut state,
);
assert_eq!(output, [0.0; BLOCK]);
}
assert_eq!(buffers.rendered.len(), QUEUE_CAPACITY);
assert!(!buffers.failed.load(Ordering::Acquire));
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
render_output(
&mut output,
/*channels*/ 1,
/*rate*/ 384_000.0,
Instant::now(),
&buffers,
&mut state,
);
assert!(buffers.failed.load(Ordering::Acquire));
}
#[test]
fn stream_xruns_recover_without_clearing_device_failure() {
let buffers = Buffers::new();
handle_stream_error(&buffers, cpal::ErrorKind::Xrun.into());
assert!(!buffers.failed.load(Ordering::Acquire));
handle_stream_error(&buffers, cpal::ErrorKind::DeviceNotAvailable.into());
assert!(buffers.failed.load(Ordering::Acquire));
handle_stream_error(&buffers, cpal::ErrorKind::Xrun.into());
assert!(buffers.failed.load(Ordering::Acquire));
}
@@ -0,0 +1,21 @@
//! Keep non-native helper targets buildable without linking a host audio backend.
use std::io;
pub(super) struct Devices;
impl Devices {
pub(super) fn open() -> io::Result<Self> {
Err(io::Error::other(
"audio devices unavailable for this helper target",
))
}
pub(super) fn set_controls(&self, _: codex_realtime_webrtc::AudioControls) -> io::Result<()> {
Self::open().map(|_| ())
}
pub(super) fn service(&self) -> io::Result<()> {
Self::open().map(|_| ())
}
}
+39 -3
View File
@@ -1,5 +1,14 @@
//! Same-build helper lifecycle, private runtime initialization and owned transport. No devices yet.
//! Same-build helper lifecycle with privately owned runtime, transport and opt-in local devices.
#[cfg_attr(
not(any(
target_os = "macos",
all(target_os = "linux", target_env = "gnu"),
all(windows, target_env = "msvc")
)),
path = "devices_unavailable.rs"
)]
mod devices;
mod runtime;
mod transport;
mod transport_runtime;
@@ -13,6 +22,8 @@ use codex_realtime_webrtc::Message;
use codex_realtime_webrtc::encode_frame;
use codex_realtime_webrtc::read_message;
const DEVICE_SERVICE_INTERVAL: Duration = Duration::from_millis(/*millis*/ 5);
const BUILD_COMMIT: &str = match option_env!("STABLE_GIT_COMMIT") {
Some(commit) => commit,
None => "dev",
@@ -80,8 +91,28 @@ fn run(
let executor = tokio::runtime::Runtime::new()?;
let mut transport = None;
let mut answered = false;
let mut devices: Option<devices::Devices> = None;
loop {
let reply = match receiver.recv() {
let message = if let Some(devices) = &devices {
devices.service()?;
receiver.recv_timeout(DEVICE_SERVICE_INTERVAL)
} else {
receiver
.recv()
.map_err(|_| mpsc::RecvTimeoutError::Disconnected)
};
let reply = match message {
Ok(Message::OpenDevices {}) if devices.is_none() && runtime.is_some() && answered => {
devices = Some(devices::Devices::open()?);
Message::DevicesOpened {}
}
Ok(Message::SetAudioControls { controls }) => {
devices
.as_ref()
.ok_or_else(|| io::Error::other("audio devices not open"))?
.set_controls(controls)?;
Message::AudioControlsApplied {}
}
Ok(Message::StartTransport {}) if transport.is_none() => {
let peer = start_transport(&executor)?;
let sdp = executor.block_on(peer.offer()).map_err(io::Error::other)?;
@@ -108,13 +139,15 @@ fn run(
Message::RuntimeReady {}
}
Ok(Message::Close {}) => {
let _ = devices.take();
if let Some(mut peer) = transport.take() {
executor.block_on(peer.close()).map_err(io::Error::other)?;
}
output.write_all(&encode_frame(&Message::Closed {})?)?;
return output.flush();
}
Err(_) => return Ok(()),
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
Ok(
Message::Hello { .. }
| Message::Ready {}
@@ -123,6 +156,9 @@ fn run(
| Message::ApplyAnswer { .. }
| Message::Offer { .. }
| Message::TransportReady {}
| Message::OpenDevices {}
| Message::DevicesOpened {}
| Message::AudioControlsApplied {}
| Message::Closed {},
) => return Err(io::Error::other("invalid voice control sequence")),
};