settings: Add ability to select audio input/output devices for collab (#49015)

Jakub Konka and Zed Zippy created

This PR adds ability to select and test audio input/output devices for
use in collaboration setting (which is what the team at Zed relies
heavily on). Currently, we only ever used whatever the system default is
and it worked well until it didn't - for some reason, when I am on my
Linux laptop, I am unable to force Zed to use my external mic +
headphones via external USB audio interface. With this PR, now I can
list all available devices and select the one I want.

There are still a couple of caveats that we should be aware of:
* I've decided to list *all* available devices meaning on Linux it is
quite possible that you may discover that what your desktop environment
is reporting to you is a significantly shorter list than what your sound
framework/hw is actually exposing. I think this makes sense given my
inexperience with audio drivers/devices and frameworks on various OSes
so that we get full control over what is available with the goal of
being able to come up with some filtering heuristic as we go along.
* We currently populate the list of available audio devices only once at
startup meaning if you unplug your device while you have Zed running
this will not register until you restart Zed which is a PITA. However,
in order to keep the changes manageable I thought it would be best to do
minimal work in this regard now, and iterate on this some more in the
near future. After all, we don't really monitor device changes on any
platform except macOS anyhow, so it might be the case that when I get
round to implementing this I will have the opportunity to tackle both at
the same time.
* In order to get a valid list of all audio devices using `cpal` crate
(which is the building block of `rodio`), I had to bump `cpal` to 0.17,
and pin `rodio` to a more recent commit sha as a result, so if you see
any regressions, lemme know and/or feel free to revert this PR.
* Finally, I've done my best to integrate this with the settings UI, but
I am sure more could be done in terms of styling, etc.

Some screenshots:

<img width="1152" height="949" alt="Screenshot From 2026-02-12 11-40-04"
src="https://github.com/user-attachments/assets/e147c153-1902-49d6-bf68-3ac317a6a7b0"
/>
<img width="1152" height="949" alt="Screenshot From 2026-02-12 11-40-16"
src="https://github.com/user-attachments/assets/b4e9a2f8-b38e-4de0-b910-067cc432b5bc"
/>


Release Notes:

- Added ability to select audio input/output devices as part of
Collaboration page in Settings. Added ability to test selected devices
with a simple playback loop routing input directly into output for
easier debugging of your audio devices.

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

Cargo.lock                                               |  63 +
Cargo.toml                                               |   4 
assets/settings/default.json                             |  12 
crates/audio/Cargo.toml                                  |   1 
crates/audio/src/audio.rs                                | 236 +++++-
crates/audio/src/audio_settings.rs                       |  22 
crates/livekit_client/src/livekit_client/playback.rs     |  14 
crates/livekit_client/src/record.rs                      |   7 
crates/settings_content/src/settings_content.rs          |  42 +
crates/settings_ui/Cargo.toml                            |   3 
crates/settings_ui/src/page_data.rs                      |  73 ++
crates/settings_ui/src/pages.rs                          |   6 
crates/settings_ui/src/pages/audio_input_output_setup.rs | 152 +++++
crates/settings_ui/src/pages/audio_test_window.rs        | 304 ++++++++++
crates/settings_ui/src/settings_ui.rs                    |  12 
15 files changed, 846 insertions(+), 105 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -572,9 +572,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
 
 [[package]]
 name = "alsa"
-version = "0.9.1"
+version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
+checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
 dependencies = [
  "alsa-sys",
  "bitflags 2.10.0",
@@ -1319,6 +1319,7 @@ dependencies = [
  "anyhow",
  "async-tar",
  "collections",
+ "cpal",
  "crossbeam",
  "denoise",
  "gpui",
@@ -3996,9 +3997,9 @@ dependencies = [
 
 [[package]]
 name = "cpal"
-version = "0.16.0"
+version = "0.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
+checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
 dependencies = [
  "alsa",
  "coreaudio-rs 0.13.0",
@@ -4006,18 +4007,22 @@ dependencies = [
  "jni",
  "js-sys",
  "libc",
- "mach2 0.4.3",
+ "mach2 0.5.0",
  "ndk",
  "ndk-context",
  "num-derive",
  "num-traits",
+ "objc2",
  "objc2-audio-toolbox",
+ "objc2-avf-audio",
  "objc2-core-audio",
  "objc2-core-audio-types",
+ "objc2-core-foundation",
+ "objc2-foundation",
  "wasm-bindgen",
  "wasm-bindgen-futures",
  "web-sys",
- "windows 0.54.0",
+ "windows 0.61.3",
 ]
 
 [[package]]
@@ -10938,16 +10943,27 @@ dependencies = [
  "objc2-foundation",
 ]
 
+[[package]]
+name = "objc2-avf-audio"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfc1d11521c211a7ebe17739fc806719da41f56c6b3f949d9861b459188ce910"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
 [[package]]
 name = "objc2-core-audio"
-version = "0.3.2"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
+checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82"
 dependencies = [
  "dispatch2",
  "objc2",
  "objc2-core-audio-types",
  "objc2-core-foundation",
+ "objc2-foundation",
 ]
 
 [[package]]
@@ -10967,7 +10983,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
 dependencies = [
  "bitflags 2.10.0",
+ "block2",
  "dispatch2",
+ "libc",
  "objc2",
 ]
 
@@ -10984,6 +11002,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
 dependencies = [
  "bitflags 2.10.0",
+ "block2",
+ "libc",
  "objc2",
  "objc2-core-foundation",
 ]
@@ -14103,12 +14123,14 @@ dependencies = [
 [[package]]
 name = "rodio"
 version = "0.21.1"
-source = "git+https://github.com/RustAudio/rodio?rev=e2074c6c2acf07b57cf717e076bdda7a9ac6e70b#e2074c6c2acf07b57cf717e076bdda7a9ac6e70b"
+source = "git+https://github.com/RustAudio/rodio?rev=e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a#e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a"
 dependencies = [
  "cpal",
  "dasp_sample",
  "hound",
  "num-rational",
+ "rand 0.9.2",
+ "rand_distr",
  "rtrb",
  "symphonia",
  "thiserror 2.0.17",
@@ -15233,12 +15255,14 @@ dependencies = [
  "agent_settings",
  "anyhow",
  "assets",
+ "audio",
  "bm25",
  "client",
  "codestral",
  "component",
  "copilot",
  "copilot_ui",
+ "cpal",
  "edit_prediction",
  "edit_prediction_ui",
  "editor",
@@ -15260,6 +15284,7 @@ dependencies = [
  "recent_projects",
  "regex",
  "release_channel",
+ "rodio",
  "schemars",
  "search",
  "serde",
@@ -19648,16 +19673,6 @@ dependencies = [
  "wasmtime-environ",
 ]
 
-[[package]]
-name = "windows"
-version = "0.54.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
-dependencies = [
- "windows-core 0.54.0",
- "windows-targets 0.52.6",
-]
-
 [[package]]
 name = "windows"
 version = "0.57.0"
@@ -19714,16 +19729,6 @@ dependencies = [
  "windows-core 0.61.2",
 ]
 
-[[package]]
-name = "windows-core"
-version = "0.54.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
-dependencies = [
- "windows-result 0.1.2",
- "windows-targets 0.52.6",
-]
-
 [[package]]
 name = "windows-core"
 version = "0.57.0"

Cargo.toml 🔗

@@ -390,7 +390,7 @@ remote_connection = { path = "crates/remote_connection" }
 remote_server = { path = "crates/remote_server" }
 repl = { path = "crates/repl" }
 reqwest_client = { path = "crates/reqwest_client" }
-rodio = { git = "https://github.com/RustAudio/rodio", rev ="e2074c6c2acf07b57cf717e076bdda7a9ac6e70b", features = ["wav", "playback", "wav_output", "recording"] }
+rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] }
 rope = { path = "crates/rope" }
 rpc = { path = "crates/rpc" }
 rules_library = { path = "crates/rules_library" }
@@ -514,7 +514,7 @@ convert_case = "0.8.0"
 core-foundation = "=0.10.0"
 core-foundation-sys = "0.8.6"
 core-video = { version = "0.4.3", features = ["metal"] }
-cpal = "0.16"
+cpal = "0.17"
 crash-handler = "0.6"
 criterion = { version = "0.5", features = ["html_reports"] }
 ctor = "0.4.0"

assets/settings/default.json 🔗

@@ -486,6 +486,18 @@
     //
     // You need to rejoin a call for this setting to apply
     "experimental.legacy_audio_compatible": true,
+    // Requires 'rodio_audio: true'
+    //
+    // Select specific output audio device.
+    // `null` means use system default.
+    // Any unrecognized output device will fall back to system default.
+    "experimental.output_audio_device": null,
+    // Requires 'rodio_audio: true'
+    //
+    // Select specific input audio device.
+    // `null` means use system default.
+    // Any unrecognized input device will fall back to system default.
+    "experimental.input_audio_device": null,
   },
   // Scrollbar related settings
   "scrollbar": {

crates/audio/Cargo.toml 🔗

@@ -16,6 +16,7 @@ doctest = false
 anyhow.workspace = true
 async-tar.workspace = true
 collections.workspace = true
+cpal.workspace = true
 crossbeam.workspace = true
 gpui.workspace = true
 denoise = { path = "../denoise" }

crates/audio/src/audio.rs 🔗

@@ -1,14 +1,16 @@
 use anyhow::{Context as _, Result};
 use collections::HashMap;
-use gpui::{App, BackgroundExecutor, BorrowAppContext, Global};
-use log::info;
+use cpal::{
+    DeviceDescription, DeviceId, default_host,
+    traits::{DeviceTrait, HostTrait},
+};
+use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global};
 
 #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
 mod non_windows_and_freebsd_deps {
-    pub(super) use gpui::AsyncApp;
+    pub(super) use cpal::Sample;
     pub(super) use libwebrtc::native::apm;
     pub(super) use parking_lot::Mutex;
-    pub(super) use rodio::cpal::Sample;
     pub(super) use rodio::source::LimitSettings;
     pub(super) use std::sync::Arc;
 }
@@ -17,7 +19,10 @@ mod non_windows_and_freebsd_deps {
 use non_windows_and_freebsd_deps::*;
 
 use rodio::{
-    Decoder, OutputStream, OutputStreamBuilder, Source, mixer::Mixer, nz, source::Buffered,
+    Decoder, DeviceSinkBuilder, MixerDeviceSink, Source,
+    mixer::Mixer,
+    nz,
+    source::{AutomaticGainControlSettings, Buffered},
 };
 use settings::Settings;
 use std::{io::Cursor, num::NonZero, path::PathBuf, sync::atomic::Ordering, time::Duration};
@@ -49,6 +54,15 @@ pub const REPLAY_DURATION: Duration = Duration::from_secs(30);
 
 pub fn init(cx: &mut App) {
     LIVE_SETTINGS.initialize(cx);
+    // TODO(jk): this is currently cached only once at startup - we should observe and react instead
+    let task = cx
+        .background_executor()
+        .spawn(async move { get_available_audio_devices() });
+    cx.spawn(async move |cx: &mut AsyncApp| {
+        let devices = task.await;
+        cx.update(|cx| cx.set_global(AvailableAudioDevices(devices)))
+    })
+    .detach();
 }
 
 #[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)]
@@ -79,8 +93,7 @@ impl Sound {
 }
 
 pub struct Audio {
-    output_handle: Option<OutputStream>,
-    output_mixer: Option<Mixer>,
+    output_handle: Option<MixerDeviceSink>,
     #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
     pub echo_canceller: Arc<Mutex<apm::AudioProcessingModule>>,
     source_cache: HashMap<Sound, Buffered<Decoder<Cursor<Vec<u8>>>>>,
@@ -91,7 +104,6 @@ impl Default for Audio {
     fn default() -> Self {
         Self {
             output_handle: Default::default(),
-            output_mixer: Default::default(),
             #[cfg(not(any(
                 all(target_os = "windows", target_env = "gnu"),
                 target_os = "freebsd"
@@ -108,51 +120,58 @@ impl Default for Audio {
 impl Global for Audio {}
 
 impl Audio {
-    fn ensure_output_exists(&mut self) -> Result<&Mixer> {
+    fn ensure_output_exists(&mut self, output_audio_device: Option<DeviceId>) -> Result<&Mixer> {
         #[cfg(debug_assertions)]
         log::warn!(
             "Audio does not sound correct without optimizations. Use a release build to debug audio issues"
         );
 
         if self.output_handle.is_none() {
-            let output_handle = OutputStreamBuilder::open_default_stream()
-                .context("Could not open default output stream")?;
-            info!("Output stream: {:?}", output_handle);
-            self.output_handle = Some(output_handle);
-            if let Some(output_handle) = &self.output_handle {
-                let (mixer, source) = rodio::mixer::mixer(CHANNEL_COUNT, SAMPLE_RATE);
-                // or the mixer will end immediately as its empty.
-                mixer.add(rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE));
-                self.output_mixer = Some(mixer);
-
-                // The webrtc apm is not yet compiling for windows & freebsd
-                #[cfg(not(any(
-                    any(all(target_os = "windows", target_env = "gnu")),
-                    target_os = "freebsd"
-                )))]
-                let echo_canceller = Arc::clone(&self.echo_canceller);
-                #[cfg(not(any(
-                    any(all(target_os = "windows", target_env = "gnu")),
-                    target_os = "freebsd"
-                )))]
-                let source = source.inspect_buffer::<BUFFER_SIZE, _>(move |buffer| {
-                    let mut buf: [i16; _] = buffer.map(|s| s.to_sample());
-                    echo_canceller
-                        .lock()
-                        .process_reverse_stream(
-                            &mut buf,
-                            SAMPLE_RATE.get() as i32,
-                            CHANNEL_COUNT.get().into(),
-                        )
-                        .expect("Audio input and output threads should not panic");
-                });
+            let output_handle = open_output_stream(output_audio_device)?;
+
+            // The webrtc apm is not yet compiling for windows & freebsd
+            #[cfg(not(any(
+                any(all(target_os = "windows", target_env = "gnu")),
+                target_os = "freebsd"
+            )))]
+            let echo_canceller = Arc::clone(&self.echo_canceller);
+
+            #[cfg(not(any(
+                any(all(target_os = "windows", target_env = "gnu")),
+                target_os = "freebsd"
+            )))]
+            {
+                let source = rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE)
+                    .inspect_buffer::<BUFFER_SIZE, _>(move |buffer| {
+                        let mut buf: [i16; _] = buffer.map(|s| s.to_sample());
+                        echo_canceller
+                            .lock()
+                            .process_reverse_stream(
+                                &mut buf,
+                                SAMPLE_RATE.get() as i32,
+                                CHANNEL_COUNT.get().into(),
+                            )
+                            .expect("Audio input and output threads should not panic");
+                    });
+                output_handle.mixer().add(source);
+            }
+
+            #[cfg(any(
+                any(all(target_os = "windows", target_env = "gnu")),
+                target_os = "freebsd"
+            ))]
+            {
+                let source = rodio::source::Zero::<f32>::new(CHANNEL_COUNT, SAMPLE_RATE);
                 output_handle.mixer().add(source);
             }
