From 3bbc1ce00395b6b31ed5940814a2dc4cf633228f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 28 Mar 2026 18:20:25 -0600 Subject: [PATCH] Remove TUI voice transcription feature (#16114) Removes the partially-completed TUI composer voice transcription flow, including its feature flag, app events, and hold-to-talk state machine. --- MODULE.bazel.lock | 1 - codex-rs/Cargo.lock | 9 - codex-rs/core/config.schema.json | 6 - codex-rs/features/src/lib.rs | 8 - codex-rs/tui/Cargo.toml | 6 +- codex-rs/tui/src/app.rs | 10 +- codex-rs/tui/src/app_event.rs | 16 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 765 +----------------- codex-rs/tui/src/bottom_pane/mod.rs | 38 +- codex-rs/tui/src/bottom_pane/textarea.rs | 1 + codex-rs/tui/src/chatwidget.rs | 20 +- codex-rs/tui/src/chatwidget/realtime.rs | 8 +- codex-rs/tui/src/chatwidget/tests.rs | 2 +- codex-rs/tui/src/lib.rs | 40 +- codex-rs/tui/src/voice.rs | 424 +--------- 15 files changed, 45 insertions(+), 1309 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 53c145b6d..e1a08359b 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -913,7 +913,6 @@ "home_0.5.12": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "home_0.5.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "hostname_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.65\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[],\"set\":[]}}", - "hound_3.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cpal\",\"req\":\"^0.2.12\"}],\"features\":{}}", "http-body-util_0.1.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"sync\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"channel\":[\"dep:tokio\"],\"default\":[],\"full\":[\"channel\"]}}", "http-body_1.0.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"}],\"features\":{}}", "http-range-header_0.4.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.8.3\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 92e0c2592..3dd569c6b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2637,7 +2637,6 @@ dependencies = [ "codex-arg0", "codex-chatgpt", "codex-cli", - "codex-client", "codex-cloud-requirements", "codex-core", "codex-features", @@ -2669,7 +2668,6 @@ dependencies = [ "diffy", "dirs", "dunce", - "hound", "image", "insta", "itertools 0.14.0", @@ -4819,12 +4817,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "hound" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" - [[package]] name = "http" version = "0.2.12" @@ -8091,7 +8083,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 78113b126..e36f495a1 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -503,9 +503,6 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, - "voice_transcription": { - "type": "boolean" - }, "web_search": { "type": "boolean" }, @@ -2148,9 +2145,6 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, - "voice_transcription": { - "type": "boolean" - }, "web_search": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 48927f491..7f0d326a0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -172,8 +172,6 @@ pub enum Feature { Artifact, /// Enable Fast mode selection in the TUI and request layer. FastMode, - /// Enable voice transcription in the TUI composer. - VoiceTranscription, /// Enable experimental realtime voice conversation mode in the TUI. RealtimeConversation, /// Removed compatibility flag. The TUI now always uses the app-server implementation. @@ -813,12 +811,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, - FeatureSpec { - id: Feature::VoiceTranscription, - key: "voice_transcription", - stage: Stage::UnderDevelopment, - default_enabled: false, - }, FeatureSpec { id: Feature::RealtimeConversation, key: "realtime_conversation", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index bc00682a4..33485008c 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -23,7 +23,7 @@ default = ["voice-input"] vt100-tests = [] # Gate verbose debug logging inside the TUI implementation. debug-logs = [] -voice-input = ["dep:cpal", "dep:hound"] +voice-input = ["dep:cpal"] [lints] workspace = true @@ -38,7 +38,6 @@ codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } -codex-client = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } codex-features = { workspace = true } @@ -80,7 +79,7 @@ ratatui = { workspace = true, features = [ ] } ratatui-macros = { workspace = true } regex-lite = { workspace = true } -reqwest = { workspace = true, features = ["json", "multipart"] } +reqwest = { workspace = true, features = ["json"] } rmcp = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } @@ -118,7 +117,6 @@ tokio-util = { workspace = true, features = ["time"] } [target.'cfg(not(target_os = "linux"))'.dependencies] cpal = { version = "0.15", optional = true } -hound = { version = "3.5", optional = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e402ea323..3945f32f5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5028,17 +5028,9 @@ impl App { } }, #[cfg(not(target_os = "linux"))] - AppEvent::TranscriptionComplete { id, text } => { - self.chat_widget.replace_transcription(&id, &text); - } - #[cfg(not(target_os = "linux"))] - AppEvent::TranscriptionFailed { id, error: _ } => { - self.chat_widget.remove_transcription_placeholder(&id); - } - #[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_transcription_in_place(&id, &text); + let updated = self.chat_widget.update_recording_meter_in_place(&id, &text); if updated || self .chat_widget diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e652cb235..2ae175013 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -489,22 +489,6 @@ pub(crate) enum AppEvent { text: String, }, - /// Voice transcription finished for the given placeholder id. - #[cfg(not(target_os = "linux"))] - #[cfg_attr(not(feature = "voice-input"), allow(dead_code))] - TranscriptionComplete { - id: String, - text: String, - }, - - /// Voice transcription failed; remove the placeholder identified by `id`. - #[cfg(not(target_os = "linux"))] - TranscriptionFailed { - id: String, - #[allow(dead_code)] - error: String, - }, - /// Open the branch picker option from the review popup. OpenReviewBranchPicker(PathBuf), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d8b862e63..22da977e7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -114,16 +114,6 @@ //! overall state machine, since it affects which transitions are even possible from a given UI //! state. //! -//! # Voice Hold-To-Talk Without Key Release -//! -//! On terminals that do not report `KeyEventKind::Release`, space hold-to-talk uses repeated -//! space key events as "still held" evidence: -//! -//! - For pending holds (non-empty composer), if timeout elapses without any repeated space event, -//! we treat the key as a normal typed space. -//! - If repeated space events are seen before timeout, we proceed with hold-to-talk. -//! - While recording, repeated space events keep the recording alive; if they stop for a short -//! window, we stop and transcribe. use crate::bottom_pane::footer::mode_indicator_line; use crate::key_hint; use crate::key_hint::KeyBinding; @@ -215,17 +205,8 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::ops::Range; use std::path::PathBuf; -use std::sync::Arc; -#[cfg(not(target_os = "linux"))] -use std::sync::Mutex; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -#[cfg(not(target_os = "linux"))] -use std::thread; use std::time::Duration; use std::time::Instant; -#[cfg(not(target_os = "linux"))] -use tokio::runtime::Handle; /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -296,34 +277,6 @@ impl ChatComposerConfig { } } -#[derive(Default)] -struct VoiceState { - transcription_enabled: bool, - // Spacebar hold-to-talk state. - space_hold_started_at: Option, - space_hold_element_id: Option, - space_hold_trigger: Option>, - key_release_supported: bool, - space_hold_repeat_seen: bool, - #[cfg(not(target_os = "linux"))] - voice: Option, - #[cfg(not(target_os = "linux"))] - recording_placeholder_id: Option, - #[cfg(not(target_os = "linux"))] - space_recording_started_at: Option, - #[cfg(not(target_os = "linux"))] - space_recording_last_repeat_at: Option, -} - -impl VoiceState { - fn new(key_release_supported: bool) -> Self { - Self { - key_release_supported, - ..Default::default() - } - } -} - pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -344,9 +297,6 @@ pub(crate) struct ChatComposer { /// `[Image #M+1]..[Image #N]`, where `M` is the number of remote images. attached_images: Vec, placeholder_text: String, - voice_state: VoiceState, - // Spinner control flags keyed by placeholder id; set to true to stop. - spinner_stop_flags: HashMap>, is_task_running: bool, /// When false, the composer is temporarily read-only (e.g. during sandbox setup). input_enabled: bool, @@ -474,8 +424,6 @@ impl ChatComposer { frame_requester: None, attached_images: Vec::new(), placeholder_text, - voice_state: VoiceState::new(enhanced_keys_supported), - spinner_stop_flags: HashMap::new(), is_task_running: false, input_enabled: true, input_disabled_placeholder: None, @@ -603,22 +551,6 @@ impl ChatComposer { /// Compatibility shim for tests that still toggle the removed steer mode flag. #[cfg(test)] pub fn set_steer_enabled(&mut self, _enabled: bool) {} - pub fn set_voice_transcription_enabled(&mut self, enabled: bool) { - self.voice_state.transcription_enabled = enabled; - if !enabled { - self.voice_state.space_hold_started_at = None; - if let Some(id) = self.voice_state.space_hold_element_id.take() { - let _ = self.textarea.replace_element_by_id(&id, " "); - } - self.voice_state.space_hold_trigger = None; - self.voice_state.space_hold_repeat_seen = false; - } - } - - #[cfg(not(target_os = "linux"))] - fn voice_transcription_enabled(&self) -> bool { - self.voice_state.transcription_enabled && cfg!(not(target_os = "linux")) - } /// Centralized feature gating keeps config checks out of call sites. fn popups_enabled(&self) -> bool { self.config.popups_enabled @@ -692,11 +624,6 @@ impl ChatComposer { return None; } - // Hide the cursor while recording voice input. - #[cfg(not(target_os = "linux"))] - if self.voice_state.voice.is_some() { - return None; - } let [_, _, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) @@ -754,10 +681,6 @@ impl ChatComposer { /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { - #[cfg(not(target_os = "linux"))] - if self.voice_state.voice.is_some() { - return false; - } let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { @@ -1002,9 +925,6 @@ impl ChatComposer { local_image_paths: Vec, mention_bindings: Vec, ) { - #[cfg(not(target_os = "linux"))] - self.stop_all_transcription_spinners(); - // Clear any existing content, placeholders, and attachments first. self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); @@ -1273,48 +1193,14 @@ impl ChatComposer { /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if matches!(key_event.kind, KeyEventKind::Release) { - self.voice_state.key_release_supported = true; - } - - // Timer-based conversion is handled in the pre-draw tick. - // If recording, stop on Space release when supported. On terminals without key-release - // events, Space repeat events are handled as "still held" and stop is driven by timeout - // in `process_space_hold_trigger`. - if let Some(result) = self.handle_key_event_while_recording(key_event) { - return result; - } - if !self.input_enabled { return (InputResult::None, false); } - // Outside of recording, ignore all key releases globally except for Space, - // which is handled explicitly for hold-to-talk behavior below. - if matches!(key_event.kind, KeyEventKind::Release) - && !matches!(key_event.code, KeyCode::Char(' ')) - { + if matches!(key_event.kind, KeyEventKind::Release) { return (InputResult::None, false); } - // If a space hold is pending and another non-space key is pressed, cancel the hold - // and convert the element into a plain space. - if self.voice_state.space_hold_started_at.is_some() - && !matches!(key_event.code, KeyCode::Char(' ')) - { - self.voice_state.space_hold_started_at = None; - if let Some(id) = self.voice_state.space_hold_element_id.take() { - let _ = self.textarea.replace_element_by_id(&id, " "); - } - self.voice_state.space_hold_trigger = None; - self.voice_state.space_hold_repeat_seen = false; - // fall through to normal handling of this other key - } - - if let Some(result) = self.handle_voice_space_key_event(&key_event) { - return result; - } - let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), @@ -2648,136 +2534,6 @@ impl ChatComposer { } } - #[cfg(target_os = "linux")] - fn handle_voice_space_key_event( - &mut self, - _key_event: &KeyEvent, - ) -> Option<(InputResult, bool)> { - None - } - - #[cfg(not(target_os = "linux"))] - fn handle_voice_space_key_event( - &mut self, - key_event: &KeyEvent, - ) -> Option<(InputResult, bool)> { - if !self.voice_transcription_enabled() || !matches!(key_event.code, KeyCode::Char(' ')) { - return None; - } - match key_event.kind { - KeyEventKind::Press => { - if self.paste_burst.is_active() { - return None; - } - - // If textarea is empty, start recording immediately without inserting a space. - if self.textarea.text().is_empty() { - if self.start_recording_with_placeholder() { - return Some((InputResult::None, true)); - } - return None; - } - - // If a hold is already pending, swallow further press events to - // avoid inserting multiple spaces and resetting the timer on key repeat. - if self.voice_state.space_hold_started_at.is_some() { - if !self.voice_state.key_release_supported { - self.voice_state.space_hold_repeat_seen = true; - } - return Some((InputResult::None, false)); - } - - // Insert a named element that renders as a space so we can later - // remove it on timeout or convert it to a plain space on release. - let elem_id = self.next_id(); - self.textarea.insert_named_element(" ", elem_id.clone()); - - // Record pending hold metadata. - self.voice_state.space_hold_started_at = Some(Instant::now()); - self.voice_state.space_hold_element_id = Some(elem_id); - self.voice_state.space_hold_repeat_seen = false; - - // Spawn a delayed task to flip an atomic flag; we check it on next key event. - let flag = Arc::new(AtomicBool::new(false)); - let frame = self.frame_requester.clone(); - Self::schedule_space_hold_timer(flag.clone(), frame); - self.voice_state.space_hold_trigger = Some(flag); - - Some((InputResult::None, true)) - } - // If we see a repeat before release, handling occurs in the top-level pending block. - KeyEventKind::Repeat => { - // Swallow repeats while a hold is pending to avoid extra spaces. - if self.voice_state.space_hold_started_at.is_some() { - if !self.voice_state.key_release_supported { - self.voice_state.space_hold_repeat_seen = true; - } - return Some((InputResult::None, false)); - } - // Fallback: if no pending hold, treat as normal input. - None - } - // Space release without pending (fallback): treat as normal input. - KeyEventKind::Release => { - // If a hold is pending, convert the element to a plain space and clear state. - self.voice_state.space_hold_started_at = None; - if let Some(id) = self.voice_state.space_hold_element_id.take() { - let _ = self.textarea.replace_element_by_id(&id, " "); - } - self.voice_state.space_hold_trigger = None; - self.voice_state.space_hold_repeat_seen = false; - Some((InputResult::None, true)) - } - } - } - - #[cfg(target_os = "linux")] - fn handle_key_event_while_recording( - &mut self, - _key_event: KeyEvent, - ) -> Option<(InputResult, bool)> { - None - } - - #[cfg(not(target_os = "linux"))] - fn handle_key_event_while_recording( - &mut self, - key_event: KeyEvent, - ) -> Option<(InputResult, bool)> { - if self.voice_state.voice.is_some() { - let should_stop = if self.voice_state.key_release_supported { - match key_event.kind { - KeyEventKind::Release => matches!(key_event.code, KeyCode::Char(' ')), - KeyEventKind::Press | KeyEventKind::Repeat => { - !matches!(key_event.code, KeyCode::Char(' ')) - } - } - } else { - match key_event.kind { - KeyEventKind::Release => matches!(key_event.code, KeyCode::Char(' ')), - KeyEventKind::Press | KeyEventKind::Repeat => { - if matches!(key_event.code, KeyCode::Char(' ')) { - self.voice_state.space_recording_last_repeat_at = Some(Instant::now()); - false - } else { - true - } - } - } - }; - - if should_stop { - let needs_redraw = self.stop_recording_and_start_transcription(); - return Some((InputResult::None, needs_redraw)); - } - - // Swallow non-stopping keys while recording. - return Some((InputResult::None, false)); - } - - None - } - fn is_bang_shell_command(&self) -> bool { self.textarea.text().trim_start().starts_with('!') } @@ -3515,11 +3271,6 @@ impl ChatComposer { self.has_focus = has_focus; } - #[cfg(not(target_os = "linux"))] - pub(crate) fn is_recording(&self) -> bool { - self.voice_state.voice.is_some() - } - #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; @@ -3553,32 +3304,6 @@ impl ChatComposer { } } - #[cfg(not(target_os = "linux"))] - fn schedule_space_hold_timer(flag: Arc, frame: Option) { - const HOLD_DELAY_MILLIS: u64 = 1_000; - if let Ok(handle) = Handle::try_current() { - let flag_clone = flag; - let frame_clone = frame; - handle.spawn(async move { - tokio::time::sleep(Duration::from_millis(HOLD_DELAY_MILLIS)).await; - Self::complete_space_hold_timer(flag_clone, frame_clone); - }); - } else { - thread::spawn(move || { - thread::sleep(Duration::from_millis(HOLD_DELAY_MILLIS)); - Self::complete_space_hold_timer(flag, frame); - }); - } - } - - #[cfg(not(target_os = "linux"))] - fn complete_space_hold_timer(flag: Arc, frame: Option) { - flag.store(true, Ordering::Relaxed); - if let Some(frame) = frame { - frame.schedule_frame(); - } - } - pub(crate) fn set_status_line(&mut self, status_line: Option>) -> bool { if self.status_line_value == status_line { return false; @@ -3611,281 +3336,17 @@ impl ChatComposer { #[cfg(not(target_os = "linux"))] impl ChatComposer { - pub(crate) fn process_space_hold_trigger(&mut self) { - if self.voice_transcription_enabled() - && let Some(flag) = self.voice_state.space_hold_trigger.as_ref() - && flag.load(Ordering::Relaxed) - && self.voice_state.space_hold_started_at.is_some() - && self.voice_state.voice.is_none() - { - let _ = self.on_space_hold_timeout(); - } - - const SPACE_REPEAT_INITIAL_GRACE_MILLIS: u64 = 700; - const SPACE_REPEAT_IDLE_TIMEOUT_MILLIS: u64 = 250; - if !self.voice_state.key_release_supported && self.voice_state.voice.is_some() { - let now = Instant::now(); - let initial_grace = Duration::from_millis(SPACE_REPEAT_INITIAL_GRACE_MILLIS); - let repeat_idle_timeout = Duration::from_millis(SPACE_REPEAT_IDLE_TIMEOUT_MILLIS); - if let Some(started_at) = self.voice_state.space_recording_started_at - && now.saturating_duration_since(started_at) >= initial_grace - { - let should_stop = match self.voice_state.space_recording_last_repeat_at { - Some(last_repeat_at) => { - now.saturating_duration_since(last_repeat_at) >= repeat_idle_timeout - } - None => true, - }; - if should_stop { - let _ = self.stop_recording_and_start_transcription(); - } - } - } - } - - /// Called when the 1s space hold timeout elapses. - /// - /// On terminals without key-release reporting, this only transitions into voice capture if we - /// observed repeated Space events while pending; otherwise the keypress is treated as a typed - /// space. - pub(crate) fn on_space_hold_timeout(&mut self) -> bool { - if !self.voice_transcription_enabled() { - return false; - } - if self.voice_state.voice.is_some() { - return false; - } - if self.voice_state.space_hold_started_at.is_some() { - if !self.voice_state.key_release_supported && !self.voice_state.space_hold_repeat_seen { - if let Some(id) = self.voice_state.space_hold_element_id.take() { - let _ = self.textarea.replace_element_by_id(&id, " "); - } - self.voice_state.space_hold_started_at = None; - self.voice_state.space_hold_trigger = None; - self.voice_state.space_hold_repeat_seen = false; - return true; - } - - // Preserve the typed space when transitioning into voice capture, but - // avoid duplicating an existing trailing space. In either case, - // convert/remove the temporary named element before inserting the - // recording/transcribing placeholder. - if let Some(id) = self.voice_state.space_hold_element_id.take() { - let replacement = if self - .textarea - .named_element_range(&id) - .and_then(|range| self.textarea.text()[..range.start].chars().next_back()) - .is_some_and(|ch| ch == ' ') - { - "" - } else { - " " - }; - let _ = self.textarea.replace_element_by_id(&id, replacement); - } - // Clear pending state before starting capture - self.voice_state.space_hold_started_at = None; - self.voice_state.space_hold_trigger = None; - self.voice_state.space_hold_repeat_seen = false; - - // Start voice capture - self.start_recording_with_placeholder() - } else { - false - } - } - - /// Stop recording if active, update the placeholder, and spawn background transcription. - /// Returns true if the UI should redraw. - fn stop_recording_and_start_transcription(&mut self) -> bool { - let Some(vc) = self.voice_state.voice.take() else { - return false; - }; - self.voice_state.space_recording_started_at = None; - self.voice_state.space_recording_last_repeat_at = None; - match vc.stop() { - Ok(audio) => { - // If the recording is too short, remove the placeholder immediately - // and skip the transcribing state entirely. - let total_samples = audio.data.len() as f32; - let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32); - let duration_seconds = if samples_per_second > 0.0 { - total_samples / samples_per_second - } else { - 0.0 - }; - const MIN_DURATION_SECONDS: f32 = 1.0; - if duration_seconds < MIN_DURATION_SECONDS { - if let Some(id) = self.voice_state.recording_placeholder_id.take() { - let _ = self.textarea.replace_element_by_id(&id, ""); - } - return true; - } - - // Otherwise, update the placeholder to show a spinner and proceed. - let id = match self.voice_state.recording_placeholder_id.take() { - Some(id) => id, - None => self.next_id(), - }; - - let placeholder_range = self.textarea.named_element_range(&id); - let prompt_source = if let Some(range) = &placeholder_range { - self.textarea.text()[..range.start].to_string() - } else { - self.textarea.text().to_string() - }; - - // Initialize with first spinner frame immediately. - let _ = self.textarea.update_named_element_by_id(&id, "⠋"); - // Spawn animated braille spinner until transcription finishes (or times out). - self.spawn_transcribing_spinner(id.clone()); - let tx = self.app_event_tx.clone(); - crate::voice::transcribe_async(id, audio, Some(prompt_source), tx); - true - } - Err(e) => { - tracing::error!("failed to stop voice capture: {e}"); - true - } - } - } - - /// Start voice capture and insert a placeholder element for the live meter. - /// Returns true if recording began and UI should redraw; false on failure. - fn start_recording_with_placeholder(&mut self) -> bool { - match crate::voice::VoiceCapture::start() { - Ok(vc) => { - self.voice_state.voice = Some(vc); - if self.voice_state.key_release_supported { - self.voice_state.space_recording_started_at = None; - } else { - self.voice_state.space_recording_started_at = Some(Instant::now()); - } - self.voice_state.space_recording_last_repeat_at = None; - // Insert visible placeholder for the meter (no label) - let id = self.next_id(); - self.textarea.insert_named_element("", id.clone()); - self.voice_state.recording_placeholder_id = Some(id); - // Spawn metering animation - if let Some(v) = &self.voice_state.voice { - let data = v.data_arc(); - let stop = v.stopped_flag(); - let sr = v.sample_rate(); - let ch = v.channels(); - let peak = v.last_peak_arc(); - if let Some(idref) = &self.voice_state.recording_placeholder_id { - self.spawn_recording_meter(idref.clone(), sr, ch, data, peak, stop); - } - } - true - } - Err(e) => { - self.voice_state.space_recording_started_at = None; - self.voice_state.space_recording_last_repeat_at = None; - tracing::error!("failed to start voice capture: {e}"); - false - } - } - } - - fn spawn_recording_meter( - &self, - id: String, - _sample_rate: u32, - _channels: u16, - _data: Arc>>, - last_peak: Arc, - stop: Arc, - ) { - let tx = self.app_event_tx.clone(); - let task = move || { - use std::time::Duration; - let mut meter = crate::voice::RecordingMeterState::new(); - loop { - if stop.load(Ordering::Relaxed) { - break; - } - let text = meter.next_text(last_peak.load(Ordering::Relaxed)); - tx.send(crate::app_event::AppEvent::UpdateRecordingMeter { - id: id.clone(), - text, - }); - - thread::sleep(Duration::from_millis(100)); - } - }; - - if let Ok(handle) = Handle::try_current() { - handle.spawn_blocking(task); - } else { - thread::spawn(task); - } - } - - fn spawn_transcribing_spinner(&mut self, id: String) { - self.stop_transcription_spinner(&id); - let stop = Arc::new(AtomicBool::new(false)); - self.spinner_stop_flags - .insert(id.clone(), Arc::clone(&stop)); - - let tx = self.app_event_tx.clone(); - let task = move || { - use std::time::Duration; - let frames: Vec<&'static str> = vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let mut i: usize = 0; - // Safety stop after ~60s to avoid a runaway task if events are lost. - let max_ticks = 600usize; // 600 * 100ms = 60s - for _ in 0..max_ticks { - if stop.load(Ordering::Relaxed) { - break; - } - let text = frames[i % frames.len()].to_string(); - tx.send(crate::app_event::AppEvent::UpdateRecordingMeter { - id: id.clone(), - text, - }); - i = i.wrapping_add(1); - thread::sleep(Duration::from_millis(100)); - } - }; - - if let Ok(handle) = Handle::try_current() { - handle.spawn_blocking(task); - } else { - thread::spawn(task); - } - } - - fn stop_transcription_spinner(&mut self, id: &str) { - if let Some(flag) = self.spinner_stop_flags.remove(id) { - flag.store(true, Ordering::Relaxed); - } - } - - fn stop_all_transcription_spinners(&mut self) { - for (_id, flag) in self.spinner_stop_flags.drain() { - flag.store(true, Ordering::Relaxed); - } - } - - pub fn replace_transcription(&mut self, id: &str, text: &str) { - self.stop_transcription_spinner(id); - let _ = self.textarea.replace_element_by_id(id, text); - } - - pub fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool { + pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { self.textarea.update_named_element_by_id(id, text) } - #[cfg(not(target_os = "linux"))] - pub fn insert_transcription_placeholder(&mut self, text: &str) -> String { + pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String { let id = self.next_id(); self.textarea.insert_named_element(text, id.clone()); id } - pub fn remove_transcription_placeholder(&mut self, id: &str) { - self.stop_transcription_spinner(id); + pub fn remove_recording_meter_placeholder(&mut self, id: &str) { let _ = self.textarea.replace_element_by_id(id, ""); } } @@ -4257,15 +3718,6 @@ impl ChatComposer { } } -impl Drop for ChatComposer { - fn drop(&mut self) { - // Stop any running spinner tasks. - for (_id, flag) in self.spinner_stop_flags.drain() { - flag.store(true, Ordering::Relaxed); - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -4377,6 +3829,26 @@ mod tests { ); } + #[cfg(not(target_os = "linux"))] + #[test] + fn remove_recording_meter_placeholder_clears_placeholder_text() { + let (tx, _rx) = unbounded_channel::(); + 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.textarea.text(), ""); + assert!(composer.textarea.named_element_range(&id).is_none()); + } + #[test] fn footer_flash_expires_and_falls_back_to_hint_override() { let (tx, _rx) = unbounded_channel::(); @@ -6700,195 +6172,6 @@ mod tests { assert!(found_error, "expected error history cell to be sent"); } - #[test] - fn voice_transcription_disabled_treats_space_as_normal_input() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyEventKind; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - 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*/ true, - ); - composer.set_text_content("x".to_string(), Vec::new(), Vec::new()); - composer.move_cursor_to_end(); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new_with_kind( - KeyCode::Char(' '), - KeyModifiers::NONE, - KeyEventKind::Release, - )); - - assert_eq!("x ", composer.textarea.text()); - assert!(composer.voice_state.space_hold_started_at.is_none()); - assert!(composer.voice_state.space_hold_element_id.is_none()); - assert!(composer.voice_state.space_hold_trigger.is_none()); - assert!(!composer.voice_state.space_hold_repeat_seen); - } - - #[cfg(not(target_os = "linux"))] - #[test] - fn space_hold_timeout_without_release_or_repeat_keeps_typed_space() { - let (tx, _rx) = unbounded_channel::(); - 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, - ); - composer.set_voice_transcription_enabled(/*enabled*/ true); - - composer.set_text_content("x".to_string(), Vec::new(), Vec::new()); - composer.move_cursor_to_end(); - let elem_id = "space-hold".to_string(); - composer.textarea.insert_named_element(" ", elem_id.clone()); - composer.voice_state.space_hold_started_at = Some(Instant::now()); - composer.voice_state.space_hold_element_id = Some(elem_id); - composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true))); - composer.voice_state.key_release_supported = false; - composer.voice_state.space_hold_repeat_seen = false; - assert_eq!("x ", composer.textarea.text()); - - composer.process_space_hold_trigger(); - - assert_eq!("x ", composer.textarea.text()); - assert!(composer.voice_state.space_hold_started_at.is_none()); - assert!(!composer.voice_state.space_hold_repeat_seen); - } - - #[cfg(not(target_os = "linux"))] - #[test] - fn space_hold_timeout_with_repeat_uses_hold_path_without_release() { - let (tx, _rx) = unbounded_channel::(); - 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, - ); - composer.set_voice_transcription_enabled(/*enabled*/ true); - - composer.set_text_content("x".to_string(), Vec::new(), Vec::new()); - composer.move_cursor_to_end(); - let elem_id = "space-hold".to_string(); - composer.textarea.insert_named_element(" ", elem_id.clone()); - composer.voice_state.space_hold_started_at = Some(Instant::now()); - composer.voice_state.space_hold_element_id = Some(elem_id); - composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true))); - composer.voice_state.key_release_supported = false; - composer.voice_state.space_hold_repeat_seen = true; - - composer.process_space_hold_trigger(); - - assert_eq!("x ", composer.textarea.text()); - assert!(composer.voice_state.space_hold_started_at.is_none()); - assert!(!composer.voice_state.space_hold_repeat_seen); - if composer.is_recording() { - let _ = composer.stop_recording_and_start_transcription(); - } - } - - #[cfg(not(target_os = "linux"))] - #[test] - fn space_hold_timeout_with_repeat_does_not_duplicate_existing_space() { - let (tx, _rx) = unbounded_channel::(); - 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, - ); - composer.set_voice_transcription_enabled(/*enabled*/ true); - - composer.set_text_content("x ".to_string(), Vec::new(), Vec::new()); - composer.move_cursor_to_end(); - let elem_id = "space-hold".to_string(); - composer.textarea.insert_named_element(" ", elem_id.clone()); - composer.voice_state.space_hold_started_at = Some(Instant::now()); - composer.voice_state.space_hold_element_id = Some(elem_id); - composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true))); - composer.voice_state.key_release_supported = false; - composer.voice_state.space_hold_repeat_seen = true; - - composer.process_space_hold_trigger(); - - assert_eq!("x ", composer.textarea.text()); - assert!(composer.voice_state.space_hold_started_at.is_none()); - assert!(!composer.voice_state.space_hold_repeat_seen); - if composer.is_recording() { - let _ = composer.stop_recording_and_start_transcription(); - } - } - - #[cfg(not(target_os = "linux"))] - #[test] - fn replace_transcription_stops_spinner_for_placeholder() { - let (tx, _rx) = unbounded_channel::(); - 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 = "voice-placeholder".to_string(); - composer.textarea.insert_named_element("", id.clone()); - let flag = Arc::new(AtomicBool::new(false)); - composer - .spinner_stop_flags - .insert(id.clone(), Arc::clone(&flag)); - - composer.replace_transcription(&id, "transcribed text"); - - assert!(flag.load(Ordering::Relaxed)); - assert!(!composer.spinner_stop_flags.contains_key(&id)); - assert_eq!(composer.textarea.text(), "transcribed text"); - } - - #[cfg(not(target_os = "linux"))] - #[test] - fn set_text_content_stops_all_transcription_spinners() { - let (tx, _rx) = unbounded_channel::(); - 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 flag_one = Arc::new(AtomicBool::new(false)); - let flag_two = Arc::new(AtomicBool::new(false)); - composer - .spinner_stop_flags - .insert("voice-1".to_string(), Arc::clone(&flag_one)); - composer - .spinner_stop_flags - .insert("voice-2".to_string(), Arc::clone(&flag_two)); - - composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - - assert!(flag_one.load(Ordering::Relaxed)); - assert!(flag_two.load(Ordering::Relaxed)); - assert!(composer.spinner_stop_flags.is_empty()); - } - #[test] fn slash_tab_completion_moves_cursor_to_end() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f06853798..4016821d4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -329,11 +329,6 @@ impl BottomPane { self.request_redraw(); } - pub fn set_voice_transcription_enabled(&mut self, enabled: bool) { - self.composer.set_voice_transcription_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) { @@ -374,17 +369,6 @@ impl BottomPane { /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { - // Do not globally intercept space; only composer handles hold-to-talk. - // While recording, route all keys to the composer so it can stop on release or next key. - #[cfg(not(target_os = "linux"))] - if self.composer.is_recording() { - let (_ir, needs_redraw) = self.composer.handle_key_event(key_event); - if needs_redraw { - self.request_redraw(); - } - return InputResult::None; - } - // If a modal/view is active, handle it here; otherwise forward to composer. if !self.view_stack.is_empty() { if key_event.kind == KeyEventKind::Release { @@ -516,11 +500,7 @@ impl BottomPane { self.request_redraw(); } - // Space hold timeout is handled inside ChatComposer via an internal timer. pub(crate) fn pre_draw_tick(&mut self) { - // Allow composer to process any time-based transitions before drawing - #[cfg(not(target_os = "linux"))] - self.composer.process_space_hold_trigger(); self.composer.sync_popups(); } @@ -1202,21 +1182,15 @@ impl BottomPane { #[cfg(not(target_os = "linux"))] impl BottomPane { - pub(crate) fn insert_transcription_placeholder(&mut self, text: &str) -> String { - let id = self.composer.insert_transcription_placeholder(text); + 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 replace_transcription(&mut self, id: &str, text: &str) { - self.composer.replace_transcription(id, text); - self.composer.sync_popups(); - self.request_redraw(); - } - - pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool { - let updated = self.composer.update_transcription_in_place(id, text); + 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(); @@ -1224,8 +1198,8 @@ impl BottomPane { updated } - pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { - self.composer.remove_transcription_placeholder(id); + 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(); } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index f9e5f457b..18a7f8d31 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -945,6 +945,7 @@ impl TextArea { 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 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dd04aec37..ba05bdbb7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4617,9 +4617,6 @@ impl ChatWidget { last_non_retry_error: None, }; - widget.bottom_pane.set_voice_transcription_enabled( - widget.config.features.enabled(Feature::VoiceTranscription), - ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -9167,9 +9164,6 @@ impl ChatWidget { ); } let enabled = self.config.features.enabled(feature); - if feature == Feature::VoiceTranscription { - self.bottom_pane.set_voice_transcription_enabled(enabled); - } if feature == Feature::RealtimeConversation { let realtime_conversation_enabled = self.realtime_conversation_enabled(); self.bottom_pane @@ -10657,22 +10651,16 @@ impl ChatWidget { #[cfg(not(target_os = "linux"))] impl ChatWidget { - pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) { - self.bottom_pane.replace_transcription(id, text); - // Ensure the UI redraws to reflect the updated transcription. - self.request_redraw(); - } - - pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool { - let updated = self.bottom_pane.update_transcription_in_place(id, text); + 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_transcription_placeholder(&mut self, id: &str) { - self.bottom_pane.remove_transcription_placeholder(id); + 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(); } diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 6860a7ca0..1d350f066 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -363,7 +363,7 @@ impl ChatWidget { return; } - let placeholder_id = self.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); + let placeholder_id = self.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤"); self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); self.request_redraw(); @@ -374,7 +374,7 @@ impl ChatWidget { Ok(capture) => capture, Err(err) => { self.realtime_conversation.meter_placeholder_id = None; - self.remove_transcription_placeholder(&placeholder_id); + self.remove_recording_meter_placeholder(&placeholder_id); self.fail_realtime_conversation(format!( "Failed to start microphone capture: {err}" )); @@ -464,10 +464,10 @@ impl ChatWidget { flag.store(true, Ordering::Relaxed); } if let Some(capture) = self.realtime_conversation.capture.take() { - let _ = capture.stop(); + capture.stop(); } if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() { - self.remove_transcription_placeholder(&id); + self.remove_recording_meter_placeholder(&id); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f54ff5374..a7119d962 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6087,7 +6087,7 @@ async fn realtime_error_closes_without_followup_closed_info() { 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_transcription_placeholder("⠤⠤⠤⠤"); + 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)); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b84d39a03..02b743fa8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -155,21 +155,13 @@ mod version; mod voice; #[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))] mod voice { - use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use codex_core::config::Config; use codex_protocol::protocol::RealtimeAudioFrame; use std::sync::Arc; - use std::sync::Mutex; use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU16; - pub struct RecordedAudio { - pub data: Vec, - pub sample_rate: u32, - pub channels: u16, - } - pub struct VoiceCapture; pub(crate) struct RecordingMeterState; @@ -177,34 +169,16 @@ mod voice { pub(crate) struct RealtimeAudioPlayer; impl VoiceCapture { - pub fn start() -> Result { - Err("voice input is unavailable in this build".to_string()) - } - pub fn start_realtime(_config: &Config, _tx: AppEventSender) -> Result { Err("voice input is unavailable in this build".to_string()) } - pub fn stop(self) -> Result { - Err("voice input is unavailable in this build".to_string()) - } - - pub fn data_arc(&self) -> Arc>> { - Arc::new(Mutex::new(Vec::new())) - } + pub fn stop(self) {} pub fn stopped_flag(&self) -> Arc { Arc::new(AtomicBool::new(true)) } - pub fn sample_rate(&self) -> u32 { - 0 - } - - pub fn channels(&self) -> u16 { - 0 - } - pub fn last_peak_arc(&self) -> Arc { Arc::new(AtomicU16::new(0)) } @@ -231,18 +205,6 @@ mod voice { pub(crate) fn clear(&self) {} } - - pub fn transcribe_async( - id: String, - _audio: RecordedAudio, - _context: Option, - tx: AppEventSender, - ) { - tx.send(AppEvent::TranscriptionFailed { - id, - error: "voice input is unavailable in this build".to_string(), - }); - } } mod wrapping; diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index df8c31953..cd135870f 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -1,89 +1,33 @@ -use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use base64::Engine; -use codex_client::build_reqwest_client_with_custom_ca; -use codex_core::auth::AuthCredentialsStoreMode; use codex_core::config::Config; -use codex_core::config::find_codex_home; -use codex_core::default_client::get_codex_user_agent; -use codex_login::AuthMode; -use codex_login::CodexAuth; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::RealtimeAudioFrame; use cpal::traits::DeviceTrait; -use cpal::traits::HostTrait; use cpal::traits::StreamTrait; -use hound::SampleFormat; -use hound::WavSpec; -use hound::WavWriter; use std::collections::VecDeque; -use std::io::Cursor; 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; -use tracing::info; -use tracing::trace; -const AUDIO_MODEL: &str = "gpt-4o-mini-transcribe"; const MODEL_AUDIO_SAMPLE_RATE: u32 = 24_000; const MODEL_AUDIO_CHANNELS: u16 = 1; -struct TranscriptionAuthContext { - mode: AuthMode, - bearer_token: String, - chatgpt_account_id: Option, - chatgpt_base_url: String, -} - -pub struct RecordedAudio { - pub data: Vec, - pub sample_rate: u32, - pub channels: u16, -} - pub struct VoiceCapture { stream: Option, - sample_rate: u32, - channels: u16, - data: Arc>>, stopped: Arc, last_peak: Arc, } impl VoiceCapture { - pub fn start() -> Result { - let (device, config) = select_default_input_device_and_config()?; - - let sample_rate = config.sample_rate().0; - let channels = config.channels(); - let data: Arc>> = Arc::new(Mutex::new(Vec::new())); - let stopped = Arc::new(AtomicBool::new(false)); - let last_peak = Arc::new(AtomicU16::new(0)); - - let stream = build_input_stream(&device, &config, data.clone(), last_peak.clone())?; - stream - .play() - .map_err(|e| format!("failed to start input stream: {e}"))?; - - Ok(Self { - stream: Some(stream), - sample_rate, - channels, - data, - stopped, - last_peak, - }) - } - pub fn start_realtime(config: &Config, tx: AppEventSender) -> Result { let (device, config) = select_realtime_input_device_and_config(config)?; let sample_rate = config.sample_rate().0; let channels = config.channels(); - let data: Arc>> = Arc::new(Mutex::new(Vec::new())); let stopped = Arc::new(AtomicBool::new(false)); let last_peak = Arc::new(AtomicU16::new(0)); @@ -101,47 +45,22 @@ impl VoiceCapture { Ok(Self { stream: Some(stream), - sample_rate, - channels, - data, stopped, last_peak, }) } - pub fn stop(mut self) -> Result { + 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(); - let data = self - .data - .lock() - .map_err(|_| "failed to lock audio buffer".to_string())? - .clone(); - Ok(RecordedAudio { - data, - sample_rate: self.sample_rate, - channels: self.channels, - }) - } - - pub fn data_arc(&self) -> Arc>> { - self.data.clone() } pub fn stopped_flag(&self) -> Arc { self.stopped.clone() } - pub fn sample_rate(&self) -> u32 { - self.sample_rate - } - - pub fn channels(&self) -> u16 { - self.channels - } - pub fn last_peak_arc(&self) -> Arc { self.last_peak.clone() } @@ -206,136 +125,16 @@ impl RecordingMeterState { } } -pub fn transcribe_async( - id: String, - audio: RecordedAudio, - context: Option, - tx: AppEventSender, -) { - std::thread::spawn(move || { - // Enforce minimum duration to avoid garbage outputs. - const MIN_DURATION_SECONDS: f32 = 1.0; - let duration_seconds = clip_duration_seconds(&audio); - if duration_seconds < MIN_DURATION_SECONDS { - let msg = format!( - "recording too short ({duration_seconds:.2}s); minimum is {MIN_DURATION_SECONDS:.2}s" - ); - info!("{msg}"); - tx.send(AppEvent::TranscriptionFailed { id, error: msg }); - return; - } - - // Encode entire clip as normalized WAV. - let wav_bytes = match encode_wav_normalized(&audio) { - Ok(b) => b, - Err(e) => { - error!("failed to encode wav: {e}"); - tx.send(AppEvent::TranscriptionFailed { id, error: e }); - return; - } - }; - - // Run the HTTP request on a small, dedicated runtime. - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(e) => { - error!("failed to create tokio runtime: {e}"); - return; - } - }; - - let tx2 = tx.clone(); - let id2 = id.clone(); - let res: Result = rt - .block_on(async move { transcribe_bytes(wav_bytes, context, duration_seconds).await }); - - match res { - Ok(text) => { - tx2.send(AppEvent::TranscriptionComplete { id: id2, text }); - info!("voice transcription succeeded"); - } - Err(e) => { - error!("voice transcription error: {e}"); - tx.send(AppEvent::TranscriptionFailed { id, error: e }); - } - } - }); -} - // ------------------------- // Voice input helpers // ------------------------- -fn select_default_input_device_and_config() --> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { - let host = cpal::default_host(); - let device = host - .default_input_device() - .ok_or_else(|| "no input audio device available".to_string())?; - let config = crate::audio_device::preferred_input_config(&device)?; - Ok((device, config)) -} - 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_input_stream( - device: &cpal::Device, - config: &cpal::SupportedStreamConfig, - data: Arc>>, - last_peak: Arc, -) -> Result { - 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); - if let Ok(mut buf) = data.lock() { - for &s in input { - buf.push(f32_to_i16(s)); - } - } - }, - 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); - if let Ok(mut buf) = data.lock() { - buf.extend_from_slice(input); - } - }, - 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], _| { - if let Ok(mut buf) = data.lock() { - let peak = convert_u16_to_i16_and_peak(input, &mut buf); - last_peak.store(peak, Ordering::Relaxed); - } - }, - 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 build_realtime_input_stream( device: &cpal::Device, config: &cpal::SupportedStreamConfig, @@ -673,210 +472,10 @@ fn convert_pcm16( out } -// ------------------------- -// Transcription helpers -// ------------------------- - -fn clip_duration_seconds(audio: &RecordedAudio) -> f32 { - let total_samples = audio.data.len() as f32; - let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32); - if samples_per_second > 0.0 { - total_samples / samples_per_second - } else { - 0.0 - } -} - -fn encode_wav_normalized(audio: &RecordedAudio) -> Result, String> { - let converted; - let (channels, sample_rate, segment) = - if audio.channels == MODEL_AUDIO_CHANNELS && audio.sample_rate == MODEL_AUDIO_SAMPLE_RATE { - (audio.channels, audio.sample_rate, audio.data.as_slice()) - } else { - converted = convert_pcm16( - &audio.data, - audio.sample_rate, - audio.channels, - MODEL_AUDIO_SAMPLE_RATE, - MODEL_AUDIO_CHANNELS, - ); - ( - MODEL_AUDIO_CHANNELS, - MODEL_AUDIO_SAMPLE_RATE, - converted.as_slice(), - ) - }; - - let mut wav_bytes: Vec = Vec::new(); - let spec = WavSpec { - channels, - sample_rate, - bits_per_sample: 16, - sample_format: SampleFormat::Int, - }; - let mut cursor = Cursor::new(&mut wav_bytes); - let mut writer = - WavWriter::new(&mut cursor, spec).map_err(|_| "failed to create wav writer".to_string())?; - - // Simple peak normalization with headroom to improve audibility on quiet inputs. - let mut peak: i16 = 0; - for &s in segment { - let a = s.unsigned_abs(); - if a > peak.unsigned_abs() { - peak = s; - } - } - let peak_abs = (peak as i32).unsigned_abs() as i32; - let target = (i16::MAX as f32) * 0.9; // leave some headroom - let gain: f32 = if peak_abs > 0 { - target / (peak_abs as f32) - } else { - 1.0 - }; - - for &s in segment { - let v = ((s as f32) * gain) - .round() - .clamp(i16::MIN as f32, i16::MAX as f32) as i16; - writer - .write_sample(v) - .map_err(|_| "failed writing wav sample".to_string())?; - } - writer - .finalize() - .map_err(|_| "failed to finalize wav".to_string())?; - Ok(wav_bytes) -} - -fn normalize_chatgpt_base_url(input: &str) -> String { - let mut base_url = input.to_string(); - while base_url.ends_with('/') { - base_url.pop(); - } - if (base_url.starts_with("https://chatgpt.com") - || base_url.starts_with("https://chat.openai.com")) - && !base_url.contains("/backend-api") - { - base_url = format!("{base_url}/backend-api"); - } - base_url -} - -async fn resolve_auth() -> Result { - let codex_home = find_codex_home().map_err(|e| format!("failed to find codex home: {e}"))?; - let auth = CodexAuth::from_auth_storage(&codex_home, AuthCredentialsStoreMode::Auto) - .map_err(|e| format!("failed to read auth.json: {e}"))? - .ok_or_else(|| "No Codex auth is configured; please run `codex login`".to_string())?; - - let chatgpt_account_id = auth.get_account_id(); - - let token = auth - .get_token() - .map_err(|e| format!("failed to get auth token: {e}"))?; - let config = Config::load_with_cli_overrides(Vec::new()) - .await - .map_err(|e| format!("failed to load config: {e}"))?; - Ok(TranscriptionAuthContext { - mode: auth.api_auth_mode(), - bearer_token: token, - chatgpt_account_id, - chatgpt_base_url: normalize_chatgpt_base_url(&config.chatgpt_base_url), - }) -} - -async fn transcribe_bytes( - wav_bytes: Vec, - context: Option, - duration_seconds: f32, -) -> Result { - let auth = resolve_auth().await?; - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder()) - .map_err(|error| format!("failed to build transcription HTTP client: {error}"))?; - let audio_bytes = wav_bytes.len(); - let prompt_for_log = context.as_deref().unwrap_or("").to_string(); - let (endpoint, request) = - if matches!(auth.mode, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) { - let part = reqwest::multipart::Part::bytes(wav_bytes) - .file_name("audio.wav") - .mime_str("audio/wav") - .map_err(|e| format!("failed to set mime: {e}"))?; - let form = reqwest::multipart::Form::new().part("file", part); - let endpoint = format!("{}/transcribe", auth.chatgpt_base_url); - let mut req = client - .post(&endpoint) - .bearer_auth(&auth.bearer_token) - .multipart(form) - .header("User-Agent", get_codex_user_agent()); - if let Some(acc) = auth.chatgpt_account_id { - req = req.header("ChatGPT-Account-Id", acc); - } - (endpoint, req) - } else { - let part = reqwest::multipart::Part::bytes(wav_bytes) - .file_name("audio.wav") - .mime_str("audio/wav") - .map_err(|e| format!("failed to set mime: {e}"))?; - let mut form = reqwest::multipart::Form::new() - .text("model", AUDIO_MODEL) - .part("file", part); - if let Some(context) = context { - form = form.text("prompt", context); - } - let endpoint = "https://api.openai.com/v1/audio/transcriptions".to_string(); - ( - endpoint, - client - .post("https://api.openai.com/v1/audio/transcriptions") - .bearer_auth(&auth.bearer_token) - .multipart(form) - .header("User-Agent", get_codex_user_agent()), - ) - }; - - let audio_kib = audio_bytes as f32 / 1024.0; - let mode = auth.mode; - trace!( - "sending transcription request: mode={mode:?} endpoint={endpoint} duration={duration_seconds:.2}s audio={audio_kib:.1}KiB prompt={prompt_for_log}" - ); - - let resp = request - .send() - .await - .map_err(|e| format!("transcription request failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(format!("transcription failed: {status} {body}")); - } - - let v: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("failed to parse json: {e}"))?; - let text = v - .get("text") - .and_then(|t| t.as_str()) - .unwrap_or("") - .to_string(); - - if text.is_empty() { - Err("empty transcription result".to_string()) - } else { - Ok(text) - } -} - #[cfg(test)] mod tests { - use super::RecordedAudio; use super::convert_pcm16; - use super::encode_wav_normalized; use pretty_assertions::assert_eq; - use std::io::Cursor; #[test] fn convert_pcm16_downmixes_and_resamples_for_model_input() { @@ -887,25 +486,4 @@ mod tests { ); assert_eq!(converted, vec![200, 700]); } - - #[test] - fn encode_wav_normalized_outputs_24khz_mono_audio() { - let audio = RecordedAudio { - data: vec![100, 300, 200, 400, 500, 700, 600, 800], - sample_rate: 48_000, - channels: 2, - }; - - let wav = encode_wav_normalized(&audio).expect("wav should encode"); - let reader = hound::WavReader::new(Cursor::new(wav)).expect("wav should parse"); - let spec = reader.spec(); - let samples = reader - .into_samples::() - .collect::, _>>() - .expect("samples should decode"); - - assert_eq!(spec.channels, 1); - assert_eq!(spec.sample_rate, 24_000); - assert_eq!(samples, vec![8_426, 29_490]); - } }