mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
refactor(realtime): reuse voice meter placeholder plumbing
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> {
|
||||
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<Mutex<Vec<i16>>>,
|
||||
prefix: Option<String>,
|
||||
last_peak: Arc<std::sync::atomic::AtomicU16>,
|
||||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||||
) {
|
||||
@@ -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<String>,
|
||||
last_peak: Arc<std::sync::atomic::AtomicU16>,
|
||||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
last_peak: Arc<AtomicU16>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
self.composer
|
||||
.spawn_live_meter_updates(id, prefix, last_peak, stop);
|
||||
}
|
||||
|
||||
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
|
||||
@@ -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();
|
||||
|
||||
@@ -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<RealtimeAudioController>,
|
||||
realtime_meter_state: RecordingMeterState,
|
||||
realtime_saved_placeholder_text: Option<String>,
|
||||
realtime_last_placeholder_text: Option<String>,
|
||||
realtime_meter_placeholder_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<AtomicU16>,
|
||||
pub(crate) stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
enum RealtimeAudioBackend {
|
||||
#[cfg(not(test))]
|
||||
Live(LiveRealtimeAudioController),
|
||||
@@ -59,6 +63,7 @@ struct LiveRealtimeAudioController {
|
||||
_output_stream: cpal::Stream,
|
||||
playback_state: Arc<Mutex<PlaybackState>>,
|
||||
last_input_peak: Arc<AtomicU16>,
|
||||
meter_stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<RealtimeMicMeterHandles> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user