+
+            self.output_handle = Some(output_handle);
         }
 
         Ok(self
-            .output_mixer
+            .output_handle
             .as_ref()
+            .map(|h| h.mixer())
             .expect("we only get here if opening the outputstream succeeded"))
     }
 
@@ -165,20 +184,7 @@ impl Audio {
 
     #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
     pub fn open_microphone(voip_parts: VoipParts) -> anyhow::Result<impl Source> {
-        let stream = rodio::microphone::MicrophoneBuilder::new()
-            .default_device()?
-            .default_config()?
-            .prefer_sample_rates([
-                SAMPLE_RATE, // sample rates trivially resamplable to `SAMPLE_RATE`
-                SAMPLE_RATE.saturating_mul(nz!(2)),
-                SAMPLE_RATE.saturating_mul(nz!(3)),
-                SAMPLE_RATE.saturating_mul(nz!(4)),
-            ])
-            .prefer_channel_counts([nz!(1), nz!(2), nz!(3), nz!(4)])
-            .prefer_buffer_sizes(512..)
-            .open_stream()?;
-        info!("Opened microphone: {:?}", stream.config());
-
+        let stream = open_input_stream(voip_parts.input_audio_device)?;
         let stream = stream
             .possibly_disconnected_channels_to_mono()
             .constant_samplerate(SAMPLE_RATE)
@@ -204,7 +210,12 @@ impl Audio {
             })
             .denoise()
             .context("Could not set up denoiser")?
-            .automatic_gain_control(0.90, 1.0, 0.0, 5.0)
+            .automatic_gain_control(AutomaticGainControlSettings {
+                target_level: 0.90,
+                attack_time: Duration::from_secs(1),
+                release_time: Duration::from_secs(0),
+                absolute_max_gain: 5.0,
+            })
             .periodic_access(Duration::from_millis(100), move |agc_source| {
                 agc_source
                     .set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed));
@@ -234,16 +245,22 @@ impl Audio {
     ) -> anyhow::Result<()> {
         let (replay_source, source) = source
             .constant_params(CHANNEL_COUNT, SAMPLE_RATE)
-            .automatic_gain_control(0.90, 1.0, 0.0, 5.0)
+            .automatic_gain_control(AutomaticGainControlSettings {
+                target_level: 0.90,
+                attack_time: Duration::from_secs(1),
+                release_time: Duration::from_secs(0),
+                absolute_max_gain: 5.0,
+            })
             .periodic_access(Duration::from_millis(100), move |agc_source| {
                 agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed));
             })
             .replayable(REPLAY_DURATION)
             .expect("REPLAY_DURATION is longer than 100ms");
