refactor(realtime): reuse voice meter placeholder plumbing

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed Ibrahim
2026-02-23 16:46:51 -08:00
co-authored by Codex
parent a7a812e7bf
commit f7c740fff1
7 changed files with 147 additions and 108 deletions
+3 -2
View File
@@ -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();
}
+3 -3
View File
@@ -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,
+52 -19
View File
@@ -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
+30 -13
View File
@@ -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();
+37 -60
View File
@@ -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.
+1 -3
View File
@@ -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)
+21 -8
View File
@@ -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,
}
}
}