From 62dcacea4b36b7e83d9df6a7f4da36e1ce6bb27f Mon Sep 17 00:00:00 2001 From: HEnquist Date: Wed, 12 Aug 2026 17:40:49 +0200 Subject: [PATCH 1/5] Add peak meters and identifiers for audio sessions Add AudioMeterInformation wrapping IAudioMeterInformation, reachable from an AudioSessionControl. Also add the display name, icon path and session identifier getters, and extend the processes example to list render devices and session peak levels. --- Cargo.toml | 1 + README.md | 2 +- examples/processes.rs | 33 +++++++++------- src/api.rs | 91 +++++++++++++++++++++++++++++++++++++++---- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4287da8..29451f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ readme = "README.md" version = "0.62" features = ["Foundation", "Win32_Media_Audio", + "Win32_Media_Audio_Endpoints", "Win32_Foundation", "Win32_Devices_FunctionDiscovery", "Win32_Devices_Properties", diff --git a/README.md b/README.md index 2a20a0a..76003c6 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,6 @@ The following is a selection of the functionality currently available in the lib | `loopback` | Shows how to simultaneously capture and render sound, with separate threads for capture and render. | | `record` | Records audio from the default device, and saves the raw samples to a file. | | `devices` | Lists all available audio devices and displays the default devices. | -| `processes` | Lists all available audio capture devices and lists the processes that are using them. | +| `processes` | Lists all audio devices and the processes that are using them, with the peak level of each session. | | `record_application` | Records audio from a single application, and saves the raw samples to a file. | | `aec` | Captures audio with Acoustic Echo Cancellation (AEC) enabled and saves the raw data to a file. | diff --git a/examples/processes.rs b/examples/processes.rs index 8a727dd..08a7550 100644 --- a/examples/processes.rs +++ b/examples/processes.rs @@ -5,22 +5,29 @@ fn main() { let enumerator = DeviceEnumerator::new().unwrap(); - println!("The following input devices are being used by:"); - for device in &enumerator - .get_device_collection(&Direction::Capture) - .unwrap() - { - let dev = device.unwrap(); - let manager = dev.get_iaudiosessionmanager().unwrap(); - let enumerator = manager.get_audiosessionenumerator().unwrap(); + for direction in [Direction::Capture, Direction::Render] { + println!("The following {direction} devices are being used by:"); + for device in &enumerator.get_device_collection(&direction).unwrap() { + let dev = device.unwrap(); + let manager = dev.get_iaudiosessionmanager().unwrap(); + let sessions = manager.get_audiosessionenumerator().unwrap(); - println!("Device: {:?}", dev.get_friendlyname().unwrap()); + println!("Device: {:?}", dev.get_friendlyname().unwrap()); - for i in 0..enumerator.get_count().unwrap() { - let control = enumerator.get_session(i).unwrap(); - let process_id = control.get_process_id().unwrap(); + for i in 0..sessions.get_count().unwrap() { + let control = sessions.get_session(i).unwrap(); + let state = control.get_state().unwrap(); + if state != SessionState::Active { + continue; + } + let process_id = control.get_process_id().unwrap(); + let identifier = control.get_session_identifier().unwrap(); + let meter = control.get_audiometerinformation().unwrap(); + let peak = meter.get_peak_value().unwrap(); - println!(" - In use by process: {:?}", process_id); + println!(" - In use by process: {process_id}, peak: {peak:.3}"); + println!(" session: {identifier}"); + } } } } diff --git a/src/api.rs b/src/api.rs index b0141cc..13658b4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,6 +7,7 @@ use std::pin::Pin; use std::sync::{Arc, Condvar, Mutex}; use std::{fmt, ptr, slice}; use windows::Win32::Foundation::{CloseHandle, E_INVALIDARG, E_NOINTERFACE, FALSE, PROPERTYKEY}; +use windows::Win32::Media::Audio::Endpoints::IAudioMeterInformation; use windows::Win32::Media::Audio::{ ActivateAudioInterfaceAsync, AudioCategory_Alerts, AudioCategory_Communications, AudioCategory_FarFieldSpeech, AudioCategory_ForegroundOnlyMedia, AudioCategory_GameChat, @@ -57,7 +58,7 @@ use windows::{ Win32::System::Com::{BLOB, STGM_READ}, Win32::System::Threading::{CreateEventA, WaitForSingleObject}, }; -use windows_core::{implement, IUnknown, Interface, Ref, HSTRING, PCWSTR}; +use windows_core::{implement, IUnknown, Interface, Ref, HSTRING, PCWSTR, PWSTR}; use crate::{make_channelmasks, AudioSessionEvents, EventCallbacks, WasapiError, WaveFormat}; @@ -309,6 +310,14 @@ impl fmt::Display for DeviceState { } } +/// Convert a [PWSTR] that was allocated by a Windows API to a String, +/// and free the memory that the pointer refers to. +fn take_pwstr(pwstr: PWSTR) -> WasapiRes { + let value = unsafe { pwstr.to_string() }; + unsafe { CoTaskMemFree(Some(pwstr.0.cast())) }; + Ok(value?) +} + /// Calculate a period in units of 100ns that corresponds to the given number of buffer frames at the given sample rate. /// See the [IAudioClient documentation](https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclient-initialize#remarks). pub fn calculate_period_100ns(frames: i64, samplerate: i64) -> i64 { @@ -564,8 +573,7 @@ impl Device { /// Parse a device string property to String fn parse_string_property(prop: &PROPVARIANT) -> WasapiRes { let propstr = unsafe { PropVariantToStringAlloc(prop)? }; - let name = unsafe { propstr.to_string()? }; - unsafe { CoTaskMemFree(Some(propstr.0.cast())) }; + let name = take_pwstr(propstr)?; trace!("name: {name}"); Ok(name) } @@ -584,10 +592,7 @@ impl Device { /// Get the Id of an [IMMDevice] pub fn get_id(&self) -> WasapiRes { let idstr = unsafe { self.device.GetId()? }; - //let wide_id = unsafe { U16CString::from_ptr_str(idstr.0) }; - let id = unsafe { idstr.to_string()? }; - unsafe { CoTaskMemFree(Some(idstr.0.cast())) }; - //let id = wide_id.to_string_lossy(); + let id = take_pwstr(idstr)?; trace!("id: {id}"); Ok(id) } @@ -1522,6 +1527,78 @@ impl AudioSessionControl { Ok(()) } + + /// Get the display name of this session. + /// This is empty unless the client that owns the session has set a name. + /// When it is empty, the volume mixer shows the name of the executable instead. + pub fn get_display_name(&self) -> WasapiRes { + let name = unsafe { self.control.GetDisplayName()? }; + + take_pwstr(name) + } + + /// Get the path of the icon of this session. + /// This is empty unless the client that owns the session has set an icon. + pub fn get_icon_path(&self) -> WasapiRes { + let path = unsafe { self.control.GetIconPath()? }; + + take_pwstr(path) + } + + /// Get the identifier of the audio session. + /// All sessions of the same application on the same device share this identifier. + pub fn get_session_identifier(&self) -> WasapiRes { + let control2: IAudioSessionControl2 = self.control.cast()?; + let id = unsafe { control2.GetSessionIdentifier()? }; + + take_pwstr(id) + } + + /// Get the identifier of this particular session instance, + /// which is unique across all session instances. + pub fn get_session_instance_identifier(&self) -> WasapiRes { + let control2: IAudioSessionControl2 = self.control.cast()?; + let id = unsafe { control2.GetSessionInstanceIdentifier()? }; + + take_pwstr(id) + } + + /// Get the [AudioMeterInformation] for reading the peak values of this session. + pub fn get_audiometerinformation(&self) -> WasapiRes { + let meter: IAudioMeterInformation = self.control.cast()?; + + Ok(AudioMeterInformation { meter }) + } +} + +/// Struct wrapping an [IAudioMeterInformation](https://learn.microsoft.com/en-us/windows/win32/api/endpointvolume/nn-endpointvolume-iaudiometerinformation). +/// +/// The peak values are the peaks of the samples that were processed +/// since the previous call, and are not affected by the volume settings. +pub struct AudioMeterInformation { + meter: IAudioMeterInformation, +} + +impl AudioMeterInformation { + /// Get the peak value of the channel with the largest peak, + /// as a value between 0.0 and 1.0. + pub fn get_peak_value(&self) -> WasapiRes { + Ok(unsafe { self.meter.GetPeakValue()? }) + } + + /// Get the number of channels that the peak meter monitors. + pub fn get_metering_channel_count(&self) -> WasapiRes { + Ok(unsafe { self.meter.GetMeteringChannelCount()? }) + } + + /// Get the peak value of each channel, as values between 0.0 and 1.0. + pub fn get_channels_peak_values(&self) -> WasapiRes> { + let nbr_channels = unsafe { self.meter.GetMeteringChannelCount()? }; + let mut peaks = vec![0.0; nbr_channels as usize]; + unsafe { self.meter.GetChannelsPeakValues(&mut peaks)? }; + + Ok(peaks) + } } /// Struct for keeping track of the registered notifications. From 2bb3bbf2cfda18621175e61c17b8f930883ff15b Mon Sep 17 00:00:00 2001 From: HEnquist Date: Wed, 12 Aug 2026 17:49:56 +0200 Subject: [PATCH 2/5] Add device level peak metering Get an AudioMeterInformation for a whole endpoint device, and query which functions the device implements in hardware. --- examples/processes.rs | 8 +++++++- src/api.rs | 45 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/examples/processes.rs b/examples/processes.rs index 08a7550..4085068 100644 --- a/examples/processes.rs +++ b/examples/processes.rs @@ -12,7 +12,13 @@ fn main() { let manager = dev.get_iaudiosessionmanager().unwrap(); let sessions = manager.get_audiosessionenumerator().unwrap(); - println!("Device: {:?}", dev.get_friendlyname().unwrap()); + let dev_meter = dev.get_audiometerinformation().unwrap(); + let dev_peak = dev_meter.get_peak_value().unwrap(); + + println!( + "Device: {:?}, peak: {dev_peak:.3}", + dev.get_friendlyname().unwrap() + ); for i in 0..sessions.get_count().unwrap() { let control = sessions.get_session(i).unwrap(); diff --git a/src/api.rs b/src/api.rs index 13658b4..2f0bccb 100644 --- a/src/api.rs +++ b/src/api.rs @@ -22,7 +22,8 @@ use windows::Win32::Media::Audio::{ AUDCLNT_STREAMOPTIONS_NONE, AUDCLNT_STREAMOPTIONS_RAW, AUDIOCLIENT_ACTIVATION_PARAMS, AUDIOCLIENT_ACTIVATION_PARAMS_0, AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK, AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS, AUDIO_EFFECT, AUDIO_STREAM_CATEGORY, - PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE, + ENDPOINT_HARDWARE_SUPPORT_METER, ENDPOINT_HARDWARE_SUPPORT_MUTE, + ENDPOINT_HARDWARE_SUPPORT_VOLUME, PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE, PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE, VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, }; use windows::Win32::Media::KernelStreaming::AUDIO_EFFECT_TYPE_ACOUSTIC_ECHO_CANCELLATION; @@ -508,6 +509,16 @@ impl Device { Ok(AudioSessionManager { session_manager }) } + /// Get the [AudioMeterInformation] for reading the peak values of this device. + /// This measures the combined streams of all sessions on the device. + pub fn get_audiometerinformation(&self) -> WasapiRes { + let meter = unsafe { + self.device + .Activate::(CLSCTX_ALL, None)? + }; + Ok(AudioMeterInformation { meter }) + } + /// Read state from an [IMMDevice] pub fn get_state(&self) -> WasapiRes { let state = unsafe { self.device.GetState()? }; @@ -1599,6 +1610,38 @@ impl AudioMeterInformation { Ok(peaks) } + + /// Query which functions the audio endpoint device implements in hardware. + /// This is only meaningful for a meter that was fetched from a [Device], + /// a meter belonging to a session always reports no hardware support. + pub fn query_hardware_support(&self) -> WasapiRes { + let mask = unsafe { self.meter.QueryHardwareSupport()? }; + + Ok(HardwareSupport::new(mask)) + } +} + +/// Struct representing the [ENDPOINT_HARDWARE_SUPPORT_XXX constants](https://learn.microsoft.com/en-us/windows/win32/coreaudio/endpoint-hardware-support-xxx-constants), +/// describing which functions an audio endpoint device implements in hardware. +#[derive(Debug)] +pub struct HardwareSupport { + /// ENDPOINT_HARDWARE_SUPPORT_VOLUME + pub volume: bool, + /// ENDPOINT_HARDWARE_SUPPORT_MUTE + pub mute: bool, + /// ENDPOINT_HARDWARE_SUPPORT_METER + pub meter: bool, +} + +impl HardwareSupport { + /// Create a new [HardwareSupport] struct from a `u32` value. + pub fn new(mask: u32) -> Self { + HardwareSupport { + volume: mask & ENDPOINT_HARDWARE_SUPPORT_VOLUME > 0, + mute: mask & ENDPOINT_HARDWARE_SUPPORT_MUTE > 0, + meter: mask & ENDPOINT_HARDWARE_SUPPORT_METER > 0, + } + } } /// Struct for keeping track of the registered notifications. From a0ab988b93deec29a89625f4f1d23f0d334cc680 Mon Sep 17 00:00:00 2001 From: HEnquist Date: Wed, 12 Aug 2026 18:07:17 +0200 Subject: [PATCH 3/5] Add device change notifications Implement IMMNotificationClient and register it on the DeviceEnumerator, with callbacks for devices being added, removed and changed state, for device property changes, and for changes of the default device. The notifications are unregistered when the returned registration is dropped. Add a TryFrom for DeviceState and use it in Device::get_state. --- README.md | 2 + examples/device_notifications.rs | 34 +++ src/api.rs | 91 +++++++-- src/events.rs | 341 ++++++++++++++++++++++++++++++- 4 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 examples/device_notifications.rs diff --git a/README.md b/README.md index 76003c6..dbb1dc4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The following is a selection of the functionality currently available in the lib - Event-driven and polled buffering - Loopback capture - Notifications for volume change, device disconnect etc +- Notifications when devices are added or removed, or when the default device changes - …and additional features beyond this list @@ -39,3 +40,4 @@ The following is a selection of the functionality currently available in the lib | `processes` | Lists all audio devices and the processes that are using them, with the peak level of each session. | | `record_application` | Records audio from a single application, and saves the raw samples to a file. | | `aec` | Captures audio with Acoustic Echo Cancellation (AEC) enabled and saves the raw data to a file. | +| `device_notifications` | Listens for devices being added, removed or changed, and for changes of the default device. | diff --git a/examples/device_notifications.rs b/examples/device_notifications.rs new file mode 100644 index 0000000..de30832 --- /dev/null +++ b/examples/device_notifications.rs @@ -0,0 +1,34 @@ +use std::thread; +use std::time::Duration; +use wasapi::*; + +// Listen to device change notifications for one minute. +// Plug or unplug a device, or change the default device in the +// Windows sound settings, to see the notifications arrive. +fn main() { + initialize_mta().unwrap(); + + let enumerator = DeviceEnumerator::new().unwrap(); + + let mut callbacks = DeviceEventCallbacks::new(); + + callbacks.set_device_added_callback(|id| println!("Device added: {id}")); + callbacks.set_device_removed_callback(|id| println!("Device removed: {id}")); + callbacks.set_device_state_callback(|id, state| println!("Device {id} is now {state}")); + callbacks.set_default_device_callback(|direction, role, id| match id { + Some(id) => println!("New default {direction} device for role {role}: {id}"), + None => println!("There is no longer a default {direction} device for role {role}"), + }); + callbacks.set_property_value_callback(|id, key| { + println!("Property {:?} of device {id} changed", key.fmtid) + }); + + // The notifications are unregistered when this value is dropped, + // so it must be kept in scope for as long as they are needed. + let _registered_events = enumerator + .register_notification_callback(callbacks) + .unwrap(); + + println!("Listening for device changes for 60 seconds..."); + thread::sleep(Duration::from_secs(60)); +} diff --git a/src/api.rs b/src/api.rs index 2f0bccb..371c8b7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -40,13 +40,13 @@ use windows::{ eCapture, eCommunications, eConsole, eMultimedia, eRender, AudioSessionStateActive, AudioSessionStateExpired, AudioSessionStateInactive, IAudioCaptureClient, IAudioClient, IAudioClock, IAudioRenderClient, IAudioSessionControl, IAudioSessionEvents, IMMDevice, - IMMDeviceCollection, IMMDeviceEnumerator, MMDeviceEnumerator, + IMMDeviceCollection, IMMDeviceEnumerator, IMMNotificationClient, MMDeviceEnumerator, AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY, AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR, AUDCLNT_SHAREMODE_EXCLUSIVE, AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, - AUDCLNT_STREAMFLAGS_LOOPBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, DEVICE_STATE_ACTIVE, - DEVICE_STATE_DISABLED, DEVICE_STATE_NOTPRESENT, DEVICE_STATE_UNPLUGGED, WAVEFORMATEX, - WAVEFORMATEXTENSIBLE, + AUDCLNT_STREAMFLAGS_LOOPBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, DEVICE_STATE, + DEVICE_STATE_ACTIVE, DEVICE_STATE_DISABLED, DEVICE_STATE_NOTPRESENT, + DEVICE_STATE_UNPLUGGED, WAVEFORMATEX, WAVEFORMATEXTENSIBLE, }, Win32::Media::KernelStreaming::WAVE_FORMAT_EXTENSIBLE, Win32::System::Com::StructuredStorage::{ @@ -61,7 +61,10 @@ use windows::{ }; use windows_core::{implement, IUnknown, Interface, Ref, HSTRING, PCWSTR, PWSTR}; -use crate::{make_channelmasks, AudioSessionEvents, EventCallbacks, WasapiError, WaveFormat}; +use crate::{ + make_channelmasks, AudioSessionEvents, DeviceEventCallbacks, EventCallbacks, + NotificationClient, WasapiError, WaveFormat, +}; pub(crate) type WasapiRes = Result; @@ -281,7 +284,7 @@ impl fmt::Display for SessionState { /// Possible states for an [IMMDevice], an enum representing the /// [DEVICE_STATE_XXX constants](https://learn.microsoft.com/en-us/windows/win32/coreaudio/device-state-xxx-constants) -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DeviceState { /// The audio endpoint device is active. That is, the audio adapter that connects to the /// endpoint device is present and enabled. In addition, if the endpoint device plugs int @@ -311,6 +314,27 @@ impl fmt::Display for DeviceState { } } +impl TryFrom<&DEVICE_STATE> for DeviceState { + type Error = WasapiError; + + fn try_from(value: &DEVICE_STATE) -> Result { + match *value { + x if x == DEVICE_STATE_ACTIVE => Ok(Self::Active), + x if x == DEVICE_STATE_DISABLED => Ok(Self::Disabled), + x if x == DEVICE_STATE_NOTPRESENT => Ok(Self::NotPresent), + x if x == DEVICE_STATE_UNPLUGGED => Ok(Self::Unplugged), + x => Err(WasapiError::IllegalDeviceState(x.0)), + } + } +} +impl TryFrom for DeviceState { + type Error = WasapiError; + + fn try_from(value: DEVICE_STATE) -> Result { + Self::try_from(&value) + } +} + /// Convert a [PWSTR] that was allocated by a Windows API to a String, /// and free the memory that the pointer refers to. fn take_pwstr(pwstr: PWSTR) -> WasapiRes { @@ -383,6 +407,52 @@ impl DeviceEnumerator { let device = Device::from_immdevice(immdevice)?; Ok(device) } + + /// Register to receive notifications when audio endpoint devices are + /// added or removed, when the state or properties of a device change, + /// or when a different device becomes the default. + /// Returns a [DeviceEventRegistration] struct. + /// The notifications are unregistered when this struct is dropped. + /// Make sure to store the [DeviceEventRegistration] in a variable that remains + /// in scope for as long as the event notifications are needed. + /// + /// The function takes ownership of the provided [DeviceEventCallbacks]. + /// + /// The callbacks are called from a thread owned by the Windows audio system. + /// They should return quickly, and must not call back into the + /// [DeviceEnumerator] that they were registered on. + pub fn register_notification_callback( + &self, + callbacks: DeviceEventCallbacks, + ) -> WasapiRes { + let client: IMMNotificationClient = NotificationClient::new(callbacks).into(); + + match unsafe { + self.enumerator + .RegisterEndpointNotificationCallback(&client) + } { + Ok(()) => Ok(DeviceEventRegistration { + client, + enumerator: self.enumerator.clone(), + }), + Err(err) => Err(WasapiError::RegisterNotifications(err)), + } + } +} + +/// Struct for keeping track of the registered device notifications. +pub struct DeviceEventRegistration { + client: IMMNotificationClient, + enumerator: IMMDeviceEnumerator, +} + +impl Drop for DeviceEventRegistration { + fn drop(&mut self) { + let _ = unsafe { + self.enumerator + .UnregisterEndpointNotificationCallback(&self.client) + }; + } } /// Struct wrapping an [IMMDeviceCollection](https://docs.microsoft.com/en-us/windows/win32/api/mmdeviceapi/nn-mmdeviceapi-immdevicecollection). @@ -523,14 +593,7 @@ impl Device { pub fn get_state(&self) -> WasapiRes { let state = unsafe { self.device.GetState()? }; trace!("state: {state:?}"); - let state_enum = match state { - _ if state == DEVICE_STATE_ACTIVE => DeviceState::Active, - _ if state == DEVICE_STATE_DISABLED => DeviceState::Disabled, - _ if state == DEVICE_STATE_NOTPRESENT => DeviceState::NotPresent, - _ if state == DEVICE_STATE_UNPLUGGED => DeviceState::Unplugged, - x => return Err(WasapiError::IllegalDeviceState(x.0)), - }; - Ok(state_enum) + DeviceState::try_from(state) } /// Read the friendly name of the endpoint device (for example, "Speakers (XYZ Audio Adapter)") diff --git a/src/events.rs b/src/events.rs index 3d631e8..b6cf6b1 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,19 +1,31 @@ use std::slice; use windows::{ core::{implement, Result, GUID, PCWSTR}, + Win32::Foundation::PROPERTYKEY, Win32::Media::Audio::{ AudioSessionDisconnectReason, AudioSessionState, AudioSessionStateActive, AudioSessionStateExpired, AudioSessionStateInactive, DisconnectReasonDeviceRemoval, DisconnectReasonExclusiveModeOverride, DisconnectReasonFormatChanged, DisconnectReasonServerShutdown, DisconnectReasonSessionDisconnected, - DisconnectReasonSessionLogoff, IAudioSessionEvents, IAudioSessionEvents_Impl, + DisconnectReasonSessionLogoff, EDataFlow, ERole, IAudioSessionEvents, + IAudioSessionEvents_Impl, IMMNotificationClient, IMMNotificationClient_Impl, DEVICE_STATE, }, }; -use crate::SessionState; +use crate::{DeviceState, Direction, Role, SessionState}; type OptionBox = Option>; +/// Read a [PCWSTR] that points to a string owned by the caller. +/// Returns None if the pointer is null, which the audio system uses +/// to signal that there is no device. +fn read_pcwstr(pcwstr: &PCWSTR) -> Option { + if pcwstr.is_null() { + return None; + } + unsafe { pcwstr.to_string().ok() } +} + /// A structure holding the callbacks for notifications pub struct EventCallbacks { simple_volume: OptionBox, @@ -279,3 +291,328 @@ impl IAudioSessionEvents_Impl for AudioSessionEvents_Impl { Ok(()) } } + +/// A structure holding the callbacks for device change notifications +pub struct DeviceEventCallbacks { + device_state: OptionBox, + device_added: OptionBox, + device_removed: OptionBox, + default_device: OptionBox) + Send + Sync>, + property_value: OptionBox, +} + +impl Default for DeviceEventCallbacks { + fn default() -> Self { + Self::new() + } +} + +impl DeviceEventCallbacks { + /// Create a new DeviceEventCallbacks with no callbacks set + pub fn new() -> Self { + Self { + device_state: None, + device_added: None, + device_removed: None, + default_device: None, + property_value: None, + } + } + + /// Set a callback for OnDeviceStateChanged notifications. + /// The parameters are the device id and the new state. + pub fn set_device_state_callback( + &mut self, + c: impl Fn(String, DeviceState) + 'static + Sync + Send, + ) { + self.device_state = Some(Box::new(c)); + } + /// Remove a callback for OnDeviceStateChanged notifications + pub fn unset_device_state_callback(&mut self) { + self.device_state = None; + } + + /// Set a callback for OnDeviceAdded notifications. + /// The parameter is the device id. + pub fn set_device_added_callback(&mut self, c: impl Fn(String) + 'static + Sync + Send) { + self.device_added = Some(Box::new(c)); + } + /// Remove a callback for OnDeviceAdded notifications + pub fn unset_device_added_callback(&mut self) { + self.device_added = None; + } + + /// Set a callback for OnDeviceRemoved notifications. + /// The parameter is the device id. + pub fn set_device_removed_callback(&mut self, c: impl Fn(String) + 'static + Sync + Send) { + self.device_removed = Some(Box::new(c)); + } + /// Remove a callback for OnDeviceRemoved notifications + pub fn unset_device_removed_callback(&mut self) { + self.device_removed = None; + } + + /// Set a callback for OnDefaultDeviceChanged notifications. + /// The parameters are the direction and role of the new default device, + /// and its device id. The id is None when there is no longer + /// a default device for that direction and role. + pub fn set_default_device_callback( + &mut self, + c: impl Fn(Direction, Role, Option) + 'static + Sync + Send, + ) { + self.default_device = Some(Box::new(c)); + } + /// Remove a callback for OnDefaultDeviceChanged notifications + pub fn unset_default_device_callback(&mut self) { + self.default_device = None; + } + + /// Set a callback for OnPropertyValueChanged notifications. + /// The parameters are the device id and the key of the changed property. + pub fn set_property_value_callback( + &mut self, + c: impl Fn(String, PROPERTYKEY) + 'static + Sync + Send, + ) { + self.property_value = Some(Box::new(c)); + } + /// Remove a callback for OnPropertyValueChanged notifications + pub fn unset_property_value_callback(&mut self) { + self.property_value = None; + } +} + +/// Wrapper for [IMMNotificationClient](https://learn.microsoft.com/en-us/windows/win32/api/mmdeviceapi/nn-mmdeviceapi-immnotificationclient). +#[implement(IMMNotificationClient)] +pub(crate) struct NotificationClient { + callbacks: DeviceEventCallbacks, +} + +impl NotificationClient { + /// Create a new [NotificationClient] instance. + pub fn new(callbacks: DeviceEventCallbacks) -> Self { + Self { callbacks } + } +} + +impl IMMNotificationClient_Impl for NotificationClient_Impl { + fn OnDeviceStateChanged(&self, pwstrdeviceid: &PCWSTR, dwnewstate: DEVICE_STATE) -> Result<()> { + let Some(id) = read_pcwstr(pwstrdeviceid) else { + warn!("OnDeviceStateChanged: received an unreadable device id"); + return Ok(()); + }; + let state = match DeviceState::try_from(dwnewstate) { + Ok(state) => state, + Err(err) => { + warn!("OnDeviceStateChanged: {err}"); + return Ok(()); + } + }; + trace!("Device {id} changed state to: {state}"); + if let Some(callback) = &self.callbacks.device_state { + callback(id, state); + } + Ok(()) + } + + fn OnDeviceAdded(&self, pwstrdeviceid: &PCWSTR) -> Result<()> { + let Some(id) = read_pcwstr(pwstrdeviceid) else { + warn!("OnDeviceAdded: received an unreadable device id"); + return Ok(()); + }; + trace!("Device added: {id}"); + if let Some(callback) = &self.callbacks.device_added { + callback(id); + } + Ok(()) + } + + fn OnDeviceRemoved(&self, pwstrdeviceid: &PCWSTR) -> Result<()> { + let Some(id) = read_pcwstr(pwstrdeviceid) else { + warn!("OnDeviceRemoved: received an unreadable device id"); + return Ok(()); + }; + trace!("Device removed: {id}"); + if let Some(callback) = &self.callbacks.device_removed { + callback(id); + } + Ok(()) + } + + fn OnDefaultDeviceChanged( + &self, + flow: EDataFlow, + role: ERole, + pwstrdefaultdeviceid: &PCWSTR, + ) -> Result<()> { + // A null id means that there is no longer a default device. + let id = read_pcwstr(pwstrdefaultdeviceid); + let direction = match Direction::try_from(flow) { + Ok(direction) => direction, + Err(err) => { + warn!("OnDefaultDeviceChanged: {err}"); + return Ok(()); + } + }; + let device_role = match Role::try_from(role) { + Ok(role) => role, + Err(err) => { + warn!("OnDefaultDeviceChanged: {err}"); + return Ok(()); + } + }; + trace!("New default {direction} device for role {device_role}: {id:?}"); + if let Some(callback) = &self.callbacks.default_device { + callback(direction, device_role, id); + } + Ok(()) + } + + fn OnPropertyValueChanged(&self, pwstrdeviceid: &PCWSTR, key: &PROPERTYKEY) -> Result<()> { + let Some(id) = read_pcwstr(pwstrdeviceid) else { + warn!("OnPropertyValueChanged: received an unreadable device id"); + return Ok(()); + }; + trace!("Property {key:?} changed for device {id}"); + if let Some(callback) = &self.callbacks.property_value { + callback(id, *key); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + use windows::core::HSTRING; + use windows::Win32::Media::Audio::{ + eAll, eCapture, eConsole, eRender, DEVICE_STATE_ACTIVE, DEVICE_STATE_UNPLUGGED, + }; + + const TEST_ID: &str = "{0.0.0.00000000}.{6e6f7420-6120-7265-616c-206465766963}"; + + /// Build a client that appends a description of every notification to the returned vector. + fn logging_client() -> (IMMNotificationClient, Arc>>) { + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let mut callbacks = DeviceEventCallbacks::new(); + + let added = log.clone(); + callbacks.set_device_added_callback(move |id| added.lock().unwrap().push(format!("+{id}"))); + let removed = log.clone(); + callbacks + .set_device_removed_callback(move |id| removed.lock().unwrap().push(format!("-{id}"))); + let state = log.clone(); + callbacks.set_device_state_callback(move |id, newstate| { + state.lock().unwrap().push(format!("{id} is {newstate}")) + }); + let default = log.clone(); + callbacks.set_default_device_callback(move |direction, role, id| { + default + .lock() + .unwrap() + .push(format!("default {direction} {role} {id:?}")) + }); + let property = log.clone(); + callbacks.set_property_value_callback(move |id, key| { + property.lock().unwrap().push(format!("{id} {}", key.pid)) + }); + + (NotificationClient::new(callbacks).into(), log) + } + + #[test] + fn notifications_reach_the_callbacks() { + let (client, log) = logging_client(); + let id = HSTRING::from(TEST_ID); + let id = PCWSTR::from_raw(id.as_ptr()); + let key = PROPERTYKEY { + fmtid: GUID::zeroed(), + pid: 14, + }; + + unsafe { + client.OnDeviceAdded(id).unwrap(); + client.OnDeviceRemoved(id).unwrap(); + client + .OnDeviceStateChanged(id, DEVICE_STATE_UNPLUGGED) + .unwrap(); + client + .OnDefaultDeviceChanged(eCapture, eConsole, id) + .unwrap(); + client.OnPropertyValueChanged(id, key).unwrap(); + } + + assert_eq!( + *log.lock().unwrap(), + vec![ + format!("+{TEST_ID}"), + format!("-{TEST_ID}"), + format!("{TEST_ID} is Unplugged"), + format!("default Capture Console Some(\"{TEST_ID}\")"), + format!("{TEST_ID} 14"), + ] + ); + } + + /// The audio system passes a null pointer when the last default device disappears. + #[test] + fn a_null_device_id_is_handled() { + let (client, log) = logging_client(); + + unsafe { + client + .OnDefaultDeviceChanged(eRender, eConsole, PCWSTR::null()) + .unwrap(); + // The remaining notifications always carry an id, but must not + // dereference a null pointer if one arrives anyway. + client.OnDeviceAdded(PCWSTR::null()).unwrap(); + client.OnDeviceRemoved(PCWSTR::null()).unwrap(); + } + + assert_eq!(*log.lock().unwrap(), vec!["default Render Console None"]); + } + + /// Values that have no counterpart in the wrapper enums are skipped, not passed on. + #[test] + fn unsupported_values_are_skipped() { + let (client, log) = logging_client(); + let id = HSTRING::from(TEST_ID); + let id = PCWSTR::from_raw(id.as_ptr()); + + unsafe { + client.OnDefaultDeviceChanged(eAll, eConsole, id).unwrap(); + client.OnDeviceStateChanged(id, DEVICE_STATE(0)).unwrap(); + } + + assert!(log.lock().unwrap().is_empty()); + } + + /// A client with no callbacks set should accept every notification. + #[test] + fn notifications_without_callbacks() { + let client: IMMNotificationClient = + NotificationClient::new(DeviceEventCallbacks::new()).into(); + let id = HSTRING::from(TEST_ID); + let id = PCWSTR::from_raw(id.as_ptr()); + + unsafe { + client.OnDeviceAdded(id).unwrap(); + client.OnDeviceRemoved(id).unwrap(); + client + .OnDeviceStateChanged(id, DEVICE_STATE_ACTIVE) + .unwrap(); + client + .OnDefaultDeviceChanged(eRender, eConsole, id) + .unwrap(); + client + .OnPropertyValueChanged( + id, + PROPERTYKEY { + fmtid: GUID::zeroed(), + pid: 0, + }, + ) + .unwrap(); + } + } +} From 1151d6d5b96e88548f2b1ad85f25735c5b99b189 Mon Sep 17 00:00:00 2001 From: HEnquist Date: Wed, 12 Aug 2026 21:14:23 +0200 Subject: [PATCH 4/5] Do not confuse an unreadable device id with a missing device A null device id means that there is no default device, while a string that cannot be read is an error. Keep the two apart so that an unreadable id is skipped instead of being reported as no device. --- src/events.rs | 67 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/src/events.rs b/src/events.rs index b6cf6b1..b859ff3 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,4 +1,5 @@ use std::slice; +use std::string::FromUtf16Error; use windows::{ core::{implement, Result, GUID, PCWSTR}, Win32::Foundation::PROPERTYKEY, @@ -17,13 +18,31 @@ use crate::{DeviceState, Direction, Role, SessionState}; type OptionBox = Option>; /// Read a [PCWSTR] that points to a string owned by the caller. -/// Returns None if the pointer is null, which the audio system uses +/// Returns Ok(None) if the pointer is null, which the audio system uses /// to signal that there is no device. -fn read_pcwstr(pcwstr: &PCWSTR) -> Option { +/// An unreadable string gives an error, and must not be confused +/// with the absence of a device. +fn read_pcwstr(pcwstr: &PCWSTR) -> std::result::Result, FromUtf16Error> { if pcwstr.is_null() { - return None; + return Ok(None); + } + unsafe { pcwstr.to_string() }.map(Some) +} + +/// Read the id of the device that a notification refers to. +/// Returns None, after logging the reason, if no usable id was provided. +fn read_device_id(pcwstr: &PCWSTR, notification: &str) -> Option { + match read_pcwstr(pcwstr) { + Ok(Some(id)) => Some(id), + Ok(None) => { + warn!("{notification}: received a null device id"); + None + } + Err(err) => { + warn!("{notification}: received an unreadable device id, {err}"); + None + } } - unsafe { pcwstr.to_string().ok() } } /// A structure holding the callbacks for notifications @@ -396,8 +415,7 @@ impl NotificationClient { impl IMMNotificationClient_Impl for NotificationClient_Impl { fn OnDeviceStateChanged(&self, pwstrdeviceid: &PCWSTR, dwnewstate: DEVICE_STATE) -> Result<()> { - let Some(id) = read_pcwstr(pwstrdeviceid) else { - warn!("OnDeviceStateChanged: received an unreadable device id"); + let Some(id) = read_device_id(pwstrdeviceid, "OnDeviceStateChanged") else { return Ok(()); }; let state = match DeviceState::try_from(dwnewstate) { @@ -415,8 +433,7 @@ impl IMMNotificationClient_Impl for NotificationClient_Impl { } fn OnDeviceAdded(&self, pwstrdeviceid: &PCWSTR) -> Result<()> { - let Some(id) = read_pcwstr(pwstrdeviceid) else { - warn!("OnDeviceAdded: received an unreadable device id"); + let Some(id) = read_device_id(pwstrdeviceid, "OnDeviceAdded") else { return Ok(()); }; trace!("Device added: {id}"); @@ -427,8 +444,7 @@ impl IMMNotificationClient_Impl for NotificationClient_Impl { } fn OnDeviceRemoved(&self, pwstrdeviceid: &PCWSTR) -> Result<()> { - let Some(id) = read_pcwstr(pwstrdeviceid) else { - warn!("OnDeviceRemoved: received an unreadable device id"); + let Some(id) = read_device_id(pwstrdeviceid, "OnDeviceRemoved") else { return Ok(()); }; trace!("Device removed: {id}"); @@ -445,7 +461,14 @@ impl IMMNotificationClient_Impl for NotificationClient_Impl { pwstrdefaultdeviceid: &PCWSTR, ) -> Result<()> { // A null id means that there is no longer a default device. - let id = read_pcwstr(pwstrdefaultdeviceid); + // An unreadable id must not be reported as no device, so it is skipped. + let id = match read_pcwstr(pwstrdefaultdeviceid) { + Ok(id) => id, + Err(err) => { + warn!("OnDefaultDeviceChanged: received an unreadable device id, {err}"); + return Ok(()); + } + }; let direction = match Direction::try_from(flow) { Ok(direction) => direction, Err(err) => { @@ -468,8 +491,7 @@ impl IMMNotificationClient_Impl for NotificationClient_Impl { } fn OnPropertyValueChanged(&self, pwstrdeviceid: &PCWSTR, key: &PROPERTYKEY) -> Result<()> { - let Some(id) = read_pcwstr(pwstrdeviceid) else { - warn!("OnPropertyValueChanged: received an unreadable device id"); + let Some(id) = read_device_id(pwstrdeviceid, "OnPropertyValueChanged") else { return Ok(()); }; trace!("Property {key:?} changed for device {id}"); @@ -572,6 +594,25 @@ mod tests { assert_eq!(*log.lock().unwrap(), vec!["default Render Console None"]); } + /// An id that cannot be read is not the same thing as a missing device, + /// and must not be reported as one. + #[test] + fn an_unreadable_device_id_is_skipped() { + let (client, log) = logging_client(); + // A lone surrogate is not valid UTF-16. + let invalid = [0xd800u16, 0]; + let id = PCWSTR::from_raw(invalid.as_ptr()); + + unsafe { + client + .OnDefaultDeviceChanged(eRender, eConsole, id) + .unwrap(); + client.OnDeviceAdded(id).unwrap(); + } + + assert!(log.lock().unwrap().is_empty()); + } + /// Values that have no counterpart in the wrapper enums are skipped, not passed on. #[test] fn unsupported_values_are_skipped() { From 66698991ac66713a524b56e42483e6be9cf2ebbb Mon Sep 17 00:00:00 2001 From: HEnquist Date: Wed, 12 Aug 2026 21:27:16 +0200 Subject: [PATCH 5/5] Make the notification registration error message generic RegisterNotifications is now returned for both session and device notifications, so drop session from the message. --- src/errors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/errors.rs b/src/errors.rs index 33999b4..f08d755 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -16,7 +16,7 @@ pub enum WasapiError { UnsupportedSubformat(windows_core::GUID), #[error("Client has not been initialized")] ClientNotInit, - #[error("Couldn't register session notifications: {0}")] + #[error("Couldn't register notifications: {0}")] RegisterNotifications(windows_core::Error), #[error("Wrong length of data, got {received}, expected exactly {expected}")] DataLengthMismatch { received: usize, expected: usize },