+        let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
 
         cx.update_default_global(|this: &mut Self, _cx| {
             let output_mixer = this
-                .ensure_output_exists()
+                .ensure_output_exists(output_audio_device)
                 .context("Could not get output mixer")?;
             output_mixer.add(source);
             if is_staff {
@@ -254,10 +271,11 @@ impl Audio {
     }
 
     pub fn play_sound(sound: Sound, cx: &mut App) {
+        let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
         cx.update_default_global(|this: &mut Self, cx| {
             let source = this.sound_source(sound, cx).log_err()?;
             let output_mixer = this
-                .ensure_output_exists()
+                .ensure_output_exists(output_audio_device)
                 .context("Could not get output mixer")
                 .log_err()?;
 
@@ -298,6 +316,7 @@ pub struct VoipParts {
     echo_canceller: Arc<Mutex<apm::AudioProcessingModule>>,
     replays: replays::Replays,
     legacy_audio_compatible: bool,
+    input_audio_device: Option<DeviceId>,
 }
 
 #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
@@ -309,11 +328,110 @@ impl VoipParts {
         let legacy_audio_compatible =
             AudioSettings::try_read_global(cx, |settings| settings.legacy_audio_compatible)
                 .unwrap_or(true);
+        let input_audio_device =
+            AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone())
+                .flatten();
 
         Ok(Self {
             legacy_audio_compatible,
             echo_canceller: apm,
             replays,
+            input_audio_device,
         })
     }
 }
+
+pub fn open_input_stream(
+    device_id: Option<DeviceId>,
+) -> anyhow::Result<rodio::microphone::Microphone> {
+    let builder = rodio::microphone::MicrophoneBuilder::new();
+    let builder = if let Some(id) = device_id {
+        // TODO(jk): upstream patch
+        // if let Some(input_device) = default_host().device_by_id(id) {
+        //     builder.device(input_device);
+        // }
+        let mut found = None;
+        for input in rodio::microphone::available_inputs()? {
+            if input.clone().into_inner().id()? == id {
+                found = Some(builder.device(input));
+                break;
+            }
+        }
+        found.unwrap_or_else(|| builder.default_device())?
+    } else {
+        builder.default_device()?
+    };
+    let stream = builder
+        .default_config()?
+        .prefer_sample_rates([
+            SAMPLE_RATE,
+            SAMPLE_RATE.saturating_mul(rodio::nz!(2)),
+            SAMPLE_RATE.saturating_mul(rodio::nz!(3)),
+            SAMPLE_RATE.saturating_mul(rodio::nz!(4)),
+        ])
+        .prefer_channel_counts([rodio::nz!(1), rodio::nz!(2), rodio::nz!(3), rodio::nz!(4)])
+        .prefer_buffer_sizes(512..)
+        .open_stream()?;
+    log::info!("Opened microphone: {:?}", stream.config());
+    Ok(stream)
+}
+
+pub fn open_output_stream(device_id: Option<DeviceId>) -> anyhow::Result<MixerDeviceSink> {
+    let output_handle = if let Some(id) = device_id {
+        if let Some(device) = default_host().device_by_id(&id) {
+            DeviceSinkBuilder::from_device(device)?.open_stream()
+        } else {
+            DeviceSinkBuilder::open_default_sink()
+        }
+    } else {
+        DeviceSinkBuilder::open_default_sink()
+    };
+    let mut output_handle = output_handle.context("Could not open output stream")?;
+    output_handle.log_on_drop(false);
+    log::info!("Output stream: {:?}", output_handle);
+    Ok(output_handle)
+}
+
+#[derive(Clone, Debug)]
+pub struct AudioDeviceInfo {
+    pub id: DeviceId,
+    pub desc: DeviceDescription,
+}
+
+impl AudioDeviceInfo {
+    pub fn matches_input(&self, is_input: bool) -> bool {
+        if is_input {
+            self.desc.supports_input()
+        } else {
+            self.desc.supports_output()
+        }
+    }
+
+    pub fn matches(&self, id: &DeviceId, is_input: bool) -> bool {
+        &self.id == id && self.matches_input(is_input)
+    }
+}
+
+impl std::fmt::Display for AudioDeviceInfo {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{} ({})", self.desc.name(), self.id)
+    }
+}
+
+fn get_available_audio_devices() -> Vec<AudioDeviceInfo> {
+    let Some(devices) = default_host().devices().ok() else {
+        return Vec::new();
+    };
+    devices
+        .filter_map(|device| {
+            let id = device.id().ok()?;
+            let desc = device.description().ok()?;
+            Some(AudioDeviceInfo { id, desc })
+        })
+        .collect()
+}
+
+#[derive(Default, Clone, Debug)]
+pub struct AvailableAudioDevices(pub Vec<AudioDeviceInfo>);
+
+impl Global for AvailableAudioDevices {}

crates/audio/src/audio_settings.rs 🔗

@@ -1,5 +1,9 @@
-use std::sync::atomic::{AtomicBool, Ordering};
+use std::{
+    str::FromStr,
+    sync::atomic::{AtomicBool, Ordering},
+};
 
+use cpal::DeviceId;
 use gpui::App;
 use settings::{RegisterSetting, Settings, SettingsStore};
 
@@ -38,6 +42,14 @@ pub struct AudioSettings {
     ///
     /// You need to rejoin a call for this setting to apply
     pub legacy_audio_compatible: bool,
+    /// Requires 'rodio_audio: true'
+    ///
+    /// Select specific output audio device.
+    pub output_audio_device: Option<DeviceId>,
+    /// Requires 'rodio_audio: true'
+    ///
+    /// Select specific input audio device.
+    pub input_audio_device: Option<DeviceId>,
 }
 
 /// Configuration of audio in Zed
@@ -50,6 +62,14 @@ impl Settings for AudioSettings {
             auto_speaker_volume: audio.auto_speaker_volume.unwrap(),
             denoise: audio.denoise.unwrap(),
             legacy_audio_compatible: audio.legacy_audio_compatible.unwrap(),
+            output_audio_device: audio
+                .output_audio_device
+                .as_ref()
+                .and_then(|x| x.0.as_ref().and_then(|id| DeviceId::from_str(&id).ok())),
+            input_audio_device: audio
+                .input_audio_device
+                .as_ref()
+                .and_then(|x| x.0.as_ref().and_then(|id| DeviceId::from_str(&id).ok())),
         }
     }
 }

crates/livekit_client/src/livekit_client/playback.rs 🔗

@@ -298,13 +298,13 @@ impl AudioStack {
                                         num_channels,
                                         sample_rate,
                                         output_config.channels() as u32,
-                                        output_config.sample_rate().0,
+                                        output_config.sample_rate(),
                                     );
                                     buf = sampled.to_vec();
                                     apm.lock()
                                         .process_reverse_stream(
                                             &mut buf,
-                                            output_config.sample_rate().0 as i32,
+                                            output_config.sample_rate() as i32,
                                             output_config.channels() as i32,
                                         )
                                         .ok();
@@ -348,14 +348,14 @@ impl AudioStack {
                 .name("AudioCapture".to_owned())
                 .spawn(move || {
                     maybe!({
-                        if let Some(name) = device.name().ok() {
-                            log::info!("Using microphone: {}", name)
+                        if let Some(desc) = device.description().ok() {
+                            log::info!("Using microphone: {}", desc.name())
                         } else {
                             log::info!("Using microphone: <unknown>");
                         }
 
                         let ten_ms_buffer_size =
-                            (config.channels() as u32 * config.sample_rate().0 / 100) as usize;
+                            (config.channels() as u32 * config.sample_rate() / 100) as usize;
                         let mut buf: Vec<i16> = Vec::with_capacity(ten_ms_buffer_size);
 
                         let stream = device
@@ -380,9 +380,9 @@ impl AudioStack {
                                             let mut sampled = resampler
                                                 .remix_and_resample(
                                                     buf.as_slice(),
-                                                    config.sample_rate().0 / 100,
+                                                    config.sample_rate() / 100,
                                                     config.channels() as u32,
-                                                    config.sample_rate().0,
+                                                    config.sample_rate(),
                                                     num_channels,
                                                     sample_rate,
                                                 )

crates/livekit_client/src/record.rs 🔗

@@ -21,7 +21,10 @@ pub struct CaptureInput {
 impl CaptureInput {
     pub fn start() -> anyhow::Result<Self> {
         let (device, config) = crate::default_device(true)?;
-        let name = device.name().unwrap_or("<unknown>".to_string());
+        let name = device
+            .description()
+            .map(|desc| desc.name().to_string())
+            .unwrap_or("<unknown>".to_string());
         log::info!("Using microphone: {}", name);
 
         let samples = Arc::new(Mutex::new(Vec::new()));
@@ -86,7 +89,7 @@ fn write_out(
     let samples: Vec<f32> = SampleTypeConverter::<_, f32>::new(samples.into_iter()).collect();
     let mut samples = SamplesBuffer::new(
         NonZero::new(config.channels()).expect("config channel is never zero"),
-        NonZero::new(config.sample_rate().0).expect("config sample_rate is never zero"),
+        NonZero::new(config.sample_rate()).expect("config sample_rate is never zero"),
         samples,
     );
     match rodio::wav_to_file(&mut samples, path) {

crates/settings_content/src/settings_content.rs 🔗

@@ -400,6 +400,48 @@ pub struct AudioSettingsContent {
     /// You need to rejoin a call for this setting to apply
     #[serde(rename = "experimental.legacy_audio_compatible")]
     pub legacy_audio_compatible: Option<bool>,
+    /// Requires 'rodio_audio: true'
+    ///
+    /// Select specific output audio device.
+    #[serde(rename = "experimental.output_audio_device")]
+    pub output_audio_device: Option<AudioOutputDeviceName>,
+    /// Requires 'rodio_audio: true'
+    ///
+    /// Select specific input audio device.
+    #[serde(rename = "experimental.input_audio_device")]
+    pub input_audio_device: Option<AudioInputDeviceName>,
+}
+
+#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
+#[serde(transparent)]
+pub struct AudioOutputDeviceName(pub Option<String>);
+
+impl AsRef<Option<String>> for AudioInputDeviceName {
+    fn as_ref(&self) -> &Option<String> {
+        &self.0
+    }
+}
+
+impl From<Option<String>> for AudioInputDeviceName {
+    fn from(value: Option<String>) -> Self {
+        Self(value)
+    }
+}
+
+#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
+#[serde(transparent)]
+pub struct AudioInputDeviceName(pub Option<String>);
+
+impl AsRef<Option<String>> for AudioOutputDeviceName {
+    fn as_ref(&self) -> &Option<String> {
+        &self.0
+    }
+}
+
+impl From<Option<String>> for AudioOutputDeviceName {
+    fn from(value: Option<String>) -> Self {
+        Self(value)
+    }
 }
 
 /// Control what info is collected by Zed.

crates/settings_ui/Cargo.toml 🔗

@@ -19,11 +19,13 @@ test-support = []
 agent.workspace = true
 agent_settings.workspace = true
 anyhow.workspace = true
+audio.workspace = true
 bm25 = "2.3.2"
 component.workspace = true
 codestral.workspace = true
 copilot.workspace = true
 copilot_ui.workspace = true
+cpal.workspace = true
 edit_prediction.workspace = true
 edit_prediction_ui.workspace = true
 editor.workspace = true
@@ -42,6 +44,7 @@ regex.workspace = true
 platform_title_bar.workspace = true
 project.workspace = true
 release_channel.workspace = true
+rodio.workspace = true
 schemars.workspace = true
 search.workspace = true
 serde.workspace = true

crates/settings_ui/src/page_data.rs 🔗

@@ -1,6 +1,9 @@
 use gpui::{Action as _, App};
 use itertools::Itertools as _;
-use settings::{LanguageSettingsContent, SemanticTokens, SettingsContent};
+use settings::{
+    AudioInputDeviceName, AudioOutputDeviceName, LanguageSettingsContent, SemanticTokens,
+    SettingsContent,
+};
 use std::sync::{Arc, OnceLock};
 use strum::{EnumMessage, IntoDiscriminant as _, VariantArray};
 use ui::IntoElement;
@@ -8,7 +11,10 @@ use ui::IntoElement;
 use crate::{
     ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata,
     SettingsPage, SettingsPageItem, SubPageLink, USER, active_language, all_language_names,
-    pages::{render_edit_prediction_setup_page, render_tool_permissions_setup_page},
+    pages::{
+        open_audio_test_window, render_edit_prediction_setup_page,
+        render_tool_permissions_setup_page,
+    },
 };
 
 const DEFAULT_STRING: String = String::new();
@@ -16,6 +22,11 @@ const DEFAULT_STRING: String = String::new();
 /// to avoid the "NO DEFAULT" case.
 const DEFAULT_EMPTY_STRING: Option<&String> = Some(&DEFAULT_STRING);
 
+const DEFAULT_AUDIO_OUTPUT: AudioOutputDeviceName = AudioOutputDeviceName(None);
+const DEFAULT_EMPTY_AUDIO_OUTPUT: Option<&AudioOutputDeviceName> = Some(&DEFAULT_AUDIO_OUTPUT);
+const DEFAULT_AUDIO_INPUT: AudioInputDeviceName = AudioInputDeviceName(None);
+const DEFAULT_EMPTY_AUDIO_INPUT: Option<&AudioInputDeviceName> = Some(&DEFAULT_AUDIO_INPUT);
+
 macro_rules! concat_sections {
     (@vec, $($arr:expr),+ $(,)?) => {{
         let total_len = 0_usize $(+ $arr.len())+;
@@ -1252,6 +1263,7 @@ fn keymap_page() -> SettingsPage {
                         .ok();
                     window.remove_window();
                 }),
+                files: USER,
             }),
         ]
     }
@@ -6759,7 +6771,7 @@ fn collaboration_page() -> SettingsPage {
         ]
     }
 
-    fn experimental_section() -> [SettingsPageItem; 6] {
+    fn experimental_section() -> [SettingsPageItem; 9] {
         [
             SettingsPageItem::SectionHeader("Experimental"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -6854,6 +6866,61 @@ fn collaboration_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::ActionLink(ActionLink {
+                title: "Test Audio".into(),
+                description: Some("Test your microphone and speaker setup".into()),
+                button_text: "Test Audio".into(),
+                on_click: Arc::new(|_settings_window, window, cx| {
+                    open_audio_test_window(window, cx);
+                }),
+                files: USER,
+            }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Output Audio Device",
+                description: "Select output audio device",
+                field: Box::new(SettingField {
+                    json_path: Some("audio.experimental.output_audio_device"),
+                    pick: |settings_content| {
+                        settings_content
+                            .audio
+                            .as_ref()?
+                            .output_audio_device
+                            .as_ref()
+                            .or(DEFAULT_EMPTY_AUDIO_OUTPUT)
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .audio
+                            .get_or_insert_default()
+                            .output_audio_device = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Input Audio Device",
+                description: "Select input audio device",
+                field: Box::new(SettingField {
+                    json_path: Some("audio.experimental.input_audio_device"),
+                    pick: |settings_content| {
+                        settings_content
+                            .audio
+                            .as_ref()?
+                            .input_audio_device
+                            .as_ref()
+                            .or(DEFAULT_EMPTY_AUDIO_INPUT)
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .audio
+                            .get_or_insert_default()
+                            .input_audio_device = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
         ]
     }
 

crates/settings_ui/src/pages.rs 🔗

@@ -1,6 +1,12 @@
+mod audio_input_output_setup;
+mod audio_test_window;
 mod edit_prediction_provider_setup;
 mod tool_permissions_setup;
 
+pub(crate) use audio_input_output_setup::{
+    render_input_audio_device_dropdown, render_output_audio_device_dropdown,
+};
+pub(crate) use audio_test_window::open_audio_test_window;
 pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page;
 pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page;
 

crates/settings_ui/src/pages/audio_input_output_setup.rs 🔗

@@ -0,0 +1,152 @@
+use audio::{AudioDeviceInfo, AvailableAudioDevices};
+use cpal::DeviceId;
+use gpui::{AnyElement, App, ElementId, ReadGlobal, SharedString, Window};
+use settings::{AudioInputDeviceName, AudioOutputDeviceName, SettingsStore};
+use std::str::FromStr;
+use ui::{ContextMenu, DropdownMenu, DropdownStyle, IconPosition, IntoElement};
+use util::ResultExt;
+
+use crate::{SettingField, SettingsFieldMetadata, SettingsUiFile, update_settings_file};
+
+pub(crate) const SYSTEM_DEFAULT: &str = "System Default";
+
+pub(crate) fn get_current_device(
+    current_id: Option<&DeviceId>,
+    is_input: bool,
+    devices: &[AudioDeviceInfo],
+) -> Option<AudioDeviceInfo> {
+    let Some(current_id) = current_id else {
+        return None;
+    };
+    devices
+        .iter()
+        .find(|d| d.matches(current_id, is_input))
+        .cloned()
+}
+
+pub(crate) fn render_audio_device_dropdown<F>(
+    dropdown_id: impl Into<ElementId>,
+    current_device_id: Option<DeviceId>,
+    is_input: bool,
+    on_select: F,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement
+where
+    F: Fn(Option<DeviceId>, &mut Window, &mut App) + Clone + 'static,
+{
+    let devices = cx.default_global::<AvailableAudioDevices>().0.clone();
+    let current_device = get_current_device(current_device_id.as_ref(), is_input, &devices);
+
+    let menu = ContextMenu::build(window, cx, {
+        let current_device = current_device.clone();
+        move |mut menu, _, _cx| {
+            let is_system_default = current_device.is_none();
+            menu = menu.toggleable_entry(
+                SYSTEM_DEFAULT,
+                is_system_default,
+                IconPosition::Start,
+                None,
+                {
+                    let on_select = on_select.clone();
+                    move |window, cx| {
+                        on_select(None, window, cx);
+                    }
+                },
+            );
+
+            for device in devices.iter().filter(|d| d.matches_input(is_input)) {
+                let is_current = current_device
+                    .as_ref()
+                    .map(|info| info.matches(&device.id, is_input))
+                    .unwrap_or(false);
+                let device_id = device.id.clone();
+
+                menu = menu.toggleable_entry(
+                    device.to_string(),
+                    is_current,
+                    IconPosition::Start,
+                    None,
+                    {
+                        let on_select = on_select.clone();
+                        move |window, cx| {
+                            on_select(Some(device_id.clone()), window, cx);
+                        }
+                    },
+                );
+            }
+            menu
+        }
+    });
+
+    DropdownMenu::new(
+        dropdown_id,
+        current_device
+            .map(|info| info.desc.name().to_string())
+            .unwrap_or(SYSTEM_DEFAULT.to_string()),
+        menu,
+    )
+    .style(DropdownStyle::Outlined)
+    .full_width(true)
+    .into_any_element()
+}
+
+fn render_settings_audio_device_dropdown<T: AsRef<Option<String>> + From<Option<String>> + Send>(
+    field: SettingField<T>,
+    file: SettingsUiFile,
+    is_input: bool,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let (_, current_value): (_, Option<&T>) =
+        SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
+    let current_device_id =
+        current_value.and_then(|x| x.as_ref().clone().and_then(|x| DeviceId::from_str(&x).ok()));
+
+    let dropdown_id: SharedString = if is_input {
+        "input-audio-device-dropdown".into()
+    } else {
+        "output-audio-device-dropdown".into()
+    };
+
+    render_audio_device_dropdown(
+        dropdown_id,
+        current_device_id,
+        is_input,
+        move |device_id, window, cx| {
+            let value: Option<T> = device_id.map(|id| T::from(Some(id.to_string())));
+            update_settings_file(
+                file.clone(),
+                field.json_path,
+                window,
+                cx,
+                move |settings, _cx| {
+                    (field.write)(settings, value);
+                },
+            )
+            .log_err();
+        },
+        window,
+        cx,
+    )
+}
+
+pub fn render_input_audio_device_dropdown(
+    field: SettingField<AudioInputDeviceName>,
+    file: SettingsUiFile,
+    _metadata: Option<&SettingsFieldMetadata>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    render_settings_audio_device_dropdown(field, file, true, window, cx)
+}
+
+pub fn render_output_audio_device_dropdown(
+    field: SettingField<AudioOutputDeviceName>,
+    file: SettingsUiFile,
+    _metadata: Option<&SettingsFieldMetadata>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    render_settings_audio_device_dropdown(field, file, false, window, cx)
+}

crates/settings_ui/src/pages/audio_test_window.rs 🔗

@@ -0,0 +1,304 @@
+use audio::{AudioSettings, CHANNEL_COUNT, RodioExt, SAMPLE_RATE};
+use cpal::DeviceId;
+use gpui::{
+    App, Context, Entity, FocusHandle, Focusable, Render, Size, Tiling, Window, WindowBounds,
+    WindowKind, WindowOptions, prelude::*, px,
+};
+use platform_title_bar::PlatformTitleBar;
+use release_channel::ReleaseChannel;
+use rodio::Source;
+use settings::{AudioInputDeviceName, AudioOutputDeviceName, Settings};
+use std::{
+    any::Any,
+    sync::{
+        Arc,
+        atomic::{AtomicBool, Ordering},
+    },
+    thread,
+    time::Duration,
+};
+use ui::{Button, ButtonStyle, Label, prelude::*};
+use util::ResultExt;
+use workspace::client_side_decorations;
+
+use super::audio_input_output_setup::render_audio_device_dropdown;
+use crate::{SettingsUiFile, update_settings_file};
+
+pub struct AudioTestWindow {
+    title_bar: Option<Entity<PlatformTitleBar>>,
+    input_device_id: Option<DeviceId>,
+    output_device_id: Option<DeviceId>,
+    focus_handle: FocusHandle,
+    _stop_playback: Option<Box<dyn Any + Send>>,
+}
+
+impl AudioTestWindow {
+    pub fn new(cx: &mut Context<Self>) -> Self {
+        let title_bar = if !cfg!(target_os = "macos") {
+            Some(cx.new(|cx| PlatformTitleBar::new("audio-test-title-bar", cx)))
+        } else {
+            None
+        };
+
+        let audio_settings = AudioSettings::get_global(cx);
+        let input_device_id = audio_settings.input_audio_device.clone();
+        let output_device_id = audio_settings.output_audio_device.clone();
+
+        Self {
+            title_bar,
+            input_device_id,
+            output_device_id,
+            focus_handle: cx.focus_handle(),
+            _stop_playback: None,
+        }
+    }
+
+    fn toggle_testing(&mut self, cx: &mut Context<Self>) {
+        if let Some(_cb) = self._stop_playback.take() {
+            cx.notify();
+            return;
+        }
+
+        if let Some(cb) =
+            start_test_playback(self.input_device_id.clone(), self.output_device_id.clone()).ok()
+        {
+            self._stop_playback = Some(cb);
+        }
+
+        cx.notify();
+    }
+}
+
+fn start_test_playback(
+    input_device_id: Option<DeviceId>,
+    output_device_id: Option<DeviceId>,
+) -> anyhow::Result<Box<dyn Any + Send>> {
+    let stop_signal = Arc::new(AtomicBool::new(false));
+
+    thread::Builder::new()
+        .name("AudioTestPlayback".to_string())
+        .spawn({
+            let stop_signal = stop_signal.clone();
+            move || {
+                let microphone = match open_test_microphone(input_device_id, stop_signal.clone()) {
+                    Ok(mic) => mic,
+                    Err(e) => {
+                        log::error!("Could not open microphone for audio test: {e}");
+                        return;
+                    }
+                };
+
+                let Ok(output) = audio::open_output_stream(output_device_id) else {
+                    log::error!("Could not open output device for audio test");
+                    return;
+                };
+
+                // let microphone = rx.recv().unwrap();
+                output.mixer().add(microphone);
+
+                // Keep thread (and output device) alive until stop signal
+                while !stop_signal.load(Ordering::Relaxed) {
+                    thread::sleep(Duration::from_millis(100));
+                }
+            }
+        })?;
+
+    Ok(Box::new(util::defer(move || {
+        stop_signal.store(true, Ordering::Relaxed);
+    })))
+}
+
+fn open_test_microphone(
+    input_device_id: Option<DeviceId>,
+    stop_signal: Arc<AtomicBool>,
+) -> anyhow::Result<impl Source> {
+    let stream = audio::open_input_stream(input_device_id)?;
+    let stream = stream
+        .possibly_disconnected_channels_to_mono()
+        .constant_samplerate(SAMPLE_RATE)
+        .constant_params(CHANNEL_COUNT, SAMPLE_RATE)
+        .stoppable()
+        .periodic_access(
+            Duration::from_millis(50),
+            move |stoppable: &mut rodio::source::Stoppable<_>| {
+                if stop_signal.load(Ordering::Relaxed) {
+                    stoppable.stop();
+                }
+            },
+        );
+    Ok(stream)
+}
+
+impl Render for AudioTestWindow {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let is_testing = self._stop_playback.is_some();
+        let button_text = if is_testing {
+            "Stop Testing"
+        } else {
+            "Start Testing"
+        };
+
+        let button_style = if is_testing {
+            ButtonStyle::Tinted(ui::TintColor::Error)
+        } else {
+            ButtonStyle::Filled
+        };
+
+        let weak_entity = cx.entity().downgrade();
+        let input_dropdown = {
+            let weak_entity = weak_entity.clone();
+            render_audio_device_dropdown(
+                "audio-test-input-dropdown",
+                self.input_device_id.clone(),
+                true,
+                move |device_id, window, cx| {
+                    weak_entity
+                        .update(cx, |this, cx| {
+                            this.input_device_id = device_id.clone();
+                            cx.notify();
+                        })
+                        .log_err();
+                    let value: Option<AudioInputDeviceName> =
+                        device_id.map(|id| AudioInputDeviceName(Some(id.to_string())));
+                    update_settings_file(
+                        SettingsUiFile::User,
+                        Some("audio.experimental.input_audio_device"),
+                        window,
+                        cx,
+                        move |settings, _cx| {
+                            settings.audio.get_or_insert_default().input_audio_device = value;
+                        },
+                    )
+                    .log_err();
+                },
+                window,
+                cx,
+            )
+        };
+
+        let output_dropdown = render_audio_device_dropdown(
+            "audio-test-output-dropdown",
+            self.output_device_id.clone(),
+            false,
+            move |device_id, window, cx| {
+                weak_entity
+                    .update(cx, |this, cx| {
+                        this.output_device_id = device_id.clone();
+                        cx.notify();
+                    })
+                    .log_err();
+                let value: Option<AudioOutputDeviceName> =
+                    device_id.map(|id| AudioOutputDeviceName(Some(id.to_string())));
+                update_settings_file(
+                    SettingsUiFile::User,
+                    Some("audio.experimental.output_audio_device"),
+                    window,
+                    cx,
+                    move |settings, _cx| {
+                        settings.audio.get_or_insert_default().output_audio_device = value;
+                    },
+                )
+                .log_err();
+            },
+            window,
+            cx,
+        );
+
+        let content = v_flex()
+            .id("audio-test-window")
+            .track_focus(&self.focus_handle)
+            .size_full()
+            .p_4()
+            .when(cfg!(target_os = "macos"), |this| this.pt_10())
+            .gap_4()
+            .bg(cx.theme().colors().editor_background)
+            .child(
+                v_flex()
+                    .gap_1()
+                    .child(Label::new("Output Device"))
+                    .child(output_dropdown),
+            )
+            .child(
+                v_flex()
+                    .gap_1()
+                    .child(Label::new("Input Device"))
+                    .child(input_dropdown),
+            )
+            .child(
+                h_flex().w_full().justify_center().pt_4().child(
+                    Button::new("test-audio-toggle", button_text)
+                        .style(button_style)
+                        .on_click(cx.listener(|this, _, _, cx| this.toggle_testing(cx))),
+                ),
+            );
+
+        client_side_decorations(
+            v_flex()
+                .size_full()
+                .text_color(cx.theme().colors().text)
+                .children(self.title_bar.clone())
+                .child(content),
+            window,
+            cx,
+            Tiling::default(),
+        )
+    }
+}
+
+impl Focusable for AudioTestWindow {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Drop for AudioTestWindow {
+    fn drop(&mut self) {
+        let _ = self._stop_playback.take();
+    }
+}
+
+pub fn open_audio_test_window(_window: &mut Window, cx: &mut App) {
+    let existing = cx
+        .windows()
+        .into_iter()
+        .find_map(|w| w.downcast::<AudioTestWindow>());
+
+    if let Some(existing) = existing {
+        existing
+            .update(cx, |_, window, _| window.activate_window())
+            .log_err();
+        return;
+    }
+
+    let app_id = ReleaseChannel::global(cx).app_id();
+    let window_size = Size {
+        width: px(640.0),
+        height: px(300.0),
+    };
+    let window_min_size = Size {
+        width: px(400.0),
+        height: px(240.0),
+    };
+
+    cx.open_window(
+        WindowOptions {
+            titlebar: Some(gpui::TitlebarOptions {
+                title: Some("Audio Test".into()),
+                appears_transparent: true,
+                traffic_light_position: Some(gpui::point(px(12.0), px(12.0))),
+            }),
+            focus: true,
+            show: true,
+            is_movable: true,
+            kind: WindowKind::Normal,
+            window_background: cx.theme().window_background_appearance(),
+            app_id: Some(app_id.to_owned()),
+            window_decorations: Some(gpui::WindowDecorations::Client),
+            window_bounds: Some(WindowBounds::centered(window_size, cx)),
+            window_min_size: Some(window_min_size),
+            ..Default::default()
+        },
+        |_, cx| cx.new(AudioTestWindow::new),
+    )
+    .log_err();
+}

crates/settings_ui/src/settings_ui.rs 🔗

@@ -51,6 +51,7 @@ use crate::components::{
     SettingsSectionHeader, font_picker, icon_theme_picker, render_ollama_model_picker,
     theme_picker,
 };
+use crate::pages::{render_input_audio_device_dropdown, render_output_audio_device_dropdown};
 
 const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
 const NAVBAR_GROUP_TAB_INDEX: isize = 1;
@@ -544,6 +545,8 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::SemanticTokens>(render_dropdown)
         .add_basic_renderer::<settings::DocumentFoldingRanges>(render_dropdown)
         .add_basic_renderer::<settings::DocumentSymbols>(render_dropdown)
+        .add_basic_renderer::<settings::AudioInputDeviceName>(render_input_audio_device_dropdown)
+        .add_basic_renderer::<settings::AudioOutputDeviceName>(render_output_audio_device_dropdown)
         // please semicolon stay on next line
         ;
 }
@@ -1373,6 +1376,7 @@ struct ActionLink {
     description: Option<SharedString>,
     button_text: SharedString,
     on_click: Arc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) + Send + Sync>,
+    files: FileMask,
 }
 
 impl PartialEq for ActionLink {
@@ -1819,8 +1823,12 @@ impl SettingsWindow {
                             any_found_since_last_header = true;
                         }
                     }
-                    SettingsPageItem::ActionLink(_) => {
-                        any_found_since_last_header = true;
+                    SettingsPageItem::ActionLink(ActionLink { files, .. }) => {
+                        if !files.contains(current_file) {
+                            page_filter[index] = false;
+                        } else {
+                            any_found_since_last_header = true;
+                        }
                     }
                 }
             }