diff --git a/.github/workflows/cpal.yml b/.github/workflows/cpal.yml index a8130178f..e7a6038d3 100644 --- a/.github/workflows/cpal.yml +++ b/.github/workflows/cpal.yml @@ -14,6 +14,8 @@ jobs: run: sudo apt-get install libasound2-dev - name: Install libjack run: sudo apt-get install libjack-jackd2-dev libjack-jackd2-0 + - name: Install libpipewire + run: sudo apt-get install libpipewire-0.3-dev - name: Install stable uses: dtolnay/rust-toolchain@stable with: @@ -67,6 +69,8 @@ jobs: run: sudo apt-get install libasound2-dev - name: Install libjack run: sudo apt-get install libjack-jackd2-dev libjack-jackd2-0 + - name: Install libpipewire + run: sudo apt-get install libpipewire-0.3-dev - name: Install stable uses: dtolnay/rust-toolchain@stable - name: Run without features diff --git a/.gitignore b/.gitignore index 0289afe13..5689be829 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -/target -/Cargo.lock +target/ +Cargo.lock .cargo/ .DS_Store recorded.wav diff --git a/Cargo.toml b/Cargo.toml index 130e36edb..7928469be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ rust-version = "1.70" [features] asio = ["asio-sys", "num-traits"] # Only available on Windows. See README for setup instructions. +jack = ["dep:jack"] +pipewire = ["dep:pipewire-client", "dep:tokio"] [dependencies] dasp_sample = "0.11" @@ -42,6 +44,8 @@ num-traits = { version = "0.2.6", optional = true } alsa = "0.9" libc = "0.2" jack = { version = "0.13.0", optional = true } +pipewire-client = { version = "0.1", git = "https://github.com/midoriiro/pipewire-client.git", optional = true } +tokio = { version = "1.43", features = ["full"], optional = true } # For PipeWire client, will change in the future. client will provide sync and async api. [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] core-foundation-sys = "0.8.2" # For linking to CoreFoundation.framework and handling device name `CFString`s. diff --git a/README.md b/README.md index b82cbd2aa..405acac07 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This library currently supports the following: Currently, supported hosts include: -- Linux (via ALSA or JACK) +- Linux (via ALSA, JACK or PipeWire) - Windows (via WASAPI by default, see ASIO instructions below) - macOS (via CoreAudio) - iOS (via CoreAudio) @@ -27,6 +27,10 @@ Note that on Linux, the ALSA development files are required. These are provided as part of the `libasound2-dev` package on Debian and Ubuntu distributions and `alsa-lib-devel` on Fedora. +When building with the `pipewire` feature flag, development files for PipeWire and Clang are required: +- On Debian and Ubuntu: install the `libpipewire-0.3-dev` and `libclang-19-dev` packages. +- On Fedora: install the `pipewire-devel` and `clang-devel` packages. + ## Compiling for Web Assembly If you are interested in using CPAL with WASM, please see [this guide](https://github.com/RustAudio/cpal/wiki/Setting-up-a-new-CPAL-WASM-project) in our Wiki which walks through setting up a new project from scratch. @@ -36,6 +40,7 @@ If you are interested in using CPAL with WASM, please see [this guide](https://g Some audio backends are optional and will only be compiled with a [feature flag](https://doc.rust-lang.org/cargo/reference/features.html). - JACK (on Linux): `jack` +- PipeWire (on Linux): `pipewire` (currently in testing, feel free to share your feedback!) - ASIO (on Windows): `asio` ## ASIO on Windows diff --git a/examples/beep.rs b/examples/beep.rs index fb1ff45a3..3a3dc2855 100644 --- a/examples/beep.rs +++ b/examples/beep.rs @@ -24,6 +24,20 @@ struct Opt { #[arg(short, long)] #[allow(dead_code)] jack: bool, + + /// Use the PipeWire host + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "pipewire" + ))] + #[arg(short, long)] + #[allow(dead_code)] + pipewire: bool, } fn main() -> anyhow::Result<()> { @@ -52,6 +66,29 @@ fn main() -> anyhow::Result<()> { cpal::default_host() }; + // Conditionally compile with pipewire if the feature is specified. + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "pipewire" + ))] + // Manually check for flags. Can be passed through cargo with -- e.g. + // cargo run --release --example beep --features pipewire -- --pipewire + let host = if opt.pipewire { + cpal::host_from_id(cpal::available_hosts() + .into_iter() + .find(|id| *id == cpal::HostId::PipeWire) + .expect( + "make sure --features pipewire is specified. only works on OSes where pipewire is available", + )).expect("pipewire host unavailable") + } else { + cpal::default_host() + }; + #[cfg(any( not(any( target_os = "linux", @@ -59,7 +96,10 @@ fn main() -> anyhow::Result<()> { target_os = "freebsd", target_os = "netbsd" )), - not(feature = "jack") + not(any( + feature = "jack", + feature = "pipewire", + )) ))] let host = cpal::default_host(); diff --git a/examples/synth_tones.rs b/examples/synth_tones.rs index fedfdb808..77aa9c880 100644 --- a/examples/synth_tones.rs +++ b/examples/synth_tones.rs @@ -115,6 +115,51 @@ where pub fn host_device_setup( ) -> Result<(cpal::Host, cpal::Device, cpal::SupportedStreamConfig), anyhow::Error> { + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + // Manually check for flags. Can be passed through cargo with -- e.g. + // cargo run --release --example beep --features jack -- --jack + let host = cpal::host_from_id(cpal::available_hosts() + .into_iter() + .find(|id| *id == cpal::HostId::Jack) + .expect( + "make sure --features jack is specified. only works on OSes where jack is available", + )).expect("jack host unavailable"); + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "pipewire" + ))] + let host = cpal::host_from_id(cpal::available_hosts() + .into_iter() + .find(|id| *id == cpal::HostId::PipeWire) + .expect( + "make sure --features pipewire is specified. only works on OSes where pipewire is available", + )).expect("pipewire host unavailable"); + + #[cfg(any( + not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + )), + not(any( + feature = "jack", + feature = "pipewire", + )) + ))] let host = cpal::default_host(); let device = host diff --git a/src/host/mod.rs b/src/host/mod.rs index 0c61a5910..3cbae5990 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -23,6 +23,16 @@ pub(crate) mod emscripten; feature = "jack" ))] pub(crate) mod jack; +#[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "pipewire" +))] +pub(crate)mod pipewire; pub(crate) mod null; #[cfg(windows)] pub(crate) mod wasapi; diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs new file mode 100644 index 000000000..ee05cebb1 --- /dev/null +++ b/src/host/pipewire/device.rs @@ -0,0 +1,208 @@ +use crate::host::pipewire::utils::{AudioBuffer, FromStreamConfigWithSampleFormat}; +use crate::host::pipewire::Stream; +use crate::traits::DeviceTrait; +use crate::{BackendSpecificError, BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, InputCallbackInfo, InputStreamTimestamp, OutputCallbackInfo, OutputStreamTimestamp, SampleFormat, SampleRate, StreamConfig, StreamError, StreamInstant, SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, SupportedStreamConfigsError}; +use std::rc::Rc; +use std::time::Duration; +use pipewire_client::{AudioStreamInfo, Direction, PipewireClient, NodeInfo}; +use pipewire_client::spa_utils::audio::raw::AudioInfoRaw; + +pub type SupportedInputConfigs = std::vec::IntoIter; +pub type SupportedOutputConfigs = std::vec::IntoIter; + +#[derive(Debug, Clone)] +pub struct Device { + pub(super) id: u32, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) nickname: String, + pub(crate) direction: Direction, + pub(super) is_default: bool, + pub(crate) format: AudioInfoRaw, + pub(super) client: Rc, +} + +impl Device { + pub(super) fn from( + info: &NodeInfo, + client: Rc, + ) -> Result { + Ok(Self { + id: info.id.clone(), + name: info.name.clone(), + description: info.description.clone(), + nickname: info.nickname.clone(), + direction: info.direction.clone(), + is_default: info.is_default.clone(), + format: info.format.clone(), + client, + }) + } + + pub fn default_config(&self) -> Result { + let settings = match self.client.core().get_settings() { + Ok(value) => value, + Err(value) => return Err(DefaultStreamConfigError::BackendSpecific { + err: BackendSpecificError { + description: value.description, + } + }), + }; + Ok(SupportedStreamConfig { + channels: *self.format.channels as u16, + sample_rate: SampleRate(self.format.sample_rate.value), + buffer_size: SupportedBufferSize::Range { + min: settings.min_buffer_size, + max: settings.max_buffer_size, + }, + sample_format: self.format.sample_format.default.try_into()?, + }) + } + + pub fn supported_configs(&self) -> Vec { + let f = match self.default_config() { + Err(_) => return vec![], + Ok(f) => f, + }; + let mut supported_configs = vec![]; + for &sample_format in self.format.sample_format.alternatives.iter() { + supported_configs.push(SupportedStreamConfigRange { + channels: f.channels, + min_sample_rate: SampleRate(self.format.sample_rate.minimum), + max_sample_rate: SampleRate(self.format.sample_rate.maximum), + buffer_size: f.buffer_size.clone(), + sample_format: sample_format.try_into().unwrap(), + }); + } + supported_configs + } + + fn build_stream_raw ( + &self, + direction: Direction, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result where + D: FnMut(&mut Data) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let format: AudioStreamInfo = FromStreamConfigWithSampleFormat::from((config, sample_format)); + let channels = config.channels; + let stream_name = self.client.stream().create( + self.id, + direction, + format, + move |_, buffer| { + let mut buffer = AudioBuffer::from( + buffer, + sample_format, + channels + ); + let data = buffer.data(); + if data.is_none() { + return; + } + let mut data = data.unwrap(); + data_callback(&mut data) + } + ).unwrap(); + Ok(Stream::new(stream_name, self.client.clone())) + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + Ok(self.nickname.clone()) + } + + fn supported_input_configs( + &self, + ) -> Result { + Ok(self.supported_configs().into_iter()) + } + + fn supported_output_configs( + &self, + ) -> Result { + Ok(self.supported_configs().into_iter()) + } + + fn default_input_config(&self) -> Result { + self.default_config() + } + + fn default_output_config(&self) -> Result { + self.default_config() + } + + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + self.build_stream_raw( + Direction::Input, + config, + sample_format, + move |data| { + data_callback( + data, + &InputCallbackInfo { + timestamp: InputStreamTimestamp { + callback: StreamInstant::from_nanos(0), + capture: StreamInstant::from_nanos(0), + }, + } + ) + }, + error_callback, + timeout + ) + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + self.build_stream_raw( + Direction::Output, + config, + sample_format, + move |data| { + data_callback( + data, + &OutputCallbackInfo { + timestamp: OutputStreamTimestamp { + callback: StreamInstant::from_nanos(0), + playback: StreamInstant::from_nanos(0), + }, + } + ) + }, + error_callback, + timeout + ) + } +} diff --git a/src/host/pipewire/host.rs b/src/host/pipewire/host.rs new file mode 100644 index 000000000..de984e1f9 --- /dev/null +++ b/src/host/pipewire/host.rs @@ -0,0 +1,88 @@ +use crate::traits::HostTrait; +use crate::{BackendSpecificError, DevicesError, HostUnavailable, SupportedStreamConfigRange}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; +use pipewire_client::{Direction, PipewireClient}; +use tokio::runtime::Runtime; +use crate::host::pipewire::Device; + +pub type SupportedInputConfigs = std::vec::IntoIter; +pub type SupportedOutputConfigs = std::vec::IntoIter; +pub type Devices = std::vec::IntoIter; + +#[derive(Debug)] +pub struct Host { + runtime: Arc, + client: Rc, +} + +impl Host { + pub fn new() -> Result { + let timeout = Duration::from_secs(30); + let runtime = Arc::new(Runtime::new().unwrap()); + let client = PipewireClient::new(runtime.clone(), timeout) + .map_err(move |error| { + eprintln!("{}", error.description); + HostUnavailable + })?; + let client = Rc::new(client); + let host = Host { + runtime, + client + }; + Ok(host) + } + + fn default_device(&self, direction: Direction) -> Option { + self.devices() + .unwrap() + .filter(move |device| device.direction == direction && device.is_default) + .collect::>() + .first() + .cloned() + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + true + } + + fn devices(&self) -> Result { + let input_devices = match self.client.node().enumerate(Direction::Input) { + Ok(values) => values.into_iter(), + Err(value) => return Err(DevicesError::BackendSpecific { + err: BackendSpecificError { + description: value.description, + }, + }), + }; + let output_devices = match self.client.node().enumerate(Direction::Output) { + Ok(values) => values.into_iter(), + Err(value) => return Err(DevicesError::BackendSpecific { + err: BackendSpecificError { + description: value.description, + }, + }), + }; + let devices = input_devices.chain(output_devices) + .map(move |device| { + Device::from(&device, self.client.clone()).unwrap() + }) + .collect::>() + .into_iter(); + Ok(devices) + } + + fn default_input_device(&self) -> Option { + self.default_device(Direction::Input) + } + + fn default_output_device(&self) -> Option { + self.default_device(Direction::Output) + } +} \ No newline at end of file diff --git a/src/host/pipewire/mod.rs b/src/host/pipewire/mod.rs new file mode 100644 index 000000000..debd81a75 --- /dev/null +++ b/src/host/pipewire/mod.rs @@ -0,0 +1,11 @@ +mod host; +pub use self::host::Host; +pub use self::host::Devices; +pub use self::host::SupportedInputConfigs; +pub use self::host::SupportedOutputConfigs; +mod device; + +pub use self::device::Device; +mod stream; +pub use self::stream::Stream; +mod utils; diff --git a/src/host/pipewire/stream.rs b/src/host/pipewire/stream.rs new file mode 100644 index 000000000..38643f827 --- /dev/null +++ b/src/host/pipewire/stream.rs @@ -0,0 +1,36 @@ +use std::rc::Rc; +use pipewire_client::PipewireClient; +use crate::{PauseStreamError, PlayStreamError}; +use crate::traits::StreamTrait; + +pub struct Stream { + name: String, + client: Rc, +} + +impl Stream { + pub(super) fn new(name: String, client: Rc) -> Self { + Self { + name, + client, + } + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + self.client.stream().connect(self.name.clone()).unwrap(); + Ok(()) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + self.client.stream().disconnect(self.name.clone()).unwrap(); + Ok(()) + } +} + +impl Drop for Stream { + fn drop(&mut self) { + self.client.stream().delete(self.name.clone()).unwrap() + } +} \ No newline at end of file diff --git a/src/host/pipewire/utils.rs b/src/host/pipewire/utils.rs new file mode 100644 index 000000000..39aa5d91f --- /dev/null +++ b/src/host/pipewire/utils.rs @@ -0,0 +1,126 @@ +use pipewire_client::spa_utils::audio::{AudioChannelPosition, AudioSampleFormat, AudioSampleFormatEnum}; +use pipewire_client::spa_utils::format::{MediaType, MediaSubtype}; +use pipewire_client::{pipewire, AudioStreamInfo}; +use crate::{BackendSpecificError, ChannelCount, Data, SampleFormat, StreamConfig}; + +impl TryFrom for AudioSampleFormat { + type Error = BackendSpecificError; + + fn try_from(value: SampleFormat) -> Result { + let value = match value { + SampleFormat::I8 => AudioSampleFormat::S8, + SampleFormat::U8 => AudioSampleFormat::U8, + SampleFormat::I16 => AudioSampleFormat::S16_LE, + SampleFormat::U16 => AudioSampleFormat::U16_LE, + SampleFormat::I32 => AudioSampleFormat::S32_LE, + SampleFormat::U32 => AudioSampleFormat::U32_LE, + SampleFormat::F32 => AudioSampleFormat::F32_LE, + SampleFormat::F64 => AudioSampleFormat::F64_LE, + _ => return Err(BackendSpecificError { + description: "Unsupported sample format".to_string(), + })}; + Ok(value) + } +} + +impl TryFrom for SampleFormat { + type Error = BackendSpecificError; + + fn try_from(value: AudioSampleFormat) -> Result { + let value = match value { + AudioSampleFormat::S8 => SampleFormat::I8, + AudioSampleFormat::U8 => SampleFormat::U8, + AudioSampleFormat::S16_LE => SampleFormat::I16, + AudioSampleFormat::U16_LE => SampleFormat::U16, + AudioSampleFormat::S32_LE => SampleFormat::I32, + AudioSampleFormat::U32_LE => SampleFormat::U32, + AudioSampleFormat::F32_LE => SampleFormat::F32, + AudioSampleFormat::F64_LE => SampleFormat::F64, + _ => return Err(BackendSpecificError { + description: "Unsupported sample format".to_string(), + })}; + Ok(value) + } +} + +impl TryFrom for SampleFormat { + type Error = BackendSpecificError; + + fn try_from(value: AudioSampleFormatEnum) -> Result { + let sample_format = SampleFormat::try_from(value.default); + if sample_format.is_ok() { + return sample_format; + } + let sample_format = value.alternatives.iter() + .map(move |sample_format| { + SampleFormat::try_from(sample_format.clone()) + }) + .filter(move |result| result.is_ok()) + .last(); + sample_format.unwrap() + } +} + +pub trait FromStreamConfigWithSampleFormat { + fn from(value: (&StreamConfig, SampleFormat)) -> Self; +} + +impl FromStreamConfigWithSampleFormat for AudioStreamInfo { + fn from(value: (&StreamConfig, SampleFormat)) -> Self { + Self { + media_type: MediaType::Audio, + media_subtype: MediaSubtype::Raw, + sample_format: value.1.try_into().unwrap(), + sample_rate: value.0.sample_rate.0, + channels: value.0.channels as u32, + position: AudioChannelPosition::default(), + } + } +} + +pub(super) struct AudioBuffer<'a> { + buffer: pipewire::buffer::Buffer<'a>, + sample_format: SampleFormat, + channels: ChannelCount, +} + +impl <'a> AudioBuffer<'a> { + pub fn from( + buffer: pipewire::buffer::Buffer<'a>, + sample_format: SampleFormat, + channels: ChannelCount, + ) -> Self { + Self { + buffer, + sample_format, + channels + } + } + + pub fn data(&mut self) -> Option { + let datas = self.buffer.datas_mut(); + let data = &mut datas[0]; + + let stride = self.sample_format.sample_size() * self.channels as usize; + + let data_info = if let Some(data) = data.data() { + let len = data.len(); + let data = unsafe { + Some(Data::from_parts( + data.as_mut_ptr() as *mut (), + data.len() / self.sample_format.sample_size(), + self.sample_format, + )) + }; + (data, len) + } + else { + return None + }; + let chunk = data.chunk_mut(); + *chunk.offset_mut() = 0; + *chunk.stride_mut() = stride as i32; + *chunk.size_mut() = data_info.1 as u32; + data_info.0 + } +} \ No newline at end of file diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 6fc35f6f4..2dfd72208 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -602,8 +602,18 @@ mod platform_impl { SupportedInputConfigs as JackSupportedInputConfigs, SupportedOutputConfigs as JackSupportedOutputConfigs, }; + #[cfg(feature = "pipewire")] + pub use crate::host::pipewire::{ + Device as PipeWireDevice, Devices as PipeWireDevices, Host as PipeWireHost, + Stream as PipeWireStream, SupportedInputConfigs as PipeWireSupportedInputConfigs, + SupportedOutputConfigs as PipeWireSupportedOutputConfigs, + }; - impl_platform_host!(#[cfg(feature = "jack")] Jack jack "JACK", Alsa alsa "ALSA"); + impl_platform_host!( + #[cfg(feature = "pipewire")] PipeWire pipewire "PipeWire", + #[cfg(feature = "jack")] Jack jack "JACK", + Alsa alsa "ALSA" + ); /// The default host for the current compilation target platform. pub fn default_host() -> Host {