livekit_client: Route selected audio input/output devices into legacy audio (#51128)

Jakub Konka created

Release Notes:

- Fixed ability to select audio input/output devices for legacy
(non-experimental/rodio-enabled) audio.

Change summary

crates/audio/src/audio.rs                            | 30 +++++++++----
crates/audio/src/audio_settings.rs                   |  4 -
crates/livekit_client/src/lib.rs                     | 24 ++++-------
crates/livekit_client/src/livekit_client.rs          |  5 +
crates/livekit_client/src/livekit_client/playback.rs | 18 ++++++-
crates/livekit_client/src/record.rs                  |  7 ++
6 files changed, 53 insertions(+), 35 deletions(-)

Detailed changes

crates/audio/src/audio.rs 🔗

@@ -384,17 +384,29 @@ pub fn open_input_stream(
     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()
+pub fn resolve_device(device_id: Option<&DeviceId>, input: bool) -> anyhow::Result<cpal::Device> {
+    if let Some(id) = device_id {
+        if let Some(device) = default_host().device_by_id(id) {
+            return Ok(device);
         }
+        log::warn!("Selected audio device not found, falling back to default");
+    }
+    if input {
+        default_host()
+            .default_input_device()
+            .context("no audio input device available")
     } else {
-        DeviceSinkBuilder::open_default_sink()
-    };
-    let mut output_handle = output_handle.context("Could not open output stream")?;
+        default_host()
+            .default_output_device()
+            .context("no audio output device available")
+    }
+}
+
+pub fn open_output_stream(device_id: Option<DeviceId>) -> anyhow::Result<MixerDeviceSink> {
+    let device = resolve_device(device_id.as_ref(), false)?;
+    let mut output_handle = DeviceSinkBuilder::from_device(device)?
+        .open_stream()
+        .context("Could not open output stream")?;
     output_handle.log_on_drop(false);
     log::info!("Output stream: {:?}", output_handle);
     Ok(output_handle)

crates/audio/src/audio_settings.rs 🔗

@@ -42,12 +42,8 @@ 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>,
 }

crates/livekit_client/src/lib.rs 🔗

@@ -1,8 +1,8 @@
 use anyhow::Context as _;
 use collections::HashMap;
+use cpal::DeviceId;
 
 mod remote_video_track_view;
-use cpal::traits::HostTrait as _;
 pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent};
 use rodio::DeviceTrait as _;
 
