Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
Expand Down Expand Up @@ -2020,6 +2021,9 @@ impl App {
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
}
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
self.chat_widget.open_realtime_audio_device_selection(kind);
}
AppEvent::OpenReasoningPopup { model } => {
self.chat_widget.open_reasoning_popup(model);
}
Expand Down Expand Up @@ -2445,6 +2449,56 @@ impl App {
}
}
}
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
let builder = match kind {
RealtimeAudioDeviceKind::Microphone => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_microphone(name.as_deref())
}
RealtimeAudioDeviceKind::Speaker => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_speaker(name.as_deref())
}
};

match builder.apply().await {
Ok(()) => {
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.config.realtime_audio.microphone = name.clone();
}
RealtimeAudioDeviceKind::Speaker => {
self.config.realtime_audio.speaker = name.clone();
}
}
self.chat_widget
.set_realtime_audio_device(kind, name.clone());

if self.chat_widget.realtime_conversation_is_live() {
self.chat_widget.open_realtime_audio_restart_prompt(kind);
} else {
let selection = name.unwrap_or_else(|| "System default".to_string());
self.chat_widget.add_info_message(
format!("Realtime {} set to {selection}", kind.noun()),
None,
);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist realtime audio selection"
);
self.chat_widget.add_error_message(format!(
"Failed to save realtime {}: {err}",
kind.noun()
));
}
}
}
AppEvent::RestartRealtimeAudioDevice { kind } => {
self.chat_widget.restart_realtime_audio_device(kind);
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.runtime_approval_policy_override = Some(policy);
if let Err(err) = self.config.permissions.approval_policy.set(policy) {
Expand Down
42 changes: 42 additions & 0 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RealtimeAudioDeviceKind {
Microphone,
Speaker,
}

impl RealtimeAudioDeviceKind {
pub(crate) fn title(self) -> &'static str {
match self {
Self::Microphone => "Microphone",
Self::Speaker => "Speaker",
}
}

pub(crate) fn noun(self) -> &'static str {
match self {
Self::Microphone => "microphone",
Self::Speaker => "speaker",
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
Expand Down Expand Up @@ -166,6 +188,26 @@ pub(crate) enum AppEvent {
personality: Personality,
},

/// Open the device picker for a realtime microphone or speaker.
OpenRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
},

/// Persist the selected realtime microphone or speaker to top-level config.
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
name: Option<String>,
},

/// Restart the selected realtime microphone or speaker locally.
RestartRealtimeAudioDevice {
kind: RealtimeAudioDeviceKind,
},

/// Open the reasoning selection popup after picking a model.
OpenReasoningPopup {
model: ModelPreset,
Expand Down
93 changes: 50 additions & 43 deletions codex-rs/tui/src/audio_device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,47 @@ use cpal::traits::DeviceTrait;
use cpal::traits::HostTrait;
use tracing::warn;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AudioDeviceKind {
Input,
Output,
}

impl AudioDeviceKind {
fn noun(self) -> &'static str {
match self {
Self::Input => "input",
Self::Output => "output",
}
}
use crate::app_event::RealtimeAudioDeviceKind;

fn configured_name(self, config: &Config) -> Option<&str> {
match self {
Self::Input => config.realtime_audio.microphone.as_deref(),
Self::Output => config.realtime_audio.speaker.as_deref(),
pub(crate) fn list_realtime_audio_device_names(
kind: RealtimeAudioDeviceKind,
) -> Result<Vec<String>, String> {
let host = cpal::default_host();
let mut device_names = Vec::new();
for device in devices(&host, kind)? {
let Ok(name) = device.name() else {
continue;
};
if !device_names.contains(&name) {
device_names.push(name);
}
}
Ok(device_names)
}

pub(crate) fn select_configured_input_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(AudioDeviceKind::Input, config)
select_device_and_config(RealtimeAudioDeviceKind::Microphone, config)
}

pub(crate) fn select_configured_output_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(AudioDeviceKind::Output, config)
select_device_and_config(RealtimeAudioDeviceKind::Speaker, config)
}

fn select_device_and_config(
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
let host = cpal::default_host();
let configured_name = kind.configured_name(config);
let configured_name = configured_name(kind, config);
let selected = configured_name
.and_then(|name| find_device_by_name(&host, kind, name))
.or_else(|| {
let default_device = default_device(&host, kind);
if let Some(name) = configured_name
&& default_device.is_some()
{
if let Some(name) = configured_name && default_device.is_some() {
warn!(
"configured {} audio device `{name}` was unavailable; falling back to system default",
kind.noun()
Expand All @@ -63,9 +57,16 @@ fn select_device_and_config(
Ok((selected, stream_config))
}

fn configured_name(kind: RealtimeAudioDeviceKind, config: &Config) -> Option<&str> {
match kind {
RealtimeAudioDeviceKind::Microphone => config.realtime_audio.microphone.as_deref(),
RealtimeAudioDeviceKind::Speaker => config.realtime_audio.speaker.as_deref(),
}
}

fn find_device_by_name(
host: &cpal::Host,
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
name: &str,
) -> Option<cpal::Device> {
let devices = devices(host, kind).ok()?;
Expand All @@ -74,49 +75,55 @@ fn find_device_by_name(
.find(|device| device.name().ok().as_deref() == Some(name))
}

fn devices(host: &cpal::Host, kind: AudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
fn devices(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
match kind {
AudioDeviceKind::Input => host
RealtimeAudioDeviceKind::Microphone => host
.input_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate input audio devices: {err}")),
AudioDeviceKind::Output => host
RealtimeAudioDeviceKind::Speaker => host
.output_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate output audio devices: {err}")),
}
}

fn default_device(host: &cpal::Host, kind: AudioDeviceKind) -> Option<cpal::Device> {
fn default_device(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Option<cpal::Device> {
match kind {
AudioDeviceKind::Input => host.default_input_device(),
AudioDeviceKind::Output => host.default_output_device(),
RealtimeAudioDeviceKind::Microphone => host.default_input_device(),
RealtimeAudioDeviceKind::Speaker => host.default_output_device(),
}
}

fn default_config(
device: &cpal::Device,
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
) -> Result<cpal::SupportedStreamConfig, String> {
match kind {
AudioDeviceKind::Input => device
RealtimeAudioDeviceKind::Microphone => device
.default_input_config()
.map_err(|err| format!("failed to get default input config: {err}")),
AudioDeviceKind::Output => device
RealtimeAudioDeviceKind::Speaker => device
.default_output_config()
.map_err(|err| format!("failed to get default output config: {err}")),
}
}

fn missing_device_error(kind: AudioDeviceKind, configured_name: Option<&str>) -> String {
fn missing_device_error(kind: RealtimeAudioDeviceKind, configured_name: Option<&str>) -> String {
match (kind, configured_name) {
(AudioDeviceKind::Input, Some(name)) => format!(
"configured input audio device `{name}` was unavailable and no default input audio device was found"
),
(AudioDeviceKind::Output, Some(name)) => format!(
"configured output audio device `{name}` was unavailable and no default output audio device was found"
),
(AudioDeviceKind::Input, None) => "no input audio device available".to_string(),
(AudioDeviceKind::Output, None) => "no output audio device available".to_string(),
(RealtimeAudioDeviceKind::Microphone, Some(name)) => {
format!(
"configured microphone `{name}` was unavailable and no default input audio device was found"
)
}
(RealtimeAudioDeviceKind::Speaker, Some(name)) => {
format!(
"configured speaker `{name}` was unavailable and no default output audio device was found"
)
}
(RealtimeAudioDeviceKind::Microphone, None) => {
"no input audio device available".to_string()
}
(RealtimeAudioDeviceKind::Speaker, None) => "no output audio device available".to_string(),
}
}
14 changes: 13 additions & 1 deletion codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ pub(crate) struct ChatComposer {
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
windows_degraded_sandbox_active: bool,
status_line_value: Option<Line<'static>>,
status_line_enabled: bool,
Expand Down Expand Up @@ -500,6 +501,7 @@ impl ChatComposer {
connectors_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
status_line_value: None,
status_line_enabled: false,
Expand Down Expand Up @@ -577,10 +579,13 @@ impl ChatComposer {
self.realtime_conversation_enabled = enabled;
}

pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.audio_device_selection_enabled = enabled;
}

/// 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 {
Expand Down Expand Up @@ -2264,6 +2269,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
Expand Down Expand Up @@ -2480,6 +2486,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
{
Expand Down Expand Up @@ -2515,6 +2522,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)?;

Expand Down Expand Up @@ -3334,6 +3342,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
Expand Down Expand Up @@ -3396,6 +3405,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
) {
return true;
Expand Down Expand Up @@ -3450,13 +3460,15 @@ impl ChatComposer {
let connectors_enabled = self.connectors_enabled;
let personality_command_enabled = self.personality_command_enabled;
let realtime_conversation_enabled = self.realtime_conversation_enabled;
let audio_device_selection_enabled = self.audio_device_selection_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
},
);
Expand Down
Loading
Loading