Add hidden /realtime TUI conversation mode

Add a hidden under-development realtime_conversation feature and /realtime slash command gating.
Wire TUI realtime audio I/O, lifecycle handling, and live delegated user-message rendering during realtime mode.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed Ibrahim
2026-02-23 10:10:06 -08:00
co-authored by Codex
parent d5fef5c190
commit 7e0bcc0812
15 changed files with 1208 additions and 6 deletions
+22
View File
File diff suppressed because one or more lines are too long
+243
View File
@@ -291,6 +291,28 @@ 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"
@@ -861,6 +883,24 @@ dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.1",
"shlex",
"syn 2.0.114",
]
[[package]]
name = "bit-set"
version = "0.5.3"
@@ -1098,6 +1138,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -1182,6 +1231,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.5.58"
@@ -2280,6 +2340,7 @@ dependencies = [
"codex-utils-sleep-inhibitor",
"codex-windows-sandbox",
"color-eyre",
"cpal",
"crossterm",
"derive_more 2.1.1",
"diffy",
@@ -2715,6 +2776,49 @@ dependencies = [
"zstd",
]
[[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",
"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"
@@ -3015,6 +3119,12 @@ dependencies = [
"syn 2.0.114",
]
[[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"
@@ -4053,6 +4163,12 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
@@ -5127,6 +5243,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -5318,6 +5444,15 @@ 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"
@@ -5501,12 +5636,35 @@ 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",
"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",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -5694,6 +5852,17 @@ 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.114",
]
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -5745,6 +5914,28 @@ 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.114",
]
[[package]]
name = "num_threads"
version = "0.1.7"
@@ -5954,6 +6145,29 @@ dependencies = [
"memchr",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni",
"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"
@@ -10384,6 +10598,16 @@ 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"
@@ -10415,6 +10639,16 @@ 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"
@@ -10523,6 +10757,15 @@ 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
@@ -161,6 +161,7 @@ clap_complete = "4"
color-eyre = "0.6.3"
crossbeam-channel = "0.5.15"
crossterm = "0.28.1"
cpal = "0.15.3"
ctor = "0.6.3"
derive_more = "2"
diffy = "0.4.2"
+6
View File
@@ -364,6 +364,9 @@
"prevent_idle_sleep": {
"type": "boolean"
},
"realtime_conversation": {
"type": "boolean"
},
"remote_models": {
"type": "boolean"
},
@@ -1642,6 +1645,9 @@
"prevent_idle_sleep": {
"type": "boolean"
},
"realtime_conversation": {
"type": "boolean"
},
"remote_models": {
"type": "boolean"
},
+8
View File
@@ -143,6 +143,8 @@ pub enum Feature {
ResponsesWebsockets,
/// Enable Responses API websocket v2 mode.
ResponsesWebsocketsV2,
/// Enable hidden TUI `/realtime` low-level realtime conversation controls.
RealtimeConversation,
}
impl Feature {
@@ -652,6 +654,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::RealtimeConversation,
key: "realtime_conversation",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
];
/// Push a warning event if any under-development features are enabled.
+1
View File
@@ -50,6 +50,7 @@ codex-utils-sandbox-summary = { workspace = true }
codex-utils-sleep-inhibitor = { workspace = true }
color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
cpal = { workspace = true }
derive_more = { workspace = true, features = ["is_variant"] }
diffy = { workspace = true }
dirs = { workspace = true }
@@ -333,6 +333,7 @@ pub(crate) struct ChatComposer {
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_command_enabled: bool,
windows_degraded_sandbox_active: bool,
status_line_value: Option<Line<'static>>,
status_line_enabled: bool,
@@ -433,6 +434,7 @@ impl ChatComposer {
collaboration_mode_indicator: None,
connectors_enabled: false,
personality_command_enabled: false,
realtime_command_enabled: false,
windows_degraded_sandbox_active: false,
status_line_value: None,
status_line_enabled: false,
@@ -505,6 +507,10 @@ impl ChatComposer {
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
self.personality_command_enabled = enabled;
}
pub fn set_realtime_command_enabled(&mut self, enabled: bool) {
self.realtime_command_enabled = enabled;
}
/// Centralized feature gating keeps config checks out of call sites.
fn popups_enabled(&self) -> bool {
self.config.popups_enabled
@@ -2100,6 +2106,7 @@ impl ChatComposer {
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_command_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
@@ -2299,6 +2306,7 @@ impl ChatComposer {
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_command_enabled,
self.windows_degraded_sandbox_active,
)
{
@@ -2333,6 +2341,7 @@ impl ChatComposer {
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_command_enabled,
self.windows_degraded_sandbox_active,
)?;
@@ -3030,6 +3039,7 @@ impl ChatComposer {
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_command_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
@@ -3091,6 +3101,7 @@ impl ChatComposer {
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_command_enabled,
self.windows_degraded_sandbox_active,
) {
return true;
@@ -3144,12 +3155,14 @@ impl ChatComposer {
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let connectors_enabled = self.connectors_enabled;
let personality_command_enabled = self.personality_command_enabled;
let realtime_command_enabled = self.realtime_command_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
realtime_command_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
},
);
@@ -39,6 +39,7 @@ pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_command_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
}
@@ -49,6 +50,7 @@ impl CommandPopup {
flags.collaboration_modes_enabled,
flags.connectors_enabled,
flags.personality_command_enabled,
flags.realtime_command_enabled,
flags.windows_degraded_sandbox_active,
)
.into_iter()
@@ -494,6 +496,7 @@ mod tests {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
realtime_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -513,6 +516,7 @@ mod tests {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
realtime_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -532,6 +536,7 @@ mod tests {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: false,
realtime_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -559,6 +564,7 @@ mod tests {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
realtime_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -570,6 +576,54 @@ mod tests {
}
}
#[test]
fn realtime_command_hidden_when_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
realtime_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/rea".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"realtime"),
"expected '/realtime' to be hidden when disabled, got {cmds:?}"
);
}
#[test]
fn realtime_command_visible_when_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
realtime_command_enabled: true,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/realtime".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "realtime"),
other => panic!("expected realtime to be selected for exact match, got {other:?}"),
}
}
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
+5
View File
@@ -290,6 +290,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_realtime_command_enabled(&mut self, enabled: bool) {
self.composer.set_realtime_command_enabled(enabled);
self.request_redraw();
}
/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) {
@@ -13,6 +13,7 @@ pub(crate) fn builtins_for_input(
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> Vec<(&'static str, SlashCommand)> {
built_in_slash_commands()
@@ -24,6 +25,7 @@ pub(crate) fn builtins_for_input(
})
.filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| realtime_command_enabled || *cmd != SlashCommand::Realtime)
.collect()
}
@@ -33,12 +35,14 @@ pub(crate) fn find_builtin_command(
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> Option<SlashCommand> {
builtins_for_input(
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
realtime_command_enabled,
allow_elevate_sandbox,
)
.into_iter()
@@ -52,12 +56,14 @@ pub(crate) fn has_builtin_prefix(
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> bool {
builtins_for_input(
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
realtime_command_enabled,
allow_elevate_sandbox,
)
.into_iter()
@@ -71,14 +77,14 @@ mod tests {
#[test]
fn debug_command_still_resolves_for_dispatch() {
let cmd = find_builtin_command("debug-config", true, true, true, false);
let cmd = find_builtin_command("debug-config", true, true, true, true, false);
assert_eq!(cmd, Some(SlashCommand::DebugConfig));
}
#[test]
fn clear_command_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("clear", true, true, true, false),
find_builtin_command("clear", true, true, true, true, false),
Some(SlashCommand::Clear)
);
}
+165 -4
View File
@@ -91,6 +91,7 @@ use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::BackgroundEventEvent;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::DeprecationNoticeEvent;
use codex_protocol::protocol::ErrorEvent;
@@ -113,6 +114,10 @@ use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
@@ -162,6 +167,8 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection";
const REALTIME_CONVERSATION_PROMPT: &str =
"Low-level realtime conversation for TUI audio loop testing.";
/// Choose the keybinding used to edit the most-recently queued message.
///
@@ -234,6 +241,7 @@ use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::markdown::append_markdown;
use crate::multi_agents;
use crate::realtime_audio::RealtimeAudioController;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
@@ -500,6 +508,13 @@ pub(crate) enum ExternalEditorState {
Active,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum RealtimeConversationUiState {
#[default]
Stopped,
Working,
}
/// Maintains the per-session UI state and interaction state machines for the chat screen.
///
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
@@ -662,6 +677,8 @@ pub(crate) struct ChatWidget {
// True once we've attempted a branch lookup for the current CWD.
status_line_branch_lookup_complete: bool,
external_editor_state: ExternalEditorState,
realtime_conversation_state: RealtimeConversationUiState,
realtime_audio_controller: Option<RealtimeAudioController>,
}
/// Snapshot of active-cell state that affects transcript overlay rendering.
@@ -2800,6 +2817,8 @@ impl ChatWidget {
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation_state: RealtimeConversationUiState::Stopped,
realtime_audio_controller: None,
};
widget.prefetch_rate_limits();
@@ -2813,6 +2832,7 @@ impl ChatWidget {
widget.config.features.enabled(Feature::CollaborationModes),
);
widget.sync_personality_command_enabled();
widget.sync_realtime_command_enabled();
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
@@ -2970,6 +2990,8 @@ impl ChatWidget {
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation_state: RealtimeConversationUiState::Stopped,
realtime_audio_controller: None,
};
widget.prefetch_rate_limits();
@@ -2983,6 +3005,7 @@ impl ChatWidget {
widget.config.features.enabled(Feature::CollaborationModes),
);
widget.sync_personality_command_enabled();
widget.sync_realtime_command_enabled();
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
@@ -3129,6 +3152,8 @@ impl ChatWidget {
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation_state: RealtimeConversationUiState::Stopped,
realtime_audio_controller: None,
};
widget.prefetch_rate_limits();
@@ -3142,6 +3167,7 @@ impl ChatWidget {
widget.config.features.enabled(Feature::CollaborationModes),
);
widget.sync_personality_command_enabled();
widget.sync_realtime_command_enabled();
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
@@ -3346,6 +3372,105 @@ impl ChatWidget {
self.bottom_pane.can_launch_external_editor()
}
fn toggle_realtime_conversation(&mut self) {
if !self.realtime_command_enabled() {
self.add_info_message(
"Realtime conversation is disabled.".to_string(),
Some(
"Enable `[features].realtime_conversation = true` in config.toml.".to_string(),
),
);
return;
}
match self.realtime_conversation_state {
RealtimeConversationUiState::Stopped => {
self.realtime_conversation_state = RealtimeConversationUiState::Working;
self.add_info_message("Starting realtime conversation...".to_string(), None);
self.submit_op(Op::RealtimeConversationStart(ConversationStartParams {
prompt: REALTIME_CONVERSATION_PROMPT.to_string(),
session_id: None,
}));
}
RealtimeConversationUiState::Working => {
self.realtime_conversation_state = RealtimeConversationUiState::Stopped;
if let Some(a) = self.realtime_audio_controller.take() {
RealtimeAudioController::shutdown(a)
}
self.add_info_message("Stopping realtime conversation...".to_string(), None);
self.submit_op(Op::RealtimeConversationClose);
}
}
}
fn on_realtime_conversation_started(&mut self, event: RealtimeConversationStartedEvent) {
if self.realtime_conversation_state == RealtimeConversationUiState::Stopped {
self.submit_op(Op::RealtimeConversationClose);
return;
}
match RealtimeAudioController::start(self.codex_op_tx.clone()) {
Ok(controller) => {
self.realtime_audio_controller = Some(controller);
self.realtime_conversation_state = RealtimeConversationUiState::Working;
let message = match event.session_id {
Some(session_id) => format!("Realtime conversation started ({session_id})."),
None => "Realtime conversation started.".to_string(),
};
self.add_info_message(message, None);
}
Err(err) => {
self.realtime_conversation_state = RealtimeConversationUiState::Stopped;
self.add_error_message(format!("Failed to start realtime audio IO: {err}"));
self.submit_op(Op::RealtimeConversationClose);
}
}
}
fn on_realtime_conversation_event(&mut self, event: RealtimeConversationRealtimeEvent) {
match event.payload {
RealtimeEvent::AudioOut(frame) => {
if let Some(controller) = &self.realtime_audio_controller
&& let Err(err) = controller.enqueue_audio_out(frame)
{
warn!("failed to queue realtime audio playback frame: {err}");
}
}
RealtimeEvent::Error(message) => {
if let Some(a) = self.realtime_audio_controller.take() {
RealtimeAudioController::shutdown(a)
}
if self.realtime_conversation_state != RealtimeConversationUiState::Stopped {
self.realtime_conversation_state = RealtimeConversationUiState::Stopped;
}
self.add_error_message(format!("Realtime conversation error: {message}"));
}
RealtimeEvent::SessionCreated { session_id } => {
debug!("realtime conversation session created: {session_id}");
}
RealtimeEvent::SessionUpdated { backend_prompt } => {
debug!(
"realtime conversation session updated: {:?}",
backend_prompt
);
}
RealtimeEvent::ConversationItemAdded(_) => {}
}
}
fn on_realtime_conversation_closed(&mut self, event: RealtimeConversationClosedEvent) {
if let Some(a) = self.realtime_audio_controller.take() {
RealtimeAudioController::shutdown(a)
}
self.realtime_conversation_state = RealtimeConversationUiState::Stopped;
let message = match event.reason {
Some(reason) => format!("Realtime conversation closed ({reason})."),
None => "Realtime conversation closed.".to_string(),
};
self.add_info_message(message, None);
}
fn dispatch_command(&mut self, cmd: SlashCommand) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
@@ -3503,6 +3628,9 @@ impl ChatWidget {
SlashCommand::Experimental => {
self.open_experimental_popup();
}
SlashCommand::Realtime => {
self.toggle_realtime_conversation();
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_quit_without_confirmation();
}
@@ -4246,7 +4374,9 @@ impl ChatWidget {
}
}
EventMsg::UserMessage(ev) => {
if from_replay {
if from_replay
|| self.realtime_conversation_state == RealtimeConversationUiState::Working
{
self.on_user_message_event(ev);
}
}
@@ -4276,14 +4406,26 @@ impl ChatWidget {
});
}
}
EventMsg::RealtimeConversationStarted(ev) => {
if !from_replay {
self.on_realtime_conversation_started(ev);
}
}
EventMsg::RealtimeConversationRealtime(ev) => {
if !from_replay {
self.on_realtime_conversation_event(ev);
}
}
EventMsg::RealtimeConversationClosed(ev) => {
if !from_replay {
self.on_realtime_conversation_closed(ev);
}
}
EventMsg::RawResponseItem(_)
| EventMsg::ItemStarted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationRealtime(_)
| EventMsg::RealtimeConversationClosed(_)
| EventMsg::DynamicToolCallRequest(_) => {}
EventMsg::ItemCompleted(event) => {
let item = event.item;
@@ -6344,6 +6486,15 @@ impl ChatWidget {
if feature == Feature::Personality {
self.sync_personality_command_enabled();
}
if feature == Feature::RealtimeConversation {
self.sync_realtime_command_enabled();
if !enabled {
if let Some(a) = self.realtime_audio_controller.take() {
RealtimeAudioController::shutdown(a)
}
self.realtime_conversation_state = RealtimeConversationUiState::Stopped;
}
}
if feature == Feature::PreventIdleSleep {
self.turn_sleep_inhibitor = SleepInhibitor::new(enabled);
self.turn_sleep_inhibitor
@@ -6456,6 +6607,16 @@ impl ChatWidget {
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
}
fn sync_realtime_command_enabled(&mut self) {
self.bottom_pane.set_realtime_command_enabled(
self.config.features.enabled(Feature::RealtimeConversation),
);
}
fn realtime_command_enabled(&self) -> bool {
self.config.features.enabled(Feature::RealtimeConversation)
}
fn current_model_supports_personality(&self) -> bool {
let model = self.current_model();
self.models_manager
+172
View File
@@ -53,6 +53,7 @@ use codex_protocol::protocol::AgentReasoningEvent;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::BackgroundEventEvent;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
@@ -73,6 +74,11 @@ use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SessionSource;
@@ -1668,6 +1674,8 @@ async fn make_chatwidget_manual(
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation_state: RealtimeConversationUiState::Stopped,
realtime_audio_controller: None,
};
widget.set_model(&resolved_model);
(widget, rx, op_rx)
@@ -4448,6 +4456,170 @@ async fn slash_quit_requests_exit() {
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn slash_realtime_shows_error_when_feature_disabled() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::RealtimeConversation, false);
chat.dispatch_command(SlashCommand::Realtime);
match op_rx.try_recv() {
Err(TryRecvError::Empty) => {}
other => panic!("expected no realtime op when feature is disabled, got {other:?}"),
}
let rendered = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Realtime conversation is disabled."),
"expected disabled message, got {rendered:?}"
);
assert!(
rendered.contains("realtime_conversation = true"),
"expected config hint, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_realtime_toggle_sends_start_then_close_ops() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::RealtimeConversation, true);
chat.dispatch_command(SlashCommand::Realtime);
let start = match op_rx.try_recv() {
Ok(Op::RealtimeConversationStart(params)) => params,
other => panic!("expected realtime start op, got {other:?}"),
};
assert_eq!(
start,
ConversationStartParams {
prompt: REALTIME_CONVERSATION_PROMPT.to_string(),
session_id: None,
}
);
chat.dispatch_command(SlashCommand::Realtime);
assert_matches!(op_rx.try_recv(), Ok(Op::RealtimeConversationClose));
}
#[tokio::test]
async fn realtime_events_update_lifecycle_state() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::RealtimeConversation, true);
chat.dispatch_command(SlashCommand::Realtime);
assert_matches!(
op_rx.try_recv(),
Ok(Op::RealtimeConversationStart(
ConversationStartParams { .. }
))
);
assert_eq!(
chat.realtime_conversation_state,
RealtimeConversationUiState::Working
);
chat.handle_codex_event(Event {
id: "rt-started".into(),
msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent {
session_id: Some("sess-123".to_string()),
}),
});
assert_eq!(
chat.realtime_conversation_state,
RealtimeConversationUiState::Working
);
chat.handle_codex_event(Event {
id: "rt-audio".into(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(RealtimeAudioFrame {
data: String::new(),
sample_rate: 24_000,
num_channels: 1,
samples_per_channel: Some(0),
}),
}),
});
assert_eq!(
chat.realtime_conversation_state,
RealtimeConversationUiState::Working
);
chat.handle_codex_event(Event {
id: "rt-closed".into(),
msg: EventMsg::RealtimeConversationClosed(RealtimeConversationClosedEvent {
reason: Some("requested".to_string()),
}),
});
assert_eq!(
chat.realtime_conversation_state,
RealtimeConversationUiState::Stopped
);
let rendered = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Starting realtime conversation..."),
"expected start status message, got {rendered:?}"
);
assert!(
rendered.contains("Realtime conversation started (sess-123)."),
"expected started status message, got {rendered:?}"
);
assert!(
rendered.contains("Realtime conversation closed (requested)."),
"expected closed status message, got {rendered:?}"
);
}
#[tokio::test]
async fn live_user_message_renders_only_during_realtime_mode() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let user_message_event = || {
EventMsg::UserMessage(UserMessageEvent {
message: "delegated user text".to_string(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
})
};
chat.handle_codex_event(Event {
id: "live-user-normal".into(),
msg: user_message_event(),
});
assert!(
drain_insert_history(&mut rx).is_empty(),
"expected live user message to remain hidden outside realtime mode"
);
chat.realtime_conversation_state = RealtimeConversationUiState::Working;
chat.handle_codex_event(Event {
id: "live-user-realtime".into(),
msg: user_message_event(),
});
let rendered = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("delegated user text"),
"expected live user message in realtime mode, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_exit_requests_exit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
+1
View File
@@ -92,6 +92,7 @@ pub mod onboarding;
mod oss_selection;
mod pager_overlay;
pub mod public_widgets;
mod realtime_audio;
mod render;
mod resume_picker;
mod selection_list;
+506
View File
@@ -0,0 +1,506 @@
use anyhow::Result;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RealtimeAudioFrame;
use tokio::sync::mpsc::UnboundedSender;
#[cfg(not(test))]
use anyhow::Context;
#[cfg(not(test))]
use base64::Engine;
#[cfg(not(test))]
use codex_protocol::protocol::ConversationAudioParams;
#[cfg(not(test))]
use cpal::traits::DeviceTrait;
#[cfg(not(test))]
use std::collections::VecDeque;
#[cfg(not(test))]
use std::sync::Arc;
#[cfg(not(test))]
use std::sync::Mutex;
#[cfg(not(test))]
use tracing::warn;
#[cfg(not(test))]
const TARGET_SAMPLE_RATE: u32 = 24_000;
#[cfg(not(test))]
const TARGET_NUM_CHANNELS: u16 = 1;
#[cfg(not(test))]
const TARGET_SAMPLES_PER_CHANNEL: u32 = 480;
#[cfg(not(test))]
const PLAYBACK_BUFFER_SECONDS: usize = 5;
pub(crate) struct RealtimeAudioController {
backend: RealtimeAudioBackend,
}
enum RealtimeAudioBackend {
#[cfg(not(test))]
Live(LiveRealtimeAudioController),
#[cfg(test)]
Stub,
}
#[cfg(not(test))]
struct LiveRealtimeAudioController {
_input_stream: cpal::Stream,
_output_stream: cpal::Stream,
playback_state: Arc<Mutex<PlaybackState>>,
}
impl RealtimeAudioController {
pub(crate) fn start(codex_op_tx: UnboundedSender<Op>) -> Result<Self> {
#[cfg(test)]
{
let _ = codex_op_tx;
Ok(Self {
backend: RealtimeAudioBackend::Stub,
})
}
#[cfg(not(test))]
{
use cpal::traits::HostTrait;
use cpal::traits::StreamTrait;
let host = cpal::default_host();
let input_device = host
.default_input_device()
.context("no default input device available")?;
let output_device = host
.default_output_device()
.context("no default output device available")?;
let input_supported = input_device
.default_input_config()
.context("failed to query default input config")?;
let output_supported = output_device
.default_output_config()
.context("failed to query default output config")?;
let input_config = input_supported.config();
let output_config = output_supported.config();
// TODO(aibrahim): Add persisted audio device + sample-rate selection/config for TUI
// realtime conversations instead of always using defaults.
let playback_state = Arc::new(Mutex::new(PlaybackState::new(
output_config.sample_rate.0,
output_config.channels,
)));
let mic_state = Arc::new(Mutex::new(MicCaptureState::new(
codex_op_tx,
input_config.sample_rate.0,
input_config.channels,
)));
let input_stream = build_input_stream(
&input_device,
input_supported.sample_format(),
&input_config,
Arc::clone(&mic_state),
)
.context("failed to open microphone input stream")?;
let output_stream = build_output_stream(
&output_device,
output_supported.sample_format(),
&output_config,
Arc::clone(&playback_state),
)
.context("failed to open speaker output stream")?;
input_stream
.play()
.context("failed to start microphone stream")?;
output_stream
.play()
.context("failed to start speaker output stream")?;
Ok(Self {
backend: RealtimeAudioBackend::Live(LiveRealtimeAudioController {
_input_stream: input_stream,
_output_stream: output_stream,
playback_state,
}),
})
}
}
pub(crate) fn enqueue_audio_out(&self, frame: RealtimeAudioFrame) -> Result<()> {
match &self.backend {
#[cfg(not(test))]
RealtimeAudioBackend::Live(controller) => {
let mut state = controller
.playback_state
.lock()
.map_err(|_| anyhow::anyhow!("playback state lock poisoned"))?;
state.enqueue(frame)?;
}
#[cfg(test)]
RealtimeAudioBackend::Stub => {
let _ = frame;
}
}
Ok(())
}
pub(crate) fn shutdown(self) {}
}
#[cfg(not(test))]
#[derive(Debug)]
struct MicCaptureState {
codex_op_tx: UnboundedSender<Op>,
source_sample_rate: u32,
source_channels: u16,
source_mono: Vec<f32>,
source_position: f64,
resampled: Vec<f32>,
}
#[cfg(not(test))]
impl MicCaptureState {
fn new(
codex_op_tx: UnboundedSender<Op>,
source_sample_rate: u32,
source_channels: u16,
) -> Self {
Self {
codex_op_tx,
source_sample_rate,
source_channels,
source_mono: Vec::new(),
source_position: 0.0,
resampled: Vec::new(),
}
}
fn push_input_samples_f32(&mut self, data: &[f32]) {
self.push_mono_samples_from_frames(data.iter().copied());
}
fn push_input_samples_i16(&mut self, data: &[i16]) {
self.push_mono_samples_from_frames(
data.iter().map(|sample| *sample as f32 / i16::MAX as f32),
);
}
fn push_input_samples_u16(&mut self, data: &[u16]) {
self.push_mono_samples_from_frames(
data.iter()
.map(|sample| (*sample as f32 / u16::MAX as f32) * 2.0 - 1.0),
);
}
fn push_mono_samples_from_frames<I>(&mut self, mut samples: I)
where
I: Iterator<Item = f32>,
{
let channels = usize::from(self.source_channels.max(1));
loop {
let mut sum = 0.0f32;
let mut count = 0usize;
for _ in 0..channels {
let Some(sample) = samples.next() else {
self.process_and_send_ready_frames();
return;
};
sum += sample;
count += 1;
}
self.source_mono.push(sum / count as f32);
}
}
fn process_and_send_ready_frames(&mut self) {
if self.source_mono.is_empty() {
return;
}
let step = self.source_sample_rate as f64 / TARGET_SAMPLE_RATE as f64;
while self.source_position + 1.0 < self.source_mono.len() as f64 {
let idx = self.source_position.floor() as usize;
let frac = (self.source_position - idx as f64) as f32;
let a = self.source_mono[idx];
let b = self.source_mono[idx + 1];
self.resampled.push(a + (b - a) * frac);
self.source_position += step;
}
let consumed = self.source_position.floor() as usize;
if consumed > 0 {
self.source_mono.drain(..consumed);
self.source_position -= consumed as f64;
}
let chunk_len = TARGET_SAMPLES_PER_CHANNEL as usize;
while self.resampled.len() >= chunk_len {
let samples: Vec<f32> = self.resampled.drain(..chunk_len).collect();
let data = encode_pcm16_le_base64(&samples);
let op = Op::RealtimeConversationAudio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data,
sample_rate: TARGET_SAMPLE_RATE,
num_channels: TARGET_NUM_CHANNELS,
samples_per_channel: Some(TARGET_SAMPLES_PER_CHANNEL),
},
});
if let Err(err) = self.codex_op_tx.send(op) {
warn!("failed to send realtime microphone frame: {err}");
break;
}
}
}
}
#[cfg(not(test))]
#[derive(Debug)]
struct PlaybackState {
output_sample_rate: u32,
output_channels: u16,
queue: VecDeque<f32>,
max_queue_samples: usize,
}
#[cfg(not(test))]
impl PlaybackState {
fn new(output_sample_rate: u32, output_channels: u16) -> Self {
let max_queue_samples =
output_sample_rate as usize * usize::from(output_channels) * PLAYBACK_BUFFER_SECONDS;
Self {
output_sample_rate,
output_channels,
queue: VecDeque::new(),
max_queue_samples,
}
}
fn enqueue(&mut self, frame: RealtimeAudioFrame) -> Result<()> {
if frame.num_channels == 0 {
return Ok(());
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(frame.data.as_bytes())
.context("failed to decode realtime audio base64")?;
if decoded.len() % 2 != 0 {
return Err(anyhow::anyhow!(
"realtime audio payload has odd byte length"
));
}
let pcm: Vec<i16> = decoded
.chunks_exact(2)
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
if pcm.is_empty() {
return Ok(());
}
let mono = interleaved_i16_to_mono_f32(&pcm, frame.num_channels);
let resampled = resample_linear_mono(
&mono,
frame.sample_rate.max(1),
self.output_sample_rate.max(1),
);
for sample in resampled {
for _ in 0..self.output_channels {
self.queue.push_back(sample);
}
}
if self.queue.len() > self.max_queue_samples {
let drop_count = self.queue.len() - self.max_queue_samples;
self.queue.drain(..drop_count);
warn!("dropping old playback samples due to realtime audio buffer overflow");
}
Ok(())
}
fn next_sample(&mut self) -> f32 {
self.queue.pop_front().unwrap_or(0.0)
}
}
#[cfg(not(test))]
fn interleaved_i16_to_mono_f32(samples: &[i16], num_channels: u16) -> Vec<f32> {
let channels = usize::from(num_channels.max(1));
let mut mono = Vec::with_capacity(samples.len() / channels.max(1));
for frame in samples.chunks(channels) {
let sum: f32 = frame
.iter()
.map(|sample| *sample as f32 / i16::MAX as f32)
.sum();
mono.push(sum / frame.len() as f32);
}
mono
}
fn resample_linear_mono(input: &[f32], src_rate: u32, dst_rate: u32) -> Vec<f32> {
if input.is_empty() || src_rate == 0 || dst_rate == 0 {
return Vec::new();
}
if src_rate == dst_rate || input.len() == 1 {
return input.to_vec();
}
let out_len = ((input.len() as u64 * dst_rate as u64) / src_rate as u64)
.max(1)
.try_into()
.unwrap_or(usize::MAX);
let step = src_rate as f64 / dst_rate as f64;
let mut pos = 0.0f64;
let mut out = Vec::with_capacity(out_len);
for _ in 0..out_len {
let idx = pos.floor() as usize;
if idx + 1 >= input.len() {
out.push(*input.last().unwrap_or(&0.0));
} else {
let frac = (pos - idx as f64) as f32;
let a = input[idx];
let b = input[idx + 1];
out.push(a + (b - a) * frac);
}
pos += step;
}
out
}
#[cfg(not(test))]
fn encode_pcm16_le_base64(samples: &[f32]) -> String {
let mut bytes = Vec::with_capacity(samples.len() * 2);
for sample in samples {
let clamped = sample.clamp(-1.0, 1.0);
let scaled = (clamped * i16::MAX as f32).round() as i16;
bytes.extend_from_slice(&scaled.to_le_bytes());
}
base64::engine::general_purpose::STANDARD.encode(bytes)
}
#[cfg(not(test))]
fn build_input_stream(
device: &cpal::Device,
sample_format: cpal::SampleFormat,
config: &cpal::StreamConfig,
mic_state: Arc<Mutex<MicCaptureState>>,
) -> Result<cpal::Stream> {
let err_fn = |err| warn!("realtime microphone stream error: {err}");
let stream = match sample_format {
cpal::SampleFormat::F32 => device.build_input_stream(
config,
move |data: &[f32], _| {
if let Ok(mut state) = mic_state.lock() {
state.push_input_samples_f32(data);
}
},
err_fn,
None,
)?,
cpal::SampleFormat::I16 => device.build_input_stream(
config,
move |data: &[i16], _| {
if let Ok(mut state) = mic_state.lock() {
state.push_input_samples_i16(data);
}
},
err_fn,
None,
)?,
cpal::SampleFormat::U16 => device.build_input_stream(
config,
move |data: &[u16], _| {
if let Ok(mut state) = mic_state.lock() {
state.push_input_samples_u16(data);
}
},
err_fn,
None,
)?,
other => {
return Err(anyhow::anyhow!(
"unsupported microphone sample format: {other:?}"
));
}
};
Ok(stream)
}
#[cfg(not(test))]
fn build_output_stream(
device: &cpal::Device,
sample_format: cpal::SampleFormat,
config: &cpal::StreamConfig,
playback_state: Arc<Mutex<PlaybackState>>,
) -> Result<cpal::Stream> {
let err_fn = |err| warn!("realtime speaker stream error: {err}");
let stream = match sample_format {
cpal::SampleFormat::F32 => device.build_output_stream(
config,
move |data: &mut [f32], _| write_output_f32(data, &playback_state),
err_fn,
None,
)?,
cpal::SampleFormat::I16 => device.build_output_stream(
config,
move |data: &mut [i16], _| write_output_i16(data, &playback_state),
err_fn,
None,
)?,
cpal::SampleFormat::U16 => device.build_output_stream(
config,
move |data: &mut [u16], _| write_output_u16(data, &playback_state),
err_fn,
None,
)?,
other => {
return Err(anyhow::anyhow!(
"unsupported speaker sample format: {other:?}"
));
}
};
Ok(stream)
}
#[cfg(not(test))]
fn write_output_f32(data: &mut [f32], playback_state: &Arc<Mutex<PlaybackState>>) {
fill_output_buffer(data, playback_state, |sample| sample);
}
#[cfg(not(test))]
fn write_output_i16(data: &mut [i16], playback_state: &Arc<Mutex<PlaybackState>>) {
fill_output_buffer(data, playback_state, |sample| {
(sample.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16
});
}
#[cfg(not(test))]
fn write_output_u16(data: &mut [u16], playback_state: &Arc<Mutex<PlaybackState>>) {
fill_output_buffer(data, playback_state, |sample| {
let normalized = (sample.clamp(-1.0, 1.0) + 1.0) * 0.5;
(normalized * u16::MAX as f32).round() as u16
});
}
#[cfg(not(test))]
fn fill_output_buffer<T>(
data: &mut [T],
playback_state: &Arc<Mutex<PlaybackState>>,
mut convert: impl FnMut(f32) -> T,
) {
let mut maybe_state = playback_state.lock().ok();
for slot in data.iter_mut() {
let sample = maybe_state
.as_mut()
.map_or(0.0, |state| state.next_sample());
*slot = convert(sample);
}
}
#[cfg(test)]
mod tests {
use super::resample_linear_mono;
use pretty_assertions::assert_eq;
#[test]
fn resample_linear_passthrough_when_same_rate() {
let input = vec![0.1, -0.2, 0.3];
assert_eq!(resample_linear_mono(&input, 24_000, 24_000), input);
}
}
+3
View File
@@ -20,6 +20,7 @@ pub enum SlashCommand {
#[strum(serialize = "sandbox-add-read-dir")]
SandboxReadRoot,
Experimental,
Realtime,
Skills,
Review,
Rename,
@@ -79,6 +80,7 @@ impl SlashCommand {
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Statusline => "configure which items appear in the status line",
SlashCommand::Theme => "choose a syntax highlighting theme",
SlashCommand::Realtime => "toggle hidden low-level realtime mic/speaker loop",
SlashCommand::Ps => "list background terminals",
SlashCommand::Clean => "stop all background terminals",
SlashCommand::MemoryDrop => "DO NOT USE",
@@ -155,6 +157,7 @@ impl SlashCommand {
| SlashCommand::Feedback
| SlashCommand::Quit
| SlashCommand::Exit => true,
SlashCommand::Realtime => true,
SlashCommand::Rollout => true,
SlashCommand::TestApproval => true,
SlashCommand::Collab => true,