From b67087d5703fa20bf9ee5083be4e5aa2c9306de5 Mon Sep 17 00:00:00 2001 From: Nabi Bukhsh Date: Sat, 17 Oct 2026 00:38:51 +0500 Subject: [PATCH] fix(platform-audio): synthesize index GUIDs on Linux and support index-based selection (#1528) --- .changeset/platform_audio_empty_guid.md | 9 + livekit/src/platform_audio/mod.rs | 267 +++++++++++++++++++-- livekit/tests/platform_audio_test.rs | 43 ++++ webrtc-sys/src/audio_device_controller.cpp | 96 ++++++-- webrtc-sys/src/audio_device_controller.rs | 2 + 5 files changed, 386 insertions(+), 31 deletions(-) create mode 100644 .changeset/platform_audio_empty_guid.md diff --git a/.changeset/platform_audio_empty_guid.md b/.changeset/platform_audio_empty_guid.md new file mode 100644 index 000000000..569ccc430 --- /dev/null +++ b/.changeset/platform_audio_empty_guid.md @@ -0,0 +1,9 @@ +--- +libwebrtc: patch +livekit: patch +livekit-capture: patch +livekit-ffi: patch +webrtc-sys: patch +--- + +Synthesize index-based identifiers on platforms without hardware audio GUIDs and add index-based selection to PlatformAudio. diff --git a/livekit/src/platform_audio/mod.rs b/livekit/src/platform_audio/mod.rs index 7af17dfc2..055adf020 100644 --- a/livekit/src/platform_audio/mod.rs +++ b/livekit/src/platform_audio/mod.rs @@ -156,6 +156,11 @@ impl RecordingDeviceId { Self(guid.to_string()) } + /// Creates a recording device ID from a device index. + pub fn from_index(index: usize) -> Self { + Self(format!("index:{index}")) + } + /// Returns the underlying GUID string. pub fn as_str(&self) -> &str { &self.0 @@ -176,7 +181,8 @@ impl fmt::Display for RecordingDeviceId { /// # Platform Notes /// /// - **Desktop (Windows, macOS, Linux):** Contains a unique GUID that persists across -/// device hot-plug events. Use this for reliable device selection. +/// device hot-plug events. On Linux, when the audio backend (e.g. PulseAudio) does not +/// provide a hardware GUID, an index-based identifier (e.g. `"index:0"`) is used. /// - **Android:** Always empty. Android doesn't provide device GUIDs and only reports /// a single "default" device. Audio routing (speaker, earpiece, Bluetooth) is handled /// by the system via `AudioManager`, not through WebRTC device selection. @@ -200,6 +206,11 @@ impl PlayoutDeviceId { Self(guid.to_string()) } + /// Creates a playout device ID from a device index. + pub fn from_index(index: usize) -> Self { + Self(format!("index:{index}")) + } + /// Returns the underlying GUID string. pub fn as_str(&self) -> &str { &self.0 @@ -610,13 +621,17 @@ impl PlatformAudio { return None; } - let index = index as u16; + let index_u16 = index as u16; + let guid = self.handle.runtime.recording_device_guid(index_u16); + let id = if guid.is_empty() { + RecordingDeviceId::from_index(index) + } else { + RecordingDeviceId::from_unchecked_guid(&guid) + }; Some(RecordingDeviceInfo { - id: RecordingDeviceId::from_unchecked_guid( - &self.handle.runtime.recording_device_guid(index), - ), - name: self.handle.runtime.recording_device_name(index), - index: index as usize, + id, + name: self.handle.runtime.recording_device_name(index_u16), + index, }) } @@ -625,13 +640,17 @@ impl PlatformAudio { return None; } - let index = index as u16; + let index_u16 = index as u16; + let guid = self.handle.runtime.playout_device_guid(index_u16); + let id = if guid.is_empty() { + PlayoutDeviceId::from_index(index) + } else { + PlayoutDeviceId::from_unchecked_guid(&guid) + }; Some(PlayoutDeviceInfo { - id: PlayoutDeviceId::from_unchecked_guid( - &self.handle.runtime.playout_device_guid(index), - ), - name: self.handle.runtime.playout_device_name(index), - index: index as usize, + id, + name: self.handle.runtime.playout_device_name(index_u16), + index, }) } @@ -688,13 +707,58 @@ impl PlatformAudio { #[cfg(not(any(target_os = "ios", target_os = "android")))] Self::ensure_device_exists(self.recording_devices(), |device| &device.id == id)?; - if self.handle.runtime.set_recording_device_by_guid(id.as_str()) { + let guid = id.as_str(); + let success = if let Some(idx_str) = guid.strip_prefix("index:") { + if let Ok(idx) = idx_str.parse::() { + self.handle.runtime.set_recording_device(idx) + } else { + self.handle.runtime.set_recording_device_by_guid(guid) + } + } else { + self.handle.runtime.set_recording_device_by_guid(guid) + }; + + if success { Ok(()) } else { Err(AudioError::DeviceNotFound) } } + /// Selects a recording (microphone) device by index. + /// + /// This is an alternative to [`set_recording_device`](Self::set_recording_device) using a 0-based index. + /// Note that device indices can change across device hot-plug events. + /// + /// # Platform Notes + /// + /// - **Desktop:** Selects the recording device at `index`. + /// - **Mobile (iOS/Android):** Device selection is a no-op (system handles routing). + /// + /// # Arguments + /// + /// * `index` - 0-based device index + /// + /// # Errors + /// + /// Returns [`AudioError::DeviceNotFound`] if `index` is out of bounds or cannot be selected. + pub fn set_recording_device_by_index(&self, index: usize) -> AudioResult<()> { + #[cfg(not(any(target_os = "ios", target_os = "android")))] + { + let device = self + .recording_devices() + .find(|d| d.index == index) + .ok_or(AudioError::DeviceNotFound)?; + self.set_recording_device(&device.id) + } + + #[cfg(any(target_os = "ios", target_os = "android"))] + { + let id = RecordingDeviceId::from_index(index); + self.set_recording_device(&id) + } + } + /// Selects a playout (speaker) device by ID. /// /// This is the preferred method for device selection as IDs are stable @@ -733,7 +797,18 @@ impl PlatformAudio { Self::ensure_device_exists(self.playout_devices(), |device| &device.id == id)?; let runtime = &self.handle.runtime; - if !runtime.set_playout_device_by_guid(id.as_str()) { + let guid = id.as_str(); + let success = if let Some(idx_str) = guid.strip_prefix("index:") { + if let Ok(idx) = idx_str.parse::() { + runtime.set_playout_device(idx) + } else { + runtime.set_playout_device_by_guid(guid) + } + } else { + runtime.set_playout_device_by_guid(guid) + }; + + if !success { return Err(AudioError::DeviceNotFound); } @@ -747,6 +822,40 @@ impl PlatformAudio { Ok(()) } + /// Selects a playout (speaker) device by index. + /// + /// This is an alternative to [`set_playout_device`](Self::set_playout_device) using a 0-based index. + /// Note that device indices can change across device hot-plug events. + /// + /// # Platform Notes + /// + /// - **Desktop:** Selects the playout device at `index`. + /// - **Mobile (iOS/Android):** Device selection is a no-op (system handles routing). + /// + /// # Arguments + /// + /// * `index` - 0-based device index + /// + /// # Errors + /// + /// Returns [`AudioError::DeviceNotFound`] if `index` is out of bounds or cannot be selected. + pub fn set_playout_device_by_index(&self, index: usize) -> AudioResult<()> { + #[cfg(not(any(target_os = "ios", target_os = "android")))] + { + let device = self + .playout_devices() + .find(|d| d.index == index) + .ok_or(AudioError::DeviceNotFound)?; + self.set_playout_device(&device.id) + } + + #[cfg(any(target_os = "ios", target_os = "android"))] + { + let id = PlayoutDeviceId::from_index(index); + self.set_playout_device(&id) + } + } + /// Switches the recording device while audio is active (hot-swap). /// /// Unlike [`set_recording_device`], this method handles the stop/change/restart @@ -771,6 +880,9 @@ impl PlatformAudio { /// /// [`set_recording_device`]: Self::set_recording_device pub fn switch_recording_device(&self, id: &RecordingDeviceId) -> AudioResult<()> { + #[cfg(not(any(target_os = "ios", target_os = "android")))] + Self::ensure_device_exists(self.recording_devices(), |device| &device.id == id)?; + let runtime = &self.handle.runtime; let was_initialized = runtime.recording_is_initialized(); @@ -780,7 +892,18 @@ impl PlatformAudio { } } - if !runtime.set_recording_device_by_guid(id.as_str()) { + let guid = id.as_str(); + let success = if let Some(idx_str) = guid.strip_prefix("index:") { + if let Ok(idx) = idx_str.parse::() { + runtime.set_recording_device(idx) + } else { + runtime.set_recording_device_by_guid(guid) + } + } else { + runtime.set_recording_device_by_guid(guid) + }; + + if !success { return Err(AudioError::DeviceNotFound); } @@ -797,6 +920,36 @@ impl PlatformAudio { Ok(()) } + /// Switches the recording device by index while audio is active (hot-swap). + /// + /// Unlike [`set_recording_device_by_index`](Self::set_recording_device_by_index), this method handles + /// the stop/change/restart sequence required when recording is already active. + /// + /// # Arguments + /// + /// * `index` - 0-based device index + /// + /// # Errors + /// + /// - [`AudioError::DeviceNotFound`] if `index` is out of bounds or device is unavailable + /// - [`AudioError::OperationFailed`] if any step fails + pub fn switch_recording_device_by_index(&self, index: usize) -> AudioResult<()> { + #[cfg(not(any(target_os = "ios", target_os = "android")))] + { + let device = self + .recording_devices() + .find(|d| d.index == index) + .ok_or(AudioError::DeviceNotFound)?; + self.switch_recording_device(&device.id) + } + + #[cfg(any(target_os = "ios", target_os = "android"))] + { + let id = RecordingDeviceId::from_index(index); + self.switch_recording_device(&id) + } + } + /// Switches the playout device while audio is active (hot-swap). /// /// Unlike [`set_playout_device`], this method handles the stop/change/restart @@ -830,7 +983,18 @@ impl PlatformAudio { } } - if !runtime.set_playout_device_by_guid(id.as_str()) { + let guid = id.as_str(); + let success = if let Some(idx_str) = guid.strip_prefix("index:") { + if let Ok(idx) = idx_str.parse::() { + runtime.set_playout_device(idx) + } else { + runtime.set_playout_device_by_guid(guid) + } + } else { + runtime.set_playout_device_by_guid(guid) + }; + + if !success { return Err(AudioError::DeviceNotFound); } @@ -847,6 +1011,36 @@ impl PlatformAudio { Ok(()) } + /// Switches the playout device by index while audio is active (hot-swap). + /// + /// Unlike [`set_playout_device_by_index`](Self::set_playout_device_by_index), this method handles + /// the stop/change/restart sequence required when playout is already active. + /// + /// # Arguments + /// + /// * `index` - 0-based device index + /// + /// # Errors + /// + /// - [`AudioError::DeviceNotFound`] if `index` is out of bounds or device is unavailable + /// - [`AudioError::OperationFailed`] if any step fails + pub fn switch_playout_device_by_index(&self, index: usize) -> AudioResult<()> { + #[cfg(not(any(target_os = "ios", target_os = "android")))] + { + let device = self + .playout_devices() + .find(|d| d.index == index) + .ok_or(AudioError::DeviceNotFound)?; + self.switch_playout_device(&device.id) + } + + #[cfg(any(target_os = "ios", target_os = "android"))] + { + let id = PlayoutDeviceId::from_index(index); + self.switch_playout_device(&id) + } + } + // ========================================================================= // Recording Control // ========================================================================= @@ -1234,4 +1428,43 @@ mod tests { let source = RtcAudioSource::Device; assert!(matches!(source, RtcAudioSource::Device)); } + + #[test] + fn recording_device_id_from_index() { + let id = RecordingDeviceId::from_index(0); + assert_eq!(id.as_str(), "index:0"); + assert_eq!(id.to_string(), "index:0"); + + let id = RecordingDeviceId::from_index(42); + assert_eq!(id.as_str(), "index:42"); + assert_eq!(id.to_string(), "index:42"); + } + + #[test] + fn playout_device_id_from_index() { + let id = PlayoutDeviceId::from_index(0); + assert_eq!(id.as_str(), "index:0"); + assert_eq!(id.to_string(), "index:0"); + + let id = PlayoutDeviceId::from_index(7); + assert_eq!(id.as_str(), "index:7"); + assert_eq!(id.to_string(), "index:7"); + } + + #[test] + fn device_id_equality_and_hash() { + use std::collections::HashSet; + + let id1 = RecordingDeviceId::from_index(1); + let id2 = RecordingDeviceId::from_unchecked_guid("index:1"); + let id3 = RecordingDeviceId::from_index(2); + + assert_eq!(id1, id2); + assert_ne!(id1, id3); + + let mut set = HashSet::new(); + set.insert(id1.clone()); + assert!(set.contains(&id2)); + assert!(!set.contains(&id3)); + } } diff --git a/livekit/tests/platform_audio_test.rs b/livekit/tests/platform_audio_test.rs index 24cc673e5..7c8cebdd9 100644 --- a/livekit/tests/platform_audio_test.rs +++ b/livekit/tests/platform_audio_test.rs @@ -362,6 +362,49 @@ async fn test_platform_audio_invalid_device_id_returns_device_not_found() -> Res Ok(()) } +/// Test device selection by index. +#[test_log::test(tokio::test)] +#[serial] +async fn test_platform_audio_device_selection_by_index() -> Result<()> { + use livekit::reset_platform_audio; + + reset_platform_audio(); + + let Some(audio) = try_create_platform_audio("test_platform_audio_device_selection_by_index") + else { + return Ok(()); + }; + + let recording_devices: Vec<_> = audio.recording_devices().collect(); + let playout_devices: Vec<_> = audio.playout_devices().collect(); + + if let Some(device) = recording_devices.first() { + audio.set_recording_device_by_index(device.index)?; + log::info!("Selected recording device by index {}: {}", device.index, device.name); + } + + if let Some(device) = playout_devices.first() { + audio.set_playout_device_by_index(device.index)?; + log::info!("Selected playout device by index {}: {}", device.index, device.name); + } + + #[cfg(not(any(target_os = "ios", target_os = "android")))] + { + let out_of_bounds_index = usize::MAX / 2; + assert_eq!( + audio.set_recording_device_by_index(out_of_bounds_index), + Err(AudioError::DeviceNotFound) + ); + assert_eq!( + audio.set_playout_device_by_index(out_of_bounds_index), + Err(AudioError::DeviceNotFound) + ); + } + + drop(audio); + Ok(()) +} + /// Test audio processing configuration without room connection. #[test_log::test(tokio::test)] #[serial] diff --git a/webrtc-sys/src/audio_device_controller.cpp b/webrtc-sys/src/audio_device_controller.cpp index 500b6efe9..ea777c6fe 100644 --- a/webrtc-sys/src/audio_device_controller.cpp +++ b/webrtc-sys/src/audio_device_controller.cpp @@ -52,14 +52,24 @@ rust::String AudioDeviceController::playout_device_guid(uint16_t index) const { char name[webrtc::kAdmMaxDeviceNameSize] = {0}; char guid[webrtc::kAdmMaxGuidSize] = {0}; adm_proxy_->PlayoutDeviceName(index, name, guid); - return rust::String(guid); + if (guid[0] != '\0') { + return rust::String(guid); + } + // Linux PulseAudio/ALSA does not populate device GUIDs in WebRTC. + // Synthesize an index-based identifier so callers can distinguish and select devices. + return rust::String("index:" + std::to_string(index)); } rust::String AudioDeviceController::recording_device_guid(uint16_t index) const { char name[webrtc::kAdmMaxDeviceNameSize] = {0}; char guid[webrtc::kAdmMaxGuidSize] = {0}; adm_proxy_->RecordingDeviceName(index, name, guid); - return rust::String(guid); + if (guid[0] != '\0') { + return rust::String(guid); + } + // Linux PulseAudio/ALSA does not populate device GUIDs in WebRTC. + // Synthesize an index-based identifier so callers can distinguish and select devices. + return rust::String("index:" + std::to_string(index)); } bool AudioDeviceController::set_playout_device(uint16_t index) const { @@ -72,49 +82,107 @@ bool AudioDeviceController::set_recording_device(uint16_t index) const { bool AudioDeviceController::set_playout_device_by_guid(rust::String guid) const { int16_t count = adm_proxy_->PlayoutDevices(); + std::string target_guid = std::string(guid.c_str()); + + if (target_guid.empty()) { + return false; + } + + // Handle synthetic index-based GUIDs (e.g. "index:1") + if (target_guid.rfind("index:", 0) == 0) { + try { + size_t pos = 0; + int idx = std::stoi(target_guid.substr(6), &pos); + if (pos == target_guid.length() - 6 && idx >= 0 && idx < count) { + return adm_proxy_->SetPlayoutDevice(static_cast(idx)) == 0; + } + } catch (...) {} + return false; + } + + // Try to find a device matching the GUID (only match non-empty device GUIDs) + for (int16_t i = 0; i < count; i++) { + char name[webrtc::kAdmMaxDeviceNameSize] = {0}; + char device_guid[webrtc::kAdmMaxGuidSize] = {0}; + if (adm_proxy_->PlayoutDeviceName(i, name, device_guid) == 0) { + if (device_guid[0] != '\0' && target_guid == std::string(device_guid)) { + return adm_proxy_->SetPlayoutDevice(i) == 0; + } + } + } - // Try to find a device matching the GUID + // Fallback: match by device name if no GUID matched for (int16_t i = 0; i < count; i++) { char name[webrtc::kAdmMaxDeviceNameSize] = {0}; char device_guid[webrtc::kAdmMaxGuidSize] = {0}; if (adm_proxy_->PlayoutDeviceName(i, name, device_guid) == 0) { - if (std::string(guid.c_str()) == std::string(device_guid)) { + if (target_guid == std::string(name)) { return adm_proxy_->SetPlayoutDevice(i) == 0; } } } - // No match found - fall back to default device (index 0). - // This handles mobile platforms (iOS/Android) where: - // - GUIDs may be empty or not meaningful - // - Device selection is a no-op (system handles routing) +#if defined(WEBRTC_IOS) || defined(WEBRTC_ANDROID) + // On mobile platforms, device selection is a no-op (system handles routing), + // so fall back to default device (index 0). if (count > 0) { return adm_proxy_->SetPlayoutDevice(0) == 0; } +#endif + return false; } bool AudioDeviceController::set_recording_device_by_guid(rust::String guid) const { int16_t count = adm_proxy_->RecordingDevices(); + std::string target_guid = std::string(guid.c_str()); + + if (target_guid.empty()) { + return false; + } + + // Handle synthetic index-based GUIDs (e.g. "index:1") + if (target_guid.rfind("index:", 0) == 0) { + try { + size_t pos = 0; + int idx = std::stoi(target_guid.substr(6), &pos); + if (pos == target_guid.length() - 6 && idx >= 0 && idx < count) { + return adm_proxy_->SetRecordingDevice(static_cast(idx)) == 0; + } + } catch (...) {} + return false; + } + + // Try to find a device matching the GUID (only match non-empty device GUIDs) + for (int16_t i = 0; i < count; i++) { + char name[webrtc::kAdmMaxDeviceNameSize] = {0}; + char device_guid[webrtc::kAdmMaxGuidSize] = {0}; + if (adm_proxy_->RecordingDeviceName(i, name, device_guid) == 0) { + if (device_guid[0] != '\0' && target_guid == std::string(device_guid)) { + return adm_proxy_->SetRecordingDevice(i) == 0; + } + } + } - // Try to find a device matching the GUID + // Fallback: match by device name if no GUID matched for (int16_t i = 0; i < count; i++) { char name[webrtc::kAdmMaxDeviceNameSize] = {0}; char device_guid[webrtc::kAdmMaxGuidSize] = {0}; if (adm_proxy_->RecordingDeviceName(i, name, device_guid) == 0) { - if (std::string(guid.c_str()) == std::string(device_guid)) { + if (target_guid == std::string(name)) { return adm_proxy_->SetRecordingDevice(i) == 0; } } } - // No match found - fall back to default device (index 0). - // This handles mobile platforms (iOS/Android) where: - // - GUIDs may be empty or not meaningful - // - Device selection is a no-op (system handles routing) +#if defined(WEBRTC_IOS) || defined(WEBRTC_ANDROID) + // On mobile platforms, device selection is a no-op (system handles routing), + // so fall back to default device (index 0). if (count > 0) { return adm_proxy_->SetRecordingDevice(0) == 0; } +#endif + return false; } diff --git a/webrtc-sys/src/audio_device_controller.rs b/webrtc-sys/src/audio_device_controller.rs index 00ce73943..f90eaa489 100644 --- a/webrtc-sys/src/audio_device_controller.rs +++ b/webrtc-sys/src/audio_device_controller.rs @@ -92,12 +92,14 @@ mod tests { if recording_count > 0 { let _ = audio.recording_device_name(0); let guid = audio.recording_device_guid(0); + assert!(!guid.is_empty(), "recording device guid should not be empty"); let _ = audio.set_recording_device_by_guid(guid); } if playout_count > 0 { let _ = audio.playout_device_name(0); let guid = audio.playout_device_guid(0); + assert!(!guid.is_empty(), "playout device guid should not be empty"); let _ = audio.set_playout_device_by_guid(guid); }