From e7926480dd0eefc2a43f49a98c28f6d6889a04e5 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 | 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 +- .../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 1ec098e6025514cee7b90ee7961880f972e59046..0eda119e6bafe7017516c254d270d7a26d533f65 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]] @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ecb469f6d83780db7192a2ac100f4d6993aaba4a..3ae1b149b3e0f26bf6ed91ae4cda8482ff1bea58 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 489d6191eafc6ed880e4dc3d447e6a9cdbc8d56a..19a149a84fd9b5dfae7305c6527147b2561a8512 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 2aee764007a791176c6e41cb77f6efaf19aa3dc4..3139eb56c7e30555c48fe0be329c55d472b3f8eb 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 49239320facdd71b47b709b67bab32b5f0aba9ac..d684b9c79e296e141a021a32c88c009e85504457 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); - - // 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"); - }); + 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::(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 f86246292833bf285904cbc27f675f8ad1ebc856..4f60a6d63aef1d2c2d7fb4761a6fc2e2eaf3d8c7 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 a5d73d78b116d7a76742f36a26d9e28c099eeb10..e825df63a6d114d7ce7821b96bf32831f9a23ab9 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 24e260e71665704c1010d07e082a03fbe6306a30..c23ab2b938178e9b634f8e0d4d298f2c86450b51 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 8644e44f84c8f1b8b38d4e1bff266b685dbbcd66..c9c01bea97debe22970e51bd10491025065134dd 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 4e3962b9aa207dc7314201a5de538225f228541a..b598585e15ff4be03037a0bc2c97eace91443584 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 44ef9da1191c1e9b14767cd175001d8e3ef9891c..81500ce1730868e8090df6d97b5c84a25dd965fb 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 b68af5725e9396033a5a3e74fc635d64add0e779..a54f52b09cae65268b95e16a2131ef3c9aa48ae3 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 0000000000000000000000000000000000000000..e19f5441eff03dcf20aa77eb6d0cd3dfceab02dc --- /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 0000000000000000000000000000000000000000..63bd1d14ffb3ad9c7d1b2d176d9de58aa762ec25 --- /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 6bc92cb560f23d810739fc3b11826acbd8d8e01f..5f324ca396017433a2bba1b709154160f68f377b 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; + } } } }