Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/cpal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,6 @@ jobs:
fail-fast: false
matrix:
include:
- windows-version: "0.59.0"
- windows-version: "0.60.0"
- windows-version: "0.61.3"
# Skip 0.62.x since we already test current version in test-native
name: test-windows-v${{ matrix.windows-version }}
Expand Down Expand Up @@ -349,6 +347,12 @@ jobs:
# Use cargo update --precise to lock the windows crate to a specific version
cargo update --precise ${{ matrix.windows-version }} windows

# Remove and re-add `windows-core` to force Cargo to re-resolve it
# I'd prefer a more surgical approach, but things get complicated once multiple `windows-core`
# versions are in play
cargo rm --target='cfg(target_os = "windows")' windows-core
cargo add --target='cfg(target_os = "windows")' windows-core@*

# Verify the version was locked correctly
echo "Locked windows crate version:"
cargo tree | grep "windows v" || echo "Windows crate not found in dependency tree"
Expand Down
18 changes: 17 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand All @@ -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",
Expand All @@ -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 = "*"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cargo publish will reject wildcard versions, please specify what we need.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... yes, that is a problem. I'll see what I can do; the problem I saw was that the CI would fail to resolve windows-core to the correct version, but that might be fine with the bodge I put in there. Will test.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, this is a problem. cargo will not resolve windows-core to the same version of windows's windows-core if I match the version constraints or use a lax constraint like ^0.61.

I can see three options here:

  1. Restrict the version to a single known version (0.61 or 0.62)
  2. Ask windows-rs to add support for referencing windows::core, instead of windows_core, somehow: Cannot use implement macro microsoft/windows-rs#3568 (comment). Even if this happened, it would have to be another windows-rs release.
  3. Ask a Cargo wizard if there are any solutions for getting *-like behaviour in a way that can still be cargo publish-ed

I'll ask around regarding 3, and ask in that windows-rs issue for future support, but 1 may be necessary in the meantime :S

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for asking in windows-rs, how did that go?

The frequent API breakage of these windows crates sure is a problem when trying to support a ~12 month window of them. I'm not a Windows user myself, and wouldn't mind "bumping" the Minimum Supported Windows Version. But from what I gather from the community here, historical support is a desired thing to have.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But from what I gather from the community here, historical support is a desired thing to have.

Yeah especially with how Microsoft is treating their users on windows 11 we will want to support windows 10 as long as we can. Thank you soo much for your work in that regard philpax and roderick. I know its a pain to support te windows platform, but the impact is so high. Especially for gamedev (bevy).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only solution I can think of is an incredibly ugly one: publish multiple crates (containing just the relevant code) for each supported windows version that wrap up the correct windows and windows-core versions and feature-gate them, so that they're paired together but otherwise isolated.

Anything that depends on the implement macro (and thus windows-core) will have this issue; I don't see a way around it, unfortunately :/

We could consider avoiding the side-dependency issue by rewriting all Windows-related code to only depend on windows-core, but I imagine this will be quite a headache, and it would still restrict the minimum version to 0.61.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch 😓 Anyway the path forward seems to be anticipating v0.61+ right?
Thinking out of the box, anything we could do with build.rs?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late reply. With the current setup, we'd have to pick a windows and windows-core and stick to them; the only way to avoid this would be to use the multi-crate solution I suggested, as that would be sufficiently decoupled to avoid any issues with Cargo.

I can't think of anything we could do with build.rs; the issue is with how Cargo does package resolution, which means any interventions we do need to be done before that stage.

We could noodle around with a windows-core -only solution that completely removes the windows dependency, but that would still limit us to >=0.61 due to the implement feature being removed, so I'm not convinced it's worth the effort to do that.

Honestly, I think the pragmatic choice would be a policy of following the latest windows, and letting users deal with the package duplication/having to update their own windows. I'm not really sure there's much else that can be done that would be a realistic solution.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I think the pragmatic choice would be a policy of following the latest windows, and letting users deal with the package duplication/having to update their own windows.

I'm hesitant to do that, because more than a few Windows-using community members had specifically asked for broader crate support. Not being a Windows user myself, I'm following the community voice here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... yeah, we're stuck between a rock and a hard place here. I can't test this, but it might be possible to make the windows-core dependency optional and to only turn it on when this feature is turned on, and then document that you need to ensure that the versions of the packages match? It's pretty messy, but I'm all out of ideas now ☹️

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 }
Expand Down
239 changes: 183 additions & 56 deletions src/host/wasapi/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()?;
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()
.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");

Expand Down Expand Up @@ -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.
Expand All @@ -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 _));
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));
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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.
Expand Down
Loading