From 0ccfaf9e67a6196e77c3fa0ba10c4c40fb644ea8 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Thu, 12 Feb 2026 14:27:55 +0100 Subject: [PATCH] settings: Add ability to select audio input/output devices for collab (#49015) 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: Screenshot From 2026-02-12 11-40-04 Screenshot From 2026-02-12 11-40-16 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> --- Cargo.lock | 65 ++-- Cargo.toml | 4 +- assets/settings/default.json | 12 + crates/audio/Cargo.toml | 1 + crates/audio/src/audio.rs | 234 ++++++++++---- crates/audio/src/audio_settings.rs | 22 +- .../src/livekit_client/playback.rs | 14 +- crates/livekit_client/src/record.rs | 7 +- .../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 + .../src/pages/audio_input_output_setup.rs | 152 +++++++++ .../src/pages/audio_test_window.rs | 304 ++++++++++++++++++ crates/settings_ui/src/settings_ui.rs | 12 +- 15 files changed, 846 insertions(+), 105 deletions(-) create mode 100644 crates/settings_ui/src/pages/audio_input_output_setup.rs create mode 100644 crates/settings_ui/src/pages/audio_test_window.rs diff --git a/Cargo.lock b/Cargo.lock index 1ec098e602..0eda119e6b 100644 --- a/Cargo.lock +++ b/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]] @@ -10939,15 +10944,26 @@ dependencies = [ ] [[package]] -name = "objc2-core-audio" -version = "0.3.2" +name = "objc2-avf-audio" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +checksum = "bfc1d11521c211a7ebe17739fc806719da41f56c6b3f949d9861b459188ce910" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" diff --git a/Cargo.toml b/Cargo.toml index ecb469f6d8..3ae1b149b3 100644 --- a/Cargo.toml +++ b/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" diff --git a/assets/settings/default.json b/assets/settings/default.json index 489d6191ea..19a149a84f 100644 --- a/assets/settings/default.json +++ b/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": { diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 2aee764007..3139eb56c7 100644 --- a/crates/audio/Cargo.toml +++ b/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" } diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index 49239320fa..d684b9c79e 100644 --- a/crates/audio/src/audio.rs +++ b/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, - output_mixer: Option, + output_handle: Option, #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] pub echo_canceller: Arc>, source_cache: HashMap>>>>, @@ -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) -> 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); + 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 = source.inspect_buffer::(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"); - }); + // 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::(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::::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 { - 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>, replays: replays::Replays, legacy_audio_compatible: bool, + input_audio_device: Option, } #[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, +) -> anyhow::Result { + 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) -> anyhow::Result { + 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 { + 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); + +impl Global for AvailableAudioDevices {} diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs index f862462928..4f60a6d63a 100644 --- a/crates/audio/src/audio_settings.rs +++ b/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, + /// Requires 'rodio_audio: true' + /// + /// Select specific input audio device. + pub input_audio_device: Option, } /// 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())), } } } diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index a5d73d78b1..e825df63a6 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/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: "); } 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 = 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, ) diff --git a/crates/livekit_client/src/record.rs b/crates/livekit_client/src/record.rs index 24e260e716..c23ab2b938 100644 --- a/crates/livekit_client/src/record.rs +++ b/crates/livekit_client/src/record.rs @@ -21,7 +21,10 @@ pub struct CaptureInput { impl CaptureInput { pub fn start() -> anyhow::Result { let (device, config) = crate::default_device(true)?; - let name = device.name().unwrap_or("".to_string()); + let name = device + .description() + .map(|desc| desc.name().to_string()) + .unwrap_or("".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 = 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) { diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 8644e44f84..c9c01bea97 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/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, + /// Requires 'rodio_audio: true' + /// + /// Select specific output audio device. + #[serde(rename = "experimental.output_audio_device")] + pub output_audio_device: Option, + /// Requires 'rodio_audio: true' + /// + /// Select specific input audio device. + #[serde(rename = "experimental.input_audio_device")] + pub input_audio_device: Option, +} + +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] +#[serde(transparent)] +pub struct AudioOutputDeviceName(pub Option); + +impl AsRef> for AudioInputDeviceName { + fn as_ref(&self) -> &Option { + &self.0 + } +} + +impl From> for AudioInputDeviceName { + fn from(value: Option) -> Self { + Self(value) + } +} + +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] +#[serde(transparent)] +pub struct AudioInputDeviceName(pub Option); + +impl AsRef> for AudioOutputDeviceName { + fn as_ref(&self) -> &Option { + &self.0 + } +} + +impl From> for AudioOutputDeviceName { + fn from(value: Option) -> Self { + Self(value) + } } /// Control what info is collected by Zed. diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 4e3962b9aa..b598585e15 100644 --- a/crates/settings_ui/Cargo.toml +++ b/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 diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 44ef9da119..81500ce173 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/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, + }), ] } diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index b68af5725e..a54f52b09c 100644 --- a/crates/settings_ui/src/pages.rs +++ b/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; diff --git a/crates/settings_ui/src/pages/audio_input_output_setup.rs b/crates/settings_ui/src/pages/audio_input_output_setup.rs new file mode 100644 index 0000000000..e19f5441ef --- /dev/null +++ b/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 { + 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( + dropdown_id: impl Into, + current_device_id: Option, + is_input: bool, + on_select: F, + window: &mut Window, + cx: &mut App, +) -> AnyElement +where + F: Fn(Option, &mut Window, &mut App) + Clone + 'static, +{ + let devices = cx.default_global::().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> + From> + Send>( + field: SettingField, + 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 = 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, + 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, + file: SettingsUiFile, + _metadata: Option<&SettingsFieldMetadata>, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + render_settings_audio_device_dropdown(field, file, false, window, cx) +} diff --git a/crates/settings_ui/src/pages/audio_test_window.rs b/crates/settings_ui/src/pages/audio_test_window.rs new file mode 100644 index 0000000000..63bd1d14ff --- /dev/null +++ b/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>, + input_device_id: Option, + output_device_id: Option, + focus_handle: FocusHandle, + _stop_playback: Option>, +} + +impl AudioTestWindow { + pub fn new(cx: &mut Context) -> 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) { + 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, + output_device_id: Option, +) -> anyhow::Result> { + 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, + stop_signal: Arc, +) -> anyhow::Result { + 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) -> 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 = + 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 = + 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::()); + + 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(); +} diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 6bc92cb560..5f324ca396 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/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::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_input_audio_device_dropdown) + .add_basic_renderer::(render_output_audio_device_dropdown) // please semicolon stay on next line ; } @@ -1373,6 +1376,7 @@ struct ActionLink { description: Option, button_text: SharedString, on_click: Arc, + 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; + } } } }