Detailed changes
@@ -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"
@@ -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"
@@ -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": {
@@ -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" }
@@ -1,14 +1,16 @@
use anyhow::{Context as _, Result};
use collections::HashMap;
-use gpui::{App, BackgroundExecutor, BorrowAppContext, Global};
-use log::info;
+use cpal::{
+ DeviceDescription, DeviceId, default_host,
+ traits::{DeviceTrait, HostTrait},
+};
+use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global};
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
mod non_windows_and_freebsd_deps {
- pub(super) use gpui::AsyncApp;
+ pub(super) use cpal::Sample;
pub(super) use libwebrtc::native::apm;
pub(super) use parking_lot::Mutex;
- pub(super) use rodio::cpal::Sample;
pub(super) use rodio::source::LimitSettings;
pub(super) use std::sync::Arc;
}
@@ -17,7 +19,10 @@ mod non_windows_and_freebsd_deps {
use non_windows_and_freebsd_deps::*;
use rodio::{
- Decoder, OutputStream, OutputStreamBuilder, Source, mixer::Mixer, nz, source::Buffered,
+ Decoder, DeviceSinkBuilder, MixerDeviceSink, Source,
+ mixer::Mixer,
+ nz,
+ source::{AutomaticGainControlSettings, Buffered},
};
use settings::Settings;
use std::{io::Cursor, num::NonZero, path::PathBuf, sync::atomic::Ordering, time::Duration};
@@ -49,6 +54,15 @@ pub const REPLAY_DURATION: Duration = Duration::from_secs(30);
pub fn init(cx: &mut App) {
LIVE_SETTINGS.initialize(cx);
+ // TODO(jk): this is currently cached only once at startup - we should observe and react instead
+ let task = cx
+ .background_executor()
+ .spawn(async move { get_available_audio_devices() });
+ cx.spawn(async move |cx: &mut AsyncApp| {
+ let devices = task.await;
+ cx.update(|cx| cx.set_global(AvailableAudioDevices(devices)))
+ })
+ .detach();
}
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)]
@@ -79,8 +93,7 @@ impl Sound {
}
pub struct Audio {
- output_handle: Option<OutputStream>,
- output_mixer: Option<Mixer>,
+ output_handle: Option<MixerDeviceSink>,
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
pub echo_canceller: Arc<Mutex<apm::AudioProcessingModule>>,
source_cache: HashMap<Sound, Buffered<Decoder<Cursor<Vec<u8>>>>>,
@@ -91,7 +104,6 @@ impl Default for Audio {
fn default() -> Self {
Self {
output_handle: Default::default(),
- output_mixer: Default::default(),
#[cfg(not(any(
all(target_os = "windows", target_env = "gnu"),
target_os = "freebsd"
@@ -108,51 +120,58 @@ impl Default for Audio {
impl Global for Audio {}
impl Audio {
- fn ensure_output_exists(&mut self) -> Result<&Mixer> {
+ fn ensure_output_exists(&mut self, output_audio_device: Option<DeviceId>) -> Result<&Mixer> {
#[cfg(debug_assertions)]
log::warn!(
"Audio does not sound correct without optimizations. Use a release build to debug audio issues"
);
if self.output_handle.is_none() {
- let output_handle = OutputStreamBuilder::open_default_stream()
- .context("Could not open default output stream")?;
- info!("Output stream: {:?}", output_handle);
- self.output_handle = Some(output_handle);
- if let Some(output_handle) = &self.output_handle {
- let (mixer, source) = rodio::mixer::mixer(CHANNEL_COUNT, SAMPLE_RATE);
- // or the mixer will end immediately as its empty.
- mixer.add(rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE));
- self.output_mixer = Some(mixer);
-
- // The webrtc apm is not yet compiling for windows & freebsd
- #[cfg(not(any(
- any(all(target_os = "windows", target_env = "gnu")),
- target_os = "freebsd"
- )))]
- let echo_canceller = Arc::clone(&self.echo_canceller);
- #[cfg(not(any(
- any(all(target_os = "windows", target_env = "gnu")),
- target_os = "freebsd"
- )))]
- let source = source.inspect_buffer::<BUFFER_SIZE, _>(move |buffer| {
- let mut buf: [i16; _] = buffer.map(|s| s.to_sample());
- echo_canceller
- .lock()
- .process_reverse_stream(
- &mut buf,
- SAMPLE_RATE.get() as i32,
- CHANNEL_COUNT.get().into(),
- )
- .expect("Audio input and output threads should not panic");
- });
+ let output_handle = open_output_stream(output_audio_device)?;
+
+ // The webrtc apm is not yet compiling for windows & freebsd
+ #[cfg(not(any(
+ any(all(target_os = "windows", target_env = "gnu")),
+ target_os = "freebsd"
+ )))]
+ let echo_canceller = Arc::clone(&self.echo_canceller);
+
+ #[cfg(not(any(
+ any(all(target_os = "windows", target_env = "gnu")),
+ target_os = "freebsd"
+ )))]
+ {
+ let source = rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE)
+ .inspect_buffer::<BUFFER_SIZE, _>(move |buffer| {
+ let mut buf: [i16; _] = buffer.map(|s| s.to_sample());
+ echo_canceller
+ .lock()
+ .process_reverse_stream(
+ &mut buf,
+ SAMPLE_RATE.get() as i32,
+ CHANNEL_COUNT.get().into(),
+ )
+ .expect("Audio input and output threads should not panic");
+ });
+ output_handle.mixer().add(source);
+ }
+
+ #[cfg(any(
+ any(all(target_os = "windows", target_env = "gnu")),
+ target_os = "freebsd"
+ ))]
+ {
+ let source = rodio::source::Zero::<f32>::new(CHANNEL_COUNT, SAMPLE_RATE);
output_handle.mixer().add(source);
}
+
+ self.output_handle = Some(output_handle);
}
Ok(self
- .output_mixer
+ .output_handle
.as_ref()
+ .map(|h| h.mixer())
.expect("we only get here if opening the outputstream succeeded"))
}
@@ -165,20 +184,7 @@ impl Audio {
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
pub fn open_microphone(voip_parts: VoipParts) -> anyhow::Result<impl Source> {
- let stream = rodio::microphone::MicrophoneBuilder::new()
- .default_device()?
- .default_config()?
- .prefer_sample_rates([
- SAMPLE_RATE, // sample rates trivially resamplable to `SAMPLE_RATE`
- SAMPLE_RATE.saturating_mul(nz!(2)),
- SAMPLE_RATE.saturating_mul(nz!(3)),
- SAMPLE_RATE.saturating_mul(nz!(4)),
- ])
- .prefer_channel_counts([nz!(1), nz!(2), nz!(3), nz!(4)])
- .prefer_buffer_sizes(512..)
- .open_stream()?;
- info!("Opened microphone: {:?}", stream.config());
-
+ let stream = open_input_stream(voip_parts.input_audio_device)?;
let stream = stream
.possibly_disconnected_channels_to_mono()
.constant_samplerate(SAMPLE_RATE)
@@ -204,7 +210,12 @@ impl Audio {
})
.denoise()
.context("Could not set up denoiser")?
- .automatic_gain_control(0.90, 1.0, 0.0, 5.0)
+ .automatic_gain_control(AutomaticGainControlSettings {
+ target_level: 0.90,
+ attack_time: Duration::from_secs(1),
+ release_time: Duration::from_secs(0),
+ absolute_max_gain: 5.0,
+ })
.periodic_access(Duration::from_millis(100), move |agc_source| {
agc_source
.set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed));
@@ -234,16 +245,22 @@ impl Audio {
) -> anyhow::Result<()> {
let (replay_source, source) = source
.constant_params(CHANNEL_COUNT, SAMPLE_RATE)
- .automatic_gain_control(0.90, 1.0, 0.0, 5.0)
+ .automatic_gain_control(AutomaticGainControlSettings {
+ target_level: 0.90,
+ attack_time: Duration::from_secs(1),
+ release_time: Duration::from_secs(0),
+ absolute_max_gain: 5.0,
+ })
.periodic_access(Duration::from_millis(100), move |agc_source| {
agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed));
})
.replayable(REPLAY_DURATION)
.expect("REPLAY_DURATION is longer than 100ms");
+ let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
cx.update_default_global(|this: &mut Self, _cx| {
let output_mixer = this
- .ensure_output_exists()
+ .ensure_output_exists(output_audio_device)
.context("Could not get output mixer")?;
output_mixer.add(source);
if is_staff {
@@ -254,10 +271,11 @@ impl Audio {
}
pub fn play_sound(sound: Sound, cx: &mut App) {
+ let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
cx.update_default_global(|this: &mut Self, cx| {
let source = this.sound_source(sound, cx).log_err()?;
let output_mixer = this
- .ensure_output_exists()
+ .ensure_output_exists(output_audio_device)
.context("Could not get output mixer")
.log_err()?;
@@ -298,6 +316,7 @@ pub struct VoipParts {
echo_canceller: Arc<Mutex<apm::AudioProcessingModule>>,
replays: replays::Replays,
legacy_audio_compatible: bool,
+ input_audio_device: Option<DeviceId>,
}
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
@@ -309,11 +328,110 @@ impl VoipParts {
let legacy_audio_compatible =
AudioSettings::try_read_global(cx, |settings| settings.legacy_audio_compatible)
.unwrap_or(true);
+ let input_audio_device =
+ AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone())
+ .flatten();
Ok(Self {
legacy_audio_compatible,
echo_canceller: apm,
replays,
+ input_audio_device,
})
}
}
+
+pub fn open_input_stream(
+ device_id: Option<DeviceId>,
+) -> anyhow::Result<rodio::microphone::Microphone> {
+ let builder = rodio::microphone::MicrophoneBuilder::new();
+ let builder = if let Some(id) = device_id {
+ // TODO(jk): upstream patch
+ // if let Some(input_device) = default_host().device_by_id(id) {
+ // builder.device(input_device);
+ // }
+ let mut found = None;
+ for input in rodio::microphone::available_inputs()? {
+ if input.clone().into_inner().id()? == id {
+ found = Some(builder.device(input));
+ break;
+ }
+ }
+ found.unwrap_or_else(|| builder.default_device())?
+ } else {
+ builder.default_device()?
+ };
+ let stream = builder
+ .default_config()?
+ .prefer_sample_rates([
+ SAMPLE_RATE,
+ SAMPLE_RATE.saturating_mul(rodio::nz!(2)),
+ SAMPLE_RATE.saturating_mul(rodio::nz!(3)),
+ SAMPLE_RATE.saturating_mul(rodio::nz!(4)),
+ ])
+ .prefer_channel_counts([rodio::nz!(1), rodio::nz!(2), rodio::nz!(3), rodio::nz!(4)])
+ .prefer_buffer_sizes(512..)
+ .open_stream()?;
+ log::info!("Opened microphone: {:?}", stream.config());
+ Ok(stream)
+}
+
+pub fn open_output_stream(device_id: Option<DeviceId>) -> anyhow::Result<MixerDeviceSink> {
+ let output_handle = if let Some(id) = device_id {
+ if let Some(device) = default_host().device_by_id(&id) {
+ DeviceSinkBuilder::from_device(device)?.open_stream()
+ } else {
+ DeviceSinkBuilder::open_default_sink()
+ }
+ } else {
+ DeviceSinkBuilder::open_default_sink()
+ };
+ let mut output_handle = output_handle.context("Could not open output stream")?;
+ output_handle.log_on_drop(false);
+ log::info!("Output stream: {:?}", output_handle);
+ Ok(output_handle)
+}
+
+#[derive(Clone, Debug)]
+pub struct AudioDeviceInfo {
+ pub id: DeviceId,
+ pub desc: DeviceDescription,
+}
+
+impl AudioDeviceInfo {
+ pub fn matches_input(&self, is_input: bool) -> bool {
+ if is_input {
+ self.desc.supports_input()
+ } else {
+ self.desc.supports_output()
+ }
+ }
+
+ pub fn matches(&self, id: &DeviceId, is_input: bool) -> bool {
+ &self.id == id && self.matches_input(is_input)
+ }
+}
+
+impl std::fmt::Display for AudioDeviceInfo {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{} ({})", self.desc.name(), self.id)
+ }
+}
+
+fn get_available_audio_devices() -> Vec<AudioDeviceInfo> {
+ let Some(devices) = default_host().devices().ok() else {
+ return Vec::new();
+ };
+ devices
+ .filter_map(|device| {
+ let id = device.id().ok()?;
+ let desc = device.description().ok()?;
+ Some(AudioDeviceInfo { id, desc })
+ })
+ .collect()
+}
+
+#[derive(Default, Clone, Debug)]
+pub struct AvailableAudioDevices(pub Vec<AudioDeviceInfo>);
+
+impl Global for AvailableAudioDevices {}
@@ -1,5 +1,9 @@
-use std::sync::atomic::{AtomicBool, Ordering};
+use std::{
+ str::FromStr,
+ sync::atomic::{AtomicBool, Ordering},
+};
+use cpal::DeviceId;
use gpui::App;
use settings::{RegisterSetting, Settings, SettingsStore};
@@ -38,6 +42,14 @@ pub struct AudioSettings {
///
/// You need to rejoin a call for this setting to apply
pub legacy_audio_compatible: bool,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific output audio device.
+ pub output_audio_device: Option<DeviceId>,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific input audio device.
+ pub input_audio_device: Option<DeviceId>,
}
/// Configuration of audio in Zed
@@ -50,6 +62,14 @@ impl Settings for AudioSettings {
auto_speaker_volume: audio.auto_speaker_volume.unwrap(),
denoise: audio.denoise.unwrap(),
legacy_audio_compatible: audio.legacy_audio_compatible.unwrap(),
+ output_audio_device: audio
+ .output_audio_device
+ .as_ref()
+ .and_then(|x| x.0.as_ref().and_then(|id| DeviceId::from_str(&id).ok())),
+ input_audio_device: audio
+ .input_audio_device
+ .as_ref()
+ .and_then(|x| x.0.as_ref().and_then(|id| DeviceId::from_str(&id).ok())),
}
}
}
@@ -298,13 +298,13 @@ impl AudioStack {
num_channels,
sample_rate,
output_config.channels() as u32,
- output_config.sample_rate().0,
+ output_config.sample_rate(),
);
buf = sampled.to_vec();
apm.lock()
.process_reverse_stream(
&mut buf,
- output_config.sample_rate().0 as i32,
+ output_config.sample_rate() as i32,
output_config.channels() as i32,
)
.ok();
@@ -348,14 +348,14 @@ impl AudioStack {
.name("AudioCapture".to_owned())
.spawn(move || {
maybe!({
- if let Some(name) = device.name().ok() {
- log::info!("Using microphone: {}", name)
+ if let Some(desc) = device.description().ok() {
+ log::info!("Using microphone: {}", desc.name())
} else {
log::info!("Using microphone: <unknown>");
}
let ten_ms_buffer_size =
- (config.channels() as u32 * config.sample_rate().0 / 100) as usize;
+ (config.channels() as u32 * config.sample_rate() / 100) as usize;
let mut buf: Vec<i16> = Vec::with_capacity(ten_ms_buffer_size);
let stream = device
@@ -380,9 +380,9 @@ impl AudioStack {
let mut sampled = resampler
.remix_and_resample(
buf.as_slice(),
- config.sample_rate().0 / 100,
+ config.sample_rate() / 100,
config.channels() as u32,
- config.sample_rate().0,
+ config.sample_rate(),
num_channels,
sample_rate,
)
@@ -21,7 +21,10 @@ pub struct CaptureInput {
impl CaptureInput {
pub fn start() -> anyhow::Result<Self> {
let (device, config) = crate::default_device(true)?;
- let name = device.name().unwrap_or("<unknown>".to_string());
+ let name = device
+ .description()
+ .map(|desc| desc.name().to_string())
+ .unwrap_or("<unknown>".to_string());
log::info!("Using microphone: {}", name);
let samples = Arc::new(Mutex::new(Vec::new()));
@@ -86,7 +89,7 @@ fn write_out(
let samples: Vec<f32> = SampleTypeConverter::<_, f32>::new(samples.into_iter()).collect();
let mut samples = SamplesBuffer::new(
NonZero::new(config.channels()).expect("config channel is never zero"),
- NonZero::new(config.sample_rate().0).expect("config sample_rate is never zero"),
+ NonZero::new(config.sample_rate()).expect("config sample_rate is never zero"),
samples,
);
match rodio::wav_to_file(&mut samples, path) {
@@ -400,6 +400,48 @@ pub struct AudioSettingsContent {
/// You need to rejoin a call for this setting to apply
#[serde(rename = "experimental.legacy_audio_compatible")]
pub legacy_audio_compatible: Option<bool>,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific output audio device.
+ #[serde(rename = "experimental.output_audio_device")]
+ pub output_audio_device: Option<AudioOutputDeviceName>,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific input audio device.
+ #[serde(rename = "experimental.input_audio_device")]
+ pub input_audio_device: Option<AudioInputDeviceName>,
+}
+
+#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
+#[serde(transparent)]
+pub struct AudioOutputDeviceName(pub Option<String>);
+
+impl AsRef<Option<String>> for AudioInputDeviceName {
+ fn as_ref(&self) -> &Option<String> {
+ &self.0
+ }
+}
+
+impl From<Option<String>> for AudioInputDeviceName {
+ fn from(value: Option<String>) -> Self {
+ Self(value)
+ }
+}
+
+#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
+#[serde(transparent)]
+pub struct AudioInputDeviceName(pub Option<String>);
+
+impl AsRef<Option<String>> for AudioOutputDeviceName {
+ fn as_ref(&self) -> &Option<String> {
+ &self.0
+ }
+}
+
+impl From<Option<String>> for AudioOutputDeviceName {
+ fn from(value: Option<String>) -> Self {
+ Self(value)
+ }
}
/// Control what info is collected by Zed.
@@ -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
@@ -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,
+ }),
]
}
@@ -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;
@@ -0,0 +1,152 @@
+use audio::{AudioDeviceInfo, AvailableAudioDevices};
+use cpal::DeviceId;
+use gpui::{AnyElement, App, ElementId, ReadGlobal, SharedString, Window};
+use settings::{AudioInputDeviceName, AudioOutputDeviceName, SettingsStore};
+use std::str::FromStr;
+use ui::{ContextMenu, DropdownMenu, DropdownStyle, IconPosition, IntoElement};
+use util::ResultExt;
+
+use crate::{SettingField, SettingsFieldMetadata, SettingsUiFile, update_settings_file};
+
+pub(crate) const SYSTEM_DEFAULT: &str = "System Default";
+
+pub(crate) fn get_current_device(
+ current_id: Option<&DeviceId>,
+ is_input: bool,
+ devices: &[AudioDeviceInfo],
+) -> Option<AudioDeviceInfo> {
+ let Some(current_id) = current_id else {
+ return None;
+ };
+ devices
+ .iter()
+ .find(|d| d.matches(current_id, is_input))
+ .cloned()
+}
+
+pub(crate) fn render_audio_device_dropdown<F>(
+ dropdown_id: impl Into<ElementId>,
+ current_device_id: Option<DeviceId>,
+ is_input: bool,
+ on_select: F,
+ window: &mut Window,
+ cx: &mut App,
+) -> AnyElement
+where
+ F: Fn(Option<DeviceId>, &mut Window, &mut App) + Clone + 'static,
+{
+ let devices = cx.default_global::<AvailableAudioDevices>().0.clone();
+ let current_device = get_current_device(current_device_id.as_ref(), is_input, &devices);
+
+ let menu = ContextMenu::build(window, cx, {
+ let current_device = current_device.clone();
+ move |mut menu, _, _cx| {
+ let is_system_default = current_device.is_none();
+ menu = menu.toggleable_entry(
+ SYSTEM_DEFAULT,
+ is_system_default,
+ IconPosition::Start,
+ None,
+ {
+ let on_select = on_select.clone();
+ move |window, cx| {
+ on_select(None, window, cx);
+ }
+ },
+ );
+
+ for device in devices.iter().filter(|d| d.matches_input(is_input)) {
+ let is_current = current_device
+ .as_ref()
+ .map(|info| info.matches(&device.id, is_input))
+ .unwrap_or(false);
+ let device_id = device.id.clone();
+
+ menu = menu.toggleable_entry(
+ device.to_string(),
+ is_current,
+ IconPosition::Start,
+ None,
+ {
+ let on_select = on_select.clone();
+ move |window, cx| {
+ on_select(Some(device_id.clone()), window, cx);
+ }
+ },
+ );
+ }
+ menu
+ }
+ });
+
+ DropdownMenu::new(
+ dropdown_id,
+ current_device
+ .map(|info| info.desc.name().to_string())
+ .unwrap_or(SYSTEM_DEFAULT.to_string()),
+ menu,
+ )
+ .style(DropdownStyle::Outlined)
+ .full_width(true)
+ .into_any_element()
+}
+
+fn render_settings_audio_device_dropdown<T: AsRef<Option<String>> + From<Option<String>> + Send>(
+ field: SettingField<T>,
+ file: SettingsUiFile,
+ is_input: bool,
+ window: &mut Window,
+ cx: &mut App,
+) -> AnyElement {
+ let (_, current_value): (_, Option<&T>) =
+ SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
+ let current_device_id =
+ current_value.and_then(|x| x.as_ref().clone().and_then(|x| DeviceId::from_str(&x).ok()));
+
+ let dropdown_id: SharedString = if is_input {
+ "input-audio-device-dropdown".into()
+ } else {
+ "output-audio-device-dropdown".into()
+ };
+
+ render_audio_device_dropdown(
+ dropdown_id,
+ current_device_id,
+ is_input,
+ move |device_id, window, cx| {
+ let value: Option<T> = device_id.map(|id| T::from(Some(id.to_string())));
+ update_settings_file(
+ file.clone(),
+ field.json_path,
+ window,
+ cx,
+ move |settings, _cx| {
+ (field.write)(settings, value);
+ },
+ )
+ .log_err();
+ },
+ window,
+ cx,
+ )
+}
+
+pub fn render_input_audio_device_dropdown(
+ field: SettingField<AudioInputDeviceName>,
+ file: SettingsUiFile,
+ _metadata: Option<&SettingsFieldMetadata>,
+ window: &mut Window,
+ cx: &mut App,
+) -> AnyElement {
+ render_settings_audio_device_dropdown(field, file, true, window, cx)
+}
+
+pub fn render_output_audio_device_dropdown(
+ field: SettingField<AudioOutputDeviceName>,
+ file: SettingsUiFile,
+ _metadata: Option<&SettingsFieldMetadata>,
+ window: &mut Window,
+ cx: &mut App,
+) -> AnyElement {
+ render_settings_audio_device_dropdown(field, file, false, window, cx)
+}
@@ -0,0 +1,304 @@
+use audio::{AudioSettings, CHANNEL_COUNT, RodioExt, SAMPLE_RATE};
+use cpal::DeviceId;
+use gpui::{
+ App, Context, Entity, FocusHandle, Focusable, Render, Size, Tiling, Window, WindowBounds,
+ WindowKind, WindowOptions, prelude::*, px,
+};
+use platform_title_bar::PlatformTitleBar;
+use release_channel::ReleaseChannel;
+use rodio::Source;
+use settings::{AudioInputDeviceName, AudioOutputDeviceName, Settings};
+use std::{
+ any::Any,
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
+ thread,
+ time::Duration,
+};
+use ui::{Button, ButtonStyle, Label, prelude::*};
+use util::ResultExt;
+use workspace::client_side_decorations;
+
+use super::audio_input_output_setup::render_audio_device_dropdown;
+use crate::{SettingsUiFile, update_settings_file};
+
+pub struct AudioTestWindow {
+ title_bar: Option<Entity<PlatformTitleBar>>,
+ input_device_id: Option<DeviceId>,
+ output_device_id: Option<DeviceId>,
+ focus_handle: FocusHandle,
+ _stop_playback: Option<Box<dyn Any + Send>>,
+}
+
+impl AudioTestWindow {
+ pub fn new(cx: &mut Context<Self>) -> Self {
+ let title_bar = if !cfg!(target_os = "macos") {
+ Some(cx.new(|cx| PlatformTitleBar::new("audio-test-title-bar", cx)))
+ } else {
+ None
+ };
+
+ let audio_settings = AudioSettings::get_global(cx);
+ let input_device_id = audio_settings.input_audio_device.clone();
+ let output_device_id = audio_settings.output_audio_device.clone();
+
+ Self {
+ title_bar,
+ input_device_id,
+ output_device_id,
+ focus_handle: cx.focus_handle(),
+ _stop_playback: None,
+ }
+ }
+
+ fn toggle_testing(&mut self, cx: &mut Context<Self>) {
+ if let Some(_cb) = self._stop_playback.take() {
+ cx.notify();
+ return;
+ }
+
+ if let Some(cb) =
+ start_test_playback(self.input_device_id.clone(), self.output_device_id.clone()).ok()
+ {
+ self._stop_playback = Some(cb);
+ }
+
+ cx.notify();
+ }
+}
+
+fn start_test_playback(
+ input_device_id: Option<DeviceId>,
+ output_device_id: Option<DeviceId>,
+) -> anyhow::Result<Box<dyn Any + Send>> {
+ let stop_signal = Arc::new(AtomicBool::new(false));
+
+ thread::Builder::new()
+ .name("AudioTestPlayback".to_string())
+ .spawn({
+ let stop_signal = stop_signal.clone();
+ move || {
+ let microphone = match open_test_microphone(input_device_id, stop_signal.clone()) {
+ Ok(mic) => mic,
+ Err(e) => {
+ log::error!("Could not open microphone for audio test: {e}");
+ return;
+ }
+ };
+
+ let Ok(output) = audio::open_output_stream(output_device_id) else {
+ log::error!("Could not open output device for audio test");
+ return;
+ };
+
+ // let microphone = rx.recv().unwrap();
+ output.mixer().add(microphone);
+
+ // Keep thread (and output device) alive until stop signal
+ while !stop_signal.load(Ordering::Relaxed) {
+ thread::sleep(Duration::from_millis(100));
+ }
+ }
+ })?;
+
+ Ok(Box::new(util::defer(move || {
+ stop_signal.store(true, Ordering::Relaxed);
+ })))
+}
+
+fn open_test_microphone(
+ input_device_id: Option<DeviceId>,
+ stop_signal: Arc<AtomicBool>,
+) -> anyhow::Result<impl Source> {
+ let stream = audio::open_input_stream(input_device_id)?;
+ let stream = stream
+ .possibly_disconnected_channels_to_mono()
+ .constant_samplerate(SAMPLE_RATE)
+ .constant_params(CHANNEL_COUNT, SAMPLE_RATE)
+ .stoppable()
+ .periodic_access(
+ Duration::from_millis(50),
+ move |stoppable: &mut rodio::source::Stoppable<_>| {
+ if stop_signal.load(Ordering::Relaxed) {
+ stoppable.stop();
+ }
+ },
+ );
+ Ok(stream)
+}
+
+impl Render for AudioTestWindow {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let is_testing = self._stop_playback.is_some();
+ let button_text = if is_testing {
+ "Stop Testing"
+ } else {
+ "Start Testing"
+ };
+
+ let button_style = if is_testing {
+ ButtonStyle::Tinted(ui::TintColor::Error)
+ } else {
+ ButtonStyle::Filled
+ };
+
+ let weak_entity = cx.entity().downgrade();
+ let input_dropdown = {
+ let weak_entity = weak_entity.clone();
+ render_audio_device_dropdown(
+ "audio-test-input-dropdown",
+ self.input_device_id.clone(),
+ true,
+ move |device_id, window, cx| {
+ weak_entity
+ .update(cx, |this, cx| {
+ this.input_device_id = device_id.clone();
+ cx.notify();
+ })
+ .log_err();
+ let value: Option<AudioInputDeviceName> =
+ device_id.map(|id| AudioInputDeviceName(Some(id.to_string())));
+ update_settings_file(
+ SettingsUiFile::User,
+ Some("audio.experimental.input_audio_device"),
+ window,
+ cx,
+ move |settings, _cx| {
+ settings.audio.get_or_insert_default().input_audio_device = value;
+ },
+ )
+ .log_err();
+ },
+ window,
+ cx,
+ )
+ };
+
+ let output_dropdown = render_audio_device_dropdown(
+ "audio-test-output-dropdown",
+ self.output_device_id.clone(),
+ false,
+ move |device_id, window, cx| {
+ weak_entity
+ .update(cx, |this, cx| {
+ this.output_device_id = device_id.clone();
+ cx.notify();
+ })
+ .log_err();
+ let value: Option<AudioOutputDeviceName> =
+ device_id.map(|id| AudioOutputDeviceName(Some(id.to_string())));
+ update_settings_file(
+ SettingsUiFile::User,
+ Some("audio.experimental.output_audio_device"),
+ window,
+ cx,
+ move |settings, _cx| {
+ settings.audio.get_or_insert_default().output_audio_device = value;
+ },
+ )
+ .log_err();
+ },
+ window,
+ cx,
+ );
+
+ let content = v_flex()
+ .id("audio-test-window")
+ .track_focus(&self.focus_handle)
+ .size_full()
+ .p_4()
+ .when(cfg!(target_os = "macos"), |this| this.pt_10())
+ .gap_4()
+ .bg(cx.theme().colors().editor_background)
+ .child(
+ v_flex()
+ .gap_1()
+ .child(Label::new("Output Device"))
+ .child(output_dropdown),
+ )
+ .child(
+ v_flex()
+ .gap_1()
+ .child(Label::new("Input Device"))
+ .child(input_dropdown),
+ )
+ .child(
+ h_flex().w_full().justify_center().pt_4().child(
+ Button::new("test-audio-toggle", button_text)
+ .style(button_style)
+ .on_click(cx.listener(|this, _, _, cx| this.toggle_testing(cx))),
+ ),
+ );
+
+ client_side_decorations(
+ v_flex()
+ .size_full()
+ .text_color(cx.theme().colors().text)
+ .children(self.title_bar.clone())
+ .child(content),
+ window,
+ cx,
+ Tiling::default(),
+ )
+ }
+}
+
+impl Focusable for AudioTestWindow {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Drop for AudioTestWindow {
+ fn drop(&mut self) {
+ let _ = self._stop_playback.take();
+ }
+}
+
+pub fn open_audio_test_window(_window: &mut Window, cx: &mut App) {
+ let existing = cx
+ .windows()
+ .into_iter()
+ .find_map(|w| w.downcast::<AudioTestWindow>());
+
+ if let Some(existing) = existing {
+ existing
+ .update(cx, |_, window, _| window.activate_window())
+ .log_err();
+ return;
+ }
+
+ let app_id = ReleaseChannel::global(cx).app_id();
+ let window_size = Size {
+ width: px(640.0),
+ height: px(300.0),
+ };
+ let window_min_size = Size {
+ width: px(400.0),
+ height: px(240.0),
+ };
+
+ cx.open_window(
+ WindowOptions {
+ titlebar: Some(gpui::TitlebarOptions {
+ title: Some("Audio Test".into()),
+ appears_transparent: true,
+ traffic_light_position: Some(gpui::point(px(12.0), px(12.0))),
+ }),
+ focus: true,
+ show: true,
+ is_movable: true,
+ kind: WindowKind::Normal,
+ window_background: cx.theme().window_background_appearance(),
+ app_id: Some(app_id.to_owned()),
+ window_decorations: Some(gpui::WindowDecorations::Client),
+ window_bounds: Some(WindowBounds::centered(window_size, cx)),
+ window_min_size: Some(window_min_size),
+ ..Default::default()
+ },
+ |_, cx| cx.new(AudioTestWindow::new),
+ )
+ .log_err();
+}
@@ -51,6 +51,7 @@ use crate::components::{
SettingsSectionHeader, font_picker, icon_theme_picker, render_ollama_model_picker,
theme_picker,
};
+use crate::pages::{render_input_audio_device_dropdown, render_output_audio_device_dropdown};
const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
const NAVBAR_GROUP_TAB_INDEX: isize = 1;
@@ -544,6 +545,8 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::SemanticTokens>(render_dropdown)
.add_basic_renderer::<settings::DocumentFoldingRanges>(render_dropdown)
.add_basic_renderer::<settings::DocumentSymbols>(render_dropdown)
+ .add_basic_renderer::<settings::AudioInputDeviceName>(render_input_audio_device_dropdown)
+ .add_basic_renderer::<settings::AudioOutputDeviceName>(render_output_audio_device_dropdown)
// please semicolon stay on next line
;
}
@@ -1373,6 +1376,7 @@ struct ActionLink {
description: Option<SharedString>,
button_text: SharedString,
on_click: Arc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) + Send + Sync>,
+ files: FileMask,
}
impl PartialEq for ActionLink {
@@ -1819,8 +1823,12 @@ impl SettingsWindow {
any_found_since_last_header = true;
}
}
- SettingsPageItem::ActionLink(_) => {
- any_found_since_last_header = true;
+ SettingsPageItem::ActionLink(ActionLink { files, .. }) => {
+ if !files.contains(current_file) {
+ page_filter[index] = false;
+ } else {
+ any_found_since_last_header = true;
+ }
}
}
}