mirror of
https://github.com/openai/codex.git
synced 2026-09-07 15:40:00 +00:00
Add bounded GStreamer playback components to the voice host (#43244)
## What changed - Add a `GstAudioSink` subclass backed by a bounded mono `F32LE` playback writer, with partial writes, cancellation on speaker epoch changes, and delay accounting for queued samples and pending device output. - Recreate the CPAL output stream and discard buffered audio when speaker controls change the epoch, preventing stale audio from surviving suppression. - Add GStreamer dependencies, adjust Windows Bazel native-tool selection, and allow the helper handshake time to load startup-linked libraries. - Separate installed-client tests from protocol lifecycle tests and supply native startup libraries in test packages. ## Testing Add unit coverage for partial writes, sample accounting, suppression cancellation, stale writers, device delay, invalid samples, and stalled consumption. GitOrigin-RevId: c48c48d2fee00659cf6f8a4039a6745385cb77cd
This commit is contained in:
committed by
copyberry
parent
ac192cd793
commit
9daf7d22ca
23
MODULE.bazel.lock
generated
23
MODULE.bazel.lock
generated
File diff suppressed because one or more lines are too long
286
codex-rs/Cargo.lock
generated
286
codex-rs/Cargo.lock
generated
@@ -844,6 +844,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atomic_refcell"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4"
|
||||
|
||||
[[package]]
|
||||
name = "audio-codec-algorithms"
|
||||
version = "0.8.1"
|
||||
@@ -1794,6 +1800,16 @@ dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -5033,6 +5049,8 @@ dependencies = [
|
||||
"cpal",
|
||||
"crossbeam-queue",
|
||||
"futures",
|
||||
"gstreamer",
|
||||
"gstreamer-audio",
|
||||
"libloading",
|
||||
"opus",
|
||||
"pretty_assertions",
|
||||
@@ -7241,6 +7259,19 @@ version = "0.32.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gix"
|
||||
version = "0.81.0"
|
||||
@@ -7303,7 +7334,7 @@ dependencies = [
|
||||
"bstr",
|
||||
"gix-date",
|
||||
"gix-error",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7418,7 +7449,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-bom",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7712,7 +7743,7 @@ dependencies = [
|
||||
"itoa",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7810,7 +7841,7 @@ dependencies = [
|
||||
"maybe-async",
|
||||
"nonempty",
|
||||
"thiserror 2.0.18",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7842,7 +7873,7 @@ dependencies = [
|
||||
"gix-validate",
|
||||
"memmap2",
|
||||
"thiserror 2.0.18",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8096,6 +8127,49 @@ dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib-macros",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@@ -8115,6 +8189,17 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -8126,6 +8211,100 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer"
|
||||
version = "0.25.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab4527e1b9bae8d29ce137bde5b8eec8ae8f78f13ad00fc6e70cbe227d6ad027"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"glib",
|
||||
"gstreamer-sys",
|
||||
"itertools 0.15.0",
|
||||
"kstring",
|
||||
"libc",
|
||||
"muldiv",
|
||||
"num-integer",
|
||||
"num-rational",
|
||||
"option-operations",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-audio"
|
||||
version = "0.25.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f97532c3e21287a6e94563a328cce89367324acf8a4489291b40ede2e39163a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"glib",
|
||||
"gstreamer",
|
||||
"gstreamer-audio-sys",
|
||||
"gstreamer-base",
|
||||
"libc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-audio-sys"
|
||||
version = "0.25.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2abfb81a073c5c8fb115a3639f47b6430f669ee9fa29123bb0116c9ef59d7d16"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gstreamer-base-sys",
|
||||
"gstreamer-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-base"
|
||||
version = "0.25.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91c94a4d3047d05dd6e1f6d91c74f61f56384c7ea1c9d0c1051572eeeb0138d"
|
||||
dependencies = [
|
||||
"atomic_refcell",
|
||||
"cfg-if",
|
||||
"glib",
|
||||
"gstreamer",
|
||||
"gstreamer-base-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-base-sys"
|
||||
version = "0.25.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fbbc623dc066908ba10c43d629c21096508dea04796592a206c4edd864e37"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gstreamer-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-sys"
|
||||
version = "0.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533fa8d28fc830eafccbcfcfddb390563ea5d3a351af2c3aab99e197e5f5b1ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gzip-header"
|
||||
version = "1.0.0"
|
||||
@@ -9179,6 +9358,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -9907,6 +10095,12 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muldiv"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0"
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.1"
|
||||
@@ -10762,6 +10956,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "option-operations"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754"
|
||||
dependencies = [
|
||||
"pastey",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opus"
|
||||
version = "0.4.0"
|
||||
@@ -14783,6 +14986,19 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "7.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml 1.0.6+spec-1.1.0",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tagptr"
|
||||
version = "0.2.0"
|
||||
@@ -14805,6 +15021,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -15357,10 +15579,25 @@ dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -15372,6 +15609,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
@@ -15379,9 +15625,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -15391,19 +15637,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -16097,6 +16343,12 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -17073,6 +17325,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
|
||||
@@ -151,7 +151,8 @@ impl VoiceHost {
|
||||
build_commit: build_commit.to_owned(),
|
||||
},
|
||||
Message::Ready {},
|
||||
DEADLINE,
|
||||
// Startup-linked native libraries load before the helper can acknowledge Hello.
|
||||
RUNTIME_INITIALIZATION_DEADLINE,
|
||||
)
|
||||
.await?;
|
||||
Ok(host)
|
||||
|
||||
@@ -8,6 +8,13 @@ license.workspace = true
|
||||
name = "codex-voice-host"
|
||||
path = "src/main.rs"
|
||||
|
||||
# These tests need a correctly linked helper and a matching prepared runtime.
|
||||
# Bazel supplies declared runtime inputs and runs this target automatically.
|
||||
# Cargo runs it explicitly with --test installed_client.
|
||||
[[test]]
|
||||
name = "installed_client"
|
||||
test = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -24,6 +31,8 @@ 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"
|
||||
gstreamer = { version = "=0.25.3", features = ["v1_28"] }
|
||||
gstreamer-audio = { version = "=0.25.3", features = ["v1_28"] }
|
||||
opus = "=0.4.0"
|
||||
rubato = { version = "=5.0.0", default-features = false }
|
||||
sonora = { version = "=0.2.0", default-features = false }
|
||||
|
||||
110
codex-rs/voice-host/src/audio_sink.rs
Normal file
110
codex-rs/voice-host/src/audio_sink.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
//! Stock GstAudioSink scheduling with a bounded, epoch-owned CPAL writer.
|
||||
//! Native resets outside an owner-initiated teardown fail the session closed.
|
||||
|
||||
use super::playback::PlaybackWriter;
|
||||
use audio::subclass::prelude::*;
|
||||
use gst::glib;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_audio as audio;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
#[derive(Default)]
|
||||
pub struct Sink {
|
||||
pub(super) writer: OnceLock<PlaybackWriter>,
|
||||
}
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for Sink {
|
||||
const NAME: &'static str = "CodexPrivateAudioSink";
|
||||
type Type = super::Sink;
|
||||
type ParentType = audio::AudioSink;
|
||||
}
|
||||
impl ObjectImpl for Sink {}
|
||||
impl GstObjectImpl for Sink {}
|
||||
impl ElementImpl for Sink {
|
||||
fn metadata() -> Option<&'static gst::subclass::ElementMetadata> {
|
||||
static META: OnceLock<gst::subclass::ElementMetadata> = OnceLock::new();
|
||||
Some(META.get_or_init(|| {
|
||||
gst::subclass::ElementMetadata::new(
|
||||
"Private audio",
|
||||
"Sink/Audio",
|
||||
"Owned speaker output",
|
||||
"OpenAI",
|
||||
)
|
||||
}))
|
||||
}
|
||||
fn pad_templates() -> &'static [gst::PadTemplate] {
|
||||
static PADS: OnceLock<Vec<gst::PadTemplate>> = OnceLock::new();
|
||||
PADS.get_or_init(|| {
|
||||
gst::PadTemplate::new(
|
||||
"sink",
|
||||
gst::PadDirection::Sink,
|
||||
gst::PadPresence::Always,
|
||||
&gst::Caps::builder("audio/x-raw")
|
||||
.field("format", "F32LE")
|
||||
.field("layout", "interleaved")
|
||||
.field("channels", 1i32)
|
||||
.field(
|
||||
"rate",
|
||||
gst::IntRange::<i32>::new(/*min*/ 8000, /*max*/ 384000),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.into_iter()
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
impl BaseSinkImpl for Sink {}
|
||||
impl AudioBaseSinkImpl for Sink {}
|
||||
impl AudioSinkImpl for Sink {
|
||||
fn prepare(&self, spec: &mut audio::AudioRingBufferSpec) -> Result<(), gst::LoggableError> {
|
||||
let writer = self
|
||||
.writer
|
||||
.get()
|
||||
.ok_or_else(|| gst::loggable_error!(gst::CAT_RUST, "speaker not bound"))?;
|
||||
let info = spec.audio_info();
|
||||
if info.rate() != writer.rate()
|
||||
|| info.channels() != 1
|
||||
|| info.format() != audio::AudioFormat::F32le
|
||||
|| info.layout() != audio::AudioLayout::Interleaved
|
||||
{
|
||||
return Err(gst::loggable_error!(
|
||||
gst::CAT_RUST,
|
||||
"unsupported speaker format"
|
||||
));
|
||||
}
|
||||
spec.set_segsize((writer.rate() / 100 * 4) as i32);
|
||||
spec.set_segtotal(4);
|
||||
spec.set_latency_time(10_000);
|
||||
spec.set_buffer_time(40_000);
|
||||
Ok(())
|
||||
}
|
||||
fn unprepare(&self) -> Result<(), gst::LoggableError> {
|
||||
Ok(())
|
||||
}
|
||||
fn write(&self, bytes: &[u8]) -> Result<i32, gst::LoggableError> {
|
||||
let writer = self
|
||||
.writer
|
||||
.get()
|
||||
.ok_or_else(|| gst::loggable_error!(gst::CAT_RUST, "speaker not bound"))?;
|
||||
writer.write(bytes).map(|n| n as i32).map_err(|_| {
|
||||
writer.fail_if_current();
|
||||
gst::loggable_error!(gst::CAT_RUST, "speaker write failed")
|
||||
})
|
||||
}
|
||||
fn delay(&self) -> u32 {
|
||||
self.writer.get().map_or(0, PlaybackWriter::delay)
|
||||
}
|
||||
fn reset(&self) {
|
||||
if let Some(writer) = self.writer.get() {
|
||||
writer.fail_if_current();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct Sink(ObjectSubclass<imp::Sink>) @extends audio::AudioSink, audio::AudioBaseSink, audio::gst_base::BaseSink, gst::Element, gst::Object;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU16;
|
||||
use std::sync::atomic::AtomicU32;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
@@ -96,6 +97,9 @@ pub(super) struct Buffers {
|
||||
pub(super) failed: AtomicBool,
|
||||
pub(super) microphone_peak: AtomicU16,
|
||||
pub(super) speaker_peak: AtomicU16,
|
||||
pub(super) queued: AtomicU32,
|
||||
pub(super) last_dac_ns: AtomicU64,
|
||||
pub(super) clock: Instant,
|
||||
}
|
||||
|
||||
impl Buffers {
|
||||
@@ -129,9 +133,20 @@ impl Buffers {
|
||||
failed: AtomicBool::new(false),
|
||||
microphone_peak: AtomicU16::new(/*v*/ 0),
|
||||
speaker_peak: AtomicU16::new(/*v*/ 0),
|
||||
queued: AtomicU32::new(/*v*/ 0),
|
||||
last_dac_ns: AtomicU64::new(/*v*/ 0),
|
||||
clock: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_playback(&self, frame: Frame) -> Result<(), ()> {
|
||||
let len = frame.len as u32;
|
||||
self.queued.fetch_add(len, Ordering::AcqRel);
|
||||
self.playback.push(frame).map_err(|_| {
|
||||
self.queued.fetch_sub(len, Ordering::AcqRel);
|
||||
})
|
||||
}
|
||||
|
||||
// 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<()> {
|
||||
@@ -190,13 +205,19 @@ pub(super) struct Playback {
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
pub(super) fn next(&mut self, buffers: &Buffers) -> f32 {
|
||||
pub(super) fn next(&mut self, buffers: &Buffers) -> Option<f32> {
|
||||
let epoch = buffers.speaker.load(Ordering::Acquire);
|
||||
if self
|
||||
.frame
|
||||
.as_ref()
|
||||
.is_some_and(|frame| frame.generation != epoch || self.offset == frame.len)
|
||||
{
|
||||
if let Some(frame) = &self.frame {
|
||||
buffers.queued.fetch_sub(
|
||||
frame.len.saturating_sub(self.offset) as u32,
|
||||
Ordering::AcqRel,
|
||||
);
|
||||
}
|
||||
self.frame = None;
|
||||
}
|
||||
// Bound stale-frame work even if a producer keeps writing during a mute.
|
||||
@@ -214,17 +235,20 @@ impl Playback {
|
||||
{
|
||||
self.frame = Some(frame);
|
||||
self.offset = 0;
|
||||
} else {
|
||||
buffers.queued.fetch_sub(frame.len as u32, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
let Some(frame) = &self.frame else {
|
||||
return 0.0;
|
||||
return None;
|
||||
};
|
||||
let sample = frame.samples[self.offset];
|
||||
self.offset += 1;
|
||||
buffers.queued.fetch_sub(/*val*/ 1, Ordering::AcqRel);
|
||||
if epoch % 2 == 1 || !sample.is_finite() {
|
||||
0.0
|
||||
Some(0.0)
|
||||
} else {
|
||||
sample.clamp(-1.0, 1.0)
|
||||
Some(sample.clamp(-1.0, 1.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,7 @@ fn suppression_discards_partial_and_queued_previous_generations() {
|
||||
for _ in 0..2 {
|
||||
assert!(
|
||||
buffers
|
||||
.playback
|
||||
.push(Frame {
|
||||
.push_playback(Frame {
|
||||
samples: [0.5; BLOCK],
|
||||
len: BLOCK,
|
||||
at: Instant::now(),
|
||||
@@ -58,11 +57,11 @@ fn suppression_discards_partial_and_queued_previous_generations() {
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
assert_eq!(playback.next(&buffers), 0.5);
|
||||
assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.5);
|
||||
Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap();
|
||||
assert_eq!(playback.next(&buffers), 0.0);
|
||||
assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.0);
|
||||
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
|
||||
assert_eq!(playback.next(&buffers), 0.0);
|
||||
assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.0);
|
||||
assert!(buffers.playback.is_empty());
|
||||
}
|
||||
|
||||
@@ -75,8 +74,7 @@ fn output_is_finite_and_bounded_and_underflow_is_silence() {
|
||||
samples[..4].copy_from_slice(&[f32::NAN, f32::INFINITY, -2.0, 2.0]);
|
||||
assert!(
|
||||
buffers
|
||||
.playback
|
||||
.push(Frame {
|
||||
.push_playback(Frame {
|
||||
samples,
|
||||
len: 4,
|
||||
at: Instant::now(),
|
||||
@@ -85,7 +83,9 @@ fn output_is_finite_and_bounded_and_underflow_is_silence() {
|
||||
.is_ok()
|
||||
);
|
||||
let mut playback = Playback::default();
|
||||
let output: Vec<_> = (0..5).map(|_| playback.next(&buffers)).collect();
|
||||
let output: Vec<_> = (0..5)
|
||||
.map(|_| playback.next(&buffers).unwrap_or(0.0))
|
||||
.collect();
|
||||
assert_eq!(output, [0.0, 0.0, -1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
//! Capture and actual rendered output carry device timing.
|
||||
//! References start with worker service; unmute rejects earlier device capture buffers.
|
||||
|
||||
#[path = "audio_sink.rs"]
|
||||
mod audio_sink;
|
||||
#[path = "device_buffers.rs"]
|
||||
mod buffers;
|
||||
#[path = "capture_worker.rs"]
|
||||
mod capture_worker;
|
||||
#[path = "playback.rs"]
|
||||
mod playback;
|
||||
#[path = "processing.rs"]
|
||||
mod processing;
|
||||
|
||||
@@ -33,12 +37,17 @@ use buffers::FramePacker;
|
||||
use buffers::MAX_CALLBACK_FRAMES;
|
||||
use buffers::Playback;
|
||||
use buffers::QUEUE_CAPACITY;
|
||||
use playback::PlaybackPort;
|
||||
|
||||
const MAX_CAPTURE_AGE: Duration = Duration::from_secs(/*secs*/ 1);
|
||||
|
||||
pub(super) struct Devices {
|
||||
_input: cpal::Stream,
|
||||
_output: cpal::Stream,
|
||||
output: Option<cpal::Stream>,
|
||||
output_device: cpal::Device,
|
||||
output_config: cpal::SupportedStreamConfig,
|
||||
output_stream_config: cpal::StreamConfig,
|
||||
playback: PlaybackPort,
|
||||
worker: capture_worker::CaptureWorker,
|
||||
}
|
||||
|
||||
@@ -93,6 +102,7 @@ impl Devices {
|
||||
input_config.sample_rate(),
|
||||
output_config.sample_rate(),
|
||||
));
|
||||
let playback = PlaybackPort::new(buffers.clone(), output_config.sample_rate());
|
||||
let processor =
|
||||
processing::Processor::new(input_config.sample_rate(), output_config.sample_rate())
|
||||
.map_err(io::Error::other)?;
|
||||
@@ -113,7 +123,7 @@ impl Devices {
|
||||
buffers.clone()
|
||||
)
|
||||
.map_err(|_| io::Error::other("failed to open microphone"))?;
|
||||
let output = stream!(
|
||||
let output_stream = stream!(
|
||||
output_config.sample_format(),
|
||||
build_output,
|
||||
&output,
|
||||
@@ -121,7 +131,7 @@ impl Devices {
|
||||
buffers.clone()
|
||||
)
|
||||
.map_err(|_| io::Error::other("failed to open speaker"))?;
|
||||
output
|
||||
output_stream
|
||||
.play()
|
||||
.map_err(|_| io::Error::other("failed to start speaker"))?;
|
||||
input
|
||||
@@ -129,7 +139,11 @@ impl Devices {
|
||||
.map_err(|_| io::Error::other("failed to start microphone"))?;
|
||||
Ok(Self {
|
||||
_input: input,
|
||||
_output: output,
|
||||
output: Some(output_stream),
|
||||
output_device: output,
|
||||
output_config,
|
||||
output_stream_config,
|
||||
playback,
|
||||
worker: capture_worker::CaptureWorker {
|
||||
buffers,
|
||||
processor,
|
||||
@@ -142,7 +156,37 @@ impl Devices {
|
||||
&mut self,
|
||||
controls: codex_realtime_webrtc::AudioControls,
|
||||
) -> io::Result<()> {
|
||||
self.worker.set_controls(controls)
|
||||
let buffers = self.worker.buffers.clone();
|
||||
let previous = buffers.speaker.load(Ordering::Acquire);
|
||||
self.worker.set_controls(controls)?;
|
||||
if previous != buffers.speaker.load(Ordering::Acquire) {
|
||||
// Invalidate writers first, then wait off the callback for any in-flight write.
|
||||
let _producer = self
|
||||
.playback
|
||||
.0
|
||||
.producer
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("speaker writer failed"))?;
|
||||
// Dropping the stream stops its callbacks and discards device-owned audio.
|
||||
drop(self.output.take());
|
||||
while buffers.playback.pop().is_some() {}
|
||||
while buffers.rendered.pop().is_some() {}
|
||||
buffers.queued.store(/*val*/ 0, Ordering::Release);
|
||||
buffers.last_dac_ns.store(/*val*/ 0, Ordering::Release);
|
||||
let output = stream!(
|
||||
self.output_config.sample_format(),
|
||||
build_output,
|
||||
&self.output_device,
|
||||
&self.output_stream_config,
|
||||
buffers
|
||||
)
|
||||
.map_err(|_| io::Error::other("failed to reset speaker"))?;
|
||||
output
|
||||
.play()
|
||||
.map_err(|_| io::Error::other("failed to restart speaker"))?;
|
||||
self.output = Some(output);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn service(
|
||||
@@ -153,6 +197,14 @@ impl Devices {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Devices {
|
||||
fn drop(&mut self) {
|
||||
self.worker.buffers.failed.store(true, Ordering::Release);
|
||||
let _producer = self.playback.0.producer.lock().ok();
|
||||
drop(self.output.take());
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_stream_config(
|
||||
supported: &cpal::SupportedStreamConfig,
|
||||
) -> io::Result<cpal::StreamConfig> {
|
||||
@@ -322,6 +374,7 @@ fn render_output<T>(
|
||||
data.fill(T::from_sample(0.0));
|
||||
return;
|
||||
}
|
||||
let mut delivered_until = None;
|
||||
for (index, chunk) in data.chunks_mut(BLOCK * channels).enumerate() {
|
||||
let mut reference = Frame {
|
||||
samples: [0.0; BLOCK],
|
||||
@@ -329,8 +382,18 @@ fn render_output<T>(
|
||||
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));
|
||||
for (offset, (frame, sample)) in chunk
|
||||
.chunks_mut(channels)
|
||||
.zip(&mut reference.samples)
|
||||
.enumerate()
|
||||
{
|
||||
let next = output.playback.next(buffers);
|
||||
if next.is_some() {
|
||||
delivered_until = Some(
|
||||
start + Duration::from_secs_f64((index * BLOCK + offset + 1) as f64 / rate),
|
||||
);
|
||||
}
|
||||
let rendered = T::from_sample(next.unwrap_or(0.0));
|
||||
frame.fill(rendered);
|
||||
*sample = f32::from_sample(rendered);
|
||||
record_peak(&buffers.speaker_peak, *sample);
|
||||
@@ -339,6 +402,14 @@ fn render_output<T>(
|
||||
buffers.failed.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
if let Some(end) = delivered_until {
|
||||
buffers.last_dac_ns.store(
|
||||
end.saturating_duration_since(buffers.clock)
|
||||
.as_nanos()
|
||||
.min(u128::from(u64::MAX)) as u64,
|
||||
Ordering::Release,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -235,8 +235,7 @@ fn tiny_output_callbacks_pack_references_without_delaying_rendering() {
|
||||
for _ in 0..8 {
|
||||
assert!(
|
||||
buffers
|
||||
.playback
|
||||
.push(Frame {
|
||||
.push_playback(Frame {
|
||||
samples: [0.5; BLOCK],
|
||||
len: BLOCK,
|
||||
at: start,
|
||||
|
||||
114
codex-rs/voice-host/src/playback.rs
Normal file
114
codex-rs/voice-host/src/playback.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! Bounded mono F32LE writes for the native sink, never called from a device callback.
|
||||
//! Each writer belongs to one speaker epoch. Reset cancels it before locking the producer.
|
||||
|
||||
use super::buffers::BLOCK;
|
||||
use super::buffers::Buffers;
|
||||
use super::buffers::Frame;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
pub(super) struct PlaybackState {
|
||||
pub(super) producer: Mutex<()>,
|
||||
buffers: Arc<Buffers>,
|
||||
rate: u32,
|
||||
}
|
||||
|
||||
pub(super) struct PlaybackPort(pub(super) Arc<PlaybackState>);
|
||||
|
||||
pub(crate) struct PlaybackWriter {
|
||||
state: Arc<PlaybackState>,
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
impl PlaybackPort {
|
||||
pub(super) fn new(buffers: Arc<Buffers>, rate: u32) -> Self {
|
||||
Self(Arc::new(PlaybackState {
|
||||
producer: Mutex::new(()),
|
||||
buffers,
|
||||
rate,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackWriter {
|
||||
pub(crate) fn fail_if_current(&self) {
|
||||
if self.state.buffers.speaker.load(Ordering::Acquire) == self.epoch {
|
||||
self.state.buffers.failed.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn rate(&self) -> u32 {
|
||||
self.state.rate
|
||||
}
|
||||
|
||||
pub(crate) fn write(&self, bytes: &[u8]) -> Result<usize, &'static str> {
|
||||
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
|
||||
return Err("invalid speaker buffer");
|
||||
}
|
||||
let mut frame = Frame {
|
||||
samples: [0.0; BLOCK],
|
||||
len: (bytes.len() / 4).min(BLOCK),
|
||||
at: Instant::now(),
|
||||
generation: self.epoch,
|
||||
};
|
||||
for (sample, bytes) in frame.samples.iter_mut().zip(bytes.chunks_exact(4)) {
|
||||
*sample = f32::from_le_bytes(bytes.try_into().map_err(|_| "invalid speaker buffer")?);
|
||||
if !sample.is_finite() {
|
||||
return Err("invalid speaker sample");
|
||||
}
|
||||
}
|
||||
let _producer = self
|
||||
.state
|
||||
.producer
|
||||
.lock()
|
||||
.map_err(|_| "speaker writer failed")?;
|
||||
let deadline = Instant::now() + Duration::from_millis(/*millis*/ 100);
|
||||
let buffers = &self.state.buffers;
|
||||
let limit = (self.state.rate / 25).min((BLOCK * buffers.playback.capacity()) as u32);
|
||||
loop {
|
||||
if self.epoch % 2 == 1
|
||||
|| buffers.speaker.load(Ordering::Acquire) != self.epoch
|
||||
|| buffers.failed.load(Ordering::Acquire)
|
||||
{
|
||||
return Err("speaker writer cancelled");
|
||||
}
|
||||
if buffers.queued.load(Ordering::Acquire) + frame.len as u32 <= limit
|
||||
&& !buffers.playback.is_full()
|
||||
{
|
||||
let bytes = frame.len * 4;
|
||||
buffers
|
||||
.push_playback(frame)
|
||||
.map_err(|_| "speaker queue failed")?;
|
||||
return Ok(bytes);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("speaker fell behind");
|
||||
}
|
||||
std::thread::park_timeout(Duration::from_millis(/*millis*/ 1));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delay(&self) -> u32 {
|
||||
let buffers = &self.state.buffers;
|
||||
if buffers.speaker.load(Ordering::Acquire) != self.epoch || self.epoch % 2 == 1 {
|
||||
return 0;
|
||||
}
|
||||
let remaining = buffers
|
||||
.last_dac_ns
|
||||
.load(Ordering::Acquire)
|
||||
.saturating_sub(buffers.clock.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64);
|
||||
let hardware =
|
||||
(u128::from(remaining) * u128::from(self.state.rate)).div_ceil(1_000_000_000);
|
||||
buffers
|
||||
.queued
|
||||
.load(Ordering::Acquire)
|
||||
.saturating_add(hardware.min(u128::from(u32::MAX)) as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "playback_tests.rs"]
|
||||
mod tests;
|
||||
88
codex-rs/voice-host/src/playback_tests.rs
Normal file
88
codex-rs/voice-host/src/playback_tests.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use super::*;
|
||||
use crate::devices::buffers::Playback;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
impl PlaybackPort {
|
||||
pub(super) fn writer(&self) -> PlaybackWriter {
|
||||
PlaybackWriter {
|
||||
state: self.0.clone(),
|
||||
epoch: self.0.buffers.speaker.load(Ordering::Acquire),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn active(rate: u32) -> (Arc<Buffers>, PlaybackPort, PlaybackWriter) {
|
||||
let buffers = Arc::new(Buffers::new(rate, rate));
|
||||
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
|
||||
let port = PlaybackPort::new(buffers.clone(), rate);
|
||||
let writer = port.writer();
|
||||
(buffers, port, writer)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_writes_account_for_samples_until_the_device_consumes_them() {
|
||||
let (buffers, _, writer) = active(/*rate*/ 48000);
|
||||
let samples: Vec<_> = (0..BLOCK + 20).map(|i| i as f32 / 512.0).collect();
|
||||
let bytes: Vec<_> = samples.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let first = writer.write(&bytes).unwrap();
|
||||
assert_eq!(first, BLOCK * 4);
|
||||
assert_eq!(writer.write(&bytes[first..]).unwrap(), 20 * 4);
|
||||
assert_eq!(
|
||||
(writer.rate(), writer.delay()),
|
||||
(48000, (BLOCK + 20) as u32)
|
||||
);
|
||||
let mut playback = Playback::default();
|
||||
let actual: Vec<_> = (0..samples.len())
|
||||
.map(|_| playback.next(&buffers).unwrap())
|
||||
.collect();
|
||||
assert_eq!(actual, samples);
|
||||
assert_eq!(writer.delay(), 0);
|
||||
assert_eq!(playback.next(&buffers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppression_cancels_a_full_writer_and_old_writers_cannot_resume() {
|
||||
let (buffers, port, writer) = active(/*rate*/ 8000);
|
||||
let bytes = vec![0; BLOCK * 4];
|
||||
writer.write(&bytes).unwrap();
|
||||
let waiting = std::thread::spawn(move || writer.write(&bytes));
|
||||
Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap();
|
||||
assert_eq!(waiting.join().unwrap(), Err("speaker writer cancelled"));
|
||||
let stale = port.writer();
|
||||
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
|
||||
assert_eq!(stale.write(&[0; 4]), Err("speaker writer cancelled"));
|
||||
let mut playback = Playback::default();
|
||||
assert_eq!(playback.next(&buffers), None);
|
||||
assert_eq!(buffers.queued.load(Ordering::Acquire), 0);
|
||||
assert_eq!(port.writer().write(&[0; 4]), Ok(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_includes_pending_dac_time_but_not_another_generation() {
|
||||
let (buffers, _, writer) = active(/*rate*/ 48000);
|
||||
writer.write(&[0; 40]).unwrap();
|
||||
let end = buffers.clock.elapsed() + Duration::from_millis(/*millis*/ 100);
|
||||
buffers
|
||||
.last_dac_ns
|
||||
.store(end.as_nanos() as u64, Ordering::Release);
|
||||
let delay = writer.delay();
|
||||
assert!((10..=4810).contains(&delay));
|
||||
assert!(delay > 10);
|
||||
Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap();
|
||||
assert_eq!(writer.delay(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_samples_and_stalled_consumption_fail_without_growing_the_queue() {
|
||||
let (buffers, _, writer) = active(/*rate*/ 8000);
|
||||
assert_eq!(
|
||||
writer.write(&f32::NAN.to_le_bytes()),
|
||||
Err("invalid speaker sample")
|
||||
);
|
||||
writer.write(&vec![0; BLOCK * 4]).unwrap();
|
||||
assert_eq!(
|
||||
writer.write(&vec![0; BLOCK * 4]),
|
||||
Err("speaker fell behind")
|
||||
);
|
||||
assert_eq!(buffers.queued.load(Ordering::Acquire), BLOCK as u32);
|
||||
}
|
||||
363
codex-rs/voice-host/tests/installed_client.rs
Normal file
363
codex-rs/voice-host/tests/installed_client.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
//! Exercise the production client against copied helper packages and startup libraries.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_install_context::CodexPackageLayout;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_realtime_webrtc::SessionDescription;
|
||||
use codex_realtime_webrtc::VoiceHost;
|
||||
use codex_utils_cargo_bin::cargo_bin;
|
||||
use futures::future::BoxFuture;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::process::Command;
|
||||
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;
|
||||
use webrtc::peer_connection::RTCIceConnectionState;
|
||||
use webrtc::peer_connection::RTCIceGatheringState;
|
||||
use webrtc::peer_connection::RTCPeerConnectionState;
|
||||
use webrtc::peer_connection::RTCSessionDescription;
|
||||
use webrtc::peer_connection::SettingEngine;
|
||||
|
||||
const DEADLINE: Duration = Duration::from_secs(/*secs*/ 10);
|
||||
|
||||
async fn build_commit() -> Result<String> {
|
||||
let output = timeout(
|
||||
DEADLINE,
|
||||
Command::new(cargo_bin("codex-voice-host")?)
|
||||
.arg("--build-commit")
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await??;
|
||||
assert!(output.status.success());
|
||||
Ok(String::from_utf8(output.stdout)?.trim().to_owned())
|
||||
}
|
||||
|
||||
fn install_startup_libraries(runtime: &Path) -> Result<()> {
|
||||
if !cfg!(any(
|
||||
target_os = "macos",
|
||||
all(target_os = "linux", target_env = "gnu"),
|
||||
all(windows, target_env = "msvc")
|
||||
)) {
|
||||
return Ok(());
|
||||
}
|
||||
let source = if codex_utils_cargo_bin::runfiles_available() {
|
||||
let resource = format!(
|
||||
"../../third_party/voice/native_link_{}_{}/runtime.json",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
);
|
||||
codex_utils_cargo_bin::find_resource!(resource)?
|
||||
.parent()
|
||||
.context("runtime parent")?
|
||||
.to_owned()
|
||||
} else {
|
||||
std::env::var_os("CODEX_TEST_VOICE_RUNTIME")
|
||||
.map(PathBuf::from)
|
||||
.context("prepared runtime required for installed helper tests")?
|
||||
};
|
||||
let libraries = if cfg!(windows) { "bin" } else { "lib" };
|
||||
let destination = runtime.join(libraries);
|
||||
fs::create_dir_all(&destination)?;
|
||||
for entry in fs::read_dir(source.join(libraries))? {
|
||||
let entry = entry?;
|
||||
if entry.path().is_file() {
|
||||
fs::copy(entry.path(), destination.join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_helper(root: &Path) -> Result<CodexPackageLayout> {
|
||||
let bin = root.join("bin");
|
||||
let helper_dir = root.join("codex-resources/voice/bin");
|
||||
fs::create_dir_all(&bin)?;
|
||||
fs::create_dir_all(&helper_dir)?;
|
||||
fs::write(root.join("codex-package.json"), "{}")?;
|
||||
let app = bin.join(if cfg!(windows) { "codex.exe" } else { "codex" });
|
||||
fs::write(&app, [])?;
|
||||
let source = cargo_bin("codex-voice-host")?;
|
||||
let helper = helper_dir.join(source.file_name().context("helper binary file name")?);
|
||||
fs::copy(&source, &helper)?;
|
||||
install_startup_libraries(helper_dir.parent().context("helper runtime directory")?)?;
|
||||
InstallContext::from_exe(
|
||||
/*is_macos*/ cfg!(target_os = "macos"),
|
||||
Some(&app),
|
||||
/*method_override*/ None,
|
||||
)
|
||||
.package_layout
|
||||
.context("package layout")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_client_rejects_mixed_builds_and_missing_helper() -> Result<()> {
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix("voice package ")
|
||||
.tempdir()?;
|
||||
let package = install_helper(directory.path())?;
|
||||
let bin = directory.path().join("bin");
|
||||
let source = cargo_bin("codex-voice-host")?;
|
||||
let helper = directory
|
||||
.path()
|
||||
.join("codex-resources/voice/bin")
|
||||
.join(source.file_name().context("helper binary file name")?);
|
||||
VoiceHost::connect(&package, &build_commit().await?)
|
||||
.await?
|
||||
.close()
|
||||
.await?;
|
||||
assert!(VoiceHost::connect(&package, "wrong-build").await.is_err());
|
||||
// Startup libraries permit handshakes; the missing runtime receipt prevents readiness.
|
||||
let host = VoiceHost::connect(&package, &build_commit().await?).await?;
|
||||
assert!(host.initialize_runtime().await.is_err());
|
||||
// The same executable elsewhere in the package must not become a fallback.
|
||||
fs::rename(&helper, bin.join(source.file_name().unwrap()))?;
|
||||
assert!(
|
||||
VoiceHost::connect(&package, &build_commit().await?)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&source, &helper)?;
|
||||
assert!(
|
||||
VoiceHost::connect(&package, &build_commit().await?)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Linux permits raw-byte filenames; macOS filesystems reject this name themselves.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn installed_client_accepts_non_utf8_package_path() -> Result<()> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory
|
||||
.path()
|
||||
.join(OsString::from_vec(b"voice-\xff".to_vec()));
|
||||
let bin = root.join("bin");
|
||||
let helper_dir = root.join("codex-resources/voice/bin");
|
||||
fs::create_dir_all(&bin)?;
|
||||
fs::create_dir_all(&helper_dir)?;
|
||||
fs::write(root.join("codex-package.json"), "{}")?;
|
||||
let app = bin.join("codex");
|
||||
fs::write(&app, [])?;
|
||||
let source = cargo_bin("codex-voice-host")?;
|
||||
fs::copy(&source, helper_dir.join(source.file_name().unwrap()))?;
|
||||
install_startup_libraries(helper_dir.parent().unwrap())?;
|
||||
let package = InstallContext::from_exe(
|
||||
/*is_macos*/ cfg!(target_os = "macos"),
|
||||
Some(&app),
|
||||
/*method_override*/ None,
|
||||
)
|
||||
.package_layout
|
||||
.context("package layout")?;
|
||||
VoiceHost::connect(&package, &build_commit().await?)
|
||||
.await?
|
||||
.close()
|
||||
.await
|
||||
}
|
||||
|
||||
enum RemoteState {
|
||||
Ice(RTCIceConnectionState),
|
||||
Peer(RTCPeerConnectionState),
|
||||
Channel,
|
||||
}
|
||||
|
||||
struct RemoteEvents {
|
||||
channels: mpsc::Sender<Arc<dyn DataChannel>>,
|
||||
gathered: Arc<Notify>,
|
||||
diagnostics: mpsc::Sender<(Duration, RemoteState)>,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl RemoteEvents {
|
||||
fn record(&self, state: RemoteState) {
|
||||
// Keep only the first 32 events; diagnostics must never wait on the connection driver.
|
||||
let _ = self.diagnostics.try_send((self.started.elapsed(), state));
|
||||
}
|
||||
}
|
||||
|
||||
impl PeerConnectionEventHandler for RemoteEvents {
|
||||
fn on_ice_connection_state_change<'a, 'async_trait>(
|
||||
&'a self,
|
||||
state: RTCIceConnectionState,
|
||||
) -> BoxFuture<'async_trait, ()>
|
||||
where
|
||||
'a: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async move { self.record(RemoteState::Ice(state)) })
|
||||
}
|
||||
|
||||
fn on_connection_state_change<'a, 'async_trait>(
|
||||
&'a self,
|
||||
state: RTCPeerConnectionState,
|
||||
) -> BoxFuture<'async_trait, ()>
|
||||
where
|
||||
'a: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async move { self.record(RemoteState::Peer(state)) })
|
||||
}
|
||||
|
||||
fn on_data_channel<'a, 'async_trait>(
|
||||
&'a self,
|
||||
channel: Arc<dyn DataChannel>,
|
||||
) -> BoxFuture<'async_trait, ()>
|
||||
where
|
||||
'a: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async move {
|
||||
self.record(RemoteState::Channel);
|
||||
assert!(
|
||||
self.channels.try_send(channel).is_ok(),
|
||||
"receiver must accept the event channel"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
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.gathered.notify_one();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_client_negotiates_and_closes_over_udp_and_tcp() -> Result<()> {
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix("voice package ")
|
||||
.tempdir()?;
|
||||
let package = install_helper(directory.path())?;
|
||||
let commit = build_commit().await?;
|
||||
for tcp in [false, true] {
|
||||
let started = Instant::now();
|
||||
let (diagnostics, mut states) = mpsc::channel(/*buffer*/ 32);
|
||||
let negotiation = timeout(Duration::from_secs(/*secs*/ 30), async {
|
||||
let (sender, mut channels) = mpsc::channel(/*buffer*/ 1);
|
||||
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,
|
||||
gathered: gathered.clone(),
|
||||
diagnostics,
|
||||
started,
|
||||
}));
|
||||
let remote = if tcp {
|
||||
builder.with_tcp_addrs(vec!["0.0.0.0:0"])
|
||||
} else {
|
||||
builder.with_udp_addrs(vec!["0.0.0.0:0"])
|
||||
}
|
||||
.build()
|
||||
.await?;
|
||||
let result: Result<()> = async {
|
||||
let host = VoiceHost::connect(&package, &commit).await?;
|
||||
let (host, offer) = host.start_transport().await?;
|
||||
remote
|
||||
.set_remote_description(RTCSessionDescription::offer(offer.into_sdp())?)
|
||||
.await?;
|
||||
let answer = remote.create_answer(/*options*/ None).await?;
|
||||
remote.set_local_description(answer).await?;
|
||||
gathered.notified().await;
|
||||
let answer = remote.local_description().await.context("local answer")?;
|
||||
let host = host
|
||||
.apply_answer(
|
||||
SessionDescription::try_from(answer.sdp).map_err(anyhow::Error::msg)?,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("tcp={tcp}: apply voice answer"))?;
|
||||
let channel = channels.recv().await.context("event channel")?;
|
||||
assert_eq!(
|
||||
(channel.label().await?, channel.ordered().await?),
|
||||
("oai-events".into(), true)
|
||||
);
|
||||
// This checks the Closed acknowledgement and successful child exit as well.
|
||||
host.close().await
|
||||
}
|
||||
.await;
|
||||
remote.close().await?;
|
||||
result
|
||||
});
|
||||
let mut heartbeat = tokio::time::interval(Duration::from_millis(/*millis*/ 100));
|
||||
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut ticks = 0_u32;
|
||||
let mut last_poll = started;
|
||||
let mut max_gap = Duration::ZERO;
|
||||
let mut max_gap_at = Duration::ZERO;
|
||||
let result = {
|
||||
tokio::pin!(negotiation);
|
||||
loop {
|
||||
let outcome = tokio::select! {
|
||||
result = &mut negotiation => Some(result),
|
||||
_ = heartbeat.tick() => {
|
||||
ticks += 1;
|
||||
None
|
||||
}
|
||||
};
|
||||
// Include the final polling gap even when negotiation or its timeout wins.
|
||||
let now = Instant::now();
|
||||
let gap = now.duration_since(last_poll);
|
||||
if gap > max_gap {
|
||||
max_gap = gap;
|
||||
max_gap_at = now.duration_since(started);
|
||||
}
|
||||
last_poll = now;
|
||||
if let Some(result) = outcome {
|
||||
break result;
|
||||
}
|
||||
}
|
||||
};
|
||||
result
|
||||
.with_context(|| format!("tcp={tcp}: helper negotiation timed out"))
|
||||
.and_then(std::convert::identity)
|
||||
.with_context(|| {
|
||||
let events: Vec<_> = std::iter::from_fn(|| states.try_recv().ok())
|
||||
.map(|(elapsed, state)| match state {
|
||||
RemoteState::Ice(state) => format!("{elapsed:?}: ICE {state}"),
|
||||
RemoteState::Peer(state) => format!("{elapsed:?}: peer {state}"),
|
||||
RemoteState::Channel => format!("{elapsed:?}: channel arrived"),
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"tcp={tcp}: remote connection events (first 32): {events:?}; \
|
||||
test runtime heartbeat: ticks={ticks}, max polling gap={max_gap:?} \
|
||||
observed at {max_gap_at:?}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,41 +1,20 @@
|
||||
//! Exercise the real helper through installed paths and bounded process I/O.
|
||||
//! Exercise the real helper protocol and bounded process I/O.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_install_context::CodexPackageLayout;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_realtime_webrtc::Message;
|
||||
use codex_realtime_webrtc::SessionDescription;
|
||||
use codex_realtime_webrtc::VoiceHost;
|
||||
use codex_realtime_webrtc::decode_frame;
|
||||
use codex_realtime_webrtc::encode_frame;
|
||||
use codex_utils_cargo_bin::cargo_bin;
|
||||
use futures::future::BoxFuture;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::Command;
|
||||
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;
|
||||
use webrtc::peer_connection::RTCIceConnectionState;
|
||||
use webrtc::peer_connection::RTCIceGatheringState;
|
||||
use webrtc::peer_connection::RTCPeerConnectionState;
|
||||
use webrtc::peer_connection::RTCSessionDescription;
|
||||
use webrtc::peer_connection::SettingEngine;
|
||||
|
||||
const DEADLINE: Duration = Duration::from_secs(/*secs*/ 10);
|
||||
|
||||
@@ -116,8 +95,7 @@ async fn closes_after_acknowledgement_and_on_parent_pipe_loss() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exits_after_parent_pipe_loss_following_transport_start_request() -> Result<()> {
|
||||
// The binary unit test separately forces startup to block and checks watchdog termination.
|
||||
async fn parent_pipe_loss_cancels_transport_startup() -> Result<()> {
|
||||
let mut child = spawn()?;
|
||||
handshake(&mut child).await?;
|
||||
child
|
||||
@@ -158,281 +136,3 @@ async fn rejects_invalid_input_without_echoing_it() -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn install_helper(root: &Path) -> Result<CodexPackageLayout> {
|
||||
let bin = root.join("bin");
|
||||
let helper_dir = root.join("codex-resources/voice/bin");
|
||||
fs::create_dir_all(&bin)?;
|
||||
fs::create_dir_all(&helper_dir)?;
|
||||
fs::write(root.join("codex-package.json"), "{}")?;
|
||||
let app = bin.join(if cfg!(windows) { "codex.exe" } else { "codex" });
|
||||
fs::write(&app, [])?;
|
||||
let source = cargo_bin("codex-voice-host")?;
|
||||
let helper = helper_dir.join(source.file_name().context("helper binary file name")?);
|
||||
fs::copy(&source, &helper)?;
|
||||
InstallContext::from_exe(
|
||||
/*is_macos*/ cfg!(target_os = "macos"),
|
||||
Some(&app),
|
||||
/*method_override*/ None,
|
||||
)
|
||||
.package_layout
|
||||
.context("package layout")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_client_rejects_mixed_builds_and_missing_helper() -> Result<()> {
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix("voice package ")
|
||||
.tempdir()?;
|
||||
let package = install_helper(directory.path())?;
|
||||
let bin = directory.path().join("bin");
|
||||
let source = cargo_bin("codex-voice-host")?;
|
||||
let helper = directory
|
||||
.path()
|
||||
.join("codex-resources/voice/bin")
|
||||
.join(source.file_name().unwrap());
|
||||
VoiceHost::connect(&package, &build_commit().await?)
|
||||
.await?
|
||||
.close()
|
||||
.await?;
|
||||
assert!(VoiceHost::connect(&package, "wrong-build").await.is_err());
|
||||
// Helper-only installations still handshake, but cannot claim native readiness.
|
||||
let host = VoiceHost::connect(&package, &build_commit().await?).await?;
|
||||
assert!(host.initialize_runtime().await.is_err());
|
||||
// The same executable elsewhere in the package must not become a fallback.
|
||||
fs::rename(&helper, bin.join(source.file_name().unwrap()))?;
|
||||
assert!(
|
||||
VoiceHost::connect(&package, &build_commit().await?)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&source, &helper)?;
|
||||
assert!(
|
||||
VoiceHost::connect(&package, &build_commit().await?)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum RemoteState {
|
||||
Ice(RTCIceConnectionState),
|
||||
Peer(RTCPeerConnectionState),
|
||||
Channel,
|
||||
}
|
||||
|
||||
struct RemoteEvents {
|
||||
channels: mpsc::Sender<Arc<dyn DataChannel>>,
|
||||
gathered: Arc<Notify>,
|
||||
diagnostics: mpsc::Sender<(Duration, RemoteState)>,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl RemoteEvents {
|
||||
fn record(&self, state: RemoteState) {
|
||||
// Keep only the first 32 events; diagnostics must never wait on the connection driver.
|
||||
let _ = self.diagnostics.try_send((self.started.elapsed(), state));
|
||||
}
|
||||
}
|
||||
|
||||
impl PeerConnectionEventHandler for RemoteEvents {
|
||||
fn on_ice_connection_state_change<'a, 'async_trait>(
|
||||
&'a self,
|
||||
state: RTCIceConnectionState,
|
||||
) -> BoxFuture<'async_trait, ()>
|
||||
where
|
||||
'a: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async move { self.record(RemoteState::Ice(state)) })
|
||||
}
|
||||
|
||||
fn on_connection_state_change<'a, 'async_trait>(
|
||||
&'a self,
|
||||
state: RTCPeerConnectionState,
|
||||
) -> BoxFuture<'async_trait, ()>
|
||||
where
|
||||
'a: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async move { self.record(RemoteState::Peer(state)) })
|
||||
}
|
||||
|
||||
fn on_data_channel<'a, 'async_trait>(
|
||||
&'a self,
|
||||
channel: Arc<dyn DataChannel>,
|
||||
) -> BoxFuture<'async_trait, ()>
|
||||
where
|
||||
'a: 'async_trait,
|
||||
Self: 'async_trait,
|
||||
{
|
||||
Box::pin(async move {
|
||||
self.record(RemoteState::Channel);
|
||||
assert!(
|
||||
self.channels.try_send(channel).is_ok(),
|
||||
"receiver must accept the event channel"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
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.gathered.notify_one();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_client_negotiates_and_closes_over_udp_and_tcp() -> Result<()> {
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix("voice package ")
|
||||
.tempdir()?;
|
||||
let package = install_helper(directory.path())?;
|
||||
let commit = build_commit().await?;
|
||||
for tcp in [false, true] {
|
||||
let started = Instant::now();
|
||||
let (diagnostics, mut states) = mpsc::channel(/*buffer*/ 32);
|
||||
let negotiation = timeout(Duration::from_secs(/*secs*/ 30), async {
|
||||
let (sender, mut channels) = mpsc::channel(/*buffer*/ 1);
|
||||
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,
|
||||
gathered: gathered.clone(),
|
||||
diagnostics,
|
||||
started,
|
||||
}));
|
||||
let remote = if tcp {
|
||||
builder.with_tcp_addrs(vec!["0.0.0.0:0"])
|
||||
} else {
|
||||
builder.with_udp_addrs(vec!["0.0.0.0:0"])
|
||||
}
|
||||
.build()
|
||||
.await?;
|
||||
let result: Result<()> = async {
|
||||
let host = VoiceHost::connect(&package, &commit).await?;
|
||||
let (host, offer) = host.start_transport().await?;
|
||||
remote
|
||||
.set_remote_description(RTCSessionDescription::offer(offer.into_sdp())?)
|
||||
.await?;
|
||||
let answer = remote.create_answer(/*options*/ None).await?;
|
||||
remote.set_local_description(answer).await?;
|
||||
gathered.notified().await;
|
||||
let answer = remote.local_description().await.context("local answer")?;
|
||||
let host = host
|
||||
.apply_answer(
|
||||
SessionDescription::try_from(answer.sdp).map_err(anyhow::Error::msg)?,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("tcp={tcp}: apply voice answer"))?;
|
||||
let channel = channels.recv().await.context("event channel")?;
|
||||
assert_eq!(
|
||||
(channel.label().await?, channel.ordered().await?),
|
||||
("oai-events".into(), true)
|
||||
);
|
||||
// This checks the Closed acknowledgement and successful child exit as well.
|
||||
host.close().await
|
||||
}
|
||||
.await;
|
||||
remote.close().await?;
|
||||
result
|
||||
});
|
||||
let mut heartbeat = tokio::time::interval(Duration::from_millis(/*millis*/ 100));
|
||||
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut ticks = 0_u32;
|
||||
let mut last_poll = started;
|
||||
let mut max_gap = Duration::ZERO;
|
||||
let mut max_gap_at = Duration::ZERO;
|
||||
let result = {
|
||||
tokio::pin!(negotiation);
|
||||
loop {
|
||||
let outcome = tokio::select! {
|
||||
result = &mut negotiation => Some(result),
|
||||
_ = heartbeat.tick() => {
|
||||
ticks += 1;
|
||||
None
|
||||
}
|
||||
};
|
||||
// Include the final polling gap even when negotiation or its timeout wins.
|
||||
let now = Instant::now();
|
||||
let gap = now.duration_since(last_poll);
|
||||
if gap > max_gap {
|
||||
max_gap = gap;
|
||||
max_gap_at = now.duration_since(started);
|
||||
}
|
||||
last_poll = now;
|
||||
if let Some(result) = outcome {
|
||||
break result;
|
||||
}
|
||||
}
|
||||
};
|
||||
result
|
||||
.with_context(|| format!("tcp={tcp}: helper negotiation timed out"))
|
||||
.and_then(std::convert::identity)
|
||||
.with_context(|| {
|
||||
let events: Vec<_> = std::iter::from_fn(|| states.try_recv().ok())
|
||||
.map(|(elapsed, state)| match state {
|
||||
RemoteState::Ice(state) => format!("{elapsed:?}: ICE {state}"),
|
||||
RemoteState::Peer(state) => format!("{elapsed:?}: peer {state}"),
|
||||
RemoteState::Channel => format!("{elapsed:?}: channel arrived"),
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"tcp={tcp}: remote connection events (first 32): {events:?}; \
|
||||
test runtime heartbeat: ticks={ticks}, max polling gap={max_gap:?} \
|
||||
observed at {max_gap_at:?}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Linux permits raw-byte filenames; macOS filesystems reject this name themselves.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn installed_client_accepts_non_utf8_package_path() -> Result<()> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory
|
||||
.path()
|
||||
.join(OsString::from_vec(b"voice-\xff".to_vec()));
|
||||
let bin = root.join("bin");
|
||||
let helper_dir = root.join("codex-resources/voice/bin");
|
||||
fs::create_dir_all(&bin)?;
|
||||
fs::create_dir_all(&helper_dir)?;
|
||||
fs::write(root.join("codex-package.json"), "{}")?;
|
||||
let app = bin.join("codex");
|
||||
fs::write(&app, [])?;
|
||||
let source = cargo_bin("codex-voice-host")?;
|
||||
fs::copy(&source, helper_dir.join(source.file_name().unwrap()))?;
|
||||
let package = InstallContext::from_exe(
|
||||
/*is_macos*/ cfg!(target_os = "macos"),
|
||||
Some(&app),
|
||||
/*method_override*/ None,
|
||||
)
|
||||
.package_layout
|
||||
.context("package layout")?;
|
||||
VoiceHost::connect(&package, &build_commit().await?)
|
||||
.await?
|
||||
.close()
|
||||
.await
|
||||
}
|
||||
|
||||
38
third_party/voice/BUILD.bazel
vendored
38
third_party/voice/BUILD.bazel
vendored
@@ -75,7 +75,7 @@ alias(
|
||||
# Keep the pinned pkg-config sources, but explicitly use LLVM archive tools.
|
||||
# The upstream bootstrap otherwise selects host ar/ranlib on macOS.
|
||||
pkg_config(
|
||||
name = "pkg_config",
|
||||
name = "pkg_config_unix",
|
||||
build_data = [
|
||||
":pkg_config_linker",
|
||||
"@llvm//tools:llvm-ar",
|
||||
@@ -116,9 +116,21 @@ pkg_config(
|
||||
|
||||
native_tool_toolchain(
|
||||
name = "pkg_config_tool",
|
||||
path = "$(execpath :pkg_config)",
|
||||
path = "$(execpath :pkg_config_unix)",
|
||||
tags = ["manual"],
|
||||
target = ":pkg_config",
|
||||
target = ":pkg_config_unix",
|
||||
)
|
||||
|
||||
# Build-script tools select their execution platform, independently of target ABI.
|
||||
alias(
|
||||
name = "pkg_config",
|
||||
actual = select({
|
||||
":windows_x86_64": ":windows_tools_x86_64",
|
||||
":windows_aarch64": ":windows_tools_aarch64",
|
||||
"//conditions:default": ":pkg_config_unix",
|
||||
}),
|
||||
tags = ["manual"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
filegroup(
|
||||
@@ -181,6 +193,9 @@ alias(
|
||||
actual = select({
|
||||
":" + os + "_" + cpu: ":native_prefix_" + os + "_" + cpu
|
||||
for os, cpu, _ in _NATIVE_PLATFORMS
|
||||
} | {
|
||||
":windows_" + cpu + "_msvc": ":native_prefix_windows_" + cpu
|
||||
for cpu in ("x86_64", "aarch64")
|
||||
}),
|
||||
tags = ["manual"],
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -209,6 +224,9 @@ alias(
|
||||
actual = select({
|
||||
":" + os + "_" + cpu: ":native_runtime_" + os + "_" + cpu
|
||||
for os, cpu, _ in _NATIVE_PLATFORMS
|
||||
} | {
|
||||
":windows_" + cpu + "_msvc": ":native_runtime_windows_" + cpu
|
||||
for cpu in ("x86_64", "aarch64")
|
||||
}),
|
||||
tags = ["manual"],
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -241,6 +259,9 @@ alias(
|
||||
actual = select({
|
||||
":" + os + "_" + cpu: ":native_link_" + os + "_" + cpu
|
||||
for os, cpu, _ in _NATIVE_PLATFORMS
|
||||
} | {
|
||||
":windows_" + cpu + "_msvc": ":native_link_windows_" + cpu
|
||||
for cpu in ("x86_64", "aarch64")
|
||||
}),
|
||||
tags = ["manual"],
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -259,6 +280,17 @@ label_flag(
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
[
|
||||
config_setting(
|
||||
name = "windows_" + cpu,
|
||||
constraint_values = [
|
||||
"@platforms//os:windows",
|
||||
"@platforms//cpu:" + cpu,
|
||||
],
|
||||
)
|
||||
for cpu in ("x86_64", "aarch64")
|
||||
]
|
||||
|
||||
[
|
||||
config_setting(
|
||||
name = "windows_" + cpu + "_msvc",
|
||||
|
||||
Reference in New Issue
Block a user