diff --git a/Cargo.lock b/Cargo.lock index e7bd92dc4..dfb0f32db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" +[[package]] +name = "cache-padded" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631ae5198c9be5e753e5cc215e1bd73c2b466a3565173db433f52bb9d3e66dba" + [[package]] name = "cc" version = "1.0.68" @@ -1299,6 +1305,7 @@ dependencies = [ "rand", "rand_distr", "rodio", + "rtrb", "sdl2", "shell-words", "thiserror", @@ -1944,6 +1951,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "rtrb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2a6de2cc3b28e99ea0f6de26b7b53a10998028812326fab55f1e318512ba426" +dependencies = [ + "cache-padded", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5ea5bf1f1..e695a7ead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,13 +65,15 @@ sha-1 = "0.9" [features] alsa-backend = ["librespot-playback/alsa-backend"] +cpal-backend = ["librespot-playback/cpal-backend"] +cpaljack-backend = ["librespot-playback/cpaljack-backend"] +gstreamer-backend = ["librespot-playback/gstreamer-backend"] +jackaudio-backend = ["librespot-playback/jackaudio-backend"] portaudio-backend = ["librespot-playback/portaudio-backend"] pulseaudio-backend = ["librespot-playback/pulseaudio-backend"] -jackaudio-backend = ["librespot-playback/jackaudio-backend"] rodio-backend = ["librespot-playback/rodio-backend"] rodiojack-backend = ["librespot-playback/rodiojack-backend"] sdl-backend = ["librespot-playback/sdl-backend"] -gstreamer-backend = ["librespot-playback/gstreamer-backend"] with-dns-sd = ["librespot-discovery/with-dns-sd"] diff --git a/playback/Cargo.toml b/playback/Cargo.toml index e19f4ffd4..73d8f1300 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -37,9 +37,10 @@ gstreamer = { version = "0.16", optional = true } gstreamer-app = { version = "0.16", optional = true } glib = { version = "0.10", optional = true } -# Rodio dependencies +# Rodio and CPAL dependencies rodio = { version = "0.14", optional = true, default-features = false } cpal = { version = "0.13", optional = true } +rtrb = { version = "0.1", optional = true } thiserror = { version = "1", optional = true } # Decoder @@ -52,10 +53,12 @@ rand_distr = "0.4" [features] alsa-backend = ["alsa"] +cpal-backend = ["cpal", "thiserror", "rtrb"] +cpaljack-backend = ["cpal/jack", "thiserror", "rtrb"] +gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"] +jackaudio-backend = ["jack"] portaudio-backend = ["portaudio-rs"] pulseaudio-backend = ["libpulse-binding", "libpulse-simple-binding"] -jackaudio-backend = ["jack"] rodio-backend = ["rodio", "cpal", "thiserror"] rodiojack-backend = ["rodio", "cpal/jack", "thiserror"] sdl-backend = ["sdl2"] -gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"] diff --git a/playback/src/audio_backend/cpal.rs b/playback/src/audio_backend/cpal.rs new file mode 100644 index 000000000..2b80f4553 --- /dev/null +++ b/playback/src/audio_backend/cpal.rs @@ -0,0 +1,312 @@ +use std::process::exit; +use std::{io, thread, time}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{Sample, StreamConfig}; +use rtrb::{Consumer, RingBuffer}; +use thiserror::Error; + +use super::Sink; +use crate::config::AudioFormat; +use crate::convert::Converter; +use crate::decoder::AudioPacket; +use crate::{NUM_CHANNELS, SAMPLE_RATE}; + +#[cfg(all( + feature = "cpaljack-backend", + not(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd")) +))] +compile_error!("CPAL JACK Audio backend is currently only supported on Linux."); + +#[derive(Debug, Error)] +pub enum CpalError { + #[error("CPAL: no device available")] + NoDeviceAvailable, + #[error("CPAL: device \"{0}\" is not available")] + DeviceNotAvailable(String), + #[error("Cannot get audio devices: {0}")] + DevicesError(#[from] cpal::DevicesError), +} + +pub struct CpalSink { + stream: cpal::Stream, + format: AudioFormat, + sample_tx: rtrb::Producer, +} + +#[cfg(feature = "cpal-backend")] +pub const NAME: &str = "cpal"; + +#[cfg(feature = "cpaljack-backend")] +pub const JACK_NAME: &str = "cpaljack"; + +fn list_formats(device: &cpal::Device) { + match device.default_output_config() { + Ok(cfg) => { + debug!(" Default config:"); + debug!(" {:?}", cfg); + } + Err(e) => { + // Use loglevel debug, since even the output is only debug + debug!("Error getting default cpal::Sink output config: {}", e); + } + }; + + match device.supported_output_configs() { + Ok(mut cfgs) => { + if let Some(first) = cfgs.next() { + debug!(" Available configs:"); + debug!(" {:?}", first); + } else { + return; + } + + for cfg in cfgs { + debug!(" {:?}", cfg); + } + } + Err(e) => { + debug!("Error getting supported cpal::Sink configs: {}", e); + } + } +} + +fn list_outputs(host: &cpal::Host) -> Result<(), cpal::DevicesError> { + let mut default_device_name = None; + + if let Some(default_device) = host.default_output_device() { + default_device_name = default_device.name().ok(); + println!( + "Default Audio Device:\n {}", + default_device_name.as_deref().unwrap_or("[unknown name]") + ); + + list_formats(&default_device); + + println!("Other Available Audio Devices:"); + } else { + warn!("No default device was found"); + } + + for device in host.output_devices()? { + match device.name() { + Ok(name) if Some(&name) == default_device_name.as_ref() => (), + Ok(name) => { + println!(" {}", name); + list_formats(&device); + } + Err(e) => { + warn!("Cannot get device name: {}", e); + println!(" [unknown name]"); + list_formats(&device); + } + } + } + + Ok(()) +} + +fn get_device(host: &cpal::Host, device: Option) -> Result { + let device = match device { + Some(ask) if &ask == "?" => { + let exit_code = match list_outputs(host) { + Ok(()) => 0, + Err(e) => { + error!("{}", e); + 1 + } + }; + exit(exit_code) + } + Some(device_name) => { + host.output_devices()? + .find(|d| d.name().ok().map_or(false, |name| name == device_name)) // Ignore devices for which getting name fails + .ok_or(CpalError::DeviceNotAvailable(device_name))? + } + None => host + .default_output_device() + .ok_or(CpalError::NoDeviceAvailable)?, + }; + + info!( + "Using audio device: {}", + device.name().as_deref().unwrap_or("[unknown name]") + ); + + Ok(device) +} + +fn data_callback( + mut consumer: Consumer, +) -> impl FnMut(&mut [T], &cpal::OutputCallbackInfo) { + let silence = ::from(&0i16); + + move |buf: &mut [T], _| { + let mut chunk = consumer.read_chunk(consumer.slots()).unwrap(); + + if chunk.len() >= buf.len() { + buf.iter_mut() + .zip(&mut chunk) + .for_each(|(to, from)| *to = *from); + chunk.commit_iterated(); + } else if let Some((last, elements)) = buf.split_last_mut() { + for element in elements { + element.clone_from(&silence); + } + *last = silence + } + } +} + +#[cfg(feature = "cpal-backend")] +pub fn mk_cpal(device: Option, format: AudioFormat) -> Box { + open(cpal::default_host(), device, format) +} + +#[cfg(feature = "cpaljack-backend")] +pub fn mk_cpaljack(device: Option, format: AudioFormat) -> Box { + open( + cpal::host_from_id(cpal::HostId::Jack).unwrap(), + device, + format, + ) +} + +fn create_sink( + device: cpal::Device, + format: AudioFormat, +) -> CpalSink { + let (sample_tx, sample_rx) = RingBuffer::new(NUM_CHANNELS as usize * 2048).split(); + + let stream = device + .build_output_stream::( + &StreamConfig { + buffer_size: cpal::BufferSize::Default, + channels: NUM_CHANNELS as u16, + sample_rate: cpal::SampleRate(SAMPLE_RATE), + }, + data_callback(sample_rx), + |e| error!("Sink error: {}", e), + ) + .expect("Could not open output stream with that format"); + + CpalSink { + stream, + format, + sample_tx, + } +} + +fn open(host: cpal::Host, device: Option, format: AudioFormat) -> Box { + info!( + "Using CPAL sink with format {:?} and host: {}", + format, + host.id().name() + ); + + let device = get_device(&host, device).expect("Could not open device"); + + // Try and see if the requested output format is actually available. + // If not, try the default output format. This provides an out-of-the-box + // experience, particularly on certain CoreAudio devices that only support + // F32 output while librespot defaults to S16. + let mut format = format; + let mut format_found = false; + match device.supported_output_configs() { + Ok(cfgs) => { + for cfg in cfgs { + format_found = match cfg.sample_format() { + cpal::SampleFormat::F32 => format == AudioFormat::F32, + cpal::SampleFormat::I16 => format == AudioFormat::S16, + _ => false, + }; + if format_found { + break; + } + } + } + Err(e) => { + debug!("Error getting supported cpal::Sink configs: {}", e); + } + } + if !format_found { + warn!( + "Requested audio format {:?} not supported by device; trying default format", + format + ); + let default_output_config = device + .default_output_config() + .expect("Could not get default output config"); + let default_sample_format = default_output_config.sample_format(); + format = match default_sample_format { + cpal::SampleFormat::F32 => AudioFormat::F32, + cpal::SampleFormat::I16 => AudioFormat::S16, + _ => unimplemented!( + "Device default sample format {:?} is not implemented", + default_sample_format + ), + }; + warn!("Changing to default audio format {:?}", format); + } + + match format { + AudioFormat::F32 => Box::new(create_sink::(device, format)), + AudioFormat::S16 => Box::new(create_sink::(device, format)), + _ => unimplemented!("CPAL currently only supports F32 and S16 formats"), + } +} + +impl Sink for CpalSink { + fn start(&mut self) -> io::Result<()> { + let result = self.stream.play(); + match result { + Ok(()) => Ok(()), + Err(e) => { + error!("CPAL error stream play {}", e); + Err(io::Error::new( + io::ErrorKind::Other, + "CPAL error: stream play failed", + )) + } + } + } + + fn stop(&mut self) -> io::Result<()> { + // This method may fail if the device does not support suspending + // the stream at the hardware level. That's OK so we ignore it. + let _ = self.stream.pause(); + Ok(()) + } + + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { + let samples = packet.samples(); + match self.format { + AudioFormat::F32 => { + let samples_f32: &[f32] = &converter.f64_to_f32(samples); + self.write_with_format::(samples_f32); + } + AudioFormat::S16 => { + let samples_s16: &[i16] = &converter.f64_to_s16(samples); + self.write_with_format::(samples_s16); + } + _ => unreachable!(), + } + + Ok(()) + } +} + +impl CpalSink { + fn write_with_format(&mut self, samples: &[T]) { + let mut write_to = loop { + match self.sample_tx.write_chunk(samples.len()) { + Ok(x) => break x, + Err(_) => thread::sleep(time::Duration::from_millis(10)), + } + }; + (&mut write_to) + .zip(samples.iter()) + .for_each(|(to, from)| *to = S::from(from)); + write_to.commit_iterated(); + } +} diff --git a/playback/src/audio_backend/mod.rs b/playback/src/audio_backend/mod.rs index 31fb847c7..d6cc69f58 100644 --- a/playback/src/audio_backend/mod.rs +++ b/playback/src/audio_backend/mod.rs @@ -68,6 +68,9 @@ mod alsa; #[cfg(feature = "alsa-backend")] use self::alsa::AlsaSink; +#[cfg(any(feature = "cpal-backend", feature = "cpaljack-backend"))] +mod cpal; + #[cfg(feature = "portaudio-backend")] mod portaudio; #[cfg(feature = "portaudio-backend")] @@ -109,6 +112,10 @@ pub const BACKENDS: &[(&str, SinkBuilder)] = &[ (RodioSink::NAME, rodio::mk_rodio), // default goes first #[cfg(feature = "alsa-backend")] (AlsaSink::NAME, mk_sink::), + #[cfg(feature = "cpal-backend")] + (cpal::NAME, cpal::mk_cpal), + #[cfg(feature = "cpaljack-backend")] + (cpal::JACK_NAME, cpal::mk_cpaljack), #[cfg(feature = "portaudio-backend")] (PortAudioSink::NAME, mk_sink::), #[cfg(feature = "pulseaudio-backend")]