diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 9915de6b40..7bcb3ef853 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -614,8 +614,27 @@ impl ProjectConfig { } } +/// Selected microphone inputs. Scalars preserve existing single-channel configuration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum MicrophoneChannels { + Single(std::num::NonZeroU16), + Multiple(Vec), +} + +impl MicrophoneChannels { + pub fn as_slice(&self) -> &[std::num::NonZeroU16] { + match self { + Self::Single(channel) => std::slice::from_ref(channel), + Self::Multiple(channels) => channels, + } + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RealtimeAudioConfig { + /// One-based microphone channels to mix; unset mixes all input channels. + pub microphone_channel: Option, pub microphone: Option, pub speaker: Option, } @@ -663,6 +682,8 @@ pub struct RealtimeToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct RealtimeAudioToml { + /// One-based microphone channels to mix; unset mixes all input channels. + pub microphone_channel: Option, pub microphone: Option, pub speaker: Option, } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f3a1811f04..02e2acd869 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2586,6 +2586,24 @@ ], "type": "string" }, + "MicrophoneChannels": { + "anyOf": [ + { + "format": "uint16", + "minimum": 1.0, + "type": "integer" + }, + { + "items": { + "format": "uint16", + "minimum": 1.0, + "type": "integer" + }, + "type": "array" + } + ], + "description": "Selected microphone inputs. Scalars preserve existing single-channel configuration." + }, "ModelAvailabilityNuxConfig": { "additionalProperties": { "format": "uint32", @@ -3792,6 +3810,14 @@ "microphone": { "type": "string" }, + "microphone_channel": { + "allOf": [ + { + "$ref": "#/definitions/MicrophoneChannels" + } + ], + "description": "One-based microphone channels to mix; unset mixes all input channels." + }, "speaker": { "type": "string" } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9a2a979b0e..79ab58e75c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -13215,35 +13215,33 @@ voice = "cedar" #[tokio::test] async fn realtime_audio_loads_from_config_toml() -> std::io::Result<()> { - let cfg: ConfigToml = toml::from_str( - r#" -[audio] -microphone = "USB Mic" -speaker = "Desk Speakers" -"#, - ) - .expect("TOML deserialization should succeed"); - - let realtime_audio = cfg - .audio - .as_ref() - .expect("realtime audio config should be present"); - assert_eq!(realtime_audio.microphone.as_deref(), Some("USB Mic")); - assert_eq!(realtime_audio.speaker.as_deref(), Some("Desk Speakers")); - - let codex_home = TempDir::new()?; - let config = Config::load_from_base_config_with_overrides( - cfg, - ConfigOverrides::default(), - codex_home.abs(), - ) - .await?; - - assert_eq!(config.realtime_audio.microphone.as_deref(), Some("USB Mic")); - assert_eq!( - config.realtime_audio.speaker.as_deref(), - Some("Desk Speakers") - ); + for selection in ["1", "[1, 2]"] { + let cfg: ConfigToml = toml::from_str(&format!( + "[audio]\nmicrophone = \"USB Mic\"\nmicrophone_channel = {selection}\nspeaker = \"Desk Speakers\"\n" + )).expect("TOML deserialization should succeed"); + let expected_audio = cfg.audio.as_ref().unwrap().clone(); + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + assert_eq!( + config.realtime_audio, + codex_config::config_toml::RealtimeAudioConfig { + microphone: Some("USB Mic".into()), + speaker: Some("Desk Speakers".into()), + microphone_channel: expected_audio.microphone_channel, + } + ); + } + for invalid in ["0", "[1, 0]"] { + assert!( + toml::from_str::(&format!("[audio]\nmicrophone_channel = {invalid}")) + .is_err() + ); + } Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e5b41a457f..9fc3ffe288 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -4434,6 +4434,7 @@ impl Config { .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { microphone: audio.microphone, + microphone_channel: audio.microphone_channel, speaker: audio.speaker, }), experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, diff --git a/codex-rs/realtime-webrtc/src/protocol.rs b/codex-rs/realtime-webrtc/src/protocol.rs index 6c067243b4..f08e782f90 100644 --- a/codex-rs/realtime-webrtc/src/protocol.rs +++ b/codex-rs/realtime-webrtc/src/protocol.rs @@ -37,6 +37,7 @@ pub enum AudioDeviceKind { pub struct AudioDeviceSelection { pub microphone: Option, pub speaker: Option, + pub channel: Option>, } /// Local device metadata; enumeration never opens streams. diff --git a/codex-rs/realtime-webrtc/tests/session_actor.rs b/codex-rs/realtime-webrtc/tests/session_actor.rs index 524f5ded14..bc00c50adc 100644 --- a/codex-rs/realtime-webrtc/tests/session_actor.rs +++ b/codex-rs/realtime-webrtc/tests/session_actor.rs @@ -31,6 +31,10 @@ fn startup_controls_meters_and_helper_loss() -> Result<()> { AudioDeviceSelection { microphone: Some("Zen Go Synergy Core Playback".into()), speaker: Some("Headphones".into()), + channel: Some(vec![ + std::num::NonZeroU16::new(/*n*/ 1).unwrap(), + std::num::NonZeroU16::new(/*n*/ 2).unwrap(), + ]), }, )?; assert_eq!(started.offer_sdp, "synthetic-offer"); @@ -44,7 +48,7 @@ fn startup_controls_meters_and_helper_loss() -> Result<()> { answer.join().expect("answer thread")?; assert_eq!( serde_json::from_slice::(&fs::read(root.join("input-selection"))?)?, - serde_json::json!({"microphone": "Zen Go Synergy Core Playback", "speaker": "Headphones"}) + serde_json::json!({"microphone": "Zen Go Synergy Core Playback", "speaker": "Headphones", "channel": [1, 2]}) ); let initial: Vec = serde_json::from_slice(&fs::read(root.join("controls"))?)?; assert_eq!( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b30db31905..deb1e1f7e7 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2490,6 +2490,7 @@ impl App { AppEvent::OpenRealtimeSoundDevices => self.chat_widget.open_realtime_sound_devices(), AppEvent::OpenRealtimeVoices => self.open_realtime_voices(app_server).await, AppEvent::OpenRealtimeDevicePicker { kind } => self.list_realtime_devices(kind), + AppEvent::OpenRealtimeInputChannels { device } => self.chat_widget.open_realtime_input_channels(device), AppEvent::RealtimeDevicesListed { origin, kind, result } => { if origin == self.active_thread_id { match result { @@ -2501,6 +2502,9 @@ impl App { AppEvent::PersistRealtimeDevice { kind, name } => { self.persist_realtime_device(kind, name).await; } + AppEvent::PersistRealtimeInputChannel { channel } => { + self.persist_realtime_input_channel(channel).await; + } AppEvent::PersistRealtimeVoiceSelection { voice } => { self.persist_realtime_voice(app_server, voice).await; } diff --git a/codex-rs/tui/src/app/realtime_settings.rs b/codex-rs/tui/src/app/realtime_settings.rs index 92e301bd13..356f508b54 100644 --- a/codex-rs/tui/src/app/realtime_settings.rs +++ b/codex-rs/tui/src/app/realtime_settings.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadRealtimeListVoicesParams; use codex_app_server_protocol::ThreadRealtimeListVoicesResponse; +use codex_config::config_toml::MicrophoneChannels; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; use codex_realtime_webrtc::AudioDeviceKind; @@ -31,6 +32,25 @@ fn configured_voice( } impl App { + pub(super) async fn persist_realtime_input_channel( + &mut self, + channel: Option, + ) { + self.persist_realtime_audio( + "microphone_channel", + channel.map(|channel| match channel { + MicrophoneChannels::Single(channel) => i64::from(channel.get()).into(), + MicrophoneChannels::Multiple(channels) => toml::Value::Array( + channels + .into_iter() + .map(|channel| i64::from(channel.get()).into()) + .collect(), + ), + }), + ) + .await; + } + pub(super) async fn persist_realtime_device( &mut self, kind: AudioDeviceKind, @@ -47,14 +67,34 @@ impl App { async fn persist_realtime_audio(&mut self, key: &str, value: Option) { // Audio runs on the TUI's machine, even when the app server is remote. let segments = vec!["audio".to_string(), key.to_string()]; - let edits = vec![match &value { + let mut edits = vec![match &value { Some(toml::Value::String(value)) => ConfigEdit::SetPath { segments, value: value.clone().into(), }, - Some(_) => unreachable!("audio device settings are names"), + Some(toml::Value::Integer(value)) => ConfigEdit::SetPath { + segments, + value: (*value).into(), + }, + Some(toml::Value::Array(channels)) => ConfigEdit::SetPath { + segments, + value: channels + .iter() + .map(|channel| match channel { + toml::Value::Integer(channel) => *channel, + _ => unreachable!("audio channel arrays contain only integers"), + }) + .collect::() + .into(), + }, + Some(_) => unreachable!("audio settings are device names or channel numbers"), None => ConfigEdit::ClearPath { segments }, }]; + if key == "microphone" { + edits.push(ConfigEdit::ClearPath { + segments: vec!["audio".into(), "microphone_channel".into()], + }); + } if let Err(error) = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits(edits) @@ -86,9 +126,12 @@ impl App { let effective = toml::Value::try_from(&audio) .ok() .and_then(|audio| audio.get(key).cloned()); + let channel_overridden = key == "microphone" && audio.microphone_channel.is_some(); self.local_settings.audio = Ok(audio); self.chat_widget.local_settings.audio = self.local_settings.audio.clone(); - if effective == value { + if channel_overridden { + self.chat_widget.add_error_message("Input device saved, but the input channel is overridden by another configuration layer. Update that override before starting voice.".into()); + } else if effective == value { self.chat_widget.add_info_message( "Audio setting saved. Applies to your next voice conversation.".to_string(), /*hint*/ None, diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs index 57bcbf2ea6..632e6bd4a5 100644 --- a/codex-rs/tui/src/app/tests/realtime_requests.rs +++ b/codex-rs/tui/src/app/tests/realtime_requests.rs @@ -2081,9 +2081,74 @@ async fn overridden_voice_save_keeps_effective_voice() -> Result<()> { } #[tokio::test] -async fn audio_devices_are_persisted() -> Result<()> { - use codex_realtime_webrtc::AudioDeviceKind; +async fn microphone_channel_is_persisted_locally_and_reloaded() -> Result<()> { let mut app = make_test_app().await; + use codex_config::config_toml::MicrophoneChannels; + for (saved, overridden, expected) in [ + (Some("1"), None, Some("1")), + (Some("[1, 2]"), None, Some("[1, 2]")), + (Some("[1, 2]"), Some("3"), Some("3")), + (Some("1"), Some("[2, 3]"), Some("[2, 3]")), + (None, None, None), + ] { + let parse = |value: &str| -> MicrophoneChannels { + toml::from_str::(&format!( + "microphone_channel = {value}" + )) + .unwrap() + .microphone_channel + .unwrap() + }; + app.cli_kv_overrides = overridden + .into_iter() + .map(|value| { + ( + "audio.microphone_channel".to_string(), + toml::Value::try_from(parse(value)).unwrap(), + ) + }) + .collect(); + let mut expected_settings = app.local_settings.clone(); + app.persist_realtime_input_channel(saved.map(parse)).await; + expected_settings.audio = app.local_settings.audio.clone(); + assert_eq!(app.local_settings, expected_settings); + let actual = [&app.local_settings, &app.chat_widget.local_settings] + .map(|settings| settings.audio.as_ref().unwrap().microphone_channel.clone()); + assert_eq!( + actual, + std::array::from_fn::<_, 2, _>(|_| expected.map(parse)) + ); + } + Ok(()) +} + +#[tokio::test] +async fn audio_devices_are_persisted_and_input_changes_clear_channels() -> Result<()> { + use codex_realtime_webrtc::AudioDeviceKind; + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.cli_kv_overrides = vec![("audio.microphone_channel".into(), 3.into())]; + app.persist_realtime_device(AudioDeviceKind::Input, Some("Mono mic".into())) + .await; + let messages = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.display_lines(/*width*/ 120) + .iter() + .map(ToString::to_string) + .collect::(), + ), + _ => None, + }) + .collect::(); + assert!(messages.contains("input channel is overridden")); + app.cli_kv_overrides.clear(); + app.persist_realtime_input_channel(Some( + codex_config::config_toml::MicrophoneChannels::Multiple(vec![ + std::num::NonZeroU16::new(/*n*/ 1).unwrap(), + std::num::NonZeroU16::new(/*n*/ 3).unwrap(), + ]), + )) + .await; app.persist_realtime_device(AudioDeviceKind::Output, Some("Headphones".into())) .await; app.persist_realtime_device(AudioDeviceKind::Input, Some("Interface".into())) @@ -2091,11 +2156,29 @@ async fn audio_devices_are_persisted() -> Result<()> { let expected = codex_config::config_toml::RealtimeAudioToml { microphone: Some("Interface".into()), speaker: Some("Headphones".into()), + microphone_channel: None, }; assert_eq!(app.local_settings.audio.as_ref().unwrap(), &expected); assert_eq!(LocalSettings::from(&app.config).audio, Ok(expected)); app.persist_realtime_device(AudioDeviceKind::Input, /*name*/ None) .await; + app.persist_realtime_input_channel(Some( + codex_config::config_toml::MicrophoneChannels::Multiple(vec![ + std::num::NonZeroU16::new(/*n*/ 1).unwrap(), + std::num::NonZeroU16::new(/*n*/ 3).unwrap(), + ]), + )) + .await; + app.persist_realtime_device(AudioDeviceKind::Input, /*name*/ None) + .await; + assert_eq!( + app.local_settings + .audio + .as_ref() + .unwrap() + .microphone_channel, + None + ); app.persist_realtime_device(AudioDeviceKind::Output, /*name*/ None) .await; assert_eq!(app.local_settings.audio, Ok(Default::default())); @@ -2107,14 +2190,14 @@ async fn audio_devices_are_persisted() -> Result<()> { } #[tokio::test] -async fn audio_device_is_machine_local_across_project_and_remote_transitions() -> Result<()> { +async fn microphone_channel_is_machine_local_across_project_and_remote_transitions() -> Result<()> { let mut app = make_test_app().await; let project = tempfile::tempdir()?; let remote = tempfile::tempdir()?; std::fs::create_dir_all(project.path().join(".codex"))?; std::fs::write( project.path().join(".codex/config.toml"), - "default_permissions = \"work\"\n[audio]\nmicrophone = \"Project mic\"\n[permissions.work]\nextends = \":workspace\"\n", + "default_permissions = \"work\"\n[audio]\nmicrophone_channel = 2\n[permissions.work]\nextends = \":workspace\"\n", )?; crate::legacy_core::config::set_project_trust_level( &app.config.codex_home, @@ -2132,9 +2215,9 @@ async fn audio_device_is_machine_local_across_project_and_remote_transitions() - ThreadId::new(), remote.path().to_path_buf(), )); - app.persist_realtime_device( - codex_realtime_webrtc::AudioDeviceKind::Input, - Some("Machine mic".into()), + app.persist_realtime_input_channel( + std::num::NonZeroU16::new(/*n*/ 1) + .map(codex_config::config_toml::MicrophoneChannels::Single), ) .await; app.cli_kv_overrides.clear(); @@ -2147,15 +2230,16 @@ async fn audio_device_is_machine_local_across_project_and_remote_transitions() - let reloaded = app.local_settings.reloaded(&config); assert_eq!(reloaded.audio, app.chat_widget.local_settings.audio); assert_eq!( - reloaded.audio.as_ref().unwrap().microphone.as_deref(), - Some("Machine mic") + reloaded.audio.as_ref().unwrap().microphone_channel, + std::num::NonZeroU16::new(/*n*/ 1) + .map(codex_config::config_toml::MicrophoneChannels::Single) ); } - // A project value must not hide invalid machine audio. + // A project value must not hide invalid machine audio and silently enable mixed capture. std::fs::write( app.local_settings.user_config_path.as_path(), std::fs::read_to_string(app.local_settings.user_config_path.as_path())? - .replace("microphone = \"Machine mic\"", "microphone = 0"), + .replace("microphone_channel = 1", "microphone_channel = 0"), )?; let config = app .rebuild_config_for_cwd(project.path().to_path_buf()) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 7c34099c2c..7327ba36c2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1238,6 +1238,9 @@ pub(crate) enum AppEvent { OpenRealtimeDevicePicker { kind: codex_realtime_webrtc::AudioDeviceKind, }, + OpenRealtimeInputChannels { + device: codex_realtime_webrtc::AudioDevice, + }, RealtimeDevicesListed { origin: Option, kind: codex_realtime_webrtc::AudioDeviceKind, @@ -1247,6 +1250,9 @@ pub(crate) enum AppEvent { kind: codex_realtime_webrtc::AudioDeviceKind, name: Option, }, + PersistRealtimeInputChannel { + channel: Option, + }, /// Save the voice for subsequent conversations through the app server. PersistRealtimeVoiceSelection { diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 59603894b4..df8d924f03 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -1,6 +1,7 @@ //! Render selectable lists with shared picker controls and full-width focus rows. //! Selection and scrolling use filtered row indices; actions map back to source items. //! Controls and overflow indicators keep their own rows outside the result viewport. +//! Letter-labeled children are indented and do not consume numeric shortcuts. use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -157,6 +158,8 @@ pub(crate) type OnCancelCallback = Option, pub name_prefix_spans: Vec>, pub toggle: Option, pub toggle_placeholder: Option<&'static str>, @@ -643,7 +646,7 @@ impl ListSelectionView { .filter(|actual_idx| { self.active_items() .get(**actual_idx) - .is_some_and(Self::item_is_enabled) + .is_some_and(|item| Self::item_is_enabled(item) && item.child_label.is_none()) }) .count() .max(1) @@ -681,6 +684,8 @@ impl ListSelectionView { } else { format!("{prefix} {}", " ".repeat(enabled_row_number_width + 2)) } + } else if let Some(label) = item.child_label { + format!("{prefix} {label}. ") } else { enabled_row_number += 1; let n = enabled_row_number; @@ -776,7 +781,7 @@ impl ListSelectionView { self.active_items() .iter() .enumerate() - .filter(|(_, item)| Self::item_is_enabled(item)) + .filter(|(_, item)| Self::item_is_enabled(item) && item.child_label.is_none()) .nth(number - 1) .map(|(idx, _)| idx) } @@ -1184,8 +1189,10 @@ impl BottomPaneView for ListSelectionView { && !modifiers.contains(KeyModifiers::ALT) => { if let Some(idx) = self.items.iter().position(|item| { - item.display_shortcut + (item + .display_shortcut .is_some_and(|shortcut| shortcut.is_press(key_event)) + || item.child_label == Some(c)) && Self::item_is_enabled(item) }) { self.select_shortcut(idx); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 912880eed1..29e4eba566 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -77,6 +77,8 @@ mod empty_state_policy; mod hook_status; mod mcp_server_elicitation; mod multi_select_picker; +pub(crate) use multi_select_picker::MultiSelectItem; +pub(crate) use multi_select_picker::MultiSelectPicker; #[cfg(test)] #[path = "questions_tests.rs"] mod question_tests; diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs index d6fe7d58fc..cf0bb3410c 100644 --- a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -5,6 +5,7 @@ //! //! - **Fuzzy search**: Type to filter items by name //! - **Toggle selection**: Space to toggle items on/off +//! - **Required selection**: Optional nonempty confirmation; cancellation always remains available //! - **Reordering**: Optional left/right arrow support to reorder items //! - **Live preview**: Optional callback to show a preview of current selections //! - **Callbacks**: Hooks for change, confirm, and cancel events @@ -191,6 +192,7 @@ pub(crate) struct MultiSelectPicker { /// Whether left/right arrow reordering is enabled. ordering_enabled: bool, + require_selection: bool, /// Shared list keybindings for navigation and completion. keymap: ListKeymap, @@ -417,7 +419,8 @@ impl MultiSelectPicker { /// Collects the IDs of all enabled items and passes them to the /// `on_confirm` callback. Does nothing if already complete. fn confirm_selection(&mut self) { - if self.complete { + if self.complete || (self.require_selection && !self.items.iter().any(|item| item.enabled)) + { return; } self.complete = true; @@ -707,6 +710,7 @@ pub(crate) struct MultiSelectPickerBuilder { instructions: Vec>, items: Vec, ordering_enabled: bool, + require_selection: bool, app_event_tx: AppEventSender, keymap: ListKeymap, preview_builder: Option, @@ -724,6 +728,7 @@ impl MultiSelectPickerBuilder { instructions: Vec::new(), items: Vec::new(), ordering_enabled: false, + require_selection: false, app_event_tx, keymap: RuntimeKeymap::defaults().list, preview_builder: None, @@ -777,6 +782,12 @@ impl MultiSelectPickerBuilder { self } + /// Requires a nonempty selection to confirm without restricting cancellation. + pub fn require_selection(mut self) -> Self { + self.require_selection = true; + self + } + /// Sets a callback invoked when the user confirms their selection (Enter). /// /// The callback receives a list of IDs for all enabled items. @@ -844,6 +855,7 @@ impl MultiSelectPickerBuilder { header: Box::new(header), footer_hint: Line::from(instructions), ordering_enabled: self.ordering_enabled, + require_selection: self.require_selection, keymap: self.keymap, search_query: String::new(), filtered_indices: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 95e4c90cfb..6215611a08 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -328,6 +328,9 @@ impl ChatWidget { let selection = codex_realtime_webrtc::AudioDeviceSelection { microphone: audio.microphone, speaker: audio.speaker, + channel: audio + .microphone_channel + .map(|channels| channels.as_slice().to_vec()), }; std::thread::spawn(move || { let result = RealtimeWebrtcSession::start(abort_registration, selection) diff --git a/codex-rs/tui/src/chatwidget/realtime_settings.rs b/codex-rs/tui/src/chatwidget/realtime_settings.rs index e0ff656791..3b5f6611d3 100644 --- a/codex-rs/tui/src/chatwidget/realtime_settings.rs +++ b/codex-rs/tui/src/chatwidget/realtime_settings.rs @@ -1,6 +1,9 @@ //! Voice settings hierarchy with parent navigation; selections apply to subsequent conversations. use super::*; +use crate::bottom_pane::MultiSelectItem; +use crate::bottom_pane::MultiSelectPicker; +use codex_config::config_toml::MicrophoneChannels; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; use codex_realtime_webrtc::AudioDevice; @@ -122,6 +125,14 @@ impl ChatWidget { AudioDeviceKind::Input => ("Input device", audio.microphone), AudioDeviceKind::Output => ("Output device", audio.speaker), }; + let selected = devices + .iter() + .find(|device| { + current + .as_ref() + .map_or(device.is_default, |name| *name == device.name) + }) + .cloned(); let ambiguous: std::collections::HashSet<_> = devices .iter() .filter(|device| { @@ -157,6 +168,39 @@ impl ChatWidget { } }) .collect(); + if kind == AudioDeviceKind::Input + && let Some(device) = selected.filter(|device| device.channels >= 3) + { + let parent = items.iter().position(|item| item.is_current).unwrap_or(0); + items.insert( + parent + 1, + SelectionItem { + name: "Input channels".into(), + child_label: Some('a'), + description: Some(audio.microphone_channel.as_ref().map_or_else( + || "All channels (mixed)".into(), + |channels| { + format!( + "Inputs {}", + channels + .as_slice() + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ) + }, + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRealtimeInputChannels { + device: device.clone(), + }) + })], + dismiss_on_select: true, + ..Default::default() + }, + ); + } items.push(back_item(|| AppEvent::OpenRealtimeSoundDevices)); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some(title.into()), @@ -168,6 +212,63 @@ impl ChatWidget { }); } + pub(crate) fn open_realtime_input_channels(&mut self, microphone: AudioDevice) { + let Some(audio) = self.realtime_audio_settings() else { + return; + }; + let current = audio.microphone_channel; + let count = microphone.channels; + let picker = MultiSelectPicker::builder( + format!("Microphone: {}", microphone.name), + Some("Select the input channels to mix. Select at least one.".into()), + self.app_event_tx.clone(), + ) + .list_keymap(self.bottom_pane.list_keymap()) + .items( + (1..=count) + .map(|number| MultiSelectItem { + id: number.to_string(), + name: format!("Input {number}"), + enabled: current.as_ref().is_none_or(|channels| { + channels + .as_slice() + .iter() + .any(|channel| channel.get() == number) + }), + ..Default::default() + }) + .collect(), + ) + .require_selection() + .on_preview(|items| { + items + .iter() + .all(|item| !item.enabled) + .then(|| "Select at least one input channel.".red().into()) + }) + .on_confirm(move |ids, tx| { + let selected: Vec<_> = (1..=count) + .filter(|number| ids.contains(&number.to_string())) + .filter_map(std::num::NonZeroU16::new) + .collect(); + let channel = if selected.len() == usize::from(count) { + None + } else if selected.len() == 1 { + selected.first().copied().map(MicrophoneChannels::Single) + } else { + Some(MicrophoneChannels::Multiple(selected)) + }; + tx.send(AppEvent::PersistRealtimeInputChannel { channel }); + }) + .on_cancel(|tx| { + tx.send(AppEvent::OpenRealtimeDevicePicker { + kind: AudioDeviceKind::Input, + }) + }) + .build(); + self.bottom_pane.show_view(Box::new(picker)); + } + pub(crate) fn set_realtime_voice(&mut self, voice: Option) { self.config.realtime.voice = voice; } diff --git a/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs b/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs index 846b0f6934..2f2f5fed19 100644 --- a/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs @@ -81,10 +81,16 @@ async fn voice_settings_uses_server_catalog() { } #[tokio::test] -async fn sound_devices_picker_routes_selection_without_starting_audio() { +async fn microphone_channel_picker_routes_selection_without_starting_audio() { let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; chat.local_settings.audio.as_mut().unwrap().microphone = Some("Zen Go Synergy Core Playback".to_string()); + chat.local_settings + .audio + .as_mut() + .unwrap() + .microphone_channel = std::num::NonZeroU16::new(/*n*/ 1) + .map(codex_config::config_toml::MicrophoneChannels::Single); chat.open_realtime_settings(); chat.bottom_pane .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -109,6 +115,81 @@ async fn sound_devices_picker_routes_selection_without_starting_audio() { } )) ); + chat.open_realtime_input_channels(AudioDevice { + name: "Zen Go Synergy Core Playback".into(), + channels: 4, + is_default: true, + }); + insta::assert_snapshot!( + "voice_input_channels", + render_bottom_popup(&chat, /*width*/ 90) + ); + // Space toggles without persisting; Enter saves both selected inputs. + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(events.try_recv().is_err()); + insta::assert_snapshot!( + "voice_input_channel_subset", + render_bottom_popup(&chat, /*width*/ 90) + ); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let selected = std::iter::from_fn(|| events.try_recv().ok()).find_map(|event| match event { + AppEvent::PersistRealtimeInputChannel { channel } => Some(channel), + _ => None, + }); + assert_eq!( + selected, + Some(Some( + codex_config::config_toml::MicrophoneChannels::Multiple(vec![ + std::num::NonZeroU16::new(/*n*/ 1).unwrap(), + std::num::NonZeroU16::new(/*n*/ 2).unwrap() + ]) + )) + ); + // Empty confirmation stays in the picker; Escape discards the edit. + chat.open_realtime_input_channels(AudioDevice { + name: "Mic".into(), + channels: 4, + is_default: true, + }); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(chat.has_active_view()); + assert!(events.try_recv().is_err()); + insta::assert_snapshot!( + "voice_input_channels_empty", + render_bottom_popup(&chat, /*width*/ 90) + ); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!( + events.try_recv().unwrap(), + AppEvent::OpenRealtimeDevicePicker { + kind: AudioDeviceKind::Input + } + )); + chat.local_settings + .audio + .as_mut() + .unwrap() + .microphone_channel = None; + chat.open_realtime_input_channels(AudioDevice { + name: "Mic".into(), + channels: 4, + is_default: true, + }); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + events.try_recv().unwrap(), + AppEvent::PersistRealtimeInputChannel { channel: None } + )); + assert!(ops.try_recv().is_err()); assert!(!chat.realtime_conversation_is_running()); } @@ -117,12 +198,28 @@ async fn sound_devices_picker_routes_selection_without_starting_audio() { async fn submenus_return_to_their_parent_with_back_or_escape() { let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; type OpenMenu = fn(&mut ChatWidget); - let menus: [OpenMenu; 2] = [ - |chat| chat.open_realtime_voices(/*current*/ None, RealtimeVoicesList::builtin()), - ChatWidget::open_realtime_sound_devices, + let menus: [(OpenMenu, bool); 3] = [ + ( + |chat| chat.open_realtime_voices(/*current*/ None, RealtimeVoicesList::builtin()), + false, + ), + (ChatWidget::open_realtime_sound_devices, false), + ( + |chat| { + chat.open_realtime_input_channels(AudioDevice { + name: "Mic".into(), + channels: 4, + is_default: true, + }) + }, + true, + ), ]; - for open in menus { + for (open, sound_parent) in menus { for key in [KeyCode::Esc, KeyCode::Enter] { + if sound_parent && key == KeyCode::Enter { + continue; + } while events.try_recv().is_ok() {} open(&mut chat); if key == KeyCode::Enter { @@ -132,8 +229,15 @@ async fn submenus_return_to_their_parent_with_back_or_escape() { chat.bottom_pane .handle_key_event(KeyEvent::new(key, KeyModifiers::NONE)); assert!( - std::iter::from_fn(|| events.try_recv().ok()) - .any(|event| matches!(event, AppEvent::OpenRealtimeSettings)) + std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( + (sound_parent, event), + ( + true, + AppEvent::OpenRealtimeDevicePicker { + kind: AudioDeviceKind::Input + } + ) | (false, AppEvent::OpenRealtimeSettings) + )) ); } } @@ -142,7 +246,7 @@ async fn submenus_return_to_their_parent_with_back_or_escape() { } #[tokio::test] -async fn device_pickers_route_selection() { +async fn device_pickers_route_selection_and_only_offer_channels_for_multichannel_inputs() { let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; for (kind, channels, microphone, snapshot) in [ ( @@ -189,6 +293,18 @@ async fn device_pickers_route_selection() { _ => None, }); assert_eq!(selected, Some((kind, Some(device.name.clone())))); + chat.open_realtime_device_picker(kind, devices.clone()); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + let channel_device = + std::iter::from_fn(|| events.try_recv().ok()).find_map(|event| match event { + AppEvent::OpenRealtimeInputChannels { device } => Some(device), + _ => None, + }); + assert_eq!( + channel_device, + (kind == AudioDeviceKind::Input && channels >= 3).then_some(device) + ); chat.open_realtime_device_picker(kind, devices); chat.bottom_pane .handle_key_event(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channel_subset.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channel_subset.snap new file mode 100644 index 0000000000..20064c957e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channel_subset.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 90)" +--- + Microphone: Zen Go Synergy Core Playback + Select the input channels to mix. Select at least one. + + Type to search + + [x] Input 1 +› [x] Input 2 + [ ] Input 3 + [ ] Input 4 + + space toggle · enter save · esc cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channels.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channels.snap new file mode 100644 index 0000000000..7c6ad3c60f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channels.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 90)" +--- + Microphone: Zen Go Synergy Core Playback + Select the input channels to mix. Select at least one. + + Type to search + +› [x] Input 1 + [ ] Input 2 + [ ] Input 3 + [ ] Input 4 + + space toggle · enter save · esc cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channels_empty.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channels_empty.snap new file mode 100644 index 0000000000..5264d6d6bf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_input_channels_empty.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 90)" +--- + Microphone: Mic + Select the input channels to mix. Select at least one. + + Type to search + +› [ ] Input 1 + [ ] Input 2 + [ ] Input 3 + [ ] Input 4 + + Select at least one input channel. + space toggle · enter save · esc cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_input_devices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_input_devices.snap index 1ecf807257..f83a781aae 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_input_devices.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_input_devices.snap @@ -7,6 +7,7 @@ expression: "render_bottom_popup(&chat, 80)" › 1. System default (current) + a. Input channels All channels (mixed) 2. Audio interface 3. Other device 4. Back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_selected_input_devices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_selected_input_devices.snap index 919bf16779..3858bae3b7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_selected_input_devices.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_selected_input_devices.snap @@ -8,6 +8,7 @@ expression: "render_bottom_popup(&chat, 80)" 1. System default › 2. Audio interface (current) + a. Input channels All channels (mixed) 3. Other device 4. Back diff --git a/codex-rs/voice-host/src/devices.rs b/codex-rs/voice-host/src/devices.rs index 4d5a88c0c2..d28eba4397 100644 --- a/codex-rs/voice-host/src/devices.rs +++ b/codex-rs/voice-host/src/devices.rs @@ -3,6 +3,9 @@ //! Capture and actual rendered output carry device timing. //! References start with worker service; unmute rejects earlier device capture buffers. +#[path = "input_channel.rs"] +mod input_channel; + #[path = "audio_sink.rs"] mod audio_sink; #[path = "device_buffers.rs"] @@ -163,6 +166,7 @@ impl Devices { .default_output_config() .map_err(|_| io::Error::other("speaker configuration unavailable"))?; let input_stream_config = bounded_stream_config(&input_config)?; + let source = input_channel::InputChannel::new(selection.channel, input_config.channels())?; let output_stream_config = bounded_stream_config(&output_config)?; let buffers = Arc::new(Buffers::new( input_config.sample_rate(), @@ -186,6 +190,7 @@ impl Devices { build_input, &input, &input_stream_config, + source, buffers.clone() ) .map_err(|_| io::Error::other("failed to open microphone"))?; @@ -367,6 +372,7 @@ fn handle_stream_error(buffers: &Buffers, error: cpal::Error) { fn build_input( device: &cpal::Device, config: &cpal::StreamConfig, + source: input_channel::InputChannel, buffers: Arc, ) -> Result where @@ -417,11 +423,7 @@ where generation, }; for (output, input) in frame.samples.iter_mut().zip(chunk.chunks_exact(channels)) { - *output = input - .iter() - .map(|sample| f32::from_sample(*sample)) - .sum::() - / channels as f32; + *output = source.sample(input); if !output.is_finite() { buffers.failed.store(true, Ordering::Release); return; diff --git a/codex-rs/voice-host/src/input_channel.rs b/codex-rs/voice-host/src/input_channel.rs new file mode 100644 index 0000000000..0b14d02b23 --- /dev/null +++ b/codex-rs/voice-host/src/input_channel.rs @@ -0,0 +1,45 @@ +//! Mixes validated microphone channels before metering or processing captured audio. +//! Unset selections retain the legacy mix; explicit selections never fall back to other channels. + +use std::io; +use std::num::NonZeroU16; + +use cpal::FromSample; +use cpal::Sample; + +pub(super) struct InputChannel(Vec); + +impl InputChannel { + pub(super) fn new(channel: Option>, channels: u16) -> io::Result { + let mut indices: Vec<_> = channel.map_or_else( + || (0..usize::from(channels)).collect(), + |selected| { + selected + .into_iter() + .map(|channel| usize::from(channel.get() - 1)) + .collect() + }, + ); + if indices.is_empty() || indices.iter().any(|index| *index >= usize::from(channels)) { + return Err(io::Error::other("selected microphone channels unavailable")); + } + indices.sort_unstable(); + indices.dedup(); + Ok(Self(indices)) + } + + pub(super) fn sample(&self, frame: &[T]) -> f32 + where + f32: FromSample, + { + self.0 + .iter() + .map(|index| f32::from_sample(frame[*index])) + .sum::() + / self.0.len() as f32 + } +} + +#[cfg(test)] +#[path = "input_channel_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/input_channel_tests.rs b/codex-rs/voice-host/src/input_channel_tests.rs new file mode 100644 index 0000000000..e3c7807f63 --- /dev/null +++ b/codex-rs/voice-host/src/input_channel_tests.rs @@ -0,0 +1,76 @@ +//! Multichannel capture regression coverage independent of physical hardware. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn microphone_selection_excludes_playback_channels() { + let source = InputChannel::new( + Some(vec![NonZeroU16::new(/*n*/ 1).unwrap()]), + /*channels*/ 16, + ) + .unwrap(); + let mut plugged_in = [0.0_f32; 16]; + plugged_in[0] = 0.25; + plugged_in[2] = 0.8; + plugged_in[3] = 0.7; + let mut unplugged = plugged_in; + unplugged[0] = 0.0; + let interleaved = [plugged_in, unplugged].concat(); + let captured: Vec<_> = interleaved + .chunks_exact(/*chunk_size*/ 16) + .map(|frame| source.sample(frame)) + .collect(); + assert_eq!(captured, vec![0.25, 0.0]); +} + +#[test] +fn selected_channel_converts_integer_samples_without_attenuation() { + let source = InputChannel::new( + Some(vec![NonZeroU16::new(/*n*/ 2).unwrap()]), + /*channels*/ 2, + ) + .unwrap(); + assert_eq!(source.sample(&[0_i16, 16384]), 0.5); +} + +#[test] +fn unset_channel_preserves_mixing() { + let source = InputChannel::new(/*channel*/ None, /*channels*/ 2).unwrap(); + assert_eq!(source.sample(&[0.25_f32, 0.75]), 0.5); +} + +#[test] +fn unavailable_channel_is_rejected() { + assert!( + InputChannel::new( + Some(vec![NonZeroU16::new(/*n*/ 17).unwrap()]), + /*channels*/ 16 + ) + .is_err() + ); + assert!(InputChannel::new(/*channel*/ None, /*channels*/ 0).is_err()); +} + +#[test] +fn subset_mixes_only_selected_inputs_and_deduplicates() { + let selection = Some(vec![ + NonZeroU16::new(/*n*/ 2).unwrap(), + NonZeroU16::new(/*n*/ 1).unwrap(), + NonZeroU16::new(/*n*/ 2).unwrap(), + ]); + let source = InputChannel::new(selection, /*channels*/ 4).unwrap(); + assert_eq!(source.sample(&[0.2_f32, 0.6, 0.9, 0.9]), 0.4); + assert_eq!(source.sample(&[0.0_f32, 0.0, 0.9, 0.9]), 0.0); + assert!(InputChannel::new(Some(vec![]), /*channels*/ 4).is_err()); + assert!( + InputChannel::new( + Some(vec![ + NonZeroU16::new(/*n*/ 1).unwrap(), + NonZeroU16::new(/*n*/ 5).unwrap() + ]), + /*channels*/ 4 + ) + .is_err() + ); +}