Remove TUI realtime voice support (#27801)

## Why

Removes the realtime audio support from TUI.

## What Changed

- Removed the TUI `/realtime` and realtime `/settings` command paths.
- Deleted TUI voice capture/playback, WebRTC session handling,
audio-device selection UI, and recording-meter code.
- Removed TUI realtime tests and snapshots that covered the deleted
surfaces.
- Dropped the TUI-only `cpal` and `codex-realtime-webrtc` dependencies
and refreshed the Rust/Bazel locks.
This commit is contained in:
Eric Traut
2026-06-12 14:20:55 -07:00
committed by GitHub
Unverified
parent 8f2d6416ce
commit 576f603440
37 changed files with 77 additions and 2494 deletions
-17
View File
File diff suppressed because one or more lines are too long
-190
View File
@@ -291,28 +291,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.10.0",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -4004,7 +3982,6 @@ dependencies = [
"codex-otel",
"codex-plugin",
"codex-protocol",
"codex-realtime-webrtc",
"codex-rollout",
"codex-sandboxing",
"codex-shell-command",
@@ -4026,7 +4003,6 @@ dependencies = [
"codex-windows-sandbox",
"color-eyre",
"core_test_support",
"cpal",
"crossterm",
"derive_more 2.1.1",
"diffy",
@@ -4646,49 +4622,6 @@ dependencies = [
"zstd 0.13.3",
]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6"
dependencies = [
"bindgen",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"ndk",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -5136,12 +5069,6 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.10.0"
@@ -8963,15 +8890,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "maplit"
version = "1.0.2"
@@ -9167,35 +9085,12 @@ dependencies = [
"tempfile",
]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.10.0",
"jni-sys 0.3.0",
"log",
"ndk-sys",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys 0.3.0",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
@@ -9368,17 +9263,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -9429,28 +9313,6 @@ dependencies = [
"libc",
]
[[package]]
name = "num_enum"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "num_threads"
version = "0.1.7"
@@ -9660,29 +9522,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni 0.21.1",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -14812,16 +14651,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.58.0"
@@ -14853,16 +14682,6 @@ dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
@@ -14971,15 +14790,6 @@ dependencies = [
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.2.0"
-1
View File
@@ -208,7 +208,6 @@ codex-plugin = { path = "plugin" }
codex-model-provider = { path = "model-provider" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-realtime-webrtc = { path = "realtime-webrtc" }
codex-prompts = { path = "prompts" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
codex-response-debug-context = { path = "response-debug-context" }
-4
View File
@@ -49,7 +49,6 @@ codex-models-manager = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-realtime-webrtc = { workspace = true }
codex-rollout = { workspace = true }
codex-sandboxing = { workspace = true }
codex-shell-command = { workspace = true }
@@ -125,9 +124,6 @@ uuid = { workspace = true }
codex-windows-sandbox = { workspace = true }
tokio-util = { workspace = true, features = ["time"] }
[target.'cfg(not(target_os = "linux"))'.dependencies]
cpal = "0.15"
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
-1
View File
@@ -13,7 +13,6 @@ use crate::app_event::HistoryLookupResponse;
use crate::app_event::PermissionProfileSelection;
use crate::app_event::PluginLocation;
use crate::app_event::RateLimitRefreshOrigin;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
-74
View File
@@ -798,18 +798,6 @@ impl App {
self.sync_active_thread_personality_setting(app_server, personality)
.await;
}
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
self.chat_widget.open_realtime_audio_device_selection(kind);
}
AppEvent::RealtimeWebrtcOfferCreated { result } => {
self.chat_widget.on_realtime_webrtc_offer_created(result);
}
AppEvent::RealtimeWebrtcEvent(event) => {
self.chat_widget.on_realtime_webrtc_event(event);
}
AppEvent::RealtimeWebrtcLocalAudioLevel(peak) => {
self.chat_widget.on_realtime_webrtc_local_audio_level(peak);
}
AppEvent::OpenReasoningPopup { model } => {
self.chat_widget.open_reasoning_popup(model);
}
@@ -1442,56 +1430,6 @@ impl App {
}
}
}
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
let builder = match kind {
RealtimeAudioDeviceKind::Microphone => {
ConfigEditsBuilder::for_config(&self.config)
.set_realtime_microphone(name.as_deref())
}
RealtimeAudioDeviceKind::Speaker => {
ConfigEditsBuilder::for_config(&self.config)
.set_realtime_speaker(name.as_deref())
}
};
match builder.apply().await {
Ok(()) => {
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.config.realtime_audio.microphone = name.clone();
}
RealtimeAudioDeviceKind::Speaker => {
self.config.realtime_audio.speaker = name.clone();
}
}
self.chat_widget
.set_realtime_audio_device(kind, name.clone());
if self.chat_widget.realtime_conversation_is_live() {
self.chat_widget.open_realtime_audio_restart_prompt(kind);
} else {
let selection = name.unwrap_or_else(|| "System default".to_string());
self.chat_widget.add_info_message(
format!("Realtime {} set to {selection}", kind.noun()),
/*hint*/ None,
);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist realtime audio selection"
);
self.chat_widget.add_error_message(format!(
"Failed to save realtime {}: {err}",
kind.noun()
));
}
}
}
AppEvent::RestartRealtimeAudioDevice { kind } => {
self.chat_widget.restart_realtime_audio_device(kind);
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
let mut config = self.config.clone();
if !self.try_set_approval_policy_on_config(
@@ -1943,18 +1881,6 @@ impl App {
));
}
},
#[cfg(not(target_os = "linux"))]
AppEvent::UpdateRecordingMeter { id, text } => {
// Update in place to preserve the element id for subsequent frames.
let updated = self.chat_widget.update_recording_meter_in_place(&id, &text);
if updated
|| self
.chat_widget
.stop_realtime_conversation_for_deleted_meter(&id)
{
tui.frame_requester().schedule_frame();
}
}
AppEvent::StatusLineSetup {
items,
use_theme_colors,
-16
View File
@@ -696,22 +696,6 @@ impl App {
.await?;
Ok(true)
}
AppCommand::RealtimeConversationStart { transport, voice } => {
app_server
.thread_realtime_start(thread_id, transport.clone(), voice.clone())
.await?;
Ok(true)
}
AppCommand::RealtimeConversationAudio(frame) => {
app_server
.thread_realtime_audio(thread_id, frame.clone())
.await?;
Ok(true)
}
AppCommand::RealtimeConversationClose => {
app_server.thread_realtime_stop(thread_id).await?;
Ok(true)
}
AppCommand::RunUserShellCommand { command } => {
app_server
.thread_shell_command(thread_id, command.to_string())
-24
View File
@@ -6,8 +6,6 @@ use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_app_server_protocol::UserInput;
use codex_config::types::ApprovalsReviewer;
@@ -30,12 +28,6 @@ pub(crate) enum AppCommand {
behavior: InterruptBehavior,
},
CleanBackgroundTerminals,
RealtimeConversationStart {
transport: Option<ThreadRealtimeStartTransport>,
voice: Option<Value>,
},
RealtimeConversationAudio(ThreadRealtimeAudioChunk),
RealtimeConversationClose,
RunUserShellCommand {
command: String,
},
@@ -135,22 +127,6 @@ impl AppCommand {
Self::CleanBackgroundTerminals
}
pub(crate) fn realtime_conversation_start(
transport: Option<ThreadRealtimeStartTransport>,
voice: Option<Value>,
) -> Self {
Self::RealtimeConversationStart { transport, voice }
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
pub(crate) fn realtime_conversation_audio(frame: ThreadRealtimeAudioChunk) -> Self {
Self::RealtimeConversationAudio(frame)
}
pub(crate) fn realtime_conversation_close() -> Self {
Self::RealtimeConversationClose
}
pub(crate) fn run_user_shell_command(command: String) -> Self {
Self::RunUserShellCommand { command }
}
-66
View File
@@ -46,17 +46,9 @@ use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::Personality;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_realtime_webrtc::RealtimeWebrtcEvent;
use codex_realtime_webrtc::RealtimeWebrtcSessionHandle;
use crate::history_cell::HistoryCell;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RealtimeAudioDeviceKind {
Microphone,
Speaker,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ThreadGoalSetMode {
ConfirmIfExists,
@@ -74,22 +66,6 @@ pub(crate) struct HistoryLookupResponse {
pub(crate) entry: Option<String>,
}
impl RealtimeAudioDeviceKind {
pub(crate) fn title(self) -> &'static str {
match self {
Self::Microphone => "Microphone",
Self::Speaker => "Speaker",
}
}
pub(crate) fn noun(self) -> &'static str {
match self {
Self::Microphone => "microphone",
Self::Speaker => "speaker",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConsolidationScrollbackReflow {
IfResizeReflowRan,
@@ -672,34 +648,6 @@ pub(crate) enum AppEvent {
service_tier: Option<String>,
},
/// Open the device picker for a realtime microphone or speaker.
OpenRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
},
/// Persist the selected realtime microphone or speaker to top-level config.
#[cfg_attr(target_os = "linux", allow(dead_code))]
PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
name: Option<String>,
},
/// Restart the selected realtime microphone or speaker locally.
RestartRealtimeAudioDevice {
kind: RealtimeAudioDeviceKind,
},
/// Result of creating a TUI-owned realtime WebRTC offer.
RealtimeWebrtcOfferCreated {
result: Result<RealtimeWebrtcOffer, String>,
},
/// Peer-connection lifecycle event from a TUI-owned realtime WebRTC session.
RealtimeWebrtcEvent(RealtimeWebrtcEvent),
/// Local microphone level from a TUI-owned realtime WebRTC session.
RealtimeWebrtcLocalAudioLevel(u16),
/// Open the reasoning selection popup after picking a model.
OpenReasoningPopup {
model: ModelPreset,
@@ -909,14 +857,6 @@ pub(crate) enum AppEvent {
/// Re-open the permissions presets popup.
OpenPermissionsPopup,
/// Live update for the in-progress voice recording placeholder. Carries
/// the placeholder `id` and the text to display (e.g., an ASCII meter).
#[cfg(not(target_os = "linux"))]
UpdateRecordingMeter {
id: String,
text: String,
},
/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),
@@ -1048,12 +988,6 @@ pub(crate) struct PermissionProfileSelection {
pub display_label: String,
}
#[derive(Debug)]
pub(crate) struct RealtimeWebrtcOffer {
pub(crate) offer_sdp: String,
pub(crate) handle: RealtimeWebrtcSessionHandle,
}
/// The exit strategy requested by the UI layer.
///
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
-8
View File
@@ -11,7 +11,6 @@ use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::ThreadId;
use codex_protocol::request_permissions::RequestPermissionsResponse;
@@ -72,13 +71,6 @@ impl AppEventSender {
)));
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
pub(crate) fn realtime_conversation_audio(&self, frame: ThreadRealtimeAudioChunk) {
self.send(AppEvent::CodexOp(AppCommand::realtime_conversation_audio(
frame,
)));
}
pub(crate) fn user_input_answer(&self, id: String, response: ToolRequestUserInputResponse) {
self.send(AppEvent::CodexOp(AppCommand::user_input_answer(
id, response,
-87
View File
@@ -81,14 +81,6 @@ use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStartResponse;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadRealtimeStopResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
@@ -1119,57 +1111,6 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_realtime_start(
&mut self,
thread_id: ThreadId,
transport: Option<ThreadRealtimeStartTransport>,
voice: Option<serde_json::Value>,
) -> Result<()> {
let request_id = self.next_request_id();
let params = thread_realtime_start_params(thread_id, transport, voice)?;
let _: ThreadRealtimeStartResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeStart { request_id, params })
.await
.wrap_err("thread/realtime/start failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_audio(
&mut self,
thread_id: ThreadId,
frame: ThreadRealtimeAudioChunk,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeAppendAudioResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeAppendAudio {
request_id,
params: ThreadRealtimeAppendAudioParams {
thread_id: thread_id.to_string(),
audio: frame,
},
})
.await
.wrap_err("thread/realtime/appendAudio failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeStopResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeStop {
request_id,
params: ThreadRealtimeStopParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("thread/realtime/stop failed in TUI")?;
Ok(())
}
pub(crate) async fn reject_server_request(
&self,
request_id: RequestId,
@@ -1222,34 +1163,6 @@ pub(crate) async fn start_thread_with_request_handle(
started_thread_from_start_response(response, &config, thread_params_mode).await
}
fn thread_realtime_start_params(
thread_id: ThreadId,
transport: Option<ThreadRealtimeStartTransport>,
voice: Option<serde_json::Value>,
) -> Result<ThreadRealtimeStartParams> {
let mut value = serde_json::Map::new();
value.insert(
"threadId".to_string(),
serde_json::Value::String(thread_id.to_string()),
);
value.insert(
"outputModality".to_string(),
serde_json::Value::String("audio".to_string()),
);
if let Some(transport) = transport {
value.insert(
"transport".to_string(),
serde_json::to_value(transport).wrap_err("serializing realtime transport")?,
);
}
if let Some(voice) = voice {
value.insert("voice".to_string(), voice);
}
serde_json::from_value(serde_json::Value::Object(value))
.wrap_err("mapping TUI realtime start params to app-server params")
}
pub(crate) fn status_account_display_from_auth_mode(
auth_mode: Option<AuthMode>,
plan_type: Option<codex_protocol::account::PlanType>,
-176
View File
@@ -1,176 +0,0 @@
use crate::legacy_core::config::Config;
use cpal::traits::DeviceTrait;
use cpal::traits::HostTrait;
use tracing::warn;
use crate::app_event::RealtimeAudioDeviceKind;
const PREFERRED_INPUT_SAMPLE_RATE: u32 = 24_000;
const PREFERRED_INPUT_CHANNELS: u16 = 1;
pub(crate) fn list_realtime_audio_device_names(
kind: RealtimeAudioDeviceKind,
) -> Result<Vec<String>, String> {
let host = cpal::default_host();
let mut device_names = Vec::new();
for device in devices(&host, kind)? {
let Ok(name) = device.name() else {
continue;
};
if !device_names.contains(&name) {
device_names.push(name);
}
}
Ok(device_names)
}
pub(crate) fn select_configured_input_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(RealtimeAudioDeviceKind::Microphone, config)
}
pub(crate) fn select_configured_output_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(RealtimeAudioDeviceKind::Speaker, config)
}
pub(crate) fn preferred_input_config(
device: &cpal::Device,
) -> Result<cpal::SupportedStreamConfig, String> {
let supported_configs = device
.supported_input_configs()
.map_err(|err| format!("failed to enumerate input audio configs: {err}"))?;
supported_configs
.filter_map(|range| {
let sample_format_rank = match range.sample_format() {
cpal::SampleFormat::I16 => 0u8,
cpal::SampleFormat::U16 => 1u8,
cpal::SampleFormat::F32 => 2u8,
_ => return None,
};
let sample_rate = preferred_input_sample_rate(&range);
let sample_rate_penalty = sample_rate.0.abs_diff(PREFERRED_INPUT_SAMPLE_RATE);
let channel_penalty = range.channels().abs_diff(PREFERRED_INPUT_CHANNELS);
Some((
(sample_rate_penalty, channel_penalty, sample_format_rank),
range.with_sample_rate(sample_rate),
))
})
.min_by_key(|(score, _)| *score)
.map(|(_, config)| config)
.or_else(|| device.default_input_config().ok())
.ok_or_else(|| "failed to get default input config".to_string())
}
fn select_device_and_config(
kind: RealtimeAudioDeviceKind,
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
let host = cpal::default_host();
let configured_name = configured_name(kind, config);
let selected = configured_name
.and_then(|name| find_device_by_name(&host, kind, name))
.or_else(|| {
let default_device = default_device(&host, kind);
if let Some(name) = configured_name && default_device.is_some() {
warn!(
"configured {} audio device `{name}` was unavailable; falling back to system default",
kind.noun()
);
}
default_device
})
.ok_or_else(|| missing_device_error(kind, configured_name))?;
let stream_config = match kind {
RealtimeAudioDeviceKind::Microphone => preferred_input_config(&selected)?,
RealtimeAudioDeviceKind::Speaker => default_config(&selected, kind)?,
};
Ok((selected, stream_config))
}
fn configured_name(kind: RealtimeAudioDeviceKind, config: &Config) -> Option<&str> {
match kind {
RealtimeAudioDeviceKind::Microphone => config.realtime_audio.microphone.as_deref(),
RealtimeAudioDeviceKind::Speaker => config.realtime_audio.speaker.as_deref(),
}
}
fn find_device_by_name(
host: &cpal::Host,
kind: RealtimeAudioDeviceKind,
name: &str,
) -> Option<cpal::Device> {
let devices = devices(host, kind).ok()?;
devices
.into_iter()
.find(|device| device.name().ok().as_deref() == Some(name))
}
fn devices(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
match kind {
RealtimeAudioDeviceKind::Microphone => host
.input_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate input audio devices: {err}")),
RealtimeAudioDeviceKind::Speaker => host
.output_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate output audio devices: {err}")),
}
}
fn default_device(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Option<cpal::Device> {
match kind {
RealtimeAudioDeviceKind::Microphone => host.default_input_device(),
RealtimeAudioDeviceKind::Speaker => host.default_output_device(),
}
}
fn default_config(
device: &cpal::Device,
kind: RealtimeAudioDeviceKind,
) -> Result<cpal::SupportedStreamConfig, String> {
match kind {
RealtimeAudioDeviceKind::Microphone => device
.default_input_config()
.map_err(|err| format!("failed to get default input config: {err}")),
RealtimeAudioDeviceKind::Speaker => device
.default_output_config()
.map_err(|err| format!("failed to get default output config: {err}")),
}
}
fn preferred_input_sample_rate(range: &cpal::SupportedStreamConfigRange) -> cpal::SampleRate {
let min = range.min_sample_rate().0;
let max = range.max_sample_rate().0;
if (min..=max).contains(&PREFERRED_INPUT_SAMPLE_RATE) {
cpal::SampleRate(PREFERRED_INPUT_SAMPLE_RATE)
} else if PREFERRED_INPUT_SAMPLE_RATE < min {
cpal::SampleRate(min)
} else {
cpal::SampleRate(max)
}
}
fn missing_device_error(kind: RealtimeAudioDeviceKind, configured_name: Option<&str>) -> String {
match (kind, configured_name) {
(RealtimeAudioDeviceKind::Microphone, Some(name)) => {
format!(
"configured microphone `{name}` was unavailable and no default input audio device was found"
)
}
(RealtimeAudioDeviceKind::Speaker, Some(name)) => {
format!(
"configured speaker `{name}` was unavailable and no default output audio device was found"
)
}
(RealtimeAudioDeviceKind::Microphone, None) => {
"no input audio device available".to_string()
}
(RealtimeAudioDeviceKind::Speaker, None) => "no output audio device available".to_string(),
}
}
@@ -358,9 +358,6 @@ pub(crate) struct ChatComposer {
/// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can
/// prepare their argument text without also double-recording the full command invocation.
pending_slash_command_history: Option<HistoryEntry>,
// Monotonically increasing identifier for textarea elements we insert.
#[cfg(not(target_os = "linux"))]
next_element_id: u64,
skills: Option<Vec<SkillMetadata>>,
plugins: Option<Vec<PluginCapabilitySummary>>,
connectors_snapshot: Option<ConnectorsSnapshot>,
@@ -373,8 +370,6 @@ pub(crate) struct ChatComposer {
mentions_v2_enabled: bool,
goal_command_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
windows_degraded_sandbox_active: bool,
side_conversation_active: bool,
history_search: Option<HistorySearchSession>,
@@ -441,8 +436,6 @@ impl ChatComposer {
service_tier_commands_enabled: self.service_tier_commands_enabled,
goal_command_enabled: self.goal_command_enabled,
personality_command_enabled: self.personality_command_enabled,
realtime_conversation_enabled: self.realtime_conversation_enabled,
audio_device_selection_enabled: self.audio_device_selection_enabled,
allow_elevate_sandbox: self.windows_degraded_sandbox_active,
side_conversation_active: self.side_conversation_active,
}
@@ -527,8 +520,6 @@ impl ChatComposer {
is_task_running: false,
queue_submissions: false,
pending_slash_command_history: None,
#[cfg(not(target_os = "linux"))]
next_element_id: 0,
skills: None,
plugins: None,
connectors_snapshot: None,
@@ -541,8 +532,6 @@ impl ChatComposer {
mentions_v2_enabled: false,
goal_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
history_search: None,
@@ -562,13 +551,6 @@ impl ChatComposer {
this
}
#[cfg(not(target_os = "linux"))]
fn next_id(&mut self) -> String {
let id = self.next_element_id;
self.next_element_id = self.next_element_id.wrapping_add(1);
id.to_string()
}
pub(crate) fn set_frame_requester(&mut self, frame_requester: FrameRequester) {
self.frame_requester = Some(frame_requester);
}
@@ -693,14 +675,6 @@ impl ChatComposer {
self.personality_command_enabled = enabled;
}
pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
self.realtime_conversation_enabled = enabled;
}
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.audio_device_selection_enabled = enabled;
}
pub fn set_side_conversation_active(&mut self, active: bool) {
self.side_conversation_active = active;
}
@@ -3957,23 +3931,6 @@ fn footer_insert_newline_key(
.or_else(|| bindings.first().copied())
}
#[cfg(not(target_os = "linux"))]
impl ChatComposer {
pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
self.draft.textarea.update_named_element_by_id(id, text)
}
pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
let id = self.next_id();
self.draft.textarea.insert_named_element(text, id.clone());
id
}
pub fn remove_recording_meter_placeholder(&mut self, id: &str) {
let _ = self.draft.textarea.replace_element_by_id(id, "");
}
}
fn skill_description(skill: &SkillMetadata) -> Option<String> {
let description = skill
.interface
@@ -4577,26 +4534,6 @@ mod tests {
);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn remove_recording_meter_placeholder_clears_placeholder_text() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let id = composer.insert_recording_meter_placeholder("⠤⠤⠤⠤");
composer.remove_recording_meter_placeholder(&id);
assert_eq!(composer.draft.textarea.text(), "");
assert!(composer.draft.textarea.named_element_range(&id).is_none());
}
#[test]
fn footer_flash_expires_and_falls_back_to_hint_override() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -177,8 +177,6 @@ impl<'a> SlashInput<'a> {
service_tier_commands_enabled: self.command_flags.service_tier_commands_enabled,
goal_command_enabled: self.command_flags.goal_command_enabled,
personality_command_enabled: self.command_flags.personality_command_enabled,
realtime_conversation_enabled: self.command_flags.realtime_conversation_enabled,
audio_device_selection_enabled: self.command_flags.audio_device_selection_enabled,
windows_degraded_sandbox_active: self.command_flags.allow_elevate_sandbox,
side_conversation_active: self.command_flags.side_conversation_active,
},
+20 -44
View File
@@ -47,8 +47,6 @@ pub(crate) struct CommandPopupFlags {
pub(crate) service_tier_commands_enabled: bool,
pub(crate) goal_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
pub(crate) side_conversation_active: bool,
}
@@ -62,8 +60,6 @@ impl From<CommandPopupFlags> for BuiltinCommandFlags {
service_tier_commands_enabled: value.service_tier_commands_enabled,
goal_command_enabled: value.goal_command_enabled,
personality_command_enabled: value.personality_command_enabled,
realtime_conversation_enabled: value.realtime_conversation_enabled,
audio_device_selection_enabled: value.audio_device_selection_enabled,
allow_elevate_sandbox: value.windows_degraded_sandbox_active,
side_conversation_active: value.side_conversation_active,
}
@@ -412,6 +408,26 @@ mod tests {
insta::assert_snapshot!("command_popup_app", format!("{buf:?}"));
}
#[cfg(target_os = "macos")]
#[test]
fn default_command_popup_items_snapshot() {
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/".to_string());
let commands = popup
.filtered_items()
.into_iter()
.map(|item| {
let command = item.command();
let description = item.description();
format!("/{command} - {description}")
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("command_popup_default_items", commands);
}
#[test]
fn prefix_filter_limits_matches_for_ac() {
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
@@ -516,8 +532,6 @@ mod tests {
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
@@ -544,8 +558,6 @@ mod tests {
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
@@ -577,8 +589,6 @@ mod tests {
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
@@ -595,40 +605,6 @@ mod tests {
}
}
#[test]
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
let mut popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
plugins_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
Vec::new(),
);
popup.on_composer_text_change("/aud".to_string());
let cmds: Vec<String> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command().to_string(),
CommandItem::ServiceTier(command) => command.name,
})
.collect();
assert!(
!cmds.iter().any(|cmd| cmd == "settings"),
"expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}"
);
}
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
-35
View File
@@ -427,16 +427,6 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
self.composer.set_realtime_conversation_enabled(enabled);
self.request_redraw();
}
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.composer.set_audio_device_selection_enabled(enabled);
self.request_redraw();
}
pub(crate) fn set_side_conversation_active(&mut self, active: bool) {
self.composer.set_side_conversation_active(active);
self.request_redraw();
@@ -1745,31 +1735,6 @@ impl Renderable for ChatComposerRightReserveRenderable<'_> {
}
}
#[cfg(not(target_os = "linux"))]
impl BottomPane {
pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
let id = self.composer.insert_recording_meter_placeholder(text);
self.composer.sync_popups();
self.request_redraw();
id
}
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.composer.update_recording_meter_in_place(id, text);
if updated {
self.composer.sync_popups();
self.request_redraw();
}
updated
}
pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) {
self.composer.remove_recording_meter_placeholder(id);
self.composer.sync_popups();
self.request_redraw();
}
}
impl Renderable for BottomPane {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_renderable().render(area, buf);
@@ -61,8 +61,6 @@ pub(crate) struct BuiltinCommandFlags {
pub(crate) service_tier_commands_enabled: bool,
pub(crate) goal_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) allow_elevate_sandbox: bool,
pub(crate) side_conversation_active: bool,
}
@@ -77,8 +75,6 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
.filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins)
.filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal)
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.filter(|(_, cmd)| !flags.side_conversation_active || cmd.available_in_side_conversation())
.collect()
}
@@ -170,8 +166,6 @@ mod tests {
service_tier_commands_enabled: true,
goal_command_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: true,
allow_elevate_sandbox: true,
side_conversation_active: false,
}
@@ -268,28 +262,6 @@ mod tests {
assert_eq!(find_builtin_command("goal", flags), None);
}
#[test]
fn realtime_command_is_hidden_when_realtime_is_disabled() {
let mut flags = all_enabled_flags();
flags.realtime_conversation_enabled = false;
assert_eq!(find_builtin_command("realtime", flags), None);
}
#[test]
fn settings_command_is_hidden_when_realtime_is_disabled() {
let mut flags = all_enabled_flags();
flags.realtime_conversation_enabled = false;
flags.audio_device_selection_enabled = false;
assert_eq!(find_builtin_command("settings", flags), None);
}
#[test]
fn settings_command_is_hidden_when_audio_device_selection_is_disabled() {
let mut flags = all_enabled_flags();
flags.audio_device_selection_enabled = false;
assert_eq!(find_builtin_command("settings", flags), None);
}
#[test]
fn side_conversation_hides_commands_without_side_flag() {
let commands = builtins_for_input(BuiltinCommandFlags {
@@ -0,0 +1,46 @@
---
source: tui/src/bottom_pane/command_popup.rs
expression: commands
---
/model - choose what model and reasoning effort to use
/ide - include current selection, open files, and other context from your IDE
/permissions - choose what Codex is allowed to do
/keymap - remap TUI shortcuts
/vim - toggle Vim mode for the composer
/experimental - toggle experimental features
/approve - approve one retry of a recent auto-review denial
/memories - configure memory use and generation
/skills - use skills to improve how Codex performs specific tasks
/import - import setup, this project, and recent chats from Claude Code
/hooks - view and manage lifecycle hooks
/review - review my current changes and find issues
/rename - rename the current thread
/new - start a new chat during a conversation
/archive - archive this session and exit
/delete - permanently delete this session and exit
/resume - resume a saved chat
/fork - fork the current chat
/app - continue this session in Codex Desktop
/init - create an AGENTS.md file with instructions for Codex
/compact - summarize conversation to prevent hitting the context limit
/agent - switch the active agent thread
/side - start a side conversation in an ephemeral fork
/copy - copy last response as markdown
/raw - toggle raw scrollback mode for copy-friendly terminal selection
/diff - show git diff (including untracked files)
/mention - mention a file
/status - show current session configuration and token usage
/title - configure which items appear in the terminal title
/statusline - configure which items appear in the status line
/theme - choose a syntax highlighting theme
/pets - choose or hide the terminal pet
/mcp - list configured MCP tools; use /mcp verbose for details
/logout - log out of Codex
/exit - exit Codex
/feedback - send logs to maintainers
/rollout - print the rollout file path
/ps - list background terminals
/stop - stop all background terminals
/clear - clear the terminal and start a new chat
/test-approval - test approval request
/subagents - switch the active agent thread
+2 -65
View File
@@ -79,7 +79,6 @@ fn split_word_pieces(run: &str) -> Vec<(usize, &str)> {
struct TextElement {
id: u64,
range: Range<usize>,
name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -209,7 +208,6 @@ impl TextArea {
self.elements.push(TextElement {
id,
range: start..end,
name: None,
});
}
self.elements.sort_by_key(|e| e.range.start);
@@ -1470,74 +1468,13 @@ impl TextArea {
id
}
#[cfg(not(target_os = "linux"))]
pub fn insert_named_element(&mut self, text: &str, id: String) {
let start = self.clamp_pos_for_insertion(self.cursor_pos);
self.insert_str_at(start, text);
let end = start + text.len();
self.add_element_with_id(start..end, Some(id));
// Place cursor at end of inserted element
self.set_cursor(end);
}
#[cfg(not(target_os = "linux"))]
pub fn replace_element_by_id(&mut self, id: &str, text: &str) -> bool {
if let Some(idx) = self
.elements
.iter()
.position(|e| e.name.as_deref() == Some(id))
{
let range = self.elements[idx].range.clone();
self.replace_range_raw(range, text);
self.elements.retain(|e| e.name.as_deref() != Some(id));
true
} else {
false
}
}
/// Update the element's text in place, preserving its id so callers can
/// update it again later (e.g. recording -> transcribing -> final).
#[allow(dead_code)]
pub fn update_named_element_by_id(&mut self, id: &str, text: &str) -> bool {
if let Some(elem_idx) = self
.elements
.iter()
.position(|e| e.name.as_deref() == Some(id))
{
let old_range = self.elements[elem_idx].range.clone();
let start = old_range.start;
self.replace_range_raw(old_range, text);
// After replace_range_raw, the old element entry was removed if fully overlapped.
// Re-add an updated element with the same id and new range.
let new_end = start + text.len();
self.add_element_with_id(start..new_end, Some(id.to_string()));
true
} else {
false
}
}
#[allow(dead_code)]
pub fn named_element_range(&self, id: &str) -> Option<std::ops::Range<usize>> {
self.elements
.iter()
.find(|e| e.name.as_deref() == Some(id))
.map(|e| e.range.clone())
}
fn add_element_with_id(&mut self, range: Range<usize>, name: Option<String>) -> u64 {
fn add_element(&mut self, range: Range<usize>) -> u64 {
let id = self.next_element_id();
let elem = TextElement { id, range, name };
self.elements.push(elem);
self.elements.push(TextElement { id, range });
self.elements.sort_by_key(|e| e.range.start);
id
}
fn add_element(&mut self, range: Range<usize>) -> u64 {
self.add_element_with_id(range, /*name*/ None)
}
/// Mark an existing text range as an atomic element without changing the text.
///
/// This is used to convert already-typed tokens (like `/plan`) into elements
+1 -35
View File
@@ -44,12 +44,9 @@ use std::time::Instant;
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_command::AppCommand;
use crate::app_event::HistoryLookupResponse;
use crate::app_event::RealtimeAudioDeviceKind;
use crate::app_server_approval_conversions::file_update_changes_to_display;
use crate::approval_events::ApplyPatchApprovalRequestEvent;
use crate::approval_events::ExecApprovalRequestEvent;
#[cfg(not(target_os = "linux"))]
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLineSetupView;
use crate::bottom_pane::StatusSurfacePreviewData;
@@ -389,11 +386,9 @@ use self::rate_limits::app_server_rate_limit_error_kind;
pub(crate) use self::rate_limits::fallback_limit_label;
use self::rate_limits::is_app_server_cyber_policy_error;
pub(crate) use self::rate_limits::limit_label_for_window;
mod realtime;
mod reasoning_shortcuts;
mod rendering;
mod replay;
use self::realtime::RealtimeConversationUiState;
mod reasoning_shortcuts;
mod review;
mod review_popups;
use self::review::ReviewState;
@@ -717,7 +712,6 @@ pub(crate) struct ChatWidget {
current_goal_status_indicator: Option<GoalStatusIndicator>,
current_goal_status: Option<GoalStatusState>,
external_editor_state: ExternalEditorState,
realtime_conversation: RealtimeConversationUiState,
last_rendered_user_message_display: Option<UserMessageDisplay>,
last_non_retry_error: Option<(String, String)>,
}
@@ -923,15 +917,6 @@ impl ChatWidget {
.unwrap_or_default()
}
fn realtime_conversation_enabled(&self) -> bool {
self.config.features.enabled(Feature::RealtimeConversation)
&& cfg!(not(target_os = "linux"))
}
fn realtime_audio_device_selection_enabled(&self) -> bool {
self.realtime_conversation_enabled()
}
fn restore_retry_status_header_if_present(&mut self) {
if let Some(header) = self.status_state.take_retry_status_header() {
self.set_status_header(header);
@@ -1856,7 +1841,6 @@ impl ChatWidget {
pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) {
self.config.features = config.features.clone();
self.config.config_layer_stack = config.config_layer_stack.clone();
self.config.realtime = config.realtime.clone();
self.config.memories = config.memories.clone();
self.config.terminal_resize_reflow = config.terminal_resize_reflow;
self.sync_mentions_v2_enabled();
@@ -1962,23 +1946,6 @@ impl ChatWidget {
}
}
#[cfg(not(target_os = "linux"))]
impl ChatWidget {
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.bottom_pane.update_recording_meter_in_place(id, text);
if updated {
self.request_redraw();
}
updated
}
pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) {
self.bottom_pane.remove_recording_meter_placeholder(id);
// Ensure the UI redraws to reflect placeholder removal.
self.request_redraw();
}
}
fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
summary.responses_api_overhead_ms > 0
|| summary.responses_api_inference_time_ms > 0
@@ -1990,7 +1957,6 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
impl Drop for ChatWidget {
fn drop(&mut self) {
self.reset_realtime_conversation_state();
self.stop_rate_limit_poller();
}
}
@@ -218,7 +218,6 @@ impl ChatWidget {
current_goal_status_indicator: None,
current_goal_status: None,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_display: None,
last_non_retry_error: None,
};
@@ -230,12 +229,6 @@ impl ChatWidget {
widget
.bottom_pane
.set_vim_enabled(widget.config.tui_vim_mode_default);
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -355,20 +355,10 @@ impl ChatWidget {
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
/// is armed.
///
/// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the
/// first press always stops live voice, even when the composer contains the recording meter.
///
/// When the double-press quit shortcut is enabled, pressing the same shortcut again before
/// expiry requests a shutdown-first quit.
fn on_ctrl_c(&mut self) {
let key = key_hint::ctrl(KeyCode::Char('c'));
if self.realtime_conversation.is_live() {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.stop_realtime_conversation_from_ui();
return;
}
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
+6 -30
View File
@@ -188,36 +188,6 @@ impl ChatWidget {
self.on_shutdown_complete();
}
}
ServerNotification::ThreadRealtimeStarted(notification) => {
if !from_replay {
self.on_realtime_conversation_started(notification);
}
}
ServerNotification::ThreadRealtimeItemAdded(notification) => {
if !from_replay {
self.on_realtime_item_added(notification);
}
}
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => {
if !from_replay {
self.on_realtime_output_audio_delta(notification);
}
}
ServerNotification::ThreadRealtimeError(notification) => {
if !from_replay {
self.on_realtime_error(notification);
}
}
ServerNotification::ThreadRealtimeClosed(notification) => {
if !from_replay {
self.on_realtime_conversation_closed(notification);
}
}
ServerNotification::ThreadRealtimeSdp(notification) => {
if !from_replay {
self.on_realtime_conversation_sdp(notification.sdp);
}
}
ServerNotification::ServerRequestResolved(_)
| ServerNotification::AccountUpdated(_)
| ServerNotification::AccountRateLimitsUpdated(_)
@@ -240,6 +210,12 @@ impl ChatWidget {
| ServerNotification::TurnModerationMetadata(_)
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
| ServerNotification::ThreadRealtimeStarted(_)
| ServerNotification::ThreadRealtimeItemAdded(_)
| ServerNotification::ThreadRealtimeOutputAudioDelta(_)
| ServerNotification::ThreadRealtimeError(_)
| ServerNotification::ThreadRealtimeClosed(_)
| ServerNotification::ThreadRealtimeSdp(_)
| ServerNotification::ThreadRealtimeTranscriptDelta(_)
| ServerNotification::ThreadRealtimeTranscriptDone(_)
| ServerNotification::WindowsWorldWritableWarning(_)
-546
View File
@@ -1,546 +0,0 @@
use super::*;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_app_server_protocol::ThreadRealtimeClosedNotification;
use codex_app_server_protocol::ThreadRealtimeErrorNotification;
use codex_app_server_protocol::ThreadRealtimeItemAddedNotification;
use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ThreadRealtimeStartedNotification;
use codex_config::config_toml::RealtimeTransport;
use codex_realtime_webrtc::RealtimeWebrtcEvent;
use codex_realtime_webrtc::RealtimeWebrtcSession;
use codex_realtime_webrtc::RealtimeWebrtcSessionHandle;
#[cfg(not(target_os = "linux"))]
use std::sync::atomic::AtomicU16;
#[cfg(not(target_os = "linux"))]
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum RealtimeConversationPhase {
#[default]
Inactive,
Starting,
Active,
Stopping,
}
#[derive(Default)]
pub(super) struct RealtimeConversationUiState {
pub(super) phase: RealtimeConversationPhase,
requested_close: bool,
realtime_session_id: Option<String>,
transport: RealtimeConversationUiTransport,
#[cfg(not(target_os = "linux"))]
pub(super) meter_placeholder_id: Option<String>,
#[cfg(not(target_os = "linux"))]
capture_stop_flag: Option<Arc<AtomicBool>>,
#[cfg(not(target_os = "linux"))]
capture: Option<crate::voice::VoiceCapture>,
#[cfg(not(target_os = "linux"))]
audio_player: Option<crate::voice::RealtimeAudioPlayer>,
}
#[derive(Debug, Default)]
enum RealtimeConversationUiTransport {
#[default]
Websocket,
Webrtc {
handle: Option<RealtimeWebrtcSessionHandle>,
},
}
impl RealtimeConversationUiState {
pub(super) fn is_live(&self) -> bool {
matches!(
self.phase,
RealtimeConversationPhase::Starting
| RealtimeConversationPhase::Active
| RealtimeConversationPhase::Stopping
)
}
#[cfg(not(target_os = "linux"))]
pub(super) fn is_active(&self) -> bool {
matches!(self.phase, RealtimeConversationPhase::Active)
}
}
impl ChatWidget {
fn realtime_footer_hint_items() -> Vec<(String, String)> {
vec![("/realtime".to_string(), "stop live voice".to_string())]
}
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
self.request_realtime_conversation_close(/*info_message*/ None);
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
if self.realtime_conversation.is_live()
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
{
self.realtime_conversation.meter_placeholder_id = None;
self.stop_realtime_conversation_from_ui();
return true;
}
false
}
pub(super) fn start_realtime_conversation(&mut self) {
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.realtime_session_id = None;
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
match self.config.realtime.transport {
RealtimeTransport::Websocket => {
self.realtime_conversation.transport = RealtimeConversationUiTransport::Websocket;
self.submit_realtime_conversation_start(/*transport*/ None);
}
RealtimeTransport::WebRtc => {
self.realtime_conversation.transport =
RealtimeConversationUiTransport::Webrtc { handle: None };
start_realtime_webrtc_offer_task(self.app_event_tx.clone());
}
}
self.request_redraw();
}
fn submit_realtime_conversation_start(
&mut self,
transport: Option<ThreadRealtimeStartTransport>,
) {
self.submit_op(AppCommand::realtime_conversation_start(
transport,
self.config
.realtime
.voice
.and_then(|voice| serde_json::to_value(voice).ok()),
));
}
pub(super) fn request_realtime_conversation_close(&mut self, info_message: Option<String>) {
if !self.realtime_conversation.is_live() {
if let Some(message) = info_message {
self.add_info_message(message, /*hint*/ None);
}
return;
}
self.realtime_conversation.requested_close = true;
self.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
self.submit_op(AppCommand::realtime_conversation_close());
self.stop_realtime_local_audio();
self.close_realtime_webrtc_transport();
self.set_footer_hint_override(/*items*/ None);
if let Some(message) = info_message {
self.add_info_message(message, /*hint*/ None);
} else {
self.request_redraw();
}
}
pub(super) fn reset_realtime_conversation_state(&mut self) {
self.stop_realtime_local_audio();
self.close_realtime_webrtc_transport();
self.set_footer_hint_override(/*items*/ None);
self.realtime_conversation.phase = RealtimeConversationPhase::Inactive;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.realtime_session_id = None;
self.realtime_conversation.transport = RealtimeConversationUiTransport::Websocket;
}
fn fail_realtime_conversation(&mut self, message: String) {
self.add_error_message(message);
if self.realtime_conversation.is_live() {
self.request_realtime_conversation_close(/*info_message*/ None);
} else {
self.reset_realtime_conversation_state();
self.request_redraw();
}
}
pub(super) fn on_realtime_conversation_started(
&mut self,
notification: ThreadRealtimeStartedNotification,
) {
if !self.realtime_conversation_enabled() {
self.request_realtime_conversation_close(/*info_message*/ None);
return;
}
self.realtime_conversation.realtime_session_id = notification.realtime_session_id;
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
if self.realtime_conversation_uses_webrtc() {
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
} else {
self.realtime_conversation.phase = RealtimeConversationPhase::Active;
self.start_realtime_local_audio();
}
self.request_redraw();
}
pub(super) fn on_realtime_output_audio_delta(
&mut self,
notification: ThreadRealtimeOutputAudioDeltaNotification,
) {
if self.realtime_conversation_uses_webrtc() {
return;
}
self.enqueue_realtime_audio_out(&notification.audio);
}
pub(super) fn on_realtime_item_added(
&mut self,
notification: ThreadRealtimeItemAddedNotification,
) {
if self.realtime_conversation_uses_webrtc() {
return;
}
if matches!(
notification
.item
.get("type")
.and_then(|value| value.as_str()),
Some("input_audio_buffer.speech_started" | "response.cancelled")
) {
self.interrupt_realtime_audio_playback();
}
}
pub(super) fn on_realtime_error(&mut self, notification: ThreadRealtimeErrorNotification) {
self.fail_realtime_conversation(format!("Realtime voice error: {}", notification.message));
}
pub(super) fn on_realtime_conversation_closed(
&mut self,
notification: ThreadRealtimeClosedNotification,
) {
if self.realtime_conversation_uses_webrtc()
&& self.realtime_conversation.is_live()
&& notification.reason.as_deref() == Some("transport_closed")
{
return;
}
let requested = self.realtime_conversation.requested_close;
let reason = notification.reason;
self.reset_realtime_conversation_state();
if !requested
&& let Some(reason) = reason
&& reason != "error"
{
self.add_info_message(
format!("Realtime voice mode closed: {reason}"),
/*hint*/ None,
);
}
self.request_redraw();
}
pub(super) fn on_realtime_conversation_sdp(&mut self, sdp: String) {
let RealtimeConversationUiTransport::Webrtc {
handle: Some(handle),
} = &self.realtime_conversation.transport
else {
return;
};
if let Err(err) = handle.apply_answer_sdp(sdp) {
self.fail_realtime_conversation(format!("Failed to connect realtime WebRTC: {err}"));
}
}
pub(crate) fn on_realtime_webrtc_offer_created(
&mut self,
result: Result<crate::app_event::RealtimeWebrtcOffer, String>,
) {
if self.realtime_conversation.phase != RealtimeConversationPhase::Starting
|| !matches!(
self.realtime_conversation.transport,
RealtimeConversationUiTransport::Webrtc { handle: None }
)
{
return;
}
let offer = match result {
Ok(offer) => offer,
Err(err) => {
self.fail_realtime_conversation(format!("Failed to start realtime WebRTC: {err}"));
return;
}
};
self.realtime_conversation.transport = RealtimeConversationUiTransport::Webrtc {
handle: Some(offer.handle),
};
self.submit_realtime_conversation_start(Some(ThreadRealtimeStartTransport::Webrtc {
sdp: offer.offer_sdp,
}));
self.request_redraw();
}
pub(crate) fn on_realtime_webrtc_event(&mut self, event: RealtimeWebrtcEvent) {
if !self.realtime_conversation_uses_webrtc() {
return;
}
match event {
RealtimeWebrtcEvent::Connected => {
if self.realtime_conversation.phase != RealtimeConversationPhase::Starting {
return;
}
self.realtime_conversation.phase = RealtimeConversationPhase::Active;
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
self.request_redraw();
}
RealtimeWebrtcEvent::Closed => {
self.reset_realtime_conversation_state();
self.request_redraw();
}
RealtimeWebrtcEvent::Failed(message) => {
self.fail_realtime_conversation(format!("Realtime WebRTC error: {message}"));
}
RealtimeWebrtcEvent::LocalAudioLevel(_) => {}
}
}
pub(crate) fn on_realtime_webrtc_local_audio_level(&mut self, peak: u16) {
if !self.realtime_conversation_uses_webrtc() || peak == 0 {
return;
}
#[cfg(target_os = "linux")]
{
let _ = peak;
}
#[cfg(not(target_os = "linux"))]
{
let RealtimeConversationUiTransport::Webrtc {
handle: Some(handle),
} = &self.realtime_conversation.transport
else {
return;
};
let peak = handle.local_audio_peak();
if self.realtime_conversation.meter_placeholder_id.is_none() {
self.start_realtime_webrtc_meter(peak);
}
}
}
fn realtime_conversation_uses_webrtc(&self) -> bool {
matches!(
self.realtime_conversation.transport,
RealtimeConversationUiTransport::Webrtc { .. }
)
}
fn close_realtime_webrtc_transport(&mut self) {
if let RealtimeConversationUiTransport::Webrtc { handle } =
&mut self.realtime_conversation.transport
&& let Some(handle) = handle.take()
{
handle.close();
}
}
fn enqueue_realtime_audio_out(&mut self, frame: &ThreadRealtimeAudioChunk) {
#[cfg(not(target_os = "linux"))]
{
if self.realtime_conversation.audio_player.is_none() {
self.realtime_conversation.audio_player =
crate::voice::RealtimeAudioPlayer::start(&self.config).ok();
}
if let Some(player) = &self.realtime_conversation.audio_player
&& let Err(err) = player.enqueue_frame(frame)
{
warn!("failed to play realtime audio: {err}");
}
}
#[cfg(target_os = "linux")]
{
let _ = frame;
}
}
#[cfg(not(target_os = "linux"))]
fn interrupt_realtime_audio_playback(&mut self) {
if let Some(player) = &self.realtime_conversation.audio_player {
player.clear();
}
}
#[cfg(target_os = "linux")]
fn interrupt_realtime_audio_playback(&mut self) {}
#[cfg(not(target_os = "linux"))]
fn start_realtime_local_audio(&mut self) {
if self.realtime_conversation.capture_stop_flag.is_some() {
return;
}
let capture = match crate::voice::VoiceCapture::start_realtime(
&self.config,
self.app_event_tx.clone(),
) {
Ok(capture) => capture,
Err(err) => {
self.fail_realtime_conversation(format!(
"Failed to start microphone capture: {err}"
));
return;
}
};
let stop_flag = capture.stopped_flag();
let peak = capture.last_peak_arc();
self.start_realtime_meter(stop_flag.clone(), peak);
self.realtime_conversation.capture_stop_flag = Some(stop_flag);
self.realtime_conversation.capture = Some(capture);
if self.realtime_conversation.audio_player.is_none() {
self.realtime_conversation.audio_player =
crate::voice::RealtimeAudioPlayer::start(&self.config).ok();
}
}
#[cfg(not(target_os = "linux"))]
fn start_realtime_webrtc_meter(&mut self, peak: Arc<AtomicU16>) {
if self.realtime_conversation.capture_stop_flag.is_some() {
return;
}
let stop_flag = Arc::new(AtomicBool::new(false));
self.start_realtime_meter(stop_flag.clone(), peak);
self.realtime_conversation.capture_stop_flag = Some(stop_flag);
}
#[cfg(not(target_os = "linux"))]
fn start_realtime_meter(&mut self, stop_flag: Arc<AtomicBool>, peak: Arc<AtomicU16>) {
let placeholder_id = self.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤");
self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
self.request_redraw();
start_realtime_meter_task(placeholder_id, self.app_event_tx.clone(), stop_flag, peak);
}
#[cfg(target_os = "linux")]
fn start_realtime_local_audio(&mut self) {}
#[cfg(not(target_os = "linux"))]
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
if !self.realtime_conversation.is_active() {
return;
}
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.stop_realtime_microphone();
self.start_realtime_local_audio();
}
RealtimeAudioDeviceKind::Speaker => {
self.stop_realtime_speaker();
match crate::voice::RealtimeAudioPlayer::start(&self.config) {
Ok(player) => {
self.realtime_conversation.audio_player = Some(player);
}
Err(err) => {
self.fail_realtime_conversation(format!(
"Failed to start speaker output: {err}"
));
}
}
}
}
self.request_redraw();
}
#[cfg(target_os = "linux")]
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
let _ = kind;
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_local_audio(&mut self) {
self.stop_realtime_microphone();
self.stop_realtime_speaker();
}
#[cfg(target_os = "linux")]
fn stop_realtime_local_audio(&mut self) {}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_microphone(&mut self) {
if let Some(flag) = self.realtime_conversation.capture_stop_flag.take() {
flag.store(true, Ordering::Relaxed);
}
if let Some(capture) = self.realtime_conversation.capture.take() {
capture.stop();
}
if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() {
self.remove_recording_meter_placeholder(&id);
}
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_speaker(&mut self) {
if let Some(player) = self.realtime_conversation.audio_player.take() {
player.clear();
}
}
}
fn start_realtime_webrtc_offer_task(app_event_tx: AppEventSender) {
std::thread::spawn(move || {
let result = match RealtimeWebrtcSession::start() {
Ok(started) => {
let event_tx = app_event_tx.clone();
let local_audio_peak = started.handle.local_audio_peak();
std::thread::spawn(move || {
for event in started.events {
if let RealtimeWebrtcEvent::LocalAudioLevel(peak) = event {
local_audio_peak.store(peak, Ordering::Relaxed);
event_tx.send(AppEvent::RealtimeWebrtcLocalAudioLevel(peak));
} else {
event_tx.send(AppEvent::RealtimeWebrtcEvent(event));
}
}
});
Ok(crate::app_event::RealtimeWebrtcOffer {
offer_sdp: started.offer_sdp,
handle: started.handle,
})
}
Err(err) => Err(err.to_string()),
};
app_event_tx.send(AppEvent::RealtimeWebrtcOfferCreated { result });
});
}
#[cfg(not(target_os = "linux"))]
fn start_realtime_meter_task(
meter_placeholder_id: String,
app_event_tx: AppEventSender,
stop_flag: Arc<AtomicBool>,
peak: Arc<AtomicU16>,
) {
std::thread::spawn(move || {
let mut meter = crate::voice::RecordingMeterState::new();
loop {
if stop_flag.load(Ordering::Relaxed) {
break;
}
let meter_text = meter.next_text(peak.load(Ordering::Relaxed));
app_event_tx.send(AppEvent::UpdateRecordingMeter {
id: meter_placeholder_id.clone(),
text: meter_text,
});
std::thread::sleep(Duration::from_millis(60));
}
});
}
-45
View File
@@ -75,18 +75,6 @@ impl ChatWidget {
);
}
let enabled = self.config.features.enabled(feature);
if feature == Feature::RealtimeConversation {
let realtime_conversation_enabled = self.realtime_conversation_enabled();
self.bottom_pane
.set_realtime_conversation_enabled(realtime_conversation_enabled);
self.bottom_pane
.set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled());
if !realtime_conversation_enabled && self.realtime_conversation.is_live() {
self.request_realtime_conversation_close(Some(
"Realtime voice mode was closed because the feature was disabled.".to_string(),
));
}
}
if feature == Feature::FastMode {
self.refresh_effective_service_tier();
self.sync_service_tier_commands();
@@ -231,17 +219,6 @@ impl ChatWidget {
.set_connectors_enabled(self.connectors_enabled());
}
pub(crate) fn set_realtime_audio_device(
&mut self,
kind: RealtimeAudioDeviceKind,
name: Option<String>,
) {
match kind {
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name,
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name,
}
}
/// Set the syntax theme override in the widget's config copy.
pub(crate) fn set_tui_theme(&mut self, theme: Option<String>) {
self.config.tui_theme = theme;
@@ -273,28 +250,6 @@ impl ChatWidget {
.unwrap_or_else(|| self.current_collaboration_mode.model())
}
pub(crate) fn realtime_conversation_is_live(&self) -> bool {
self.realtime_conversation.is_live()
}
pub(super) fn current_realtime_audio_device_name(
&self,
kind: RealtimeAudioDeviceKind,
) -> Option<String> {
match kind {
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(),
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(),
}
}
pub(super) fn current_realtime_audio_selection_label(
&self,
kind: RealtimeAudioDeviceKind,
) -> String {
self.current_realtime_audio_device_name(kind)
.unwrap_or_else(|| "System default".to_string())
}
pub(super) fn sync_personality_command_enabled(&mut self) {
self.bottom_pane
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
+2 -157
View File
@@ -1,7 +1,7 @@
//! Settings-adjacent popup surfaces for `ChatWidget`.
//!
//! This keeps theme, personality, audio-device, and experimental-feature UI
//! out of the main orchestration module without changing their event wiring.
//! This keeps theme, personality, and experimental-feature UI out of the main
//! orchestration module without changing their event wiring.
use super::*;
@@ -90,161 +90,6 @@ impl ChatWidget {
});
}
pub(crate) fn open_realtime_audio_popup(&mut self) {
let items = [
RealtimeAudioDeviceKind::Microphone,
RealtimeAudioDeviceKind::Speaker,
]
.into_iter()
.map(|kind| {
let description = Some(format!(
"Current: {}",
self.current_realtime_audio_selection_label(kind)
));
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind });
})];
SelectionItem {
name: kind.title().to_string(),
description,
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Settings".to_string()),
subtitle: Some("Configure settings for Codex.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
match list_realtime_audio_device_names(kind) {
Ok(device_names) => {
self.open_realtime_audio_device_selection_with_names(kind, device_names);
}
Err(err) => {
self.add_error_message(format!(
"Failed to load realtime {} devices: {err}",
kind.noun()
));
}
}
}
#[cfg(target_os = "linux")]
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
let _ = kind;
}
#[cfg(not(target_os = "linux"))]
pub(super) fn open_realtime_audio_device_selection_with_names(
&mut self,
kind: RealtimeAudioDeviceKind,
device_names: Vec<String>,
) {
let current_selection = self.current_realtime_audio_device_name(kind);
let current_available = current_selection
.as_deref()
.is_some_and(|name| device_names.iter().any(|device_name| device_name == name));
let mut items = vec![SelectionItem {
name: "System default".to_string(),
description: Some("Use your operating system default device.".to_string()),
is_current: current_selection.is_none(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None });
})],
dismiss_on_select: true,
..Default::default()
}];
if let Some(selection) = current_selection.as_deref()
&& !current_available
{
items.push(SelectionItem {
name: format!("Unavailable: {selection}"),
description: Some("Configured device is not currently available.".to_string()),
is_current: true,
is_disabled: true,
disabled_reason: Some("Reconnect the device or choose another one.".to_string()),
..Default::default()
});
}
items.extend(device_names.into_iter().map(|device_name| {
let persisted_name = device_name.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection {
kind,
name: Some(persisted_name.clone()),
});
})];
SelectionItem {
is_current: current_selection.as_deref() == Some(device_name.as_str()),
name: device_name,
actions,
dismiss_on_select: true,
..Default::default()
}
}));
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Select {}", kind.title()).bold()));
header.push(Line::from(
"Saved devices apply to realtime voice only.".dim(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) {
let restart_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::RestartRealtimeAudioDevice { kind });
})];
let items = vec![
SelectionItem {
name: "Restart now".to_string(),
description: Some(format!("Restart local {} audio now.", kind.noun())),
actions: restart_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Apply later".to_string(),
description: Some(format!(
"Keep the current {} until local audio starts again.",
kind.noun()
)),
dismiss_on_select: true,
..Default::default()
},
];
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Restart {} now?", kind.title()).bold()));
header.push(Line::from(
"Configuration is saved. Restart local audio to use it immediately.".dim(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_experimental_popup(&mut self) {
let features: Vec<ExperimentalFeatureItem> = FEATURES
.iter()
@@ -261,22 +261,6 @@ impl ChatWidget {
SlashCommand::Model => {
self.open_model_popup();
}
SlashCommand::Realtime => {
if !self.realtime_conversation_enabled() {
return;
}
if self.realtime_conversation.is_live() {
self.stop_realtime_conversation_from_ui();
} else {
self.start_realtime_conversation();
}
}
SlashCommand::Settings => {
if !self.realtime_audio_device_selection_enabled() {
return;
}
self.open_realtime_audio_popup();
}
SlashCommand::Personality => {
self.open_personality_popup();
}
@@ -962,8 +946,6 @@ impl ChatWidget {
goal_command_enabled: self.config.features.enabled(Feature::Goals),
service_tier_commands_enabled: self.fast_mode_enabled(),
personality_command_enabled: self.config.features.enabled(Feature::Personality),
realtime_conversation_enabled: self.realtime_conversation_enabled(),
audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(),
allow_elevate_sandbox,
side_conversation_active: self.active_side_conversation,
}
@@ -1003,8 +985,6 @@ impl ChatWidget {
| SlashCommand::Compact
| SlashCommand::Review
| SlashCommand::Model
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::Personality
| SlashCommand::Plan
| SlashCommand::Goal
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Settings
Configure settings for Codex.
1. Microphone Current: System default
2. Speaker Current: System default
Press enter to confirm or esc to go back
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Settings
Configure settings for Codex.
1. Microphone Current: System default
2. Speaker Current: System default
Press enter to confirm or esc to go back
@@ -1,18 +0,0 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Select Microphone
Saved devices apply to realtime voice only.
1. System default Use your operating system
default device.
Unavailable: Studio Mic (current) (disabled) Configured device is not
currently available.
(disabled: Reconnect the
device or choose another
one.)
2. Built-in Mic
3. USB Mic
Press enter to confirm or esc to go back
-5
View File
@@ -8,15 +8,12 @@ pub(super) use super::*;
pub(super) use crate::app_command::AppCommand as Op;
pub(super) use crate::app_event::AppEvent;
pub(super) use crate::app_event::ExitMode;
#[cfg(not(target_os = "linux"))]
pub(super) use crate::app_event::RealtimeAudioDeviceKind;
pub(super) use crate::app_event_sender::AppEventSender;
pub(super) use crate::approval_events::ApplyPatchApprovalRequestEvent;
pub(super) use crate::approval_events::ExecApprovalRequestEvent;
pub(super) use crate::bottom_pane::LocalImageAttachment;
pub(super) use crate::bottom_pane::MentionBinding;
pub(super) use crate::bottom_pane::QueuedInputAction;
pub(super) use crate::chatwidget::realtime::RealtimeConversationPhase;
pub(super) use crate::diff_model::FileChange;
pub(super) use crate::history_cell::UserHistoryCell;
pub(super) use crate::legacy_core::config::Config;
@@ -104,8 +101,6 @@ pub(super) use codex_app_server_protocol::ServerNotification;
pub(super) use codex_app_server_protocol::SkillSummary;
pub(super) use codex_app_server_protocol::ThreadClosedNotification;
pub(super) use codex_app_server_protocol::ThreadItem as AppServerThreadItem;
pub(super) use codex_app_server_protocol::ThreadRealtimeClosedNotification;
pub(super) use codex_app_server_protocol::ThreadRealtimeErrorNotification;
pub(super) use codex_app_server_protocol::ToolRequestUserInputOption;
pub(super) use codex_app_server_protocol::ToolRequestUserInputParams;
pub(super) use codex_app_server_protocol::ToolRequestUserInputQuestion;
@@ -217,21 +217,6 @@ pub(super) fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver
}
}
pub(super) fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
loop {
match op_rx.try_recv() {
Ok(Op::RealtimeConversationClose) => return,
Ok(_) => continue,
Err(TryRecvError::Empty) => {
panic!("expected realtime close op but queue was empty")
}
Err(TryRecvError::Disconnected) => {
panic!("expected realtime close op but channel closed")
}
}
}
}
pub(super) fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
while let Ok(op) = op_rx.try_recv() {
assert!(
@@ -10,47 +10,6 @@ use codex_app_server_protocol::PluginAvailability;
use codex_features::Stage;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn realtime_error_closes_without_followup_closed_info() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
chat.on_realtime_error(ThreadRealtimeErrorNotification {
thread_id: ThreadId::new().to_string(),
message: "boom".to_string(),
});
next_realtime_close_op(&mut op_rx);
chat.on_realtime_conversation_closed(ThreadRealtimeClosedNotification {
thread_id: ThreadId::new().to_string(),
reason: Some("error".to_string()),
});
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<Vec<_>>();
insta::assert_snapshot!(rendered.join("\n\n"), @"■ Realtime voice error: boom");
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn deleted_realtime_meter_uses_shared_stop_path() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
let placeholder_id = chat.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤");
chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id));
next_realtime_close_op(&mut op_rx);
assert_eq!(chat.realtime_conversation.meter_placeholder_id, None);
assert_eq!(
chat.realtime_conversation.phase,
RealtimeConversationPhase::Stopping
);
}
#[tokio::test]
async fn experimental_mode_plan_is_ignored_on_startup() {
let codex_home = tempdir().expect("tempdir");
@@ -2616,62 +2575,6 @@ async fn personality_selection_popup_snapshot() {
assert_chatwidget_snapshot!("personality_selection_popup", popup);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn realtime_audio_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.open_realtime_audio_popup();
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("realtime_audio_selection_popup", popup);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn realtime_audio_selection_popup_narrow_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.open_realtime_audio_popup();
let popup = render_bottom_popup(&chat, /*width*/ 56);
assert_chatwidget_snapshot!("realtime_audio_selection_popup_narrow", popup);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn realtime_microphone_picker_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.config.realtime_audio.microphone = Some("Studio Mic".to_string());
chat.open_realtime_audio_device_selection_with_names(
RealtimeAudioDeviceKind::Microphone,
vec!["Built-in Mic".to_string(), "USB Mic".to_string()],
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("realtime_microphone_picker_popup", popup);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn realtime_audio_picker_emits_persist_event() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.open_realtime_audio_device_selection_with_names(
RealtimeAudioDeviceKind::Speaker,
vec!["Desk Speakers".to_string(), "Headphones".to_string()],
);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind::Speaker,
name: Some(name),
}) if name == "Headphones"
);
}
#[tokio::test]
async fn model_picker_hides_show_in_picker_false_models_from_cache() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await;
@@ -1004,25 +1004,6 @@ async fn ctrl_c_interrupts_without_arming_quit_when_double_press_disabled() {
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn ctrl_c_closes_realtime_conversation_before_interrupt_or_quit() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
chat.bottom_pane
.set_composer_text("recording meter".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
next_realtime_close_op(&mut op_rx);
assert_eq!(
chat.realtime_conversation.phase,
RealtimeConversationPhase::Stopping
);
assert_eq!(chat.bottom_pane.composer_text(), "recording meter");
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
-75
View File
@@ -94,22 +94,6 @@ mod app_server_approval_conversions;
mod app_server_session;
mod approval_events;
mod ascii_animation;
#[cfg(not(target_os = "linux"))]
mod audio_device;
#[cfg(target_os = "linux")]
#[allow(dead_code)]
mod audio_device {
use crate::app_event::RealtimeAudioDeviceKind;
pub(crate) fn list_realtime_audio_device_names(
kind: RealtimeAudioDeviceKind,
) -> Result<Vec<String>, String> {
Err(format!(
"Failed to load realtime {} devices: voice input is unavailable in this build",
kind.noun()
))
}
}
mod bottom_pane;
mod branch_summary;
mod chatwidget;
@@ -210,69 +194,10 @@ mod updates;
#[cfg(any(not(debug_assertions), test))]
mod updates_cache;
mod version;
#[cfg(not(target_os = "linux"))]
mod voice;
mod width;
#[cfg(any(target_os = "windows", test))]
mod windows_sandbox;
mod workspace_command;
#[cfg(target_os = "linux")]
#[allow(dead_code)]
mod voice {
use crate::app_event_sender::AppEventSender;
use crate::legacy_core::config::Config;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
pub struct VoiceCapture;
pub(crate) struct RecordingMeterState;
pub(crate) struct RealtimeAudioPlayer;
impl VoiceCapture {
pub fn start_realtime(_config: &Config, _tx: AppEventSender) -> Result<Self, String> {
Err("voice input is unavailable in this build".to_string())
}
pub fn stop(self) {}
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(true))
}
pub fn last_peak_arc(&self) -> Arc<AtomicU16> {
Arc::new(AtomicU16::new(0))
}
}
impl RecordingMeterState {
pub(crate) fn new() -> Self {
Self
}
pub(crate) fn next_text(&mut self, _peak: u16) -> String {
"⠤⠤⠤⠤".to_string()
}
}
impl RealtimeAudioPlayer {
pub(crate) fn start(_config: &Config) -> Result<Self, String> {
Err("voice output is unavailable in this build".to_string())
}
pub(crate) fn enqueue_frame(
&self,
_frame: &ThreadRealtimeAudioChunk,
) -> Result<(), String> {
Err("voice output is unavailable in this build".to_string())
}
pub(crate) fn clear(&self) {}
}
}
mod wrapping;
-6
View File
@@ -67,8 +67,6 @@ pub enum SlashCommand {
Stop,
Clear,
Personality,
Realtime,
Settings,
TestApproval,
#[strum(serialize = "subagents")]
MultiAgents,
@@ -118,8 +116,6 @@ impl SlashCommand {
"include current selection, open files, and other context from your IDE"
}
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
SlashCommand::Settings => "configure realtime microphone/speaker",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Goal => "set or view the goal for a long-running task",
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
@@ -237,8 +233,6 @@ impl SlashCommand {
| SlashCommand::Btw => true,
SlashCommand::Rollout => true,
SlashCommand::TestApproval => true,
SlashCommand::Realtime => true,
SlashCommand::Settings => true,
SlashCommand::Agent | SlashCommand::MultiAgents => true,
SlashCommand::Theme | SlashCommand::Pets => false,
}
-486
View File
@@ -1,486 +0,0 @@
use crate::app_event_sender::AppEventSender;
use crate::legacy_core::config::Config;
use base64::Engine;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use cpal::traits::DeviceTrait;
use cpal::traits::StreamTrait;
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
use std::sync::atomic::Ordering;
use tracing::error;
const MODEL_AUDIO_SAMPLE_RATE: u32 = 24_000;
const MODEL_AUDIO_CHANNELS: u16 = 1;
pub struct VoiceCapture {
stream: Option<cpal::Stream>,
stopped: Arc<AtomicBool>,
last_peak: Arc<AtomicU16>,
}
impl VoiceCapture {
pub fn start_realtime(config: &Config, tx: AppEventSender) -> Result<Self, String> {
let (device, config) = select_realtime_input_device_and_config(config)?;
let sample_rate = config.sample_rate().0;
let channels = config.channels();
let stopped = Arc::new(AtomicBool::new(false));
let last_peak = Arc::new(AtomicU16::new(0));
let stream = build_realtime_input_stream(
&device,
&config,
sample_rate,
channels,
tx,
last_peak.clone(),
)?;
stream
.play()
.map_err(|e| format!("failed to start input stream: {e}"))?;
Ok(Self {
stream: Some(stream),
stopped,
last_peak,
})
}
pub fn stop(mut self) {
// Mark stopped so any metering task can exit cleanly.
self.stopped.store(true, Ordering::SeqCst);
// Dropping the stream stops capture.
self.stream.take();
}
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
self.stopped.clone()
}
pub fn last_peak_arc(&self) -> Arc<AtomicU16> {
self.last_peak.clone()
}
}
pub(crate) struct RecordingMeterState {
history: VecDeque<char>,
noise_ema: f64,
env: f64,
}
impl RecordingMeterState {
pub(crate) fn new() -> Self {
let mut history = VecDeque::with_capacity(4);
while history.len() < 4 {
history.push_back('⠤');
}
Self {
history,
noise_ema: 0.02,
env: 0.0,
}
}
pub(crate) fn next_text(&mut self, peak: u16) -> String {
const SYMBOLS: [char; 7] = ['⠤', '⠴', '⠶', '⠷', '⡷', '⡿', '⣿'];
const ALPHA_NOISE: f64 = 0.05;
const ATTACK: f64 = 0.80;
const RELEASE: f64 = 0.25;
let latest_peak = peak as f64 / (i16::MAX as f64);
if latest_peak > self.env {
self.env = ATTACK * latest_peak + (1.0 - ATTACK) * self.env;
} else {
self.env = RELEASE * latest_peak + (1.0 - RELEASE) * self.env;
}
let rms_approx = self.env * 0.7;
self.noise_ema = (1.0 - ALPHA_NOISE) * self.noise_ema + ALPHA_NOISE * rms_approx;
let ref_level = self.noise_ema.max(0.01);
let fast_signal = 0.8 * latest_peak + 0.2 * self.env;
let target = 2.0f64;
let raw = (fast_signal / (ref_level * target)).max(0.0);
let k = 1.6f64;
let compressed = (raw.ln_1p() / k.ln_1p()).min(1.0);
let idx = (compressed * (SYMBOLS.len() as f64 - 1.0))
.round()
.clamp(0.0, SYMBOLS.len() as f64 - 1.0) as usize;
let level_char = SYMBOLS[idx];
if self.history.len() >= 4 {
self.history.pop_front();
}
self.history.push_back(level_char);
let mut text = String::with_capacity(4);
for ch in &self.history {
text.push(*ch);
}
text
}
}
// -------------------------
// Voice input helpers
// -------------------------
fn select_realtime_input_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
crate::audio_device::select_configured_input_device_and_config(config)
}
fn build_realtime_input_stream(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
sample_rate: u32,
channels: u16,
tx: AppEventSender,
last_peak: Arc<AtomicU16>,
) -> Result<cpal::Stream, String> {
match config.sample_format() {
cpal::SampleFormat::F32 => device
.build_input_stream(
&config.clone().into(),
move |input: &[f32], _| {
let peak = peak_f32(input);
last_peak.store(peak, Ordering::Relaxed);
let samples = input.iter().copied().map(f32_to_i16).collect::<Vec<_>>();
send_realtime_audio_chunk(&tx, samples, sample_rate, channels);
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
cpal::SampleFormat::I16 => device
.build_input_stream(
&config.clone().into(),
move |input: &[i16], _| {
let peak = peak_i16(input);
last_peak.store(peak, Ordering::Relaxed);
send_realtime_audio_chunk(&tx, input.to_vec(), sample_rate, channels);
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
cpal::SampleFormat::U16 => device
.build_input_stream(
&config.clone().into(),
move |input: &[u16], _| {
let mut samples = Vec::with_capacity(input.len());
let peak = convert_u16_to_i16_and_peak(input, &mut samples);
last_peak.store(peak, Ordering::Relaxed);
send_realtime_audio_chunk(&tx, samples, sample_rate, channels);
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
_ => Err("unsupported input sample format".to_string()),
}
}
fn send_realtime_audio_chunk(
tx: &AppEventSender,
samples: Vec<i16>,
sample_rate: u32,
channels: u16,
) {
if samples.is_empty() || sample_rate == 0 || channels == 0 {
return;
}
let samples = if sample_rate == MODEL_AUDIO_SAMPLE_RATE && channels == MODEL_AUDIO_CHANNELS {
samples
} else {
convert_pcm16(
&samples,
sample_rate,
channels,
MODEL_AUDIO_SAMPLE_RATE,
MODEL_AUDIO_CHANNELS,
)
};
if samples.is_empty() {
return;
}
let mut bytes = Vec::with_capacity(samples.len() * 2);
for sample in &samples {
bytes.extend_from_slice(&sample.to_le_bytes());
}
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
let samples_per_channel = (samples.len() / usize::from(MODEL_AUDIO_CHANNELS)) as u32;
tx.realtime_conversation_audio(ThreadRealtimeAudioChunk {
data: encoded,
sample_rate: MODEL_AUDIO_SAMPLE_RATE,
num_channels: MODEL_AUDIO_CHANNELS,
samples_per_channel: Some(samples_per_channel),
item_id: None,
});
}
#[inline]
fn f32_abs_to_u16(x: f32) -> u16 {
let peak_u = (x.abs().min(1.0) * i16::MAX as f32) as i32;
peak_u.max(0) as u16
}
#[inline]
fn f32_to_i16(s: f32) -> i16 {
(s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
}
fn peak_f32(input: &[f32]) -> u16 {
let mut peak: f32 = 0.0;
for &s in input {
let a = s.abs();
if a > peak {
peak = a;
}
}
f32_abs_to_u16(peak)
}
fn peak_i16(input: &[i16]) -> u16 {
let mut peak: i32 = 0;
for &s in input {
let a = (s as i32).unsigned_abs() as i32;
if a > peak {
peak = a;
}
}
peak as u16
}
fn convert_u16_to_i16_and_peak(input: &[u16], out: &mut Vec<i16>) -> u16 {
let mut peak: i32 = 0;
for &s in input {
let v_i16 = (s as i32 - 32768) as i16;
let a = (v_i16 as i32).unsigned_abs() as i32;
if a > peak {
peak = a;
}
out.push(v_i16);
}
peak as u16
}
// -------------------------
// Realtime audio playback helpers
// -------------------------
pub(crate) struct RealtimeAudioPlayer {
_stream: cpal::Stream,
queue: Arc<Mutex<VecDeque<i16>>>,
output_sample_rate: u32,
output_channels: u16,
}
impl RealtimeAudioPlayer {
pub(crate) fn start(config: &Config) -> Result<Self, String> {
let (device, config) =
crate::audio_device::select_configured_output_device_and_config(config)?;
let output_sample_rate = config.sample_rate().0;
let output_channels = config.channels();
let queue = Arc::new(Mutex::new(VecDeque::new()));
let stream = build_output_stream(&device, &config, Arc::clone(&queue))?;
stream
.play()
.map_err(|e| format!("failed to start output stream: {e}"))?;
Ok(Self {
_stream: stream,
queue,
output_sample_rate,
output_channels,
})
}
pub(crate) fn enqueue_frame(&self, frame: &ThreadRealtimeAudioChunk) -> Result<(), String> {
if frame.num_channels == 0 || frame.sample_rate == 0 {
return Err("invalid realtime audio frame format".to_string());
}
let raw_bytes = base64::engine::general_purpose::STANDARD
.decode(&frame.data)
.map_err(|e| format!("failed to decode realtime audio: {e}"))?;
if raw_bytes.len() % 2 != 0 {
return Err("realtime audio frame had odd byte length".to_string());
}
let mut pcm = Vec::with_capacity(raw_bytes.len() / 2);
for pair in raw_bytes.chunks_exact(2) {
pcm.push(i16::from_le_bytes([pair[0], pair[1]]));
}
let converted = convert_pcm16(
&pcm,
frame.sample_rate,
frame.num_channels,
self.output_sample_rate,
self.output_channels,
);
if converted.is_empty() {
return Ok(());
}
let mut guard = self
.queue
.lock()
.map_err(|_| "failed to lock output audio queue".to_string())?;
// TODO(aibrahim): Cap or trim this queue if we observe producer bursts outrunning playback.
guard.extend(converted);
Ok(())
}
pub(crate) fn clear(&self) {
if let Ok(mut guard) = self.queue.lock() {
guard.clear();
}
}
}
fn build_output_stream(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
queue: Arc<Mutex<VecDeque<i16>>>,
) -> Result<cpal::Stream, String> {
let config_any: cpal::StreamConfig = config.clone().into();
match config.sample_format() {
cpal::SampleFormat::F32 => device
.build_output_stream(
&config_any,
move |output: &mut [f32], _| fill_output_f32(output, &queue),
move |err| error!("audio output error: {err}"),
None,
)
.map_err(|e| format!("failed to build f32 output stream: {e}")),
cpal::SampleFormat::I16 => device
.build_output_stream(
&config_any,
move |output: &mut [i16], _| fill_output_i16(output, &queue),
move |err| error!("audio output error: {err}"),
None,
)
.map_err(|e| format!("failed to build i16 output stream: {e}")),
cpal::SampleFormat::U16 => device
.build_output_stream(
&config_any,
move |output: &mut [u16], _| fill_output_u16(output, &queue),
move |err| error!("audio output error: {err}"),
None,
)
.map_err(|e| format!("failed to build u16 output stream: {e}")),
other => Err(format!("unsupported output sample format: {other:?}")),
}
}
fn fill_output_i16(output: &mut [i16], queue: &Arc<Mutex<VecDeque<i16>>>) {
if let Ok(mut guard) = queue.lock() {
for sample in output {
*sample = guard.pop_front().unwrap_or(0);
}
return;
}
output.fill(0);
}
fn fill_output_f32(output: &mut [f32], queue: &Arc<Mutex<VecDeque<i16>>>) {
if let Ok(mut guard) = queue.lock() {
for sample in output {
let v = guard.pop_front().unwrap_or(0);
*sample = (v as f32) / (i16::MAX as f32);
}
return;
}
output.fill(0.0);
}
fn fill_output_u16(output: &mut [u16], queue: &Arc<Mutex<VecDeque<i16>>>) {
if let Ok(mut guard) = queue.lock() {
for sample in output {
let v = guard.pop_front().unwrap_or(0);
*sample = (v as i32 + 32768).clamp(0, u16::MAX as i32) as u16;
}
return;
}
output.fill(32768);
}
fn convert_pcm16(
input: &[i16],
input_sample_rate: u32,
input_channels: u16,
output_sample_rate: u32,
output_channels: u16,
) -> Vec<i16> {
if input.is_empty() || input_channels == 0 || output_channels == 0 {
return Vec::new();
}
let in_channels = input_channels as usize;
let out_channels = output_channels as usize;
let in_frames = input.len() / in_channels;
if in_frames == 0 {
return Vec::new();
}
let out_frames = if input_sample_rate == output_sample_rate {
in_frames
} else {
(((in_frames as u64) * (output_sample_rate as u64)) / (input_sample_rate as u64)).max(1)
as usize
};
let mut out = Vec::with_capacity(out_frames.saturating_mul(out_channels));
for out_frame_idx in 0..out_frames {
let src_frame_idx = if out_frames <= 1 || in_frames <= 1 {
0
} else {
((out_frame_idx as u64) * ((in_frames - 1) as u64) / ((out_frames - 1) as u64)) as usize
};
let src_start = src_frame_idx.saturating_mul(in_channels);
let src = &input[src_start..src_start + in_channels];
match (in_channels, out_channels) {
(1, 1) => out.push(src[0]),
(1, n) => {
for _ in 0..n {
out.push(src[0]);
}
}
(n, 1) if n >= 2 => {
let sum: i32 = src.iter().map(|s| *s as i32).sum();
out.push((sum / (n as i32)) as i16);
}
(n, m) if n == m => out.extend_from_slice(src),
(n, m) if n > m => out.extend_from_slice(&src[..m]),
(n, m) => {
out.extend_from_slice(src);
let last = *src.last().unwrap_or(&0);
for _ in n..m {
out.push(last);
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::convert_pcm16;
use pretty_assertions::assert_eq;
#[test]
fn convert_pcm16_downmixes_and_resamples_for_model_input() {
let input = vec![100, 300, 200, 400, 500, 700, 600, 800];
let converted = convert_pcm16(
&input, /*input_sample_rate*/ 48_000, /*input_channels*/ 2,
/*output_sample_rate*/ 24_000, /*output_channels*/ 1,
);
assert_eq!(converted, vec![200, 700]);
}
}