diff --git a/CHANGELOG.md b/CHANGELOG.md index bb840e059..a61f81458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `DeviceTrait::input_channel_descriptions()` and `DeviceTrait::output_channel_descriptions()`, + returning per-channel metadata index-aligned with the channels of a stream built on the device. + Both have a default implementation returning an empty `Vec`, so existing custom hosts are + unaffected. +- Added `ChannelDescription` (an optional driver-supplied `name` and an optional `position`) and + `ChannelPosition` (the WAVE `dwChannelMask` speaker positions). +- **ASIO**: `output_channel_descriptions()` / `input_channel_descriptions()` report the driver's + per-channel names. For aggregating drivers such as ASIO4ALL the name is the only stable handle on + a channel, since enabling another device renumbers everything after it. +- **WASAPI**: `output_channel_descriptions()` / `input_channel_descriptions()` report speaker + positions expanded from the mix format's `dwChannelMask`. +- `examples/enumerate.rs` now prints each channel's index, name and position. - **AAudio**: Xruns are now reported as `ErrorKind::Xrun`. - **CoreAudio**: Xruns are now reported as `ErrorKind::Xrun`. - **PipeWire**: Xruns are now reported as `ErrorKind::Xrun`. diff --git a/Cargo.toml b/Cargo.toml index 9be59f904..136d87070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,7 +119,7 @@ windows = { version = "0.62", features = [ "Win32_UI_Shell_PropertiesSystem", ] } audio_thread_priority = { version = "0.35", optional = true, default-features = false } -asio-sys = { version = "0.3.0", path = "asio-sys", optional = true } +asio-sys = { version = "0.3.1", path = "asio-sys", optional = true } num-traits = { version = "0.2", optional = true } jack = { version = "0.13.5", optional = true } diff --git a/asio-sys/CHANGELOG.md b/asio-sys/CHANGELOG.md index 991ca6921..c7f7fab66 100644 --- a/asio-sys/CHANGELOG.md +++ b/asio-sys/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] - 2026-08-10 + +### Added +- Added `ChannelInfo`, describing a single channel: its index, direction, active flag, channel + group, sample type and driver-assigned name. Marked `#[non_exhaustive]` so further fields can + be added without a breaking change +- Added `Driver::input_channel_info()` and `Driver::output_channel_info()` to query one channel, + and `Driver::input_channel_infos()` / `Driver::output_channel_infos()` to query every channel in + a direction. Channel names are the only stable way to identify a channel on aggregating drivers + such as ASIO4ALL, whose channel count and ordering change with its control-panel selection +- `AsioSampleType` now derives `Clone`, `Copy`, `Eq`, `Hash` and `PartialEq` + ## [0.3.0] - 2026-06-06 ### Added @@ -125,6 +137,7 @@ Initial release. - Support for MSVC toolchain on Windows - Basic error types: `AsioError`, `LoadDriverError` +[0.3.1]: https://github.com/RustAudio/cpal/compare/asio-sys-v0.3.0...asio-sys-v0.3.1 [0.3.0]: https://github.com/RustAudio/cpal/compare/asio-sys-v0.2.6...asio-sys-v0.3.0 [0.2.6]: https://github.com/RustAudio/cpal/compare/asio-sys-v0.2.5...asio-sys-v0.2.6 [0.2.5]: https://github.com/RustAudio/cpal/compare/asio-sys-v0.2.4...asio-sys-v0.2.5 diff --git a/asio-sys/Cargo.toml b/asio-sys/Cargo.toml index b8cd2b26c..d156d407a 100644 --- a/asio-sys/Cargo.toml +++ b/asio-sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "asio-sys" -version = "0.3.0" +version = "0.3.1" authors = ["Tom Gowan "] description = "Low-level interface and binding generation for the steinberg ASIO SDK." repository = "https://github.com/RustAudio/cpal/" diff --git a/asio-sys/examples/enumerate.rs b/asio-sys/examples/enumerate.rs index 4ee2fa93f..2ca7af21f 100644 --- a/asio-sys/examples/enumerate.rs +++ b/asio-sys/examples/enumerate.rs @@ -54,6 +54,36 @@ fn main() { data_type: SampleFormat::F32, }; println!(" Input {:?}", in_fmt); + print_channels( + &driver + .input_channel_infos() + .expect("failed to retrieve input channel info"), + ); println!(" Output {:?}", out_fmt); + print_channels( + &driver + .output_channel_infos() + .expect("failed to retrieve output channel info"), + ); + } +} + +fn print_channels(infos: &[sys::ChannelInfo]) { + for info in infos { + // An unnamed channel is legal, but an empty name here is also what a botched string + // decode would look like, so call it out rather than printing a blank. Quote real names + // ourselves so the placeholder is not mistaken for one. + let name = if info.name.is_empty() { + "".to_string() + } else { + format!("{:?}", info.name) + }; + println!( + " {}: {} (group {}, {})", + info.channel, + name, + info.channel_group, + if info.is_active { "active" } else { "inactive" }, + ); } } diff --git a/asio-sys/src/bindings/mod.rs b/asio-sys/src/bindings/mod.rs index 36be4e102..ca905aec8 100644 --- a/asio-sys/src/bindings/mod.rs +++ b/asio-sys/src/bindings/mod.rs @@ -91,6 +91,33 @@ pub struct Channels { pub outs: i32, } +/// Information about a single input or output channel of a driver. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +#[non_exhaustive] +pub struct ChannelInfo { + /// Index of this channel within its direction (0-based). + pub channel: i32, + /// `true` for an input channel, `false` for an output channel. + pub is_input: bool, + /// Whether the driver currently has this channel active (buffers created for it). + pub is_active: bool, + /// Driver-defined grouping; channels of one physical device usually share a group. + pub channel_group: i32, + /// Sample format of this channel. `None` if the driver reported a value this crate does + /// not know — the channel is still usable and still has a name. + pub sample_type: Option, + /// Human-readable name, e.g. "Realtek HD Audio output 1". + /// + /// May be empty: a driver is permitted to leave a channel unnamed. + /// + /// The ASIO SDK does not specify an encoding for this field and drivers write it in the + /// system code page, which this crate decodes as UTF-8 with replacement characters. A name + /// containing non-ASCII characters is therefore mangled, and two channels whose names differ + /// only in such characters can decode to the same string — so do not rely on the name alone + /// being unique. ASCII names, which is what drivers overwhelmingly use, are exact. + pub name: String, +} + /// Hardware latency in frames for the input and output streams. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct Latencies { @@ -149,7 +176,7 @@ pub struct AsioStream { /// All the possible types from ASIO. /// This is a direct copy of the ASIOSampleType /// inside ASIO SDK. -#[derive(Debug, FromPrimitive)] +#[derive(Clone, Copy, Debug, Eq, FromPrimitive, Hash, PartialEq)] #[repr(C)] pub enum AsioSampleType { ASIOSTInt16MSB = 0, @@ -504,12 +531,51 @@ impl Driver { /// Returns the number of input and output channels available on the driver. pub fn channels(&self) -> Result { let _guard = self.inner.lock_state(); - let mut ins: c_long = 0; - let mut outs: c_long = 0; - unsafe { - asio_result!(ai::ASIOGetChannels(&mut ins, &mut outs))?; - } - Ok(Channels { ins, outs }) + asio_channels() + } + + /// Information about a single input channel. + /// + /// Returns `AsioError::InvalidInput` if `index` is not below `channels()?.ins`. That is also + /// the error a driver reports for a parameter it rejects, so the two are indistinguishable. + /// + /// Each call queries the channel count as well as the channel, so prefer + /// [`Driver::input_channel_infos`] over calling this in a loop. + pub fn input_channel_info(&self, index: i32) -> Result { + let _guard = self.inner.lock_state(); + let channels = asio_channels()?; + checked_channel_info(index, true, channels.ins) + } + + /// Information about a single output channel. + /// + /// Returns `AsioError::InvalidInput` if `index` is not below `channels()?.outs`. That is also + /// the error a driver reports for a parameter it rejects, so the two are indistinguishable. + /// + /// Each call queries the channel count as well as the channel, so prefer + /// [`Driver::output_channel_infos`] over calling this in a loop. + pub fn output_channel_info(&self, index: i32) -> Result { + let _guard = self.inner.lock_state(); + let channels = asio_channels()?; + checked_channel_info(index, false, channels.outs) + } + + /// Information for every input channel, in index order. + pub fn input_channel_infos(&self) -> Result, AsioError> { + let _guard = self.inner.lock_state(); + let channels = asio_channels()?; + (0..channels.ins) + .map(|index| checked_channel_info(index, true, channels.ins)) + .collect() + } + + /// Information for every output channel, in index order. + pub fn output_channel_infos(&self) -> Result, AsioError> { + let _guard = self.inner.lock_state(); + let channels = asio_channels()?; + (0..channels.outs) + .map(|index| checked_channel_info(index, false, channels.outs)) + .collect() } /// Get the input and output hardware latency in frames. @@ -1075,6 +1141,70 @@ fn asio_channel_info(channel: c_long, is_input: bool) -> Result Result { + let mut ins: c_long = 0; + let mut outs: c_long = 0; + unsafe { + asio_result!(ai::ASIOGetChannels(&mut ins, &mut outs))?; + } + Ok(Channels { ins, outs }) +} + +/// Retrieve the `ChannelInfo` for the given channel, having first checked the index against the +/// number of channels in that direction. +/// +/// Drivers are not required to validate the index and some read out of range, so the bounds check +/// must happen before we call into the SDK. +/// +/// The caller must hold the `DriverState` lock. +fn checked_channel_info(index: i32, is_input: bool, count: i32) -> Result { + if index < 0 || index >= count { + return Err(AsioError::InvalidInput); + } + Ok(ChannelInfo::from_raw( + index, + is_input, + asio_channel_info(index, is_input)?, + )) +} + +impl ChannelInfo { + /// Build a `ChannelInfo` from the struct the driver filled in for channel `index` in the + /// direction given by `is_input`. + fn from_raw(index: i32, is_input: bool, info: ai::ASIOChannelInfo) -> Self { + // The name is NUL-terminated only by convention: a driver may fill all 32 bytes without a + // terminator, so stop at the first NUL *or* the end of the array. The SDK does not specify + // an encoding and drivers write the system code page, which we decode as UTF-8 lossily: + // doing it properly needs `MultiByteToWideChar`, and this crate has no Windows API + // dependency. See the caveat on `ChannelInfo::name`. `c_char` is `i8` on the MSVC target, + // hence the cast. + let bytes: Vec = info + .name + .iter() + .take_while(|&&c| c != 0) + .map(|&c| c as u8) + .collect(); + // Several drivers pad the name with trailing spaces. + let name = String::from_utf8_lossy(&bytes).trim_end().to_string(); + ChannelInfo { + // `channel` and `isInput` are inputs to `ASIOGetChannelInfo` that the driver is under + // no obligation to preserve, so report what we asked for rather than what came back. + channel: index, + is_input, + // `isActive` is an `ASIOBool`, for which the SDK only guarantees zero/non-zero. + is_active: info.isActive != 0, + channel_group: info.channelGroup, + // Unlike `stream_data_type`, an unrecognised sample type must not panic: the caller is + // most likely after the channel's name, which is valid regardless. + sample_type: FromPrimitive::from_i32(info.type_), + name, + } + } +} + /// Retrieve the data type of either the input or output stream. /// /// If `is_input` is true, this will be queried on the input stream. diff --git a/examples/enumerate.rs b/examples/enumerate.rs index 6e2680961..60c3bac91 100644 --- a/examples/enumerate.rs +++ b/examples/enumerate.rs @@ -6,13 +6,44 @@ //! - Retrieving device IDs for persistent identification //! - Getting device descriptions with metadata //! - Listing supported input and output stream configurations +//! - Printing per-channel names and speaker positions where the host reports them //! //! Run with: `cargo run --example enumerate` extern crate anyhow; extern crate cpal; -use cpal::traits::{DeviceTrait, HostTrait}; +use cpal::{ + traits::{DeviceTrait, HostTrait}, + ChannelDescription, +}; + +/// Prints one line per channel: its index, and whatever the host knows about it. +/// +/// An empty list is the normal result for a host with no per-channel metadata (ALSA, JACK, ...), +/// so nothing is printed at all in that case. +fn print_channel_descriptions(label: &str, descriptions: &[ChannelDescription]) { + if descriptions.is_empty() { + return; + } + println!(" {label} channels:"); + for (index, description) in descriptions.iter().enumerate() { + let name = description + .name + .as_deref() + .map_or_else(|| "".to_string(), |n| format!("{n:?}")); + let position = description + .position + .map_or_else(|| "".to_string(), |p| p.to_string()); + println!(" {index}. name: {name}, position: {position}"); + + // A driver may legally leave a channel unnamed, but an empty name where one was + // expected is also what a decoding bug looks like — so say when it happens. + if description.name.is_none() && description.position.is_none() { + println!(" (host reported neither a name nor a position)"); + } + } +} fn main() -> Result<(), anyhow::Error> { // To print raw ALSA errors to stderr during enumeration, comment out the line below: @@ -50,6 +81,16 @@ fn main() -> Result<(), anyhow::Error> { println!(" {}. {id}", device_index + 1); } + // Per-channel metadata + match device.input_channel_descriptions() { + Ok(descriptions) => print_channel_descriptions("Input", &descriptions), + Err(e) => println!(" Error getting input channel descriptions: {e:?}"), + } + match device.output_channel_descriptions() { + Ok(descriptions) => print_channel_descriptions("Output", &descriptions), + Err(e) => println!(" Error getting output channel descriptions: {e:?}"), + } + // Input configs if let Ok(conf) = device.default_input_config() { println!(" Default input stream config:\n {conf:?}"); diff --git a/src/device_description.rs b/src/device_description.rs index f797aa00b..51096a6d2 100644 --- a/src/device_description.rs +++ b/src/device_description.rs @@ -1,8 +1,9 @@ //! Device metadata and description types. //! //! This module provides structured information about audio devices including manufacturer, -//! device type, interface type, and connection details. Not all backends provide complete -//! information - availability depends on platform capabilities. +//! device type, interface type, and connection details, as well as per-channel metadata via +//! [`ChannelDescription`]. Not all backends provide complete information - availability depends +//! on platform capabilities. use std::fmt; @@ -150,6 +151,118 @@ pub enum DeviceDirection { Unknown, } +/// Where a channel sits in a standard speaker layout. +/// +/// Mirrors the WAVE `dwChannelMask` speaker positions, which CoreAudio's `AudioChannelLabel` and +/// ALSA's channel maps also map onto. +/// +/// A position is *not* a unique identifier: an aggregate device can carry two channels that are +/// both [`FrontLeft`](ChannelPosition::FrontLeft) on different sub-devices. Use the channel's +/// [`name`](ChannelDescription::name) where one exists if a selection has to be persisted. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[non_exhaustive] +pub enum ChannelPosition { + /// Front left speaker. + FrontLeft, + /// Front right speaker. + FrontRight, + /// Front centre speaker. + FrontCenter, + /// Low-frequency effects (subwoofer). + LowFrequency, + /// Back (surround) left speaker. + BackLeft, + /// Back (surround) right speaker. + BackRight, + /// Front left-of-centre speaker. + FrontLeftOfCenter, + /// Front right-of-centre speaker. + FrontRightOfCenter, + /// Back centre speaker. + BackCenter, + /// Side left speaker. + SideLeft, + /// Side right speaker. + SideRight, + /// Top centre speaker. + TopCenter, + /// Top front left speaker. + TopFrontLeft, + /// Top front centre speaker. + TopFrontCenter, + /// Top front right speaker. + TopFrontRight, + /// Top back left speaker. + TopBackLeft, + /// Top back centre speaker. + TopBackCenter, + /// Top back right speaker. + TopBackRight, +} + +impl fmt::Display for ChannelPosition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ChannelPosition::FrontLeft => "Front Left", + ChannelPosition::FrontRight => "Front Right", + ChannelPosition::FrontCenter => "Front Center", + ChannelPosition::LowFrequency => "Low Frequency", + ChannelPosition::BackLeft => "Back Left", + ChannelPosition::BackRight => "Back Right", + ChannelPosition::FrontLeftOfCenter => "Front Left of Center", + ChannelPosition::FrontRightOfCenter => "Front Right of Center", + ChannelPosition::BackCenter => "Back Center", + ChannelPosition::SideLeft => "Side Left", + ChannelPosition::SideRight => "Side Right", + ChannelPosition::TopCenter => "Top Center", + ChannelPosition::TopFrontLeft => "Top Front Left", + ChannelPosition::TopFrontCenter => "Top Front Center", + ChannelPosition::TopFrontRight => "Top Front Right", + ChannelPosition::TopBackLeft => "Top Back Left", + ChannelPosition::TopBackCenter => "Top Back Center", + ChannelPosition::TopBackRight => "Top Back Right", + }; + f.write_str(s) + } +} + +/// What a host knows about one channel of a device. +/// +/// Both fields are optional and independent: ASIO gives names without positions, WASAPI gives +/// positions without names, and a host that knows neither returns neither. +/// +/// Obtained from [`DeviceTrait::output_channel_descriptions`] and +/// [`DeviceTrait::input_channel_descriptions`]. +/// +/// [`DeviceTrait::output_channel_descriptions`]: crate::traits::DeviceTrait::output_channel_descriptions +/// [`DeviceTrait::input_channel_descriptions`]: crate::traits::DeviceTrait::input_channel_descriptions +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] +pub struct ChannelDescription { + /// Driver-supplied name, e.g. `"AMD HD Audio DP out #3 1"`. + /// + /// `None` when the host has no per-channel names, and never `Some("")` — a driver that + /// reports an empty name is treated as having reported nothing. + pub name: Option, + + /// Speaker position, when the host reports a layout. + pub position: Option, +} + +impl fmt::Display for ChannelDescription { + /// Formats the most informative label available, or `"Unknown"` when the host knew nothing. + /// + /// Callers building a channel picker will usually want their own fallback chain ending in + /// `"Channel N"`, since the index is not part of this type. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match (&self.name, self.position) { + (Some(name), Some(position)) => write!(f, "{name} ({position})"), + (Some(name), None) => f.write_str(name), + (None, Some(position)) => write!(f, "{position}"), + (None, None) => f.write_str("Unknown"), + } + } +} + impl DeviceDescription { /// Returns the human-readable device name. /// diff --git a/src/host/asio/device.rs b/src/host/asio/device.rs index 9652146f2..23fbbdf0c 100644 --- a/src/host/asio/device.rs +++ b/src/host/asio/device.rs @@ -7,9 +7,9 @@ use std::{ use super::sys; pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; use crate::{ - host::com, ChannelCount, DeviceDescription, DeviceDescriptionBuilder, DeviceId, Error, - ErrorKind, FrameCount, SampleFormat, SampleRate, SupportedBufferSize, SupportedStreamConfig, - SupportedStreamConfigRange, + host::com, ChannelCount, ChannelDescription, DeviceDescription, DeviceDescriptionBuilder, + DeviceId, Error, ErrorKind, FrameCount, SampleFormat, SampleRate, SupportedBufferSize, + SupportedStreamConfig, SupportedStreamConfigRange, }; /// A ASIO Device @@ -84,6 +84,76 @@ impl Device { self.default_config(self.channels_out, self.output_sample_format) } + /// Per-channel metadata for this driver's input channels. + /// + /// ASIO drivers name every channel but report no speaker layout, so `position` is always + /// `None`. + pub fn input_channel_descriptions(&self) -> Result, Error> { + self.channel_descriptions(true) + } + + /// Per-channel metadata for this driver's output channels. + /// + /// ASIO drivers name every channel but report no speaker layout, so `position` is always + /// `None`. + pub fn output_channel_descriptions(&self) -> Result, Error> { + self.channel_descriptions(false) + } + + /// Query the loaded driver for one direction's channel names. + /// + /// The index alignment the API promises holds because a cpal ASIO stream creates its buffers + /// with `channel_num` running `0..channels` in the same direction, so interleaved offset `i` + /// is always ASIO channel `i`. + fn channel_descriptions(&self, is_input: bool) -> Result, Error> { + com::com_initialized(); + + // Reuse the process-wide instance rather than creating one: ASIO is one-driver-per-process + // and `load_driver` returns the already-loaded driver when the name matches, so this does + // not disturb a live stream on this device. + let driver = super::GLOBAL_ASIO + .get() + .ok_or_else(|| { + Error::with_message( + ErrorKind::DeviceNotAvailable, + "ASIO driver is not initialized", + ) + })? + .load_driver(&self.name) + .map_err(|e| match e { + // Another ASIO driver is loaded (e.g. a stream on a different device holds it). + // Its channel names are not ours to report. + sys::LoadDriverError::DriverAlreadyExists => Error::with_message( + ErrorKind::DeviceBusy, + "A different ASIO driver is currently loaded", + ), + _ => Error::with_message(ErrorKind::DeviceNotAvailable, "Failed to load driver"), + })?; + + let infos = if is_input { + driver.input_channel_infos() + } else { + driver.output_channel_infos() + } + .map_err(|_| { + Error::with_message( + ErrorKind::BackendError, + "Failed to query ASIO channel information", + ) + })?; + + Ok(infos + .into_iter() + .map(|info| ChannelDescription { + // A driver is permitted to leave a channel unnamed; report that as "no name" + // rather than as an empty label. + name: Some(info.name).filter(|n| !n.is_empty()), + // ASIO has no concept of a speaker layout. + position: None, + }) + .collect()) + } + fn default_config( &self, channels: ChannelCount, diff --git a/src/host/asio/mod.rs b/src/host/asio/mod.rs index c529ecee3..ddf53521b 100644 --- a/src/host/asio/mod.rs +++ b/src/host/asio/mod.rs @@ -17,8 +17,8 @@ pub use self::{ use crate::{ host::com, traits::{DeviceTrait, HostTrait, StreamTrait}, - Data, DeviceDescription, DeviceId, Error, FrameCount, InputCallbackInfo, OutputCallbackInfo, - SampleFormat, StreamConfig, StreamInstant, SupportedStreamConfig, + ChannelDescription, Data, DeviceDescription, DeviceId, Error, FrameCount, InputCallbackInfo, + OutputCallbackInfo, SampleFormat, StreamConfig, StreamInstant, SupportedStreamConfig, }; mod device; @@ -100,6 +100,14 @@ impl DeviceTrait for Device { Device::default_output_config(self) } + fn input_channel_descriptions(&self) -> Result, Error> { + Device::input_channel_descriptions(self) + } + + fn output_channel_descriptions(&self) -> Result, Error> { + Device::output_channel_descriptions(self) + } + fn build_input_stream_raw( &self, config: StreamConfig, diff --git a/src/host/custom/mod.rs b/src/host/custom/mod.rs index d51acd26d..a81457a0d 100644 --- a/src/host/custom/mod.rs +++ b/src/host/custom/mod.rs @@ -8,9 +8,9 @@ use std::fmt; use crate::{ traits::{DeviceTrait, HostTrait, StreamTrait}, - Data, DeviceDescription, DeviceId, Error, ErrorKind, FrameCount, InputCallbackInfo, - OutputCallbackInfo, SampleFormat, StreamConfig, StreamInstant, SupportedStreamConfig, - SupportedStreamConfigRange, + ChannelDescription, Data, DeviceDescription, DeviceId, Error, ErrorKind, FrameCount, + InputCallbackInfo, OutputCallbackInfo, SampleFormat, StreamConfig, StreamInstant, + SupportedStreamConfig, SupportedStreamConfigRange, }; /// A host that can be used to write custom [`HostTrait`] implementations. @@ -183,6 +183,8 @@ trait DeviceErased: Send + Sync { fn supported_output_configs(&self) -> Result; fn default_input_config(&self) -> Result; fn default_output_config(&self) -> Result; + fn input_channel_descriptions(&self) -> Result, Error>; + fn output_channel_descriptions(&self) -> Result, Error>; fn build_input_stream_raw( &self, config: StreamConfig, @@ -284,6 +286,14 @@ where ::default_output_config(self) } + fn input_channel_descriptions(&self) -> Result, Error> { + ::input_channel_descriptions(self) + } + + fn output_channel_descriptions(&self) -> Result, Error> { + ::output_channel_descriptions(self) + } + fn build_input_stream_raw( &self, config: StreamConfig, @@ -410,6 +420,14 @@ impl DeviceTrait for Device { self.0.default_output_config() } + fn input_channel_descriptions(&self) -> Result, Error> { + self.0.input_channel_descriptions() + } + + fn output_channel_descriptions(&self) -> Result, Error> { + self.0.output_channel_descriptions() + } + fn build_input_stream_raw( &self, config: StreamConfig, diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index f5208d793..4ea24b6eb 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -12,10 +12,10 @@ use std::{ use crate::{ error::ResultExt, host::{com::ComString, ErrorCallbackArc}, - BufferSize, Data, DeviceDescription, DeviceDescriptionBuilder, DeviceDirection, DeviceId, - DeviceType, Error, ErrorKind, FrameCount, InputCallbackInfo, InterfaceType, OutputCallbackInfo, - SampleFormat, SampleRate, StreamConfig, SupportedBufferSize, SupportedStreamConfig, - SupportedStreamConfigRange, COMMON_SAMPLE_RATES, + BufferSize, ChannelDescription, ChannelPosition, Data, DeviceDescription, + DeviceDescriptionBuilder, DeviceDirection, DeviceId, DeviceType, Error, ErrorKind, FrameCount, + InputCallbackInfo, InterfaceType, OutputCallbackInfo, SampleFormat, SampleRate, StreamConfig, + SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, COMMON_SAMPLE_RATES, }; use windows::{ @@ -114,6 +114,14 @@ impl DeviceTrait for Device { Self::default_output_config(self) } + fn input_channel_descriptions(&self) -> Result, Error> { + Self::input_channel_descriptions(self) + } + + fn output_channel_descriptions(&self) -> Result, Error> { + Self::output_channel_descriptions(self) + } + fn build_input_stream_raw( &self, config: StreamConfig, @@ -173,6 +181,63 @@ impl Drop for WaveFormatExPtr { } } +/// The WAVE `dwChannelMask` speaker bits, in ascending bit order. +/// +/// The order of this table is load-bearing: the channels of a `WAVEFORMATEXTENSIBLE` buffer appear +/// in ascending bit order of the set bits, so expanding the mask means walking this table from the +/// low bit up and emitting one entry per bit that is set. Iterating positions in any "logical" +/// order instead would silently produce a shifted mapping. +const SPEAKER_POSITIONS: [(u32, ChannelPosition); 18] = [ + (0x1, ChannelPosition::FrontLeft), + (0x2, ChannelPosition::FrontRight), + (0x4, ChannelPosition::FrontCenter), + (0x8, ChannelPosition::LowFrequency), + (0x10, ChannelPosition::BackLeft), + (0x20, ChannelPosition::BackRight), + (0x40, ChannelPosition::FrontLeftOfCenter), + (0x80, ChannelPosition::FrontRightOfCenter), + (0x100, ChannelPosition::BackCenter), + (0x200, ChannelPosition::SideLeft), + (0x400, ChannelPosition::SideRight), + (0x800, ChannelPosition::TopCenter), + (0x1000, ChannelPosition::TopFrontLeft), + (0x2000, ChannelPosition::TopFrontCenter), + (0x4000, ChannelPosition::TopFrontRight), + (0x8000, ChannelPosition::TopBackLeft), + (0x10000, ChannelPosition::TopBackCenter), + (0x20000, ChannelPosition::TopBackRight), +]; + +/// Expands a `WAVEFORMATEXTENSIBLE.dwChannelMask` into one position per channel, in buffer order. +/// +/// Returns an empty `Vec` whenever the mask cannot be trusted to describe exactly `channels` +/// channels in order: +/// +/// - `mask == 0`. `KSAUDIO_SPEAKER_DIRECTOUT` is `0` and means "no assignment"; cpal's own +/// `config_to_waveformatextensible` passes `0` too. +/// - The number of set bits differs from `channels`. A partial mapping would shift every label +/// after the discrepancy onto the wrong channel. +/// - A bit outside [`SPEAKER_POSITIONS`] is set. The bit still consumes a channel slot, so +/// skipping it would shift everything after it. +/// +/// A shifted label is worse than no label, so all three cases yield "unknown" rather than a guess. +fn positions_from_mask(mask: u32, channels: u16) -> Vec { + if mask == 0 || mask.count_ones() != u32::from(channels) { + return Vec::new(); + } + + let known: u32 = SPEAKER_POSITIONS.iter().map(|&(bit, _)| bit).sum(); + if mask & !known != 0 { + return Vec::new(); + } + + SPEAKER_POSITIONS + .iter() + .filter(|&&(bit, _)| mask & bit != 0) + .map(|&(_, position)| position) + .collect() +} + unsafe fn immendpoint_from_immdevice(device: Audio::IMMDevice) -> Audio::IMMEndpoint { device .cast::() @@ -827,6 +892,70 @@ impl Device { } } + /// Per-channel speaker positions taken from the endpoint's mix format. + /// + /// WASAPI endpoints carry no per-channel names, so `name` is always `None`. + /// + /// The layout described is the **shared-mode mix format**'s, which is the format cpal streams + /// this endpoint in. An exclusive-mode stream at a different channel count is not described by + /// it. + fn channel_descriptions( + &self, + data_flow: Audio::EDataFlow, + ) -> Result, Error> { + // An endpoint is either capture or render, never both; the other direction has no + // channels to describe. Empty is the documented "no information" answer. + if self.data_flow() != data_flow { + return Ok(Vec::new()); + } + + // initializing COM because we call `CoTaskMemFree` to release the format. + com::com_initialized(); + + let lock = self + .ensure_future_audio_client(None) + .context("Failed to get audio client")?; + // ensure_future_audio_client always sets the Option to Some before returning Ok. + let client = &lock.as_ref().unwrap().0; + + unsafe { + let format_ptr = client + .GetMixFormat() + .map(WaveFormatExPtr) + .context("Failed to get mix format")?; + + // `dwChannelMask` only exists on WAVEFORMATEXTENSIBLE. A plain WAVEFORMATEX carries no + // layout, and reading past it would be reading memory the endpoint never allocated. + let format = &*format_ptr.0; + if format.wFormatTag as u32 != KernelStreaming::WAVE_FORMAT_EXTENSIBLE + || (format.cbSize as usize) + < mem::size_of::() + - mem::size_of::() + { + return Ok(Vec::new()); + } + + let extensible = &*(format_ptr.0 as *const Audio::WAVEFORMATEXTENSIBLE); + Ok( + positions_from_mask(extensible.dwChannelMask, format.nChannels) + .into_iter() + .map(|position| ChannelDescription { + name: None, + position: Some(position), + }) + .collect(), + ) + } + } + + pub fn input_channel_descriptions(&self) -> Result, Error> { + self.channel_descriptions(Audio::eCapture) + } + + pub fn output_channel_descriptions(&self) -> Result, Error> { + self.channel_descriptions(Audio::eRender) + } + pub(crate) fn build_input_stream_raw_inner( &self, config: StreamConfig, @@ -1444,3 +1573,83 @@ fn buffer_size_to_duration(buffer_size: &BufferSize, sample_rate: SampleRate) -> fn buffer_duration_to_frames(buffer_duration: i64, sample_rate: SampleRate) -> FrameCount { ((buffer_duration * sample_rate as i64 * 100 + 500_000_000) / 1_000_000_000) as FrameCount } + +#[cfg(test)] +mod tests { + use super::*; + + // Masks as they appear in Ksmedia.h. + const KSAUDIO_SPEAKER_DIRECTOUT: u32 = 0; + const KSAUDIO_SPEAKER_STEREO: u32 = 0x3; + const KSAUDIO_SPEAKER_5POINT1: u32 = 0x3F; + const KSAUDIO_SPEAKER_5POINT1_SURROUND: u32 = 0x60F; + + #[test] + fn stereo_mask_expands_left_then_right() { + assert_eq!( + positions_from_mask(KSAUDIO_SPEAKER_STEREO, 2), + vec![ChannelPosition::FrontLeft, ChannelPosition::FrontRight], + ); + } + + #[test] + fn five_point_one_expands_in_bit_order() { + // Not speaker-pair order: LFE sits at index 3, between the front and back pairs. + assert_eq!( + positions_from_mask(KSAUDIO_SPEAKER_5POINT1, 6), + vec![ + ChannelPosition::FrontLeft, + ChannelPosition::FrontRight, + ChannelPosition::FrontCenter, + ChannelPosition::LowFrequency, + ChannelPosition::BackLeft, + ChannelPosition::BackRight, + ], + ); + } + + #[test] + fn side_channels_sort_after_the_lfe_not_after_the_front_pair() { + // The side pair is bits 9 and 10, so it lands at indices 4 and 5 despite being the + // "surround" pair a speaker-order list would emit second. + let positions = positions_from_mask(KSAUDIO_SPEAKER_5POINT1_SURROUND, 6); + assert_eq!(positions[3], ChannelPosition::LowFrequency); + assert_eq!(positions[4], ChannelPosition::SideLeft); + assert_eq!(positions[5], ChannelPosition::SideRight); + } + + #[test] + fn directout_yields_no_information() { + assert!(positions_from_mask(KSAUDIO_SPEAKER_DIRECTOUT, 2).is_empty()); + } + + #[test] + fn mask_with_too_few_bits_for_the_channel_count_is_rejected() { + // A stereo mask on a 6-channel endpoint would label channels 0 and 1 and leave 2..6 + // unlabelled — but nothing guarantees the two named ones are the first two. + assert!(positions_from_mask(KSAUDIO_SPEAKER_STEREO, 6).is_empty()); + } + + #[test] + fn mask_with_too_many_bits_for_the_channel_count_is_rejected() { + assert!(positions_from_mask(KSAUDIO_SPEAKER_5POINT1, 2).is_empty()); + } + + #[test] + fn unknown_bit_rejects_the_whole_mask() { + // Bit 31 consumes a channel slot we cannot name; emitting the other five would shift + // every label after it. + let mask = KSAUDIO_SPEAKER_STEREO | 0x8000_0000; + assert!(positions_from_mask(mask, 3).is_empty()); + } + + #[test] + fn every_position_is_reachable_and_distinct() { + let all: u32 = SPEAKER_POSITIONS.iter().map(|&(bit, _)| bit).sum(); + let positions = positions_from_mask(all, SPEAKER_POSITIONS.len() as u16); + assert_eq!(positions.len(), SPEAKER_POSITIONS.len()); + for (i, &(_, expected)) in SPEAKER_POSITIONS.iter().enumerate() { + assert_eq!(positions[i], expected); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 2adbecd6c..1bdef929d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -184,7 +184,8 @@ extern crate wasm_bindgen; extern crate web_sys; pub use device_description::{ - DeviceDescription, DeviceDescriptionBuilder, DeviceDirection, DeviceType, InterfaceType, + ChannelDescription, ChannelPosition, DeviceDescription, DeviceDescriptionBuilder, + DeviceDirection, DeviceType, InterfaceType, }; pub use error::*; pub use platform::{ diff --git a/src/platform/mod.rs b/src/platform/mod.rs index eb6a482f6..c2df42ef1 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -468,6 +468,28 @@ macro_rules! impl_platform_host { } } + fn input_channel_descriptions(&self) -> Result, crate::Error> { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => { + crate::traits::DeviceTrait::input_channel_descriptions(d) + } + )* + } + } + + fn output_channel_descriptions(&self) -> Result, crate::Error> { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => { + crate::traits::DeviceTrait::output_channel_descriptions(d) + } + )* + } + } + fn build_input_stream_raw( &self, config: crate::StreamConfig, diff --git a/src/traits.rs b/src/traits.rs index cc0b91124..8c5cb2239 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -12,9 +12,9 @@ use std::{ }; use crate::{ - Data, DeviceDescription, DeviceId, Error, InputCallbackInfo, InputDevices, OutputCallbackInfo, - OutputDevices, SampleFormat, SizedSample, StreamConfig, StreamInstant, SupportedStreamConfig, - SupportedStreamConfigRange, + ChannelDescription, Data, DeviceDescription, DeviceId, Error, InputCallbackInfo, InputDevices, + OutputCallbackInfo, OutputDevices, SampleFormat, SizedSample, StreamConfig, StreamInstant, + SupportedStreamConfig, SupportedStreamConfigRange, }; /// A [`Host`] provides access to the available audio devices on the system. @@ -224,6 +224,48 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display { /// [`ErrorKind::UnsupportedOperation`]: crate::ErrorKind::UnsupportedOperation fn default_output_config(&self) -> Result; + /// Per-channel metadata for the device's input channels. + /// + /// See [`output_channel_descriptions`](Self::output_channel_descriptions); the ordering + /// contract, the meaning of an empty result and the error cases are identical. + /// + /// # Errors + /// + /// - [`ErrorKind::DeviceNotAvailable`] if the device has been disconnected. + /// - [`ErrorKind::BackendError`] for unclassifiable backend failures. + /// + /// [`ErrorKind::DeviceNotAvailable`]: crate::ErrorKind::DeviceNotAvailable + /// [`ErrorKind::BackendError`]: crate::ErrorKind::BackendError + fn input_channel_descriptions(&self) -> Result, Error> { + Ok(Vec::new()) + } + + /// Per-channel metadata for the device's output channels, in the same order as the channels of + /// a stream built on this device. + /// + /// `descriptions[i]` describes the samples at offset `i` of each interleaved frame. A host that + /// cannot guarantee that alignment returns an empty `Vec` rather than a best guess, because a + /// label that does not match the buffer index it appears to describe is worse than no label. + /// + /// An empty `Vec` means "this host has no per-channel information" and is **not** an error — + /// it is the normal result for ALSA, JACK, and every backend that does not implement this. The + /// returned length may also be *shorter* than a stream's channel count, so callers must index + /// defensively and fall back to a positional label such as `"Channel 3"`. + /// + /// This queries the device and allocates. It is an enumeration-time call and must not be made + /// from a stream's data callback. + /// + /// # Errors + /// + /// - [`ErrorKind::DeviceNotAvailable`] if the device has been disconnected. + /// - [`ErrorKind::BackendError`] for unclassifiable backend failures. + /// + /// [`ErrorKind::DeviceNotAvailable`]: crate::ErrorKind::DeviceNotAvailable + /// [`ErrorKind::BackendError`]: crate::ErrorKind::BackendError + fn output_channel_descriptions(&self) -> Result, Error> { + Ok(Vec::new()) + } + /// Create an input stream. /// /// # Parameters