-
Notifications
You must be signed in to change notification settings - Fork 442
WASAPI: Use ActivateAudioInterfaceAsync
with virtual device IDs for default devices
#1027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d68c885
dd00d9e
b8892b9
7e82d99
8215207
66ed6be
f208797
c7395ce
5722483
6a17582
04c8eaa
0830b3f
87d5df3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,11 +10,20 @@ edition = "2021" | |
rust-version = "1.70" | ||
|
||
[features] | ||
default = ["wasapi-virtual-default-devices"] | ||
|
||
asio = [ | ||
"asio-sys", | ||
"num-traits", | ||
] # Only available on Windows. See README for setup instructions. | ||
|
||
# Enable virtual default devices for WASAPI, so that audio will be | ||
# automatically rerouted when the default input or output device is changed. | ||
# | ||
# Note that this only works on Windows 8 and above. It is turned on by default, | ||
# but consider turning it off if you are supporting an older version of Windows. | ||
wasapi-virtual-default-devices = [] | ||
|
||
# Deprecated, the `oboe` backend has been removed | ||
oboe-shared-stdcxx = [] | ||
|
||
|
@@ -31,7 +40,12 @@ clap = { version = "4.5", features = ["derive"] } | |
# 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.58, <=0.62", features = [ | ||
# The `implement` feature was removed in windows-0.61, which means that we can't | ||
# use older versions of the `windows` crate without explicitly activating `implement` | ||
# for them, which will cause problems for >=0.61. | ||
# | ||
# See <https://github.com/microsoft/windows-rs/pull/3333>. | ||
windows = { version = ">=0.61, <=0.62", features = [ | ||
"Win32_Media_Audio", | ||
"Win32_Foundation", | ||
"Win32_Devices_Properties", | ||
|
@@ -44,6 +58,8 @@ windows = { version = ">=0.58, <=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 = "*" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
audio_thread_priority = { version = "0.34.0", optional = true } | ||
asio-sys = { version = "0.2", path = "asio-sys", optional = true } | ||
num-traits = { version = "0.2.6", optional = true } | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -40,10 +40,17 @@ struct IAudioClientWrapper(Audio::IAudioClient); | |
unsafe impl Send for IAudioClientWrapper {} | ||
unsafe impl Sync for IAudioClientWrapper {} | ||
|
||
#[derive(Debug, Clone)] | ||
enum DeviceType { | ||
DefaultOutput, | ||
DefaultInput, | ||
Specific(Audio::IMMDevice), | ||
} | ||
|
||
/// An opaque type that identifies an end point. | ||
#[derive(Clone)] | ||
pub struct Device { | ||
device: Audio::IMMDevice, | ||
device: DeviceType, | ||
/// 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<Mutex<Option<IAudioClientWrapper>>>, // TODO: add NonZero around the ptr | ||
|
@@ -266,15 +273,70 @@ unsafe fn format_from_waveformatex_ptr( | |
Some(format) | ||
} | ||
|
||
#[cfg(feature = "wasapi-virtual-default-devices")] | ||
unsafe fn activate_audio_interface_sync( | ||
deviceinterfacepath: windows::core::PWSTR, | ||
) -> windows::core::Result<Audio::IAudioClient> { | ||
use windows::core::IUnknown; | ||
|
||
#[windows::core::implement(Audio::IActivateAudioInterfaceCompletionHandler)] | ||
struct CompletionHandler(std::sync::mpsc::Sender<windows::core::Result<IUnknown>>); | ||
|
||
fn retrieve_result( | ||
operation: &Audio::IActivateAudioInterfaceAsyncOperation, | ||
) -> windows::core::Result<IUnknown> { | ||
let mut result = windows::core::HRESULT::default(); | ||
let mut interface: Option<IUnknown> = 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<Audio::IActivateAudioInterfaceAsyncOperation>, | ||
) -> 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, | ||
)?; | ||
let result = receiver.recv_timeout(Duration::from_secs(2)).unwrap()?; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why 2 seconds? How about having it return 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 {} | ||
|
||
impl Device { | ||
pub fn name(&self) -> Result<String, DeviceNameError> { | ||
let device = self | ||
.immdevice() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How much of a performance hit does this take when enumerating? Would it make sense to cache it during enumeration or is that a negligible thing? |
||
.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"); | ||
|
||
|
@@ -325,13 +387,43 @@ impl Device { | |
#[inline] | ||
fn from_immdevice(device: Audio::IMMDevice) -> Self { | ||
Device { | ||
device, | ||
device: DeviceType::Specific(device), | ||
future_audio_client: Arc::new(Mutex::new(None)), | ||
} | ||
} | ||
|
||
#[inline] | ||
fn default_output() -> Self { | ||
Device { | ||
device: DeviceType::DefaultOutput, | ||
future_audio_client: Arc::new(Mutex::new(None)), | ||
} | ||
} | ||
|
||
#[inline] | ||
fn default_input() -> Self { | ||
Device { | ||
device: DeviceType::DefaultInput, | ||
future_audio_client: Arc::new(Mutex::new(None)), | ||
} | ||
} | ||
|
||
pub fn immdevice(&self) -> &Audio::IMMDevice { | ||
&self.device | ||
pub fn immdevice(&self) -> Option<Audio::IMMDevice> { | ||
match &self.device { | ||
DeviceType::DefaultOutput => unsafe { | ||
get_enumerator() | ||
.0 | ||
.GetDefaultAudioEndpoint(Audio::eRender, Audio::eConsole) | ||
.ok() | ||
}, | ||
DeviceType::DefaultInput => unsafe { | ||
get_enumerator() | ||
.0 | ||
.GetDefaultAudioEndpoint(Audio::eCapture, Audio::eConsole) | ||
.ok() | ||
}, | ||
DeviceType::Specific(device) => Some(device.clone()), | ||
} | ||
} | ||
|
||
/// Ensures that `future_audio_client` contains a `Some` and returns a locked mutex to it. | ||
|
@@ -343,10 +435,43 @@ impl Device { | |
return Ok(lock); | ||
} | ||
|
||
// When using virtual default devices, we use `ActivateAudioInterfaceAsync` to get | ||
// an `IAudioClient` for the default output or input device. When retrieved this way, | ||
// streams will be automatically rerouted if the default device is changed. | ||
// | ||
// Otherwise, we use `Activate` to get an `IAudioClient` for the current device. | ||
|
||
#[cfg(feature = "wasapi-virtual-default-devices")] | ||
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 { | ||
DeviceType::DefaultOutput => { | ||
let default_audio = Com::StringFromIID(&Audio::DEVINTERFACE_AUDIO_RENDER)?; | ||
let result = activate_audio_interface_sync(default_audio); | ||
Com::CoTaskMemFree(Some(default_audio.as_ptr() as _)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good that you're freeing before returning the error. An safer / more defensive pattern like with struct ComString(windows::core::PWSTR);
impl Drop for ComString {
fn drop(&mut self) {
unsafe { Com::CoTaskMemFree(Some(self.0.as_ptr() as _)) }
}
}
DeviceType::DefaultOutput => {
let default_audio = ComString(Com::StringFromIID(&Audio::DEVINTERFACE_AUDIO_RENDER)?);
activate_audio_interface_sync(default_audio.0)?
} |
||
result? | ||
} | ||
DeviceType::DefaultInput => { | ||
let default_audio = Com::StringFromIID(&Audio::DEVINTERFACE_AUDIO_CAPTURE)?; | ||
let result = activate_audio_interface_sync(default_audio); | ||
Com::CoTaskMemFree(Some(default_audio.as_ptr() as _)); | ||
result? | ||
} | ||
DeviceType::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(not(feature = "wasapi-virtual-default-devices"))] | ||
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)); | ||
|
@@ -515,8 +640,14 @@ impl Device { | |
} | ||
|
||
pub(crate) fn data_flow(&self) -> Audio::EDataFlow { | ||
let endpoint = Endpoint::from(self.device.clone()); | ||
endpoint.data_flow() | ||
match &self.device { | ||
DeviceType::DefaultOutput => Audio::eRender, | ||
DeviceType::DefaultInput => Audio::eCapture, | ||
DeviceType::Specific(device) => { | ||
let endpoint = Endpoint::from(device.clone()); | ||
endpoint.data_flow() | ||
} | ||
} | ||
} | ||
|
||
pub fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> { | ||
|
@@ -766,40 +897,47 @@ impl Device { | |
impl PartialEq for Device { | ||
#[inline] | ||
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) { | ||
(DeviceType::DefaultOutput, DeviceType::DefaultOutput) => true, | ||
(DeviceType::DefaultInput, DeviceType::DefaultInput) => true, | ||
(DeviceType::Specific(dev1), DeviceType::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 { | ||
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 = dev1.GetId().expect("cpal: GetId failure"); | ||
let id1 = IdRAII(id1); | ||
let id2 = dev2.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; | ||
} | ||
offset += 1; | ||
} | ||
} | ||
offset += 1; | ||
} | ||
_ => false, | ||
} | ||
} | ||
} | ||
|
@@ -911,23 +1049,12 @@ impl Iterator for Devices { | |
} | ||
} | ||
|
||
fn default_device(data_flow: Audio::EDataFlow) -> Option<Device> { | ||
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<Device> { | ||
default_device(Audio::eCapture) | ||
Some(Device::default_input()) | ||
} | ||
|
||
pub fn default_output_device() -> Option<Device> { | ||
default_device(Audio::eRender) | ||
Some(Device::default_output()) | ||
} | ||
|
||
/// Get the audio clock used to produce `StreamInstant`s. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First, a documentation suggestion:
Second, I wonder if we should invert the feature and rename it to
windows-legacy
(disabled by default). I'm not so sure if "virtual default devices" clearly communicates its intention. What do you think?