From f7c740fff1558aedf478e692bdd0fa20026a8c9a Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 23 Feb 2026 16:46:43 -0800 Subject: [PATCH] refactor(realtime): reuse voice meter placeholder plumbing Co-authored-by: Codex --- codex-rs/tui/src/app.rs | 5 +- codex-rs/tui/src/app_event.rs | 6 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 71 ++++++++++---- codex-rs/tui/src/bottom_pane/mod.rs | 43 +++++--- codex-rs/tui/src/chatwidget.rs | 97 +++++++------------ codex-rs/tui/src/chatwidget/tests.rs | 4 +- codex-rs/tui/src/realtime_audio.rs | 29 ++++-- 7 files changed, 147 insertions(+), 108 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 94c84e5fc6..a3a5875b86 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2717,10 +2717,11 @@ impl App { 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_named_placeholder_in_place(&id, &text); if updated { tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 27ebe8cfe3..0b0c7ee623 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -321,9 +321,9 @@ pub(crate) enum AppEvent { /// Re-open the permissions presets popup. OpenPermissionsPopup, - /// Live update for the in-progress voice recording placeholder. Carries - /// the placeholder `id` and the text to display (e.g., an ASCII meter). - #[cfg(not(target_os = "linux"))] + /// Live update for an in-progress named composer placeholder (used by + /// voice recording/transcription and realtime mic metering). Carries the + /// placeholder `id` and the text to display. UpdateRecordingMeter { id: String, text: String, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 9f060b1ed8..1cd60b775c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -215,8 +215,6 @@ 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"))] @@ -1005,16 +1003,24 @@ impl ChatComposer { self.placeholder_text = placeholder; } - pub(crate) fn placeholder_text(&self) -> &str { - &self.placeholder_text - } - /// Move the cursor to the end of the current text buffer. pub(crate) fn move_cursor_to_end(&mut self) { self.textarea.set_cursor(self.textarea.text().len()); self.sync_popups(); } + pub(crate) fn insert_named_placeholder(&mut self, id: &str, text: &str) { + self.textarea.insert_named_element(text, id.to_string()); + } + + pub(crate) fn update_named_placeholder_in_place(&mut self, id: &str, text: &str) -> bool { + self.textarea.update_named_element_by_id(id, text) + } + + pub(crate) fn remove_named_placeholder(&mut self, id: &str) { + let _ = self.textarea.replace_element_by_id(id, ""); + } + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { if self.is_empty() { return None; @@ -3846,13 +3852,10 @@ impl ChatComposer { 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); + self.spawn_live_meter_updates(idref.clone(), None, peak, stop); } } true @@ -3866,12 +3869,10 @@ impl ChatComposer { } } - fn spawn_recording_meter( + pub(crate) fn spawn_live_meter_updates( &self, id: String, - _sample_rate: u32, - _channels: u16, - _data: Arc>>, + prefix: Option, last_peak: Arc, stop: Arc, ) { @@ -3883,7 +3884,11 @@ impl ChatComposer { if stop.load(Ordering::Relaxed) { break; } - let text = meter.next_text(last_peak.load(Ordering::Relaxed)); + let meter_text = meter.next_text(last_peak.load(Ordering::Relaxed)); + let text = match &prefix { + Some(prefix) => format!("{prefix}{meter_text}"), + None => meter_text, + }; tx.send(crate::app_event::AppEvent::UpdateRecordingMeter { id: id.clone(), text, @@ -3951,16 +3956,44 @@ impl ChatComposer { let _ = self.textarea.replace_element_by_id(id, text); } - pub fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool { - self.textarea.update_named_element_by_id(id, text) - } - pub fn remove_transcription_placeholder(&mut self, id: &str) { self.stop_transcription_spinner(id); let _ = self.textarea.replace_element_by_id(id, ""); } } +#[cfg(target_os = "linux")] +impl ChatComposer { + pub(crate) fn spawn_live_meter_updates( + &self, + id: String, + prefix: Option, + last_peak: Arc, + stop: Arc, + ) { + let tx = self.app_event_tx.clone(); + std::thread::spawn(move || { + use std::time::Duration; + let mut meter = crate::voice::RecordingMeterState::new(); + loop { + if stop.load(Ordering::Relaxed) { + break; + } + let meter_text = meter.next_text(last_peak.load(Ordering::Relaxed)); + let text = match &prefix { + Some(prefix) => format!("{prefix}{meter_text}"), + None => meter_text, + }; + tx.send(crate::app_event::AppEvent::UpdateRecordingMeter { + id: id.clone(), + text, + }); + std::thread::sleep(Duration::from_millis(100)); + } + }); + } +} + fn skill_display_name(skill: &SkillMetadata) -> &str { skill .interface diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e9dfb21c97..de12c46603 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -14,6 +14,9 @@ //! Some UI is time-based rather than input-based, such as the transient "press again to quit" //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU16; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; @@ -566,13 +569,36 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn set_composer_placeholder_text(&mut self, placeholder: String) { - self.composer.set_placeholder_text(placeholder); + pub(crate) fn insert_named_placeholder(&mut self, id: &str, text: &str) { + self.composer.insert_named_placeholder(id, text); + self.composer.sync_popups(); self.request_redraw(); } - pub(crate) fn composer_placeholder_text(&self) -> String { - self.composer.placeholder_text().to_string() + pub(crate) fn update_named_placeholder_in_place(&mut self, id: &str, text: &str) -> bool { + let updated = self.composer.update_named_placeholder_in_place(id, text); + if updated { + self.composer.sync_popups(); + self.request_redraw(); + } + updated + } + + pub(crate) fn remove_named_placeholder(&mut self, id: &str) { + self.composer.remove_named_placeholder(id); + self.composer.sync_popups(); + self.request_redraw(); + } + + pub(crate) fn spawn_live_meter_updates( + &self, + id: String, + prefix: Option, + last_peak: Arc, + stop: Arc, + ) { + self.composer + .spawn_live_meter_updates(id, prefix, last_peak, stop); } pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { @@ -1025,15 +1051,6 @@ impl BottomPane { 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); - if updated { - self.composer.sync_popups(); - self.request_redraw(); - } - updated - } - pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { self.composer.remove_transcription_placeholder(id); self.composer.sync_popups(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ed344ce5c5..d5f30ecc7e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -170,6 +170,7 @@ const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and P const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; const REALTIME_CONVERSATION_PROMPT: &str = "Low-level realtime conversation for TUI audio loop testing."; +const REALTIME_METER_PLACEHOLDER_ID: &str = "realtime-meter"; /// Choose the keybinding used to edit the most-recently queued message. /// @@ -243,6 +244,7 @@ use crate::key_hint::KeyBinding; use crate::markdown::append_markdown; use crate::multi_agents; use crate::realtime_audio::RealtimeAudioController; +use crate::realtime_audio::RealtimeMicMeterHandles; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::FlexRenderable; @@ -255,7 +257,6 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; -use crate::voice::RecordingMeterState; mod interrupts; use self::interrupts::InterruptManager; mod agent; @@ -685,9 +686,7 @@ pub(crate) struct ChatWidget { external_editor_state: ExternalEditorState, realtime_conversation_state: RealtimeConversationUiState, realtime_audio_controller: Option, - realtime_meter_state: RecordingMeterState, - realtime_saved_placeholder_text: Option, - realtime_last_placeholder_text: Option, + realtime_meter_placeholder_id: Option, } /// Snapshot of active-cell state that affects transcript overlay rendering. @@ -2284,11 +2283,6 @@ impl ChatWidget { pub(crate) fn pre_draw_tick(&mut self) { self.bottom_pane.pre_draw_tick(); - self.update_realtime_composer_meter(); - if self.realtime_conversation_state == RealtimeConversationUiState::Working { - self.frame_requester - .schedule_frame_in(Duration::from_millis(75)); - } } /// Handle completion of an `AgentMessage` turn item. @@ -2880,9 +2874,7 @@ impl ChatWidget { external_editor_state: ExternalEditorState::Closed, realtime_conversation_state: RealtimeConversationUiState::Stopped, realtime_audio_controller: None, - realtime_meter_state: RecordingMeterState::new(), - realtime_saved_placeholder_text: None, - realtime_last_placeholder_text: None, + realtime_meter_placeholder_id: None, }; widget.prefetch_rate_limits(); @@ -3059,9 +3051,7 @@ impl ChatWidget { external_editor_state: ExternalEditorState::Closed, realtime_conversation_state: RealtimeConversationUiState::Stopped, realtime_audio_controller: None, - realtime_meter_state: RecordingMeterState::new(), - realtime_saved_placeholder_text: None, - realtime_last_placeholder_text: None, + realtime_meter_placeholder_id: None, }; widget.prefetch_rate_limits(); @@ -3228,9 +3218,7 @@ impl ChatWidget { external_editor_state: ExternalEditorState::Closed, realtime_conversation_state: RealtimeConversationUiState::Stopped, realtime_audio_controller: None, - realtime_meter_state: RecordingMeterState::new(), - realtime_saved_placeholder_text: None, - realtime_last_placeholder_text: None, + realtime_meter_placeholder_id: None, }; widget.prefetch_rate_limits(); @@ -3448,12 +3436,21 @@ impl ChatWidget { self.bottom_pane.set_footer_hint_override(items); } - pub(crate) fn set_composer_placeholder_text(&mut self, placeholder: String) { - self.bottom_pane.set_composer_placeholder_text(placeholder); + pub(crate) fn insert_named_placeholder(&mut self, id: &str, text: &str) { + self.bottom_pane.insert_named_placeholder(id, text); } - pub(crate) fn composer_placeholder_text(&self) -> String { - self.bottom_pane.composer_placeholder_text() + pub(crate) fn update_named_placeholder_in_place(&mut self, id: &str, text: &str) -> bool { + let updated = self.bottom_pane.update_named_placeholder_in_place(id, text); + if updated { + self.request_redraw(); + } + updated + } + + pub(crate) fn remove_named_placeholder(&mut self, id: &str) { + self.bottom_pane.remove_named_placeholder(id); + self.request_redraw(); } pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) { @@ -3466,45 +3463,33 @@ impl ChatWidget { } fn enable_realtime_ui(&mut self) { - if self.realtime_saved_placeholder_text.is_none() { - self.realtime_saved_placeholder_text = Some(self.composer_placeholder_text()); - } + self.disable_realtime_ui(); self.set_footer_hint_override(Some(vec![( "/realtime".to_string(), "Stop voice chat".to_string(), )])); - self.realtime_meter_state = RecordingMeterState::new(); - self.realtime_last_placeholder_text = None; - self.update_realtime_composer_meter(); + let id = REALTIME_METER_PLACEHOLDER_ID.to_string(); + self.insert_named_placeholder(&id, "Realtime listening тадтадтадтад"); + + if let Some(controller) = &self.realtime_audio_controller + && let Some(RealtimeMicMeterHandles { last_peak, stop }) = controller.meter_handles() + { + self.bottom_pane.spawn_live_meter_updates( + id.clone(), + Some("Realtime listening ".to_string()), + last_peak, + stop, + ); + } + + self.realtime_meter_placeholder_id = Some(id); } fn disable_realtime_ui(&mut self) { self.set_footer_hint_override(None); - if let Some(placeholder) = self.realtime_saved_placeholder_text.take() { - self.set_composer_placeholder_text(placeholder); + if let Some(id) = self.realtime_meter_placeholder_id.take() { + self.remove_named_placeholder(&id); } - self.realtime_meter_state = RecordingMeterState::new(); - self.realtime_last_placeholder_text = None; - } - - fn update_realtime_composer_meter(&mut self) { - if self.realtime_conversation_state != RealtimeConversationUiState::Working { - return; - } - let Some(controller) = self.realtime_audio_controller.as_ref() else { - return; - }; - - let meter = self - .realtime_meter_state - .next_text(controller.last_input_peak()); - let placeholder = format!("Realtime listening {meter}"); - if self.realtime_last_placeholder_text.as_ref() == Some(&placeholder) { - return; - } - - self.realtime_last_placeholder_text = Some(placeholder.clone()); - self.set_composer_placeholder_text(placeholder); } fn toggle_realtime_conversation(&mut self) { @@ -7794,14 +7779,6 @@ impl ChatWidget { 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); - if updated { - self.request_redraw(); - } - updated - } - pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { self.bottom_pane.remove_transcription_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 943800e924..5e5de8db39 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1730,9 +1730,7 @@ async fn make_chatwidget_manual( external_editor_state: ExternalEditorState::Closed, realtime_conversation_state: RealtimeConversationUiState::Stopped, realtime_audio_controller: None, - realtime_meter_state: RecordingMeterState::new(), - realtime_saved_placeholder_text: None, - realtime_last_placeholder_text: None, + realtime_meter_placeholder_id: None, }; widget.set_model(&resolved_model); (widget, rx, op_rx) diff --git a/codex-rs/tui/src/realtime_audio.rs b/codex-rs/tui/src/realtime_audio.rs index c92e777a71..5e9a25f06c 100644 --- a/codex-rs/tui/src/realtime_audio.rs +++ b/codex-rs/tui/src/realtime_audio.rs @@ -15,11 +15,10 @@ use base64::Engine; 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 std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU16; #[cfg(not(test))] use std::sync::atomic::Ordering; @@ -46,6 +45,11 @@ pub(crate) struct RealtimeAudioController { backend: RealtimeAudioBackend, } +pub(crate) struct RealtimeMicMeterHandles { + pub(crate) last_peak: Arc, + pub(crate) stop: Arc, +} + enum RealtimeAudioBackend { #[cfg(not(test))] Live(LiveRealtimeAudioController), @@ -59,6 +63,7 @@ struct LiveRealtimeAudioController { _output_stream: cpal::Stream, playback_state: Arc>, last_input_peak: Arc, + meter_stop: Arc, } impl RealtimeAudioController { @@ -101,6 +106,7 @@ impl RealtimeAudioController { output_config.channels, ))); let last_input_peak = Arc::new(AtomicU16::new(0)); + let meter_stop = Arc::new(AtomicBool::new(false)); let mic_state = Arc::new(Mutex::new(MicCaptureState::new( realtime_audio_op_tx, input_config.sample_rate.0, @@ -136,6 +142,7 @@ impl RealtimeAudioController { _output_stream: output_stream, playback_state, last_input_peak, + meter_stop, }), }) } @@ -159,16 +166,22 @@ impl RealtimeAudioController { Ok(()) } - pub(crate) fn shutdown(self) {} + pub(crate) fn shutdown(self) { + #[cfg(not(test))] + let RealtimeAudioBackend::Live(controller) = &self.backend; + #[cfg(not(test))] + controller.meter_stop.store(true, Ordering::Relaxed); + } - pub(crate) fn last_input_peak(&self) -> u16 { + pub(crate) fn meter_handles(&self) -> Option { match &self.backend { #[cfg(not(test))] - RealtimeAudioBackend::Live(controller) => { - controller.last_input_peak.load(Ordering::Relaxed) - } + RealtimeAudioBackend::Live(controller) => Some(RealtimeMicMeterHandles { + last_peak: Arc::clone(&controller.last_input_peak), + stop: Arc::clone(&controller.meter_stop), + }), #[cfg(test)] - RealtimeAudioBackend::Stub => 0, + RealtimeAudioBackend::Stub => None, } } }