diff --git a/asio-sys/src/bindings/mod.rs b/asio-sys/src/bindings/mod.rs index 5b2dd474c..6d63e4df5 100644 --- a/asio-sys/src/bindings/mod.rs +++ b/asio-sys/src/bindings/mod.rs @@ -7,7 +7,7 @@ pub mod errors; #[cfg(target_os = "windows")] use std::os::raw::c_long; use std::{ - ffi::{CStr, CString}, + ffi::CString, os::raw::{c_char, c_double, c_void}, ptr::null_mut, sync::{ @@ -981,6 +981,18 @@ impl Driver { drop(dcb); drop(removed); } + + /// Returns the name of the channel at the given index. + /// + /// `channel` is a 0-based channel index. `is_input` selects the input (`true`) or output + /// (`false`) direction. + /// + /// The driver must already be loaded (i.e. this `Driver` instance must be alive). + pub fn channel_name(&self, channel: i32, is_input: bool) -> Result { + let _guard = self.inner.lock_state(); + let info = asio_channel_info(channel, is_input)?; + Ok(driver_name_to_utf8(&info.name).into_owned()) + } } impl DriverState { @@ -1107,6 +1119,10 @@ fn asio_get_buffer_sizes() -> Result { /// Retrieve the `ASIOChannelInfo` associated with the channel at the given index on either the /// input or output stream (`true` for input). fn asio_channel_info(channel: c_long, is_input: bool) -> Result { + if channel < 0 { + return Err(AsioError::InvalidInput); + } + let mut channel_info = ai::ASIOChannelInfo { // Which channel we are querying channel, @@ -1137,7 +1153,15 @@ fn stream_data_type(is_input: bool) -> Result { /// /// This converts to utf8. fn driver_name_to_utf8(bytes: &[c_char]) -> std::borrow::Cow<'_, str> { - unsafe { CStr::from_ptr(bytes.as_ptr()).to_string_lossy() } + let length = bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(bytes.len()); + let bytes = bytes[..length] + .iter() + .map(|&byte| byte as u8) + .collect::>(); + String::from_utf8_lossy(&bytes).into_owned().into() } /// Convert an `ASIOTimeStamp` (high and low 32-bit halves) to a `u64` nanosecond value. diff --git a/examples/custom.rs b/examples/custom.rs index 2f0e73d14..ab7067a0e 100644 --- a/examples/custom.rs +++ b/examples/custom.rs @@ -183,6 +183,13 @@ impl DeviceTrait for MyDevice { handle: Some(handle), }) } + + fn get_channel_name(&self, channel_index: u16, input: bool) -> Result { + Ok(format!( + "{} {channel_index}", + if input { "Input" } else { "Output" } + )) + } } impl fmt::Display for MyDevice { diff --git a/src/host/asio/device.rs b/src/host/asio/device.rs index f2162b32b..74dd34520 100644 --- a/src/host/asio/device.rs +++ b/src/host/asio/device.rs @@ -26,6 +26,8 @@ pub struct Device { input_sample_format: Option, output_sample_format: Option, supported_sample_rates: Box<[SampleRate]>, + input_channel_names: Box<[String]>, + output_channel_names: Box<[String]>, pub(super) current_callback_flag: Arc, } @@ -123,6 +125,26 @@ impl Device { } configs } + + pub fn get_channel_name(&self, channel_index: u16, input: bool) -> Result { + let names = if input { + &self.input_channel_names + } else { + &self.output_channel_names + }; + + names.get(channel_index as usize).cloned().ok_or_else(|| { + Error::with_message( + ErrorKind::InvalidInput, + format!( + "channel index {} is out of range (device has {} {} channels)", + channel_index, + names.len(), + if input { "input" } else { "output" }, + ), + ) + }) + } } impl PartialEq for Device { @@ -185,6 +207,12 @@ impl Iterator for Devices { if channels.ins == 0 && channels.outs == 0 { continue; } + let Ok(channels_in) = ChannelCount::try_from(channels.ins) else { + continue; + }; + let Ok(channels_out) = ChannelCount::try_from(channels.outs) else { + continue; + }; // Some drivers (e.g. Realtek ASIO) return 0 for sample_rate() until a // stream is active. Treat 0 as "not yet known" rather than skipping. @@ -209,18 +237,39 @@ impl Iterator for Devices { .filter(|&r| driver.can_sample_rate(r.into()).unwrap_or(false)) .collect(); + let input_channel_names: Box<[String]> = (0..channels_in) + .map(|ch| { + driver + .channel_name(ch.into(), true) + .ok() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| format!("Input {ch}")) + }) + .collect(); + let output_channel_names: Box<[String]> = (0..channels_out) + .map(|ch| { + driver + .channel_name(ch.into(), false) + .ok() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| format!("Output {ch}")) + }) + .collect(); + self.current_driver = Some(driver); return Some(Device { name, - channels_in: channels.ins as ChannelCount, - channels_out: channels.outs as ChannelCount, + channels_in, + channels_out, sample_rate: sample_rate as SampleRate, buffer_size_min: buffer_size_range.min as FrameCount, buffer_size_max: buffer_size_range.max as FrameCount, input_sample_format, output_sample_format, supported_sample_rates, + input_channel_names, + output_channel_names, // Initialize with sentinel value so it never matches global flag state (0 or 1). current_callback_flag: Arc::new(AtomicU32::new(u32::MAX)), }); diff --git a/src/host/asio/mod.rs b/src/host/asio/mod.rs index 3d91a1818..52fbb2e16 100644 --- a/src/host/asio/mod.rs +++ b/src/host/asio/mod.rs @@ -143,6 +143,10 @@ impl DeviceTrait for Device { timeout, ) } + + fn get_channel_name(&self, channel_index: u16, input: bool) -> Result { + Device::get_channel_name(self, channel_index, input) + } } impl StreamTrait for Stream { diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index bde36af6b..1c61be99f 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -31,13 +31,13 @@ use objc2_core_audio::{ kAudioDevicePropertyLatency, kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertySafetyOffset, kAudioDevicePropertyStreamConfiguration, kAudioDevicePropertyStreamFormat, kAudioObjectPropertyClass, kAudioObjectPropertyElementMain, - kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeInput, - kAudioObjectPropertyScopeOutput, + kAudioObjectPropertyElementName, kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyScopeInput, kAudioObjectPropertyScopeOutput, }; use objc2_core_audio_types::{ AudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioValueRange, }; -use objc2_core_foundation::{CFRetained, CFString}; +use objc2_core_foundation::CFString; pub use super::enumerate::{SupportedInputConfigs, SupportedOutputConfigs}; use super::{ @@ -357,6 +357,10 @@ impl DeviceTrait for Device { timeout, ) } + + fn get_channel_name(&self, channel_index: u16, input: bool) -> Result { + Device::get_channel_name(self, channel_index, input) + } } #[derive(Clone)] @@ -437,40 +441,15 @@ impl Device { mElement: kAudioObjectPropertyElementMain, }; - // CFString is returned under the create rule, so take ownership of the +1 reference. - let mut uid: *mut CFString = std::ptr::null_mut(); - let mut data_size = size_of::<*mut CFString>() as u32; - - // SAFETY: AudioObjectGetPropertyData is documented to write a CFString pointer - // for kAudioDevicePropertyDeviceUID. We check the status code before use. - let status = unsafe { - AudioObjectGetPropertyData( - self.audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&mut data_size), - NonNull::from(&mut uid).cast(), - ) - }; - check_os_status(status)?; - - // SAFETY: Status was successful, meaning the API call succeeded. - // We now check if the returned uid is non-null before use. - if !uid.is_null() { - let uid_string = - unsafe { CFRetained::from_raw(NonNull::new(uid).unwrap()).to_string() }; - Ok(DeviceId::new( - crate::platform::HostId::CoreAudio, - uid_string, - )) - } else { - Err(ErrorKind::DeviceNotAvailable.into()) - } + let uid_string = get_cf_string_property(self.audio_device_id, &property_address)? + .ok_or(ErrorKind::DeviceNotAvailable)?; + Ok(DeviceId::new( + crate::platform::HostId::CoreAudio, + uid_string, + )) } // Logic re-used between `supported_input_configs` and `supported_output_configs`. - #[expect(clippy::cast_ptr_alignment)] fn supported_configs( &self, scope: AudioObjectPropertyScope, @@ -482,49 +461,7 @@ impl Device { }; unsafe { - // Retrieve the devices audio buffer list. - let mut data_size = 0u32; - let status = AudioObjectGetPropertyDataSize( - self.audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&mut data_size), - ); - check_os_status(status)?; - - let mut audio_buffer_list: Vec = vec![]; - audio_buffer_list.reserve_exact(data_size as usize); - let status = AudioObjectGetPropertyData( - self.audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&mut data_size), - NonNull::new(audio_buffer_list.as_mut_ptr()).unwrap().cast(), - ); - check_os_status(status)?; - - let audio_buffer_list = audio_buffer_list.as_mut_ptr() as *mut AudioBufferList; - - // Read the number of buffers without assuming alignment (avoid UB). - let nb_ptr = core::ptr::addr_of!((*audio_buffer_list).mNumberBuffers); - let n_buffers = core::ptr::read_unaligned(nb_ptr) as usize; - // If there are no buffers, skip. - if n_buffers == 0 { - return Ok(vec![].into_iter()); - } - - // Count the number of channels as the sum of all channels in all output buffers. - let first_buf_ptr = - core::ptr::addr_of!((*audio_buffer_list).mBuffers) as *const AudioBuffer; - let mut n_channels = 0usize; - for i in 0..n_buffers { - let buf_ptr = first_buf_ptr.add(i); - // Read potentially unaligned - let buf: AudioBuffer = core::ptr::read_unaligned(buf_ptr); - n_channels += buf.mNumberChannels as usize; - } + let n_channels = get_channel_count_for_device(self.audio_device_id, scope)?; // TODO: macOS should support U8, I16, I32, F32 and F64. This should allow for using // I16 but just use F32 for now as it's the default anyway. @@ -580,7 +517,7 @@ impl Device { let fmts: Vec<_> = ranges .iter() .map(|range| SupportedStreamConfigRange { - channels: n_channels as ChannelCount, + channels: n_channels, min_sample_rate: range.mMinimum as u32, max_sample_rate: range.mMaximum as u32, buffer_size, @@ -690,6 +627,28 @@ impl Device { .map(|mut configs| configs.next().is_some()) .unwrap_or(false) } + + fn get_channel_name(&self, channel_index: u16, input: bool) -> Result { + let max_channels = get_channel_count_for_device( + self.audio_device_id, + if input { + kAudioObjectPropertyScopeInput + } else { + kAudioObjectPropertyScopeOutput + }, + )?; + if channel_index >= max_channels { + return Err(Error::with_message( + ErrorKind::InvalidInput, + format!( + "channel index {channel_index} is out of range (device has {max_channels} {} channels)", + if input { "input" } else { "output" }, + ), + )); + } + + get_channel_name_for_device(self.audio_device_id, channel_index, input) + } } impl Device { @@ -1116,3 +1075,101 @@ pub(crate) fn get_device_buffer_frame_size( )?; Ok(frames as usize) } + +fn get_channel_name_for_device( + device_id: AudioDeviceID, + channel_index: u16, + input: bool, +) -> Result { + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioObjectPropertyElementName, + mScope: if input { + kAudioObjectPropertyScopeInput + } else { + kAudioObjectPropertyScopeOutput + }, + // Channels numbers start on 1 here + mElement: channel_index as u32 + 1, + }; + + get_cf_string_property(device_id, &property_address)? + .ok_or_else(|| Error::with_message(ErrorKind::Other, "channel name is null")) +} + +fn get_cf_string_property( + device_id: AudioDeviceID, + property_address: &AudioObjectPropertyAddress, +) -> Result, Error> { + let mut value: *mut CFString = std::ptr::null_mut(); + let mut data_size = size_of::<*mut CFString>() as u32; + + let status = unsafe { + AudioObjectGetPropertyData( + device_id, + NonNull::from(property_address), + 0, + null(), + NonNull::from(&mut data_size), + NonNull::from(&mut value).cast(), + ) + }; + check_os_status(status)?; + + Ok(NonNull::new(value) + .map(|value| unsafe { objc2_core_foundation::CFRetained::from_raw(value).to_string() })) +} + +#[allow(clippy::cast_ptr_alignment)] +fn get_channel_count_for_device( + device_id: AudioDeviceID, + scope: AudioObjectPropertyScope, +) -> Result { + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: scope, + mElement: kAudioObjectPropertyElementMain, + }; + + unsafe { + let mut data_size = 0u32; + let status = AudioObjectGetPropertyDataSize( + device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&mut data_size), + ); + check_os_status(status)?; + + let mut audio_buffer_list: Vec = vec![]; + audio_buffer_list.reserve_exact(data_size as usize); + let status = AudioObjectGetPropertyData( + device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&mut data_size), + NonNull::new(audio_buffer_list.as_mut_ptr()).unwrap().cast(), + ); + check_os_status(status)?; + + let audio_buffer_list = audio_buffer_list.as_mut_ptr() as *mut AudioBufferList; + let n_buffers = + core::ptr::read_unaligned(core::ptr::addr_of!((*audio_buffer_list).mNumberBuffers)) + as usize; + let first_buf_ptr = + core::ptr::addr_of!((*audio_buffer_list).mBuffers) as *const AudioBuffer; + let mut n_channels = 0usize; + for index in 0..n_buffers { + let buffer = core::ptr::read_unaligned(first_buf_ptr.add(index)); + n_channels += buffer.mNumberChannels as usize; + } + + n_channels.try_into().map_err(|_| { + Error::with_message( + ErrorKind::UnsupportedConfig, + format!("device has too many channels ({n_channels})"), + ) + }) + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 5b2d5aeb6..7c3651cd6 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -564,6 +564,15 @@ macro_rules! impl_platform_host { ) .map(StreamInner::$HostVariant) .map(Stream::from), + )* + } + } + + fn get_channel_name(&self, channel_index: u16, input: bool) -> Result { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => d.get_channel_name(channel_index, input), )* } } diff --git a/src/traits.rs b/src/traits.rs index beeff09d2..2c475d08b 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -667,6 +667,36 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display + Send + Sync { "duplex streams are not supported by this device", )) } + + /// Obtain the associated string name for a channel index. + /// + /// The CoreAudio (macOS) and ASIO backends provide channel names. All other built-in + /// backends return [`ErrorKind::UnsupportedOperation`]. + /// + /// # Parameters + /// + /// * `channel_index` - Channel index to query. + /// * `input` - Whether to query an input channel (`true`) or output channel (`false`). + /// + /// # Errors + /// + /// - [`ErrorKind::UnsupportedOperation`] if the backend does not provide channel names, or + /// if the device does not support the requested direction. + /// - [`ErrorKind::InvalidInput`] if `channel_index` is out of range for the device. + /// - [`ErrorKind::BackendError`] if the underlying audio API returns an error that cannot be + /// mapped to a more specific error kind. + /// - [`ErrorKind::Other`] if the backend returns no channel name. + /// + /// [`ErrorKind::UnsupportedOperation`]: crate::ErrorKind::UnsupportedOperation + /// [`ErrorKind::InvalidInput`]: crate::ErrorKind::InvalidInput + /// [`ErrorKind::BackendError`]: crate::ErrorKind::BackendError + /// [`ErrorKind::Other`]: crate::ErrorKind::Other + fn get_channel_name(&self, _channel_index: u16, _input: bool) -> Result { + Err(Error::with_message( + ErrorKind::UnsupportedOperation, + "device does not support channel names", + )) + } } /// A stream created from [`Device`](DeviceTrait), with methods to control it.