Files
codex/codex-rs/voice-host/src/main.rs
T
Benjamin Carlsson b01c3986fd Add live WebRTC voice conversations to the TUI (#43581)
## What changed

- Add feature-gated `/voice`, `/voice mute`, and `/voice stop` commands with local WebRTC audio and app-server signaling.
- Show live transcripts, conversation status, and microphone and speaker levels. Preserve captions and undelivered answers across thread switches.
- Speak final answers from voice handoffs while keeping delegated reasoning and commentary hidden and typed answers unspoken.
- Retry eligible startup failures once and clean up voice sessions on thread switches and disconnects.
- Stop voice and block late handoffs after a misalignment policy violation. Remove realtime event payloads and spoken text from receipt and debug logs.

## Testing

Add coverage for voice command mapping, rejected starts, disconnect cleanup, delegated final-answer speech, late handoff rejection, and transcript and footer rendering.

GitOrigin-RevId: 11def1f86b0e023b21d0d92072d6636ccad89815
2026-09-07 20:25:30 +00:00

247 lines
9.2 KiB
Rust

//! 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",
all(target_os = "linux", target_env = "gnu"),
all(windows, target_env = "msvc")
)),
path = "devices_unavailable.rs"
)]
mod devices;
mod incoming;
mod runtime;
mod transport;
mod transport_runtime;
use std::io;
use std::io::Write;
use std::sync::mpsc;
use std::time::Duration;
use codex_realtime_webrtc::Message;
use codex_realtime_webrtc::encode_frame;
use codex_realtime_webrtc::read_message;
const DEVICE_SERVICE_INTERVAL: Duration = Duration::from_millis(/*millis*/ 5);
const BUILD_COMMIT: &str = match option_env!("STABLE_GIT_COMMIT") {
Some(commit) => commit,
None => "dev",
};
fn main() {
codex_process_hardening::pre_main_hardening();
let mut args = std::env::args_os().skip(/*n*/ 1);
match (args.next(), args.next()) {
(Some(arg), None) if arg == "--build-commit" => println!("{BUILD_COMMIT}"),
(None, None) => {
if run(|executor| {
executor
.block_on(transport::Transport::new())
.map_err(io::Error::other)
})
.is_err()
{
std::process::exit(/*code*/ 1);
}
}
_ => std::process::exit(/*code*/ 2),
}
}
fn run(
start_transport: impl Fn(&tokio::runtime::Runtime) -> io::Result<transport::Transport>,
) -> io::Result<()> {
let (sender, receiver) = mpsc::sync_channel(/*bound*/ 1);
std::thread::Builder::new()
.name("voice-control".into())
.spawn(move || {
let mut input = io::stdin().lock();
loop {
match read_message(&mut input) {
Ok(Some(message)) => {
if sender.try_send(message).is_err() {
std::process::exit(/*code*/ 1);
}
}
Ok(None) => break,
Err(_) => std::process::exit(/*code*/ 1),
}
}
drop(sender);
// Independent of the main worker or a blocked stdout write, including after parent death.
std::thread::sleep(Duration::from_secs(/*secs*/ 2));
std::process::exit(/*code*/ 1);
})?;
let Ok(hello) = receiver.recv() else {
return Ok(());
};
if hello
!= (Message::Hello {
protocol: 1,
build_commit: BUILD_COMMIT.to_owned(),
})
{
return Err(io::Error::other("incompatible voice helper"));
}
let mut output = io::stdout().lock();
output.write_all(&encode_frame(&Message::Ready {})?)?;
output.flush()?;
let mut runtime = None;
let executor = tokio::runtime::Runtime::new()?;
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) = &mut devices {
let peer = transport
.as_mut()
.ok_or_else(|| io::Error::other("voice peer not started"))?;
wait_for_control(&receiver, || {
// Bound one pass to the ingress queue capacity so new media
// cannot indefinitely postpone a waiting privacy control.
for _ in 0..64 {
let Some(packet) = peer.incoming.take().map_err(io::Error::other)? else {
break;
};
devices.receive(packet)?;
}
executor.block_on(devices.service(&mut peer.audio))?;
Ok(())
})?
.ok_or(mpsc::RecvTimeoutError::Disconnected)
} else {
receiver
.recv()
.map_err(|_| mpsc::RecvTimeoutError::Disconnected)
};
let reply = match message {
Ok(Message::InspectAudio {}) => {
if answered && transport.as_ref().is_none_or(|peer| !*peer.ready.borrow()) {
return Err(io::Error::other("voice connection closed"));
}
Message::AudioState {
state: devices
.as_ref()
.map(devices::Devices::take_state)
.transpose()?
.unwrap_or_default(),
}
}
Ok(Message::OpenDevices {}) if devices.is_none() && runtime.is_some() && answered => {
devices = Some(devices::Devices::open()?);
Message::DevicesOpened {}
}
Ok(Message::SetAudioControls { controls }) => {
let peer = transport
.as_ref()
.ok_or_else(|| io::Error::other("voice peer not started"))?;
if controls.speaker_suppressed {
peer.incoming
.set_suppressed(controls.speaker_suppressed)
.map_err(io::Error::other)?;
}
devices
.as_mut()
.ok_or_else(|| io::Error::other("audio devices not open"))?
.set_controls(controls)?;
if !controls.speaker_suppressed {
peer.incoming
.set_suppressed(controls.speaker_suppressed)
.map_err(io::Error::other)?;
}
Message::AudioControlsApplied {}
}
Ok(Message::StartTransport {}) if transport.is_none() => {
let peer = start_transport(&executor)?;
let sdp = executor.block_on(peer.offer()).map_err(io::Error::other)?;
transport = Some(peer);
Message::Offer {
sdp: sdp.try_into().map_err(io::Error::other)?,
}
}
Ok(Message::ApplyAnswer { sdp }) if !answered => {
let Some(peer) = transport.as_ref() else {
return Err(io::Error::other("voice transport not started"));
};
let outcome = executor
.block_on(peer.apply_answer(sdp.into_sdp()))
.map_err(io::Error::other)?;
if outcome == transport::AnswerOutcome::TimedOut {
// The client reaps this helper before considering a fresh negotiation.
output.write_all(&encode_frame(&Message::TransportTimedOut {})?)?;
output.flush()?;
return Ok(());
}
answered = true;
Message::TransportReady {}
}
Ok(Message::InitializeRuntime {}) => {
if runtime.is_some() {
return Err(io::Error::other("runtime already initialized"));
}
runtime = Some(runtime::Runtime::initialize()?);
Message::RuntimeReady {}
}
Ok(Message::Close {}) => {
let _ = devices.take();
if let Some(mut peer) = transport.take() {
executor.block_on(peer.close()).map_err(io::Error::other)?;
}
output.write_all(&encode_frame(&Message::Closed {})?)?;
return output.flush();
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
Ok(
Message::Hello { .. }
| Message::Ready {}
| Message::RuntimeReady {}
| Message::StartTransport {}
| Message::ApplyAnswer { .. }
| Message::Offer { .. }
| Message::TransportReady {}
| Message::TransportTimedOut {}
| Message::OpenDevices {}
| Message::DevicesOpened {}
| Message::AudioControlsApplied {}
| Message::AudioState { .. }
| Message::Closed {},
) => return Err(io::Error::other("invalid voice control sequence")),
};
output.write_all(&encode_frame(&reply)?)?;
output.flush()?;
}
}
// 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;