Send processed microphone audio over RTP in voice-host (#43090)

## Why

`voice-host` previously drained captured audio locally without sending it to the peer. Connect capture to the outgoing media track while preserving mute boundaries and limiting stale audio.

## What changed

- Resample capture and render references to 48 kHz with Rubato, apply Sonora echo cancellation, noise suppression, and gain control, and encode capture as 20 ms mono Opus packets.
- Attach an Opus track to the WebRTC peer and advance RTP timestamps across mute gaps without introducing sequence-number gaps.
- Prioritize queued controls and shutdown between sends. Clear pending audio and processing history on microphone mute transitions, reject delayed pre-unmute buffers, and reset partial history after capture gaps.
- Size bounded capture and render queues for device rates, validate callback timing, enforce a 500 ms processing deadline, and time out stalled sends after 100 ms.

Receive/decode and TUI integration remain unimplemented. The bundled Opus encoder build requires CMake and a C compiler.

## Testing

Add synthetic-audio tests for Opus decoding at 44.1 and 48 kHz input rates, echo reduction, capture gaps, stale audio, queue capacity, and control priority. A loopback WebRTC test verifies RTP delivery, decoded signal, mute isolation, and timestamp and sequence continuity across mute gaps.

GitOrigin-RevId: 38b8438577cfadb8124f8aeb426dffbab4845964
This commit is contained in:
Benjamin Carlsson
2026-09-05 20:02:55 +00:00
committed by copyberry
parent 0683368584
commit 7dc7c7a756
20 changed files with 1526 additions and 67 deletions
+16
View File
File diff suppressed because one or more lines are too long
+162
View File
@@ -844,6 +844,39 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "audio-codec-algorithms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1254ebf6529f3763c491acfb5ab6e960809e3c75a38584cc664f8c5667fb7107"
[[package]]
name = "audioadapter"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1292ef9edf681b7426ed089004b021a42896492f58bd0c909ad44e5faec9ac4"
[[package]]
name = "audioadapter-buffers"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46289a81a3bfa26d0f8b2415f8ca9decde9a66308a150568af5c102381b3c8ce"
dependencies = [
"audioadapter",
"audioadapter-sample",
"num-traits",
]
[[package]]
name = "audioadapter-sample"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5258faecf4edbe35ec483bda9b1f7438e6b391891819269c6b5461aeaa0e8d7d"
dependencies = [
"audio-codec-algorithms",
"num-traits",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -4996,8 +5029,12 @@ dependencies = [
"crossbeam-queue",
"futures",
"libloading",
"opus",
"pretty_assertions",
"rand 0.9.3",
"rtc",
"rubato",
"sonora",
"tempfile",
"tokio",
"webrtc",
@@ -10720,6 +10757,24 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "opus"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33718946cc77d4032911d4efe03a66dbcbfbd2bb16c3da06aaeadcc637c32216"
dependencies = [
"opusic-sys",
]
[[package]]
name = "opusic-sys"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a"
dependencies = [
"cmake",
]
[[package]]
name = "ordered-float"
version = "4.6.0"
@@ -12873,6 +12928,20 @@ dependencies = [
"sansio",
]
[[package]]
name = "rubato"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cb1ffaf8738df50aab642a7f6465df81c6ba9e2818268053487165298114be"
dependencies = [
"audioadapter",
"audioadapter-buffers",
"num-integer",
"num-traits",
"visibility",
"windowfunctions",
]
[[package]]
name = "runfiles"
version = "0.1.0"
@@ -13931,6 +14000,79 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "sonora"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e1ce442ab5cb392c4067b8f2cced20be4cf12e479f6dfbf44acbe9b14ac9250"
dependencies = [
"sonora-aec3",
"sonora-agc2",
"sonora-common-audio",
"sonora-ns",
"sonora-simd",
"tracing",
]
[[package]]
name = "sonora-aec3"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50280e31256d09f7838aff6715ed4b7640e514dc1092a1e14013c01e552380db"
dependencies = [
"sonora-common-audio",
"sonora-fft",
"sonora-simd",
]
[[package]]
name = "sonora-agc2"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a93a9fa8373a33e61a7fb441d8a9e725ac6024079d2c77a845cf6abe1790b9f"
dependencies = [
"bytemuck",
"derive_more 2.1.1",
"sonora-common-audio",
"sonora-fft",
"sonora-simd",
"tracing",
]
[[package]]
name = "sonora-common-audio"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6635bc61b24807ceac93ad0807b1292b6b0620caa6cd360d3be89fc616b833cc"
dependencies = [
"derive_more 2.1.1",
"sonora-simd",
]
[[package]]
name = "sonora-fft"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27e272c70f34363afb5382dd1818c05f8f63ef6785635736a14e9ccbbb5ba012"
[[package]]
name = "sonora-ns"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d511feef19302c716d245c4555087d161ba91c178735ea11107dcce0d0b7a86f"
dependencies = [
"sonora-fft",
]
[[package]]
name = "sonora-simd"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cf0804e4752a5562262e143014f53043080ead4cd5a98f9278d3ceaf32d868"
dependencies = [
"cpufeatures 0.3.0",
]
[[package]]
name = "sorted_vector_map"
version = "0.2.1"
@@ -15956,6 +16098,17 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "visibility"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "vsimd"
version = "0.8.0"
@@ -16481,6 +16634,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windowfunctions"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a"
dependencies = [
"num-traits",
]
[[package]]
name = "windows"
version = "0.58.0"
+4
View File
@@ -16,6 +16,7 @@ codex-process-hardening = { workspace = true }
codex-realtime-webrtc = { workspace = true }
futures = { workspace = true }
libloading = "=0.8.9"
rand = { workspace = true }
rtc = "=0.20.3"
tokio = { workspace = true, features = ["rt-multi-thread", "sync", "time"] }
webrtc = "=0.20.3"
@@ -23,6 +24,9 @@ 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"
opus = "=0.4.0"
rubato = { version = "=5.0.0", default-features = false }
sonora = { version = "=0.2.0", default-features = false }
[dev-dependencies]
anyhow = { workspace = true }
+8 -2
View File
@@ -44,8 +44,14 @@ stale at one second. The queue's sample capacity must span more than that servic
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.
`devicesOpened` confirms device opening only. Capture now uses Rubato resampling,
Sonora echo/noise/gain processing and 20 ms Opus encoding before sending RTP.
Mute resets retained capture history and rejects delayed pre-unmute buffers.
The receive/decode pipeline and TUI connection remain subsequent stages.
The capture encoder uses `opus 0.4.0` and its bundled `opusic-sys` build, which
requires CMake and a C compiler. This is separate from the runtime's decoder Opus
copy; final symbol binding and packaging validation must cover both copies.
Cargo Linux builds require ALSA development inputs discoverable by pkg-config
(for example `libasound2-dev` on Debian/Ubuntu). Bazel uses the declared `alsa_lib`
+122
View File
@@ -0,0 +1,122 @@
//! A single Opus RTP track. Elapsed mute time advances its clock without inventing packet loss.
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use rtc::media::Sample;
use rtc::rtp_transceiver::rtp_sender::RTCRtpCodec;
use rtc::rtp_transceiver::rtp_sender::RTCRtpCodecParameters;
use rtc::rtp_transceiver::rtp_sender::RTCRtpCodingParameters;
use rtc::rtp_transceiver::rtp_sender::RTCRtpEncodingParameters;
use rtc::rtp_transceiver::rtp_sender::RtpCodecKind;
use webrtc::media_stream::MediaStreamTrack;
use webrtc::media_stream::track_local::static_sample::TrackLocalStaticSample;
use webrtc::peer_connection::MediaEngine;
pub(crate) const SEND_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 100);
pub(crate) struct EncodedAudio {
pub(crate) data: Vec<u8>,
pub(crate) at: Instant,
}
pub(crate) struct AudioTrack {
pub(crate) track: Arc<TrackLocalStaticSample>,
ssrc: u32,
end: Option<Instant>,
}
impl AudioTrack {
pub(crate) fn new() -> Result<(MediaEngine, Self), &'static str> {
let ssrc = rand::random();
let codec = RTCRtpCodec {
mime_type: "audio/opus".into(),
clock_rate: 48_000,
channels: 2,
sdp_fmtp_line: "minptime=10;useinbandfec=1".into(),
rtcp_feedback: vec![],
};
let mut media = MediaEngine::default();
media
.register_codec(
RTCRtpCodecParameters {
rtp_codec: codec.clone(),
payload_type: 111,
},
RtpCodecKind::Audio,
)
.map_err(|_| "failed to register voice codec")?;
let track = TrackLocalStaticSample::new(MediaStreamTrack::new(
"realtime".into(),
format!("audio-{ssrc}"),
"microphone".into(),
RtpCodecKind::Audio,
vec![RTCRtpEncodingParameters {
rtp_coding_parameters: RTCRtpCodingParameters {
ssrc: Some(ssrc),
..Default::default()
},
codec,
active: true,
..Default::default()
}],
))
.map_err(|_| "failed to create voice track")?;
Ok((
media,
Self {
track: Arc::new(track),
ssrc,
end: None,
},
))
}
pub(crate) async fn send(&mut self, frame: EncodedAudio) -> Result<(), &'static str> {
tokio::time::timeout(SEND_TIMEOUT, async {
let mut gap = self
.end
.map(|end| frame.at.saturating_duration_since(end))
.unwrap_or_default();
while !gap.is_zero() {
let duration = gap.min(Duration::from_secs(/*secs*/ 3600));
self.track
.write_sample(
self.ssrc,
/*payload_type*/ 111,
&Sample {
duration,
..Default::default()
},
&[],
)
.await
.map_err(|_| "failed to advance voice clock")?;
gap -= duration;
}
let duration = Duration::from_millis(/*millis*/ 20);
self.track
.write_sample(
self.ssrc,
/*payload_type*/ 111,
&Sample {
data: frame.data.into(),
duration,
..Default::default()
},
&[],
)
.await
.map_err(|_| "failed to send voice audio")?;
self.end = Some(self.end.unwrap_or(frame.at).max(frame.at) + duration);
Ok(())
})
.await
.map_err(|_| "voice audio sender stalled")?
}
}
#[cfg(test)]
#[path = "audio_track_tests.rs"]
mod tests;
@@ -0,0 +1,17 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn timestamp_jitter_does_not_accumulate_but_mute_advances_the_clock() {
let (_, mut track) = AudioTrack::new().unwrap();
let start = Instant::now();
// Empty samples exercise the real packetizer without requiring a bound network peer.
for tick in 0..500 {
let at = start + Duration::from_millis(tick * 20 + tick % 2);
track.send(EncodedAudio { data: vec![], at }).await.unwrap();
}
assert_eq!(track.end, Some(start + Duration::from_millis(10_001)));
let at = start + Duration::from_secs(30);
track.send(EncodedAudio { data: vec![], at }).await.unwrap();
assert_eq!(track.end, Some(at + Duration::from_millis(20)));
}
+79
View File
@@ -0,0 +1,79 @@
//! Pumps bounded device queues through capture processing and the outgoing audio track.
//! Devices retain real stream ownership; all queued and partial capture obey mute generations.
//! Retain at most one encoded batch and return between packets so controls can take priority.
use std::collections::VecDeque;
use std::io;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Instant;
use super::MAX_CAPTURE_AGE;
use super::buffers::Buffers;
use super::processing;
pub(super) struct CaptureWorker {
pub(super) buffers: Arc<Buffers>,
pub(super) processor: processing::Processor,
pub(super) pending: VecDeque<crate::audio_track::EncodedAudio>,
}
impl CaptureWorker {
pub(super) fn set_controls(
&mut self,
controls: codex_realtime_webrtc::AudioControls,
) -> io::Result<()> {
let previous = self.buffers.microphone.load(Ordering::Acquire);
Buffers::set_disabled(&self.buffers.microphone, controls.microphone_muted)?;
if previous != self.buffers.microphone.load(Ordering::Acquire) {
self.pending.clear();
self.processor.reset().map_err(io::Error::other)?;
}
Buffers::set_disabled(&self.buffers.speaker, controls.speaker_suppressed)
}
pub(super) async fn service(
&mut self,
audio: &mut crate::audio_track::AudioTrack,
now: impl Fn() -> Instant,
) -> io::Result<usize> {
// Some backends start callbacks during stream construction, before open returns.
self.buffers.serviced.store(true, Ordering::Release);
let generation = self.buffers.microphone.load(Ordering::Acquire);
for _ in 0..self.buffers.rendered.capacity() {
let Some(frame) = self.buffers.rendered.pop() else {
break;
};
if frame.at.elapsed() > MAX_CAPTURE_AGE {
return Err(io::Error::other("audio rendering fell behind"));
}
self.processor.render(&frame).map_err(io::Error::other)?;
}
if self.pending.is_empty() {
for _ in 0..self.buffers.capture.capacity() {
let Some(frame) = self.buffers.capture.pop() else {
break;
};
if generation.is_multiple_of(2) && frame.generation == generation {
let frames = self.processor.capture(&frame, &now);
self.pending.extend(frames.map_err(io::Error::other)?);
}
}
}
if self.buffers.failed.load(Ordering::Acquire) {
return Err(io::Error::other("audio device failed"));
}
let Some(frame) = self.pending.pop_front() else {
return Ok(0);
};
if now().saturating_duration_since(frame.at) > processing::MAX_PROCESSING_DELAY {
return Err(io::Error::other("voice processing fell behind"));
}
audio.send(frame).await.map_err(io::Error::other)?;
Ok(1)
}
}
#[cfg(test)]
#[path = "capture_worker_tests.rs"]
mod tests;
@@ -0,0 +1,300 @@
use super::super::buffers::BLOCK;
use super::super::buffers::Frame;
use super::*;
use crate::audio_track::AudioTrack;
use crate::transport::Transport;
use codex_realtime_webrtc::AudioControls;
use futures::future::BoxFuture;
use pretty_assertions::assert_eq;
use rtc::interceptor::Interceptor;
use rtc::interceptor::NoopInterceptor;
use rtc::interceptor::Packet;
use rtc::interceptor::StreamInfo;
use rtc::interceptor::TaggedPacket;
use rtc::interceptor::interceptor;
use rtc::sansio;
use rtc::shared::error::Error;
use std::time::Duration;
use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::time::timeout;
use webrtc::peer_connection::PeerConnection;
use webrtc::peer_connection::PeerConnectionBuilder;
use webrtc::peer_connection::PeerConnectionEventHandler;
use webrtc::peer_connection::RTCIceGatheringState;
use webrtc::peer_connection::RTCSessionDescription;
// Consume remote RTP before the upstream track queue: receipt proves the packet
// traversed negotiation, encryption, the loopback socket, and remote decryption.
#[derive(Interceptor)]
struct RemoteRtp {
#[next]
next: NoopInterceptor,
sender: mpsc::Sender<rtc::rtp::packet::Packet>,
}
#[interceptor]
impl RemoteRtp {
#[overrides]
fn handle_read(&mut self, message: TaggedPacket) -> Result<(), Self::Error> {
if let Packet::Rtp(packet) = message.message {
self.sender.try_send(packet).unwrap();
}
Ok(())
}
}
struct Gathered(Arc<Notify>);
impl PeerConnectionEventHandler for Gathered {
fn on_ice_gathering_state_change<'a, 'async_trait>(
&'a self,
state: RTCIceGatheringState,
) -> BoxFuture<'async_trait, ()>
where
'a: 'async_trait,
Self: 'async_trait,
{
Box::pin(async move {
if state == RTCIceGatheringState::Complete {
self.0.notify_one();
}
})
}
}
// Synthetic callback output enters the same bounded queue as the real cpal
// callback. No hardware availability or physical microphone behavior is claimed.
fn enqueue(worker: &CaptureWorker, samples: usize, generation: u64, at: Instant) {
for offset in (0..samples).step_by(BLOCK) {
let mut frame = Frame {
samples: [0.0; BLOCK],
len: BLOCK.min(samples - offset),
at: at + Duration::from_secs_f64(offset as f64 / 48_000.0),
generation,
};
for (index, sample) in frame.samples.iter_mut().enumerate() {
*sample =
(std::f32::consts::TAU * 440.0 * (offset + index) as f32 / 48_000.0).sin() * 0.25;
}
assert!(worker.buffers.capture.push(frame).is_ok());
}
}
async fn receive_capture(
worker: &mut CaptureWorker,
local: &mut Transport,
received: &mut mpsc::Receiver<rtc::rtp::packet::Packet>,
now: Instant,
) -> Vec<rtc::rtp::packet::Packet> {
let mut count = 0;
loop {
let sent = worker.service(&mut local.audio, || now).await.unwrap();
if sent == 0 {
break;
}
assert_eq!(sent, 1);
count += sent;
}
assert!(count > 0, "fresh capture must produce audio");
let mut packets = Vec::new();
let mut decoder = opus::Decoder::new(/*sample_rate*/ 48_000, opus::Channels::Mono).unwrap();
let mut energy = 0.0_f32;
for _ in 0..count {
let packet = received.recv().await.unwrap();
assert_eq!(packet.header.payload_type, 111);
let mut decoded = [0.0; 960];
assert_eq!(
decoder
.decode_float(&packet.payload, &mut decoded, /*fec*/ false)
.unwrap(),
960
);
assert!(decoded.iter().all(|sample| sample.is_finite()));
energy += decoded.iter().map(|sample| sample * sample).sum::<f32>();
packets.push(packet);
}
assert!(
energy > 0.01,
"decoded microphone signal must not be silence"
);
packets
}
#[tokio::test]
async fn capture_reaches_remote_rtp_and_mute_discards_queued_and_partial_audio() {
timeout(Duration::from_secs(/*secs*/ 30), async {
let (sender, mut received) = mpsc::channel(/*buffer*/ 32);
let gathered = Arc::new(Notify::new());
let (media, _) = AudioTrack::new().unwrap();
let mut settings = webrtc::peer_connection::SettingEngine::default();
settings.set_lite(/*lite*/ true);
let remote = PeerConnectionBuilder::new()
.with_media_engine(media)
.with_setting_engine(settings)
.with_handler(Arc::new(Gathered(gathered.clone())))
.with_interceptor_registry(rtc::interceptor::Registry::from(RemoteRtp {
next: NoopInterceptor::new(),
sender,
}))
.with_udp_addrs(vec!["0.0.0.0:0"])
.build()
.await
.unwrap();
let mut local = Transport::new().await.unwrap();
remote
.set_remote_description(
RTCSessionDescription::offer(local.offer().await.unwrap()).unwrap(),
)
.await
.unwrap();
let answer = remote.create_answer(/*options*/ None).await.unwrap();
remote.set_local_description(answer).await.unwrap();
gathered.notified().await;
local
.apply_answer(remote.local_description().await.unwrap().sdp)
.await
.unwrap();
let mut worker = CaptureWorker {
buffers: Arc::new(Buffers::new(
/*input_rate*/ 48_000, /*output_rate*/ 48_000,
)),
pending: Default::default(),
processor: processing::Processor::new(
/*input_rate*/ 48_000, /*output_rate*/ 44_100,
)
.unwrap(),
};
let unmuted = || AudioControls {
microphone_muted: false,
speaker_suppressed: false,
};
let muted = || AudioControls {
microphone_muted: true,
speaker_suppressed: true,
};
worker.set_controls(unmuted()).unwrap();
let generation = worker.buffers.microphone.load(Ordering::Acquire);
// Freeze processing time so this transport test does not benchmark the CI worker.
// Processor tests separately exercise the real backlog cutoff.
let start = Instant::now();
let mut now = start;
// Feed a distinct speaker tone through the real render resampler and APM
// before capture; this exercises a different output rate without hardware.
for offset in (0..2_646).step_by(BLOCK) {
let mut frame = Frame {
samples: [0.0; BLOCK],
len: BLOCK.min(2_646 - offset),
at: start + Duration::from_secs_f64(offset as f64 / 44_100.0),
generation: 0,
};
for (index, sample) in frame.samples.iter_mut().enumerate() {
*sample = (std::f32::consts::TAU * 220.0 * (offset + index) as f32 / 44_100.0)
.sin()
* 0.1;
}
assert!(worker.buffers.rendered.push(frame).is_ok());
}
enqueue(&worker, /*samples*/ 2_880, generation, now);
let before = receive_capture(&mut worker, &mut local, &mut received, now).await;
assert!(worker.buffers.rendered.is_empty());
// Start with a clean processor, then retain less than one Opus packet.
// If either resampler or partial encoder history survives the next mute,
// the equally short fresh segment below would emit audio too early.
worker.set_controls(muted()).unwrap();
worker.set_controls(unmuted()).unwrap();
let old_generation = worker.buffers.microphone.load(Ordering::Acquire);
now = Instant::now();
enqueue(&worker, /*samples*/ 720, old_generation, now);
assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0);
enqueue(&worker, /*samples*/ 2_880, old_generation, now);
worker.set_controls(muted()).unwrap();
assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0);
// A callback that began before mute may enqueue its old generation only
// after unmute; a newly started callback may still carry an old device
// timestamp. Exercise both independently through the actual service loop.
let pre_unmute = Instant::now() - Duration::from_millis(/*millis*/ 100);
worker.set_controls(unmuted()).unwrap();
let generation = worker.buffers.microphone.load(Ordering::Acquire);
let resumed = Instant::now() + Duration::from_secs(/*secs*/ 1);
now = resumed;
enqueue(&worker, /*samples*/ 2_880, old_generation, now);
assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0);
enqueue(&worker, /*samples*/ 2_880, generation, pre_unmute);
assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0);
enqueue(&worker, /*samples*/ 720, generation, now);
assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0);
now += Duration::from_millis(/*millis*/ 15);
enqueue(&worker, /*samples*/ 2_880, generation, now);
let after = receive_capture(&mut worker, &mut local, &mut received, now).await;
let clock_gap = after[0]
.header
.timestamp
.wrapping_sub(before[0].header.timestamp);
let expected_gap = (resumed.duration_since(start).as_secs_f64() * 48_000.0) as u32;
// Allow 1 ms for sample rounding without imposing a wall-clock throughput requirement.
assert!(
clock_gap.abs_diff(expected_gap) <= 48,
"received RTP clock gap: {clock_gap}, expected {expected_gap}"
);
assert_eq!(
after[0].header.sequence_number,
before
.last()
.unwrap()
.header
.sequence_number
.wrapping_add(1)
);
assert!(received.try_recv().is_err());
// A mute received during a send must discard the rest of that encoded
// batch before the next service call, including after a quick unmute.
worker.set_controls(muted()).unwrap();
worker.set_controls(unmuted()).unwrap();
let generation = worker.buffers.microphone.load(Ordering::Acquire);
now = Instant::now() + Duration::from_secs(/*secs*/ 1);
enqueue(&worker, /*samples*/ 2_880, generation, now);
assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 1);
received.recv().await.unwrap();
assert!(!worker.pending.is_empty());
let pending = worker.pending.len();
worker
.set_controls(AudioControls {
microphone_muted: false,
speaker_suppressed: true,
})
.unwrap();
assert_eq!(worker.pending.len(), pending);
worker.set_controls(muted()).unwrap();
worker.set_controls(unmuted()).unwrap();
assert!(worker.pending.is_empty());
assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0);
assert!(received.try_recv().is_err());
// Waiting behind a slow send cannot extend the capture freshness limit.
let generation = worker.buffers.microphone.load(Ordering::Acquire);
now = Instant::now() + Duration::from_secs(/*secs*/ 1);
enqueue(&worker, /*samples*/ 2_880, generation, now);
assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 1);
received.recv().await.unwrap();
let stale = now + Duration::from_secs(/*secs*/ 1);
assert_eq!(
worker
.service(&mut local.audio, || stale)
.await
.unwrap_err()
.to_string(),
"voice processing fell behind"
);
assert!(received.try_recv().is_err());
local.close().await.unwrap();
remote.close().await.unwrap();
})
.await
.unwrap();
}
+28 -4
View File
@@ -1,5 +1,6 @@
//! Preallocated callback buffers pack small callbacks into full queue slots without locks.
//! Generation changes and rejected capture buffers discard incomplete frames.
//! Generation changes, rejected capture buffers and capture gaps discard incomplete frames.
//! Capture/render capacity covers the worker deadline; playback and callback limits stay fixed.
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
@@ -11,6 +12,9 @@ use crossbeam_queue::ArrayQueue;
pub(super) const BLOCK: usize = 256;
pub(super) const QUEUE_CAPACITY: usize = 32;
pub(super) const MAX_CALLBACK_FRAMES: usize = BLOCK * QUEUE_CAPACITY;
// Allow ordinary timestamp jitter consistently at both capture buffering boundaries.
pub(super) const CAPTURE_GAP_TOLERANCE: Duration = Duration::from_millis(/*millis*/ 20);
pub(super) struct Frame {
pub(super) samples: [f32; BLOCK],
@@ -31,6 +35,17 @@ impl FramePacker {
self.frame = None;
}
// Capture checks continuity before packing can hide a gap inside an old partial block.
// Render callbacks retain their separate existing packing behavior.
pub(super) fn discard_capture_gap(&mut self, start: Instant, rate: f64) {
if self.frame.as_ref().is_some_and(|partial| {
let end = partial.at + Duration::from_secs_f64(partial.len as f64 / rate);
start.saturating_duration_since(end) > CAPTURE_GAP_TOLERANCE
}) {
self.reset();
}
}
pub(super) fn push(&mut self, frame: Frame, rate: f64, queue: &ArrayQueue<Frame>) -> bool {
if self
.frame
@@ -80,10 +95,19 @@ pub(super) struct Buffers {
}
impl Buffers {
pub(super) fn new() -> Self {
pub(super) fn new(input_rate: u32, output_rate: u32) -> Self {
// Rates are validated before opening devices. A pending batch can defer
// capture consumption until its freshness deadline plus one in-flight send.
let budget = super::processing::MAX_PROCESSING_DELAY
+ crate::audio_track::SEND_TIMEOUT
+ 2 * crate::DEVICE_SERVICE_INTERVAL;
let capacity = |rate: u32| {
let samples = (u128::from(rate) * budget.as_nanos()).div_ceil(1_000_000_000);
(samples as usize + MAX_CALLBACK_FRAMES + BLOCK).div_ceil(BLOCK)
};
Self {
capture: ArrayQueue::new(QUEUE_CAPACITY),
rendered: ArrayQueue::new(QUEUE_CAPACITY),
capture: ArrayQueue::new(capacity(input_rate)),
rendered: ArrayQueue::new(capacity(output_rate)),
playback: ArrayQueue::new(QUEUE_CAPACITY),
microphone: AtomicU64::new(/*v*/ 1),
speaker: AtomicU64::new(/*v*/ 1),
@@ -41,7 +41,7 @@ fn unmute_rejects_backlog_until_capture_reaches_the_following_callback() {
#[test]
fn suppression_discards_partial_and_queued_previous_generations() {
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000);
let mut playback = Playback::default();
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
let generation = buffers.speaker.load(Ordering::Acquire);
@@ -68,7 +68,7 @@ fn suppression_discards_partial_and_queued_previous_generations() {
#[test]
fn output_is_finite_and_bounded_and_underflow_is_silence() {
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
let generation = buffers.speaker.load(Ordering::Acquire);
let mut samples = [0.0; BLOCK];
@@ -91,7 +91,7 @@ fn output_is_finite_and_bounded_and_underflow_is_silence() {
#[test]
fn repeated_controls_preserve_epoch_but_transitions_invalidate_capture() {
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000);
Buffers::set_disabled(&buffers.microphone, /*disabled*/ false).unwrap();
let first = buffers.microphone.load(Ordering::Acquire);
Buffers::set_disabled(&buffers.microphone, /*disabled*/ false).unwrap();
@@ -103,7 +103,7 @@ fn repeated_controls_preserve_epoch_but_transitions_invalidate_capture() {
#[test]
fn tiny_callbacks_share_slots_and_preserve_the_oldest_timestamp() {
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000);
let start = Instant::now();
for queue in [&buffers.capture, &buffers.rendered] {
let mut packer = FramePacker::default();
@@ -210,3 +210,43 @@ fn packing_preserves_a_remainder_timestamp_across_uneven_callbacks() {
);
assert!(queue.is_empty());
}
#[test]
fn capture_queue_retains_high_rate_audio_during_bounded_service_pause() {
let buffers = Buffers::new(/*input_rate*/ 384_000, /*output_rate*/ 48_000);
let mut packer = FramePacker::default();
let start = Instant::now();
// 630 ms covers a pending batch's 500 ms deadline, an in-flight 100 ms
// send and callback/service margins. Consumption is deliberately paused.
let samples = 384_000 * 63 / 100;
for offset in (0..samples).step_by(17) {
assert!(packer.push(
Frame {
samples: [0.25; BLOCK],
len: (samples - offset).min(17),
at: start + Duration::from_secs_f64(offset as f64 / 384_000.0),
generation: 2,
},
/*rate*/ 384_000.0,
&buffers.capture,
));
}
assert_eq!(buffers.capture.len(), samples / BLOCK);
// Enlarging the queue must not turn overflow into unbounded accumulation.
for _ in buffers.capture.len()..=buffers.capture.capacity() {
let available = buffers.capture.len() < buffers.capture.capacity();
assert_eq!(
packer.push(
Frame {
samples: [0.25; BLOCK],
len: BLOCK,
at: start,
generation: 2,
},
/*rate*/ 384_000.0,
&buffers.capture,
),
available,
);
}
}
+36 -31
View File
@@ -5,6 +5,10 @@
#[path = "device_buffers.rs"]
mod buffers;
#[path = "capture_worker.rs"]
mod capture_worker;
#[path = "processing.rs"]
mod processing;
use std::io;
use std::sync::Arc;
@@ -25,6 +29,7 @@ use buffers::Buffers;
use buffers::CaptureBoundary;
use buffers::Frame;
use buffers::FramePacker;
use buffers::MAX_CALLBACK_FRAMES;
use buffers::Playback;
use buffers::QUEUE_CAPACITY;
@@ -33,7 +38,7 @@ const MAX_CAPTURE_AGE: Duration = Duration::from_secs(/*secs*/ 1);
pub(super) struct Devices {
_input: cpal::Stream,
_output: cpal::Stream,
buffers: Arc<Buffers>,
worker: capture_worker::CaptureWorker,
}
macro_rules! stream {
@@ -79,7 +84,22 @@ impl Devices {
}
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 buffers = Arc::new(Buffers::new(
input_config.sample_rate(),
output_config.sample_rate(),
));
let processor =
processing::Processor::new(input_config.sample_rate(), output_config.sample_rate())
.map_err(io::Error::other)?;
let (cpal::BufferSize::Fixed(input_frames), cpal::BufferSize::Fixed(output_frames)) = (
input_stream_config.buffer_size,
output_stream_config.buffer_size,
) else {
return Err(io::Error::other("audio callback size unavailable"));
};
processor
.validate_callback_timing(input_frames, output_frames)
.map_err(io::Error::other)?;
let input = stream!(
input_config.sample_format(),
build_input,
@@ -105,37 +125,26 @@ impl Devices {
Ok(Self {
_input: input,
_output: output,
buffers,
worker: capture_worker::CaptureWorker {
buffers,
processor,
pending: Default::default(),
},
})
}
pub(super) fn set_controls(
&self,
&mut 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)
self.worker.set_controls(controls)
}
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(())
pub(super) async fn service(
&mut self,
audio: &mut crate::audio_track::AudioTrack,
) -> io::Result<usize> {
self.worker.service(audio, Instant::now).await
}
}
@@ -146,13 +155,8 @@ fn bounded_stream_config(
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);
let max = max.min(MAX_CALLBACK_FRAMES as u32);
if min > max {
return Err(io::Error::other("unsupported audio callback size range"));
}
@@ -225,6 +229,7 @@ where
.unwrap_or_default(),
)
.unwrap_or_else(Instant::now);
capture.discard_capture_gap(start, rate);
for (index, chunk) in data.chunks(BLOCK * channels).enumerate() {
let mut frame = Frame {
samples: [0.0; BLOCK],
+75 -18
View File
@@ -3,7 +3,7 @@ use pretty_assertions::assert_eq;
#[test]
fn startup_output_waits_for_service_but_later_overflow_still_fails() {
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000);
let mut state = OutputState::default();
let mut output = [1.0_f32; BLOCK * 2];
let start = Instant::now();
@@ -46,7 +46,7 @@ fn startup_output_waits_for_service_but_later_overflow_still_fails() {
),
);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
for _ in 0..QUEUE_CAPACITY {
for _ in 0..buffers.rendered.capacity() {
render_output(
&mut output,
/*channels*/ 2,
@@ -96,7 +96,7 @@ fn callback_configuration_fits_supported_range_and_actual_queue() {
buffer_size: cpal::BufferSize::Fixed(frames),
}
);
let buffers = Buffers::new();
let buffers = Buffers::new(rate, rate);
for queue in [&buffers.capture, &buffers.rendered] {
for offset in (0..frames as usize).step_by(BLOCK) {
assert!(
@@ -115,23 +115,20 @@ fn callback_configuration_fits_supported_range_and_actual_queue() {
}
#[test]
fn callback_configuration_reserves_room_for_partial_and_following_callbacks() {
for min in [6_016, 6_017, 8_192] {
fn callback_configuration_fits_dynamic_queue_with_following_callbacks() {
for min in [6_016, 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 {
let cpal::BufferSize::Fixed(frames) =
bounded_stream_config(&supported).unwrap().buffer_size
else {
panic!("callback size must be bounded");
};
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000);
buffers.serviced.store(true, Ordering::Release);
let mut output = OutputState::default();
let start = Instant::now();
@@ -146,7 +143,11 @@ fn callback_configuration_reserves_room_for_partial_and_following_callbacks() {
&mut output,
);
}
assert_eq!(buffers.rendered.len(), QUEUE_CAPACITY - 1);
assert_eq!(
buffers.rendered.len(),
(BLOCK - 1 + frames as usize + 15 * 128) / BLOCK,
);
assert!(buffers.rendered.len() < buffers.rendered.capacity());
assert!(!buffers.failed.load(Ordering::Acquire));
}
}
@@ -191,9 +192,41 @@ fn callback_timing_must_fit_service_and_stale_limits() {
}
}
#[test]
fn callback_pair_must_fit_the_processing_deadline() {
for (input_rate, input_frames, output_rate, output_frames, accepted) in [
(8_000, 7_680, 48_000, 480, false),
(8_000, 2_000, 8_000, 2_000, false),
(48_000, 480, 48_000, 480, true),
(44_100, 441, 48_000, 480, true),
(8_000, 1_600, 48_000, 480, true),
] {
for (rate, frames) in [(input_rate, input_frames), (output_rate, output_frames)] {
let supported = cpal::SupportedStreamConfig::new(
/*channels*/ 1,
rate,
cpal::SupportedBufferSize::Range {
min: frames,
max: frames,
},
cpal::SampleFormat::F32,
);
assert!(bounded_stream_config(&supported).is_ok());
}
let processor = processing::Processor::new(input_rate, output_rate).unwrap();
assert_eq!(
processor
.validate_callback_timing(input_frames, output_frames)
.is_ok(),
accepted,
"input={input_rate}/{input_frames}, output={output_rate}/{output_frames}",
);
}
}
#[test]
fn tiny_output_callbacks_pack_references_without_delaying_rendering() {
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000);
buffers.serviced.store(true, Ordering::Release);
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
let generation = buffers.speaker.load(Ordering::Acquire);
@@ -228,13 +261,37 @@ fn tiny_output_callbacks_pack_references_without_delaying_rendering() {
assert!(!buffers.failed.load(Ordering::Acquire));
}
#[test]
fn rendered_queue_retains_high_rate_callbacks_during_bounded_service_pause() {
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000);
buffers.serviced.store(true, Ordering::Release);
let mut state = OutputState::default();
let start = Instant::now();
let samples = 384_000 * 63 / 100;
for offset in (0..samples).step_by(17) {
let mut output = [1.0_f32; 34];
let len = (samples - offset).min(17) * 2;
render_output(
&mut output[..len],
/*channels*/ 2,
/*rate*/ 384_000.0,
start + Duration::from_secs_f64(offset as f64 / 384_000.0),
&buffers,
&mut state,
);
assert_eq!(&output[..len], &[0.0; 34][..len]);
assert!(!buffers.failed.load(Ordering::Acquire));
}
assert_eq!(buffers.rendered.len(), samples / BLOCK);
}
#[test]
fn suppressed_output_does_not_fail_when_reference_queue_is_full() {
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000);
buffers.serviced.store(true, Ordering::Release);
let mut state = OutputState::default();
let mut output = [1.0_f32; BLOCK];
for _ in 0..QUEUE_CAPACITY * 3 {
for _ in 0..buffers.rendered.capacity() * 3 {
render_output(
&mut output,
/*channels*/ 1,
@@ -245,7 +302,7 @@ fn suppressed_output_does_not_fail_when_reference_queue_is_full() {
);
assert_eq!(output, [0.0; BLOCK]);
}
assert_eq!(buffers.rendered.len(), QUEUE_CAPACITY);
assert_eq!(buffers.rendered.len(), buffers.rendered.capacity());
assert!(!buffers.failed.load(Ordering::Acquire));
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
render_output(
@@ -261,7 +318,7 @@ fn suppressed_output_does_not_fail_when_reference_queue_is_full() {
#[test]
fn stream_xruns_recover_without_clearing_device_failure() {
let buffers = Buffers::new();
let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000);
handle_stream_error(&buffers, cpal::ErrorKind::Xrun.into());
assert!(!buffers.failed.load(Ordering::Acquire));
handle_stream_error(&buffers, cpal::ErrorKind::DeviceNotAvailable.into());
@@ -15,7 +15,10 @@ impl Devices {
Self::open().map(|_| ())
}
pub(super) fn service(&self) -> io::Result<()> {
Self::open().map(|_| ())
pub(super) async fn service(
&mut self,
_: &mut crate::audio_track::AudioTrack,
) -> io::Result<usize> {
Self::open().map(|_| 0)
}
}
+37 -5
View File
@@ -1,5 +1,16 @@
//! Same-build helper lifecycle with privately owned runtime, transport and opt-in local devices.
//! Queued privacy controls take priority over starting another capture batch.
// Non-native helper targets negotiate transport but have no device backend to send audio.
#[cfg_attr(
not(any(
target_os = "macos",
all(target_os = "linux", target_env = "gnu"),
all(windows, target_env = "msvc")
)),
allow(dead_code)
)]
mod audio_track;
#[cfg_attr(
not(any(
target_os = "macos",
@@ -89,13 +100,19 @@ fn run(
output.flush()?;
let mut runtime = None;
let executor = tokio::runtime::Runtime::new()?;
let mut transport = None;
let mut transport: Option<transport::Transport> = None;
let mut answered = false;
let mut devices: Option<devices::Devices> = None;
loop {
let message = if let Some(devices) = &devices {
devices.service()?;
receiver.recv_timeout(DEVICE_SERVICE_INTERVAL)
let message = if let Some(devices) = &mut devices {
let peer = transport
.as_mut()
.ok_or_else(|| io::Error::other("voice peer not started"))?;
wait_for_control(&receiver, || {
executor.block_on(devices.service(&mut peer.audio))?;
Ok(())
})?
.ok_or(mpsc::RecvTimeoutError::Disconnected)
} else {
receiver
.recv()
@@ -108,7 +125,7 @@ fn run(
}
Ok(Message::SetAudioControls { controls }) => {
devices
.as_ref()
.as_mut()
.ok_or_else(|| io::Error::other("audio devices not open"))?
.set_controls(controls)?;
Message::AudioControlsApplied {}
@@ -167,6 +184,21 @@ fn run(
}
}
// Pending privacy controls and shutdown always precede the next capture batch.
// A command arriving after service begins cannot retract an already in-flight packet.
fn wait_for_control(
receiver: &mpsc::Receiver<Message>,
mut service: impl FnMut() -> io::Result<()>,
) -> io::Result<Option<Message>> {
loop {
match receiver.recv_timeout(DEVICE_SERVICE_INTERVAL) {
Ok(message) => return Ok(Some(message)),
Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(None),
Err(mpsc::RecvTimeoutError::Timeout) => service()?,
}
}
}
#[cfg(test)]
#[path = "main_tests.rs"]
mod tests;
+43 -1
View File
@@ -1,4 +1,4 @@
//! Verify the real parent-loss watchdog while the startup worker is deterministically blocked.
//! Verify control-first capture servicing and the real parent-loss watchdog during blocked startup.
use super::*;
use std::process::Stdio;
@@ -12,6 +12,48 @@ use tokio::time::timeout;
const STARTUP_BLOCKED: &[u8] = b"voice startup blocked\n";
const CHILD_ENV: &str = "CODEX_VOICE_WATCHDOG_TEST_CHILD";
#[test]
fn queued_privacy_controls_and_close_precede_capture_service() {
for message in [
Message::SetAudioControls {
controls: codex_realtime_webrtc::AudioControls {
microphone_muted: true,
speaker_suppressed: true,
},
},
Message::Close {},
] {
let expected = encode_frame(&message).unwrap();
let (sender, receiver) = mpsc::sync_channel(/*bound*/ 1);
sender.send(message).unwrap();
let next = wait_for_control(&receiver, || panic!("capture preceded queued control"))
.unwrap()
.unwrap();
assert_eq!(encode_frame(&next).unwrap(), expected);
drop(sender);
assert!(
wait_for_control(&receiver, || panic!("capture after parent loss"))
.unwrap()
.is_none()
);
}
}
#[test]
fn idle_service_rechecks_controls_before_another_batch() {
let (sender, receiver) = mpsc::sync_channel(/*bound*/ 1);
let mut services = 0;
let next = wait_for_control(&receiver, || {
services += 1;
sender.send(Message::Close {}).unwrap();
Ok(())
})
.unwrap()
.unwrap();
assert_eq!(next, Message::Close {});
assert_eq!(services, 1);
}
#[test]
#[ignore = "subprocess fixture for the parent-loss watchdog test"]
fn blocked_startup_fixture() {
+251
View File
@@ -0,0 +1,251 @@
//! Streaming device-rate conversion, echo/noise/gain processing, and 20 ms Opus encoding.
//! Mute transitions and capture gaps reset history; delayed pre-unmute buffers are discarded.
use std::collections::VecDeque;
use std::time::Duration;
use std::time::Instant;
use rubato::Resampler;
use rubato::audioadapter_buffers::direct::InterleavedSlice;
use sonora::AudioProcessing;
use sonora::StreamConfig;
use sonora::config::AdaptiveDigital;
use sonora::config::EchoCanceller;
use sonora::config::GainController2;
use sonora::config::NoiseSuppression;
use super::buffers::CAPTURE_GAP_TOLERANCE;
use super::buffers::Frame;
use crate::audio_track::EncodedAudio;
type Result<T> = std::result::Result<T, &'static str>;
const BLOCK: usize = 480;
pub(super) const MAX_PROCESSING_DELAY: Duration = Duration::from_millis(/*millis*/ 500);
struct Converter {
resampler: rubato::Async<f32>,
input: VecDeque<f32>,
output: VecDeque<f32>,
scratch: Vec<f32>,
rate: u32,
end: Option<Instant>,
}
impl Converter {
fn new(rate: u32) -> Result<Self> {
let resampler = rubato::Async::new_sinc(
48_000.0 / f64::from(rate),
/*max_resample_ratio_relative*/ 1.0,
&rubato::SincInterpolationParameters::default(),
rate.div_ceil(100) as usize,
/*nbr_channels*/ 1,
rubato::FixedAsync::Input,
)
.map_err(|_| "failed to create voice resampler")?;
let scratch = vec![0.0; resampler.output_frames_max()];
Ok(Self {
resampler,
input: VecDeque::new(),
output: VecDeque::new(),
scratch,
rate,
end: None,
})
}
fn push(&mut self, frame: &Frame) -> Result<()> {
if frame.len > frame.samples.len()
|| self.input.len() + frame.len > self.rate as usize
|| self.output.len() > 48_000
{
return Err("voice resampler backlog exceeded");
}
self.end =
Some(frame.at + Duration::from_secs_f64(frame.len as f64 / f64::from(self.rate)));
self.input.extend(&frame.samples[..frame.len]);
while self.input.len() >= self.resampler.input_frames_next() {
let samples = self.input.make_contiguous();
let input = InterleavedSlice::new(samples, /*channels*/ 1, samples.len())
.map_err(|_| "invalid voice resampler input")?;
let capacity = self.scratch.len();
let mut output =
InterleavedSlice::new_mut(&mut self.scratch, /*channels*/ 1, capacity)
.map_err(|_| "invalid voice resampler output")?;
let (consumed, produced) = self
.resampler
.process_into_buffer(&input, &mut output, /*indexing*/ None)
.map_err(|_| "failed to resample voice audio")?;
self.input.drain(..consumed);
self.output.extend(&self.scratch[..produced]);
}
Ok(())
}
fn next(&mut self) -> Option<(Instant, [f32; BLOCK])> {
if self.output.len() < BLOCK {
return None;
}
let delay = self.input.len() as f64 / f64::from(self.rate)
+ (self.output.len() + self.resampler.output_delay()) as f64 / 48_000.0;
let end = self.end?;
let at = end
.checked_sub(Duration::from_secs_f64(delay))
.unwrap_or(end);
let mut output = [0.0; BLOCK];
for (out, sample) in output.iter_mut().zip(self.output.drain(..BLOCK)) {
*out = sample;
}
Some((at, output))
}
}
pub(super) struct Processor {
capture: Converter,
render: Converter,
apm: AudioProcessing,
encoder: opus::Encoder,
pending: Vec<f32>,
at: Instant,
cutoff: Instant,
render_delay: i64,
}
impl Processor {
pub(super) fn new(input_rate: u32, output_rate: u32) -> Result<Self> {
Ok(Self {
capture: Converter::new(input_rate)?,
render: Converter::new(output_rate)?,
apm: AudioProcessing::builder()
.config(sonora::Config {
echo_canceller: Some(EchoCanceller::default()),
noise_suppression: Some(NoiseSuppression::default()),
gain_controller2: Some(GainController2 {
adaptive_digital: Some(AdaptiveDigital::default()),
..Default::default()
}),
..Default::default()
})
.capture_config(StreamConfig::new(
/*sample_rate_hz*/ 48_000, /*num_channels*/ 1,
))
.render_config(StreamConfig::new(
/*sample_rate_hz*/ 48_000, /*num_channels*/ 1,
))
.build(),
encoder: opus::Encoder::new(
/*sample_rate*/ 48_000,
opus::Channels::Mono,
opus::Application::Voip,
)
.map_err(|_| "failed to create voice encoder")?,
pending: Vec::with_capacity(960),
at: Instant::now(),
cutoff: Instant::now(),
render_delay: 0,
})
}
pub(super) fn reset(&mut self) -> Result<()> {
*self = Self::new(self.capture.rate, self.render.rate)?;
Ok(())
}
pub(super) fn validate_callback_timing(
&self,
input_frames: u32,
output_frames: u32,
) -> Result<()> {
let mut delay = super::super::DEVICE_SERVICE_INTERVAL;
for (converter, frames) in [(&self.capture, input_frames), (&self.render, output_frames)] {
// Include callback and queue packing latency, plus the resampler's
// actual delay and one processing block, for both device directions.
delay += Duration::from_secs_f64(
(f64::from(frames) + super::buffers::BLOCK as f64) / f64::from(converter.rate)
+ (converter.resampler.output_delay() + BLOCK) as f64 / 48_000.0,
);
}
if delay >= MAX_PROCESSING_DELAY {
return Err("audio callback timing exceeds processing deadline");
}
Ok(())
}
pub(super) fn render(&mut self, frame: &Frame) -> Result<()> {
self.render.push(frame)?;
while let Some((at, input)) = self.render.next() {
let mut output = [0.0; BLOCK];
self.apm
.process_render_f32(&[&input], &mut [&mut output])
.map_err(|_| "voice echo reference failed")?;
let now = Instant::now();
self.render_delay = if at > now {
at.duration_since(now).as_millis() as i64
} else {
-(now.duration_since(at).as_millis() as i64)
};
}
Ok(())
}
pub(super) fn capture(
&mut self,
frame: &Frame,
now: impl Fn() -> Instant,
) -> Result<Vec<EncodedAudio>> {
if frame.at < self.cutoff {
return Ok(vec![]);
}
if self
.capture
.end
.is_some_and(|end| frame.at.saturating_duration_since(end) > CAPTURE_GAP_TOLERANCE)
{
// Drop resampler, APM and half-packet history without advancing the
// unmute boundary: this frame may already have waited in the queue.
let cutoff = self.cutoff;
self.reset()?;
self.cutoff = cutoff;
}
self.capture.push(frame)?;
let mut encoded = Vec::new();
while let Some((at, input)) = self.capture.next() {
let capture_age = now().saturating_duration_since(at);
if capture_age > MAX_PROCESSING_DELAY {
return Err("voice processing fell behind");
}
let delay = (capture_age.as_millis() as i64 + self.render_delay).max(0);
if delay > MAX_PROCESSING_DELAY.as_millis() as i64 {
return Err("voice processing fell behind");
}
self.apm
.set_stream_delay_ms(delay as i32)
.map_err(|_| "invalid voice echo delay")?;
let mut output = [0.0; BLOCK];
self.apm
.process_capture_f32(&[&input], &mut [&mut output])
.map_err(|_| "voice capture processing failed")?;
if output.iter().any(|sample| !sample.is_finite()) {
return Err("invalid processed voice audio");
}
if self.pending.is_empty() {
self.at = at;
}
self.pending.extend(output);
if self.pending.len() == 960 {
let mut data = vec![0; 1275];
let len = self
.encoder
.encode_float(&self.pending, &mut data)
.map_err(|_| "failed to encode voice audio")?;
data.truncate(len);
encoded.push(EncodedAudio { data, at: self.at });
self.pending.clear();
}
}
Ok(encoded)
}
}
#[cfg(test)]
#[path = "processing_tests.rs"]
mod tests;
+285
View File
@@ -0,0 +1,285 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn rendered_reference_changes_decoded_echo_capture() {
let mut processors = [
Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 44_100).unwrap(),
Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 44_100).unwrap(),
];
for processor in &mut processors {
// Isolate echo cancellation from independent noise/gain adaptation.
processor.apm.apply_config(sonora::Config {
echo_canceller: Some(EchoCanceller::default()),
..Default::default()
});
}
let start = Instant::now();
let signal = |seconds: f32| {
[311.0, 719.0, 1_423.0]
.into_iter()
.map(|frequency| (std::f32::consts::TAU * frequency * seconds).sin() * 0.1)
.sum::<f32>()
};
let mut packets = [Vec::new(), Vec::new()];
// Two seconds of deterministic samples allow AEC startup, without sleeping.
for block in 0..200 {
for offset in [0, 256] {
let mut frame = Frame {
samples: [0.0; 256],
len: 256.min(441 - offset),
at: start - Duration::from_secs(/*secs*/ 10)
+ Duration::from_secs_f64((block * 441 + offset) as f64 / 44_100.0),
generation: 0,
};
for (index, sample) in frame.samples[..frame.len].iter_mut().enumerate() {
*sample = signal((block * 441 + offset + index) as f32 / 44_100.0);
}
processors[0].render(&frame).unwrap();
}
// Keep delay metadata identical: only consuming reference samples may
// explain the difference in the decoded capture signal.
processors[1].render_delay = processors[0].render_delay;
for offset in [0, 256] {
let mut frame = Frame {
samples: [0.0; 256],
len: 256.min(480 - offset),
at: start + Duration::from_secs_f64((block * 480 + offset) as f64 / 48_000.0),
generation: 2,
};
for (index, sample) in frame.samples[..frame.len].iter_mut().enumerate() {
*sample = signal((block * 480 + offset + index) as f32 / 48_000.0 - 0.05);
}
for (processor, packets) in processors.iter_mut().zip(&mut packets) {
packets.extend(processor.capture(&frame, || frame.at).unwrap());
}
}
}
let decoded = packets.map(|packets| {
let mut decoder = opus::Decoder::new(/*sample_rate*/ 48_000, opus::Channels::Mono).unwrap();
let mut decoded = Vec::new();
for packet in packets {
let mut samples = [0.0; 960];
assert_eq!(
decoder
.decode_float(&packet.data, &mut samples, /*fec*/ false)
.unwrap(),
960
);
assert!(samples.iter().all(|sample| sample.is_finite()));
decoded.extend(samples);
}
decoded
});
assert_eq!(decoded[0].len(), decoded[1].len());
assert!(decoded[0].len() > 48_000);
let energy = decoded.map(|samples| {
samples[48_000..]
.iter()
.map(|sample| sample * sample)
.sum::<f32>()
});
assert!(
energy[1] > 1.0,
"control capture must contain the echo signal"
);
assert!(
energy[0] < energy[1] * 0.5,
"reference/control echo energy: {energy:?}"
);
}
#[test]
fn real_encoder_produces_twenty_millisecond_opus_packets() {
for rate in [44_100, 48_000] {
let mut processor = Processor::new(rate, /*output_rate*/ 48_000).unwrap();
let mut packets = Vec::new();
for block in 0..40 {
let mut frame = Frame {
samples: [0.0; 256],
len: 256,
at: Instant::now(),
generation: 2,
};
for (index, sample) in frame.samples.iter_mut().enumerate() {
*sample = (std::f32::consts::TAU * 440.0 * (block as usize * 256 + index) as f32
/ rate as f32)
.sin()
* 0.1;
}
packets.extend(processor.capture(&frame, || frame.at).unwrap());
}
assert!(!packets.is_empty());
let mut decoder = opus::Decoder::new(/*sample_rate*/ 48_000, opus::Channels::Mono).unwrap();
let mut energy = 0.0_f32;
for packet in packets {
let mut output = [0.0; 960];
assert_eq!(
decoder
.decode_float(&packet.data, &mut output, /*fec*/ false)
.unwrap(),
960
);
assert!(output.iter().all(|sample| sample.is_finite()));
energy += output.iter().map(|sample| sample * sample).sum::<f32>();
}
assert!(
energy > 0.01,
"decoded capture must contain signal at {rate} Hz"
);
}
}
#[test]
fn unmute_reset_rejects_delayed_pre_unmute_audio_and_partial_history() {
let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap();
let frame = Frame {
samples: [0.25; 256],
len: 256,
at: Instant::now(),
generation: 2,
};
assert!(processor.capture(&frame, || frame.at).unwrap().is_empty());
assert_eq!(
(0..10).find_map(|_| processor
.capture(&frame, || frame.at + Duration::from_secs(1))
.err()),
Some("voice processing fell behind")
);
processor.reset().unwrap();
for _ in 0..10 {
assert!(processor.capture(&frame, || frame.at).unwrap().is_empty());
}
assert!(processor.pending.is_empty());
assert!(processor.capture.input.is_empty());
assert!(processor.capture.output.is_empty());
}
#[test]
fn capture_gap_discards_old_partial_packet_and_preserves_unmute_cutoff() {
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 cutoff = processor.cutoff;
let start = Instant::now();
let mut frame = Frame {
samples: [0.25; 256],
len: 240,
at: start,
generation: 2,
};
for index in 0..5 {
frame.at = start + Duration::from_millis(index * 5);
assert!(processor.capture(&frame, || frame.at).unwrap().is_empty());
}
assert!(!processor.pending.is_empty());
assert!(!processor.capture.input.is_empty());
let resumed = start + Duration::from_millis(/*millis*/ 100);
frame.samples.fill(0.0);
let mut packets = Vec::new();
let mut expected = Vec::new();
for index in 0..12 {
frame.at = resumed + Duration::from_millis(index * 5);
let emitted = processor.capture(&frame, || frame.at).unwrap();
if index < 2 {
assert!(emitted.is_empty());
}
packets.extend(emitted.into_iter().map(|packet| (packet.at, packet.data)));
expected.extend(
fresh
.capture(&frame, || frame.at)
.unwrap()
.into_iter()
.map(|packet| (packet.at, packet.data)),
);
}
assert!(!packets.is_empty());
assert_eq!(packets, expected);
assert_eq!(processor.cutoff, cutoff);
}
#[test]
fn ordinary_capture_timestamp_jitter_keeps_partial_audio() {
for jitter_ms in [-2_i64, 0, 2] {
let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap();
let start = Instant::now();
let mut frame = Frame {
samples: [0.1; 256],
len: 240,
at: start,
generation: 2,
};
for index in 0..5 {
frame.at = start + Duration::from_millis(index * 5);
assert!(processor.capture(&frame, || frame.at).unwrap().is_empty());
}
frame.at = start + Duration::from_millis((25 + jitter_ms) as u64);
assert_eq!(processor.capture(&frame, || frame.at).unwrap().len(), 1);
}
}
#[test]
fn callback_gap_cannot_hide_in_a_packed_partial_block() {
let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap();
let mut packer = super::super::buffers::FramePacker::default();
let queue = crossbeam_queue::ArrayQueue::new(/*capacity*/ 8);
let start = Instant::now();
// Leave both an incomplete callback block and a processed half-packet.
for index in 0..6 {
let at = start + Duration::from_millis(index * 5);
packer.discard_capture_gap(at, /*rate*/ 48_000.0);
assert!(packer.push(
Frame {
samples: [0.25; 256],
len: 240,
at,
generation: 2
},
/*rate*/ 48_000.0,
&queue
));
while let Some(frame) = queue.pop() {
assert!(processor.capture(&frame, || frame.at).unwrap().is_empty());
}
}
assert!(!processor.pending.is_empty());
let resumed = start + Duration::from_millis(/*millis*/ 100);
packer.discard_capture_gap(resumed, /*rate*/ 48_000.0);
assert!(packer.push(
Frame {
samples: [0.0; 256],
len: 256,
at: resumed,
generation: 2
},
/*rate*/ 48_000.0,
&queue
));
let frame = queue.pop().unwrap();
assert_eq!(
(frame.samples, frame.len, frame.at, frame.generation),
([0.0; 256], 256, resumed, 2)
);
assert!(processor.capture(&frame, || resumed).unwrap().is_empty());
assert!(processor.pending.is_empty());
}
#[test]
fn stale_echo_reference_cannot_cancel_capture_age() {
for render_delay in [0, -750] {
let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap();
processor.render_delay = render_delay;
let start = Instant::now();
let now = start + Duration::from_millis(/*millis*/ 750);
let failure = (0..8).find_map(|index| {
let frame = Frame {
samples: [0.25; 256],
len: 256,
at: start + Duration::from_secs_f64((index * 256) as f64 / 48_000.0),
generation: 2,
};
processor.capture(&frame, || now).err()
});
assert_eq!(failure, Some("voice processing fell behind"));
}
}
+8
View File
@@ -60,6 +60,7 @@ impl PeerConnectionEventHandler for Events {
}
pub(crate) struct Transport {
pub(crate) audio: crate::audio_track::AudioTrack,
connection: Arc<dyn PeerConnection>,
gathered: Arc<Notify>,
observer: JoinHandle<()>,
@@ -72,6 +73,7 @@ impl Transport {
}
async fn with_runtime(runtime: Arc<dyn webrtc::runtime::Runtime>) -> Result<Self> {
let (media, audio) = crate::audio_track::AudioTrack::new()?;
let gathered = Arc::new(Notify::new());
// Upstream defaults exhaust checks after 1.4s, including checks sent before
// a TCP connection exists. Keep probing throughout our negotiation deadline.
@@ -82,6 +84,7 @@ impl Transport {
settings.set_ice_connection_attempts(Some(check_interval), Some(attempts));
let connection: Arc<dyn PeerConnection> = Arc::new(
PeerConnectionBuilder::new()
.with_media_engine(media)
.with_runtime(runtime)
.with_setting_engine(settings)
.with_handler(Arc::new(Events(gathered.clone())))
@@ -93,6 +96,10 @@ impl Transport {
.await
.map_err(|_| "failed to create voice peer")?,
);
if connection.add_track(audio.track.clone()).await.is_err() {
let _ = timeout(Duration::from_secs(/*secs*/ 2), connection.close()).await;
return Err("failed to attach voice track");
}
let channel = match connection
.create_data_channel("oai-events", /*options*/ None)
.await
@@ -121,6 +128,7 @@ impl Transport {
sender.send_replace(false);
});
Ok(Self {
audio,
connection,
gathered,
observer,
@@ -55,7 +55,9 @@ async fn check_negotiation(runtime: Arc<dyn webrtc::runtime::Runtime>) {
let gathered = Arc::new(Notify::new());
let mut settings = webrtc::peer_connection::SettingEngine::default();
settings.set_lite(/*lite*/ true);
let (media, _) = crate::audio_track::AudioTrack::new().unwrap();
let builder = PeerConnectionBuilder::new()
.with_media_engine(media)
.with_setting_engine(settings)
.with_handler(Arc::new(RemoteEvents(sender, gathered.clone())));
let remote = if tcp {
+4
View File
@@ -27,6 +27,7 @@ use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::time::timeout;
use webrtc::data_channel::DataChannel;
use webrtc::peer_connection::MediaEngine;
use webrtc::peer_connection::PeerConnection;
use webrtc::peer_connection::PeerConnectionBuilder;
use webrtc::peer_connection::PeerConnectionEventHandler;
@@ -307,7 +308,10 @@ async fn installed_client_negotiates_and_closes_over_udp_and_tcp() -> Result<()>
let gathered = Arc::new(Notify::new());
let mut settings = SettingEngine::default();
settings.set_lite(/*lite*/ true);
let mut media = MediaEngine::default();
media.register_default_codecs()?;
let builder = PeerConnectionBuilder::new()
.with_media_engine(media)
.with_setting_engine(settings)
.with_handler(Arc::new(RemoteEvents {
channels: sender,