@@ -192,24 +192,18 @@ pub enum RoomEvent {
 
 pub(crate) fn default_device(
     input: bool,
+    device_id: Option<&DeviceId>,
 ) -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> {
-    let device;
-    let config;
-    if input {
-        device = cpal::default_host()
-            .default_input_device()
-            .context("no audio input device available")?;
-        config = device
+    let device = audio::resolve_device(device_id, input)?;
+    let config = if input {
+        device
             .default_input_config()
-            .context("failed to get default input config")?;
+            .context("failed to get default input config")?
     } else {
-        device = cpal::default_host()
-            .default_output_device()
-            .context("no audio output device available")?;
-        config = device
+        device
             .default_output_config()
-            .context("failed to get default output config")?;
-    }
+            .context("failed to get default output config")?
+    };
     Ok((device, config))
 }
 

crates/livekit_client/src/livekit_client.rs 🔗

@@ -150,7 +150,10 @@ impl Room {
             info!("Using experimental.rodio_audio audio pipeline for output");
             playback::play_remote_audio_track(&track.0, speaker, cx)
         } else if speaker.sends_legacy_audio {
-            Ok(self.playback.play_remote_audio_track(&track.0))
+            let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
+            Ok(self
+                .playback
+                .play_remote_audio_track(&track.0, output_audio_device))
         } else {
             Err(anyhow!("Client version too old to play audio in call"))
         }

crates/livekit_client/src/livekit_client/playback.rs 🔗

@@ -1,6 +1,7 @@
 use anyhow::{Context as _, Result};
 
 use audio::{AudioSettings, CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE};
+use cpal::DeviceId;
 use cpal::traits::{DeviceTrait, StreamTrait as _};
 use futures::channel::mpsc::UnboundedSender;
 use futures::{Stream, StreamExt as _};
@@ -91,8 +92,9 @@ impl AudioStack {
     pub(crate) fn play_remote_audio_track(
         &self,
         track: &livekit::track::RemoteAudioTrack,
+        output_audio_device: Option<DeviceId>,
     ) -> AudioStream {
-        let output_task = self.start_output();
+        let output_task = self.start_output(output_audio_device);
 
         let next_ssrc = self.next_ssrc.fetch_add(1, Ordering::Relaxed);
         let source = AudioMixerSource {
@@ -130,7 +132,7 @@ impl AudioStack {
         }
     }
 
-    fn start_output(&self) -> Arc<Task<()>> {
+    fn start_output(&self, output_audio_device: Option<DeviceId>) -> Arc<Task<()>> {
         if let Some(task) = self._output_task.borrow().upgrade() {
             return task;
         }
@@ -143,6 +145,7 @@ impl AudioStack {
                     mixer,
                     LEGACY_SAMPLE_RATE.get(),
                     LEGACY_CHANNEL_COUNT.get().into(),
+                    output_audio_device,
                 )
                 .await
                 .log_err();
@@ -219,12 +222,16 @@ impl AudioStack {
                     Ok(())
                 })
         } else {
+            let input_audio_device =
+                AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone())
+                    .flatten();
             self.executor.spawn(async move {
                 Self::capture_input(
                     apm,
                     frame_tx,
                     LEGACY_SAMPLE_RATE.get(),
                     LEGACY_CHANNEL_COUNT.get().into(),
+                    input_audio_device,
                 )
                 .await
             })
@@ -247,6 +254,7 @@ impl AudioStack {
         mixer: Arc<Mutex<audio_mixer::AudioMixer>>,
         sample_rate: u32,
         num_channels: u32,
+        output_audio_device: Option<DeviceId>,
     ) -> Result<()> {
         // Prevent App Nap from throttling audio playback on macOS.
         // This guard is held for the entire duration of audio output.
@@ -255,7 +263,8 @@ impl AudioStack {
 
         loop {
             let mut device_change_listener = DeviceChangeListener::new(false)?;
-            let (output_device, output_config) = crate::default_device(false)?;
+            let (output_device, output_config) =
+                crate::default_device(false, output_audio_device.as_ref())?;
             let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>();
             let mixer = mixer.clone();
             let apm = apm.clone();
@@ -327,10 +336,11 @@ impl AudioStack {
         frame_tx: UnboundedSender<AudioFrame<'static>>,
         sample_rate: u32,
         num_channels: u32,
+        input_audio_device: Option<DeviceId>,
     ) -> Result<()> {
         loop {
             let mut device_change_listener = DeviceChangeListener::new(true)?;
-            let (device, config) = crate::default_device(true)?;
+            let (device, config) = crate::default_device(true, input_audio_device.as_ref())?;
             let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>();
             let apm = apm.clone();
             let frame_tx = frame_tx.clone();

crates/livekit_client/src/record.rs 🔗

@@ -7,20 +7,22 @@ use std::{
 };
 
 use anyhow::{Context, Result};
+use cpal::DeviceId;
 use cpal::traits::{DeviceTrait, StreamTrait};
 use rodio::{buffer::SamplesBuffer, conversions::SampleTypeConverter};
 use util::ResultExt;
 
 pub struct CaptureInput {
     pub name: String,
+    pub input_device: Option<DeviceId>,
     config: cpal::SupportedStreamConfig,
     samples: Arc<Mutex<Vec<i16>>>,
     _stream: cpal::Stream,
 }
 
 impl CaptureInput {
-    pub fn start() -> anyhow::Result<Self> {
-        let (device, config) = crate::default_device(true)?;
+    pub fn start(input_device: Option<DeviceId>) -> anyhow::Result<Self> {
+        let (device, config) = crate::default_device(true, input_device.as_ref())?;
         let name = device
             .description()
             .map(|desc| desc.name().to_string())
@@ -32,6 +34,7 @@ impl CaptureInput {
 
         Ok(Self {
             name,
+            input_device,
             _stream: stream,
             config,
             samples,