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; + } } } }