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: <img width="1152" height="949" alt="Screenshot From 2026-02-12 11-40-04" src="https://github.com/user-attachments/assets/e147c153-1902-49d6-bf68-3ac317a6a7b0" /> <img width="1152" height="949" alt="Screenshot From 2026-02-12 11-40-16" src="https://github.com/user-attachments/assets/b4e9a2f8-b38e-4de0-b910-067cc432b5bc" /> Release Notes: - Added ability to select audio input/output devices as part of Collaboration page in Settings. Added ability to test selected devices with a simple playback loop routing input directly into output for easier debugging of your audio devices. --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
fc0ffc51df
commit
0ccfaf9e67
15 files changed
+846
-105
No files matched your search
Generated
+35
-30
@@ -572,9 +572,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.10.0",
|
||||
@@ -1319,6 +1319,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-tar",
|
||||
"collections",
|
||||
"cpal",
|
||||
"crossbeam",
|
||||
"denoise",
|
||||
"gpui",
|
||||
@@ -3996,9 +3997,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.16.0"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
|
||||
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"coreaudio-rs 0.13.0",
|
||||
@@ -4006,18 +4007,22 @@ dependencies = [
|
||||
"jni",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2 0.4.3",
|
||||
"mach2 0.5.0",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"objc2",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-avf-audio",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10939,15 +10944,26 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
name = "objc2-avf-audio"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
checksum = "bfc1d11521c211a7ebe17739fc806719da41f56c6b3f949d9861b459188ce910"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10967,7 +10983,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -10984,6 +11002,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -14103,12 +14123,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.21.1"
|
||||
source = "git+https://github.com/RustAudio/rodio?rev=e2074c6c2acf07b57cf717e076bdda7a9ac6e70b#e2074c6c2acf07b57cf717e076bdda7a9ac6e70b"
|
||||
source = "git+https://github.com/RustAudio/rodio?rev=e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a#e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"dasp_sample",
|
||||
"hound",
|
||||
"num-rational",
|
||||
"rand 0.9.2",
|
||||
"rand_distr",
|
||||
"rtrb",
|
||||
"symphonia",
|
||||
"thiserror 2.0.17",
|
||||
@@ -15233,12 +15255,14 @@ dependencies = [
|
||||
"agent_settings",
|
||||
"anyhow",
|
||||
"assets",
|
||||
"audio",
|
||||
"bm25",
|
||||
"client",
|
||||
"codestral",
|
||||
"component",
|
||||
"copilot",
|
||||
"copilot_ui",
|
||||
"cpal",
|
||||
"edit_prediction",
|
||||
"edit_prediction_ui",
|
||||
"editor",
|
||||
@@ -15260,6 +15284,7 @@ dependencies = [
|
||||
"recent_projects",
|
||||
"regex",
|
||||
"release_channel",
|
||||
"rodio",
|
||||
"schemars",
|
||||
"search",
|
||||
"serde",
|
||||
@@ -19648,16 +19673,6 @@ dependencies = [
|
||||
"wasmtime-environ",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
@@ -19714,16 +19729,6 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
|
||||
+2
-2
@@ -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" }
|
||||
|
||||
+176
-58
@@ -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);
|
||||
let output_handle = open_output_stream(output_audio_device)?;
|
||||
|
||||
// The webrtc apm is not yet compiling for windows & freebsd
|
||||
#[cfg(not(any(
|
||||
any(all(target_os = "windows", target_env = "gnu")),
|
||||
target_os = "freebsd"
|
||||
)))]
|
||||
let echo_canceller = Arc::clone(&self.echo_canceller);
|
||||
#[cfg(not(any(
|
||||
any(all(target_os = "windows", target_env = "gnu")),
|
||||
target_os = "freebsd"
|
||||
)))]
|
||||
let source = source.inspect_buffer::<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");
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user