diff --git a/.github/workflows/platforms.yml b/.github/workflows/platforms.yml index 25e67ffe6..0bd6a78c7 100644 --- a/.github/workflows/platforms.yml +++ b/.github/workflows/platforms.yml @@ -351,44 +351,6 @@ jobs: - name: Build beep example run: cargo build --example beep --target ${{ env.TARGET }} - # Windows crate version compatibility - windows-versions: - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - version: ["0.59.0", "0.60.0", "0.61.3"] - - name: windows-crate-v${{ matrix.version }} - steps: - - uses: actions/checkout@v5 - - - name: Install dependencies - run: choco install llvm - - - name: Install Rust MSRV (${{ env.MSRV_WINDOWS }}) - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ env.MSRV_WINDOWS }} - - - name: Rust Cache - uses: Swatinem/rust-cache@v2 - with: - key: windows-v${{ matrix.version }} - - - name: Lock windows crate to specific version - shell: bash - run: | - cargo generate-lockfile - cargo update -p windows --precise ${{ matrix.version }} - echo "Locked windows crate version:" - cargo tree | grep "windows v" || echo "Windows crate not found in dependency tree" - echo "Cargo.lock entry:" - grep -A 5 "name = \"windows\"" Cargo.lock | head -10 - - - name: Check WASAPI with windows v${{ matrix.version }} - run: cargo check --verbose - # cpal publishing (only on cpal release events) publish-cpal: if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v') @@ -403,7 +365,6 @@ jobs: - wasm-bindgen - wasm-audioworklet - wasm-wasip1 - - windows-versions runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 diff --git a/Cargo.toml b/Cargo.toml index ac7decdf7..ff00840f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ edition = "2021" rust-version = "1.77" [features] +default = [] + # ASIO backend for Windows # Provides low-latency audio I/O by bypassing the Windows audio stack # Requires: ASIO drivers and LLVM/Clang for build-time bindings @@ -19,6 +21,19 @@ asio = [ "dep:num-traits", ] +# Legacy Windows audio device activation mode. When enabled: +# - Uses IMMDevice::Activate instead of ActivateAudioInterfaceAsync +# - Audio does NOT automatically reroute when the default device changes +# - Streams will break when the default device changes (e.g., plugging in headphones) +# +# By default (without this feature), CPAL uses virtual default devices that: +# - Automatically reroute audio when the default device changes +# - Allow streams to survive device changes +# - Require Windows 8 or later +# +# Enable this feature only if supporting Windows 7 or earlier. +windows-legacy = [] + # JACK Audio Connection Kit backend # Provides low-latency connections between applications and audio hardware # Requires: JACK server and client libraries installed on the system @@ -62,11 +77,8 @@ hound = "3.5" ringbuf = "0.4" clap = { version = ">=4.0, <=4.5", features = ["derive"] } -# Support a range of versions in order to avoid duplication of this crate. Make sure to test all -# versions when bumping to a new release, and only increase the minimum when absolutely necessary. -# When updating this, also update the "windows-version" matrix in the CI workflow. [target.'cfg(target_os = "windows")'.dependencies] -windows = { version = ">=0.59, <=0.62", features = [ +windows = { version = "0.62.2", features = [ "Win32_Media_Audio", "Win32_Foundation", "Win32_Devices_Properties", @@ -79,6 +91,8 @@ windows = { version = ">=0.59, <=0.62", features = [ "Win32_Media_Multimedia", "Win32_UI_Shell_PropertiesSystem", ] } +# Explicitly depend on windows-core for use with the `windows::core::implement` macro. +windows-core = "0.62.2" audio_thread_priority = { version = "0.34", optional = true } asio-sys = { version = "0.2", path = "asio-sys", optional = true } num-traits = { version = "0.2", optional = true } diff --git a/src/host/wasapi/com.rs b/src/host/wasapi/com.rs index 973d8f444..209865a5c 100644 --- a/src/host/wasapi/com.rs +++ b/src/host/wasapi/com.rs @@ -4,7 +4,9 @@ use super::IoError; use std::marker::PhantomData; use windows::Win32::Foundation::RPC_E_CHANGED_MODE; -use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED}; +use windows::Win32::System::Com::{ + CoInitializeEx, CoTaskMemFree, CoUninitialize, COINIT_APARTMENTTHREADED, +}; thread_local!(static COM_INITIALIZED: ComInitialized = { unsafe { @@ -49,6 +51,14 @@ impl Drop for ComInitialized { } } +/// RAII for COM-originating strings that need to be freed with `CoTaskMemFree`. +pub struct ComString(pub windows::core::PWSTR); +impl Drop for ComString { + fn drop(&mut self) { + unsafe { CoTaskMemFree(Some(self.0.as_ptr() as *mut _)) } + } +} + /// Ensures that COM is initialized in this thread. #[inline] pub fn com_initialized() { diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 0caa0bd6f..8938dcbb3 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -1,3 +1,4 @@ +use crate::host::wasapi::com::ComString; use crate::{ BackendSpecificError, BufferSize, Data, DefaultStreamConfigError, DeviceDescription, DeviceDescriptionBuilder, DeviceDirection, DeviceId, DeviceIdError, DeviceNameError, @@ -67,10 +68,17 @@ struct IAudioClientWrapper(Audio::IAudioClient); unsafe impl Send for IAudioClientWrapper {} unsafe impl Sync for IAudioClientWrapper {} +#[derive(Debug, Clone)] +enum DeviceHandle { + DefaultOutput, + DefaultInput, + Specific(Audio::IMMDevice), +} + /// An opaque type that identifies an end point. #[derive(Clone)] pub struct Device { - device: Audio::IMMDevice, + device: DeviceHandle, /// We cache an uninitialized `IAudioClient` so that we can call functions from it without /// having to create/destroy audio clients all the time. future_audio_client: Arc>>, // TODO: add NonZero around the ptr @@ -297,6 +305,65 @@ unsafe fn format_from_waveformatex_ptr( Some(format) } +#[cfg(not(feature = "windows-legacy"))] +unsafe fn activate_audio_interface_sync( + deviceinterfacepath: windows::core::PWSTR, +) -> windows::core::Result { + use windows::core::IUnknown; + + #[windows::core::implement(Audio::IActivateAudioInterfaceCompletionHandler)] + struct CompletionHandler(std::sync::mpsc::Sender>); + + fn retrieve_result( + operation: &Audio::IActivateAudioInterfaceAsyncOperation, + ) -> windows::core::Result { + let mut result = windows::core::HRESULT::default(); + let mut interface: Option = None; + unsafe { + operation.GetActivateResult(&mut result, &mut interface)?; + } + result.ok()?; + interface.ok_or_else(|| { + windows::core::Error::new( + Audio::AUDCLNT_E_DEVICE_INVALIDATED, + "audio interface could not be retrieved during activation", + ) + }) + } + + impl Audio::IActivateAudioInterfaceCompletionHandler_Impl for CompletionHandler_Impl { + fn ActivateCompleted( + &self, + operation: windows::core::Ref, + ) -> windows::core::Result<()> { + let result = operation.ok().and_then(retrieve_result); + let _ = self.0.send(result); + Ok(()) + } + } + + let (sender, receiver) = std::sync::mpsc::channel(); + let completion: Audio::IActivateAudioInterfaceCompletionHandler = + CompletionHandler(sender).into(); + Audio::ActivateAudioInterfaceAsync( + deviceinterfacepath, + &Audio::IAudioClient::IID, + None, + &completion, + )?; + // The choice of 2 seconds here is arbitrary; it is a failsafe in the event that + // `ActivateAudioInterfaceAsync` never resolves. + let result = receiver + .recv_timeout(Duration::from_secs(2)) + .map_err(|_| { + windows::core::Error::new( + Audio::AUDCLNT_E_DEVICE_INVALIDATED, + "timeout waiting for audio interface activation", + ) + })??; + result.cast() +} + unsafe impl Send for Device {} unsafe impl Sync for Device {} @@ -347,10 +414,15 @@ fn enumerator_to_interface_type(enumerator: &str) -> Option Result { + let device = self + .immdevice() + .ok_or(DeviceNameError::from(BackendSpecificError { + description: "device not found while getting name".to_string(), + }))?; + unsafe { // Open the device's property store. - let property_store = self - .device + let property_store = device .OpenPropertyStore(STGM_READ) .expect("could not open property store"); @@ -442,8 +514,16 @@ impl Device { } fn id(&self) -> Result { + let device = self + .immdevice() + .ok_or_else(|| DeviceIdError::BackendSpecific { + err: BackendSpecificError { + description: "device not found while getting id".to_string(), + }, + })?; + unsafe { - match self.device.GetId() { + match device.GetId() { Ok(pwstr) => match pwstr.to_string() { Ok(id_str) => Ok(DeviceId(crate::platform::HostId::Wasapi, id_str)), Err(e) => Err(DeviceIdError::BackendSpecific { @@ -459,13 +539,43 @@ impl Device { fn from_immdevice(device: Audio::IMMDevice) -> Self { Device { - device, + device: DeviceHandle::Specific(device), future_audio_client: Arc::new(Mutex::new(None)), } } - pub fn immdevice(&self) -> &Audio::IMMDevice { - &self.device + #[inline] + fn default_output() -> Self { + Device { + device: DeviceHandle::DefaultOutput, + future_audio_client: Arc::new(Mutex::new(None)), + } + } + + #[inline] + fn default_input() -> Self { + Device { + device: DeviceHandle::DefaultInput, + future_audio_client: Arc::new(Mutex::new(None)), + } + } + + pub fn immdevice(&self) -> Option { + match &self.device { + DeviceHandle::DefaultOutput => unsafe { + get_enumerator() + .0 + .GetDefaultAudioEndpoint(Audio::eRender, Audio::eConsole) + .ok() + }, + DeviceHandle::DefaultInput => unsafe { + get_enumerator() + .0 + .GetDefaultAudioEndpoint(Audio::eCapture, Audio::eConsole) + .ok() + }, + DeviceHandle::Specific(device) => Some(device.clone()), + } } /// Ensures that `future_audio_client` contains a `Some` and returns a locked mutex to it. @@ -477,10 +587,42 @@ impl Device { return Ok(lock); } + // By default, we use `ActivateAudioInterfaceAsync` to get an `IAudioClient` for + // virtual default devices. When retrieved this way, streams will be automatically + // rerouted if the default device is changed. + // + // When the `windows-legacy` feature is enabled, we use `Activate` to get an + // `IAudioClient` for the current device, which does not support automatic rerouting. + + #[cfg(not(feature = "windows-legacy"))] let audio_client: Audio::IAudioClient = unsafe { - // can fail if the device has been disconnected since we enumerated it, or if - // the device doesn't support playback for some reason - self.device.Activate(Com::CLSCTX_ALL, None)? + match &self.device { + DeviceHandle::DefaultOutput => { + let default_audio = + ComString(Com::StringFromIID(&Audio::DEVINTERFACE_AUDIO_RENDER)?); + activate_audio_interface_sync(default_audio.0)? + } + DeviceHandle::DefaultInput => { + let default_audio = + ComString(Com::StringFromIID(&Audio::DEVINTERFACE_AUDIO_CAPTURE)?); + activate_audio_interface_sync(default_audio.0)? + } + DeviceHandle::Specific(device) => { + // can fail if the device has been disconnected since we enumerated it, or if + // the device doesn't support playback for some reason + device.Activate(Com::CLSCTX_ALL, None)? + } + } + }; + + #[cfg(feature = "windows-legacy")] + let audio_client = unsafe { + self.immdevice() + .ok_or(windows::core::Error::new( + Audio::AUDCLNT_E_DEVICE_INVALIDATED, + "device not found while getting audio client", + ))? + .Activate(Com::CLSCTX_ALL, None)? }; *lock = Some(IAudioClientWrapper(audio_client)); @@ -648,8 +790,14 @@ impl Device { } pub(crate) fn data_flow(&self) -> Audio::EDataFlow { - let endpoint = Endpoint::from(self.device.clone()); - endpoint.data_flow() + match &self.device { + DeviceHandle::DefaultOutput => Audio::eRender, + DeviceHandle::DefaultInput => Audio::eCapture, + DeviceHandle::Specific(device) => { + let endpoint = Endpoint::from(device.clone()); + endpoint.data_flow() + } + } } pub fn default_input_config(&self) -> Result { @@ -900,40 +1048,40 @@ impl Device { impl PartialEq for Device { fn eq(&self, other: &Device) -> bool { - // Use case: In order to check whether the default device has changed - // the client code might need to compare the previous default device with the current one. - // The pointer comparison (`self.device == other.device`) don't work there, - // because the pointers are different even when the default device stays the same. - // - // In this code section we're trying to use the GetId method for the device comparison, cf. - // https://docs.microsoft.com/en-us/windows/desktop/api/mmdeviceapi/nf-mmdeviceapi-immdevice-getid - unsafe { - struct IdRAII(windows::core::PWSTR); - /// RAII for device IDs. - impl Drop for IdRAII { - fn drop(&mut self) { - unsafe { Com::CoTaskMemFree(Some(self.0 .0 as *mut _)) } - } - } - // GetId only fails with E_OUTOFMEMORY and if it does, we're probably dead already. - // Plus it won't do to change the device comparison logic unexpectedly. - let id1 = self.device.GetId().expect("cpal: GetId failure"); - let id1 = IdRAII(id1); - let id2 = other.device.GetId().expect("cpal: GetId failure"); - let id2 = IdRAII(id2); - // 16-bit null-terminated comparison. - let mut offset = 0; - loop { - let w1: u16 = *(id1.0).0.offset(offset); - let w2: u16 = *(id2.0).0.offset(offset); - if w1 == 0 && w2 == 0 { - return true; - } - if w1 != w2 { - return false; + match (&self.device, &other.device) { + (DeviceHandle::DefaultOutput, DeviceHandle::DefaultOutput) => true, + (DeviceHandle::DefaultInput, DeviceHandle::DefaultInput) => true, + (DeviceHandle::Specific(dev1), DeviceHandle::Specific(dev2)) => { + // Use case: In order to check whether the default device has changed + // the client code might need to compare the previous default device with the current one. + // The pointer comparison (`self.device == other.device`) don't work there, + // because the pointers are different even when the default device stays the same. + // + // In this code section we're trying to use the GetId method for the device comparison, cf. + // https://docs.microsoft.com/en-us/windows/desktop/api/mmdeviceapi/nf-mmdeviceapi-immdevice-getid + unsafe { + // GetId only fails with E_OUTOFMEMORY and if it does, we're probably dead already. + // Plus it won't do to change the device comparison logic unexpectedly. + let id1 = dev1.GetId().expect("cpal: GetId failure"); + let id1 = ComString(id1); + let id2 = dev2.GetId().expect("cpal: GetId failure"); + let id2 = ComString(id2); + // 16-bit null-terminated comparison. + let mut offset = 0; + loop { + let w1: u16 = *(id1.0).0.offset(offset); + let w2: u16 = *(id2.0).0.offset(offset); + if w1 == 0 && w2 == 0 { + return true; + } + if w1 != w2 { + return false; + } + offset += 1; + } } - offset += 1; } + _ => false, } } } @@ -942,31 +1090,26 @@ impl Eq for Device {} impl std::hash::Hash for Device { fn hash(&self, state: &mut H) { - // Hash the device ID for consistency with PartialEq - // SAFETY: GetId only fails with E_OUTOFMEMORY, which is unrecoverable. - // We need consistent hash/eq behavior. - unsafe { - use windows::Win32::System::Com; - - struct IdRAII(windows::core::PWSTR); - impl Drop for IdRAII { - fn drop(&mut self) { - unsafe { Com::CoTaskMemFree(Some(self.0 .0 as *mut _)) } - } - } - - let id = self.device.GetId().expect("cpal: GetId failure"); - let id = IdRAII(id); - - // Hash the 16-bit null-terminated string - let mut offset = 0; - loop { - let w: u16 = *(id.0).0.offset(offset); - if w == 0 { - break; + // Hash consistently with PartialEq + mem::discriminant(&self.device).hash(state); + + if let DeviceHandle::Specific(device) = &self.device { + // SAFETY: GetId only fails with E_OUTOFMEMORY, which is unrecoverable. + // We need consistent hash/eq behavior. + unsafe { + let id = device.GetId().expect("cpal: GetId failure"); + let id = ComString(id); + + // Hash the 16-bit null-terminated string + let mut offset = 0; + loop { + let w: u16 = *(id.0).0.offset(offset); + if w == 0 { + break; + } + w.hash(state); + offset += 1; } - w.hash(state); - offset += 1; } } } @@ -1137,23 +1280,12 @@ impl Iterator for Devices { } } -fn default_device(data_flow: Audio::EDataFlow) -> Option { - unsafe { - let device = get_enumerator() - .0 - .GetDefaultAudioEndpoint(data_flow, Audio::eConsole) - .ok()?; - // TODO: check specifically for `E_NOTFOUND`, and panic otherwise - Some(Device::from_immdevice(device)) - } -} - pub fn default_input_device() -> Option { - default_device(Audio::eCapture) + Some(Device::default_input()) } pub fn default_output_device() -> Option { - default_device(Audio::eRender) + Some(Device::default_output()) } /// Get the audio clock used to produce `StreamInstant`s.