mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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.
This commit is contained in:
committed by
GitHub
Unverified
parent
4e119a3b38
commit
3bbc1ce003
Generated
-1
@@ -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\":{}}",
|
||||
|
||||
Generated
-9
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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<Instant>,
|
||||
space_hold_element_id: Option<String>,
|
||||
space_hold_trigger: Option<Arc<AtomicBool>>,
|
||||
key_release_supported: bool,
|
||||
space_hold_repeat_seen: bool,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
voice: Option<crate::voice::VoiceCapture>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
recording_placeholder_id: Option<String>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
space_recording_started_at: Option<Instant>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
space_recording_last_repeat_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl VoiceState {
|
||||
fn new(key_release_supported: bool) -> Self {
|
||||
Self {
|
||||
key_release_supported,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ChatComposer {
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
@@ -344,9 +297,6 @@ pub(crate) struct ChatComposer {
|
||||
/// `[Image #M+1]..[Image #N]`, where `M` is the number of remote images.
|
||||
attached_images: Vec<AttachedImage>,
|
||||
placeholder_text: String,
|
||||
voice_state: VoiceState,
|
||||
// Spinner control flags keyed by placeholder id; set to true to stop.
|
||||
spinner_stop_flags: HashMap<String, Arc<AtomicBool>>,
|
||||
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<PathBuf>,
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
) {
|
||||
#[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<String>) {
|
||||
self.input_enabled = enabled;
|
||||
@@ -3553,32 +3304,6 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn schedule_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
|
||||
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<AtomicBool>, frame: Option<FrameRequester>) {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
if let Some(frame) = frame {
|
||||
frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) -> 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<Mutex<Vec<i16>>>,
|
||||
last_peak: Arc<std::sync::atomic::AtomicU16>,
|
||||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||||
) {
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
@@ -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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
+1
-39
@@ -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<i16>,
|
||||
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<Self, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn start_realtime(_config: &Config, _tx: AppEventSender) -> Result<Self, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn stop(self) -> Result<RecordedAudio, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn data_arc(&self) -> Arc<Mutex<Vec<i16>>> {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
pub fn stop(self) {}
|
||||
|
||||
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
|
||||
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<AtomicU16> {
|
||||
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<String>,
|
||||
tx: AppEventSender,
|
||||
) {
|
||||
tx.send(AppEvent::TranscriptionFailed {
|
||||
id,
|
||||
error: "voice input is unavailable in this build".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod wrapping;
|
||||
|
||||
+1
-423
@@ -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<String>,
|
||||
chatgpt_base_url: String,
|
||||
}
|
||||
|
||||
pub struct RecordedAudio {
|
||||
pub data: Vec<i16>,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
pub struct VoiceCapture {
|
||||
stream: Option<cpal::Stream>,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
data: Arc<Mutex<Vec<i16>>>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
last_peak: Arc<AtomicU16>,
|
||||
}
|
||||
|
||||
impl VoiceCapture {
|
||||
pub fn start() -> Result<Self, String> {
|
||||
let (device, config) = select_default_input_device_and_config()?;
|
||||
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
let data: Arc<Mutex<Vec<i16>>> = 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<Self, String> {
|
||||
let (device, config) = select_realtime_input_device_and_config(config)?;
|
||||
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
let data: Arc<Mutex<Vec<i16>>> = 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<RecordedAudio, String> {
|
||||
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<Mutex<Vec<i16>>> {
|
||||
self.data.clone()
|
||||
}
|
||||
|
||||
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
|
||||
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<AtomicU16> {
|
||||
self.last_peak.clone()
|
||||
}
|
||||
@@ -206,136 +125,16 @@ impl RecordingMeterState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transcribe_async(
|
||||
id: String,
|
||||
audio: RecordedAudio,
|
||||
context: Option<String>,
|
||||
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<String, String> = 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<Mutex<Vec<i16>>>,
|
||||
last_peak: Arc<AtomicU16>,
|
||||
) -> Result<cpal::Stream, String> {
|
||||
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<Vec<u8>, 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<u8> = 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<TranscriptionAuthContext, String> {
|
||||
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<u8>,
|
||||
context: Option<String>,
|
||||
duration_seconds: f32,
|
||||
) -> Result<String, String> {
|
||||
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(|_| "<failed to read body>".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::<i16>()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("samples should decode");
|
||||
|
||||
assert_eq!(spec.channels, 1);
|
||||
assert_eq!(spec.sample_rate, 24_000);
|
||||
assert_eq!(samples, vec![8_426, 29_490]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user