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..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 @@ -36,6 +37,7 @@ 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. | +| `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/examples/processes.rs b/examples/processes.rs index 8a727dd..4085068 100644 --- a/examples/processes.rs +++ b/examples/processes.rs @@ -5,22 +5,35 @@ 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()); + let dev_meter = dev.get_audiometerinformation().unwrap(); + let dev_peak = dev_meter.get_peak_value().unwrap(); - for i in 0..enumerator.get_count().unwrap() { - let control = enumerator.get_session(i).unwrap(); - let process_id = control.get_process_id().unwrap(); + println!( + "Device: {:?}, peak: {dev_peak:.3}", + dev.get_friendlyname().unwrap() + ); - println!(" - In use by process: {:?}", process_id); + 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}, peak: {peak:.3}"); + println!(" session: {identifier}"); + } } } } diff --git a/src/api.rs b/src/api.rs index b0141cc..371c8b7 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, @@ -21,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; @@ -38,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::{ @@ -57,9 +59,12 @@ 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}; +use crate::{ + make_channelmasks, AudioSessionEvents, DeviceEventCallbacks, EventCallbacks, + NotificationClient, WasapiError, WaveFormat, +}; pub(crate) type WasapiRes = Result; @@ -279,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 @@ -309,6 +314,35 @@ 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 { + 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 { @@ -373,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). @@ -499,18 +579,21 @@ 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()? }; 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)") @@ -564,8 +647,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 +666,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 +1601,110 @@ 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) + } + + /// 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. 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 }, diff --git a/src/events.rs b/src/events.rs index 3d631e8..b859ff3 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,19 +1,50 @@ use std::slice; +use std::string::FromUtf16Error; 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 Ok(None) if the pointer is null, which the audio system uses +/// to signal that there is no device. +/// 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 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 + } + } +} + /// A structure holding the callbacks for notifications pub struct EventCallbacks { simple_volume: OptionBox, @@ -279,3 +310,350 @@ 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_device_id(pwstrdeviceid, "OnDeviceStateChanged") else { + 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_device_id(pwstrdeviceid, "OnDeviceAdded") else { + 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_device_id(pwstrdeviceid, "OnDeviceRemoved") else { + 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. + // 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) => { + 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_device_id(pwstrdeviceid, "OnPropertyValueChanged") else { + 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"]); + } + + /// 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() { + 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(); + } + } +}